From 3daff4df9b191a9f8604aed70ee08e4e48afb787 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 28 Nov 2022 18:05:00 +0100 Subject: [PATCH 001/546] initial commit --- .gitignore | 8 + README.md | 205 ++ logger.py | 5 + requirements.txt | 6 + scenario/__init__.py | 1 + scenario/consts.py | 10 + scenario/event_db.py | 28 + scenario/runtime/__init__.py | 0 scenario/runtime/memo.py | 835 +++++++ scenario/runtime/memo_tools.py | 158 ++ scenario/runtime/runtime.py | 306 +++ scenario/scenario-old.py | 1007 +++++++++ scenario/scenario.py | 258 +++ scenario/structs.py | 211 ++ tests/memo_tools_test_files/mock_ops.py | 19 + .../prom-0-update-status.json | 112 + .../memo_tools_test_files/trfk-re-relate.json | 2011 +++++++++++++++++ tests/setup_tests.py | 11 + tests/test_e2e/test_state.py | 71 + tests/test_memo_tools.py | 430 ++++ tests/test_replay_local_runtime.py | 153 ++ tox.ini | 34 + 22 files changed, 5879 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 logger.py create mode 100644 requirements.txt create mode 100644 scenario/__init__.py create mode 100644 scenario/consts.py create mode 100644 scenario/event_db.py create mode 100644 scenario/runtime/__init__.py create mode 100644 scenario/runtime/memo.py create mode 100644 scenario/runtime/memo_tools.py create mode 100644 scenario/runtime/runtime.py create mode 100644 scenario/scenario-old.py create mode 100644 scenario/scenario.py create mode 100644 scenario/structs.py create mode 100644 tests/memo_tools_test_files/mock_ops.py create mode 100644 tests/memo_tools_test_files/prom-0-update-status.json create mode 100644 tests/memo_tools_test_files/trfk-re-relate.json create mode 100644 tests/setup_tests.py create mode 100644 tests/test_e2e/test_state.py create mode 100644 tests/test_memo_tools.py create mode 100644 tests/test_replay_local_runtime.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ca7114737 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..36ff2d7d7 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +Ops-Scenario +============ + +This is a python library that you can use to run scenario-based tests. + +Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow +you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single +event on the charm and execute its logic. + +This puts scenario tests somewhere in between unit and integration tests. + +Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the +union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my +config?...). +The output is another context instance: the context after the charm has had a chance to interact with the mocked juju +model. + +Testing a charm under a given scenario, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the scenario +- the output state (or the diff with the input state) is as expected + +# Writing scenario tests + +Writing a scenario tests consists of two broad steps: + +- define a Scenario +- run the scenario + +The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is +available. The charm has no config, no relations, no networks, and no leadership. + +With that, we can write the simplest possible scenario test: + +```python +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context +from ops.charm import CharmBase + + +class MyCharm(CharmBase): + pass + + +def test_scenario_base(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(Scene(event=get_event('start'), context=Context())) + assert out.context_out.state.status.unit == ('unknown', '') +``` + +Now let's start making it more complicated. +Our charm sets a special state if it has leadership on 'start': + +```python +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context, State +from ops.charm import CharmBase +from ops.model import ActiveStatus + + +class MyCharm(CharmBase): + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + + +def test_scenario_base(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(Scene(event=get_event('start'), context=Context())) + assert out.context_out.state.status.unit == ('unknown', '') + + +def test_status_leader(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run( + Scene( + event=get_event('start'), + context=Context( + state=State(leader=True) + ))) + assert out.context_out.state.status.unit == ('active', 'I rule') +``` + +This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more +concisely (and parametrically) as: + +```python +import pytest +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context +from ops.charm import CharmBase +from ops.model import ActiveStatus + + +class MyCharm(CharmBase): + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I follow') + + +@pytest.fixture +def scenario(): + return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + + +@pytest.fixture +def start_scene(): + return Scene(event=get_event('start'), context=Context()) + + +def test_scenario_base(scenario, start_scene): + out = scenario.run(start_scene) + assert out.context_out.state.status.unit == ('unknown', '') + + +@pytest.mark.parametrize('leader', [True, False]) +def test_status_leader(scenario, start_scene, leader): + leader_scene = start_scene.copy() + leader_scene.context.state.leader = leader + out = scenario.run(leader_scene) + if leader: + assert out.context_out.state.status.unit == ('active', 'I rule') + else: + assert out.context_out.state.status.unit == ('active', 'I follow') +``` + +By defining the right state we can programmatically define what answers will the charm get to all the questions it can +ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... + +# Caveats +The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. + +Options we're considering: +- have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. +- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, YOU SHOULD REALLY DO THAT + +# Advanced Mockery +The Harness mocks data by providing a separate backend. When the charm code asks: am I leader? there's a variable +in `harness._backend` that decides whether the return value is True or False. +A Scene exposes two layers of data to the charm: memos and a state. + +- Memos are strict, cached input->output mappings. They basically map a function call to a hardcoded return value, or + multiple return values. +- A State is a static database providing the same mapping, but only a single return value is supported per input. + +Scenario tests mock the data by operating at the hook tool call level, not the backend level. Every backend call that +would normally result in a hook tool call is instead redirected to query the available memos, and as a fallback, is +going to query the State we define as part of a Scene. If neither one can provide an answer, the hook tool call is +propagated -- which unless you have taken care of mocking that executable as well, will likely result in an error. + +Let's see the difference with an example: + +Suppose the charm does: + +```python + ... + + +def _on_start(self, _): + assert self.unit.is_leader() + + import time + time.sleep(31) + + assert not self.unit.is_leader() + + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I follow') +``` + +Suppose we want this test to pass. How could we mock this using Scenario? + +```python +scene = Scene( + event=get_event('start'), + context=Context(memos=[ + {'name': '_ModelBackend.leader_get', + 'values': ['True', 'False'], + 'caching_mode': 'strict'} + ]) +) +``` +What this means in words is: the mocked hook-tool 'leader-get' call will return True at first, but False the second time around. + +Since we didn't pass a State to the Context object, when the runtime fails to find a third value for leader-get, it will fall back and use the static value provided by the default State -- False. So if the charm were to call `is_leader` at any point after the first two calls, it would consistently get False. + +NOTE: the API is work in progress. We're working on exposing friendlier ways of defining memos. +The good news is that you can generate memos by scraping them off of a live unit using `jhack replay`. + + +# TODOS: +- Figure out how to distribute this. I'm thinking `pip install ops[scenario]` +- Better syntax for memo generation +- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) +- Expose instructions or facilities re. how to use this without touching your venv. \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 000000000..dc37bcd46 --- /dev/null +++ b/logger.py @@ -0,0 +1,5 @@ +import logging +import os + +logger = logging.getLogger('ops-scenario') +logger.setLevel(os.getenv('OPS_SCENARIO_LOGGING', 'WARNING')) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..1a2608627 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +typer==0.4.1 +ops==1.5.3 +parse==1.19.0 +juju==2.9.7 +asttokens +astunparse \ No newline at end of file diff --git a/scenario/__init__.py b/scenario/__init__.py new file mode 100644 index 000000000..1ede09876 --- /dev/null +++ b/scenario/__init__.py @@ -0,0 +1 @@ +from scenario.runtime.runtime import Runtime diff --git a/scenario/consts.py b/scenario/consts.py new file mode 100644 index 000000000..d0a680c05 --- /dev/null +++ b/scenario/consts.py @@ -0,0 +1,10 @@ +ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" +CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" +BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" +DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" +META_EVENTS = { + "ATTACH_ALL_STORAGES", + "CREATE_ALL_RELATIONS", + "BREAK_ALL_RELATIONS", + "DETACH_ALL_STORAGES", +} diff --git a/scenario/event_db.py b/scenario/event_db.py new file mode 100644 index 000000000..ae3e7c6d7 --- /dev/null +++ b/scenario/event_db.py @@ -0,0 +1,28 @@ +import tempfile +import typing +from pathlib import Path + +if typing.TYPE_CHECKING: + from runtime.memo import Scene + + +class TemporaryEventDB: + def __init__(self, scene: "Scene", tempdir=None): + self.scene = scene + self._tempdir = tempdir + self._tempfile = None + self.path = None + + def __enter__(self): + from scenario.scenario import Playbook + + self._tempfile = tempfile.NamedTemporaryFile(dir=self._tempdir, delete=False) + self.path = Path(self._tempfile.name).absolute() + self.path.write_text(Playbook([self.scene]).to_json()) + return self.path + + def __exit__(self, exc_type, exc_val, exc_tb): + self.path.unlink() + + self._tempfile = None + self.path = None diff --git a/scenario/runtime/__init__.py b/scenario/runtime/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py new file mode 100644 index 000000000..c4e81e948 --- /dev/null +++ b/scenario/runtime/memo.py @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 + +import base64 +import dataclasses +import datetime as DT +import functools +import inspect +import io +import json +import os +import pickle +import typing +import warnings +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, Generator, List, Literal, Optional, Tuple, Union +from uuid import uuid4 + +from logger import logger as pkg_logger + +logger = pkg_logger.getChild("recorder") + +DEFAULT_DB_NAME = "event_db.json" +USE_STATE_KEY = "MEMO_REPLAY_IDX" +MEMO_REPLAY_INDEX_KEY = "MEMO_REPLAY_IDX" +MEMO_DATABASE_NAME_KEY = "MEMO_DATABASE_NAME" +MEMO_MODE_KEY = "MEMO_MODE" +DEFAULT_NAMESPACE = "" + +# fixme: generalize the serializer by allowing to pass in a (pickled?) arbitrary callable. +# right now we have this PebblePush nonsense because that's the only special case. + +SUPPORTED_SERIALIZERS = Literal["pickle", "json", "io", "PebblePush"] +SUPPORTED_SERIALIZERS_LIST = ["pickle", "json", "io", "PebblePush"] + + +MemoModes = Literal["record", "replay"] +_CachingPolicy = Literal["strict", "loose"] + +# notify just once of what mode we're running in +_PRINTED_MODE = False + +# flag to mark when a memo cache's return value is not found as opposed to being None +_NotFound = object() + + +def _check_caching_policy(policy: _CachingPolicy) -> _CachingPolicy: + if policy in {"strict", "loose"}: + return policy + else: + logger.warning( + f"invalid caching policy: {policy!r}. " f"defaulting to `strict`" + ) + return "strict" + + +def _load_memo_mode() -> MemoModes: + global _PRINTED_MODE + + val = os.getenv(MEMO_MODE_KEY, "record") + if val == "record": + # don't use logger, but print, to avoid recursion issues with juju-log. + if not _PRINTED_MODE: + print("MEMO: recording") + elif val == "replay": + if not _PRINTED_MODE: + print("MEMO: replaying") + else: + warnings.warn( + f"[ERROR]: MEMO: invalid value ({val!r}). Defaulting to `record`." + ) + _PRINTED_MODE = True + return "record" + + _PRINTED_MODE = True + return typing.cast(MemoModes, val) + + +def _is_bound_method(fn: Any): + try: + return next(iter(inspect.signature(fn).parameters.items()))[0] == "self" + except: + return False + + +def _log_memo( + fn: Callable, + args, + kwargs, + recorded_output: Any = None, + cache_hit: bool = False, + # use print, not logger calls, else the root logger will recurse if + # juju-log calls are being @memo'd. + log_fn: Callable[[str], None] = print, +): + try: + output_repr = repr(recorded_output) + except: # noqa catchall + output_repr = "" + + trim = output_repr[:100] + trimmed = "[...]" if len(output_repr) > 100 else "" + hit = "hit" if cache_hit else "miss" + + fn_name = getattr(fn, "__name__", str(fn)) + + if _self := getattr(fn, "__self__", None): + # it's a method + fn_repr = type(_self).__name__ + fn_name + else: + fn_repr = fn_name + + log_fn( + f"@memo[{hit}]: replaying {fn_repr}(*{args}, **{kwargs})" + f"\n\t --> {trim}{trimmed}" + ) + + +def _check_serializer( + serializer: Union[ + SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] + ] +) -> Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS]: + if isinstance(serializer, str): + input_serializer = output_serializer = serializer + else: + input_serializer, output_serializer = serializer + + if input_serializer not in SUPPORTED_SERIALIZERS_LIST: + warnings.warn( + f"invalid input serializer name: {input_serializer}; " + f"falling back to `json`." + ) + input_serializer = "json" + if output_serializer not in SUPPORTED_SERIALIZERS_LIST: + warnings.warn( + f"invalid output serializer name: {input_serializer}; " + f"falling back to `json`." + ) + output_serializer = "json" + + return input_serializer, output_serializer + + +def memo( + namespace: str = DEFAULT_NAMESPACE, + name: str = None, + caching_policy: _CachingPolicy = "strict", + log_on_replay: bool = True, + serializer: Union[ + SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] + ] = "json", +): + def decorator(fn): + if not inspect.isfunction(fn): + raise RuntimeError(f"Cannot memoize non-function obj {fn!r}.") + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + + _MEMO_MODE: MemoModes = _load_memo_mode() + input_serializer, output_serializer = _check_serializer(serializer) + + def _load(obj: str, method: SUPPORTED_SERIALIZERS): + if log_on_replay and _MEMO_MODE == "replay": + _log_memo(fn, args, kwargs, recorded_output, cache_hit=True) + if method == "pickle": + byt = base64.b64decode(obj) + return pickle.loads(byt) + elif method == "json": + return json.loads(obj) + elif method == "io": + byt = base64.b64decode(obj) + raw = pickle.loads(byt) + byio = io.StringIO(raw) + return byio + raise ValueError(f"Invalid method: {method!r}") + + def _dump(obj: Any, method: SUPPORTED_SERIALIZERS, output_=None): + if method == "pickle": + if isinstance(obj, io.TextIOWrapper): + pass + byt = pickle.dumps(obj) + return base64.b64encode(byt).decode("utf-8") + elif method == "json": + return json.dumps(obj) + elif method == "PebblePush": + _args, _kwargs = obj + assert len(_args) == 2 + path, source = _args + # pebble._Client.push's second argument: + # source: Union[bytes, str, BinaryIO, TextIO] + + if isinstance(source, (bytes, str)): + source_ = pickle.dumps(source) + return _dump(((path, source_), _kwargs), "pickle") + else: # AnyIO: + if not output_: + if _MEMO_MODE == "record": + raise ValueError( + "we serialize AnyIO by just caching the contents. " + "Output required." + ) + # attempt to obtain it by reading the obj + try: + output_ = source.read() + except Exception as e: + raise RuntimeError( + f"Cannot read source: {source}; unable to compare to cache" + ) from e + return _dump(((path, output_), _kwargs), "pickle") + + elif method == "io": + if not hasattr(obj, "read"): + raise TypeError( + "you can only serialize with `io` " + "stuff that has a .read method." + ) + byt = pickle.dumps(obj.read()) + return base64.b64encode(byt).decode("utf-8") + raise ValueError(f"Invalid method: {method!r}") + + def propagate(): + """Make the real wrapped call.""" + + if _MEMO_MODE == "replay" and log_on_replay: + _log_memo(fn, args, kwargs, "", cache_hit=False) + + # todo: if we are replaying, should we be caching this result? + return fn(*args, **kwargs) + + def load_from_state( + context: Context, question: Tuple[str, Tuple[Any], Dict[str, Any]] + ): + if not os.getenv(USE_STATE_KEY): + return propagate() + + logger.debug("Attempting to load from state.") + if not hasattr(context, "state"): + logger.warning( + "Context has no state; probably there is a version mismatch." + ) + return propagate() + + if not context.state: + logger.debug("No state found for this call.") + return propagate() + + try: + return get_from_state(context.state, question) + except StateError as e: + logger.error(f"Error trying to get_from_state {memo_name}: {e}") + return propagate() + + memoizable_args = args + if args: + if _is_bound_method(fn): + # which means args[0] is `self` + memoizable_args = args[1:] + else: + memoizable_args = args + + # convert args to list for comparison purposes because memos are + # loaded from json, where tuples become lists. + memo_args = list(memoizable_args) + + database = os.environ.get(MEMO_DATABASE_NAME_KEY, DEFAULT_DB_NAME) + with event_db(database) as data: + if not data.scenes: + raise RuntimeError("No scenes: cannot memoize.") + idx = os.environ.get(MEMO_REPLAY_INDEX_KEY, None) + + strict_caching = _check_caching_policy(caching_policy) == "strict" + + memo_name = f"{namespace}.{name or fn.__name__}" + + if _MEMO_MODE == "record": + memo = data.scenes[-1].context.memos.get(memo_name) + if memo is None: + cpolicy_name = typing.cast( + _CachingPolicy, "strict" if strict_caching else "loose" + ) + memo = Memo( + caching_policy=cpolicy_name, + serializer=(input_serializer, output_serializer), + ) + + output = propagate() + + # we can't hash dicts, so we dump args and kwargs + # regardless of what they are + serialized_args_kwargs = _dump( + (memo_args, kwargs), input_serializer, output_=output + ) + serialized_output = _dump(output, output_serializer) + + memo.cache_call(serialized_args_kwargs, serialized_output) + data.scenes[-1].context.memos[memo_name] = memo + + # if we're in IO mode, output might be a file handle and + # serialized_output might be the b64encoded, pickle repr of its contents. + # We need to mock a file-like stream to return. + if output_serializer == "io": + return _load(serialized_output, "io") + return output + + elif _MEMO_MODE == "replay": + if idx is None: + raise RuntimeError( + f"provide a {MEMO_REPLAY_INDEX_KEY} envvar" + "to tell the replay environ which scene to look at" + ) + try: + idx = int(idx) + except TypeError: + raise RuntimeError( + f"invalid idx: ({idx}); expecting an integer." + ) + + try: + memo = data.scenes[idx].context.memos[memo_name] + + except KeyError: + # if no memo is present for this function, that might mean that + # in the recorded session it was not called (this path is new!) + warnings.warn( + f"No memo found for {memo_name}: " f"this path must be new." + ) + return load_from_state( + data.scenes[idx].context, + (memo_name, memoizable_args, kwargs), + ) + + if not all( + ( + memo.caching_policy == caching_policy, + # loading from yaml makes it a list + ( + ( + memo.serializer + == [input_serializer, output_serializer] + ) + or ( + memo.serializer + == (input_serializer, output_serializer) + ) + ), + ) + ): + warnings.warn( + f"Stored memo params differ from those passed to @memo at runtime. " + f"The database must have been generated by an outdated version of " + f"memo-tools. Falling back to stored memo: \n " + f"\tpolicy: {memo.caching_policy} (vs {caching_policy!r}), \n" + f"\tserializer: {memo.serializer} " + f"(vs {(input_serializer, output_serializer)!r})..." + ) + strict_caching = ( + _check_caching_policy(memo.caching_policy) == "strict" + ) + input_serializer, output_serializer = _check_serializer( + memo.serializer + ) + + # we serialize args and kwargs to compare them with the memo'd ones + fn_args_kwargs = _dump((memo_args, kwargs), input_serializer) + + if strict_caching: + # in strict mode, fn might return different results every time it is called -- + # regardless of the arguments it is called with. So each memo contains a sequence of values, + # and a cursor to keep track of which one is next in the replay routine. + try: + current_cursor = memo.cursor + recording = memo.calls[current_cursor] + memo.cursor += 1 + except IndexError: + # There is a memo, but its cursor is out of bounds. + # this means the current path is calling the wrapped function + # more times than the recorded path did. + # if this happens while replaying locally, of course, game over. + warnings.warn( + f"Memo cursor {current_cursor} out of bounds for {memo_name}: " + f"this path must have diverged. Propagating call..." + ) + return load_from_state( + data.scenes[idx].context, + (memo_name, memoizable_args, kwargs), + ) + + recorded_args_kwargs, recorded_output = recording + + if recorded_args_kwargs != fn_args_kwargs: + # if this happens while replaying locally, of course, game over. + warnings.warn( + f"memoized {memo_name} arguments ({recorded_args_kwargs}) " + f"don't match the ones received at runtime ({fn_args_kwargs}). " + f"This path has diverged. Propagating call..." + ) + return load_from_state( + data.scenes[idx].context, + (memo_name, memoizable_args, kwargs), + ) + + return _load( + recorded_output, output_serializer + ) # happy path! good for you, path. + + else: + # in non-strict mode, we don't care about the order in which fn is called: + # it will return values in function of the arguments it is called with, + # regardless of when it is called. + # so all we have to check is whether the arguments are known. + # in non-strict mode, memo.calls is an inputs/output dict. + recorded_output = memo.calls.get(fn_args_kwargs, _NotFound) + if recorded_output is not _NotFound: + return _load( + recorded_output, output_serializer + ) # happy path! good for you, path. + + warnings.warn( + f"No memo for {memo_name} matches the arguments received at runtime. " + f"This path has diverged." + ) + return load_from_state( + data.scenes[idx].context, + (memo_name, memoizable_args, kwargs), + ) + + else: + msg = f"invalid memo mode: {_MEMO_MODE}" + warnings.warn(msg) + raise ValueError(msg) + + raise RuntimeError("Unhandled memo path.") + + return wrapper + + return decorator + + +class DB: + def __init__(self, file: Path) -> None: + self._file = file + self.data = None + + def load(self): + text = self._file.read_text() + if not text: + logger.debug("database empty; initializing with data=[]") + self.data = Data([]) + return + + try: + raw = json.loads(text) + except json.JSONDecodeError: + raise ValueError(f"database invalid: could not json-decode {self._file}") + + try: + scenes = [Scene.from_dict(obj) for obj in raw.get("scenes", ())] + except Exception as e: + raise RuntimeError( + f"database invalid: could not parse Scenes from {raw['scenes']!r}..." + ) from e + + self.data = Data(scenes) + + def commit(self): + self._file.write_text(json.dumps(asdict(self.data), indent=2)) + + +@dataclass +class Event: + env: Dict[str, str] + timestamp: str = dataclasses.field( + default_factory=lambda: DT.datetime.now().isoformat() + ) + + @property + def name(self): + return self.env["JUJU_DISPATCH_PATH"].split("/")[1] + + @property + def datetime(self): + return DT.datetime.fromisoformat(self.timestamp) + + +@dataclass +class Memo: + # todo clean this up by subclassing out to two separate StrictMemo and LooseMemo objects. + # list of (args, kwargs), return-value pairs for this memo + # warning: in reality it's all lists, no tuples. + calls: Union[ + List[Tuple[str, Any]], # if caching_policy == 'strict' + Dict[str, Any], # if caching_policy == 'loose' + ] = field(default_factory=list) + # indicates the position of the replay cursor if we're replaying the memo + cursor: Union[ + int, # if caching_policy == 'strict' + Literal["n/a"], # if caching_policy == 'loose' + ] = 0 + caching_policy: _CachingPolicy = "strict" + serializer: Union[ + SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] + ] = "json" + + def __post_init__(self): + if self.caching_policy == "loose" and not self.calls: # first time only! + self.calls = {} + self.cursor = "n/a" + + def cache_call(self, input: str, output: str): + assert isinstance(input, str), input + assert isinstance(output, str), output + + if self.caching_policy == "loose": + self.calls[input] = output + else: + self.calls.append((input, output)) + + +def _random_model_name(): + import random + import string + + space = string.ascii_letters + string.digits + return "".join(random.choice(space) for _ in range(20)) + + +@dataclass +class Model: + name: str = _random_model_name() + uuid: str = str(uuid4()) + + +@dataclass +class ContainerSpec: + name: str + can_connect: bool = False + # todo mock filesystem and pebble proc? + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class Address: + hostname: str + value: str + cidr: str + + +@dataclass +class BindAddress: + mac_address: str + interface_name: str + interfacename: str # legacy + addresses: List[Address] + + +@dataclass +class Network: + bind_addresses: List[BindAddress] + bind_address: str + egress_subnets: List[str] + ingress_addresses: List[str] + + +@dataclass +class NetworkSpec: + name: str + bind_id: int + network: Network + is_default: bool = False + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class RelationMeta: + endpoint: str + interface: str + remote_app_name: str + relation_id: int + + # local limit + limit: int = 1 + + remote_unit_ids: Tuple[int, ...] = (0,) + # scale of the remote application; number of units, leader ID? + # TODO figure out if this is relevant + scale: int = 1 + leader_id: int = 0 + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class RelationSpec: + meta: RelationMeta + application_data: dict = dataclasses.field(default_factory=dict) + units_data: Dict[int, dict] = dataclasses.field(default_factory=dict) + + @classmethod + def from_dict(cls, obj): + meta = RelationMeta.from_dict(obj.pop("meta")) + return cls(meta=meta, **obj) + + +@dataclass +class Status: + app: Tuple[str, str] = ("unknown", "") + unit: Tuple[str, str] = ("unknown", "") + + +@dataclass +class State: + config: Dict[str, Union[str, int, float, bool]] = None + relations: Tuple[RelationSpec] = () + networks: Tuple[NetworkSpec] = () + containers: Tuple[ContainerSpec] = () + status: Status = field(default_factory=Status) + leader: bool = False + model: Model = Model() + juju_log: List[Tuple[str, str]] = field(default_factory=list) + + # todo: add pebble stuff, unit/app status, etc... + # actions? + # juju topology + + @classmethod + def null(cls): + return cls() + + @classmethod + def from_dict(cls, obj): + if obj is None: + return cls.null() + + return cls( + config=obj["config"], + relations=tuple( + RelationSpec.from_dict(raw_ard) for raw_ard in obj["relations"] + ), + networks=tuple(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), + containers=tuple( + ContainerSpec.from_dict(raw_cs) for raw_cs in obj["containers"] + ), + leader=obj.get("leader", False), + status=Status(**{k: tuple(v) for k, v in obj.get("status", {}).items()}), + model=Model(**obj.get("model", {})), + ) + + +@dataclass +class Context: + memos: Dict[str, Memo] = field(default_factory=dict) + state: State = None + + @staticmethod + def from_dict(obj: dict): + return Context( + memos={name: Memo(**content) for name, content in obj["memos"].items()}, + state=State.from_dict(obj["state"]), + ) + + +@dataclass +class Scene: + event: Event + context: Context = Context() + + @staticmethod + def from_dict(obj): + return Scene( + event=Event(**obj["event"]), + context=Context.from_dict(obj.get("context", {})), + ) + + +@dataclass +class Data: + scenes: List[Scene] + + +@contextmanager +def event_db(file=DEFAULT_DB_NAME) -> Generator[Data, None, None]: + path = Path(file) + if not path.exists(): + print(f"Initializing DB file at {path}...") + path.touch(mode=0o666) + path.write_text("{}") # empty json obj + + db = DB(file=path) + db.load() + yield db.data + db.commit() + + +def _capture() -> Event: + return Event(env=dict(os.environ), timestamp=DT.datetime.now().isoformat()) + + +def _reset_replay_cursors(file=DEFAULT_DB_NAME, *scene_idx: int): + """Reset the replay cursor for all scenes, or the specified ones.""" + with event_db(file) as data: + to_reset = (data.scenes[idx] for idx in scene_idx) if scene_idx else data.scenes + for scene in to_reset: + for memo in scene.context.memos.values(): + memo.cursor = 0 + + +def _record_current_event(file) -> Event: + with event_db(file) as data: + scenes = data.scenes + event = _capture() + scenes.append(Scene(event=event)) + return event + + +def setup(file=DEFAULT_DB_NAME): + _MEMO_MODE: MemoModes = _load_memo_mode() + + if _MEMO_MODE == "record": + event = _record_current_event(file) + print(f"Captured event: {event.name}.") + + if _MEMO_MODE == "replay": + _reset_replay_cursors() + print(f"Replaying: reset replay cursors.") + + +class StateError(RuntimeError): + pass + + +class QuestionNotImplementedError(StateError): + pass + + +def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any]]): + memo_name, call_args, call_kwargs = question + ns, _, meth = memo_name.rpartition(".") + setter = False + + try: + # MODEL BACKEND CALLS + if ns == "_ModelBackend": + if meth == "relation_get": + pass + elif meth == "is_leader": + return state.leader + elif meth == "status_get": + status, message = ( + state.status.app if call_kwargs.get("app") else state.status.unit + ) + return {"status": status, "message": message} + elif meth == "action_get": + pass + elif meth == "relation_ids": + pass + elif meth == "relation_list": + pass + elif meth == "relation_remote_app_name": + pass + elif meth == "config_get": + return state.config[call_args[0]] + elif meth == "resource_get": + pass + elif meth == "storage_list": + pass + elif meth == "storage_get": + pass + elif meth == "network_get": + pass + elif meth == "planned_units": + pass + else: + setter = True + + # # setter methods + if meth == "application_version_set": + pass + elif meth == "relation_set": + pass + elif meth == "action_set": + pass + elif meth == "action_fail": + pass + elif meth == "status_set": + status = call_args + if call_kwargs.get("is_app"): + state.status.app = status + else: + state.status.unit = status + return None + elif meth == "action_log": + pass + elif meth == "juju_log": + state.juju_log.append(call_args) + return None + elif meth == "storage_add": + pass + + # todo add + # 'secret_get' + # 'secret_set' + # 'secret_grant' + # 'secret_remove' + + # PEBBLE CALLS + elif ns == "Client": + if meth == "_request": + pass + elif meth == "pull": + pass + elif meth == "push": + setter = True + pass + + else: + raise QuestionNotImplementedError(ns) + except Exception as e: + action = "setting" if setter else "getting" + raise StateError( + f"Error {action} state for {ns}.{meth} given " + f"({call_args}, {call_kwargs})" + ) from e + + raise QuestionNotImplementedError((ns, meth)) diff --git a/scenario/runtime/memo_tools.py b/scenario/runtime/memo_tools.py new file mode 100644 index 000000000..d232792c1 --- /dev/null +++ b/scenario/runtime/memo_tools.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import ast +import typing +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent +from typing import Dict, Literal, Optional, Tuple, Union + +import asttokens +from asttokens.util import Token +from astunparse import unparse + +if typing.TYPE_CHECKING: + from memo import SUPPORTED_SERIALIZERS + + +@dataclass +class DecorateSpec: + # on strict caching mode, each call will be separately logged. + # use this when calling the same method (with the same arguments) at different times CAN return + # different values. + # Use loose caching mode when the method is guaranteed to return consistent results throughout + # a single charm execution. + caching_policy: Literal["strict", "loose"] = "strict" + + # the memo's namespace will default to the class name it's being defined in + namespace: Optional[str] = None + + # the memo's name will default to the memoized function's __name__ + name: Optional[str] = None + + # (de) serializer for the return object of the decorated function + serializer: Union[ + "SUPPORTED_SERIALIZERS", Tuple["SUPPORTED_SERIALIZERS", "SUPPORTED_SERIALIZERS"] + ] = "json" + + def as_token(self, default_namespace: str) -> Token: + name = f"'{self.name}'" if self.name else "None" + + if isinstance(self.serializer, str): + serializer = f"'{self.serializer}'" + else: + serializer = f"{str(self.serializer)}" + + raw = dedent( + f"""@memo( + name={name}, + namespace='{self.namespace or default_namespace}', + caching_policy='{self.caching_policy}', + serializer={serializer}, + )\ndef foo():...""" + ) + return asttokens.ASTTokens(raw, parse=True).tree.body[0].decorator_list[0] + + +DECORATE_MODEL = { + "_ModelBackend": { + "relation_get": DecorateSpec(), + "relation_set": DecorateSpec(), + "is_leader": DecorateSpec(), # technically could be loose + "application_version_set": DecorateSpec(), + "status_get": DecorateSpec(), + "action_get": DecorateSpec(), + "add_metrics": DecorateSpec(), # deprecated, I guess + "action_set": DecorateSpec(caching_policy="loose"), + "action_fail": DecorateSpec(caching_policy="loose"), + "action_log": DecorateSpec(caching_policy="loose"), + "relation_ids": DecorateSpec(caching_policy="loose"), + "relation_list": DecorateSpec(caching_policy="loose"), + "relation_remote_app_name": DecorateSpec(caching_policy="loose"), + "config_get": DecorateSpec(caching_policy="loose"), + "resource_get": DecorateSpec(caching_policy="loose"), + "storage_list": DecorateSpec(caching_policy="loose"), + "storage_get": DecorateSpec(caching_policy="loose"), + "network_get": DecorateSpec(caching_policy="loose"), + # methods that return None can all be loosely cached + "status_set": DecorateSpec(caching_policy="loose"), + "storage_add": DecorateSpec(caching_policy="loose"), + "juju_log": DecorateSpec(caching_policy="loose"), + "planned_units": DecorateSpec(caching_policy="loose"), + # 'secret_get', + # 'secret_set', + # 'secret_grant', + # 'secret_remove', + } +} + +DECORATE_PEBBLE = { + "Client": { + # todo: we could be more fine-grained and decorate individual Container methods, + # e.g. can_connect, ... just like in _ModelBackend we don't just memo `_run`. + "_request": DecorateSpec(), + # some methods such as pebble.pull use _request_raw directly, + # and deal in objects that cannot be json-serialized + "pull": DecorateSpec(serializer=("json", "io")), + "push": DecorateSpec(serializer=("PebblePush", "json")), + } +} + + +memo_import_block = dedent( + """# ==== block added by scenario.runtime.memo_tools === +try: + from memo import memo +except ModuleNotFoundError as e: + msg = "recorder not installed. " \ + "This can happen if you're playing with Runtime in a local venv. " \ + "In that case all you have to do is ensure that the PYTHONPATH is patched to include the path to " \ + "recorder.py before loading this module. " \ + "Tread carefully." + raise RuntimeError(msg) from e +# ==== end block === +""" +) + + +def inject_memoizer(source_file: Path, decorate: Dict[str, Dict[str, DecorateSpec]]): + """Rewrite source_file by decorating methods in a number of classes. + + Decorate: a dict mapping class names to methods of that class that should be decorated. + Example:: + >>> inject_memoizer(Path('foo.py'), {'MyClass': { + ... 'do_x': DecorateSpec(), + ... 'is_ready': DecorateSpec(caching_policy='loose'), + ... 'refresh': DecorateSpec(caching_policy='loose'), + ... 'bar': DecorateSpec(caching_policy='loose') + ... }}) + """ + + atok = asttokens.ASTTokens(source_file.read_text(), parse=True).tree + + def _should_decorate_class(token: ast.AST): + return isinstance(token, ast.ClassDef) and token.name in decorate + + for cls in filter(_should_decorate_class, atok.body): + + def _should_decorate_method(token: ast.AST): + return ( + isinstance(token, ast.FunctionDef) and token.name in decorate[cls.name] + ) + + for method in filter(_should_decorate_method, cls.body): + existing_decorators = { + token.first_token.string for token in method.decorator_list + } + # only add the decorator if the function is not already decorated: + if "memo" not in existing_decorators: + spec_token = decorate[cls.name][method.name].as_token( + default_namespace=cls.name + ) + method.decorator_list.append(spec_token) + + unparsed_source = unparse(atok) + if "from recorder import memo" not in unparsed_source: + # only add the import if necessary: + unparsed_source = memo_import_block + unparsed_source + + source_file.write_text(unparsed_source) diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py new file mode 100644 index 000000000..a796b100b --- /dev/null +++ b/scenario/runtime/runtime.py @@ -0,0 +1,306 @@ +import dataclasses +import os +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Type + +import yaml +from ops.charm import CharmBase +from ops.framework import EventBase + +from logger import logger as pkg_logger +from scenario.runtime.memo import ( + MEMO_DATABASE_NAME_KEY, + MEMO_MODE_KEY, + MEMO_REPLAY_INDEX_KEY, + USE_STATE_KEY, +) +from scenario.runtime.memo import Event as MemoEvent +from scenario.runtime.memo import Scene as MemoScene +from scenario.runtime.memo import event_db +from scenario.runtime.memo_tools import DECORATE_MODEL, DECORATE_PEBBLE, inject_memoizer +from scenario.event_db import TemporaryEventDB + +if TYPE_CHECKING: + from ops.testing import CharmType + + from scenario.structs import CharmSpec, Scene + +logger = pkg_logger.getChild("runtime") + +RUNTIME_MODULE = Path(__file__).parent +logger = logger.getChild("event_recorder.runtime") + + +@dataclasses.dataclass +class RuntimeRunResult: + charm: CharmBase + scene: "Scene" + event: EventBase + + +class Runtime: + """Charm runtime wrapper. + + This object bridges a local environment and a charm artifact. + """ + + def __init__( + self, + charm_spec: "CharmSpec", + ): + + self._charm_spec = charm_spec + self._charm_type = charm_spec.charm_type + # todo consider cleaning up venv on __delete__, but ideally you should be + # running this in a clean venv or a container anyway. + + @staticmethod + def from_local_file( + local_charm_src: Path, + charm_cls_name: str, + ) -> "Runtime": + sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) + + ldict = {} + + try: + exec( + f"from charm import {charm_cls_name} as my_charm_type", globals(), ldict + ) + except ModuleNotFoundError as e: + raise RuntimeError( + f"Failed to load charm {charm_cls_name}. " + f"Probably some dependency is missing. " + f"Try `pip install -r {local_charm_src / 'requirements.txt'}`" + ) from e + + my_charm_type: Type[CharmBase] = ldict["my_charm_type"] + return Runtime(my_charm_type) + + @staticmethod + def install(force=False): + """Install the runtime LOCALLY. + + Fine prints: + - this will **REWRITE** your local ops.model module to include a @memo decorator + in front of all hook-tool calls. + - this will mess with your os.environ. + - These operations might not be reversible, so consider your environment corrupted. + You should be calling this in a throwaway venv, and probably a container sandbox. + + Nobody will help you fix your borked env. + Have fun! + """ + if not force and Runtime._is_installed(): + logger.warning( + "Runtime is already installed. " + "Pass `force=True` if you wish to proceed anyway. " + "Skipping..." + ) + return + + logger.warning( + "Installing Runtime... " + "DISCLAIMER: this **might** (aka: most definitely will) corrupt your venv." + ) + + from ops import pebble + + ops_pebble_module = Path(pebble.__file__) + logger.info(f"rewriting ops.pebble ({ops_pebble_module})") + inject_memoizer(ops_pebble_module, decorate=DECORATE_PEBBLE) + + from ops import model + + ops_model_module = Path(model.__file__) + logger.info(f"rewriting ops.model ({ops_model_module})") + inject_memoizer(ops_model_module, decorate=DECORATE_MODEL) + + # make main return the charm instance, for testing + from ops import main + + ops_main_module = Path(main.__file__) + logger.info(f"rewriting ops.main ({ops_main_module})") + + retcharm = "return charm # added by jhack.replay.Runtime" + ops_main_module_text = ops_main_module.read_text() + if retcharm not in ops_main_module_text: + ops_main_module.write_text(ops_main_module_text + f" {retcharm}\n") + + @staticmethod + def _is_installed(): + from ops import model + + model_path = Path(model.__file__) + + if "from memo import memo" not in model_path.read_text(): + logger.error( + f"ops.model ({model_path} does not seem to import runtime.memo.memo" + ) + return False + + try: + import memo + except ModuleNotFoundError: + logger.error("Could not `import memo`.") + return False + + logger.info(f"Recorder is installed at {model_path}") + return True + + def _redirect_root_logger(self): + # the root logger set up by ops calls a hook tool: `juju-log`. + # that is a problem for us because `juju-log` is itself memoized, which leads to recursion. + def _patch_logger(*args, **kwargs): + logger.debug("Hijacked root logger.") + pass + + import ops.main + + ops.main.setup_root_logging = _patch_logger + + def _cleanup_env(self, env): + # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. + for key in env: + del os.environ[key] + + @property + def unit_name(self): + meta = self._charm_spec.meta + if not meta: + return "foo/0" + return meta["name"] + "/0" # todo allow override + + def _get_event_env(self, scene: "Scene", charm_root: Path): + return { + "JUJU_UNIT_NAME": self.unit_name, + "_": "./dispatch", + "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", + "JUJU_MODEL_NAME": scene.context.state.model.name, + "JUJU_MODEL_UUID": scene.context.state.model.uuid, + "JUJU_CHARM_DIR": str(charm_root.absolute()) + # todo consider setting pwd, (python)path + } + + def _drop_meta(self, charm_root: Path): + logger.debug("Dropping metadata.yaml and actions.yaml...") + (charm_root / "metadata.yaml").write_text(yaml.safe_dump(self._charm_spec.meta)) + if self._charm_spec.actions: + (charm_root / "actions.yaml").write_text( + yaml.safe_dump(self._charm_spec.actions) + ) + if self._charm_spec.config: + (charm_root / "config.yaml").write_text( + yaml.safe_dump(self._charm_spec.config) + ) + + def _get_runtime_env(self, scene_idx: int, db_path: Path): + env = {} + env.update( + { + USE_STATE_KEY: "1", + MEMO_REPLAY_INDEX_KEY: str(scene_idx), + MEMO_DATABASE_NAME_KEY: str(db_path), + } + ) + sys.path.append(str(RUNTIME_MODULE.absolute())) + env[MEMO_MODE_KEY] = "replay" + + os.environ.update(env) # todo consider subprocess + return env + + def _scene_to_memo_scene(self, scene: "Scene", env: dict) -> MemoScene: + """Convert scenario.structs.Scene to Memo.Scene.""" + return MemoScene(event=MemoEvent(env=env), context=scene.context) + + def run(self, scene: "Scene") -> RuntimeRunResult: + """Executes a scene on the charm. + + This will set the environment up and call ops.main.main(). + After that it's up to ops. + """ + if not Runtime._is_installed(): + raise RuntimeError( + "Runtime is not installed. Call `runtime.install()` (and read the fine prints)." + ) + + logger.info( + f"Preparing to fire {scene.event.name} on {self._charm_type.__name__}" + ) + + logger.info(" - clearing env") + + logger.info(" - preparing env") + with tempfile.TemporaryDirectory() as charm_root: + charm_root_path = Path(charm_root) + env = self._get_event_env(scene, charm_root_path) + + memo_scene = self._scene_to_memo_scene(scene, env) + with TemporaryEventDB(memo_scene, charm_root) as db_path: + self._drop_meta(charm_root_path) + env.update(self._get_runtime_env(0, db_path)) + + logger.info(" - redirecting root logging") + self._redirect_root_logger() + + # logger.info("Resetting scene {} replay cursor.") + # _reset_replay_cursors(self._local_db_path, 0) + os.environ.update(env) + + from ops.main import main + + logger.info(" - Entering ops.main.") + + try: + charm, event = main(self._charm_type) + except Exception as e: + raise RuntimeError( + f"Uncaught error in operator/charm code: {e}." + ) from e + finally: + logger.info(" - Exited ops.main.") + + self._cleanup_env(env) + + with event_db(db_path) as data: + scene_out = data.scenes[0] + + return RuntimeRunResult(charm, scene_out, event) + + +if __name__ == "__main__": + # install Runtime **in your current venv** so that all + # relevant pebble.Client | model._ModelBackend juju/container-facing calls are + # @memo-decorated and can be used in "replay" mode to reproduce a remote run. + Runtime.install(force=False) + + # IRL one would probably manually @memo the annoying ksp calls. + def _patch_traefik_charm(charm: Type["CharmType"]): + from charms.observability_libs.v0 import kubernetes_service_patch # noqa + + def _do_nothing(*args, **kwargs): + print("KubernetesServicePatch call skipped") + + def _null_evt_handler(self, event): + print(f"event {event} received and skipped") + + kubernetes_service_patch.KubernetesServicePatch._service_object = _do_nothing + kubernetes_service_patch.KubernetesServicePatch._patch = _null_evt_handler + return charm + + # here's the magic: + # this env grabs the event db from the "trfk/0" unit (assuming the unit is available + # in the currently switched-to juju model/controller). + runtime = Runtime.from_local_file( + local_charm_src=Path("/home/pietro/canonical/traefik-k8s-operator"), + charm_cls_name="TraefikIngressCharm", + ) + # then it will grab the TraefikIngressCharm from that local path and simulate the whole + # remote runtime env by calling `ops.main.main()` on it. + # this tells the runtime which event to replay. Right now, #X of the + # `jhack replay list trfk/0` queue. Switch it to whatever number you like to + # locally replay that event. + runtime._charm_type = _patch_traefik_charm(runtime._charm_type) + runtime.run(2) diff --git a/scenario/scenario-old.py b/scenario/scenario-old.py new file mode 100644 index 000000000..4323d7f9b --- /dev/null +++ b/scenario/scenario-old.py @@ -0,0 +1,1007 @@ +"""This is a library providing a utility for unit testing event sequences with the harness. +""" + +# The unique Charmhub library identifier, never change it +LIBID = "884af95dbb1d4e8db20e0c29e6231ffe" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import dataclasses +import json +from dataclasses import dataclass +from functools import partial +from typing import Any, Dict, Iterable, Tuple, Union +from uuid import uuid4 + +import ops +import yaml +from ops.testing import CharmType + +if __name__ == "__main__": + pass # to prevent isort from complaining about what follows + +# from networking: +import logging +from collections import defaultdict +from contextlib import contextmanager +from copy import deepcopy +from typing import Callable, Dict, List, Optional, Sequence, TextIO, TypedDict, Union + +from ops.model import Relation, StatusBase + +network_logger = logging.getLogger("networking") +CharmMeta = Optional[Union[str, TextIO, dict]] +AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] + + +class NetworkingError(RuntimeError): + """Base class for errors raised from this module.""" + + +JUJU_INFO = { + "bind-addresses": [ + { + "mac-address": "", + "interface-name": "", + "interfacename": "", + "addresses": [{"hostname": "", "value": "1.1.1.1", "cidr": ""}], + } + ], + "bind-address": "1.1.1.1", + "egress-subnets": ["1.1.1.2/32"], + "ingress-addresses": ["1.1.1.2"], +} # type: _Network + +_Address = TypedDict("_Address", {"hostname": str, "value": str, "cidr": str}) +_BindAddress = TypedDict( + "_BindAddress", + { + "mac-address": str, + "interface-name": str, + "interfacename": str, # ? + "addresses": List[_Address], + }, +) +_Network = TypedDict( + "_Network", + { + "bind-addresses": List[_BindAddress], + "bind-address": str, + "egress-subnets": List[str], + "ingress-addresses": List[str], + }, +) + + +def activate(juju_info_network: "_Network" = JUJU_INFO): + """Patches harness.backend.network_get and initializes the juju-info binding.""" + global PATCH_ACTIVE, _NETWORKS + if PATCH_ACTIVE: + raise NetworkingError("patch already active") + assert not _NETWORKS # type guard + + from ops.testing import _TestingModelBackend + + _NETWORKS = defaultdict(dict) + _TestingModelBackend.network_get = _network_get # type: ignore + _NETWORKS["juju-info"][None] = juju_info_network + + PATCH_ACTIVE = True + + +def deactivate(): + """Undoes the patch.""" + global PATCH_ACTIVE, _NETWORKS + assert PATCH_ACTIVE, "patch not active" + + PATCH_ACTIVE = False + _NETWORKS = None # type: ignore + + +_NETWORKS = None # type: Optional[Dict[str, Dict[Optional[int], _Network]]] +PATCH_ACTIVE = False + + +def _network_get(_, endpoint_name, relation_id=None) -> _Network: + if not PATCH_ACTIVE: + raise NotImplementedError("network-get") + assert _NETWORKS # type guard + + try: + endpoints = _NETWORKS[endpoint_name] + network = endpoints.get(relation_id) + if not network: + # fall back to default binding for relation: + return endpoints[None] + return network + except KeyError as e: + raise NetworkingError( + f"No network for {endpoint_name} -r {relation_id}; " + f"try `add_network({endpoint_name}, {relation_id} | None, Network(...))`" + ) from e + + +def add_network( + endpoint_name: str, + relation_id: Optional[int], + network: _Network, + make_default=False, +): + """Add a network to the harness. + + - `endpoint_name`: the relation name this network belongs to + - `relation_id`: ID of the relation this network belongs to. If None, this will + be the default network for the relation. + - `network`: network data. + - `make_default`: Make this the default network for the endpoint. + Equivalent to calling this again with `relation_id==None`. + """ + if not PATCH_ACTIVE: + raise NetworkingError("module not initialized; " "run activate() first.") + assert _NETWORKS # type guard + + if _NETWORKS[endpoint_name].get(relation_id): + network_logger.warning( + f"Endpoint {endpoint_name} is already bound " + f"to a network for relation id {relation_id}." + f"Overwriting..." + ) + + _NETWORKS[endpoint_name][relation_id] = network + + if relation_id and make_default: + # make it default as well + _NETWORKS[endpoint_name][None] = network + + +def remove_network(endpoint_name: str, relation_id: Optional[int]): + """Remove a network from the harness.""" + if not PATCH_ACTIVE: + raise NetworkingError("module not initialized; " "run activate() first.") + assert _NETWORKS # type guard + + _NETWORKS[endpoint_name].pop(relation_id) + if not _NETWORKS[endpoint_name]: + del _NETWORKS[endpoint_name] + + +def Network( + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), +) -> _Network: + """Construct a network object.""" + return { + "bind-addresses": [ + { + "mac-address": mac_address, + "interface-name": interface_name, + "interfacename": interface_name, + "addresses": [ + {"hostname": hostname, "value": private_address, "cidr": cidr} + ], + } + ], + "bind-address": private_address, + "egress-subnets": list(egress_subnets), + "ingress-addresses": list(ingress_addresses), + } + + +_not_given = object() # None is meaningful, but JUJU_INFO is mutable + + +@contextmanager +def networking( + juju_info_network: Optional[_Network] = _not_given, # type: ignore + networks: Optional[Dict[Union[str, Relation], _Network]] = None, + make_default: bool = False, +): + """Context manager to activate/deactivate networking within a scope. + + Arguments: + - `juju_info_network`: network assigned to the implicit 'juju-info' endpoint. + - `networks`: mapping from endpoints (names, or relations) to networks. + - `make_default`: whether the networks passed as relations should also + be interpreted as default networks for the endpoint. + + Example usage: + >>> with networking(): + >>> assert charm.model.get_binding('juju-info').network.private_address + + >>> foo_relation = harness.model.get_relation('foo', 1) + >>> bar_relation = harness.model.get_relation('bar', 2) + >>> with networking(networks={ + ... foo_relation: Network(private_address='42.42.42.42')} + ... 'bar': Network(private_address='50.50.50.1')}, + ... make_default=True, + ... ): + >>> assert charm.model.get_binding(foo_relation).network.private_address + >>> assert charm.model.get_binding('foo').network.private_address + >>> assert charm.model.get_binding('bar').network.private_address + ... + >>> # this will raise an error! We only defined a default bar + >>> # network, not one specific to this relation ID. + >>> # assert charm.model.get_binding(bar_relation).network.private_address + + """ + global _NETWORKS + old = deepcopy(_NETWORKS) + patch_was_inactive = False + + if juju_info_network is _not_given: + juju_info_network = JUJU_INFO + + if not PATCH_ACTIVE: + patch_was_inactive = True + activate(juju_info_network or JUJU_INFO) + else: + assert _NETWORKS # type guard + + if juju_info_network: + _NETWORKS["juju-info"][None] = juju_info_network + + for binding, network in networks.items() if networks else (): + if isinstance(binding, str): + name = binding + bind_id = None + elif isinstance(binding, Relation): + name = binding.name + bind_id = binding.id + else: + raise TypeError(binding) + add_network(name, bind_id, network, make_default=make_default) + + yield + + _NETWORKS = old + if patch_was_inactive: + deactivate() + + +# from HARNESS_CTX v0 + +import typing +from typing import Callable, Protocol, Type + +from ops.charm import CharmBase, CharmEvents +from ops.framework import BoundEvent, EventBase, Handle +from ops.testing import Harness + + +class _HasOn(Protocol): + @property + def on(self) -> CharmEvents: + ... + + +def _DefaultEmitter(charm: CharmBase, harness: Harness): + return charm + + +class Emitter: + """Event emitter.""" + + def __init__(self, harness: Harness, emit: Callable[[], BoundEvent]): + self.harness = harness + self._emit = emit + self.event = None + self._emitted = False + + @property + def emitted(self): + """Has the event been emitted already?""" # noqa + return self._emitted + + def emit(self): + """Emit the event. + + Will get called automatically when HarnessCtx exits if you didn't call it already. + """ + assert not self._emitted, "already emitted; should not emit twice" + self._emitted = True + self.event = self._emit() + return self.event + + +class HarnessCtx: + """Harness-based context for emitting a single event. + + Example usage: + >>> class MyCharm(CharmBase): + >>> def __init__(self, framework: Framework, key: typing.Optional = None): + >>> super().__init__(framework, key) + >>> self.framework.observe(self.on.update_status, self._listen) + >>> self.framework.observe(self.framework.on.commit, self._listen) + >>> + >>> def _listen(self, e): + >>> self.event = e + >>> + >>> with HarnessCtx(MyCharm, "update-status") as h: + >>> event = h.emit() + >>> assert event.handle.kind == "update_status" + >>> + >>> assert h.harness.charm.event.handle.kind == "commit" + """ + + def __init__( + self, + charm: Type[CharmBase], + event_name: str, + emitter: Callable[[CharmBase, Harness], _HasOn] = _DefaultEmitter, + meta: Optional[CharmMeta] = None, + actions: Optional[CharmMeta] = None, + config: Optional[CharmMeta] = None, + event_args: Tuple[Any, ...] = (), + event_kwargs: Dict[str, Any] = None, + pre_begin_hook: Optional[Callable[[Harness], None]] = None, + ): + self.charm_cls = charm + self.emitter = emitter + self.event_name = event_name.replace("-", "_") + self.event_args = event_args + self.event_kwargs = event_kwargs or {} + self.pre_begin_hook = pre_begin_hook + + def _to_yaml(obj): + if isinstance(obj, str): + return obj + elif not obj: + return None + return yaml.safe_dump(obj) + + self.harness_kwargs = { + "meta": _to_yaml(meta), + "actions": _to_yaml(actions), + "config": _to_yaml(config), + } + + @staticmethod + def _inject(harness: Harness, obj): + if isinstance(obj, InjectRelation): + return harness.model.get_relation( + relation_name=obj.relation_name, relation_id=obj.relation_id + ) + + return obj + + def _process_event_args(self, harness): + return map(partial(self._inject, harness), self.event_args) + + def _process_event_kwargs(self, harness): + kwargs = self.event_kwargs + return kwargs + + def __enter__(self): + self._harness = harness = Harness(self.charm_cls, **self.harness_kwargs) + if self.pre_begin_hook: + logger.debug("running harness pre-begin hook") + self.pre_begin_hook(harness) + + harness.begin() + + emitter = self.emitter(harness.charm, harness) + events = getattr(emitter, "on") + event_source: BoundEvent = getattr(events, self.event_name) + + def _emit() -> BoundEvent: + # we don't call event_source.emit() + # because we want to grab the event + framework = event_source.emitter.framework + key = framework._next_event_key() # noqa + handle = Handle(event_source.emitter, event_source.event_kind, key) + + event_args = self._process_event_args(harness) + event_kwargs = self._process_event_kwargs(harness) + + event = event_source.event_type(handle, *event_args, **event_kwargs) + event.framework = framework + framework._emit(event) # type: ignore # noqa + return typing.cast(BoundEvent, event) + + self._emitter = bound_ctx = Emitter(harness, _emit) + return bound_ctx + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self._emitter.emitted: + self._emitter.emit() + self._harness.framework.on.commit.emit() # type: ignore + + +# from show-relation! + + +@dataclass +class DCBase: + def replace(self, *args, **kwargs): + return dataclasses.replace(self, *args, **kwargs) + + +@dataclass +class RelationMeta(DCBase): + endpoint: str + interface: str + remote_app_name: str + relation_id: int + + # local limit + limit: int = 1 + + remote_unit_ids: Tuple[int, ...] = (0,) + # scale of the remote application; number of units, leader ID? + # TODO figure out if this is relevant + scale: int = 1 + leader_id: int = 0 + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class RelationSpec(DCBase): + meta: RelationMeta + application_data: dict = dataclasses.field(default_factory=dict) + units_data: Dict[int, dict] = dataclasses.field(default_factory=dict) + + @classmethod + def from_dict(cls, obj): + meta = RelationMeta.from_dict(obj.pop("meta")) + return cls(meta=meta, **obj) + + def copy(self): + return dataclasses.replace() + + +# ACTUAL LIBRARY CODE. Dependencies above. + +logger = logging.getLogger("evt-sequences") + +ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" +CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" +BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" +DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" +META_EVENTS = { + "ATTACH_ALL_STORAGES", + "CREATE_ALL_RELATIONS", + "BREAK_ALL_RELATIONS", + "DETACH_ALL_STORAGES", +} + + +@dataclass +class CharmSpec: + """Charm spec.""" + + charm_type: Type[CharmType] + meta: Optional[CharmMeta] = None + actions: Optional[CharmMeta] = None + config: Optional[CharmMeta] = None + + @staticmethod + def cast(obj: Union["CharmSpec", CharmType, Type[CharmBase]]): + if isinstance(obj, type) and issubclass(obj, CharmBase): + return CharmSpec(charm_type=obj) + elif isinstance(obj, CharmSpec): + return obj + else: + raise ValueError(f"cannot convert {obj} to CharmSpec") + + +class PlayResult: + # TODO: expose the 'final context' or a Delta object from the PlayResult. + def __init__(self, event: "BoundEvent", context: "Context", emitter: "Emitter"): + self.event = event + self.context = context + self.emitter = emitter + + # some useful attributes + self.harness = emitter.harness + self.charm = self.harness.charm + self.status = self.emitter.harness.charm.unit.status + + +@dataclass +class _Event(DCBase): + name: str + args: Tuple[Any] = () + kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + + @property + def is_meta(self): + return self.name in META_EVENTS + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + def as_scenario(self, context: "Context"): + """Utility to get to a single-event Scenario from a single event instance.""" + return Scenario.from_scenes(Scene(context=context, event=self)) + + def play( + self, + context: "Context", + charm_spec: CharmSpec, + assertions: Sequence[AssertionType] = (), + ) -> PlayResult: + """Utility to play this as a single scene.""" + return ( + self.as_scenario(context) + .bind( + charm_spec=charm_spec, + ) + .play_until_complete(assertions=assertions) + ) + + +def _derive_args(event_name: str): + args = [] + terms = { + "-relation-changed", + "-relation-broken", + "-relation-joined", + "-relation-departed", + "-relation-created", + } + + for term in terms: + # fixme: we can't disambiguate between relation IDs. + if event_name.endswith(term): + args.append(InjectRelation(relation_name=event_name[: -len(term)])) + + return tuple(args) + + +def Event(name: str, append_args: Tuple[Any] = (), **kwargs) -> _Event: + """This routine will attempt to generate event args for you, based on the event name.""" + return _Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs) + + +@dataclass +class NetworkSpec(DCBase): + name: str + bind_id: int + network: _Network + is_default: bool = False + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class ContainerSpec(DCBase): + name: str + can_connect: bool = False + # todo mock filesystem and pebble proc? + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class Model(DCBase): + name: str = "foo" + uuid: str = str(uuid4()) + + +@dataclass +class Context(DCBase): + config: Dict[str, Union[str, int, float, bool]] = None + relations: Tuple[RelationSpec] = () + networks: Tuple[NetworkSpec] = () + containers: Tuple[ContainerSpec] = () + leader: bool = False + model: Model = Model() + + # todo: add pebble stuff, unit/app status, etc... + # containers + # status + # actions? + # juju topology + + @classmethod + def from_dict(cls, obj): + return cls( + config=obj["config"], + relations=tuple( + RelationSpec.from_dict(raw_ard) for raw_ard in obj["relations"] + ), + networks=tuple(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), + leader=obj["leader"], + ) + + def as_scenario(self, event: _Event): + """Utility to get to a single-event Scenario from a single context instance.""" + return Scenario.from_scenes(Scene(context=self, event=event)) + + def play( + self, + event: _Event, + charm_spec: CharmSpec, + assertions: Sequence[AssertionType] = (), + ) -> PlayResult: + """Utility to play this as a single scene.""" + return ( + self.as_scenario(event) + .bind( + charm_spec=charm_spec, + ) + .play_until_complete(assertions=assertions) + ) + + # utilities to quickly mutate states "deep" inside the tree + def replace_container_connectivity(self, container_name: str, can_connect: bool): + def replacer(container: ContainerSpec): + if container.name == container_name: + return container.replace(can_connect=can_connect) + return container + + ctrs = tuple(map(replacer, self.containers)) + return self.replace(containers=ctrs) + + +null_context = Context() + + +@dataclass +class Scene(DCBase): + event: _Event + context: Context = None + name: str = "" + + @classmethod + def from_dict(cls, obj): + evt = obj["event"] + return cls( + event=_Event(evt) if isinstance(evt, str) else _Event.from_dict(evt), + context=Context.from_dict(obj["context"]) + if obj["context"] is not None + else None, + name=obj["name"], + ) + + +class _Builtins: + @staticmethod + def startup(leader=True): + return Scenario.from_events( + ( + ATTACH_ALL_STORAGES, + "start", + CREATE_ALL_RELATIONS, + "leader-elected" if leader else "leader-settings-changed", + "config-changed", + "install", + ) + ) + + @staticmethod + def teardown(): + return Scenario.from_events( + (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") + ) + + +class Playbook: + def __init__(self, scenes: Iterable[Scene]): + self._scenes = list(scenes) + self._cursor = 0 + + def __bool__(self): + return bool(self._scenes) + + @property + def is_done(self): + return self._cursor < (len(self._scenes) - 1) + + def add(self, scene: Scene): + self._scenes.append(scene) + + def next(self): + self.scroll(1) + return self._scenes[self._cursor] + + def scroll(self, n): + if not 0 <= self._cursor + n <= len(self._scenes): + raise RuntimeError(f"Cursor out of bounds: can't scroll ({self}) by {n}.") + self._cursor += n + + def restart(self): + self._cursor = 0 + + def __repr__(self): + return f"" + + def __iter__(self): + yield from self._scenes + + def __next__(self): + return self.next() + + def dump(self) -> str: + """Serialize.""" + obj = {"scenes": [dataclasses.asdict(scene) for scene in self._scenes]} + return json.dumps(obj, indent=2) + + @staticmethod + def load(s: str) -> "Playbook": + obj = json.loads(s) + scenes = tuple(Scene.from_dict(raw_scene) for raw_scene in obj["scenes"]) + return Playbook(scenes=scenes) + + +class _UnboundScenario: + def __init__( + self, + playbook: Playbook = Playbook(()), + ): + self._playbook = playbook + + @property + def playbook(self): + return self._playbook + + def __call__(self, charm_spec: Union[CharmSpec, CharmType]): + return Scenario(charm_spec=CharmSpec.cast(charm_spec), playbook=self.playbook) + + bind = __call__ # alias + + +@dataclass +class Inject: + """Base class for injectors: special placeholders used to tell harness_ctx + to inject instances that can't be retrieved in advance in event args or kwargs. + """ + + pass + + +@dataclass +class InjectRelation(Inject): + relation_name: str + relation_id: Optional[int] = None + + +class Scenario: + builtins = _Builtins() + + def __init__(self, charm_spec: CharmSpec, playbook: Playbook = Playbook(())): + + self._playbook = playbook + self._charm_spec = CharmSpec.cast(charm_spec) + + @staticmethod + def from_scenes(playbook: Union[Scene, Iterable[Scene]]) -> _UnboundScenario: + _scenes = (playbook,) if isinstance(playbook, Scene) else tuple(playbook) + for i, scene in enumerate(_scenes): + if not scene.name: + scene.name = f"" + return _UnboundScenario(playbook=Playbook(_scenes)) + + @staticmethod + def from_events(events: typing.Sequence[Union[str, _Event]]) -> _UnboundScenario: + def _to_event(obj): + if isinstance(obj, str): + return _Event(obj) + elif isinstance(obj, _Event): + return obj + else: + raise TypeError(obj) + + return Scenario.from_scenes(map(Scene, map(_to_event, events))) + + @property + def playbook(self) -> Playbook: + return self._playbook + + def __enter__(self): + self._entered = True + activate() + return self + + def __exit__(self, *exc_info): + self._playbook.restart() + deactivate() + self._entered = False + if exc_info: + return False + return True + + @staticmethod + def _pre_setup_context(harness: Harness, context: Context): + # Harness initialization that needs to be done pre-begin() + + # juju topology: + harness.set_model_info(name=context.model.name, uuid=context.model.uuid) + + @staticmethod + def _setup_context(harness: Harness, context: Context): + harness.disable_hooks() + be: ops.testing._TestingModelBackend = harness._backend # noqa + + # relation data + for container in context.containers: + harness.set_can_connect(container.name, container.can_connect) + + # relation data + for relation in context.relations: + remote_app_name = relation.meta.remote_app_name + r_id = harness.add_relation(relation.meta.endpoint, remote_app_name) + if remote_app_name != harness.charm.app.name: + if relation.application_data: + harness.update_relation_data( + r_id, remote_app_name, relation.application_data + ) + for unit_n, unit_data in relation.units_data.items(): + unit_name = f"{remote_app_name}/{unit_n}" + harness.add_relation_unit(r_id, unit_name) + harness.update_relation_data(r_id, unit_name, unit_data) + else: + if relation.application_data: + harness.update_relation_data( + r_id, harness.charm.app.name, relation.application_data + ) + if relation.units_data: + if not tuple(relation.units_data) == (0,): + raise RuntimeError("Only one local unit is supported.") + harness.update_relation_data( + r_id, harness.charm.unit.name, relation.units_data[0] + ) + # leadership: + harness.set_leader(context.leader) + + # networking + for network in context.networks: + add_network( + endpoint_name=network.name, + relation_id=network.bind_id, + network=network.network, + make_default=network.is_default, + ) + harness.enable_hooks() + + @staticmethod + def _cleanup_context(harness: Harness, context: Context): + # Harness will be reinitialized, so nothing to clean up there; + # however: + for network in context.networks: + remove_network(endpoint_name=network.name, relation_id=network.bind_id) + + def _play_meta( + self, event: _Event, context: Context = None, add_to_playbook: bool = False + ): + # decompose the meta event + events = [] + + if event.name == ATTACH_ALL_STORAGES: + logger.warning(f"meta-event {event.name} not supported yet") + return + + elif event.name == DETACH_ALL_STORAGES: + logger.warning(f"meta-event {event.name} not supported yet") + return + + elif event.name == CREATE_ALL_RELATIONS: + if context: + for relation in context.relations: + # RELATION_OBJ is to indicate to the harness_ctx that + # it should retrieve the + evt = _Event( + f"{relation.meta.endpoint}-relation-created", + args=( + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id + ), + ), + ) + events.append(evt) + + elif event.name == BREAK_ALL_RELATIONS: + if context: + for relation in context.relations: + evt = _Event( + f"{relation.meta.endpoint}-relation-broken", + args=( + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id + ), + ), + ) + events.append(evt) + # todo should we ensure there's no relation data in this context? + + else: + raise RuntimeError(f"unknown meta-event {event.name}") + + logger.debug(f"decomposed meta {event.name} into {events}") + last = None + for event in events: + last = self.play(event, context, add_to_playbook=add_to_playbook) + return last + + def play( + self, + event: Union[_Event, str], + context: Context = None, + add_to_playbook: bool = False, + ) -> PlayResult: + if not self._entered: + raise RuntimeError( + "Scenario.play() should be only called " + "within the Scenario's context." + ) + _event = _Event(event) if isinstance(event, str) else event + + if _event.is_meta: + return self._play_meta(_event, context, add_to_playbook=add_to_playbook) + + charm_spec = self._charm_spec + pre_begin_hook = None + + if context: + # some context needs to be set up before harness.begin() is called. + pre_begin_hook = partial(self._pre_setup_context, context=context) + + with HarnessCtx( + charm_spec.charm_type, + event_name=_event.name, + event_args=_event.args, + event_kwargs=_event.kwargs, + meta=charm_spec.meta, + actions=charm_spec.actions, + config=charm_spec.config, + pre_begin_hook=pre_begin_hook, + ) as emitter: + if context: + self._setup_context(emitter.harness, context) + + ops_evt_obj: BoundEvent = emitter.emit() + + # todo verify that if state was mutated, it was mutated + # in a way that makes sense: + # e.g. - charm cannot modify leadership status, etc... + if context: + self._cleanup_context(emitter.harness, context) + + if add_to_playbook: + # so we can later export it + self._playbook.add(Scene(context=context, event=event)) + + return PlayResult(event=ops_evt_obj, context=context, emitter=emitter) + + def play_next(self): + next_scene: Scene = self._playbook.next() + self.play(*next_scene) + + def play_until_complete(self): + if not self._playbook: + raise RuntimeError("playbook is empty") + + with self: + for context, event in self._playbook: + ctx = self.play(event=event, context=context) + return ctx + + @staticmethod + def _check_assertions( + ctx: PlayResult, assertions: Union[AssertionType, Iterable[AssertionType]] + ): + if callable(assertions): + assertions = [assertions] + + for assertion in assertions: + ret_val = assertion(*ctx) + if ret_val is False: + raise ValueError(f"Assertion {assertion} returned False") diff --git a/scenario/scenario.py b/scenario/scenario.py new file mode 100644 index 000000000..7fc7da594 --- /dev/null +++ b/scenario/scenario.py @@ -0,0 +1,258 @@ +import json +from dataclasses import asdict +from typing import Callable, Iterable, TextIO, List, Optional, Union, Dict, Any + +from ops.charm import CharmBase +from ops.framework import BoundEvent, EventBase + +from logger import logger as pkg_logger +from scenario import Runtime +from scenario.consts import (ATTACH_ALL_STORAGES, BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, CREATE_ALL_RELATIONS,) +from scenario.structs import Event, Scene, Context, InjectRelation, CharmSpec + +CharmMeta = Optional[Union[str, TextIO, dict]] +AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] + +logger = pkg_logger.getChild("scenario") + + +class Emitter: + """Event emitter.""" + + def __init__(self, emit: Callable[[], BoundEvent]): + self._emit = emit + self.event = None + self._emitted = False + + @property + def emitted(self): + """Has the event been emitted already?""" # noqa + return self._emitted + + def emit(self): + """Emit the event. + + Will get called automatically when the context exits if you didn't call it already. + """ + if self._emitted: + raise RuntimeError("already emitted; should not emit twice") + + self._emitted = True + self.event = self._emit() + return self.event + + +class PlayResult: + # TODO: expose the 'final context' or a Delta object from the PlayResult. + def __init__( + self, + charm: CharmBase, + scene_in: "Scene", + event: EventBase, + context_out: "Context", + ): + self.charm = charm + self.scene_in = scene_in + self.context_out = context_out + self.event = event + + def delta(self): + try: + import jsonpatch + except ModuleNotFoundError: + raise ImportError( + "cannot import jsonpatch: using the .delta() " + "extension requires jsonpatch to be installed." + "Fetch it with pip install jsonpatch." + ) + if self.scene_in.context == self.context_out: + return None + + return jsonpatch.make_patch( + asdict(self.scene_in.context), asdict(self.context_out) + ) + + +class _Builtins: + @staticmethod + def startup(leader=True): + return Scenario.from_events( + ( + ATTACH_ALL_STORAGES, + "start", + CREATE_ALL_RELATIONS, + "leader-elected" if leader else "leader-settings-changed", + "config-changed", + "install", + ) + ) + + @staticmethod + def teardown(): + return Scenario.from_events( + (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") + ) + + +class Playbook: + def __init__(self, scenes: Iterable[Scene]): + self._scenes = list(scenes) + self._cursor = 0 + + def __bool__(self): + return bool(self._scenes) + + @property + def is_done(self): + return self._cursor < (len(self._scenes) - 1) + + def add(self, scene: Scene): + self._scenes.append(scene) + + def next(self): + self.scroll(1) + return self._scenes[self._cursor] + + def scroll(self, n): + if not 0 <= self._cursor + n <= len(self._scenes): + raise RuntimeError(f"Cursor out of bounds: can't scroll ({self}) by {n}.") + self._cursor += n + + def restart(self): + self._cursor = 0 + + def __repr__(self): + return f"" + + def __iter__(self): + yield from self._scenes + + def __next__(self): + return self.next() + + def to_dict(self) -> Dict[str, List[Any]]: + """Serialize.""" + return {"scenes": [asdict(scene) for scene in self._scenes]} + + def to_json(self) -> str: + """Dump as json dict.""" + return json.dumps(self.to_dict(), indent=2) + + @staticmethod + def load(s: str) -> "Playbook": + obj = json.loads(s) + scenes = tuple(Scene.from_dict(raw_scene) for raw_scene in obj["scenes"]) + return Playbook(scenes=scenes) + + +class Scenario: + builtins = _Builtins() + + def __init__(self, charm_spec: CharmSpec, playbook: Playbook = Playbook(())): + self._playbook = playbook + self._charm_spec = charm_spec + self._charm_type = charm_spec.charm_type + self._runtime = Runtime(charm_spec) + + @property + def playbook(self) -> Playbook: + return self._playbook + + def reset(self): + self._playbook.restart() + + def _play_meta( + self, event: Event, context: Context = None, add_to_playbook: bool = False + ): + # decompose the meta event + events = [] + + if event.name == ATTACH_ALL_STORAGES: + logger.warning(f"meta-event {event.name} not supported yet") + return + + elif event.name == DETACH_ALL_STORAGES: + logger.warning(f"meta-event {event.name} not supported yet") + return + + elif event.name == CREATE_ALL_RELATIONS: + if context: + for relation in context.relations: + # RELATION_OBJ is to indicate to the harness_ctx that + # it should retrieve the + evt = Event( + f"{relation.meta.endpoint}-relation-created", + args=( + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id + ), + ), + ) + events.append(evt) + + elif event.name == BREAK_ALL_RELATIONS: + if context: + for relation in context.relations: + evt = Event( + f"{relation.meta.endpoint}-relation-broken", + args=( + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id + ), + ), + ) + events.append(evt) + # todo should we ensure there's no relation data in this context? + + else: + raise RuntimeError(f"unknown meta-event {event.name}") + + logger.debug(f"decomposed meta {event.name} into {events}") + last = None + for event in events: + last = self.play(event, context, add_to_playbook=add_to_playbook) + return last + + def run(self, scene: Scene, add_to_playbook: bool = False): + return self.play(scene, add_to_playbook=add_to_playbook) + + def play( + self, + obj: Union[Scene, str], + context: Context = None, + add_to_playbook: bool = False, + ) -> PlayResult: + + if isinstance(obj, str): + _event = Event(obj) if isinstance(obj, str) else obj + if _event.is_meta: + return self._play_meta(_event, context, add_to_playbook=add_to_playbook) + scene = Scene(_event, context) + else: + scene = obj + + runtime = self._runtime + result = runtime.run(scene) + # todo verify that if state was mutated, it was mutated + # in a way that makes sense: + # e.g. - charm cannot modify leadership status, etc... + + if add_to_playbook: + # so we can later export it + self._playbook.add(scene) + + return PlayResult( + charm=result.charm, + scene_in=scene, + context_out=result.scene.context, + event=result.event, + ) + + def play_until_complete(self): + if not self._playbook: + raise RuntimeError("playbook is empty") + + with self: + for context, event in self._playbook: + ctx = self.play(event=event, context=context) + return ctx diff --git a/scenario/structs.py b/scenario/structs.py new file mode 100644 index 000000000..726852f9c --- /dev/null +++ b/scenario/structs.py @@ -0,0 +1,211 @@ +import dataclasses +from dataclasses import dataclass +from typing import Any, Dict, Literal, Optional, Tuple, Type, Union + +from ops.charm import CharmBase +from ops.testing import CharmType + +from scenario.runtime import memo +from scenario.consts import META_EVENTS + + +@dataclass +class DCBase: + def replace(self, *args, **kwargs): + return dataclasses.replace(self, *args, **kwargs) + + def copy(self): + return dataclasses.replace(self) + + +@dataclass +class Event(DCBase): + name: str + args: Tuple[Any] = () + kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + + @property + def is_meta(self): + return self.name in META_EVENTS + + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + def as_scene(self, state: "State") -> "Scene": + """Utility to get to a single-event Scenario from a single event instance.""" + return Scene(context=Context(state=state), event=self) + + +# from show-relation! +@dataclass +class RelationMeta(memo.RelationMeta, DCBase): + pass + + +@dataclass +class RelationSpec(memo.RelationSpec, DCBase): + pass + + +def network( + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), +) -> memo.Network: + """Construct a network object.""" + return memo.Network( + bind_addresses=[ + memo.BindAddress( + mac_address=mac_address, + interface_name=interface_name, + interfacename=interface_name, + addresses=[ + memo.Address(hostname=hostname, value=private_address, cidr=cidr) + ], + ) + ], + bind_address=private_address, + egress_subnets=list(egress_subnets), + ingress_addresses=list(ingress_addresses), + ) + + +@dataclass +class NetworkSpec(memo.NetworkSpec, DCBase): + pass + + +@dataclass +class ContainerSpec(memo.ContainerSpec, DCBase): + @classmethod + def from_dict(cls, obj): + return cls(**obj) + + +@dataclass +class Model(memo.Model, DCBase): + pass + + +@dataclass +class State(memo.State, DCBase): + def with_can_connect(self, container_name: str, can_connect: bool): + def replacer(container: ContainerSpec): + if container.name == container_name: + return container.replace(can_connect=can_connect) + return container + + ctrs = tuple(map(replacer, self.containers)) + return self.replace(containers=ctrs) + + def with_leadership(self, leader: bool): + return self.replace(leader=leader) + + def with_unit_status(self, status: str, message: str): + return self.replace(status=dataclasses.replace( + self.status, unit=(status, message))) + + +@ dataclass +class CharmSpec: + """Charm spec.""" + + charm_type: Type["CharmType"] + meta: Optional[Dict[str, Any]] = None + actions: Optional[Dict[str, Any]] = None + config: Optional[Dict[str, Any]] = None + + @staticmethod + def cast(obj: Union["CharmSpec", CharmType, Type[CharmBase]]): + if isinstance(obj, type) and issubclass(obj, CharmBase): + return CharmSpec(charm_type=obj) + elif isinstance(obj, CharmSpec): + return obj + else: + raise ValueError(f"cannot convert {obj} to CharmSpec") + + +@dataclass +class Memo(DCBase): + calls: Dict[str, Any] + cursor: int = 0 + caching_policy: Literal["loose", "strict"] = "strict" + + @classmethod + def from_dict(cls, obj): + return Memo(**obj) + + +@dataclass +class Context(DCBase): + memos: Dict[str, Memo] = dataclasses.field(default_factory=dict) + state: State = dataclasses.field(default_factory=State.null) + + @classmethod + def from_dict(cls, obj): + if obj is None: + return Context() + return cls( + memos={x: Memo.from_dict(m) for x, m in obj.get("memos", {}).items()}, + state=State.from_dict(obj.get("state")), + ) + + def to_dict(self): + return dataclasses.asdict(self) + + +@dataclass +class Scene(DCBase): + event: Event + context: Context = dataclasses.field(default_factory=Context) + + @classmethod + def from_dict(cls, obj): + evt = obj["event"] + return cls( + event=Event(evt) if isinstance(evt, str) else Event.from_dict(evt), + context=Context.from_dict(obj.get("context")), + ) + + +@dataclass +class Inject: + """Base class for injectors: special placeholders used to tell harness_ctx + to inject instances that can't be retrieved in advance in event args or kwargs. + """ + + pass + + +@dataclass +class InjectRelation(Inject): + relation_name: str + relation_id: Optional[int] = None + + +def _derive_args(event_name: str): + args = [] + terms = { + "-relation-changed", + "-relation-broken", + "-relation-joined", + "-relation-departed", + "-relation-created", + } + + for term in terms: + # fixme: we can't disambiguate between relation IDs. + if event_name.endswith(term): + args.append(InjectRelation(relation_name=event_name[: -len(term)])) + + return tuple(args) + + +def get_event(name: str, append_args: Tuple[Any] = (), **kwargs) -> Event: + """This routine will attempt to generate event args for you, based on the event name.""" + return Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs) diff --git a/tests/memo_tools_test_files/mock_ops.py b/tests/memo_tools_test_files/mock_ops.py new file mode 100644 index 000000000..7f99d665f --- /dev/null +++ b/tests/memo_tools_test_files/mock_ops.py @@ -0,0 +1,19 @@ +import random + +from recorder import memo + + +class _ModelBackend: + def _private_method(self): + pass + + def other_method(self): + pass + + @memo + def action_set(self, *args, **kwargs): + return str(random.random()) + + @memo + def action_get(self, *args, **kwargs): + return str(random.random()) diff --git a/tests/memo_tools_test_files/prom-0-update-status.json b/tests/memo_tools_test_files/prom-0-update-status.json new file mode 100644 index 000000000..15ae9bdab --- /dev/null +++ b/tests/memo_tools_test_files/prom-0-update-status.json @@ -0,0 +1,112 @@ +{ + "event": { + "env": { + "JUJU_UNIT_NAME": "prom/0", + "KUBERNETES_SERVICE_PORT": "443", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "JUJU_VERSION": "3.0-beta4", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "prom/0-run-commands-6599488296390959740", + "SHLVL": "1", + "JUJU_API_ADDRESSES": "10.152.183.162:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-prom-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/update-status", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-prom-0/charm", + "_": "./dispatch", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "PATH": "/var/lib/juju/tools/unit-prom-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "LANG": "C.UTF-8", + "JUJU_HOOK_NAME": "", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "JUJU_MODEL_UUID": "62f2bf65-4e77-445b-85d3-e95b02c2a720", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-prom-0/charm", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_MACHINE_ID": "", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-prom-0/charm" + }, + "timestamp": "2022-10-13T09:28:46.116952" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.3 up and running.\"], {}]": null, + "[[\"DEBUG\", \"Emitting Juju event update_status.\"], {}]": null + }, + "cursor": 0, + "caching_policy": "loose" + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": { + "evaluation_interval": "1m", + "log_level": "info", + "maximum_retention_size": "80%", + "metrics_retention_time": "15d", + "metrics_wal_compression": false, + "web_external_url": "" + } + }, + "cursor": 0, + "caching_policy": "loose" + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"self-metrics-endpoint\"], {}]": [] + }, + "cursor": 0, + "caching_policy": "loose" + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + [ + [], + {} + ], + false + ] + ], + "cursor": 1, + "caching_policy": "strict" + }, + "_ModelBackend.status_get": { + "calls": [ + [ + [ + [], + { + "is_app": false + } + ], + { + "message": "", + "status": "active", + "status-data": {} + } + ] + ], + "cursor": 1, + "caching_policy": "strict" + } + } + } +} diff --git a/tests/memo_tools_test_files/trfk-re-relate.json b/tests/memo_tools_test_files/trfk-re-relate.json new file mode 100644 index 000000000..80a24b8f2 --- /dev/null +++ b/tests/memo_tools_test_files/trfk-re-relate.json @@ -0,0 +1,2011 @@ +{ + "scenes": [ + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-3853912271400585799", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/0", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:2", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "JUJU_DEPARTING_UNIT": "prom/0", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:11.549417" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[2]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[2], {}]": "[\"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-7784712693186482924", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/1", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:2", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "JUJU_DEPARTING_UNIT": "prom/1", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:12.150858" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[2]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[2], {}]": "[]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_remote_app_name": { + "calls": { + "[[2], {}]": "\"prom\"" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-broken-3056476173106073839", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-broken", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:2", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-broken", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:12.747513" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-broken does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_broken.\"], {}]": "null", + "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:2' relation\"], {}]": "null", + "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml ingress configuration file\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[2]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[2], {}]": "[]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_remote_app_name": { + "calls": { + "[[2], {}]": "\"prom\"" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml\", \"recursive\": true}]}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[2, \"ingress\", \"\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-created-4762862175976873534", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-created", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-created", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:17.000591" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-created does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_created.\"], {}]": "null", + "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:3' relation\"], {}]": "null", + "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml ingress configuration file\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_remote_app_name": { + "calls": { + "[[3], {}]": "\"prom\"" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\", \"recursive\": true}]}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-joined-6745869886287524665", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-joined", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/1", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-joined", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:17.643738" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-joined does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_joined.\"], {}]": "null", + "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", + "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_get": { + "calls": [ + [ + "[[3, \"prom/1\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"trfk\", true], {}]", + "{}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ], + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.status_set": { + "calls": { + "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", + "[[\"active\", \"\"], {\"is_app\": false}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "Client.push": { + "calls": [ + [ + "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "PebblePush", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-changed-6061150792313454742", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/1", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-changed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:18.375687" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", + "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", + "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_get": { + "calls": [ + [ + "[[3, \"prom/1\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"trfk\", true], {}]", + "{}" + ] + ], + "cursor": 2, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ], + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.status_set": { + "calls": { + "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", + "[[\"active\", \"\"], {\"is_app\": false}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "Client.push": { + "calls": [ + [ + "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "PebblePush", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_SERVICE_PORT": "443", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-run-commands-4164421675469075254", + "SHLVL": "1", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "_": "./dispatch", + "TERM": "tmux-256color", + "JUJU_RELATION": "ingress-per-unit", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "3", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "LANG": "C.UTF-8", + "JUJU_HOOK_NAME": "", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_MACHINE_ID": "", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm" + }, + "timestamp": "2022-10-21T17:31:26.302339" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", + "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", + "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_get": { + "calls": [ + [ + "[[3, \"prom/1\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"trfk\", true], {}]", + "{}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ], + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.status_set": { + "calls": { + "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", + "[[\"active\", \"\"], {\"is_app\": false}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "Client.push": { + "calls": [ + [ + "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "PebblePush", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-joined-6035930779166359233", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-joined", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/0", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-joined", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:45.160862" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-joined does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_joined.\"], {}]": "null", + "[[\"ERROR\", \"Remote unit prom/0 sent invalid data (({}, {'type': 'object', 'properties': {'model': {'type': 'string'}, 'name': {'type': 'string'}, 'host': {'type': 'string'}, 'port': {'type': 'string'}, 'mode': {'type': 'string'}, 'strip-prefix': {'type': 'string'}}, 'required': ['model', 'name', 'host', 'port']})).\"], {}]": "null", + "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", + "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/0\", \"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_get": { + "calls": [ + [ + "[[3, \"prom/1\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"prom/0\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"ingress-address\": \"10.152.183.124\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"trfk\", true], {}]", + "{}" + ] + ], + "cursor": 3, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ], + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ] + ], + "cursor": 4, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.status_set": { + "calls": { + "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", + "[[\"active\", \"\"], {\"is_app\": false}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 6, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ] + ], + "cursor": 2, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "Client.push": { + "calls": [ + [ + "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", + "null" + ] + ], + "cursor": 1, + "caching_policy": "strict", + "serializer": [ + "PebblePush", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-changed-4364058908358574353", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/0", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-changed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:31:45.982074" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", + "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", + "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/0\", \"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_get": { + "calls": [ + [ + "[[3, \"prom/1\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"prom/0\", false], {}]", + "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" + ], + [ + "[[3, \"trfk\", true], {}]", + "{}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.config_get": { + "calls": { + "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ], + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.status_set": { + "calls": { + "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", + "[[\"active\", \"\"], {\"is_app\": false}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ], + [ + "[[3, \"ingress\", \"prom/0:\\n url: http://foo.com:80/foo-prom-0\\nprom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "Client.push": { + "calls": [ + [ + "gASV9QIAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJRAgAAgASVRgIAAAAAAABYPwIAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMC1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMGApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMC1zZXJ2aWNlCiAgICBqdWp1LWZvby1wcm9tLTEtcm91dGVyOgogICAgICBlbnRyeVBvaW50czoKICAgICAgLSB3ZWIKICAgICAgcnVsZTogUGF0aFByZWZpeChgL2Zvby1wcm9tLTFgKQogICAgICBzZXJ2aWNlOiBqdWp1LWZvby1wcm9tLTEtc2VydmljZQogIHNlcnZpY2VzOgogICAganVqdS1mb28tcHJvbS0wLXNlcnZpY2U6CiAgICAgIGxvYWRCYWxhbmNlcjoKICAgICAgICBzZXJ2ZXJzOgogICAgICAgIC0gdXJsOiBodHRwOi8vcHJvbS0wLnByb20tZW5kcG9pbnRzLmZvby5zdmMuY2x1c3Rlci5sb2NhbDo5MDkwCiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "PebblePush", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-685455772410773578", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/0", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "JUJU_DEPARTING_UNIT": "prom/0", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:41:29.212268" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[\"prom/1\"]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-4600292350819057959", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "prom/1", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "JUJU_DEPARTING_UNIT": "prom/1", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:41:29.868875" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_remote_app_name": { + "calls": { + "[[3], {}]": "\"prom\"" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + } + } + } + }, + { + "event": { + "env": { + "JUJU_UNIT_NAME": "trfk/0", + "KUBERNETES_PORT": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT": "443", + "JUJU_VERSION": "3.1-beta1", + "JUJU_CHARM_HTTP_PROXY": "", + "APT_LISTCHANGES_FRONTEND": "none", + "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-broken-2334772132445027897", + "JUJU_AGENT_SOCKET_NETWORK": "unix", + "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", + "JUJU_CHARM_HTTPS_PROXY": "", + "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", + "JUJU_MODEL_NAME": "foo", + "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-broken", + "JUJU_AVAILABILITY_ZONE": "", + "JUJU_REMOTE_UNIT": "", + "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "TERM": "tmux-256color", + "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", + "JUJU_RELATION": "ingress-per-unit", + "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", + "JUJU_RELATION_ID": "ingress-per-unit:3", + "KUBERNETES_PORT_443_TCP_PORT": "443", + "JUJU_METER_STATUS": "AMBER", + "KUBERNETES_PORT_443_TCP_PROTO": "tcp", + "JUJU_HOOK_NAME": "ingress-per-unit-relation-broken", + "LANG": "C.UTF-8", + "CLOUD_API_VERSION": "1.25.0", + "DEBIAN_FRONTEND": "noninteractive", + "JUJU_SLA": "unsupported", + "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", + "KUBERNETES_SERVICE_PORT_HTTPS": "443", + "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", + "KUBERNETES_SERVICE_HOST": "10.152.183.1", + "JUJU_MACHINE_ID": "", + "JUJU_CHARM_FTP_PROXY": "", + "JUJU_METER_INFO": "not set", + "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_PRINCIPAL_UNIT": "", + "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", + "PYTHONPATH": "lib:venv", + "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", + "JUJU_REMOTE_APP": "prom" + }, + "timestamp": "2022-10-21T17:41:30.508188" + }, + "context": { + "memos": { + "_ModelBackend.juju_log": { + "calls": { + "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", + "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-broken does not exist.\"], {}]": "null", + "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", + "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", + "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_broken.\"], {}]": "null", + "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:3' relation\"], {}]": "null", + "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml ingress configuration file\"], {}]": "null" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_ids": { + "calls": { + "[[\"ingress-per-unit\"], {}]": "[3]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_list": { + "calls": { + "[[3], {}]": "[]" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_remote_app_name": { + "calls": { + "[[3], {}]": "\"prom\"" + }, + "cursor": 0, + "caching_policy": "loose", + "serializer": [ + "json", + "json" + ] + }, + "Client._request": { + "calls": [ + [ + "[[\"GET\", \"/v1/system-info\"], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" + ], + [ + "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\", \"recursive\": true}]}], {}]", + "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"}]}" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.is_leader": { + "calls": [ + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ], + [ + "[[], {}]", + "true" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + }, + "_ModelBackend.relation_set": { + "calls": [ + [ + "[[3, \"ingress\", \"\", true], {}]", + "null" + ] + ], + "cursor": 0, + "caching_policy": "strict", + "serializer": [ + "json", + "json" + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/tests/setup_tests.py b/tests/setup_tests.py new file mode 100644 index 000000000..6ab2bb33b --- /dev/null +++ b/tests/setup_tests.py @@ -0,0 +1,11 @@ +import sys +from pathlib import Path + + +def setup_tests(): + runtime_path = Path(__file__).parent.parent / "scenario" / "runtime" + sys.path.append(str(runtime_path)) # allow 'import memo' + + from scenario import Runtime + + Runtime.install(force=False) # ensure Runtime is installed diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py new file mode 100644 index 000000000..c7e7be5bc --- /dev/null +++ b/tests/test_e2e/test_state.py @@ -0,0 +1,71 @@ +from tests.setup_tests import setup_tests + +setup_tests() # noqa & keep this on top + +from typing import Optional + +import pytest +from ops.charm import CharmBase, StartEvent +from ops.framework import Framework +from ops.model import ActiveStatus, UnknownStatus + +from scenario.scenario import Scenario +from scenario.structs import CharmSpec, Context, Scene, State, get_event + + +class MyCharm(CharmBase): + _call = None + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + self.called = False + + if self._call: + self.called = True + self._call() + + +@pytest.fixture +def dummy_state(): + return State(config={"foo": "bar"}, leader=True) + + +@pytest.fixture +def start_scene(dummy_state): + return Scene(get_event("start"), context=Context(state=dummy_state)) + + +def test_bare_event(start_scene): + MyCharm._call = lambda charm: True + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(start_scene) + + assert isinstance(out.charm, MyCharm) + assert out.charm.called + assert isinstance(out.event, StartEvent) + assert out.charm.unit.name == "foo/0" + assert out.charm.model.uuid == start_scene.context.state.model.uuid + + +def test_leader_get(start_scene): + def call(charm): + assert charm.unit.is_leader() + + MyCharm._call = call + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + scenario.run(start_scene) + + +def test_status_setting(start_scene): + def call(charm): + assert isinstance(charm.unit.status, UnknownStatus) + charm.unit.status = ActiveStatus("foo test") + + MyCharm._call = call + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(start_scene) + assert out.context_out.state.status.unit == ("active", "foo test") + assert out.context_out.state.status.app == ("unknown", "") + assert out.delta().patch == [ + {"op": "replace", "path": "/state/status/unit", "value": ("active", "foo test")} + ] diff --git a/tests/test_memo_tools.py b/tests/test_memo_tools.py new file mode 100644 index 000000000..42a0b07bb --- /dev/null +++ b/tests/test_memo_tools.py @@ -0,0 +1,430 @@ +import json +import os +import random +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from scenario.runtime import ( + DEFAULT_NAMESPACE, + MEMO_DATABASE_NAME_KEY, + MEMO_MODE_KEY, + Context, + Event, + Memo, + Scene, + _reset_replay_cursors, + event_db, + memo, +) +from scenario.runtime.memo_tools import DecorateSpec, inject_memoizer, memo_import_block + +# we always replay the last event in the default test env. +os.environ["MEMO_REPLAY_IDX"] = "-1" + +mock_ops_source = """ +import random + +class _ModelBackend: + def _private_method(self): + pass + def other_method(self): + pass + def action_set(self, *args, **kwargs): + return str(random.random()) + def action_get(self, *args, **kwargs): + return str(random.random()) + + +class Foo: + def bar(self, *args, **kwargs): + return str(random.random()) + def baz(self, *args, **kwargs): + return str(random.random()) +""" + +expected_decorated_source = f"""{memo_import_block} +import random + +class _ModelBackend(): + + def _private_method(self): + pass + + def other_method(self): + pass + + @memo(name=None, namespace='_ModelBackend', caching_policy='strict', serializer='json') + def action_set(self, *args, **kwargs): + return str(random.random()) + + @memo(name=None, namespace='_ModelBackend', caching_policy='loose', serializer='pickle') + def action_get(self, *args, **kwargs): + return str(random.random()) + +class Foo(): + + @memo(name=None, namespace='Bar', caching_policy='loose', serializer=('json', 'io')) + def bar(self, *args, **kwargs): + return str(random.random()) + + def baz(self, *args, **kwargs): + return str(random.random()) +""" + + +def test_memoizer_injection(): + with tempfile.NamedTemporaryFile() as file: + target_file = Path(file.name) + target_file.write_text(mock_ops_source) + + inject_memoizer( + target_file, + decorate={ + "_ModelBackend": { + "action_set": DecorateSpec(), + "action_get": DecorateSpec( + caching_policy="loose", serializer="pickle" + ), + }, + "Foo": { + "bar": DecorateSpec( + namespace="Bar", + caching_policy="loose", + serializer=("json", "io"), + ) + }, + }, + ) + + assert target_file.read_text() == expected_decorated_source + + +def test_memoizer_recording(): + with tempfile.NamedTemporaryFile() as temp_db_file: + Path(temp_db_file.name).write_text("{}") + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + @memo() + def my_fn(*args, retval=None, **kwargs): + return retval + + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + my_fn(10, retval=10, foo="bar") + + with event_db(temp_db_file.name) as data: + ctx = data.scenes[0].context + assert ctx.memos + assert ctx.memos[f"{DEFAULT_NAMESPACE}.my_fn"].calls == [ + [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "10"] + ] + + +def test_memo_args(): + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + @memo(namespace="foo", name="bar", caching_policy="loose") + def my_fn(*args, retval=None, **kwargs): + return retval + + my_fn(10, retval=10, foo="bar") + + with event_db(temp_db_file.name) as data: + assert data.scenes[0].context.memos["foo.bar"].caching_policy == "loose" + + +def test_memoizer_replay(): + os.environ[MEMO_MODE_KEY] = "replay" + + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + @memo(log_on_replay=True) + def my_fn(*args, retval=None, **kwargs): + return retval + + with event_db(temp_db_file.name) as data: + data.scenes.append( + Scene( + event=Event(env={}, timestamp="10:10"), + context=Context( + memos={ + f"{DEFAULT_NAMESPACE}.my_fn": Memo( + calls=[ + [ + json.dumps( + [[10], {"retval": 10, "foo": "bar"}] + ), + "20", + ], + [ + json.dumps( + [[10], {"retval": 11, "foo": "baz"}] + ), + "21", + ], + [ + json.dumps( + [ + [11], + {"retval": 10, "foo": "baq", "a": "b"}, + ] + ), + "22", + ], + ] + ) + } + ), + ) + ) + + caught_calls = [] + + def _catch_log_call(_, *args, **kwargs): + caught_calls.append((args, kwargs)) + + with patch( + "jhack.utils.event_recorder.recorder._log_memo", new=_catch_log_call + ): + assert my_fn(10, retval=10, foo="bar") == 20 + assert my_fn(10, retval=11, foo="baz") == 21 + assert my_fn(11, retval=10, foo="baq", a="b") == 22 + # memos are all up! we run the actual function. + assert my_fn(11, retval=10, foo="baq", a="b") == 10 + + assert caught_calls == [ + (((10,), {"foo": "bar", "retval": 10}, "20"), {"cache_hit": True}), + (((10,), {"foo": "baz", "retval": 11}, "21"), {"cache_hit": True}), + ( + ((11,), {"a": "b", "foo": "baq", "retval": 10}, "22"), + {"cache_hit": True}, + ), + ( + ((11,), {"a": "b", "foo": "baq", "retval": 10}, ""), + {"cache_hit": False}, + ), + ] + + with event_db(temp_db_file.name) as data: + ctx = data.scenes[0].context + assert ctx.memos[f"{DEFAULT_NAMESPACE}.my_fn"].cursor == 3 + + +def test_memoizer_loose_caching(): + with tempfile.NamedTemporaryFile() as temp_db_file: + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + _backing = {x: x + 1 for x in range(50)} + + @memo(caching_policy="loose", log_on_replay=True) + def my_fn(m): + return _backing[m] + + os.environ[MEMO_MODE_KEY] = "record" + for i in range(50): + assert my_fn(i) == i + 1 + + # clear the backing storage, so that a cache miss would raise a + # KeyError. my_fn is, as of now, totally useless + _backing.clear() + + os.environ[MEMO_MODE_KEY] = "replay" + + # check that the function still works, with unordered arguments and repeated ones. + values = list(range(50)) * 2 + random.shuffle(values) + for i in values: + assert my_fn(i) == i + 1 + + +def test_memoizer_classmethod_recording(): + os.environ[MEMO_MODE_KEY] = "record" + + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + class Foo: + @memo("foo") + def my_fn(self, *args, retval=None, **kwargs): + return retval + + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + f = Foo() + f.my_fn(10, retval=10, foo="bar") + + with event_db(temp_db_file.name) as data: + memos = data.scenes[0].context.memos + assert memos["foo.my_fn"].calls == [ + [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "10"] + ] + + # replace return_value for replay test + memos["foo.my_fn"].calls = [ + [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "20"] + ] + + os.environ[MEMO_MODE_KEY] = "replay" + assert f.my_fn(10, retval=10, foo="bar") == 20 + + # memos are up + assert f.my_fn(10, retval=10, foo="bar") == 10 + assert f.my_fn(10, retval=10, foo="bar") == 10 + + +def test_reset_replay_cursor(): + os.environ[MEMO_MODE_KEY] = "replay" + + with tempfile.NamedTemporaryFile() as temp_db_file: + Path(temp_db_file.name).write_text("{}") + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + @memo() + def my_fn(*args, retval=None, **kwargs): + return retval + + with event_db(temp_db_file.name) as data: + calls = [ + [[[10], {"retval": 10, "foo": "bar"}], 20], + [[[10], {"retval": 11, "foo": "baz"}], 21], + [[[11], {"retval": 10, "foo": "baq", "a": "b"}], 22], + ] + + data.scenes.append( + Scene( + event=Event(env={}, timestamp="10:10"), + context=Context(memos={"my_fn": Memo(calls=calls, cursor=2)}), + ) + ) + + with event_db(temp_db_file.name) as data: + _memo = data.scenes[0].context.memos["my_fn"] + assert _memo.cursor == 2 + assert _memo.calls == calls + + _reset_replay_cursors(temp_db_file.name) + + with event_db(temp_db_file.name) as data: + _memo = data.scenes[0].context.memos["my_fn"] + assert _memo.cursor == 0 + assert _memo.calls == calls + + +class Foo: + pass + + +@pytest.mark.parametrize( + "obj, serializer", + ( + (b"1234", "pickle"), + (object(), "pickle"), + (Foo(), "pickle"), + ), +) +def test_memo_exotic_types(obj, serializer): + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + os.environ[MEMO_MODE_KEY] = "record" + + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + @memo(serializer=serializer) + def my_fn(_obj): + return _obj + + assert obj is my_fn(obj) + + os.environ[MEMO_MODE_KEY] = "replay" + + replay_output = my_fn(obj) + assert obj is not replay_output + + assert type(obj) is type(replay_output) + + +def test_memo_pebble_push(): + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + os.environ[MEMO_MODE_KEY] = "record" + + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + stored = None + + class Foo: + @memo(serializer=("PebblePush", "json")) + def push( + self, + path, + source, + *, + encoding: str = "utf-8", + make_dirs: bool = False, + permissions=42, + user_id=42, + user=42, + group_id=42, + group=42, + ): + + nonlocal stored + stored = source.read() + return stored + + tf = tempfile.NamedTemporaryFile(delete=False) + Path(tf.name).write_text("helloworld") + + obj = open(tf.name) + assert Foo().push(42, obj, user="lolz") == stored == "helloworld" + obj.close() + stored = None + + os.environ[MEMO_MODE_KEY] = "replay" + + obj = open(tf.name) + assert Foo().push(42, obj, user="lolz") == "helloworld" + assert stored == None + obj.close() + + tf.close() + del tf + + +def test_memo_pebble_pull(): + with tempfile.NamedTemporaryFile() as temp_db_file: + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + os.environ[MEMO_MODE_KEY] = "record" + + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + class Foo: + @memo(serializer=("json", "io")) + def pull(self, foo: str): + tf = tempfile.NamedTemporaryFile() + Path(tf.name).write_text("helloworld") + return open(tf.name) + + def getfile(self, foo: str): + return self.pull(foo).read() + + assert Foo().getfile(foo="helloworld") == "helloworld" + + os.environ[MEMO_MODE_KEY] = "replay" + + assert Foo().getfile(foo="helloworld") == "helloworld" diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py new file mode 100644 index 000000000..a357e1541 --- /dev/null +++ b/tests/test_replay_local_runtime.py @@ -0,0 +1,153 @@ +import sys +import tempfile +from pathlib import Path +from subprocess import Popen + +import pytest + +# keep this block before `ops` imports. This ensures that if you've called Runtime.install() on +# your current venv, ops.model won't break as it tries to import recorder.py + +try: + from scenario.runtime import Runtime +except ModuleNotFoundError: + import os + + from jhack.utils.event_recorder.runtime import RECORDER_MODULE + + sys.path.append(str(RECORDER_MODULE.absolute())) + +from ops.charm import CharmBase, CharmEvents + +from scenario.runtime import Runtime + +MEMO_TOOLS_RESOURCES_FOLDER = Path(__file__).parent / "memo_tools_test_files" + + +@pytest.fixture(scope="module", autouse=True) +def runtime_ctx(): + # helper to install the runtime and try and + # prevent ops from being destroyed every time + import ops + + ops_dir = Path(ops.__file__).parent + with tempfile.TemporaryDirectory() as td: + # stash away the ops source + Popen(f"cp -r {ops_dir} {td}".split()) + + Runtime.install() + yield + + Popen(f"mv {Path(td) / 'ops'} {ops_dir}".split()) + + +def charm_type(): + class _CharmEvents(CharmEvents): + pass + + class MyCharm(CharmBase): + on = _CharmEvents() + + def __init__(self, framework, key=None): + super().__init__(framework, key) + for evt in self.on.events().values(): + self.framework.observe(evt, self._catchall) + self._event = None + + def _catchall(self, e): + self._event = e + + return MyCharm + + +@pytest.mark.parametrize( + "evt_idx, expected_name", + ( + (0, "ingress_per_unit_relation_departed"), + (1, "ingress_per_unit_relation_departed"), + (2, "ingress_per_unit_relation_broken"), + (3, "ingress_per_unit_relation_created"), + (4, "ingress_per_unit_relation_joined"), + (5, "ingress_per_unit_relation_changed"), + ), +) +def test_run(evt_idx, expected_name): + charm = charm_type() + runtime = Runtime( + charm, + meta={ + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + }, + local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", + install=True, + ) + + charm, scene = runtime.run(evt_idx) + assert charm.unit.name == "trfk/0" + assert charm.model.name == "foo" + assert ( + charm._event.handle.kind == scene.event.name.replace("-", "_") == expected_name + ) + + +def test_relation_data(): + charm = charm_type() + runtime = Runtime( + charm, + meta={ + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + }, + local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", + ) + charm, scene = runtime.run(5) # ipu-relation-changed + + assert scene.event.name == "ingress-per-unit-relation-changed" + + rel = charm.model.relations["ingress-per-unit"][0] + + for _ in range(2): + # the order in which we call the hook tools should not matter because + # relation-get is cached in 'loose' mode! yay! + _ = rel.data[charm.app] + + remote_unit_data = rel.data[list(rel.units)[0]] + assert remote_unit_data["host"] == "prom-1.prom-endpoints.foo.svc.cluster.local" + assert remote_unit_data["port"] == "9090" + assert remote_unit_data["model"] == "foo" + assert remote_unit_data["name"] == "prom/1" + + local_app_data = rel.data[charm.app] + assert local_app_data == {} + + +def test_local_run_loose(): + runtime = Runtime( + charm_type(), + meta={ + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + }, + local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", + ) + charm, scene = runtime.run(5) # ipu-relation-changed + + assert scene.event.name == "ingress-per-unit-relation-changed" + + rel = charm.model.relations["ingress-per-unit"][0] + + # fixme: we need to access the data in the same ORDER in which we did before. + # for relation-get, it should be safe to ignore the memo ordering, + # since the data is frozen for the hook duration. + # actually it should be fine for most hook tools, except leader and status. + # pebble is a different story. + + remote_unit_data = rel.data[list(rel.units)[0]] + assert remote_unit_data["host"] == "prom-1.prom-endpoints.foo.svc.cluster.local" + assert remote_unit_data["port"] == "9090" + assert remote_unit_data["model"] == "foo" + assert remote_unit_data["name"] == "prom/1" + + local_app_data = rel.data[charm.app] + assert local_app_data == {} diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..5c5d3e1d8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,34 @@ +# Copyright 2022 Canonical +# See LICENSE file for licensing details. + +[tox] +skipsdist=True +skip_missing_interpreters = True +envlist = unit + + +[vars] +src_path = {toxinidir}/src +tst_path = {toxinidir}/tests + +[testenv:scenario] +description = Scenario tests +deps = + coverage[toml] + pytest + -r{toxinidir}/requirements.txt +commands = + coverage run \ + --source={[vars]src_path} \ + -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} + coverage report + + +[testenv:fmt] +description = Format code +deps = + black + isort +commands = + black tests scenario runtime + isort --profile black tests scenario runtime From d792d6970231e33eec4326614077784148e858fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:11:44 -0300 Subject: [PATCH 002/546] remove redundant if...else --- scenario/scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/scenario.py b/scenario/scenario.py index 7fc7da594..6f380884e 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -224,7 +224,7 @@ def play( ) -> PlayResult: if isinstance(obj, str): - _event = Event(obj) if isinstance(obj, str) else obj + _event = Event(obj) if _event.is_meta: return self._play_meta(_event, context, add_to_playbook=add_to_playbook) scene = Scene(_event, context) From 879e3a9ef780d618af3ffb8c084727fa3684277d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:12:39 -0300 Subject: [PATCH 003/546] remove unnecessary else --- scenario/scenario.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scenario/scenario.py b/scenario/scenario.py index 6f380884e..d593d8f2f 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -222,14 +222,13 @@ def play( context: Context = None, add_to_playbook: bool = False, ) -> PlayResult: + scene = obj if isinstance(obj, str): _event = Event(obj) if _event.is_meta: return self._play_meta(_event, context, add_to_playbook=add_to_playbook) scene = Scene(_event, context) - else: - scene = obj runtime = self._runtime result = runtime.run(scene) From 98f2af402bf3554f944139a09fd21d832ec7041d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:14:32 -0300 Subject: [PATCH 004/546] simplify if...elif...else --- scenario/scenario.py | 51 ++++++++++++++++---------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/scenario/scenario.py b/scenario/scenario.py index d593d8f2f..329960a9e 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -167,43 +167,28 @@ def _play_meta( # decompose the meta event events = [] - if event.name == ATTACH_ALL_STORAGES: + if event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: logger.warning(f"meta-event {event.name} not supported yet") return - elif event.name == DETACH_ALL_STORAGES: - logger.warning(f"meta-event {event.name} not supported yet") - return - - elif event.name == CREATE_ALL_RELATIONS: - if context: - for relation in context.relations: - # RELATION_OBJ is to indicate to the harness_ctx that - # it should retrieve the - evt = Event( - f"{relation.meta.endpoint}-relation-created", - args=( - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), + if event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS] and context: + for relation in context.relations: + # RELATION_OBJ is to indicate to the harness_ctx that + # it should retrieve the + if CREATE_ALL_RELATIONS: + name = f"{relation.meta.endpoint}-relation-created" + elif BREAK_ALL_RELATIONS: + name = f"{relation.meta.endpoint}-relation-broken" + + evt = Event( + name, + args=( + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id ), - ) - events.append(evt) - - elif event.name == BREAK_ALL_RELATIONS: - if context: - for relation in context.relations: - evt = Event( - f"{relation.meta.endpoint}-relation-broken", - args=( - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), - ), - ) - events.append(evt) - # todo should we ensure there's no relation data in this context? - + ), + ) + events.append(evt) else: raise RuntimeError(f"unknown meta-event {event.name}") From 3aaedb181dfecebeaf1b0096dd0c149d4037ad60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:16:03 -0300 Subject: [PATCH 005/546] remove space between @ and dataclass --- scenario/structs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/structs.py b/scenario/structs.py index 726852f9c..1c555c590 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -111,7 +111,7 @@ def with_unit_status(self, status: str, message: str): self.status, unit=(status, message))) -@ dataclass +@dataclass class CharmSpec: """Charm spec.""" From 870cdeff229f7331cc8b40d6fa44a675e50992cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:22:32 -0300 Subject: [PATCH 006/546] fix fmt tox env --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5c5d3e1d8..f9c5cbc94 100644 --- a/tox.ini +++ b/tox.ini @@ -30,5 +30,5 @@ deps = black isort commands = - black tests scenario runtime - isort --profile black tests scenario runtime + black tests scenario scenario/runtime + isort --profile black tests scenario scenario/runtime From 2aae5f5bc8ae4d8933fd02b03ebe762f0143f17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Mon, 28 Nov 2022 16:22:53 -0300 Subject: [PATCH 007/546] linting --- scenario/runtime/runtime.py | 2 +- scenario/scenario.py | 11 ++++++++--- scenario/structs.py | 7 ++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index a796b100b..d3779c498 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -10,6 +10,7 @@ from ops.framework import EventBase from logger import logger as pkg_logger +from scenario.event_db import TemporaryEventDB from scenario.runtime.memo import ( MEMO_DATABASE_NAME_KEY, MEMO_MODE_KEY, @@ -20,7 +21,6 @@ from scenario.runtime.memo import Scene as MemoScene from scenario.runtime.memo import event_db from scenario.runtime.memo_tools import DECORATE_MODEL, DECORATE_PEBBLE, inject_memoizer -from scenario.event_db import TemporaryEventDB if TYPE_CHECKING: from ops.testing import CharmType diff --git a/scenario/scenario.py b/scenario/scenario.py index 329960a9e..a3afe2b24 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -1,14 +1,19 @@ import json from dataclasses import asdict -from typing import Callable, Iterable, TextIO, List, Optional, Union, Dict, Any +from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union from ops.charm import CharmBase from ops.framework import BoundEvent, EventBase from logger import logger as pkg_logger from scenario import Runtime -from scenario.consts import (ATTACH_ALL_STORAGES, BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, CREATE_ALL_RELATIONS,) -from scenario.structs import Event, Scene, Context, InjectRelation, CharmSpec +from scenario.consts import ( + ATTACH_ALL_STORAGES, + BREAK_ALL_RELATIONS, + CREATE_ALL_RELATIONS, + DETACH_ALL_STORAGES, +) +from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene CharmMeta = Optional[Union[str, TextIO, dict]] AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] diff --git a/scenario/structs.py b/scenario/structs.py index 1c555c590..afad15377 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -5,8 +5,8 @@ from ops.charm import CharmBase from ops.testing import CharmType -from scenario.runtime import memo from scenario.consts import META_EVENTS +from scenario.runtime import memo @dataclass @@ -107,8 +107,9 @@ def with_leadership(self, leader: bool): return self.replace(leader=leader) def with_unit_status(self, status: str, message: str): - return self.replace(status=dataclasses.replace( - self.status, unit=(status, message))) + return self.replace( + status=dataclasses.replace(self.status, unit=(status, message)) + ) @dataclass From 81348196949138d43b40f98708808b52d8b758a8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 29 Nov 2022 10:30:40 +0100 Subject: [PATCH 008/546] fixed some bugs and testing setup --- README.md | 1 + scenario/consts.py | 8 ++++---- scenario/runtime/__init__.py | 0 scenario/runtime/runtime.py | 11 +++++------ scenario/scenario.py | 26 ++++++++++++-------------- tests/test_e2e/test_state.py | 16 +++++++++++++--- tests/test_memo_tools.py | 2 +- tests/test_replay_local_runtime.py | 11 ++++------- tox.ini | 13 +++++++------ 9 files changed, 47 insertions(+), 41 deletions(-) delete mode 100644 scenario/runtime/__init__.py diff --git a/README.md b/README.md index 36ff2d7d7..8b29afe2b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ def test_scenario_base(scenario, start_scene): def test_status_leader(scenario, start_scene, leader): leader_scene = start_scene.copy() leader_scene.context.state.leader = leader + out = scenario.run(leader_scene) if leader: assert out.context_out.state.status.unit == ('active', 'I rule') diff --git a/scenario/consts.py b/scenario/consts.py index d0a680c05..ad3fec630 100644 --- a/scenario/consts.py +++ b/scenario/consts.py @@ -3,8 +3,8 @@ BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" META_EVENTS = { - "ATTACH_ALL_STORAGES", - "CREATE_ALL_RELATIONS", - "BREAK_ALL_RELATIONS", - "DETACH_ALL_STORAGES", + "CREATE_ALL_RELATIONS": "-relation-created", + "BREAK_ALL_RELATIONS": "-relation-broken", + "DETACH_ALL_STORAGES": "-storage-detaching", + "ATTACH_ALL_STORAGES": "-storage-attached", } diff --git a/scenario/runtime/__init__.py b/scenario/runtime/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index d3779c498..ebc6a3429 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -6,8 +6,6 @@ from typing import TYPE_CHECKING, Type import yaml -from ops.charm import CharmBase -from ops.framework import EventBase from logger import logger as pkg_logger from scenario.event_db import TemporaryEventDB @@ -24,7 +22,8 @@ if TYPE_CHECKING: from ops.testing import CharmType - + from ops.charm import CharmBase + from ops.framework import EventBase from scenario.structs import CharmSpec, Scene logger = pkg_logger.getChild("runtime") @@ -35,9 +34,9 @@ @dataclasses.dataclass class RuntimeRunResult: - charm: CharmBase + charm: "CharmBase" scene: "Scene" - event: EventBase + event: "EventBase" class Runtime: @@ -76,7 +75,7 @@ def from_local_file( f"Try `pip install -r {local_charm_src / 'requirements.txt'}`" ) from e - my_charm_type: Type[CharmBase] = ldict["my_charm_type"] + my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] return Runtime(my_charm_type) @staticmethod diff --git a/scenario/scenario.py b/scenario/scenario.py index a3afe2b24..dddcd0d93 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -12,6 +12,7 @@ BREAK_ALL_RELATIONS, CREATE_ALL_RELATIONS, DETACH_ALL_STORAGES, + META_EVENTS, ) from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene @@ -166,9 +167,7 @@ def playbook(self) -> Playbook: def reset(self): self._playbook.restart() - def _play_meta( - self, event: Event, context: Context = None, add_to_playbook: bool = False - ): + def _play_meta(self, event: Event, context: Context, add_to_playbook: bool = False): # decompose the meta event events = [] @@ -176,18 +175,13 @@ def _play_meta( logger.warning(f"meta-event {event.name} not supported yet") return - if event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS] and context: - for relation in context.relations: - # RELATION_OBJ is to indicate to the harness_ctx that - # it should retrieve the - if CREATE_ALL_RELATIONS: - name = f"{relation.meta.endpoint}-relation-created" - elif BREAK_ALL_RELATIONS: - name = f"{relation.meta.endpoint}-relation-broken" - + if event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: + for relation in context.state.relations: evt = Event( - name, + relation.meta.endpoint + META_EVENTS[event.name], args=( + # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. + # this will be replaced by a Relation instance before the event is fired. InjectRelation( relation.meta.endpoint, relation.meta.relation_id ), @@ -199,8 +193,11 @@ def _play_meta( logger.debug(f"decomposed meta {event.name} into {events}") last = None + for event in events: - last = self.play(event, context, add_to_playbook=add_to_playbook) + scene = Scene(event, context) + last = self.play(scene, add_to_playbook=add_to_playbook) + return last def run(self, scene: Scene, add_to_playbook: bool = False): @@ -213,6 +210,7 @@ def play( add_to_playbook: bool = False, ) -> PlayResult: scene = obj + context = context or Context() if isinstance(obj, str): _event = Event(obj) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index c7e7be5bc..f3159558a 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -27,12 +27,20 @@ def __init__(self, framework: Framework, key: Optional[str] = None): @pytest.fixture def dummy_state(): - return State(config={"foo": "bar"}, leader=True) + return State( + config={"foo": "bar"}, + leader=True + ) @pytest.fixture def start_scene(dummy_state): - return Scene(get_event("start"), context=Context(state=dummy_state)) + return Scene( + get_event("start"), + context=Context( + state=dummy_state + ) + ) def test_bare_event(start_scene): @@ -67,5 +75,7 @@ def call(charm): assert out.context_out.state.status.unit == ("active", "foo test") assert out.context_out.state.status.app == ("unknown", "") assert out.delta().patch == [ - {"op": "replace", "path": "/state/status/unit", "value": ("active", "foo test")} + {"op": "replace", + "path": "/state/status/unit", + "value": ("active", "foo test")} ] diff --git a/tests/test_memo_tools.py b/tests/test_memo_tools.py index 42a0b07bb..9f351f4dd 100644 --- a/tests/test_memo_tools.py +++ b/tests/test_memo_tools.py @@ -7,7 +7,7 @@ import pytest -from scenario.runtime import ( +from scenario.runtime.memo import ( DEFAULT_NAMESPACE, MEMO_DATABASE_NAME_KEY, MEMO_MODE_KEY, diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py index a357e1541..b3c230888 100644 --- a/tests/test_replay_local_runtime.py +++ b/tests/test_replay_local_runtime.py @@ -9,17 +9,14 @@ # your current venv, ops.model won't break as it tries to import recorder.py try: - from scenario.runtime import Runtime + from memo import memo except ModuleNotFoundError: - import os - - from jhack.utils.event_recorder.runtime import RECORDER_MODULE - - sys.path.append(str(RECORDER_MODULE.absolute())) + from scenario.runtime.runtime import RUNTIME_MODULE + sys.path.append(str(RUNTIME_MODULE.absolute())) from ops.charm import CharmBase, CharmEvents -from scenario.runtime import Runtime +from scenario.runtime.runtime import Runtime MEMO_TOOLS_RESOURCES_FOLDER = Path(__file__).parent / "memo_tools_test_files" diff --git a/tox.ini b/tox.ini index f9c5cbc94..761f02cf0 100644 --- a/tox.ini +++ b/tox.ini @@ -4,15 +4,16 @@ [tox] skipsdist=True skip_missing_interpreters = True -envlist = unit +envlist = e2e lint [vars] src_path = {toxinidir}/src tst_path = {toxinidir}/tests -[testenv:scenario] -description = Scenario tests + +[testenv:e2e] +description = End to end tests deps = coverage[toml] pytest @@ -20,7 +21,7 @@ deps = commands = coverage run \ --source={[vars]src_path} \ - -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} + -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/e2e coverage report @@ -30,5 +31,5 @@ deps = black isort commands = - black tests scenario scenario/runtime - isort --profile black tests scenario scenario/runtime + black tests scenario + isort --profile black tests scenario From f6b86c78f2afc68ddbb65ed60643a0a9395c47d1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 29 Nov 2022 14:21:00 +0100 Subject: [PATCH 009/546] added container connectivity and relation data --- README.md | 18 + scenario/runtime/memo.py | 163 +++-- scenario/runtime/runtime.py | 10 +- scenario/scenario-old.py | 1007 ---------------------------- scenario/scenario.py | 20 +- scenario/structs.py | 29 +- tests/test_e2e/test_state.py | 214 +++++- tests/test_replay_local_runtime.py | 6 +- 8 files changed, 372 insertions(+), 1095 deletions(-) delete mode 100644 scenario/scenario-old.py diff --git a/README.md b/README.md index 36ff2d7d7..e393b33e3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,24 @@ Testing a charm under a given scenario, then, means verifying that: - the charm does not raise uncaught exceptions while handling the scenario - the output state (or the diff with the input state) is as expected + +# Core concepts as a metaphor +I like metaphors, so here we go: +- There is a theatre stage (Scenario). +- You pick an actor (a Charm) to put on the stage. +- You pick a sketch that the actor will have to play out (a Scene). The sketch is specified as: + - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written on those books on the table? + - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed)) +- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something to a book (pebble.push). + +# Core concepts not as a metaphor +Each scene maps to a single event. +The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the context in which they occur. + +Crucially, this decoupling of charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... + +In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. + # Writing scenario tests Writing a scenario tests consists of two broad steps: diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index c4e81e948..7d6e99030 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -45,6 +45,10 @@ _NotFound = object() +class NotFoundError(RuntimeError): + pass + + def _check_caching_policy(policy: _CachingPolicy) -> _CachingPolicy: if policy in {"strict", "loose"}: return policy @@ -231,24 +235,24 @@ def propagate(): return fn(*args, **kwargs) def load_from_state( - context: Context, question: Tuple[str, Tuple[Any], Dict[str, Any]] + scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any]] ): if not os.getenv(USE_STATE_KEY): return propagate() logger.debug("Attempting to load from state.") - if not hasattr(context, "state"): + if not hasattr(scene.context, "state"): logger.warning( "Context has no state; probably there is a version mismatch." ) return propagate() - if not context.state: + if not scene.context.state: logger.debug("No state found for this call.") return propagate() try: - return get_from_state(context.state, question) + return get_from_state(scene, question) except StateError as e: logger.error(f"Error trying to get_from_state {memo_name}: {e}") return propagate() @@ -328,7 +332,7 @@ def load_from_state( f"No memo found for {memo_name}: " f"this path must be new." ) return load_from_state( - data.scenes[idx].context, + data.scenes[idx], (memo_name, memoizable_args, kwargs), ) @@ -480,6 +484,15 @@ class Event: def name(self): return self.env["JUJU_DISPATCH_PATH"].split("/")[1] + @property + def unit_name(self): + return self.env.get("JUJU_UNIT_NAME", "") + + @property + def app_name(self): + unit_name = self.unit_name + return unit_name.split("/")[0] if unit_name else "" + @property def datetime(self): return DT.datetime.fromisoformat(self.timestamp) @@ -583,13 +596,13 @@ def from_dict(cls, obj): class RelationMeta: endpoint: str interface: str - remote_app_name: str relation_id: int + remote_app_name: str + remote_unit_ids: Tuple[int, ...] = (0,) # local limit limit: int = 1 - remote_unit_ids: Tuple[int, ...] = (0,) # scale of the remote application; number of units, leader ID? # TODO figure out if this is relevant scale: int = 1 @@ -603,8 +616,9 @@ def from_dict(cls, obj): @dataclass class RelationSpec: meta: RelationMeta - application_data: dict = dataclasses.field(default_factory=dict) - units_data: Dict[int, dict] = dataclasses.field(default_factory=dict) + local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @classmethod def from_dict(cls, obj): @@ -616,14 +630,26 @@ def from_dict(cls, obj): class Status: app: Tuple[str, str] = ("unknown", "") unit: Tuple[str, str] = ("unknown", "") + app_version: str = "" + + @classmethod + def from_dict(cls, obj: dict): + if obj is None: + return cls() + + return cls( + app=tuple(obj.get("app", ("unknown", ""))), + unit=tuple(obj.get("unit", ("unknown", ""))), + app_version=obj.get("app_version", ""), + ) @dataclass class State: config: Dict[str, Union[str, int, float, bool]] = None - relations: Tuple[RelationSpec] = () - networks: Tuple[NetworkSpec] = () - containers: Tuple[ContainerSpec] = () + relations: List[RelationSpec] = field(default_factory=list) + networks: List[NetworkSpec] = field(default_factory=list) + containers: List[ContainerSpec] = field(default_factory=list) status: Status = field(default_factory=Status) leader: bool = False model: Model = Model() @@ -633,29 +659,31 @@ class State: # actions? # juju topology - @classmethod - def null(cls): - return cls() - @classmethod def from_dict(cls, obj): if obj is None: - return cls.null() + return cls() return cls( config=obj["config"], - relations=tuple( + relations=list( RelationSpec.from_dict(raw_ard) for raw_ard in obj["relations"] ), - networks=tuple(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), - containers=tuple( + networks=list(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), + containers=list( ContainerSpec.from_dict(raw_cs) for raw_cs in obj["containers"] ), leader=obj.get("leader", False), - status=Status(**{k: tuple(v) for k, v in obj.get("status", {}).items()}), + status=Status.from_dict(obj.get("status")), model=Model(**obj.get("model", {})), ) + def get_container(self, name) -> ContainerSpec: + try: + return next(filter(lambda c: c.name == name, self.containers)) + except StopIteration as e: + raise NotFoundError(f"container: {name}") from e + @dataclass class Context: @@ -743,7 +771,9 @@ class QuestionNotImplementedError(StateError): pass -def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any]]): +def get_from_state(scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any]]): + state = scene.context.state + this_unit_name = scene.event.unit_name memo_name, call_args, call_kwargs = question ns, _, meth = memo_name.rpartition(".") setter = False @@ -752,24 +782,51 @@ def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any] # MODEL BACKEND CALLS if ns == "_ModelBackend": if meth == "relation_get": - pass + rel_id, obj_name, app = call_args + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, state.relations) + ) + if app and obj_name == scene.event.app_name: + return relation.local_app_data + elif app: + return relation.remote_app_data + elif obj_name == this_unit_name: + return relation.local_unit_data.get(this_unit_name, {}) + else: + unit_id = obj_name.split("/")[-1] + return relation.local_unit_data[unit_id] + elif meth == "is_leader": return state.leader + elif meth == "status_get": status, message = ( state.status.app if call_kwargs.get("app") else state.status.unit ) return {"status": status, "message": message} - elif meth == "action_get": - pass + elif meth == "relation_ids": - pass + return [rel.meta.relation_id for rel in state.relations] + elif meth == "relation_list": + rel_id = call_args[0] + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, state.relations) + ) + return tuple( + f"{relation.meta.remote_app_name}/{unit_id}" + for unit_id in relation.meta.remote_unit_ids + ) + + elif meth == "config_get": + return state.config[call_args[0]] + + elif meth == "action_get": pass + elif meth == "relation_remote_app_name": pass - elif meth == "config_get": - return state.config[call_args[0]] + elif meth == "resource_get": pass elif meth == "storage_list": @@ -784,14 +841,11 @@ def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any] setter = True # # setter methods + if meth == "application_version_set": - pass - elif meth == "relation_set": - pass - elif meth == "action_set": - pass - elif meth == "action_fail": - pass + state.status.app_version = call_args[0] + return None + elif meth == "status_set": status = call_args if call_kwargs.get("is_app"): @@ -799,11 +853,31 @@ def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any] else: state.status.unit = status return None - elif meth == "action_log": - pass + elif meth == "juju_log": state.juju_log.append(call_args) return None + + elif meth == "relation_set": + rel_id, key, value, app = call_args + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, state.relations) + ) + if app: + if not state.leader: + raise RuntimeError("needs leadership to set app data") + tgt = relation.local_app_data + else: + tgt = relation.local_unit_data + tgt[key] = value + return None + + elif meth == "action_set": + pass + elif meth == "action_fail": + pass + elif meth == "action_log": + pass elif meth == "storage_add": pass @@ -816,7 +890,13 @@ def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any] # PEBBLE CALLS elif ns == "Client": if meth == "_request": - pass + if call_args == ("GET", "/v1/system-info"): + # fixme: can't differentiate between containers ATM, because Client._request + # does not pass around the container name as argument + if state.containers[0].can_connect: + return {"result": {"version": "unknown"}} + else: + raise FileNotFoundError("") elif meth == "pull": pass elif meth == "push": @@ -827,9 +907,8 @@ def get_from_state(state: State, question: Tuple[str, Tuple[Any], Dict[str, Any] raise QuestionNotImplementedError(ns) except Exception as e: action = "setting" if setter else "getting" - raise StateError( - f"Error {action} state for {ns}.{meth} given " - f"({call_args}, {call_kwargs})" - ) from e + msg = f"Error {action} state for {ns}.{meth} given ({call_args}, {call_kwargs})" + logger.error(msg) + raise StateError(msg) from e raise QuestionNotImplementedError((ns, meth)) diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index a796b100b..57dd20066 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -23,6 +23,8 @@ from scenario.event_db import TemporaryEventDB if TYPE_CHECKING: + from ops.charm import CharmBase + from ops.framework import EventBase from ops.testing import CharmType from scenario.structs import CharmSpec, Scene @@ -46,12 +48,9 @@ class Runtime: This object bridges a local environment and a charm artifact. """ - def __init__( - self, - charm_spec: "CharmSpec", - ): - + def __init__(self, charm_spec: "CharmSpec", juju_version: str = "3.0.0"): self._charm_spec = charm_spec + self._juju_version = juju_version self._charm_type = charm_spec.charm_type # todo consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @@ -175,6 +174,7 @@ def unit_name(self): def _get_event_env(self, scene: "Scene", charm_root: Path): return { + "JUJU_VERSION": self._juju_version, "JUJU_UNIT_NAME": self.unit_name, "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", diff --git a/scenario/scenario-old.py b/scenario/scenario-old.py deleted file mode 100644 index 4323d7f9b..000000000 --- a/scenario/scenario-old.py +++ /dev/null @@ -1,1007 +0,0 @@ -"""This is a library providing a utility for unit testing event sequences with the harness. -""" - -# The unique Charmhub library identifier, never change it -LIBID = "884af95dbb1d4e8db20e0c29e6231ffe" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 1 - -import dataclasses -import json -from dataclasses import dataclass -from functools import partial -from typing import Any, Dict, Iterable, Tuple, Union -from uuid import uuid4 - -import ops -import yaml -from ops.testing import CharmType - -if __name__ == "__main__": - pass # to prevent isort from complaining about what follows - -# from networking: -import logging -from collections import defaultdict -from contextlib import contextmanager -from copy import deepcopy -from typing import Callable, Dict, List, Optional, Sequence, TextIO, TypedDict, Union - -from ops.model import Relation, StatusBase - -network_logger = logging.getLogger("networking") -CharmMeta = Optional[Union[str, TextIO, dict]] -AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] - - -class NetworkingError(RuntimeError): - """Base class for errors raised from this module.""" - - -JUJU_INFO = { - "bind-addresses": [ - { - "mac-address": "", - "interface-name": "", - "interfacename": "", - "addresses": [{"hostname": "", "value": "1.1.1.1", "cidr": ""}], - } - ], - "bind-address": "1.1.1.1", - "egress-subnets": ["1.1.1.2/32"], - "ingress-addresses": ["1.1.1.2"], -} # type: _Network - -_Address = TypedDict("_Address", {"hostname": str, "value": str, "cidr": str}) -_BindAddress = TypedDict( - "_BindAddress", - { - "mac-address": str, - "interface-name": str, - "interfacename": str, # ? - "addresses": List[_Address], - }, -) -_Network = TypedDict( - "_Network", - { - "bind-addresses": List[_BindAddress], - "bind-address": str, - "egress-subnets": List[str], - "ingress-addresses": List[str], - }, -) - - -def activate(juju_info_network: "_Network" = JUJU_INFO): - """Patches harness.backend.network_get and initializes the juju-info binding.""" - global PATCH_ACTIVE, _NETWORKS - if PATCH_ACTIVE: - raise NetworkingError("patch already active") - assert not _NETWORKS # type guard - - from ops.testing import _TestingModelBackend - - _NETWORKS = defaultdict(dict) - _TestingModelBackend.network_get = _network_get # type: ignore - _NETWORKS["juju-info"][None] = juju_info_network - - PATCH_ACTIVE = True - - -def deactivate(): - """Undoes the patch.""" - global PATCH_ACTIVE, _NETWORKS - assert PATCH_ACTIVE, "patch not active" - - PATCH_ACTIVE = False - _NETWORKS = None # type: ignore - - -_NETWORKS = None # type: Optional[Dict[str, Dict[Optional[int], _Network]]] -PATCH_ACTIVE = False - - -def _network_get(_, endpoint_name, relation_id=None) -> _Network: - if not PATCH_ACTIVE: - raise NotImplementedError("network-get") - assert _NETWORKS # type guard - - try: - endpoints = _NETWORKS[endpoint_name] - network = endpoints.get(relation_id) - if not network: - # fall back to default binding for relation: - return endpoints[None] - return network - except KeyError as e: - raise NetworkingError( - f"No network for {endpoint_name} -r {relation_id}; " - f"try `add_network({endpoint_name}, {relation_id} | None, Network(...))`" - ) from e - - -def add_network( - endpoint_name: str, - relation_id: Optional[int], - network: _Network, - make_default=False, -): - """Add a network to the harness. - - - `endpoint_name`: the relation name this network belongs to - - `relation_id`: ID of the relation this network belongs to. If None, this will - be the default network for the relation. - - `network`: network data. - - `make_default`: Make this the default network for the endpoint. - Equivalent to calling this again with `relation_id==None`. - """ - if not PATCH_ACTIVE: - raise NetworkingError("module not initialized; " "run activate() first.") - assert _NETWORKS # type guard - - if _NETWORKS[endpoint_name].get(relation_id): - network_logger.warning( - f"Endpoint {endpoint_name} is already bound " - f"to a network for relation id {relation_id}." - f"Overwriting..." - ) - - _NETWORKS[endpoint_name][relation_id] = network - - if relation_id and make_default: - # make it default as well - _NETWORKS[endpoint_name][None] = network - - -def remove_network(endpoint_name: str, relation_id: Optional[int]): - """Remove a network from the harness.""" - if not PATCH_ACTIVE: - raise NetworkingError("module not initialized; " "run activate() first.") - assert _NETWORKS # type guard - - _NETWORKS[endpoint_name].pop(relation_id) - if not _NETWORKS[endpoint_name]: - del _NETWORKS[endpoint_name] - - -def Network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), -) -> _Network: - """Construct a network object.""" - return { - "bind-addresses": [ - { - "mac-address": mac_address, - "interface-name": interface_name, - "interfacename": interface_name, - "addresses": [ - {"hostname": hostname, "value": private_address, "cidr": cidr} - ], - } - ], - "bind-address": private_address, - "egress-subnets": list(egress_subnets), - "ingress-addresses": list(ingress_addresses), - } - - -_not_given = object() # None is meaningful, but JUJU_INFO is mutable - - -@contextmanager -def networking( - juju_info_network: Optional[_Network] = _not_given, # type: ignore - networks: Optional[Dict[Union[str, Relation], _Network]] = None, - make_default: bool = False, -): - """Context manager to activate/deactivate networking within a scope. - - Arguments: - - `juju_info_network`: network assigned to the implicit 'juju-info' endpoint. - - `networks`: mapping from endpoints (names, or relations) to networks. - - `make_default`: whether the networks passed as relations should also - be interpreted as default networks for the endpoint. - - Example usage: - >>> with networking(): - >>> assert charm.model.get_binding('juju-info').network.private_address - - >>> foo_relation = harness.model.get_relation('foo', 1) - >>> bar_relation = harness.model.get_relation('bar', 2) - >>> with networking(networks={ - ... foo_relation: Network(private_address='42.42.42.42')} - ... 'bar': Network(private_address='50.50.50.1')}, - ... make_default=True, - ... ): - >>> assert charm.model.get_binding(foo_relation).network.private_address - >>> assert charm.model.get_binding('foo').network.private_address - >>> assert charm.model.get_binding('bar').network.private_address - ... - >>> # this will raise an error! We only defined a default bar - >>> # network, not one specific to this relation ID. - >>> # assert charm.model.get_binding(bar_relation).network.private_address - - """ - global _NETWORKS - old = deepcopy(_NETWORKS) - patch_was_inactive = False - - if juju_info_network is _not_given: - juju_info_network = JUJU_INFO - - if not PATCH_ACTIVE: - patch_was_inactive = True - activate(juju_info_network or JUJU_INFO) - else: - assert _NETWORKS # type guard - - if juju_info_network: - _NETWORKS["juju-info"][None] = juju_info_network - - for binding, network in networks.items() if networks else (): - if isinstance(binding, str): - name = binding - bind_id = None - elif isinstance(binding, Relation): - name = binding.name - bind_id = binding.id - else: - raise TypeError(binding) - add_network(name, bind_id, network, make_default=make_default) - - yield - - _NETWORKS = old - if patch_was_inactive: - deactivate() - - -# from HARNESS_CTX v0 - -import typing -from typing import Callable, Protocol, Type - -from ops.charm import CharmBase, CharmEvents -from ops.framework import BoundEvent, EventBase, Handle -from ops.testing import Harness - - -class _HasOn(Protocol): - @property - def on(self) -> CharmEvents: - ... - - -def _DefaultEmitter(charm: CharmBase, harness: Harness): - return charm - - -class Emitter: - """Event emitter.""" - - def __init__(self, harness: Harness, emit: Callable[[], BoundEvent]): - self.harness = harness - self._emit = emit - self.event = None - self._emitted = False - - @property - def emitted(self): - """Has the event been emitted already?""" # noqa - return self._emitted - - def emit(self): - """Emit the event. - - Will get called automatically when HarnessCtx exits if you didn't call it already. - """ - assert not self._emitted, "already emitted; should not emit twice" - self._emitted = True - self.event = self._emit() - return self.event - - -class HarnessCtx: - """Harness-based context for emitting a single event. - - Example usage: - >>> class MyCharm(CharmBase): - >>> def __init__(self, framework: Framework, key: typing.Optional = None): - >>> super().__init__(framework, key) - >>> self.framework.observe(self.on.update_status, self._listen) - >>> self.framework.observe(self.framework.on.commit, self._listen) - >>> - >>> def _listen(self, e): - >>> self.event = e - >>> - >>> with HarnessCtx(MyCharm, "update-status") as h: - >>> event = h.emit() - >>> assert event.handle.kind == "update_status" - >>> - >>> assert h.harness.charm.event.handle.kind == "commit" - """ - - def __init__( - self, - charm: Type[CharmBase], - event_name: str, - emitter: Callable[[CharmBase, Harness], _HasOn] = _DefaultEmitter, - meta: Optional[CharmMeta] = None, - actions: Optional[CharmMeta] = None, - config: Optional[CharmMeta] = None, - event_args: Tuple[Any, ...] = (), - event_kwargs: Dict[str, Any] = None, - pre_begin_hook: Optional[Callable[[Harness], None]] = None, - ): - self.charm_cls = charm - self.emitter = emitter - self.event_name = event_name.replace("-", "_") - self.event_args = event_args - self.event_kwargs = event_kwargs or {} - self.pre_begin_hook = pre_begin_hook - - def _to_yaml(obj): - if isinstance(obj, str): - return obj - elif not obj: - return None - return yaml.safe_dump(obj) - - self.harness_kwargs = { - "meta": _to_yaml(meta), - "actions": _to_yaml(actions), - "config": _to_yaml(config), - } - - @staticmethod - def _inject(harness: Harness, obj): - if isinstance(obj, InjectRelation): - return harness.model.get_relation( - relation_name=obj.relation_name, relation_id=obj.relation_id - ) - - return obj - - def _process_event_args(self, harness): - return map(partial(self._inject, harness), self.event_args) - - def _process_event_kwargs(self, harness): - kwargs = self.event_kwargs - return kwargs - - def __enter__(self): - self._harness = harness = Harness(self.charm_cls, **self.harness_kwargs) - if self.pre_begin_hook: - logger.debug("running harness pre-begin hook") - self.pre_begin_hook(harness) - - harness.begin() - - emitter = self.emitter(harness.charm, harness) - events = getattr(emitter, "on") - event_source: BoundEvent = getattr(events, self.event_name) - - def _emit() -> BoundEvent: - # we don't call event_source.emit() - # because we want to grab the event - framework = event_source.emitter.framework - key = framework._next_event_key() # noqa - handle = Handle(event_source.emitter, event_source.event_kind, key) - - event_args = self._process_event_args(harness) - event_kwargs = self._process_event_kwargs(harness) - - event = event_source.event_type(handle, *event_args, **event_kwargs) - event.framework = framework - framework._emit(event) # type: ignore # noqa - return typing.cast(BoundEvent, event) - - self._emitter = bound_ctx = Emitter(harness, _emit) - return bound_ctx - - def __exit__(self, exc_type, exc_val, exc_tb): - if not self._emitter.emitted: - self._emitter.emit() - self._harness.framework.on.commit.emit() # type: ignore - - -# from show-relation! - - -@dataclass -class DCBase: - def replace(self, *args, **kwargs): - return dataclasses.replace(self, *args, **kwargs) - - -@dataclass -class RelationMeta(DCBase): - endpoint: str - interface: str - remote_app_name: str - relation_id: int - - # local limit - limit: int = 1 - - remote_unit_ids: Tuple[int, ...] = (0,) - # scale of the remote application; number of units, leader ID? - # TODO figure out if this is relevant - scale: int = 1 - leader_id: int = 0 - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class RelationSpec(DCBase): - meta: RelationMeta - application_data: dict = dataclasses.field(default_factory=dict) - units_data: Dict[int, dict] = dataclasses.field(default_factory=dict) - - @classmethod - def from_dict(cls, obj): - meta = RelationMeta.from_dict(obj.pop("meta")) - return cls(meta=meta, **obj) - - def copy(self): - return dataclasses.replace() - - -# ACTUAL LIBRARY CODE. Dependencies above. - -logger = logging.getLogger("evt-sequences") - -ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" -CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" -BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" -DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" -META_EVENTS = { - "ATTACH_ALL_STORAGES", - "CREATE_ALL_RELATIONS", - "BREAK_ALL_RELATIONS", - "DETACH_ALL_STORAGES", -} - - -@dataclass -class CharmSpec: - """Charm spec.""" - - charm_type: Type[CharmType] - meta: Optional[CharmMeta] = None - actions: Optional[CharmMeta] = None - config: Optional[CharmMeta] = None - - @staticmethod - def cast(obj: Union["CharmSpec", CharmType, Type[CharmBase]]): - if isinstance(obj, type) and issubclass(obj, CharmBase): - return CharmSpec(charm_type=obj) - elif isinstance(obj, CharmSpec): - return obj - else: - raise ValueError(f"cannot convert {obj} to CharmSpec") - - -class PlayResult: - # TODO: expose the 'final context' or a Delta object from the PlayResult. - def __init__(self, event: "BoundEvent", context: "Context", emitter: "Emitter"): - self.event = event - self.context = context - self.emitter = emitter - - # some useful attributes - self.harness = emitter.harness - self.charm = self.harness.charm - self.status = self.emitter.harness.charm.unit.status - - -@dataclass -class _Event(DCBase): - name: str - args: Tuple[Any] = () - kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - - @property - def is_meta(self): - return self.name in META_EVENTS - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - def as_scenario(self, context: "Context"): - """Utility to get to a single-event Scenario from a single event instance.""" - return Scenario.from_scenes(Scene(context=context, event=self)) - - def play( - self, - context: "Context", - charm_spec: CharmSpec, - assertions: Sequence[AssertionType] = (), - ) -> PlayResult: - """Utility to play this as a single scene.""" - return ( - self.as_scenario(context) - .bind( - charm_spec=charm_spec, - ) - .play_until_complete(assertions=assertions) - ) - - -def _derive_args(event_name: str): - args = [] - terms = { - "-relation-changed", - "-relation-broken", - "-relation-joined", - "-relation-departed", - "-relation-created", - } - - for term in terms: - # fixme: we can't disambiguate between relation IDs. - if event_name.endswith(term): - args.append(InjectRelation(relation_name=event_name[: -len(term)])) - - return tuple(args) - - -def Event(name: str, append_args: Tuple[Any] = (), **kwargs) -> _Event: - """This routine will attempt to generate event args for you, based on the event name.""" - return _Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs) - - -@dataclass -class NetworkSpec(DCBase): - name: str - bind_id: int - network: _Network - is_default: bool = False - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class ContainerSpec(DCBase): - name: str - can_connect: bool = False - # todo mock filesystem and pebble proc? - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class Model(DCBase): - name: str = "foo" - uuid: str = str(uuid4()) - - -@dataclass -class Context(DCBase): - config: Dict[str, Union[str, int, float, bool]] = None - relations: Tuple[RelationSpec] = () - networks: Tuple[NetworkSpec] = () - containers: Tuple[ContainerSpec] = () - leader: bool = False - model: Model = Model() - - # todo: add pebble stuff, unit/app status, etc... - # containers - # status - # actions? - # juju topology - - @classmethod - def from_dict(cls, obj): - return cls( - config=obj["config"], - relations=tuple( - RelationSpec.from_dict(raw_ard) for raw_ard in obj["relations"] - ), - networks=tuple(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), - leader=obj["leader"], - ) - - def as_scenario(self, event: _Event): - """Utility to get to a single-event Scenario from a single context instance.""" - return Scenario.from_scenes(Scene(context=self, event=event)) - - def play( - self, - event: _Event, - charm_spec: CharmSpec, - assertions: Sequence[AssertionType] = (), - ) -> PlayResult: - """Utility to play this as a single scene.""" - return ( - self.as_scenario(event) - .bind( - charm_spec=charm_spec, - ) - .play_until_complete(assertions=assertions) - ) - - # utilities to quickly mutate states "deep" inside the tree - def replace_container_connectivity(self, container_name: str, can_connect: bool): - def replacer(container: ContainerSpec): - if container.name == container_name: - return container.replace(can_connect=can_connect) - return container - - ctrs = tuple(map(replacer, self.containers)) - return self.replace(containers=ctrs) - - -null_context = Context() - - -@dataclass -class Scene(DCBase): - event: _Event - context: Context = None - name: str = "" - - @classmethod - def from_dict(cls, obj): - evt = obj["event"] - return cls( - event=_Event(evt) if isinstance(evt, str) else _Event.from_dict(evt), - context=Context.from_dict(obj["context"]) - if obj["context"] is not None - else None, - name=obj["name"], - ) - - -class _Builtins: - @staticmethod - def startup(leader=True): - return Scenario.from_events( - ( - ATTACH_ALL_STORAGES, - "start", - CREATE_ALL_RELATIONS, - "leader-elected" if leader else "leader-settings-changed", - "config-changed", - "install", - ) - ) - - @staticmethod - def teardown(): - return Scenario.from_events( - (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") - ) - - -class Playbook: - def __init__(self, scenes: Iterable[Scene]): - self._scenes = list(scenes) - self._cursor = 0 - - def __bool__(self): - return bool(self._scenes) - - @property - def is_done(self): - return self._cursor < (len(self._scenes) - 1) - - def add(self, scene: Scene): - self._scenes.append(scene) - - def next(self): - self.scroll(1) - return self._scenes[self._cursor] - - def scroll(self, n): - if not 0 <= self._cursor + n <= len(self._scenes): - raise RuntimeError(f"Cursor out of bounds: can't scroll ({self}) by {n}.") - self._cursor += n - - def restart(self): - self._cursor = 0 - - def __repr__(self): - return f"" - - def __iter__(self): - yield from self._scenes - - def __next__(self): - return self.next() - - def dump(self) -> str: - """Serialize.""" - obj = {"scenes": [dataclasses.asdict(scene) for scene in self._scenes]} - return json.dumps(obj, indent=2) - - @staticmethod - def load(s: str) -> "Playbook": - obj = json.loads(s) - scenes = tuple(Scene.from_dict(raw_scene) for raw_scene in obj["scenes"]) - return Playbook(scenes=scenes) - - -class _UnboundScenario: - def __init__( - self, - playbook: Playbook = Playbook(()), - ): - self._playbook = playbook - - @property - def playbook(self): - return self._playbook - - def __call__(self, charm_spec: Union[CharmSpec, CharmType]): - return Scenario(charm_spec=CharmSpec.cast(charm_spec), playbook=self.playbook) - - bind = __call__ # alias - - -@dataclass -class Inject: - """Base class for injectors: special placeholders used to tell harness_ctx - to inject instances that can't be retrieved in advance in event args or kwargs. - """ - - pass - - -@dataclass -class InjectRelation(Inject): - relation_name: str - relation_id: Optional[int] = None - - -class Scenario: - builtins = _Builtins() - - def __init__(self, charm_spec: CharmSpec, playbook: Playbook = Playbook(())): - - self._playbook = playbook - self._charm_spec = CharmSpec.cast(charm_spec) - - @staticmethod - def from_scenes(playbook: Union[Scene, Iterable[Scene]]) -> _UnboundScenario: - _scenes = (playbook,) if isinstance(playbook, Scene) else tuple(playbook) - for i, scene in enumerate(_scenes): - if not scene.name: - scene.name = f"" - return _UnboundScenario(playbook=Playbook(_scenes)) - - @staticmethod - def from_events(events: typing.Sequence[Union[str, _Event]]) -> _UnboundScenario: - def _to_event(obj): - if isinstance(obj, str): - return _Event(obj) - elif isinstance(obj, _Event): - return obj - else: - raise TypeError(obj) - - return Scenario.from_scenes(map(Scene, map(_to_event, events))) - - @property - def playbook(self) -> Playbook: - return self._playbook - - def __enter__(self): - self._entered = True - activate() - return self - - def __exit__(self, *exc_info): - self._playbook.restart() - deactivate() - self._entered = False - if exc_info: - return False - return True - - @staticmethod - def _pre_setup_context(harness: Harness, context: Context): - # Harness initialization that needs to be done pre-begin() - - # juju topology: - harness.set_model_info(name=context.model.name, uuid=context.model.uuid) - - @staticmethod - def _setup_context(harness: Harness, context: Context): - harness.disable_hooks() - be: ops.testing._TestingModelBackend = harness._backend # noqa - - # relation data - for container in context.containers: - harness.set_can_connect(container.name, container.can_connect) - - # relation data - for relation in context.relations: - remote_app_name = relation.meta.remote_app_name - r_id = harness.add_relation(relation.meta.endpoint, remote_app_name) - if remote_app_name != harness.charm.app.name: - if relation.application_data: - harness.update_relation_data( - r_id, remote_app_name, relation.application_data - ) - for unit_n, unit_data in relation.units_data.items(): - unit_name = f"{remote_app_name}/{unit_n}" - harness.add_relation_unit(r_id, unit_name) - harness.update_relation_data(r_id, unit_name, unit_data) - else: - if relation.application_data: - harness.update_relation_data( - r_id, harness.charm.app.name, relation.application_data - ) - if relation.units_data: - if not tuple(relation.units_data) == (0,): - raise RuntimeError("Only one local unit is supported.") - harness.update_relation_data( - r_id, harness.charm.unit.name, relation.units_data[0] - ) - # leadership: - harness.set_leader(context.leader) - - # networking - for network in context.networks: - add_network( - endpoint_name=network.name, - relation_id=network.bind_id, - network=network.network, - make_default=network.is_default, - ) - harness.enable_hooks() - - @staticmethod - def _cleanup_context(harness: Harness, context: Context): - # Harness will be reinitialized, so nothing to clean up there; - # however: - for network in context.networks: - remove_network(endpoint_name=network.name, relation_id=network.bind_id) - - def _play_meta( - self, event: _Event, context: Context = None, add_to_playbook: bool = False - ): - # decompose the meta event - events = [] - - if event.name == ATTACH_ALL_STORAGES: - logger.warning(f"meta-event {event.name} not supported yet") - return - - elif event.name == DETACH_ALL_STORAGES: - logger.warning(f"meta-event {event.name} not supported yet") - return - - elif event.name == CREATE_ALL_RELATIONS: - if context: - for relation in context.relations: - # RELATION_OBJ is to indicate to the harness_ctx that - # it should retrieve the - evt = _Event( - f"{relation.meta.endpoint}-relation-created", - args=( - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), - ), - ) - events.append(evt) - - elif event.name == BREAK_ALL_RELATIONS: - if context: - for relation in context.relations: - evt = _Event( - f"{relation.meta.endpoint}-relation-broken", - args=( - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), - ), - ) - events.append(evt) - # todo should we ensure there's no relation data in this context? - - else: - raise RuntimeError(f"unknown meta-event {event.name}") - - logger.debug(f"decomposed meta {event.name} into {events}") - last = None - for event in events: - last = self.play(event, context, add_to_playbook=add_to_playbook) - return last - - def play( - self, - event: Union[_Event, str], - context: Context = None, - add_to_playbook: bool = False, - ) -> PlayResult: - if not self._entered: - raise RuntimeError( - "Scenario.play() should be only called " - "within the Scenario's context." - ) - _event = _Event(event) if isinstance(event, str) else event - - if _event.is_meta: - return self._play_meta(_event, context, add_to_playbook=add_to_playbook) - - charm_spec = self._charm_spec - pre_begin_hook = None - - if context: - # some context needs to be set up before harness.begin() is called. - pre_begin_hook = partial(self._pre_setup_context, context=context) - - with HarnessCtx( - charm_spec.charm_type, - event_name=_event.name, - event_args=_event.args, - event_kwargs=_event.kwargs, - meta=charm_spec.meta, - actions=charm_spec.actions, - config=charm_spec.config, - pre_begin_hook=pre_begin_hook, - ) as emitter: - if context: - self._setup_context(emitter.harness, context) - - ops_evt_obj: BoundEvent = emitter.emit() - - # todo verify that if state was mutated, it was mutated - # in a way that makes sense: - # e.g. - charm cannot modify leadership status, etc... - if context: - self._cleanup_context(emitter.harness, context) - - if add_to_playbook: - # so we can later export it - self._playbook.add(Scene(context=context, event=event)) - - return PlayResult(event=ops_evt_obj, context=context, emitter=emitter) - - def play_next(self): - next_scene: Scene = self._playbook.next() - self.play(*next_scene) - - def play_until_complete(self): - if not self._playbook: - raise RuntimeError("playbook is empty") - - with self: - for context, event in self._playbook: - ctx = self.play(event=event, context=context) - return ctx - - @staticmethod - def _check_assertions( - ctx: PlayResult, assertions: Union[AssertionType, Iterable[AssertionType]] - ): - if callable(assertions): - assertions = [assertions] - - for assertion in assertions: - ret_val = assertion(*ctx) - if ret_val is False: - raise ValueError(f"Assertion {assertion} returned False") diff --git a/scenario/scenario.py b/scenario/scenario.py index 7fc7da594..182025637 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -42,6 +42,9 @@ def emit(self): return self.event +patch_sort_key = lambda obj: obj["path"] + obj["op"] + + class PlayResult: # TODO: expose the 'final context' or a Delta object from the PlayResult. def __init__( @@ -68,9 +71,13 @@ def delta(self): if self.scene_in.context == self.context_out: return None - return jsonpatch.make_patch( + patch = jsonpatch.make_patch( asdict(self.scene_in.context), asdict(self.context_out) - ) + ).patch + return sorted(patch, key=patch_sort_key) + + def sort_patch(self, patch: List[Dict]): + return sorted(patch, key=patch_sort_key) class _Builtins: @@ -148,11 +155,16 @@ def load(s: str) -> "Playbook": class Scenario: builtins = _Builtins() - def __init__(self, charm_spec: CharmSpec, playbook: Playbook = Playbook(())): + def __init__( + self, + charm_spec: CharmSpec, + playbook: Playbook = Playbook(()), + juju_version: str = "3.0.0", + ): self._playbook = playbook self._charm_spec = charm_spec self._charm_type = charm_spec.charm_type - self._runtime = Runtime(charm_spec) + self._runtime = Runtime(charm_spec, juju_version=juju_version) @property def playbook(self) -> Playbook: diff --git a/scenario/structs.py b/scenario/structs.py index 726852f9c..509863ce4 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -48,6 +48,33 @@ class RelationSpec(memo.RelationSpec, DCBase): pass +def relation( + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: Tuple[int, ...] = (0,), + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, +): + """Helper function to construct a RelationMeta object with some sensible defaults.""" + metadata = RelationMeta( + endpoint=endpoint, + interface=interface, + remote_app_name=remote_app_name, + remote_unit_ids=remote_unit_ids, + relation_id=relation_id, + ) + return RelationSpec( + meta=metadata, + local_unit_data=local_unit_data or {}, + local_app_data=local_app_data or {}, + remote_app_data=remote_app_data or {}, + ) + + def network( private_address: str = "1.1.1.1", mac_address: str = "", @@ -144,7 +171,7 @@ def from_dict(cls, obj): @dataclass class Context(DCBase): memos: Dict[str, Memo] = dataclasses.field(default_factory=dict) - state: State = dataclasses.field(default_factory=State.null) + state: State = dataclasses.field(default_factory=State) @classmethod def from_dict(cls, obj): diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index c7e7be5bc..4c95f9dab 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -2,27 +2,63 @@ setup_tests() # noqa & keep this on top -from typing import Optional +from typing import Optional, Type import pytest -from ops.charm import CharmBase, StartEvent -from ops.framework import Framework -from ops.model import ActiveStatus, UnknownStatus +from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.framework import EventBase, Framework +from ops.model import ActiveStatus, UnknownStatus, WaitingStatus from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Context, Scene, State, get_event - - -class MyCharm(CharmBase): - _call = None - - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) - self.called = False - - if self._call: - self.called = True - self._call() +from scenario.structs import ( + CharmSpec, + ContainerSpec, + Context, + Scene, + State, + get_event, + relation, +) + +CUSTOM_EVT_SUFFIXES = { + "relation_created", + "relation_joined", + "relation_changed", + "relation_departed", + "relation_broken", + "storage_attached", + "storage_detaching", + "action", + "pebble_ready", +} + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharmEvents(CharmEvents): + @classmethod + def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): + if getattr(cls, event_kind, None): + delattr(cls, event_kind) + return super().define_event(event_kind, event_type) + + class MyCharm(CharmBase): + _call = None + on = MyCharmEvents() + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + self.called = False + + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + if self._call: + self.called = True + self._call(event) + + return MyCharm @pytest.fixture @@ -35,37 +71,151 @@ def start_scene(dummy_state): return Scene(get_event("start"), context=Context(state=dummy_state)) -def test_bare_event(start_scene): - MyCharm._call = lambda charm: True - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) +def test_bare_event(start_scene, mycharm): + mycharm._call = lambda *_: True + scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) out = scenario.run(start_scene) - assert isinstance(out.charm, MyCharm) + assert isinstance(out.charm, mycharm) assert out.charm.called assert isinstance(out.event, StartEvent) assert out.charm.unit.name == "foo/0" assert out.charm.model.uuid == start_scene.context.state.model.uuid -def test_leader_get(start_scene): - def call(charm): +def test_leader_get(start_scene, mycharm): + def call(charm, _): assert charm.unit.is_leader() - MyCharm._call = call - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + mycharm._call = call + scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) scenario.run(start_scene) -def test_status_setting(start_scene): - def call(charm): +def test_status_setting(start_scene, mycharm): + def call(charm: CharmBase, _): assert isinstance(charm.unit.status, UnknownStatus) charm.unit.status = ActiveStatus("foo test") + charm.app.status = WaitingStatus("foo barz") - MyCharm._call = call - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + mycharm._call = call + scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) out = scenario.run(start_scene) assert out.context_out.state.status.unit == ("active", "foo test") - assert out.context_out.state.status.app == ("unknown", "") - assert out.delta().patch == [ - {"op": "replace", "path": "/state/status/unit", "value": ("active", "foo test")} + assert out.context_out.state.status.app == ("waiting", "foo barz") + assert out.context_out.state.status.app_version == "" + assert out.delta() == out.sort_patch( + [ + { + "op": "replace", + "path": "/state/status/app", + "value": ("waiting", "foo barz"), + }, + { + "op": "replace", + "path": "/state/status/unit", + "value": ("active", "foo test"), + }, + ] + ) + + +@pytest.mark.parametrize("connect", (True, False)) +def test_container(start_scene: Scene, connect, mycharm): + def call(charm: CharmBase, _): + container = charm.unit.get_container("foo") + assert container is not None + assert container.name == "foo" + assert container.can_connect() is connect + + mycharm._call = call + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "foo", + "containers": {"foo": {"resource": "bar"}}, + }, + ) + ) + scene = start_scene.copy() + scene.context.state.containers = (ContainerSpec(name="foo", can_connect=connect),) + scenario.run(scene) + + +def test_relation_get(start_scene: Scene, mycharm): + def call(charm: CharmBase, _): + rel = charm.model.get_relation("foo") + assert rel is not None + assert rel.data[charm.app]["a"] == "because" + assert rel.data[rel.app]["a"] == "b" + assert not rel.data[charm.unit] # empty + + mycharm._call = call + + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "local", + "requires": {"foo": {"interface": "bar"}}, + }, + ) + ) + scene = start_scene.copy() + scene.context.state.relations = ( + relation( + endpoint="foo", + interface="bar", + local_app_data={"a": "because"}, + remote_app_name="remote", + remote_unit_ids=(0, 1, 2), + remote_app_data={"a": "b"}, + local_unit_data={"c": "d"}, + ), + ) + scenario.run(scene) + + +def test_relation_set(start_scene: Scene, mycharm): + def call(charm: CharmBase, _): + rel = charm.model.get_relation("foo") + rel.data[charm.app]["a"] = "b" + rel.data[charm.unit]["c"] = "d" + + with pytest.raises(Exception): + rel.data[rel.app]["a"] = "b" + with pytest.raises(Exception): + rel.data[charm.model.get_unit("remote/1")]["c"] = "d" + + mycharm._call = call + + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "foo", + "requires": {"foo": {"interface": "bar"}}, + }, + ) + ) + scene = start_scene.copy() + scene.context.state.relations = [ # we could also append... + relation( + endpoint="foo", + interface="bar", + remote_unit_ids=(1, 4), + local_app_data={}, + local_unit_data={}, + ) ] + out = scenario.run(scene) + + assert out.context_out.state.relations[0].local_app_data == {"a": "b"} + assert out.context_out.state.relations[0].local_unit_data == {"c": "d"} + assert out.delta() == out.sort_patch( + [ + {"op": "add", "path": "/state/relations/0/local_app_data/a", "value": "b"}, + {"op": "add", "path": "/state/relations/0/local_unit_data/c", "value": "d"}, + ] + ) diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py index a357e1541..10fc492d9 100644 --- a/tests/test_replay_local_runtime.py +++ b/tests/test_replay_local_runtime.py @@ -11,11 +11,9 @@ try: from scenario.runtime import Runtime except ModuleNotFoundError: - import os + from scenario.runtime.runtime import RUNTIME_MODULE - from jhack.utils.event_recorder.runtime import RECORDER_MODULE - - sys.path.append(str(RECORDER_MODULE.absolute())) + sys.path.append(str(RUNTIME_MODULE.absolute())) from ops.charm import CharmBase, CharmEvents From b00fb97a1cf954713e254ccdf37519923eaa3d78 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 29 Nov 2022 18:10:09 +0100 Subject: [PATCH 010/546] relation dataclass comparison --- README.md | 46 ++++++++++++++++++++++++++++++++++-- scenario/runtime/memo.py | 2 +- scenario/structs.py | 15 ++++++++---- tests/test_e2e/test_state.py | 21 ++++++++++++---- tox.ini | 3 ++- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7b056d13c..c4379b31c 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,49 @@ def test_status_leader(scenario, start_scene, leader): assert out.context_out.state.status.unit == ('active', 'I follow') ``` -By defining the right state we can programmatically define what answers will the charm get to all the questions it can -ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... +By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... + +An example involving relations: + +```python +from scenario.structs import relation + +# This charm copies over remote app data to local unit data +class MyCharm(CharmBase): + ... + def _on_event(self, e): + relation = self.model.relations['foo'][0] + assert relation.app.name == 'remote' + assert e.relation.data[self.unit]['abc'] == 'foo' + e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] + + +def test_relation_data(scenario, start_scene): + scene = start_scene.copy() + scene.context.state.relations = [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ] + out = scenario.run(scene) + assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} + # one could probably even do: + assert out.context_out.state.relations == [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] + # which is very idiomatic and superbly explicit. Noice. +``` + # Caveats The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. @@ -161,6 +202,7 @@ Options we're considering: - have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. - trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, YOU SHOULD REALLY DO THAT + # Advanced Mockery The Harness mocks data by providing a separate backend. When the charm code asks: am I leader? there's a variable in `harness._backend` that decides whether the return value is True or False. diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 7d6e99030..06bb272af 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -598,7 +598,7 @@ class RelationMeta: interface: str relation_id: int remote_app_name: str - remote_unit_ids: Tuple[int, ...] = (0,) + remote_unit_ids: List[int] = field(default_factory=lambda: list((0, ))) # local limit limit: int = 1 diff --git a/scenario/structs.py b/scenario/structs.py index 45e0b2cc5..4d2aad3a0 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,6 +1,13 @@ import dataclasses +import typing from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional, Tuple, Type, Union +from typing import Any, Dict, Literal, Optional, Tuple, Type, Union, List + +if typing.TYPE_CHECKING: + try: + from typing import Self + except ImportError: + from typing_extensions import Self from ops.charm import CharmBase from ops.testing import CharmType @@ -14,7 +21,7 @@ class DCBase: def replace(self, *args, **kwargs): return dataclasses.replace(self, *args, **kwargs) - def copy(self): + def copy(self) -> "Self": return dataclasses.replace(self) @@ -53,7 +60,7 @@ def relation( interface: str, remote_app_name: str = "remote", relation_id: int = 0, - remote_unit_ids: Tuple[int, ...] = (0,), + remote_unit_ids: List[int] = (0,), # mapping from unit ID to databag contents local_unit_data: Dict[str, str] = None, local_app_data: Dict[str, str] = None, @@ -64,7 +71,7 @@ def relation( endpoint=endpoint, interface=interface, remote_app_name=remote_app_name, - remote_unit_ids=remote_unit_ids, + remote_unit_ids=list(remote_unit_ids), relation_id=relation_id, ) return RelationSpec( diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index be5e54554..37cbd5655 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,3 +1,5 @@ +from dataclasses import asdict + from tests.setup_tests import setup_tests setup_tests() # noqa & keep this on top @@ -171,17 +173,17 @@ def call(charm: CharmBase, _): ) ) scene = start_scene.copy() - scene.context.state.relations = ( + scene.context.state.relations = [ relation( endpoint="foo", interface="bar", local_app_data={"a": "because"}, remote_app_name="remote", - remote_unit_ids=(0, 1, 2), + remote_unit_ids=[0, 1, 2], remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, ), - ) + ] scenario.run(scene) @@ -212,13 +214,24 @@ def call(charm: CharmBase, _): relation( endpoint="foo", interface="bar", - remote_unit_ids=(1, 4), + remote_unit_ids=[1, 4], local_app_data={}, local_unit_data={}, ) ] out = scenario.run(scene) + assert asdict(out.context_out.state.relations[0]) == \ + asdict( + relation( + endpoint="foo", + interface="bar", + remote_unit_ids=[1, 4], + local_app_data={"a": "b"}, + local_unit_data={"c": "d"}, + ) + ) + assert out.context_out.state.relations[0].local_app_data == {"a": "b"} assert out.context_out.state.relations[0].local_unit_data == {"c": "d"} assert out.delta() == out.sort_patch( diff --git a/tox.ini b/tox.ini index 761f02cf0..cc165c6b2 100644 --- a/tox.ini +++ b/tox.ini @@ -17,11 +17,12 @@ description = End to end tests deps = coverage[toml] pytest + jsonpatch -r{toxinidir}/requirements.txt commands = coverage run \ --source={[vars]src_path} \ - -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/e2e + -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/test_e2e coverage report From 4f3bf611dbc1608df774caaa169b6c89b8c1d063 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 1 Dec 2022 16:25:59 +0100 Subject: [PATCH 011/546] mocked out ops.main --- scenario/ops_main_mock.py | 157 ++++++++++++++++++++++++++++++++++++ scenario/runtime/runtime.py | 17 ++-- 2 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 scenario/ops_main_mock.py diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py new file mode 100644 index 000000000..1ca266b67 --- /dev/null +++ b/scenario/ops_main_mock.py @@ -0,0 +1,157 @@ +### This file contains stuff that ideally should be in ops. +# see https://github.com/canonical/operator/pull/862 + +import inspect +import logging +import os +import warnings +from typing import TYPE_CHECKING, Any, Optional, Tuple, Type + +import ops.charm +import ops.framework +import ops.model +import ops.storage +from ops.charm import CharmMeta +from ops.jujuversion import JujuVersion +from ops.log import setup_root_logging + +if TYPE_CHECKING: + from ops.charm import CharmBase, EventBase + +from ops.main import _get_charm_dir, _Dispatcher, _should_use_controller_storage, _get_event_args +from ops.framework import Handle + +CHARM_STATE_FILE = '.unit-state.db' + +logger = logging.getLogger() + + +def patched_bound_event_emit(self, *args: Any, **kwargs: Any) -> 'EventBase': + """Emit event to all registered observers. + + The current storage state is committed before and after each observer is notified. + """ + framework = self.emitter.framework + key = framework._next_event_key() # noqa + event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs) + event.framework = framework + framework._emit(event) # noqa + return event + + +from ops import framework + +framework.BoundEvent.emit = patched_bound_event_emit + + +def _emit_charm_event(charm: 'CharmBase', event_name: str) -> Optional['EventBase']: + """Emits a charm event based on a Juju event name. + + Args: + charm: A charm instance to emit an event from. + event_name: A Juju event name to emit on a charm. + """ + event_to_emit = None + try: + event_to_emit = getattr(charm.on, event_name) + except AttributeError: + logger.debug("Event %s not defined for %s.", event_name, charm) + + # If the event is not supported by the charm implementation, do + # not error out or try to emit it. This is to support rollbacks. + if event_to_emit is not None: + args, kwargs = _get_event_args(charm, event_to_emit) + logger.debug('Emitting Juju event %s.', event_name) + return event_to_emit.emit(*args, **kwargs) + + +def main(charm_class: Type[ops.charm.CharmBase], + use_juju_for_storage: Optional[bool] = None + ) -> Optional[Tuple['CharmBase', Optional['EventBase']]]: + """Setup the charm and dispatch the observed event. + + The event name is based on the way this executable was called (argv[0]). + + Args: + charm_class: your charm class. + use_juju_for_storage: whether to use controller-side storage. If not specified + then kubernetes charms that haven't previously used local storage and that + are running on a new enough Juju default to controller-side storage, + otherwise local storage is used. + """ + charm_dir = _get_charm_dir() + + model_backend = ops.model._ModelBackend() # pyright: reportPrivateUsage=false + debug = ('JUJU_DEBUG' in os.environ) + setup_root_logging(model_backend, debug=debug) + logger.debug("Operator Framework %s up and running.", ops.__version__) # type:ignore + + dispatcher = _Dispatcher(charm_dir) + dispatcher.run_any_legacy_hook() + + metadata = (charm_dir / 'metadata.yaml').read_text() + actions_meta = charm_dir / 'actions.yaml' + if actions_meta.exists(): + actions_metadata = actions_meta.read_text() + else: + actions_metadata = None + + meta = CharmMeta.from_yaml(metadata, actions_metadata) + model = ops.model.Model(meta, model_backend) + + charm_state_path = charm_dir / CHARM_STATE_FILE + + if use_juju_for_storage and not ops.storage.juju_backend_available(): + # raise an exception; the charm is broken and needs fixing. + msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it' + raise RuntimeError(msg.format(JujuVersion.from_environ())) + + if use_juju_for_storage is None: + use_juju_for_storage = _should_use_controller_storage(charm_state_path, meta) + + if use_juju_for_storage: + if dispatcher.is_restricted_context(): + # TODO: jam 2020-06-30 This unconditionally avoids running a collect metrics event + # Though we eventually expect that juju will run collect-metrics in a + # non-restricted context. Once we can determine that we are running collect-metrics + # in a non-restricted context, we should fire the event as normal. + logger.debug('"%s" is not supported when using Juju for storage\n' + 'see: https://github.com/canonical/operator/issues/348', + dispatcher.event_name) + # Note that we don't exit nonzero, because that would cause Juju to rerun the hook + return + store = ops.storage.JujuStorage() + else: + store = ops.storage.SQLiteStorage(charm_state_path) + framework = ops.framework.Framework(store, charm_dir, meta, model) + framework.set_breakpointhook() + try: + sig = inspect.signature(charm_class) + try: + sig.bind(framework) + except TypeError: + msg = ( + "the second argument, 'key', has been deprecated and will be " + "removed after the 0.7 release") + warnings.warn(msg, DeprecationWarning) + charm = charm_class(framework, None) + else: + charm = charm_class(framework) + dispatcher.ensure_event_links(charm) + + # TODO: Remove the collect_metrics check below as soon as the relevant + # Juju changes are made. Also adjust the docstring on + # EventBase.defer(). + # + # Skip reemission of deferred events for collect-metrics events because + # they do not have the full access to all hook tools. + if not dispatcher.is_restricted_context(): + framework.reemit() + + event = _emit_charm_event(charm, dispatcher.event_name) + + framework.commit() + finally: + framework.close() + + return charm, event diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index 0ea769b80..b21df0758 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -116,16 +116,6 @@ def install(force=False): logger.info(f"rewriting ops.model ({ops_model_module})") inject_memoizer(ops_model_module, decorate=DECORATE_MODEL) - # make main return the charm instance, for testing - from ops import main - - ops_main_module = Path(main.__file__) - logger.info(f"rewriting ops.main ({ops_main_module})") - - retcharm = "return charm # added by jhack.replay.Runtime" - ops_main_module_text = ops_main_module.read_text() - if retcharm not in ops_main_module_text: - ops_main_module.write_text(ops_main_module_text + f" {retcharm}\n") @staticmethod def _is_installed(): @@ -248,9 +238,12 @@ def run(self, scene: "Scene") -> RuntimeRunResult: # _reset_replay_cursors(self._local_db_path, 0) os.environ.update(env) - from ops.main import main + # we don't import from ops because we need some extra return statements. + # see https://github.com/canonical/operator/pull/862 + # from ops.main import main + from scenario.ops_main_mock import main - logger.info(" - Entering ops.main.") + logger.info(" - Entering ops.main (mocked).") try: charm, event = main(self._charm_type) From 8e74d0d2a2bf2260564a2df1a3e9bb4ea4adf6ab Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 1 Dec 2022 16:31:08 +0100 Subject: [PATCH 012/546] mocked out ops.main --- ops_scenario.egg-info/PKG-INFO | 283 +++++++++++++++++++++ ops_scenario.egg-info/SOURCES.txt | 13 + ops_scenario.egg-info/dependency_links.txt | 1 + ops_scenario.egg-info/top_level.txt | 1 + scenario/version.py | 1 + setup.py | 65 +++++ 6 files changed, 364 insertions(+) create mode 100644 ops_scenario.egg-info/PKG-INFO create mode 100644 ops_scenario.egg-info/SOURCES.txt create mode 100644 ops_scenario.egg-info/dependency_links.txt create mode 100644 ops_scenario.egg-info/top_level.txt create mode 100644 scenario/version.py create mode 100644 setup.py diff --git a/ops_scenario.egg-info/PKG-INFO b/ops_scenario.egg-info/PKG-INFO new file mode 100644 index 000000000..41292c12d --- /dev/null +++ b/ops_scenario.egg-info/PKG-INFO @@ -0,0 +1,283 @@ +Metadata-Version: 2.1 +Name: ops-scenario +Version: 0.1 +Summary: Python library providing a Scenario-based testing API for Operator Framework charms. +Home-page: https://github.com/PietroPasotti/ops-scenario +Author: Pietro Pasotti. +Author-email: pietro.pasotti@canonical.com +License: Apache-2.0 +Platform: UNKNOWN +Classifier: Programming Language :: Python :: 3 +Classifier: Intended Audience :: Developers +Classifier: Operating System :: MacOS :: MacOS X +Classifier: Operating System :: POSIX :: Linux +Requires-Python: >=3.8 +Description-Content-Type: text/markdown + +Ops-Scenario +============ + +This is a python library that you can use to run scenario-based tests. + +Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow +you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single +event on the charm and execute its logic. + +This puts scenario tests somewhere in between unit and integration tests. + +Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the +union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my +config?...). +The output is another context instance: the context after the charm has had a chance to interact with the mocked juju +model. + +Testing a charm under a given scenario, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the scenario +- the output state (or the diff with the input state) is as expected + + +# Core concepts as a metaphor +I like metaphors, so here we go: +- There is a theatre stage (Scenario). +- You pick an actor (a Charm) to put on the stage. +- You pick a sketch that the actor will have to play out (a Scene). The sketch is specified as: + - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written on those books on the table? + - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed)) +- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something to a book (pebble.push). + +# Core concepts not as a metaphor +Each scene maps to a single event. +The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the context in which they occur. + +Crucially, this decoupling of charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... + +In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. + +# Writing scenario tests + +Writing a scenario tests consists of two broad steps: + +- define a Scenario +- run the scenario + +The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is +available. The charm has no config, no relations, no networks, and no leadership. + +With that, we can write the simplest possible scenario test: + +```python +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context +from ops.charm import CharmBase + + +class MyCharm(CharmBase): + pass + + +def test_scenario_base(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(Scene(event=get_event('start'), context=Context())) + assert out.context_out.state.status.unit == ('unknown', '') +``` + +Now let's start making it more complicated. +Our charm sets a special state if it has leadership on 'start': + +```python +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context, State +from ops.charm import CharmBase +from ops.model import ActiveStatus + + +class MyCharm(CharmBase): + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + + +def test_scenario_base(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run(Scene(event=get_event('start'), context=Context())) + assert out.context_out.state.status.unit == ('unknown', '') + + +def test_status_leader(): + scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + out = scenario.run( + Scene( + event=get_event('start'), + context=Context( + state=State(leader=True) + ))) + assert out.context_out.state.status.unit == ('active', 'I rule') +``` + +This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more +concisely (and parametrically) as: + +```python +import pytest +from scenario.scenario import Scenario, Scene +from scenario.structs import CharmSpec, get_event, Context +from ops.charm import CharmBase +from ops.model import ActiveStatus + + +class MyCharm(CharmBase): + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I follow') + + +@pytest.fixture +def scenario(): + return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + + +@pytest.fixture +def start_scene(): + return Scene(event=get_event('start'), context=Context()) + + +def test_scenario_base(scenario, start_scene): + out = scenario.run(start_scene) + assert out.context_out.state.status.unit == ('unknown', '') + + +@pytest.mark.parametrize('leader', [True, False]) +def test_status_leader(scenario, start_scene, leader): + leader_scene = start_scene.copy() + leader_scene.context.state.leader = leader + + out = scenario.run(leader_scene) + if leader: + assert out.context_out.state.status.unit == ('active', 'I rule') + else: + assert out.context_out.state.status.unit == ('active', 'I follow') +``` + +By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... + +An example involving relations: + +```python +from scenario.structs import relation + +# This charm copies over remote app data to local unit data +class MyCharm(CharmBase): + ... + def _on_event(self, e): + relation = self.model.relations['foo'][0] + assert relation.app.name == 'remote' + assert e.relation.data[self.unit]['abc'] == 'foo' + e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] + + +def test_relation_data(scenario, start_scene): + scene = start_scene.copy() + scene.context.state.relations = [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ] + out = scenario.run(scene) + assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} + # one could probably even do: + assert out.context_out.state.relations == [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] + # which is very idiomatic and superbly explicit. Noice. +``` + + +# Caveats +The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. + +Options we're considering: +- have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. +- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, YOU SHOULD REALLY DO THAT + + +# Advanced Mockery +The Harness mocks data by providing a separate backend. When the charm code asks: am I leader? there's a variable +in `harness._backend` that decides whether the return value is True or False. +A Scene exposes two layers of data to the charm: memos and a state. + +- Memos are strict, cached input->output mappings. They basically map a function call to a hardcoded return value, or + multiple return values. +- A State is a static database providing the same mapping, but only a single return value is supported per input. + +Scenario tests mock the data by operating at the hook tool call level, not the backend level. Every backend call that +would normally result in a hook tool call is instead redirected to query the available memos, and as a fallback, is +going to query the State we define as part of a Scene. If neither one can provide an answer, the hook tool call is +propagated -- which unless you have taken care of mocking that executable as well, will likely result in an error. + +Let's see the difference with an example: + +Suppose the charm does: + +```python + ... + + +def _on_start(self, _): + assert self.unit.is_leader() + + import time + time.sleep(31) + + assert not self.unit.is_leader() + + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I follow') +``` + +Suppose we want this test to pass. How could we mock this using Scenario? + +```python +scene = Scene( + event=get_event('start'), + context=Context(memos=[ + {'name': '_ModelBackend.leader_get', + 'values': ['True', 'False'], + 'caching_mode': 'strict'} + ]) +) +``` +What this means in words is: the mocked hook-tool 'leader-get' call will return True at first, but False the second time around. + +Since we didn't pass a State to the Context object, when the runtime fails to find a third value for leader-get, it will fall back and use the static value provided by the default State -- False. So if the charm were to call `is_leader` at any point after the first two calls, it would consistently get False. + +NOTE: the API is work in progress. We're working on exposing friendlier ways of defining memos. +The good news is that you can generate memos by scraping them off of a live unit using `jhack replay`. + + +# TODOS: +- Figure out how to distribute this. I'm thinking `pip install ops[scenario]` +- Better syntax for memo generation +- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) +- Expose instructions or facilities re. how to use this without touching your venv. + diff --git a/ops_scenario.egg-info/SOURCES.txt b/ops_scenario.egg-info/SOURCES.txt new file mode 100644 index 000000000..9aba4fbb0 --- /dev/null +++ b/ops_scenario.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +README.md +setup.py +ops_scenario.egg-info/PKG-INFO +ops_scenario.egg-info/SOURCES.txt +ops_scenario.egg-info/dependency_links.txt +ops_scenario.egg-info/top_level.txt +scenario/__init__.py +scenario/consts.py +scenario/event_db.py +scenario/ops_main_mock.py +scenario/scenario.py +scenario/structs.py +scenario/version.py \ No newline at end of file diff --git a/ops_scenario.egg-info/dependency_links.txt b/ops_scenario.egg-info/dependency_links.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ops_scenario.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/ops_scenario.egg-info/top_level.txt b/ops_scenario.egg-info/top_level.txt new file mode 100644 index 000000000..de43fb96d --- /dev/null +++ b/ops_scenario.egg-info/top_level.txt @@ -0,0 +1 @@ +scenario diff --git a/scenario/version.py b/scenario/version.py new file mode 100644 index 000000000..c128876ac --- /dev/null +++ b/scenario/version.py @@ -0,0 +1 @@ +version = "0.1" \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..26e96dbd9 --- /dev/null +++ b/setup.py @@ -0,0 +1,65 @@ +# Copyright 2019-2020 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Setup script for Ops-Scenario.""" + +from importlib.util import spec_from_file_location, module_from_spec +from pathlib import Path +from setuptools import setup, find_packages + + +def _read_me() -> str: + """Return the README content from the file.""" + with open("README.md", "rt", encoding="utf8") as fh: + readme = fh.read() + return readme + + +def _get_version() -> str: + """Get the version via ops/version.py, without loading ops/__init__.py.""" + spec = spec_from_file_location('scenario.version', 'scenario/version.py') + if spec is None: + raise ModuleNotFoundError('could not find /scenario/version.py') + if spec.loader is None: + raise AttributeError('loader', spec, 'invalid module') + module = module_from_spec(spec) + spec.loader.exec_module(module) + + return module.version + + +version = _get_version() + +setup( + name="ops-scenario", + version=version, + description="Python library providing a Scenario-based " + "testing API for Operator Framework charms.", + long_description=_read_me(), + long_description_content_type="text/markdown", + license="Apache-2.0", + url="https://github.com/PietroPasotti/ops-scenario", + author="Pietro Pasotti.", + author_email="pietro.pasotti@canonical.com", + packages=find_packages( + include=('scenario', + 'scenario.runtime')), + classifiers=[ + "Programming Language :: Python :: 3", + "Intended Audience :: Developers", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX :: Linux", + ], + python_requires='>=3.8', +) From bd5c9f324d8107f6aa841a23657d25b0edb1ce2f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 2 Dec 2022 11:12:07 +0100 Subject: [PATCH 013/546] some import fixes and utilities --- ops_scenario.egg-info/PKG-INFO | 283 --------------------- ops_scenario.egg-info/SOURCES.txt | 13 - ops_scenario.egg-info/dependency_links.txt | 1 - ops_scenario.egg-info/top_level.txt | 1 - requirements.txt | 3 - scenario/__init__.py | 4 +- logger.py => scenario/logger.py | 0 scenario/runtime/memo.py | 4 +- scenario/runtime/memo_tools.py | 4 +- scenario/runtime/runtime.py | 78 +++--- scenario/scenario.py | 18 +- scenario/structs.py | 51 ++-- setup.py | 12 +- tests/test_e2e/test_state.py | 4 +- tests/test_replay_local_runtime.py | 2 +- 15 files changed, 85 insertions(+), 393 deletions(-) delete mode 100644 ops_scenario.egg-info/PKG-INFO delete mode 100644 ops_scenario.egg-info/SOURCES.txt delete mode 100644 ops_scenario.egg-info/dependency_links.txt delete mode 100644 ops_scenario.egg-info/top_level.txt rename logger.py => scenario/logger.py (100%) diff --git a/ops_scenario.egg-info/PKG-INFO b/ops_scenario.egg-info/PKG-INFO deleted file mode 100644 index 41292c12d..000000000 --- a/ops_scenario.egg-info/PKG-INFO +++ /dev/null @@ -1,283 +0,0 @@ -Metadata-Version: 2.1 -Name: ops-scenario -Version: 0.1 -Summary: Python library providing a Scenario-based testing API for Operator Framework charms. -Home-page: https://github.com/PietroPasotti/ops-scenario -Author: Pietro Pasotti. -Author-email: pietro.pasotti@canonical.com -License: Apache-2.0 -Platform: UNKNOWN -Classifier: Programming Language :: Python :: 3 -Classifier: Intended Audience :: Developers -Classifier: Operating System :: MacOS :: MacOS X -Classifier: Operating System :: POSIX :: Linux -Requires-Python: >=3.8 -Description-Content-Type: text/markdown - -Ops-Scenario -============ - -This is a python library that you can use to run scenario-based tests. - -Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow -you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single -event on the charm and execute its logic. - -This puts scenario tests somewhere in between unit and integration tests. - -Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the -union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my -config?...). -The output is another context instance: the context after the charm has had a chance to interact with the mocked juju -model. - -Testing a charm under a given scenario, then, means verifying that: - -- the charm does not raise uncaught exceptions while handling the scenario -- the output state (or the diff with the input state) is as expected - - -# Core concepts as a metaphor -I like metaphors, so here we go: -- There is a theatre stage (Scenario). -- You pick an actor (a Charm) to put on the stage. -- You pick a sketch that the actor will have to play out (a Scene). The sketch is specified as: - - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written on those books on the table? - - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed)) -- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something to a book (pebble.push). - -# Core concepts not as a metaphor -Each scene maps to a single event. -The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the context in which they occur. - -Crucially, this decoupling of charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... - -In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. - -# Writing scenario tests - -Writing a scenario tests consists of two broad steps: - -- define a Scenario -- run the scenario - -The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is -available. The charm has no config, no relations, no networks, and no leadership. - -With that, we can write the simplest possible scenario test: - -```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context -from ops.charm import CharmBase - - -class MyCharm(CharmBase): - pass - - -def test_scenario_base(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run(Scene(event=get_event('start'), context=Context())) - assert out.context_out.state.status.unit == ('unknown', '') -``` - -Now let's start making it more complicated. -Our charm sets a special state if it has leadership on 'start': - -```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context, State -from ops.charm import CharmBase -from ops.model import ActiveStatus - - -class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) - - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - - -def test_scenario_base(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run(Scene(event=get_event('start'), context=Context())) - assert out.context_out.state.status.unit == ('unknown', '') - - -def test_status_leader(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run( - Scene( - event=get_event('start'), - context=Context( - state=State(leader=True) - ))) - assert out.context_out.state.status.unit == ('active', 'I rule') -``` - -This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more -concisely (and parametrically) as: - -```python -import pytest -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context -from ops.charm import CharmBase -from ops.model import ActiveStatus - - -class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) - - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') - - -@pytest.fixture -def scenario(): - return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - - -@pytest.fixture -def start_scene(): - return Scene(event=get_event('start'), context=Context()) - - -def test_scenario_base(scenario, start_scene): - out = scenario.run(start_scene) - assert out.context_out.state.status.unit == ('unknown', '') - - -@pytest.mark.parametrize('leader', [True, False]) -def test_status_leader(scenario, start_scene, leader): - leader_scene = start_scene.copy() - leader_scene.context.state.leader = leader - - out = scenario.run(leader_scene) - if leader: - assert out.context_out.state.status.unit == ('active', 'I rule') - else: - assert out.context_out.state.status.unit == ('active', 'I follow') -``` - -By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... - -An example involving relations: - -```python -from scenario.structs import relation - -# This charm copies over remote app data to local unit data -class MyCharm(CharmBase): - ... - def _on_event(self, e): - relation = self.model.relations['foo'][0] - assert relation.app.name == 'remote' - assert e.relation.data[self.unit]['abc'] == 'foo' - e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] - - -def test_relation_data(scenario, start_scene): - scene = start_scene.copy() - scene.context.state.relations = [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ] - out = scenario.run(scene) - assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} - # one could probably even do: - assert out.context_out.state.relations == [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] - # which is very idiomatic and superbly explicit. Noice. -``` - - -# Caveats -The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. - -Options we're considering: -- have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. -- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, YOU SHOULD REALLY DO THAT - - -# Advanced Mockery -The Harness mocks data by providing a separate backend. When the charm code asks: am I leader? there's a variable -in `harness._backend` that decides whether the return value is True or False. -A Scene exposes two layers of data to the charm: memos and a state. - -- Memos are strict, cached input->output mappings. They basically map a function call to a hardcoded return value, or - multiple return values. -- A State is a static database providing the same mapping, but only a single return value is supported per input. - -Scenario tests mock the data by operating at the hook tool call level, not the backend level. Every backend call that -would normally result in a hook tool call is instead redirected to query the available memos, and as a fallback, is -going to query the State we define as part of a Scene. If neither one can provide an answer, the hook tool call is -propagated -- which unless you have taken care of mocking that executable as well, will likely result in an error. - -Let's see the difference with an example: - -Suppose the charm does: - -```python - ... - - -def _on_start(self, _): - assert self.unit.is_leader() - - import time - time.sleep(31) - - assert not self.unit.is_leader() - - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') -``` - -Suppose we want this test to pass. How could we mock this using Scenario? - -```python -scene = Scene( - event=get_event('start'), - context=Context(memos=[ - {'name': '_ModelBackend.leader_get', - 'values': ['True', 'False'], - 'caching_mode': 'strict'} - ]) -) -``` -What this means in words is: the mocked hook-tool 'leader-get' call will return True at first, but False the second time around. - -Since we didn't pass a State to the Context object, when the runtime fails to find a third value for leader-get, it will fall back and use the static value provided by the default State -- False. So if the charm were to call `is_leader` at any point after the first two calls, it would consistently get False. - -NOTE: the API is work in progress. We're working on exposing friendlier ways of defining memos. -The good news is that you can generate memos by scraping them off of a live unit using `jhack replay`. - - -# TODOS: -- Figure out how to distribute this. I'm thinking `pip install ops[scenario]` -- Better syntax for memo generation -- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) -- Expose instructions or facilities re. how to use this without touching your venv. - diff --git a/ops_scenario.egg-info/SOURCES.txt b/ops_scenario.egg-info/SOURCES.txt deleted file mode 100644 index 9aba4fbb0..000000000 --- a/ops_scenario.egg-info/SOURCES.txt +++ /dev/null @@ -1,13 +0,0 @@ -README.md -setup.py -ops_scenario.egg-info/PKG-INFO -ops_scenario.egg-info/SOURCES.txt -ops_scenario.egg-info/dependency_links.txt -ops_scenario.egg-info/top_level.txt -scenario/__init__.py -scenario/consts.py -scenario/event_db.py -scenario/ops_main_mock.py -scenario/scenario.py -scenario/structs.py -scenario/version.py \ No newline at end of file diff --git a/ops_scenario.egg-info/dependency_links.txt b/ops_scenario.egg-info/dependency_links.txt deleted file mode 100644 index 8b1378917..000000000 --- a/ops_scenario.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ops_scenario.egg-info/top_level.txt b/ops_scenario.egg-info/top_level.txt deleted file mode 100644 index de43fb96d..000000000 --- a/ops_scenario.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -scenario diff --git a/requirements.txt b/requirements.txt index 1a2608627..916966bef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,3 @@ -typer==0.4.1 ops==1.5.3 -parse==1.19.0 -juju==2.9.7 asttokens astunparse \ No newline at end of file diff --git a/scenario/__init__.py b/scenario/__init__.py index 1ede09876..aad9e3b67 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1 +1,3 @@ -from scenario.runtime.runtime import Runtime +from .runtime.runtime import Runtime +from .runtime.memo import memo +from .scenario import Scenario, Scene, Playbook \ No newline at end of file diff --git a/logger.py b/scenario/logger.py similarity index 100% rename from logger.py rename to scenario/logger.py diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 06bb272af..852e2ef3d 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -14,10 +14,10 @@ from contextlib import contextmanager from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Literal, Optional, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Literal, Tuple, Union from uuid import uuid4 -from logger import logger as pkg_logger +from scenario.logger import logger as pkg_logger logger = pkg_logger.getChild("recorder") diff --git a/scenario/runtime/memo_tools.py b/scenario/runtime/memo_tools.py index d232792c1..b4aee3047 100644 --- a/scenario/runtime/memo_tools.py +++ b/scenario/runtime/memo_tools.py @@ -11,7 +11,7 @@ from astunparse import unparse if typing.TYPE_CHECKING: - from memo import SUPPORTED_SERIALIZERS + from scenario import SUPPORTED_SERIALIZERS @dataclass @@ -101,7 +101,7 @@ def as_token(self, default_namespace: str) -> Token: memo_import_block = dedent( """# ==== block added by scenario.runtime.memo_tools === try: - from memo import memo + from scenario import memo except ModuleNotFoundError as e: msg = "recorder not installed. " \ "This can happen if you're playing with Runtime in a local venv. " \ diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index b21df0758..805179ec2 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -3,11 +3,11 @@ import sys import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Type +from typing import TYPE_CHECKING, Type, TypeVar import yaml -from logger import logger as pkg_logger +from scenario.logger import logger as pkg_logger from scenario.event_db import TemporaryEventDB from scenario.runtime.memo import ( MEMO_DATABASE_NAME_KEY, @@ -27,11 +27,13 @@ from ops.charm import CharmBase from ops.framework import EventBase from scenario.structs import CharmSpec, Scene + _CT = TypeVar("_CT", bound=Type["CharmType"]) + logger = pkg_logger.getChild("runtime") + RUNTIME_MODULE = Path(__file__).parent -logger = logger.getChild("event_recorder.runtime") @dataclasses.dataclass @@ -56,8 +58,8 @@ def __init__(self, charm_spec: "CharmSpec", juju_version: str = "3.0.0"): @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -91,6 +93,7 @@ def install(force=False): Nobody will help you fix your borked env. Have fun! """ + if not force and Runtime._is_installed(): logger.warning( "Runtime is already installed. " @@ -116,21 +119,27 @@ def install(force=False): logger.info(f"rewriting ops.model ({ops_model_module})") inject_memoizer(ops_model_module, decorate=DECORATE_MODEL) - @staticmethod def _is_installed(): - from ops import model + try: + from ops import model + except RuntimeError as e: + # we rewrite ops.model to import memo. + # We try to import ops from here --> circular import. + if e.args[0].startswith('recorder not installed'): + return True + raise e model_path = Path(model.__file__) - if "from memo import memo" not in model_path.read_text(): + if "from scenario import memo" not in model_path.read_text(): logger.error( f"ops.model ({model_path} does not seem to import runtime.memo.memo" ) return False try: - import memo + from scenario import memo except ModuleNotFoundError: logger.error("Could not `import memo`.") return False @@ -145,9 +154,8 @@ def _patch_logger(*args, **kwargs): logger.debug("Hijacked root logger.") pass - import ops.main - - ops.main.setup_root_logging = _patch_logger + from scenario import ops_main_mock + ops_main_mock.setup_root_logging = _patch_logger def _cleanup_env(self, env): # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. @@ -204,7 +212,20 @@ def _scene_to_memo_scene(self, scene: "Scene", env: dict) -> MemoScene: """Convert scenario.structs.Scene to Memo.Scene.""" return MemoScene(event=MemoEvent(env=env), context=scene.context) - def run(self, scene: "Scene") -> RuntimeRunResult: + def _wrap(self, charm_type: "_CT") -> "_CT": + # dark sorcery to work around framework using class attrs to hold on to event sources + class WrappedEvents(charm_type.on.__class__): + pass + + WrappedEvents.__name__ = charm_type.on.__class__.__name__ + + class WrappedCharm(charm_type): # type: ignore + on = WrappedEvents() + + WrappedCharm.__name__ = charm_type.__name__ + return WrappedCharm + + def run(self, scene: "Scene") -> RuntimeRunResult[C]: """Executes a scene on the charm. This will set the environment up and call ops.main.main(). @@ -246,7 +267,7 @@ def run(self, scene: "Scene") -> RuntimeRunResult: logger.info(" - Entering ops.main (mocked).") try: - charm, event = main(self._charm_type) + charm, event = main(self._wrap(self._charm_type)) except Exception as e: raise RuntimeError( f"Uncaught error in operator/charm code: {e}." @@ -267,32 +288,3 @@ def run(self, scene: "Scene") -> RuntimeRunResult: # relevant pebble.Client | model._ModelBackend juju/container-facing calls are # @memo-decorated and can be used in "replay" mode to reproduce a remote run. Runtime.install(force=False) - - # IRL one would probably manually @memo the annoying ksp calls. - def _patch_traefik_charm(charm: Type["CharmType"]): - from charms.observability_libs.v0 import kubernetes_service_patch # noqa - - def _do_nothing(*args, **kwargs): - print("KubernetesServicePatch call skipped") - - def _null_evt_handler(self, event): - print(f"event {event} received and skipped") - - kubernetes_service_patch.KubernetesServicePatch._service_object = _do_nothing - kubernetes_service_patch.KubernetesServicePatch._patch = _null_evt_handler - return charm - - # here's the magic: - # this env grabs the event db from the "trfk/0" unit (assuming the unit is available - # in the currently switched-to juju model/controller). - runtime = Runtime.from_local_file( - local_charm_src=Path("/home/pietro/canonical/traefik-k8s-operator"), - charm_cls_name="TraefikIngressCharm", - ) - # then it will grab the TraefikIngressCharm from that local path and simulate the whole - # remote runtime env by calling `ops.main.main()` on it. - # this tells the runtime which event to replay. Right now, #X of the - # `jhack replay list trfk/0` queue. Switch it to whatever number you like to - # locally replay that event. - runtime._charm_type = _patch_traefik_charm(runtime._charm_type) - runtime.run(2) diff --git a/scenario/scenario.py b/scenario/scenario.py index 102da8500..1843fd184 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -1,11 +1,9 @@ import json +import typing from dataclasses import asdict from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union -from ops.charm import CharmBase -from ops.framework import BoundEvent, EventBase - -from logger import logger as pkg_logger +from scenario.logger import logger as pkg_logger from scenario import Runtime from scenario.consts import ( ATTACH_ALL_STORAGES, @@ -16,6 +14,12 @@ ) from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene + +if typing.TYPE_CHECKING: + from ops.charm import CharmBase + from ops.framework import BoundEvent, EventBase + + CharmMeta = Optional[Union[str, TextIO, dict]] AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] @@ -25,7 +29,7 @@ class Emitter: """Event emitter.""" - def __init__(self, emit: Callable[[], BoundEvent]): + def __init__(self, emit: Callable[[], "BoundEvent"]): self._emit = emit self.event = None self._emitted = False @@ -55,9 +59,9 @@ class PlayResult: # TODO: expose the 'final context' or a Delta object from the PlayResult. def __init__( self, - charm: CharmBase, + charm: "CharmBase", scene_in: "Scene", - event: EventBase, + event: "EventBase", context_out: "Context", ): self.charm = charm diff --git a/scenario/structs.py b/scenario/structs.py index 4d2aad3a0..f6ff7bc51 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,7 +1,7 @@ import dataclasses import typing from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional, Tuple, Type, Union, List +from typing import Any, Dict, Literal, Optional, Tuple, Type, List if typing.TYPE_CHECKING: try: @@ -9,12 +9,12 @@ except ImportError: from typing_extensions import Self -from ops.charm import CharmBase -from ops.testing import CharmType - from scenario.consts import META_EVENTS from scenario.runtime import memo +if typing.TYPE_CHECKING: + from ops.testing import CharmType + @dataclass class DCBase: @@ -56,15 +56,15 @@ class RelationSpec(memo.RelationSpec, DCBase): def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[int] = (0,), - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[int] = (0,), + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" metadata = RelationMeta( @@ -83,13 +83,13 @@ def relation( def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> memo.Network: """Construct a network object.""" return memo.Network( @@ -155,15 +155,6 @@ class CharmSpec: actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None - @staticmethod - def cast(obj: Union["CharmSpec", CharmType, Type[CharmBase]]): - if isinstance(obj, type) and issubclass(obj, CharmBase): - return CharmSpec(charm_type=obj) - elif isinstance(obj, CharmSpec): - return obj - else: - raise ValueError(f"cannot convert {obj} to CharmSpec") - @dataclass class Memo(DCBase): @@ -241,6 +232,6 @@ def _derive_args(event_name: str): return tuple(args) -def get_event(name: str, append_args: Tuple[Any] = (), **kwargs) -> Event: +def event(name: str, append_args: Tuple[Any] = (), **kwargs) -> Event: """This routine will attempt to generate event args for you, based on the event name.""" return Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs) diff --git a/setup.py b/setup.py index 26e96dbd9..790b9cdd6 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def _get_version() -> str: version = _get_version() setup( - name="ops-scenario", + name="scenario", version=version, description="Python library providing a Scenario-based " "testing API for Operator Framework charms.", @@ -52,9 +52,10 @@ def _get_version() -> str: url="https://github.com/PietroPasotti/ops-scenario", author="Pietro Pasotti.", author_email="pietro.pasotti@canonical.com", - packages=find_packages( - include=('scenario', - 'scenario.runtime')), + packages=[ + 'scenario', + 'scenario.runtime' + ], classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", @@ -62,4 +63,7 @@ def _get_version() -> str: "Operating System :: POSIX :: Linux", ], python_requires='>=3.8', + install_requires=["asttokens", + "astunparse", + "ops"], ) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 37cbd5655..e32be164b 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -18,7 +18,7 @@ Context, Scene, State, - get_event, + event, relation, ) @@ -74,7 +74,7 @@ def dummy_state(): @pytest.fixture def start_scene(dummy_state): return Scene( - get_event("start"), + event("start"), context=Context( state=dummy_state ) diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py index b3c230888..344dafbe1 100644 --- a/tests/test_replay_local_runtime.py +++ b/tests/test_replay_local_runtime.py @@ -9,7 +9,7 @@ # your current venv, ops.model won't break as it tries to import recorder.py try: - from memo import memo + from scenario import memo except ModuleNotFoundError: from scenario.runtime.runtime import RUNTIME_MODULE sys.path.append(str(RUNTIME_MODULE.absolute())) From d5909c81930f360fd5f187a46377af088087c627 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 2 Jan 2023 15:57:06 +0100 Subject: [PATCH 014/546] fixed play_until_complete --- scenario/runtime/runtime.py | 6 +- scenario/scenario.py | 99 +++++++++++----------- tests/test_e2e/test_play_until_complete.py | 63 ++++++++++++++ 3 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 tests/test_e2e/test_play_until_complete.py diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index 805179ec2..9fb766565 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -3,7 +3,7 @@ import sys import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Type, TypeVar +from typing import TYPE_CHECKING, Type, TypeVar, Generic import yaml @@ -27,7 +27,7 @@ from ops.charm import CharmBase from ops.framework import EventBase from scenario.structs import CharmSpec, Scene - _CT = TypeVar("_CT", bound=Type["CharmType"]) + _CT = TypeVar("_CT", bound=Type[CharmType]) logger = pkg_logger.getChild("runtime") @@ -225,7 +225,7 @@ class WrappedCharm(charm_type): # type: ignore WrappedCharm.__name__ = charm_type.__name__ return WrappedCharm - def run(self, scene: "Scene") -> RuntimeRunResult[C]: + def run(self, scene: "Scene") -> RuntimeRunResult: """Executes a scene on the charm. This will set the environment up and call ops.main.main(). diff --git a/scenario/scenario.py b/scenario/scenario.py index 1843fd184..96400b502 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -52,11 +52,11 @@ def emit(self): return self.event -patch_sort_key = lambda obj: obj["path"] + obj["op"] +def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): + return sorted(patch, key=key) class PlayResult: - # TODO: expose the 'final context' or a Delta object from the PlayResult. def __init__( self, charm: "CharmBase", @@ -84,31 +84,7 @@ def delta(self): patch = jsonpatch.make_patch( asdict(self.scene_in.context), asdict(self.context_out) ).patch - return sorted(patch, key=patch_sort_key) - - def sort_patch(self, patch: List[Dict]): - return sorted(patch, key=patch_sort_key) - - -class _Builtins: - @staticmethod - def startup(leader=True): - return Scenario.from_events( - ( - ATTACH_ALL_STORAGES, - "start", - CREATE_ALL_RELATIONS, - "leader-elected" if leader else "leader-settings-changed", - "config-changed", - "install", - ) - ) - - @staticmethod - def teardown(): - return Scenario.from_events( - (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") - ) + return sort_patch(patch) class Playbook: @@ -163,8 +139,6 @@ def load(s: str) -> "Playbook": class Scenario: - builtins = _Builtins() - def __init__( self, charm_spec: CharmSpec, @@ -216,26 +190,12 @@ def _play_meta(self, event: Event, context: Context, add_to_playbook: bool = Fal return last - def run(self, scene: Scene, add_to_playbook: bool = False): - return self.play(scene, add_to_playbook=add_to_playbook) - def play( self, - obj: Union[Scene, str], - context: Context = None, + scene: Scene, add_to_playbook: bool = False, ) -> PlayResult: - scene = obj - context = context or Context() - - if isinstance(obj, str): - _event = Event(obj) - if _event.is_meta: - return self._play_meta(_event, context, add_to_playbook=add_to_playbook) - scene = Scene(_event, context) - - runtime = self._runtime - result = runtime.run(scene) + result = self._runtime.run(scene) # todo verify that if state was mutated, it was mutated # in a way that makes sense: # e.g. - charm cannot modify leadership status, etc... @@ -251,11 +211,50 @@ def play( event=result.event, ) - def play_until_complete(self): + def play_until_complete(self) -> List[PlayResult]: + """Plays every scene in the Playbook and returns a list of results.""" if not self._playbook: raise RuntimeError("playbook is empty") - with self: - for context, event in self._playbook: - ctx = self.play(event=event, context=context) - return ctx + results = [] + for scene in self._playbook: + result = self.play(scene) + results.append(result) + + return results + + +def events_to_scenes(events: typing.Sequence[Union[str, Event]]): + def _to_event(obj): + if isinstance(obj, str): + return Event(obj) + elif isinstance(obj, Event): + return obj + else: + raise TypeError(obj) + scenes = map(Scene, map(_to_event, events)) + for i, scene in enumerate(scenes): + scene.name = f"" + yield scene + + +class StartupScenario(Scenario): + def __init__(self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0"): + playbook: Playbook = Playbook(events_to_scenes(( + ATTACH_ALL_STORAGES, + "start", + CREATE_ALL_RELATIONS, + "leader-elected" if leader else "leader-settings-changed", + "config-changed", + "install", + ))) + super().__init__(charm_spec, playbook, juju_version) + + +class TeardownScenario(Scenario): + def __init__(self, charm_spec: CharmSpec, juju_version: str = "3.0.0"): + playbook: Playbook = Playbook(events_to_scenes( + (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") + )) + super().__init__(charm_spec, playbook, juju_version) + diff --git a/tests/test_e2e/test_play_until_complete.py b/tests/test_e2e/test_play_until_complete.py new file mode 100644 index 000000000..adb29b6e2 --- /dev/null +++ b/tests/test_e2e/test_play_until_complete.py @@ -0,0 +1,63 @@ +from tests.setup_tests import setup_tests + +setup_tests() # noqa & keep this on top + +from typing import Optional, Type + +import pytest +from ops.charm import CharmBase, CharmEvents +from ops.framework import EventBase, Framework + +from scenario.scenario import StartupScenario, TeardownScenario +from scenario.structs import ( + CharmSpec, +) + +CHARM_CALLED = 0 + + +@pytest.fixture(scope="function") +def mycharm(): + global CHARM_CALLED + CHARM_CALLED = 0 + + class MyCharmEvents(CharmEvents): + @classmethod + def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): + if getattr(cls, event_kind, None): + delattr(cls, event_kind) + return super().define_event(event_kind, event_type) + + class MyCharm(CharmBase): + _call = None + on = MyCharmEvents() + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + self.called = False + + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + global CHARM_CALLED + CHARM_CALLED += 1 + + if self._call: + self.called = True + self._call(event) + + return MyCharm + + +@pytest.mark.parametrize('leader', (True, False)) +def test_setup(leader, mycharm): + scenario = StartupScenario(CharmSpec(mycharm, meta={"name": "foo"}), leader=leader) + scenario.play_until_complete() + assert CHARM_CALLED == 4 + + +def test_teardown(mycharm): + scenario = TeardownScenario(CharmSpec(mycharm, meta={"name": "foo"})) + scenario.play_until_complete() + assert CHARM_CALLED == 2 From f7cc7456d6766ddd2ab742d20488250028116094 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Jan 2023 10:57:58 +0100 Subject: [PATCH 015/546] tests green --- README.md | 100 ++--- scenario/__init__.py | 4 +- scenario/logger.py | 4 +- scenario/ops_main_mock.py | 53 ++- scenario/runtime/memo.py | 61 ++- scenario/runtime/memo_tools.py | 18 +- scenario/runtime/runtime.py | 148 +++++-- .../runtime/scripts/install.py | 9 +- scenario/scenario.py | 39 +- scenario/structs.py | 34 +- scenario/version.py | 2 +- .../memo_tools_test_files/trfk-re-relate.json | 370 +++++++++++++++--- tests/test_e2e/test_play_until_complete.py | 10 +- tests/test_e2e/test_state.py | 63 ++- tests/test_memo_tools.py | 8 +- tests/test_replay_local_runtime.py | 84 ++-- tox.ini | 10 +- 17 files changed, 691 insertions(+), 326 deletions(-) rename tests/setup_tests.py => scenario/runtime/scripts/install.py (56%) diff --git a/README.md b/README.md index c4379b31c..689216ba4 100644 --- a/README.md +++ b/README.md @@ -114,41 +114,41 @@ from ops.model import ActiveStatus class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I follow') @pytest.fixture def scenario(): - return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) @pytest.fixture def start_scene(): - return Scene(event=get_event('start'), context=Context()) + return Scene(event=get_event('start'), context=Context()) def test_scenario_base(scenario, start_scene): - out = scenario.run(start_scene) - assert out.context_out.state.status.unit == ('unknown', '') + out = scenario.play(start_scene) + assert out.context_out.state.status.unit == ('unknown', '') @pytest.mark.parametrize('leader', [True, False]) def test_status_leader(scenario, start_scene, leader): - leader_scene = start_scene.copy() - leader_scene.context.state.leader = leader - - out = scenario.run(leader_scene) - if leader: - assert out.context_out.state.status.unit == ('active', 'I rule') - else: - assert out.context_out.state.status.unit == ('active', 'I follow') + leader_scene = start_scene.copy() + leader_scene.context.state.leader = leader + + out = scenario.play(leader_scene) + if leader: + assert out.context_out.state.status.unit == ('active', 'I rule') + else: + assert out.context_out.state.status.unit == ('active', 'I follow') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... @@ -158,40 +158,42 @@ An example involving relations: ```python from scenario.structs import relation + # This charm copies over remote app data to local unit data class MyCharm(CharmBase): - ... - def _on_event(self, e): - relation = self.model.relations['foo'][0] - assert relation.app.name == 'remote' - assert e.relation.data[self.unit]['abc'] == 'foo' - e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] - + ... + + def _on_event(self, e): + relation = self.model.relations['foo'][0] + assert relation.app.name == 'remote' + assert e.relation.data[self.unit]['abc'] == 'foo' + e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] + def test_relation_data(scenario, start_scene): - scene = start_scene.copy() - scene.context.state.relations = [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ] - out = scenario.run(scene) - assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} - # one could probably even do: - assert out.context_out.state.relations == [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] - # which is very idiomatic and superbly explicit. Noice. + scene = start_scene.copy() + scene.context.state.relations = [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ] + out = scenario.play(scene) + assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} + # one could probably even do: + assert out.context_out.state.relations == [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] + # which is very idiomatic and superbly explicit. Noice. ``` diff --git a/scenario/__init__.py b/scenario/__init__.py index aad9e3b67..9ccada295 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,3 +1,3 @@ -from .runtime.runtime import Runtime from .runtime.memo import memo -from .scenario import Scenario, Scene, Playbook \ No newline at end of file +from .runtime.runtime import Runtime +from .scenario import Playbook, Scenario, Scene diff --git a/scenario/logger.py b/scenario/logger.py index dc37bcd46..e12fdd893 100644 --- a/scenario/logger.py +++ b/scenario/logger.py @@ -1,5 +1,5 @@ import logging import os -logger = logging.getLogger('ops-scenario') -logger.setLevel(os.getenv('OPS_SCENARIO_LOGGING', 'WARNING')) +logger = logging.getLogger("ops-scenario") +logger.setLevel(os.getenv("OPS_SCENARIO_LOGGING", "WARNING")) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 1ca266b67..a39a804ca 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -18,15 +18,20 @@ if TYPE_CHECKING: from ops.charm import CharmBase, EventBase -from ops.main import _get_charm_dir, _Dispatcher, _should_use_controller_storage, _get_event_args from ops.framework import Handle +from ops.main import ( + _Dispatcher, + _get_charm_dir, + _get_event_args, + _should_use_controller_storage, +) -CHARM_STATE_FILE = '.unit-state.db' +CHARM_STATE_FILE = ".unit-state.db" logger = logging.getLogger() -def patched_bound_event_emit(self, *args: Any, **kwargs: Any) -> 'EventBase': +def patched_bound_event_emit(self, *args: Any, **kwargs: Any) -> "EventBase": """Emit event to all registered observers. The current storage state is committed before and after each observer is notified. @@ -44,7 +49,7 @@ def patched_bound_event_emit(self, *args: Any, **kwargs: Any) -> 'EventBase': framework.BoundEvent.emit = patched_bound_event_emit -def _emit_charm_event(charm: 'CharmBase', event_name: str) -> Optional['EventBase']: +def _emit_charm_event(charm: "CharmBase", event_name: str) -> Optional["EventBase"]: """Emits a charm event based on a Juju event name. Args: @@ -61,13 +66,16 @@ def _emit_charm_event(charm: 'CharmBase', event_name: str) -> Optional['EventBas # not error out or try to emit it. This is to support rollbacks. if event_to_emit is not None: args, kwargs = _get_event_args(charm, event_to_emit) - logger.debug('Emitting Juju event %s.', event_name) + logger.debug("Emitting Juju event %s.", event_name) return event_to_emit.emit(*args, **kwargs) -def main(charm_class: Type[ops.charm.CharmBase], - use_juju_for_storage: Optional[bool] = None - ) -> Optional[Tuple['CharmBase', Optional['EventBase']]]: +def main( + charm_class: Type[ops.charm.CharmBase], + use_juju_for_storage: Optional[bool] = None, + pre_event=None, + post_event=None, +) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: """Setup the charm and dispatch the observed event. The event name is based on the way this executable was called (argv[0]). @@ -82,15 +90,17 @@ def main(charm_class: Type[ops.charm.CharmBase], charm_dir = _get_charm_dir() model_backend = ops.model._ModelBackend() # pyright: reportPrivateUsage=false - debug = ('JUJU_DEBUG' in os.environ) + debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) - logger.debug("Operator Framework %s up and running.", ops.__version__) # type:ignore + logger.debug( + "Operator Framework %s up and running.", ops.__version__ + ) # type:ignore dispatcher = _Dispatcher(charm_dir) dispatcher.run_any_legacy_hook() - metadata = (charm_dir / 'metadata.yaml').read_text() - actions_meta = charm_dir / 'actions.yaml' + metadata = (charm_dir / "metadata.yaml").read_text() + actions_meta = charm_dir / "actions.yaml" if actions_meta.exists(): actions_metadata = actions_meta.read_text() else: @@ -103,7 +113,7 @@ def main(charm_class: Type[ops.charm.CharmBase], if use_juju_for_storage and not ops.storage.juju_backend_available(): # raise an exception; the charm is broken and needs fixing. - msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it' + msg = "charm set use_juju_for_storage=True, but Juju version {} does not support it" raise RuntimeError(msg.format(JujuVersion.from_environ())) if use_juju_for_storage is None: @@ -115,9 +125,11 @@ def main(charm_class: Type[ops.charm.CharmBase], # Though we eventually expect that juju will run collect-metrics in a # non-restricted context. Once we can determine that we are running collect-metrics # in a non-restricted context, we should fire the event as normal. - logger.debug('"%s" is not supported when using Juju for storage\n' - 'see: https://github.com/canonical/operator/issues/348', - dispatcher.event_name) + logger.debug( + '"%s" is not supported when using Juju for storage\n' + "see: https://github.com/canonical/operator/issues/348", + dispatcher.event_name, + ) # Note that we don't exit nonzero, because that would cause Juju to rerun the hook return store = ops.storage.JujuStorage() @@ -132,7 +144,8 @@ def main(charm_class: Type[ops.charm.CharmBase], except TypeError: msg = ( "the second argument, 'key', has been deprecated and will be " - "removed after the 0.7 release") + "removed after the 0.7 release" + ) warnings.warn(msg, DeprecationWarning) charm = charm_class(framework, None) else: @@ -148,8 +161,14 @@ def main(charm_class: Type[ops.charm.CharmBase], if not dispatcher.is_restricted_context(): framework.reemit() + if pre_event: + pre_event(charm) + event = _emit_charm_event(charm, dispatcher.event_name) + if post_event: + post_event(charm) + framework.commit() finally: framework.close() diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 852e2ef3d..80526c0df 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -35,7 +35,7 @@ SUPPORTED_SERIALIZERS_LIST = ["pickle", "json", "io", "PebblePush"] -MemoModes = Literal["record", "replay"] +MemoModes = Literal["record", "replay", "isolated"] _CachingPolicy = Literal["strict", "loose"] # notify just once of what mode we're running in @@ -70,6 +70,9 @@ def _load_memo_mode() -> MemoModes: elif val == "replay": if not _PRINTED_MODE: print("MEMO: replaying") + elif val == "isolated": + if not _PRINTED_MODE: + print("MEMO: replaying (isolated mode)") else: warnings.warn( f"[ERROR]: MEMO: invalid value ({val!r}). Defaulting to `record`." @@ -88,6 +91,21 @@ def _is_bound_method(fn: Any): return False +def _call_repr( + fn: Callable, + args, + kwargs, +): + """Str repr of memoized function call address.""" + fn_name = getattr(fn, "__name__", str(fn)) + if _self := getattr(fn, "__self__", None): + # it's a method + fn_repr = type(_self).__name__ + fn_name + else: + fn_repr = fn_name + return f"{fn_repr}(*{args}, **{kwargs})" + + def _log_memo( fn: Callable, args, @@ -107,16 +125,8 @@ def _log_memo( trimmed = "[...]" if len(output_repr) > 100 else "" hit = "hit" if cache_hit else "miss" - fn_name = getattr(fn, "__name__", str(fn)) - - if _self := getattr(fn, "__self__", None): - # it's a method - fn_repr = type(_self).__name__ + fn_name - else: - fn_repr = fn_name - - log_fn( - f"@memo[{hit}]: replaying {fn_repr}(*{args}, **{kwargs})" + return log_fn( + f"@memo[{hit}]: replaying {_call_repr(fn, args, kwargs)}" f"\n\t --> {trim}{trimmed}" ) @@ -167,7 +177,7 @@ def wrapper(*args, **kwargs): input_serializer, output_serializer = _check_serializer(serializer) def _load(obj: str, method: SUPPORTED_SERIALIZERS): - if log_on_replay and _MEMO_MODE == "replay": + if log_on_replay and _MEMO_MODE in ["replay", "isolated"]: _log_memo(fn, args, kwargs, recorded_output, cache_hit=True) if method == "pickle": byt = base64.b64decode(obj) @@ -227,6 +237,11 @@ def _dump(obj: Any, method: SUPPORTED_SERIALIZERS, output_=None): def propagate(): """Make the real wrapped call.""" + if _MEMO_MODE == "isolated": + raise RuntimeError( + f"Attempted propagation in isolated mode: " + f"{_call_repr(fn, args, kwargs)}" + ) if _MEMO_MODE == "replay" and log_on_replay: _log_memo(fn, args, kwargs, "", cache_hit=False) @@ -270,9 +285,13 @@ def load_from_state( memo_args = list(memoizable_args) database = os.environ.get(MEMO_DATABASE_NAME_KEY, DEFAULT_DB_NAME) + if not Path(database).exists(): + raise RuntimeError( + f"Database not found at {database}. " + f"@memo requires a scene to be set." + ) + with event_db(database) as data: - if not data.scenes: - raise RuntimeError("No scenes: cannot memoize.") idx = os.environ.get(MEMO_REPLAY_INDEX_KEY, None) strict_caching = _check_caching_policy(caching_policy) == "strict" @@ -309,7 +328,7 @@ def load_from_state( return _load(serialized_output, "io") return output - elif _MEMO_MODE == "replay": + elif _MEMO_MODE in ["replay", "isolated"]: if idx is None: raise RuntimeError( f"provide a {MEMO_REPLAY_INDEX_KEY} envvar" @@ -388,7 +407,7 @@ def load_from_state( f"this path must have diverged. Propagating call..." ) return load_from_state( - data.scenes[idx].context, + data.scenes[idx], (memo_name, memoizable_args, kwargs), ) @@ -402,7 +421,7 @@ def load_from_state( f"This path has diverged. Propagating call..." ) return load_from_state( - data.scenes[idx].context, + data.scenes[idx], (memo_name, memoizable_args, kwargs), ) @@ -427,7 +446,7 @@ def load_from_state( f"This path has diverged." ) return load_from_state( - data.scenes[idx].context, + data.scenes[idx], (memo_name, memoizable_args, kwargs), ) @@ -598,7 +617,7 @@ class RelationMeta: interface: str relation_id: int remote_app_name: str - remote_unit_ids: List[int] = field(default_factory=lambda: list((0, ))) + remote_unit_ids: List[int] = field(default_factory=lambda: list((0,))) # local limit limit: int = 1 @@ -694,7 +713,7 @@ class Context: def from_dict(obj: dict): return Context( memos={name: Memo(**content) for name, content in obj["memos"].items()}, - state=State.from_dict(obj["state"]), + state=State.from_dict(obj.get("state")), ) @@ -758,7 +777,7 @@ def setup(file=DEFAULT_DB_NAME): event = _record_current_event(file) print(f"Captured event: {event.name}.") - if _MEMO_MODE == "replay": + if _MEMO_MODE in ["replay", "isolated"]: _reset_replay_cursors() print(f"Replaying: reset replay cursors.") diff --git a/scenario/runtime/memo_tools.py b/scenario/runtime/memo_tools.py index b4aee3047..00a4d760d 100644 --- a/scenario/runtime/memo_tools.py +++ b/scenario/runtime/memo_tools.py @@ -97,21 +97,25 @@ def as_token(self, default_namespace: str) -> Token: } } +IMPORT_BLOCK = "from scenario import memo" +RUNTIME_PATH = str(Path(__file__).parent.absolute()) -memo_import_block = dedent( +MEMO_IMPORT_BLOCK = dedent( """# ==== block added by scenario.runtime.memo_tools === +import sys +sys.path.append({runtime_path!r}) # add /path/to/scenario/runtime to the PATH try: - from scenario import memo + {import_block} except ModuleNotFoundError as e: - msg = "recorder not installed. " \ + msg = "scenario not installed. " \ "This can happen if you're playing with Runtime in a local venv. " \ "In that case all you have to do is ensure that the PYTHONPATH is patched to include the path to " \ - "recorder.py before loading this module. " \ + "scenario before loading this module. " \ "Tread carefully." raise RuntimeError(msg) from e # ==== end block === """ -) +).format(import_block=IMPORT_BLOCK, runtime_path=str(RUNTIME_PATH)) def inject_memoizer(source_file: Path, decorate: Dict[str, Dict[str, DecorateSpec]]): @@ -151,8 +155,8 @@ def _should_decorate_method(token: ast.AST): method.decorator_list.append(spec_token) unparsed_source = unparse(atok) - if "from recorder import memo" not in unparsed_source: + if IMPORT_BLOCK not in unparsed_source: # only add the import if necessary: - unparsed_source = memo_import_block + unparsed_source + unparsed_source = MEMO_IMPORT_BLOCK + unparsed_source source_file.write_text(unparsed_source) diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index 9fb766565..6b69720a3 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -3,12 +3,12 @@ import sys import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Type, TypeVar, Generic +from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar, Union import yaml -from scenario.logger import logger as pkg_logger from scenario.event_db import TemporaryEventDB +from scenario.logger import logger as pkg_logger from scenario.runtime.memo import ( MEMO_DATABASE_NAME_KEY, MEMO_MODE_KEY, @@ -16,23 +16,27 @@ USE_STATE_KEY, ) from scenario.runtime.memo import Event as MemoEvent +from scenario.runtime.memo import MemoModes from scenario.runtime.memo import Scene as MemoScene -from scenario.runtime.memo import event_db -from scenario.runtime.memo_tools import DECORATE_MODEL, DECORATE_PEBBLE, inject_memoizer +from scenario.runtime.memo import _reset_replay_cursors, event_db +from scenario.runtime.memo_tools import ( + DECORATE_MODEL, + DECORATE_PEBBLE, + IMPORT_BLOCK, + inject_memoizer, +) if TYPE_CHECKING: from ops.charm import CharmBase from ops.framework import EventBase from ops.testing import CharmType - from ops.charm import CharmBase - from ops.framework import EventBase + from scenario.structs import CharmSpec, Scene - _CT = TypeVar("_CT", bound=Type[CharmType]) + _CT = TypeVar("_CT", bound=Type[CharmType]) logger = pkg_logger.getChild("runtime") - RUNTIME_MODULE = Path(__file__).parent @@ -49,17 +53,23 @@ class Runtime: This object bridges a local environment and a charm artifact. """ - def __init__(self, charm_spec: "CharmSpec", juju_version: str = "3.0.0"): + def __init__( + self, + charm_spec: "CharmSpec", + juju_version: str = "3.0.0", + event_db_path: Optional[Union[Path, str]] = None, + ): + self._event_db_path = Path(event_db_path) if event_db_path else None self._charm_spec = charm_spec self._juju_version = juju_version self._charm_type = charm_spec.charm_type - # todo consider cleaning up venv on __delete__, but ideally you should be + # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -77,7 +87,7 @@ def from_local_file( ) from e my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] - return Runtime(my_charm_type) + return Runtime(CharmSpec(my_charm_type)) # TODO add meta, options,... @staticmethod def install(force=False): @@ -126,13 +136,13 @@ def _is_installed(): except RuntimeError as e: # we rewrite ops.model to import memo. # We try to import ops from here --> circular import. - if e.args[0].startswith('recorder not installed'): + if e.args[0].startswith("scenario not installed"): return True raise e model_path = Path(model.__file__) - if "from scenario import memo" not in model_path.read_text(): + if IMPORT_BLOCK not in model_path.read_text(): logger.error( f"ops.model ({model_path} does not seem to import runtime.memo.memo" ) @@ -155,6 +165,7 @@ def _patch_logger(*args, **kwargs): pass from scenario import ops_main_mock + ops_main_mock.setup_root_logging = _patch_logger def _cleanup_env(self, env): @@ -182,7 +193,7 @@ def _get_event_env(self, scene: "Scene", charm_root: Path): } def _drop_meta(self, charm_root: Path): - logger.debug("Dropping metadata.yaml and actions.yaml...") + logger.debug("Dropping metadata.yaml, config.yaml, actions.yaml...") (charm_root / "metadata.yaml").write_text(yaml.safe_dump(self._charm_spec.meta)) if self._charm_spec.actions: (charm_root / "actions.yaml").write_text( @@ -193,7 +204,9 @@ def _drop_meta(self, charm_root: Path): yaml.safe_dump(self._charm_spec.config) ) - def _get_runtime_env(self, scene_idx: int, db_path: Path): + def _get_runtime_env( + self, scene_idx: int, db_path: Path, mode: MemoModes = "replay" + ): env = {} env.update( { @@ -203,7 +216,7 @@ def _get_runtime_env(self, scene_idx: int, db_path: Path): } ) sys.path.append(str(RUNTIME_MODULE.absolute())) - env[MEMO_MODE_KEY] = "replay" + env[MEMO_MODE_KEY] = mode os.environ.update(env) # todo consider subprocess return env @@ -225,8 +238,13 @@ class WrappedCharm(charm_type): # type: ignore WrappedCharm.__name__ = charm_type.__name__ return WrappedCharm - def run(self, scene: "Scene") -> RuntimeRunResult: - """Executes a scene on the charm. + def play( + self, + scene: "Scene", + pre_event: Optional[Callable[["_CT"], None]] = None, + post_event: Optional[Callable[["_CT"], None]] = None, + ) -> RuntimeRunResult: + """Plays a scene on the charm. This will set the environment up and call ops.main.main(). After that it's up to ops. @@ -240,16 +258,14 @@ def run(self, scene: "Scene") -> RuntimeRunResult: f"Preparing to fire {scene.event.name} on {self._charm_type.__name__}" ) - logger.info(" - clearing env") - logger.info(" - preparing env") with tempfile.TemporaryDirectory() as charm_root: charm_root_path = Path(charm_root) env = self._get_event_env(scene, charm_root_path) + self._drop_meta(charm_root_path) memo_scene = self._scene_to_memo_scene(scene, env) with TemporaryEventDB(memo_scene, charm_root) as db_path: - self._drop_meta(charm_root_path) env.update(self._get_runtime_env(0, db_path)) logger.info(" - redirecting root logging") @@ -267,7 +283,11 @@ def run(self, scene: "Scene") -> RuntimeRunResult: logger.info(" - Entering ops.main (mocked).") try: - charm, event = main(self._wrap(self._charm_type)) + charm, event = main( + self._wrap(self._charm_type), + pre_event=pre_event, + post_event=post_event, + ) except Exception as e: raise RuntimeError( f"Uncaught error in operator/charm code: {e}." @@ -275,6 +295,7 @@ def run(self, scene: "Scene") -> RuntimeRunResult: finally: logger.info(" - Exited ops.main.") + logger.info(" - clearing env") self._cleanup_env(env) with event_db(db_path) as data: @@ -282,6 +303,85 @@ def run(self, scene: "Scene") -> RuntimeRunResult: return RuntimeRunResult(charm, scene_out, event) + def replay( + self, + index: int, + pre_event: Optional[Callable[["_CT"], None]] = None, + post_event: Optional[Callable[["_CT"], None]] = None, + ) -> RuntimeRunResult: + """Replays a stored scene by index. + + This requires having a statically defined event DB. + """ + if not Runtime._is_installed(): + raise RuntimeError( + "Runtime is not installed. Call `runtime.install()` (and read the fine prints)." + ) + if not self._event_db_path: + raise ValueError( + "No event_db_path set. Pass one to the Runtime constructor." + ) + + logger.info(f"Preparing to fire scene #{index} on {self._charm_type.__name__}") + logger.info(" - redirecting root logging") + self._redirect_root_logger() + + logger.info(" - setting up temporary charm root") + with tempfile.TemporaryDirectory() as charm_root: + charm_root_path = Path(charm_root).absolute() + self._drop_meta(charm_root_path) + + # extract the env from the scene + with event_db(self._event_db_path) as data: + logger.info( + f" - resetting scene {index} replay cursor." + ) # just in case + _reset_replay_cursors(self._event_db_path, index) + + logger.info(" - preparing env") + scene = data.scenes[index] + env = dict(scene.event.env) + # declare the charm root for ops to pick up + env["JUJU_CHARM_DIR"] = str(charm_root_path) + + # inject the memo envvars + env.update( + self._get_runtime_env( + index, + self._event_db_path, + # set memo to isolated mode so that we raise + # instead of propagating: it'd be useless + # anyway in most cases TODO generalize? + mode="isolated", + ) + ) + os.environ.update(env) + + # we don't import from ops because we need some extra return statements. + # see https://github.com/canonical/operator/pull/862 + # from ops.main import main + from scenario.ops_main_mock import main + + logger.info(" - Entering ops.main (mocked).") + + try: + charm, event = main( + self._wrap(self._charm_type), + pre_event=pre_event, + post_event=post_event, + ) + except Exception as e: + raise RuntimeError( + f"Uncaught error in operator/charm code: {e}." + ) from e + finally: + logger.info(" - Exited ops.main.") + + logger.info(" - cleaning up env") + self._cleanup_env(env) + + return RuntimeRunResult(charm, scene, event) + if __name__ == "__main__": # install Runtime **in your current venv** so that all diff --git a/tests/setup_tests.py b/scenario/runtime/scripts/install.py similarity index 56% rename from tests/setup_tests.py rename to scenario/runtime/scripts/install.py index 6ab2bb33b..f842bc497 100644 --- a/tests/setup_tests.py +++ b/scenario/runtime/scripts/install.py @@ -1,11 +1,16 @@ +#! /bin/python3 import sys from pathlib import Path -def setup_tests(): - runtime_path = Path(__file__).parent.parent / "scenario" / "runtime" +def install_runtime(): + runtime_path = Path(__file__).parent.parent.parent.parent sys.path.append(str(runtime_path)) # allow 'import memo' from scenario import Runtime Runtime.install(force=False) # ensure Runtime is installed + + +if __name__ == "__main__": + install_runtime() diff --git a/scenario/scenario.py b/scenario/scenario.py index 96400b502..327bbb9aa 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -3,7 +3,6 @@ from dataclasses import asdict from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union -from scenario.logger import logger as pkg_logger from scenario import Runtime from scenario.consts import ( ATTACH_ALL_STORAGES, @@ -12,9 +11,9 @@ DETACH_ALL_STORAGES, META_EVENTS, ) +from scenario.logger import logger as pkg_logger from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene - if typing.TYPE_CHECKING: from ops.charm import CharmBase from ops.framework import BoundEvent, EventBase @@ -195,7 +194,7 @@ def play( scene: Scene, add_to_playbook: bool = False, ) -> PlayResult: - result = self._runtime.run(scene) + result = self._runtime.play(scene) # todo verify that if state was mutated, it was mutated # in a way that makes sense: # e.g. - charm cannot modify leadership status, etc... @@ -232,6 +231,7 @@ def _to_event(obj): return obj else: raise TypeError(obj) + scenes = map(Scene, map(_to_event, events)) for i, scene in enumerate(scenes): scene.name = f"" @@ -239,22 +239,29 @@ def _to_event(obj): class StartupScenario(Scenario): - def __init__(self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0"): - playbook: Playbook = Playbook(events_to_scenes(( - ATTACH_ALL_STORAGES, - "start", - CREATE_ALL_RELATIONS, - "leader-elected" if leader else "leader-settings-changed", - "config-changed", - "install", - ))) + def __init__( + self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0" + ): + playbook: Playbook = Playbook( + events_to_scenes( + ( + ATTACH_ALL_STORAGES, + "start", + CREATE_ALL_RELATIONS, + "leader-elected" if leader else "leader-settings-changed", + "config-changed", + "install", + ) + ) + ) super().__init__(charm_spec, playbook, juju_version) class TeardownScenario(Scenario): def __init__(self, charm_spec: CharmSpec, juju_version: str = "3.0.0"): - playbook: Playbook = Playbook(events_to_scenes( - (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") - )) + playbook: Playbook = Playbook( + events_to_scenes( + (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") + ) + ) super().__init__(charm_spec, playbook, juju_version) - diff --git a/scenario/structs.py b/scenario/structs.py index f6ff7bc51..4b956beec 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,7 +1,7 @@ import dataclasses import typing from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional, Tuple, Type, List +from typing import Any, Dict, List, Literal, Optional, Tuple, Type if typing.TYPE_CHECKING: try: @@ -56,15 +56,15 @@ class RelationSpec(memo.RelationSpec, DCBase): def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[int] = (0,), - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[int] = (0,), + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" metadata = RelationMeta( @@ -83,13 +83,13 @@ def relation( def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> memo.Network: """Construct a network object.""" return memo.Network( diff --git a/scenario/version.py b/scenario/version.py index c128876ac..5f1541084 100644 --- a/scenario/version.py +++ b/scenario/version.py @@ -1 +1 @@ -version = "0.1" \ No newline at end of file +version = "0.1" diff --git a/tests/memo_tools_test_files/trfk-re-relate.json b/tests/memo_tools_test_files/trfk-re-relate.json index 80a24b8f2..5c5e42960 100644 --- a/tests/memo_tools_test_files/trfk-re-relate.json +++ b/tests/memo_tools_test_files/trfk-re-relate.json @@ -88,6 +88,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -190,6 +213,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -347,6 +393,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -504,6 +573,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -597,18 +689,12 @@ ] }, "_ModelBackend.relation_get": { - "calls": [ - [ - "[[3, \"prom/1\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"trfk\", true], {}]", - "{}" - ] - ], + "calls": { + "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"trfk\", true], {}]": "{}" + }, "cursor": 0, - "caching_policy": "strict", + "caching_policy": "loose", "serializer": [ "json", "json" @@ -729,6 +815,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -822,18 +931,12 @@ ] }, "_ModelBackend.relation_get": { - "calls": [ - [ - "[[3, \"prom/1\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"trfk\", true], {}]", - "{}" - ] - ], - "cursor": 2, - "caching_policy": "strict", + "calls": { + "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"trfk\", true], {}]": "{}" + }, + "cursor": 0, + "caching_policy": "loose", "serializer": [ "json", "json" @@ -915,7 +1018,7 @@ "true" ] ], - "cursor": 0, + "cursor": 4, "caching_policy": "strict", "serializer": [ "json", @@ -954,6 +1057,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -1047,18 +1173,12 @@ ] }, "_ModelBackend.relation_get": { - "calls": [ - [ - "[[3, \"prom/1\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"trfk\", true], {}]", - "{}" - ] - ], + "calls": { + "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"trfk\", true], {}]": "{}" + }, "cursor": 0, - "caching_policy": "strict", + "caching_policy": "loose", "serializer": [ "json", "json" @@ -1179,6 +1299,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -1273,22 +1416,13 @@ ] }, "_ModelBackend.relation_get": { - "calls": [ - [ - "[[3, \"prom/1\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"prom/0\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"ingress-address\": \"10.152.183.124\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"trfk\", true], {}]", - "{}" - ] - ], + "calls": { + "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"prom/0\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"trfk\", true], {}]": "{}" + }, "cursor": 3, - "caching_policy": "strict", + "caching_policy": "loose", "serializer": [ "json", "json" @@ -1409,6 +1543,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -1502,22 +1659,13 @@ ] }, "_ModelBackend.relation_get": { - "calls": [ - [ - "[[3, \"prom/1\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"prom/0\", false], {}]", - "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}" - ], - [ - "[[3, \"trfk\", true], {}]", - "{}" - ] - ], + "calls": { + "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"prom/0\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", + "[[3, \"trfk\", true], {}]": "{}" + }, "cursor": 0, - "caching_policy": "strict", + "caching_policy": "loose", "serializer": [ "json", "json" @@ -1654,6 +1802,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -1745,6 +1916,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -1847,6 +2041,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } }, @@ -2004,6 +2221,29 @@ "json" ] } + }, + "state": { + "config": null, + "relations": [], + "networks": [], + "containers": [], + "status": { + "app": [ + "unknown", + "" + ], + "unit": [ + "unknown", + "" + ], + "app_version": "" + }, + "leader": false, + "model": { + "name": "LkRovLn9ze9sHbOQSGAs", + "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" + }, + "juju_log": [] } } } diff --git a/tests/test_e2e/test_play_until_complete.py b/tests/test_e2e/test_play_until_complete.py index adb29b6e2..92f07dbf2 100644 --- a/tests/test_e2e/test_play_until_complete.py +++ b/tests/test_e2e/test_play_until_complete.py @@ -1,7 +1,3 @@ -from tests.setup_tests import setup_tests - -setup_tests() # noqa & keep this on top - from typing import Optional, Type import pytest @@ -9,9 +5,7 @@ from ops.framework import EventBase, Framework from scenario.scenario import StartupScenario, TeardownScenario -from scenario.structs import ( - CharmSpec, -) +from scenario.structs import CharmSpec CHARM_CALLED = 0 @@ -50,7 +44,7 @@ def _on_event(self, event): return MyCharm -@pytest.mark.parametrize('leader', (True, False)) +@pytest.mark.parametrize("leader", (True, False)) def test_setup(leader, mycharm): scenario = StartupScenario(CharmSpec(mycharm, meta={"name": "foo"}), leader=leader) scenario.play_until_complete() diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index e32be164b..110a31093 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,9 +1,4 @@ from dataclasses import asdict - -from tests.setup_tests import setup_tests - -setup_tests() # noqa & keep this on top - from typing import Optional, Type import pytest @@ -11,7 +6,7 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.scenario import Scenario +from scenario.scenario import Scenario, sort_patch from scenario.structs import ( CharmSpec, ContainerSpec, @@ -22,6 +17,13 @@ relation, ) +# from tests.setup_tests import setup_tests +# +# setup_tests() # noqa & keep this on top + + + + CUSTOM_EVT_SUFFIXES = { "relation_created", "relation_joined", @@ -65,26 +67,18 @@ def _on_event(self, event): @pytest.fixture def dummy_state(): - return State( - config={"foo": "bar"}, - leader=True - ) + return State(config={"foo": "bar"}, leader=True) @pytest.fixture def start_scene(dummy_state): - return Scene( - event("start"), - context=Context( - state=dummy_state - ) - ) + return Scene(event("start"), context=Context(state=dummy_state)) def test_bare_event(start_scene, mycharm): mycharm._call = lambda *_: True scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - out = scenario.run(start_scene) + out = scenario.play(start_scene) assert isinstance(out.charm, mycharm) assert out.charm.called @@ -99,7 +93,7 @@ def call(charm, _): mycharm._call = call scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - scenario.run(start_scene) + scenario.play(start_scene) def test_status_setting(start_scene, mycharm): @@ -110,11 +104,11 @@ def call(charm: CharmBase, _): mycharm._call = call scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - out = scenario.run(start_scene) + out = scenario.play(start_scene) assert out.context_out.state.status.unit == ("active", "foo test") assert out.context_out.state.status.app == ("waiting", "foo barz") assert out.context_out.state.status.app_version == "" - assert out.delta() == out.sort_patch( + assert out.delta() == sort_patch( [ { "op": "replace", @@ -150,7 +144,7 @@ def call(charm: CharmBase, _): ) scene = start_scene.copy() scene.context.state.containers = (ContainerSpec(name="foo", can_connect=connect),) - scenario.run(scene) + scenario.play(scene) def test_relation_get(start_scene: Scene, mycharm): @@ -184,7 +178,7 @@ def call(charm: CharmBase, _): local_unit_data={"c": "d"}, ), ] - scenario.run(scene) + scenario.play(scene) def test_relation_set(start_scene: Scene, mycharm): @@ -219,22 +213,21 @@ def call(charm: CharmBase, _): local_unit_data={}, ) ] - out = scenario.run(scene) - - assert asdict(out.context_out.state.relations[0]) == \ - asdict( - relation( - endpoint="foo", - interface="bar", - remote_unit_ids=[1, 4], - local_app_data={"a": "b"}, - local_unit_data={"c": "d"}, - ) - ) + out = scenario.play(scene) + + assert asdict(out.context_out.state.relations[0]) == asdict( + relation( + endpoint="foo", + interface="bar", + remote_unit_ids=[1, 4], + local_app_data={"a": "b"}, + local_unit_data={"c": "d"}, + ) + ) assert out.context_out.state.relations[0].local_app_data == {"a": "b"} assert out.context_out.state.relations[0].local_unit_data == {"c": "d"} - assert out.delta() == out.sort_patch( + assert out.delta() == sort_patch( [ {"op": "add", "path": "/state/relations/0/local_app_data/a", "value": "b"}, {"op": "add", "path": "/state/relations/0/local_unit_data/c", "value": "d"}, diff --git a/tests/test_memo_tools.py b/tests/test_memo_tools.py index 9f351f4dd..53e844796 100644 --- a/tests/test_memo_tools.py +++ b/tests/test_memo_tools.py @@ -19,7 +19,7 @@ event_db, memo, ) -from scenario.runtime.memo_tools import DecorateSpec, inject_memoizer, memo_import_block +from scenario.runtime.memo_tools import MEMO_IMPORT_BLOCK, DecorateSpec, inject_memoizer # we always replay the last event in the default test env. os.environ["MEMO_REPLAY_IDX"] = "-1" @@ -45,7 +45,7 @@ def baz(self, *args, **kwargs): return str(random.random()) """ -expected_decorated_source = f"""{memo_import_block} +expected_decorated_source = f"""{MEMO_IMPORT_BLOCK} import random class _ModelBackend(): @@ -191,9 +191,7 @@ def my_fn(*args, retval=None, **kwargs): def _catch_log_call(_, *args, **kwargs): caught_calls.append((args, kwargs)) - with patch( - "jhack.utils.event_recorder.recorder._log_memo", new=_catch_log_call - ): + with patch("scenario.runtime.memo._log_memo", new=_catch_log_call): assert my_fn(10, retval=10, foo="bar") == 20 assert my_fn(10, retval=11, foo="baz") == 21 assert my_fn(11, retval=10, foo="baq", a="b") == 22 diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py index 344dafbe1..e243a04e3 100644 --- a/tests/test_replay_local_runtime.py +++ b/tests/test_replay_local_runtime.py @@ -5,6 +5,8 @@ import pytest +from scenario.structs import CharmSpec + # keep this block before `ops` imports. This ensures that if you've called Runtime.install() on # your current venv, ops.model won't break as it tries to import recorder.py @@ -12,6 +14,7 @@ from scenario import memo except ModuleNotFoundError: from scenario.runtime.runtime import RUNTIME_MODULE + sys.path.append(str(RUNTIME_MODULE.absolute())) from ops.charm import CharmBase, CharmEvents @@ -69,18 +72,21 @@ def _catchall(self, e): ), ) def test_run(evt_idx, expected_name): - charm = charm_type() runtime = Runtime( - charm, - meta={ - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - }, - local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", - install=True, + CharmSpec( + charm_type(), + meta={ + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + }, + ), + event_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", ) - charm, scene = runtime.run(evt_idx) + result = runtime.replay(evt_idx) + charm = result.charm + scene = result.scene + assert charm.unit.name == "trfk/0" assert charm.model.name == "foo" assert ( @@ -89,25 +95,26 @@ def test_run(evt_idx, expected_name): def test_relation_data(): - charm = charm_type() runtime = Runtime( - charm, - meta={ - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - }, - local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", + CharmSpec( + charm_type(), + meta={ + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + }, + ), + event_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", ) - charm, scene = runtime.run(5) # ipu-relation-changed - assert scene.event.name == "ingress-per-unit-relation-changed" + def pre_event(charm): + assert not charm._event - rel = charm.model.relations["ingress-per-unit"][0] - - for _ in range(2): - # the order in which we call the hook tools should not matter because + def post_event(charm): + rel = charm.model.relations["ingress-per-unit"][0] + # the [order in which/number of times] we call the hook tools should not matter because # relation-get is cached in 'loose' mode! yay! _ = rel.data[charm.app] + _ = rel.data[charm.app] remote_unit_data = rel.data[list(rel.units)[0]] assert remote_unit_data["host"] == "prom-1.prom-endpoints.foo.svc.cluster.local" @@ -117,34 +124,11 @@ def test_relation_data(): local_app_data = rel.data[charm.app] assert local_app_data == {} + assert charm._event - -def test_local_run_loose(): - runtime = Runtime( - charm_type(), - meta={ - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - }, - local_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", - ) - charm, scene = runtime.run(5) # ipu-relation-changed + result = runtime.replay( + 5, pre_event=pre_event, post_event=post_event + ) # ipu-relation-changed + scene = result.scene assert scene.event.name == "ingress-per-unit-relation-changed" - - rel = charm.model.relations["ingress-per-unit"][0] - - # fixme: we need to access the data in the same ORDER in which we did before. - # for relation-get, it should be safe to ignore the memo ordering, - # since the data is frozen for the hook duration. - # actually it should be fine for most hook tools, except leader and status. - # pebble is a different story. - - remote_unit_data = rel.data[list(rel.units)[0]] - assert remote_unit_data["host"] == "prom-1.prom-endpoints.foo.svc.cluster.local" - assert remote_unit_data["port"] == "9090" - assert remote_unit_data["model"] == "foo" - assert remote_unit_data["name"] == "prom/1" - - local_app_data = rel.data[charm.app] - assert local_app_data == {} diff --git a/tox.ini b/tox.ini index cc165c6b2..176cf08a7 100644 --- a/tox.ini +++ b/tox.ini @@ -4,16 +4,16 @@ [tox] skipsdist=True skip_missing_interpreters = True -envlist = e2e lint +envlist = integration lint [vars] -src_path = {toxinidir}/src +src_path = {toxinidir}/scenario tst_path = {toxinidir}/tests -[testenv:e2e] -description = End to end tests +[testenv:integration] +description = integration tests deps = coverage[toml] pytest @@ -22,7 +22,7 @@ deps = commands = coverage run \ --source={[vars]src_path} \ - -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/test_e2e + -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} coverage report From 966d345fb59a707fd1f2cb405381198d100845c2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Jan 2023 11:16:40 +0100 Subject: [PATCH 016/546] readme updates --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 689216ba4..f4b1f7b31 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ I like metaphors, so here we go: - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed)) - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something to a book (pebble.push). + # Core concepts not as a metaphor Each scene maps to a single event. The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the context in which they occur. @@ -52,7 +53,7 @@ With that, we can write the simplest possible scenario test: ```python from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context +from scenario.structs import CharmSpec, event, Context from ops.charm import CharmBase @@ -62,7 +63,7 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run(Scene(event=get_event('start'), context=Context())) + out = scenario.play(Scene(event=event('start'), context=Context())) assert out.context_out.state.status.unit == ('unknown', '') ``` @@ -71,7 +72,7 @@ Our charm sets a special state if it has leadership on 'start': ```python from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context, State +from scenario.structs import CharmSpec, event, Context, State from ops.charm import CharmBase from ops.model import ActiveStatus @@ -87,15 +88,15 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run(Scene(event=get_event('start'), context=Context())) + out = scenario.play(Scene(event=event('start'), context=Context())) assert out.context_out.state.status.unit == ('unknown', '') def test_status_leader(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.run( + out = scenario.play( Scene( - event=get_event('start'), + event=event('start'), context=Context( state=State(leader=True) ))) @@ -108,7 +109,7 @@ concisely (and parametrically) as: ```python import pytest from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, get_event, Context +from scenario.structs import CharmSpec, event, Context from ops.charm import CharmBase from ops.model import ActiveStatus @@ -131,7 +132,7 @@ def scenario(): @pytest.fixture def start_scene(): - return Scene(event=get_event('start'), context=Context()) + return Scene(event=event('start'), context=Context()) def test_scenario_base(scenario, start_scene): @@ -245,7 +246,7 @@ Suppose we want this test to pass. How could we mock this using Scenario? ```python scene = Scene( - event=get_event('start'), + event=event('start'), context=Context(memos=[ {'name': '_ModelBackend.leader_get', 'values': ['True', 'False'], @@ -265,4 +266,4 @@ The good news is that you can generate memos by scraping them off of a live unit - Figure out how to distribute this. I'm thinking `pip install ops[scenario]` - Better syntax for memo generation - Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) -- Expose instructions or facilities re. how to use this without touching your venv. \ No newline at end of file +- Expose instructions or facilities re. how to use this without borking your venv. \ No newline at end of file From e262876f6fe1548d30b7df8b53656b26a3b72f9b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Jan 2023 11:54:49 +0100 Subject: [PATCH 017/546] added playbook to readme --- README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f4b1f7b31..9ad040c1b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ config?...). The output is another context instance: the context after the charm has had a chance to interact with the mocked juju model. -Testing a charm under a given scenario, then, means verifying that: +Scenario-testing a charm, then, means verifying that: -- the charm does not raise uncaught exceptions while handling the scenario -- the output state (or the diff with the input state) is as expected +- the charm does not raise uncaught exceptions while handling the scene +- the output state (or the diff with the input state) is as expected. # Core concepts as a metaphor @@ -198,12 +198,47 @@ def test_relation_data(scenario, start_scene): ``` +# Playbooks + +A playbook encapsulates a sequence of scenes. + +For example: +```python +from scenario.scenario import Playbook +from scenario.structs import State, Scene, Event, Context +playbook = Playbook( + ( + Scene(Event("update-status"), + context=Context(state=State(config={'foo':'bar'}))), + Scene(Event("config-changed"), + context=Context(state=State(config={'foo':'baz'}))), + ) + ) +``` + +This allows us to write concisely common event sequences, such as the charm startup/teardown sequences. These are the only ones that are built-into the framework. +This is the new `Harness.begin_with_initial_hooks`: +```python +import pytest +from scenario.scenario import StartupScenario +from scenario.structs import CharmSpec + +@pytest.mark.parametrize("leader", (True, False)) +def test_setup(leader, mycharm): + scenario = StartupScenario(CharmSpec(mycharm, meta={"name": "foo"}), leader=leader) + scenario.play_until_complete() +``` + +The idea is that users can write down sequences common to their use case +(or multiple charms in a bundle) and share them between tests. + + # Caveats The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. Options we're considering: - have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. -- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, YOU SHOULD REALLY DO THAT +- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, **YOU SHOULD REALLY DO THAT** # Advanced Mockery From ef95faff4d731d32dfe27a29bd5d76ec4e63ce5c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Jan 2023 13:18:44 +0100 Subject: [PATCH 018/546] updated readme --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9ad040c1b..c9dea0da2 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ Scenario-testing a charm, then, means verifying that: # Core concepts as a metaphor I like metaphors, so here we go: - There is a theatre stage (Scenario). -- You pick an actor (a Charm) to put on the stage. +- You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You pick a sketch that the actor will have to play out (a Scene). The sketch is specified as: - - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written on those books on the table? - - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed)) -- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something to a book (pebble.push). + - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those books on the table? + - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). +- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. # Core concepts not as a metaphor @@ -37,14 +37,14 @@ The Scenario encapsulates the charm and its metadata. A scenario can play scenes Crucially, this decoupling of charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... -In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. +In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. More on this later. -# Writing scenario tests -Writing a scenario tests consists of two broad steps: +# Writing scenario tests +Writing a scenario test consists of two broad steps: -- define a Scenario -- run the scenario +- define a scene +- play it The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. From b9cca1d2d0607f17c2ea7c215c50de5d7148584f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Jan 2023 15:14:10 +0100 Subject: [PATCH 019/546] added passthrough mode --- scenario/runtime/memo.py | 58 ++++++++++++++++------ tests/test_e2e/test_play_until_complete.py | 1 - tests/test_memo_tools.py | 36 ++++++++++++++ 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 80526c0df..d7aea8acd 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -35,7 +35,7 @@ SUPPORTED_SERIALIZERS_LIST = ["pickle", "json", "io", "PebblePush"] -MemoModes = Literal["record", "replay", "isolated"] +MemoModes = Literal["passthrough", "record", "replay", "isolated"] _CachingPolicy = Literal["strict", "loose"] # notify just once of what mode we're running in @@ -63,6 +63,9 @@ def _load_memo_mode() -> MemoModes: global _PRINTED_MODE val = os.getenv(MEMO_MODE_KEY, "record") + if val == "passthrough": + # avoid doing anything at all with passthrough, to save time. + pass if val == "record": # don't use logger, but print, to avoid recursion issues with juju-log. if not _PRINTED_MODE: @@ -166,6 +169,27 @@ def memo( SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] ] = "json", ): + f"""This decorator wraps a callable and memoizes its calls. + + Based on the value of the {MEMO_MODE_KEY!r} environment variable, it can work in multiple ways: + + - "passthrough": does nothing. As if the decorator wasn't there. + - "record": each function call gets intercepted, and the [arguments -> return value] mapping is + stored in a database, using `namespace`.`name` as key. The `serializers` arg tells how the args/kwargs and + return value should be serialized, respectively. + - "isolated": each function call gets intercepted, and instead of propagating the call to the wrapped function, + the database is searched for a matching argument set. If one is found, the stored return value is + deserialized and returned. If none is found, a RuntimeError is raised. + - "replay": like "isolated", but in case of a cache miss, the call is propagated to the wrapped function + (the database is NOT implicitly updated). + + `caching_policy` can be either: + - "strict": each function call is stored individually and in an ordered sequence. Useful for when a + function can return different values when called on distinct occasions. + - "loose": the arguments -> return value mapping is stored as a mapping. Assumes that same + arguments == same return value. + """ + def decorator(fn): if not inspect.isfunction(fn): raise RuntimeError(f"Cannot memoize non-function obj {fn!r}.") @@ -174,6 +198,24 @@ def decorator(fn): def wrapper(*args, **kwargs): _MEMO_MODE: MemoModes = _load_memo_mode() + + def propagate(): + """Make the real wrapped call.""" + if _MEMO_MODE == "isolated": + raise RuntimeError( + f"Attempted propagation in isolated mode: " + f"{_call_repr(fn, args, kwargs)}" + ) + + if _MEMO_MODE == "replay" and log_on_replay: + _log_memo(fn, args, kwargs, "", cache_hit=False) + + # todo: if we are replaying, should we be caching this result? + return fn(*args, **kwargs) + + if _MEMO_MODE == "passthrough": + return propagate() + input_serializer, output_serializer = _check_serializer(serializer) def _load(obj: str, method: SUPPORTED_SERIALIZERS): @@ -235,20 +277,6 @@ def _dump(obj: Any, method: SUPPORTED_SERIALIZERS, output_=None): return base64.b64encode(byt).decode("utf-8") raise ValueError(f"Invalid method: {method!r}") - def propagate(): - """Make the real wrapped call.""" - if _MEMO_MODE == "isolated": - raise RuntimeError( - f"Attempted propagation in isolated mode: " - f"{_call_repr(fn, args, kwargs)}" - ) - - if _MEMO_MODE == "replay" and log_on_replay: - _log_memo(fn, args, kwargs, "", cache_hit=False) - - # todo: if we are replaying, should we be caching this result? - return fn(*args, **kwargs) - def load_from_state( scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any]] ): diff --git a/tests/test_e2e/test_play_until_complete.py b/tests/test_e2e/test_play_until_complete.py index 92f07dbf2..50f33f03c 100644 --- a/tests/test_e2e/test_play_until_complete.py +++ b/tests/test_e2e/test_play_until_complete.py @@ -29,7 +29,6 @@ class MyCharm(CharmBase): def __init__(self, framework: Framework, key: Optional[str] = None): super().__init__(framework, key) self.called = False - for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_memo_tools.py b/tests/test_memo_tools.py index 53e844796..2d2c54dd1 100644 --- a/tests/test_memo_tools.py +++ b/tests/test_memo_tools.py @@ -246,6 +246,42 @@ def my_fn(m): assert my_fn(i) == i + 1 +def test_memoizer_passthrough(): + with tempfile.NamedTemporaryFile() as temp_db_file: + with event_db(temp_db_file.name) as data: + data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) + + os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name + + _backing = {x: x + 1 for x in range(50)} + + @memo(caching_policy="loose", log_on_replay=True) + def my_fn(m): + return _backing[m] + + os.environ[MEMO_MODE_KEY] = "record" + for i in range(50): + assert my_fn(i) == i + 1 + + # set the mode to passthrough, so that the original function will + # be called even though there are memos stored + os.environ[MEMO_MODE_KEY] = "passthrough" + + # clear the backing storage. + _backing.clear() + with pytest.raises(KeyError): + my_fn(1) + with pytest.raises(KeyError): + my_fn(10) + + # go to replay mode + os.environ[MEMO_MODE_KEY] = "replay" + + # now it works again + assert my_fn(1) == 2 + assert my_fn(10) == 11 + + def test_memoizer_classmethod_recording(): os.environ[MEMO_MODE_KEY] = "record" From d7f8a958db404580373560d9a2fa0605d6f64cce Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 4 Jan 2023 09:56:07 +0100 Subject: [PATCH 020/546] better readme and docs --- README.md | 4 ++-- scenario/runtime/memo.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9dea0da2..76fcfea98 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Scenario-testing a charm, then, means verifying that: I like metaphors, so here we go: - There is a theatre stage (Scenario). - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. -- You pick a sketch that the actor will have to play out (a Scene). The sketch is specified as: +- You arrange the stage with content that the the actor will have to interact with (a Scene). Setting up the scene consists of selecting: - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those books on the table? - - Something that happens (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index d7aea8acd..c117df59f 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -173,15 +173,19 @@ def memo( Based on the value of the {MEMO_MODE_KEY!r} environment variable, it can work in multiple ways: - - "passthrough": does nothing. As if the decorator wasn't there. + - "passthrough": does nothing. As if the decorator wasn't there. Useful as production default, + to minimize the runtime impact of memo. - "record": each function call gets intercepted, and the [arguments -> return value] mapping is stored in a database, using `namespace`.`name` as key. The `serializers` arg tells how the args/kwargs and - return value should be serialized, respectively. + return value should be serialized, respectively. + Useful for populating a database for replaying/testing purposes. - "isolated": each function call gets intercepted, and instead of propagating the call to the wrapped function, the database is searched for a matching argument set. If one is found, the stored return value is deserialized and returned. If none is found, a RuntimeError is raised. + Useful for replaying in local environments, where propagating the call would result in errors further down. - "replay": like "isolated", but in case of a cache miss, the call is propagated to the wrapped function (the database is NOT implicitly updated). + Useful for replaying in 'live' environments where propagating the call would get you the right result. `caching_policy` can be either: - "strict": each function call is stored individually and in an ordered sequence. Useful for when a From 8ea32c203e8358e211a266259a663ae002526aa5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 4 Jan 2023 09:56:28 +0100 Subject: [PATCH 021/546] lint --- README.md | 6 +++--- scenario/runtime/memo.py | 20 ++++++++++---------- tests/test_e2e/test_state.py | 2 -- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 76fcfea98..ebba76471 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ I like metaphors, so here we go: # Core concepts not as a metaphor Each scene maps to a single event. -The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the context in which they occur. - -Crucially, this decoupling of charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... +The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the c +Crucially, this decoupling of charm and context aontext in which they occur. +llows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. More on this later. diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index c117df59f..aba0ed20e 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -170,27 +170,27 @@ def memo( ] = "json", ): f"""This decorator wraps a callable and memoizes its calls. - + Based on the value of the {MEMO_MODE_KEY!r} environment variable, it can work in multiple ways: - - - "passthrough": does nothing. As if the decorator wasn't there. Useful as production default, + + - "passthrough": does nothing. As if the decorator wasn't there. Useful as production default, to minimize the runtime impact of memo. - - "record": each function call gets intercepted, and the [arguments -> return value] mapping is + - "record": each function call gets intercepted, and the [arguments -> return value] mapping is stored in a database, using `namespace`.`name` as key. The `serializers` arg tells how the args/kwargs and - return value should be serialized, respectively. + return value should be serialized, respectively. Useful for populating a database for replaying/testing purposes. - "isolated": each function call gets intercepted, and instead of propagating the call to the wrapped function, - the database is searched for a matching argument set. If one is found, the stored return value is + the database is searched for a matching argument set. If one is found, the stored return value is deserialized and returned. If none is found, a RuntimeError is raised. Useful for replaying in local environments, where propagating the call would result in errors further down. - - "replay": like "isolated", but in case of a cache miss, the call is propagated to the wrapped function + - "replay": like "isolated", but in case of a cache miss, the call is propagated to the wrapped function (the database is NOT implicitly updated). Useful for replaying in 'live' environments where propagating the call would get you the right result. - + `caching_policy` can be either: - - "strict": each function call is stored individually and in an ordered sequence. Useful for when a + - "strict": each function call is stored individually and in an ordered sequence. Useful for when a function can return different values when called on distinct occasions. - - "loose": the arguments -> return value mapping is stored as a mapping. Assumes that same + - "loose": the arguments -> return value mapping is stored as a mapping. Assumes that same arguments == same return value. """ diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 110a31093..e219ce7e5 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -22,8 +22,6 @@ # setup_tests() # noqa & keep this on top - - CUSTOM_EVT_SUFFIXES = { "relation_created", "relation_joined", From a0276193ea7867934ada9afe10a13e3b6c8a3411 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 9 Jan 2023 16:37:26 +0100 Subject: [PATCH 022/546] added event wrapping for relations --- .gitignore | 3 ++- README.md | 8 +++---- scenario/runtime/memo.py | 46 +++++++++++++++++++++++++++++++++---- scenario/runtime/runtime.py | 12 +++++++++- scenario/scenario.py | 35 ++++++++++++++++++++++++---- scenario/structs.py | 43 ++++++++++++++++++++++++---------- 6 files changed, 118 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index ca7114737..efdc7db78 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ build/ .coverage __pycache__/ *.py[cod] -.idea \ No newline at end of file +.idea +*.eggi-info \ No newline at end of file diff --git a/README.md b/README.md index ebba76471..cab0bf96b 100644 --- a/README.md +++ b/README.md @@ -146,13 +146,11 @@ def test_status_leader(scenario, start_scene, leader): leader_scene.context.state.leader = leader out = scenario.play(leader_scene) - if leader: - assert out.context_out.state.status.unit == ('active', 'I rule') - else: - assert out.context_out.state.status.unit == ('active', 'I follow') + expected_status = ('active', 'I rule') if leader else ('active', 'I follow') + assert out.context_out.state.status.unit == expected_status ``` -By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask to the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... +By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... An example involving relations: diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index aba0ed20e..2ed11fbc6 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -14,7 +14,7 @@ from contextlib import contextmanager from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Literal, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Literal, Tuple, Union, Sequence from uuid import uuid4 from scenario.logger import logger as pkg_logger @@ -670,12 +670,48 @@ class RelationSpec: local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) @classmethod def from_dict(cls, obj): meta = RelationMeta.from_dict(obj.pop("meta")) return cls(meta=meta, **obj) + @property + def changed(self): + """Sugar to generate a -changed event.""" + from scenario import structs + return structs.Event(self.meta.endpoint + '-changed', + meta=structs.EventMeta(relation=self.meta)) + + @property + def joined(self): + """Sugar to generate a -joined event.""" + from scenario import structs + return structs.Event(self.meta.endpoint + '-joined', + meta=structs.EventMeta(relation=self.meta)) + + @property + def created(self): + """Sugar to generate a -created event.""" + from scenario import structs + return structs.Event(self.meta.endpoint + '-created', + meta=structs.EventMeta(relation=self.meta)) + + @property + def departed(self): + """Sugar to generate a -departed event.""" + from scenario import structs + return structs.Event(self.meta.endpoint + '-departed', + meta=structs.EventMeta(relation=self.meta)) + + @property + def removed(self): + """Sugar to generate a -removed event.""" + from scenario import structs + return structs.Event(self.meta.endpoint + '-removed', + meta=structs.EventMeta(relation=self.meta)) + @dataclass class Status: @@ -698,13 +734,13 @@ def from_dict(cls, obj: dict): @dataclass class State: config: Dict[str, Union[str, int, float, bool]] = None - relations: List[RelationSpec] = field(default_factory=list) - networks: List[NetworkSpec] = field(default_factory=list) - containers: List[ContainerSpec] = field(default_factory=list) + relations: Sequence[RelationSpec] = field(default_factory=list) + networks: Sequence[NetworkSpec] = field(default_factory=list) + containers: Sequence[ContainerSpec] = field(default_factory=list) status: Status = field(default_factory=Status) leader: bool = False model: Model = Model() - juju_log: List[Tuple[str, str]] = field(default_factory=list) + juju_log: Sequence[Tuple[str, str]] = field(default_factory=list) # todo: add pebble stuff, unit/app status, etc... # actions? diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index 6b69720a3..f539dd411 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -181,7 +181,7 @@ def unit_name(self): return meta["name"] + "/0" # todo allow override def _get_event_env(self, scene: "Scene", charm_root: Path): - return { + env = { "JUJU_VERSION": self._juju_version, "JUJU_UNIT_NAME": self.unit_name, "_": "./dispatch", @@ -192,6 +192,16 @@ def _get_event_env(self, scene: "Scene", charm_root: Path): # todo consider setting pwd, (python)path } + if scene.event.meta and scene.event.meta.relation: + relation = scene.event.meta.relation + env.update( + { + 'JUJU_RELATION': relation.endpoint, + 'JUJU_RELATION_ID': str(relation.relation_id), + } + ) + return env + def _drop_meta(self, charm_root: Path): logger.debug("Dropping metadata.yaml, config.yaml, actions.yaml...") (charm_root / "metadata.yaml").write_text(yaml.safe_dump(self._charm_spec.meta)) diff --git a/scenario/scenario.py b/scenario/scenario.py index 327bbb9aa..8e4470437 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -12,7 +12,7 @@ META_EVENTS, ) from scenario.logger import logger as pkg_logger -from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene +from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene, State if typing.TYPE_CHECKING: from ops.charm import CharmBase @@ -223,7 +223,8 @@ def play_until_complete(self) -> List[PlayResult]: return results -def events_to_scenes(events: typing.Sequence[Union[str, Event]]): +def events_to_scenes(events: typing.Sequence[Union[str, Event]], + context: Optional[Context] = None): def _to_event(obj): if isinstance(obj, str): return Event(obj) @@ -235,6 +236,7 @@ def _to_event(obj): scenes = map(Scene, map(_to_event, events)) for i, scene in enumerate(scenes): scene.name = f"" + scene.context = context yield scene @@ -251,17 +253,40 @@ def __init__( "leader-elected" if leader else "leader-settings-changed", "config-changed", "install", - ) + ), + context=Context(state=State(leader=leader)) ) ) super().__init__(charm_spec, playbook, juju_version) class TeardownScenario(Scenario): - def __init__(self, charm_spec: CharmSpec, juju_version: str = "3.0.0"): + def __init__(self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0"): playbook: Playbook = Playbook( events_to_scenes( - (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove") + (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove"), + context=Context(state=State(leader=leader)) ) ) super().__init__(charm_spec, playbook, juju_version) + + +def check_builtin_sequences(charm_spec: CharmSpec, leader: Optional[bool] = None): + """Test that the builtin StartupScenario and TeardownScenario pass. + + This will play both scenarios with and without leadership, and raise any exceptions. + If leader is True, it will exclude the non-leader cases, and vice-versa. + """ + + out = { + 'startup': {}, + 'teardown': {}, + } + if leader in {True, None}: + out['startup'][True] = StartupScenario(charm_spec=charm_spec, leader=True).play_until_complete() + out['teardown'][True] = TeardownScenario(charm_spec=charm_spec, leader=True).play_until_complete() + if leader in {False, None}: + out['startup'][False] = StartupScenario(charm_spec=charm_spec, leader=False).play_until_complete() + out['teardown'][False] = TeardownScenario(charm_spec=charm_spec, leader=False).play_until_complete() + + return out diff --git a/scenario/structs.py b/scenario/structs.py index 4b956beec..01c532090 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,7 +1,7 @@ import dataclasses import typing from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional, Tuple, Type +from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Iterable if typing.TYPE_CHECKING: try: @@ -25,11 +25,28 @@ def copy(self) -> "Self": return dataclasses.replace(self) +# from show-relation! +@dataclass +class RelationMeta(memo.RelationMeta, DCBase): + pass + + +@dataclass +class RelationSpec(memo.RelationSpec, DCBase): + pass + + +@dataclass +class EventMeta(DCBase): + relation: RelationMeta = None # if this is a relation event, the metadata of the relation + + @dataclass class Event(DCBase): name: str args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + meta: EventMeta = None @property def is_meta(self): @@ -44,17 +61,6 @@ def as_scene(self, state: "State") -> "Scene": return Scene(context=Context(state=state), event=self) -# from show-relation! -@dataclass -class RelationMeta(memo.RelationMeta, DCBase): - pass - - -@dataclass -class RelationSpec(memo.RelationSpec, DCBase): - pass - - def relation( endpoint: str, interface: str, @@ -184,6 +190,19 @@ def from_dict(cls, obj): def to_dict(self): return dataclasses.asdict(self) + def with_can_connect(self, container_name: str, can_connect: bool): + return self.replace(state=self.state.with_can_connect(container_name, can_connect)) + + def with_leadership(self, leader: bool): + return self.replace(state=self.state.with_leadership(leader)) + + def with_unit_status(self, status: str, message: str): + return self.replace(state=self.state.with_unit_status(status, message)) + + def with_relations(self, relations: Iterable[RelationSpec]): + return self.replace(state=self.state.replace(relations=tuple(relations))) + + @dataclass class Scene(DCBase): From 733f7b4ad9bf57d885ff5272d0f1a262ad287588 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 9 Jan 2023 16:41:42 +0100 Subject: [PATCH 023/546] gitignore addition --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index efdc7db78..bbda3c6db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ build/ __pycache__/ *.py[cod] .idea -*.eggi-info \ No newline at end of file +*.egg-info \ No newline at end of file From 627119ef7266f93a41ca335d2bf47559ac1fd384 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 9 Jan 2023 18:15:03 +0100 Subject: [PATCH 024/546] example assertion code --- scenario/runtime/memo.py | 4 +- scenario/runtime/runtime.py | 3 +- scenario/scenario.py | 26 +++++- scenario/structs.py | 2 + tests/test_e2e/test_play_assertions.py | 122 +++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 tests/test_e2e/test_play_assertions.py diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 2ed11fbc6..4772341b7 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -670,7 +670,7 @@ class RelationSpec: local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) + remote_units_data: Dict[str, Dict[str, str]] = dataclasses.field(default_factory=dict) @classmethod def from_dict(cls, obj): @@ -881,7 +881,7 @@ def get_from_state(scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any] return relation.local_unit_data.get(this_unit_name, {}) else: unit_id = obj_name.split("/")[-1] - return relation.local_unit_data[unit_id] + return relation.remote_units_data[unit_id] elif meth == "is_leader": return state.leader diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index f539dd411..693efec6c 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -253,6 +253,7 @@ def play( scene: "Scene", pre_event: Optional[Callable[["_CT"], None]] = None, post_event: Optional[Callable[["_CT"], None]] = None, + memo_mode: MemoModes = 'replay' ) -> RuntimeRunResult: """Plays a scene on the charm. @@ -276,7 +277,7 @@ def play( memo_scene = self._scene_to_memo_scene(scene, env) with TemporaryEventDB(memo_scene, charm_root) as db_path: - env.update(self._get_runtime_env(0, db_path)) + env.update(self._get_runtime_env(0, db_path, mode=memo_mode)) logger.info(" - redirecting root logging") self._redirect_root_logger() diff --git a/scenario/scenario.py b/scenario/scenario.py index 8e4470437..abdf6672d 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -1,7 +1,9 @@ import json +import os import typing +from contextlib import contextmanager from dataclasses import asdict -from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union, Type, TypeVar from scenario import Runtime from scenario.consts import ( @@ -13,11 +15,14 @@ ) from scenario.logger import logger as pkg_logger from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene, State +from scenario.runtime import memo if typing.TYPE_CHECKING: from ops.charm import CharmBase from ops.framework import BoundEvent, EventBase + from ops.testing import CharmType + _CT = TypeVar("_CT", bound=Type[CharmType]) CharmMeta = Optional[Union[str, TextIO, dict]] AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] @@ -193,8 +198,12 @@ def play( self, scene: Scene, add_to_playbook: bool = False, + pre_event: Optional[Callable[["_CT"], None]] = None, + post_event: Optional[Callable[["_CT"], None]] = None, + memo_mode: memo.MemoModes = 'replay' ) -> PlayResult: - result = self._runtime.play(scene) + result = self._runtime.play(scene, pre_event=pre_event, post_event=post_event, + memo_mode=memo_mode) # todo verify that if state was mutated, it was mutated # in a way that makes sense: # e.g. - charm cannot modify leadership status, etc... @@ -290,3 +299,16 @@ def check_builtin_sequences(charm_spec: CharmSpec, leader: Optional[bool] = None out['teardown'][False] = TeardownScenario(charm_spec=charm_spec, leader=False).play_until_complete() return out + + +@contextmanager +def memo_mode(mode: memo.MemoModes): + previous = os.getenv(memo.MEMO_MODE_KEY) + os.environ[memo.MEMO_MODE_KEY] = mode + + yield + + if previous: + os.environ[memo.MEMO_MODE_KEY] = previous + else: + os.unsetenv(memo.MEMO_MODE_KEY) diff --git a/scenario/structs.py b/scenario/structs.py index 01c532090..94666503a 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -71,6 +71,7 @@ def relation( local_unit_data: Dict[str, str] = None, local_app_data: Dict[str, str] = None, remote_app_data: Dict[str, str] = None, + remote_units_data: Dict[str, Dict[str, str]] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" metadata = RelationMeta( @@ -85,6 +86,7 @@ def relation( local_unit_data=local_unit_data or {}, local_app_data=local_app_data or {}, remote_app_data=remote_app_data or {}, + remote_units_data=remote_units_data or {}, ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py new file mode 100644 index 000000000..887793ed1 --- /dev/null +++ b/tests/test_e2e/test_play_assertions.py @@ -0,0 +1,122 @@ +from typing import Optional, Type + +import pytest +from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.framework import EventBase, Framework + +from scenario.scenario import Scenario +from scenario.structs import ( + CharmSpec, + Context, + Scene, + State, + event, + relation, +) + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharmEvents(CharmEvents): + @classmethod + def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): + if getattr(cls, event_kind, None): + delattr(cls, event_kind) + return super().define_event(event_kind, event_type) + + class MyCharm(CharmBase): + _call = None + on = MyCharmEvents() + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + self.called = False + + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + if self._call: + self.called = True + self._call(event) + + return MyCharm + + +def test_called(mycharm): + mycharm._call = lambda *_: True + scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) + + def pre_event(charm): + pre_event._called = True + assert not charm.called + + def post_event(charm): + post_event._called = True + + from ops.model import ActiveStatus + charm.unit.status = ActiveStatus('yabadoodle') + + assert charm.called + + out = scenario.play( + Scene( + event("start"), + context=Context( + state=State(config={"foo": "bar"}, leader=True))), + pre_event=pre_event, post_event=post_event) + + assert pre_event._called + assert post_event._called + + assert out.delta() == [ + {'op': 'replace', 'path': '/state/status/unit', 'value': ('active', 'yabadoodle')} + ] + + +def test_relation_data_access(mycharm): + mycharm._call = lambda *_: True + scenario = Scenario(CharmSpec( + mycharm, + meta={"name": "foo", + "requires": {"relation_test": + {"interface": "azdrubales"}}})) + + def check_relation_data(charm): + foo_relations = charm.model.relations['relation_test'] + assert len(foo_relations) == 1 + foo_rel = foo_relations[0] + assert len(foo_rel.units) == 2 + + remote_units_data = {} + for remote_unit in foo_rel.units: + remote_units_data[remote_unit.name] = dict(foo_rel.data[remote_unit]) + + remote_app_data = foo_rel.data[foo_rel.app] + + assert remote_units_data == { + 'karlos/0': {'foo': 'bar'}, + 'karlos/1': {'baz': 'qux'}} + + assert remote_app_data == {'yaba': 'doodle'} + + scene = Scene( + context=Context( + state=State(relations=[ + relation(endpoint="relation_test", + interface="azdrubales", + remote_app_name="karlos", + remote_app_data={'yaba': 'doodle'}, + remote_unit_ids=[0, 1], + remote_units_data={ + '0': {'foo': 'bar'}, + '1': {'baz': 'qux'} + } + ) + ])), + event=event('update-status') + ) + + scenario.play(scene, + post_event=check_relation_data, + ) From c2bead9b70616ae4b3f9e7ec2abde22e951ea3e2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 9 Jan 2023 18:15:22 +0100 Subject: [PATCH 025/546] fmt --- scenario/runtime/memo.py | 36 +++++++---- scenario/runtime/runtime.py | 8 +-- scenario/scenario.py | 55 ++++++++++++----- scenario/structs.py | 11 ++-- tests/test_e2e/test_play_assertions.py | 83 ++++++++++++++------------ 5 files changed, 119 insertions(+), 74 deletions(-) diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py index 4772341b7..478eb39f4 100644 --- a/scenario/runtime/memo.py +++ b/scenario/runtime/memo.py @@ -14,7 +14,7 @@ from contextlib import contextmanager from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Literal, Tuple, Union, Sequence +from typing import Any, Callable, Dict, Generator, List, Literal, Sequence, Tuple, Union from uuid import uuid4 from scenario.logger import logger as pkg_logger @@ -670,7 +670,9 @@ class RelationSpec: local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_units_data: Dict[str, Dict[str, str]] = dataclasses.field(default_factory=dict) + remote_units_data: Dict[str, Dict[str, str]] = dataclasses.field( + default_factory=dict + ) @classmethod def from_dict(cls, obj): @@ -681,36 +683,46 @@ def from_dict(cls, obj): def changed(self): """Sugar to generate a -changed event.""" from scenario import structs - return structs.Event(self.meta.endpoint + '-changed', - meta=structs.EventMeta(relation=self.meta)) + + return structs.Event( + self.meta.endpoint + "-changed", meta=structs.EventMeta(relation=self.meta) + ) @property def joined(self): """Sugar to generate a -joined event.""" from scenario import structs - return structs.Event(self.meta.endpoint + '-joined', - meta=structs.EventMeta(relation=self.meta)) + + return structs.Event( + self.meta.endpoint + "-joined", meta=structs.EventMeta(relation=self.meta) + ) @property def created(self): """Sugar to generate a -created event.""" from scenario import structs - return structs.Event(self.meta.endpoint + '-created', - meta=structs.EventMeta(relation=self.meta)) + + return structs.Event( + self.meta.endpoint + "-created", meta=structs.EventMeta(relation=self.meta) + ) @property def departed(self): """Sugar to generate a -departed event.""" from scenario import structs - return structs.Event(self.meta.endpoint + '-departed', - meta=structs.EventMeta(relation=self.meta)) + + return structs.Event( + self.meta.endpoint + "-departed", meta=structs.EventMeta(relation=self.meta) + ) @property def removed(self): """Sugar to generate a -removed event.""" from scenario import structs - return structs.Event(self.meta.endpoint + '-removed', - meta=structs.EventMeta(relation=self.meta)) + + return structs.Event( + self.meta.endpoint + "-removed", meta=structs.EventMeta(relation=self.meta) + ) @dataclass diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py index 693efec6c..465c6a3c0 100644 --- a/scenario/runtime/runtime.py +++ b/scenario/runtime/runtime.py @@ -196,9 +196,9 @@ def _get_event_env(self, scene: "Scene", charm_root: Path): relation = scene.event.meta.relation env.update( { - 'JUJU_RELATION': relation.endpoint, - 'JUJU_RELATION_ID': str(relation.relation_id), - } + "JUJU_RELATION": relation.endpoint, + "JUJU_RELATION_ID": str(relation.relation_id), + } ) return env @@ -253,7 +253,7 @@ def play( scene: "Scene", pre_event: Optional[Callable[["_CT"], None]] = None, post_event: Optional[Callable[["_CT"], None]] = None, - memo_mode: MemoModes = 'replay' + memo_mode: MemoModes = "replay", ) -> RuntimeRunResult: """Plays a scene on the charm. diff --git a/scenario/scenario.py b/scenario/scenario.py index abdf6672d..cf3bf4981 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -3,7 +3,18 @@ import typing from contextlib import contextmanager from dataclasses import asdict -from typing import Any, Callable, Dict, Iterable, List, Optional, TextIO, Union, Type, TypeVar +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + TextIO, + Type, + TypeVar, + Union, +) from scenario import Runtime from scenario.consts import ( @@ -14,8 +25,8 @@ META_EVENTS, ) from scenario.logger import logger as pkg_logger -from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene, State from scenario.runtime import memo +from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene, State if typing.TYPE_CHECKING: from ops.charm import CharmBase @@ -200,10 +211,11 @@ def play( add_to_playbook: bool = False, pre_event: Optional[Callable[["_CT"], None]] = None, post_event: Optional[Callable[["_CT"], None]] = None, - memo_mode: memo.MemoModes = 'replay' + memo_mode: memo.MemoModes = "replay", ) -> PlayResult: - result = self._runtime.play(scene, pre_event=pre_event, post_event=post_event, - memo_mode=memo_mode) + result = self._runtime.play( + scene, pre_event=pre_event, post_event=post_event, memo_mode=memo_mode + ) # todo verify that if state was mutated, it was mutated # in a way that makes sense: # e.g. - charm cannot modify leadership status, etc... @@ -232,8 +244,9 @@ def play_until_complete(self) -> List[PlayResult]: return results -def events_to_scenes(events: typing.Sequence[Union[str, Event]], - context: Optional[Context] = None): +def events_to_scenes( + events: typing.Sequence[Union[str, Event]], context: Optional[Context] = None +): def _to_event(obj): if isinstance(obj, str): return Event(obj) @@ -263,18 +276,20 @@ def __init__( "config-changed", "install", ), - context=Context(state=State(leader=leader)) + context=Context(state=State(leader=leader)), ) ) super().__init__(charm_spec, playbook, juju_version) class TeardownScenario(Scenario): - def __init__(self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0"): + def __init__( + self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0" + ): playbook: Playbook = Playbook( events_to_scenes( (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove"), - context=Context(state=State(leader=leader)) + context=Context(state=State(leader=leader)), ) ) super().__init__(charm_spec, playbook, juju_version) @@ -288,15 +303,23 @@ def check_builtin_sequences(charm_spec: CharmSpec, leader: Optional[bool] = None """ out = { - 'startup': {}, - 'teardown': {}, + "startup": {}, + "teardown": {}, } if leader in {True, None}: - out['startup'][True] = StartupScenario(charm_spec=charm_spec, leader=True).play_until_complete() - out['teardown'][True] = TeardownScenario(charm_spec=charm_spec, leader=True).play_until_complete() + out["startup"][True] = StartupScenario( + charm_spec=charm_spec, leader=True + ).play_until_complete() + out["teardown"][True] = TeardownScenario( + charm_spec=charm_spec, leader=True + ).play_until_complete() if leader in {False, None}: - out['startup'][False] = StartupScenario(charm_spec=charm_spec, leader=False).play_until_complete() - out['teardown'][False] = TeardownScenario(charm_spec=charm_spec, leader=False).play_until_complete() + out["startup"][False] = StartupScenario( + charm_spec=charm_spec, leader=False + ).play_until_complete() + out["teardown"][False] = TeardownScenario( + charm_spec=charm_spec, leader=False + ).play_until_complete() return out diff --git a/scenario/structs.py b/scenario/structs.py index 94666503a..0c0248f36 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,7 +1,7 @@ import dataclasses import typing from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Iterable +from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, Type if typing.TYPE_CHECKING: try: @@ -38,7 +38,9 @@ class RelationSpec(memo.RelationSpec, DCBase): @dataclass class EventMeta(DCBase): - relation: RelationMeta = None # if this is a relation event, the metadata of the relation + relation: RelationMeta = ( + None # if this is a relation event, the metadata of the relation + ) @dataclass @@ -193,7 +195,9 @@ def to_dict(self): return dataclasses.asdict(self) def with_can_connect(self, container_name: str, can_connect: bool): - return self.replace(state=self.state.with_can_connect(container_name, can_connect)) + return self.replace( + state=self.state.with_can_connect(container_name, can_connect) + ) def with_leadership(self, leader: bool): return self.replace(state=self.state.with_leadership(leader)) @@ -205,7 +209,6 @@ def with_relations(self, relations: Iterable[RelationSpec]): return self.replace(state=self.state.replace(relations=tuple(relations))) - @dataclass class Scene(DCBase): event: Event diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 887793ed1..4ef3b744b 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -5,14 +5,7 @@ from ops.framework import EventBase, Framework from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - Context, - Scene, - State, - event, - relation, -) +from scenario.structs import CharmSpec, Context, Scene, State, event, relation @pytest.fixture(scope="function") @@ -55,35 +48,46 @@ def post_event(charm): post_event._called = True from ops.model import ActiveStatus - charm.unit.status = ActiveStatus('yabadoodle') + + charm.unit.status = ActiveStatus("yabadoodle") assert charm.called out = scenario.play( Scene( event("start"), - context=Context( - state=State(config={"foo": "bar"}, leader=True))), - pre_event=pre_event, post_event=post_event) + context=Context(state=State(config={"foo": "bar"}, leader=True)), + ), + pre_event=pre_event, + post_event=post_event, + ) assert pre_event._called assert post_event._called assert out.delta() == [ - {'op': 'replace', 'path': '/state/status/unit', 'value': ('active', 'yabadoodle')} + { + "op": "replace", + "path": "/state/status/unit", + "value": ("active", "yabadoodle"), + } ] def test_relation_data_access(mycharm): mycharm._call = lambda *_: True - scenario = Scenario(CharmSpec( - mycharm, - meta={"name": "foo", - "requires": {"relation_test": - {"interface": "azdrubales"}}})) + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "foo", + "requires": {"relation_test": {"interface": "azdrubales"}}, + }, + ) + ) def check_relation_data(charm): - foo_relations = charm.model.relations['relation_test'] + foo_relations = charm.model.relations["relation_test"] assert len(foo_relations) == 1 foo_rel = foo_relations[0] assert len(foo_rel.units) == 2 @@ -95,28 +99,31 @@ def check_relation_data(charm): remote_app_data = foo_rel.data[foo_rel.app] assert remote_units_data == { - 'karlos/0': {'foo': 'bar'}, - 'karlos/1': {'baz': 'qux'}} + "karlos/0": {"foo": "bar"}, + "karlos/1": {"baz": "qux"}, + } - assert remote_app_data == {'yaba': 'doodle'} + assert remote_app_data == {"yaba": "doodle"} scene = Scene( context=Context( - state=State(relations=[ - relation(endpoint="relation_test", - interface="azdrubales", - remote_app_name="karlos", - remote_app_data={'yaba': 'doodle'}, - remote_unit_ids=[0, 1], - remote_units_data={ - '0': {'foo': 'bar'}, - '1': {'baz': 'qux'} - } - ) - ])), - event=event('update-status') + state=State( + relations=[ + relation( + endpoint="relation_test", + interface="azdrubales", + remote_app_name="karlos", + remote_app_data={"yaba": "doodle"}, + remote_unit_ids=[0, 1], + remote_units_data={"0": {"foo": "bar"}, "1": {"baz": "qux"}}, + ) + ] + ) + ), + event=event("update-status"), ) - scenario.play(scene, - post_event=check_relation_data, - ) + scenario.play( + scene, + post_event=check_relation_data, + ) From c3805c8786d774c3b788d548b54a2436b0ad264a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Jan 2023 10:34:30 +0100 Subject: [PATCH 026/546] stash --- scenario/scenario.py | 7 +++++-- tests/test_e2e/test_play_assertions.py | 4 +--- tests/test_e2e/test_state.py | 14 ++++++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scenario/scenario.py b/scenario/scenario.py index cf3bf4981..5e973aae1 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -75,7 +75,7 @@ class PlayResult: def __init__( self, charm: "CharmBase", - scene_in: "Scene", + scene_in: "Scene", # todo: consider removing event: "EventBase", context_out: "Context", ): @@ -288,7 +288,10 @@ def __init__( ): playbook: Playbook = Playbook( events_to_scenes( - (BREAK_ALL_RELATIONS, DETACH_ALL_STORAGES, "stop", "remove"), + (BREAK_ALL_RELATIONS, + DETACH_ALL_STORAGES, + "stop", + "remove"), context=Context(state=State(leader=leader)), ) ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 4ef3b744b..7243b4d9d 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -36,7 +36,7 @@ def _on_event(self, event): return MyCharm -def test_called(mycharm): +def test_charm_heals_on_start(mycharm): mycharm._call = lambda *_: True scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) @@ -46,11 +46,9 @@ def pre_event(charm): def post_event(charm): post_event._called = True - from ops.model import ActiveStatus charm.unit.status = ActiveStatus("yabadoodle") - assert charm.called out = scenario.play( diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index e219ce7e5..befe84be7 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -65,18 +65,20 @@ def _on_event(self, event): @pytest.fixture def dummy_state(): - return State(config={"foo": "bar"}, leader=True) + return State(config={"foo": "bar"}, + leader=True) -@pytest.fixture +@pytest.fixture(scope='function') def start_scene(dummy_state): - return Scene(event("start"), context=Context(state=dummy_state)) + return Scene(event("start"), + context=Context(state=dummy_state)) def test_bare_event(start_scene, mycharm): mycharm._call = lambda *_: True scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - out = scenario.play(start_scene) + out = scenario.play(scene=start_scene) assert isinstance(out.charm, mycharm) assert out.charm.called @@ -201,7 +203,10 @@ def call(charm: CharmBase, _): }, ) ) + scene = start_scene.copy() + + scene.context.state.leader = True scene.context.state.relations = [ # we could also append... relation( endpoint="foo", @@ -211,6 +216,7 @@ def call(charm: CharmBase, _): local_unit_data={}, ) ] + out = scenario.play(scene) assert asdict(out.context_out.state.relations[0]) == asdict( From e596bb83d8c25de58f1a0790fded941ed10c0ef0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Jan 2023 10:38:14 +0100 Subject: [PATCH 027/546] stripped down memo part --- scenario/__init__.py | 3 - scenario/consts.py | 10 - scenario/event_db.py | 28 - scenario/mocking.py | 290 +++ scenario/ops_main_mock.py | 72 +- scenario/runtime.py | 267 ++ scenario/runtime/memo.py | 1013 -------- scenario/runtime/memo_tools.py | 162 -- scenario/runtime/runtime.py | 401 --- scenario/runtime/scripts/install.py | 16 - scenario/scenario.py | 362 +-- scenario/structs.py | 451 ++-- scenario/version.py | 1 - tests/memo_tools_test_files/mock_ops.py | 19 - .../prom-0-update-status.json | 112 - .../memo_tools_test_files/trfk-re-relate.json | 2251 ----------------- tests/resources/__init__.py | 0 tests/resources/demo_decorate_class.py | 18 + ...til_complete.py => test_builtin_scenes.py} | 27 +- tests/test_e2e/test_play_assertions.py | 70 +- tests/test_e2e/test_state.py | 118 +- tests/test_memo_tools.py | 464 ---- tests/test_mocking.py | 94 + tests/test_replay_local_runtime.py | 134 - tests/test_runtime.py | 83 + 25 files changed, 1263 insertions(+), 5203 deletions(-) delete mode 100644 scenario/__init__.py delete mode 100644 scenario/consts.py delete mode 100644 scenario/event_db.py create mode 100644 scenario/mocking.py create mode 100644 scenario/runtime.py delete mode 100644 scenario/runtime/memo.py delete mode 100644 scenario/runtime/memo_tools.py delete mode 100644 scenario/runtime/runtime.py delete mode 100644 scenario/runtime/scripts/install.py delete mode 100644 scenario/version.py delete mode 100644 tests/memo_tools_test_files/mock_ops.py delete mode 100644 tests/memo_tools_test_files/prom-0-update-status.json delete mode 100644 tests/memo_tools_test_files/trfk-re-relate.json create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/demo_decorate_class.py rename tests/test_e2e/{test_play_until_complete.py => test_builtin_scenes.py} (51%) delete mode 100644 tests/test_memo_tools.py create mode 100644 tests/test_mocking.py delete mode 100644 tests/test_replay_local_runtime.py create mode 100644 tests/test_runtime.py diff --git a/scenario/__init__.py b/scenario/__init__.py deleted file mode 100644 index 9ccada295..000000000 --- a/scenario/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .runtime.memo import memo -from .runtime.runtime import Runtime -from .scenario import Playbook, Scenario, Scene diff --git a/scenario/consts.py b/scenario/consts.py deleted file mode 100644 index ad3fec630..000000000 --- a/scenario/consts.py +++ /dev/null @@ -1,10 +0,0 @@ -ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" -CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" -BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" -DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" -META_EVENTS = { - "CREATE_ALL_RELATIONS": "-relation-created", - "BREAK_ALL_RELATIONS": "-relation-broken", - "DETACH_ALL_STORAGES": "-storage-detaching", - "ATTACH_ALL_STORAGES": "-storage-attached", -} diff --git a/scenario/event_db.py b/scenario/event_db.py deleted file mode 100644 index ae3e7c6d7..000000000 --- a/scenario/event_db.py +++ /dev/null @@ -1,28 +0,0 @@ -import tempfile -import typing -from pathlib import Path - -if typing.TYPE_CHECKING: - from runtime.memo import Scene - - -class TemporaryEventDB: - def __init__(self, scene: "Scene", tempdir=None): - self.scene = scene - self._tempdir = tempdir - self._tempfile = None - self.path = None - - def __enter__(self): - from scenario.scenario import Playbook - - self._tempfile = tempfile.NamedTemporaryFile(dir=self._tempdir, delete=False) - self.path = Path(self._tempfile.name).absolute() - self.path.write_text(Playbook([self.scene]).to_json()) - return self.path - - def __exit__(self, exc_type, exc_val, exc_tb): - self.path.unlink() - - self._tempfile = None - self.path = None diff --git a/scenario/mocking.py b/scenario/mocking.py new file mode 100644 index 000000000..0b26357ab --- /dev/null +++ b/scenario/mocking.py @@ -0,0 +1,290 @@ +import functools +from dataclasses import dataclass +from typing import Dict, Optional, Tuple, Any, TYPE_CHECKING, Callable, Type + +from scenario.logger import logger as scenario_logger + +if TYPE_CHECKING: + from scenario.scenario import Scene + +logger = scenario_logger.getChild('mocking') + +Simulator = Callable[ + [Callable[[Any], Any], # simulated function + str, # namespace + str, # tool name + "Scene", # scene + Tuple[Any, ...], # call args + Dict[str, Any]], # call kwargs + None] + + +def wrap_tool( + fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + call_args: Tuple[Any, ...], + call_kwargs: Dict[str, Any] +): + # all builtin tools we wrap are methods: + # _self = call_args[0] + args = tuple(call_args[1:]) + input_state = scene.state + this_unit_name = scene.meta.unit_name + this_app_name = scene.meta.app_name + + setter = False + wrap_errors = True + + try: + # MODEL BACKEND CALLS + if namespace == "_ModelBackend": + if tool_name == "relation_get": + rel_id, obj_name, app = args + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, input_state.relations) + ) + if app and obj_name == this_app_name: + return relation.local_app_data + elif app: + return relation.remote_app_data + elif obj_name == this_unit_name: + return relation.local_unit_data + else: + unit_id = obj_name.split("/")[-1] + return relation.remote_units_data[int(unit_id)] + + elif tool_name == "is_leader": + return input_state.leader + + elif tool_name == "status_get": + status, message = ( + input_state.status.app if call_kwargs.get("app") else input_state.status.unit + ) + return {"status": status, "message": message} + + elif tool_name == "relation_ids": + return [rel.meta.relation_id for rel in input_state.relations] + + elif tool_name == "relation_list": + rel_id = args[0] + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, input_state.relations) + ) + return tuple( + f"{relation.meta.remote_app_name}/{unit_id}" + for unit_id in relation.meta.remote_unit_ids + ) + + elif tool_name == "config_get": + return input_state.config[args[0]] + + elif tool_name == "action_get": + raise NotImplementedError("action_get") + elif tool_name == "relation_remote_app_name": + raise NotImplementedError("relation_remote_app_name") + elif tool_name == "resource_get": + raise NotImplementedError("resource_get") + elif tool_name == "storage_list": + raise NotImplementedError("storage_list") + elif tool_name == "storage_get": + raise NotImplementedError("storage_get") + elif tool_name == "network_get": + raise NotImplementedError("network_get") + elif tool_name == "planned_units": + raise NotImplementedError("planned_units") + else: + setter = True + + # # setter methods + + if tool_name == "application_version_set": + scene.state.status.app_version = args[0] + return None + + elif tool_name == "status_set": + if call_kwargs.get("is_app"): + scene.state.status.app = args + else: + scene.state.status.unit = args + return None + + elif tool_name == "juju_log": + scene.state.juju_log.append(args) + return None + + elif tool_name == "relation_set": + rel_id, key, value, app = args + relation = next( + filter(lambda r: r.meta.relation_id == rel_id, scene.state.relations) + ) + if app: + if not scene.state.leader: + raise RuntimeError("needs leadership to set app data") + tgt = relation.local_app_data + else: + tgt = relation.local_unit_data + tgt[key] = value + return None + + elif tool_name == "action_set": + raise NotImplementedError("action_set") + elif tool_name == "action_fail": + raise NotImplementedError("action_fail") + elif tool_name == "action_log": + raise NotImplementedError("action_log") + elif tool_name == "storage_add": + raise NotImplementedError("storage_add") + elif tool_name == "secret_get": + raise NotImplementedError("secret_get") + elif tool_name == "secret_set": + raise NotImplementedError("secret_set") + elif tool_name == "secret_grant": + raise NotImplementedError("secret_grant") + elif tool_name == "secret_remove": + raise NotImplementedError("secret_remove") + + # PEBBLE CALLS + elif namespace == "Client": + if tool_name == "_request": + if args == ("GET", "/v1/system-info"): + # fixme: can't differentiate between containers ATM, because Client._request + # does not pass around the container name as argument + if input_state.containers[0].can_connect: + return {"result": {"version": "unknown"}} + else: + wrap_errors = False # this is what pebble.Client expects! + raise FileNotFoundError("") + elif tool_name == "pull": + raise NotImplementedError("pull") + elif tool_name == "push": + setter = True + raise NotImplementedError("push") + + else: + raise QuestionNotImplementedError(namespace) + except Exception as e: + if not wrap_errors: + # reraise + raise e + + action = "setting" if setter else "getting" + msg = f"Error {action} state for {namespace}.{tool_name} given ({call_args}, {call_kwargs})" + raise StateError(msg) from e + + raise QuestionNotImplementedError((namespace, tool_name, call_args, call_kwargs)) + + +@dataclass +class DecorateSpec: + # the memo's namespace will default to the class name it's being defined in + namespace: Optional[str] = None + + # the memo's name will default to the memoized function's __name__ + name: Optional[str] = None + + # the function to be called instead of the decorated one + simulator: Simulator = wrap_tool + + +def _log_call( + namespace: str, + tool_name: str, + args, + kwargs, + recorded_output: Any = None, + # use print, not logger calls, else the root logger will recurse if + # juju-log calls are being @wrapped as well. + log_fn: Callable[[str], None] = logger.debug, +): + try: + output_repr = repr(recorded_output) + except: # noqa catchall + output_repr = "" + + trim = output_repr[:100] + trimmed = "[...]" if len(output_repr) > 100 else "" + + return log_fn( + f"@wrap_tool: intercepted {namespace}.{tool_name}(*{args}, **{kwargs})" + f"\n\t --> {trim}{trimmed}" + ) + + +class StateError(RuntimeError): + pass + + +class QuestionNotImplementedError(StateError): + pass + + +def wrap( + fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + simulator: Simulator = wrap_tool +): + @functools.wraps(fn) + def wrapper(*call_args, **call_kwargs): + out = simulator( + fn=fn, + namespace=namespace, + tool_name=tool_name, + scene=scene, + call_args=call_args, + call_kwargs=call_kwargs) + + _log_call(namespace, tool_name, call_args, call_kwargs, out) + return out + + return wrapper + + +# todo: figure out how to allow users to manually tag individual functions for wrapping +def patch_module( + module, + decorate: Dict[str, Dict[str, DecorateSpec]], + scene: "Scene"): + """Patch a module by decorating methods in a number of classes. + + Decorate: a dict mapping class names to methods of that class that should be decorated. + Example:: + >>> patch_module(my_module, {'MyClass': { + ... 'do_x': DecorateSpec(), + ... 'is_ready': DecorateSpec(caching_policy='loose'), + ... 'refresh': DecorateSpec(caching_policy='loose'), + ... 'bar': DecorateSpec(caching_policy='loose') + ... }}, + ... some_scene) + """ + + for name, obj in module.__dict__.items(): + specs = decorate.get(name) + + if not specs: + continue + + patch_class(specs, obj, + scene=scene) + + +def patch_class(specs: Dict[str, DecorateSpec], + obj: Type, + scene: "Scene"): + for meth_name, fn in obj.__dict__.items(): + spec = specs.get(meth_name) + + if not spec: + continue + + # todo: use mock.patch and lift after exit + wrapped_fn = wrap(fn, + namespace=obj.__name__, + tool_name=meth_name, + scene=scene, + simulator=spec.simulator) + + setattr(obj, meth_name, wrapped_fn) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index a39a804ca..b733365c2 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -5,7 +5,7 @@ import logging import os import warnings -from typing import TYPE_CHECKING, Any, Optional, Tuple, Type +from typing import TYPE_CHECKING, Optional, Tuple, Type, Callable import ops.charm import ops.framework @@ -14,68 +14,26 @@ from ops.charm import CharmMeta from ops.jujuversion import JujuVersion from ops.log import setup_root_logging - -if TYPE_CHECKING: - from ops.charm import CharmBase, EventBase - -from ops.framework import Handle from ops.main import ( _Dispatcher, _get_charm_dir, - _get_event_args, - _should_use_controller_storage, + _emit_charm_event, + _should_use_controller_storage, CHARM_STATE_FILE, ) +from scenario.logger import logger as scenario_logger -CHARM_STATE_FILE = ".unit-state.db" - -logger = logging.getLogger() - - -def patched_bound_event_emit(self, *args: Any, **kwargs: Any) -> "EventBase": - """Emit event to all registered observers. - - The current storage state is committed before and after each observer is notified. - """ - framework = self.emitter.framework - key = framework._next_event_key() # noqa - event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs) - event.framework = framework - framework._emit(event) # noqa - return event - - -from ops import framework - -framework.BoundEvent.emit = patched_bound_event_emit +if TYPE_CHECKING: + from ops.testing import CharmType + from ops.charm import CharmBase, EventBase +logger = scenario_logger.getChild('ops_main_mock') -def _emit_charm_event(charm: "CharmBase", event_name: str) -> Optional["EventBase"]: - """Emits a charm event based on a Juju event name. - Args: - charm: A charm instance to emit an event from. - event_name: A Juju event name to emit on a charm. - """ - event_to_emit = None - try: - event_to_emit = getattr(charm.on, event_name) - except AttributeError: - logger.debug("Event %s not defined for %s.", event_name, charm) - - # If the event is not supported by the charm implementation, do - # not error out or try to emit it. This is to support rollbacks. - if event_to_emit is not None: - args, kwargs = _get_event_args(charm, event_to_emit) - logger.debug("Emitting Juju event %s.", event_name) - return event_to_emit.emit(*args, **kwargs) - - -def main( - charm_class: Type[ops.charm.CharmBase], - use_juju_for_storage: Optional[bool] = None, - pre_event=None, - post_event=None, -) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: +def main(charm_class: Type[ops.charm.CharmBase], + use_juju_for_storage: Optional[bool] = None, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: """Setup the charm and dispatch the observed event. The event name is based on the way this executable was called (argv[0]). @@ -164,7 +122,7 @@ def main( if pre_event: pre_event(charm) - event = _emit_charm_event(charm, dispatcher.event_name) + _emit_charm_event(charm, dispatcher.event_name) if post_event: post_event(charm) @@ -172,5 +130,3 @@ def main( framework.commit() finally: framework.close() - - return charm, event diff --git a/scenario/runtime.py b/scenario/runtime.py new file mode 100644 index 000000000..68d55772c --- /dev/null +++ b/scenario/runtime.py @@ -0,0 +1,267 @@ +import dataclasses +import inspect +import os +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar + +import yaml + +from scenario.logger import logger as scenario_logger +from scenario.mocking import patch_module, DecorateSpec + +if TYPE_CHECKING: + from ops.charm import CharmBase + from ops.framework import EventBase + from ops.testing import CharmType + + from scenario.structs import CharmSpec, Scene, State + + _CT = TypeVar("_CT", bound=Type[CharmType]) + +logger = scenario_logger.getChild("runtime") + +RUNTIME_MODULE = Path(__file__).parent + + +@dataclasses.dataclass +class RuntimeRunResult: + charm: "CharmBase" + scene: "Scene" + event: "EventBase" + + +class Runtime: + """Charm runtime wrapper. + + This object bridges a local environment and a charm artifact. + """ + + def __init__( + self, + charm_spec: "CharmSpec", + juju_version: str = "3.0.0", + ): + self._charm_spec = charm_spec + self._juju_version = juju_version + self._charm_type = charm_spec.charm_type + # TODO consider cleaning up venv on __delete__, but ideally you should be + # running this in a clean venv or a container anyway. + + @staticmethod + def from_local_file( + local_charm_src: Path, + charm_cls_name: str, + ) -> "Runtime": + sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) + + ldict = {} + + try: + exec( + f"from charm import {charm_cls_name} as my_charm_type", globals(), ldict + ) + except ModuleNotFoundError as e: + raise RuntimeError( + f"Failed to load charm {charm_cls_name}. " + f"Probably some dependency is missing. " + f"Try `pip install -r {local_charm_src / 'requirements.txt'}`" + ) from e + + my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] + return Runtime(CharmSpec(my_charm_type)) # TODO add meta, options,... + + @contextmanager + def patching(self, scene: "Scene"): + """Install the runtime: patch all required backend calls. + """ + + # copy input state to act as blueprint for output state + logger.info(f"Installing {self}... ") + from ops import pebble + logger.info("patching ops.pebble") + pebble_decorator_specs = { + "Client": { + # todo: we could be more fine-grained and decorate individual Container methods, + # e.g. can_connect, ... just like in _ModelBackend we don't just memo `_run`. + "_request": DecorateSpec(), + # some methods such as pebble.pull use _request_raw directly, + # and deal in objects that cannot be json-serialized + "pull": DecorateSpec(), + "push": DecorateSpec(), + } + } + patch_module(pebble, decorate=pebble_decorator_specs, + scene=scene) + + from ops import model + logger.info("patching ops.model") + model_decorator_specs = { + "_ModelBackend": { + "relation_get": DecorateSpec(), + "relation_set": DecorateSpec(), + "is_leader": DecorateSpec(), + "application_version_set": DecorateSpec(), + "status_get": DecorateSpec(), + "action_get": DecorateSpec(), + "add_metrics": DecorateSpec(), # deprecated, I guess + "action_set": DecorateSpec(), + "action_fail": DecorateSpec(), + "action_log": DecorateSpec(), + "relation_ids": DecorateSpec(), + "relation_list": DecorateSpec(), + "relation_remote_app_name": DecorateSpec(), + "config_get": DecorateSpec(), + "resource_get": DecorateSpec(), + "storage_list": DecorateSpec(), + "storage_get": DecorateSpec(), + "network_get": DecorateSpec(), + "status_set": DecorateSpec(), + "storage_add": DecorateSpec(), + "juju_log": DecorateSpec(), + "planned_units": DecorateSpec(), + + # todo different ops version support? + # "secret_get": DecorateSpec(), + # "secret_set": DecorateSpec(), + # "secret_grant": DecorateSpec(), + # "secret_remove": DecorateSpec(), + } + } + patch_module(model, decorate=model_decorator_specs, + scene=scene) + + yield + + @staticmethod + def _redirect_root_logger(): + # the root logger set up by ops calls a hook tool: `juju-log`. + # that is a problem for us because `juju-log` is itself memoized, which leads to recursion. + def _patch_logger(*args, **kwargs): + logger.debug("Hijacked root logger.") + pass + + from scenario import ops_main_mock + ops_main_mock.setup_root_logging = _patch_logger + + @staticmethod + def _cleanup_env(env): + # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. + for key in env: + os.unsetenv(key) + + @property + def unit_name(self): + meta = self._charm_spec.meta + if not meta: + return "local/0" + return meta["name"] + "/0" # todo allow override + + def _get_event_env(self, scene: "Scene", charm_root: Path): + env = { + "JUJU_VERSION": self._juju_version, + "JUJU_UNIT_NAME": self.unit_name, + "_": "./dispatch", + "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", + "JUJU_MODEL_NAME": scene.state.model.name, + "JUJU_MODEL_UUID": scene.state.model.uuid, + "JUJU_CHARM_DIR": str(charm_root.absolute()) + # todo consider setting pwd, (python)path + } + + if scene.event.meta and scene.event.meta.relation: + relation = scene.event.meta.relation + env.update( + { + "JUJU_RELATION": relation.endpoint, + "JUJU_RELATION_ID": str(relation.relation_id), + } + ) + return env + + @staticmethod + def _wrap(charm_type: "_CT") -> "_CT": + # dark sorcery to work around framework using class attrs to hold on to event sources + # todo this should only be needed if we call play multiple times on the same runtime. + # can we avoid it? + class WrappedEvents(charm_type.on.__class__): + pass + + WrappedEvents.__name__ = charm_type.on.__class__.__name__ + + class WrappedCharm(charm_type): # type: ignore + on = WrappedEvents() + + WrappedCharm.__name__ = charm_type.__name__ + return WrappedCharm + + @contextmanager + def virtual_charm_root(self): + # If we are using runtime on a real charm, we can make some assumptions about the directory structure + # we are going to find. + # If we're, say, dynamically defining charm types and doing tests on them, we'll have to generate + # the metadata files ourselves. To be sure, we ALWAYS use a tempdir. Ground truth is what the user + # passed via the CharmSpec + spec = self._charm_spec + with tempfile.TemporaryDirectory() as tempdir: + temppath = Path(tempdir) + (temppath / 'metadata.yaml').write_text(yaml.safe_dump(spec.meta)) + (temppath / 'config.yaml').write_text(yaml.safe_dump(spec.config)) + (temppath / 'actions.yaml').write_text(yaml.safe_dump(spec.actions)) + yield temppath + + def play( + self, + scene: "Scene", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> 'State': + """Plays a scene on the charm. + + This will set the environment up and call ops.main.main(). + After that it's up to ops. + """ + logger.info( + f"Preparing to fire {scene.event.name} on {self._charm_type.__name__}" + ) + + # we make a copy to avoid mutating the input scene + scene = scene.copy() + + logger.info(" - generating virtual charm root") + with self.virtual_charm_root() as temporary_charm_root: + with self.patching(scene): + # todo consider forking out a real subprocess and do the mocking by + # generating hook tool callables + + logger.info(" - redirecting root logging") + self._redirect_root_logger() + + logger.info(" - preparing env") + env = self._get_event_env(scene, + charm_root=temporary_charm_root) + os.environ.update(env) + + logger.info(" - Entering ops.main (mocked).") + # we don't import from ops.main because we need some extras, such as the pre/post_event hooks + from scenario.ops_main_mock import main as mocked_main + try: + mocked_main( + self._wrap(self._charm_type), + pre_event=pre_event, + post_event=post_event, + ) + except Exception as e: + raise RuntimeError( + f"Uncaught error in operator/charm code: {e}." + ) from e + finally: + logger.info(" - Exited ops.main.") + + logger.info(" - clearing env") + self._cleanup_env(env) + + logger.info('event fired; done.') + return scene.state diff --git a/scenario/runtime/memo.py b/scenario/runtime/memo.py deleted file mode 100644 index 478eb39f4..000000000 --- a/scenario/runtime/memo.py +++ /dev/null @@ -1,1013 +0,0 @@ -#!/usr/bin/env python3 - -import base64 -import dataclasses -import datetime as DT -import functools -import inspect -import io -import json -import os -import pickle -import typing -import warnings -from contextlib import contextmanager -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Literal, Sequence, Tuple, Union -from uuid import uuid4 - -from scenario.logger import logger as pkg_logger - -logger = pkg_logger.getChild("recorder") - -DEFAULT_DB_NAME = "event_db.json" -USE_STATE_KEY = "MEMO_REPLAY_IDX" -MEMO_REPLAY_INDEX_KEY = "MEMO_REPLAY_IDX" -MEMO_DATABASE_NAME_KEY = "MEMO_DATABASE_NAME" -MEMO_MODE_KEY = "MEMO_MODE" -DEFAULT_NAMESPACE = "" - -# fixme: generalize the serializer by allowing to pass in a (pickled?) arbitrary callable. -# right now we have this PebblePush nonsense because that's the only special case. - -SUPPORTED_SERIALIZERS = Literal["pickle", "json", "io", "PebblePush"] -SUPPORTED_SERIALIZERS_LIST = ["pickle", "json", "io", "PebblePush"] - - -MemoModes = Literal["passthrough", "record", "replay", "isolated"] -_CachingPolicy = Literal["strict", "loose"] - -# notify just once of what mode we're running in -_PRINTED_MODE = False - -# flag to mark when a memo cache's return value is not found as opposed to being None -_NotFound = object() - - -class NotFoundError(RuntimeError): - pass - - -def _check_caching_policy(policy: _CachingPolicy) -> _CachingPolicy: - if policy in {"strict", "loose"}: - return policy - else: - logger.warning( - f"invalid caching policy: {policy!r}. " f"defaulting to `strict`" - ) - return "strict" - - -def _load_memo_mode() -> MemoModes: - global _PRINTED_MODE - - val = os.getenv(MEMO_MODE_KEY, "record") - if val == "passthrough": - # avoid doing anything at all with passthrough, to save time. - pass - if val == "record": - # don't use logger, but print, to avoid recursion issues with juju-log. - if not _PRINTED_MODE: - print("MEMO: recording") - elif val == "replay": - if not _PRINTED_MODE: - print("MEMO: replaying") - elif val == "isolated": - if not _PRINTED_MODE: - print("MEMO: replaying (isolated mode)") - else: - warnings.warn( - f"[ERROR]: MEMO: invalid value ({val!r}). Defaulting to `record`." - ) - _PRINTED_MODE = True - return "record" - - _PRINTED_MODE = True - return typing.cast(MemoModes, val) - - -def _is_bound_method(fn: Any): - try: - return next(iter(inspect.signature(fn).parameters.items()))[0] == "self" - except: - return False - - -def _call_repr( - fn: Callable, - args, - kwargs, -): - """Str repr of memoized function call address.""" - fn_name = getattr(fn, "__name__", str(fn)) - if _self := getattr(fn, "__self__", None): - # it's a method - fn_repr = type(_self).__name__ + fn_name - else: - fn_repr = fn_name - return f"{fn_repr}(*{args}, **{kwargs})" - - -def _log_memo( - fn: Callable, - args, - kwargs, - recorded_output: Any = None, - cache_hit: bool = False, - # use print, not logger calls, else the root logger will recurse if - # juju-log calls are being @memo'd. - log_fn: Callable[[str], None] = print, -): - try: - output_repr = repr(recorded_output) - except: # noqa catchall - output_repr = "" - - trim = output_repr[:100] - trimmed = "[...]" if len(output_repr) > 100 else "" - hit = "hit" if cache_hit else "miss" - - return log_fn( - f"@memo[{hit}]: replaying {_call_repr(fn, args, kwargs)}" - f"\n\t --> {trim}{trimmed}" - ) - - -def _check_serializer( - serializer: Union[ - SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] - ] -) -> Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS]: - if isinstance(serializer, str): - input_serializer = output_serializer = serializer - else: - input_serializer, output_serializer = serializer - - if input_serializer not in SUPPORTED_SERIALIZERS_LIST: - warnings.warn( - f"invalid input serializer name: {input_serializer}; " - f"falling back to `json`." - ) - input_serializer = "json" - if output_serializer not in SUPPORTED_SERIALIZERS_LIST: - warnings.warn( - f"invalid output serializer name: {input_serializer}; " - f"falling back to `json`." - ) - output_serializer = "json" - - return input_serializer, output_serializer - - -def memo( - namespace: str = DEFAULT_NAMESPACE, - name: str = None, - caching_policy: _CachingPolicy = "strict", - log_on_replay: bool = True, - serializer: Union[ - SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] - ] = "json", -): - f"""This decorator wraps a callable and memoizes its calls. - - Based on the value of the {MEMO_MODE_KEY!r} environment variable, it can work in multiple ways: - - - "passthrough": does nothing. As if the decorator wasn't there. Useful as production default, - to minimize the runtime impact of memo. - - "record": each function call gets intercepted, and the [arguments -> return value] mapping is - stored in a database, using `namespace`.`name` as key. The `serializers` arg tells how the args/kwargs and - return value should be serialized, respectively. - Useful for populating a database for replaying/testing purposes. - - "isolated": each function call gets intercepted, and instead of propagating the call to the wrapped function, - the database is searched for a matching argument set. If one is found, the stored return value is - deserialized and returned. If none is found, a RuntimeError is raised. - Useful for replaying in local environments, where propagating the call would result in errors further down. - - "replay": like "isolated", but in case of a cache miss, the call is propagated to the wrapped function - (the database is NOT implicitly updated). - Useful for replaying in 'live' environments where propagating the call would get you the right result. - - `caching_policy` can be either: - - "strict": each function call is stored individually and in an ordered sequence. Useful for when a - function can return different values when called on distinct occasions. - - "loose": the arguments -> return value mapping is stored as a mapping. Assumes that same - arguments == same return value. - """ - - def decorator(fn): - if not inspect.isfunction(fn): - raise RuntimeError(f"Cannot memoize non-function obj {fn!r}.") - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - - _MEMO_MODE: MemoModes = _load_memo_mode() - - def propagate(): - """Make the real wrapped call.""" - if _MEMO_MODE == "isolated": - raise RuntimeError( - f"Attempted propagation in isolated mode: " - f"{_call_repr(fn, args, kwargs)}" - ) - - if _MEMO_MODE == "replay" and log_on_replay: - _log_memo(fn, args, kwargs, "", cache_hit=False) - - # todo: if we are replaying, should we be caching this result? - return fn(*args, **kwargs) - - if _MEMO_MODE == "passthrough": - return propagate() - - input_serializer, output_serializer = _check_serializer(serializer) - - def _load(obj: str, method: SUPPORTED_SERIALIZERS): - if log_on_replay and _MEMO_MODE in ["replay", "isolated"]: - _log_memo(fn, args, kwargs, recorded_output, cache_hit=True) - if method == "pickle": - byt = base64.b64decode(obj) - return pickle.loads(byt) - elif method == "json": - return json.loads(obj) - elif method == "io": - byt = base64.b64decode(obj) - raw = pickle.loads(byt) - byio = io.StringIO(raw) - return byio - raise ValueError(f"Invalid method: {method!r}") - - def _dump(obj: Any, method: SUPPORTED_SERIALIZERS, output_=None): - if method == "pickle": - if isinstance(obj, io.TextIOWrapper): - pass - byt = pickle.dumps(obj) - return base64.b64encode(byt).decode("utf-8") - elif method == "json": - return json.dumps(obj) - elif method == "PebblePush": - _args, _kwargs = obj - assert len(_args) == 2 - path, source = _args - # pebble._Client.push's second argument: - # source: Union[bytes, str, BinaryIO, TextIO] - - if isinstance(source, (bytes, str)): - source_ = pickle.dumps(source) - return _dump(((path, source_), _kwargs), "pickle") - else: # AnyIO: - if not output_: - if _MEMO_MODE == "record": - raise ValueError( - "we serialize AnyIO by just caching the contents. " - "Output required." - ) - # attempt to obtain it by reading the obj - try: - output_ = source.read() - except Exception as e: - raise RuntimeError( - f"Cannot read source: {source}; unable to compare to cache" - ) from e - return _dump(((path, output_), _kwargs), "pickle") - - elif method == "io": - if not hasattr(obj, "read"): - raise TypeError( - "you can only serialize with `io` " - "stuff that has a .read method." - ) - byt = pickle.dumps(obj.read()) - return base64.b64encode(byt).decode("utf-8") - raise ValueError(f"Invalid method: {method!r}") - - def load_from_state( - scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any]] - ): - if not os.getenv(USE_STATE_KEY): - return propagate() - - logger.debug("Attempting to load from state.") - if not hasattr(scene.context, "state"): - logger.warning( - "Context has no state; probably there is a version mismatch." - ) - return propagate() - - if not scene.context.state: - logger.debug("No state found for this call.") - return propagate() - - try: - return get_from_state(scene, question) - except StateError as e: - logger.error(f"Error trying to get_from_state {memo_name}: {e}") - return propagate() - - memoizable_args = args - if args: - if _is_bound_method(fn): - # which means args[0] is `self` - memoizable_args = args[1:] - else: - memoizable_args = args - - # convert args to list for comparison purposes because memos are - # loaded from json, where tuples become lists. - memo_args = list(memoizable_args) - - database = os.environ.get(MEMO_DATABASE_NAME_KEY, DEFAULT_DB_NAME) - if not Path(database).exists(): - raise RuntimeError( - f"Database not found at {database}. " - f"@memo requires a scene to be set." - ) - - with event_db(database) as data: - idx = os.environ.get(MEMO_REPLAY_INDEX_KEY, None) - - strict_caching = _check_caching_policy(caching_policy) == "strict" - - memo_name = f"{namespace}.{name or fn.__name__}" - - if _MEMO_MODE == "record": - memo = data.scenes[-1].context.memos.get(memo_name) - if memo is None: - cpolicy_name = typing.cast( - _CachingPolicy, "strict" if strict_caching else "loose" - ) - memo = Memo( - caching_policy=cpolicy_name, - serializer=(input_serializer, output_serializer), - ) - - output = propagate() - - # we can't hash dicts, so we dump args and kwargs - # regardless of what they are - serialized_args_kwargs = _dump( - (memo_args, kwargs), input_serializer, output_=output - ) - serialized_output = _dump(output, output_serializer) - - memo.cache_call(serialized_args_kwargs, serialized_output) - data.scenes[-1].context.memos[memo_name] = memo - - # if we're in IO mode, output might be a file handle and - # serialized_output might be the b64encoded, pickle repr of its contents. - # We need to mock a file-like stream to return. - if output_serializer == "io": - return _load(serialized_output, "io") - return output - - elif _MEMO_MODE in ["replay", "isolated"]: - if idx is None: - raise RuntimeError( - f"provide a {MEMO_REPLAY_INDEX_KEY} envvar" - "to tell the replay environ which scene to look at" - ) - try: - idx = int(idx) - except TypeError: - raise RuntimeError( - f"invalid idx: ({idx}); expecting an integer." - ) - - try: - memo = data.scenes[idx].context.memos[memo_name] - - except KeyError: - # if no memo is present for this function, that might mean that - # in the recorded session it was not called (this path is new!) - warnings.warn( - f"No memo found for {memo_name}: " f"this path must be new." - ) - return load_from_state( - data.scenes[idx], - (memo_name, memoizable_args, kwargs), - ) - - if not all( - ( - memo.caching_policy == caching_policy, - # loading from yaml makes it a list - ( - ( - memo.serializer - == [input_serializer, output_serializer] - ) - or ( - memo.serializer - == (input_serializer, output_serializer) - ) - ), - ) - ): - warnings.warn( - f"Stored memo params differ from those passed to @memo at runtime. " - f"The database must have been generated by an outdated version of " - f"memo-tools. Falling back to stored memo: \n " - f"\tpolicy: {memo.caching_policy} (vs {caching_policy!r}), \n" - f"\tserializer: {memo.serializer} " - f"(vs {(input_serializer, output_serializer)!r})..." - ) - strict_caching = ( - _check_caching_policy(memo.caching_policy) == "strict" - ) - input_serializer, output_serializer = _check_serializer( - memo.serializer - ) - - # we serialize args and kwargs to compare them with the memo'd ones - fn_args_kwargs = _dump((memo_args, kwargs), input_serializer) - - if strict_caching: - # in strict mode, fn might return different results every time it is called -- - # regardless of the arguments it is called with. So each memo contains a sequence of values, - # and a cursor to keep track of which one is next in the replay routine. - try: - current_cursor = memo.cursor - recording = memo.calls[current_cursor] - memo.cursor += 1 - except IndexError: - # There is a memo, but its cursor is out of bounds. - # this means the current path is calling the wrapped function - # more times than the recorded path did. - # if this happens while replaying locally, of course, game over. - warnings.warn( - f"Memo cursor {current_cursor} out of bounds for {memo_name}: " - f"this path must have diverged. Propagating call..." - ) - return load_from_state( - data.scenes[idx], - (memo_name, memoizable_args, kwargs), - ) - - recorded_args_kwargs, recorded_output = recording - - if recorded_args_kwargs != fn_args_kwargs: - # if this happens while replaying locally, of course, game over. - warnings.warn( - f"memoized {memo_name} arguments ({recorded_args_kwargs}) " - f"don't match the ones received at runtime ({fn_args_kwargs}). " - f"This path has diverged. Propagating call..." - ) - return load_from_state( - data.scenes[idx], - (memo_name, memoizable_args, kwargs), - ) - - return _load( - recorded_output, output_serializer - ) # happy path! good for you, path. - - else: - # in non-strict mode, we don't care about the order in which fn is called: - # it will return values in function of the arguments it is called with, - # regardless of when it is called. - # so all we have to check is whether the arguments are known. - # in non-strict mode, memo.calls is an inputs/output dict. - recorded_output = memo.calls.get(fn_args_kwargs, _NotFound) - if recorded_output is not _NotFound: - return _load( - recorded_output, output_serializer - ) # happy path! good for you, path. - - warnings.warn( - f"No memo for {memo_name} matches the arguments received at runtime. " - f"This path has diverged." - ) - return load_from_state( - data.scenes[idx], - (memo_name, memoizable_args, kwargs), - ) - - else: - msg = f"invalid memo mode: {_MEMO_MODE}" - warnings.warn(msg) - raise ValueError(msg) - - raise RuntimeError("Unhandled memo path.") - - return wrapper - - return decorator - - -class DB: - def __init__(self, file: Path) -> None: - self._file = file - self.data = None - - def load(self): - text = self._file.read_text() - if not text: - logger.debug("database empty; initializing with data=[]") - self.data = Data([]) - return - - try: - raw = json.loads(text) - except json.JSONDecodeError: - raise ValueError(f"database invalid: could not json-decode {self._file}") - - try: - scenes = [Scene.from_dict(obj) for obj in raw.get("scenes", ())] - except Exception as e: - raise RuntimeError( - f"database invalid: could not parse Scenes from {raw['scenes']!r}..." - ) from e - - self.data = Data(scenes) - - def commit(self): - self._file.write_text(json.dumps(asdict(self.data), indent=2)) - - -@dataclass -class Event: - env: Dict[str, str] - timestamp: str = dataclasses.field( - default_factory=lambda: DT.datetime.now().isoformat() - ) - - @property - def name(self): - return self.env["JUJU_DISPATCH_PATH"].split("/")[1] - - @property - def unit_name(self): - return self.env.get("JUJU_UNIT_NAME", "") - - @property - def app_name(self): - unit_name = self.unit_name - return unit_name.split("/")[0] if unit_name else "" - - @property - def datetime(self): - return DT.datetime.fromisoformat(self.timestamp) - - -@dataclass -class Memo: - # todo clean this up by subclassing out to two separate StrictMemo and LooseMemo objects. - # list of (args, kwargs), return-value pairs for this memo - # warning: in reality it's all lists, no tuples. - calls: Union[ - List[Tuple[str, Any]], # if caching_policy == 'strict' - Dict[str, Any], # if caching_policy == 'loose' - ] = field(default_factory=list) - # indicates the position of the replay cursor if we're replaying the memo - cursor: Union[ - int, # if caching_policy == 'strict' - Literal["n/a"], # if caching_policy == 'loose' - ] = 0 - caching_policy: _CachingPolicy = "strict" - serializer: Union[ - SUPPORTED_SERIALIZERS, Tuple[SUPPORTED_SERIALIZERS, SUPPORTED_SERIALIZERS] - ] = "json" - - def __post_init__(self): - if self.caching_policy == "loose" and not self.calls: # first time only! - self.calls = {} - self.cursor = "n/a" - - def cache_call(self, input: str, output: str): - assert isinstance(input, str), input - assert isinstance(output, str), output - - if self.caching_policy == "loose": - self.calls[input] = output - else: - self.calls.append((input, output)) - - -def _random_model_name(): - import random - import string - - space = string.ascii_letters + string.digits - return "".join(random.choice(space) for _ in range(20)) - - -@dataclass -class Model: - name: str = _random_model_name() - uuid: str = str(uuid4()) - - -@dataclass -class ContainerSpec: - name: str - can_connect: bool = False - # todo mock filesystem and pebble proc? - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class Address: - hostname: str - value: str - cidr: str - - -@dataclass -class BindAddress: - mac_address: str - interface_name: str - interfacename: str # legacy - addresses: List[Address] - - -@dataclass -class Network: - bind_addresses: List[BindAddress] - bind_address: str - egress_subnets: List[str] - ingress_addresses: List[str] - - -@dataclass -class NetworkSpec: - name: str - bind_id: int - network: Network - is_default: bool = False - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class RelationMeta: - endpoint: str - interface: str - relation_id: int - remote_app_name: str - remote_unit_ids: List[int] = field(default_factory=lambda: list((0,))) - - # local limit - limit: int = 1 - - # scale of the remote application; number of units, leader ID? - # TODO figure out if this is relevant - scale: int = 1 - leader_id: int = 0 - - @classmethod - def from_dict(cls, obj): - return cls(**obj) - - -@dataclass -class RelationSpec: - meta: RelationMeta - local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_units_data: Dict[str, Dict[str, str]] = dataclasses.field( - default_factory=dict - ) - - @classmethod - def from_dict(cls, obj): - meta = RelationMeta.from_dict(obj.pop("meta")) - return cls(meta=meta, **obj) - - @property - def changed(self): - """Sugar to generate a -changed event.""" - from scenario import structs - - return structs.Event( - self.meta.endpoint + "-changed", meta=structs.EventMeta(relation=self.meta) - ) - - @property - def joined(self): - """Sugar to generate a -joined event.""" - from scenario import structs - - return structs.Event( - self.meta.endpoint + "-joined", meta=structs.EventMeta(relation=self.meta) - ) - - @property - def created(self): - """Sugar to generate a -created event.""" - from scenario import structs - - return structs.Event( - self.meta.endpoint + "-created", meta=structs.EventMeta(relation=self.meta) - ) - - @property - def departed(self): - """Sugar to generate a -departed event.""" - from scenario import structs - - return structs.Event( - self.meta.endpoint + "-departed", meta=structs.EventMeta(relation=self.meta) - ) - - @property - def removed(self): - """Sugar to generate a -removed event.""" - from scenario import structs - - return structs.Event( - self.meta.endpoint + "-removed", meta=structs.EventMeta(relation=self.meta) - ) - - -@dataclass -class Status: - app: Tuple[str, str] = ("unknown", "") - unit: Tuple[str, str] = ("unknown", "") - app_version: str = "" - - @classmethod - def from_dict(cls, obj: dict): - if obj is None: - return cls() - - return cls( - app=tuple(obj.get("app", ("unknown", ""))), - unit=tuple(obj.get("unit", ("unknown", ""))), - app_version=obj.get("app_version", ""), - ) - - -@dataclass -class State: - config: Dict[str, Union[str, int, float, bool]] = None - relations: Sequence[RelationSpec] = field(default_factory=list) - networks: Sequence[NetworkSpec] = field(default_factory=list) - containers: Sequence[ContainerSpec] = field(default_factory=list) - status: Status = field(default_factory=Status) - leader: bool = False - model: Model = Model() - juju_log: Sequence[Tuple[str, str]] = field(default_factory=list) - - # todo: add pebble stuff, unit/app status, etc... - # actions? - # juju topology - - @classmethod - def from_dict(cls, obj): - if obj is None: - return cls() - - return cls( - config=obj["config"], - relations=list( - RelationSpec.from_dict(raw_ard) for raw_ard in obj["relations"] - ), - networks=list(NetworkSpec.from_dict(raw_ns) for raw_ns in obj["networks"]), - containers=list( - ContainerSpec.from_dict(raw_cs) for raw_cs in obj["containers"] - ), - leader=obj.get("leader", False), - status=Status.from_dict(obj.get("status")), - model=Model(**obj.get("model", {})), - ) - - def get_container(self, name) -> ContainerSpec: - try: - return next(filter(lambda c: c.name == name, self.containers)) - except StopIteration as e: - raise NotFoundError(f"container: {name}") from e - - -@dataclass -class Context: - memos: Dict[str, Memo] = field(default_factory=dict) - state: State = None - - @staticmethod - def from_dict(obj: dict): - return Context( - memos={name: Memo(**content) for name, content in obj["memos"].items()}, - state=State.from_dict(obj.get("state")), - ) - - -@dataclass -class Scene: - event: Event - context: Context = Context() - - @staticmethod - def from_dict(obj): - return Scene( - event=Event(**obj["event"]), - context=Context.from_dict(obj.get("context", {})), - ) - - -@dataclass -class Data: - scenes: List[Scene] - - -@contextmanager -def event_db(file=DEFAULT_DB_NAME) -> Generator[Data, None, None]: - path = Path(file) - if not path.exists(): - print(f"Initializing DB file at {path}...") - path.touch(mode=0o666) - path.write_text("{}") # empty json obj - - db = DB(file=path) - db.load() - yield db.data - db.commit() - - -def _capture() -> Event: - return Event(env=dict(os.environ), timestamp=DT.datetime.now().isoformat()) - - -def _reset_replay_cursors(file=DEFAULT_DB_NAME, *scene_idx: int): - """Reset the replay cursor for all scenes, or the specified ones.""" - with event_db(file) as data: - to_reset = (data.scenes[idx] for idx in scene_idx) if scene_idx else data.scenes - for scene in to_reset: - for memo in scene.context.memos.values(): - memo.cursor = 0 - - -def _record_current_event(file) -> Event: - with event_db(file) as data: - scenes = data.scenes - event = _capture() - scenes.append(Scene(event=event)) - return event - - -def setup(file=DEFAULT_DB_NAME): - _MEMO_MODE: MemoModes = _load_memo_mode() - - if _MEMO_MODE == "record": - event = _record_current_event(file) - print(f"Captured event: {event.name}.") - - if _MEMO_MODE in ["replay", "isolated"]: - _reset_replay_cursors() - print(f"Replaying: reset replay cursors.") - - -class StateError(RuntimeError): - pass - - -class QuestionNotImplementedError(StateError): - pass - - -def get_from_state(scene: Scene, question: Tuple[str, Tuple[Any], Dict[str, Any]]): - state = scene.context.state - this_unit_name = scene.event.unit_name - memo_name, call_args, call_kwargs = question - ns, _, meth = memo_name.rpartition(".") - setter = False - - try: - # MODEL BACKEND CALLS - if ns == "_ModelBackend": - if meth == "relation_get": - rel_id, obj_name, app = call_args - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, state.relations) - ) - if app and obj_name == scene.event.app_name: - return relation.local_app_data - elif app: - return relation.remote_app_data - elif obj_name == this_unit_name: - return relation.local_unit_data.get(this_unit_name, {}) - else: - unit_id = obj_name.split("/")[-1] - return relation.remote_units_data[unit_id] - - elif meth == "is_leader": - return state.leader - - elif meth == "status_get": - status, message = ( - state.status.app if call_kwargs.get("app") else state.status.unit - ) - return {"status": status, "message": message} - - elif meth == "relation_ids": - return [rel.meta.relation_id for rel in state.relations] - - elif meth == "relation_list": - rel_id = call_args[0] - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, state.relations) - ) - return tuple( - f"{relation.meta.remote_app_name}/{unit_id}" - for unit_id in relation.meta.remote_unit_ids - ) - - elif meth == "config_get": - return state.config[call_args[0]] - - elif meth == "action_get": - pass - - elif meth == "relation_remote_app_name": - pass - - elif meth == "resource_get": - pass - elif meth == "storage_list": - pass - elif meth == "storage_get": - pass - elif meth == "network_get": - pass - elif meth == "planned_units": - pass - else: - setter = True - - # # setter methods - - if meth == "application_version_set": - state.status.app_version = call_args[0] - return None - - elif meth == "status_set": - status = call_args - if call_kwargs.get("is_app"): - state.status.app = status - else: - state.status.unit = status - return None - - elif meth == "juju_log": - state.juju_log.append(call_args) - return None - - elif meth == "relation_set": - rel_id, key, value, app = call_args - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, state.relations) - ) - if app: - if not state.leader: - raise RuntimeError("needs leadership to set app data") - tgt = relation.local_app_data - else: - tgt = relation.local_unit_data - tgt[key] = value - return None - - elif meth == "action_set": - pass - elif meth == "action_fail": - pass - elif meth == "action_log": - pass - elif meth == "storage_add": - pass - - # todo add - # 'secret_get' - # 'secret_set' - # 'secret_grant' - # 'secret_remove' - - # PEBBLE CALLS - elif ns == "Client": - if meth == "_request": - if call_args == ("GET", "/v1/system-info"): - # fixme: can't differentiate between containers ATM, because Client._request - # does not pass around the container name as argument - if state.containers[0].can_connect: - return {"result": {"version": "unknown"}} - else: - raise FileNotFoundError("") - elif meth == "pull": - pass - elif meth == "push": - setter = True - pass - - else: - raise QuestionNotImplementedError(ns) - except Exception as e: - action = "setting" if setter else "getting" - msg = f"Error {action} state for {ns}.{meth} given ({call_args}, {call_kwargs})" - logger.error(msg) - raise StateError(msg) from e - - raise QuestionNotImplementedError((ns, meth)) diff --git a/scenario/runtime/memo_tools.py b/scenario/runtime/memo_tools.py deleted file mode 100644 index 00a4d760d..000000000 --- a/scenario/runtime/memo_tools.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -import ast -import typing -from dataclasses import dataclass -from pathlib import Path -from textwrap import dedent -from typing import Dict, Literal, Optional, Tuple, Union - -import asttokens -from asttokens.util import Token -from astunparse import unparse - -if typing.TYPE_CHECKING: - from scenario import SUPPORTED_SERIALIZERS - - -@dataclass -class DecorateSpec: - # on strict caching mode, each call will be separately logged. - # use this when calling the same method (with the same arguments) at different times CAN return - # different values. - # Use loose caching mode when the method is guaranteed to return consistent results throughout - # a single charm execution. - caching_policy: Literal["strict", "loose"] = "strict" - - # the memo's namespace will default to the class name it's being defined in - namespace: Optional[str] = None - - # the memo's name will default to the memoized function's __name__ - name: Optional[str] = None - - # (de) serializer for the return object of the decorated function - serializer: Union[ - "SUPPORTED_SERIALIZERS", Tuple["SUPPORTED_SERIALIZERS", "SUPPORTED_SERIALIZERS"] - ] = "json" - - def as_token(self, default_namespace: str) -> Token: - name = f"'{self.name}'" if self.name else "None" - - if isinstance(self.serializer, str): - serializer = f"'{self.serializer}'" - else: - serializer = f"{str(self.serializer)}" - - raw = dedent( - f"""@memo( - name={name}, - namespace='{self.namespace or default_namespace}', - caching_policy='{self.caching_policy}', - serializer={serializer}, - )\ndef foo():...""" - ) - return asttokens.ASTTokens(raw, parse=True).tree.body[0].decorator_list[0] - - -DECORATE_MODEL = { - "_ModelBackend": { - "relation_get": DecorateSpec(), - "relation_set": DecorateSpec(), - "is_leader": DecorateSpec(), # technically could be loose - "application_version_set": DecorateSpec(), - "status_get": DecorateSpec(), - "action_get": DecorateSpec(), - "add_metrics": DecorateSpec(), # deprecated, I guess - "action_set": DecorateSpec(caching_policy="loose"), - "action_fail": DecorateSpec(caching_policy="loose"), - "action_log": DecorateSpec(caching_policy="loose"), - "relation_ids": DecorateSpec(caching_policy="loose"), - "relation_list": DecorateSpec(caching_policy="loose"), - "relation_remote_app_name": DecorateSpec(caching_policy="loose"), - "config_get": DecorateSpec(caching_policy="loose"), - "resource_get": DecorateSpec(caching_policy="loose"), - "storage_list": DecorateSpec(caching_policy="loose"), - "storage_get": DecorateSpec(caching_policy="loose"), - "network_get": DecorateSpec(caching_policy="loose"), - # methods that return None can all be loosely cached - "status_set": DecorateSpec(caching_policy="loose"), - "storage_add": DecorateSpec(caching_policy="loose"), - "juju_log": DecorateSpec(caching_policy="loose"), - "planned_units": DecorateSpec(caching_policy="loose"), - # 'secret_get', - # 'secret_set', - # 'secret_grant', - # 'secret_remove', - } -} - -DECORATE_PEBBLE = { - "Client": { - # todo: we could be more fine-grained and decorate individual Container methods, - # e.g. can_connect, ... just like in _ModelBackend we don't just memo `_run`. - "_request": DecorateSpec(), - # some methods such as pebble.pull use _request_raw directly, - # and deal in objects that cannot be json-serialized - "pull": DecorateSpec(serializer=("json", "io")), - "push": DecorateSpec(serializer=("PebblePush", "json")), - } -} - -IMPORT_BLOCK = "from scenario import memo" -RUNTIME_PATH = str(Path(__file__).parent.absolute()) - -MEMO_IMPORT_BLOCK = dedent( - """# ==== block added by scenario.runtime.memo_tools === -import sys -sys.path.append({runtime_path!r}) # add /path/to/scenario/runtime to the PATH -try: - {import_block} -except ModuleNotFoundError as e: - msg = "scenario not installed. " \ - "This can happen if you're playing with Runtime in a local venv. " \ - "In that case all you have to do is ensure that the PYTHONPATH is patched to include the path to " \ - "scenario before loading this module. " \ - "Tread carefully." - raise RuntimeError(msg) from e -# ==== end block === -""" -).format(import_block=IMPORT_BLOCK, runtime_path=str(RUNTIME_PATH)) - - -def inject_memoizer(source_file: Path, decorate: Dict[str, Dict[str, DecorateSpec]]): - """Rewrite source_file by decorating methods in a number of classes. - - Decorate: a dict mapping class names to methods of that class that should be decorated. - Example:: - >>> inject_memoizer(Path('foo.py'), {'MyClass': { - ... 'do_x': DecorateSpec(), - ... 'is_ready': DecorateSpec(caching_policy='loose'), - ... 'refresh': DecorateSpec(caching_policy='loose'), - ... 'bar': DecorateSpec(caching_policy='loose') - ... }}) - """ - - atok = asttokens.ASTTokens(source_file.read_text(), parse=True).tree - - def _should_decorate_class(token: ast.AST): - return isinstance(token, ast.ClassDef) and token.name in decorate - - for cls in filter(_should_decorate_class, atok.body): - - def _should_decorate_method(token: ast.AST): - return ( - isinstance(token, ast.FunctionDef) and token.name in decorate[cls.name] - ) - - for method in filter(_should_decorate_method, cls.body): - existing_decorators = { - token.first_token.string for token in method.decorator_list - } - # only add the decorator if the function is not already decorated: - if "memo" not in existing_decorators: - spec_token = decorate[cls.name][method.name].as_token( - default_namespace=cls.name - ) - method.decorator_list.append(spec_token) - - unparsed_source = unparse(atok) - if IMPORT_BLOCK not in unparsed_source: - # only add the import if necessary: - unparsed_source = MEMO_IMPORT_BLOCK + unparsed_source - - source_file.write_text(unparsed_source) diff --git a/scenario/runtime/runtime.py b/scenario/runtime/runtime.py deleted file mode 100644 index 465c6a3c0..000000000 --- a/scenario/runtime/runtime.py +++ /dev/null @@ -1,401 +0,0 @@ -import dataclasses -import os -import sys -import tempfile -from pathlib import Path -from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar, Union - -import yaml - -from scenario.event_db import TemporaryEventDB -from scenario.logger import logger as pkg_logger -from scenario.runtime.memo import ( - MEMO_DATABASE_NAME_KEY, - MEMO_MODE_KEY, - MEMO_REPLAY_INDEX_KEY, - USE_STATE_KEY, -) -from scenario.runtime.memo import Event as MemoEvent -from scenario.runtime.memo import MemoModes -from scenario.runtime.memo import Scene as MemoScene -from scenario.runtime.memo import _reset_replay_cursors, event_db -from scenario.runtime.memo_tools import ( - DECORATE_MODEL, - DECORATE_PEBBLE, - IMPORT_BLOCK, - inject_memoizer, -) - -if TYPE_CHECKING: - from ops.charm import CharmBase - from ops.framework import EventBase - from ops.testing import CharmType - - from scenario.structs import CharmSpec, Scene - - _CT = TypeVar("_CT", bound=Type[CharmType]) - -logger = pkg_logger.getChild("runtime") - -RUNTIME_MODULE = Path(__file__).parent - - -@dataclasses.dataclass -class RuntimeRunResult: - charm: "CharmBase" - scene: "Scene" - event: "EventBase" - - -class Runtime: - """Charm runtime wrapper. - - This object bridges a local environment and a charm artifact. - """ - - def __init__( - self, - charm_spec: "CharmSpec", - juju_version: str = "3.0.0", - event_db_path: Optional[Union[Path, str]] = None, - ): - self._event_db_path = Path(event_db_path) if event_db_path else None - self._charm_spec = charm_spec - self._juju_version = juju_version - self._charm_type = charm_spec.charm_type - # TODO consider cleaning up venv on __delete__, but ideally you should be - # running this in a clean venv or a container anyway. - - @staticmethod - def from_local_file( - local_charm_src: Path, - charm_cls_name: str, - ) -> "Runtime": - sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) - - ldict = {} - - try: - exec( - f"from charm import {charm_cls_name} as my_charm_type", globals(), ldict - ) - except ModuleNotFoundError as e: - raise RuntimeError( - f"Failed to load charm {charm_cls_name}. " - f"Probably some dependency is missing. " - f"Try `pip install -r {local_charm_src / 'requirements.txt'}`" - ) from e - - my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] - return Runtime(CharmSpec(my_charm_type)) # TODO add meta, options,... - - @staticmethod - def install(force=False): - """Install the runtime LOCALLY. - - Fine prints: - - this will **REWRITE** your local ops.model module to include a @memo decorator - in front of all hook-tool calls. - - this will mess with your os.environ. - - These operations might not be reversible, so consider your environment corrupted. - You should be calling this in a throwaway venv, and probably a container sandbox. - - Nobody will help you fix your borked env. - Have fun! - """ - - if not force and Runtime._is_installed(): - logger.warning( - "Runtime is already installed. " - "Pass `force=True` if you wish to proceed anyway. " - "Skipping..." - ) - return - - logger.warning( - "Installing Runtime... " - "DISCLAIMER: this **might** (aka: most definitely will) corrupt your venv." - ) - - from ops import pebble - - ops_pebble_module = Path(pebble.__file__) - logger.info(f"rewriting ops.pebble ({ops_pebble_module})") - inject_memoizer(ops_pebble_module, decorate=DECORATE_PEBBLE) - - from ops import model - - ops_model_module = Path(model.__file__) - logger.info(f"rewriting ops.model ({ops_model_module})") - inject_memoizer(ops_model_module, decorate=DECORATE_MODEL) - - @staticmethod - def _is_installed(): - try: - from ops import model - except RuntimeError as e: - # we rewrite ops.model to import memo. - # We try to import ops from here --> circular import. - if e.args[0].startswith("scenario not installed"): - return True - raise e - - model_path = Path(model.__file__) - - if IMPORT_BLOCK not in model_path.read_text(): - logger.error( - f"ops.model ({model_path} does not seem to import runtime.memo.memo" - ) - return False - - try: - from scenario import memo - except ModuleNotFoundError: - logger.error("Could not `import memo`.") - return False - - logger.info(f"Recorder is installed at {model_path}") - return True - - def _redirect_root_logger(self): - # the root logger set up by ops calls a hook tool: `juju-log`. - # that is a problem for us because `juju-log` is itself memoized, which leads to recursion. - def _patch_logger(*args, **kwargs): - logger.debug("Hijacked root logger.") - pass - - from scenario import ops_main_mock - - ops_main_mock.setup_root_logging = _patch_logger - - def _cleanup_env(self, env): - # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. - for key in env: - del os.environ[key] - - @property - def unit_name(self): - meta = self._charm_spec.meta - if not meta: - return "foo/0" - return meta["name"] + "/0" # todo allow override - - def _get_event_env(self, scene: "Scene", charm_root: Path): - env = { - "JUJU_VERSION": self._juju_version, - "JUJU_UNIT_NAME": self.unit_name, - "_": "./dispatch", - "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", - "JUJU_MODEL_NAME": scene.context.state.model.name, - "JUJU_MODEL_UUID": scene.context.state.model.uuid, - "JUJU_CHARM_DIR": str(charm_root.absolute()) - # todo consider setting pwd, (python)path - } - - if scene.event.meta and scene.event.meta.relation: - relation = scene.event.meta.relation - env.update( - { - "JUJU_RELATION": relation.endpoint, - "JUJU_RELATION_ID": str(relation.relation_id), - } - ) - return env - - def _drop_meta(self, charm_root: Path): - logger.debug("Dropping metadata.yaml, config.yaml, actions.yaml...") - (charm_root / "metadata.yaml").write_text(yaml.safe_dump(self._charm_spec.meta)) - if self._charm_spec.actions: - (charm_root / "actions.yaml").write_text( - yaml.safe_dump(self._charm_spec.actions) - ) - if self._charm_spec.config: - (charm_root / "config.yaml").write_text( - yaml.safe_dump(self._charm_spec.config) - ) - - def _get_runtime_env( - self, scene_idx: int, db_path: Path, mode: MemoModes = "replay" - ): - env = {} - env.update( - { - USE_STATE_KEY: "1", - MEMO_REPLAY_INDEX_KEY: str(scene_idx), - MEMO_DATABASE_NAME_KEY: str(db_path), - } - ) - sys.path.append(str(RUNTIME_MODULE.absolute())) - env[MEMO_MODE_KEY] = mode - - os.environ.update(env) # todo consider subprocess - return env - - def _scene_to_memo_scene(self, scene: "Scene", env: dict) -> MemoScene: - """Convert scenario.structs.Scene to Memo.Scene.""" - return MemoScene(event=MemoEvent(env=env), context=scene.context) - - def _wrap(self, charm_type: "_CT") -> "_CT": - # dark sorcery to work around framework using class attrs to hold on to event sources - class WrappedEvents(charm_type.on.__class__): - pass - - WrappedEvents.__name__ = charm_type.on.__class__.__name__ - - class WrappedCharm(charm_type): # type: ignore - on = WrappedEvents() - - WrappedCharm.__name__ = charm_type.__name__ - return WrappedCharm - - def play( - self, - scene: "Scene", - pre_event: Optional[Callable[["_CT"], None]] = None, - post_event: Optional[Callable[["_CT"], None]] = None, - memo_mode: MemoModes = "replay", - ) -> RuntimeRunResult: - """Plays a scene on the charm. - - This will set the environment up and call ops.main.main(). - After that it's up to ops. - """ - if not Runtime._is_installed(): - raise RuntimeError( - "Runtime is not installed. Call `runtime.install()` (and read the fine prints)." - ) - - logger.info( - f"Preparing to fire {scene.event.name} on {self._charm_type.__name__}" - ) - - logger.info(" - preparing env") - with tempfile.TemporaryDirectory() as charm_root: - charm_root_path = Path(charm_root) - env = self._get_event_env(scene, charm_root_path) - self._drop_meta(charm_root_path) - - memo_scene = self._scene_to_memo_scene(scene, env) - with TemporaryEventDB(memo_scene, charm_root) as db_path: - env.update(self._get_runtime_env(0, db_path, mode=memo_mode)) - - logger.info(" - redirecting root logging") - self._redirect_root_logger() - - # logger.info("Resetting scene {} replay cursor.") - # _reset_replay_cursors(self._local_db_path, 0) - os.environ.update(env) - - # we don't import from ops because we need some extra return statements. - # see https://github.com/canonical/operator/pull/862 - # from ops.main import main - from scenario.ops_main_mock import main - - logger.info(" - Entering ops.main (mocked).") - - try: - charm, event = main( - self._wrap(self._charm_type), - pre_event=pre_event, - post_event=post_event, - ) - except Exception as e: - raise RuntimeError( - f"Uncaught error in operator/charm code: {e}." - ) from e - finally: - logger.info(" - Exited ops.main.") - - logger.info(" - clearing env") - self._cleanup_env(env) - - with event_db(db_path) as data: - scene_out = data.scenes[0] - - return RuntimeRunResult(charm, scene_out, event) - - def replay( - self, - index: int, - pre_event: Optional[Callable[["_CT"], None]] = None, - post_event: Optional[Callable[["_CT"], None]] = None, - ) -> RuntimeRunResult: - """Replays a stored scene by index. - - This requires having a statically defined event DB. - """ - if not Runtime._is_installed(): - raise RuntimeError( - "Runtime is not installed. Call `runtime.install()` (and read the fine prints)." - ) - if not self._event_db_path: - raise ValueError( - "No event_db_path set. Pass one to the Runtime constructor." - ) - - logger.info(f"Preparing to fire scene #{index} on {self._charm_type.__name__}") - logger.info(" - redirecting root logging") - self._redirect_root_logger() - - logger.info(" - setting up temporary charm root") - with tempfile.TemporaryDirectory() as charm_root: - charm_root_path = Path(charm_root).absolute() - self._drop_meta(charm_root_path) - - # extract the env from the scene - with event_db(self._event_db_path) as data: - logger.info( - f" - resetting scene {index} replay cursor." - ) # just in case - _reset_replay_cursors(self._event_db_path, index) - - logger.info(" - preparing env") - scene = data.scenes[index] - env = dict(scene.event.env) - # declare the charm root for ops to pick up - env["JUJU_CHARM_DIR"] = str(charm_root_path) - - # inject the memo envvars - env.update( - self._get_runtime_env( - index, - self._event_db_path, - # set memo to isolated mode so that we raise - # instead of propagating: it'd be useless - # anyway in most cases TODO generalize? - mode="isolated", - ) - ) - os.environ.update(env) - - # we don't import from ops because we need some extra return statements. - # see https://github.com/canonical/operator/pull/862 - # from ops.main import main - from scenario.ops_main_mock import main - - logger.info(" - Entering ops.main (mocked).") - - try: - charm, event = main( - self._wrap(self._charm_type), - pre_event=pre_event, - post_event=post_event, - ) - except Exception as e: - raise RuntimeError( - f"Uncaught error in operator/charm code: {e}." - ) from e - finally: - logger.info(" - Exited ops.main.") - - logger.info(" - cleaning up env") - self._cleanup_env(env) - - return RuntimeRunResult(charm, scene, event) - - -if __name__ == "__main__": - # install Runtime **in your current venv** so that all - # relevant pebble.Client | model._ModelBackend juju/container-facing calls are - # @memo-decorated and can be used in "replay" mode to reproduce a remote run. - Runtime.install(force=False) diff --git a/scenario/runtime/scripts/install.py b/scenario/runtime/scripts/install.py deleted file mode 100644 index f842bc497..000000000 --- a/scenario/runtime/scripts/install.py +++ /dev/null @@ -1,16 +0,0 @@ -#! /bin/python3 -import sys -from pathlib import Path - - -def install_runtime(): - runtime_path = Path(__file__).parent.parent.parent.parent - sys.path.append(str(runtime_path)) # allow 'import memo' - - from scenario import Runtime - - Runtime.install(force=False) # ensure Runtime is installed - - -if __name__ == "__main__": - install_runtime() diff --git a/scenario/scenario.py b/scenario/scenario.py index 5e973aae1..caa1e7476 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -1,189 +1,54 @@ -import json -import os import typing -from contextlib import contextmanager -from dataclasses import asdict +from itertools import chain from typing import ( - Any, Callable, - Dict, Iterable, - List, Optional, TextIO, Type, - TypeVar, Union, ) -from scenario import Runtime -from scenario.consts import ( +from scenario.runtime import Runtime +from scenario.logger import logger as scenario_logger +from scenario.structs import ( ATTACH_ALL_STORAGES, BREAK_ALL_RELATIONS, CREATE_ALL_RELATIONS, DETACH_ALL_STORAGES, META_EVENTS, ) -from scenario.logger import logger as pkg_logger -from scenario.runtime import memo -from scenario.structs import CharmSpec, Context, Event, InjectRelation, Scene, State +from scenario.structs import CharmSpec, Event, InjectRelation, Scene, State if typing.TYPE_CHECKING: - from ops.charm import CharmBase - from ops.framework import BoundEvent, EventBase from ops.testing import CharmType - _CT = TypeVar("_CT", bound=Type[CharmType]) - CharmMeta = Optional[Union[str, TextIO, dict]] -AssertionType = Callable[["BoundEvent", "Context", "Emitter"], Optional[bool]] - -logger = pkg_logger.getChild("scenario") - - -class Emitter: - """Event emitter.""" - - def __init__(self, emit: Callable[[], "BoundEvent"]): - self._emit = emit - self.event = None - self._emitted = False - - @property - def emitted(self): - """Has the event been emitted already?""" # noqa - return self._emitted - - def emit(self): - """Emit the event. - - Will get called automatically when the context exits if you didn't call it already. - """ - if self._emitted: - raise RuntimeError("already emitted; should not emit twice") - - self._emitted = True - self.event = self._emit() - return self.event - - -def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): - return sorted(patch, key=key) - - -class PlayResult: - def __init__( - self, - charm: "CharmBase", - scene_in: "Scene", # todo: consider removing - event: "EventBase", - context_out: "Context", - ): - self.charm = charm - self.scene_in = scene_in - self.context_out = context_out - self.event = event - - def delta(self): - try: - import jsonpatch - except ModuleNotFoundError: - raise ImportError( - "cannot import jsonpatch: using the .delta() " - "extension requires jsonpatch to be installed." - "Fetch it with pip install jsonpatch." - ) - if self.scene_in.context == self.context_out: - return None - patch = jsonpatch.make_patch( - asdict(self.scene_in.context), asdict(self.context_out) - ).patch - return sort_patch(patch) - - -class Playbook: - def __init__(self, scenes: Iterable[Scene]): - self._scenes = list(scenes) - self._cursor = 0 - - def __bool__(self): - return bool(self._scenes) - - @property - def is_done(self): - return self._cursor < (len(self._scenes) - 1) - - def add(self, scene: Scene): - self._scenes.append(scene) - - def next(self): - self.scroll(1) - return self._scenes[self._cursor] - - def scroll(self, n): - if not 0 <= self._cursor + n <= len(self._scenes): - raise RuntimeError(f"Cursor out of bounds: can't scroll ({self}) by {n}.") - self._cursor += n - - def restart(self): - self._cursor = 0 - - def __repr__(self): - return f"" - - def __iter__(self): - yield from self._scenes - - def __next__(self): - return self.next() - - def to_dict(self) -> Dict[str, List[Any]]: - """Serialize.""" - return {"scenes": [asdict(scene) for scene in self._scenes]} - - def to_json(self) -> str: - """Dump as json dict.""" - return json.dumps(self.to_dict(), indent=2) - - @staticmethod - def load(s: str) -> "Playbook": - obj = json.loads(s) - scenes = tuple(Scene.from_dict(raw_scene) for raw_scene in obj["scenes"]) - return Playbook(scenes=scenes) +logger = scenario_logger.getChild("scenario") class Scenario: def __init__( - self, - charm_spec: CharmSpec, - playbook: Playbook = Playbook(()), - juju_version: str = "3.0.0", + self, + charm_spec: CharmSpec, + juju_version: str = "3.0.0", ): - self._playbook = playbook - self._charm_spec = charm_spec - self._charm_type = charm_spec.charm_type - self._runtime = Runtime(charm_spec, juju_version=juju_version) + self._runtime = Runtime(charm_spec, + juju_version=juju_version) - @property - def playbook(self) -> Playbook: - return self._playbook - - def reset(self): - self._playbook.restart() - - def _play_meta(self, event: Event, context: Context, add_to_playbook: bool = False): + @staticmethod + def decompose_meta_event(meta_event: Event, state: State): # decompose the meta event - events = [] - if event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: - logger.warning(f"meta-event {event.name} not supported yet") + if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: + logger.warning(f"meta-event {meta_event.name} not supported yet") return - if event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: - for relation in context.state.relations: - evt = Event( - relation.meta.endpoint + META_EVENTS[event.name], + if meta_event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: + for relation in state.relations: + event = Event( + relation.meta.endpoint + META_EVENTS[meta_event.name], args=( # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. # this will be replaced by a Relation instance before the event is fired. @@ -192,149 +57,80 @@ def _play_meta(self, event: Event, context: Context, add_to_playbook: bool = Fal ), ), ) - events.append(evt) - else: - raise RuntimeError(f"unknown meta-event {event.name}") + logger.debug(f"decomposed meta {meta_event.name}: {event}") + yield event - logger.debug(f"decomposed meta {event.name} into {events}") - last = None - - for event in events: - scene = Scene(event, context) - last = self.play(scene, add_to_playbook=add_to_playbook) - - return last + else: + raise RuntimeError(f"unknown meta-event {meta_event.name}") def play( - self, - scene: Scene, - add_to_playbook: bool = False, - pre_event: Optional[Callable[["_CT"], None]] = None, - post_event: Optional[Callable[["_CT"], None]] = None, - memo_mode: memo.MemoModes = "replay", - ) -> PlayResult: - result = self._runtime.play( - scene, pre_event=pre_event, post_event=post_event, memo_mode=memo_mode - ) - # todo verify that if state was mutated, it was mutated + self, + scene: Scene, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> "State": + # TODO check state transition consistency: verify that if state was mutated, it was # in a way that makes sense: # e.g. - charm cannot modify leadership status, etc... - if add_to_playbook: - # so we can later export it - self._playbook.add(scene) - - return PlayResult( - charm=result.charm, - scene_in=scene, - context_out=result.scene.context, - event=result.event, - ) - - def play_until_complete(self) -> List[PlayResult]: - """Plays every scene in the Playbook and returns a list of results.""" - if not self._playbook: - raise RuntimeError("playbook is empty") - - results = [] - for scene in self._playbook: - result = self.play(scene) - results.append(result) - - return results - - -def events_to_scenes( - events: typing.Sequence[Union[str, Event]], context: Optional[Context] = None -): - def _to_event(obj): - if isinstance(obj, str): - return Event(obj) - elif isinstance(obj, Event): - return obj - else: - raise TypeError(obj) - - scenes = map(Scene, map(_to_event, events)) - for i, scene in enumerate(scenes): - scene.name = f"" - scene.context = context - yield scene - - -class StartupScenario(Scenario): - def __init__( - self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0" - ): - playbook: Playbook = Playbook( - events_to_scenes( - ( - ATTACH_ALL_STORAGES, - "start", - CREATE_ALL_RELATIONS, - "leader-elected" if leader else "leader-settings-changed", - "config-changed", - "install", - ), - context=Context(state=State(leader=leader)), - ) + return self._runtime.play( + scene, + pre_event=pre_event, + post_event=post_event, ) - super().__init__(charm_spec, playbook, juju_version) -class TeardownScenario(Scenario): - def __init__( - self, charm_spec: CharmSpec, leader: bool = True, juju_version: str = "3.0.0" - ): - playbook: Playbook = Playbook( - events_to_scenes( - (BREAK_ALL_RELATIONS, - DETACH_ALL_STORAGES, - "stop", - "remove"), - context=Context(state=State(leader=leader)), - ) +def generate_startup_scenes(state_template: State): + yield from ( + Scene(event=Event(ATTACH_ALL_STORAGES), state=state_template.copy()), + Scene(event=Event("start"), state=state_template.copy()), + Scene(event=Event(CREATE_ALL_RELATIONS), state=state_template.copy()), + Scene(event=Event("leader-elected" if state_template.leader else "leader-settings-changed"), + state=state_template.copy()), + Scene(event=Event("config-changed"), state=state_template.copy()), + Scene(event=Event("install"), state=state_template.copy()), + ) + + +def generate_teardown_scenes(state_template: State): + yield from ( + Scene(event=Event(BREAK_ALL_RELATIONS), state=state_template.copy()), + Scene(event=Event(DETACH_ALL_STORAGES), state=state_template.copy()), + Scene(event=Event("stop"), state=state_template.copy()), + Scene(event=Event("remove"), state=state_template.copy()), + ) + + +def generate_builtin_scenes(template_states: Iterable[State]): + for template_state in template_states: + yield from chain( + generate_startup_scenes(template_state), + generate_teardown_scenes(template_state) ) - super().__init__(charm_spec, playbook, juju_version) -def check_builtin_sequences(charm_spec: CharmSpec, leader: Optional[bool] = None): - """Test that the builtin StartupScenario and TeardownScenario pass. +def check_builtin_sequences( + charm_spec: CharmSpec, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, +): + """Test that all the builtin startup and teardown events can fire without errors. This will play both scenarios with and without leadership, and raise any exceptions. If leader is True, it will exclude the non-leader cases, and vice-versa. - """ - - out = { - "startup": {}, - "teardown": {}, - } - if leader in {True, None}: - out["startup"][True] = StartupScenario( - charm_spec=charm_spec, leader=True - ).play_until_complete() - out["teardown"][True] = TeardownScenario( - charm_spec=charm_spec, leader=True - ).play_until_complete() - if leader in {False, None}: - out["startup"][False] = StartupScenario( - charm_spec=charm_spec, leader=False - ).play_until_complete() - out["teardown"][False] = TeardownScenario( - charm_spec=charm_spec, leader=False - ).play_until_complete() - return out + This is a baseline check that in principle all charms (except specific use-cases perhaps), + should pass out of the box. - -@contextmanager -def memo_mode(mode: memo.MemoModes): - previous = os.getenv(memo.MEMO_MODE_KEY) - os.environ[memo.MEMO_MODE_KEY] = mode - - yield - - if previous: - os.environ[memo.MEMO_MODE_KEY] = previous - else: - os.unsetenv(memo.MEMO_MODE_KEY) + If you want to, you can inject more stringent state checks using the + pre_event and post_event hooks. + """ + scenario = Scenario(charm_spec) + + for scene in generate_builtin_scenes(( + State(leader=True), + State(leader=False), + )): + scenario.play(scene, + pre_event=pre_event, + post_event=post_event) diff --git a/scenario/structs.py b/scenario/structs.py index 0c0248f36..c61edd7b2 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,143 +1,188 @@ +import copy import dataclasses import typing -from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, Type +from typing import Any, Dict, List, Literal, Sequence, Tuple, Union +from typing import Optional, Type +from uuid import uuid4 + +from scenario.logger import logger as scenario_logger if typing.TYPE_CHECKING: try: from typing import Self except ImportError: from typing_extensions import Self + from ops.testing import CharmType -from scenario.consts import META_EVENTS -from scenario.runtime import memo +logger = scenario_logger.getChild('structs') -if typing.TYPE_CHECKING: - from ops.testing import CharmType +ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" +CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" +BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" +DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" +META_EVENTS = { + "CREATE_ALL_RELATIONS": "-relation-created", + "BREAK_ALL_RELATIONS": "-relation-broken", + "DETACH_ALL_STORAGES": "-storage-detaching", + "ATTACH_ALL_STORAGES": "-storage-attached", +} -@dataclass -class DCBase: +@dataclasses.dataclass +class _DCBase: def replace(self, *args, **kwargs): return dataclasses.replace(self, *args, **kwargs) def copy(self) -> "Self": - return dataclasses.replace(self) + return copy.deepcopy(self) + + +@dataclasses.dataclass +class RelationMeta(_DCBase): + endpoint: str + interface: str + relation_id: int + remote_app_name: str + remote_unit_ids: List[int] = dataclasses.field(default_factory=lambda: list((0,))) + + # local limit + limit: int = 1 + + # scale of the remote application; number of units, leader ID? + # TODO figure out if this is relevant + scale: int = 1 + leader_id: int = 0 + + +@dataclasses.dataclass +class RelationSpec(_DCBase): + meta: "RelationMeta" + local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( + default_factory=dict + ) + @property + def changed_event(self): + """Sugar to generate a -changed event.""" + return Event( + name=self.meta.endpoint + "-changed", + meta=EventMeta(relation=self.meta) + ) -# from show-relation! -@dataclass -class RelationMeta(memo.RelationMeta, DCBase): - pass + @property + def joined_event(self): + """Sugar to generate a -joined event.""" + return Event( + name=self.meta.endpoint + "-joined", + meta=EventMeta(relation=self.meta) + ) + @property + def created_event(self): + """Sugar to generate a -created event.""" + return Event( + name=self.meta.endpoint + "-created", + meta=EventMeta(relation=self.meta) + ) -@dataclass -class RelationSpec(memo.RelationSpec, DCBase): - pass + @property + def departed_event(self): + """Sugar to generate a -departed event.""" + return Event( + name=self.meta.endpoint + "-departed", + meta=EventMeta(relation=self.meta) + ) + @property + def removed_event(self): + """Sugar to generate a -removed event.""" + return Event( + name=self.meta.endpoint + "-removed", + meta=EventMeta(relation=self.meta) + ) -@dataclass -class EventMeta(DCBase): - relation: RelationMeta = ( - None # if this is a relation event, the metadata of the relation - ) +def _random_model_name(): + import random + import string -@dataclass -class Event(DCBase): - name: str - args: Tuple[Any] = () - kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - meta: EventMeta = None + space = string.ascii_letters + string.digits + return "".join(random.choice(space) for _ in range(20)) - @property - def is_meta(self): - return self.name in META_EVENTS - @classmethod - def from_dict(cls, obj): - return cls(**obj) +@dataclasses.dataclass +class Model(_DCBase): + name: str = _random_model_name() + uuid: str = str(uuid4()) - def as_scene(self, state: "State") -> "Scene": - """Utility to get to a single-event Scenario from a single event instance.""" - return Scene(context=Context(state=state), event=self) +@dataclasses.dataclass +class ContainerSpec(_DCBase): + name: str + can_connect: bool = False + # todo mock filesystem and pebble proc? -def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[int] = (0,), - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, - remote_units_data: Dict[str, Dict[str, str]] = None, -): - """Helper function to construct a RelationMeta object with some sensible defaults.""" - metadata = RelationMeta( - endpoint=endpoint, - interface=interface, - remote_app_name=remote_app_name, - remote_unit_ids=list(remote_unit_ids), - relation_id=relation_id, - ) - return RelationSpec( - meta=metadata, - local_unit_data=local_unit_data or {}, - local_app_data=local_app_data or {}, - remote_app_data=remote_app_data or {}, - remote_units_data=remote_units_data or {}, - ) +@dataclasses.dataclass +class Address(_DCBase): + hostname: str + value: str + cidr: str -def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), -) -> memo.Network: - """Construct a network object.""" - return memo.Network( - bind_addresses=[ - memo.BindAddress( - mac_address=mac_address, - interface_name=interface_name, - interfacename=interface_name, - addresses=[ - memo.Address(hostname=hostname, value=private_address, cidr=cidr) - ], - ) - ], - bind_address=private_address, - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) +@dataclasses.dataclass +class BindAddress(_DCBase): + mac_address: str + interface_name: str + interfacename: str # noqa legacy + addresses: List[Address] -@dataclass -class NetworkSpec(memo.NetworkSpec, DCBase): - pass + +@dataclasses.dataclass +class Network(_DCBase): + bind_addresses: List[BindAddress] + bind_address: str + egress_subnets: List[str] + ingress_addresses: List[str] -@dataclass -class ContainerSpec(memo.ContainerSpec, DCBase): +@dataclasses.dataclass +class NetworkSpec(_DCBase): + name: str + bind_id: int + network: Network + is_default: bool = False + @classmethod def from_dict(cls, obj): return cls(**obj) -@dataclass -class Model(memo.Model, DCBase): - pass +@dataclasses.dataclass +class Status(_DCBase): + app: Tuple[str, str] = ("unknown", "") + unit: Tuple[str, str] = ("unknown", "") + app_version: str = "" -@dataclass -class State(memo.State, DCBase): +@dataclasses.dataclass +class State(_DCBase): + config: Dict[str, Union[str, int, float, bool]] = None + relations: Sequence[RelationSpec] = dataclasses.field(default_factory=list) + networks: Sequence[NetworkSpec] = dataclasses.field(default_factory=list) + containers: Sequence[ContainerSpec] = dataclasses.field(default_factory=list) + status: Status = dataclasses.field(default_factory=Status) + leader: bool = False + model: Model = Model() + juju_log: Sequence[Tuple[str, str]] = dataclasses.field(default_factory=list) + + # todo: add pebble stuff, unit/app status, etc... + # actions? + # juju topology + def with_can_connect(self, container_name: str, can_connect: bool): def replacer(container: ContainerSpec): if container.name == container_name: @@ -155,9 +200,30 @@ def with_unit_status(self, status: str, message: str): status=dataclasses.replace(self.status, unit=(status, message)) ) + def get_container(self, name) -> ContainerSpec: + try: + return next(filter(lambda c: c.name == name, self.containers)) + except StopIteration as e: + raise ValueError(f"container: {name}") from e + + def delta(self, other: "State"): + try: + import jsonpatch + except ModuleNotFoundError: + logger.error( + "cannot import jsonpatch: using the .delta() " + "extension requires jsonpatch to be installed." + "Fetch it with pip install jsonpatch." + ) + return NotImplemented + patch = jsonpatch.make_patch( + dataclasses.asdict(other), dataclasses.asdict(self) + ).patch + return sort_patch(patch) + -@dataclass -class CharmSpec: +@dataclasses.dataclass +class CharmSpec(_DCBase): """Charm spec.""" charm_type: Type["CharmType"] @@ -165,9 +231,28 @@ class CharmSpec: actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None - -@dataclass -class Memo(DCBase): + # todo: implement + # @staticmethod + # def from_charm_type(charm_type: Type["CharmType"]): + # charm_source_path = Path(inspect.getfile(charm_type)) + # charm_root = charm_source_path.parent.parent + # + # meta = yaml.safe_load(charm_root / 'metadata.yaml') + # config_file = charm_root / 'config.yaml' + # + # if config_file.exists(): + # config = yaml.safe_load(config_file) + # + # return CharmSpec( + # charm_type=charm_type, + # meta=meta, + # actions=actions, + # config=config + # ) + + +@dataclasses.dataclass +class Memo(_DCBase): calls: Dict[str, Any] cursor: int = 0 caching_policy: Literal["loose", "strict"] = "strict" @@ -177,54 +262,71 @@ def from_dict(cls, obj): return Memo(**obj) -@dataclass -class Context(DCBase): - memos: Dict[str, Memo] = dataclasses.field(default_factory=dict) - state: State = dataclasses.field(default_factory=State) +def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): + return sorted(patch, key=key) - @classmethod - def from_dict(cls, obj): - if obj is None: - return Context() - return cls( - memos={x: Memo.from_dict(m) for x, m in obj.get("memos", {}).items()}, - state=State.from_dict(obj.get("state")), - ) - def to_dict(self): - return dataclasses.asdict(self) +@dataclasses.dataclass +class EventMeta(_DCBase): + # if this is a relation event, the metadata of the relation + relation: Optional[RelationMeta] = None + # todo add other meta for + # - secret events + # - pebble? + # - action? - def with_can_connect(self, container_name: str, can_connect: bool): - return self.replace( - state=self.state.with_can_connect(container_name, can_connect) - ) - def with_leadership(self, leader: bool): - return self.replace(state=self.state.with_leadership(leader)) +@dataclasses.dataclass +class Event(_DCBase): + name: str + args: Tuple[Any] = () + kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + meta: EventMeta = None - def with_unit_status(self, status: str, message: str): - return self.replace(state=self.state.with_unit_status(status, message)) + @property + def is_meta(self): + """Is this a meta event?""" + return self.name in META_EVENTS - def with_relations(self, relations: Iterable[RelationSpec]): - return self.replace(state=self.state.replace(relations=tuple(relations))) +# @dataclass +# class Event: +# env: Dict[str, str] +# +# @property +# def name(self): +# return self.env["JUJU_DISPATCH_PATH"].split("/")[1] +# +# @property +# def unit_name(self): +# return self.env.get("JUJU_UNIT_NAME", "") +# +# @property +# def app_name(self): +# unit_name = self.unit_name +# return unit_name.split("/")[0] if unit_name else "" +# + +@dataclasses.dataclass +class SceneMeta(_DCBase): + unit_id: str = '0' + app_name: str = 'local' -@dataclass -class Scene(DCBase): - event: Event - context: Context = dataclasses.field(default_factory=Context) + @property + def unit_name(self): + return self.app_name + '/' + self.unit_id - @classmethod - def from_dict(cls, obj): - evt = obj["event"] - return cls( - event=Event(evt) if isinstance(evt, str) else Event.from_dict(evt), - context=Context.from_dict(obj.get("context")), - ) + +@dataclasses.dataclass +class Scene(_DCBase): + event: Event + state: State = dataclasses.field(default_factory=State) + # data that doesn't belong to the event nor + meta: SceneMeta = SceneMeta() -@dataclass -class Inject: +@dataclasses.dataclass +class Inject(_DCBase): """Base class for injectors: special placeholders used to tell harness_ctx to inject instances that can't be retrieved in advance in event args or kwargs. """ @@ -232,12 +334,79 @@ class Inject: pass -@dataclass +@dataclasses.dataclass class InjectRelation(Inject): relation_name: str relation_id: Optional[int] = None +def relation( + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[int] = None, # defaults to (0,) if remote_units_data is not provided + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, + remote_units_data: Dict[int, Dict[str, str]] = None, +): + """Helper function to construct a RelationMeta object with some sensible defaults.""" + if remote_unit_ids and remote_units_data: + if not set(remote_unit_ids) == set(remote_units_data): + raise ValueError(f"{remote_unit_ids} should include any and all IDs from {remote_units_data}") + elif remote_unit_ids: + remote_units_data = {x: {} for x in remote_unit_ids} + elif remote_units_data: + remote_unit_ids = [x for x in remote_units_data] + else: + remote_unit_ids = [0] + remote_units_data = {0: {}} + + metadata = RelationMeta( + endpoint=endpoint, + interface=interface, + remote_app_name=remote_app_name, + remote_unit_ids=remote_unit_ids, + relation_id=relation_id, + ) + return RelationSpec( + meta=metadata, + local_unit_data=local_unit_data or {}, + local_app_data=local_app_data or {}, + remote_app_data=remote_app_data or {}, + remote_units_data=remote_units_data, + ) + + +def network( + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), +) -> Network: + """Construct a network object.""" + return Network( + bind_addresses=[ + BindAddress( + mac_address=mac_address, + interface_name=interface_name, + interfacename=interface_name, + addresses=[ + Address(hostname=hostname, value=private_address, cidr=cidr) + ], + ) + ], + bind_address=private_address, + egress_subnets=list(egress_subnets), + ingress_addresses=list(ingress_addresses), + ) + + def _derive_args(event_name: str): args = [] terms = { diff --git a/scenario/version.py b/scenario/version.py deleted file mode 100644 index 5f1541084..000000000 --- a/scenario/version.py +++ /dev/null @@ -1 +0,0 @@ -version = "0.1" diff --git a/tests/memo_tools_test_files/mock_ops.py b/tests/memo_tools_test_files/mock_ops.py deleted file mode 100644 index 7f99d665f..000000000 --- a/tests/memo_tools_test_files/mock_ops.py +++ /dev/null @@ -1,19 +0,0 @@ -import random - -from recorder import memo - - -class _ModelBackend: - def _private_method(self): - pass - - def other_method(self): - pass - - @memo - def action_set(self, *args, **kwargs): - return str(random.random()) - - @memo - def action_get(self, *args, **kwargs): - return str(random.random()) diff --git a/tests/memo_tools_test_files/prom-0-update-status.json b/tests/memo_tools_test_files/prom-0-update-status.json deleted file mode 100644 index 15ae9bdab..000000000 --- a/tests/memo_tools_test_files/prom-0-update-status.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "event": { - "env": { - "JUJU_UNIT_NAME": "prom/0", - "KUBERNETES_SERVICE_PORT": "443", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "JUJU_VERSION": "3.0-beta4", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "prom/0-run-commands-6599488296390959740", - "SHLVL": "1", - "JUJU_API_ADDRESSES": "10.152.183.162:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-prom-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/update-status", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-prom-0/charm", - "_": "./dispatch", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "PATH": "/var/lib/juju/tools/unit-prom-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "LANG": "C.UTF-8", - "JUJU_HOOK_NAME": "", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "JUJU_MODEL_UUID": "62f2bf65-4e77-445b-85d3-e95b02c2a720", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-prom-0/charm", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_MACHINE_ID": "", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-prom-0/charm" - }, - "timestamp": "2022-10-13T09:28:46.116952" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.3 up and running.\"], {}]": null, - "[[\"DEBUG\", \"Emitting Juju event update_status.\"], {}]": null - }, - "cursor": 0, - "caching_policy": "loose" - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": { - "evaluation_interval": "1m", - "log_level": "info", - "maximum_retention_size": "80%", - "metrics_retention_time": "15d", - "metrics_wal_compression": false, - "web_external_url": "" - } - }, - "cursor": 0, - "caching_policy": "loose" - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"self-metrics-endpoint\"], {}]": [] - }, - "cursor": 0, - "caching_policy": "loose" - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - [ - [], - {} - ], - false - ] - ], - "cursor": 1, - "caching_policy": "strict" - }, - "_ModelBackend.status_get": { - "calls": [ - [ - [ - [], - { - "is_app": false - } - ], - { - "message": "", - "status": "active", - "status-data": {} - } - ] - ], - "cursor": 1, - "caching_policy": "strict" - } - } - } -} diff --git a/tests/memo_tools_test_files/trfk-re-relate.json b/tests/memo_tools_test_files/trfk-re-relate.json deleted file mode 100644 index 5c5e42960..000000000 --- a/tests/memo_tools_test_files/trfk-re-relate.json +++ /dev/null @@ -1,2251 +0,0 @@ -{ - "scenes": [ - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-3853912271400585799", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/0", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:2", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "JUJU_DEPARTING_UNIT": "prom/0", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:11.549417" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[2]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[2], {}]": "[\"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-7784712693186482924", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/1", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:2", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "JUJU_DEPARTING_UNIT": "prom/1", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:12.150858" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[2]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[2], {}]": "[]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_remote_app_name": { - "calls": { - "[[2], {}]": "\"prom\"" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-broken-3056476173106073839", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-broken", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:2", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-broken", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:12.747513" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-broken does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_broken.\"], {}]": "null", - "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:2' relation\"], {}]": "null", - "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml ingress configuration file\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[2]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[2], {}]": "[]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_remote_app_name": { - "calls": { - "[[2], {}]": "\"prom\"" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml\", \"recursive\": true}]}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_2_prom.yaml\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[2, \"ingress\", \"\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-created-4762862175976873534", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-created", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-created", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:17.000591" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-created does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_created.\"], {}]": "null", - "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:3' relation\"], {}]": "null", - "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml ingress configuration file\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_remote_app_name": { - "calls": { - "[[3], {}]": "\"prom\"" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\", \"recursive\": true}]}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-joined-6745869886287524665", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-joined", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/1", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-joined", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:17.643738" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-joined does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_joined.\"], {}]": "null", - "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", - "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_get": { - "calls": { - "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"trfk\", true], {}]": "{}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ], - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.status_set": { - "calls": { - "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", - "[[\"active\", \"\"], {\"is_app\": false}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "Client.push": { - "calls": [ - [ - "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "PebblePush", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-changed-6061150792313454742", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/1", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-changed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:18.375687" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", - "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", - "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_get": { - "calls": { - "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"trfk\", true], {}]": "{}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ], - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.status_set": { - "calls": { - "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", - "[[\"active\", \"\"], {\"is_app\": false}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 4, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "Client.push": { - "calls": [ - [ - "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "PebblePush", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_SERVICE_PORT": "443", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-run-commands-4164421675469075254", - "SHLVL": "1", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "_": "./dispatch", - "TERM": "tmux-256color", - "JUJU_RELATION": "ingress-per-unit", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "3", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "LANG": "C.UTF-8", - "JUJU_HOOK_NAME": "", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_MACHINE_ID": "", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm" - }, - "timestamp": "2022-10-21T17:31:26.302339" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", - "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", - "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_get": { - "calls": { - "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"trfk\", true], {}]": "{}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ], - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.status_set": { - "calls": { - "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", - "[[\"active\", \"\"], {\"is_app\": false}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "Client.push": { - "calls": [ - [ - "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "PebblePush", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-joined-6035930779166359233", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-joined", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/0", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-joined", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:45.160862" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-joined does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_joined.\"], {}]": "null", - "[[\"ERROR\", \"Remote unit prom/0 sent invalid data (({}, {'type': 'object', 'properties': {'model': {'type': 'string'}, 'name': {'type': 'string'}, 'host': {'type': 'string'}, 'port': {'type': 'string'}, 'mode': {'type': 'string'}, 'strip-prefix': {'type': 'string'}}, 'required': ['model', 'name', 'host', 'port']})).\"], {}]": "null", - "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", - "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/0\", \"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_get": { - "calls": { - "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"prom/0\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"trfk\", true], {}]": "{}" - }, - "cursor": 3, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ], - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ] - ], - "cursor": 4, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.status_set": { - "calls": { - "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", - "[[\"active\", \"\"], {\"is_app\": false}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 6, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ] - ], - "cursor": 2, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "Client.push": { - "calls": [ - [ - "gASV5AEAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJAAQAAgASVNQEAAAAAAABYLgEAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMS1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMWApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMS1zZXJ2aWNlCiAgc2VydmljZXM6CiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", - "null" - ] - ], - "cursor": 1, - "caching_policy": "strict", - "serializer": [ - "PebblePush", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-changed-4364058908358574353", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-changed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/0", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-changed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:31:45.982074" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-changed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_changed.\"], {}]": "null", - "[[\"DEBUG\", \"Updating ingress for relation 'ingress-per-unit:3'\"], {}]": "null", - "[[\"DEBUG\", \"Updated ingress configuration file: /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/0\", \"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_get": { - "calls": { - "[[3, \"prom/1\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-1.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/1\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"prom/0\", false], {}]": "{\"egress-subnets\": \"10.152.183.124/32\", \"host\": \"prom-0.prom-endpoints.foo.svc.cluster.local\", \"ingress-address\": \"10.152.183.124\", \"mode\": \"http\", \"model\": \"foo\", \"name\": \"prom/0\", \"port\": \"9090\", \"private-address\": \"10.152.183.124\"}", - "[[3, \"trfk\", true], {}]": "{}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.config_get": { - "calls": { - "[[], {}]": "{\"external_hostname\": \"foo.com\", \"routing_mode\": \"path\"}" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ], - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"GET\", \"/v1/services\", {\"names\": \"traefik\"}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"name\": \"traefik\", \"startup\": \"enabled\", \"current\": \"active\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.status_set": { - "calls": { - "[[\"maintenance\", \"updating ingress configuration for 'ingress-per-unit:3'\"], {\"is_app\": false}]": "null", - "[[\"active\", \"\"], {\"is_app\": false}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ], - [ - "[[3, \"ingress\", \"prom/0:\\n url: http://foo.com:80/foo-prom-0\\nprom/1:\\n url: http://foo.com:80/foo-prom-1\\n\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "Client.push": { - "calls": [ - [ - "gASV9QIAAAAAAACMOy9vcHQvdHJhZWZpay9qdWp1L2p1anVfaW5ncmVzc19pbmdyZXNzLXBlci11bml0XzNfcHJvbS55YW1slEJRAgAAgASVRgIAAAAAAABYPwIAAGh0dHA6CiAgcm91dGVyczoKICAgIGp1anUtZm9vLXByb20tMC1yb3V0ZXI6CiAgICAgIGVudHJ5UG9pbnRzOgogICAgICAtIHdlYgogICAgICBydWxlOiBQYXRoUHJlZml4KGAvZm9vLXByb20tMGApCiAgICAgIHNlcnZpY2U6IGp1anUtZm9vLXByb20tMC1zZXJ2aWNlCiAgICBqdWp1LWZvby1wcm9tLTEtcm91dGVyOgogICAgICBlbnRyeVBvaW50czoKICAgICAgLSB3ZWIKICAgICAgcnVsZTogUGF0aFByZWZpeChgL2Zvby1wcm9tLTFgKQogICAgICBzZXJ2aWNlOiBqdWp1LWZvby1wcm9tLTEtc2VydmljZQogIHNlcnZpY2VzOgogICAganVqdS1mb28tcHJvbS0wLXNlcnZpY2U6CiAgICAgIGxvYWRCYWxhbmNlcjoKICAgICAgICBzZXJ2ZXJzOgogICAgICAgIC0gdXJsOiBodHRwOi8vcHJvbS0wLnByb20tZW5kcG9pbnRzLmZvby5zdmMuY2x1c3Rlci5sb2NhbDo5MDkwCiAgICBqdWp1LWZvby1wcm9tLTEtc2VydmljZToKICAgICAgbG9hZEJhbGFuY2VyOgogICAgICAgIHNlcnZlcnM6CiAgICAgICAgLSB1cmw6IGh0dHA6Ly9wcm9tLTEucHJvbS1lbmRwb2ludHMuZm9vLnN2Yy5jbHVzdGVyLmxvY2FsOjkwOTAKlC6UhpR9lCiMCGVuY29kaW5nlIwFdXRmLTiUjAltYWtlX2RpcnOUiIwLcGVybWlzc2lvbnOUTowHdXNlcl9pZJROjAR1c2VylE6MCGdyb3VwX2lklE6MBWdyb3VwlE51hpQu", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "PebblePush", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-685455772410773578", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/0", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "JUJU_DEPARTING_UNIT": "prom/0", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:41:29.212268" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[\"prom/1\"]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-departed-4600292350819057959", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-departed", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "prom/1", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-departed", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "JUJU_DEPARTING_UNIT": "prom/1", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:41:29.868875" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-departed does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_departed.\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_remote_app_name": { - "calls": { - "[[3], {}]": "\"prom\"" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - }, - { - "event": { - "env": { - "JUJU_UNIT_NAME": "trfk/0", - "KUBERNETES_PORT": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT": "443", - "JUJU_VERSION": "3.1-beta1", - "JUJU_CHARM_HTTP_PROXY": "", - "APT_LISTCHANGES_FRONTEND": "none", - "JUJU_CONTEXT_ID": "trfk/0-ingress-per-unit-relation-broken-2334772132445027897", - "JUJU_AGENT_SOCKET_NETWORK": "unix", - "JUJU_API_ADDRESSES": "10.152.183.49:17070 controller-service.controller-mk8scloud.svc.cluster.local:17070", - "JUJU_CHARM_HTTPS_PROXY": "", - "JUJU_AGENT_SOCKET_ADDRESS": "@/var/lib/juju/agents/unit-trfk-0/agent.socket", - "JUJU_MODEL_NAME": "foo", - "JUJU_DISPATCH_PATH": "hooks/ingress-per-unit-relation-broken", - "JUJU_AVAILABILITY_ZONE": "", - "JUJU_REMOTE_UNIT": "", - "JUJU_CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "TERM": "tmux-256color", - "KUBERNETES_PORT_443_TCP_ADDR": "10.152.183.1", - "JUJU_RELATION": "ingress-per-unit", - "PATH": "/var/lib/juju/tools/unit-trfk-0:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/charm/bin", - "JUJU_RELATION_ID": "ingress-per-unit:3", - "KUBERNETES_PORT_443_TCP_PORT": "443", - "JUJU_METER_STATUS": "AMBER", - "KUBERNETES_PORT_443_TCP_PROTO": "tcp", - "JUJU_HOOK_NAME": "ingress-per-unit-relation-broken", - "LANG": "C.UTF-8", - "CLOUD_API_VERSION": "1.25.0", - "DEBIAN_FRONTEND": "noninteractive", - "JUJU_SLA": "unsupported", - "KUBERNETES_PORT_443_TCP": "tcp://10.152.183.1:443", - "KUBERNETES_SERVICE_PORT_HTTPS": "443", - "JUJU_MODEL_UUID": "9930d85f-3474-439c-8c8a-207f41ba8611", - "KUBERNETES_SERVICE_HOST": "10.152.183.1", - "JUJU_MACHINE_ID": "", - "JUJU_CHARM_FTP_PROXY": "", - "JUJU_METER_INFO": "not set", - "PWD": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_PRINCIPAL_UNIT": "", - "JUJU_CHARM_NO_PROXY": "127.0.0.1,localhost,::1", - "PYTHONPATH": "lib:venv", - "CHARM_DIR": "/var/lib/juju/agents/unit-trfk-0/charm", - "JUJU_REMOTE_APP": "prom" - }, - "timestamp": "2022-10-21T17:41:30.508188" - }, - "context": { - "memos": { - "_ModelBackend.juju_log": { - "calls": { - "[[\"DEBUG\", \"Operator Framework 1.5.2 up and running.\"], {}]": "null", - "[[\"DEBUG\", \"Legacy hooks/ingress-per-unit-relation-broken does not exist.\"], {}]": "null", - "[[\"DEBUG\", \"yaml does not have libyaml extensions, using slower pure Python yaml loader\"], {}]": "null", - "[[\"DEBUG\", \"Using local storage: /var/lib/juju/agents/unit-trfk-0/charm/.unit-state.db already exists\"], {}]": "null", - "[[\"DEBUG\", \"Emitting Juju event ingress_per_unit_relation_broken.\"], {}]": "null", - "[[\"DEBUG\", \"Wiping the ingress setup for the 'ingress-per-unit:3' relation\"], {}]": "null", - "[[\"DEBUG\", \"Deleted orphaned /opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml ingress configuration file\"], {}]": "null" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_ids": { - "calls": { - "[[\"ingress-per-unit\"], {}]": "[3]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_list": { - "calls": { - "[[3], {}]": "[]" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_remote_app_name": { - "calls": { - "[[3], {}]": "\"prom\"" - }, - "cursor": 0, - "caching_policy": "loose", - "serializer": [ - "json", - "json" - ] - }, - "Client._request": { - "calls": [ - [ - "[[\"GET\", \"/v1/system-info\"], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": {\"boot-id\": \"aeb476a6-761c-6960-ef25-cc73c7f6207d\", \"version\": \"unknown\"}}" - ], - [ - "[[\"POST\", \"/v1/files\", null, {\"action\": \"remove\", \"paths\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\", \"recursive\": true}]}], {}]", - "{\"type\": \"sync\", \"status-code\": 200, \"status\": \"OK\", \"result\": [{\"path\": \"/opt/traefik/juju/juju_ingress_ingress-per-unit_3_prom.yaml\"}]}" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.is_leader": { - "calls": [ - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ], - [ - "[[], {}]", - "true" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - }, - "_ModelBackend.relation_set": { - "calls": [ - [ - "[[3, \"ingress\", \"\", true], {}]", - "null" - ] - ], - "cursor": 0, - "caching_policy": "strict", - "serializer": [ - "json", - "json" - ] - } - }, - "state": { - "config": null, - "relations": [], - "networks": [], - "containers": [], - "status": { - "app": [ - "unknown", - "" - ], - "unit": [ - "unknown", - "" - ], - "app_version": "" - }, - "leader": false, - "model": { - "name": "LkRovLn9ze9sHbOQSGAs", - "uuid": "257152f0-b1b3-46ea-ac21-73f28ace77aa" - }, - "juju_log": [] - } - } - } - ] -} \ No newline at end of file diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/demo_decorate_class.py b/tests/resources/demo_decorate_class.py new file mode 100644 index 000000000..56e329f85 --- /dev/null +++ b/tests/resources/demo_decorate_class.py @@ -0,0 +1,18 @@ +class MyDemoClass: + _foo: int = 0 + + def get_foo(self, *args, **kwargs): + return self._foo + + def set_foo(self, foo): + self._foo = foo + + def unpatched(self, *args, **kwargs): + return self._foo + + +class MyOtherClass: + _foo: int = 0 + + def foo(self, *args, **kwargs): + return self._foo diff --git a/tests/test_e2e/test_play_until_complete.py b/tests/test_e2e/test_builtin_scenes.py similarity index 51% rename from tests/test_e2e/test_play_until_complete.py rename to tests/test_e2e/test_builtin_scenes.py index 50f33f03c..176167d58 100644 --- a/tests/test_e2e/test_play_until_complete.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -1,10 +1,10 @@ from typing import Optional, Type import pytest + from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework - -from scenario.scenario import StartupScenario, TeardownScenario +from scenario.scenario import check_builtin_sequences from scenario.structs import CharmSpec CHARM_CALLED = 0 @@ -15,16 +15,8 @@ def mycharm(): global CHARM_CALLED CHARM_CALLED = 0 - class MyCharmEvents(CharmEvents): - @classmethod - def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): - if getattr(cls, event_kind, None): - delattr(cls, event_kind) - return super().define_event(event_kind, event_type) - class MyCharm(CharmBase): _call = None - on = MyCharmEvents() def __init__(self, framework: Framework, key: Optional[str] = None): super().__init__(framework, key) @@ -43,14 +35,7 @@ def _on_event(self, event): return MyCharm -@pytest.mark.parametrize("leader", (True, False)) -def test_setup(leader, mycharm): - scenario = StartupScenario(CharmSpec(mycharm, meta={"name": "foo"}), leader=leader) - scenario.play_until_complete() - assert CHARM_CALLED == 4 - - -def test_teardown(mycharm): - scenario = TeardownScenario(CharmSpec(mycharm, meta={"name": "foo"})) - scenario.play_until_complete() - assert CHARM_CALLED == 2 +def test_builtin_scenes(mycharm): + charm_spec = CharmSpec(mycharm, meta={"name": "foo"}) + check_builtin_sequences(charm_spec) + assert CHARM_CALLED == 12 diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 7243b4d9d..d0da4bf26 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -1,72 +1,74 @@ -from typing import Optional, Type +from typing import Optional import pytest -from ops.charm import CharmBase, CharmEvents, StartEvent -from ops.framework import EventBase, Framework +from ops.charm import CharmBase +from ops.framework import Framework +from ops.model import BlockedStatus, ActiveStatus from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Context, Scene, State, event, relation +from scenario.structs import CharmSpec, Scene, State, event, relation, Status @pytest.fixture(scope="function") def mycharm(): - class MyCharmEvents(CharmEvents): - @classmethod - def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): - if getattr(cls, event_kind, None): - delattr(cls, event_kind) - return super().define_event(event_kind, event_type) - class MyCharm(CharmBase): _call = None - on = MyCharmEvents() + called = False def __init__(self, framework: Framework, key: Optional[str] = None): super().__init__(framework, key) - self.called = False for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) def _on_event(self, event): - if self._call: - self.called = True - self._call(event) + if MyCharm._call: + MyCharm.called = True + MyCharm._call(self, event) return MyCharm def test_charm_heals_on_start(mycharm): - mycharm._call = lambda *_: True scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) def pre_event(charm): pre_event._called = True + assert not charm.is_ready() + assert charm.unit.status == BlockedStatus("foo") assert not charm.called + def call(charm, _): + if charm.unit.status.message == "foo": + charm.unit.status = ActiveStatus("yabadoodle") + def post_event(charm): post_event._called = True - from ops.model import ActiveStatus - charm.unit.status = ActiveStatus("yabadoodle") + assert charm.is_ready() + assert charm.unit.status == ActiveStatus("yabadoodle") assert charm.called + mycharm._call = call + + initial_state = State( + config={"foo": "bar"}, leader=True, + status=Status(unit=('blocked', 'foo')) + ) + out = scenario.play( Scene( - event("start"), - context=Context(state=State(config={"foo": "bar"}, leader=True)), - ), - pre_event=pre_event, - post_event=post_event, + event("update-status"), + state=initial_state), ) - assert pre_event._called - assert post_event._called + assert out.status.unit == ('active', 'yabadoodle') - assert out.delta() == [ + out.juju_log = [] # exclude juju log from delta + assert out.delta(initial_state) == [ { "op": "replace", - "path": "/state/status/unit", + "path": "/status/unit", "value": ("active", "yabadoodle"), } ] @@ -104,7 +106,6 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} scene = Scene( - context=Context( state=State( relations=[ relation( @@ -112,14 +113,13 @@ def check_relation_data(charm): interface="azdrubales", remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, - remote_unit_ids=[0, 1], - remote_units_data={"0": {"foo": "bar"}, "1": {"baz": "qux"}}, + remote_units_data={0: {"foo": "bar"}, + 1: {"baz": "qux"}}, ) ] - ) - ), - event=event("update-status"), - ) + ), + event=event("update-status"), + ) scenario.play( scene, diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index befe84be7..98657c29b 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,15 +6,14 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.scenario import Scenario, sort_patch +from scenario.scenario import Scenario from scenario.structs import ( CharmSpec, ContainerSpec, - Context, Scene, State, event, - relation, + relation, sort_patch, ) # from tests.setup_tests import setup_tests @@ -46,19 +45,18 @@ def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): class MyCharm(CharmBase): _call = None + called = False on = MyCharmEvents() def __init__(self, framework: Framework, key: Optional[str] = None): super().__init__(framework, key) - self.called = False - for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) def _on_event(self, event): if self._call: - self.called = True - self._call(event) + MyCharm.called = True + MyCharm._call(self, event) return MyCharm @@ -71,29 +69,28 @@ def dummy_state(): @pytest.fixture(scope='function') def start_scene(dummy_state): - return Scene(event("start"), - context=Context(state=dummy_state)) + return Scene(event("start"), state=dummy_state) + + +@pytest.fixture(scope='function') +def scenario(mycharm): + return Scenario(CharmSpec(mycharm, meta={"name": "foo"})) def test_bare_event(start_scene, mycharm): - mycharm._call = lambda *_: True scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) out = scenario.play(scene=start_scene) - - assert isinstance(out.charm, mycharm) - assert out.charm.called - assert isinstance(out.event, StartEvent) - assert out.charm.unit.name == "foo/0" - assert out.charm.model.uuid == start_scene.context.state.model.uuid + out.juju_log = [] # ignore logging output in the delta + assert start_scene.state.delta(out) == [] def test_leader_get(start_scene, mycharm): - def call(charm, _): + def pre_event(charm): assert charm.unit.is_leader() - mycharm._call = call scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - scenario.play(start_scene) + scenario.play(start_scene, + pre_event=pre_event) def test_status_setting(start_scene, mycharm): @@ -105,34 +102,33 @@ def call(charm: CharmBase, _): mycharm._call = call scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) out = scenario.play(start_scene) - assert out.context_out.state.status.unit == ("active", "foo test") - assert out.context_out.state.status.app == ("waiting", "foo barz") - assert out.context_out.state.status.app_version == "" - assert out.delta() == sort_patch( - [ + assert out.status.unit == ("active", "foo test") + assert out.status.app == ("waiting", "foo barz") + assert out.status.app_version == "" + + out.juju_log = [] # ignore logging output in the delta + assert out.delta(start_scene.state) == sort_patch([ { "op": "replace", - "path": "/state/status/app", + "path": "/status/app", "value": ("waiting", "foo barz"), }, { "op": "replace", - "path": "/state/status/unit", + "path": "/status/unit", "value": ("active", "foo test"), }, - ] - ) + ]) @pytest.mark.parametrize("connect", (True, False)) def test_container(start_scene: Scene, connect, mycharm): - def call(charm: CharmBase, _): + def pre_event(charm: CharmBase): container = charm.unit.get_container("foo") assert container is not None assert container.name == "foo" assert container.can_connect() is connect - mycharm._call = call scenario = Scenario( CharmSpec( mycharm, @@ -143,19 +139,26 @@ def call(charm: CharmBase, _): ) ) scene = start_scene.copy() - scene.context.state.containers = (ContainerSpec(name="foo", can_connect=connect),) - scenario.play(scene) + scene.state.containers = (ContainerSpec(name="foo", can_connect=connect),) + scenario.play(scene, pre_event=pre_event) def test_relation_get(start_scene: Scene, mycharm): - def call(charm: CharmBase, _): + def pre_event(charm: CharmBase): rel = charm.model.get_relation("foo") assert rel is not None assert rel.data[charm.app]["a"] == "because" + assert rel.data[rel.app]["a"] == "b" - assert not rel.data[charm.unit] # empty + assert rel.data[charm.unit]["c"] == "d" - mycharm._call = call + for unit in rel.units: + if unit is charm.unit: + continue + if unit.name == 'remote/1': + assert rel.data[unit]['e'] == 'f' + else: + assert not rel.data[unit] scenario = Scenario( CharmSpec( @@ -167,7 +170,7 @@ def call(charm: CharmBase, _): ) ) scene = start_scene.copy() - scene.context.state.relations = [ + scene.state.relations = [ relation( endpoint="foo", interface="bar", @@ -176,24 +179,41 @@ def call(charm: CharmBase, _): remote_unit_ids=[0, 1, 2], remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, + remote_units_data={0: {}, 1: {"e": "f"}, 2: {}} ), ] - scenario.play(scene) + scenario.play(scene, + pre_event=pre_event) def test_relation_set(start_scene: Scene, mycharm): - def call(charm: CharmBase, _): + def event_handler(charm: CharmBase, _): rel = charm.model.get_relation("foo") rel.data[charm.app]["a"] = "b" rel.data[charm.unit]["c"] = "d" + # this will NOT raise an exception because we're not in an event context! + # we're right before the event context is entered in fact. + # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? with pytest.raises(Exception): rel.data[rel.app]["a"] = "b" with pytest.raises(Exception): rel.data[charm.model.get_unit("remote/1")]["c"] = "d" - mycharm._call = call + assert charm.unit.is_leader() + + def pre_event(charm: CharmBase): + assert charm.model.get_relation("foo") + # this would NOT raise an exception because we're not in an event context! + # we're right before the event context is entered in fact. + # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? + # with pytest.raises(Exception): + # rel.data[rel.app]["a"] = "b" + # with pytest.raises(Exception): + # rel.data[charm.model.get_unit("remote/1")]["c"] = "d" + + mycharm._call = event_handler scenario = Scenario( CharmSpec( mycharm, @@ -206,8 +226,8 @@ def call(charm: CharmBase, _): scene = start_scene.copy() - scene.context.state.leader = True - scene.context.state.relations = [ # we could also append... + scene.state.leader = True + scene.state.relations = [ relation( endpoint="foo", interface="bar", @@ -217,9 +237,11 @@ def call(charm: CharmBase, _): ) ] - out = scenario.play(scene) + assert not mycharm.called + out = scenario.play(scene, pre_event=pre_event) + assert mycharm.called - assert asdict(out.context_out.state.relations[0]) == asdict( + assert asdict(out.relations[0]) == asdict( relation( endpoint="foo", interface="bar", @@ -229,11 +251,5 @@ def call(charm: CharmBase, _): ) ) - assert out.context_out.state.relations[0].local_app_data == {"a": "b"} - assert out.context_out.state.relations[0].local_unit_data == {"c": "d"} - assert out.delta() == sort_patch( - [ - {"op": "add", "path": "/state/relations/0/local_app_data/a", "value": "b"}, - {"op": "add", "path": "/state/relations/0/local_unit_data/c", "value": "d"}, - ] - ) + assert out.relations[0].local_app_data == {"a": "b"} + assert out.relations[0].local_unit_data == {"c": "d"} diff --git a/tests/test_memo_tools.py b/tests/test_memo_tools.py deleted file mode 100644 index 2d2c54dd1..000000000 --- a/tests/test_memo_tools.py +++ /dev/null @@ -1,464 +0,0 @@ -import json -import os -import random -import tempfile -from pathlib import Path -from unittest.mock import patch - -import pytest - -from scenario.runtime.memo import ( - DEFAULT_NAMESPACE, - MEMO_DATABASE_NAME_KEY, - MEMO_MODE_KEY, - Context, - Event, - Memo, - Scene, - _reset_replay_cursors, - event_db, - memo, -) -from scenario.runtime.memo_tools import MEMO_IMPORT_BLOCK, DecorateSpec, inject_memoizer - -# we always replay the last event in the default test env. -os.environ["MEMO_REPLAY_IDX"] = "-1" - -mock_ops_source = """ -import random - -class _ModelBackend: - def _private_method(self): - pass - def other_method(self): - pass - def action_set(self, *args, **kwargs): - return str(random.random()) - def action_get(self, *args, **kwargs): - return str(random.random()) - - -class Foo: - def bar(self, *args, **kwargs): - return str(random.random()) - def baz(self, *args, **kwargs): - return str(random.random()) -""" - -expected_decorated_source = f"""{MEMO_IMPORT_BLOCK} -import random - -class _ModelBackend(): - - def _private_method(self): - pass - - def other_method(self): - pass - - @memo(name=None, namespace='_ModelBackend', caching_policy='strict', serializer='json') - def action_set(self, *args, **kwargs): - return str(random.random()) - - @memo(name=None, namespace='_ModelBackend', caching_policy='loose', serializer='pickle') - def action_get(self, *args, **kwargs): - return str(random.random()) - -class Foo(): - - @memo(name=None, namespace='Bar', caching_policy='loose', serializer=('json', 'io')) - def bar(self, *args, **kwargs): - return str(random.random()) - - def baz(self, *args, **kwargs): - return str(random.random()) -""" - - -def test_memoizer_injection(): - with tempfile.NamedTemporaryFile() as file: - target_file = Path(file.name) - target_file.write_text(mock_ops_source) - - inject_memoizer( - target_file, - decorate={ - "_ModelBackend": { - "action_set": DecorateSpec(), - "action_get": DecorateSpec( - caching_policy="loose", serializer="pickle" - ), - }, - "Foo": { - "bar": DecorateSpec( - namespace="Bar", - caching_policy="loose", - serializer=("json", "io"), - ) - }, - }, - ) - - assert target_file.read_text() == expected_decorated_source - - -def test_memoizer_recording(): - with tempfile.NamedTemporaryFile() as temp_db_file: - Path(temp_db_file.name).write_text("{}") - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - @memo() - def my_fn(*args, retval=None, **kwargs): - return retval - - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - my_fn(10, retval=10, foo="bar") - - with event_db(temp_db_file.name) as data: - ctx = data.scenes[0].context - assert ctx.memos - assert ctx.memos[f"{DEFAULT_NAMESPACE}.my_fn"].calls == [ - [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "10"] - ] - - -def test_memo_args(): - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - @memo(namespace="foo", name="bar", caching_policy="loose") - def my_fn(*args, retval=None, **kwargs): - return retval - - my_fn(10, retval=10, foo="bar") - - with event_db(temp_db_file.name) as data: - assert data.scenes[0].context.memos["foo.bar"].caching_policy == "loose" - - -def test_memoizer_replay(): - os.environ[MEMO_MODE_KEY] = "replay" - - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - @memo(log_on_replay=True) - def my_fn(*args, retval=None, **kwargs): - return retval - - with event_db(temp_db_file.name) as data: - data.scenes.append( - Scene( - event=Event(env={}, timestamp="10:10"), - context=Context( - memos={ - f"{DEFAULT_NAMESPACE}.my_fn": Memo( - calls=[ - [ - json.dumps( - [[10], {"retval": 10, "foo": "bar"}] - ), - "20", - ], - [ - json.dumps( - [[10], {"retval": 11, "foo": "baz"}] - ), - "21", - ], - [ - json.dumps( - [ - [11], - {"retval": 10, "foo": "baq", "a": "b"}, - ] - ), - "22", - ], - ] - ) - } - ), - ) - ) - - caught_calls = [] - - def _catch_log_call(_, *args, **kwargs): - caught_calls.append((args, kwargs)) - - with patch("scenario.runtime.memo._log_memo", new=_catch_log_call): - assert my_fn(10, retval=10, foo="bar") == 20 - assert my_fn(10, retval=11, foo="baz") == 21 - assert my_fn(11, retval=10, foo="baq", a="b") == 22 - # memos are all up! we run the actual function. - assert my_fn(11, retval=10, foo="baq", a="b") == 10 - - assert caught_calls == [ - (((10,), {"foo": "bar", "retval": 10}, "20"), {"cache_hit": True}), - (((10,), {"foo": "baz", "retval": 11}, "21"), {"cache_hit": True}), - ( - ((11,), {"a": "b", "foo": "baq", "retval": 10}, "22"), - {"cache_hit": True}, - ), - ( - ((11,), {"a": "b", "foo": "baq", "retval": 10}, ""), - {"cache_hit": False}, - ), - ] - - with event_db(temp_db_file.name) as data: - ctx = data.scenes[0].context - assert ctx.memos[f"{DEFAULT_NAMESPACE}.my_fn"].cursor == 3 - - -def test_memoizer_loose_caching(): - with tempfile.NamedTemporaryFile() as temp_db_file: - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - _backing = {x: x + 1 for x in range(50)} - - @memo(caching_policy="loose", log_on_replay=True) - def my_fn(m): - return _backing[m] - - os.environ[MEMO_MODE_KEY] = "record" - for i in range(50): - assert my_fn(i) == i + 1 - - # clear the backing storage, so that a cache miss would raise a - # KeyError. my_fn is, as of now, totally useless - _backing.clear() - - os.environ[MEMO_MODE_KEY] = "replay" - - # check that the function still works, with unordered arguments and repeated ones. - values = list(range(50)) * 2 - random.shuffle(values) - for i in values: - assert my_fn(i) == i + 1 - - -def test_memoizer_passthrough(): - with tempfile.NamedTemporaryFile() as temp_db_file: - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - _backing = {x: x + 1 for x in range(50)} - - @memo(caching_policy="loose", log_on_replay=True) - def my_fn(m): - return _backing[m] - - os.environ[MEMO_MODE_KEY] = "record" - for i in range(50): - assert my_fn(i) == i + 1 - - # set the mode to passthrough, so that the original function will - # be called even though there are memos stored - os.environ[MEMO_MODE_KEY] = "passthrough" - - # clear the backing storage. - _backing.clear() - with pytest.raises(KeyError): - my_fn(1) - with pytest.raises(KeyError): - my_fn(10) - - # go to replay mode - os.environ[MEMO_MODE_KEY] = "replay" - - # now it works again - assert my_fn(1) == 2 - assert my_fn(10) == 11 - - -def test_memoizer_classmethod_recording(): - os.environ[MEMO_MODE_KEY] = "record" - - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - class Foo: - @memo("foo") - def my_fn(self, *args, retval=None, **kwargs): - return retval - - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - f = Foo() - f.my_fn(10, retval=10, foo="bar") - - with event_db(temp_db_file.name) as data: - memos = data.scenes[0].context.memos - assert memos["foo.my_fn"].calls == [ - [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "10"] - ] - - # replace return_value for replay test - memos["foo.my_fn"].calls = [ - [json.dumps([[10], {"retval": 10, "foo": "bar"}]), "20"] - ] - - os.environ[MEMO_MODE_KEY] = "replay" - assert f.my_fn(10, retval=10, foo="bar") == 20 - - # memos are up - assert f.my_fn(10, retval=10, foo="bar") == 10 - assert f.my_fn(10, retval=10, foo="bar") == 10 - - -def test_reset_replay_cursor(): - os.environ[MEMO_MODE_KEY] = "replay" - - with tempfile.NamedTemporaryFile() as temp_db_file: - Path(temp_db_file.name).write_text("{}") - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - - @memo() - def my_fn(*args, retval=None, **kwargs): - return retval - - with event_db(temp_db_file.name) as data: - calls = [ - [[[10], {"retval": 10, "foo": "bar"}], 20], - [[[10], {"retval": 11, "foo": "baz"}], 21], - [[[11], {"retval": 10, "foo": "baq", "a": "b"}], 22], - ] - - data.scenes.append( - Scene( - event=Event(env={}, timestamp="10:10"), - context=Context(memos={"my_fn": Memo(calls=calls, cursor=2)}), - ) - ) - - with event_db(temp_db_file.name) as data: - _memo = data.scenes[0].context.memos["my_fn"] - assert _memo.cursor == 2 - assert _memo.calls == calls - - _reset_replay_cursors(temp_db_file.name) - - with event_db(temp_db_file.name) as data: - _memo = data.scenes[0].context.memos["my_fn"] - assert _memo.cursor == 0 - assert _memo.calls == calls - - -class Foo: - pass - - -@pytest.mark.parametrize( - "obj, serializer", - ( - (b"1234", "pickle"), - (object(), "pickle"), - (Foo(), "pickle"), - ), -) -def test_memo_exotic_types(obj, serializer): - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - os.environ[MEMO_MODE_KEY] = "record" - - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - @memo(serializer=serializer) - def my_fn(_obj): - return _obj - - assert obj is my_fn(obj) - - os.environ[MEMO_MODE_KEY] = "replay" - - replay_output = my_fn(obj) - assert obj is not replay_output - - assert type(obj) is type(replay_output) - - -def test_memo_pebble_push(): - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - os.environ[MEMO_MODE_KEY] = "record" - - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - stored = None - - class Foo: - @memo(serializer=("PebblePush", "json")) - def push( - self, - path, - source, - *, - encoding: str = "utf-8", - make_dirs: bool = False, - permissions=42, - user_id=42, - user=42, - group_id=42, - group=42, - ): - - nonlocal stored - stored = source.read() - return stored - - tf = tempfile.NamedTemporaryFile(delete=False) - Path(tf.name).write_text("helloworld") - - obj = open(tf.name) - assert Foo().push(42, obj, user="lolz") == stored == "helloworld" - obj.close() - stored = None - - os.environ[MEMO_MODE_KEY] = "replay" - - obj = open(tf.name) - assert Foo().push(42, obj, user="lolz") == "helloworld" - assert stored == None - obj.close() - - tf.close() - del tf - - -def test_memo_pebble_pull(): - with tempfile.NamedTemporaryFile() as temp_db_file: - os.environ[MEMO_DATABASE_NAME_KEY] = temp_db_file.name - os.environ[MEMO_MODE_KEY] = "record" - - with event_db(temp_db_file.name) as data: - data.scenes.append(Scene(event=Event(env={}, timestamp="10:10"))) - - class Foo: - @memo(serializer=("json", "io")) - def pull(self, foo: str): - tf = tempfile.NamedTemporaryFile() - Path(tf.name).write_text("helloworld") - return open(tf.name) - - def getfile(self, foo: str): - return self.pull(foo).read() - - assert Foo().getfile(foo="helloworld") == "helloworld" - - os.environ[MEMO_MODE_KEY] = "replay" - - assert Foo().getfile(foo="helloworld") == "helloworld" diff --git a/tests/test_mocking.py b/tests/test_mocking.py new file mode 100644 index 000000000..0a5792c90 --- /dev/null +++ b/tests/test_mocking.py @@ -0,0 +1,94 @@ +import dataclasses + +import pytest + +from scenario.mocking import DecorateSpec, patch_module +from scenario.structs import State, Scene, Event, _DCBase, relation +from typing import Dict, Tuple, Any, Callable + + +def mock_simulator(fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + call_args: Tuple[Any, ...], + call_kwargs: Dict[str, Any]): + assert namespace == 'MyDemoClass' + + if tool_name == 'get_foo': + return scene.state.foo + if tool_name == 'set_foo': + scene.state.foo = call_args[1] + return + raise RuntimeError() + + +@dataclasses.dataclass +class MockState(_DCBase): + foo: int + + +@pytest.mark.parametrize('mock_foo', (42, 12, 20)) +def test_patch_generic_module(mock_foo): + state = MockState(foo=mock_foo) + scene = Scene(state=state.copy(), + event=Event('foo')) + + from tests.resources import demo_decorate_class + patch_module( + demo_decorate_class, + { + "MyDemoClass": { + "get_foo": DecorateSpec( + simulator=mock_simulator + ), + "set_foo": DecorateSpec( + simulator=mock_simulator + ) + } + }, + scene=scene) + + from tests.resources.demo_decorate_class import MyDemoClass + assert MyDemoClass._foo == 0 + assert MyDemoClass().get_foo() == mock_foo + + MyDemoClass().set_foo(12) + assert MyDemoClass._foo == 0 # set_foo didn't "really" get called + assert MyDemoClass().get_foo() == 12 # get_foo now returns the updated value + + assert state.foo == mock_foo # initial state has original value + + +def test_patch_ops(): + state = State( + relations=[ + relation( + endpoint='dead', + interface='beef', + local_app_data={"foo": "bar"}, + local_unit_data={"foo": "wee"}, + remote_units_data={0: {"baz": "qux"}} + ) + ] + ) + scene = Scene(state=state.copy(), + event=Event('foo')) + + from ops import model + patch_module( + model, + { + "_ModelBackend": { + "relation_ids": DecorateSpec(), + "relation_get": DecorateSpec(), + "relation_set": DecorateSpec() + } + }, + scene=scene) + + mb = model._ModelBackend('foo', 'bar', 'baz') + assert mb.relation_ids('dead') == [0] + assert mb.relation_get(0, 'local/0', False) == {'foo': 'wee'} + assert mb.relation_get(0, 'local', True) == {'foo': 'bar'} + assert mb.relation_get(0, 'remote/0', False) == {'baz': 'qux'} diff --git a/tests/test_replay_local_runtime.py b/tests/test_replay_local_runtime.py deleted file mode 100644 index e243a04e3..000000000 --- a/tests/test_replay_local_runtime.py +++ /dev/null @@ -1,134 +0,0 @@ -import sys -import tempfile -from pathlib import Path -from subprocess import Popen - -import pytest - -from scenario.structs import CharmSpec - -# keep this block before `ops` imports. This ensures that if you've called Runtime.install() on -# your current venv, ops.model won't break as it tries to import recorder.py - -try: - from scenario import memo -except ModuleNotFoundError: - from scenario.runtime.runtime import RUNTIME_MODULE - - sys.path.append(str(RUNTIME_MODULE.absolute())) - -from ops.charm import CharmBase, CharmEvents - -from scenario.runtime.runtime import Runtime - -MEMO_TOOLS_RESOURCES_FOLDER = Path(__file__).parent / "memo_tools_test_files" - - -@pytest.fixture(scope="module", autouse=True) -def runtime_ctx(): - # helper to install the runtime and try and - # prevent ops from being destroyed every time - import ops - - ops_dir = Path(ops.__file__).parent - with tempfile.TemporaryDirectory() as td: - # stash away the ops source - Popen(f"cp -r {ops_dir} {td}".split()) - - Runtime.install() - yield - - Popen(f"mv {Path(td) / 'ops'} {ops_dir}".split()) - - -def charm_type(): - class _CharmEvents(CharmEvents): - pass - - class MyCharm(CharmBase): - on = _CharmEvents() - - def __init__(self, framework, key=None): - super().__init__(framework, key) - for evt in self.on.events().values(): - self.framework.observe(evt, self._catchall) - self._event = None - - def _catchall(self, e): - self._event = e - - return MyCharm - - -@pytest.mark.parametrize( - "evt_idx, expected_name", - ( - (0, "ingress_per_unit_relation_departed"), - (1, "ingress_per_unit_relation_departed"), - (2, "ingress_per_unit_relation_broken"), - (3, "ingress_per_unit_relation_created"), - (4, "ingress_per_unit_relation_joined"), - (5, "ingress_per_unit_relation_changed"), - ), -) -def test_run(evt_idx, expected_name): - runtime = Runtime( - CharmSpec( - charm_type(), - meta={ - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - }, - ), - event_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", - ) - - result = runtime.replay(evt_idx) - charm = result.charm - scene = result.scene - - assert charm.unit.name == "trfk/0" - assert charm.model.name == "foo" - assert ( - charm._event.handle.kind == scene.event.name.replace("-", "_") == expected_name - ) - - -def test_relation_data(): - runtime = Runtime( - CharmSpec( - charm_type(), - meta={ - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - }, - ), - event_db_path=MEMO_TOOLS_RESOURCES_FOLDER / "trfk-re-relate.json", - ) - - def pre_event(charm): - assert not charm._event - - def post_event(charm): - rel = charm.model.relations["ingress-per-unit"][0] - # the [order in which/number of times] we call the hook tools should not matter because - # relation-get is cached in 'loose' mode! yay! - _ = rel.data[charm.app] - _ = rel.data[charm.app] - - remote_unit_data = rel.data[list(rel.units)[0]] - assert remote_unit_data["host"] == "prom-1.prom-endpoints.foo.svc.cluster.local" - assert remote_unit_data["port"] == "9090" - assert remote_unit_data["model"] == "foo" - assert remote_unit_data["name"] == "prom/1" - - local_app_data = rel.data[charm.app] - assert local_app_data == {} - assert charm._event - - result = runtime.replay( - 5, pre_event=pre_event, post_event=post_event - ) # ipu-relation-changed - scene = result.scene - - assert scene.event.name == "ingress-per-unit-relation-changed" diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 000000000..5852dba82 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,83 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import MagicMock + +import yaml + +from ops.charm import CharmEvents, CharmBase +from ops.framework import EventBase +from scenario.runtime import Runtime +from scenario.structs import CharmSpec, Scene, event + + +def charm_type(): + class _CharmEvents(CharmEvents): + pass + + class MyCharm(CharmBase): + on = _CharmEvents() + _event = None + + def __init__(self, framework, key=None): + super().__init__(framework, key) + for evt in self.on.events().values(): + self.framework.observe(evt, self._catchall) + + def _catchall(self, e): + MyCharm._event = e + + return MyCharm + + +def test_event_hooks(): + with TemporaryDirectory() as tempdir: + meta = { + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + } + temppath = Path(tempdir) + meta_file = (temppath / 'metadata.yaml') + meta_file.write_text(yaml.safe_dump(meta)) + + runtime = Runtime( + CharmSpec( + charm_type(), + meta=meta, + ) + ) + + pre_event = MagicMock(return_value=None) + post_event = MagicMock(return_value=None) + runtime.play(Scene(event=event('foo')), + pre_event=pre_event, + post_event=post_event) + + assert pre_event.called + assert post_event.called + + +def test_event_emission(): + with TemporaryDirectory() as tempdir: + meta = { + "name": "foo", + "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, + } + + my_charm_type = charm_type() + + class MyEvt(EventBase): + pass + + my_charm_type.on.define_event('bar', MyEvt) + + runtime = Runtime( + CharmSpec( + my_charm_type, + meta=meta, + ), + ) + + runtime.play(Scene(event=event('bar'))) + + assert my_charm_type._event + assert isinstance(my_charm_type._event, MyEvt) From 122eb5f1e9d3f219bddb70014d369a63ce06e9df Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Jan 2023 10:52:25 +0100 Subject: [PATCH 028/546] charm spec autoload --- scenario/runtime.py | 4 ++-- scenario/structs.py | 47 +++++++++++++++++++++++++++------------------ setup.py | 16 +-------------- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 68d55772c..f8c602a50 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -208,8 +208,8 @@ def virtual_charm_root(self): with tempfile.TemporaryDirectory() as tempdir: temppath = Path(tempdir) (temppath / 'metadata.yaml').write_text(yaml.safe_dump(spec.meta)) - (temppath / 'config.yaml').write_text(yaml.safe_dump(spec.config)) - (temppath / 'actions.yaml').write_text(yaml.safe_dump(spec.actions)) + (temppath / 'config.yaml').write_text(yaml.safe_dump(spec.config or {})) + (temppath / 'actions.yaml').write_text(yaml.safe_dump(spec.actions or {})) yield temppath def play( diff --git a/scenario/structs.py b/scenario/structs.py index c61edd7b2..aae3fb263 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,10 +1,14 @@ import copy import dataclasses +import inspect import typing +from pathlib import Path from typing import Any, Dict, List, Literal, Sequence, Tuple, Union from typing import Optional, Type from uuid import uuid4 +import yaml + from scenario.logger import logger as scenario_logger if typing.TYPE_CHECKING: @@ -227,28 +231,33 @@ class CharmSpec(_DCBase): """Charm spec.""" charm_type: Type["CharmType"] - meta: Optional[Dict[str, Any]] = None + meta: Optional[Dict[str, Any]] actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None - # todo: implement - # @staticmethod - # def from_charm_type(charm_type: Type["CharmType"]): - # charm_source_path = Path(inspect.getfile(charm_type)) - # charm_root = charm_source_path.parent.parent - # - # meta = yaml.safe_load(charm_root / 'metadata.yaml') - # config_file = charm_root / 'config.yaml' - # - # if config_file.exists(): - # config = yaml.safe_load(config_file) - # - # return CharmSpec( - # charm_type=charm_type, - # meta=meta, - # actions=actions, - # config=config - # ) + @staticmethod + def from_charm(charm_type: Type["CharmType"]): + charm_source_path = Path(inspect.getfile(charm_type)) + charm_root = charm_source_path.parent.parent + + meta = yaml.safe_load(charm_root / 'metadata.yaml') + + actions = config = None + + config_path = charm_root / 'config.yaml' + if config_path.exists(): + config = yaml.safe_load(config_path) + + actions_path = charm_root / 'actions.yaml' + if actions_path.exists(): + actions = yaml.safe_load(actions_path) + + return CharmSpec( + charm_type=charm_type, + meta=meta, + actions=actions, + config=config + ) @dataclasses.dataclass diff --git a/setup.py b/setup.py index 790b9cdd6..704f4efcc 100644 --- a/setup.py +++ b/setup.py @@ -26,20 +26,7 @@ def _read_me() -> str: return readme -def _get_version() -> str: - """Get the version via ops/version.py, without loading ops/__init__.py.""" - spec = spec_from_file_location('scenario.version', 'scenario/version.py') - if spec is None: - raise ModuleNotFoundError('could not find /scenario/version.py') - if spec.loader is None: - raise AttributeError('loader', spec, 'invalid module') - module = module_from_spec(spec) - spec.loader.exec_module(module) - - return module.version - - -version = _get_version() +version = "0.2" setup( name="scenario", @@ -54,7 +41,6 @@ def _get_version() -> str: author_email="pietro.pasotti@canonical.com", packages=[ 'scenario', - 'scenario.runtime' ], classifiers=[ "Programming Language :: Python :: 3", From a08eae47187e513e3998537201ac1476a6b29d20 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 18 Jan 2023 14:47:16 +0100 Subject: [PATCH 029/546] added action envvar and tests --- scenario/runtime.py | 7 ++++ tests/test_e2e/test_observers.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/test_e2e/test_observers.py diff --git a/scenario/runtime.py b/scenario/runtime.py index f8c602a50..5f7e7f21d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -160,12 +160,19 @@ def unit_name(self): return meta["name"] + "/0" # todo allow override def _get_event_env(self, scene: "Scene", charm_root: Path): + if scene.event.name.endswith('_action'): + # todo: do we need some special metadata, or can we assume action names are always dashes? + action_name = scene.event.name[:-len('_action')].replace('_', '-') + else: + action_name = "" + env = { "JUJU_VERSION": self._juju_version, "JUJU_UNIT_NAME": self.unit_name, "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", "JUJU_MODEL_NAME": scene.state.model.name, + "JUJU_ACTION_NAME": action_name, "JUJU_MODEL_UUID": scene.state.model.uuid, "JUJU_CHARM_DIR": str(charm_root.absolute()) # todo consider setting pwd, (python)path diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py new file mode 100644 index 000000000..65048a96e --- /dev/null +++ b/tests/test_e2e/test_observers.py @@ -0,0 +1,61 @@ +import pytest +from dataclasses import asdict +from typing import Optional, Type + +import pytest +from ops.charm import CharmBase, CharmEvents, StartEvent, ActionEvent +from ops.framework import EventBase, Framework +from ops.model import ActiveStatus, UnknownStatus, WaitingStatus + +from scenario.scenario import Scenario +from scenario.structs import ( + CharmSpec, + ContainerSpec, + Scene, + State, + event, + relation, sort_patch, +) + + +@pytest.fixture(scope="function") +def charm_evts(): + events = [] + class MyCharm(CharmBase): + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + print(self.on.show_proxied_endpoints_action) + + def _on_event(self, event): + events.append(event) + + return MyCharm, events + + +def test_start_event(charm_evts): + charm, evts = charm_evts + scenario = Scenario( + CharmSpec(charm, + meta={"name": "foo"}, + actions={"show_proxied_endpoints": {}})) + scene = Scene(event("start"), state=State()) + scenario.play(scene) + assert len(evts) == 1 + assert isinstance(evts[0], StartEvent) + + +@pytest.mark.xfail(reason="actions not implemented yet") +def test_action_event(charm_evts): + charm, evts = charm_evts + + scenario = Scenario( + CharmSpec(charm, + meta={"name": "foo"}, + actions={"show_proxied_endpoints": {}})) + scene = Scene(event("show_proxied_endpoints_action"), state=State()) + scenario.play(scene) + assert len(evts) == 1 + assert isinstance(evts[0], ActionEvent) From 5f79a65e10099baf0fc146e3c8c52afce5f0e387 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 18 Jan 2023 17:44:27 +0100 Subject: [PATCH 030/546] config_get fix --- scenario/mocking.py | 9 ++++++++- scenario/structs.py | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 0b26357ab..478195475 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -78,7 +78,14 @@ def wrap_tool( ) elif tool_name == "config_get": - return input_state.config[args[0]] + state_config = input_state.config + if state_config: + if args: # one specific key requested + return state_config[args[0]] + return state_config # full config + + # todo fetch default config value from config.yaml + return state_config elif tool_name == "action_get": raise NotImplementedError("action_get") diff --git a/scenario/structs.py b/scenario/structs.py index aae3fb263..d46dec51c 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -240,17 +240,18 @@ def from_charm(charm_type: Type["CharmType"]): charm_source_path = Path(inspect.getfile(charm_type)) charm_root = charm_source_path.parent.parent - meta = yaml.safe_load(charm_root / 'metadata.yaml') + metadata_path = charm_root / 'metadata.yaml' + meta = yaml.safe_load(metadata_path.open()) actions = config = None config_path = charm_root / 'config.yaml' if config_path.exists(): - config = yaml.safe_load(config_path) + config = yaml.safe_load(config_path.open()) actions_path = charm_root / 'actions.yaml' if actions_path.exists(): - actions = yaml.safe_load(actions_path) + actions = yaml.safe_load(actions_path.open()) return CharmSpec( charm_type=charm_type, From a43c8b264101c4db8fea9a4d89fd9b2ada5621c8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 09:42:20 +0100 Subject: [PATCH 031/546] ci --- .github/ci/build_wheels.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/ci/build_wheels.yaml diff --git a/.github/ci/build_wheels.yaml b/.github/ci/build_wheels.yaml new file mode 100644 index 000000000..0c4264fb1 --- /dev/null +++ b/.github/ci/build_wheels.yaml @@ -0,0 +1,30 @@ +name: Build + +on: [push, pull_request] + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019, macOS-11] + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v3 + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.12.0 + + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse + env: + - CIBW_BUILD: cp38-* + - CIBW_ARCHS: "x86_64 universal2 arm64" + - CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" + + - uses: actions/upload-artifact@v3 + with: + path: ./wheelhouse/*.whl From c3a072fc484fff1ca677d72c002eb42373db3c44 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 09:44:47 +0100 Subject: [PATCH 032/546] ci --- .github/{ci => workflows}/build_wheels.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ci => workflows}/build_wheels.yaml (100%) diff --git a/.github/ci/build_wheels.yaml b/.github/workflows/build_wheels.yaml similarity index 100% rename from .github/ci/build_wheels.yaml rename to .github/workflows/build_wheels.yaml From 934affcc55a64949469168c97c34e45a2ffc0a48 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 09:46:38 +0100 Subject: [PATCH 033/546] ci --- .github/workflows/build_wheels.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 0c4264fb1..d2d5d54a0 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -21,9 +21,9 @@ jobs: - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse env: - - CIBW_BUILD: cp38-* - - CIBW_ARCHS: "x86_64 universal2 arm64" - - CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" + CIBW_BUILD: cp38-* + CIBW_ARCHS: "x86_64 universal2 arm64" + CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" - uses: actions/upload-artifact@v3 with: From 9e27194f5df0f2421229d0a27b76a4781ed26e07 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 09:51:24 +0100 Subject: [PATCH 034/546] ci --- .github/workflows/build_wheels.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index d2d5d54a0..92285c0e4 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -22,7 +22,7 @@ jobs: run: python -m cibuildwheel --output-dir wheelhouse env: CIBW_BUILD: cp38-* - CIBW_ARCHS: "x86_64 universal2 arm64" + CIBW_ARCHS: "x86_64" CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" - uses: actions/upload-artifact@v3 From 44ca238deb8fdeb7fbaa1b366dee09cff8360cba Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 09:54:58 +0100 Subject: [PATCH 035/546] ci --- .github/workflows/build_wheels.yaml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 92285c0e4..87d44b78f 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -15,15 +15,8 @@ jobs: - uses: actions/setup-python@v3 - - name: Install cibuildwheel - run: python -m pip install cibuildwheel==2.12.0 - - - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse - env: - CIBW_BUILD: cp38-* - CIBW_ARCHS: "x86_64" - CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" + - name: Build wheel + run: python setup.py bdist_wheel -d ./wheelhouse/ - uses: actions/upload-artifact@v3 with: From b355e99026e554038b256c4648170459e2eda2fa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:20:25 +0100 Subject: [PATCH 036/546] pebble layers --- scenario/mocking.py | 60 +++++++++++++++++++++++++++++++++++---------- scenario/runtime.py | 2 +- scenario/structs.py | 5 +++- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 478195475..5edf1f824 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -5,7 +5,7 @@ from scenario.logger import logger as scenario_logger if TYPE_CHECKING: - from scenario.scenario import Scene + from scenario.scenario import Scene, CharmSpec logger = scenario_logger.getChild('mocking') @@ -14,6 +14,7 @@ str, # namespace str, # tool name "Scene", # scene + Optional["CharmSpec"], # charm spec Tuple[Any, ...], # call args Dict[str, Any]], # call kwargs None] @@ -24,6 +25,7 @@ def wrap_tool( namespace: str, tool_name: str, scene: "Scene", + charm_spec: Optional["CharmSpec"], call_args: Tuple[Any, ...], call_kwargs: Dict[str, Any] ): @@ -79,13 +81,14 @@ def wrap_tool( elif tool_name == "config_get": state_config = input_state.config - if state_config: - if args: # one specific key requested - return state_config[args[0]] - return state_config # full config + if not state_config: + state_config = {key: value.get('default') for key, value in charm_spec.config.items()} - # todo fetch default config value from config.yaml - return state_config + if args: # one specific key requested + # Fixme: may raise KeyError if the key isn't defaulted. What do we do then? + return state_config[args[0]] + + return state_config # full config elif tool_name == "action_get": raise NotImplementedError("action_get") @@ -155,14 +158,36 @@ def wrap_tool( # PEBBLE CALLS elif namespace == "Client": if tool_name == "_request": + # fixme: can't differentiate between containers ATM, because Client._request + # does not pass around the container name as argument + container = input_state.containers[0] + if args == ("GET", "/v1/system-info"): - # fixme: can't differentiate between containers ATM, because Client._request - # does not pass around the container name as argument - if input_state.containers[0].can_connect: + if container.can_connect: return {"result": {"version": "unknown"}} else: wrap_errors = False # this is what pebble.Client expects! raise FileNotFoundError("") + + elif args[:2] == ("GET", "/v1/services"): + service_names = list(args[2]['names'].split(',')) + result = [] + + for layer in container.layers: + if not service_names: + break + + for name in service_names: + if name in layer['services']: + service_names.remove(name) + result.append(layer['services'][name]) + + # todo: what do we do if we don't find the requested service(s)? + return {'result': result} + + else: + raise NotImplementedError(f'_request: {args}') + elif tool_name == "pull": raise NotImplementedError("pull") elif tool_name == "push": @@ -171,6 +196,7 @@ def wrap_tool( else: raise QuestionNotImplementedError(namespace) + except Exception as e: if not wrap_errors: # reraise @@ -232,6 +258,7 @@ def wrap( namespace: str, tool_name: str, scene: "Scene", + charm_spec: "CharmSpec", simulator: Simulator = wrap_tool ): @functools.wraps(fn) @@ -241,6 +268,7 @@ def wrapper(*call_args, **call_kwargs): namespace=namespace, tool_name=tool_name, scene=scene, + charm_spec=charm_spec, call_args=call_args, call_kwargs=call_kwargs) @@ -254,7 +282,9 @@ def wrapper(*call_args, **call_kwargs): def patch_module( module, decorate: Dict[str, Dict[str, DecorateSpec]], - scene: "Scene"): + scene: "Scene", + charm_spec: "CharmSpec" = None +): """Patch a module by decorating methods in a number of classes. Decorate: a dict mapping class names to methods of that class that should be decorated. @@ -275,12 +305,15 @@ def patch_module( continue patch_class(specs, obj, - scene=scene) + scene=scene, + charm_spec=charm_spec) def patch_class(specs: Dict[str, DecorateSpec], obj: Type, - scene: "Scene"): + scene: "Scene", + charm_spec: "CharmSpec", + ): for meth_name, fn in obj.__dict__.items(): spec = specs.get(meth_name) @@ -292,6 +325,7 @@ def patch_class(specs: Dict[str, DecorateSpec], namespace=obj.__name__, tool_name=meth_name, scene=scene, + charm_spec=charm_spec, simulator=spec.simulator) setattr(obj, meth_name, wrapped_fn) diff --git a/scenario/runtime.py b/scenario/runtime.py index 5f7e7f21d..9ae2b4e66 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -131,7 +131,7 @@ def patching(self, scene: "Scene"): } } patch_module(model, decorate=model_decorator_specs, - scene=scene) + scene=scene, charm_spec=self._charm_spec) yield diff --git a/scenario/structs.py b/scenario/structs.py index d46dec51c..20e7b5e7c 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -17,6 +17,8 @@ except ImportError: from typing_extensions import Self from ops.testing import CharmType + from ops.pebble import LayerDict + logger = scenario_logger.getChild('structs') @@ -127,6 +129,7 @@ class Model(_DCBase): class ContainerSpec(_DCBase): name: str can_connect: bool = False + layers: Tuple["LayerDict"] = () # todo mock filesystem and pebble proc? @@ -331,7 +334,7 @@ def unit_name(self): class Scene(_DCBase): event: Event state: State = dataclasses.field(default_factory=State) - # data that doesn't belong to the event nor + # data that doesn't belong to the event nor the state meta: SceneMeta = SceneMeta() From ec0f1dff7f2c38ad1771c9ba02b6831ac04e46da Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:22:19 +0100 Subject: [PATCH 037/546] pebble layers --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bbda3c6db..d4e11a999 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build/ __pycache__/ *.py[cod] .idea -*.egg-info \ No newline at end of file +*.egg-info +dist/ \ No newline at end of file From b88ed09b49e9c68847273f7a52cc7b304b15702d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:23:51 +0100 Subject: [PATCH 038/546] ci --- .github/workflows/build_wheels.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 87d44b78f..36d31ccdd 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-python@v3 - name: Build wheel - run: python setup.py bdist_wheel -d ./wheelhouse/ + run: pip wheel -w ./wheelhouse/ . - uses: actions/upload-artifact@v3 with: From 80d9bf7997b0c77d6d3ceacf23036c5fb26be384 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:26:34 +0100 Subject: [PATCH 039/546] ci --- .github/workflows/build_wheels.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 36d31ccdd..63c3f46e0 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -4,17 +4,17 @@ on: [push, pull_request] jobs: build_wheels: - name: Build wheels on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-20.04, windows-2019, macOS-11] + name: Build wheels on ubuntu (where else???) + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v3 + - name: Install wheel + run: pip install wheel + - name: Build wheel run: pip wheel -w ./wheelhouse/ . From 9a7efa500e73fcf5e387eadfc9346dd9bd10c419 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:44:56 +0100 Subject: [PATCH 040/546] ci --- .github/workflows/build_wheels.yaml | 36 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 63c3f46e0..b4ffdb41c 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -1,10 +1,14 @@ name: Build -on: [push, pull_request] +on: + push: + tags: + - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 + jobs: - build_wheels: - name: Build wheels on ubuntu (where else???) + build_wheel: + name: Build wheel on ubuntu (where else???) runs-on: ubuntu-latest steps: @@ -16,8 +20,30 @@ jobs: run: pip install wheel - name: Build wheel - run: pip wheel -w ./wheelhouse/ . + run: pip wheel -w ./dist/ . - uses: actions/upload-artifact@v3 with: - path: ./wheelhouse/*.whl + path: ./dist/*.whl + + - name: release + uses: actions/create-release@v1 + id: create_release + with: + draft: false + prerelease: false + tag_name: ${{ github.ref }} # the tag + release_name: ${{ github.ref }} + + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: upload wheel + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/scenario-${{ github.ref }}-py3-none-any.whl + asset_name: scenario-${{ github.ref }}-py3-none-any.whl + asset_content_type: application/wheel From 631fcecdab27a28416bd0398ace3761edd3b861f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 19 Jan 2023 10:52:46 +0100 Subject: [PATCH 041/546] ci --- .github/workflows/build_wheels.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index b4ffdb41c..bcef4c518 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -26,14 +26,18 @@ jobs: with: path: ./dist/*.whl + - name: Get the version + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} + - name: release uses: actions/create-release@v1 id: create_release with: draft: false prerelease: false - tag_name: ${{ github.ref }} # the tag - release_name: ${{ github.ref }} + tag_name: ${{ steps.get_version.outputs.VERSION }} # the tag + release_name: ${{ steps.get_version.outputs.VERSION }} env: GITHUB_TOKEN: ${{ github.token }} @@ -44,6 +48,6 @@ jobs: GITHUB_TOKEN: ${{ github.token }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/scenario-${{ github.ref }}-py3-none-any.whl - asset_name: scenario-${{ github.ref }}-py3-none-any.whl + asset_path: ./dist/scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl + asset_name: scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl asset_content_type: application/wheel From db358711bfec549eb3440103fa1e721ecc1d8f2e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 09:22:22 +0100 Subject: [PATCH 042/546] ci --- .github/workflows/build_wheels.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index bcef4c518..84bfd3140 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -42,6 +42,9 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} + - name: Setup upterm session + uses: lhotari/action-upterm@v1 + - name: upload wheel uses: actions/upload-release-asset@v1 env: From 61b2948474f5e9d67276c579f1211955c7108167 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 09:26:02 +0100 Subject: [PATCH 043/546] ci --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 704f4efcc..b9eeb3dab 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def _read_me() -> str: return readme -version = "0.2" +version = "0.2.1" setup( name="scenario", From f0388de9911e8e7814a84fffc832f85e42565096 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 11:58:57 +0100 Subject: [PATCH 044/546] pebble push/pull/exec mocks --- .github/workflows/build_wheels.yaml | 4 +- scenario/mocking.py | 108 +++++++++++++- scenario/runtime.py | 5 +- scenario/structs.py | 60 +++++++- tests/test_e2e/test_observers.py | 11 +- tests/test_e2e/test_pebble.py | 215 ++++++++++++++++++++++++++++ 6 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 tests/test_e2e/test_pebble.py diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 84bfd3140..acafdf3d6 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -42,8 +42,8 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - - name: Setup upterm session - uses: lhotari/action-upterm@v1 +# - name: Setup upterm session +# uses: lhotari/action-upterm@v1 - name: upload wheel uses: actions/upload-release-asset@v1 diff --git a/scenario/mocking.py b/scenario/mocking.py index 5edf1f824..59ae786cf 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,11 +1,16 @@ import functools +import tempfile from dataclasses import dataclass -from typing import Dict, Optional, Tuple, Any, TYPE_CHECKING, Callable, Type +from io import StringIO +from pathlib import Path +from typing import Dict, Optional, Tuple, Any, TYPE_CHECKING, Callable, Type, Union from scenario.logger import logger as scenario_logger +from scenario.structs import ExecOutput if TYPE_CHECKING: from scenario.scenario import Scene, CharmSpec + from ops import pebble logger = scenario_logger.getChild('mocking') @@ -20,6 +25,32 @@ None] +class _MockExecProcess: + def __init__(self, command: Tuple[str], change_id: int, out: ExecOutput): + self._command = command + self._change_id = change_id + self._out = out + self._waited = False + self.stdout = StringIO(self._out.stdout) + self.stderr = StringIO(self._out.stderr) + + def wait(self): + self._waited = True + exit_code = self._out.return_code + if exit_code != 0: + raise pebble.ExecError(list(self._command), exit_code, None, None) + + def wait_output(self): + out = self._out + exit_code = out.return_code + if exit_code != 0: + raise pebble.ExecError(list(self._command), exit_code, None, None) + return out.stdout, out.stderr + + def send_signal(self, sig: Union[int, str]): + pass + + def wrap_tool( fn: Callable, namespace: str, @@ -157,11 +188,21 @@ def wrap_tool( # PEBBLE CALLS elif namespace == "Client": - if tool_name == "_request": - # fixme: can't differentiate between containers ATM, because Client._request - # does not pass around the container name as argument - container = input_state.containers[0] + # fixme: can't differentiate between containers, because Client._request + # does not pass around the container name as argument. Here we do it a bit ugly + # and extract it from 'self'. We could figure out a way to pass in a spec in a more + # generic/abstract way... + + client: "pebble.Client" = call_args[0] + container_name = client.socket_path.split('/')[-2] + try: + container = next(filter(lambda x: x.name == container_name, input_state.containers)) + except StopIteration: + raise RuntimeError(f'container with name={container_name!r} not found. ' + f'Did you forget a ContainerSpec, or is the socket path ' + f'{client.socket_path!r} wrong?') + if tool_name == "_request": if args == ("GET", "/v1/system-info"): if container.can_connect: return {"result": {"version": "unknown"}} @@ -188,11 +229,61 @@ def wrap_tool( else: raise NotImplementedError(f'_request: {args}') + elif tool_name == "exec": + cmd = tuple(args[0]) + out = container.exec_mock.get(cmd) + if not out: + raise RuntimeError(f'mock for cmd {cmd} not found.') + + change_id = out._run() + return _MockExecProcess( + change_id=change_id, + command=cmd, + out=out + ) + elif tool_name == "pull": - raise NotImplementedError("pull") + # todo double-check how to surface error + wrap_errors = False + + path_txt = args[0] + pos = container.filesystem + for token in path_txt.split("/")[1:]: + pos = pos.get(token) + if not pos: + raise FileNotFoundError(path_txt) + local_path = Path(pos) + if not local_path.exists() or not local_path.is_file(): + raise FileNotFoundError(local_path) + return local_path.open() + elif tool_name == "push": setter = True - raise NotImplementedError("push") + # todo double-check how to surface error + wrap_errors = False + + path_txt, contents = args + + pos = container.filesystem + tokens = path_txt.split('/')[1:] + for token in tokens[:-1]: + nxt = pos.get(token) + if not nxt and call_kwargs['make_dirs']: + pos[token] = {} + pos = pos[token] + elif not nxt: + raise FileNotFoundError(path_txt) + else: + pos = pos[token] + + # dump contents + # fixme: memory leak here if tmp isn't regularly cleaned up + file = tempfile.NamedTemporaryFile(delete=False) + pth = Path(file.name) + pth.write_text(contents) + + pos[tokens[-1]] = pth + return else: raise QuestionNotImplementedError(namespace) @@ -220,6 +311,9 @@ class DecorateSpec: # the function to be called instead of the decorated one simulator: Simulator = wrap_tool + # extra-args: callable to extract any other arguments from 'self' and pass them along. + extra_args: Optional[Callable[[Any], Dict[str, Any]]] = None + def _log_call( namespace: str, diff --git a/scenario/runtime.py b/scenario/runtime.py index 9ae2b4e66..d7e75adad 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -82,6 +82,7 @@ def patching(self, scene: "Scene"): logger.info(f"Installing {self}... ") from ops import pebble logger.info("patching ops.pebble") + pebble_decorator_specs = { "Client": { # todo: we could be more fine-grained and decorate individual Container methods, @@ -91,9 +92,11 @@ def patching(self, scene: "Scene"): # and deal in objects that cannot be json-serialized "pull": DecorateSpec(), "push": DecorateSpec(), + "exec": DecorateSpec(), } } - patch_module(pebble, decorate=pebble_decorator_specs, + patch_module(pebble, + decorate=pebble_decorator_specs, scene=scene) from ops import model diff --git a/scenario/structs.py b/scenario/structs.py index 20e7b5e7c..2d820ba91 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -1,10 +1,13 @@ import copy import dataclasses import inspect +import tempfile import typing +from io import StringIO, BytesIO from pathlib import Path from typing import Any, Dict, List, Literal, Sequence, Tuple, Union from typing import Optional, Type +from ops import testing from uuid import uuid4 import yaml @@ -19,7 +22,6 @@ from ops.testing import CharmType from ops.pebble import LayerDict - logger = scenario_logger.getChild('structs') ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" @@ -125,12 +127,66 @@ class Model(_DCBase): uuid: str = str(uuid4()) +_SimpleFS = Dict[ + str, # file/dirname + Union[ + "_SimpleFS", # subdir + Path # local-filesystem path resolving to a file. + ] +] + +# for now, proc mock allows you to map one command to one mocked output. +# todo extend: one input -> multiple outputs, at different times + + +_CHANGE_IDS = 0 +@dataclasses.dataclass +class ExecOutput: + return_code: int = 0 + stdout: str = "" + stderr: str = "" + + # change ID: used internally to keep track of mocked processes + _change_id: int = -1 + + def _run(self) -> int: + global _CHANGE_IDS + _CHANGE_IDS = self._change_id = _CHANGE_IDS + 1 + return _CHANGE_IDS + + +_ExecMock = Dict[Tuple[str, ...], ExecOutput] + + @dataclasses.dataclass class ContainerSpec(_DCBase): name: str can_connect: bool = False layers: Tuple["LayerDict"] = () - # todo mock filesystem and pebble proc? + + # this is how you specify the contents of the filesystem: suppose you want to express that your + # container has: + # - /home/foo/bar.py + # - /bin/bash + # - /bin/baz + # + # this becomes: + # filesystem = { + # 'home': { + # 'foo': Path('/path/to/local/file/containing/bar.py') + # }, + # 'bin': { + # 'bash': Path('/path/to/local/bash'), + # 'baz': Path('/path/to/local/baz') + # } + # } + # when the charm runs `pebble.pull`, it will return .open() from one of those paths. + # when the charm pushes, it will either overwrite one of those paths (careful!) or it will + # create a tempfile and insert its path in the mock filesystem tree + # charm-created tempfiles will NOT be automatically deleted -- you have to clean them up yourself! + filesystem: _SimpleFS = dataclasses.field(default_factory=dict) + + exec_mock: _ExecMock = dataclasses.field(default_factory=dict) @dataclasses.dataclass diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 65048a96e..f8c2e5783 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -1,20 +1,15 @@ -import pytest -from dataclasses import asdict -from typing import Optional, Type +from typing import Optional import pytest -from ops.charm import CharmBase, CharmEvents, StartEvent, ActionEvent -from ops.framework import EventBase, Framework -from ops.model import ActiveStatus, UnknownStatus, WaitingStatus +from ops.charm import CharmBase, StartEvent, ActionEvent +from ops.framework import Framework from scenario.scenario import Scenario from scenario.structs import ( CharmSpec, - ContainerSpec, Scene, State, event, - relation, sort_patch, ) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py new file mode 100644 index 000000000..0e55bcd47 --- /dev/null +++ b/tests/test_e2e/test_pebble.py @@ -0,0 +1,215 @@ +import tempfile +from io import StringIO +from pathlib import Path +from typing import Optional + +import pytest +from ops.charm import CharmBase, StartEvent, ActionEvent +from ops.framework import Framework + +from scenario.scenario import Scenario +from scenario.structs import ( + CharmSpec, + Scene, + State, + event, ContainerSpec, ExecOutput, +) + + +@pytest.fixture(scope="function") +def charm_cls(): + class MyCharm(CharmBase): + callback = None + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + self.callback(event) + + return MyCharm + + +def test_no_containers(charm_cls): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo"})) + scene = Scene(event("start"), state=State()) + + def callback(self: CharmBase, evt): + assert not self.unit.containers + + charm_cls.callback = callback + scenario.play(scene) + + +def test_containers_from_meta(charm_cls): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo", + "containers": {"foo": {}}})) + scene = Scene(event("start"), state=State()) + + def callback(self: CharmBase, evt): + assert self.unit.containers + assert self.unit.get_container('foo') + + charm_cls.callback = callback + scenario.play(scene) + + +@pytest.mark.parametrize('can_connect', (True, False)) +def test_connectivity(charm_cls, can_connect): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo", + "containers": {"foo": {}}})) + scene = Scene( + event("start"), + state=State( + containers=[ + ContainerSpec(name='foo', can_connect=can_connect) + ] + ) + ) + + def callback(self: CharmBase, evt): + assert can_connect == self.unit.get_container('foo').can_connect() + + charm_cls.callback = callback + scenario.play(scene) + + +def test_fs_push(charm_cls): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo", + "containers": {"foo": {}}})) + + text = "lorem ipsum/n alles amat gloriae foo" + file = tempfile.NamedTemporaryFile() + pth = Path(file.name) + pth.write_text(text) + + scene = Scene( + event("start"), + state=State( + containers=[ + ContainerSpec( + name='foo', + can_connect=True, + filesystem={'bar': + {'baz.txt': pth} + }) + ] + ) + ) + + def callback(self: CharmBase, evt): + container = self.unit.get_container('foo') + baz = container.pull('/bar/baz.txt') + assert baz.read() == text + + charm_cls.callback = callback + scenario.play(scene) + + +@pytest.mark.parametrize('make_dirs', (True, False)) +def test_fs_pull(charm_cls, make_dirs): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo", + "containers": {"foo": {}}})) + + scene = Scene( + event("start"), + state=State( + containers=[ + ContainerSpec( + name='foo', + can_connect=True) + ] + ) + ) + + text = "lorem ipsum/n alles amat gloriae foo" + + def callback(self: CharmBase, evt): + container = self.unit.get_container('foo') + if make_dirs: + container.push('/bar/baz.txt', text, make_dirs=make_dirs) + # check that pulling immediately 'works' + baz = container.pull('/bar/baz.txt') + assert baz.read() == text + else: + with pytest.raises(FileNotFoundError): + container.push('/bar/baz.txt', text, make_dirs=make_dirs) + + # check that nothing was changed + with pytest.raises(FileNotFoundError): + container.pull('/bar/baz.txt') + + charm_cls.callback = callback + out = scenario.play(scene) + + if make_dirs: + file = out.get_container('foo').filesystem['bar']['baz.txt'] + assert file.read_text() == text + else: + # nothing has changed + assert not out.delta(scene.state) + + +LS = """ +.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml +.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml +.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md +drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib +.rw-rw-r-- 11k ubuntu ubuntu 18 jan 12:05 -- LICENSE +.rw-rw-r-- 1,6k ubuntu ubuntu 18 jan 12:05 -- metadata.yaml +.rw-rw-r-- 845 ubuntu ubuntu 18 jan 12:05 -- pyproject.toml +.rw-rw-r-- 831 ubuntu ubuntu 18 jan 12:05 -- README.md +.rw-rw-r-- 13 ubuntu ubuntu 18 jan 12:05 -- requirements.txt +drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- src +drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- tests +.rw-rw-r-- 1,9k ubuntu ubuntu 18 jan 12:05 -- tox.ini +""" +PS = """ + PID TTY TIME CMD + 298238 pts/3 00:00:04 zsh +1992454 pts/3 00:00:00 ps +""" + + +@pytest.mark.parametrize('cmd, out', ( + ('ls', LS), + ('ps', PS), +)) +def test_exec(charm_cls, cmd, out): + scenario = Scenario( + CharmSpec(charm_cls, + meta={"name": "foo", + "containers": {"foo": {}}})) + + scene = Scene( + event("start"), + state=State( + containers=[ + ContainerSpec( + name='foo', + can_connect=True, + exec_mock={(cmd, ): ExecOutput(stdout='hello pebble')}) + ] + ) + ) + + def callback(self: CharmBase, evt): + container = self.unit.get_container('foo') + proc = container.exec([cmd]) + proc.wait() + assert proc.stdout.read() == 'hello pebble' + + charm_cls.callback = callback + scenario.play(scene) From 26c316dd3b4a24d48af4b5cb831db1f5f3ef66ba Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 11:59:09 +0100 Subject: [PATCH 045/546] vbump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b9eeb3dab..53003ce8b 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def _read_me() -> str: return readme -version = "0.2.1" +version = "0.2.2" setup( name="scenario", From f8744d2ca788f1ccc0165fc64ef081a105021f6d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 12:03:45 +0100 Subject: [PATCH 046/546] lint --- scenario/mocking.py | 165 ++++++++++++++----------- scenario/ops_main_mock.py | 23 ++-- scenario/runtime.py | 57 +++++---- scenario/scenario.py | 60 ++++----- scenario/structs.py | 91 +++++++------- tests/test_e2e/test_builtin_scenes.py | 2 +- tests/test_e2e/test_observers.py | 20 ++- tests/test_e2e/test_pebble.py | 107 +++++++--------- tests/test_e2e/test_play_assertions.py | 42 +++---- tests/test_e2e/test_state.py | 28 ++--- tests/test_mocking.py | 68 +++++----- tests/test_runtime.py | 16 +-- 12 files changed, 334 insertions(+), 345 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 59ae786cf..ebbb146f7 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -3,26 +3,30 @@ from dataclasses import dataclass from io import StringIO from pathlib import Path -from typing import Dict, Optional, Tuple, Any, TYPE_CHECKING, Callable, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union from scenario.logger import logger as scenario_logger from scenario.structs import ExecOutput if TYPE_CHECKING: - from scenario.scenario import Scene, CharmSpec from ops import pebble -logger = scenario_logger.getChild('mocking') + from scenario.scenario import CharmSpec, Scene + +logger = scenario_logger.getChild("mocking") Simulator = Callable[ - [Callable[[Any], Any], # simulated function - str, # namespace - str, # tool name - "Scene", # scene - Optional["CharmSpec"], # charm spec - Tuple[Any, ...], # call args - Dict[str, Any]], # call kwargs - None] + [ + Callable[[Any], Any], # simulated function + str, # namespace + str, # tool name + "Scene", # scene + Optional["CharmSpec"], # charm spec + Tuple[Any, ...], # call args + Dict[str, Any], + ], # call kwargs + None, +] class _MockExecProcess: @@ -52,13 +56,13 @@ def send_signal(self, sig: Union[int, str]): def wrap_tool( - fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - charm_spec: Optional["CharmSpec"], - call_args: Tuple[Any, ...], - call_kwargs: Dict[str, Any] + fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + charm_spec: Optional["CharmSpec"], + call_args: Tuple[Any, ...], + call_kwargs: Dict[str, Any], ): # all builtin tools we wrap are methods: # _self = call_args[0] @@ -76,7 +80,9 @@ def wrap_tool( if tool_name == "relation_get": rel_id, obj_name, app = args relation = next( - filter(lambda r: r.meta.relation_id == rel_id, input_state.relations) + filter( + lambda r: r.meta.relation_id == rel_id, input_state.relations + ) ) if app and obj_name == this_app_name: return relation.local_app_data @@ -93,7 +99,9 @@ def wrap_tool( elif tool_name == "status_get": status, message = ( - input_state.status.app if call_kwargs.get("app") else input_state.status.unit + input_state.status.app + if call_kwargs.get("app") + else input_state.status.unit ) return {"status": status, "message": message} @@ -103,7 +111,9 @@ def wrap_tool( elif tool_name == "relation_list": rel_id = args[0] relation = next( - filter(lambda r: r.meta.relation_id == rel_id, input_state.relations) + filter( + lambda r: r.meta.relation_id == rel_id, input_state.relations + ) ) return tuple( f"{relation.meta.remote_app_name}/{unit_id}" @@ -113,7 +123,10 @@ def wrap_tool( elif tool_name == "config_get": state_config = input_state.config if not state_config: - state_config = {key: value.get('default') for key, value in charm_spec.config.items()} + state_config = { + key: value.get("default") + for key, value in charm_spec.config.items() + } if args: # one specific key requested # Fixme: may raise KeyError if the key isn't defaulted. What do we do then? @@ -158,7 +171,9 @@ def wrap_tool( elif tool_name == "relation_set": rel_id, key, value, app = args relation = next( - filter(lambda r: r.meta.relation_id == rel_id, scene.state.relations) + filter( + lambda r: r.meta.relation_id == rel_id, scene.state.relations + ) ) if app: if not scene.state.leader: @@ -194,13 +209,17 @@ def wrap_tool( # generic/abstract way... client: "pebble.Client" = call_args[0] - container_name = client.socket_path.split('/')[-2] + container_name = client.socket_path.split("/")[-2] try: - container = next(filter(lambda x: x.name == container_name, input_state.containers)) + container = next( + filter(lambda x: x.name == container_name, input_state.containers) + ) except StopIteration: - raise RuntimeError(f'container with name={container_name!r} not found. ' - f'Did you forget a ContainerSpec, or is the socket path ' - f'{client.socket_path!r} wrong?') + raise RuntimeError( + f"container with name={container_name!r} not found. " + f"Did you forget a ContainerSpec, or is the socket path " + f"{client.socket_path!r} wrong?" + ) if tool_name == "_request": if args == ("GET", "/v1/system-info"): @@ -211,7 +230,7 @@ def wrap_tool( raise FileNotFoundError("") elif args[:2] == ("GET", "/v1/services"): - service_names = list(args[2]['names'].split(',')) + service_names = list(args[2]["names"].split(",")) result = [] for layer in container.layers: @@ -219,28 +238,24 @@ def wrap_tool( break for name in service_names: - if name in layer['services']: + if name in layer["services"]: service_names.remove(name) - result.append(layer['services'][name]) + result.append(layer["services"][name]) # todo: what do we do if we don't find the requested service(s)? - return {'result': result} + return {"result": result} else: - raise NotImplementedError(f'_request: {args}') + raise NotImplementedError(f"_request: {args}") elif tool_name == "exec": cmd = tuple(args[0]) out = container.exec_mock.get(cmd) if not out: - raise RuntimeError(f'mock for cmd {cmd} not found.') + raise RuntimeError(f"mock for cmd {cmd} not found.") change_id = out._run() - return _MockExecProcess( - change_id=change_id, - command=cmd, - out=out - ) + return _MockExecProcess(change_id=change_id, command=cmd, out=out) elif tool_name == "pull": # todo double-check how to surface error @@ -265,10 +280,10 @@ def wrap_tool( path_txt, contents = args pos = container.filesystem - tokens = path_txt.split('/')[1:] + tokens = path_txt.split("/")[1:] for token in tokens[:-1]: nxt = pos.get(token) - if not nxt and call_kwargs['make_dirs']: + if not nxt and call_kwargs["make_dirs"]: pos[token] = {} pos = pos[token] elif not nxt: @@ -316,14 +331,14 @@ class DecorateSpec: def _log_call( - namespace: str, - tool_name: str, - args, - kwargs, - recorded_output: Any = None, - # use print, not logger calls, else the root logger will recurse if - # juju-log calls are being @wrapped as well. - log_fn: Callable[[str], None] = logger.debug, + namespace: str, + tool_name: str, + args, + kwargs, + recorded_output: Any = None, + # use print, not logger calls, else the root logger will recurse if + # juju-log calls are being @wrapped as well. + log_fn: Callable[[str], None] = logger.debug, ): try: output_repr = repr(recorded_output) @@ -348,12 +363,12 @@ class QuestionNotImplementedError(StateError): def wrap( - fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - charm_spec: "CharmSpec", - simulator: Simulator = wrap_tool + fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + charm_spec: "CharmSpec", + simulator: Simulator = wrap_tool, ): @functools.wraps(fn) def wrapper(*call_args, **call_kwargs): @@ -364,7 +379,8 @@ def wrapper(*call_args, **call_kwargs): scene=scene, charm_spec=charm_spec, call_args=call_args, - call_kwargs=call_kwargs) + call_kwargs=call_kwargs, + ) _log_call(namespace, tool_name, call_args, call_kwargs, out) return out @@ -374,10 +390,10 @@ def wrapper(*call_args, **call_kwargs): # todo: figure out how to allow users to manually tag individual functions for wrapping def patch_module( - module, - decorate: Dict[str, Dict[str, DecorateSpec]], - scene: "Scene", - charm_spec: "CharmSpec" = None + module, + decorate: Dict[str, Dict[str, DecorateSpec]], + scene: "Scene", + charm_spec: "CharmSpec" = None, ): """Patch a module by decorating methods in a number of classes. @@ -398,16 +414,15 @@ def patch_module( if not specs: continue - patch_class(specs, obj, - scene=scene, - charm_spec=charm_spec) + patch_class(specs, obj, scene=scene, charm_spec=charm_spec) -def patch_class(specs: Dict[str, DecorateSpec], - obj: Type, - scene: "Scene", - charm_spec: "CharmSpec", - ): +def patch_class( + specs: Dict[str, DecorateSpec], + obj: Type, + scene: "Scene", + charm_spec: "CharmSpec", +): for meth_name, fn in obj.__dict__.items(): spec = specs.get(meth_name) @@ -415,11 +430,13 @@ def patch_class(specs: Dict[str, DecorateSpec], continue # todo: use mock.patch and lift after exit - wrapped_fn = wrap(fn, - namespace=obj.__name__, - tool_name=meth_name, - scene=scene, - charm_spec=charm_spec, - simulator=spec.simulator) + wrapped_fn = wrap( + fn, + namespace=obj.__name__, + tool_name=meth_name, + scene=scene, + charm_spec=charm_spec, + simulator=spec.simulator, + ) setattr(obj, meth_name, wrapped_fn) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b733365c2..087c35502 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -5,7 +5,7 @@ import logging import os import warnings -from typing import TYPE_CHECKING, Optional, Tuple, Type, Callable +from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type import ops.charm import ops.framework @@ -15,25 +15,28 @@ from ops.jujuversion import JujuVersion from ops.log import setup_root_logging from ops.main import ( + CHARM_STATE_FILE, _Dispatcher, - _get_charm_dir, _emit_charm_event, - _should_use_controller_storage, CHARM_STATE_FILE, + _get_charm_dir, + _should_use_controller_storage, ) + from scenario.logger import logger as scenario_logger if TYPE_CHECKING: - from ops.testing import CharmType from ops.charm import CharmBase, EventBase + from ops.testing import CharmType -logger = scenario_logger.getChild('ops_main_mock') +logger = scenario_logger.getChild("ops_main_mock") -def main(charm_class: Type[ops.charm.CharmBase], - use_juju_for_storage: Optional[bool] = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: +def main( + charm_class: Type[ops.charm.CharmBase], + use_juju_for_storage: Optional[bool] = None, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, +) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: """Setup the charm and dispatch the observed event. The event name is based on the way this executable was called (argv[0]). diff --git a/scenario/runtime.py b/scenario/runtime.py index d7e75adad..22aa90676 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,7 +10,7 @@ import yaml from scenario.logger import logger as scenario_logger -from scenario.mocking import patch_module, DecorateSpec +from scenario.mocking import DecorateSpec, patch_module if TYPE_CHECKING: from ops.charm import CharmBase @@ -40,9 +40,9 @@ class Runtime: """ def __init__( - self, - charm_spec: "CharmSpec", - juju_version: str = "3.0.0", + self, + charm_spec: "CharmSpec", + juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -52,8 +52,8 @@ def __init__( @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -75,12 +75,12 @@ def from_local_file( @contextmanager def patching(self, scene: "Scene"): - """Install the runtime: patch all required backend calls. - """ + """Install the runtime: patch all required backend calls.""" # copy input state to act as blueprint for output state logger.info(f"Installing {self}... ") from ops import pebble + logger.info("patching ops.pebble") pebble_decorator_specs = { @@ -95,11 +95,10 @@ def patching(self, scene: "Scene"): "exec": DecorateSpec(), } } - patch_module(pebble, - decorate=pebble_decorator_specs, - scene=scene) + patch_module(pebble, decorate=pebble_decorator_specs, scene=scene) from ops import model + logger.info("patching ops.model") model_decorator_specs = { "_ModelBackend": { @@ -125,7 +124,6 @@ def patching(self, scene: "Scene"): "storage_add": DecorateSpec(), "juju_log": DecorateSpec(), "planned_units": DecorateSpec(), - # todo different ops version support? # "secret_get": DecorateSpec(), # "secret_set": DecorateSpec(), @@ -133,8 +131,12 @@ def patching(self, scene: "Scene"): # "secret_remove": DecorateSpec(), } } - patch_module(model, decorate=model_decorator_specs, - scene=scene, charm_spec=self._charm_spec) + patch_module( + model, + decorate=model_decorator_specs, + scene=scene, + charm_spec=self._charm_spec, + ) yield @@ -147,6 +149,7 @@ def _patch_logger(*args, **kwargs): pass from scenario import ops_main_mock + ops_main_mock.setup_root_logging = _patch_logger @staticmethod @@ -163,9 +166,9 @@ def unit_name(self): return meta["name"] + "/0" # todo allow override def _get_event_env(self, scene: "Scene", charm_root: Path): - if scene.event.name.endswith('_action'): + if scene.event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names are always dashes? - action_name = scene.event.name[:-len('_action')].replace('_', '-') + action_name = scene.event.name[: -len("_action")].replace("_", "-") else: action_name = "" @@ -217,17 +220,17 @@ def virtual_charm_root(self): spec = self._charm_spec with tempfile.TemporaryDirectory() as tempdir: temppath = Path(tempdir) - (temppath / 'metadata.yaml').write_text(yaml.safe_dump(spec.meta)) - (temppath / 'config.yaml').write_text(yaml.safe_dump(spec.config or {})) - (temppath / 'actions.yaml').write_text(yaml.safe_dump(spec.actions or {})) + (temppath / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) + (temppath / "config.yaml").write_text(yaml.safe_dump(spec.config or {})) + (temppath / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) yield temppath def play( - self, - scene: "Scene", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> 'State': + self, + scene: "Scene", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> "State": """Plays a scene on the charm. This will set the environment up and call ops.main.main(). @@ -250,13 +253,13 @@ def play( self._redirect_root_logger() logger.info(" - preparing env") - env = self._get_event_env(scene, - charm_root=temporary_charm_root) + env = self._get_event_env(scene, charm_root=temporary_charm_root) os.environ.update(env) logger.info(" - Entering ops.main (mocked).") # we don't import from ops.main because we need some extras, such as the pre/post_event hooks from scenario.ops_main_mock import main as mocked_main + try: mocked_main( self._wrap(self._charm_type), @@ -273,5 +276,5 @@ def play( logger.info(" - clearing env") self._cleanup_env(env) - logger.info('event fired; done.') + logger.info("event fired; done.") return scene.state diff --git a/scenario/scenario.py b/scenario/scenario.py index caa1e7476..a761be356 100644 --- a/scenario/scenario.py +++ b/scenario/scenario.py @@ -1,24 +1,21 @@ import typing from itertools import chain -from typing import ( - Callable, - Iterable, - Optional, - TextIO, - Type, - Union, -) +from typing import Callable, Iterable, Optional, TextIO, Type, Union -from scenario.runtime import Runtime from scenario.logger import logger as scenario_logger +from scenario.runtime import Runtime from scenario.structs import ( ATTACH_ALL_STORAGES, BREAK_ALL_RELATIONS, CREATE_ALL_RELATIONS, DETACH_ALL_STORAGES, META_EVENTS, + CharmSpec, + Event, + InjectRelation, + Scene, + State, ) -from scenario.structs import CharmSpec, Event, InjectRelation, Scene, State if typing.TYPE_CHECKING: from ops.testing import CharmType @@ -30,12 +27,11 @@ class Scenario: def __init__( - self, - charm_spec: CharmSpec, - juju_version: str = "3.0.0", + self, + charm_spec: CharmSpec, + juju_version: str = "3.0.0", ): - self._runtime = Runtime(charm_spec, - juju_version=juju_version) + self._runtime = Runtime(charm_spec, juju_version=juju_version) @staticmethod def decompose_meta_event(meta_event: Event, state: State): @@ -64,10 +60,10 @@ def decompose_meta_event(meta_event: Event, state: State): raise RuntimeError(f"unknown meta-event {meta_event.name}") def play( - self, - scene: Scene, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + self, + scene: Scene, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": # TODO check state transition consistency: verify that if state was mutated, it was # in a way that makes sense: @@ -85,8 +81,12 @@ def generate_startup_scenes(state_template: State): Scene(event=Event(ATTACH_ALL_STORAGES), state=state_template.copy()), Scene(event=Event("start"), state=state_template.copy()), Scene(event=Event(CREATE_ALL_RELATIONS), state=state_template.copy()), - Scene(event=Event("leader-elected" if state_template.leader else "leader-settings-changed"), - state=state_template.copy()), + Scene( + event=Event( + "leader-elected" if state_template.leader else "leader-settings-changed" + ), + state=state_template.copy(), + ), Scene(event=Event("config-changed"), state=state_template.copy()), Scene(event=Event("install"), state=state_template.copy()), ) @@ -105,14 +105,14 @@ def generate_builtin_scenes(template_states: Iterable[State]): for template_state in template_states: yield from chain( generate_startup_scenes(template_state), - generate_teardown_scenes(template_state) + generate_teardown_scenes(template_state), ) def check_builtin_sequences( - charm_spec: CharmSpec, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + charm_spec: CharmSpec, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ): """Test that all the builtin startup and teardown events can fire without errors. @@ -127,10 +127,10 @@ def check_builtin_sequences( """ scenario = Scenario(charm_spec) - for scene in generate_builtin_scenes(( + for scene in generate_builtin_scenes( + ( State(leader=True), State(leader=False), - )): - scenario.play(scene, - pre_event=pre_event, - post_event=post_event) + ) + ): + scenario.play(scene, pre_event=pre_event, post_event=post_event) diff --git a/scenario/structs.py b/scenario/structs.py index 2d820ba91..1d0870acd 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -3,14 +3,13 @@ import inspect import tempfile import typing -from io import StringIO, BytesIO +from io import BytesIO, StringIO from pathlib import Path -from typing import Any, Dict, List, Literal, Sequence, Tuple, Union -from typing import Optional, Type -from ops import testing +from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Type, Union from uuid import uuid4 import yaml +from ops import testing from scenario.logger import logger as scenario_logger @@ -19,10 +18,10 @@ from typing import Self except ImportError: from typing_extensions import Self - from ops.testing import CharmType from ops.pebble import LayerDict + from ops.testing import CharmType -logger = scenario_logger.getChild('structs') +logger = scenario_logger.getChild("structs") ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" @@ -76,40 +75,35 @@ class RelationSpec(_DCBase): def changed_event(self): """Sugar to generate a -changed event.""" return Event( - name=self.meta.endpoint + "-changed", - meta=EventMeta(relation=self.meta) + name=self.meta.endpoint + "-changed", meta=EventMeta(relation=self.meta) ) @property def joined_event(self): """Sugar to generate a -joined event.""" return Event( - name=self.meta.endpoint + "-joined", - meta=EventMeta(relation=self.meta) + name=self.meta.endpoint + "-joined", meta=EventMeta(relation=self.meta) ) @property def created_event(self): """Sugar to generate a -created event.""" return Event( - name=self.meta.endpoint + "-created", - meta=EventMeta(relation=self.meta) + name=self.meta.endpoint + "-created", meta=EventMeta(relation=self.meta) ) @property def departed_event(self): """Sugar to generate a -departed event.""" return Event( - name=self.meta.endpoint + "-departed", - meta=EventMeta(relation=self.meta) + name=self.meta.endpoint + "-departed", meta=EventMeta(relation=self.meta) ) @property def removed_event(self): """Sugar to generate a -removed event.""" return Event( - name=self.meta.endpoint + "-removed", - meta=EventMeta(relation=self.meta) + name=self.meta.endpoint + "-removed", meta=EventMeta(relation=self.meta) ) @@ -129,10 +123,7 @@ class Model(_DCBase): _SimpleFS = Dict[ str, # file/dirname - Union[ - "_SimpleFS", # subdir - Path # local-filesystem path resolving to a file. - ] + Union["_SimpleFS", Path], # subdir # local-filesystem path resolving to a file. ] # for now, proc mock allows you to map one command to one mocked output. @@ -140,6 +131,8 @@ class Model(_DCBase): _CHANGE_IDS = 0 + + @dataclasses.dataclass class ExecOutput: return_code: int = 0 @@ -299,24 +292,21 @@ def from_charm(charm_type: Type["CharmType"]): charm_source_path = Path(inspect.getfile(charm_type)) charm_root = charm_source_path.parent.parent - metadata_path = charm_root / 'metadata.yaml' + metadata_path = charm_root / "metadata.yaml" meta = yaml.safe_load(metadata_path.open()) actions = config = None - config_path = charm_root / 'config.yaml' + config_path = charm_root / "config.yaml" if config_path.exists(): config = yaml.safe_load(config_path.open()) - actions_path = charm_root / 'actions.yaml' + actions_path = charm_root / "actions.yaml" if actions_path.exists(): actions = yaml.safe_load(actions_path.open()) return CharmSpec( - charm_type=charm_type, - meta=meta, - actions=actions, - config=config + charm_type=charm_type, meta=meta, actions=actions, config=config ) @@ -376,14 +366,15 @@ def is_meta(self): # return unit_name.split("/")[0] if unit_name else "" # + @dataclasses.dataclass class SceneMeta(_DCBase): - unit_id: str = '0' - app_name: str = 'local' + unit_id: str = "0" + app_name: str = "local" @property def unit_name(self): - return self.app_name + '/' + self.unit_id + return self.app_name + "/" + self.unit_id @dataclasses.dataclass @@ -410,21 +401,25 @@ class InjectRelation(Inject): def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[int] = None, # defaults to (0,) if remote_units_data is not provided - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, - remote_units_data: Dict[int, Dict[str, str]] = None, + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[ + int + ] = None, # defaults to (0,) if remote_units_data is not provided + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, + remote_units_data: Dict[int, Dict[str, str]] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" if remote_unit_ids and remote_units_data: if not set(remote_unit_ids) == set(remote_units_data): - raise ValueError(f"{remote_unit_ids} should include any and all IDs from {remote_units_data}") + raise ValueError( + f"{remote_unit_ids} should include any and all IDs from {remote_units_data}" + ) elif remote_unit_ids: remote_units_data = {x: {} for x in remote_unit_ids} elif remote_units_data: @@ -450,13 +445,13 @@ def relation( def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> Network: """Construct a network object.""" return Network( diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index 176167d58..3fbf74824 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -1,9 +1,9 @@ from typing import Optional, Type import pytest - from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework + from scenario.scenario import check_builtin_sequences from scenario.structs import CharmSpec diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index f8c2e5783..728ab9b27 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -1,21 +1,17 @@ from typing import Optional import pytest -from ops.charm import CharmBase, StartEvent, ActionEvent +from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - Scene, - State, - event, -) +from scenario.structs import CharmSpec, Scene, State, event @pytest.fixture(scope="function") def charm_evts(): events = [] + class MyCharm(CharmBase): def __init__(self, framework: Framework, key: Optional[str] = None): super().__init__(framework, key) @@ -33,9 +29,8 @@ def _on_event(self, event): def test_start_event(charm_evts): charm, evts = charm_evts scenario = Scenario( - CharmSpec(charm, - meta={"name": "foo"}, - actions={"show_proxied_endpoints": {}})) + CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) + ) scene = Scene(event("start"), state=State()) scenario.play(scene) assert len(evts) == 1 @@ -47,9 +42,8 @@ def test_action_event(charm_evts): charm, evts = charm_evts scenario = Scenario( - CharmSpec(charm, - meta={"name": "foo"}, - actions={"show_proxied_endpoints": {}})) + CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) + ) scene = Scene(event("show_proxied_endpoints_action"), state=State()) scenario.play(scene) assert len(evts) == 1 diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 0e55bcd47..e902b9724 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -4,16 +4,11 @@ from typing import Optional import pytest -from ops.charm import CharmBase, StartEvent, ActionEvent +from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - Scene, - State, - event, ContainerSpec, ExecOutput, -) +from scenario.structs import CharmSpec, ContainerSpec, ExecOutput, Scene, State, event @pytest.fixture(scope="function") @@ -33,9 +28,7 @@ def _on_event(self, event): def test_no_containers(charm_cls): - scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo"})) + scenario = Scenario(CharmSpec(charm_cls, meta={"name": "foo"})) scene = Scene(event("start"), state=State()) def callback(self: CharmBase, evt): @@ -47,36 +40,30 @@ def callback(self: CharmBase, evt): def test_containers_from_meta(charm_cls): scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo", - "containers": {"foo": {}}})) + CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + ) scene = Scene(event("start"), state=State()) def callback(self: CharmBase, evt): assert self.unit.containers - assert self.unit.get_container('foo') + assert self.unit.get_container("foo") charm_cls.callback = callback scenario.play(scene) -@pytest.mark.parametrize('can_connect', (True, False)) +@pytest.mark.parametrize("can_connect", (True, False)) def test_connectivity(charm_cls, can_connect): scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo", - "containers": {"foo": {}}})) + CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + ) scene = Scene( event("start"), - state=State( - containers=[ - ContainerSpec(name='foo', can_connect=can_connect) - ] - ) + state=State(containers=[ContainerSpec(name="foo", can_connect=can_connect)]), ) def callback(self: CharmBase, evt): - assert can_connect == self.unit.get_container('foo').can_connect() + assert can_connect == self.unit.get_container("foo").can_connect() charm_cls.callback = callback scenario.play(scene) @@ -84,9 +71,8 @@ def callback(self: CharmBase, evt): def test_fs_push(charm_cls): scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo", - "containers": {"foo": {}}})) + CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + ) text = "lorem ipsum/n alles amat gloriae foo" file = tempfile.NamedTemporaryFile() @@ -98,64 +84,54 @@ def test_fs_push(charm_cls): state=State( containers=[ ContainerSpec( - name='foo', - can_connect=True, - filesystem={'bar': - {'baz.txt': pth} - }) + name="foo", can_connect=True, filesystem={"bar": {"baz.txt": pth}} + ) ] - ) + ), ) def callback(self: CharmBase, evt): - container = self.unit.get_container('foo') - baz = container.pull('/bar/baz.txt') + container = self.unit.get_container("foo") + baz = container.pull("/bar/baz.txt") assert baz.read() == text charm_cls.callback = callback scenario.play(scene) -@pytest.mark.parametrize('make_dirs', (True, False)) +@pytest.mark.parametrize("make_dirs", (True, False)) def test_fs_pull(charm_cls, make_dirs): scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo", - "containers": {"foo": {}}})) + CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + ) scene = Scene( event("start"), - state=State( - containers=[ - ContainerSpec( - name='foo', - can_connect=True) - ] - ) + state=State(containers=[ContainerSpec(name="foo", can_connect=True)]), ) text = "lorem ipsum/n alles amat gloriae foo" def callback(self: CharmBase, evt): - container = self.unit.get_container('foo') + container = self.unit.get_container("foo") if make_dirs: - container.push('/bar/baz.txt', text, make_dirs=make_dirs) + container.push("/bar/baz.txt", text, make_dirs=make_dirs) # check that pulling immediately 'works' - baz = container.pull('/bar/baz.txt') + baz = container.pull("/bar/baz.txt") assert baz.read() == text else: with pytest.raises(FileNotFoundError): - container.push('/bar/baz.txt', text, make_dirs=make_dirs) + container.push("/bar/baz.txt", text, make_dirs=make_dirs) # check that nothing was changed with pytest.raises(FileNotFoundError): - container.pull('/bar/baz.txt') + container.pull("/bar/baz.txt") charm_cls.callback = callback out = scenario.play(scene) if make_dirs: - file = out.get_container('foo').filesystem['bar']['baz.txt'] + file = out.get_container("foo").filesystem["bar"]["baz.txt"] assert file.read_text() == text else: # nothing has changed @@ -183,33 +159,36 @@ def callback(self: CharmBase, evt): """ -@pytest.mark.parametrize('cmd, out', ( - ('ls', LS), - ('ps', PS), -)) +@pytest.mark.parametrize( + "cmd, out", + ( + ("ls", LS), + ("ps", PS), + ), +) def test_exec(charm_cls, cmd, out): scenario = Scenario( - CharmSpec(charm_cls, - meta={"name": "foo", - "containers": {"foo": {}}})) + CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + ) scene = Scene( event("start"), state=State( containers=[ ContainerSpec( - name='foo', + name="foo", can_connect=True, - exec_mock={(cmd, ): ExecOutput(stdout='hello pebble')}) + exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, + ) ] - ) + ), ) def callback(self: CharmBase, evt): - container = self.unit.get_container('foo') + container = self.unit.get_container("foo") proc = container.exec([cmd]) proc.wait() - assert proc.stdout.read() == 'hello pebble' + assert proc.stdout.read() == "hello pebble" charm_cls.callback = callback scenario.play(scene) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index d0da4bf26..53e1ef1b2 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -1,12 +1,12 @@ from typing import Optional import pytest - from ops.charm import CharmBase from ops.framework import Framework -from ops.model import BlockedStatus, ActiveStatus +from ops.model import ActiveStatus, BlockedStatus + from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Scene, State, event, relation, Status +from scenario.structs import CharmSpec, Scene, State, Status, event, relation @pytest.fixture(scope="function") @@ -52,17 +52,14 @@ def post_event(charm): mycharm._call = call initial_state = State( - config={"foo": "bar"}, leader=True, - status=Status(unit=('blocked', 'foo')) + config={"foo": "bar"}, leader=True, status=Status(unit=("blocked", "foo")) ) out = scenario.play( - Scene( - event("update-status"), - state=initial_state), + Scene(event("update-status"), state=initial_state), ) - assert out.status.unit == ('active', 'yabadoodle') + assert out.status.unit == ("active", "yabadoodle") out.juju_log = [] # exclude juju log from delta assert out.delta(initial_state) == [ @@ -106,20 +103,19 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} scene = Scene( - state=State( - relations=[ - relation( - endpoint="relation_test", - interface="azdrubales", - remote_app_name="karlos", - remote_app_data={"yaba": "doodle"}, - remote_units_data={0: {"foo": "bar"}, - 1: {"baz": "qux"}}, - ) - ] - ), - event=event("update-status"), - ) + state=State( + relations=[ + relation( + endpoint="relation_test", + interface="azdrubales", + remote_app_name="karlos", + remote_app_data={"yaba": "doodle"}, + remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, + ) + ] + ), + event=event("update-status"), + ) scenario.play( scene, diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 98657c29b..bce75a340 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -13,7 +13,8 @@ Scene, State, event, - relation, sort_patch, + relation, + sort_patch, ) # from tests.setup_tests import setup_tests @@ -63,16 +64,15 @@ def _on_event(self, event): @pytest.fixture def dummy_state(): - return State(config={"foo": "bar"}, - leader=True) + return State(config={"foo": "bar"}, leader=True) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def start_scene(dummy_state): return Scene(event("start"), state=dummy_state) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def scenario(mycharm): return Scenario(CharmSpec(mycharm, meta={"name": "foo"})) @@ -89,8 +89,7 @@ def pre_event(charm): assert charm.unit.is_leader() scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - scenario.play(start_scene, - pre_event=pre_event) + scenario.play(start_scene, pre_event=pre_event) def test_status_setting(start_scene, mycharm): @@ -107,7 +106,8 @@ def call(charm: CharmBase, _): assert out.status.app_version == "" out.juju_log = [] # ignore logging output in the delta - assert out.delta(start_scene.state) == sort_patch([ + assert out.delta(start_scene.state) == sort_patch( + [ { "op": "replace", "path": "/status/app", @@ -118,7 +118,8 @@ def call(charm: CharmBase, _): "path": "/status/unit", "value": ("active", "foo test"), }, - ]) + ] + ) @pytest.mark.parametrize("connect", (True, False)) @@ -155,8 +156,8 @@ def pre_event(charm: CharmBase): for unit in rel.units: if unit is charm.unit: continue - if unit.name == 'remote/1': - assert rel.data[unit]['e'] == 'f' + if unit.name == "remote/1": + assert rel.data[unit]["e"] == "f" else: assert not rel.data[unit] @@ -179,11 +180,10 @@ def pre_event(charm: CharmBase): remote_unit_ids=[0, 1, 2], remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, - remote_units_data={0: {}, 1: {"e": "f"}, 2: {}} + remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, ), ] - scenario.play(scene, - pre_event=pre_event) + scenario.play(scene, pre_event=pre_event) def test_relation_set(start_scene: Scene, mycharm): diff --git a/tests/test_mocking.py b/tests/test_mocking.py index 0a5792c90..18918bcfe 100644 --- a/tests/test_mocking.py +++ b/tests/test_mocking.py @@ -1,23 +1,26 @@ import dataclasses +from typing import Any, Callable, Dict, Tuple import pytest from scenario.mocking import DecorateSpec, patch_module -from scenario.structs import State, Scene, Event, _DCBase, relation -from typing import Dict, Tuple, Any, Callable +from scenario.structs import Event, Scene, State, _DCBase, relation -def mock_simulator(fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - call_args: Tuple[Any, ...], - call_kwargs: Dict[str, Any]): - assert namespace == 'MyDemoClass' +def mock_simulator( + fn: Callable, + namespace: str, + tool_name: str, + scene: "Scene", + charm_spec: "CharmSpec", + call_args: Tuple[Any, ...], + call_kwargs: Dict[str, Any], +): + assert namespace == "MyDemoClass" - if tool_name == 'get_foo': + if tool_name == "get_foo": return scene.state.foo - if tool_name == 'set_foo': + if tool_name == "set_foo": scene.state.foo = call_args[1] return raise RuntimeError() @@ -28,28 +31,26 @@ class MockState(_DCBase): foo: int -@pytest.mark.parametrize('mock_foo', (42, 12, 20)) +@pytest.mark.parametrize("mock_foo", (42, 12, 20)) def test_patch_generic_module(mock_foo): state = MockState(foo=mock_foo) - scene = Scene(state=state.copy(), - event=Event('foo')) + scene = Scene(state=state.copy(), event=Event("foo")) from tests.resources import demo_decorate_class + patch_module( demo_decorate_class, { "MyDemoClass": { - "get_foo": DecorateSpec( - simulator=mock_simulator - ), - "set_foo": DecorateSpec( - simulator=mock_simulator - ) + "get_foo": DecorateSpec(simulator=mock_simulator), + "set_foo": DecorateSpec(simulator=mock_simulator), } }, - scene=scene) + scene=scene, + ) from tests.resources.demo_decorate_class import MyDemoClass + assert MyDemoClass._foo == 0 assert MyDemoClass().get_foo() == mock_foo @@ -64,31 +65,32 @@ def test_patch_ops(): state = State( relations=[ relation( - endpoint='dead', - interface='beef', + endpoint="dead", + interface="beef", local_app_data={"foo": "bar"}, local_unit_data={"foo": "wee"}, - remote_units_data={0: {"baz": "qux"}} + remote_units_data={0: {"baz": "qux"}}, ) ] ) - scene = Scene(state=state.copy(), - event=Event('foo')) + scene = Scene(state=state.copy(), event=Event("foo")) from ops import model + patch_module( model, { "_ModelBackend": { "relation_ids": DecorateSpec(), "relation_get": DecorateSpec(), - "relation_set": DecorateSpec() + "relation_set": DecorateSpec(), } }, - scene=scene) + scene=scene, + ) - mb = model._ModelBackend('foo', 'bar', 'baz') - assert mb.relation_ids('dead') == [0] - assert mb.relation_get(0, 'local/0', False) == {'foo': 'wee'} - assert mb.relation_get(0, 'local', True) == {'foo': 'bar'} - assert mb.relation_get(0, 'remote/0', False) == {'baz': 'qux'} + mb = model._ModelBackend("foo", "bar", "baz") + assert mb.relation_ids("dead") == [0] + assert mb.relation_get(0, "local/0", False) == {"foo": "wee"} + assert mb.relation_get(0, "local", True) == {"foo": "bar"} + assert mb.relation_get(0, "remote/0", False) == {"baz": "qux"} diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 5852dba82..b45ae7bba 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -3,9 +3,9 @@ from unittest.mock import MagicMock import yaml - -from ops.charm import CharmEvents, CharmBase +from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase + from scenario.runtime import Runtime from scenario.structs import CharmSpec, Scene, event @@ -36,7 +36,7 @@ def test_event_hooks(): "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, } temppath = Path(tempdir) - meta_file = (temppath / 'metadata.yaml') + meta_file = temppath / "metadata.yaml" meta_file.write_text(yaml.safe_dump(meta)) runtime = Runtime( @@ -48,9 +48,9 @@ def test_event_hooks(): pre_event = MagicMock(return_value=None) post_event = MagicMock(return_value=None) - runtime.play(Scene(event=event('foo')), - pre_event=pre_event, - post_event=post_event) + runtime.play( + Scene(event=event("foo")), pre_event=pre_event, post_event=post_event + ) assert pre_event.called assert post_event.called @@ -68,7 +68,7 @@ def test_event_emission(): class MyEvt(EventBase): pass - my_charm_type.on.define_event('bar', MyEvt) + my_charm_type.on.define_event("bar", MyEvt) runtime = Runtime( CharmSpec( @@ -77,7 +77,7 @@ class MyEvt(EventBase): ), ) - runtime.play(Scene(event=event('bar'))) + runtime.play(Scene(event=event("bar"))) assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) From a10e37932f2a093e99cb444b033ce56f16713d45 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 14:15:06 +0100 Subject: [PATCH 047/546] readme --- README.md | 194 ++++++++++++++++++---------------- scenario/structs.py | 54 ++++++---- tests/test_e2e/test_pebble.py | 8 +- 3 files changed, 142 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index cab0bf96b..b2735f9a8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,10 @@ In this spirit, but that I still have to think through how useful it really is, Writing a scenario test consists of two broad steps: - define a scene -- play it + - an event + - an input state +- play the scene (obtain the output state) +- assert that the output state is how you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -64,7 +67,7 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) out = scenario.play(Scene(event=event('start'), context=Context())) - assert out.context_out.state.status.unit == ('unknown', '') + assert out.status.unit == ('unknown', '') ``` Now let's start making it more complicated. @@ -89,7 +92,7 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) out = scenario.play(Scene(event=event('start'), context=Context())) - assert out.context_out.state.status.unit == ('unknown', '') + assert out.status.unit == ('unknown', '') def test_status_leader(): @@ -100,7 +103,7 @@ def test_status_leader(): context=Context( state=State(leader=True) ))) - assert out.context_out.state.status.unit == ('active', 'I rule') + assert out.status.unit == ('active', 'I rule') ``` This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more @@ -108,8 +111,8 @@ concisely (and parametrically) as: ```python import pytest -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, event, Context +from scenario.scenario import Scenario, Scene, State +from scenario.structs import CharmSpec, event from ops.charm import CharmBase from ops.model import ActiveStatus @@ -132,12 +135,12 @@ def scenario(): @pytest.fixture def start_scene(): - return Scene(event=event('start'), context=Context()) + return Scene(event=event('start'), state=State()) def test_scenario_base(scenario, start_scene): out = scenario.play(start_scene) - assert out.context_out.state.status.unit == ('unknown', '') + assert out.status.unit == ('unknown', '') @pytest.mark.parametrize('leader', [True, False]) @@ -147,15 +150,18 @@ def test_status_leader(scenario, start_scene, leader): out = scenario.play(leader_scene) expected_status = ('active', 'I rule') if leader else ('active', 'I follow') - assert out.context_out.state.status.unit == expected_status + assert out.status.unit == expected_status ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... -An example involving relations: +## Relations + +You can write scenario tests to verify the shape of relation data: ```python from scenario.structs import relation +from ops.charm import CharmBase # This charm copies over remote app data to local unit data @@ -181,9 +187,9 @@ def test_relation_data(scenario, start_scene): ), ] out = scenario.play(scene) - assert out.context_out.state.relations[0].local_unit_data == {"abc": "baz!"} + assert out.relations[0].local_unit_data == {"abc": "baz!"} # one could probably even do: - assert out.context_out.state.relations == [ + assert out.relations == [ relation( endpoint="foo", interface="bar", @@ -195,108 +201,114 @@ def test_relation_data(scenario, start_scene): # which is very idiomatic and superbly explicit. Noice. ``` +## Containers -# Playbooks - -A playbook encapsulates a sequence of scenes. +When testing a kubernetes charm, you can mock container interactions. +When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. -For example: -```python -from scenario.scenario import Playbook -from scenario.structs import State, Scene, Event, Context -playbook = Playbook( - ( - Scene(Event("update-status"), - context=Context(state=State(config={'foo':'bar'}))), - Scene(Event("config-changed"), - context=Context(state=State(config={'foo':'baz'}))), - ) - ) -``` +To give the charm access to some containers, you need to pass them to the input state, like so: +`State(containers=[...])` -This allows us to write concisely common event sequences, such as the charm startup/teardown sequences. These are the only ones that are built-into the framework. -This is the new `Harness.begin_with_initial_hooks`: +An example of a scene including some containers: ```python -import pytest -from scenario.scenario import StartupScenario -from scenario.structs import CharmSpec - -@pytest.mark.parametrize("leader", (True, False)) -def test_setup(leader, mycharm): - scenario = StartupScenario(CharmSpec(mycharm, meta={"name": "foo"}), leader=leader) - scenario.play_until_complete() +from scenario.structs import Scene, event, container, State +scene = Scene( + event("start"), + state=State(containers=[ + container(name="foo", can_connect=True), + container(name="bar", can_connect=False) + ]), +) ``` -The idea is that users can write down sequences common to their use case -(or multiple charms in a bundle) and share them between tests. - - -# Caveats -The way we're injecting memo calls is by rewriting parts of `ops.main`, and `ops.framework` using the python ast module. This means that we're seriously messing with your venv. This is a temporary measure and will be factored out of the code as we move out of the alpha phase. - -Options we're considering: -- have a script that generates our own `ops` lib, distribute that along with the scenario source, and in your scenario tests you'll have to import from the patched-ops we provide instead of the 'canonical' ops module. -- trust you to run all of this in ephemeral contexts (e.g. containers, tox env...) for now, **YOU SHOULD REALLY DO THAT** - +In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. -# Advanced Mockery -The Harness mocks data by providing a separate backend. When the charm code asks: am I leader? there's a variable -in `harness._backend` that decides whether the return value is True or False. -A Scene exposes two layers of data to the charm: memos and a state. +You can also configure a container to have some files in it: -- Memos are strict, cached input->output mappings. They basically map a function call to a hardcoded return value, or - multiple return values. -- A State is a static database providing the same mapping, but only a single return value is supported per input. - -Scenario tests mock the data by operating at the hook tool call level, not the backend level. Every backend call that -would normally result in a hook tool call is instead redirected to query the available memos, and as a fallback, is -going to query the State we define as part of a Scene. If neither one can provide an answer, the hook tool call is -propagated -- which unless you have taken care of mocking that executable as well, will likely result in an error. +```python +from scenario.structs import Scene, event, container, State +from pathlib import Path -Let's see the difference with an example: +local_file = Path('/path/to/local/real/file.txt') -Suppose the charm does: +scene = Scene( + event("start"), + state=State(containers=[ + container(name="foo", + can_connect=True, + filesystem={'local': {'share': {'config.yaml': local_file}}}) + ]), +) +``` +In this case, if the charm were to: ```python - ... - - def _on_start(self, _): - assert self.unit.is_leader() + foo = self.unit.get_container('foo') + content = foo.pull('/local/share/config.yaml').read() +``` - import time - time.sleep(31) +then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping strings and passing them to the charm via the container. - assert not self.unit.is_leader() +`container.push` works similarly, so you can write a test like: - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') +```python +from ops.charm import CharmBase +from scenario.structs import Scene, event, State, container + +class MyCharm(CharmBase): + def _on_start(self, _): + foo = self.unit.get_container('foo') + foo.push('/local/share/config.yaml', "TEST", make_dirs=True) + +def test_pebble_push(scenario, start_scene): + out = scenario.play(Scene( + event=event('start'), + state=State( + containers=[container(name='foo')] + ))) + assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" ``` -Suppose we want this test to pass. How could we mock this using Scenario? +`container.exec` is a little bit more complicated. +You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. ```python -scene = Scene( - event=event('start'), - context=Context(memos=[ - {'name': '_ModelBackend.leader_get', - 'values': ['True', 'False'], - 'caching_mode': 'strict'} - ]) -) -``` -What this means in words is: the mocked hook-tool 'leader-get' call will return True at first, but False the second time around. +from ops.charm import CharmBase +from scenario.structs import Scene, event, State, container, ExecOutput -Since we didn't pass a State to the Context object, when the runtime fails to find a third value for leader-get, it will fall back and use the static value provided by the default State -- False. So if the charm were to call `is_leader` at any point after the first two calls, it would consistently get False. +LS_LL = """ +.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml +.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml +.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md +drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib +""" -NOTE: the API is work in progress. We're working on exposing friendlier ways of defining memos. -The good news is that you can generate memos by scraping them off of a live unit using `jhack replay`. + +class MyCharm(CharmBase): + def _on_start(self, _): + foo = self.unit.get_container('foo') + proc = foo.exec(['ls', '-ll']) + stdout, _ = proc.wait_output() + assert stdout == LS_LL + + +def test_pebble_exec(scenario, start_scene): + scenario.play(Scene( + event=event('start'), + state=State( + containers=[container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + )] + ))) +``` # TODOS: - Figure out how to distribute this. I'm thinking `pip install ops[scenario]` -- Better syntax for memo generation -- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) -- Expose instructions or facilities re. how to use this without borking your venv. \ No newline at end of file +- Better syntax for memo generation \ No newline at end of file diff --git a/scenario/structs.py b/scenario/structs.py index 1d0870acd..f48f3f3e3 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -182,6 +182,22 @@ class ContainerSpec(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) +def container(name: str, + can_connect: bool = False, + layers: Tuple["LayerDict"] = (), + filesystem: _SimpleFS = None, + exec_mock: _ExecMock = None + ) -> ContainerSpec: + """Helper function to instantiate a ContainerSpec.""" + return ContainerSpec( + name=name, + can_connect=can_connect, + layers=layers, + filesystem=filesystem or {}, + exec_mock=exec_mock or {} + ) + + @dataclasses.dataclass class Address(_DCBase): hostname: str @@ -401,18 +417,18 @@ class InjectRelation(Inject): def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[ - int - ] = None, # defaults to (0,) if remote_units_data is not provided - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, - remote_units_data: Dict[int, Dict[str, str]] = None, + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[ + int + ] = None, # defaults to (0,) if remote_units_data is not provided + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, + remote_units_data: Dict[int, Dict[str, str]] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" if remote_unit_ids and remote_units_data: @@ -445,13 +461,13 @@ def relation( def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> Network: """Construct a network object.""" return Network( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index e902b9724..1a3af11b1 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,14 +1,13 @@ import tempfile -from io import StringIO from pathlib import Path from typing import Optional import pytest -from ops.charm import ActionEvent, CharmBase, StartEvent +from ops.charm import CharmBase from ops.framework import Framework from scenario.scenario import Scenario -from scenario.structs import CharmSpec, ContainerSpec, ExecOutput, Scene, State, event +from scenario.structs import CharmSpec, ContainerSpec, ExecOutput, Scene, State, event, container @pytest.fixture(scope="function") @@ -52,6 +51,7 @@ def callback(self: CharmBase, evt): scenario.play(scene) + @pytest.mark.parametrize("can_connect", (True, False)) def test_connectivity(charm_cls, can_connect): scenario = Scenario( @@ -59,7 +59,7 @@ def test_connectivity(charm_cls, can_connect): ) scene = Scene( event("start"), - state=State(containers=[ContainerSpec(name="foo", can_connect=can_connect)]), + state=State(containers=[container(name="foo", can_connect=can_connect)]), ) def callback(self: CharmBase, evt): From 2059e9691b3ee19f19b548f2a7231b56a67131fa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Jan 2023 14:15:19 +0100 Subject: [PATCH 048/546] lint --- scenario/structs.py | 53 ++++++++++++++++++----------------- tests/test_e2e/test_pebble.py | 11 ++++++-- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/scenario/structs.py b/scenario/structs.py index f48f3f3e3..f93ccb323 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -182,19 +182,20 @@ class ContainerSpec(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) -def container(name: str, - can_connect: bool = False, - layers: Tuple["LayerDict"] = (), - filesystem: _SimpleFS = None, - exec_mock: _ExecMock = None - ) -> ContainerSpec: +def container( + name: str, + can_connect: bool = False, + layers: Tuple["LayerDict"] = (), + filesystem: _SimpleFS = None, + exec_mock: _ExecMock = None, +) -> ContainerSpec: """Helper function to instantiate a ContainerSpec.""" return ContainerSpec( name=name, can_connect=can_connect, layers=layers, filesystem=filesystem or {}, - exec_mock=exec_mock or {} + exec_mock=exec_mock or {}, ) @@ -417,18 +418,18 @@ class InjectRelation(Inject): def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[ - int - ] = None, # defaults to (0,) if remote_units_data is not provided - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, - remote_units_data: Dict[int, Dict[str, str]] = None, + endpoint: str, + interface: str, + remote_app_name: str = "remote", + relation_id: int = 0, + remote_unit_ids: List[ + int + ] = None, # defaults to (0,) if remote_units_data is not provided + # mapping from unit ID to databag contents + local_unit_data: Dict[str, str] = None, + local_app_data: Dict[str, str] = None, + remote_app_data: Dict[str, str] = None, + remote_units_data: Dict[int, Dict[str, str]] = None, ): """Helper function to construct a RelationMeta object with some sensible defaults.""" if remote_unit_ids and remote_units_data: @@ -461,13 +462,13 @@ def relation( def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> Network: """Construct a network object.""" return Network( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 1a3af11b1..090d2f1a4 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -7,7 +7,15 @@ from ops.framework import Framework from scenario.scenario import Scenario -from scenario.structs import CharmSpec, ContainerSpec, ExecOutput, Scene, State, event, container +from scenario.structs import ( + CharmSpec, + ContainerSpec, + ExecOutput, + Scene, + State, + container, + event, +) @pytest.fixture(scope="function") @@ -51,7 +59,6 @@ def callback(self: CharmBase, evt): scenario.play(scene) - @pytest.mark.parametrize("can_connect", (True, False)) def test_connectivity(charm_cls, can_connect): scenario = Scenario( From 5ab248e00bf94acb8f39c2b90a86a829ea3ff6c9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 23 Jan 2023 14:33:55 +0100 Subject: [PATCH 049/546] added network mocking --- scenario/mocking.py | 14 ++++++-- scenario/structs.py | 56 +++++++++++------------------ tests/test_e2e/test_network.py | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 tests/test_e2e/test_network.py diff --git a/scenario/mocking.py b/scenario/mocking.py index ebbb146f7..38d09928d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,6 +1,6 @@ import functools import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, asdict from io import StringIO from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union @@ -134,6 +134,16 @@ def wrap_tool( return state_config # full config + elif tool_name == "network_get": + name, relation_id = args + + network = next( + filter( + lambda r: r.name == name, input_state.networks + ) + ) + return network.network.hook_tool_output_fmt() + elif tool_name == "action_get": raise NotImplementedError("action_get") elif tool_name == "relation_remote_app_name": @@ -144,8 +154,6 @@ def wrap_tool( raise NotImplementedError("storage_list") elif tool_name == "storage_get": raise NotImplementedError("storage_get") - elif tool_name == "network_get": - raise NotImplementedError("network_get") elif tool_name == "planned_units": raise NotImplementedError("planned_units") else: diff --git a/scenario/structs.py b/scenario/structs.py index f93ccb323..342c14a37 100644 --- a/scenario/structs.py +++ b/scenario/structs.py @@ -213,6 +213,15 @@ class BindAddress(_DCBase): interfacename: str # noqa legacy addresses: List[Address] + def hook_tool_output_fmt(self): + # dumps itself to dict in the same format the hook tool would + return { + "bind-addresses": self.mac_address, + "interface-name": self.interface_name, + "interfacename": self.interfacename, + "addresses": [dataclasses.asdict(addr) for addr in self.addresses], + } + @dataclasses.dataclass class Network(_DCBase): @@ -221,6 +230,15 @@ class Network(_DCBase): egress_subnets: List[str] ingress_addresses: List[str] + def hook_tool_output_fmt(self): + # dumps itself to dict in the same format the hook tool would + return { + "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], + "bind-address": self.bind_address, + "egress-subnets": self.egress_subnets, + "ingress-addresses": self.ingress_addresses, + } + @dataclasses.dataclass class NetworkSpec(_DCBase): @@ -229,10 +247,6 @@ class NetworkSpec(_DCBase): network: Network is_default: bool = False - @classmethod - def from_dict(cls, obj): - return cls(**obj) - @dataclasses.dataclass class Status(_DCBase): @@ -327,17 +341,6 @@ def from_charm(charm_type: Type["CharmType"]): ) -@dataclasses.dataclass -class Memo(_DCBase): - calls: Dict[str, Any] - cursor: int = 0 - caching_policy: Literal["loose", "strict"] = "strict" - - @classmethod - def from_dict(cls, obj): - return Memo(**obj) - - def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): return sorted(patch, key=key) @@ -365,25 +368,6 @@ def is_meta(self): return self.name in META_EVENTS -# @dataclass -# class Event: -# env: Dict[str, str] -# -# @property -# def name(self): -# return self.env["JUJU_DISPATCH_PATH"].split("/")[1] -# -# @property -# def unit_name(self): -# return self.env.get("JUJU_UNIT_NAME", "") -# -# @property -# def app_name(self): -# unit_name = self.unit_name -# return unit_name.split("/")[0] if unit_name else "" -# - - @dataclasses.dataclass class SceneMeta(_DCBase): unit_id: str = "0" @@ -506,6 +490,6 @@ def _derive_args(event_name: str): return tuple(args) -def event(name: str, append_args: Tuple[Any] = (), **kwargs) -> Event: +def event(name: str, append_args: Tuple[Any] = (), meta: EventMeta = None, **kwargs) -> Event: """This routine will attempt to generate event args for you, based on the event name.""" - return Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs) + return Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs, meta=meta) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py new file mode 100644 index 000000000..9079eaf9e --- /dev/null +++ b/tests/test_e2e/test_network.py @@ -0,0 +1,66 @@ +from typing import Optional + +import pytest +from ops.charm import CharmBase +from ops.framework import Framework + +from scenario.scenario import Scenario +from scenario.structs import CharmSpec, Scene, State, event, NetworkSpec, network, relation + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + _call = None + called = False + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + if MyCharm._call: + MyCharm.called = True + MyCharm._call(self, event) + + return MyCharm + + +def test_ip_get(mycharm): + mycharm._call = lambda *_: True + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "foo", + "requires": {"metrics-endpoint": {"interface": "foo"}}, + }, + ) + ) + + def fetch_unit_address(charm: CharmBase): + rel = charm.model.get_relation('metrics-endpoint') + assert str(charm.model.get_binding(rel).network.bind_address) == '1.1.1.1' + + scene = Scene( + state=State( + relations=[ + relation(endpoint='metrics-endpoint', interface='foo') + ], + networks=[ + NetworkSpec( + 'metrics-endpoint', + bind_id=0, + network=network() + ) + ] + ), + event=event("update-status"), + ) + + scenario.play( + scene, + post_event=fetch_unit_address, + ) From f66ce03e0554c3cc96d51250e0187548dd43aa62 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 25 Jan 2023 11:21:49 +0100 Subject: [PATCH 050/546] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b2735f9a8..c28e75669 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ llows us to swap out easily any part of this flow, and even share context data a In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. More on this later. +![image](https://user-images.githubusercontent.com/6230162/214538381-ba969ac3-a302-4122-8960-c5efc1fe8b6d.png) + + # Writing scenario tests Writing a scenario test consists of two broad steps: @@ -311,4 +314,4 @@ def test_pebble_exec(scenario, start_scene): # TODOS: - Figure out how to distribute this. I'm thinking `pip install ops[scenario]` -- Better syntax for memo generation \ No newline at end of file +- Better syntax for memo generation From 162e21d0b0bfce1558345aafe47d8ec0f0d86989 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 25 Jan 2023 11:23:53 +0100 Subject: [PATCH 051/546] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c28e75669..3ca3952b3 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ llows us to swap out easily any part of this flow, and even share context data a In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. More on this later. -![image](https://user-images.githubusercontent.com/6230162/214538381-ba969ac3-a302-4122-8960-c5efc1fe8b6d.png) +![image](https://user-images.githubusercontent.com/6230162/214538871-a44e29c6-3fd5-46a3-82c8-d7fa34452dcf.png) # Writing scenario tests From 49aaa0a0ecd5c8832b718929a4e4cddf88cf138b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Jan 2023 11:26:32 +0100 Subject: [PATCH 052/546] fixed bug in relation_ids --- scenario/mocking.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 38d09928d..394602ace 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -106,7 +106,8 @@ def wrap_tool( return {"status": status, "message": message} elif tool_name == "relation_ids": - return [rel.meta.relation_id for rel in input_state.relations] + endpoint = args[0] + return [rel.meta.relation_id for rel in input_state.relations if rel.meta.endpoint == endpoint] elif tool_name == "relation_list": rel_id = args[0] From d2b4ff5998e7d7931cdbdb3c4679fdb76ef68994 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Jan 2023 11:34:01 +0100 Subject: [PATCH 053/546] relation test for relation-ids fix --- tests/test_e2e/test_relations.py | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_e2e/test_relations.py diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py new file mode 100644 index 000000000..74e2dd124 --- /dev/null +++ b/tests/test_e2e/test_relations.py @@ -0,0 +1,87 @@ +from dataclasses import asdict +from typing import Optional, Type + +import pytest +from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.framework import EventBase, Framework +from ops.model import ActiveStatus, UnknownStatus, WaitingStatus + +from scenario.scenario import Scenario +from scenario.structs import ( + CharmSpec, + ContainerSpec, + Scene, + State, + event, + relation, + sort_patch, +) + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharmEvents(CharmEvents): + @classmethod + def define_event(cls, event_kind: str, event_type: "Type[EventBase]"): + if getattr(cls, event_kind, None): + delattr(cls, event_kind) + return super().define_event(event_kind, event_type) + + class MyCharm(CharmBase): + _call = None + called = False + on = MyCharmEvents() + + def __init__(self, framework: Framework, key: Optional[str] = None): + super().__init__(framework, key) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + if self._call: + MyCharm.called = True + MyCharm._call(self, event) + + return MyCharm + + +@pytest.fixture(scope="function") +def start_scene(): + return Scene(event("start"), state=State(config={"foo": "bar"}, leader=True)) + + +def test_get_relation(start_scene: Scene, mycharm): + def pre_event(charm: CharmBase): + assert charm.model.get_relation("foo") + assert charm.model.get_relation("bar") is None + assert charm.model.get_relation("qux") + assert charm.model.get_relation("zoo") is None + + scenario = Scenario( + CharmSpec( + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + "bar": {"interface": "bar"}, + }, + "provides": { + "qux": {"interface": "qux"}, + "zoo": {"interface": "zoo"}, + }, + }, + ) + ) + scene = start_scene.copy() + scene.state.relations = [ + relation( + endpoint="foo", + interface="foo" + ), + relation( + endpoint="qux", + interface="qux" + ), + ] + scenario.play(scene, pre_event=pre_event) From 17ce60fc493ac355c545d36988a44bdf7ed5602b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Jan 2023 15:27:15 +0100 Subject: [PATCH 054/546] ops 2.0 compat --- requirements.txt | 2 +- scenario/ops_main_mock.py | 2 +- setup.py | 2 +- tests/test_e2e/test_builtin_scenes.py | 4 ++-- tests/test_e2e/test_network.py | 4 ++-- tests/test_e2e/test_observers.py | 4 ++-- tests/test_e2e/test_pebble.py | 4 ++-- tests/test_e2e/test_play_assertions.py | 4 ++-- tests/test_e2e/test_relations.py | 4 ++-- tests/test_e2e/test_state.py | 4 ++-- tests/test_runtime.py | 4 ++-- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/requirements.txt b/requirements.txt index 916966bef..9e4a76569 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -ops==1.5.3 +ops==2.0.0 asttokens astunparse \ No newline at end of file diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 087c35502..60f755918 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -108,7 +108,7 @@ def main( "removed after the 0.7 release" ) warnings.warn(msg, DeprecationWarning) - charm = charm_class(framework, None) + charm = charm_class(framework) else: charm = charm_class(framework) dispatcher.ensure_event_links(charm) diff --git a/setup.py b/setup.py index 53003ce8b..ef9bb255f 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def _read_me() -> str: return readme -version = "0.2.2" +version = "1.0.0" setup( name="scenario", diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index 3fbf74824..c996df117 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -18,8 +18,8 @@ def mycharm(): class MyCharm(CharmBase): _call = None - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) self.called = False for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 9079eaf9e..c309d50f9 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -14,8 +14,8 @@ class MyCharm(CharmBase): _call = None called = False - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 728ab9b27..168fd61f1 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -13,8 +13,8 @@ def charm_evts(): events = [] class MyCharm(CharmBase): - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 090d2f1a4..e57eca100 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -23,8 +23,8 @@ def charm_cls(): class MyCharm(CharmBase): callback = None - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 53e1ef1b2..be730e2e3 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -15,8 +15,8 @@ class MyCharm(CharmBase): _call = None called = False - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 74e2dd124..a92d8e2e0 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -32,8 +32,8 @@ class MyCharm(CharmBase): called = False on = MyCharmEvents() - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index bce75a340..a0da37058 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -49,8 +49,8 @@ class MyCharm(CharmBase): called = False on = MyCharmEvents() - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index b45ae7bba..4afccc69d 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -18,8 +18,8 @@ class MyCharm(CharmBase): on = _CharmEvents() _event = None - def __init__(self, framework, key=None): - super().__init__(framework, key) + def __init__(self, framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._catchall) From 1e3646d778c929cc00caa8a22a96e4ae2f711344 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 25 Jan 2023 17:50:32 +0100 Subject: [PATCH 055/546] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3ca3952b3..ccb264c4a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +NOTE: [we're trying](https://github.com/canonical/operator/pull/887) to merge this in ops main. Stay tuned! + Ops-Scenario ============ From 6388e0caa5fce8aa1727e24dd90ff6a3f4e97d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Thu, 26 Jan 2023 00:52:03 -0300 Subject: [PATCH 056/546] now, the first 3 examples are valid and green --- README.md | 67 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ccb264c4a..e67b3a7a8 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ available. The charm has no config, no relations, no networks, and no leadership With that, we can write the simplest possible scenario test: ```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, event, Context +from scenario.scenario import Scenario, Scene, State +from scenario.structs import CharmSpec, event from ops.charm import CharmBase @@ -71,7 +71,7 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play(Scene(event=event('start'), context=Context())) + out = scenario.play(Scene(event=event("start"), state=State())) assert out.status.unit == ('unknown', '') ``` @@ -79,14 +79,15 @@ Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, event, Context, State +from scenario.scenario import Scenario, Scene, State +from scenario.structs import CharmSpec, event from ops.charm import CharmBase from ops.model import ActiveStatus class MyCharm(CharmBase): - def __init__(self, ...): + def __init__(self, *args): + super().__init__(*args) self.framework.observe(self.on.start, self._on_start) def _on_start(self, _): @@ -96,18 +97,13 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play(Scene(event=event('start'), context=Context())) + out = scenario.play(Scene(event=event("start"), state=State())) assert out.status.unit == ('unknown', '') def test_status_leader(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play( - Scene( - event=event('start'), - context=Context( - state=State(leader=True) - ))) + out = scenario.play(Scene(event=event("start"), state=State(leader=True))) assert out.status.unit == ('active', 'I rule') ``` @@ -116,46 +112,48 @@ concisely (and parametrically) as: ```python import pytest -from scenario.scenario import Scenario, Scene, State -from scenario.structs import CharmSpec, event + from ops.charm import CharmBase from ops.model import ActiveStatus +from scenario.scenario import Scenario, Scene, State +from scenario.structs import CharmSpec, event class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self._on_start) - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus("I rule") + else: + self.unit.status = ActiveStatus("I follow") @pytest.fixture def scenario(): - return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) + return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) @pytest.fixture def start_scene(): - return Scene(event=event('start'), state=State()) + return Scene(event=event("start"), state=State()) def test_scenario_base(scenario, start_scene): - out = scenario.play(start_scene) - assert out.status.unit == ('unknown', '') + out = scenario.play(start_scene) + assert out.status.unit == ("active", "I follow") -@pytest.mark.parametrize('leader', [True, False]) +@pytest.mark.parametrize("leader", [True, False]) def test_status_leader(scenario, start_scene, leader): - leader_scene = start_scene.copy() - leader_scene.context.state.leader = leader + leader_scene = start_scene.copy() + leader_scene.state.leader = leader - out = scenario.play(leader_scene) - expected_status = ('active', 'I rule') if leader else ('active', 'I follow') - assert out.status.unit == expected_status + out = scenario.play(leader_scene) + expected_status = ("active", "I rule") if leader else ("active", "I follow") + assert out.status.unit == expected_status ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... @@ -317,3 +315,8 @@ def test_pebble_exec(scenario, start_scene): # TODOS: - Figure out how to distribute this. I'm thinking `pip install ops[scenario]` - Better syntax for memo generation +<<<<<<< HEAD +======= +- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) +- Expose instructions or facilities re. how to use this without borking your venv. +>>>>>>> f9b8896 (now, the first 3 examples are valid and green) From 5f6a2e6696024b5223ded9398b3abff1fcd4c756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Thu, 26 Jan 2023 01:02:36 -0300 Subject: [PATCH 057/546] linting... --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e67b3a7a8..f27c455c5 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ available. The charm has no config, no relations, no networks, and no leadership With that, we can write the simplest possible scenario test: ```python +from ops.charm import CharmBase from scenario.scenario import Scenario, Scene, State from scenario.structs import CharmSpec, event -from ops.charm import CharmBase class MyCharm(CharmBase): @@ -72,17 +72,17 @@ class MyCharm(CharmBase): def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) out = scenario.play(Scene(event=event("start"), state=State())) - assert out.status.unit == ('unknown', '') + assert out.status.unit == ("unknown", "") ``` Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -from scenario.scenario import Scenario, Scene, State -from scenario.structs import CharmSpec, event from ops.charm import CharmBase from ops.model import ActiveStatus +from scenario.scenario import Scenario, Scene, State +from scenario.structs import CharmSpec, event class MyCharm(CharmBase): @@ -92,19 +92,19 @@ class MyCharm(CharmBase): def _on_start(self, _): if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') + self.unit.status = ActiveStatus("I rule") def test_scenario_base(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) out = scenario.play(Scene(event=event("start"), state=State())) - assert out.status.unit == ('unknown', '') + assert out.status.unit == ("unknown", "") def test_status_leader(): scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) out = scenario.play(Scene(event=event("start"), state=State(leader=True))) - assert out.status.unit == ('active', 'I rule') + assert out.status.unit == ("active", "I rule") ``` This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more From da0b2fd1494278dac791b0a4819be99ff2ee8f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Thu, 26 Jan 2023 01:05:02 -0300 Subject: [PATCH 058/546] remove rebase vestiges --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index f27c455c5..7ca8121f3 100644 --- a/README.md +++ b/README.md @@ -315,8 +315,3 @@ def test_pebble_exec(scenario, start_scene): # TODOS: - Figure out how to distribute this. I'm thinking `pip install ops[scenario]` - Better syntax for memo generation -<<<<<<< HEAD -======= -- Consider consolidating memo and State (e.g. passing a Sequence object to a State value...) -- Expose instructions or facilities re. how to use this without borking your venv. ->>>>>>> f9b8896 (now, the first 3 examples are valid and green) From caed680b933c6c62e1305ec7773a9bf0c81e5661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20C=2E=20Mass=C3=B3n?= Date: Thu, 26 Jan 2023 20:36:03 -0300 Subject: [PATCH 059/546] fix TypeError: CharmBase.__init__() takes 2 positional arguments but 3 were given --- tests/test_e2e/test_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index bce75a340..a0da37058 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -49,8 +49,8 @@ class MyCharm(CharmBase): called = False on = MyCharmEvents() - def __init__(self, framework: Framework, key: Optional[str] = None): - super().__init__(framework, key) + def __init__(self, framework: Framework): + super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) From 3df63ee484d8a5e65df25e3622a4179108b3e2be Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 12:28:38 +0100 Subject: [PATCH 060/546] stripped Scenario and Scene --- scenario/mocking.py | 675 ++++++++++--------------- scenario/ops_main_mock.py | 81 +-- scenario/runtime.py | 167 ++---- scenario/scenario.py | 136 ----- scenario/sequences.py | 109 ++++ scenario/{structs.py => state.py} | 50 +- tests/test_e2e/test_pebble.py | 2 +- tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_state.py | 138 +++-- 9 files changed, 533 insertions(+), 827 deletions(-) delete mode 100644 scenario/scenario.py create mode 100644 scenario/sequences.py rename scenario/{structs.py => state.py} (95%) diff --git a/scenario/mocking.py b/scenario/mocking.py index 394602ace..a72849480 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,36 +1,22 @@ -import functools import tempfile -from dataclasses import dataclass, asdict +import urllib.request from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING, Optional, Tuple, Union + +from ops.model import _ModelBackend +from ops.pebble import Client, ExecError from scenario.logger import logger as scenario_logger -from scenario.structs import ExecOutput if TYPE_CHECKING: - from ops import pebble - - from scenario.scenario import CharmSpec, Scene + from scenario.state import CharmSpec, State, Event, ExecOutput logger = scenario_logger.getChild("mocking") -Simulator = Callable[ - [ - Callable[[Any], Any], # simulated function - str, # namespace - str, # tool name - "Scene", # scene - Optional["CharmSpec"], # charm spec - Tuple[Any, ...], # call args - Dict[str, Any], - ], # call kwargs - None, -] - class _MockExecProcess: - def __init__(self, command: Tuple[str], change_id: int, out: ExecOutput): + def __init__(self, command: Tuple[str], change_id: int, out: "ExecOutput"): self._command = command self._change_id = change_id self._out = out @@ -42,410 +28,275 @@ def wait(self): self._waited = True exit_code = self._out.return_code if exit_code != 0: - raise pebble.ExecError(list(self._command), exit_code, None, None) + raise ExecError(list(self._command), exit_code, None, None) def wait_output(self): out = self._out exit_code = out.return_code if exit_code != 0: - raise pebble.ExecError(list(self._command), exit_code, None, None) + raise ExecError(list(self._command), exit_code, None, None) return out.stdout, out.stderr def send_signal(self, sig: Union[int, str]): pass -def wrap_tool( - fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - charm_spec: Optional["CharmSpec"], - call_args: Tuple[Any, ...], - call_kwargs: Dict[str, Any], -): - # all builtin tools we wrap are methods: - # _self = call_args[0] - args = tuple(call_args[1:]) - input_state = scene.state - this_unit_name = scene.meta.unit_name - this_app_name = scene.meta.app_name - - setter = False - wrap_errors = True - - try: - # MODEL BACKEND CALLS - if namespace == "_ModelBackend": - if tool_name == "relation_get": - rel_id, obj_name, app = args - relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, input_state.relations - ) - ) - if app and obj_name == this_app_name: - return relation.local_app_data - elif app: - return relation.remote_app_data - elif obj_name == this_unit_name: - return relation.local_unit_data - else: - unit_id = obj_name.split("/")[-1] - return relation.remote_units_data[int(unit_id)] - - elif tool_name == "is_leader": - return input_state.leader - - elif tool_name == "status_get": - status, message = ( - input_state.status.app - if call_kwargs.get("app") - else input_state.status.unit - ) - return {"status": status, "message": message} - - elif tool_name == "relation_ids": - endpoint = args[0] - return [rel.meta.relation_id for rel in input_state.relations if rel.meta.endpoint == endpoint] - - elif tool_name == "relation_list": - rel_id = args[0] - relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, input_state.relations - ) - ) - return tuple( - f"{relation.meta.remote_app_name}/{unit_id}" - for unit_id in relation.meta.remote_unit_ids - ) - - elif tool_name == "config_get": - state_config = input_state.config - if not state_config: - state_config = { - key: value.get("default") - for key, value in charm_spec.config.items() - } - - if args: # one specific key requested - # Fixme: may raise KeyError if the key isn't defaulted. What do we do then? - return state_config[args[0]] - - return state_config # full config - - elif tool_name == "network_get": - name, relation_id = args - - network = next( - filter( - lambda r: r.name == name, input_state.networks - ) - ) - return network.network.hook_tool_output_fmt() - - elif tool_name == "action_get": - raise NotImplementedError("action_get") - elif tool_name == "relation_remote_app_name": - raise NotImplementedError("relation_remote_app_name") - elif tool_name == "resource_get": - raise NotImplementedError("resource_get") - elif tool_name == "storage_list": - raise NotImplementedError("storage_list") - elif tool_name == "storage_get": - raise NotImplementedError("storage_get") - elif tool_name == "planned_units": - raise NotImplementedError("planned_units") - else: - setter = True - - # # setter methods - - if tool_name == "application_version_set": - scene.state.status.app_version = args[0] - return None - - elif tool_name == "status_set": - if call_kwargs.get("is_app"): - scene.state.status.app = args - else: - scene.state.status.unit = args - return None - - elif tool_name == "juju_log": - scene.state.juju_log.append(args) - return None - - elif tool_name == "relation_set": - rel_id, key, value, app = args - relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, scene.state.relations - ) - ) - if app: - if not scene.state.leader: - raise RuntimeError("needs leadership to set app data") - tgt = relation.local_app_data - else: - tgt = relation.local_unit_data - tgt[key] = value - return None - - elif tool_name == "action_set": - raise NotImplementedError("action_set") - elif tool_name == "action_fail": - raise NotImplementedError("action_fail") - elif tool_name == "action_log": - raise NotImplementedError("action_log") - elif tool_name == "storage_add": - raise NotImplementedError("storage_add") - elif tool_name == "secret_get": - raise NotImplementedError("secret_get") - elif tool_name == "secret_set": - raise NotImplementedError("secret_set") - elif tool_name == "secret_grant": - raise NotImplementedError("secret_grant") - elif tool_name == "secret_remove": - raise NotImplementedError("secret_remove") - - # PEBBLE CALLS - elif namespace == "Client": - # fixme: can't differentiate between containers, because Client._request - # does not pass around the container name as argument. Here we do it a bit ugly - # and extract it from 'self'. We could figure out a way to pass in a spec in a more - # generic/abstract way... - - client: "pebble.Client" = call_args[0] - container_name = client.socket_path.split("/")[-2] - try: - container = next( - filter(lambda x: x.name == container_name, input_state.containers) - ) - except StopIteration: - raise RuntimeError( - f"container with name={container_name!r} not found. " - f"Did you forget a ContainerSpec, or is the socket path " - f"{client.socket_path!r} wrong?" - ) - - if tool_name == "_request": - if args == ("GET", "/v1/system-info"): - if container.can_connect: - return {"result": {"version": "unknown"}} - else: - wrap_errors = False # this is what pebble.Client expects! - raise FileNotFoundError("") - - elif args[:2] == ("GET", "/v1/services"): - service_names = list(args[2]["names"].split(",")) - result = [] - - for layer in container.layers: - if not service_names: - break - - for name in service_names: - if name in layer["services"]: - service_names.remove(name) - result.append(layer["services"][name]) - - # todo: what do we do if we don't find the requested service(s)? - return {"result": result} - - else: - raise NotImplementedError(f"_request: {args}") - - elif tool_name == "exec": - cmd = tuple(args[0]) - out = container.exec_mock.get(cmd) - if not out: - raise RuntimeError(f"mock for cmd {cmd} not found.") - - change_id = out._run() - return _MockExecProcess(change_id=change_id, command=cmd, out=out) - - elif tool_name == "pull": - # todo double-check how to surface error - wrap_errors = False - - path_txt = args[0] - pos = container.filesystem - for token in path_txt.split("/")[1:]: - pos = pos.get(token) - if not pos: - raise FileNotFoundError(path_txt) - local_path = Path(pos) - if not local_path.exists() or not local_path.is_file(): - raise FileNotFoundError(local_path) - return local_path.open() - - elif tool_name == "push": - setter = True - # todo double-check how to surface error - wrap_errors = False - - path_txt, contents = args - - pos = container.filesystem - tokens = path_txt.split("/")[1:] - for token in tokens[:-1]: - nxt = pos.get(token) - if not nxt and call_kwargs["make_dirs"]: - pos[token] = {} - pos = pos[token] - elif not nxt: - raise FileNotFoundError(path_txt) - else: - pos = pos[token] - - # dump contents - # fixme: memory leak here if tmp isn't regularly cleaned up - file = tempfile.NamedTemporaryFile(delete=False) - pth = Path(file.name) - pth.write_text(contents) - - pos[tokens[-1]] = pth - return - +class _MockModelBackend(_ModelBackend): + def __init__(self, state: "State", event: "Event", charm_spec: "CharmSpec"): + super().__init__(state.unit_name, state.model.name, state.model.uuid) + self._state = state + self._event = event + self._charm_spec = charm_spec + + def get_pebble(self, socket_path: str) -> 'Client': + return _MockPebbleClient(socket_path=socket_path, + state=self._state, + event=self._event, + charm_spec=self._charm_spec) + + def relation_get(self, rel_id, obj_name, app): + relation = next( + filter( + lambda r: r.meta.relation_id == rel_id, self._state.relations + ) + ) + if app and obj_name == self._state.app_name: + return relation.local_app_data + elif app: + return relation.remote_app_data + elif obj_name == self._state.unit_name: + return relation.local_unit_data else: - raise QuestionNotImplementedError(namespace) - - except Exception as e: - if not wrap_errors: - # reraise - raise e - - action = "setting" if setter else "getting" - msg = f"Error {action} state for {namespace}.{tool_name} given ({call_args}, {call_kwargs})" - raise StateError(msg) from e - - raise QuestionNotImplementedError((namespace, tool_name, call_args, call_kwargs)) - - -@dataclass -class DecorateSpec: - # the memo's namespace will default to the class name it's being defined in - namespace: Optional[str] = None - - # the memo's name will default to the memoized function's __name__ - name: Optional[str] = None - - # the function to be called instead of the decorated one - simulator: Simulator = wrap_tool - - # extra-args: callable to extract any other arguments from 'self' and pass them along. - extra_args: Optional[Callable[[Any], Dict[str, Any]]] = None - - -def _log_call( - namespace: str, - tool_name: str, - args, - kwargs, - recorded_output: Any = None, - # use print, not logger calls, else the root logger will recurse if - # juju-log calls are being @wrapped as well. - log_fn: Callable[[str], None] = logger.debug, -): - try: - output_repr = repr(recorded_output) - except: # noqa catchall - output_repr = "" - - trim = output_repr[:100] - trimmed = "[...]" if len(output_repr) > 100 else "" - - return log_fn( - f"@wrap_tool: intercepted {namespace}.{tool_name}(*{args}, **{kwargs})" - f"\n\t --> {trim}{trimmed}" - ) - - -class StateError(RuntimeError): - pass - - -class QuestionNotImplementedError(StateError): - pass - - -def wrap( - fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - charm_spec: "CharmSpec", - simulator: Simulator = wrap_tool, -): - @functools.wraps(fn) - def wrapper(*call_args, **call_kwargs): - out = simulator( - fn=fn, - namespace=namespace, - tool_name=tool_name, - scene=scene, - charm_spec=charm_spec, - call_args=call_args, - call_kwargs=call_kwargs, + unit_id = obj_name.split("/")[-1] + return relation.remote_units_data[int(unit_id)] + + def is_leader(self): + return self._state.leader + + def status_get(self, *args, **kwargs): + status, message = ( + self._state.status.app + if kwargs.get("app") + else self._state.status.unit + ) + return {"status": status, "message": message} + + def relation_ids(self, endpoint, *args, **kwargs): + return [rel.meta.relation_id for rel in self._state.relations if rel.meta.endpoint == endpoint] + + def relation_list(self, rel_id, *args, **kwargs): + relation = next( + filter( + lambda r: r.meta.relation_id == rel_id, self._state.relations + ) + ) + return tuple( + f"{relation.meta.remote_app_name}/{unit_id}" + for unit_id in relation.meta.remote_unit_ids + ) + + def config_get(self, *args, **kwargs): + state_config = self._state.config + if not state_config: + state_config = { + key: value.get("default") + for key, value in self._charm_spec.config.items() + } + + if args: # one specific key requested + # Fixme: may raise KeyError if the key isn't defaulted. What do we do then? + return state_config[args[0]] + + return state_config # full config + + def network_get(self, *args, **kwargs): + name, relation_id = args + + network = next( + filter( + lambda r: r.name == name, self._state.networks + ) ) + return network.network.hook_tool_output_fmt() - _log_call(namespace, tool_name, call_args, call_kwargs, out) - return out - - return wrapper - - -# todo: figure out how to allow users to manually tag individual functions for wrapping -def patch_module( - module, - decorate: Dict[str, Dict[str, DecorateSpec]], - scene: "Scene", - charm_spec: "CharmSpec" = None, -): - """Patch a module by decorating methods in a number of classes. - - Decorate: a dict mapping class names to methods of that class that should be decorated. - Example:: - >>> patch_module(my_module, {'MyClass': { - ... 'do_x': DecorateSpec(), - ... 'is_ready': DecorateSpec(caching_policy='loose'), - ... 'refresh': DecorateSpec(caching_policy='loose'), - ... 'bar': DecorateSpec(caching_policy='loose') - ... }}, - ... some_scene) - """ - - for name, obj in module.__dict__.items(): - specs = decorate.get(name) - - if not specs: - continue - - patch_class(specs, obj, scene=scene, charm_spec=charm_spec) - - -def patch_class( - specs: Dict[str, DecorateSpec], - obj: Type, - scene: "Scene", - charm_spec: "CharmSpec", -): - for meth_name, fn in obj.__dict__.items(): - spec = specs.get(meth_name) - - if not spec: - continue - - # todo: use mock.patch and lift after exit - wrapped_fn = wrap( - fn, - namespace=obj.__name__, - tool_name=meth_name, - scene=scene, - charm_spec=charm_spec, - simulator=spec.simulator, + def action_get(self, *args, **kwargs): + raise NotImplementedError("action_get") + + def relation_remote_app_name(self, *args, **kwargs): + raise NotImplementedError("relation_remote_app_name") + + def resource_get(self, *args, **kwargs): + raise NotImplementedError("resource_get") + + def storage_list(self, *args, **kwargs): + raise NotImplementedError("storage_list") + + def storage_get(self, *args, **kwargs): + raise NotImplementedError("storage_get") + + def planned_units(self, *args, **kwargs): + raise NotImplementedError("planned_units") + + # setter methods: these can mutate the state. + def application_version_set(self, *args, **kwargs): + self._state.status.app_version = args[0] + return None + + def status_set(self, *args, **kwargs): + if kwargs.get("is_app"): + self._state.status.app = args + else: + self._state.status.unit = args + return None + + def juju_log(self, *args, **kwargs): + self._state.juju_log.append(args) + return None + + def relation_set(self, *args, **kwargs): + rel_id, key, value, app = args + relation = next( + filter( + lambda r: r.meta.relation_id == rel_id, self._state.relations + ) ) + if app: + if not self._state.leader: + raise RuntimeError("needs leadership to set app data") + tgt = relation.local_app_data + else: + tgt = relation.local_unit_data + tgt[key] = value + return None + + # TODO: + def action_set(self, *args, **kwargs): + raise NotImplementedError("action_set") + + def action_fail(self, *args, **kwargs): + raise NotImplementedError("action_fail") + + def action_log(self, *args, **kwargs): + raise NotImplementedError("action_log") + + def storage_add(self, *args, **kwargs): + raise NotImplementedError("storage_add") + + def secret_get(self, *args, **kwargs): + raise NotImplementedError("secret_get") + + def secret_set(self, *args, **kwargs): + raise NotImplementedError("secret_set") + + def secret_grant(self, *args, **kwargs): + raise NotImplementedError("secret_grant") + + def secret_remove(self, *args, **kwargs): + raise NotImplementedError("secret_remove") + + +class _MockPebbleClient(Client): + + def __init__(self, socket_path: str, + opener: Optional[urllib.request.OpenerDirector] = None, + base_url: str = 'http://localhost', + timeout: float = 5.0, + *, + state: "State", + event: "Event", + charm_spec: "CharmSpec"): + super().__init__(socket_path, opener, base_url, timeout) + self._state = state + self._event = event + self._charm_spec = charm_spec + + container_name = socket_path.split("/")[-2] + try: + self._container = next( + filter(lambda x: x.name == container_name, state.containers) + ) + except StopIteration: + raise RuntimeError( + f"container with name={container_name!r} not found. " + f"Did you forget a ContainerSpec, or is the socket path " + f"{socket_path!r} wrong?" + ) + + def _request(self, *args, **kwargs): + if args == ("GET", "/v1/system-info"): + if self._container.can_connect: + return {"result": {"version": "unknown"}} + else: + wrap_errors = False # this is what Client expects! + raise FileNotFoundError("") + + elif args[:2] == ("GET", "/v1/services"): + service_names = list(args[2]["names"].split(",")) + result = [] + + for layer in self._container.layers: + if not service_names: + break + + for name in service_names: + if name in layer["services"]: + service_names.remove(name) + result.append(layer["services"][name]) + + # todo: what do we do if we don't find the requested service(s)? + return {"result": result} + + else: + raise NotImplementedError(f"_request: {args}") + + def exec(self, *args, **kwargs): + cmd = tuple(args[0]) + out = self._container.exec_mock.get(cmd) + if not out: + raise RuntimeError(f"mock for cmd {cmd} not found.") + + change_id = out._run() + return _MockExecProcess(change_id=change_id, command=cmd, out=out) + + def pull(self, *args, **kwargs): + # todo double-check how to surface error + wrap_errors = False + + path_txt = args[0] + pos = self._container.filesystem + for token in path_txt.split("/")[1:]: + pos = pos.get(token) + if not pos: + raise FileNotFoundError(path_txt) + local_path = Path(pos) + if not local_path.exists() or not local_path.is_file(): + raise FileNotFoundError(local_path) + return local_path.open() + + def push(self, *args, **kwargs): + setter = True + # todo double-check how to surface error + wrap_errors = False + + path_txt, contents = args + + pos = self._container.filesystem + tokens = path_txt.split("/")[1:] + for token in tokens[:-1]: + nxt = pos.get(token) + if not nxt and kwargs["make_dirs"]: + pos[token] = {} + pos = pos[token] + elif not nxt: + raise FileNotFoundError(path_txt) + else: + pos = pos[token] + + # dump contents + # fixme: memory leak here if tmp isn't regularly cleaned up + file = tempfile.NamedTemporaryFile(delete=False) + pth = Path(file.name) + pth.write_text(contents) - setattr(obj, meth_name, wrapped_fn) + pos[tokens[-1]] = pth + return diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 60f755918..e128c4bd2 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -2,55 +2,44 @@ # see https://github.com/canonical/operator/pull/862 import inspect -import logging import os -import warnings -from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type +from typing import TYPE_CHECKING, Callable, Optional import ops.charm import ops.framework import ops.model import ops.storage from ops.charm import CharmMeta -from ops.jujuversion import JujuVersion from ops.log import setup_root_logging from ops.main import ( CHARM_STATE_FILE, _Dispatcher, _emit_charm_event, _get_charm_dir, - _should_use_controller_storage, ) from scenario.logger import logger as scenario_logger +from scenario.mocking import _MockModelBackend if TYPE_CHECKING: - from ops.charm import CharmBase, EventBase from ops.testing import CharmType + from scenario.state import CharmSpec, State, Event logger = scenario_logger.getChild("ops_main_mock") def main( - charm_class: Type[ops.charm.CharmBase], - use_juju_for_storage: Optional[bool] = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, -) -> Optional[Tuple["CharmBase", Optional["EventBase"]]]: - """Setup the charm and dispatch the observed event. - - The event name is based on the way this executable was called (argv[0]). - - Args: - charm_class: your charm class. - use_juju_for_storage: whether to use controller-side storage. If not specified - then kubernetes charms that haven't previously used local storage and that - are running on a new enough Juju default to controller-side storage, - otherwise local storage is used. - """ + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + state: "State" = None, + event: "Event" = None, + charm_spec: "CharmSpec" = None, +): + """Set up the charm and dispatch the observed event.""" + charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() - - model_backend = ops.model._ModelBackend() # pyright: reportPrivateUsage=false + model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false + state=state, event=event, charm_spec=charm_spec) debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) logger.debug( @@ -72,51 +61,17 @@ def main( charm_state_path = charm_dir / CHARM_STATE_FILE - if use_juju_for_storage and not ops.storage.juju_backend_available(): - # raise an exception; the charm is broken and needs fixing. - msg = "charm set use_juju_for_storage=True, but Juju version {} does not support it" - raise RuntimeError(msg.format(JujuVersion.from_environ())) - - if use_juju_for_storage is None: - use_juju_for_storage = _should_use_controller_storage(charm_state_path, meta) - - if use_juju_for_storage: - if dispatcher.is_restricted_context(): - # TODO: jam 2020-06-30 This unconditionally avoids running a collect metrics event - # Though we eventually expect that juju will run collect-metrics in a - # non-restricted context. Once we can determine that we are running collect-metrics - # in a non-restricted context, we should fire the event as normal. - logger.debug( - '"%s" is not supported when using Juju for storage\n' - "see: https://github.com/canonical/operator/issues/348", - dispatcher.event_name, - ) - # Note that we don't exit nonzero, because that would cause Juju to rerun the hook - return - store = ops.storage.JujuStorage() - else: - store = ops.storage.SQLiteStorage(charm_state_path) + # TODO: add use_juju_for_storage support + store = ops.storage.SQLiteStorage(charm_state_path) framework = ops.framework.Framework(store, charm_dir, meta, model) framework.set_breakpointhook() try: sig = inspect.signature(charm_class) - try: - sig.bind(framework) - except TypeError: - msg = ( - "the second argument, 'key', has been deprecated and will be " - "removed after the 0.7 release" - ) - warnings.warn(msg, DeprecationWarning) - charm = charm_class(framework) - else: - charm = charm_class(framework) + sig.bind(framework) # signature check + + charm = charm_class(framework) dispatcher.ensure_event_links(charm) - # TODO: Remove the collect_metrics check below as soon as the relevant - # Juju changes are made. Also adjust the docstring on - # EventBase.defer(). - # # Skip reemission of deferred events for collect-metrics events because # they do not have the full access to all hook tools. if not dispatcher.is_restricted_context(): diff --git a/scenario/runtime.py b/scenario/runtime.py index 22aa90676..a53f53053 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,5 +1,4 @@ import dataclasses -import inspect import os import sys import tempfile @@ -10,14 +9,12 @@ import yaml from scenario.logger import logger as scenario_logger -from scenario.mocking import DecorateSpec, patch_module if TYPE_CHECKING: from ops.charm import CharmBase from ops.framework import EventBase from ops.testing import CharmType - - from scenario.structs import CharmSpec, Scene, State + from scenario.state import CharmSpec, State, Event _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -73,73 +70,6 @@ def from_local_file( my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] return Runtime(CharmSpec(my_charm_type)) # TODO add meta, options,... - @contextmanager - def patching(self, scene: "Scene"): - """Install the runtime: patch all required backend calls.""" - - # copy input state to act as blueprint for output state - logger.info(f"Installing {self}... ") - from ops import pebble - - logger.info("patching ops.pebble") - - pebble_decorator_specs = { - "Client": { - # todo: we could be more fine-grained and decorate individual Container methods, - # e.g. can_connect, ... just like in _ModelBackend we don't just memo `_run`. - "_request": DecorateSpec(), - # some methods such as pebble.pull use _request_raw directly, - # and deal in objects that cannot be json-serialized - "pull": DecorateSpec(), - "push": DecorateSpec(), - "exec": DecorateSpec(), - } - } - patch_module(pebble, decorate=pebble_decorator_specs, scene=scene) - - from ops import model - - logger.info("patching ops.model") - model_decorator_specs = { - "_ModelBackend": { - "relation_get": DecorateSpec(), - "relation_set": DecorateSpec(), - "is_leader": DecorateSpec(), - "application_version_set": DecorateSpec(), - "status_get": DecorateSpec(), - "action_get": DecorateSpec(), - "add_metrics": DecorateSpec(), # deprecated, I guess - "action_set": DecorateSpec(), - "action_fail": DecorateSpec(), - "action_log": DecorateSpec(), - "relation_ids": DecorateSpec(), - "relation_list": DecorateSpec(), - "relation_remote_app_name": DecorateSpec(), - "config_get": DecorateSpec(), - "resource_get": DecorateSpec(), - "storage_list": DecorateSpec(), - "storage_get": DecorateSpec(), - "network_get": DecorateSpec(), - "status_set": DecorateSpec(), - "storage_add": DecorateSpec(), - "juju_log": DecorateSpec(), - "planned_units": DecorateSpec(), - # todo different ops version support? - # "secret_get": DecorateSpec(), - # "secret_set": DecorateSpec(), - # "secret_grant": DecorateSpec(), - # "secret_remove": DecorateSpec(), - } - } - patch_module( - model, - decorate=model_decorator_specs, - scene=scene, - charm_spec=self._charm_spec, - ) - - yield - @staticmethod def _redirect_root_logger(): # the root logger set up by ops calls a hook tool: `juju-log`. @@ -165,10 +95,10 @@ def unit_name(self): return "local/0" return meta["name"] + "/0" # todo allow override - def _get_event_env(self, scene: "Scene", charm_root: Path): - if scene.event.name.endswith("_action"): + def _get_event_env(self, state: "State", event: "Event", charm_root: Path): + if event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names are always dashes? - action_name = scene.event.name[: -len("_action")].replace("_", "-") + action_name = event.name[: -len("_action")].replace("_", "-") else: action_name = "" @@ -176,16 +106,16 @@ def _get_event_env(self, scene: "Scene", charm_root: Path): "JUJU_VERSION": self._juju_version, "JUJU_UNIT_NAME": self.unit_name, "_": "./dispatch", - "JUJU_DISPATCH_PATH": f"hooks/{scene.event.name}", - "JUJU_MODEL_NAME": scene.state.model.name, + "JUJU_DISPATCH_PATH": f"hooks/{event.name}", + "JUJU_MODEL_NAME": state.model.name, "JUJU_ACTION_NAME": action_name, - "JUJU_MODEL_UUID": scene.state.model.uuid, + "JUJU_MODEL_UUID": state.model.uuid, "JUJU_CHARM_DIR": str(charm_root.absolute()) # todo consider setting pwd, (python)path } - if scene.event.meta and scene.event.meta.relation: - relation = scene.event.meta.relation + if event.meta and event.meta.relation: + relation = event.meta.relation env.update( { "JUJU_RELATION": relation.endpoint, @@ -225,9 +155,10 @@ def virtual_charm_root(self): (temppath / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) yield temppath - def play( + def run( self, - scene: "Scene", + state: "State", + event: "Event", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -237,44 +168,46 @@ def play( After that it's up to ops. """ logger.info( - f"Preparing to fire {scene.event.name} on {self._charm_type.__name__}" + f"Preparing to fire {event.name} on {self._charm_type.__name__}" ) - # we make a copy to avoid mutating the input scene - scene = scene.copy() + # we make a copy to avoid mutating the input state + output_state = state.copy() logger.info(" - generating virtual charm root") with self.virtual_charm_root() as temporary_charm_root: - with self.patching(scene): - # todo consider forking out a real subprocess and do the mocking by - # generating hook tool callables - - logger.info(" - redirecting root logging") - self._redirect_root_logger() - - logger.info(" - preparing env") - env = self._get_event_env(scene, charm_root=temporary_charm_root) - os.environ.update(env) - - logger.info(" - Entering ops.main (mocked).") - # we don't import from ops.main because we need some extras, such as the pre/post_event hooks - from scenario.ops_main_mock import main as mocked_main - - try: - mocked_main( - self._wrap(self._charm_type), - pre_event=pre_event, - post_event=post_event, - ) - except Exception as e: - raise RuntimeError( - f"Uncaught error in operator/charm code: {e}." - ) from e - finally: - logger.info(" - Exited ops.main.") - - logger.info(" - clearing env") - self._cleanup_env(env) - - logger.info("event fired; done.") - return scene.state + # todo consider forking out a real subprocess and do the mocking by + # generating hook tool callables + + logger.info(" - redirecting root logging") + self._redirect_root_logger() + + logger.info(" - preparing env") + env = self._get_event_env(state=state, event=event, + charm_root=temporary_charm_root) + os.environ.update(env) + + logger.info(" - Entering ops.main (mocked).") + # we don't import from ops.main because we need some extras, such as the pre/post_event hooks + from scenario.ops_main_mock import main as mocked_main + + try: + mocked_main( + pre_event=pre_event, + post_event=post_event, + state=output_state, + event=event, + charm_spec=self._charm_spec + ) + except Exception as e: + raise RuntimeError( + f"Uncaught error in operator/charm code: {e}." + ) from e + finally: + logger.info(" - Exited ops.main.") + + logger.info(" - clearing env") + self._cleanup_env(env) + + logger.info("event fired; done.") + return output_state diff --git a/scenario/scenario.py b/scenario/scenario.py deleted file mode 100644 index a761be356..000000000 --- a/scenario/scenario.py +++ /dev/null @@ -1,136 +0,0 @@ -import typing -from itertools import chain -from typing import Callable, Iterable, Optional, TextIO, Type, Union - -from scenario.logger import logger as scenario_logger -from scenario.runtime import Runtime -from scenario.structs import ( - ATTACH_ALL_STORAGES, - BREAK_ALL_RELATIONS, - CREATE_ALL_RELATIONS, - DETACH_ALL_STORAGES, - META_EVENTS, - CharmSpec, - Event, - InjectRelation, - Scene, - State, -) - -if typing.TYPE_CHECKING: - from ops.testing import CharmType - -CharmMeta = Optional[Union[str, TextIO, dict]] - -logger = scenario_logger.getChild("scenario") - - -class Scenario: - def __init__( - self, - charm_spec: CharmSpec, - juju_version: str = "3.0.0", - ): - self._runtime = Runtime(charm_spec, juju_version=juju_version) - - @staticmethod - def decompose_meta_event(meta_event: Event, state: State): - # decompose the meta event - - if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: - logger.warning(f"meta-event {meta_event.name} not supported yet") - return - - if meta_event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: - for relation in state.relations: - event = Event( - relation.meta.endpoint + META_EVENTS[meta_event.name], - args=( - # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. - # this will be replaced by a Relation instance before the event is fired. - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), - ), - ) - logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event - - else: - raise RuntimeError(f"unknown meta-event {meta_event.name}") - - def play( - self, - scene: Scene, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> "State": - # TODO check state transition consistency: verify that if state was mutated, it was - # in a way that makes sense: - # e.g. - charm cannot modify leadership status, etc... - - return self._runtime.play( - scene, - pre_event=pre_event, - post_event=post_event, - ) - - -def generate_startup_scenes(state_template: State): - yield from ( - Scene(event=Event(ATTACH_ALL_STORAGES), state=state_template.copy()), - Scene(event=Event("start"), state=state_template.copy()), - Scene(event=Event(CREATE_ALL_RELATIONS), state=state_template.copy()), - Scene( - event=Event( - "leader-elected" if state_template.leader else "leader-settings-changed" - ), - state=state_template.copy(), - ), - Scene(event=Event("config-changed"), state=state_template.copy()), - Scene(event=Event("install"), state=state_template.copy()), - ) - - -def generate_teardown_scenes(state_template: State): - yield from ( - Scene(event=Event(BREAK_ALL_RELATIONS), state=state_template.copy()), - Scene(event=Event(DETACH_ALL_STORAGES), state=state_template.copy()), - Scene(event=Event("stop"), state=state_template.copy()), - Scene(event=Event("remove"), state=state_template.copy()), - ) - - -def generate_builtin_scenes(template_states: Iterable[State]): - for template_state in template_states: - yield from chain( - generate_startup_scenes(template_state), - generate_teardown_scenes(template_state), - ) - - -def check_builtin_sequences( - charm_spec: CharmSpec, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, -): - """Test that all the builtin startup and teardown events can fire without errors. - - This will play both scenarios with and without leadership, and raise any exceptions. - If leader is True, it will exclude the non-leader cases, and vice-versa. - - This is a baseline check that in principle all charms (except specific use-cases perhaps), - should pass out of the box. - - If you want to, you can inject more stringent state checks using the - pre_event and post_event hooks. - """ - scenario = Scenario(charm_spec) - - for scene in generate_builtin_scenes( - ( - State(leader=True), - State(leader=False), - ) - ): - scenario.play(scene, pre_event=pre_event, post_event=post_event) diff --git a/scenario/sequences.py b/scenario/sequences.py new file mode 100644 index 000000000..fa80525b1 --- /dev/null +++ b/scenario/sequences.py @@ -0,0 +1,109 @@ +import typing +from itertools import chain +from typing import Callable, Iterable, Optional, TextIO, Union + +from scenario.logger import logger as scenario_logger +from scenario.state import ( + ATTACH_ALL_STORAGES, + BREAK_ALL_RELATIONS, + CREATE_ALL_RELATIONS, + DETACH_ALL_STORAGES, + META_EVENTS, + CharmSpec, + Event, + InjectRelation, + State, +) + +if typing.TYPE_CHECKING: + from ops.testing import CharmType + +CharmMeta = Optional[Union[str, TextIO, dict]] + +logger = scenario_logger.getChild("scenario") + + +def decompose_meta_event(meta_event: Event, state: State): + # decompose the meta event + + if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: + logger.warning(f"meta-event {meta_event.name} not supported yet") + return + + if meta_event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: + for relation in state.relations: + event = Event( + relation.meta.endpoint + META_EVENTS[meta_event.name], + args=( + # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. + # this will be replaced by a Relation instance before the event is fired. + InjectRelation( + relation.meta.endpoint, relation.meta.relation_id + ), + ), + ) + logger.debug(f"decomposed meta {meta_event.name}: {event}") + yield event + + else: + raise RuntimeError(f"unknown meta-event {meta_event.name}") + + +def generate_startup_sequence(state_template: State): + yield from ( + (Event(ATTACH_ALL_STORAGES), state_template.copy()), + (Event("start"), state_template.copy()), + (Event(CREATE_ALL_RELATIONS), state_template.copy()), + ( + Event( + "leader-elected" if state_template.leader else "leader-settings-changed" + ), + state_template.copy(), + ), + (Event("config-changed"), state_template.copy()), + (Event("install"), state_template.copy()), + ) + + +def generate_teardown_sequence(state_template: State): + yield from ( + (Event(BREAK_ALL_RELATIONS), state_template.copy()), + (Event(DETACH_ALL_STORAGES), state_template.copy()), + (Event("stop"), state_template.copy()), + (Event("remove"), state_template.copy()), + ) + + +def generate_builtin_sequences(template_states: Iterable[State]): + for template_state in template_states: + yield from chain( + generate_startup_sequence(template_state), + generate_teardown_sequence(template_state), + ) + + +def check_builtin_sequences( + charm_spec: CharmSpec, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, +): + """Test that all the builtin startup and teardown events can fire without errors. + + This will play both scenarios with and without leadership, and raise any exceptions. + If leader is True, it will exclude the non-leader cases, and vice-versa. + + This is a baseline check that in principle all charms (except specific use-cases perhaps), + should pass out of the box. + + If you want to, you can inject more stringent state checks using the + pre_event and post_event hooks. + """ + + for event, state in generate_builtin_sequences( + ( + State(leader=True), + State(leader=False), + ) + ): + state.run(event=event, charm_spec=charm_spec, + pre_event=pre_event, post_event=post_event) diff --git a/scenario/structs.py b/scenario/state.py similarity index 95% rename from scenario/structs.py rename to scenario/state.py index 342c14a37..c6e5e667b 100644 --- a/scenario/structs.py +++ b/scenario/state.py @@ -1,17 +1,16 @@ import copy import dataclasses import inspect -import tempfile import typing -from io import BytesIO, StringIO from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Type, Union +from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Type, Union, Callable from uuid import uuid4 import yaml from ops import testing from scenario.logger import logger as scenario_logger +from scenario.runtime import Runtime if typing.TYPE_CHECKING: try: @@ -266,10 +265,19 @@ class State(_DCBase): model: Model = Model() juju_log: Sequence[Tuple[str, str]] = dataclasses.field(default_factory=list) + # meta stuff: actually belongs in event data structure. + juju_version: str = "3.0.0" + unit_id: str = "0" + app_name: str = "local" + # todo: add pebble stuff, unit/app status, etc... # actions? # juju topology + @property + def unit_name(self): + return self.app_name + "/" + self.unit_id + def with_can_connect(self, container_name: str, can_connect: bool): def replacer(container: ContainerSpec): if container.name == container_name: @@ -293,7 +301,7 @@ def get_container(self, name) -> ContainerSpec: except StopIteration as e: raise ValueError(f"container: {name}") from e - def delta(self, other: "State"): + def jsonpatch_delta(self, other: "State"): try: import jsonpatch except ModuleNotFoundError: @@ -308,6 +316,22 @@ def delta(self, other: "State"): ).patch return sort_patch(patch) + def run( + self, + event: "Event", + charm_spec: "CharmSpec", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> "State": + runtime = Runtime(charm_spec, + juju_version=self.juju_version) + return runtime.run( + state=self, + event=event, + pre_event=pre_event, + post_event=post_event, + ) + @dataclasses.dataclass class CharmSpec(_DCBase): @@ -368,24 +392,6 @@ def is_meta(self): return self.name in META_EVENTS -@dataclasses.dataclass -class SceneMeta(_DCBase): - unit_id: str = "0" - app_name: str = "local" - - @property - def unit_name(self): - return self.app_name + "/" + self.unit_id - - -@dataclasses.dataclass -class Scene(_DCBase): - event: Event - state: State = dataclasses.field(default_factory=State) - # data that doesn't belong to the event nor the state - meta: SceneMeta = SceneMeta() - - @dataclasses.dataclass class Inject(_DCBase): """Base class for injectors: special placeholders used to tell harness_ctx diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index e57eca100..0b53d4b29 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -142,7 +142,7 @@ def callback(self: CharmBase, evt): assert file.read_text() == text else: # nothing has changed - assert not out.delta(scene.state) + assert not out.jsonpatch_delta(scene.state) LS = """ diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index be730e2e3..61d6b1a0a 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -62,7 +62,7 @@ def post_event(charm): assert out.status.unit == ("active", "yabadoodle") out.juju_log = [] # exclude juju log from delta - assert out.delta(initial_state) == [ + assert out.jsonpatch_delta(initial_state) == [ { "op": "replace", "path": "/status/unit", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index a0da37058..956175f62 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,16 +1,14 @@ from dataclasses import asdict -from typing import Optional, Type +from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.scenario import Scenario -from scenario.structs import ( +from scenario.state import ( CharmSpec, ContainerSpec, - Scene, State, event, relation, @@ -63,50 +61,44 @@ def _on_event(self, event): @pytest.fixture -def dummy_state(): +def state(): return State(config={"foo": "bar"}, leader=True) -@pytest.fixture(scope="function") -def start_scene(dummy_state): - return Scene(event("start"), state=dummy_state) - - -@pytest.fixture(scope="function") -def scenario(mycharm): - return Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - - -def test_bare_event(start_scene, mycharm): - scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - out = scenario.play(scene=start_scene) +def test_bare_event(state, mycharm): + out = state.run(event('start'), + charm_spec=CharmSpec(mycharm, meta={"name": "foo"})) out.juju_log = [] # ignore logging output in the delta - assert start_scene.state.delta(out) == [] + assert state.jsonpatch_delta(out) == [] -def test_leader_get(start_scene, mycharm): +def test_leader_get(state, mycharm): def pre_event(charm): assert charm.unit.is_leader() - scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - scenario.play(start_scene, pre_event=pre_event) + state.run(event=event('start'), + charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), + pre_event=pre_event) -def test_status_setting(start_scene, mycharm): +def test_status_setting(state, mycharm): def call(charm: CharmBase, _): assert isinstance(charm.unit.status, UnknownStatus) charm.unit.status = ActiveStatus("foo test") charm.app.status = WaitingStatus("foo barz") mycharm._call = call - scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - out = scenario.play(start_scene) + out = state.run( + charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), + event=event('start'), + + ) assert out.status.unit == ("active", "foo test") assert out.status.app == ("waiting", "foo barz") assert out.status.app_version == "" out.juju_log = [] # ignore logging output in the delta - assert out.delta(start_scene.state) == sort_patch( + assert out.jsonpatch_delta(state) == sort_patch( [ { "op": "replace", @@ -123,28 +115,28 @@ def call(charm: CharmBase, _): @pytest.mark.parametrize("connect", (True, False)) -def test_container(start_scene: Scene, connect, mycharm): +def test_container(connect, mycharm): def pre_event(charm: CharmBase): container = charm.unit.get_container("foo") assert container is not None assert container.name == "foo" assert container.can_connect() is connect - scenario = Scenario( - CharmSpec( - mycharm, - meta={ - "name": "foo", - "containers": {"foo": {"resource": "bar"}}, - }, - ) + spec = CharmSpec( + mycharm, + meta={ + "name": "foo", + "containers": {"foo": {"resource": "bar"}}, + }, ) - scene = start_scene.copy() - scene.state.containers = (ContainerSpec(name="foo", can_connect=connect),) - scenario.play(scene, pre_event=pre_event) + + out = State( + containers=(ContainerSpec(name="foo", can_connect=connect),) + ).run(event=event('start'), pre_event=pre_event, + charm_spec=spec) -def test_relation_get(start_scene: Scene, mycharm): +def test_relation_get(mycharm): def pre_event(charm: CharmBase): rel = charm.model.get_relation("foo") assert rel is not None @@ -161,17 +153,14 @@ def pre_event(charm: CharmBase): else: assert not rel.data[unit] - scenario = Scenario( - CharmSpec( - mycharm, - meta={ - "name": "local", - "requires": {"foo": {"interface": "bar"}}, - }, - ) + spec = CharmSpec( + mycharm, + meta={ + "name": "local", + "requires": {"foo": {"interface": "bar"}}, + }, ) - scene = start_scene.copy() - scene.state.relations = [ + state = State(relations=[ relation( endpoint="foo", interface="bar", @@ -181,12 +170,14 @@ def pre_event(charm: CharmBase): remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, - ), - ] - scenario.play(scene, pre_event=pre_event) + )] + ) + state.run(event=event('start'), + pre_event=pre_event, + charm_spec=spec) -def test_relation_set(start_scene: Scene, mycharm): +def test_relation_set(mycharm): def event_handler(charm: CharmBase, _): rel = charm.model.get_relation("foo") rel.data[charm.app]["a"] = "b" @@ -214,31 +205,28 @@ def pre_event(charm: CharmBase): # rel.data[charm.model.get_unit("remote/1")]["c"] = "d" mycharm._call = event_handler - scenario = Scenario( - CharmSpec( - mycharm, - meta={ - "name": "foo", - "requires": {"foo": {"interface": "bar"}}, - }, - ) + spec = CharmSpec( + mycharm, + meta={ + "name": "foo", + "requires": {"foo": {"interface": "bar"}}, + }, ) - scene = start_scene.copy() - - scene.state.leader = True - scene.state.relations = [ - relation( - endpoint="foo", - interface="bar", - remote_unit_ids=[1, 4], - local_app_data={}, - local_unit_data={}, - ) - ] + state = State(leader=True, + relations=[ + relation( + endpoint="foo", + interface="bar", + remote_unit_ids=[1, 4], + local_app_data={}, + local_unit_data={}, + ) + ] + ) assert not mycharm.called - out = scenario.play(scene, pre_event=pre_event) + out = state.run(event=event('start'), charm_spec=spec, pre_event=pre_event) assert mycharm.called assert asdict(out.relations[0]) == asdict( From 3e7fabf4c25d571d8eb7bb36d0b5367af14887db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 12:29:01 +0100 Subject: [PATCH 061/546] lint --- scenario/mocking.py | 62 +++++++++++------------- scenario/ops_main_mock.py | 23 ++++----- scenario/runtime.py | 14 +++--- scenario/sequences.py | 26 +++++----- scenario/state.py | 24 +++++++-- tests/test_e2e/test_network.py | 26 +++++----- tests/test_e2e/test_relations.py | 10 +--- tests/test_e2e/test_state.py | 83 +++++++++++++++----------------- 8 files changed, 132 insertions(+), 136 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index a72849480..340a3699e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -10,7 +10,7 @@ from scenario.logger import logger as scenario_logger if TYPE_CHECKING: - from scenario.state import CharmSpec, State, Event, ExecOutput + from scenario.state import CharmSpec, Event, ExecOutput, State logger = scenario_logger.getChild("mocking") @@ -48,17 +48,17 @@ def __init__(self, state: "State", event: "Event", charm_spec: "CharmSpec"): self._event = event self._charm_spec = charm_spec - def get_pebble(self, socket_path: str) -> 'Client': - return _MockPebbleClient(socket_path=socket_path, - state=self._state, - event=self._event, - charm_spec=self._charm_spec) + def get_pebble(self, socket_path: str) -> "Client": + return _MockPebbleClient( + socket_path=socket_path, + state=self._state, + event=self._event, + charm_spec=self._charm_spec, + ) def relation_get(self, rel_id, obj_name, app): relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, self._state.relations - ) + filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) ) if app and obj_name == self._state.app_name: return relation.local_app_data @@ -75,20 +75,20 @@ def is_leader(self): def status_get(self, *args, **kwargs): status, message = ( - self._state.status.app - if kwargs.get("app") - else self._state.status.unit + self._state.status.app if kwargs.get("app") else self._state.status.unit ) return {"status": status, "message": message} def relation_ids(self, endpoint, *args, **kwargs): - return [rel.meta.relation_id for rel in self._state.relations if rel.meta.endpoint == endpoint] + return [ + rel.meta.relation_id + for rel in self._state.relations + if rel.meta.endpoint == endpoint + ] def relation_list(self, rel_id, *args, **kwargs): relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, self._state.relations - ) + filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) ) return tuple( f"{relation.meta.remote_app_name}/{unit_id}" @@ -112,11 +112,7 @@ def config_get(self, *args, **kwargs): def network_get(self, *args, **kwargs): name, relation_id = args - network = next( - filter( - lambda r: r.name == name, self._state.networks - ) - ) + network = next(filter(lambda r: r.name == name, self._state.networks)) return network.network.hook_tool_output_fmt() def action_get(self, *args, **kwargs): @@ -156,9 +152,7 @@ def juju_log(self, *args, **kwargs): def relation_set(self, *args, **kwargs): rel_id, key, value, app = args relation = next( - filter( - lambda r: r.meta.relation_id == rel_id, self._state.relations - ) + filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) ) if app: if not self._state.leader: @@ -196,15 +190,17 @@ def secret_remove(self, *args, **kwargs): class _MockPebbleClient(Client): - - def __init__(self, socket_path: str, - opener: Optional[urllib.request.OpenerDirector] = None, - base_url: str = 'http://localhost', - timeout: float = 5.0, - *, - state: "State", - event: "Event", - charm_spec: "CharmSpec"): + def __init__( + self, + socket_path: str, + opener: Optional[urllib.request.OpenerDirector] = None, + base_url: str = "http://localhost", + timeout: float = 5.0, + *, + state: "State", + event: "Event", + charm_spec: "CharmSpec", + ): super().__init__(socket_path, opener, base_url, timeout) self._state = state self._event = event diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index e128c4bd2..5cf4734da 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -11,35 +11,32 @@ import ops.storage from ops.charm import CharmMeta from ops.log import setup_root_logging -from ops.main import ( - CHARM_STATE_FILE, - _Dispatcher, - _emit_charm_event, - _get_charm_dir, -) +from ops.main import CHARM_STATE_FILE, _Dispatcher, _emit_charm_event, _get_charm_dir from scenario.logger import logger as scenario_logger from scenario.mocking import _MockModelBackend if TYPE_CHECKING: from ops.testing import CharmType - from scenario.state import CharmSpec, State, Event + + from scenario.state import CharmSpec, Event, State logger = scenario_logger.getChild("ops_main_mock") def main( - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - state: "State" = None, - event: "Event" = None, - charm_spec: "CharmSpec" = None, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + state: "State" = None, + event: "Event" = None, + charm_spec: "CharmSpec" = None, ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false - state=state, event=event, charm_spec=charm_spec) + state=state, event=event, charm_spec=charm_spec + ) debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) logger.debug( diff --git a/scenario/runtime.py b/scenario/runtime.py index a53f53053..3536a5ee5 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -14,7 +14,8 @@ from ops.charm import CharmBase from ops.framework import EventBase from ops.testing import CharmType - from scenario.state import CharmSpec, State, Event + + from scenario.state import CharmSpec, Event, State _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -167,9 +168,7 @@ def run( This will set the environment up and call ops.main.main(). After that it's up to ops. """ - logger.info( - f"Preparing to fire {event.name} on {self._charm_type.__name__}" - ) + logger.info(f"Preparing to fire {event.name} on {self._charm_type.__name__}") # we make a copy to avoid mutating the input state output_state = state.copy() @@ -183,8 +182,9 @@ def run( self._redirect_root_logger() logger.info(" - preparing env") - env = self._get_event_env(state=state, event=event, - charm_root=temporary_charm_root) + env = self._get_event_env( + state=state, event=event, charm_root=temporary_charm_root + ) os.environ.update(env) logger.info(" - Entering ops.main (mocked).") @@ -197,7 +197,7 @@ def run( post_event=post_event, state=output_state, event=event, - charm_spec=self._charm_spec + charm_spec=self._charm_spec, ) except Exception as e: raise RuntimeError( diff --git a/scenario/sequences.py b/scenario/sequences.py index fa80525b1..46a33934c 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -37,9 +37,7 @@ def decompose_meta_event(meta_event: Event, state: State): args=( # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. # this will be replaced by a Relation instance before the event is fired. - InjectRelation( - relation.meta.endpoint, relation.meta.relation_id - ), + InjectRelation(relation.meta.endpoint, relation.meta.relation_id), ), ) logger.debug(f"decomposed meta {meta_event.name}: {event}") @@ -83,9 +81,9 @@ def generate_builtin_sequences(template_states: Iterable[State]): def check_builtin_sequences( - charm_spec: CharmSpec, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + charm_spec: CharmSpec, + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ): """Test that all the builtin startup and teardown events can fire without errors. @@ -100,10 +98,14 @@ def check_builtin_sequences( """ for event, state in generate_builtin_sequences( - ( - State(leader=True), - State(leader=False), - ) + ( + State(leader=True), + State(leader=False), + ) ): - state.run(event=event, charm_spec=charm_spec, - pre_event=pre_event, post_event=post_event) + state.run( + event=event, + charm_spec=charm_spec, + pre_event=pre_event, + post_event=post_event, + ) diff --git a/scenario/state.py b/scenario/state.py index c6e5e667b..3aa5101f9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -3,7 +3,18 @@ import inspect import typing from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Type, Union, Callable +from typing import ( + Any, + Callable, + Dict, + List, + Literal, + Optional, + Sequence, + Tuple, + Type, + Union, +) from uuid import uuid4 import yaml @@ -323,8 +334,7 @@ def run( pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": - runtime = Runtime(charm_spec, - juju_version=self.juju_version) + runtime = Runtime(charm_spec, juju_version=self.juju_version) return runtime.run( state=self, event=event, @@ -496,6 +506,10 @@ def _derive_args(event_name: str): return tuple(args) -def event(name: str, append_args: Tuple[Any] = (), meta: EventMeta = None, **kwargs) -> Event: +def event( + name: str, append_args: Tuple[Any] = (), meta: EventMeta = None, **kwargs +) -> Event: """This routine will attempt to generate event args for you, based on the event name.""" - return Event(name=name, args=_derive_args(name) + append_args, kwargs=kwargs, meta=meta) + return Event( + name=name, args=_derive_args(name) + append_args, kwargs=kwargs, meta=meta + ) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index c309d50f9..cbbf39e58 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -5,7 +5,15 @@ from ops.framework import Framework from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Scene, State, event, NetworkSpec, network, relation +from scenario.structs import ( + CharmSpec, + NetworkSpec, + Scene, + State, + event, + network, + relation, +) @pytest.fixture(scope="function") @@ -41,21 +49,13 @@ def test_ip_get(mycharm): ) def fetch_unit_address(charm: CharmBase): - rel = charm.model.get_relation('metrics-endpoint') - assert str(charm.model.get_binding(rel).network.bind_address) == '1.1.1.1' + rel = charm.model.get_relation("metrics-endpoint") + assert str(charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" scene = Scene( state=State( - relations=[ - relation(endpoint='metrics-endpoint', interface='foo') - ], - networks=[ - NetworkSpec( - 'metrics-endpoint', - bind_id=0, - network=network() - ) - ] + relations=[relation(endpoint="metrics-endpoint", interface="foo")], + networks=[NetworkSpec("metrics-endpoint", bind_id=0, network=network())], ), event=event("update-status"), ) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index a92d8e2e0..e61b436d2 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -75,13 +75,7 @@ def pre_event(charm: CharmBase): ) scene = start_scene.copy() scene.state.relations = [ - relation( - endpoint="foo", - interface="foo" - ), - relation( - endpoint="qux", - interface="qux" - ), + relation(endpoint="foo", interface="foo"), + relation(endpoint="qux", interface="qux"), ] scenario.play(scene, pre_event=pre_event) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 956175f62..193a2b169 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,14 +6,7 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import ( - CharmSpec, - ContainerSpec, - State, - event, - relation, - sort_patch, -) +from scenario.state import CharmSpec, ContainerSpec, State, event, relation, sort_patch # from tests.setup_tests import setup_tests # @@ -66,8 +59,7 @@ def state(): def test_bare_event(state, mycharm): - out = state.run(event('start'), - charm_spec=CharmSpec(mycharm, meta={"name": "foo"})) + out = state.run(event("start"), charm_spec=CharmSpec(mycharm, meta={"name": "foo"})) out.juju_log = [] # ignore logging output in the delta assert state.jsonpatch_delta(out) == [] @@ -76,9 +68,11 @@ def test_leader_get(state, mycharm): def pre_event(charm): assert charm.unit.is_leader() - state.run(event=event('start'), - charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), - pre_event=pre_event) + state.run( + event=event("start"), + charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), + pre_event=pre_event, + ) def test_status_setting(state, mycharm): @@ -90,8 +84,7 @@ def call(charm: CharmBase, _): mycharm._call = call out = state.run( charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), - event=event('start'), - + event=event("start"), ) assert out.status.unit == ("active", "foo test") assert out.status.app == ("waiting", "foo barz") @@ -130,10 +123,9 @@ def pre_event(charm: CharmBase): }, ) - out = State( - containers=(ContainerSpec(name="foo", can_connect=connect),) - ).run(event=event('start'), pre_event=pre_event, - charm_spec=spec) + out = State(containers=(ContainerSpec(name="foo", can_connect=connect),)).run( + event=event("start"), pre_event=pre_event, charm_spec=spec + ) def test_relation_get(mycharm): @@ -160,21 +152,21 @@ def pre_event(charm: CharmBase): "requires": {"foo": {"interface": "bar"}}, }, ) - state = State(relations=[ - relation( - endpoint="foo", - interface="bar", - local_app_data={"a": "because"}, - remote_app_name="remote", - remote_unit_ids=[0, 1, 2], - remote_app_data={"a": "b"}, - local_unit_data={"c": "d"}, - remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, - )] + state = State( + relations=[ + relation( + endpoint="foo", + interface="bar", + local_app_data={"a": "because"}, + remote_app_name="remote", + remote_unit_ids=[0, 1, 2], + remote_app_data={"a": "b"}, + local_unit_data={"c": "d"}, + remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, + ) + ] ) - state.run(event=event('start'), - pre_event=pre_event, - charm_spec=spec) + state.run(event=event("start"), pre_event=pre_event, charm_spec=spec) def test_relation_set(mycharm): @@ -213,20 +205,21 @@ def pre_event(charm: CharmBase): }, ) - state = State(leader=True, - relations=[ - relation( - endpoint="foo", - interface="bar", - remote_unit_ids=[1, 4], - local_app_data={}, - local_unit_data={}, - ) - ] - ) + state = State( + leader=True, + relations=[ + relation( + endpoint="foo", + interface="bar", + remote_unit_ids=[1, 4], + local_app_data={}, + local_unit_data={}, + ) + ], + ) assert not mycharm.called - out = state.run(event=event('start'), charm_spec=spec, pre_event=pre_event) + out = state.run(event=event("start"), charm_spec=spec, pre_event=pre_event) assert mycharm.called assert asdict(out.relations[0]) == asdict( From 920b4e9aad7896a046906b9cea41deb720ba06db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 14:15:12 +0100 Subject: [PATCH 062/546] readme --- README.md | 259 ++++++++++++++++++-------------------------- scenario/runtime.py | 4 +- 2 files changed, 107 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index b2735f9a8..5120547f1 100644 --- a/README.md +++ b/README.md @@ -23,31 +23,33 @@ Scenario-testing a charm, then, means verifying that: # Core concepts as a metaphor I like metaphors, so here we go: -- There is a theatre stage (Scenario). +- There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. -- You arrange the stage with content that the the actor will have to interact with (a Scene). Setting up the scene consists of selecting: - - An initial situation (Context) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). +- You arrange the stage with content that the actor will have to interact with. This consists of selecting: + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. # Core concepts not as a metaphor -Each scene maps to a single event. -The Scenario encapsulates the charm and its metadata. A scenario can play scenes, which represent the several events one can fire on a charm and the c -Crucially, this decoupling of charm and context aontext in which they occur. -llows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... - -In this spirit, but that I still have to think through how useful it really is, a Scenario exposes a `playbook`: a sequence of scenes it can run sequentially (although given that each Scene's input state is totally disconnected from any other's, the ordering of the sequence is irrelevant) and potentially share with other projects. More on this later. +Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black box. +An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. +Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. +Comparing scenario tests with `Harness` tests: +- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired , then verify the validity of the state. +- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). +- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. +- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... # Writing scenario tests Writing a scenario test consists of two broad steps: -- define a scene - - an event - - an input state -- play the scene (obtain the output state) -- assert that the output state is how you expect it to be +- Arrange + - an event + - an input state +- Act: run the state (obtain the output state) +- Assert: verify that the output state is how you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -55,8 +57,7 @@ available. The charm has no config, no relations, no networks, and no leadership With that, we can write the simplest possible scenario test: ```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, event, Context +from scenario.state import CharmSpec, event, State from ops.charm import CharmBase @@ -65,8 +66,8 @@ class MyCharm(CharmBase): def test_scenario_base(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play(Scene(event=event('start'), context=Context())) + spec = CharmSpec(MyCharm, meta={"name": "foo"}) + out = State().run(event=event('start'), charm_spec=CharmSpec(MyCharm, meta={"name": "foo"})) assert out.status.unit == ('unknown', '') ``` @@ -74,8 +75,8 @@ Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -from scenario.scenario import Scenario, Scene -from scenario.structs import CharmSpec, event, Context, State +import pytest +from scenario.state import CharmSpec, event, State from ops.charm import CharmBase from ops.model import ActiveStatus @@ -87,70 +88,15 @@ class MyCharm(CharmBase): def _on_start(self, _): if self.unit.is_leader(): self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I am ruled') -def test_scenario_base(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play(Scene(event=event('start'), context=Context())) - assert out.status.unit == ('unknown', '') - - -def test_status_leader(): - scenario = Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - out = scenario.play( - Scene( - event=event('start'), - context=Context( - state=State(leader=True) - ))) - assert out.status.unit == ('active', 'I rule') -``` - -This is starting to get messy, but fortunately scenarios are easily turned into fixtures. We can rewrite this more -concisely (and parametrically) as: - -```python -import pytest -from scenario.scenario import Scenario, Scene, State -from scenario.structs import CharmSpec, event -from ops.charm import CharmBase -from ops.model import ActiveStatus - - -class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) - - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I follow') - - -@pytest.fixture -def scenario(): - return Scenario(CharmSpec(MyCharm, meta={"name": "foo"})) - - -@pytest.fixture -def start_scene(): - return Scene(event=event('start'), state=State()) - - -def test_scenario_base(scenario, start_scene): - out = scenario.play(start_scene) - assert out.status.unit == ('unknown', '') - - -@pytest.mark.parametrize('leader', [True, False]) -def test_status_leader(scenario, start_scene, leader): - leader_scene = start_scene.copy() - leader_scene.context.state.leader = leader - - out = scenario.play(leader_scene) - expected_status = ('active', 'I rule') if leader else ('active', 'I follow') - assert out.status.unit == expected_status +@pytest.mark.parametrize('leader', (True, False)) +def test_status_leader(leader): + spec = CharmSpec(MyCharm, meta={"name": "foo"}) + out = State(leader=leader).run(event=event('start'), charm_spec=CharmSpec(MyCharm, meta={"name": "foo"})) + assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... @@ -160,45 +106,47 @@ By defining the right state we can programmatically define what answers will the You can write scenario tests to verify the shape of relation data: ```python -from scenario.structs import relation from ops.charm import CharmBase +from scenario.state import relation, State, event, CharmSpec + # This charm copies over remote app data to local unit data class MyCharm(CharmBase): - ... - - def _on_event(self, e): - relation = self.model.relations['foo'][0] - assert relation.app.name == 'remote' - assert e.relation.data[self.unit]['abc'] == 'foo' - e.relation.data[self.unit]['abc'] = e.relation.data[e.app]['cde'] - - -def test_relation_data(scenario, start_scene): - scene = start_scene.copy() - scene.context.state.relations = [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ] - out = scenario.play(scene) - assert out.relations[0].local_unit_data == {"abc": "baz!"} - # one could probably even do: - assert out.relations == [ - relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] - # which is very idiomatic and superbly explicit. Noice. + ... + + def _on_event(self, e): + rel = e.relation + assert rel.app.name == 'remote' + assert rel.data[self.unit]['abc'] == 'foo' + rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] + + +def test_relation_data(): + out = State(relations=[ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ] + ).run(charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}), event=event('start')) + + assert out.relations[0].local_unit_data == {"abc": "baz!"} + # you can do this to check that there are no other differences: + assert out.relations == [ + relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] + +# which is very idiomatic and superbly explicit. Noice. ``` ## Containers @@ -211,14 +159,11 @@ To give the charm access to some containers, you need to pass them to the input An example of a scene including some containers: ```python -from scenario.structs import Scene, event, container, State -scene = Scene( - event("start"), - state=State(containers=[ - container(name="foo", can_connect=True), - container(name="bar", can_connect=False) - ]), -) +from scenario.state import container, State +state = State(containers=[ + container(name="foo", can_connect=True), + container(name="bar", can_connect=False) +]) ``` In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. @@ -226,18 +171,17 @@ In this case, `self.unit.get_container('foo').can_connect()` would return `True` You can also configure a container to have some files in it: ```python -from scenario.structs import Scene, event, container, State from pathlib import Path +from scenario.state import container, State + local_file = Path('/path/to/local/real/file.txt') -scene = Scene( - event("start"), - state=State(containers=[ - container(name="foo", - can_connect=True, - filesystem={'local': {'share': {'config.yaml': local_file}}}) - ]), +state = State(containers=[ + container(name="foo", + can_connect=True, + filesystem={'local': {'share': {'config.yaml': local_file}}}) +] ) ``` @@ -254,28 +198,32 @@ then `content` would be the contents of our locally-supplied `file.txt`. You can ```python from ops.charm import CharmBase -from scenario.structs import Scene, event, State, container +from scenario.state import event, State, container, CharmSpec + class MyCharm(CharmBase): def _on_start(self, _): foo = self.unit.get_container('foo') foo.push('/local/share/config.yaml', "TEST", make_dirs=True) -def test_pebble_push(scenario, start_scene): - out = scenario.play(Scene( - event=event('start'), - state=State( - containers=[container(name='foo')] - ))) - assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" + +def test_pebble_push(): + out = State( + containers=[container(name='foo')] + ).run( + event=event('start'), + charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}) + ) + assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" ``` -`container.exec` is a little bit more complicated. +`container.exec` is a little bit more complicated. You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. ```python from ops.charm import CharmBase -from scenario.structs import Scene, event, State, container, ExecOutput + +from scenario.state import event, State, container, ExecOutput, CharmSpec LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml @@ -293,19 +241,20 @@ class MyCharm(CharmBase): assert stdout == LS_LL -def test_pebble_exec(scenario, start_scene): - scenario.play(Scene( +def test_pebble_exec(): + out = State( + containers=[container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + )] + ).run( event=event('start'), - state=State( - containers=[container( - name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) - } - )] - ))) + charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}) + ) ``` diff --git a/scenario/runtime.py b/scenario/runtime.py index 3536a5ee5..4cd1c38d5 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -163,7 +163,9 @@ def run( pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": - """Plays a scene on the charm. + """Runs an event with this state as initial state on a charm. + + Returns the 'output state', that is, the state as mutated by the charm during the event handling. This will set the environment up and call ops.main.main(). After that it's up to ops. From 1bc5fcf920e4abcfc7ce4a50cf188917c6fff65a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 14:28:20 +0100 Subject: [PATCH 063/546] readme pebbled --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5120547f1..3aeba9374 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ I like metaphors, so here we go: - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those books on the table? + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those pebble-shaped books on the table? - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. From 36748a88d71495ae83c98d3b4e250487a081172d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 14:29:38 +0100 Subject: [PATCH 064/546] todo --- scenario/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 4cd1c38d5..7766054c7 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -178,7 +178,7 @@ def run( logger.info(" - generating virtual charm root") with self.virtual_charm_root() as temporary_charm_root: # todo consider forking out a real subprocess and do the mocking by - # generating hook tool callables + # generating hook tool executables logger.info(" - redirecting root logging") self._redirect_root_logger() From 67e8428510ea5c41a535e3b6027f03a28fdc8ab7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 14:31:18 +0100 Subject: [PATCH 065/546] readme --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3aeba9374..29cd78e1d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,16 @@ Comparing scenario tests with `Harness` tests: - TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... # Writing scenario tests -Writing a scenario test consists of two broad steps: - -- Arrange - - an event - - an input state -- Act: run the state (obtain the output state) -- Assert: verify that the output state is how you expect it to be +A scenario test consists of three broad steps: + +- Arrange: + - declare the input state + - select an event to fire +- Act: + - run the state (i.e. obtain the output state) +- Assert: + - verify that the output state is how you expect it to be + - verify that the delta with the input state is what you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. From e12d82d830f5f93c311d0c5d0fa223c1d28d098d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 14:56:42 +0100 Subject: [PATCH 066/546] tests green again --- scenario/mocking.py | 10 +- scenario/runtime.py | 8 +- tests/test_e2e/test_builtin_scenes.py | 4 +- tests/test_e2e/test_network.py | 40 ++----- tests/test_e2e/test_observers.py | 12 +- tests/test_e2e/test_pebble.py | 153 +++++++++++-------------- tests/test_e2e/test_play_assertions.py | 58 ++++------ tests/test_e2e/test_relations.py | 42 +++---- tests/test_mocking.py | 96 ---------------- tests/test_runtime.py | 11 +- 10 files changed, 140 insertions(+), 294 deletions(-) delete mode 100644 tests/test_mocking.py diff --git a/scenario/mocking.py b/scenario/mocking.py index 340a3699e..e6f0ec0d9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -206,16 +206,18 @@ def __init__( self._event = event self._charm_spec = charm_spec - container_name = socket_path.split("/")[-2] + @property + def _container(self): + container_name = self.socket_path.split("/")[-2] try: - self._container = next( - filter(lambda x: x.name == container_name, state.containers) + return next( + filter(lambda x: x.name == container_name, self._state.containers) ) except StopIteration: raise RuntimeError( f"container with name={container_name!r} not found. " f"Did you forget a ContainerSpec, or is the socket path " - f"{socket_path!r} wrong?" + f"{self.socket_path!r} wrong?" ) def _request(self, *args, **kwargs): diff --git a/scenario/runtime.py b/scenario/runtime.py index 7766054c7..e27b21cff 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -44,7 +44,6 @@ def __init__( ): self._charm_spec = charm_spec self._juju_version = juju_version - self._charm_type = charm_spec.charm_type # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @@ -170,7 +169,8 @@ def run( This will set the environment up and call ops.main.main(). After that it's up to ops. """ - logger.info(f"Preparing to fire {event.name} on {self._charm_type.__name__}") + charm_type = self._charm_spec.charm_type + logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") # we make a copy to avoid mutating the input state output_state = state.copy() @@ -199,7 +199,9 @@ def run( post_event=post_event, state=output_state, event=event, - charm_spec=self._charm_spec, + charm_spec=self._charm_spec.replace( + charm_type=self._wrap(charm_type) + ), ) except Exception as e: raise RuntimeError( diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index c996df117..2c2933080 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -4,8 +4,8 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework -from scenario.scenario import check_builtin_sequences -from scenario.structs import CharmSpec +from scenario.sequences import check_builtin_sequences +from scenario.state import CharmSpec CHARM_CALLED = 0 diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index cbbf39e58..f8122abeb 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -1,19 +1,8 @@ -from typing import Optional - import pytest from ops.charm import CharmBase from ops.framework import Framework -from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - NetworkSpec, - Scene, - State, - event, - network, - relation, -) +from scenario.state import CharmSpec, NetworkSpec, State, event, network, relation @pytest.fixture(scope="function") @@ -38,29 +27,22 @@ def _on_event(self, event): def test_ip_get(mycharm): mycharm._call = lambda *_: True - scenario = Scenario( + + def fetch_unit_address(charm: CharmBase): + rel = charm.model.get_relation("metrics-endpoint") + assert str(charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" + + State( + relations=[relation(endpoint="metrics-endpoint", interface="foo")], + networks=[NetworkSpec("metrics-endpoint", bind_id=0, network=network())], + ).run( + event("update-status"), CharmSpec( mycharm, meta={ "name": "foo", "requires": {"metrics-endpoint": {"interface": "foo"}}, }, - ) - ) - - def fetch_unit_address(charm: CharmBase): - rel = charm.model.get_relation("metrics-endpoint") - assert str(charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" - - scene = Scene( - state=State( - relations=[relation(endpoint="metrics-endpoint", interface="foo")], - networks=[NetworkSpec("metrics-endpoint", bind_id=0, network=network())], ), - event=event("update-status"), - ) - - scenario.play( - scene, post_event=fetch_unit_address, ) diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 168fd61f1..f0d3cab35 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -4,8 +4,7 @@ from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework -from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Scene, State, event +from scenario.state import CharmSpec, State, event @pytest.fixture(scope="function") @@ -28,11 +27,12 @@ def _on_event(self, event): def test_start_event(charm_evts): charm, evts = charm_evts - scenario = Scenario( - CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) + State().run( + event=event("start"), + charm_spec=CharmSpec( + charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}} + ), ) - scene = Scene(event("start"), state=State()) - scenario.play(scene) assert len(evts) == 1 assert isinstance(evts[0], StartEvent) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 0b53d4b29..da452fab3 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -6,120 +6,93 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - ContainerSpec, - ExecOutput, - Scene, - State, - container, - event, -) +from scenario.state import CharmSpec, ContainerSpec, ExecOutput, State, container, event @pytest.fixture(scope="function") def charm_cls(): class MyCharm(CharmBase): - callback = None - def __init__(self, framework: Framework): super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) def _on_event(self, event): - self.callback(event) + pass return MyCharm def test_no_containers(charm_cls): - scenario = Scenario(CharmSpec(charm_cls, meta={"name": "foo"})) - scene = Scene(event("start"), state=State()) - - def callback(self: CharmBase, evt): + def callback(self: CharmBase): assert not self.unit.containers - charm_cls.callback = callback - scenario.play(scene) + State().run( + charm_spec=CharmSpec(charm_cls, meta={"name": "foo"}), + event=event("start"), + post_event=callback, + ) def test_containers_from_meta(charm_cls): - scenario = Scenario( - CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - ) - scene = Scene(event("start"), state=State()) - - def callback(self: CharmBase, evt): + def callback(self: CharmBase): assert self.unit.containers assert self.unit.get_container("foo") - charm_cls.callback = callback - scenario.play(scene) + State().run( + charm_spec=CharmSpec( + charm_cls, meta={"name": "foo", "containers": {"foo": {}}} + ), + event=event("start"), + post_event=callback, + ) @pytest.mark.parametrize("can_connect", (True, False)) def test_connectivity(charm_cls, can_connect): - scenario = Scenario( - CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - ) - scene = Scene( - event("start"), - state=State(containers=[container(name="foo", can_connect=can_connect)]), - ) - - def callback(self: CharmBase, evt): + def callback(self: CharmBase): assert can_connect == self.unit.get_container("foo").can_connect() - charm_cls.callback = callback - scenario.play(scene) + State(containers=[container(name="foo", can_connect=can_connect)]).run( + charm_spec=CharmSpec( + charm_cls, meta={"name": "foo", "containers": {"foo": {}}} + ), + event=event("start"), + post_event=callback, + ) def test_fs_push(charm_cls): - scenario = Scenario( - CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - ) - text = "lorem ipsum/n alles amat gloriae foo" file = tempfile.NamedTemporaryFile() pth = Path(file.name) pth.write_text(text) - scene = Scene( - event("start"), - state=State( - containers=[ - ContainerSpec( - name="foo", can_connect=True, filesystem={"bar": {"baz.txt": pth}} - ) - ] - ), - ) - - def callback(self: CharmBase, evt): + def callback(self: CharmBase): container = self.unit.get_container("foo") baz = container.pull("/bar/baz.txt") assert baz.read() == text - charm_cls.callback = callback - scenario.play(scene) + State( + containers=[ + container( + name="foo", can_connect=True, filesystem={"bar": {"baz.txt": pth}} + ) + ] + ).run( + charm_spec=CharmSpec( + charm_cls, meta={"name": "foo", "containers": {"foo": {}}} + ), + event=event("start"), + post_event=callback, + ) @pytest.mark.parametrize("make_dirs", (True, False)) def test_fs_pull(charm_cls, make_dirs): - scenario = Scenario( - CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - ) - - scene = Scene( - event("start"), - state=State(containers=[ContainerSpec(name="foo", can_connect=True)]), - ) - text = "lorem ipsum/n alles amat gloriae foo" - def callback(self: CharmBase, evt): + def callback(self: CharmBase): container = self.unit.get_container("foo") if make_dirs: container.push("/bar/baz.txt", text, make_dirs=make_dirs) @@ -135,14 +108,23 @@ def callback(self: CharmBase, evt): container.pull("/bar/baz.txt") charm_cls.callback = callback - out = scenario.play(scene) + + state = State(containers=[container(name="foo", can_connect=True)]) + + out = state.run( + charm_spec=CharmSpec( + charm_cls, meta={"name": "foo", "containers": {"foo": {}}} + ), + event=event("start"), + post_event=callback, + ) if make_dirs: file = out.get_container("foo").filesystem["bar"]["baz.txt"] assert file.read_text() == text else: # nothing has changed - assert not out.jsonpatch_delta(scene.state) + assert not out.jsonpatch_delta(state) LS = """ @@ -174,28 +156,25 @@ def callback(self: CharmBase, evt): ), ) def test_exec(charm_cls, cmd, out): - scenario = Scenario( - CharmSpec(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - ) - - scene = Scene( - event("start"), - state=State( - containers=[ - ContainerSpec( - name="foo", - can_connect=True, - exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, - ) - ] - ), - ) - - def callback(self: CharmBase, evt): + def callback(self: CharmBase): container = self.unit.get_container("foo") proc = container.exec([cmd]) proc.wait() assert proc.stdout.read() == "hello pebble" charm_cls.callback = callback - scenario.play(scene) + State( + containers=[ + container( + name="foo", + can_connect=True, + exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, + ) + ] + ).run( + charm_spec=CharmSpec( + charm_cls, meta={"name": "foo", "containers": {"foo": {}}} + ), + event=event("start"), + post_event=callback, + ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 61d6b1a0a..2c4ffd08c 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -1,12 +1,9 @@ -from typing import Optional - import pytest from ops.charm import CharmBase from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus -from scenario.scenario import Scenario -from scenario.structs import CharmSpec, Scene, State, Status, event, relation +from scenario.state import CharmSpec, State, Status, event, relation @pytest.fixture(scope="function") @@ -30,11 +27,8 @@ def _on_event(self, event): def test_charm_heals_on_start(mycharm): - scenario = Scenario(CharmSpec(mycharm, meta={"name": "foo"})) - def pre_event(charm): pre_event._called = True - assert not charm.is_ready() assert charm.unit.status == BlockedStatus("foo") assert not charm.called @@ -44,8 +38,6 @@ def call(charm, _): def post_event(charm): post_event._called = True - - assert charm.is_ready() assert charm.unit.status == ActiveStatus("yabadoodle") assert charm.called @@ -55,8 +47,11 @@ def post_event(charm): config={"foo": "bar"}, leader=True, status=Status(unit=("blocked", "foo")) ) - out = scenario.play( - Scene(event("update-status"), state=initial_state), + out = initial_state.run( + charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), + event=event("start"), + post_event=post_event, + pre_event=pre_event, ) assert out.status.unit == ("active", "yabadoodle") @@ -73,15 +68,6 @@ def post_event(charm): def test_relation_data_access(mycharm): mycharm._call = lambda *_: True - scenario = Scenario( - CharmSpec( - mycharm, - meta={ - "name": "foo", - "requires": {"relation_test": {"interface": "azdrubales"}}, - }, - ) - ) def check_relation_data(charm): foo_relations = charm.model.relations["relation_test"] @@ -102,22 +88,24 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} - scene = Scene( - state=State( - relations=[ - relation( - endpoint="relation_test", - interface="azdrubales", - remote_app_name="karlos", - remote_app_data={"yaba": "doodle"}, - remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, - ) - ] + State( + relations=[ + relation( + endpoint="relation_test", + interface="azdrubales", + remote_app_name="karlos", + remote_app_data={"yaba": "doodle"}, + remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, + ) + ] + ).run( + charm_spec=CharmSpec( + mycharm, + meta={ + "name": "foo", + "requires": {"relation_test": {"interface": "azdrubales"}}, + }, ), event=event("update-status"), - ) - - scenario.play( - scene, post_event=check_relation_data, ) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index e61b436d2..acf67024a 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -1,21 +1,10 @@ -from dataclasses import asdict -from typing import Optional, Type +from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework -from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.scenario import Scenario -from scenario.structs import ( - CharmSpec, - ContainerSpec, - Scene, - State, - event, - relation, - sort_patch, -) +from scenario.state import CharmSpec, State, event, relation @pytest.fixture(scope="function") @@ -45,19 +34,22 @@ def _on_event(self, event): return MyCharm -@pytest.fixture(scope="function") -def start_scene(): - return Scene(event("start"), state=State(config={"foo": "bar"}, leader=True)) - - -def test_get_relation(start_scene: Scene, mycharm): +def test_get_relation(mycharm): def pre_event(charm: CharmBase): assert charm.model.get_relation("foo") assert charm.model.get_relation("bar") is None assert charm.model.get_relation("qux") assert charm.model.get_relation("zoo") is None - scenario = Scenario( + State( + config={"foo": "bar"}, + leader=True, + relations=[ + relation(endpoint="foo", interface="foo"), + relation(endpoint="qux", interface="qux"), + ], + ).run( + event("start"), CharmSpec( mycharm, meta={ @@ -71,11 +63,5 @@ def pre_event(charm: CharmBase): "zoo": {"interface": "zoo"}, }, }, - ) + ), ) - scene = start_scene.copy() - scene.state.relations = [ - relation(endpoint="foo", interface="foo"), - relation(endpoint="qux", interface="qux"), - ] - scenario.play(scene, pre_event=pre_event) diff --git a/tests/test_mocking.py b/tests/test_mocking.py deleted file mode 100644 index 18918bcfe..000000000 --- a/tests/test_mocking.py +++ /dev/null @@ -1,96 +0,0 @@ -import dataclasses -from typing import Any, Callable, Dict, Tuple - -import pytest - -from scenario.mocking import DecorateSpec, patch_module -from scenario.structs import Event, Scene, State, _DCBase, relation - - -def mock_simulator( - fn: Callable, - namespace: str, - tool_name: str, - scene: "Scene", - charm_spec: "CharmSpec", - call_args: Tuple[Any, ...], - call_kwargs: Dict[str, Any], -): - assert namespace == "MyDemoClass" - - if tool_name == "get_foo": - return scene.state.foo - if tool_name == "set_foo": - scene.state.foo = call_args[1] - return - raise RuntimeError() - - -@dataclasses.dataclass -class MockState(_DCBase): - foo: int - - -@pytest.mark.parametrize("mock_foo", (42, 12, 20)) -def test_patch_generic_module(mock_foo): - state = MockState(foo=mock_foo) - scene = Scene(state=state.copy(), event=Event("foo")) - - from tests.resources import demo_decorate_class - - patch_module( - demo_decorate_class, - { - "MyDemoClass": { - "get_foo": DecorateSpec(simulator=mock_simulator), - "set_foo": DecorateSpec(simulator=mock_simulator), - } - }, - scene=scene, - ) - - from tests.resources.demo_decorate_class import MyDemoClass - - assert MyDemoClass._foo == 0 - assert MyDemoClass().get_foo() == mock_foo - - MyDemoClass().set_foo(12) - assert MyDemoClass._foo == 0 # set_foo didn't "really" get called - assert MyDemoClass().get_foo() == 12 # get_foo now returns the updated value - - assert state.foo == mock_foo # initial state has original value - - -def test_patch_ops(): - state = State( - relations=[ - relation( - endpoint="dead", - interface="beef", - local_app_data={"foo": "bar"}, - local_unit_data={"foo": "wee"}, - remote_units_data={0: {"baz": "qux"}}, - ) - ] - ) - scene = Scene(state=state.copy(), event=Event("foo")) - - from ops import model - - patch_module( - model, - { - "_ModelBackend": { - "relation_ids": DecorateSpec(), - "relation_get": DecorateSpec(), - "relation_set": DecorateSpec(), - } - }, - scene=scene, - ) - - mb = model._ModelBackend("foo", "bar", "baz") - assert mb.relation_ids("dead") == [0] - assert mb.relation_get(0, "local/0", False) == {"foo": "wee"} - assert mb.relation_get(0, "local", True) == {"foo": "bar"} - assert mb.relation_get(0, "remote/0", False) == {"baz": "qux"} diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 4afccc69d..2ef0abb59 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -7,7 +7,7 @@ from ops.framework import EventBase from scenario.runtime import Runtime -from scenario.structs import CharmSpec, Scene, event +from scenario.state import CharmSpec, State, event def charm_type(): @@ -48,8 +48,11 @@ def test_event_hooks(): pre_event = MagicMock(return_value=None) post_event = MagicMock(return_value=None) - runtime.play( - Scene(event=event("foo")), pre_event=pre_event, post_event=post_event + runtime.run( + state=State(), + event=event("foo"), + pre_event=pre_event, + post_event=post_event, ) assert pre_event.called @@ -77,7 +80,7 @@ class MyEvt(EventBase): ), ) - runtime.play(Scene(event=event("bar"))) + runtime.run(state=State(), event=event("bar")) assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) From 6c98a43dab7f00acd210e8567521d088df9190c6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 1 Feb 2023 17:18:34 +0100 Subject: [PATCH 067/546] simplified main trigger api --- LICENSE.txt | 202 +++++++++++++++++++ README.md | 28 +-- pyproject.toml | 43 ++++ scenario/__init__.py | 2 + scenario/mocking.py | 38 ++-- scenario/ops_main_mock.py | 4 +- scenario/runtime.py | 50 ++++- scenario/sequences.py | 15 +- scenario/state.py | 260 ++++++++++--------------- setup.py | 55 ------ tests/test_e2e/test_builtin_scenes.py | 5 +- tests/test_e2e/test_network.py | 31 +-- tests/test_e2e/test_observers.py | 18 +- tests/test_e2e/test_pebble.py | 60 +++--- tests/test_e2e/test_play_assertions.py | 28 +-- tests/test_e2e/test_relations.py | 34 ++-- tests/test_e2e/test_state.py | 84 ++++---- tests/test_runtime.py | 12 +- 18 files changed, 572 insertions(+), 397 deletions(-) create mode 100644 LICENSE.txt create mode 100644 pyproject.toml create mode 100644 scenario/__init__.py delete mode 100644 setup.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 29cd78e1d..91c4be0ad 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ available. The charm has no config, no relations, no networks, and no leadership With that, we can write the simplest possible scenario test: ```python -from scenario.state import CharmSpec, event, State +from scenario.state import _CharmSpec, event, State from ops.charm import CharmBase @@ -69,8 +69,8 @@ class MyCharm(CharmBase): def test_scenario_base(): - spec = CharmSpec(MyCharm, meta={"name": "foo"}) - out = State().run(event=event('start'), charm_spec=CharmSpec(MyCharm, meta={"name": "foo"})) + spec = _CharmSpec(MyCharm, meta={"name": "foo"}) + out = State().trigger(event=event('start'), charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"})) assert out.status.unit == ('unknown', '') ``` @@ -79,7 +79,7 @@ Our charm sets a special state if it has leadership on 'start': ```python import pytest -from scenario.state import CharmSpec, event, State +from scenario.state import _CharmSpec, event, State from ops.charm import CharmBase from ops.model import ActiveStatus @@ -97,8 +97,8 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - spec = CharmSpec(MyCharm, meta={"name": "foo"}) - out = State(leader=leader).run(event=event('start'), charm_spec=CharmSpec(MyCharm, meta={"name": "foo"})) + spec = _CharmSpec(MyCharm, meta={"name": "foo"}) + out = State(leader=leader).trigger(event=event('start'), charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"})) assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') ``` @@ -111,7 +111,7 @@ You can write scenario tests to verify the shape of relation data: ```python from ops.charm import CharmBase -from scenario.state import relation, State, event, CharmSpec +from scenario.state import relation, State, event, _CharmSpec # This charm copies over remote app data to local unit data @@ -135,7 +135,7 @@ def test_relation_data(): remote_app_data={"cde": "baz!"}, ), ] - ).run(charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}), event=event('start')) + ).trigger(charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}), event=event('start')) assert out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: @@ -201,7 +201,7 @@ then `content` would be the contents of our locally-supplied `file.txt`. You can ```python from ops.charm import CharmBase -from scenario.state import event, State, container, CharmSpec +from scenario.state import event, State, container, _CharmSpec class MyCharm(CharmBase): @@ -213,9 +213,9 @@ class MyCharm(CharmBase): def test_pebble_push(): out = State( containers=[container(name='foo')] - ).run( + ).trigger( event=event('start'), - charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}) + charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}) ) assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" ``` @@ -226,7 +226,7 @@ You need to specify, for each possible command the charm might run on the contai ```python from ops.charm import CharmBase -from scenario.state import event, State, container, ExecOutput, CharmSpec +from scenario.state import event, State, container, ExecOutput, _CharmSpec LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml @@ -254,9 +254,9 @@ def test_pebble_exec(): stdout=LS_LL) } )] - ).run( + ).trigger( event=event('start'), - charm_spec=CharmSpec(MyCharm, meta={"name": "foo"}) + charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}) ) ``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..d4585d4ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "ops-scenario" +version = "2.0.0" +authors = [ + { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } +] +description = "Python library providing a Scenario-based testing API for Operator Framework charms." +license.text = "Apache-2.0" +keywords = ["juju", "test"] + +dependencies = ["ops>=2.0"] +readme = "README.md" +requires-python = ">=3.8" + +classifiers = [ + "Development Status :: 3 - Alpha", + "Topic :: Utilities", + "License :: OSI Approved :: Apache Software License", +] + + +[project.urls] +"Homepage" = "https://github.com/PietroPasotti/ops-scenario" +"Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" + + +[tool.setuptools.package-dir] +scenario = "scenario" + + +[tool.black] +include = '\.pyi?$' + + +[tool.isort] +profile = "black" + +[bdist_wheel] +universal=1 diff --git a/scenario/__init__.py b/scenario/__init__.py new file mode 100644 index 000000000..753885e0d --- /dev/null +++ b/scenario/__init__.py @@ -0,0 +1,2 @@ +from scenario.runtime import trigger +from scenario.state import * diff --git a/scenario/mocking.py b/scenario/mocking.py index e6f0ec0d9..13e1c8ec4 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -10,7 +10,7 @@ from scenario.logger import logger as scenario_logger if TYPE_CHECKING: - from scenario.state import CharmSpec, Event, ExecOutput, State + from scenario.state import Event, ExecOutput, State, _CharmSpec logger = scenario_logger.getChild("mocking") @@ -42,7 +42,7 @@ def send_signal(self, sig: Union[int, str]): class _MockModelBackend(_ModelBackend): - def __init__(self, state: "State", event: "Event", charm_spec: "CharmSpec"): + def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec"): super().__init__(state.unit_name, state.model.name, state.model.uuid) self._state = state self._event = event @@ -56,10 +56,16 @@ def get_pebble(self, socket_path: str) -> "Client": charm_spec=self._charm_spec, ) + def _get_relation_by_id(self, rel_id): + try: + return next( + filter(lambda r: r.relation_id == rel_id, self._state.relations) + ) + except StopIteration as e: + raise RuntimeError(f"Not found: relation with id={rel_id}.") from e + def relation_get(self, rel_id, obj_name, app): - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) - ) + relation = self._get_relation_by_id(rel_id) if app and obj_name == self._state.app_name: return relation.local_app_data elif app: @@ -81,18 +87,14 @@ def status_get(self, *args, **kwargs): def relation_ids(self, endpoint, *args, **kwargs): return [ - rel.meta.relation_id - for rel in self._state.relations - if rel.meta.endpoint == endpoint + rel.relation_id for rel in self._state.relations if rel.endpoint == endpoint ] def relation_list(self, rel_id, *args, **kwargs): - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) - ) + relation = self._get_relation_by_id(rel_id) return tuple( - f"{relation.meta.remote_app_name}/{unit_id}" - for unit_id in relation.meta.remote_unit_ids + f"{relation.remote_app_name}/{unit_id}" + for unit_id in relation.remote_unit_ids ) def config_get(self, *args, **kwargs): @@ -113,7 +115,7 @@ def network_get(self, *args, **kwargs): name, relation_id = args network = next(filter(lambda r: r.name == name, self._state.networks)) - return network.network.hook_tool_output_fmt() + return network.hook_tool_output_fmt() def action_get(self, *args, **kwargs): raise NotImplementedError("action_get") @@ -151,9 +153,7 @@ def juju_log(self, *args, **kwargs): def relation_set(self, *args, **kwargs): rel_id, key, value, app = args - relation = next( - filter(lambda r: r.meta.relation_id == rel_id, self._state.relations) - ) + relation = self._get_relation_by_id(rel_id) if app: if not self._state.leader: raise RuntimeError("needs leadership to set app data") @@ -199,7 +199,7 @@ def __init__( *, state: "State", event: "Event", - charm_spec: "CharmSpec", + charm_spec: "_CharmSpec", ): super().__init__(socket_path, opener, base_url, timeout) self._state = state @@ -216,7 +216,7 @@ def _container(self): except StopIteration: raise RuntimeError( f"container with name={container_name!r} not found. " - f"Did you forget a ContainerSpec, or is the socket path " + f"Did you forget a Container, or is the socket path " f"{self.socket_path!r} wrong?" ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 5cf4734da..6859ea529 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.state import CharmSpec, Event, State + from scenario.state import Event, State, _CharmSpec logger = scenario_logger.getChild("ops_main_mock") @@ -29,7 +29,7 @@ def main( post_event: Optional[Callable[["CharmType"], None]] = None, state: "State" = None, event: "Event" = None, - charm_spec: "CharmSpec" = None, + charm_spec: "_CharmSpec" = None, ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type diff --git a/scenario/runtime.py b/scenario/runtime.py index e27b21cff..021b4ee95 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -4,7 +4,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union import yaml @@ -15,7 +15,7 @@ from ops.framework import EventBase from ops.testing import CharmType - from scenario.state import CharmSpec, Event, State + from scenario.state import Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -39,7 +39,7 @@ class Runtime: def __init__( self, - charm_spec: "CharmSpec", + charm_spec: "_CharmSpec", juju_version: str = "3.0.0", ): self._charm_spec = charm_spec @@ -68,7 +68,7 @@ def from_local_file( ) from e my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] - return Runtime(CharmSpec(my_charm_type)) # TODO add meta, options,... + return Runtime(_CharmSpec(my_charm_type)) # TODO add meta, options,... @staticmethod def _redirect_root_logger(): @@ -114,8 +114,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): # todo consider setting pwd, (python)path } - if event.meta and event.meta.relation: - relation = event.meta.relation + if relation := event.relation: env.update( { "JUJU_RELATION": relation.endpoint, @@ -155,7 +154,7 @@ def virtual_charm_root(self): (temppath / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) yield temppath - def run( + def exec( self, state: "State", event: "Event", @@ -215,3 +214,40 @@ def run( logger.info("event fired; done.") return output_state + + +def trigger( + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, +) -> "State": + + from scenario.state import Event, _CharmSpec + + if isinstance(event, str): + event = Event(event) + + if not any((meta, actions, config)): + logger.debug("Autoloading charmspec...") + spec = _CharmSpec.autoload(charm_type) + else: + if not meta: + meta = {"name": str(charm_type.__name__)} + spec = _CharmSpec( + charm_type=charm_type, meta=meta, actions=actions, config=config + ) + + runtime = Runtime(charm_spec=spec, juju_version=state.juju_version) + + return runtime.exec( + state=state, + event=event, + pre_event=pre_event, + post_event=post_event, + ) diff --git a/scenario/sequences.py b/scenario/sequences.py index 46a33934c..2752b9324 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -1,6 +1,6 @@ import typing from itertools import chain -from typing import Callable, Iterable, Optional, TextIO, Union +from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union from scenario.logger import logger as scenario_logger from scenario.state import ( @@ -9,7 +9,6 @@ CREATE_ALL_RELATIONS, DETACH_ALL_STORAGES, META_EVENTS, - CharmSpec, Event, InjectRelation, State, @@ -81,7 +80,10 @@ def generate_builtin_sequences(template_states: Iterable[State]): def check_builtin_sequences( - charm_spec: CharmSpec, + charm_type: Type["CharmType"], + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ): @@ -103,9 +105,12 @@ def check_builtin_sequences( State(leader=False), ) ): - state.run( + state.trigger( event=event, - charm_spec=charm_spec, + charm_type=charm_type, + meta=meta, + actions=actions, + config=config, pre_event=pre_event, post_event=post_event, ) diff --git a/scenario/state.py b/scenario/state.py index 3aa5101f9..5cc33f46c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -21,7 +21,7 @@ from ops import testing from scenario.logger import logger as scenario_logger -from scenario.runtime import Runtime +from scenario.runtime import Runtime, trigger if typing.TYPE_CHECKING: try: @@ -54,13 +54,14 @@ def copy(self) -> "Self": return copy.deepcopy(self) +_RELATION_IDS_CTR = 0 + + @dataclasses.dataclass -class RelationMeta(_DCBase): +class Relation(_DCBase): endpoint: str - interface: str - relation_id: int remote_app_name: str - remote_unit_ids: List[int] = dataclasses.field(default_factory=lambda: list((0,))) + remote_unit_ids: List[int] = dataclasses.field(default_factory=list) # local limit limit: int = 1 @@ -70,10 +71,12 @@ class RelationMeta(_DCBase): scale: int = 1 leader_id: int = 0 + # we can derive this from the charm's metadata + interface: str = None + + # Every new Relation instance gets a new one, if there's trouble, override. + relation_id: int = -1 -@dataclasses.dataclass -class RelationSpec(_DCBase): - meta: "RelationMeta" local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -81,40 +84,49 @@ class RelationSpec(_DCBase): default_factory=dict ) + def __post_init__(self): + global _RELATION_IDS_CTR + if self.relation_id == -1: + _RELATION_IDS_CTR += 1 + self.relation_id = _RELATION_IDS_CTR + + if self.remote_unit_ids and self.remote_units_data: + if not set(self.remote_unit_ids) == set(self.remote_units_data): + raise ValueError( + f"{self.remote_unit_ids} should include any and all IDs from {self.remote_units_data}" + ) + elif self.remote_unit_ids: + self.remote_units_data = {x: {} for x in self.remote_unit_ids} + elif self.remote_units_data: + self.remote_unit_ids = [x for x in self.remote_units_data] + else: + self.remote_unit_ids = [0] + self.remote_units_data = {0: {}} + @property def changed_event(self): """Sugar to generate a -changed event.""" - return Event( - name=self.meta.endpoint + "-changed", meta=EventMeta(relation=self.meta) - ) + return Event(name=self.endpoint + "-changed", relation_meta=self) @property def joined_event(self): """Sugar to generate a -joined event.""" - return Event( - name=self.meta.endpoint + "-joined", meta=EventMeta(relation=self.meta) - ) + return Event(name=self.endpoint + "-joined", relation_meta=self) @property def created_event(self): """Sugar to generate a -created event.""" - return Event( - name=self.meta.endpoint + "-created", meta=EventMeta(relation=self.meta) - ) + return Event(name=self.endpoint + "-created", relation_meta=self) @property def departed_event(self): """Sugar to generate a -departed event.""" - return Event( - name=self.meta.endpoint + "-departed", meta=EventMeta(relation=self.meta) - ) + return Event(name=self.endpoint + "-departed", relation_meta=self) @property def removed_event(self): """Sugar to generate a -removed event.""" - return Event( - name=self.meta.endpoint + "-removed", meta=EventMeta(relation=self.meta) - ) + return Event(name=self.endpoint + "-removed", relation_meta=self) def _random_model_name(): @@ -162,7 +174,7 @@ def _run(self) -> int: @dataclasses.dataclass -class ContainerSpec(_DCBase): +class Container(_DCBase): name: str can_connect: bool = False layers: Tuple["LayerDict"] = () @@ -192,23 +204,6 @@ class ContainerSpec(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) -def container( - name: str, - can_connect: bool = False, - layers: Tuple["LayerDict"] = (), - filesystem: _SimpleFS = None, - exec_mock: _ExecMock = None, -) -> ContainerSpec: - """Helper function to instantiate a ContainerSpec.""" - return ContainerSpec( - name=name, - can_connect=can_connect, - layers=layers, - filesystem=filesystem or {}, - exec_mock=exec_mock or {}, - ) - - @dataclasses.dataclass class Address(_DCBase): hostname: str @@ -235,11 +230,16 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass class Network(_DCBase): + name: str + bind_id: int + bind_addresses: List[BindAddress] bind_address: str egress_subnets: List[str] ingress_addresses: List[str] + is_default: bool = False + def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { @@ -249,13 +249,37 @@ def hook_tool_output_fmt(self): "ingress-addresses": self.ingress_addresses, } - -@dataclasses.dataclass -class NetworkSpec(_DCBase): - name: str - bind_id: int - network: Network - is_default: bool = False + @classmethod + def default( + cls, + name, + bind_id, + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), + ) -> "Network": + """Helper to create a minimal, heavily defaulted Network.""" + return cls( + name=name, + bind_id=bind_id, + bind_addresses=[ + BindAddress( + mac_address=mac_address, + interface_name=interface_name, + interfacename=interface_name, + addresses=[ + Address(hostname=hostname, value=private_address, cidr=cidr) + ], + ) + ], + bind_address=private_address, + egress_subnets=list(egress_subnets), + ingress_addresses=list(ingress_addresses), + ) @dataclasses.dataclass @@ -268,9 +292,9 @@ class Status(_DCBase): @dataclasses.dataclass class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = None - relations: Sequence[RelationSpec] = dataclasses.field(default_factory=list) - networks: Sequence[NetworkSpec] = dataclasses.field(default_factory=list) - containers: Sequence[ContainerSpec] = dataclasses.field(default_factory=list) + relations: Sequence[Relation] = dataclasses.field(default_factory=list) + networks: Sequence[Network] = dataclasses.field(default_factory=list) + containers: Sequence[Container] = dataclasses.field(default_factory=list) status: Status = dataclasses.field(default_factory=Status) leader: bool = False model: Model = Model() @@ -290,7 +314,7 @@ def unit_name(self): return self.app_name + "/" + self.unit_id def with_can_connect(self, container_name: str, can_connect: bool): - def replacer(container: ContainerSpec): + def replacer(container: Container): if container.name == container_name: return container.replace(can_connect=can_connect) return container @@ -306,12 +330,13 @@ def with_unit_status(self, status: str, message: str): status=dataclasses.replace(self.status, unit=(status, message)) ) - def get_container(self, name) -> ContainerSpec: + def get_container(self, name) -> Container: try: return next(filter(lambda c: c.name == name, self.containers)) except StopIteration as e: raise ValueError(f"container: {name}") from e + # FIXME: not a great way to obtain a delta, but is "complete" todo figure out a better way. def jsonpatch_delta(self, other: "State"): try: import jsonpatch @@ -327,24 +352,33 @@ def jsonpatch_delta(self, other: "State"): ).patch return sort_patch(patch) - def run( + def trigger( self, - event: "Event", - charm_spec: "CharmSpec", + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> "State": - runtime = Runtime(charm_spec, juju_version=self.juju_version) - return runtime.run( + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + ): + """Fluent API for trigger.""" + return trigger( state=self, event=event, + charm_type=charm_type, pre_event=pre_event, post_event=post_event, + meta=meta, + actions=actions, + config=config, ) @dataclasses.dataclass -class CharmSpec(_DCBase): +class _CharmSpec(_DCBase): """Charm spec.""" charm_type: Type["CharmType"] @@ -353,7 +387,7 @@ class CharmSpec(_DCBase): config: Optional[Dict[str, Any]] = None @staticmethod - def from_charm(charm_type: Type["CharmType"]): + def autoload(charm_type: Type["CharmType"]): charm_source_path = Path(inspect.getfile(charm_type)) charm_root = charm_source_path.parent.parent @@ -370,7 +404,7 @@ def from_charm(charm_type: Type["CharmType"]): if actions_path.exists(): actions = yaml.safe_load(actions_path.open()) - return CharmSpec( + return _CharmSpec( charm_type=charm_type, meta=meta, actions=actions, config=config ) @@ -379,27 +413,19 @@ def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): return sorted(patch, key=key) -@dataclasses.dataclass -class EventMeta(_DCBase): - # if this is a relation event, the metadata of the relation - relation: Optional[RelationMeta] = None - # todo add other meta for - # - secret events - # - pebble? - # - action? - - @dataclasses.dataclass class Event(_DCBase): name: str args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - meta: EventMeta = None - @property - def is_meta(self): - """Is this a meta event?""" - return self.name in META_EVENTS + # if this is a relation event, the metadata of the relation + relation: Optional[Relation] = None + + # todo add other meta for + # - secret events + # - pebble? + # - action? @dataclasses.dataclass @@ -417,77 +443,6 @@ class InjectRelation(Inject): relation_id: Optional[int] = None -def relation( - endpoint: str, - interface: str, - remote_app_name: str = "remote", - relation_id: int = 0, - remote_unit_ids: List[ - int - ] = None, # defaults to (0,) if remote_units_data is not provided - # mapping from unit ID to databag contents - local_unit_data: Dict[str, str] = None, - local_app_data: Dict[str, str] = None, - remote_app_data: Dict[str, str] = None, - remote_units_data: Dict[int, Dict[str, str]] = None, -): - """Helper function to construct a RelationMeta object with some sensible defaults.""" - if remote_unit_ids and remote_units_data: - if not set(remote_unit_ids) == set(remote_units_data): - raise ValueError( - f"{remote_unit_ids} should include any and all IDs from {remote_units_data}" - ) - elif remote_unit_ids: - remote_units_data = {x: {} for x in remote_unit_ids} - elif remote_units_data: - remote_unit_ids = [x for x in remote_units_data] - else: - remote_unit_ids = [0] - remote_units_data = {0: {}} - - metadata = RelationMeta( - endpoint=endpoint, - interface=interface, - remote_app_name=remote_app_name, - remote_unit_ids=remote_unit_ids, - relation_id=relation_id, - ) - return RelationSpec( - meta=metadata, - local_unit_data=local_unit_data or {}, - local_app_data=local_app_data or {}, - remote_app_data=remote_app_data or {}, - remote_units_data=remote_units_data, - ) - - -def network( - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), -) -> Network: - """Construct a network object.""" - return Network( - bind_addresses=[ - BindAddress( - mac_address=mac_address, - interface_name=interface_name, - interfacename=interface_name, - addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr) - ], - ) - ], - bind_address=private_address, - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) - - def _derive_args(event_name: str): args = [] terms = { @@ -504,12 +459,3 @@ def _derive_args(event_name: str): args.append(InjectRelation(relation_name=event_name[: -len(term)])) return tuple(args) - - -def event( - name: str, append_args: Tuple[Any] = (), meta: EventMeta = None, **kwargs -) -> Event: - """This routine will attempt to generate event args for you, based on the event name.""" - return Event( - name=name, args=_derive_args(name) + append_args, kwargs=kwargs, meta=meta - ) diff --git a/setup.py b/setup.py deleted file mode 100644 index ef9bb255f..000000000 --- a/setup.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2019-2020 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Setup script for Ops-Scenario.""" - -from importlib.util import spec_from_file_location, module_from_spec -from pathlib import Path -from setuptools import setup, find_packages - - -def _read_me() -> str: - """Return the README content from the file.""" - with open("README.md", "rt", encoding="utf8") as fh: - readme = fh.read() - return readme - - -version = "1.0.0" - -setup( - name="scenario", - version=version, - description="Python library providing a Scenario-based " - "testing API for Operator Framework charms.", - long_description=_read_me(), - long_description_content_type="text/markdown", - license="Apache-2.0", - url="https://github.com/PietroPasotti/ops-scenario", - author="Pietro Pasotti.", - author_email="pietro.pasotti@canonical.com", - packages=[ - 'scenario', - ], - classifiers=[ - "Programming Language :: Python :: 3", - "Intended Audience :: Developers", - "Operating System :: MacOS :: MacOS X", - "Operating System :: POSIX :: Linux", - ], - python_requires='>=3.8', - install_requires=["asttokens", - "astunparse", - "ops"], -) diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index 2c2933080..b65580d7c 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, Framework from scenario.sequences import check_builtin_sequences -from scenario.state import CharmSpec +from scenario.state import _CharmSpec CHARM_CALLED = 0 @@ -36,6 +36,5 @@ def _on_event(self, event): def test_builtin_scenes(mycharm): - charm_spec = CharmSpec(mycharm, meta={"name": "foo"}) - check_builtin_sequences(charm_spec) + check_builtin_sequences(mycharm, meta={"name": "foo"}) assert CHARM_CALLED == 12 diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index f8122abeb..957fe1cc4 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -2,7 +2,8 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario.state import CharmSpec, NetworkSpec, State, event, network, relation +from scenario import trigger +from scenario.state import Event, Network, Relation, State, _CharmSpec @pytest.fixture(scope="function") @@ -32,17 +33,23 @@ def fetch_unit_address(charm: CharmBase): rel = charm.model.get_relation("metrics-endpoint") assert str(charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" - State( - relations=[relation(endpoint="metrics-endpoint", interface="foo")], - networks=[NetworkSpec("metrics-endpoint", bind_id=0, network=network())], - ).run( - event("update-status"), - CharmSpec( - mycharm, - meta={ - "name": "foo", - "requires": {"metrics-endpoint": {"interface": "foo"}}, - }, + trigger( + State( + relations=[ + Relation( + interface="foo", + remote_app_name="remote", + endpoint="metrics-endpoint", + relation_id=1, + ) + ], + networks=[Network.default("metrics-endpoint", bind_id=0)], ), + "update-status", + mycharm, + meta={ + "name": "foo", + "requires": {"metrics-endpoint": {"interface": "foo"}}, + }, post_event=fetch_unit_address, ) diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index f0d3cab35..1005145a1 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -1,10 +1,8 @@ -from typing import Optional - import pytest from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework -from scenario.state import CharmSpec, State, event +from scenario.state import Event, State, _CharmSpec @pytest.fixture(scope="function") @@ -27,11 +25,11 @@ def _on_event(self, event): def test_start_event(charm_evts): charm, evts = charm_evts - State().run( - event=event("start"), - charm_spec=CharmSpec( - charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}} - ), + State().trigger( + event="start", + charm_type=charm, + meta={"name": "foo"}, + actions={"show_proxied_endpoints": {}}, ) assert len(evts) == 1 assert isinstance(evts[0], StartEvent) @@ -42,9 +40,9 @@ def test_action_event(charm_evts): charm, evts = charm_evts scenario = Scenario( - CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) + _CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) ) - scene = Scene(event("show_proxied_endpoints_action"), state=State()) + scene = Scene(Event("show_proxied_endpoints_action"), state=State()) scenario.play(scene) assert len(evts) == 1 assert isinstance(evts[0], ActionEvent) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index da452fab3..d68c0a77a 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -6,7 +6,7 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario.state import CharmSpec, ContainerSpec, ExecOutput, State, container, event +from scenario.state import Container, Event, ExecOutput, State, _CharmSpec @pytest.fixture(scope="function") @@ -27,9 +27,10 @@ def test_no_containers(charm_cls): def callback(self: CharmBase): assert not self.unit.containers - State().run( - charm_spec=CharmSpec(charm_cls, meta={"name": "foo"}), - event=event("start"), + State().trigger( + charm_type=charm_cls, + meta={"name": "foo"}, + event="start", post_event=callback, ) @@ -39,11 +40,10 @@ def callback(self: CharmBase): assert self.unit.containers assert self.unit.get_container("foo") - State().run( - charm_spec=CharmSpec( - charm_cls, meta={"name": "foo", "containers": {"foo": {}}} - ), - event=event("start"), + State().trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event="start", post_event=callback, ) @@ -53,11 +53,10 @@ def test_connectivity(charm_cls, can_connect): def callback(self: CharmBase): assert can_connect == self.unit.get_container("foo").can_connect() - State(containers=[container(name="foo", can_connect=can_connect)]).run( - charm_spec=CharmSpec( - charm_cls, meta={"name": "foo", "containers": {"foo": {}}} - ), - event=event("start"), + State(containers=[Container(name="foo", can_connect=can_connect)]).trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event="start", post_event=callback, ) @@ -75,15 +74,14 @@ def callback(self: CharmBase): State( containers=[ - container( + Container( name="foo", can_connect=True, filesystem={"bar": {"baz.txt": pth}} ) ] - ).run( - charm_spec=CharmSpec( - charm_cls, meta={"name": "foo", "containers": {"foo": {}}} - ), - event=event("start"), + ).trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event="start", post_event=callback, ) @@ -109,13 +107,12 @@ def callback(self: CharmBase): charm_cls.callback = callback - state = State(containers=[container(name="foo", can_connect=True)]) + state = State(containers=[Container(name="foo", can_connect=True)]) - out = state.run( - charm_spec=CharmSpec( - charm_cls, meta={"name": "foo", "containers": {"foo": {}}} - ), - event=event("start"), + out = state.trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event="start", post_event=callback, ) @@ -165,16 +162,15 @@ def callback(self: CharmBase): charm_cls.callback = callback State( containers=[ - container( + Container( name="foo", can_connect=True, exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, ) ] - ).run( - charm_spec=CharmSpec( - charm_cls, meta={"name": "foo", "containers": {"foo": {}}} - ), - event=event("start"), + ).trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event="start", post_event=callback, ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 2c4ffd08c..780c19b4d 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -3,7 +3,7 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus -from scenario.state import CharmSpec, State, Status, event, relation +from scenario.state import Event, Relation, State, Status, _CharmSpec @pytest.fixture(scope="function") @@ -47,9 +47,10 @@ def post_event(charm): config={"foo": "bar"}, leader=True, status=Status(unit=("blocked", "foo")) ) - out = initial_state.run( - charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), - event=event("start"), + out = initial_state.trigger( + charm_type=mycharm, + meta={"name": "foo"}, + event="start", post_event=post_event, pre_event=pre_event, ) @@ -90,22 +91,21 @@ def check_relation_data(charm): State( relations=[ - relation( + Relation( endpoint="relation_test", interface="azdrubales", + relation_id=1, remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, ) ] - ).run( - charm_spec=CharmSpec( - mycharm, - meta={ - "name": "foo", - "requires": {"relation_test": {"interface": "azdrubales"}}, - }, - ), - event=event("update-status"), + ).trigger( + charm_type=mycharm, + meta={ + "name": "foo", + "requires": {"relation_test": {"interface": "azdrubales"}}, + }, + event="update-status", post_event=check_relation_data, ) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index acf67024a..8de4e2ef9 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -4,7 +4,7 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework -from scenario.state import CharmSpec, State, event, relation +from scenario.state import Event, Relation, State, _CharmSpec @pytest.fixture(scope="function") @@ -45,23 +45,21 @@ def pre_event(charm: CharmBase): config={"foo": "bar"}, leader=True, relations=[ - relation(endpoint="foo", interface="foo"), - relation(endpoint="qux", interface="qux"), + Relation(endpoint="foo", interface="foo", remote_app_name="remote"), + Relation(endpoint="qux", interface="qux", remote_app_name="remote"), ], - ).run( - event("start"), - CharmSpec( - mycharm, - meta={ - "name": "local", - "requires": { - "foo": {"interface": "foo"}, - "bar": {"interface": "bar"}, - }, - "provides": { - "qux": {"interface": "qux"}, - "zoo": {"interface": "zoo"}, - }, + ).trigger( + "start", + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + "bar": {"interface": "bar"}, }, - ), + "provides": { + "qux": {"interface": "qux"}, + "zoo": {"interface": "zoo"}, + }, + }, ) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 193a2b169..5d6a714b2 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,7 +6,7 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import CharmSpec, ContainerSpec, State, event, relation, sort_patch +from scenario.state import Container, Relation, State, sort_patch # from tests.setup_tests import setup_tests # @@ -59,7 +59,7 @@ def state(): def test_bare_event(state, mycharm): - out = state.run(event("start"), charm_spec=CharmSpec(mycharm, meta={"name": "foo"})) + out = state.trigger("start", mycharm, meta={"name": "foo"}) out.juju_log = [] # ignore logging output in the delta assert state.jsonpatch_delta(out) == [] @@ -68,9 +68,10 @@ def test_leader_get(state, mycharm): def pre_event(charm): assert charm.unit.is_leader() - state.run( - event=event("start"), - charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), + state.trigger( + "start", + mycharm, + meta={"name": "foo"}, pre_event=pre_event, ) @@ -82,9 +83,10 @@ def call(charm: CharmBase, _): charm.app.status = WaitingStatus("foo barz") mycharm._call = call - out = state.run( - charm_spec=CharmSpec(mycharm, meta={"name": "foo"}), - event=event("start"), + out = state.trigger( + "start", + mycharm, + meta={"name": "foo"}, ) assert out.status.unit == ("active", "foo test") assert out.status.app == ("waiting", "foo barz") @@ -115,16 +117,14 @@ def pre_event(charm: CharmBase): assert container.name == "foo" assert container.can_connect() is connect - spec = CharmSpec( + State(containers=(Container(name="foo", can_connect=connect),)).trigger( + "start", mycharm, meta={ "name": "foo", "containers": {"foo": {"resource": "bar"}}, }, - ) - - out = State(containers=(ContainerSpec(name="foo", can_connect=connect),)).run( - event=event("start"), pre_event=pre_event, charm_spec=spec + pre_event=pre_event, ) @@ -145,16 +145,9 @@ def pre_event(charm: CharmBase): else: assert not rel.data[unit] - spec = CharmSpec( - mycharm, - meta={ - "name": "local", - "requires": {"foo": {"interface": "bar"}}, - }, - ) state = State( relations=[ - relation( + Relation( endpoint="foo", interface="bar", local_app_data={"a": "because"}, @@ -166,7 +159,15 @@ def pre_event(charm: CharmBase): ) ] ) - state.run(event=event("start"), pre_event=pre_event, charm_spec=spec) + state.trigger( + "start", + mycharm, + meta={ + "name": "local", + "requires": {"foo": {"interface": "bar"}}, + }, + pre_event=pre_event, + ) def test_relation_set(mycharm): @@ -197,36 +198,33 @@ def pre_event(charm: CharmBase): # rel.data[charm.model.get_unit("remote/1")]["c"] = "d" mycharm._call = event_handler - spec = CharmSpec( - mycharm, - meta={ - "name": "foo", - "requires": {"foo": {"interface": "bar"}}, - }, + relation = Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + remote_unit_ids=[1, 4], + local_app_data={}, + local_unit_data={}, ) - state = State( leader=True, - relations=[ - relation( - endpoint="foo", - interface="bar", - remote_unit_ids=[1, 4], - local_app_data={}, - local_unit_data={}, - ) - ], + relations=[relation], ) assert not mycharm.called - out = state.run(event=event("start"), charm_spec=spec, pre_event=pre_event) + out = state.trigger( + event="start", + charm_type=mycharm, + meta={ + "name": "foo", + "requires": {"foo": {"interface": "bar"}}, + }, + pre_event=pre_event, + ) assert mycharm.called assert asdict(out.relations[0]) == asdict( - relation( - endpoint="foo", - interface="bar", - remote_unit_ids=[1, 4], + relation.replace( local_app_data={"a": "b"}, local_unit_data={"c": "d"}, ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 2ef0abb59..fa6cba864 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -7,7 +7,7 @@ from ops.framework import EventBase from scenario.runtime import Runtime -from scenario.state import CharmSpec, State, event +from scenario.state import Event, State, _CharmSpec def charm_type(): @@ -40,7 +40,7 @@ def test_event_hooks(): meta_file.write_text(yaml.safe_dump(meta)) runtime = Runtime( - CharmSpec( + _CharmSpec( charm_type(), meta=meta, ) @@ -48,9 +48,9 @@ def test_event_hooks(): pre_event = MagicMock(return_value=None) post_event = MagicMock(return_value=None) - runtime.run( + runtime.exec( state=State(), - event=event("foo"), + event=Event("foo"), pre_event=pre_event, post_event=post_event, ) @@ -74,13 +74,13 @@ class MyEvt(EventBase): my_charm_type.on.define_event("bar", MyEvt) runtime = Runtime( - CharmSpec( + _CharmSpec( my_charm_type, meta=meta, ), ) - runtime.run(state=State(), event=event("bar")) + runtime.exec(state=State(), event=Event("bar")) assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) From 9802f47ed9c7da4214288d31888977a29265ee55 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 09:53:58 +0100 Subject: [PATCH 068/546] test publish to pypi --- .github/workflows/build_wheels.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index acafdf3d6..c7b8d24a7 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -54,3 +54,9 @@ jobs: asset_path: ./dist/scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl asset_name: scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl asset_content_type: application/wheel + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ \ No newline at end of file From 65cbd5679dbcaa4aa9d473cfedfb1ae24a227408 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:24:58 +0100 Subject: [PATCH 069/546] fixed relation-meta --- .github/workflows/build_wheels.yaml | 3 +-- pyproject.toml | 2 ++ scenario/state.py | 10 +++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index c7b8d24a7..2e22a303b 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -58,5 +58,4 @@ jobs: - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d4585d4ae..8124f6bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" + +# this needs to MATCH EXACTLY the tag name for the publish pipeline to activate. version = "2.0.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/state.py b/scenario/state.py index 5cc33f46c..3e20327b6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -106,27 +106,27 @@ def __post_init__(self): @property def changed_event(self): """Sugar to generate a -changed event.""" - return Event(name=self.endpoint + "-changed", relation_meta=self) + return Event(name=self.endpoint + "-changed", relation=self) @property def joined_event(self): """Sugar to generate a -joined event.""" - return Event(name=self.endpoint + "-joined", relation_meta=self) + return Event(name=self.endpoint + "-joined", relation=self) @property def created_event(self): """Sugar to generate a -created event.""" - return Event(name=self.endpoint + "-created", relation_meta=self) + return Event(name=self.endpoint + "-created", relation=self) @property def departed_event(self): """Sugar to generate a -departed event.""" - return Event(name=self.endpoint + "-departed", relation_meta=self) + return Event(name=self.endpoint + "-departed", relation=self) @property def removed_event(self): """Sugar to generate a -removed event.""" - return Event(name=self.endpoint + "-removed", relation_meta=self) + return Event(name=self.endpoint + "-removed", relation=self) def _random_model_name(): From 1f82703a45383aced3374a823a0400b5a471502f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:25:26 +0100 Subject: [PATCH 070/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8124f6bbd..c93010e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "ops-scenario" # this needs to MATCH EXACTLY the tag name for the publish pipeline to activate. -version = "2.0.0" +version = "2.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From dee0a94b1c693f3dfb0aeb45b53fe2ec130683fa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:31:00 +0100 Subject: [PATCH 071/546] fixed asset paths --- .github/workflows/build_wheels.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 2e22a303b..132e97a6e 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -51,8 +51,8 @@ jobs: GITHUB_TOKEN: ${{ github.token }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl - asset_name: scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl + asset_path: ./dist/ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl + asset_name: ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl asset_content_type: application/wheel - name: Publish to TestPyPI From 729bd7ff5b744d30b4ffb03ae413bc9805203c6a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:39:10 +0100 Subject: [PATCH 072/546] new version flow --- .github/workflows/build_wheels.yaml | 4 ++-- pyproject.toml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 132e97a6e..d6ca4bb3c 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -3,7 +3,7 @@ name: Build on: push: tags: - - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - '*' # any tag jobs: @@ -28,7 +28,7 @@ jobs: - name: Get the version id: get_version - run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} + run: echo "VERSION=$(sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml)" >> $GITHUB_OUTPUT - name: release uses: actions/create-release@v1 diff --git a/pyproject.toml b/pyproject.toml index c93010e73..cd4c5ff0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -# this needs to MATCH EXACTLY the tag name for the publish pipeline to activate. -version = "2.0.1" +version = "2.0.1-rc1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From e06477cbc866fd6282478ef742ea7aee3cf8a458 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:39:53 +0100 Subject: [PATCH 073/546] new ci flow --- .github/workflows/build_wheels.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index d6ca4bb3c..4dfb76154 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -2,8 +2,8 @@ name: Build on: push: - tags: - - '*' # any tag + branches: + - main jobs: From 59b24969d55f62000735e51c9281ec546d5ea6fe Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:42:22 +0100 Subject: [PATCH 074/546] new ci flow v2 --- .github/workflows/build_wheels.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 4dfb76154..a9c8707b6 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -36,7 +36,7 @@ jobs: with: draft: false prerelease: false - tag_name: ${{ steps.get_version.outputs.VERSION }} # the tag + tag_name: ${{ steps.get_version.outputs.VERSION }} release_name: ${{ steps.get_version.outputs.VERSION }} env: diff --git a/pyproject.toml b/pyproject.toml index cd4c5ff0f..42fa22005 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.1-rc1" +version = "2.0.1-rc2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From d1786988bb326bca1141da69312cfc90fa0038e8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:45:52 +0100 Subject: [PATCH 075/546] v202 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42fa22005..fe24e169f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.1-rc2" +version = "2.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From fe61cb5fbca3d1a9aada62898d0a9b913c18e933 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:47:22 +0100 Subject: [PATCH 076/546] fixed decompose --- pyproject.toml | 2 +- scenario/sequences.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe24e169f..092210b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.2" +version = "2.0.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/sequences.py b/scenario/sequences.py index 2752b9324..7f259d440 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -32,11 +32,11 @@ def decompose_meta_event(meta_event: Event, state: State): if meta_event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: for relation in state.relations: event = Event( - relation.meta.endpoint + META_EVENTS[meta_event.name], + relation.endpoint + META_EVENTS[meta_event.name], args=( # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. # this will be replaced by a Relation instance before the event is fired. - InjectRelation(relation.meta.endpoint, relation.meta.relation_id), + InjectRelation(relation.endpoint, relation.relation_id), ), ) logger.debug(f"decomposed meta {meta_event.name}: {event}") From 4c7ac1158c2510efadf1650db0419ac1a3889104 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:53:13 +0100 Subject: [PATCH 077/546] fixed builder --- .github/workflows/build_wheels.yaml | 2 +- pyproject.toml | 2 +- requirements.txt | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index a9c8707b6..59fc86c9e 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -20,7 +20,7 @@ jobs: run: pip install wheel - name: Build wheel - run: pip wheel -w ./dist/ . + run: python -m build - uses: actions/upload-artifact@v3 with: diff --git a/pyproject.toml b/pyproject.toml index 092210b81..249f4b9c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.3" +version = "2.0.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/requirements.txt b/requirements.txt index 9e4a76569..8161ae96d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1 @@ -ops==2.0.0 -asttokens -astunparse \ No newline at end of file +ops==2.0.0 \ No newline at end of file From f6daaab3c66906b197a866677b0c6962d2087510 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 2 Feb 2023 10:57:33 +0100 Subject: [PATCH 078/546] fixed builder again --- .github/workflows/build_wheels.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 59fc86c9e..70de2718f 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -16,8 +16,8 @@ jobs: - uses: actions/setup-python@v3 - - name: Install wheel - run: pip install wheel + - name: Install build + run: pip install build - name: Build wheel run: python -m build From bd854dd8f1ea40593c1d7e980a6be62f97e1b43c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Feb 2023 16:36:32 +0100 Subject: [PATCH 079/546] pebble events and secret-get API --- README.md | 83 +++++++++++++++++-------------- scenario/mocking.py | 36 ++++++++++++-- scenario/runtime.py | 12 +++++ scenario/state.py | 81 ++++++++++++++++++++++++++++-- tests/test_e2e/test_pebble.py | 18 +++++-- tests/test_e2e/test_secrets.py | 90 ++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+), 48 deletions(-) create mode 100644 tests/test_e2e/test_secrets.py diff --git a/README.md b/README.md index 91c4be0ad..dd3cead03 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ Comparing scenario tests with `Harness` tests: A scenario test consists of three broad steps: - Arrange: - - declare the input state - - select an event to fire -- Act: - - run the state (i.e. obtain the output state) -- Assert: - - verify that the output state is how you expect it to be - - verify that the delta with the input state is what you expect it to be + - declare the input state + - select an event to fire +- Act: + - run the state (i.e. obtain the output state) +- Assert: + - verify that the output state is how you expect it to be + - verify that the delta with the input state is what you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -60,7 +60,7 @@ available. The charm has no config, no relations, no networks, and no leadership With that, we can write the simplest possible scenario test: ```python -from scenario.state import _CharmSpec, event, State +from scenario.state import State from ops.charm import CharmBase @@ -69,8 +69,9 @@ class MyCharm(CharmBase): def test_scenario_base(): - spec = _CharmSpec(MyCharm, meta={"name": "foo"}) - out = State().trigger(event=event('start'), charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"})) + out = State().trigger( + 'start', + MyCharm, meta={"name": "foo"}) assert out.status.unit == ('unknown', '') ``` @@ -79,7 +80,7 @@ Our charm sets a special state if it has leadership on 'start': ```python import pytest -from scenario.state import _CharmSpec, event, State +from scenario.state import State from ops.charm import CharmBase from ops.model import ActiveStatus @@ -97,8 +98,10 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - spec = _CharmSpec(MyCharm, meta={"name": "foo"}) - out = State(leader=leader).trigger(event=event('start'), charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"})) + out = State(leader=leader).trigger( + 'start', + MyCharm, + meta={"name": "foo"}) assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') ``` @@ -111,7 +114,7 @@ You can write scenario tests to verify the shape of relation data: ```python from ops.charm import CharmBase -from scenario.state import relation, State, event, _CharmSpec +from scenario.state import Relation, State # This charm copies over remote app data to local unit data @@ -127,7 +130,7 @@ class MyCharm(CharmBase): def test_relation_data(): out = State(relations=[ - relation( + Relation( endpoint="foo", interface="bar", remote_app_name="remote", @@ -135,12 +138,12 @@ def test_relation_data(): remote_app_data={"cde": "baz!"}, ), ] - ).trigger(charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}), event=event('start')) + ).trigger("start", MyCharm, meta={"name": "foo"}) assert out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: assert out.relations == [ - relation( + Relation( endpoint="foo", interface="bar", remote_app_name="remote", @@ -162,10 +165,10 @@ To give the charm access to some containers, you need to pass them to the input An example of a scene including some containers: ```python -from scenario.state import container, State +from scenario.state import Container, State state = State(containers=[ - container(name="foo", can_connect=True), - container(name="bar", can_connect=False) + Container(name="foo", can_connect=True), + Container(name="bar", can_connect=False) ]) ``` @@ -176,12 +179,12 @@ You can also configure a container to have some files in it: ```python from pathlib import Path -from scenario.state import container, State +from scenario.state import Container, State local_file = Path('/path/to/local/real/file.txt') state = State(containers=[ - container(name="foo", + Container(name="foo", can_connect=True, filesystem={'local': {'share': {'config.yaml': local_file}}}) ] @@ -201,7 +204,7 @@ then `content` would be the contents of our locally-supplied `file.txt`. You can ```python from ops.charm import CharmBase -from scenario.state import event, State, container, _CharmSpec +from scenario.state import State, Container class MyCharm(CharmBase): @@ -211,22 +214,26 @@ class MyCharm(CharmBase): def test_pebble_push(): + container = Container(name='foo') out = State( - containers=[container(name='foo')] + containers=[container] ).trigger( - event=event('start'), - charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}) + container.pebble_ready_event, + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, ) assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" ``` +`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event ins that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. + `container.exec` is a little bit more complicated. You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. ```python from ops.charm import CharmBase -from scenario.state import event, State, container, ExecOutput, _CharmSpec +from scenario.state import State, Container, ExecOutput LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml @@ -245,18 +252,20 @@ class MyCharm(CharmBase): def test_pebble_exec(): + container = Container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + ) out = State( - containers=[container( - name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) - } - )] + containers=[container] ).trigger( - event=event('start'), - charm_spec=_CharmSpec(MyCharm, meta={"name": "foo"}) + container.pebble_ready_event, + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, ) ``` diff --git a/scenario/mocking.py b/scenario/mocking.py index 13e1c8ec4..0b8c1b1d5 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -2,7 +2,7 @@ import urllib.request from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union from ops.model import _ModelBackend from ops.pebble import Client, ExecError @@ -176,8 +176,38 @@ def action_log(self, *args, **kwargs): def storage_add(self, *args, **kwargs): raise NotImplementedError("storage_add") - def secret_get(self, *args, **kwargs): - raise NotImplementedError("secret_get") + def secret_get( + self, + *, + id: str = None, + label: str = None, + refresh: bool = False, + peek: bool = False, + ) -> Dict[str, str]: + # cleanup id: + if id and id.startswith("secret:"): + id = id[7:] + + if id: + try: + secret = next(filter(lambda s: s.id == id, self._state.secrets)) + except StopIteration: + raise RuntimeError(f"not found: secret with id={id}.") + elif label: + try: + secret = next(filter(lambda s: s.label == label, self._state.secrets)) + except StopIteration: + raise RuntimeError(f"not found: secret with label={label}.") + else: + raise RuntimeError(f"need id or label.") + + revision = secret.revision + if peek or refresh: + revision = max(secret.contents.keys()) + if refresh: + secret.revision = revision + + return secret.contents[revision] def secret_set(self, *args, **kwargs): raise NotImplementedError("secret_set") diff --git a/scenario/runtime.py b/scenario/runtime.py index 021b4ee95..3b61575c9 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -121,6 +121,18 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_RELATION_ID": str(relation.relation_id), } ) + + if container := event.container: + env.update({"JUJU_WORKLOAD_NAME": container.name}) + + if secret := event.secret: + env.update( + { + "JUJU_SECRET_ID": secret.id, + "JUJU_SECRET_LABEL": secret.label or "", + } + ) + return env @staticmethod diff --git a/scenario/state.py b/scenario/state.py index 3e20327b6..5f5e576a7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -18,10 +18,9 @@ from uuid import uuid4 import yaml -from ops import testing from scenario.logger import logger as scenario_logger -from scenario.runtime import Runtime, trigger +from scenario.runtime import trigger if typing.TYPE_CHECKING: try: @@ -54,6 +53,62 @@ def copy(self) -> "Self": return copy.deepcopy(self) +@dataclasses.dataclass +class Secret(_DCBase): + id: str + + # mapping from revision IDs to each revision's contents + contents: Dict[int, Dict[str, str]] + + owned_by_this_unit: bool = False + + # has this secret been granted to this unit/app or neither? + granted: Literal["unit", "app", False] = False + + # what revision is currently tracked by this charm. Only meaningful if owned_by_this_unit=False + revision: int = 0 + + label: str = None + + # consumer-only events + @property + def changed_event(self): + """Sugar to generate a secret-changed event.""" + if self.owned_by_this_unit: + raise ValueError( + "This unit will never receive secret-changed for a secret it owns." + ) + return Event(name="secret-changed", secret=self) + + # owner-only events + @property + def rotate_event(self): + """Sugar to generate a secret-rotate event.""" + if not self.owned_by_this_unit: + raise ValueError( + "This unit will never receive secret-rotate for a secret it does not own." + ) + return Event(name="secret-rotate", secret=self) + + @property + def expired_event(self): + """Sugar to generate a secret-expired event.""" + if not self.owned_by_this_unit: + raise ValueError( + "This unit will never receive secret-expire for a secret it does not own." + ) + return Event(name="secret-expire", secret=self) + + @property + def remove_event(self): + """Sugar to generate a secret-remove event.""" + if not self.owned_by_this_unit: + raise ValueError( + "This unit will never receive secret-removed for a secret it does not own." + ) + return Event(name="secret-removed", secret=self) + + _RELATION_IDS_CTR = 0 @@ -203,6 +258,16 @@ class Container(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + @property + def pebble_ready_event(self): + """Sugar to generate a -pebble-ready event.""" + if not self.can_connect: + logger.warning( + "you **can** fire pebble-ready while the container cannot connect, " + "but that's most likely not what you want." + ) + return Event(name=self.name + "-pebble-ready", container=self) + @dataclasses.dataclass class Address(_DCBase): @@ -299,15 +364,15 @@ class State(_DCBase): leader: bool = False model: Model = Model() juju_log: Sequence[Tuple[str, str]] = dataclasses.field(default_factory=list) + secrets: List[Secret] = dataclasses.field(default_factory=list) # meta stuff: actually belongs in event data structure. juju_version: str = "3.0.0" unit_id: str = "0" app_name: str = "local" - # todo: add pebble stuff, unit/app status, etc... + # todo: # actions? - # juju topology @property def unit_name(self): @@ -419,9 +484,15 @@ class Event(_DCBase): args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - # if this is a relation event, the metadata of the relation + # if this is a relation event, the relation it refers to relation: Optional[Relation] = None + # if this is a secret event, the secret it refers to + secret: Optional[Secret] = None + + # if this is a workload (container) event, the container it refers to + container: Optional[Container] = None + # todo add other meta for # - secret events # - pebble? diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index d68c0a77a..268edec37 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -105,8 +105,6 @@ def callback(self: CharmBase): with pytest.raises(FileNotFoundError): container.pull("/bar/baz.txt") - charm_cls.callback = callback - state = State(containers=[Container(name="foo", can_connect=True)]) out = state.trigger( @@ -159,7 +157,6 @@ def callback(self: CharmBase): proc.wait() assert proc.stdout.read() == "hello pebble" - charm_cls.callback = callback State( containers=[ Container( @@ -174,3 +171,18 @@ def callback(self: CharmBase): event="start", post_event=callback, ) + + +def test_pebble_ready(charm_cls): + def callback(self: CharmBase): + foo = self.unit.get_container("foo") + assert foo.can_connect() + + container = Container(name="foo", can_connect=True) + + State(containers=[container]).trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event=container.pebble_ready_event, + post_event=callback, + ) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py new file mode 100644 index 000000000..4af1cc0a4 --- /dev/null +++ b/tests/test_e2e/test_secrets.py @@ -0,0 +1,90 @@ +import pytest +from ops.charm import CharmBase +from ops.framework import Framework + +from scenario.state import Secret, State + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + pass + + return MyCharm + + +def test_get_secret_no_secret(mycharm): + def post_event(charm: CharmBase): + with pytest.raises(RuntimeError): + assert charm.model.get_secret(id="foo") + with pytest.raises(RuntimeError): + assert charm.model.get_secret(label="foo") + + State().trigger( + "update-status", mycharm, meta={"name": "local"}, post_event=post_event + ) + + +def test_get_secret(mycharm): + def post_event(charm: CharmBase): + assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + + State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]).trigger( + "update-status", mycharm, meta={"name": "local"}, post_event=post_event + ) + + +def test_get_secret_peek_update(mycharm): + def post_event(charm: CharmBase): + assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" + assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + + assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + assert charm.model.get_secret(id="foo").get_content()["a"] == "c" + + State( + secrets=[ + Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + ) + ] + ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + + +def test_secret_changed_owner_evt_fails(mycharm): + with pytest.raises(ValueError): + _ = Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + owned_by_this_unit=True, + ).changed_event + + +@pytest.mark.parametrize("evt_prefix", ("rotate", "expired", "remove")) +def test_consumer_events_failures(mycharm, evt_prefix): + with pytest.raises(ValueError): + _ = getattr( + Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + owned_by_this_unit=False, + ), + evt_prefix + "_event", + ) From 08f8ee186f6e918b0f805a6716de1d9a92b247aa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Feb 2023 17:11:06 +0100 Subject: [PATCH 080/546] fixed relation events --- scenario/state.py | 22 +++++++++++----------- tests/test_e2e/test_relations.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 5f5e576a7..01a981e00 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -160,28 +160,28 @@ def __post_init__(self): @property def changed_event(self): - """Sugar to generate a -changed event.""" - return Event(name=self.endpoint + "-changed", relation=self) + """Sugar to generate a -relation-changed event.""" + return Event(name=self.endpoint + "-relation-changed", relation=self) @property def joined_event(self): - """Sugar to generate a -joined event.""" - return Event(name=self.endpoint + "-joined", relation=self) + """Sugar to generate a -relation-joined event.""" + return Event(name=self.endpoint + "-relation-joined", relation=self) @property def created_event(self): - """Sugar to generate a -created event.""" - return Event(name=self.endpoint + "-created", relation=self) + """Sugar to generate a -relation-created event.""" + return Event(name=self.endpoint + "-relation-created", relation=self) @property def departed_event(self): - """Sugar to generate a -departed event.""" - return Event(name=self.endpoint + "-departed", relation=self) + """Sugar to generate a -relation-departed event.""" + return Event(name=self.endpoint + "-relation-departed", relation=self) @property - def removed_event(self): - """Sugar to generate a -removed event.""" - return Event(name=self.endpoint + "-removed", relation=self) + def broken_event(self): + """Sugar to generate a -relation-broken event.""" + return Event(name=self.endpoint + "-relation-broken", relation=self) def _random_model_name(): diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 8de4e2ef9..3cd259e6d 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -63,3 +63,25 @@ def pre_event(charm: CharmBase): }, }, ) + + +@pytest.mark.parametrize( + "evt_name", ("changed", "broken", "departed", "joined", "created") +) +def test_relation_events(mycharm, evt_name): + relation = Relation(endpoint="foo", interface="foo", remote_app_name="remote") + + mycharm._call = lambda self, evt: None + + State(relations=[relation,],).trigger( + getattr(relation, f"{evt_name}_event"), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + }, + ) + + assert mycharm.called From b6e6658542f0d3610d1780c65e8c4afd2f11dcb1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Feb 2023 17:11:31 +0100 Subject: [PATCH 081/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 249f4b9c4..9e3d8f2da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.4" +version = "2.0.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 6254657a5ef30ada720738bceb89c05c338d710b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 8 Feb 2023 10:42:00 +0100 Subject: [PATCH 082/546] pebble mockery --- pyproject.toml | 2 +- scenario/mocking.py | 123 ++++++++++++++-------------------- scenario/state.py | 50 +++++++++----- tests/test_e2e/test_pebble.py | 84 ++++++++++++++++++++--- tox.ini | 6 +- 5 files changed, 162 insertions(+), 103 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e3d8f2da..1dcc59d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.0.5" +version = "2.1.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/mocking.py b/scenario/mocking.py index 0b8c1b1d5..c9f357ff9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,15 +1,17 @@ -import tempfile +import pathlib import urllib.request from io import StringIO -from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union +from ops import pebble from ops.model import _ModelBackend from ops.pebble import Client, ExecError +from ops.testing import _TestingFilesystem, _TestingPebbleClient, _TestingStorageMount from scenario.logger import logger as scenario_logger if TYPE_CHECKING: + from scenario.state import Container as ContainerSpec from scenario.state import Event, ExecOutput, State, _CharmSpec logger = scenario_logger.getChild("mocking") @@ -219,7 +221,36 @@ def secret_remove(self, *args, **kwargs): raise NotImplementedError("secret_remove") -class _MockPebbleClient(Client): +class _MockStorageMount(_TestingStorageMount): + def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path): + """Creates a new simulated storage mount. + + Args: + location: The path within simulated filesystem at which this storage will be mounted. + src: The temporary on-disk location where the simulated storage will live. + """ + self._src = src + self._location = location + if ( + not src.exists() + ): # we need to add this guard because the directory might exist already. + src.mkdir(exist_ok=True, parents=True) + + +# todo consider duplicating the filesystem on State.copy() to be able to diff and have true state snapshots +class _MockFileSystem(_TestingFilesystem): + def __init__(self, mounts: Dict[str, _MockStorageMount]): + super().__init__() + self._mounts = mounts + + def add_mount(self, *args, **kwargs): + raise NotImplementedError("Cannot mutate mounts; declare them all in State.") + + def remove_mount(self, *args, **kwargs): + raise NotImplementedError("Cannot mutate mounts; declare them all in State.") + + +class _MockPebbleClient(_TestingPebbleClient): def __init__( self, socket_path: str, @@ -231,13 +262,13 @@ def __init__( event: "Event", charm_spec: "_CharmSpec", ): - super().__init__(socket_path, opener, base_url, timeout) self._state = state + self.socket_path = socket_path self._event = event self._charm_spec = charm_spec @property - def _container(self): + def _container(self) -> "ContainerSpec": container_name = self.socket_path.split("/")[-2] try: return next( @@ -250,32 +281,17 @@ def _container(self): f"{self.socket_path!r} wrong?" ) - def _request(self, *args, **kwargs): - if args == ("GET", "/v1/system-info"): - if self._container.can_connect: - return {"result": {"version": "unknown"}} - else: - wrap_errors = False # this is what Client expects! - raise FileNotFoundError("") - - elif args[:2] == ("GET", "/v1/services"): - service_names = list(args[2]["names"].split(",")) - result = [] - - for layer in self._container.layers: - if not service_names: - break - - for name in service_names: - if name in layer["services"]: - service_names.remove(name) - result.append(layer["services"][name]) + @property + def _fs(self): + return self._container.filesystem - # todo: what do we do if we don't find the requested service(s)? - return {"result": result} + @property + def _layers(self) -> Dict[str, pebble.Layer]: + return self._container.layers - else: - raise NotImplementedError(f"_request: {args}") + @property + def _service_status(self) -> Dict[str, pebble.ServiceStatus]: + return self._container.service_status def exec(self, *args, **kwargs): cmd = tuple(args[0]) @@ -286,45 +302,10 @@ def exec(self, *args, **kwargs): change_id = out._run() return _MockExecProcess(change_id=change_id, command=cmd, out=out) - def pull(self, *args, **kwargs): - # todo double-check how to surface error - wrap_errors = False - - path_txt = args[0] - pos = self._container.filesystem - for token in path_txt.split("/")[1:]: - pos = pos.get(token) - if not pos: - raise FileNotFoundError(path_txt) - local_path = Path(pos) - if not local_path.exists() or not local_path.is_file(): - raise FileNotFoundError(local_path) - return local_path.open() - - def push(self, *args, **kwargs): - setter = True - # todo double-check how to surface error - wrap_errors = False - - path_txt, contents = args - - pos = self._container.filesystem - tokens = path_txt.split("/")[1:] - for token in tokens[:-1]: - nxt = pos.get(token) - if not nxt and kwargs["make_dirs"]: - pos[token] = {} - pos = pos[token] - elif not nxt: - raise FileNotFoundError(path_txt) - else: - pos = pos[token] - - # dump contents - # fixme: memory leak here if tmp isn't regularly cleaned up - file = tempfile.NamedTemporaryFile(delete=False) - pth = Path(file.name) - pth.write_text(contents) - - pos[tokens[-1]] = pth - return + def _check_connection(self): + if not self._container.can_connect: # pyright: reportPrivateUsage=false + msg = ( + f"Cannot connect to Pebble; did you forget to set " + f"can_connect=True for container {self._container.name}?" + ) + raise pebble.ConnectionError(msg) diff --git a/scenario/state.py b/scenario/state.py index 01a981e00..a9ca8daa2 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -2,7 +2,7 @@ import dataclasses import inspect import typing -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import ( Any, Callable, @@ -18,8 +18,10 @@ from uuid import uuid4 import yaml +from ops import pebble from scenario.logger import logger as scenario_logger +from scenario.mocking import _MockFileSystem, _MockStorageMount from scenario.runtime import trigger if typing.TYPE_CHECKING: @@ -198,11 +200,6 @@ class Model(_DCBase): uuid: str = str(uuid4()) -_SimpleFS = Dict[ - str, # file/dirname - Union["_SimpleFS", Path], # subdir # local-filesystem path resolving to a file. -] - # for now, proc mock allows you to map one command to one mocked output. # todo extend: one input -> multiple outputs, at different times @@ -228,11 +225,23 @@ def _run(self) -> int: _ExecMock = Dict[Tuple[str, ...], ExecOutput] +@dataclasses.dataclass +class Mount(_DCBase): + location: Union[str, PurePosixPath] + src: Union[str, Path] + + def __post_init__(self): + self.src = Path(self.src) + + @dataclasses.dataclass class Container(_DCBase): name: str can_connect: bool = False - layers: Tuple["LayerDict"] = () + layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) + service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( + default_factory=dict + ) # this is how you specify the contents of the filesystem: suppose you want to express that your # container has: @@ -241,23 +250,25 @@ class Container(_DCBase): # - /bin/baz # # this becomes: - # filesystem = { - # 'home': { - # 'foo': Path('/path/to/local/file/containing/bar.py') - # }, - # 'bin': { - # 'bash': Path('/path/to/local/bash'), - # 'baz': Path('/path/to/local/baz') - # } + # mounts = { + # 'foo': Mount('/home/foo/', Path('/path/to/local/dir/containing/bar/py/')) + # 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')) # } # when the charm runs `pebble.pull`, it will return .open() from one of those paths. # when the charm pushes, it will either overwrite one of those paths (careful!) or it will # create a tempfile and insert its path in the mock filesystem tree - # charm-created tempfiles will NOT be automatically deleted -- you have to clean them up yourself! - filesystem: _SimpleFS = dataclasses.field(default_factory=dict) + mounts: Dict[str, Mount] = dataclasses.field(default_factory=dict) exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + @property + def filesystem(self) -> _MockFileSystem: + mounts = { + name: _MockStorageMount(src=spec.src, location=spec.location) + for name, spec in self.mounts.items() + } + return _MockFileSystem(mounts=mounts) + @property def pebble_ready_event(self): """Sugar to generate a -pebble-ready event.""" @@ -530,3 +541,8 @@ def _derive_args(event_name: str): args.append(InjectRelation(relation_name=event_name[: -len(term)])) return tuple(args) + + +# todo: consider +# def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: +# pass diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 268edec37..fade9c6f7 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,12 +1,13 @@ import tempfile from pathlib import Path -from typing import Optional import pytest +from ops import pebble from ops.charm import CharmBase from ops.framework import Framework +from ops.pebble import ServiceStartup, ServiceStatus -from scenario.state import Container, Event, ExecOutput, State, _CharmSpec +from scenario.state import Container, ExecOutput, Mount, State @pytest.fixture(scope="function") @@ -75,7 +76,7 @@ def callback(self: CharmBase): State( containers=[ Container( - name="foo", can_connect=True, filesystem={"bar": {"baz.txt": pth}} + name="foo", can_connect=True, mounts={"bar": Mount("/bar/baz.txt", pth)} ) ] ).trigger( @@ -93,19 +94,26 @@ def test_fs_pull(charm_cls, make_dirs): def callback(self: CharmBase): container = self.unit.get_container("foo") if make_dirs: - container.push("/bar/baz.txt", text, make_dirs=make_dirs) + container.push("/foo/bar/baz.txt", text, make_dirs=make_dirs) # check that pulling immediately 'works' - baz = container.pull("/bar/baz.txt") + baz = container.pull("/foo/bar/baz.txt") assert baz.read() == text else: - with pytest.raises(FileNotFoundError): - container.push("/bar/baz.txt", text, make_dirs=make_dirs) + with pytest.raises(pebble.PathError): + container.push("/foo/bar/baz.txt", text, make_dirs=make_dirs) # check that nothing was changed with pytest.raises(FileNotFoundError): - container.pull("/bar/baz.txt") + container.pull("/foo/bar/baz.txt") - state = State(containers=[Container(name="foo", can_connect=True)]) + td = tempfile.TemporaryDirectory() + state = State( + containers=[ + Container( + name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} + ) + ] + ) out = state.trigger( charm_type=charm_cls, @@ -115,8 +123,8 @@ def callback(self: CharmBase): ) if make_dirs: - file = out.get_container("foo").filesystem["bar"]["baz.txt"] - assert file.read_text() == text + file = out.get_container("foo").filesystem.open("/foo/bar/baz.txt") + assert file.read() == text else: # nothing has changed assert not out.jsonpatch_delta(state) @@ -186,3 +194,57 @@ def callback(self: CharmBase): event=container.pebble_ready_event, post_event=callback, ) + + +def test_pebble_plan(charm_cls): + def callback(self: CharmBase): + foo = self.unit.get_container("foo") + + assert foo.get_plan().to_dict() == { + "services": {"fooserv": {"startup": "enabled"}} + } + fooserv = foo.get_services("fooserv")["fooserv"] + assert fooserv.startup == ServiceStartup.ENABLED + assert fooserv.current == ServiceStatus.INACTIVE + + foo.add_layer( + "bar", + { + "summary": "bla", + "description": "deadbeef", + "services": {"barserv": {"startup": "disabled"}}, + }, + ) + + foo.replan() + assert foo.get_plan().to_dict() == { + "services": { + "barserv": {"startup": "disabled"}, + "fooserv": {"startup": "enabled"}, + } + } + + assert foo.get_service("barserv").current == ServiceStatus.INACTIVE + foo.start("barserv") + assert foo.get_service("barserv").current == ServiceStatus.ACTIVE + + container = Container( + name="foo", + can_connect=True, + layers={ + "foo": pebble.Layer( + { + "summary": "bla", + "description": "deadbeef", + "services": {"fooserv": {"startup": "enabled"}}, + } + ) + }, + ) + + State(containers=[container]).trigger( + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event=container.pebble_ready_event, + post_event=callback, + ) diff --git a/tox.ini b/tox.ini index 176cf08a7..ba3ed4b68 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ [tox] skipsdist=True skip_missing_interpreters = True -envlist = integration lint +envlist = unit, fmt [vars] @@ -12,8 +12,8 @@ src_path = {toxinidir}/scenario tst_path = {toxinidir}/tests -[testenv:integration] -description = integration tests +[testenv:unit] +description = unit tests deps = coverage[toml] pytest From 96312976ca1158065f1c17692fa3d84a00cc41eb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 8 Feb 2023 11:10:27 +0100 Subject: [PATCH 083/546] readme --- README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index dd3cead03..714488988 100644 --- a/README.md +++ b/README.md @@ -174,19 +174,19 @@ state = State(containers=[ In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. -You can also configure a container to have some files in it: +You can configure a container to have some files in it: ```python from pathlib import Path -from scenario.state import Container, State +from scenario.state import Container, State, Mount local_file = Path('/path/to/local/real/file.txt') state = State(containers=[ Container(name="foo", can_connect=True, - filesystem={'local': {'share': {'config.yaml': local_file}}}) + mounts={'local': Mount('/local/share/config.yaml', local_file)}) ] ) ``` @@ -203,8 +203,9 @@ then `content` would be the contents of our locally-supplied `file.txt`. You can `container.push` works similarly, so you can write a test like: ```python +import tempfile from ops.charm import CharmBase -from scenario.state import State, Container +from scenario.state import State, Container, Mount class MyCharm(CharmBase): @@ -214,7 +215,9 @@ class MyCharm(CharmBase): def test_pebble_push(): - container = Container(name='foo') + local_file = tempfile.TemporaryFile() + container = Container(name='foo', + mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) out = State( containers=[container] ).trigger( @@ -222,12 +225,12 @@ def test_pebble_push(): MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, ) - assert out.get_container('foo').filesystem['local']['share']['config.yaml'].read_text() == "TEST" + assert local_file.open().read() == "TEST" ``` -`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event ins that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. +`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event is that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. -`container.exec` is a little bit more complicated. +`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. ```python @@ -271,5 +274,7 @@ def test_pebble_exec(): # TODOS: -- Figure out how to distribute this. I'm thinking `pip install ops[scenario]` -- Better syntax for memo generation \ No newline at end of file +- State-State consistency checks. +- State-Metadata consistency checks. +- When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. +- Recorder \ No newline at end of file From dc34f3f7f434ffd78b37457b498fc69d6de0e3bb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Feb 2023 15:49:09 +0100 Subject: [PATCH 084/546] no evt handler check --- README.md | 2 ++ resources/state-transition-model.png | Bin 0 -> 19480 bytes scenario/ops_main_mock.py | 24 ++++++++++++++- scenario/runtime.py | 5 +++ scenario/state.py | 3 ++ tests/test_e2e/test_rubbish_events.py | 42 ++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 resources/state-transition-model.png create mode 100644 tests/test_e2e/test_rubbish_events.py diff --git a/README.md b/README.md index 714488988..59b8aa7de 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ config?...). The output is another context instance: the context after the charm has had a chance to interact with the mocked juju model. +![state transition model depiction](resources/state-transition-model.png) + Scenario-testing a charm, then, means verifying that: - the charm does not raise uncaught exceptions while handling the scene diff --git a/resources/state-transition-model.png b/resources/state-transition-model.png new file mode 100644 index 0000000000000000000000000000000000000000..08228fe9635ba6901e3a271f443fe2189671b78e GIT binary patch literal 19480 zcmcG$WmuJ4*EXyON_R+0r?6-c7AW1_UD7Gtx}>|ik?sa57b&>tZfPW?Q+O}fd*Azh zj_-N@z26U99CF2&V~#QAd5%~iit-X@$VA9bo;*R5k`z^b^5iKD`27s=Iq>h|Y>&p1 zCo;=YqVH7Q_4nI+9B`)-E)HipgyKVa@{hk-ZM;^twyj1QnA2i%`;$H5v*E#e3 z-BXjOF73czgd^-$97-lbrW&k}fZVcU`c~dpTN*_B?g31Q=BwDm;3ps8g%yIULaOvJ zL59@NDRSP~d;49S%F1vo8auZX7MgRu$tqE4xZXH-kRQGpYV*0taGR{Np_jNBR&VT)8}H0$`=|wf+9gbG=@x z&J`v=ZM`H7=Iai@AGdzLpxYX7yi5k z(&&#O2xXQXMJxsl)B{xz8r}t)CQfkr$p}sN8dvlcBWdXR{A&e{8LojV=Z8$ zU83Fw9y2ds6!|X}d8X@H(&yI;?PZ=}Q^`;yZHJP9I2P4-GyMMO6mz z&BdL5n;b)Kw7;=n9m|?WF4#6|r}j)vo17I}>G6D&#|)6?;dWIPY;f0BSf6NZBVNLf zxEPSaDJbBr`g>|h%LKQv$U-Ahv!zPGE?ME@AMO1DrvmI=NXSGMP3NuaByr57ur>I?wSzQ}o+naIBhBw7_e!qYLD`3y=ZhZ^16=c&!?#B{xp< zFW;Ia_+=6UHG24d{AlCQ|BP6`E_1GS$gD*aE9W^DQX;?Xh>!EHRn{VY?|N8v2${|B zcV@Wbs)s$xeKF6$9`;dS4=|SGZRmM8jPe!6O{mdJT2DYZ|3BRl8@@0y^6~c8Ti|A| z*8Mm1h2t>z;%7z6fikvWnvb|3-LeWQJw7f@C|Vcb-ERsI#y37t@bD01-QR8<^|1P4 zm1SIYbwgpbK_El;`M|BVT3Kn2)cK8D50*7 zFL+k0K*7O*L+<_i(>W&XK=wiS|IXu!{f;k)#T|T;>;_Cd$=b8 zI_GV~lTF1H9&UDj$$g&lQl-p;5)-&`pHKtztNvN4R6mY)0XnXAC|}2iP7o@G;2JV* zTd71Pxn_S}lwgTYN}$}*cfXZDWBF=xU~f;i;p5al=MXHE`wqv(3Yw7d-%79*&YXn4 z1>%&avsQAVVxF2&S#P92`Hz_mp-}MRvxm_7>MOJCZe*ZIGk*)VZ<%q@J^i^e#zUS8eVDbNvxOlRN0z3`&@~PHkrq_WI zGG@>-ejHzB+K3f!^XM+f0p} z1aFP}J?_ozR?opMn{SmNyR0hqX9!X{3$8Jb4|;kUlVhD2lV)M{O3ath%(c;^wl<}W zq6TAFb5ggw7fDDKFN?^j_AdoDG<5;aDe)(T+=iQLJ;K}wK^c;n8otCvT!9&PRD11Y zYeAC`zAwPx98PGg43hdOf5`AH6d_l_-JWFifB@VX!R5dZLZt{|TmK}nACAstOp3H( zH0UTIv63F+@8TP_%=sAJd#llW=eJrnbXs-O8xL0a=-Ez4R6n=f)!uumMCPcgniW!b zXbY^auEy|MBZWp?`D$Om*BUAyCBmddy>&@e3k$zpwI6fHLQjnN(g8JLk_Yi`-%60s z^9o$YlhG*jQt-9FBb>xyk{`a^h$g0S>S?Iopw|+7!9k9>PfJZv{+nLgn&;b*CVOmw z5g?ESIDJ?!MrN!| zVn07h?0?RcaJ|6a6W{ps5S_gRRu9i+7ab+9ere`%ge~6H-gv4+^lHRmU3|li&-&1{ zW+yf|mm>Vlze?|u251dCn$w=NM7#S=zuM0Od$r*8JG4JdI6`~e0wh8H2sFHMM86z! z#M2dS+J<3^^jTYYvv_!gD+T6Q?Y52=!|$L{hpeL0I!^vC>0VvsW7je6kAcYxJ1PkS zm4d-g-2VK6)grg;iKwZpf>*xpXwJZY{1s2>dsuDz(CZ~$mIbARqv4eO`Z?POFXbcy zSNS^D;J6I!RWUlk{OwlP!LA%bpjFkX?~#aA?Q+JL7m->=k?2~HVJNv>y-ACuyU?h8 z^3<9ubYCcEZ{j)i^6P_9mD3;v5^~n_76PAn{7W!pwv)eM1uw5>^zk;uK|i((85Kyj z49g25)z(Uglq@1rwZl9+L{yfR@G<3TiOdXICK^L6(B7RO@MdDi(+?r`Q0^Nx&8JJ! zLV?GVl50tneO>tdIW{imr2_MZybnsNGY)0QA^Xo(iEx7VMQ1jJ$XyNWrB?uG($eG8 zpwQCQ=ROQdu;uU>o)*Ko;r@2yUc){X^V7?Rk%dJOw8l|#OZ3}LM)P3W%ZA~bEVfpM z*U9!Ab;JleY96*11Znvs!-F#AGEgi5IA|-6!Cj}>j%%{i3u(c1gXFnKg_e?Eg|Pag zzR+_RSnK26nDHtuZTjh!E6j&5;1R4X}rnp+wuQ_Sj@!yljmzPG$&ez%S^)mvLzOnw(< zXO) z58H1P4Gb!QcVZp$TrFCSqHgF1<7-)#GK%(lE711wk`=r`u=?foI#*?z8=NsffVBS9s z&!lSoKQe6)4w?Y##gZk$|B{Iw_TKbWN8EimNhX&Ut0)e0a`g+80=cHh-1SMC z)dTsrW4d(G^4FrwZ$SuT7y>29F8EX%`Y%fAAXhn<-z|k-N$^LO)4MZcF`<|EmAYpc zjn&GOyeIKqc=zpZ$Xpvv1=oC3x1w*XGfwmkzx)oBBTa_l4#NL4-*%^W;(&{Fuvl1w$H;8BM=0xg`8ih?0;R<91chiMAejPvLz1OPYB;hxqB}wb^k{CY(%PzMh zAC1SxB308J;~e{{Pa$@{R7_qmafHa(|1~4ZpEtd zJjMOHp&EY-H9q{w)Sxfwg>H}HG!3e&FX}M4(A)cvHw(P#5$#`n*5)Fvi8fvi7YiBw z_9hN&5rV!RM*EslH>mIi$d1$kVO0lSg;d)VX4LJ+eiRKYai#1x#e?bafSWuT2-|{u zKOZzMcW;pUz95*+&vWY0b^an0dcM^W*ih&9txv(nSJ(PxQ)GaRlocE}q*miubJFn6cL5i1fS`)55k88S{3?Ra#MDsP03{!)P}52UJ`6?I5OY% z<@yQla`;6>Z4CjB_IpPc+PjCc+CbC?!7HdV5R=3Z+uZ8!lPo+ z-azWGes&y`M%MyL- zI5~3;oReH7y8m4VwKjs&?`xM+QofH&LV8g{2~MNjLRxfY4XNAEh7J%8LB~C1MIxSY z_Mc%TrgY}ya4oW#ilD}%x>6==!WH7Dh%L&TvZUuV!Y4mxsdp&HCD zt0A?I_YV|YAxOH{jCq$zXdn;i}JA7sQ^_+NhS>=GCn6UH#+6k0TPSKz?50W;Yq^e4XHePOW9eL-@ zjb8*1TG1h;;IRU3Rf3IBpXQkly_KX{hEsUKYxt%VZIZgRD^F?&#?9wHi63R`;|r&! zZgvms4_a8xp={+&O0w?4BNcqe#8m1F%qZfWVXm0<7pV5TORKjK7!Q1NBqOMHOY9~C z>-g_&1srQ6n*DT6?^vXc^|nO+OYX!_rRQ_-&5}x>m4w~m*j+6p++T)5-~*ZcO7Kf~ zn}o)8sRNueGBXlxmW0{NBh?z(7pOM8=P!b2aeY3-mZMWkKw77Oc$lbW;>Xh=lFQ3Y z%|9~D>Oax=@~ue9*t0=FPIqFc>M*gW4_gT5pM9Lg03D_BTg(g67Apenz`mhUMTAi& z54>bSB@K#yZrmy5eU)$rR$ z*VBa0^ZqwK#qIHhO%sqJg&7Cj=PG~{_AuKTCN;G^q-yg}@OJ&9Uo(odUHdqaep9BX zI4QO+LSD4qG0l*fX;;5(v#P`dl^c2nxh9#p2T7=WhahbVA4W5>&B-Sno&=wmu{wK3 z%qc}qW=Tx%@zy8WO^w*M(< z`I~IB2~vCx>e>3W4f&C;jq$vf&p!O!X{Nmh7g|5^D6HbwG+B06@crNMH&zk|Qhwxi zB(qUN?-h1>%&~QLTVEtB(Y_9Ju;%SfsFGvHgO3ZPSG3iU-c$V)e3z(InU?bMnCdF- zR{D-ch?s#4+Xo^w46>x>YI~V4ky>q^c1XC1bZ?$qUu6F#a%{TBpMJxHKcZRwRr2fi z!rP+!d|s)eW7;q1z{}qIPl$#`*0at+4Ow3Ef3}M%GlW~@;!L4OVQS>bxtegq-PknR zXrMifbRv6$ZkR7ZqC*=Ao)76QQHB{r2c%QsU^o#T{YT|UP?GC)%8K?IlRAE~Q6za$ z8$0Ydd~YqbML`_#+}UP!ytexj>jk3dmt@t+ngsuVzd~iqOeCRoa{!0w1#aWWLf`hW z*=Q&2@}$*`^T;%izgBy{WF(hL(_+OWO+2Ah14VH9S%z8WOvH}2IzRIHn*C6AZuW;i zD2w-V=mIlLKRIX&V*?@1tbfY>VlWmjtFPLQu0)UeVV!X}L_x4X<6^?2`_Tf6SdHSGskoKe^c-B|1Uh`F@I ziW<+SwF&$)H1TDn=PnlA$ai*k>syjR!gsVCKZ4Cvk!f*)|Mi&$q|I`jmme~^dNkVe zaq#)e)lO+yd9%cZ?R|gg@t<=q9G|Xe zJ;!c*nhKjF?RnX1H*A^>Jn~;)i1-|3llt3#Y!aIvaEwp7use-I?eaCmP(4@QGl7NM zA}OrSC*pw#E>``{!6p(y_q?_PsT!Pq1-4-3?p(t$8@bevouXu+B2}}s4T!W!7wj0o zkAfzG3Sr>BDr1NR0yXxO+Hu~u%#Giec6V1%O~Z;}aL$wxqvzAABfkxnEXylfbe317L@T(k z9Hu0PtHC6fPAmpui>vXOAY#H#TlbS|y1h7``bKNqWbVXLo~&aG?&WW#%yCC(-#Fa| z@80<3m)YR57%PE_H8@6VIeN+H(V?C<4Tzfn6suEWJxO=7h#CACGwgr(J&67L-|=q+ zTXv^kiLk>zH=SmOzI0{_w=H)Xzp4vyDfZ!%9ab&bg0>D;2!U zx5zu!F!`XRAE)exTFU6z2br1Rb4?UY&ln+gEPTXUVEO(rot5pRz}Yx*aOp+mn& z`11%BiQP1|SKx$65+Y>_i6p&o^gZKo?kk>Z|83+h|HtUmJ(WvFO}c0y>0FaT{Ku0q zbHo>bzW#!Uyg`9x;vX!k+-E401*omj$zstW91zX?b_xF|n)~mM1>!x;a?D;E-j=*K zUvw-P^~ycp#hvl{oU>e&Ik5KisQ2$DJ40Ug zu|mW8|00!YY0!%x@pvvx5iN0+bl?U3L*|0zWvHUAM>njYYnIsKm$g=SY5dRjhlQ{W z+3&V5r-VERRI3p2VTkOHNYWx@TTN&2>rzZ#@I9o*#0sV0=4B1R8wsXBR9(T(U#SKy zs?8HvE&sH}(eX(;!`|9o4AIR1D`Xp}6_)I*l3}dR}r5uQ_kLtmmVYAEpN0$75tj z4l;|!q&5boRWs<37$}DA*e+Sg7pLUUEV2cE5>Xvc98CHP*~;c)`AloZhwWVqxmV^C zGmvm<X4CUNbKD;b!N)#J7?*Udr%5`)In-we|^N9wMt za_8tHrhoPi`Jlf{v&W>%8j0tzFG{DonpwO={~cE3*iyc1VJRGcwAztHJK~hxGsrDH{m@c4b4KWXP@6L}-9SS1r^}tUW_lX@C8asI*_uD>v72)Vh5qf@*0ZQuB1H zUfGM1m&W-8=AW4)8E~(%s#mXIl$*OT=2?&Pn#cWtJFCL&y(jOFop_^=CCrZHf4Q1J z5LA4P-F~G}RMryN0mh~}+r4c~lLOSO2MN;)qz|7#=0-LVg)UUv;BXxRxe#4%QOHma7M% zB&zF(GUrD=bwN|h@si6Z6Unn#Jyi1w_-KhR&R^HU*~KqVvOZ}D;j^na^&xZ2Fg*+IEAYQ?jan7D z$NvGeEt+h>mIsk4Adf}6BQq<4NKM7u(Fd~L6nshZM^J$?uuJb4OlrA)qok)6w8OD@ zO}1%ifN=uuAluyDn|cMAbwZW*bY+Ni3GsO*A^rIR)Hj%5g`wh|8Cgwo@Zl_2w_NA} zzK42Mrv{pe-3|Q%-vQ@8My3UKsrnb8wxrD|cC_HiWauc3mSDlUmfo`X6cP^7p}(Sw zvrE>s!3MW~!qI1Q!6g@2^B7dL`^Ev=96h?d5|c9-2H88|OyE`a?Y++**s{vmDwv3T z12cmAuYJRY=}`QgYk;ZT!BMdd-o^qmBYF9ixEja7PmH@;lzx0 zjS}i0v5JbEeo-yW<~F3k!pJ;&9t@_&h%A#)$0tmrt26A5d$aPK>Iueokg`#91%R{v zwVM#^&)aR~dWRWo37J9Zi%ThnPdm3PyUrJM-;2-OYPjYeoU*M4v;5iX;a6N94}UFB zWadKYb8}g1+IVT!U%)$M@_uv%m1TbmY1>HO4*S!^5LsRWX(ghP5Wl0M{eQ)@97Ng> z^z!Bsxyw-tafI6av~!I967imrVhQVqV?ccQRrW#=jCTKYFm_);MlHon6M+8}bD?9; zT@q~^pd`FZ@JtzjvH_Rer`hc={?MS8H~bG&A)#Ii9o8MqO!x=!D80Z98-`^Ey&bgo&0EBEHEp*R|L`R5@!bE6;Dl}XPg+d1{i5w~ zEp)^Y3k}EqG=JET z_TINP=nBRAB#1`GzRX3%_i@yu+Yh>G6lVCrlITQJE43F2Bz`056_WQ4@59U}o>WxJR)?X)oiKuRmeR?3EnW z1VeL}SbpUn)w!;CMLs#=66QrvX_F}aL!j^eR6*bfUYDi$P>&v1HE&RA-vEgfAij$-AU1Z0_5GPsaX^pU>;iQ@orHC*>+SA6 z5f^q-gLwyJ$~;^Hm5nM{!}m@E6)gfOYHNZVQeiPc6M5kmbZqt`Yoz*;ZPAf^s(DWe zweH@d?lqOa3aI{qK4fe8Zhv|8_ez(qgjUplA?8P3G0OVIX~~W)?I~3j(sB&O`{+ji zl()LvrFwO_7R6>g#cXP73UAYh%mI5=mMw2VwUz-N%$qOtyNQD@aGlad`^P zEMIyxE#IUliC!MZ@3Xj3C%eTxiRn$ef)k_yIB!AmZ?5(Ru=Re~=Yvn97LTWe1(NtDCij zBw@uDnyaF|Vq0?o4yxq3;E%2iJ0lw?O${fnoy`QxTT7*6;PjW)RXwv*-|C2AO}}m) z$laN9&ENY)xcz6}?@CXLtOX&isIsz;p#3+3_x;WC^g#`L$|2F#$Ox?DVS|qyAbe$H z@*s%Xe$fI8VX@6j)zhq-g)K!LozlhkL>v&ly@ML>&f#X7dkAq~Nqy2WJNqiv{^qV- zei6r@#biSv7a+dt`xA^_AG%o2S?WEiKLc=CZv^Q4qRz!?o%adZJP*fFV=!fNTUv+} zz-RI7KjznBG{Xj;0LDI)vsv{fXpS=Z_S>9*x?mOegBb8K;*1^Fla-hR1VOg*4VG>x zo#BS)&2L7JR_~{1+-t?#KyE$i?`f^Rxv*sQMFcLdT>IS@8fR{TZrDc>$+bhf$ zOu>+8L-dC$e>m)NcNU49vcC`%wDqis>hMuc&bQSIl|(8Cee>gHg2BZlZY1Wa!V#S- zDuHhTAFA5Y6VCS!XNU}yWv7%+=l%48%>9Y=bj9QWJK%Gz9-H@C0Jvro=SSrq@qJz~ z-ChpswoSW_Hg+B?oezu8%ts!aX;arKu~xA*Lx?N0UC|Iz;E>=mZ_TV2_1lFhj)gb~>{mtrmslqA+id?hjrE28+tV=?0&fjB5<+lo>-o*{ za}q)4ViiPdha(mExQR*g36yv|*_x>d>i093arfVQBTPEfy5nf!7n_`wQi7drr};y_ z=`^eUY!2^xc9&5j`q>a$edsvxs)DfpX*e0x9SVNb48E)e4&(Vv$;Y68qbdG6;C~-z zNX@R{UW`2y}7| z9G_wg(W4fKoWiNi;NuRIzGl6bRR{<#6Bx~+gNdK`%B^k`mE># zFDK@afC9&^u{OvuW_gxE6)EKlQY{k91guvw(tujb+X33@%QhPNiz8$Na^euab@n?IZX$%Z?>~NU zfPd(0wwO`B2p@h`!lOLI)OB9t{pF1}pG>*cYU!Jrd}yh&HgwN+E^JS&UALhOQ)j2g zG0DecBFUWe{hEe1b_=Fen7p2I!lf(S&YF&62qi4y`TM?z0&m6r0E2K^cEDC zyZS&hvu4GDf+gHaq&RsQ|Bh2FrdJ(-48!6bpoyKMnn(qE!Zlj<;opXE2)KdpqZ zCjEabeA=ni6XF2R_*k}$CYojSZpy9h({(1iv~l14MS zouc!6m0Leg!W}Spdq$;f0_v>ZkdtQ(l9XHFC=#Ogk9i}|zEJL+iJ&FPS|Z}p_0)8n z3*U>M4WP!^Z!&Zu>vEcl(fM-MR%WJbO1j=X1|(TBsA`Zpz){{6bojIl$7@*SY)2o? zOstxPPkm4s=5Mi9#U*Y;s$OW4K`g-;`V8K#z&75&~0uWTfRU*xXDQyvtnso}+(AbQE@=$f3RPfpzd7IzH>VCoRrc$2ii3Ayg(H zlo~9FLi_sDv`#Hk_8$jC+QEi1gRT0;YabPvy;)1@fy)UC*Rfl!1-$^zvpB8?K)C)ja@CQZ$GUn8>A_2)Sp> z-94&L^0INxRjgFT^5*)0ZtV+ZxP-`0f5{0!Xr_Ntj=c8j4f_6Xd@Hp;=5z;}GO^ka zx6Fvgf*)W?=IoT`hs%|+zmke|ETlmge|p)WHhu@>qi#)%*?&UdYuEDoM%+$cJ)ajO zsYkKSnkFSMRPcLRL2jDe=-N1T*X);8E<`)9w=hNrcS|W#heGRkv?_LcW^i9$BOHzT z7{v^|dsFNkje{`x2XvMeq`GS2OaGzLeMSQ&j5NbBQiuhWfASMdsF32F+IsTNvgop_ z>b6^L{5UX*ccSe*1w$i~$>X>lxw!y@Pap61)B2!hg24)wCe__svTSkCD3$MGa2ll> zQ#y)Mwb*U&Ay_kfHbp0y=>u6tzzinrzXBM6~BGMzZ_^q z6pOZ)lfs{WkX7pki24tW&pNnnRAuWZ+{5}sMD;2^)qRnvHxNi?Va66W; z%?*GXs!T2=aZ_6`Sv<7qq!a2#)-GWp3TI4ArkF$E*zrofd#FOmq>ip63ANn7IW4{g zhfpF}7P3=IQ#mU`ig%5a(ebY)0ixZSVOa7H%`Q#gqaW-Q77Be#39jSAj7B3^v7tK2 zLAfI4Bf1>~vqRqNlhJ)GibtWmc1mmwJkGgZiu;@6l?m(HbIgTngHY)1sT z%7i}Je0ANF=frr9BWpjRrxuXQlNyjN0?xHfeG(s#@LtYB@UL1D!L*V+Gu2=9jA+}w z3R6+0GHN zt`fdM@VR+%#lriizA-eZ2;+OwYJc0nDI5ZD8KM%QD^GonuNy<+o5tK+HLBzwnFOVV z{6o&XtL~@krpgK&1ZkpC_CNhKWJ!DaM%?u|;(-)ot%vhn|1D)IZt=Z<31l|v>*`r@ zTbb&W?(5Tq_&KDNh!>BTLW(8%_fP@0o?9w7O}MrybZ2Ch_j%dz0E<7do_a<-X|Im7_1%0|OoPI{WasZUat4rZ49q7EiXl zq50GqFrPJNT3OsFzO5690|bdQtkKt+XBLcb80Vo8dvcK3=x_gp4A7A5=cd z%0d0sXJ_ESnbj{w>U3j`Q9?4VVfKm48(Rr^S6V=t$ul`AO_92yIGt|JwUtuzPubB> z;G?}roC3A#?|&Pzv?zLzNMp^gyIH(qsc14bjO?O&7?oxa7QLs61+z||d77n_Xn_mU z^v2+rdQ5y7{->lBZ5yP1n4{)h$8=*6q5kZcb%-BJeS!j5t!mp^T<760>{h}&F5^Me z{@$;K4T2|6u6@mKU*H<4gMRrYvQb?Wsh#P*|G(1g@Y{jHwXz~-dvTEE)8kV9P#3AC z@OmKyk(wL0RC6eGhc{ZL5GlqbtR@*tRx5lVuQhRRz4IYS*@X{XALHDtuHlS$phE$^ z%?hWx8Q3c*tgRpEGTq@=k`_96UYfzH6k~qK#xA$K&z3nAALOV00hTRysqlRMd21d{ zu$FlRS2o2f9`r`XixNl%q>BuiyhpDW12tb3^OdpqTEUmjDW%X8`dqFAUCvFn2f|>o z`z+&tES*kYm9*^S)3zgsP&RoBS~`_ z6y;ZXqYljM+jsNMe;a-tC`uh76nwGpIqP==E}-NeTq+HSzXZ${F#h64HcuFgaxm zb#=T}b00lG8)7}Cr>0%{mx>%QRP1d}I>OtR4>2xVA2J6^2V`h24d~l{d1EGB)2;TZX;3?8 zo$)_UO6}TtB!$ncPc*N-%{{RG)vRy}VGOyw93;UNXJZlT zHtlOuebUm7-q*EUD2uIbfKDdy)2vDQ6?|9%lLliHl6n~;$tL>$$k0IlyHkp>&U?n- zZo@!2NuUOzh!7X}o^_*qg2q>igM(UTO7EHq6*U5_4QQ{JJ3m?=eA*ZD>OwVSX+tvm zqb|#3Ma7^b@mX}GTHw&;$ z5AhNMXto6kPk%L)90e6@&PxHg+tRz!sn)250E}Jux4p1apsx1%iU74P6P?$7z1QWG z=Z;Lzeq&|8&)Wb?QQ_q1<1~le;#Y*>tUJSg{x9nDSCc0r%d_0)80=EK2yuvge{Ckf z`_zR|a);4#q`!Ap8Sa3NjR?X2RSRHFhO8X?X!_}`PWOb}OdZr;f!h%nqhIeIN%C1Q z>8I!Z{MQCbJB8u58vPiS=K<$b#Iy?#LZrt;-p5EI`^BHL2=&r5v?V{|Q?)`DN!zEH zi&j+t4#e-RmcZBs9=Kj*wm&kf3DEgnOU{>&8ZVttRH0q=MLC3!e?-#nf>ffxf~RdY zwsBQOs=Dzfr<%9+Bmcq6I{z>JLm*ZNhv1Z*FhHr?3}Spr`=&|4wKFAUqr4zCCaSfB zJD;{t>5-ZNEz3{;U7aRHvgq*Az9&{Rs8*!LwETojRoXD(^t)U@Vg4pdTTDpQ7VWRO z(7FsAW>47ur(vzZPKYI3eRvLLQTjl^=-2q#Wr2!`{4&-8$cP91o3W71lJevZLoj#7 z%kGb*NMfZ$`kUygS^J%Q*!%_zq9rjYsqJLNC@0k!U$Y9`Lb-c9-FuCb)Ezv|UIB?S!Cv*qxaSg40YdMue|-EQObHCzJ2ZUK0)1jfalw!HMxM%QX zIk+s-&2_qnp4$$`xY;oYQWiUMZy#al4>^4GODJ zBBv>Wq(trMj^^pr;oTIG(XQ}ClI)ySFm#=Z3|ct;cr5@TdxY9R2m6zMGyG*hhQCW> z+x|@nuKyIWFbG8rBr)%C&a|z|M)1(v$8v8GVVuNmy1L4=EWA}iH+nueLJ*3Pr1#zP z9pX*G6LNnzlmaAGsCPN~k`}xmg>F5;>-L-p+#N?2XT}0l=;l~8P=h=@zf=#+G$|er zN$_IHX{Sdi7LzN&xmOdNUbAMsnAsN1SxPNWemw`{6DVQ@l_S&Y#Ra+(tmkgmF@}HM zer&b!z6lt4!#$Nd+dbg_zpg`eb?Td(?o~(S4m?G2{m@P9vFuSCxu5;S(EW|~qGPp{ zYH#V`#eKQUd&w%p)VFxDV(>^pP!dJ2WUtY^9f(Xl%g$3PcwlBWiAj{}FF3tXhS~MG zCpn`TZ#K7Yhu&JH!li}GY}5k7i3F;v8NNs=Hxs`?8jF^0${DKQg?VmxXQ<8u88Yk=K-4d_dU0HrShde4yE0K<)(?GZxG z=|iyw4ss7JPL{cVP>pWKu+ZwZ9Gpc;4cFGBt$n1>1uX?qXk?pa$*E#(^z>U|&SB6l z#;df7jAop{c3u8_ykPf`%I8yi!y^NN|4+vj>wJHdqF|eCekHS~mu_^ zCFA#Vr)OM_K@A!$eO)J-rEEAMtO=6CR0XYM(A=11vU_p&RFlbs{SV;EP_k$&GL8;Y z!11&Hjn-UZdqew-VT5_sHo$>7cVEk)wE<^x%cs_q4pSDj!APcn*B&Sod0vmU7UIhI zh|~H~!n+D`k&w1yah(}#b%fI}uB#UOSFQ_lyHT2AVDfE52cweGkJIB3$WJr6Jt zyFYsKqbK&EjSwi2Kv)0Ai4{Z53}JZLG!iLO$=cUfjMLHiE)Hp)S_ zw@zcT3xrL&Rq7s#G`hevo7p2!tpczbXM{%?vz!Tl)k3I&hM)i)s_oMGAlE)B&z0m{ zSEK!tmlijYy9_e_-RKe*mIZ*6)j|L-0sV>UP+ZiI%hS6GWYS+zJKZMIDcf3^25dZK zV3~ws#$*5%8o4~cLVI-7$U9+U4!d>!;~dn#JE*0a5aO~^DyLoBI9$X|ezDTi^j<6Y zH-4`fP{otIzH3xM2Nw=w*x(* z9qEC35UG*hd-Vn#VM7~E@%l;Xn#P0Mq$(N*%7-n9-0W%yaY3eoSS}(p53?%{x0jCl zX0&jMB=ltJh7bMqd$_WY#NoW0eoceB$nIkR+8b>fw@e`znU^3xbQdY8%3W)-Q!Tc>JL;OmQb0;%Tt}<@zH)>5EW-_oWp{h+T zGQVuUy0z!4vW$i*aq8?YP`_vBxLfP!Do@J{Mul1#{o@KoA%@t%{0hrK%b_5?TpER6 z+;acDI`%*t=4;Ua!|Ut|F0h}~H=0ouYXeRhE7G~(nQ;R}WP_|9?!s7C6w`d~p;s24 z{^@uM0$RKj#|kphF^1=!U8UUG)@x!vjNiQFL@GdT^Bn_sOD>wCe`PwFzBScMmf| zsTR0^yEHoNnh8DU*IB8U2^{kCQl`^))du=rSFWMTr}rysmtpx@1acg`o1REqCI8 z2iuF5fhy4J#0uHX6w&{L3P3l#H24Pr{j|wX!^7#t3WFB)nyyx)-j{1%cnPWCqrIZY zpGyS>AJ!IR?#mD_lN{P=far3Y)<9qcxn6pO{@PBLF#pKqeyVi z_XxqtGySgL31NBMV@@tMSydM9Xx&ztpu7N}{Yy6@9_R*t?fbbIfSxq3QPSjt^|}UU z;#tniOW#9H3!Yayuy%hjlz04(djLya!$!G)`+GL!ai=~b#1STGHWBFj zfoW2a4`4Er8DYVq1g{Zyrz`uYq68qrzUQ^lEjCjx|89p4Lisvn8ic@c9?S)_5faAS zRVZ~9>SLo6w#HO`H?J|4f)5MYfLvu9dP_*{EdM!hbKD-GXj-(}r;tUA2HL&>OXaxw zcesse3O+8IhSj5OfBWO=+oLAZM5N6C6oU9n1#$7`VPE8;v-+3U-HtmF{6DaYK8P&A zj7(dOL0UnH9L`3MjvF;^^e}DayB5JymqtkZIQ2yx??s_)2n`26^qnTdsl77(yQB4= z@h}{|@AvBH!>Mfqxk$it4i!EE(58;CI|({}=ZL6|#Api4P0^h)M+6yBpj^}A;Ui<~|hTLX!6u&m| zOo(-9lH;NSkC{GhsXokc3$LO&!n!PH97g7yoRO4dJ&T_punt1g`(k_AY3S{-_^qZX z_$bgF$74Yp^G$1A0cuA;x&!z^3QZ$vB-%*6nA49q5<#tgU;SbN{Sh`CFu07nx)pG<6bhqx1%&d-{ z!t9Tp0l=KP_bb9(`LSxdcveixW|32BAzU~K^my&G=$t{PXIui?tUU;31^Q23%e6-Qc~$ojXGL z`9dlsojV@Kv1|Q}TPQPE)%);9zKqM26u}p?W zF^{nJhJ<4hVMh;Ckq z2x#7>y1!L5-*?}?nwO@;09+z3sD(J_LSe?wpFbn^R&D*ijPvJ<#r+i#W?TX7$(pE^ z-UOa}qF?v1``_#(KhBl!Ywqdcd7F034Oob;HZJ0d(P( z+3GFx=I;G?O#0T@9^MJ}*H)By2E2JGmjLuF(?!m-ygWT4W8-YNO>VOGJJuh zOf=)sdG8p3Myd(SP1qHqSMw@({vqHwCtG$eS&-;hHO);pAmf@^_wudKa(Q#;;SDTD z=Op=mX8;|&(&1;&xn_-yPV_dP-H}qWDx5Z(u6*w72`m88Hy=9zY!iH1FDhK~XW@+C zH=sjMY#(!_)z{nGecyRL@&CWSw_<&IoHi~)NUCiFp0MIG-%d7secaV|Nl83ihDT>5 zodX7Q&-q;7G%)aR72x?POYfdJA|aC8WeRjbadi`y+Q&!T`i~HYryQLDJO_mH9Pmh$ z0H>vK9Q@~x@rihMX?Cjuizt~OU}JfLtnKqhoeq=byA+R3YEJ~Z>?C^xf#QlOK5E7lWs(pUXO@geCy%%#nQn literal 0 HcmV?d00001 diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 6859ea529..ce5f86de4 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,7 +3,7 @@ import inspect import os -from typing import TYPE_CHECKING, Callable, Optional +from typing import TYPE_CHECKING, Callable, Literal, Optional import ops.charm import ops.framework @@ -23,6 +23,8 @@ logger = scenario_logger.getChild("ops_main_mock") +OnNoEventHandler = Literal["raise", "warn", "pass"] + def main( pre_event: Optional[Callable[["CharmType"], None]] = None, @@ -30,6 +32,7 @@ def main( state: "State" = None, event: "Event" = None, charm_spec: "_CharmSpec" = None, + on_no_event_handler: OnNoEventHandler = "raise", ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type @@ -77,6 +80,25 @@ def main( if pre_event: pre_event(charm) + if not getattr(charm.on, dispatcher.event_name, None): + if on_no_event_handler == "raise": + raise RuntimeError( + f"Charm has no registered observers for {dispatcher.event_name!r}. " + f"This is probably not what you were looking for." + f"You can pass `trigger(..., on_no_event_handler='ignore'|'pass')` " + f"to suppress this exception if you know what you're doing." + ) + elif on_no_event_handler == "warn": + logger.warning( + f"Charm has no registered observers for {dispatcher.event_name!r}. " + f"This is probably not what you were looking for." + f"You can pass `trigger(..., on_no_event_handler='pass')` " + f"to suppress this warning if you know what you're doing." + ) + elif on_no_event_handler != "pass": + raise ValueError(f"Bad on_no_event_handler value: {on_no_event_handler!r} " + f"(expected one of ['raise', 'warn', 'pass'])") + _emit_charm_event(charm, dispatcher.event_name) if post_event: diff --git a/scenario/runtime.py b/scenario/runtime.py index 3b61575c9..a9ad99ef3 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -8,6 +8,7 @@ import yaml +from scenario.ops_main_mock import OnNoEventHandler from scenario.logger import logger as scenario_logger if TYPE_CHECKING: @@ -172,6 +173,7 @@ def exec( event: "Event", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + on_no_event_handler: OnNoEventHandler = 'raise' ) -> "State": """Runs an event with this state as initial state on a charm. @@ -213,6 +215,7 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type) ), + on_no_event_handler=on_no_event_handler ) except Exception as e: raise RuntimeError( @@ -238,6 +241,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, + on_no_event_handler: OnNoEventHandler = 'raise' ) -> "State": from scenario.state import Event, _CharmSpec @@ -262,4 +266,5 @@ def trigger( event=event, pre_event=pre_event, post_event=post_event, + on_no_event_handler=on_no_event_handler ) diff --git a/scenario/state.py b/scenario/state.py index a9ca8daa2..a8ff8f64a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -22,6 +22,7 @@ from scenario.logger import logger as scenario_logger from scenario.mocking import _MockFileSystem, _MockStorageMount +from scenario.ops_main_mock import OnNoEventHandler from scenario.runtime import trigger if typing.TYPE_CHECKING: @@ -439,6 +440,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, + on_no_event_handler: OnNoEventHandler = 'raise' ): """Fluent API for trigger.""" return trigger( @@ -450,6 +452,7 @@ def trigger( meta=meta, actions=actions, config=config, + on_no_event_handler=on_no_event_handler ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py new file mode 100644 index 000000000..135e2b2f5 --- /dev/null +++ b/tests/test_e2e/test_rubbish_events.py @@ -0,0 +1,42 @@ +from dataclasses import asdict +from typing import Type + +import pytest +from ops.charm import CharmBase, CharmEvents +from ops.framework import EventBase, Framework +from ops.model import ActiveStatus, UnknownStatus, WaitingStatus + +from scenario.state import Container, Relation, State, sort_patch + + +# from tests.setup_tests import setup_tests +# +# setup_tests() # noqa & keep this on top + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + + return MyCharm + + +@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +def test_rubbish_event_raises(mycharm, evt_name): + with pytest.raises(RuntimeError): + State().trigger(evt_name, mycharm, meta={"name": "foo"}) + + +@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +def test_rubbish_event_warns(mycharm, evt_name, caplog): + State().trigger(evt_name, mycharm, meta={"name": "foo"}, + on_no_event_handler='warn') + assert caplog.messages[0].startswith(f"Charm has no registered observers for {evt_name!r}.") + + +@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +def test_rubbish_event_passes(mycharm, evt_name): + State().trigger(evt_name, mycharm, meta={"name": "foo"}, + on_no_event_handler='pass') From a45bf9131f2ce9f1ae2efdb1294f614bc1c00eb1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Feb 2023 16:01:24 +0100 Subject: [PATCH 085/546] added custom event emission test --- tests/test_e2e/test_custom_event_triggers.py | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_e2e/test_custom_event_triggers.py diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py new file mode 100644 index 000000000..73fd33613 --- /dev/null +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -0,0 +1,34 @@ +import pytest +from ops.charm import CharmBase, CharmEvents +from ops.framework import EventBase, EventSource + +from scenario import State + + +class FooEvent(EventBase): + pass + + +@pytest.fixture +def mycharm(): + class MyCharmEvents(CharmEvents): + foo = EventSource(FooEvent) + + class MyCharm(CharmBase): + META = {"name": "mycharm"} + on = MyCharmEvents() + _foo_called = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.framework.observe(self.on.foo, self._on_foo) + + def _on_foo(self, e): + MyCharm._foo_called = True + + return MyCharm + + +def test_custom_event_emitted(mycharm): + State().trigger("foo", mycharm, meta=mycharm.META) + assert mycharm._foo_called From c9bcd9c89b5680133ffe546fb31c31014c3a31f6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Feb 2023 16:25:41 +0100 Subject: [PATCH 086/546] template state for sequences.check_builtin_sequences --- scenario/sequences.py | 8 +++++--- tests/test_e2e/test_builtin_scenes.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scenario/sequences.py b/scenario/sequences.py index 7f259d440..b1f896f4f 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -84,13 +84,13 @@ def check_builtin_sequences( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, + template_state:State = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ): """Test that all the builtin startup and teardown events can fire without errors. This will play both scenarios with and without leadership, and raise any exceptions. - If leader is True, it will exclude the non-leader cases, and vice-versa. This is a baseline check that in principle all charms (except specific use-cases perhaps), should pass out of the box. @@ -99,10 +99,12 @@ def check_builtin_sequences( pre_event and post_event hooks. """ + template = template_state if template_state else State() + for event, state in generate_builtin_sequences( ( - State(leader=True), - State(leader=False), + template.replace(leader=True), + template.replace(leader=False), ) ): state.trigger( diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index b65580d7c..ebd4455e2 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, Framework from scenario.sequences import check_builtin_sequences -from scenario.state import _CharmSpec +from scenario.state import _CharmSpec, State CHARM_CALLED = 0 @@ -17,10 +17,14 @@ def mycharm(): class MyCharm(CharmBase): _call = None + require_config = False def __init__(self, framework: Framework): super().__init__(framework) self.called = False + if self.require_config: + assert self.config['foo'] == 'bar' + for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) @@ -38,3 +42,10 @@ def _on_event(self, event): def test_builtin_scenes(mycharm): check_builtin_sequences(mycharm, meta={"name": "foo"}) assert CHARM_CALLED == 12 + + +def test_builtin_scenes_template(mycharm): + mycharm.require_config = True + check_builtin_sequences(mycharm, meta={"name": "foo"}, + template_state=State(config={'foo': 'bar'})) + assert CHARM_CALLED == 12 From eb79ef722774e7dd609b9db24cb5d66deaae6e01 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Feb 2023 16:25:48 +0100 Subject: [PATCH 087/546] lint --- scenario/sequences.py | 2 +- tests/test_e2e/test_builtin_scenes.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scenario/sequences.py b/scenario/sequences.py index b1f896f4f..6bf8dde73 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -84,7 +84,7 @@ def check_builtin_sequences( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - template_state:State = None, + template_state: State = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ): diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index ebd4455e2..eee8a0ea7 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, Framework from scenario.sequences import check_builtin_sequences -from scenario.state import _CharmSpec, State +from scenario.state import State, _CharmSpec CHARM_CALLED = 0 @@ -23,7 +23,7 @@ def __init__(self, framework: Framework): super().__init__(framework) self.called = False if self.require_config: - assert self.config['foo'] == 'bar' + assert self.config["foo"] == "bar" for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) @@ -46,6 +46,7 @@ def test_builtin_scenes(mycharm): def test_builtin_scenes_template(mycharm): mycharm.require_config = True - check_builtin_sequences(mycharm, meta={"name": "foo"}, - template_state=State(config={'foo': 'bar'})) + check_builtin_sequences( + mycharm, meta={"name": "foo"}, template_state=State(config={"foo": "bar"}) + ) assert CHARM_CALLED == 12 From d96f13d9eaaad6edc760442d123fb31a14407147 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Feb 2023 17:05:00 +0100 Subject: [PATCH 088/546] extra test --- scenario/ops_main_mock.py | 6 ++- scenario/runtime.py | 10 ++--- scenario/state.py | 4 +- tests/test_e2e/test_rubbish_events.py | 63 +++++++++++++++++++-------- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index ce5f86de4..6a3574eb3 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -96,8 +96,10 @@ def main( f"to suppress this warning if you know what you're doing." ) elif on_no_event_handler != "pass": - raise ValueError(f"Bad on_no_event_handler value: {on_no_event_handler!r} " - f"(expected one of ['raise', 'warn', 'pass'])") + raise ValueError( + f"Bad on_no_event_handler value: {on_no_event_handler!r} " + f"(expected one of ['raise', 'warn', 'pass'])" + ) _emit_charm_event(charm, dispatcher.event_name) diff --git a/scenario/runtime.py b/scenario/runtime.py index a9ad99ef3..7e47fa125 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -8,8 +8,8 @@ import yaml -from scenario.ops_main_mock import OnNoEventHandler from scenario.logger import logger as scenario_logger +from scenario.ops_main_mock import OnNoEventHandler if TYPE_CHECKING: from ops.charm import CharmBase @@ -173,7 +173,7 @@ def exec( event: "Event", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - on_no_event_handler: OnNoEventHandler = 'raise' + on_no_event_handler: OnNoEventHandler = "raise", ) -> "State": """Runs an event with this state as initial state on a charm. @@ -215,7 +215,7 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type) ), - on_no_event_handler=on_no_event_handler + on_no_event_handler=on_no_event_handler, ) except Exception as e: raise RuntimeError( @@ -241,7 +241,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - on_no_event_handler: OnNoEventHandler = 'raise' + on_no_event_handler: OnNoEventHandler = "raise", ) -> "State": from scenario.state import Event, _CharmSpec @@ -266,5 +266,5 @@ def trigger( event=event, pre_event=pre_event, post_event=post_event, - on_no_event_handler=on_no_event_handler + on_no_event_handler=on_no_event_handler, ) diff --git a/scenario/state.py b/scenario/state.py index a8ff8f64a..959f8e845 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -440,7 +440,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - on_no_event_handler: OnNoEventHandler = 'raise' + on_no_event_handler: OnNoEventHandler = "raise", ): """Fluent API for trigger.""" return trigger( @@ -452,7 +452,7 @@ def trigger( meta=meta, actions=actions, config=config, - on_no_event_handler=on_no_event_handler + on_no_event_handler=on_no_event_handler, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 135e2b2f5..526c40ac7 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -1,42 +1,71 @@ -from dataclasses import asdict -from typing import Type - import pytest from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, Framework -from ops.model import ActiveStatus, UnknownStatus, WaitingStatus +from ops.framework import EventBase, EventSource, Framework, Object + +from scenario.state import State -from scenario.state import Container, Relation, State, sort_patch +class QuxEvent(EventBase): + pass -# from tests.setup_tests import setup_tests -# -# setup_tests() # noqa & keep this on top + +class SubEvent(EventBase): + pass @pytest.fixture(scope="function") def mycharm(): + class MyCharmEvents(CharmEvents): + qux = EventSource(QuxEvent) + + class MySubEvents(CharmEvents): + sub = EventSource(SubEvent) + + class Sub(Object): + on = MySubEvents() + class MyCharm(CharmBase): + on = MyCharmEvents() + evts = [] + def __init__(self, framework: Framework): super().__init__(framework) + self.sub = Sub(self, "sub") + self.framework.observe(self.sub.on.sub, self._on_event) + self.framework.observe(self.on.qux, self._on_event) + + def _on_event(self, e): + MyCharm.evts.append(e) return MyCharm -@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): with pytest.raises(RuntimeError): State().trigger(evt_name, mycharm, meta={"name": "foo"}) -@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_warns(mycharm, evt_name, caplog): - State().trigger(evt_name, mycharm, meta={"name": "foo"}, - on_no_event_handler='warn') - assert caplog.messages[0].startswith(f"Charm has no registered observers for {evt_name!r}.") + State().trigger(evt_name, mycharm, meta={"name": "foo"}, on_no_event_handler="warn") + assert caplog.messages[0].startswith( + f"Charm has no registered observers for {evt_name!r}." + ) -@pytest.mark.parametrize('evt_name', ('rubbish', 'foo', 'bar', 'kazoo_pebble_ready')) +@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_passes(mycharm, evt_name): - State().trigger(evt_name, mycharm, meta={"name": "foo"}, - on_no_event_handler='pass') + State().trigger(evt_name, mycharm, meta={"name": "foo"}, on_no_event_handler="pass") + + +@pytest.mark.parametrize("evt_name", ("qux",)) +def test_custom_events_pass(mycharm, evt_name): + State().trigger(evt_name, mycharm, meta={"name": "foo"}) + + +# cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961 +@pytest.mark.parametrize("evt_name", ("sub",)) +def test_custom_events_sub_raise(mycharm, evt_name): + with pytest.raises(RuntimeError): + State().trigger(evt_name, mycharm, meta={"name": "foo"}) From 32269665a29fb0afb823fbe015a10e99017248a6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Feb 2023 15:58:59 +0100 Subject: [PATCH 089/546] simplified --- scenario/ops_main_mock.py | 28 ++++++--------------- scenario/runtime.py | 15 +++++------ scenario/sequences.py | 36 ++++++++++++++++----------- scenario/state.py | 4 --- tests/test_e2e/test_rubbish_events.py | 16 ++---------- 5 files changed, 38 insertions(+), 61 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 6a3574eb3..e4bdc8ef6 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -23,7 +23,9 @@ logger = scenario_logger.getChild("ops_main_mock") -OnNoEventHandler = Literal["raise", "warn", "pass"] + +class NoObserverError(RuntimeError): + """Error raised when the event being dispatched has no registered observers.""" def main( @@ -32,7 +34,6 @@ def main( state: "State" = None, event: "Event" = None, charm_spec: "_CharmSpec" = None, - on_no_event_handler: OnNoEventHandler = "raise", ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type @@ -81,25 +82,10 @@ def main( pre_event(charm) if not getattr(charm.on, dispatcher.event_name, None): - if on_no_event_handler == "raise": - raise RuntimeError( - f"Charm has no registered observers for {dispatcher.event_name!r}. " - f"This is probably not what you were looking for." - f"You can pass `trigger(..., on_no_event_handler='ignore'|'pass')` " - f"to suppress this exception if you know what you're doing." - ) - elif on_no_event_handler == "warn": - logger.warning( - f"Charm has no registered observers for {dispatcher.event_name!r}. " - f"This is probably not what you were looking for." - f"You can pass `trigger(..., on_no_event_handler='pass')` " - f"to suppress this warning if you know what you're doing." - ) - elif on_no_event_handler != "pass": - raise ValueError( - f"Bad on_no_event_handler value: {on_no_event_handler!r} " - f"(expected one of ['raise', 'warn', 'pass'])" - ) + raise NoObserverError( + f"Charm has no registered observers for {dispatcher.event_name!r}. " + f"This is probably not what you were looking for." + ) _emit_charm_event(charm, dispatcher.event_name) diff --git a/scenario/runtime.py b/scenario/runtime.py index 7e47fa125..17ab751ec 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -9,7 +9,7 @@ import yaml from scenario.logger import logger as scenario_logger -from scenario.ops_main_mock import OnNoEventHandler +from scenario.ops_main_mock import NoObserverError if TYPE_CHECKING: from ops.charm import CharmBase @@ -25,6 +25,10 @@ RUNTIME_MODULE = Path(__file__).parent +class UncaughtCharmError(RuntimeError): + """Error raised if the charm raises while handling the event being dispatched.""" + + @dataclasses.dataclass class RuntimeRunResult: charm: "CharmBase" @@ -173,7 +177,6 @@ def exec( event: "Event", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - on_no_event_handler: OnNoEventHandler = "raise", ) -> "State": """Runs an event with this state as initial state on a charm. @@ -215,10 +218,11 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type) ), - on_no_event_handler=on_no_event_handler, ) + except NoObserverError: + raise # propagate along except Exception as e: - raise RuntimeError( + raise UncaughtCharmError( f"Uncaught error in operator/charm code: {e}." ) from e finally: @@ -241,9 +245,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - on_no_event_handler: OnNoEventHandler = "raise", ) -> "State": - from scenario.state import Event, _CharmSpec if isinstance(event, str): @@ -266,5 +268,4 @@ def trigger( event=event, pre_event=pre_event, post_event=post_event, - on_no_event_handler=on_no_event_handler, ) diff --git a/scenario/sequences.py b/scenario/sequences.py index 7f259d440..36f11f010 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -40,34 +40,40 @@ def decompose_meta_event(meta_event: Event, state: State): ), ) logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event + yield event, state.copy() else: raise RuntimeError(f"unknown meta-event {meta_event.name}") def generate_startup_sequence(state_template: State): - yield from ( - (Event(ATTACH_ALL_STORAGES), state_template.copy()), - (Event("start"), state_template.copy()), - (Event(CREATE_ALL_RELATIONS), state_template.copy()), + yield from chain( + decompose_meta_event(Event(ATTACH_ALL_STORAGES), state_template.copy()), + ((Event("start"), state_template.copy()),), + decompose_meta_event(Event(CREATE_ALL_RELATIONS), state_template.copy()), ( - Event( - "leader-elected" if state_template.leader else "leader-settings-changed" + ( + Event( + "leader-elected" + if state_template.leader + else "leader-settings-changed" + ), + state_template.copy(), ), - state_template.copy(), + (Event("config-changed"), state_template.copy()), + (Event("install"), state_template.copy()), ), - (Event("config-changed"), state_template.copy()), - (Event("install"), state_template.copy()), ) def generate_teardown_sequence(state_template: State): - yield from ( - (Event(BREAK_ALL_RELATIONS), state_template.copy()), - (Event(DETACH_ALL_STORAGES), state_template.copy()), - (Event("stop"), state_template.copy()), - (Event("remove"), state_template.copy()), + yield from chain( + decompose_meta_event(Event(BREAK_ALL_RELATIONS), state_template.copy()), + decompose_meta_event(Event(DETACH_ALL_STORAGES), state_template.copy()), + ( + (Event("stop"), state_template.copy()), + (Event("remove"), state_template.copy()), + ), ) diff --git a/scenario/state.py b/scenario/state.py index 959f8e845..8538487da 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -22,7 +22,6 @@ from scenario.logger import logger as scenario_logger from scenario.mocking import _MockFileSystem, _MockStorageMount -from scenario.ops_main_mock import OnNoEventHandler from scenario.runtime import trigger if typing.TYPE_CHECKING: @@ -30,7 +29,6 @@ from typing import Self except ImportError: from typing_extensions import Self - from ops.pebble import LayerDict from ops.testing import CharmType logger = scenario_logger.getChild("structs") @@ -440,7 +438,6 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - on_no_event_handler: OnNoEventHandler = "raise", ): """Fluent API for trigger.""" return trigger( @@ -452,7 +449,6 @@ def trigger( meta=meta, actions=actions, config=config, - on_no_event_handler=on_no_event_handler, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 526c40ac7..e1e21575f 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -2,6 +2,7 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource, Framework, Object +from scenario.ops_main_mock import NoObserverError from scenario.state import State @@ -42,23 +43,10 @@ def _on_event(self, e): @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): - with pytest.raises(RuntimeError): + with pytest.raises(NoObserverError): State().trigger(evt_name, mycharm, meta={"name": "foo"}) -@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) -def test_rubbish_event_warns(mycharm, evt_name, caplog): - State().trigger(evt_name, mycharm, meta={"name": "foo"}, on_no_event_handler="warn") - assert caplog.messages[0].startswith( - f"Charm has no registered observers for {evt_name!r}." - ) - - -@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) -def test_rubbish_event_passes(mycharm, evt_name): - State().trigger(evt_name, mycharm, meta={"name": "foo"}, on_no_event_handler="pass") - - @pytest.mark.parametrize("evt_name", ("qux",)) def test_custom_events_pass(mycharm, evt_name): State().trigger(evt_name, mycharm, meta={"name": "foo"}) From 2b458ba6c38411dce3eb1fdc9c9e49a03efbdbaa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Feb 2023 17:23:51 +0100 Subject: [PATCH 090/546] event queue --- README.md | 46 ++++++++++++++ scenario/runtime.py | 87 ++++++++++++++++++++------- scenario/state.py | 20 ++++++ tests/test_e2e/test_builtin_scenes.py | 8 +-- tests/test_e2e/test_event_queue.py | 55 +++++++++++++++++ 5 files changed, 190 insertions(+), 26 deletions(-) create mode 100644 tests/test_e2e/test_event_queue.py diff --git a/README.md b/README.md index 714488988..129cb22b2 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,52 @@ def test_pebble_exec(): ``` +# Event queue + +Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. +On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. + +```python +from scenario import State, StoredEvent + + +class MyCharm(...): + [...] + def _on_start(self, e): + e.defer() + + +def test_defer(MyCharm): + out = State( + event_queue=[ + StoredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') + ] + ).trigger('start', MyCharm) + assert len(out.event_queue) == 1 + assert out.event_queue[0].name == 'start' +``` + +On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. + +```python +from scenario import State + + +class MyCharm(...): + [...] + def _on_start(self, e): + e.defer() + + +def test_defer(MyCharm): + out = State().trigger('start', MyCharm) + assert len(out.event_queue) == 1 + assert out.event_queue[0].name == 'start' +``` + + + + # TODOS: - State-State consistency checks. - State-Metadata consistency checks. diff --git a/scenario/runtime.py b/scenario/runtime.py index 3b61575c9..7dcad41c0 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,5 +1,7 @@ import dataclasses +import marshal import os +import re import sys import tempfile from contextlib import contextmanager @@ -7,6 +9,8 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union import yaml +from ops.framework import _event_regex +from ops.storage import SQLiteStorage from scenario.logger import logger as scenario_logger @@ -38,9 +42,9 @@ class Runtime: """ def __init__( - self, - charm_spec: "_CharmSpec", - juju_version: str = "3.0.0", + self, + charm_spec: "_CharmSpec", + juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -49,8 +53,8 @@ def __init__( @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -166,12 +170,48 @@ def virtual_charm_root(self): (temppath / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) yield temppath + @staticmethod + def _get_store(temporary_charm_root: Path): + charm_state_path = temporary_charm_root / ".unit-state.db" + store = SQLiteStorage(charm_state_path) + return store + + def _initialize_storage(self, state: "State", temporary_charm_root: Path): + """Before we start processing this event, expose the relevant parts of State through the storage.""" + store = self._get_store(temporary_charm_root) + event_queue = state.event_queue + + for event in event_queue: + store.save_notice(event.handle_path, event.owner, event.observer) + data = marshal.dumps(event.snapshot_data) + store.save_snapshot(event.handle_path, data) + + store.close() + + def _close_storage(self, state: "State", temporary_charm_root: Path): + """Now that we're done processing this event, read the charm state and expose it via State.""" + from scenario.state import StoredEvent # avoid cyclic import + + store = self._get_store(temporary_charm_root) + + event_queue = [] + event_regex = re.compile(_event_regex) + for handle_path in store.list_snapshots(): + if event_regex.match(handle_path): + notices = store.notices(handle_path) + for handle, owner, observer in notices: + event = StoredEvent(handle_path=handle, owner=owner, observer=observer) + event_queue.append(event) + + store.close() + return state.replace(event_queue=event_queue) + def exec( - self, - state: "State", - event: "Event", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + self, + state: "State", + event: "Event", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -191,6 +231,9 @@ def exec( # todo consider forking out a real subprocess and do the mocking by # generating hook tool executables + logger.info(" - initializing storage") + self._initialize_storage(state, temporary_charm_root) + logger.info(" - redirecting root logging") self._redirect_root_logger() @@ -224,22 +267,24 @@ def exec( logger.info(" - clearing env") self._cleanup_env(env) - logger.info("event fired; done.") + logger.info(" - closing storage") + output_state = self._close_storage(output_state, temporary_charm_root) + + logger.info("event dispatched. done.") return output_state def trigger( - state: "State", - event: Union["Event", str], - charm_type: Type["CharmType"], - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, ) -> "State": - from scenario.state import Event, _CharmSpec if isinstance(event, str): diff --git a/scenario/state.py b/scenario/state.py index a9ca8daa2..ffa2b49bb 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -382,6 +382,12 @@ class State(_DCBase): unit_id: str = "0" app_name: str = "local" + # represents the OF's event queue. These events will be emitted before the event being dispatched, + # and represent the events that had been deferred during the previous run. + # If the charm defers any events during "this execution", they will be appended + # to this list. + event_queue: List["StoredEvent"] = dataclasses.field(default_factory=list) + # todo: # actions? @@ -489,6 +495,20 @@ def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): return sorted(patch, key=key) +@dataclasses.dataclass +class StoredEvent(_DCBase): + handle_path: str + owner: str + observer: str + + # needs to be marshal.dumps-able. + snapshot_data: Dict = dataclasses.field(default_factory=dict) + + @property + def name(self): + return self.handle_path.split('/')[-1].split('[')[0] + + @dataclasses.dataclass class Event(_DCBase): name: str diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index eee8a0ea7..195f29a22 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -1,11 +1,9 @@ -from typing import Optional, Type - import pytest -from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, Framework +from ops.charm import CharmBase +from ops.framework import Framework from scenario.sequences import check_builtin_sequences -from scenario.state import State, _CharmSpec +from scenario.state import State CHARM_CALLED = 0 diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py new file mode 100644 index 000000000..f9d767ba6 --- /dev/null +++ b/tests/test_e2e/test_event_queue.py @@ -0,0 +1,55 @@ +import pytest +from ops.charm import CharmBase, UpdateStatusEvent, StartEvent +from ops.framework import Framework + +from scenario.state import State, StoredEvent + +CHARM_CALLED = 0 + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + META = {'name': 'mycharm'} + defer_next = 0 + captured = [] + + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + self.captured.append(event) + if self.defer_next: + self.defer_next -= 1 + return event.defer() + + return MyCharm + + +def test_defer(mycharm): + mycharm.defer_next = True + out = State().trigger('start', mycharm, meta=mycharm.META) + assert len(out.event_queue) == 1 + assert out.event_queue[0].name == 'start' + + +def test_deferred_evt_emitted(mycharm): + mycharm.defer_next = 2 + out = State( + event_queue=[ + StoredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') + ] + ).trigger('start', mycharm, meta=mycharm.META) + + # we deferred the first 2 events we saw: update-status, start. + assert len(out.event_queue) == 2 + assert out.event_queue[0].name == 'start' + assert out.event_queue[1].name == 'update_status' + + # we saw start and update-status. + assert len(mycharm.captured) == 2 + upstat, start = mycharm.captured + assert isinstance(upstat, UpdateStatusEvent) + assert isinstance(start, StartEvent) From 47f370fc84589d5b7d1c125904d06fc333009b3a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Feb 2023 17:26:27 +0100 Subject: [PATCH 091/546] lint --- scenario/runtime.py | 42 ++++++++++++++++-------------- scenario/state.py | 2 +- tests/test_e2e/test_event_queue.py | 18 ++++++------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 7dcad41c0..25ea95b3b 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -42,9 +42,9 @@ class Runtime: """ def __init__( - self, - charm_spec: "_CharmSpec", - juju_version: str = "3.0.0", + self, + charm_spec: "_CharmSpec", + juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -53,8 +53,8 @@ def __init__( @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -200,18 +200,20 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): if event_regex.match(handle_path): notices = store.notices(handle_path) for handle, owner, observer in notices: - event = StoredEvent(handle_path=handle, owner=owner, observer=observer) + event = StoredEvent( + handle_path=handle, owner=owner, observer=observer + ) event_queue.append(event) store.close() return state.replace(event_queue=event_queue) def exec( - self, - state: "State", - event: "Event", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + self, + state: "State", + event: "Event", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -275,15 +277,15 @@ def exec( def trigger( - state: "State", - event: Union["Event", str], - charm_type: Type["CharmType"], - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, ) -> "State": from scenario.state import Event, _CharmSpec diff --git a/scenario/state.py b/scenario/state.py index ffa2b49bb..479a0a117 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -506,7 +506,7 @@ class StoredEvent(_DCBase): @property def name(self): - return self.handle_path.split('/')[-1].split('[')[0] + return self.handle_path.split("/")[-1].split("[")[0] @dataclasses.dataclass diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py index f9d767ba6..35029046a 100644 --- a/tests/test_e2e/test_event_queue.py +++ b/tests/test_e2e/test_event_queue.py @@ -1,5 +1,5 @@ import pytest -from ops.charm import CharmBase, UpdateStatusEvent, StartEvent +from ops.charm import CharmBase, StartEvent, UpdateStatusEvent from ops.framework import Framework from scenario.state import State, StoredEvent @@ -10,7 +10,7 @@ @pytest.fixture(scope="function") def mycharm(): class MyCharm(CharmBase): - META = {'name': 'mycharm'} + META = {"name": "mycharm"} defer_next = 0 captured = [] @@ -30,23 +30,21 @@ def _on_event(self, event): def test_defer(mycharm): mycharm.defer_next = True - out = State().trigger('start', mycharm, meta=mycharm.META) + out = State().trigger("start", mycharm, meta=mycharm.META) assert len(out.event_queue) == 1 - assert out.event_queue[0].name == 'start' + assert out.event_queue[0].name == "start" def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 out = State( - event_queue=[ - StoredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') - ] - ).trigger('start', mycharm, meta=mycharm.META) + event_queue=[StoredEvent("MyCharm/on/update_status[1]", "MyCharm", "_on_event")] + ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: update-status, start. assert len(out.event_queue) == 2 - assert out.event_queue[0].name == 'start' - assert out.event_queue[1].name == 'update_status' + assert out.event_queue[0].name == "start" + assert out.event_queue[1].name == "update_status" # we saw start and update-status. assert len(mycharm.captured) == 2 From 74ff071dbc04aac73450e5d4e413836e164179d9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 10:42:26 +0100 Subject: [PATCH 092/546] helpers for deferred events --- README.md | 14 ++--- scenario/runtime.py | 21 ++++--- scenario/state.py | 71 +++++++++++++++++++---- tests/test_e2e/test_event_queue.py | 90 +++++++++++++++++++++++++++--- tests/test_runtime.py | 2 +- 5 files changed, 160 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d43e2e47e..cf2fc665c 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ Scenario allows you to accurately simulate the Operator Framework's event queue. On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. ```python -from scenario import State, StoredEvent +from scenario import State, DeferredEvent class MyCharm(...): @@ -292,12 +292,12 @@ class MyCharm(...): def test_defer(MyCharm): out = State( - event_queue=[ - StoredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') + deferred=[ + DeferredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') ] ).trigger('start', MyCharm) - assert len(out.event_queue) == 1 - assert out.event_queue[0].name == 'start' + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' ``` On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. @@ -314,8 +314,8 @@ class MyCharm(...): def test_defer(MyCharm): out = State().trigger('start', MyCharm) - assert len(out.event_queue) == 1 - assert out.event_queue[0].name == 'start' + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' ``` diff --git a/scenario/runtime.py b/scenario/runtime.py index 15a15350e..8526e520e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -184,34 +184,37 @@ def _get_store(temporary_charm_root: Path): def _initialize_storage(self, state: "State", temporary_charm_root: Path): """Before we start processing this event, expose the relevant parts of State through the storage.""" store = self._get_store(temporary_charm_root) - event_queue = state.event_queue + deferred = state.deferred - for event in event_queue: + for event in deferred: store.save_notice(event.handle_path, event.owner, event.observer) - data = marshal.dumps(event.snapshot_data) - store.save_snapshot(event.handle_path, data) + 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 + store.save_snapshot(event.handle_path, event.snapshot_data) store.close() def _close_storage(self, state: "State", temporary_charm_root: Path): """Now that we're done processing this event, read the charm state and expose it via State.""" - from scenario.state import StoredEvent # avoid cyclic import + from scenario.state import DeferredEvent # avoid cyclic import store = self._get_store(temporary_charm_root) - event_queue = [] + deferred = [] event_regex = re.compile(_event_regex) for handle_path in store.list_snapshots(): if event_regex.match(handle_path): notices = store.notices(handle_path) for handle, owner, observer in notices: - event = StoredEvent( + event = DeferredEvent( handle_path=handle, owner=owner, observer=observer ) - event_queue.append(event) + deferred.append(event) store.close() - return state.replace(event_queue=event_queue) + return state.replace(deferred=deferred) def exec( self, diff --git a/scenario/state.py b/scenario/state.py index dd694caef..bf9e9e9e2 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1,6 +1,7 @@ import copy import dataclasses import inspect +import re import typing from pathlib import Path, PurePosixPath from typing import ( @@ -37,6 +38,13 @@ CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" +RELATION_EVENTS_SUFFIX = { + "-relation-changed", + "-relation-broken", + "-relation-joined", + "-relation-departed", + "-relation-created", +} META_EVENTS = { "CREATE_ALL_RELATIONS": "-relation-created", "BREAK_ALL_RELATIONS": "-relation-broken", @@ -116,7 +124,7 @@ def remove_event(self): @dataclasses.dataclass class Relation(_DCBase): endpoint: str - remote_app_name: str + remote_app_name: str = 'remote' remote_unit_ids: List[int] = dataclasses.field(default_factory=list) # local limit @@ -385,7 +393,7 @@ class State(_DCBase): # and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. - event_queue: List["StoredEvent"] = dataclasses.field(default_factory=list) + deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) # todo: # actions? @@ -495,7 +503,7 @@ def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): @dataclasses.dataclass -class StoredEvent(_DCBase): +class DeferredEvent(_DCBase): handle_path: str owner: str observer: str @@ -528,6 +536,53 @@ class Event(_DCBase): # - pebble? # - action? + def __post_init__(self): + if '-' in self.name: + logger.warning(f"Only use underscores in event names. {self.name!r}") + self.name = self.name.replace('-', '_') + + def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: + """Construct a DeferredEvent from this Event.""" + handler_repr = repr(handler) + handler_re = re.compile(r"") + match = handler_re.match(handler_repr) + if not match: + raise ValueError(f'cannot construct DeferredEvent from {handler}; please create one manually.') + owner_name, handler_name = match.groups()[0].split('.')[-2:] + handle_path = f"{owner_name}/on/{self.name}[{event_id}]" + + snapshot_data = {} + if self.relation: + # this is a RelationEvent. The snapshot: + snapshot_data = { + 'relation_name': self.relation.endpoint, + 'relation_id': self.relation.relation_id + # 'app_name': local app name + # 'unit_name': local unit name + } + + return DeferredEvent( + handle_path, + owner_name, + handler_name, + snapshot_data=snapshot_data, + ) + + +def deferred(event: Union[str, Event], handler: Callable, event_id: int = 1, + relation: Relation = None): + """Construct a DeferredEvent from an Event or an event name.""" + if isinstance(event, str): + norm_evt = event.replace('_', '-') + + if not relation: + if any(map(norm_evt.endswith, RELATION_EVENTS_SUFFIX)): + raise ValueError('cannot construct a deferred relation event without the relation instance. ' + 'Please pass one.') + + event = Event(event, relation=relation) + return event.deferred(handler=handler, event_id=event_id) + @dataclasses.dataclass class Inject(_DCBase): @@ -546,15 +601,7 @@ class InjectRelation(Inject): def _derive_args(event_name: str): args = [] - terms = { - "-relation-changed", - "-relation-broken", - "-relation-joined", - "-relation-departed", - "-relation-created", - } - - for term in terms: + for term in RELATION_EVENTS_SUFFIX: # fixme: we can't disambiguate between relation IDs. if event_name.endswith(term): args.append(InjectRelation(relation_name=event_name[: -len(term)])) diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py index 35029046a..feb5f0379 100644 --- a/tests/test_e2e/test_event_queue.py +++ b/tests/test_e2e/test_event_queue.py @@ -1,8 +1,10 @@ +from dataclasses import asdict + import pytest -from ops.charm import CharmBase, StartEvent, UpdateStatusEvent +from ops.charm import CharmBase, StartEvent, UpdateStatusEvent, RelationChangedEvent from ops.framework import Framework -from scenario.state import State, StoredEvent +from scenario.state import State, DeferredEvent, deferred, Relation CHARM_CALLED = 0 @@ -10,7 +12,8 @@ @pytest.fixture(scope="function") def mycharm(): class MyCharm(CharmBase): - META = {"name": "mycharm"} + META = {"name": "mycharm", + "requires": {"foo": {"interface": "bar"}}} defer_next = 0 captured = [] @@ -31,23 +34,92 @@ def _on_event(self, event): def test_defer(mycharm): mycharm.defer_next = True out = State().trigger("start", mycharm, meta=mycharm.META) - assert len(out.event_queue) == 1 - assert out.event_queue[0].name == "start" + assert len(out.deferred) == 1 + assert out.deferred[0].name == "start" def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 + out = State( - event_queue=[StoredEvent("MyCharm/on/update_status[1]", "MyCharm", "_on_event")] + deferred=[ + deferred(event="update_status", handler=mycharm._on_event) + ] ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: update-status, start. - assert len(out.event_queue) == 2 - assert out.event_queue[0].name == "start" - assert out.event_queue[1].name == "update_status" + assert len(out.deferred) == 2 + assert out.deferred[0].name == "start" + assert out.deferred[1].name == "update_status" # we saw start and update-status. assert len(mycharm.captured) == 2 upstat, start = mycharm.captured assert isinstance(upstat, UpdateStatusEvent) assert isinstance(start, StartEvent) + + +def test_deferred_relation_event_without_relation_raises(mycharm): + with pytest.raises(ValueError): + deferred(event="foo_relation_changed", handler=mycharm._on_event) + + +def test_deferred(mycharm): + rel = Relation(endpoint='foo', + remote_app_name='remote') + evt1 = rel.changed_event.deferred(handler=mycharm._on_event) + evt2 = deferred(event="foo_relation_changed", + handler=mycharm._on_event, + relation=rel) + + assert asdict(evt2) == asdict(evt1) + + +def test_deferred_relation_event(mycharm): + mycharm.defer_next = 2 + + rel = Relation(endpoint='foo', + remote_app_name='remote') + + out = State( + relations=[rel], + deferred=[ + deferred(event="foo_relation_changed", + handler=mycharm._on_event, + relation=rel) + ] + ).trigger("start", mycharm, meta=mycharm.META) + + # we deferred the first 2 events we saw: relation-changed, start. + assert len(out.deferred) == 2 + assert out.deferred[0].name == "foo_relation_changed" + assert out.deferred[1].name == "start" + + # we saw start and relation-changed. + assert len(mycharm.captured) == 2 + upstat, start = mycharm.captured + assert isinstance(upstat, RelationChangedEvent) + assert isinstance(start, StartEvent) + + +def test_deferred_relation_event_from_relation(mycharm): + mycharm.defer_next = 2 + rel = Relation(endpoint='foo', + remote_app_name='remote') + out = State( + relations=[rel], + deferred=[ + rel.changed_event.deferred(handler=mycharm._on_event) + ] + ).trigger("start", mycharm, meta=mycharm.META) + + # we deferred the first 2 events we saw: foo_relation_changed, start. + assert len(out.deferred) == 2 + assert out.deferred[0].name == "foo_relation_changed" + assert out.deferred[1].name == "start" + + # we saw start and foo_relation_changed. + assert len(mycharm.captured) == 2 + upstat, start = mycharm.captured + assert isinstance(upstat, RelationChangedEvent) + assert isinstance(start, StartEvent) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index fa6cba864..2bdaf124a 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -50,7 +50,7 @@ def test_event_hooks(): post_event = MagicMock(return_value=None) runtime.exec( state=State(), - event=Event("foo"), + event=Event("update-status"), pre_event=pre_event, post_event=post_event, ) From 6d33088cbb83e755706695414280a51a9582ff9f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 10:57:04 +0100 Subject: [PATCH 093/546] docs --- README.md | 89 +++++++++++++++++++++++++++--- scenario/runtime.py | 4 +- scenario/state.py | 32 +++++++---- tests/test_e2e/test_event_queue.py | 38 +++++-------- 4 files changed, 120 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index cf2fc665c..466ea2b97 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. To give the charm access to some containers, you need to pass them to the input state, like so: -`State(containers=[...])` +`State(containers=...)` An example of a scene including some containers: ```python @@ -275,31 +275,52 @@ def test_pebble_exec(): ``` -# Event queue +# Deferred events Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. ```python -from scenario import State, DeferredEvent +from scenario import State, deferred class MyCharm(...): - [...] + ... + def _on_update_status(self, e): + e.defer() def _on_start(self, e): e.defer() -def test_defer(MyCharm): +def test_start_on_deferred_update_status(MyCharm): + """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" out = State( deferred=[ - DeferredEvent('MyCharm/on/update_status[1]', 'MyCharm', '_on_event') + deferred('update_status', + handler=MyCharm._on_update_status) ] ).trigger('start', MyCharm) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` +You can also generate the 'deferred' data structure (called a `DeferredEvent`) from the corresponding Event (and the handler): + +```python +from scenario import Event, Relation + +class MyCharm(...): + ... + +deferred_start = Event('start').deferred(MyCharm._on_start) +deferred_install = Event('install').deferred(MyCharm._on_start) +... + +# relation events: +foo_relation = Relation('foo') +deferred_relation_changed_evt = foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +``` + On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. ```python @@ -307,7 +328,7 @@ from scenario import State class MyCharm(...): - [...] + ... def _on_start(self, e): e.defer() @@ -319,6 +340,60 @@ def test_defer(MyCharm): ``` +## Deferring relation events + +If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the Relation instance they are about. So do they in Scenario. You can use the `deferred` helper to generate the data structure: + +```python +from scenario import State, Relation, deferred + + +class MyCharm(...): + ... + def _on_foo_relation_changed(self, e): + e.defer() + + +def test_start_on_deferred_update_status(MyCharm): + foo_relation = Relation('foo') + State( + relations=[foo_relation], + deferred=[ + deferred('foo_relation_changed', + handler=MyCharm._on_foo_relation_changed, + relation=foo_relation) + ] + ) +``` + +but you can also use a shortcut from the relation event itself, as mentioned above: + +```python +from scenario import Relation + +class MyCharm(...): + ... + +foo_relation = Relation('foo') +foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +``` + + +## Fine-tuning +The `deferred` helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by charm libraries or objects other than the main charm class. + +For general-purpose usage, you will need to instantiate `DeferredEvent` directly. + +```python +from scenario import DeferredEvent + +my_deferred_event = DeferredEvent( + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' +) + +``` # TODOS: diff --git a/scenario/runtime.py b/scenario/runtime.py index 8526e520e..f1cdf6247 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -191,7 +191,9 @@ def _initialize_storage(self, state: "State", temporary_charm_root: Path): 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 + raise ValueError( + f"unable to save the data for {event}, it must contain only simple types." + ) from e store.save_snapshot(event.handle_path, event.snapshot_data) store.close() diff --git a/scenario/state.py b/scenario/state.py index bf9e9e9e2..9a31019a9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -124,7 +124,7 @@ def remove_event(self): @dataclasses.dataclass class Relation(_DCBase): endpoint: str - remote_app_name: str = 'remote' + remote_app_name: str = "remote" remote_unit_ids: List[int] = dataclasses.field(default_factory=list) # local limit @@ -537,9 +537,9 @@ class Event(_DCBase): # - action? def __post_init__(self): - if '-' in self.name: + if "-" in self.name: logger.warning(f"Only use underscores in event names. {self.name!r}") - self.name = self.name.replace('-', '_') + self.name = self.name.replace("-", "_") def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" @@ -547,16 +547,18 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: handler_re = re.compile(r"") match = handler_re.match(handler_repr) if not match: - raise ValueError(f'cannot construct DeferredEvent from {handler}; please create one manually.') - owner_name, handler_name = match.groups()[0].split('.')[-2:] + raise ValueError( + f"cannot construct DeferredEvent from {handler}; please create one manually." + ) + owner_name, handler_name = match.groups()[0].split(".")[-2:] handle_path = f"{owner_name}/on/{self.name}[{event_id}]" snapshot_data = {} if self.relation: # this is a RelationEvent. The snapshot: snapshot_data = { - 'relation_name': self.relation.endpoint, - 'relation_id': self.relation.relation_id + "relation_name": self.relation.endpoint, + "relation_id": self.relation.relation_id # 'app_name': local app name # 'unit_name': local unit name } @@ -569,16 +571,22 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: ) -def deferred(event: Union[str, Event], handler: Callable, event_id: int = 1, - relation: Relation = None): +def deferred( + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: Relation = None, +): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - norm_evt = event.replace('_', '-') + norm_evt = event.replace("_", "-") if not relation: if any(map(norm_evt.endswith, RELATION_EVENTS_SUFFIX)): - raise ValueError('cannot construct a deferred relation event without the relation instance. ' - 'Please pass one.') + raise ValueError( + "cannot construct a deferred relation event without the relation instance. " + "Please pass one." + ) event = Event(event, relation=relation) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py index feb5f0379..aef4e1827 100644 --- a/tests/test_e2e/test_event_queue.py +++ b/tests/test_e2e/test_event_queue.py @@ -1,10 +1,10 @@ from dataclasses import asdict import pytest -from ops.charm import CharmBase, StartEvent, UpdateStatusEvent, RelationChangedEvent +from ops.charm import CharmBase, RelationChangedEvent, StartEvent, UpdateStatusEvent from ops.framework import Framework -from scenario.state import State, DeferredEvent, deferred, Relation +from scenario.state import DeferredEvent, Relation, State, deferred CHARM_CALLED = 0 @@ -12,8 +12,7 @@ @pytest.fixture(scope="function") def mycharm(): class MyCharm(CharmBase): - META = {"name": "mycharm", - "requires": {"foo": {"interface": "bar"}}} + META = {"name": "mycharm", "requires": {"foo": {"interface": "bar"}}} defer_next = 0 captured = [] @@ -42,9 +41,7 @@ def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 out = State( - deferred=[ - deferred(event="update_status", handler=mycharm._on_event) - ] + deferred=[deferred(event="update_status", handler=mycharm._on_event)] ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: update-status, start. @@ -65,12 +62,11 @@ def test_deferred_relation_event_without_relation_raises(mycharm): def test_deferred(mycharm): - rel = Relation(endpoint='foo', - remote_app_name='remote') + rel = Relation(endpoint="foo", remote_app_name="remote") evt1 = rel.changed_event.deferred(handler=mycharm._on_event) - evt2 = deferred(event="foo_relation_changed", - handler=mycharm._on_event, - relation=rel) + evt2 = deferred( + event="foo_relation_changed", handler=mycharm._on_event, relation=rel + ) assert asdict(evt2) == asdict(evt1) @@ -78,16 +74,15 @@ def test_deferred(mycharm): def test_deferred_relation_event(mycharm): mycharm.defer_next = 2 - rel = Relation(endpoint='foo', - remote_app_name='remote') + rel = Relation(endpoint="foo", remote_app_name="remote") out = State( relations=[rel], deferred=[ - deferred(event="foo_relation_changed", - handler=mycharm._on_event, - relation=rel) - ] + deferred( + event="foo_relation_changed", handler=mycharm._on_event, relation=rel + ) + ], ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: relation-changed, start. @@ -104,13 +99,10 @@ def test_deferred_relation_event(mycharm): def test_deferred_relation_event_from_relation(mycharm): mycharm.defer_next = 2 - rel = Relation(endpoint='foo', - remote_app_name='remote') + rel = Relation(endpoint="foo", remote_app_name="remote") out = State( relations=[rel], - deferred=[ - rel.changed_event.deferred(handler=mycharm._on_event) - ] + deferred=[rel.changed_event.deferred(handler=mycharm._on_event)], ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: foo_relation_changed, start. From ed6f8ea67d076830264ea2debb134ed3fce0ddd2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 11:02:23 +0100 Subject: [PATCH 094/546] container events deferral --- scenario/state.py | 19 ++++++++++-- tests/test_e2e/test_event_queue.py | 46 +++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 9a31019a9..f303199f5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -554,7 +554,14 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: handle_path = f"{owner_name}/on/{self.name}[{event_id}]" snapshot_data = {} - if self.relation: + + if self.container: + # this is a WorkloadEvent. The snapshot: + snapshot_data = { + "container_name": self.container.name, + } + + elif self.relation: # this is a RelationEvent. The snapshot: snapshot_data = { "relation_name": self.relation.endpoint, @@ -575,7 +582,8 @@ def deferred( event: Union[str, Event], handler: Callable, event_id: int = 1, - relation: Relation = None, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -587,8 +595,13 @@ def deferred( "cannot construct a deferred relation event without the relation instance. " "Please pass one." ) + if not container and norm_evt.endswith('_pebble_ready'): + raise ValueError( + "cannot construct a deferred workload event without the container instance. " + "Please pass one." + ) - event = Event(event, relation=relation) + event = Event(event, relation=relation, container=container) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py index aef4e1827..622c9acba 100644 --- a/tests/test_e2e/test_event_queue.py +++ b/tests/test_e2e/test_event_queue.py @@ -1,10 +1,10 @@ from dataclasses import asdict import pytest -from ops.charm import CharmBase, RelationChangedEvent, StartEvent, UpdateStatusEvent +from ops.charm import CharmBase, RelationChangedEvent, StartEvent, UpdateStatusEvent, WorkloadEvent from ops.framework import Framework -from scenario.state import DeferredEvent, Relation, State, deferred +from scenario.state import DeferredEvent, Relation, State, deferred, Container CHARM_CALLED = 0 @@ -12,7 +12,11 @@ @pytest.fixture(scope="function") def mycharm(): class MyCharm(CharmBase): - META = {"name": "mycharm", "requires": {"foo": {"interface": "bar"}}} + META = { + "name": "mycharm", + "requires": {"foo": {"interface": "bar"}}, + "containers": {"foo": {"type": "oci-image"}}, + } defer_next = 0 captured = [] @@ -61,7 +65,7 @@ def test_deferred_relation_event_without_relation_raises(mycharm): deferred(event="foo_relation_changed", handler=mycharm._on_event) -def test_deferred(mycharm): +def test_deferred_relation_evt(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") evt1 = rel.changed_event.deferred(handler=mycharm._on_event) evt2 = deferred( @@ -71,6 +75,16 @@ def test_deferred(mycharm): assert asdict(evt2) == asdict(evt1) +def test_deferred_workload_evt(mycharm): + ctr = Container("foo") + evt1 = ctr.pebble_ready_event.deferred(handler=mycharm._on_event) + evt2 = deferred( + event="foo_pebble_ready", handler=mycharm._on_event, container=ctr + ) + + assert asdict(evt2) == asdict(evt1) + + def test_deferred_relation_event(mycharm): mycharm.defer_next = 2 @@ -115,3 +129,27 @@ def test_deferred_relation_event_from_relation(mycharm): upstat, start = mycharm.captured assert isinstance(upstat, RelationChangedEvent) assert isinstance(start, StartEvent) + + +def test_deferred_workload_event(mycharm): + mycharm.defer_next = 2 + + ctr = Container("foo") + + out = State( + containers=[ctr], + deferred=[ + ctr.pebble_ready_event.deferred(handler=mycharm._on_event) + ], + ).trigger("start", mycharm, meta=mycharm.META) + + # we deferred the first 2 events we saw: foo_pebble_ready, start. + assert len(out.deferred) == 2 + assert out.deferred[0].name == "foo_pebble_ready" + assert out.deferred[1].name == "start" + + # we saw start and foo_pebble_ready. + assert len(mycharm.captured) == 2 + upstat, start = mycharm.captured + assert isinstance(upstat, WorkloadEvent) + assert isinstance(start, StartEvent) From 2434b0fcc4c55fe662b888788b9c1e77f5ae35d6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 11:02:35 +0100 Subject: [PATCH 095/546] lint: --- scenario/state.py | 2 +- tests/test_e2e/test_event_queue.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index f303199f5..3e6f15c76 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -595,7 +595,7 @@ def deferred( "cannot construct a deferred relation event without the relation instance. " "Please pass one." ) - if not container and norm_evt.endswith('_pebble_ready'): + if not container and norm_evt.endswith("_pebble_ready"): raise ValueError( "cannot construct a deferred workload event without the container instance. " "Please pass one." diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_event_queue.py index 622c9acba..11c898d05 100644 --- a/tests/test_e2e/test_event_queue.py +++ b/tests/test_e2e/test_event_queue.py @@ -1,10 +1,16 @@ from dataclasses import asdict import pytest -from ops.charm import CharmBase, RelationChangedEvent, StartEvent, UpdateStatusEvent, WorkloadEvent +from ops.charm import ( + CharmBase, + RelationChangedEvent, + StartEvent, + UpdateStatusEvent, + WorkloadEvent, +) from ops.framework import Framework -from scenario.state import DeferredEvent, Relation, State, deferred, Container +from scenario.state import Container, DeferredEvent, Relation, State, deferred CHARM_CALLED = 0 @@ -78,9 +84,7 @@ def test_deferred_relation_evt(mycharm): def test_deferred_workload_evt(mycharm): ctr = Container("foo") evt1 = ctr.pebble_ready_event.deferred(handler=mycharm._on_event) - evt2 = deferred( - event="foo_pebble_ready", handler=mycharm._on_event, container=ctr - ) + evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr) assert asdict(evt2) == asdict(evt1) @@ -138,9 +142,7 @@ def test_deferred_workload_event(mycharm): out = State( containers=[ctr], - deferred=[ - ctr.pebble_ready_event.deferred(handler=mycharm._on_event) - ], + deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)], ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: foo_pebble_ready, start. From c3f0570c8b0a02a078db36aada6037b128b697c6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 11:10:38 +0100 Subject: [PATCH 096/546] underscores --- scenario/sequences.py | 6 +++--- scenario/state.py | 12 ++++++------ .../{test_event_queue.py => test_deferred.py} | 0 tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_secrets.py | 6 +++--- tests/test_runtime.py | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) rename tests/test_e2e/{test_event_queue.py => test_deferred.py} (100%) diff --git a/scenario/sequences.py b/scenario/sequences.py index 37a03ad82..2125d9756 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -54,13 +54,13 @@ def generate_startup_sequence(state_template: State): ( ( Event( - "leader-elected" + "leader_elected" if state_template.leader - else "leader-settings-changed" + else "leader_settings_changed" ), state_template.copy(), ), - (Event("config-changed"), state_template.copy()), + (Event("config_changed"), state_template.copy()), (Event("install"), state_template.copy()), ), ) diff --git a/scenario/state.py b/scenario/state.py index 3e6f15c76..c27e49562 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -170,27 +170,27 @@ def __post_init__(self): @property def changed_event(self): """Sugar to generate a -relation-changed event.""" - return Event(name=self.endpoint + "-relation-changed", relation=self) + return Event(name=self.endpoint + "_relation_changed", relation=self) @property def joined_event(self): """Sugar to generate a -relation-joined event.""" - return Event(name=self.endpoint + "-relation-joined", relation=self) + return Event(name=self.endpoint + "_relation_joined", relation=self) @property def created_event(self): """Sugar to generate a -relation-created event.""" - return Event(name=self.endpoint + "-relation-created", relation=self) + return Event(name=self.endpoint + "_relation_created", relation=self) @property def departed_event(self): """Sugar to generate a -relation-departed event.""" - return Event(name=self.endpoint + "-relation-departed", relation=self) + return Event(name=self.endpoint + "_relation_departed", relation=self) @property def broken_event(self): """Sugar to generate a -relation-broken event.""" - return Event(name=self.endpoint + "-relation-broken", relation=self) + return Event(name=self.endpoint + "_relation_broken", relation=self) def _random_model_name(): @@ -284,7 +284,7 @@ def pebble_ready_event(self): "you **can** fire pebble-ready while the container cannot connect, " "but that's most likely not what you want." ) - return Event(name=self.name + "-pebble-ready", container=self) + return Event(name=self.name + "_pebble_ready", container=self) @dataclasses.dataclass diff --git a/tests/test_e2e/test_event_queue.py b/tests/test_e2e/test_deferred.py similarity index 100% rename from tests/test_e2e/test_event_queue.py rename to tests/test_e2e/test_deferred.py diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 780c19b4d..64894ccfb 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -106,6 +106,6 @@ def check_relation_data(charm): "name": "foo", "requires": {"relation_test": {"interface": "azdrubales"}}, }, - event="update-status", + event="update_status", post_event=check_relation_data, ) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 4af1cc0a4..6a0c0b093 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -27,7 +27,7 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(label="foo") State().trigger( - "update-status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) @@ -36,7 +36,7 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content()["a"] == "b" State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]).trigger( - "update-status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) @@ -59,7 +59,7 @@ def post_event(charm: CharmBase): }, ) ] - ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) def test_secret_changed_owner_evt_fails(mycharm): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 2bdaf124a..df15ce8bf 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -50,7 +50,7 @@ def test_event_hooks(): post_event = MagicMock(return_value=None) runtime.exec( state=State(), - event=Event("update-status"), + event=Event("update_status"), pre_event=pre_event, post_event=post_event, ) From 175d84524b9a36ea334cbcae44216fec11fe6d54 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 12:11:52 +0100 Subject: [PATCH 097/546] storedstate --- README.md | 54 ++++++++++++++++++-------- scenario/runtime.py | 45 ++++++++++++--------- scenario/state.py | 17 ++++++++ tests/test_e2e/test_juju_log.py | 32 +++++++++++++++ tests/test_e2e/test_pebble.py | 2 + tests/test_e2e/test_play_assertions.py | 1 + tests/test_e2e/test_state.py | 2 + tests/test_e2e/test_stored_state.py | 53 +++++++++++++++++++++++++ 8 files changed, 170 insertions(+), 36 deletions(-) create mode 100644 tests/test_e2e/test_juju_log.py create mode 100644 tests/test_e2e/test_stored_state.py diff --git a/README.md b/README.md index 466ea2b97..385fc13ab 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ class MyCharm(CharmBase): def test_scenario_base(): out = State().trigger( - 'start', + 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit == ('unknown', '') ``` @@ -101,7 +101,7 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): out = State(leader=leader).trigger( - 'start', + 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') @@ -277,7 +277,7 @@ def test_pebble_exec(): # Deferred events -Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. +Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. ```python @@ -291,12 +291,12 @@ class MyCharm(...): def _on_start(self, e): e.defer() - + def test_start_on_deferred_update_status(MyCharm): """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" out = State( - deferred=[ - deferred('update_status', + deferred=[ + deferred('update_status', handler=MyCharm._on_update_status) ] ).trigger('start', MyCharm) @@ -317,7 +317,7 @@ deferred_install = Event('install').deferred(MyCharm._on_start) ... # relation events: -foo_relation = Relation('foo') +foo_relation = Relation('foo') deferred_relation_changed_evt = foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -332,7 +332,7 @@ class MyCharm(...): def _on_start(self, e): e.defer() - + def test_defer(MyCharm): out = State().trigger('start', MyCharm) assert len(out.deferred) == 1 @@ -353,13 +353,13 @@ class MyCharm(...): def _on_foo_relation_changed(self, e): e.defer() - + def test_start_on_deferred_update_status(MyCharm): - foo_relation = Relation('foo') + foo_relation = Relation('foo') State( - relations=[foo_relation], - deferred=[ - deferred('foo_relation_changed', + relations=[foo_relation], + deferred=[ + deferred('foo_relation_changed', handler=MyCharm._on_foo_relation_changed, relation=foo_relation) ] @@ -374,7 +374,7 @@ from scenario import Relation class MyCharm(...): ... -foo_relation = Relation('foo') +foo_relation = Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -388,14 +388,34 @@ For general-purpose usage, you will need to instantiate `DeferredEvent` directly from scenario import DeferredEvent my_deferred_event = DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' ) ``` +# StoredState + +Scenario's State includes a charm's StoredState. +You can define it on the input side as: +```python +from scenario import State, StoredState + +state = State(stored_state=[ + StoredState( + owner="MyCharmType", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) +]) +``` + +And you can run assertions on it on the output side the same as any other bit of state. + + # TODOS: - State-State consistency checks. - State-Metadata consistency checks. diff --git a/scenario/runtime.py b/scenario/runtime.py index f1cdf6247..5fb7455ad 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,4 +1,5 @@ import dataclasses +import logging import marshal import os import re @@ -10,6 +11,7 @@ import yaml from ops.framework import _event_regex +from ops.log import JujuLogHandler from ops.storage import SQLiteStorage from scenario.logger import logger as scenario_logger @@ -25,6 +27,8 @@ _CT = TypeVar("_CT", bound=Type[CharmType]) logger = scenario_logger.getChild("runtime") +# _stored_state_regex = "(.*)\/(\D+)\[(.*)\]" +_stored_state_regex = "((?P.*)\/)?(?P\D+)\[(?P.*)\]" RUNTIME_MODULE = Path(__file__).parent @@ -79,18 +83,6 @@ def from_local_file( my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] return Runtime(_CharmSpec(my_charm_type)) # TODO add meta, options,... - @staticmethod - def _redirect_root_logger(): - # the root logger set up by ops calls a hook tool: `juju-log`. - # that is a problem for us because `juju-log` is itself memoized, which leads to recursion. - def _patch_logger(*args, **kwargs): - logger.debug("Hijacked root logger.") - pass - - from scenario import ops_main_mock - - ops_main_mock.setup_root_logging = _patch_logger - @staticmethod def _cleanup_env(env): # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. @@ -184,9 +176,8 @@ def _get_store(temporary_charm_root: Path): def _initialize_storage(self, state: "State", temporary_charm_root: Path): """Before we start processing this event, expose the relevant parts of State through the storage.""" store = self._get_store(temporary_charm_root) - deferred = state.deferred - for event in deferred: + for event in state.deferred: store.save_notice(event.handle_path, event.owner, event.observer) try: marshal.dumps(event.snapshot_data) @@ -196,16 +187,21 @@ def _initialize_storage(self, state: "State", temporary_charm_root: Path): ) from e store.save_snapshot(event.handle_path, event.snapshot_data) + for stored_state in state.stored_state: + store.save_snapshot(stored_state.handle_path, stored_state.content) + store.close() def _close_storage(self, state: "State", temporary_charm_root: Path): """Now that we're done processing this event, read the charm state and expose it via State.""" - from scenario.state import DeferredEvent # avoid cyclic import + from scenario.state import DeferredEvent, StoredState # avoid cyclic import store = self._get_store(temporary_charm_root) deferred = [] + stored_state = [] event_regex = re.compile(_event_regex) + sst_regex = re.compile(_stored_state_regex) for handle_path in store.list_snapshots(): if event_regex.match(handle_path): notices = store.notices(handle_path) @@ -215,8 +211,22 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): ) deferred.append(event) + else: + # it's a StoredState. TODO: No other option, right? + stored_state_snapshot = store.load_snapshot(handle_path) + match = sst_regex.match(handle_path) + if not match: + logger.warning(f"could not parse handle path {handle_path!r} as stored state") + continue + + kwargs = match.groupdict() + sst = StoredState( + content=stored_state_snapshot, + **kwargs) + stored_state.append(sst) + store.close() - return state.replace(deferred=deferred) + return state.replace(deferred=deferred, stored_state=stored_state) def exec( self, @@ -246,9 +256,6 @@ def exec( logger.info(" - initializing storage") self._initialize_storage(state, temporary_charm_root) - logger.info(" - redirecting root logging") - self._redirect_root_logger() - logger.info(" - preparing env") env = self._get_event_env( state=state, event=event, charm_root=temporary_charm_root diff --git a/scenario/state.py b/scenario/state.py index c27e49562..753cbc037 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -372,6 +372,22 @@ class Status(_DCBase): app_version: str = "" +@dataclasses.dataclass +class StoredState(_DCBase): + # /-separated Object names. E.g. MyCharm/MyCharmLib. + # if None, this StoredState instance is owned by the Framework. + owner_path: Optional[str] + + name: str = '_stored' + content: Dict[str, Any] = dataclasses.field(default_factory=dict) + + data_type_name: str = "StoredStateData" + + @property + def handle_path(self): + return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" + + @dataclasses.dataclass class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = None @@ -394,6 +410,7 @@ class State(_DCBase): # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) + stored_state: List["StoredState"] = dataclasses.field(default_factory=dict) # todo: # actions? diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py new file mode 100644 index 000000000..7bc0921aa --- /dev/null +++ b/tests/test_e2e/test_juju_log.py @@ -0,0 +1,32 @@ +import logging + +import pytest +from ops.charm import CharmBase + +from scenario.state import State + +logger = logging.getLogger('testing logger') + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + META = {"name": "mycharm"} + + def __init__(self, framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + print('foo!') + logger.warning('bar!') + + return MyCharm + + +def test_juju_log(mycharm): + out = State().trigger('start', mycharm, meta=mycharm.META) + assert out.juju_log[16] == ('DEBUG', 'Emitting Juju event start.') + # prints are not juju-logged. + assert out.juju_log[17] == ('WARNING', 'bar!') diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index fade9c6f7..14325fa82 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -127,6 +127,8 @@ def callback(self: CharmBase): assert file.read() == text else: # nothing has changed + out.juju_log = [] + out.stored_state = state.stored_state # ignore stored state in delta. assert not out.jsonpatch_delta(state) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 64894ccfb..5f1cfeffc 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -58,6 +58,7 @@ def post_event(charm): assert out.status.unit == ("active", "yabadoodle") out.juju_log = [] # exclude juju log from delta + out.stored_state = initial_state.stored_state # ignore stored state in delta. assert out.jsonpatch_delta(initial_state) == [ { "op": "replace", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 5d6a714b2..2b9872ab7 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -61,6 +61,7 @@ def state(): def test_bare_event(state, mycharm): out = state.trigger("start", mycharm, meta={"name": "foo"}) out.juju_log = [] # ignore logging output in the delta + out.stored_state = state.stored_state # ignore stored state in delta. assert state.jsonpatch_delta(out) == [] @@ -93,6 +94,7 @@ def call(charm: CharmBase, _): assert out.status.app_version == "" out.juju_log = [] # ignore logging output in the delta + out.stored_state = state.stored_state # ignore stored state in delta. assert out.jsonpatch_delta(state) == sort_patch( [ { diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py new file mode 100644 index 000000000..6148e7679 --- /dev/null +++ b/tests/test_e2e/test_stored_state.py @@ -0,0 +1,53 @@ +import pytest +from ops.charm import CharmBase +from ops.framework import Framework, StoredState as ops_storedstate + +from scenario.state import State, StoredState + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + META = {"name": "mycharm"} + + _read = {} + _stored = ops_storedstate() + _stored2 = ops_storedstate() + + def __init__(self, framework: Framework): + super().__init__(framework) + self._stored.set_default(foo='bar', baz={12: 142}) + self._stored2.set_default(foo='bar', baz={12: 142}) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + self._read['foo'] = self._stored.foo + self._read['baz'] = self._stored.baz + + return MyCharm + + +def test_stored_state_default(mycharm): + out = State().trigger('start', mycharm, meta=mycharm.META) + assert out.stored_state[0].content == { + 'foo': 'bar', + 'baz': {12: 142} + } + + +def test_stored_state_initialized(mycharm): + out = State( + stored_state=[ + StoredState('MyCharm', name='_stored', content={'foo': 'FOOX'}), + ] + ).trigger('start', mycharm, meta=mycharm.META) + # todo: ordering is messy? + assert out.stored_state[1].content == { + 'foo': 'FOOX', + 'baz': {12: 142} + } + assert out.stored_state[0].content == { + 'foo': 'bar', + 'baz': {12: 142} + } From 1219b86ee5b9f933b9b26f08da8ed47bf5a3a141 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 12:12:00 +0100 Subject: [PATCH 098/546] lint --- scenario/runtime.py | 8 ++++---- scenario/state.py | 2 +- tests/test_e2e/test_juju_log.py | 12 +++++------ tests/test_e2e/test_stored_state.py | 32 +++++++++++------------------ 4 files changed, 23 insertions(+), 31 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 5fb7455ad..6a60319be 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -216,13 +216,13 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): stored_state_snapshot = store.load_snapshot(handle_path) match = sst_regex.match(handle_path) if not match: - logger.warning(f"could not parse handle path {handle_path!r} as stored state") + logger.warning( + f"could not parse handle path {handle_path!r} as stored state" + ) continue kwargs = match.groupdict() - sst = StoredState( - content=stored_state_snapshot, - **kwargs) + sst = StoredState(content=stored_state_snapshot, **kwargs) stored_state.append(sst) store.close() diff --git a/scenario/state.py b/scenario/state.py index 753cbc037..c66d250e7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -378,7 +378,7 @@ class StoredState(_DCBase): # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] - name: str = '_stored' + name: str = "_stored" content: Dict[str, Any] = dataclasses.field(default_factory=dict) data_type_name: str = "StoredStateData" diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index 7bc0921aa..782a3b6cf 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -5,7 +5,7 @@ from scenario.state import State -logger = logging.getLogger('testing logger') +logger = logging.getLogger("testing logger") @pytest.fixture(scope="function") @@ -19,14 +19,14 @@ def __init__(self, framework): self.framework.observe(evt, self._on_event) def _on_event(self, event): - print('foo!') - logger.warning('bar!') + print("foo!") + logger.warning("bar!") return MyCharm def test_juju_log(mycharm): - out = State().trigger('start', mycharm, meta=mycharm.META) - assert out.juju_log[16] == ('DEBUG', 'Emitting Juju event start.') + out = State().trigger("start", mycharm, meta=mycharm.META) + assert out.juju_log[16] == ("DEBUG", "Emitting Juju event start.") # prints are not juju-logged. - assert out.juju_log[17] == ('WARNING', 'bar!') + assert out.juju_log[17] == ("WARNING", "bar!") diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 6148e7679..6a328f742 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -1,6 +1,7 @@ import pytest from ops.charm import CharmBase -from ops.framework import Framework, StoredState as ops_storedstate +from ops.framework import Framework +from ops.framework import StoredState as ops_storedstate from scenario.state import State, StoredState @@ -16,38 +17,29 @@ class MyCharm(CharmBase): def __init__(self, framework: Framework): super().__init__(framework) - self._stored.set_default(foo='bar', baz={12: 142}) - self._stored2.set_default(foo='bar', baz={12: 142}) + self._stored.set_default(foo="bar", baz={12: 142}) + self._stored2.set_default(foo="bar", baz={12: 142}) for evt in self.on.events().values(): self.framework.observe(evt, self._on_event) def _on_event(self, event): - self._read['foo'] = self._stored.foo - self._read['baz'] = self._stored.baz + self._read["foo"] = self._stored.foo + self._read["baz"] = self._stored.baz return MyCharm def test_stored_state_default(mycharm): - out = State().trigger('start', mycharm, meta=mycharm.META) - assert out.stored_state[0].content == { - 'foo': 'bar', - 'baz': {12: 142} - } + out = State().trigger("start", mycharm, meta=mycharm.META) + assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} def test_stored_state_initialized(mycharm): out = State( stored_state=[ - StoredState('MyCharm', name='_stored', content={'foo': 'FOOX'}), + StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), ] - ).trigger('start', mycharm, meta=mycharm.META) + ).trigger("start", mycharm, meta=mycharm.META) # todo: ordering is messy? - assert out.stored_state[1].content == { - 'foo': 'FOOX', - 'baz': {12: 142} - } - assert out.stored_state[0].content == { - 'foo': 'bar', - 'baz': {12: 142} - } + assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} + assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} From 5cd6d0d574fa2a2a320e91140af09bd3df5f1a43 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 14:23:31 +0100 Subject: [PATCH 099/546] readme fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 385fc13ab..ea234888f 100644 --- a/README.md +++ b/README.md @@ -405,7 +405,7 @@ from scenario import State, StoredState state = State(stored_state=[ StoredState( - owner="MyCharmType", + owner_path="MyCharmType", content={ 'foo': 'bar', 'baz': {42: 42}, From 0936b9a035af33383ac9b3babb045040f63bb727 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 16:12:41 +0100 Subject: [PATCH 100/546] secrets API complete --- scenario/mocking.py | 220 +++++++++++++++++++++---------- scenario/state.py | 68 ++++++---- tests/test_e2e/test_relations.py | 28 +++- tests/test_e2e/test_secrets.py | 130 +++++++++++++++++- tests/test_runtime.py | 2 +- 5 files changed, 345 insertions(+), 103 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index c9f357ff9..70c725c42 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,10 +1,12 @@ import pathlib +import random import urllib.request +import datetime from io import StringIO from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union from ops import pebble -from ops.model import _ModelBackend +from ops.model import _ModelBackend, SecretRotate, SecretInfo from ops.pebble import Client, ExecError from ops.testing import _TestingFilesystem, _TestingPebbleClient, _TestingStorageMount @@ -81,25 +83,25 @@ def relation_get(self, rel_id, obj_name, app): def is_leader(self): return self._state.leader - def status_get(self, *args, **kwargs): + def status_get(self, *, is_app: bool = False): status, message = ( - self._state.status.app if kwargs.get("app") else self._state.status.unit + self._state.status.app if is_app else self._state.status.unit ) return {"status": status, "message": message} - def relation_ids(self, endpoint, *args, **kwargs): + def relation_ids(self, relation_name): return [ - rel.relation_id for rel in self._state.relations if rel.endpoint == endpoint + rel.relation_id for rel in self._state.relations if rel.endpoint == relation_name ] - def relation_list(self, rel_id, *args, **kwargs): - relation = self._get_relation_by_id(rel_id) + def relation_list(self, relation_id: int): + relation = self._get_relation_by_id(relation_id) return tuple( f"{relation.remote_app_name}/{unit_id}" for unit_id in relation.remote_unit_ids ) - def config_get(self, *args, **kwargs): + def config_get(self): state_config = self._state.config if not state_config: state_config = { @@ -107,36 +109,15 @@ def config_get(self, *args, **kwargs): for key, value in self._charm_spec.config.items() } - if args: # one specific key requested - # Fixme: may raise KeyError if the key isn't defaulted. What do we do then? - return state_config[args[0]] - return state_config # full config - def network_get(self, *args, **kwargs): - name, relation_id = args + def network_get(self, binding_name: str, relation_id: Optional[int] = None): + if relation_id: + logger.warning('network-get -r not implemented') - network = next(filter(lambda r: r.name == name, self._state.networks)) + network = next(filter(lambda r: r.name == binding_name, self._state.networks)) return network.hook_tool_output_fmt() - def action_get(self, *args, **kwargs): - raise NotImplementedError("action_get") - - def relation_remote_app_name(self, *args, **kwargs): - raise NotImplementedError("relation_remote_app_name") - - def resource_get(self, *args, **kwargs): - raise NotImplementedError("resource_get") - - def storage_list(self, *args, **kwargs): - raise NotImplementedError("storage_list") - - def storage_get(self, *args, **kwargs): - raise NotImplementedError("storage_get") - - def planned_units(self, *args, **kwargs): - raise NotImplementedError("planned_units") - # setter methods: these can mutate the state. def application_version_set(self, *args, **kwargs): self._state.status.app_version = args[0] @@ -149,14 +130,13 @@ def status_set(self, *args, **kwargs): self._state.status.unit = args return None - def juju_log(self, *args, **kwargs): - self._state.juju_log.append(args) + def juju_log(self, level: str, message: str): + self._state.juju_log.append((level, message)) return None - def relation_set(self, *args, **kwargs): - rel_id, key, value, app = args - relation = self._get_relation_by_id(rel_id) - if app: + def relation_set(self, relation_id: int, key: str, value: str, is_app: bool): + relation = self._get_relation_by_id(relation_id) + if is_app: if not self._state.leader: raise RuntimeError("needs leadership to set app data") tgt = relation.local_app_data @@ -165,44 +145,59 @@ def relation_set(self, *args, **kwargs): tgt[key] = value return None - # TODO: - def action_set(self, *args, **kwargs): - raise NotImplementedError("action_set") - - def action_fail(self, *args, **kwargs): - raise NotImplementedError("action_fail") - - def action_log(self, *args, **kwargs): - raise NotImplementedError("action_log") - - def storage_add(self, *args, **kwargs): - raise NotImplementedError("storage_add") - - def secret_get( - self, - *, - id: str = None, - label: str = None, - refresh: bool = False, - peek: bool = False, - ) -> Dict[str, str]: + def _get_secret(self, id=None, label=None): # cleanup id: if id and id.startswith("secret:"): id = id[7:] if id: try: - secret = next(filter(lambda s: s.id == id, self._state.secrets)) + return next(filter(lambda s: s.id == id, self._state.secrets)) except StopIteration: raise RuntimeError(f"not found: secret with id={id}.") elif label: try: - secret = next(filter(lambda s: s.label == label, self._state.secrets)) + return next(filter(lambda s: s.label == label, self._state.secrets)) except StopIteration: raise RuntimeError(f"not found: secret with label={label}.") else: raise RuntimeError(f"need id or label.") + @staticmethod + def _generate_secret_id(): + id = ''.join(map(str, [random.choice(list(range(10))) for _ in range(20)])) + return f"secret:{id}" + + def secret_add(self, content: Dict[str, str], *, + label: Optional[str] = None, + description: Optional[str] = None, + expire: Optional[datetime.datetime] = None, + rotate: Optional[SecretRotate] = None, + owner: Optional[str] = None) -> str: + from scenario.state import Secret + + id = self._generate_secret_id() + secret = Secret( + id=id, + contents={0: content}, + label=label, + description=description, + expire=expire, + rotate=rotate, + owner=owner + ) + self._state.secrets.append(secret) + return id + + def secret_get( + self, + *, + id: str = None, + label: str = None, + refresh: bool = False, + peek: bool = False, + ) -> Dict[str, str]: + secret = self._get_secret(id, label) revision = secret.revision if peek or refresh: revision = max(secret.contents.keys()) @@ -210,15 +205,106 @@ def secret_get( secret.revision = revision return secret.contents[revision] + def secret_info_get(self, *, + id: Optional[str] = None, + label: Optional[str] = None) -> SecretInfo: + secret = self._get_secret(id, label) + if not secret.owner: + raise RuntimeError(f'not the owner of {secret}') + + return SecretInfo( + id=secret.id, + label=secret.label, + revision=max(secret.contents), + expires=secret.expire, + rotation=secret.rotate, + rotates=None # not implemented yet. + ) - def secret_set(self, *args, **kwargs): + def secret_set(self, id: str, *, + content: Optional[Dict[str, str]] = None, + label: Optional[str] = None, + description: Optional[str] = None, + expire: Optional[datetime.datetime] = None, + rotate: Optional[SecretRotate] = None): + secret = self._get_secret(id, label) + if not secret.owner: + raise RuntimeError(f'not the owner of {secret}') + + revision = max(secret.contents.keys()) + secret.contents[revision + 1] = content + if label: + secret.label = label + if description: + secret.description = description + if expire: + if isinstance(expire, datetime.timedelta): + expire = datetime.datetime.now() + expire + secret.expire = expire + if rotate: + secret.rotate = rotate raise NotImplementedError("secret_set") - def secret_grant(self, *args, **kwargs): - raise NotImplementedError("secret_grant") + def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): + secret = self._get_secret(id) + if not secret.owner: + raise RuntimeError(f'not the owner of {secret}') + + grantee = unit or self._get_relation_by_id(relation_id).remote_app_name + + if not secret.remote_grants.get(relation_id): + secret.remote_grants[relation_id] = set() + + secret.remote_grants[relation_id].add(grantee) + + def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): + secret = self._get_secret(id) + if not secret.owner: + raise RuntimeError(f'not the owner of {secret}') + + grantee = unit or self._get_relation_by_id(relation_id).remote_app_name + secret.remote_grants[relation_id].remove(grantee) + + def secret_remove(self, id: str, *, revision: Optional[int] = None): + secret = self._get_secret(id) + if not secret.owner: + raise RuntimeError(f'not the owner of {secret}') + + if revision: + del secret.contents[revision] + else: + secret.contents.clear() + + # TODO: + def action_set(self, *args, **kwargs): + raise NotImplementedError("action_set") + + def action_fail(self, *args, **kwargs): + raise NotImplementedError("action_fail") + + def action_log(self, *args, **kwargs): + raise NotImplementedError("action_log") + + def storage_add(self, *args, **kwargs): + raise NotImplementedError("storage_add") + + def action_get(self): + raise NotImplementedError("action_get") + + def relation_remote_app_name(self, *args, **kwargs): + raise NotImplementedError("relation_remote_app_name") + + def resource_get(self, *args, **kwargs): + raise NotImplementedError("resource_get") + + def storage_list(self, *args, **kwargs): + raise NotImplementedError("storage_list") - def secret_remove(self, *args, **kwargs): - raise NotImplementedError("secret_remove") + def storage_get(self, *args, **kwargs): + raise NotImplementedError("storage_get") + + def planned_units(self, *args, **kwargs): + raise NotImplementedError("planned_units") class _MockStorageMount(_TestingStorageMount): diff --git a/scenario/state.py b/scenario/state.py index 8538487da..51c54ed61 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1,5 +1,6 @@ import copy import dataclasses +import datetime import inspect import typing from pathlib import Path, PurePosixPath @@ -7,6 +8,7 @@ Any, Callable, Dict, + Set, List, Literal, Optional, @@ -19,6 +21,7 @@ import yaml from ops import pebble +from ops.model import SecretRotate from scenario.logger import logger as scenario_logger from scenario.mocking import _MockFileSystem, _MockStorageMount @@ -61,21 +64,29 @@ class Secret(_DCBase): # mapping from revision IDs to each revision's contents contents: Dict[int, Dict[str, str]] - owned_by_this_unit: bool = False + # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. + owner: Literal['unit', 'application', None] = None - # has this secret been granted to this unit/app or neither? + # has this secret been granted to this unit/app or neither? Only applicable if NOT owner granted: Literal["unit", "app", False] = False - # what revision is currently tracked by this charm. Only meaningful if owned_by_this_unit=False + # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 - label: str = None + # mapping from relation IDs to remote unit/apps to which this secret has been granted. + # Only applicable if owner + remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict) + + label: Optional[str] = None + description: Optional[str] = None + expire: Optional[datetime.datetime] = None + rotate: SecretRotate = SecretRotate.NEVER # consumer-only events @property def changed_event(self): """Sugar to generate a secret-changed event.""" - if self.owned_by_this_unit: + if self.owner: raise ValueError( "This unit will never receive secret-changed for a secret it owns." ) @@ -85,7 +96,7 @@ def changed_event(self): @property def rotate_event(self): """Sugar to generate a secret-rotate event.""" - if not self.owned_by_this_unit: + if not self.owner: raise ValueError( "This unit will never receive secret-rotate for a secret it does not own." ) @@ -94,7 +105,7 @@ def rotate_event(self): @property def expired_event(self): """Sugar to generate a secret-expired event.""" - if not self.owned_by_this_unit: + if not self.owner: raise ValueError( "This unit will never receive secret-expire for a secret it does not own." ) @@ -103,7 +114,7 @@ def expired_event(self): @property def remove_event(self): """Sugar to generate a secret-remove event.""" - if not self.owned_by_this_unit: + if not self.owner: raise ValueError( "This unit will never receive secret-removed for a secret it does not own." ) @@ -326,16 +337,16 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - bind_id, - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + bind_id, + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -428,16 +439,16 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, + self, + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, ): """Fluent API for trigger.""" return trigger( @@ -541,7 +552,6 @@ def _derive_args(event_name: str): return tuple(args) - # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 3cd259e6d..b890ff550 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -73,7 +73,7 @@ def test_relation_events(mycharm, evt_name): mycharm._call = lambda self, evt: None - State(relations=[relation,],).trigger( + State(relations=[relation, ], ).trigger( getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -85,3 +85,29 @@ def test_relation_events(mycharm, evt_name): ) assert mycharm.called + + +@pytest.mark.parametrize( + "evt_name", ("changed", "broken", "departed", "joined", "created"), +) +@pytest.mark.parametrize( + "remote_app_name", ("remote", "prometheus", "aodeok123"), +) +def test_relation_events(mycharm, evt_name, remote_app_name): + relation = Relation(endpoint="foo", interface="foo", remote_app_name=remote_app_name) + + def callback(charm: CharmBase, _): + assert charm.model.get_relation('foo').app.name == remote_app_name + + mycharm._call = callback + + State(relations=[relation, ], ).trigger( + getattr(relation, f"{evt_name}_event"), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + } + ) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 4af1cc0a4..8d9adbc8b 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -1,8 +1,9 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework +from ops.model import SecretRotate -from scenario.state import Secret, State +from scenario.state import Secret, State, Relation @pytest.fixture(scope="function") @@ -70,7 +71,7 @@ def test_secret_changed_owner_evt_fails(mycharm): 0: {"a": "b"}, 1: {"a": "c"}, }, - owned_by_this_unit=True, + owner='unit', ).changed_event @@ -83,8 +84,127 @@ def test_consumer_events_failures(mycharm, evt_prefix): contents={ 0: {"a": "b"}, 1: {"a": "c"}, - }, - owned_by_this_unit=False, - ), + }), evt_prefix + "_event", ) + + +def test_add(mycharm): + def post_event(charm: CharmBase): + charm.unit.add_secret({'foo': 'bar'}, label='mylabel') + + out = State().trigger("update-status", mycharm, + meta={"name": "local"}, + post_event=post_event) + assert out.secrets + secret = out.secrets[0] + assert secret.contents[0] == {'foo': 'bar'} + assert secret.label == 'mylabel' + + +def test_meta(mycharm): + def post_event(charm: CharmBase): + assert charm.model.get_secret(label='mylabel') + + secret = charm.model.get_secret(id="foo") + info = secret.get_info() + + assert secret.label is None + assert info.label == 'mylabel' + assert info.rotation == SecretRotate.HOURLY + + State( + secrets=[ + Secret( + owner='unit', + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + + +def test_meta_nonowner(mycharm): + def post_event(charm: CharmBase): + secret = charm.model.get_secret(id="foo") + with pytest.raises(RuntimeError): + info = secret.get_info() + + State( + secrets=[ + Secret( + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + + +@pytest.mark.parametrize('app', (True, False)) +def test_grant(mycharm, app): + def post_event(charm: CharmBase): + secret = charm.model.get_secret(label="mylabel") + foo = charm.model.get_relation('foo') + if app: + secret.grant(relation=foo) + else: + secret.grant(relation=foo, unit=foo.units.pop()) + + out = State( + relations=[Relation('foo', 'remote')], + secrets=[ + Secret( + owner='unit', + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ).trigger("update-status", mycharm, + meta={"name": "local", + "requires": {"foo": {"interface": "bar"}}}, post_event=post_event) + + if app: + assert out.secrets[0].remote_grants[1] == {'remote'} + else: + assert out.secrets[0].remote_grants[2] == {'remote/0'} + + +def test_grant_nonowner(mycharm): + def post_event(charm: CharmBase): + secret = charm.model.get_secret(id="foo") + with pytest.raises(RuntimeError): + secret = charm.model.get_secret(label="mylabel") + foo = charm.model.get_relation('foo') + secret.grant(relation=foo) + + out = State( + relations=[Relation('foo', 'remote')], + secrets=[ + Secret( + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ).trigger("update-status", mycharm, + meta={"name": "local", + "requires": {"foo": {"interface": "bar"}}}, post_event=post_event) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index fa6cba864..2bdaf124a 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -50,7 +50,7 @@ def test_event_hooks(): post_event = MagicMock(return_value=None) runtime.exec( state=State(), - event=Event("foo"), + event=Event("update-status"), pre_event=pre_event, post_event=post_event, ) From 61fb5c35e7e56e6038507f3622e5933aa3c1fc0d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 16:13:00 +0100 Subject: [PATCH 101/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1dcc59d46..92fcddd51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.0" +version = "2.1.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 6f1323d72f4f6ab90598e2a748549204ca892229 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 16:19:50 +0100 Subject: [PATCH 102/546] fixed tests --- scenario/mocking.py | 69 ++++++++++++++++++-------------- scenario/state.py | 45 +++++++++++---------- tests/test_e2e/test_relations.py | 18 +++++---- tests/test_e2e/test_secrets.py | 63 ++++++++++++++++------------- 4 files changed, 108 insertions(+), 87 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 70c725c42..ca7f28ae7 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,12 +1,12 @@ +import datetime import pathlib import random import urllib.request -import datetime from io import StringIO from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union from ops import pebble -from ops.model import _ModelBackend, SecretRotate, SecretInfo +from ops.model import SecretInfo, SecretRotate, _ModelBackend from ops.pebble import Client, ExecError from ops.testing import _TestingFilesystem, _TestingPebbleClient, _TestingStorageMount @@ -84,14 +84,14 @@ def is_leader(self): return self._state.leader def status_get(self, *, is_app: bool = False): - status, message = ( - self._state.status.app if is_app else self._state.status.unit - ) + status, message = self._state.status.app if is_app else self._state.status.unit return {"status": status, "message": message} def relation_ids(self, relation_name): return [ - rel.relation_id for rel in self._state.relations if rel.endpoint == relation_name + rel.relation_id + for rel in self._state.relations + if rel.endpoint == relation_name ] def relation_list(self, relation_id: int): @@ -113,7 +113,7 @@ def config_get(self): def network_get(self, binding_name: str, relation_id: Optional[int] = None): if relation_id: - logger.warning('network-get -r not implemented') + logger.warning("network-get -r not implemented") network = next(filter(lambda r: r.name == binding_name, self._state.networks)) return network.hook_tool_output_fmt() @@ -165,15 +165,19 @@ def _get_secret(self, id=None, label=None): @staticmethod def _generate_secret_id(): - id = ''.join(map(str, [random.choice(list(range(10))) for _ in range(20)])) + id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) return f"secret:{id}" - def secret_add(self, content: Dict[str, str], *, - label: Optional[str] = None, - description: Optional[str] = None, - expire: Optional[datetime.datetime] = None, - rotate: Optional[SecretRotate] = None, - owner: Optional[str] = None) -> str: + def secret_add( + self, + content: Dict[str, str], + *, + label: Optional[str] = None, + description: Optional[str] = None, + expire: Optional[datetime.datetime] = None, + rotate: Optional[SecretRotate] = None, + owner: Optional[str] = None, + ) -> str: from scenario.state import Secret id = self._generate_secret_id() @@ -184,7 +188,7 @@ def secret_add(self, content: Dict[str, str], *, description=description, expire=expire, rotate=rotate, - owner=owner + owner=owner, ) self._state.secrets.append(secret) return id @@ -205,12 +209,13 @@ def secret_get( secret.revision = revision return secret.contents[revision] - def secret_info_get(self, *, - id: Optional[str] = None, - label: Optional[str] = None) -> SecretInfo: + + def secret_info_get( + self, *, id: Optional[str] = None, label: Optional[str] = None + ) -> SecretInfo: secret = self._get_secret(id, label) if not secret.owner: - raise RuntimeError(f'not the owner of {secret}') + raise RuntimeError(f"not the owner of {secret}") return SecretInfo( id=secret.id, @@ -218,18 +223,22 @@ def secret_info_get(self, *, revision=max(secret.contents), expires=secret.expire, rotation=secret.rotate, - rotates=None # not implemented yet. + rotates=None, # not implemented yet. ) - def secret_set(self, id: str, *, - content: Optional[Dict[str, str]] = None, - label: Optional[str] = None, - description: Optional[str] = None, - expire: Optional[datetime.datetime] = None, - rotate: Optional[SecretRotate] = None): + def secret_set( + self, + id: str, + *, + content: Optional[Dict[str, str]] = None, + label: Optional[str] = None, + description: Optional[str] = None, + expire: Optional[datetime.datetime] = None, + rotate: Optional[SecretRotate] = None, + ): secret = self._get_secret(id, label) if not secret.owner: - raise RuntimeError(f'not the owner of {secret}') + raise RuntimeError(f"not the owner of {secret}") revision = max(secret.contents.keys()) secret.contents[revision + 1] = content @@ -248,7 +257,7 @@ def secret_set(self, id: str, *, def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) if not secret.owner: - raise RuntimeError(f'not the owner of {secret}') + raise RuntimeError(f"not the owner of {secret}") grantee = unit or self._get_relation_by_id(relation_id).remote_app_name @@ -260,7 +269,7 @@ def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) if not secret.owner: - raise RuntimeError(f'not the owner of {secret}') + raise RuntimeError(f"not the owner of {secret}") grantee = unit or self._get_relation_by_id(relation_id).remote_app_name secret.remote_grants[relation_id].remove(grantee) @@ -268,7 +277,7 @@ def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) if not secret.owner: - raise RuntimeError(f'not the owner of {secret}') + raise RuntimeError(f"not the owner of {secret}") if revision: del secret.contents[revision] diff --git a/scenario/state.py b/scenario/state.py index 51c54ed61..23f6ae088 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -8,11 +8,11 @@ Any, Callable, Dict, - Set, List, Literal, Optional, Sequence, + Set, Tuple, Type, Union, @@ -65,7 +65,7 @@ class Secret(_DCBase): contents: Dict[int, Dict[str, str]] # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. - owner: Literal['unit', 'application', None] = None + owner: Literal["unit", "application", None] = None # has this secret been granted to this unit/app or neither? Only applicable if NOT owner granted: Literal["unit", "app", False] = False @@ -337,16 +337,16 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - bind_id, - private_address: str = "1.1.1.1", - mac_address: str = "", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + bind_id, + private_address: str = "1.1.1.1", + mac_address: str = "", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -439,16 +439,16 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, + self, + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, ): """Fluent API for trigger.""" return trigger( @@ -552,6 +552,7 @@ def _derive_args(event_name: str): return tuple(args) + # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index b890ff550..4a1507a4d 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -73,7 +73,7 @@ def test_relation_events(mycharm, evt_name): mycharm._call = lambda self, evt: None - State(relations=[relation, ], ).trigger( + State(relations=[relation,],).trigger( getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -88,20 +88,24 @@ def test_relation_events(mycharm, evt_name): @pytest.mark.parametrize( - "evt_name", ("changed", "broken", "departed", "joined", "created"), + "evt_name", + ("changed", "broken", "departed", "joined", "created"), ) @pytest.mark.parametrize( - "remote_app_name", ("remote", "prometheus", "aodeok123"), + "remote_app_name", + ("remote", "prometheus", "aodeok123"), ) def test_relation_events(mycharm, evt_name, remote_app_name): - relation = Relation(endpoint="foo", interface="foo", remote_app_name=remote_app_name) + relation = Relation( + endpoint="foo", interface="foo", remote_app_name=remote_app_name + ) def callback(charm: CharmBase, _): - assert charm.model.get_relation('foo').app.name == remote_app_name + assert charm.model.get_relation("foo").app.name == remote_app_name mycharm._call = callback - State(relations=[relation, ], ).trigger( + State(relations=[relation,],).trigger( getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -109,5 +113,5 @@ def callback(charm: CharmBase, _): "requires": { "foo": {"interface": "foo"}, }, - } + }, ) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 8d9adbc8b..7009ea4ca 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,7 +3,7 @@ from ops.framework import Framework from ops.model import SecretRotate -from scenario.state import Secret, State, Relation +from scenario.state import Relation, Secret, State @pytest.fixture(scope="function") @@ -71,7 +71,7 @@ def test_secret_changed_owner_evt_fails(mycharm): 0: {"a": "b"}, 1: {"a": "c"}, }, - owner='unit', + owner="unit", ).changed_event @@ -84,39 +84,40 @@ def test_consumer_events_failures(mycharm, evt_prefix): contents={ 0: {"a": "b"}, 1: {"a": "c"}, - }), + }, + ), evt_prefix + "_event", ) def test_add(mycharm): def post_event(charm: CharmBase): - charm.unit.add_secret({'foo': 'bar'}, label='mylabel') + charm.unit.add_secret({"foo": "bar"}, label="mylabel") - out = State().trigger("update-status", mycharm, - meta={"name": "local"}, - post_event=post_event) + out = State().trigger( + "update-status", mycharm, meta={"name": "local"}, post_event=post_event + ) assert out.secrets secret = out.secrets[0] - assert secret.contents[0] == {'foo': 'bar'} - assert secret.label == 'mylabel' + assert secret.contents[0] == {"foo": "bar"} + assert secret.label == "mylabel" def test_meta(mycharm): def post_event(charm: CharmBase): - assert charm.model.get_secret(label='mylabel') + assert charm.model.get_secret(label="mylabel") secret = charm.model.get_secret(id="foo") info = secret.get_info() assert secret.label is None - assert info.label == 'mylabel' + assert info.label == "mylabel" assert info.rotation == SecretRotate.HOURLY State( secrets=[ Secret( - owner='unit', + owner="unit", id="foo", label="mylabel", description="foobarbaz", @@ -150,21 +151,21 @@ def post_event(charm: CharmBase): ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) -@pytest.mark.parametrize('app', (True, False)) +@pytest.mark.parametrize("app", (True, False)) def test_grant(mycharm, app): def post_event(charm: CharmBase): secret = charm.model.get_secret(label="mylabel") - foo = charm.model.get_relation('foo') + foo = charm.model.get_relation("foo") if app: secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) out = State( - relations=[Relation('foo', 'remote')], + relations=[Relation("foo", "remote")], secrets=[ Secret( - owner='unit', + owner="unit", id="foo", label="mylabel", description="foobarbaz", @@ -173,15 +174,18 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, }, ) - ] - ).trigger("update-status", mycharm, - meta={"name": "local", - "requires": {"foo": {"interface": "bar"}}}, post_event=post_event) + ], + ).trigger( + "update-status", + mycharm, + meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, + post_event=post_event, + ) if app: - assert out.secrets[0].remote_grants[1] == {'remote'} + assert out.secrets[0].remote_grants[1] == {"remote"} else: - assert out.secrets[0].remote_grants[2] == {'remote/0'} + assert out.secrets[0].remote_grants[2] == {"remote/0"} def test_grant_nonowner(mycharm): @@ -189,11 +193,11 @@ def post_event(charm: CharmBase): secret = charm.model.get_secret(id="foo") with pytest.raises(RuntimeError): secret = charm.model.get_secret(label="mylabel") - foo = charm.model.get_relation('foo') + foo = charm.model.get_relation("foo") secret.grant(relation=foo) out = State( - relations=[Relation('foo', 'remote')], + relations=[Relation("foo", "remote")], secrets=[ Secret( id="foo", @@ -204,7 +208,10 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, }, ) - ] - ).trigger("update-status", mycharm, - meta={"name": "local", - "requires": {"foo": {"interface": "bar"}}}, post_event=post_event) + ], + ).trigger( + "update-status", + mycharm, + meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, + post_event=post_event, + ) From e7dedac861dde2e76a942b971d5bd898759bd4a0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 17:47:16 +0100 Subject: [PATCH 103/546] minor refactor --- scenario/mocking.py | 46 +++++++++++++++++++-------------------- scenario/ops_main_mock.py | 2 +- scenario/state.py | 9 ++++---- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index ca7f28ae7..bc40a8209 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -68,6 +68,29 @@ def _get_relation_by_id(self, rel_id): except StopIteration as e: raise RuntimeError(f"Not found: relation with id={rel_id}.") from e + def _get_secret(self, id=None, label=None): + # cleanup id: + if id and id.startswith("secret:"): + id = id[7:] + + if id: + try: + return next(filter(lambda s: s.id == id, self._state.secrets)) + except StopIteration: + raise RuntimeError(f"not found: secret with id={id}.") + elif label: + try: + return next(filter(lambda s: s.label == label, self._state.secrets)) + except StopIteration: + raise RuntimeError(f"not found: secret with label={label}.") + else: + raise RuntimeError(f"need id or label.") + + @staticmethod + def _generate_secret_id(): + id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) + return f"secret:{id}" + def relation_get(self, rel_id, obj_name, app): relation = self._get_relation_by_id(rel_id) if app and obj_name == self._state.app_name: @@ -145,29 +168,6 @@ def relation_set(self, relation_id: int, key: str, value: str, is_app: bool): tgt[key] = value return None - def _get_secret(self, id=None, label=None): - # cleanup id: - if id and id.startswith("secret:"): - id = id[7:] - - if id: - try: - return next(filter(lambda s: s.id == id, self._state.secrets)) - except StopIteration: - raise RuntimeError(f"not found: secret with id={id}.") - elif label: - try: - return next(filter(lambda s: s.label == label, self._state.secrets)) - except StopIteration: - raise RuntimeError(f"not found: secret with label={label}.") - else: - raise RuntimeError(f"need id or label.") - - @staticmethod - def _generate_secret_id(): - id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) - return f"secret:{id}" - def secret_add( self, content: Dict[str, str], diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index e4bdc8ef6..a4fce8f53 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,7 +3,7 @@ import inspect import os -from typing import TYPE_CHECKING, Callable, Literal, Optional +from typing import TYPE_CHECKING, Callable, Optional import ops.charm import ops.framework diff --git a/scenario/state.py b/scenario/state.py index 23f6ae088..4b3f1e568 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -11,7 +11,6 @@ List, Literal, Optional, - Sequence, Set, Tuple, Type, @@ -378,13 +377,13 @@ class Status(_DCBase): @dataclasses.dataclass class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = None - relations: Sequence[Relation] = dataclasses.field(default_factory=list) - networks: Sequence[Network] = dataclasses.field(default_factory=list) - containers: Sequence[Container] = dataclasses.field(default_factory=list) + relations: List[Relation] = dataclasses.field(default_factory=list) + networks: List[Network] = dataclasses.field(default_factory=list) + containers: List[Container] = dataclasses.field(default_factory=list) status: Status = dataclasses.field(default_factory=Status) leader: bool = False model: Model = Model() - juju_log: Sequence[Tuple[str, str]] = dataclasses.field(default_factory=list) + juju_log: List[Tuple[str, str]] = dataclasses.field(default_factory=list) secrets: List[Secret] = dataclasses.field(default_factory=list) # meta stuff: actually belongs in event data structure. From 6bccee69b37ed24a189c1148d1384a3f400a23b6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Feb 2023 17:57:56 +0100 Subject: [PATCH 104/546] fixed secret tests --- scenario/state.py | 13 +------------ tests/test_e2e/test_secrets.py | 6 ++---- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 4b3f1e568..663c8d768 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -4,18 +4,7 @@ import inspect import typing from pathlib import Path, PurePosixPath -from typing import ( - Any, - Callable, - Dict, - List, - Literal, - Optional, - Set, - Tuple, - Type, - Union, -) +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 import yaml diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 7009ea4ca..4763e1275 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -182,10 +182,8 @@ def post_event(charm: CharmBase): post_event=post_event, ) - if app: - assert out.secrets[0].remote_grants[1] == {"remote"} - else: - assert out.secrets[0].remote_grants[2] == {"remote/0"} + vals = list(out.secrets[0].remote_grants.values()) + assert vals == [{"remote"}] if app else [{"remote/0"}] def test_grant_nonowner(mycharm): From 894c2e8ddc4b0e80e3e960e4ded45d5814f172fb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Feb 2023 09:38:29 +0100 Subject: [PATCH 105/546] readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea234888f..148eec3a4 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ An initial state goes in, an event occurs (say, `'start'`) and a new state comes Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. Comparing scenario tests with `Harness` tests: -- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired , then verify the validity of the state. -- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). +- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired state, then assert that everything looks right (typically by calling certain charm methods directly, or verifying the content of the mock filesystem). +- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time (modulo deferred events). This ensures that the execution environment is as clean as possible (for a unit test), and much closer to that of a 'real' Juju unit. - Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. - TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... From fa03c967d0608e8ebda85475034c573279cf2fec Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Tue, 14 Feb 2023 11:52:32 +0100 Subject: [PATCH 106/546] archive warning --- README.md | 284 +----------------------------------------------------- 1 file changed, 2 insertions(+), 282 deletions(-) diff --git a/README.md b/README.md index 59b8aa7de..10d0da043 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,2 @@ -Ops-Scenario -============ - -This is a python library that you can use to run scenario-based tests. - -Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow -you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single -event on the charm and execute its logic. - -This puts scenario tests somewhere in between unit and integration tests. - -Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the -union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my -config?...). -The output is another context instance: the context after the charm has had a chance to interact with the mocked juju -model. - -![state transition model depiction](resources/state-transition-model.png) - -Scenario-testing a charm, then, means verifying that: - -- the charm does not raise uncaught exceptions while handling the scene -- the output state (or the diff with the input state) is as expected. - - -# Core concepts as a metaphor -I like metaphors, so here we go: -- There is a theatre stage. -- You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. -- You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). -- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. - - -# Core concepts not as a metaphor -Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black box. -An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. -Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. - -Comparing scenario tests with `Harness` tests: -- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired , then verify the validity of the state. -- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). -- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. -- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... - -# Writing scenario tests -A scenario test consists of three broad steps: - -- Arrange: - - declare the input state - - select an event to fire -- Act: - - run the state (i.e. obtain the output state) -- Assert: - - verify that the output state is how you expect it to be - - verify that the delta with the input state is what you expect it to be - -The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is -available. The charm has no config, no relations, no networks, and no leadership. - -With that, we can write the simplest possible scenario test: - -```python -from scenario.state import State -from ops.charm import CharmBase - - -class MyCharm(CharmBase): - pass - - -def test_scenario_base(): - out = State().trigger( - 'start', - MyCharm, meta={"name": "foo"}) - assert out.status.unit == ('unknown', '') -``` - -Now let's start making it more complicated. -Our charm sets a special state if it has leadership on 'start': - -```python -import pytest -from scenario.state import State -from ops.charm import CharmBase -from ops.model import ActiveStatus - - -class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) - - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I am ruled') - - -@pytest.mark.parametrize('leader', (True, False)) -def test_status_leader(leader): - out = State(leader=leader).trigger( - 'start', - MyCharm, - meta={"name": "foo"}) - assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') -``` - -By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... - -## Relations - -You can write scenario tests to verify the shape of relation data: - -```python -from ops.charm import CharmBase - -from scenario.state import Relation, State - - -# This charm copies over remote app data to local unit data -class MyCharm(CharmBase): - ... - - def _on_event(self, e): - rel = e.relation - assert rel.app.name == 'remote' - assert rel.data[self.unit]['abc'] == 'foo' - rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] - - -def test_relation_data(): - out = State(relations=[ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ] - ).trigger("start", MyCharm, meta={"name": "foo"}) - - assert out.relations[0].local_unit_data == {"abc": "baz!"} - # you can do this to check that there are no other differences: - assert out.relations == [ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] - -# which is very idiomatic and superbly explicit. Noice. -``` - -## Containers - -When testing a kubernetes charm, you can mock container interactions. -When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. - -To give the charm access to some containers, you need to pass them to the input state, like so: -`State(containers=[...])` - -An example of a scene including some containers: -```python -from scenario.state import Container, State -state = State(containers=[ - Container(name="foo", can_connect=True), - Container(name="bar", can_connect=False) -]) -``` - -In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. - -You can configure a container to have some files in it: - -```python -from pathlib import Path - -from scenario.state import Container, State, Mount - -local_file = Path('/path/to/local/real/file.txt') - -state = State(containers=[ - Container(name="foo", - can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file)}) -] -) -``` - -In this case, if the charm were to: -```python -def _on_start(self, _): - foo = self.unit.get_container('foo') - content = foo.pull('/local/share/config.yaml').read() -``` - -then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping strings and passing them to the charm via the container. - -`container.push` works similarly, so you can write a test like: - -```python -import tempfile -from ops.charm import CharmBase -from scenario.state import State, Container, Mount - - -class MyCharm(CharmBase): - def _on_start(self, _): - foo = self.unit.get_container('foo') - foo.push('/local/share/config.yaml', "TEST", make_dirs=True) - - -def test_pebble_push(): - local_file = tempfile.TemporaryFile() - container = Container(name='foo', - mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) - out = State( - containers=[container] - ).trigger( - container.pebble_ready_event, - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}, - ) - assert local_file.open().read() == "TEST" -``` - -`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event is that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. - -`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. -You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. - -```python -from ops.charm import CharmBase - -from scenario.state import State, Container, ExecOutput - -LS_LL = """ -.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml -.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml -.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md -drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib -""" - - -class MyCharm(CharmBase): - def _on_start(self, _): - foo = self.unit.get_container('foo') - proc = foo.exec(['ls', '-ll']) - stdout, _ = proc.wait_output() - assert stdout == LS_LL - - -def test_pebble_exec(): - container = Container( - name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) - } - ) - out = State( - containers=[container] - ).trigger( - container.pebble_ready_event, - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}, - ) -``` - - -# TODOS: -- State-State consistency checks. -- State-Metadata consistency checks. -- When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. -- Recorder \ No newline at end of file +THIS REPO IS BEING MIGRATED TO https://github.com/canonical/ops-scenario +After the currently open PRs are closed, this repo will be euthanized. Don't open new issues/PRs in here. From bd545a8d7dd785d2c28d3009db930ceb1e776d8a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Feb 2023 10:44:57 +0100 Subject: [PATCH 107/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 92fcddd51..b5cc120c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.1" +version = "2.1.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 1983252e92f229cb89dd334c03c8660e97b3f1fb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Feb 2023 10:49:01 +0100 Subject: [PATCH 108/546] .gitgub --- .github/workflows/build_wheels.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index 70de2718f..f6414e093 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -55,7 +55,7 @@ jobs: asset_name: ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl asset_content_type: application/wheel - - name: Publish to TestPyPI + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file From 5267f8ccbf346ef7fcebfe391a23f829d22a5adb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Feb 2023 10:54:50 +0100 Subject: [PATCH 109/546] vbump-ci --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5cc120c9..1ef13f6e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2" +version = "2.1.2-rc1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 05f63e912eb614883929476afda39049461782e3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Feb 2023 11:05:32 +0100 Subject: [PATCH 110/546] vbump-ci --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1ef13f6e5..b6cdeecbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2-rc1" +version = "2.1.2.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From b9390ff9bf36a8e0b03b18e9b1658909c4968e9b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Feb 2023 16:01:53 +0100 Subject: [PATCH 111/546] readme --- README.md | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 419 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10d0da043..54600340e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,419 @@ -THIS REPO IS BEING MIGRATED TO https://github.com/canonical/ops-scenario -After the currently open PRs are closed, this repo will be euthanized. Don't open new issues/PRs in here. +Ops-Scenario +============ + +This is a python library that you can use to run scenario-based tests. + +Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow +you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single +event on the charm and execute its logic. + +This puts scenario tests somewhere in between unit and integration tests. + +Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the +union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my +config?...). +The output is another context instance: the context after the charm has had a chance to interact with the mocked juju +model. + +![state transition model depiction](resources/state-transition-model.png) + +Scenario-testing a charm, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the scene +- the output state (or the diff with the input state) is as expected. + + +# Core concepts as a metaphor +I like metaphors, so here we go: +- There is a theatre stage. +- You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. +- You arrange the stage with content that the actor will have to interact with. This consists of selecting: + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those pebble-shaped books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). +- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. + + +# Core concepts not as a metaphor +Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black box. +An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. +Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. + +Comparing scenario tests with `Harness` tests: +- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired , then verify the validity of the state. +- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). +- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. +- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... + +# Writing scenario tests +A scenario test consists of three broad steps: + +- Arrange: + - declare the input state + - select an event to fire +- Act: + - run the state (i.e. obtain the output state) +- Assert: + - verify that the output state is how you expect it to be + - verify that the delta with the input state is what you expect it to be + +The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is +available. The charm has no config, no relations, no networks, and no leadership. + +With that, we can write the simplest possible scenario test: + +```python +from scenario.state import State +from ops.charm import CharmBase + + +class MyCharm(CharmBase): + pass + + +def test_scenario_base(): + out = State().trigger( + 'start', + MyCharm, meta={"name": "foo"}) + assert out.status.unit == ('unknown', '') +``` + +Now let's start making it more complicated. +Our charm sets a special state if it has leadership on 'start': + +```python +import pytest +from scenario.state import State +from ops.charm import CharmBase +from ops.model import ActiveStatus + + +class MyCharm(CharmBase): + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I am ruled') + + +@pytest.mark.parametrize('leader', (True, False)) +def test_status_leader(leader): + out = State(leader=leader).trigger( + 'start', + MyCharm, + meta={"name": "foo"}) + assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') +``` + +By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... + +## Relations + +You can write scenario tests to verify the shape of relation data: + +```python +from ops.charm import CharmBase + +from scenario.state import Relation, State + + +# This charm copies over remote app data to local unit data +class MyCharm(CharmBase): + ... + + def _on_event(self, e): + rel = e.relation + assert rel.app.name == 'remote' + assert rel.data[self.unit]['abc'] == 'foo' + rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] + + +def test_relation_data(): + out = State(relations=[ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ] + ).trigger("start", MyCharm, meta={"name": "foo"}) + + assert out.relations[0].local_unit_data == {"abc": "baz!"} + # you can do this to check that there are no other differences: + assert out.relations == [ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] + +# which is very idiomatic and superbly explicit. Noice. +``` + +## Containers + +When testing a kubernetes charm, you can mock container interactions. +When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. + +To give the charm access to some containers, you need to pass them to the input state, like so: +`State(containers=[...])` + +An example of a scene including some containers: +```python +from scenario.state import Container, State +state = State(containers=[ + Container(name="foo", can_connect=True), + Container(name="bar", can_connect=False) +]) +``` + +In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. + +You can configure a container to have some files in it: + +```python +from pathlib import Path + +from scenario.state import Container, State, Mount + +local_file = Path('/path/to/local/real/file.txt') + +state = State(containers=[ + Container(name="foo", + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file)}) +] +) +``` + +In this case, if the charm were to: +```python +def _on_start(self, _): + foo = self.unit.get_container('foo') + content = foo.pull('/local/share/config.yaml').read() +``` + +then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping strings and passing them to the charm via the container. + +`container.push` works similarly, so you can write a test like: + +```python +import tempfile +from ops.charm import CharmBase +from scenario.state import State, Container, Mount + + +class MyCharm(CharmBase): + def _on_start(self, _): + foo = self.unit.get_container('foo') + foo.push('/local/share/config.yaml', "TEST", make_dirs=True) + + +def test_pebble_push(): + local_file = tempfile.TemporaryFile() + container = Container(name='foo', + mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) + out = State( + containers=[container] + ).trigger( + container.pebble_ready_event, + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, + ) + assert local_file.open().read() == "TEST" +``` + +`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event is that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. + +`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. +You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. + +```python +from ops.charm import CharmBase + +from scenario.state import State, Container, ExecOutput + +LS_LL = """ +.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml +.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml +.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md +drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib +""" + + +class MyCharm(CharmBase): + def _on_start(self, _): + foo = self.unit.get_container('foo') + proc = foo.exec(['ls', '-ll']) + stdout, _ = proc.wait_output() + assert stdout == LS_LL + + +def test_pebble_exec(): + container = Container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + ) + out = State( + containers=[container] + ).trigger( + container.pebble_ready_event, + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, + ) +``` + + +# Deferred events +Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. +On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. + +```python +from scenario import State, deferred + + +class MyCharm(...): + ... + def _on_update_status(self, e): + e.defer() + def _on_start(self, e): + e.defer() + + +def test_start_on_deferred_update_status(MyCharm): + """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" + out = State( + deferred=[ + deferred('update_status', + handler=MyCharm._on_update_status) + ] + ).trigger('start', MyCharm) + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' +``` + +You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the handler): + +```python +from scenario import Event, Relation + +class MyCharm(...): + ... + +deferred_start = Event('start').deferred(MyCharm._on_start) +deferred_install = Event('install').deferred(MyCharm._on_start) +``` + +## relation events: +```python +foo_relation = Relation('foo') +deferred_relation_changed_evt = foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +``` +On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. + +```python +from scenario import State + + +class MyCharm(...): + ... + def _on_start(self, e): + e.defer() + + +def test_defer(MyCharm): + out = State().trigger('start', MyCharm) + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' +``` + +## Deferring relation events +If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data structure: + +```python +from scenario import State, Relation, deferred + + +class MyCharm(...): + ... + def _on_foo_relation_changed(self, e): + e.defer() + + +def test_start_on_deferred_update_status(MyCharm): + foo_relation = Relation('foo') + State( + relations=[foo_relation], + deferred=[ + deferred('foo_relation_changed', + handler=MyCharm._on_foo_relation_changed, + relation=foo_relation) + ] + ) +``` +but you can also use a shortcut from the relation event itself, as mentioned above: + +```python + +from scenario import Relation + +class MyCharm(...): + ... + +foo_relation = Relation('foo') +foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +``` + +Fine-tuning +The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by charm libraries or objects other than the main charm class. + +For general-purpose usage, you will need to instantiate DeferredEvent directly. + +```python +from scenario import DeferredEvent + +my_deferred_event = DeferredEvent( + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' +) +``` + + +# StoredState + +Scenario can simulate StoredState. +You can define it on the input side as: + +```python + +from scenario import State, StoredState + +state = State(stored_state=[ + StoredState( + owner="MyCharmType", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) +]) +``` + +And you can run assertions on it on the output side the same as any other bit of state. + +# TODOS: +- State-State consistency checks. +- State-Metadata consistency checks. +- When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. +- Recorder \ No newline at end of file From 7ebf2a475a80f351ef7eff3b9dbd40fda83e1041 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 15 Feb 2023 16:32:21 +0100 Subject: [PATCH 112/546] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 54600340e..6ec6dd682 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Ops-Scenario ============ -This is a python library that you can use to run scenario-based tests. +This is a black-box, contract testing framework for Operator Framework charms. Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single @@ -416,4 +416,4 @@ And you can run assertions on it on the output side the same as any other bit of - State-State consistency checks. - State-Metadata consistency checks. - When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. -- Recorder \ No newline at end of file +- Recorder From 4aa7c317e5ea910976f0eb171f0eda9a3bb54f84 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 16 Feb 2023 09:37:31 +0100 Subject: [PATCH 113/546] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ec6dd682..11e83024d 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,8 @@ foo_relation = Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` -Fine-tuning +### Fine-tuning + The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. From e6363062f33f82134258ac938c960235a8e98cba Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 10:06:28 +0100 Subject: [PATCH 114/546] pebble.get_plan --- scenario/mocking.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scenario/mocking.py b/scenario/mocking.py index bc40a8209..08b01290f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -5,6 +5,7 @@ from io import StringIO from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union +import yaml from ops import pebble from ops.model import SecretInfo, SecretRotate, _ModelBackend from ops.pebble import Client, ExecError @@ -362,6 +363,19 @@ def __init__( self._event = event self._charm_spec = charm_spec + def get_plan(self) -> pebble.Plan: + # TODO: verify + services = {} + checks = {} + for _, layer in self._container.layers.items(): + services.update({name: s.to_dict() for name, s in layer.services.items()}) + checks.update({name: s.to_dict() for name, s in layer.checks.items()}) + + plandict = {'services': services, + 'checks': checks} + planyaml = yaml.safe_dump(plandict) + return pebble.Plan(planyaml) + @property def _container(self) -> "ContainerSpec": container_name = self.socket_path.split("/")[-2] From 0293c387ccbe6d3b8dd9e29a2cf5d5c2c46e09aa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 10:06:46 +0100 Subject: [PATCH 115/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6cdeecbd..2350080ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2.1" +version = "2.1.2.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 0de470c4eeae0055234de6567ff5865f033c1700 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 10:18:05 +0100 Subject: [PATCH 116/546] plan moved to Container --- scenario/mocking.py | 12 +----------- scenario/state.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 08b01290f..a11087d3b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -364,17 +364,7 @@ def __init__( self._charm_spec = charm_spec def get_plan(self) -> pebble.Plan: - # TODO: verify - services = {} - checks = {} - for _, layer in self._container.layers.items(): - services.update({name: s.to_dict() for name, s in layer.services.items()}) - checks.update({name: s.to_dict() for name, s in layer.checks.items()}) - - plandict = {'services': services, - 'checks': checks} - planyaml = yaml.safe_dump(plandict) - return pebble.Plan(planyaml) + return self._container.plan @property def _container(self) -> "ContainerSpec": diff --git a/scenario/state.py b/scenario/state.py index ade27b406..e5e738389 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -267,6 +267,20 @@ class Container(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + @property + def plan(self) -> pebble.Plan: + # TODO: verify + services = {} + checks = {} + for _, layer in self.layers.items(): + services.update({name: s.to_dict() for name, s in layer.services.items()}) + checks.update({name: s.to_dict() for name, s in layer.checks.items()}) + + plandict = {'services': services, + 'checks': checks} + planyaml = yaml.safe_dump(plandict) + return pebble.Plan(planyaml) + @property def filesystem(self) -> _MockFileSystem: mounts = { From 7a375d3aa023bfdd777b19759a7cd875a0220bf5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 10:44:28 +0100 Subject: [PATCH 117/546] lint --- scenario/state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index e5e738389..00e630776 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -276,8 +276,7 @@ def plan(self) -> pebble.Plan: services.update({name: s.to_dict() for name, s in layer.services.items()}) checks.update({name: s.to_dict() for name, s in layer.checks.items()}) - plandict = {'services': services, - 'checks': checks} + plandict = {"services": services, "checks": checks} planyaml = yaml.safe_dump(plandict) return pebble.Plan(planyaml) From 38eb0f7cbef726ea397405863bea5284e36ed022 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Fri, 17 Feb 2023 12:08:30 +0100 Subject: [PATCH 118/546] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11e83024d..b6f03539c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Ops-Scenario ============ -This is a black-box, contract testing framework for Operator Framework charms. +This is a state transition testing framework for Operator Framework charms. Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single From 68b7b7ce04bec7c8c59290141ab01a10d97dbc3c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Feb 2023 12:32:01 +0100 Subject: [PATCH 119/546] fixed some pebble model and network model issues --- README.md | 2 +- pyproject.toml | 2 +- scenario/state.py | 98 ++++++++++++++++++++++++---------- tests/test_e2e/test_network.py | 2 +- tests/test_e2e/test_pebble.py | 31 ++++++++--- 5 files changed, 98 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 11e83024d..b6f03539c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Ops-Scenario ============ -This is a black-box, contract testing framework for Operator Framework charms. +This is a state transition testing framework for Operator Framework charms. Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single diff --git a/pyproject.toml b/pyproject.toml index 2350080ed..5d1c0e9a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2.2" +version = "2.1.2.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/state.py b/scenario/state.py index 00e630776..ab8f14e12 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -4,6 +4,8 @@ import inspect import re import typing +from itertools import chain +from operator import attrgetter from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 @@ -204,6 +206,7 @@ def _random_model_name(): class Model(_DCBase): name: str = _random_model_name() uuid: str = str(uuid4()) + type: Literal["kubernetes", "lxd"] = "kubernetes" # for now, proc mock allows you to map one command to one mocked output. @@ -244,7 +247,17 @@ def __post_init__(self): class Container(_DCBase): name: str can_connect: bool = False + + # This is the base plan. On top of it, one can add layers. + # We need to model pebble in this way because it's impossible to retrieve the layers from pebble + # or derive them from the resulting plan (which one CAN get from pebble). + # So if we are instantiating Container by fetching info from a 'live' charm, the 'layers' will be unknown. + # all that we can know is the resulting plan (the 'computed plan'). + _base_plan: dict = dataclasses.field(default_factory=dict) + # We expect most of the user-facing testing to be covered by this 'layers' attribute, + # as all will be known when unit-testing. layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) + service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( default_factory=dict ) @@ -267,18 +280,53 @@ class Container(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + def _render_services(self): + # copied over from ops.testing._TestingPebbleClient._render_services() + services = {} # type: Dict[str, pebble.Service] + for key in sorted(self.layers.keys()): + layer = self.layers[key] + for name, service in layer.services.items(): + services[name] = service + return services + @property def plan(self) -> pebble.Plan: - # TODO: verify - services = {} - checks = {} - for _, layer in self.layers.items(): - services.update({name: s.to_dict() for name, s in layer.services.items()}) - checks.update({name: s.to_dict() for name, s in layer.checks.items()}) + """This is the 'computed' pebble plan; i.e. the base plan plus the layers that have been added on top. - plandict = {"services": services, "checks": checks} - planyaml = yaml.safe_dump(plandict) - return pebble.Plan(planyaml) + You should run your assertions on the plan, not so much on the layers, as those are input data. + """ + + # copied over from ops.testing._TestingPebbleClient.get_plan(). + plan = pebble.Plan(yaml.safe_dump(self._base_plan)) + services = self._render_services() + if not services: + return plan + for name in sorted(services.keys()): + plan.services[name] = services[name] + return plan + + @property + def services(self) -> Dict[str, pebble.ServiceInfo]: + services = self._render_services() + infos = {} # type: Dict[str, pebble.ServiceInfo] + names = sorted(services.keys()) + for name in names: + try: + service = services[name] + except KeyError: + # in pebble, it just returns "nothing matched" if there are 0 matches, + # but it ignores services it doesn't recognize + continue + status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) + if service.startup == '': + startup = pebble.ServiceStartup.DISABLED + else: + startup = pebble.ServiceStartup(service.startup) + info = pebble.ServiceInfo(name, + startup=startup, + current=pebble.ServiceStatus(status)) + infos[name] = info + return infos @property def filesystem(self) -> _MockFileSystem: @@ -304,42 +352,39 @@ class Address(_DCBase): hostname: str value: str cidr: str + address: str = "" # legacy @dataclasses.dataclass class BindAddress(_DCBase): - mac_address: str interface_name: str - interfacename: str # noqa legacy addresses: List[Address] + mac_address: Optional[str] = None def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would - return { - "bind-addresses": self.mac_address, + # todo support for legacy (deprecated `interfacename` and `macaddress` fields? + dct = { "interface-name": self.interface_name, - "interfacename": self.interfacename, "addresses": [dataclasses.asdict(addr) for addr in self.addresses], } + if self.mac_address: + dct["mac-address"] = self.mac_address + return dct @dataclasses.dataclass class Network(_DCBase): name: str - bind_id: int bind_addresses: List[BindAddress] - bind_address: str - egress_subnets: List[str] ingress_addresses: List[str] - - is_default: bool = False + egress_subnets: List[str] def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], - "bind-address": self.bind_address, "egress-subnets": self.egress_subnets, "ingress-addresses": self.ingress_addresses, } @@ -348,24 +393,21 @@ def hook_tool_output_fmt(self): def default( cls, name, - bind_id, private_address: str = "1.1.1.1", - mac_address: str = "", hostname: str = "", cidr: str = "", interface_name: str = "", + mac_address: Optional[str] = None, egress_subnets=("1.1.1.2/32",), ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( name=name, - bind_id=bind_id, bind_addresses=[ BindAddress( - mac_address=mac_address, interface_name=interface_name, - interfacename=interface_name, + mac_address=mac_address, addresses=[ Address(hostname=hostname, value=private_address, cidr=cidr) ], @@ -402,7 +444,7 @@ def handle_path(self): @dataclasses.dataclass class State(_DCBase): - config: Dict[str, Union[str, int, float, bool]] = None + config: Dict[str, Union[str, int, float, bool]] = dataclasses.field(default_factory=dict) relations: List[Relation] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) @@ -414,7 +456,7 @@ class State(_DCBase): # meta stuff: actually belongs in event data structure. juju_version: str = "3.0.0" - unit_id: str = "0" + unit_id: int = 0 app_name: str = "local" # represents the OF's event queue. These events will be emitted before the event being dispatched, @@ -429,7 +471,7 @@ class State(_DCBase): @property def unit_name(self): - return self.app_name + "/" + self.unit_id + return f"{self.app_name}/{self.unit_id}" def with_can_connect(self, container_name: str, can_connect: bool): def replacer(container: Container): diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 957fe1cc4..cc6ee742c 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -43,7 +43,7 @@ def fetch_unit_address(charm: CharmBase): relation_id=1, ) ], - networks=[Network.default("metrics-endpoint", bind_id=0)], + networks=[Network.default("metrics-endpoint")], ), "update-status", mycharm, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 14325fa82..3502a2887 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +import yaml from ops import pebble from ops.charm import CharmBase from ops.framework import Framework @@ -156,8 +157,8 @@ def callback(self: CharmBase): @pytest.mark.parametrize( "cmd, out", ( - ("ls", LS), - ("ps", PS), + ("ls", LS), + ("ps", PS), ), ) def test_exec(charm_cls, cmd, out): @@ -198,7 +199,8 @@ def callback(self: CharmBase): ) -def test_pebble_plan(charm_cls): +@pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus) +def test_pebble_plan(charm_cls, starting_service_status): def callback(self: CharmBase): foo = self.unit.get_container("foo") @@ -207,7 +209,7 @@ def callback(self: CharmBase): } fooserv = foo.get_services("fooserv")["fooserv"] assert fooserv.startup == ServiceStartup.ENABLED - assert fooserv.current == ServiceStatus.INACTIVE + assert fooserv.current == ServiceStatus.ACTIVE foo.add_layer( "bar", @@ -226,8 +228,9 @@ def callback(self: CharmBase): } } - assert foo.get_service("barserv").current == ServiceStatus.INACTIVE + assert foo.get_service("barserv").current == starting_service_status foo.start("barserv") + # whatever the original state, starting a service sets it to active assert foo.get_service("barserv").current == ServiceStatus.ACTIVE container = Container( @@ -242,11 +245,27 @@ def callback(self: CharmBase): } ) }, + service_status={ + "fooserv": pebble.ServiceStatus.ACTIVE, + # todo: should we disallow setting status for services that aren't known YET? + "barserv": starting_service_status, + } ) - State(containers=[container]).trigger( + out = State(containers=[container]).trigger( charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event=container.pebble_ready_event, post_event=callback, ) + + serv = lambda name, obj: pebble.Service(name, raw=obj) + container = out.containers[0] + assert container.plan.services == { + 'barserv': serv('barserv', {'startup': 'disabled'}), + 'fooserv': serv('fooserv', {'startup': 'enabled'})} + assert container.services['fooserv'].current == pebble.ServiceStatus.ACTIVE + assert container.services['fooserv'].startup == pebble.ServiceStartup.ENABLED + + assert container.services['barserv'].current == pebble.ServiceStatus.ACTIVE + assert container.services['barserv'].startup == pebble.ServiceStartup.DISABLED From ae8691bdc257c3387fda8f72aadcd32198c4b4f5 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 23 Feb 2023 12:46:25 +0100 Subject: [PATCH 120/546] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b6f03539c..dc6b2c776 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ An initial state goes in, an event occurs (say, `'start'`) and a new state comes Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. Comparing scenario tests with `Harness` tests: -- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired , then verify the validity of the state. +- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired state, then verify its validity by calling charm methods or inspecting the raw data. - Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). - Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. - TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... From b9ea7d1dc33299e61925e3b5f10ea6201a51ccf1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 23 Feb 2023 17:46:09 +0100 Subject: [PATCH 121/546] rebased on main --- pyproject.toml | 2 + requirements.txt | 3 +- scenario/scripts/__init__.py | 0 scenario/scripts/main.py | 24 ++ scenario/scripts/snapshot.py | 495 +++++++++++++++++++++++++++++++++++ 5 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 scenario/scripts/__init__.py create mode 100644 scenario/scripts/main.py create mode 100644 scenario/scripts/snapshot.py diff --git a/pyproject.toml b/pyproject.toml index 5d1c0e9a7..16fd298be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ classifiers = [ "Homepage" = "https://github.com/PietroPasotti/ops-scenario" "Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" +[project.scripts] +scenario = "scenario.scripts.main:main" [tool.setuptools.package-dir] scenario = "scenario" diff --git a/requirements.txt b/requirements.txt index 8161ae96d..2fe5dcba4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -ops==2.0.0 \ No newline at end of file +ops==2.0.0 +typer \ No newline at end of file diff --git a/scenario/scripts/__init__.py b/scenario/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py new file mode 100644 index 000000000..a53f9a49f --- /dev/null +++ b/scenario/scripts/main.py @@ -0,0 +1,24 @@ +import typer + +from scenario.scripts.snapshot import snapshot + + +def main(): + app = typer.Typer( + name="scenario", + help="Scenario utilities.", + no_args_is_help=True, + rich_markup_mode="markdown", + ) + + # trick to prevent 'snapshot' from being the toplevel command. + # We want to do `scenario snapshot`, not just `snapshot`. + # TODO remove if/when scenario has more subcommands. + app.command(name="_", hidden=True)(lambda: None) + + app.command(name="snapshot", no_args_is_help=True)(snapshot) + app() + + +if __name__ == "__main__": + main() diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py new file mode 100644 index 000000000..42a26f654 --- /dev/null +++ b/scenario/scripts/snapshot.py @@ -0,0 +1,495 @@ +import json +import logging +import os +import re +from pathlib import Path +from subprocess import run +from textwrap import dedent +from typing import Any, Dict, List, Union, Optional, BinaryIO, TextIO, Iterable +import ops.pebble + +import typer +import yaml + +from scenario.state import Address, BindAddress, Model, Network, Relation, State, Status, Container + +logger = logging.getLogger("snapshot") + +JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) +JUJU_CONFIG_KEYS = frozenset({}) + + +class SnapshotError(RuntimeError): + pass + + +class InvalidTarget(SnapshotError): + pass + + +class InvalidModel(SnapshotError): + pass + + +class Target(str): + def __init__(self, unit_name: str): + super().__init__() + app_name, _, unit_id = unit_name.rpartition('/') + if not app_name or not unit_id: + raise InvalidTarget(f'invalid unit name: {unit_name!r}') + self.unit_name = unit_name + self.app_name = app_name + self.unit_id = int(unit_id) + self.normalized = f"{app_name}-{unit_id}" + + +def _try_format(string): + try: + import black + + try: + return black.format_str(string, mode=black.Mode()) + except black.parsing.InvalidInput as e: + logger.error(f"error parsing {string}: {e}") + return string + except ModuleNotFoundError: + logger.warning("install black for formatting") + return string + + +def format_state(state): + return _try_format(repr(state)) + + +def format_test_case(state, charm_type_name=None, event_name=None): + ct = charm_type_name or "CHARM_TYPE # TODO: replace with charm type name" + en = event_name or "EVENT_NAME, # TODO: replace with event name" + return _try_format( + dedent( + f""" +from scenario.state import * +from charm import {ct} + +def test_case(): + state = {state} + out = state.trigger( + {en} + {ct} + ) + +""" + ) + ) + + +def _juju_run(cmd, model=None) -> Dict[str, Any]: + _model = f" -m {model}" if model else "" + raw = run( + f"""juju {cmd}{_model} --format json""".split(), capture_output=True + ).stdout.decode("utf-8") + return json.loads(raw) + + +def _juju_ssh(target: Target, cmd, model=None) -> str: + _model = f" -m {model}" if model else "" + raw = run( + f"""juju ssh {target.unit_name} {_model} {cmd}""".split(), capture_output=True + ).stdout.decode("utf-8") + return raw + + +def _juju_exec(target: Target, model, cmd) -> str: + # action-fail juju-reboot payload-unregister secret-remove + # action-get jujuc pod-spec-get secret-revoke + # action-log k8s-raw-get pod-spec-set secret-set + # action-set k8s-raw-set relation-get state-delete + # add-metric k8s-spec-get relation-ids state-get + # application-version-set k8s-spec-set relation-list state-set + # close-port leader-get relation-set status-get + # config-get leader-set resource-get status-set + # containeragent network-get secret-add storage-add + # credential-get open-port secret-get storage-get + # goal-state opened-ports secret-grant storage-list + # is-leader payload-register secret-ids unit-get + # juju-log payload-status-set secret-info-get + _model = f" -m {model}" if model else "" + _target = f" -u {target}" if target else "" + return run( + f"juju exec{_model}{_target} -- {cmd}".split(), capture_output=True + ).stdout.decode("utf-8") + + +def get_leader(target: Target, model): + # could also get it from _juju_run('status')... + return _juju_exec(target, model, "is-leader") == "True" + + +def get_network(target: Target, model, relation_name: str, is_default=False) -> Network: + status = _juju_run(f"status {target}", model=model) + app = status["applications"][target.app_name] + bind_address = app.get("address", "") + + raw = _juju_exec(target, model, f"network-get {relation_name}") + jsn = yaml.safe_load(raw) + + bind_addresses = [] + for raw_bind in jsn["bind-addresses"]: + + addresses = [] + for raw_adds in raw_bind["addresses"]: + addresses.append( + Address( + hostname=raw_adds["hostname"], + value=raw_adds["value"], + cidr=raw_adds["cidr"], + address=raw_adds.get("address", ""), + ) + ) + + bind_addresses.append( + BindAddress( + interface_name=raw_bind.get("interface-name", ""), addresses=addresses + ) + ) + return Network( + relation_name, + bind_addresses=bind_addresses, + bind_address=bind_address, + egress_subnets=jsn["egress-subnets"], + ingress_addresses=jsn["ingress-addresses"], + is_default=is_default, + ) + + +def get_networks(target: Target, model, relations: List[str]) -> List[Network]: + networks = [] + networks.append(get_network(target, model, "juju-info")) + for relation in relations: + networks.append(get_network(target, model, relation)) + return networks + + +def get_metadata(target: Target, model: str): + raw_meta = _juju_ssh(target, f"cat ./agents/unit-{target.normalized}/charm/metadata.yaml", model=model) + return yaml.safe_load(raw_meta) + + +class RemotePebbleClient: + """Clever little class that wraps calls to a remote pebble client.""" + + # TODO: there is a .pebble.state + # " j ssh --container traefik traefik/0 cat var/lib/pebble/default/.pebble.state | jq" + # figure out what it's for. + + def __init__(self, container: str, target: Target, model: str = None): + self.container = container + self.target = target + self.model = model + self.socket_path = f"/charm/containers/{container}/pebble.socket" + + def _run(self, cmd) -> str: + _model = f" -m {self.model}" if self.model else "" + command = f'juju ssh --container charm {self.target.unit_name}{_model} {cmd}' + proc = run(command.split(), capture_output=True) + if proc.returncode == 0: + return proc.stdout.decode('utf-8') + raise RuntimeError(f"error wrapping pebble call with {command}: " + f"process exited with {proc.returncode}; " + f"stdout = {proc.stdout}; " + f"stderr = {proc.stderr}") + + def wrap_call(self, meth: str): + # todo: machine charm compat? + cd = f"cd ./agents/unit-{self.target.normalized}/charm/venv" + imports = "from ops.pebble import Client" + method_call = f"print(Client(socket_path='{self.socket_path}').{meth})" + cmd = dedent(f"""{cd}; python3 -c "{imports};{method_call}" """) + out = self._run(cmd) + return out + + def can_connect(self) -> bool: + try: + version = self.get_system_info() + except Exception: + return False + return bool(version) + + def get_system_info(self): + return self.wrap_call('get_system_info().version') + + def get_plan(self): + dct_plan = self.wrap_call('get_plan().to_dict()') + return ops.pebble.Plan(dct_plan) + + def pull(self, + path: str, + *, + encoding: Optional[str] = 'utf-8') -> Union[BinaryIO, TextIO]: + raise NotImplementedError() + + def list_files(self, path: str, *, pattern: Optional[str] = None, + itself: bool = False) -> List[ops.pebble.FileInfo]: + raise NotImplementedError() + + def get_checks( + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None + ) -> List[ops.pebble.CheckInfo]: + raise NotImplementedError() + + +def get_container(target: Target, model, container_name: str, container_meta) -> Container: + pebble = RemotePebbleClient(container_name, target, model) + layers = pebble.get_plan() + return Container( + name=container_name, + layers=layers, + can_connect=pebble.can_connect() + ) + + +def get_containers(target: Target, model, metadata) -> List[Container]: + containers = [] + for container_name, container_meta in metadata.get('containers', {}).items(): + container = get_container(target, model, container_name, container_meta) + containers.append(container) + return containers + + +def get_status(target: Target, model) -> Status: + status = _juju_run(f"status {target}", model=model) + app = status["applications"][target.app_name] + + app_status_raw = app["application-status"] + app_status = app_status_raw["current"], app_status_raw.get("message", "") + + unit_status_raw = app["units"][target]["workload-status"] + unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") + + app_version = app.get("version", "") + return Status(app=app_status, unit=unit_status, app_version=app_version) + + +def _cast(value: str, _type): + if _type == "string": + return value + elif _type == "integer": + return int(value) + elif _type == "number": + return float(value) + elif _type == "bool": + return value == "true" + elif _type == "attrs": # TODO: WOT? + return value + else: + raise ValueError(_type) + + +def get_config(target: Target, model: str) -> Dict[str, Union[str, int, float, bool]]: + _model = f" -m {model}" if model else "" + jsn = _juju_run(f"config {target.app_name}", model=model) + + cfg = {} + for name, option in jsn.get("settings", ()).items(): + if not option.get("value"): + logger.debug(f"skipped {name}: no value.") + continue + cfg[name] = _cast(option["value"], option["type"]) + + return cfg + + +def _get_interface_from_metadata(endpoint, metadata): + for role in ['provides', 'requires']: + for ep, ep_meta in metadata.get(role, {}).items(): + if ep == endpoint: + return ep_meta['interface'] + + logger.error(f'No interface for endpoint {endpoint} found in charm metadata.') + return None + + +def get_relations( + target: Target, model: str, metadata: Dict, include_juju_relation_data=False, +) -> List[Relation]: + _model = f" -m {model}" if model else "" + try: + jsn = _juju_run(f"show-unit {target}", model=model) + except json.JSONDecodeError as e: + raise InvalidTarget(target) from e + + def _clean(relation_data: dict): + if include_juju_relation_data: + return relation_data + else: + for key in JUJU_RELATION_KEYS: + del relation_data[key] + return relation_data + + relations = [] + for raw_relation in jsn[target].get("relation-info", ()): + related_units = raw_relation["related-units"] + # related-units: + # owner/0: + # in-scope: true + # data: + # egress-subnets: 10.152.183.130/32 + # ingress-address: 10.152.183.130 + # private-address: 10.152.183.130 + + relation_id = raw_relation["relation-id"] + + local_unit_data_raw = _juju_exec( + target, model, f"relation-get -r {relation_id} - {target} --format json" + ) + local_unit_data = json.loads(local_unit_data_raw) + local_app_data_raw = _juju_exec( + target, + model, + f"relation-get -r {relation_id} - {target} --format json --app", + ) + local_app_data = json.loads(local_app_data_raw) + + some_remote_unit_id = Target(next(iter(related_units))) + relations.append( + Relation( + endpoint=raw_relation["endpoint"], + interface=_get_interface_from_metadata(raw_relation["endpoint"], metadata), + relation_id=relation_id, + remote_app_data=raw_relation["application-data"], + remote_app_name=some_remote_unit_id.app_name, + remote_units_data={ + Target(tgt).unit_id: _clean(val["data"]) + for tgt, val in related_units.items() + }, + local_app_data=local_app_data, + local_unit_data=_clean(local_unit_data), + ) + ) + return relations + + +def get_model(name: str = None) -> Model: + jsn = _juju_run("models") + model_name = name or jsn["current-model"] + try: + model_info = next( + filter(lambda m: m["short-name"] == model_name, jsn["models"]) + ) + except StopIteration as e: + raise InvalidModel(name) from e + + model_uuid = model_info["model-uuid"] + model_type = model_info["type"] + + return Model(name=model_name, uuid=model_uuid, type=model_type) + + +def try_guess_charm_type_name(): + try: + charm_path = Path(os.getcwd()) / "src" / "charm.py" + if charm_path.exists(): + source = charm_path.read_text() + charms = re.compile(r"class (\D+)\(CharmBase\):").findall(source) + if len(charms) < 1: + raise RuntimeError(f"Not enough charms at {charm_path}.") + elif len(charms) > 1: + raise RuntimeError(f"Too many charms at {charm_path}.") + return charms[0] + except Exception as e: + logger.warning(f"unable to guess charm type: {e}") + return None + + +def _snapshot( + target: str, + model: str = None, + pprint: bool = True, + include_juju_relation_data=False, + full_case=False, +): + try: + target = Target(target) + except InvalidTarget: + print(f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" + f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.") + exit(1) + + metadata = get_metadata(target, model) + + try: + relations = get_relations( + target, model, metadata=metadata, + include_juju_relation_data=include_juju_relation_data + ) + except InvalidTarget: + _model = f"model {model}" or "the current model" + print(f"invalid target: {target!r} not found in {_model}") + exit(1) + + try: + model_info = get_model(model) + except InvalidModel: + # todo: this should surface earlier. + print(f"invalid model: {model!r} not found.") + exit(1) + + state = State( + leader=get_leader(target, model), + model=model_info, + status=get_status(target, model), + config=get_config(target, model), + relations=relations, + app_name=target.app_name, + unit_id=target.unit_id, + containers=get_containers(target, model, metadata), + networks=get_networks(target, model, [r.endpoint for r in relations]), + ) + + if pprint: + if full_case: + charm_type_name = try_guess_charm_type_name() + txt = format_test_case(state, charm_type_name=charm_type_name) + else: + txt = format_state(state) + print(txt) + + return state + + +def snapshot( + target: str = typer.Argument(..., help="Target unit."), + model: str = typer.Option(None, "-m", "--model", help="Which model to look at."), + full: bool = typer.Option( + False, + "-f", + "--full", + help="Whether to print a full, nearly-executable Scenario test, or just the State.", + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), +) -> State: + """Print the State of a remote target unit. + + If black is available, the output will be piped through it for formatting. + + Usage: snapshot myapp/0 > ./tests/scenario/case1.py + """ + return _snapshot( + target, + model, + full_case=full, + include_juju_relation_data=include_juju_relation_data, + ) + + +if __name__ == "__main__": + # print(_snapshot("owner/0")) + print(get_container(Target('traefik/0'), "", container_name='traefik', container_meta={})) + From 50a3867d6084f16ec130646e20508aa08daa7ce7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 12:10:00 +0100 Subject: [PATCH 122/546] fixed some typos in network-get --- scenario/scripts/snapshot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 42a26f654..a77b4f3c7 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -2,6 +2,7 @@ import logging import os import re +from dataclasses import asdict from pathlib import Path from subprocess import run from textwrap import dedent @@ -453,7 +454,7 @@ def _snapshot( txt = format_test_case(state, charm_type_name=charm_type_name) else: txt = format_state(state) - print(txt) + print(asdict(txt)) return state @@ -490,6 +491,5 @@ def snapshot( if __name__ == "__main__": - # print(_snapshot("owner/0")) - print(get_container(Target('traefik/0'), "", container_name='traefik', container_meta={})) + print(_snapshot("controller/0")) From 206d501a894bca1229f3e71b3ca6d93ba9f643b5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Feb 2023 14:42:32 +0100 Subject: [PATCH 123/546] fixed pebble plan --- scenario/scripts/snapshot.py | 57 ++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index a77b4f3c7..9f1aed4af 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -2,7 +2,6 @@ import logging import os import re -from dataclasses import asdict from pathlib import Path from subprocess import run from textwrap import dedent @@ -11,6 +10,7 @@ import typer import yaml +from ops import pebble from scenario.state import Address, BindAddress, Model, Network, Relation, State, Status, Container @@ -93,8 +93,9 @@ def _juju_run(cmd, model=None) -> Dict[str, Any]: def _juju_ssh(target: Target, cmd, model=None) -> str: _model = f" -m {model}" if model else "" + command = f"""juju ssh{_model} {target.unit_name} {cmd}""" raw = run( - f"""juju ssh {target.unit_name} {_model} {cmd}""".split(), capture_output=True + command.split(), capture_output=True ).stdout.decode("utf-8") return raw @@ -156,8 +157,8 @@ def get_network(target: Target, model, relation_name: str, is_default=False) -> relation_name, bind_addresses=bind_addresses, bind_address=bind_address, - egress_subnets=jsn["egress-subnets"], - ingress_addresses=jsn["ingress-addresses"], + egress_subnets=jsn.get("egress-subnets", None), + ingress_addresses=jsn.get("ingress-addresses", None), is_default=is_default, ) @@ -175,6 +176,13 @@ def get_metadata(target: Target, model: str): return yaml.safe_load(raw_meta) +class Plan(ops.pebble.Plan): + """pprintable pebble plan""" + def __repr__(self): + raw_dct = repr(yaml.safe_load(self._raw)) + return f"pebble.Plan(yaml.safe_dump({raw_dct}))" + + class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" @@ -183,14 +191,14 @@ class RemotePebbleClient: # figure out what it's for. def __init__(self, container: str, target: Target, model: str = None): + self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container self.target = target self.model = model - self.socket_path = f"/charm/containers/{container}/pebble.socket" - def _run(self, cmd) -> str: + def _run(self, cmd: str) -> str: _model = f" -m {self.model}" if self.model else "" - command = f'juju ssh --container charm {self.target.unit_name}{_model} {cmd}' + command = f'juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}' proc = run(command.split(), capture_output=True) if proc.returncode == 0: return proc.stdout.decode('utf-8') @@ -199,15 +207,6 @@ def _run(self, cmd) -> str: f"stdout = {proc.stdout}; " f"stderr = {proc.stderr}") - def wrap_call(self, meth: str): - # todo: machine charm compat? - cd = f"cd ./agents/unit-{self.target.normalized}/charm/venv" - imports = "from ops.pebble import Client" - method_call = f"print(Client(socket_path='{self.socket_path}').{meth})" - cmd = dedent(f"""{cd}; python3 -c "{imports};{method_call}" """) - out = self._run(cmd) - return out - def can_connect(self) -> bool: try: version = self.get_system_info() @@ -216,11 +215,11 @@ def can_connect(self) -> bool: return bool(version) def get_system_info(self): - return self.wrap_call('get_system_info().version') + return self._run('version') def get_plan(self): - dct_plan = self.wrap_call('get_plan().to_dict()') - return ops.pebble.Plan(dct_plan) + plan_raw = self._run('plan') + return Plan(plan_raw) def pull(self, path: str, @@ -237,6 +236,11 @@ def get_checks( level: Optional[ops.pebble.CheckLevel] = None, names: Optional[Iterable[str]] = None ) -> List[ops.pebble.CheckInfo]: + _level = f" --level={level}" if level else "" + _names = (" " + f" ".join(names)) if names else "" + out = self._run(f'checks{_level}{_names}') + if out == 'Plan has no health checks.': + return [] raise NotImplementedError() @@ -250,7 +254,11 @@ def get_container(target: Target, model, container_name: str, container_meta) -> ) -def get_containers(target: Target, model, metadata) -> List[Container]: +def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Container]: + if not metadata: + logger.warning('no metadata: unable to get containers') + return [] + containers = [] for container_name, container_meta in metadata.get('containers', {}).items(): container = get_container(target, model, container_name, container_meta) @@ -279,7 +287,7 @@ def _cast(value: str, _type): return int(value) elif _type == "number": return float(value) - elif _type == "bool": + elif _type == "boolean": return value == "true" elif _type == "attrs": # TODO: WOT? return value @@ -312,7 +320,7 @@ def _get_interface_from_metadata(endpoint, metadata): def get_relations( - target: Target, model: str, metadata: Dict, include_juju_relation_data=False, + target: Target, model: str, metadata: Optional[Dict], include_juju_relation_data=False, ) -> List[Relation]: _model = f" -m {model}" if model else "" try: @@ -454,7 +462,7 @@ def _snapshot( txt = format_test_case(state, charm_type_name=charm_type_name) else: txt = format_state(state) - print(asdict(txt)) + print(txt) return state @@ -491,5 +499,4 @@ def snapshot( if __name__ == "__main__": - print(_snapshot("controller/0")) - + print(_snapshot("trfk/0", model='foo')) From 85d06eea51425d0b340f44c72c7e536f3cbffe37 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 20 Feb 2023 14:15:25 +0100 Subject: [PATCH 124/546] formatters --- scenario/scripts/snapshot.py | 60 +++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 9f1aed4af..54ac007d8 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -2,15 +2,16 @@ import logging import os import re +from dataclasses import asdict +from enum import Enum from pathlib import Path from subprocess import run from textwrap import dedent from typing import Any, Dict, List, Union, Optional, BinaryIO, TextIO, Iterable -import ops.pebble +import ops.pebble import typer import yaml -from ops import pebble from scenario.state import Address, BindAddress, Model, Network, Relation, State, Status, Container @@ -176,13 +177,6 @@ def get_metadata(target: Target, model: str): return yaml.safe_load(raw_meta) -class Plan(ops.pebble.Plan): - """pprintable pebble plan""" - def __repr__(self): - raw_dct = repr(yaml.safe_load(self._raw)) - return f"pebble.Plan(yaml.safe_dump({raw_dct}))" - - class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" @@ -217,9 +211,9 @@ def can_connect(self) -> bool: def get_system_info(self): return self._run('version') - def get_plan(self): + def get_plan(self) -> dict: plan_raw = self._run('plan') - return Plan(plan_raw) + return yaml.safe_load(plan_raw) def pull(self, path: str, @@ -245,12 +239,12 @@ def get_checks( def get_container(target: Target, model, container_name: str, container_meta) -> Container: - pebble = RemotePebbleClient(container_name, target, model) - layers = pebble.get_plan() + remote_client = RemotePebbleClient(container_name, target, model) + plan = remote_client.get_plan() return Container( name=container_name, - layers=layers, - can_connect=pebble.can_connect() + _base_plan=plan, + can_connect=remote_client.can_connect() ) @@ -411,12 +405,18 @@ def try_guess_charm_type_name(): return None +class FormatOption(str, Enum): + state = "state" + json = "json" + pytest = "pytest" + + def _snapshot( target: str, model: str = None, pprint: bool = True, include_juju_relation_data=False, - full_case=False, + format='state', ): try: target = Target(target) @@ -457,11 +457,19 @@ def _snapshot( ) if pprint: - if full_case: + if format == FormatOption.pytest: charm_type_name = try_guess_charm_type_name() txt = format_test_case(state, charm_type_name=charm_type_name) - else: + elif format == FormatOption.state: txt = format_state(state) + elif format == FormatOption.json: + txt = json.dumps( + asdict(state), + indent=2 + ) + else: + raise ValueError(f'unknown format {format}') + print(txt) return state @@ -470,11 +478,15 @@ def _snapshot( def snapshot( target: str = typer.Argument(..., help="Target unit."), model: str = typer.Option(None, "-m", "--model", help="Which model to look at."), - full: bool = typer.Option( - False, + format: FormatOption = typer.Option( + "state", "-f", - "--full", - help="Whether to print a full, nearly-executable Scenario test, or just the State.", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " + "``json``: Outputs a Jsonified State object. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + , ), include_juju_relation_data: bool = typer.Option( False, @@ -493,10 +505,10 @@ def snapshot( return _snapshot( target, model, - full_case=full, + format=format, include_juju_relation_data=include_juju_relation_data, ) if __name__ == "__main__": - print(_snapshot("trfk/0", model='foo')) + print(_snapshot("trfk/0", model='foo', format=FormatOption.json)) From 8efdeb4ca647d3300ea025062aabe7bf441ccf95 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 20 Feb 2023 14:15:31 +0100 Subject: [PATCH 125/546] lint --- scenario/scripts/snapshot.py | 162 +++++++++++++++++++---------------- 1 file changed, 90 insertions(+), 72 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 54ac007d8..5ddcfee6d 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -7,13 +7,22 @@ from pathlib import Path from subprocess import run from textwrap import dedent -from typing import Any, Dict, List, Union, Optional, BinaryIO, TextIO, Iterable +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Union import ops.pebble import typer import yaml -from scenario.state import Address, BindAddress, Model, Network, Relation, State, Status, Container +from scenario.state import ( + Address, + BindAddress, + Container, + Model, + Network, + Relation, + State, + Status, +) logger = logging.getLogger("snapshot") @@ -36,9 +45,9 @@ class InvalidModel(SnapshotError): class Target(str): def __init__(self, unit_name: str): super().__init__() - app_name, _, unit_id = unit_name.rpartition('/') + app_name, _, unit_id = unit_name.rpartition("/") if not app_name or not unit_id: - raise InvalidTarget(f'invalid unit name: {unit_name!r}') + raise InvalidTarget(f"invalid unit name: {unit_name!r}") self.unit_name = unit_name self.app_name = app_name self.unit_id = int(unit_id) @@ -95,9 +104,7 @@ def _juju_run(cmd, model=None) -> Dict[str, Any]: def _juju_ssh(target: Target, cmd, model=None) -> str: _model = f" -m {model}" if model else "" command = f"""juju ssh{_model} {target.unit_name} {cmd}""" - raw = run( - command.split(), capture_output=True - ).stdout.decode("utf-8") + raw = run(command.split(), capture_output=True).stdout.decode("utf-8") return raw @@ -173,7 +180,11 @@ def get_networks(target: Target, model, relations: List[str]) -> List[Network]: def get_metadata(target: Target, model: str): - raw_meta = _juju_ssh(target, f"cat ./agents/unit-{target.normalized}/charm/metadata.yaml", model=model) + raw_meta = _juju_ssh( + target, + f"cat ./agents/unit-{target.normalized}/charm/metadata.yaml", + model=model, + ) return yaml.safe_load(raw_meta) @@ -192,14 +203,16 @@ def __init__(self, container: str, target: Target, model: str = None): def _run(self, cmd: str) -> str: _model = f" -m {self.model}" if self.model else "" - command = f'juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}' + command = f"juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}" proc = run(command.split(), capture_output=True) if proc.returncode == 0: - return proc.stdout.decode('utf-8') - raise RuntimeError(f"error wrapping pebble call with {command}: " - f"process exited with {proc.returncode}; " - f"stdout = {proc.stdout}; " - f"stderr = {proc.stderr}") + return proc.stdout.decode("utf-8") + raise RuntimeError( + f"error wrapping pebble call with {command}: " + f"process exited with {proc.returncode}; " + f"stdout = {proc.stdout}; " + f"stderr = {proc.stderr}" + ) def can_connect(self) -> bool: try: @@ -209,52 +222,52 @@ def can_connect(self) -> bool: return bool(version) def get_system_info(self): - return self._run('version') + return self._run("version") def get_plan(self) -> dict: - plan_raw = self._run('plan') + plan_raw = self._run("plan") return yaml.safe_load(plan_raw) - def pull(self, - path: str, - *, - encoding: Optional[str] = 'utf-8') -> Union[BinaryIO, TextIO]: + def pull( + self, path: str, *, encoding: Optional[str] = "utf-8" + ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() - def list_files(self, path: str, *, pattern: Optional[str] = None, - itself: bool = False) -> List[ops.pebble.FileInfo]: + def list_files( + self, path: str, *, pattern: Optional[str] = None, itself: bool = False + ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" - out = self._run(f'checks{_level}{_names}') - if out == 'Plan has no health checks.': + out = self._run(f"checks{_level}{_names}") + if out == "Plan has no health checks.": return [] raise NotImplementedError() -def get_container(target: Target, model, container_name: str, container_meta) -> Container: +def get_container( + target: Target, model, container_name: str, container_meta +) -> Container: remote_client = RemotePebbleClient(container_name, target, model) plan = remote_client.get_plan() return Container( - name=container_name, - _base_plan=plan, - can_connect=remote_client.can_connect() + name=container_name, _base_plan=plan, can_connect=remote_client.can_connect() ) def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Container]: if not metadata: - logger.warning('no metadata: unable to get containers') + logger.warning("no metadata: unable to get containers") return [] containers = [] - for container_name, container_meta in metadata.get('containers', {}).items(): + for container_name, container_meta in metadata.get("containers", {}).items(): container = get_container(target, model, container_name, container_meta) containers.append(container) return containers @@ -304,17 +317,20 @@ def get_config(target: Target, model: str) -> Dict[str, Union[str, int, float, b def _get_interface_from_metadata(endpoint, metadata): - for role in ['provides', 'requires']: + for role in ["provides", "requires"]: for ep, ep_meta in metadata.get(role, {}).items(): if ep == endpoint: - return ep_meta['interface'] + return ep_meta["interface"] - logger.error(f'No interface for endpoint {endpoint} found in charm metadata.') + logger.error(f"No interface for endpoint {endpoint} found in charm metadata.") return None def get_relations( - target: Target, model: str, metadata: Optional[Dict], include_juju_relation_data=False, + target: Target, + model: str, + metadata: Optional[Dict], + include_juju_relation_data=False, ) -> List[Relation]: _model = f" -m {model}" if model else "" try: @@ -358,7 +374,9 @@ def _clean(relation_data: dict): relations.append( Relation( endpoint=raw_relation["endpoint"], - interface=_get_interface_from_metadata(raw_relation["endpoint"], metadata), + interface=_get_interface_from_metadata( + raw_relation["endpoint"], metadata + ), relation_id=relation_id, remote_app_data=raw_relation["application-data"], remote_app_name=some_remote_unit_id.app_name, @@ -412,25 +430,29 @@ class FormatOption(str, Enum): def _snapshot( - target: str, - model: str = None, - pprint: bool = True, - include_juju_relation_data=False, - format='state', + target: str, + model: str = None, + pprint: bool = True, + include_juju_relation_data=False, + format="state", ): try: target = Target(target) except InvalidTarget: - print(f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.") + print( + f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" + f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." + ) exit(1) metadata = get_metadata(target, model) try: relations = get_relations( - target, model, metadata=metadata, - include_juju_relation_data=include_juju_relation_data + target, + model, + metadata=metadata, + include_juju_relation_data=include_juju_relation_data, ) except InvalidTarget: _model = f"model {model}" or "the current model" @@ -463,12 +485,9 @@ def _snapshot( elif format == FormatOption.state: txt = format_state(state) elif format == FormatOption.json: - txt = json.dumps( - asdict(state), - indent=2 - ) + txt = json.dumps(asdict(state), indent=2) else: - raise ValueError(f'unknown format {format}') + raise ValueError(f"unknown format {format}") print(txt) @@ -476,25 +495,24 @@ def _snapshot( def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: str = typer.Option(None, "-m", "--model", help="Which model to look at."), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " - "``json``: Outputs a Jsonified State object. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - , - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: str = typer.Option(None, "-m", "--model", help="Which model to look at."), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " + "``json``: Outputs a Jsonified State object. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), ) -> State: """Print the State of a remote target unit. @@ -511,4 +529,4 @@ def snapshot( if __name__ == "__main__": - print(_snapshot("trfk/0", model='foo', format=FormatOption.json)) + print(_snapshot("trfk/0", model="foo", format=FormatOption.json)) From 8bf416fe412b79b50676852fbcb39105a555a824 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Feb 2023 12:00:54 +0100 Subject: [PATCH 126/546] includes and networks optimized --- scenario/scripts/main.py | 16 +++ scenario/scripts/snapshot.py | 211 +++++++++++++++++++++-------------- 2 files changed, 146 insertions(+), 81 deletions(-) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index a53f9a49f..916de249d 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -1,8 +1,19 @@ +import logging +import os + import typer from scenario.scripts.snapshot import snapshot +def _setup_logging(verbosity: int): + base_loglevel = int(os.getenv('LOGLEVEL', 30)) + verbosity = min(verbosity, 2) + loglevel = base_loglevel - (verbosity * 10) + logging.basicConfig(level=loglevel, + format='%(message)s') + + def main(): app = typer.Typer( name="scenario", @@ -17,6 +28,11 @@ def main(): app.command(name="_", hidden=True)(lambda: None) app.command(name="snapshot", no_args_is_help=True)(snapshot) + + @app.callback() + def setup_logging(verbose: int = typer.Option(0, '-v', count=True)): + _setup_logging(verbose) + app() diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 5ddcfee6d..f7008e348 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -4,10 +4,11 @@ import re from dataclasses import asdict from enum import Enum +from itertools import chain from pathlib import Path from subprocess import run from textwrap import dedent -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Union +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Union, Tuple import ops.pebble import typer @@ -101,7 +102,7 @@ def _juju_run(cmd, model=None) -> Dict[str, Any]: return json.loads(raw) -def _juju_ssh(target: Target, cmd, model=None) -> str: +def _juju_ssh(target: Target, cmd, model: Optional[str] = None) -> str: _model = f" -m {model}" if model else "" command = f"""juju ssh{_model} {target.unit_name} {cmd}""" raw = run(command.split(), capture_output=True).stdout.decode("utf-8") @@ -129,17 +130,14 @@ def _juju_exec(target: Target, model, cmd) -> str: ).stdout.decode("utf-8") -def get_leader(target: Target, model): +def get_leader(target: Target, model: Optional[str]): # could also get it from _juju_run('status')... + logger.info('getting leader...') return _juju_exec(target, model, "is-leader") == "True" -def get_network(target: Target, model, relation_name: str, is_default=False) -> Network: - status = _juju_run(f"status {target}", model=model) - app = status["applications"][target.app_name] - bind_address = app.get("address", "") - - raw = _juju_exec(target, model, f"network-get {relation_name}") +def get_network(target: Target, model: Optional[str], endpoint: str) -> Network: + raw = _juju_exec(target, model, f"network-get {endpoint}") jsn = yaml.safe_load(raw) bind_addresses = [] @@ -162,24 +160,36 @@ def get_network(target: Target, model, relation_name: str, is_default=False) -> ) ) return Network( - relation_name, + name=endpoint, bind_addresses=bind_addresses, - bind_address=bind_address, egress_subnets=jsn.get("egress-subnets", None), ingress_addresses=jsn.get("ingress-addresses", None), - is_default=is_default, ) -def get_networks(target: Target, model, relations: List[str]) -> List[Network]: +def get_networks(target: Target, model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = ()) -> List[Network]: + logger.info('getting networks...') networks = [] networks.append(get_network(target, model, "juju-info")) - for relation in relations: - networks.append(get_network(target, model, relation)) + + endpoints = relations # only alive relations + if include_dead: + endpoints = chain(metadata.get("provides", ()), + metadata.get("requires", ()), + metadata.get("peers", ())) + + for endpoint in endpoints: + logger.debug(f' getting network for endpoint {endpoint!r}') + networks.append(get_network(target, model, endpoint)) return networks -def get_metadata(target: Target, model: str): +def get_metadata(target: Target, model: Optional[str]): + logger.info('fetching metadata...') + raw_meta = _juju_ssh( target, f"cat ./agents/unit-{target.normalized}/charm/metadata.yaml", @@ -195,7 +205,7 @@ class RemotePebbleClient: # " j ssh --container traefik traefik/0 cat var/lib/pebble/default/.pebble.state | jq" # figure out what it's for. - def __init__(self, container: str, target: Target, model: str = None): + def __init__(self, container: str, target: Target, model: Optional[str] = None): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container self.target = target @@ -229,19 +239,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -252,7 +262,7 @@ def get_checks( def get_container( - target: Target, model, container_name: str, container_meta + target: Target, model, container_name: str, container_meta ) -> Container: remote_client = RemotePebbleClient(container_name, target, model) plan = remote_client.get_plan() @@ -262,6 +272,8 @@ def get_container( def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Container]: + logger.info('getting containers...') + if not metadata: logger.warning("no metadata: unable to get containers") return [] @@ -273,8 +285,10 @@ def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Cont return containers -def get_status(target: Target, model) -> Status: - status = _juju_run(f"status {target}", model=model) +def get_status_and_endpoints(target: Target, model: Optional[str]) -> Tuple[Status, Tuple[str, ...]]: + logger.info('getting status...') + + status = _juju_run(f"status --relations {target}", model=model) app = status["applications"][target.app_name] app_status_raw = app["application-status"] @@ -283,8 +297,9 @@ def get_status(target: Target, model) -> Status: unit_status_raw = app["units"][target]["workload-status"] unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") + relations = tuple(app['relations'].keys()) app_version = app.get("version", "") - return Status(app=app_status, unit=unit_status, app_version=app_version) + return Status(app=app_status, unit=unit_status, app_version=app_version), relations def _cast(value: str, _type): @@ -302,7 +317,8 @@ def _cast(value: str, _type): raise ValueError(_type) -def get_config(target: Target, model: str) -> Dict[str, Union[str, int, float, bool]]: +def get_config(target: Target, model: Optional[str]) -> Dict[str, Union[str, int, float, bool]]: + logger.info('getting config...') _model = f" -m {model}" if model else "" jsn = _juju_run(f"config {target.app_name}", model=model) @@ -327,11 +343,13 @@ def _get_interface_from_metadata(endpoint, metadata): def get_relations( - target: Target, - model: str, - metadata: Optional[Dict], - include_juju_relation_data=False, + target: Target, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: + logger.info('getting relations...') + _model = f" -m {model}" if model else "" try: jsn = _juju_run(f"show-unit {target}", model=model) @@ -348,6 +366,7 @@ def _clean(relation_data: dict): relations = [] for raw_relation in jsn[target].get("relation-info", ()): + logger.debug(f" getting relation data for endpoint {raw_relation.get('endpoint')!r}") related_units = raw_relation["related-units"] # related-units: # owner/0: @@ -392,6 +411,8 @@ def _clean(relation_data: dict): def get_model(name: str = None) -> Model: + logger.info('getting model...') + jsn = _juju_run("models") model_name = name or jsn["current-model"] try: @@ -430,53 +451,67 @@ class FormatOption(str, Enum): def _snapshot( - target: str, - model: str = None, - pprint: bool = True, - include_juju_relation_data=False, - format="state", + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", ): try: target = Target(target) except InvalidTarget: - print( + logger.critical( f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." ) exit(1) + logger.info(f'beginning snapshot of {target} in model {model or ""}...') + + def ifinclude(key, get_value, null_value): + if include is None or key in include: + return get_value() + return null_value + metadata = get_metadata(target, model) + if not metadata: + logger.critical(f'could not fetch metadata from {target}.') + exit(1) try: - relations = get_relations( - target, - model, - metadata=metadata, - include_juju_relation_data=include_juju_relation_data, + status, endpoints = get_status_and_endpoints(target, model) + state = State( + leader=get_leader(target, model), + model=get_model(model), + status=status, + config=ifinclude('c', lambda: get_config(target, model), {}), + relations=ifinclude('r', lambda: get_relations( + target, + model, + metadata=metadata, + include_juju_relation_data=include_juju_relation_data, + ), []), + app_name=target.app_name, + unit_id=target.unit_id, + containers=ifinclude('k', lambda: get_containers(target, model, metadata), []), + networks=ifinclude('n', lambda: get_networks( + target, model, + metadata, include_dead=include_dead_relation_networks, + relations=endpoints), []), ) + + # todo: these errors should surface earlier. except InvalidTarget: _model = f"model {model}" or "the current model" - print(f"invalid target: {target!r} not found in {_model}") + logger.critical(f"invalid target: {target!r} not found in {_model}") exit(1) - - try: - model_info = get_model(model) except InvalidModel: - # todo: this should surface earlier. - print(f"invalid model: {model!r} not found.") + logger.critical(f"invalid model: {model!r} not found.") exit(1) - state = State( - leader=get_leader(target, model), - model=model_info, - status=get_status(target, model), - config=get_config(target, model), - relations=relations, - app_name=target.app_name, - unit_id=target.unit_id, - containers=get_containers(target, model, metadata), - networks=get_networks(target, model, [r.endpoint for r in relations]), - ) + logger.info(f'snapshot done.') if pprint: if format == FormatOption.pytest: @@ -495,38 +530,52 @@ def _snapshot( def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: str = typer.Option(None, "-m", "--model", help="Which model to look at."), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " - "``json``: Outputs a Jsonified State object. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option(None, "-m", "--model", help="Which model to look at."), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " + "``json``: Outputs a Jsonified State object. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers ``n``: networks." + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), ) -> State: - """Print the State of a remote target unit. + """Gather and output the State of a remote target unit. If black is available, the output will be piped through it for formatting. Usage: snapshot myapp/0 > ./tests/scenario/case1.py """ return _snapshot( - target, - model, + target=target, + model=model, format=format, + include=include, include_juju_relation_data=include_juju_relation_data, + include_dead_relation_networks=include_dead_relation_networks, ) if __name__ == "__main__": - print(_snapshot("trfk/0", model="foo", format=FormatOption.json)) + print(_snapshot("trfk/0", model="foo", format=FormatOption.json, include='k')) From d28f0aabfe1c3c74c3c74b711a5b903e68e37626 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 23 Feb 2023 17:41:36 +0100 Subject: [PATCH 127/546] mounts --- scenario/scripts/main.py | 7 +- scenario/scripts/snapshot.py | 345 +++++++++++++++++++++++++--------- scenario/state.py | 20 +- tests/test_e2e/test_pebble.py | 21 ++- 4 files changed, 282 insertions(+), 111 deletions(-) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 916de249d..ab1f90533 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -7,11 +7,10 @@ def _setup_logging(verbosity: int): - base_loglevel = int(os.getenv('LOGLEVEL', 30)) + base_loglevel = int(os.getenv("LOGLEVEL", 30)) verbosity = min(verbosity, 2) loglevel = base_loglevel - (verbosity * 10) - logging.basicConfig(level=loglevel, - format='%(message)s') + logging.basicConfig(level=loglevel, format="%(message)s") def main(): @@ -30,7 +29,7 @@ def main(): app.command(name="snapshot", no_args_is_help=True)(snapshot) @app.callback() - def setup_logging(verbose: int = typer.Option(0, '-v', count=True)): + def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): _setup_logging(verbose) app() diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index f7008e348..e8af13a47 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -2,13 +2,14 @@ import logging import os import re +import tempfile from dataclasses import asdict from enum import Enum from itertools import chain from pathlib import Path -from subprocess import run +from subprocess import CalledProcessError, check_output, run from textwrap import dedent -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Union, Tuple +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union import ops.pebble import typer @@ -19,6 +20,7 @@ BindAddress, Container, Model, + Mount, Network, Relation, State, @@ -29,6 +31,7 @@ JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) JUJU_CONFIG_KEYS = frozenset({}) +SNAPSHOT_TEMPDIR_ROOT = (Path(os.getcwd()).parent / "snapshot_storage").absolute() class SnapshotError(RuntimeError): @@ -132,7 +135,7 @@ def _juju_exec(target: Target, model, cmd) -> str: def get_leader(target: Target, model: Optional[str]): # could also get it from _juju_run('status')... - logger.info('getting leader...') + logger.info("getting leader...") return _juju_exec(target, model, "is-leader") == "True" @@ -167,28 +170,33 @@ def get_network(target: Target, model: Optional[str], endpoint: str) -> Network: ) -def get_networks(target: Target, model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = ()) -> List[Network]: - logger.info('getting networks...') +def get_networks( + target: Target, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), +) -> List[Network]: + logger.info("getting networks...") networks = [] networks.append(get_network(target, model, "juju-info")) endpoints = relations # only alive relations if include_dead: - endpoints = chain(metadata.get("provides", ()), - metadata.get("requires", ()), - metadata.get("peers", ())) + endpoints = chain( + metadata.get("provides", ()), + metadata.get("requires", ()), + metadata.get("peers", ()), + ) for endpoint in endpoints: - logger.debug(f' getting network for endpoint {endpoint!r}') + logger.debug(f" getting network for endpoint {endpoint!r}") networks.append(get_network(target, model, endpoint)) return networks def get_metadata(target: Target, model: Optional[str]): - logger.info('fetching metadata...') + logger.info("fetching metadata...") raw_meta = _juju_ssh( target, @@ -239,19 +247,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -261,18 +269,127 @@ def get_checks( raise NotImplementedError() +def fetch_file( + target: Target, + remote_path: str, + container_name: str, + local_path: Path = None, + model: str = None, +) -> Optional[str]: + # copied from jhack + model_arg = f" -m {model}" if model else "" + cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" + try: + raw = check_output(cmd.split()) + except CalledProcessError as e: + raise RuntimeError( + f"Failed to fetch {remote_path} from {target.unit_name}." + ) from e + + if not local_path: + return raw.decode("utf-8") + + local_path.write_bytes(raw) + + +def get_mounts( + target: Target, + model, + container_name: str, + container_meta, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, +) -> Dict[str, Mount]: + mount_meta = container_meta.get("mounts") + + if fetch_files and not mount_meta: + logger.error( + f"No mounts defined for container {container_name} in metadata.yaml. " + f"Cannot fetch files {fetch_files} for this container." + ) + return {} + + mount_spec = {} + for mt in mount_meta: + if name := mt.get("storage"): + mount_spec[name] = mt["location"] + else: + logger.error(f"unknown mount type: {mt}") + + mounts = {} + for remote_path in fetch_files or (): + found = None + for mn, mt in mount_spec.items(): + if str(remote_path).startswith(mt): + found = mn, mt + + if not found: + logger.error( + f"could not find mount corresponding to requested remote_path {remote_path}: skipping..." + ) + continue + + mount_name, src = found + mount = mounts.get(mount_name) + if not mount: + # create the mount obj and tempdir + location = tempfile.TemporaryDirectory(prefix=str(temp_dir_base_path)).name + mount = Mount(src=src, location=location) + mounts[mount_name] = mount + + # populate the local tempdir + filepath = Path(mount.location).joinpath(*remote_path.parts[1:]) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + try: + fetch_file( + target, + container_name=container_name, + model=model, + remote_path=remote_path, + local_path=filepath, + ) + + except RuntimeError as e: + logger.error(e) + + return mounts + + def get_container( - target: Target, model, container_name: str, container_meta + target: Target, + model, + container_name: str, + container_meta, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Container: remote_client = RemotePebbleClient(container_name, target, model) plan = remote_client.get_plan() + return Container( - name=container_name, _base_plan=plan, can_connect=remote_client.can_connect() + name=container_name, + _base_plan=plan, + can_connect=remote_client.can_connect(), + mounts=get_mounts( + target, + model, + container_name, + container_meta, + fetch_files, + temp_dir_base_path=temp_dir_base_path, + ), ) -def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Container]: - logger.info('getting containers...') +def get_containers( + target: Target, + model, + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, +) -> List[Container]: + fetch_files = fetch_files or {} + logger.info("getting containers...") if not metadata: logger.warning("no metadata: unable to get containers") @@ -280,13 +397,22 @@ def get_containers(target: Target, model, metadata: Optional[Dict]) -> List[Cont containers = [] for container_name, container_meta in metadata.get("containers", {}).items(): - container = get_container(target, model, container_name, container_meta) + container = get_container( + target, + model, + container_name, + container_meta, + fetch_files=fetch_files.get(container_name), + temp_dir_base_path=temp_dir_base_path, + ) containers.append(container) return containers -def get_status_and_endpoints(target: Target, model: Optional[str]) -> Tuple[Status, Tuple[str, ...]]: - logger.info('getting status...') +def get_status_and_endpoints( + target: Target, model: Optional[str] +) -> Tuple[Status, Tuple[str, ...]]: + logger.info("getting status...") status = _juju_run(f"status --relations {target}", model=model) app = status["applications"][target.app_name] @@ -297,7 +423,7 @@ def get_status_and_endpoints(target: Target, model: Optional[str]) -> Tuple[Stat unit_status_raw = app["units"][target]["workload-status"] unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") - relations = tuple(app['relations'].keys()) + relations = tuple(app["relations"].keys()) app_version = app.get("version", "") return Status(app=app_status, unit=unit_status, app_version=app_version), relations @@ -317,8 +443,10 @@ def _cast(value: str, _type): raise ValueError(_type) -def get_config(target: Target, model: Optional[str]) -> Dict[str, Union[str, int, float, bool]]: - logger.info('getting config...') +def get_config( + target: Target, model: Optional[str] +) -> Dict[str, Union[str, int, float, bool]]: + logger.info("getting config...") _model = f" -m {model}" if model else "" jsn = _juju_run(f"config {target.app_name}", model=model) @@ -343,12 +471,12 @@ def _get_interface_from_metadata(endpoint, metadata): def get_relations( - target: Target, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: Target, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: - logger.info('getting relations...') + logger.info("getting relations...") _model = f" -m {model}" if model else "" try: @@ -366,7 +494,9 @@ def _clean(relation_data: dict): relations = [] for raw_relation in jsn[target].get("relation-info", ()): - logger.debug(f" getting relation data for endpoint {raw_relation.get('endpoint')!r}") + logger.debug( + f" getting relation data for endpoint {raw_relation.get('endpoint')!r}" + ) related_units = raw_relation["related-units"] # related-units: # owner/0: @@ -411,7 +541,7 @@ def _clean(relation_data: dict): def get_model(name: str = None) -> Model: - logger.info('getting model...') + logger.info("getting model...") jsn = _juju_run("models") model_name = name or jsn["current-model"] @@ -451,13 +581,15 @@ class FormatOption(str, Enum): def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ): try: target = Target(target) @@ -477,7 +609,7 @@ def ifinclude(key, get_value, null_value): metadata = get_metadata(target, model) if not metadata: - logger.critical(f'could not fetch metadata from {target}.') + logger.critical(f"could not fetch metadata from {target}.") exit(1) try: @@ -486,20 +618,41 @@ def ifinclude(key, get_value, null_value): leader=get_leader(target, model), model=get_model(model), status=status, - config=ifinclude('c', lambda: get_config(target, model), {}), - relations=ifinclude('r', lambda: get_relations( - target, - model, - metadata=metadata, - include_juju_relation_data=include_juju_relation_data, - ), []), + config=ifinclude("c", lambda: get_config(target, model), {}), + relations=ifinclude( + "r", + lambda: get_relations( + target, + model, + metadata=metadata, + include_juju_relation_data=include_juju_relation_data, + ), + [], + ), app_name=target.app_name, unit_id=target.unit_id, - containers=ifinclude('k', lambda: get_containers(target, model, metadata), []), - networks=ifinclude('n', lambda: get_networks( - target, model, - metadata, include_dead=include_dead_relation_networks, - relations=endpoints), []), + containers=ifinclude( + "k", + lambda: get_containers( + target, + model, + metadata, + fetch_files=fetch_files, + temp_dir_base_path=temp_dir_base_path, + ), + [], + ), + networks=ifinclude( + "n", + lambda: get_networks( + target, + model, + metadata, + include_dead=include_dead_relation_networks, + relations=endpoints, + ), + [], + ), ) # todo: these errors should surface earlier. @@ -511,7 +664,7 @@ def ifinclude(key, get_value, null_value): logger.critical(f"invalid model: {model!r} not found.") exit(1) - logger.info(f'snapshot done.') + logger.info(f"snapshot done.") if pprint: if format == FormatOption.pytest: @@ -530,36 +683,39 @@ def ifinclude(key, get_value, null_value): def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option(None, "-m", "--model", help="Which model to look at."), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " - "``json``: Outputs a Jsonified State object. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers ``n``: networks." - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " + "``json``: Outputs a Jsonified State object. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), ) -> State: """Gather and output the State of a remote target unit. @@ -578,4 +734,19 @@ def snapshot( if __name__ == "__main__": - print(_snapshot("trfk/0", model="foo", format=FormatOption.json, include='k')) + print( + _snapshot( + "trfk/0", + model="foo", + format=FormatOption.json, + include="k", + fetch_files={ + "traefik": [ + Path("/opt/traefik/juju/certificates.yaml"), + Path("/opt/traefik/juju/certificate.cert"), + Path("/opt/traefik/juju/certificate.key"), + Path("/etc/traefik/traefik.yaml"), + ] + }, + ) + ) diff --git a/scenario/state.py b/scenario/state.py index ab8f14e12..5470f0ea3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -239,9 +239,6 @@ class Mount(_DCBase): location: Union[str, PurePosixPath] src: Union[str, Path] - def __post_init__(self): - self.src = Path(self.src) - @dataclasses.dataclass class Container(_DCBase): @@ -318,20 +315,22 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: # but it ignores services it doesn't recognize continue status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) - if service.startup == '': + if service.startup == "": startup = pebble.ServiceStartup.DISABLED else: startup = pebble.ServiceStartup(service.startup) - info = pebble.ServiceInfo(name, - startup=startup, - current=pebble.ServiceStatus(status)) + info = pebble.ServiceInfo( + name, startup=startup, current=pebble.ServiceStatus(status) + ) infos[name] = info return infos @property def filesystem(self) -> _MockFileSystem: mounts = { - name: _MockStorageMount(src=spec.src, location=spec.location) + name: _MockStorageMount( + src=Path(spec.src), location=PurePosixPath(spec.location) + ) for name, spec in self.mounts.items() } return _MockFileSystem(mounts=mounts) @@ -413,7 +412,6 @@ def default( ], ) ], - bind_address=private_address, egress_subnets=list(egress_subnets), ingress_addresses=list(ingress_addresses), ) @@ -444,7 +442,9 @@ def handle_path(self): @dataclasses.dataclass class State(_DCBase): - config: Dict[str, Union[str, int, float, bool]] = dataclasses.field(default_factory=dict) + config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( + default_factory=dict + ) relations: List[Relation] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 3502a2887..5921032af 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -157,8 +157,8 @@ def callback(self: CharmBase): @pytest.mark.parametrize( "cmd, out", ( - ("ls", LS), - ("ps", PS), + ("ls", LS), + ("ps", PS), ), ) def test_exec(charm_cls, cmd, out): @@ -249,7 +249,7 @@ def callback(self: CharmBase): "fooserv": pebble.ServiceStatus.ACTIVE, # todo: should we disallow setting status for services that aren't known YET? "barserv": starting_service_status, - } + }, ) out = State(containers=[container]).trigger( @@ -262,10 +262,11 @@ def callback(self: CharmBase): serv = lambda name, obj: pebble.Service(name, raw=obj) container = out.containers[0] assert container.plan.services == { - 'barserv': serv('barserv', {'startup': 'disabled'}), - 'fooserv': serv('fooserv', {'startup': 'enabled'})} - assert container.services['fooserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['fooserv'].startup == pebble.ServiceStartup.ENABLED - - assert container.services['barserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['barserv'].startup == pebble.ServiceStartup.DISABLED + "barserv": serv("barserv", {"startup": "disabled"}), + "fooserv": serv("fooserv", {"startup": "enabled"}), + } + assert container.services["fooserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["fooserv"].startup == pebble.ServiceStartup.ENABLED + + assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED From 7aee546859084bfc741e3b45d83a28850b51d6a4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 3 Mar 2023 14:14:59 +0100 Subject: [PATCH 128/546] added resource injection --- scenario/runtime.py | 38 ++++++++++++++++++++----- scenario/state.py | 2 ++ tests/test_e2e/test_vroot_resources.py | 39 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 tests/test_e2e/test_vroot_resources.py diff --git a/scenario/runtime.py b/scenario/runtime.py index 6a60319be..3d79dae0f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -3,11 +3,12 @@ import marshal import os import re +import shutil import sys import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, List import yaml from ops.framework import _event_regex @@ -53,10 +54,12 @@ class Runtime: def __init__( self, charm_spec: "_CharmSpec", + resources: Dict[Path, Path] = None, juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version + self._resources = resources # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @@ -161,11 +164,31 @@ def virtual_charm_root(self): # passed via the CharmSpec spec = self._charm_spec with tempfile.TemporaryDirectory() as tempdir: - temppath = Path(tempdir) - (temppath / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) - (temppath / "config.yaml").write_text(yaml.safe_dump(spec.config or {})) - (temppath / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) - yield temppath + virtual_charm_root = Path(tempdir) + (virtual_charm_root / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) + (virtual_charm_root / "config.yaml").write_text(yaml.safe_dump(spec.config or {})) + (virtual_charm_root / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) + + for origin, subtree in (self._resources or {}).items(): + if subtree.name.startswith('/'): + raise ValueError( + 'invalid subtree. Should be relative paths starting without a /: they will ' + 'be interpreted relative to the virtual charm root') + + parts = subtree.parts + if parts[0] == '/': + parts = parts[1:] + + new_loc = virtual_charm_root.joinpath(*parts) + if not new_loc.parent.exists(): + new_loc.parent.mkdir(parents=True) + + if origin.is_dir(): + shutil.copytree(origin, new_loc) + else: + shutil.copy2(origin, new_loc) + + yield virtual_charm_root @staticmethod def _get_store(temporary_charm_root: Path): @@ -305,6 +328,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, + resources: Optional[Dict[Path, Path]] = None, ) -> "State": from scenario.state import Event, _CharmSpec @@ -321,7 +345,7 @@ def trigger( charm_type=charm_type, meta=meta, actions=actions, config=config ) - runtime = Runtime(charm_spec=spec, juju_version=state.juju_version) + runtime = Runtime(charm_spec=spec, juju_version=state.juju_version, resources=resources) return runtime.exec( state=state, diff --git a/scenario/state.py b/scenario/state.py index ab8f14e12..d201be67a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -523,6 +523,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, + resources: Optional[Dict[Path, Path]] = None, ): """Fluent API for trigger.""" return trigger( @@ -534,6 +535,7 @@ def trigger( meta=meta, actions=actions, config=config, + resources=resources, ) diff --git a/tests/test_e2e/test_vroot_resources.py b/tests/test_e2e/test_vroot_resources.py new file mode 100644 index 000000000..535a09895 --- /dev/null +++ b/tests/test_e2e/test_vroot_resources.py @@ -0,0 +1,39 @@ +import tempfile +from pathlib import Path + +from ops.charm import CharmBase +from ops.framework import Framework +from ops.model import ActiveStatus + +from scenario import State + + +class MyCharm(CharmBase): + META = {'name': 'my-charm'} + + def __init__(self, framework: Framework): + super().__init__(framework) + foo = self.framework.charm_dir / 'src' / 'foo.bar' + baz = self.framework.charm_dir / 'src' / 'baz' / 'qux.kaboodle' + + self.unit.status = ActiveStatus(f"{foo.read_text()} {baz.read_text()}") + + +def test_resources(): + with tempfile.TemporaryDirectory() as td: + t = Path(td) + foobar = t / 'foo.bar' + foobar.write_text('hello') + + baz = t / 'baz' + baz.mkdir(parents=True) + quxcos = (baz / 'qux.cos') + quxcos.write_text('world') + + out = State().trigger( + 'start', + charm_type=MyCharm, meta=MyCharm.META, + resources={foobar: Path('/src/foo.bar'), quxcos: Path('/src/baz/qux.kaboodle')} + ) + + assert out.status.unit == ('active', 'hello world') From 8e89830e71fde84de89f05221b012b2d6f9c5acf Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 3 Mar 2023 14:21:05 +0100 Subject: [PATCH 129/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5d1c0e9a7..07d30b05d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2.3" +version = "2.1.2.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 3fd23b00cf68b96370e95d0cd24a6d4e9ae2a88a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 6 Mar 2023 16:10:51 +0100 Subject: [PATCH 130/546] pr comments --- scenario/runtime.py | 48 +++++++++++++++++++------- scenario/state.py | 19 +++++----- tests/test_e2e/test_pebble.py | 21 +++++------ tests/test_e2e/test_vroot_resources.py | 28 ++++++++------- 4 files changed, 74 insertions(+), 42 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 3d79dae0f..3b7322568 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -8,7 +8,17 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, List +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Type, + TypeVar, + Union, +) import yaml from ops.framework import _event_regex @@ -27,6 +37,8 @@ _CT = TypeVar("_CT", bound=Type[CharmType]) + PathLike = Union[str, Path] + logger = scenario_logger.getChild("runtime") # _stored_state_regex = "(.*)\/(\D+)\[(.*)\]" _stored_state_regex = "((?P.*)\/)?(?P\D+)\[(?P.*)\]" @@ -54,12 +66,12 @@ class Runtime: def __init__( self, charm_spec: "_CharmSpec", - resources: Dict[Path, Path] = None, + copy_to_charm_root: Dict["PathLike", "PathLike"] = None, juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version - self._resources = resources + self._copy_to_charm_root = copy_to_charm_root # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @@ -166,17 +178,25 @@ def virtual_charm_root(self): with tempfile.TemporaryDirectory() as tempdir: virtual_charm_root = Path(tempdir) (virtual_charm_root / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) - (virtual_charm_root / "config.yaml").write_text(yaml.safe_dump(spec.config or {})) - (virtual_charm_root / "actions.yaml").write_text(yaml.safe_dump(spec.actions or {})) + (virtual_charm_root / "config.yaml").write_text( + yaml.safe_dump(spec.config or {}) + ) + (virtual_charm_root / "actions.yaml").write_text( + yaml.safe_dump(spec.actions or {}) + ) - for origin, subtree in (self._resources or {}).items(): - if subtree.name.startswith('/'): + for subtree, origin in (self._copy_to_charm_root or {}).items(): + subtree = Path(subtree) + origin = Path(origin) + + if subtree.name.startswith("/"): raise ValueError( - 'invalid subtree. Should be relative paths starting without a /: they will ' - 'be interpreted relative to the virtual charm root') + "invalid subtree. Should be relative paths starting without a /: they will " + "be interpreted relative to the virtual charm root" + ) parts = subtree.parts - if parts[0] == '/': + if parts[0] == "/": parts = parts[1:] new_loc = virtual_charm_root.joinpath(*parts) @@ -328,7 +348,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - resources: Optional[Dict[Path, Path]] = None, + copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, ) -> "State": from scenario.state import Event, _CharmSpec @@ -345,7 +365,11 @@ def trigger( charm_type=charm_type, meta=meta, actions=actions, config=config ) - runtime = Runtime(charm_spec=spec, juju_version=state.juju_version, resources=resources) + runtime = Runtime( + charm_spec=spec, + juju_version=state.juju_version, + copy_to_charm_root=copy_to_charm_root, + ) return runtime.exec( state=state, diff --git a/scenario/state.py b/scenario/state.py index d201be67a..c0e4e5ed7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -25,6 +25,8 @@ from typing_extensions import Self from ops.testing import CharmType + PathLike = Union[str, Path] + logger = scenario_logger.getChild("structs") ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" @@ -318,13 +320,13 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: # but it ignores services it doesn't recognize continue status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) - if service.startup == '': + if service.startup == "": startup = pebble.ServiceStartup.DISABLED else: startup = pebble.ServiceStartup(service.startup) - info = pebble.ServiceInfo(name, - startup=startup, - current=pebble.ServiceStatus(status)) + info = pebble.ServiceInfo( + name, startup=startup, current=pebble.ServiceStatus(status) + ) infos[name] = info return infos @@ -413,7 +415,6 @@ def default( ], ) ], - bind_address=private_address, egress_subnets=list(egress_subnets), ingress_addresses=list(ingress_addresses), ) @@ -444,7 +445,9 @@ def handle_path(self): @dataclasses.dataclass class State(_DCBase): - config: Dict[str, Union[str, int, float, bool]] = dataclasses.field(default_factory=dict) + config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( + default_factory=dict + ) relations: List[Relation] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) @@ -523,7 +526,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - resources: Optional[Dict[Path, Path]] = None, + copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, ): """Fluent API for trigger.""" return trigger( @@ -535,7 +538,7 @@ def trigger( meta=meta, actions=actions, config=config, - resources=resources, + copy_to_charm_root=copy_to_charm_root, ) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 3502a2887..5921032af 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -157,8 +157,8 @@ def callback(self: CharmBase): @pytest.mark.parametrize( "cmd, out", ( - ("ls", LS), - ("ps", PS), + ("ls", LS), + ("ps", PS), ), ) def test_exec(charm_cls, cmd, out): @@ -249,7 +249,7 @@ def callback(self: CharmBase): "fooserv": pebble.ServiceStatus.ACTIVE, # todo: should we disallow setting status for services that aren't known YET? "barserv": starting_service_status, - } + }, ) out = State(containers=[container]).trigger( @@ -262,10 +262,11 @@ def callback(self: CharmBase): serv = lambda name, obj: pebble.Service(name, raw=obj) container = out.containers[0] assert container.plan.services == { - 'barserv': serv('barserv', {'startup': 'disabled'}), - 'fooserv': serv('fooserv', {'startup': 'enabled'})} - assert container.services['fooserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['fooserv'].startup == pebble.ServiceStartup.ENABLED - - assert container.services['barserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['barserv'].startup == pebble.ServiceStartup.DISABLED + "barserv": serv("barserv", {"startup": "disabled"}), + "fooserv": serv("fooserv", {"startup": "enabled"}), + } + assert container.services["fooserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["fooserv"].startup == pebble.ServiceStartup.ENABLED + + assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED diff --git a/tests/test_e2e/test_vroot_resources.py b/tests/test_e2e/test_vroot_resources.py index 535a09895..44b9075ab 100644 --- a/tests/test_e2e/test_vroot_resources.py +++ b/tests/test_e2e/test_vroot_resources.py @@ -9,12 +9,12 @@ class MyCharm(CharmBase): - META = {'name': 'my-charm'} + META = {"name": "my-charm"} def __init__(self, framework: Framework): super().__init__(framework) - foo = self.framework.charm_dir / 'src' / 'foo.bar' - baz = self.framework.charm_dir / 'src' / 'baz' / 'qux.kaboodle' + foo = self.framework.charm_dir / "src" / "foo.bar" + baz = self.framework.charm_dir / "src" / "baz" / "qux.kaboodle" self.unit.status = ActiveStatus(f"{foo.read_text()} {baz.read_text()}") @@ -22,18 +22,22 @@ def __init__(self, framework: Framework): def test_resources(): with tempfile.TemporaryDirectory() as td: t = Path(td) - foobar = t / 'foo.bar' - foobar.write_text('hello') + foobar = t / "foo.bar" + foobar.write_text("hello") - baz = t / 'baz' + baz = t / "baz" baz.mkdir(parents=True) - quxcos = (baz / 'qux.cos') - quxcos.write_text('world') + quxcos = baz / "qux.cos" + quxcos.write_text("world") out = State().trigger( - 'start', - charm_type=MyCharm, meta=MyCharm.META, - resources={foobar: Path('/src/foo.bar'), quxcos: Path('/src/baz/qux.kaboodle')} + "start", + charm_type=MyCharm, + meta=MyCharm.META, + copy_to_charm_root={ + "/src/foo.bar": foobar, + "/src/baz/qux.kaboodle": quxcos, + }, ) - assert out.status.unit == ('active', 'hello world') + assert out.status.unit == ("active", "hello world") From 610ae3cd6d538ca33fa999d537e29f6c19799e63 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 6 Mar 2023 16:14:21 +0100 Subject: [PATCH 131/546] syntactic sugar for get_container --- scenario/state.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index c0e4e5ed7..6f8d61de6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -493,7 +493,9 @@ def with_unit_status(self, status: str, message: str): status=dataclasses.replace(self.status, unit=(status, message)) ) - def get_container(self, name) -> Container: + def get_container(self, container: Union[str, Container]) -> Container: + """Get container from this State, based on an input container or its name.""" + name = container.name if isinstance(container, Container) else container try: return next(filter(lambda c: c.name == name, self.containers)) except StopIteration as e: From d2ceab16aa6e4d04e49ff118bdcf09c2f4301c68 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Mar 2023 12:36:43 +0100 Subject: [PATCH 132/546] statusbase --- scenario/state.py | 76 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index ab8f14e12..225f31aec 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -12,7 +12,7 @@ import yaml from ops import pebble -from ops.model import SecretRotate +from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger from scenario.mocking import _MockFileSystem, _MockStorageMount @@ -391,15 +391,15 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -419,12 +419,53 @@ def default( ) +@dataclasses.dataclass +class _EntityStatus(_DCBase): + """This class represents StatusBase and should not be interacted with directly.""" + # Why not use StatusBase directly? Because that's not json-serializable. + + name: str + message: str = "" + + def __eq__(self, other): + if isinstance(other, Tuple): + logger.warning('Comparing Status with Tuples is deprecated and will be removed soon.') + return (self.name, self.message) == other + if isinstance(other, StatusBase): + return (self.name, self.message) == (other.name, other.message) + logger.warning(f'Comparing Status with {other} is not stable and will be forbidden soon.' + f'Please compare with StatusBase directly.') + return super().__eq__(other) + + @classmethod + def _from_statusbase(cls, obj: StatusBase): + return _EntityStatus(obj.name, obj.message) + + def __iter__(self): + return iter([self.name, self.message]) + + @dataclasses.dataclass class Status(_DCBase): - app: Tuple[str, str] = ("unknown", "") - unit: Tuple[str, str] = ("unknown", "") + app: _EntityStatus = _EntityStatus("unknown") + unit: _EntityStatus = _EntityStatus("unknown") app_version: str = "" + def __post_init__(self): + for name in ["app", "unit"]: + val = getattr(self, name) + if isinstance(val, _EntityStatus): + pass + elif isinstance(val, StatusBase): + setattr(self, name, _EntityStatus._from_statusbase(val)) + elif isinstance(val, tuple): + logger.warning('Initializing Status.[app/unit] with Tuple[str, str] is deprecated ' + 'and will be removed soon. \n' + f'Please pass a StatusBase instance: `StatusBase(*{val})`') + setattr(self, name, _EntityStatus(*val)) + else: + raise TypeError(f"Invalid {self}.{name}: {val!r}") + @dataclasses.dataclass class StoredState(_DCBase): @@ -650,11 +691,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def deferred( - event: Union[str, Event], - handler: Callable, - event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -700,7 +741,6 @@ def _derive_args(event_name: str): return tuple(args) - # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass From af7ccc0c834f173bde48d770a87dfecdb64e16bd Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Mar 2023 12:37:50 +0100 Subject: [PATCH 133/546] docs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dc6b2c776..19c0a88a3 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ With that, we can write the simplest possible scenario test: ```python from scenario.state import State from ops.charm import CharmBase - +from ops.model import UnknownStatus class MyCharm(CharmBase): pass @@ -74,7 +74,7 @@ def test_scenario_base(): out = State().trigger( 'start', MyCharm, meta={"name": "foo"}) - assert out.status.unit == ('unknown', '') + assert out.status.unit == UnknownStatus() ``` Now let's start making it more complicated. @@ -104,7 +104,7 @@ def test_status_leader(leader): 'start', MyCharm, meta={"name": "foo"}) - assert out.status.unit == ('active', 'I rule' if leader else 'I am ruled') + assert out.status.unit == ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... From de2be5ac1816c1bcfbc0f2201704f2f04aea4134 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Mar 2023 12:43:37 +0100 Subject: [PATCH 134/546] better repr --- scenario/state.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 225f31aec..89573fbe5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -444,6 +444,9 @@ def _from_statusbase(cls, obj: StatusBase): def __iter__(self): return iter([self.name, self.message]) + def __repr__(self): + return f"" + @dataclasses.dataclass class Status(_DCBase): @@ -464,7 +467,7 @@ def __post_init__(self): f'Please pass a StatusBase instance: `StatusBase(*{val})`') setattr(self, name, _EntityStatus(*val)) else: - raise TypeError(f"Invalid {self}.{name}: {val!r}") + raise TypeError(f"Invalid status.{name}: {val!r}") @dataclasses.dataclass From f134d578b9253fab80d3b58178ba4d3398898c80 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Mar 2023 12:43:59 +0100 Subject: [PATCH 135/546] bindaddr fix --- scenario/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 89573fbe5..3f25ac245 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -413,7 +413,6 @@ def default( ], ) ], - bind_address=private_address, egress_subnets=list(egress_subnets), ingress_addresses=list(ingress_addresses), ) From 37dd9323532597011d593b38c8cd26fc1e481f2d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 7 Mar 2023 12:44:11 +0100 Subject: [PATCH 136/546] lint --- scenario/state.py | 60 ++++++++++++++++++++--------------- tests/test_e2e/test_pebble.py | 21 ++++++------ 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 3f25ac245..b9d44d85b 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -318,13 +318,13 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: # but it ignores services it doesn't recognize continue status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) - if service.startup == '': + if service.startup == "": startup = pebble.ServiceStartup.DISABLED else: startup = pebble.ServiceStartup(service.startup) - info = pebble.ServiceInfo(name, - startup=startup, - current=pebble.ServiceStatus(status)) + info = pebble.ServiceInfo( + name, startup=startup, current=pebble.ServiceStatus(status) + ) infos[name] = info return infos @@ -391,15 +391,15 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -421,6 +421,7 @@ def default( @dataclasses.dataclass class _EntityStatus(_DCBase): """This class represents StatusBase and should not be interacted with directly.""" + # Why not use StatusBase directly? Because that's not json-serializable. name: str @@ -428,12 +429,16 @@ class _EntityStatus(_DCBase): def __eq__(self, other): if isinstance(other, Tuple): - logger.warning('Comparing Status with Tuples is deprecated and will be removed soon.') + logger.warning( + "Comparing Status with Tuples is deprecated and will be removed soon." + ) return (self.name, self.message) == other if isinstance(other, StatusBase): return (self.name, self.message) == (other.name, other.message) - logger.warning(f'Comparing Status with {other} is not stable and will be forbidden soon.' - f'Please compare with StatusBase directly.') + logger.warning( + f"Comparing Status with {other} is not stable and will be forbidden soon." + f"Please compare with StatusBase directly." + ) return super().__eq__(other) @classmethod @@ -461,9 +466,11 @@ def __post_init__(self): elif isinstance(val, StatusBase): setattr(self, name, _EntityStatus._from_statusbase(val)) elif isinstance(val, tuple): - logger.warning('Initializing Status.[app/unit] with Tuple[str, str] is deprecated ' - 'and will be removed soon. \n' - f'Please pass a StatusBase instance: `StatusBase(*{val})`') + logger.warning( + "Initializing Status.[app/unit] with Tuple[str, str] is deprecated " + "and will be removed soon. \n" + f"Please pass a StatusBase instance: `StatusBase(*{val})`" + ) setattr(self, name, _EntityStatus(*val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") @@ -487,7 +494,9 @@ def handle_path(self): @dataclasses.dataclass class State(_DCBase): - config: Dict[str, Union[str, int, float, bool]] = dataclasses.field(default_factory=dict) + config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( + default_factory=dict + ) relations: List[Relation] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) @@ -693,11 +702,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def deferred( - event: Union[str, Event], - handler: Callable, - event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -743,6 +752,7 @@ def _derive_args(event_name: str): return tuple(args) + # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 3502a2887..5921032af 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -157,8 +157,8 @@ def callback(self: CharmBase): @pytest.mark.parametrize( "cmd, out", ( - ("ls", LS), - ("ps", PS), + ("ls", LS), + ("ps", PS), ), ) def test_exec(charm_cls, cmd, out): @@ -249,7 +249,7 @@ def callback(self: CharmBase): "fooserv": pebble.ServiceStatus.ACTIVE, # todo: should we disallow setting status for services that aren't known YET? "barserv": starting_service_status, - } + }, ) out = State(containers=[container]).trigger( @@ -262,10 +262,11 @@ def callback(self: CharmBase): serv = lambda name, obj: pebble.Service(name, raw=obj) container = out.containers[0] assert container.plan.services == { - 'barserv': serv('barserv', {'startup': 'disabled'}), - 'fooserv': serv('fooserv', {'startup': 'enabled'})} - assert container.services['fooserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['fooserv'].startup == pebble.ServiceStartup.ENABLED - - assert container.services['barserv'].current == pebble.ServiceStatus.ACTIVE - assert container.services['barserv'].startup == pebble.ServiceStartup.DISABLED + "barserv": serv("barserv", {"startup": "disabled"}), + "fooserv": serv("fooserv", {"startup": "enabled"}), + } + assert container.services["fooserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["fooserv"].startup == pebble.ServiceStartup.ENABLED + + assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE + assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED From e5c869a2d21221cd6cbde96b766a52c827a5c7a9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Mar 2023 11:19:16 +0100 Subject: [PATCH 137/546] some pr comments --- pyproject.toml | 5 +- scenario/scripts/snapshot.py | 197 +++++++++++++++++------------------ 2 files changed, 99 insertions(+), 103 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16fd298be..741c1f9f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,10 @@ description = "Python library providing a Scenario-based testing API for Operato license.text = "Apache-2.0" keywords = ["juju", "test"] -dependencies = ["ops>=2.0"] +dependencies = [ + "ops>=2.0", + "PyYAML==6.0" +] readme = "README.md" requires-python = ">=3.8" diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index e8af13a47..5cd87677d 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -2,6 +2,7 @@ import logging import os import re +import shlex import tempfile from dataclasses import asdict from enum import Enum @@ -99,37 +100,29 @@ def test_case(): def _juju_run(cmd, model=None) -> Dict[str, Any]: _model = f" -m {model}" if model else "" - raw = run( - f"""juju {cmd}{_model} --format json""".split(), capture_output=True - ).stdout.decode("utf-8") + cmd = f"juju {cmd}{_model} --format json" + raw = run(shlex.split(cmd), capture_output=True).stdout.decode("utf-8") return json.loads(raw) def _juju_ssh(target: Target, cmd, model: Optional[str] = None) -> str: _model = f" -m {model}" if model else "" - command = f"""juju ssh{_model} {target.unit_name} {cmd}""" - raw = run(command.split(), capture_output=True).stdout.decode("utf-8") + command = f"juju ssh{_model} {target.unit_name} {cmd}" + raw = run(shlex.split(command), capture_output=True).stdout.decode("utf-8") return raw -def _juju_exec(target: Target, model, cmd) -> str: - # action-fail juju-reboot payload-unregister secret-remove - # action-get jujuc pod-spec-get secret-revoke - # action-log k8s-raw-get pod-spec-set secret-set - # action-set k8s-raw-set relation-get state-delete - # add-metric k8s-spec-get relation-ids state-get - # application-version-set k8s-spec-set relation-list state-set - # close-port leader-get relation-set status-get - # config-get leader-set resource-get status-set - # containeragent network-get secret-add storage-add - # credential-get open-port secret-get storage-get - # goal-state opened-ports secret-grant storage-list - # is-leader payload-register secret-ids unit-get - # juju-log payload-status-set secret-info-get +def _juju_exec(target: Target, model: Optional[str], cmd: str) -> str: + """Execute a juju command. + + Notes: + Visit the Juju documentation to view all possible Juju commands: + https://juju.is/docs/olm/juju-cli-commands + """ _model = f" -m {model}" if model else "" _target = f" -u {target}" if target else "" return run( - f"juju exec{_model}{_target} -- {cmd}".split(), capture_output=True + shlex.split(f"juju exec{_model}{_target} -- {cmd}"), capture_output=True ).stdout.decode("utf-8") @@ -171,11 +164,11 @@ def get_network(target: Target, model: Optional[str], endpoint: str) -> Network: def get_networks( - target: Target, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: Target, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: logger.info("getting networks...") networks = [] @@ -222,7 +215,7 @@ def __init__(self, container: str, target: Target, model: Optional[str] = None): def _run(self, cmd: str) -> str: _model = f" -m {self.model}" if self.model else "" command = f"juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}" - proc = run(command.split(), capture_output=True) + proc = run(shlex.split(command), capture_output=True) if proc.returncode == 0: return proc.stdout.decode("utf-8") raise RuntimeError( @@ -247,19 +240,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -270,17 +263,17 @@ def get_checks( def fetch_file( - target: Target, - remote_path: str, - container_name: str, - local_path: Path = None, - model: str = None, + target: Target, + remote_path: str, + container_name: str, + local_path: Path = None, + model: str = None, ) -> Optional[str]: # copied from jhack model_arg = f" -m {model}" if model else "" cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" try: - raw = check_output(cmd.split()) + raw = check_output(shlex.split(cmd)) except CalledProcessError as e: raise RuntimeError( f"Failed to fetch {remote_path} from {target.unit_name}." @@ -293,12 +286,12 @@ def fetch_file( def get_mounts( - target: Target, - model, - container_name: str, - container_meta, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: Target, + model, + container_name: str, + container_meta, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Dict[str, Mount]: mount_meta = container_meta.get("mounts") @@ -356,12 +349,12 @@ def get_mounts( def get_container( - target: Target, - model, - container_name: str, - container_meta, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: Target, + model, + container_name: str, + container_meta, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Container: remote_client = RemotePebbleClient(container_name, target, model) plan = remote_client.get_plan() @@ -382,11 +375,11 @@ def get_container( def get_containers( - target: Target, - model, - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: Target, + model, + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> List[Container]: fetch_files = fetch_files or {} logger.info("getting containers...") @@ -410,7 +403,7 @@ def get_containers( def get_status_and_endpoints( - target: Target, model: Optional[str] + target: Target, model: Optional[str] ) -> Tuple[Status, Tuple[str, ...]]: logger.info("getting status...") @@ -444,7 +437,7 @@ def _cast(value: str, _type): def get_config( - target: Target, model: Optional[str] + target: Target, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: logger.info("getting config...") _model = f" -m {model}" if model else "" @@ -471,10 +464,10 @@ def _get_interface_from_metadata(endpoint, metadata): def get_relations( - target: Target, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: Target, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: logger.info("getting relations...") @@ -581,15 +574,15 @@ class FormatOption(str, Enum): def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ): try: target = Target(target) @@ -683,39 +676,39 @@ def ifinclude(key, get_value, null_value): def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " - "``json``: Outputs a Jsonified State object. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " + "``json``: Outputs a Jsonified State object. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), ) -> State: """Gather and output the State of a remote target unit. From 8050005d47886f98fcfc8659aee873a505c13c9b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Mar 2023 11:29:52 +0100 Subject: [PATCH 138/546] trigger docstrings --- scenario/runtime.py | 23 +++++++++++++++++++++++ scenario/state.py | 8 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 3b7322568..c7d0db004 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -350,6 +350,29 @@ def trigger( config: Optional[Dict[str, Any]] = None, copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, ) -> "State": + """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. + :arg state: the State instance to use as data source for the hook tool calls that the charm will + invoke when handling the Event. + :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + :arg pre_event: callback to be invoked right before emitting the event on the newly + instantiated charm. Will receive the charm instance as only positional argument. + :arg post_event: callback to be invoked right after emitting the event on the charm instance. + Will receive the charm instance as only positional argument. + :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a python dict). + If none is provided, we will search for a ``metadata.yaml`` file in the charm root. + :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a python dict). + If none is provided, we will search for a ``actions.yaml`` file in the charm root. + :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). + If none is provided, we will search for a ``config.yaml`` file in the charm root. + :arg copy_to_charm_root: files to copy to the virtual charm root that we create when executing + the charm. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the + execution cwd, you need to specify that here. + """ from scenario.state import Event, _CharmSpec if isinstance(event, str): diff --git a/scenario/state.py b/scenario/state.py index 6f8d61de6..0efddc5be 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -16,7 +16,7 @@ from scenario.logger import logger as scenario_logger from scenario.mocking import _MockFileSystem, _MockStorageMount -from scenario.runtime import trigger +from scenario.runtime import trigger as _runtime_trigger if typing.TYPE_CHECKING: try: @@ -530,8 +530,8 @@ def trigger( config: Optional[Dict[str, Any]] = None, copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, ): - """Fluent API for trigger.""" - return trigger( + """Fluent API for trigger. See runtime.trigger's docstring.""" + return _runtime_trigger( state=self, event=event, charm_type=charm_type, @@ -543,6 +543,8 @@ def trigger( copy_to_charm_root=copy_to_charm_root, ) + trigger.__doc__ = _runtime_trigger.__doc__ + @dataclasses.dataclass class _CharmSpec(_DCBase): From 59c3c09813559c2e5a553bbe185b9c2f201520f9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Mar 2023 11:34:24 +0100 Subject: [PATCH 139/546] copy_to_charm_root docstring --- scenario/runtime.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scenario/runtime.py b/scenario/runtime.py index c7d0db004..9624ab9b1 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -372,6 +372,12 @@ def trigger( :arg copy_to_charm_root: files to copy to the virtual charm root that we create when executing the charm. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to specify that here. + The format is {destination_path: source_path}; so for the aforementioned scenario you + would: + >>> local_file = tempfile.NamedTemporaryFile(suffix='yaml') + >>> local_path = Path(local_path.name) + >>> local_path.write_text('foo: bar') + >>> scenario.State().trigger(..., copy_to_charm_root = {'./src/foo/bar.yaml': local_path}) """ from scenario.state import Event, _CharmSpec From adebb976f2d8a6b5b7eeb9f0044728feee7247ee Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 9 Mar 2023 12:01:07 +0100 Subject: [PATCH 140/546] vroot --- README.md | 69 +++++++++++++++--- scenario/runtime.py | 70 ++++++++----------- scenario/state.py | 4 +- ...{test_vroot_resources.py => test_vroot.py} | 19 +++-- 4 files changed, 100 insertions(+), 62 deletions(-) rename tests/test_e2e/{test_vroot_resources.py => test_vroot.py} (72%) diff --git a/README.md b/README.md index dc6b2c776..fa43d42b4 100644 --- a/README.md +++ b/README.md @@ -398,20 +398,73 @@ Scenario can simulate StoredState. You can define it on the input side as: ```python - +from ops.charm import CharmBase +from ops.framework import StoredState as Ops_StoredState, Framework from scenario import State, StoredState + +class MyCharmType(CharmBase): + my_stored_state = Ops_StoredState() + + def __init__(self, framework: Framework): + super().__init__(framework) + assert self.my_stored_state.foo == 'bar' # this will pass! + + state = State(stored_state=[ - StoredState( - owner="MyCharmType", - content={ - 'foo': 'bar', - 'baz': {42: 42}, - }) + StoredState( + owner_path="MyCharmType", + name="my_stored_state", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) ]) ``` -And you can run assertions on it on the output side the same as any other bit of state. +And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. +Also, you can run assertions on it on the output side the same as any other bit of state. + + +# The virtual charm root +Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. +The charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can +be either inferred from the charm type being passed to `trigger()` or be passed to it as an argument, thereby overriding +the inferred one. This also allows you to test with charms defined on the fly, as in: + +```python +from ops.charm import CharmBase +from scenario import State + +class MyCharmType(CharmBase): + pass + +state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start') +``` + +A consequence of this fact is that you have no direct control over the tempdir that we are +creating to put the metadata you are passing to trigger (because `ops` expects it to be a file...). +That is, unless you pass your own: + +```python +from ops.charm import CharmBase +from scenario import State +import tempfile + + +class MyCharmType(CharmBase): + pass + + +td = tempfile.TemporaryDirectory() +state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start', + charm_root=td.name) +``` + +Do this, and you will be able to set up said directory as you like before the charm is run, as well +as verify its contents after the charm has run. Do keep in mind that the metadata files will +be overwritten by Scenario, and therefore ignored. + # TODOS: - State-State consistency checks. diff --git a/scenario/runtime.py b/scenario/runtime.py index 9624ab9b1..62b5db215 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -66,12 +66,12 @@ class Runtime: def __init__( self, charm_spec: "_CharmSpec", - copy_to_charm_root: Dict["PathLike", "PathLike"] = None, + charm_root: Optional["PathLike"] = None, juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version - self._copy_to_charm_root = copy_to_charm_root + self._charm_root = charm_root # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. @@ -175,40 +175,27 @@ def virtual_charm_root(self): # the metadata files ourselves. To be sure, we ALWAYS use a tempdir. Ground truth is what the user # passed via the CharmSpec spec = self._charm_spec - with tempfile.TemporaryDirectory() as tempdir: - virtual_charm_root = Path(tempdir) - (virtual_charm_root / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) - (virtual_charm_root / "config.yaml").write_text( - yaml.safe_dump(spec.config or {}) - ) - (virtual_charm_root / "actions.yaml").write_text( - yaml.safe_dump(spec.actions or {}) - ) - - for subtree, origin in (self._copy_to_charm_root or {}).items(): - subtree = Path(subtree) - origin = Path(origin) - - if subtree.name.startswith("/"): - raise ValueError( - "invalid subtree. Should be relative paths starting without a /: they will " - "be interpreted relative to the virtual charm root" - ) - parts = subtree.parts - if parts[0] == "/": - parts = parts[1:] + if vroot := self._charm_root: + cleanup = False + virtual_charm_root = Path(vroot) + else: + vroot = tempfile.TemporaryDirectory() + virtual_charm_root = Path(vroot.name) + cleanup = True - new_loc = virtual_charm_root.joinpath(*parts) - if not new_loc.parent.exists(): - new_loc.parent.mkdir(parents=True) + (virtual_charm_root / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) + (virtual_charm_root / "config.yaml").write_text( + yaml.safe_dump(spec.config or {}) + ) + (virtual_charm_root / "actions.yaml").write_text( + yaml.safe_dump(spec.actions or {}) + ) - if origin.is_dir(): - shutil.copytree(origin, new_loc) - else: - shutil.copy2(origin, new_loc) + yield virtual_charm_root - yield virtual_charm_root + if cleanup: + vroot.cleanup() @staticmethod def _get_store(temporary_charm_root: Path): @@ -348,7 +335,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + charm_root: Optional[Dict["PathLike", "PathLike"]] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -369,15 +356,14 @@ def trigger( If none is provided, we will search for a ``actions.yaml`` file in the charm root. :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). If none is provided, we will search for a ``config.yaml`` file in the charm root. - :arg copy_to_charm_root: files to copy to the virtual charm root that we create when executing - the charm. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to specify that here. - The format is {destination_path: source_path}; so for the aforementioned scenario you - would: - >>> local_file = tempfile.NamedTemporaryFile(suffix='yaml') + :arg charm_root: virtual charm root the charm will be executed with. + If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the + execution cwd, you need to use this. + >>> virtual_root = tempfile.TemporaryDirectory() >>> local_path = Path(local_path.name) - >>> local_path.write_text('foo: bar') - >>> scenario.State().trigger(..., copy_to_charm_root = {'./src/foo/bar.yaml': local_path}) + >>> (local_path / 'foo').mkdir() + >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + >>> scenario.State().trigger(..., charm_root = virtual_root) """ from scenario.state import Event, _CharmSpec @@ -397,7 +383,7 @@ def trigger( runtime = Runtime( charm_spec=spec, juju_version=state.juju_version, - copy_to_charm_root=copy_to_charm_root, + charm_root=charm_root, ) return runtime.exec( diff --git a/scenario/state.py b/scenario/state.py index 0efddc5be..5bc82b2e7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -528,7 +528,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - copy_to_charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + charm_root: Optional["PathLike"] = None, ): """Fluent API for trigger. See runtime.trigger's docstring.""" return _runtime_trigger( @@ -540,7 +540,7 @@ def trigger( meta=meta, actions=actions, config=config, - copy_to_charm_root=copy_to_charm_root, + charm_root=charm_root, ) trigger.__doc__ = _runtime_trigger.__doc__ diff --git a/tests/test_e2e/test_vroot_resources.py b/tests/test_e2e/test_vroot.py similarity index 72% rename from tests/test_e2e/test_vroot_resources.py rename to tests/test_e2e/test_vroot.py index 44b9075ab..c4c4c36ea 100644 --- a/tests/test_e2e/test_vroot_resources.py +++ b/tests/test_e2e/test_vroot.py @@ -19,25 +19,24 @@ def __init__(self, framework: Framework): self.unit.status = ActiveStatus(f"{foo.read_text()} {baz.read_text()}") -def test_resources(): - with tempfile.TemporaryDirectory() as td: - t = Path(td) - foobar = t / "foo.bar" +def test_vroot(): + with tempfile.TemporaryDirectory() as myvroot: + t = Path(myvroot) + src = t / "src" + src.mkdir() + foobar = src / "foo.bar" foobar.write_text("hello") - baz = t / "baz" + baz = src / "baz" baz.mkdir(parents=True) - quxcos = baz / "qux.cos" + quxcos = baz / "qux.kaboodle" quxcos.write_text("world") out = State().trigger( "start", charm_type=MyCharm, meta=MyCharm.META, - copy_to_charm_root={ - "/src/foo.bar": foobar, - "/src/baz/qux.kaboodle": quxcos, - }, + charm_root=t, ) assert out.status.unit == ("active", "hello world") From 3ad495a00d5d3a909e1875f39d8f60009910ffe9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 10:21:10 +0100 Subject: [PATCH 141/546] fixed bug in config-get --- scenario/mocking.py | 15 ++++++---- tests/test_e2e/test_config.py | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/test_e2e/test_config.py diff --git a/scenario/mocking.py b/scenario/mocking.py index a11087d3b..8d5902f6e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -127,11 +127,16 @@ def relation_list(self, relation_id: int): def config_get(self): state_config = self._state.config - if not state_config: - state_config = { - key: value.get("default") - for key, value in self._charm_spec.config.items() - } + + # add defaults + charm_config = self._charm_spec.config + if not charm_config: + return state_config + + for key, value in charm_config["options"].items(): + # if it has a default, and it's not overwritten from State, use it: + if key not in state_config and (default_value := value.get("default")): + state_config[key] = default_value return state_config # full config diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py new file mode 100644 index 000000000..55a6a15d9 --- /dev/null +++ b/tests/test_e2e/test_config.py @@ -0,0 +1,55 @@ +import pytest +from ops.charm import CharmBase +from ops.framework import Framework + +from scenario import trigger +from scenario.state import Event, Network, Relation, State, _CharmSpec + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + pass + + return MyCharm + + +def test_config_get(mycharm): + def check_cfg(charm: CharmBase): + assert charm.config["foo"] == "bar" + assert charm.config["baz"] == 1 + + trigger( + State( + config={"foo": "bar", "baz": 1}, + ), + "update-status", + mycharm, + meta={"name": "foo"}, + post_event=check_cfg, + ) + + +def test_config_get_default_from_meta(mycharm): + def check_cfg(charm: CharmBase): + assert charm.config["foo"] == "bar" + assert charm.config["baz"] == 2 + + trigger( + State( + config={"foo": "bar"}, + ), + "update-status", + mycharm, + meta={"name": "foo"}, + config={ + "options": {"baz": {"type": "integer", "default": 2}}, + }, + post_event=check_cfg, + ) From 02d8bf3183fd09cd3fecd3b07fe0aa03f28d25d8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 10:47:15 +0100 Subject: [PATCH 142/546] raise if vroot is dirty with metadata --- scenario/runtime.py | 36 +++++++++++++++++++++++------------- tests/test_e2e/test_vroot.py | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 62b5db215..4da63d06a 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -46,15 +46,16 @@ RUNTIME_MODULE = Path(__file__).parent -class UncaughtCharmError(RuntimeError): +class ScenarioRuntimeError(RuntimeError): + """Base class for exceptions raised by scenario.runtime.""" + + +class UncaughtCharmError(ScenarioRuntimeError): """Error raised if the charm raises while handling the event being dispatched.""" -@dataclasses.dataclass -class RuntimeRunResult: - charm: "CharmBase" - scene: "Scene" - event: "EventBase" +class DirtyVirtualCharmRootError(ScenarioRuntimeError): + """Error raised when the runtime can't initialize the vroot without overwriting existing metadata files.""" class Runtime: @@ -184,13 +185,22 @@ def virtual_charm_root(self): virtual_charm_root = Path(vroot.name) cleanup = True - (virtual_charm_root / "metadata.yaml").write_text(yaml.safe_dump(spec.meta)) - (virtual_charm_root / "config.yaml").write_text( - yaml.safe_dump(spec.config or {}) - ) - (virtual_charm_root / "actions.yaml").write_text( - yaml.safe_dump(spec.actions or {}) - ) + metadata_yaml = virtual_charm_root / "metadata.yaml" + config_yaml = virtual_charm_root / "config.yaml" + actions_yaml = virtual_charm_root / "actions.yaml" + + if any((file.exists() for file in (metadata_yaml, config_yaml, actions_yaml))): + logger.error( + f"Some metadata files found in custom user-provided vroot {vroot}. " + "We don't want to risk overwriting them mindlessly, so we abort. " + "You should not include any metadata files in the charm_root. " + "Single source of truth are the arguments passed to trigger()." + ) + raise DirtyVirtualCharmRootError(vroot) + + metadata_yaml.write_text(yaml.safe_dump(spec.meta)) + config_yaml.write_text(yaml.safe_dump(spec.config or {})) + actions_yaml.write_text(yaml.safe_dump(spec.actions or {})) yield virtual_charm_root diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index c4c4c36ea..eacc8e511 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -1,11 +1,13 @@ import tempfile from pathlib import Path +import pytest from ops.charm import CharmBase from ops.framework import Framework from ops.model import ActiveStatus from scenario import State +from scenario.runtime import DirtyVirtualCharmRootError class MyCharm(CharmBase): @@ -40,3 +42,19 @@ def test_vroot(): ) assert out.status.unit == ("active", "hello world") + + +@pytest.mark.parametrize("meta_overwrite", ["metadata", "actions", "config"]) +def test_dirty_vroot_raises(meta_overwrite): + with tempfile.TemporaryDirectory() as myvroot: + t = Path(myvroot) + meta_file = t / f"{meta_overwrite}.yaml" + meta_file.touch() + + with pytest.raises(DirtyVirtualCharmRootError): + State().trigger( + "start", + charm_type=MyCharm, + meta=MyCharm.META, + charm_root=t, + ) From 14503a69943b521736d4fa80cd072d80df4fdcfe Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 10:58:21 +0100 Subject: [PATCH 143/546] added custom vroot log --- scenario/runtime.py | 28 ++++++++++++++++++++++------ scenario/state.py | 10 +++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 4da63d06a..14ef3dde3 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -178,23 +178,39 @@ def virtual_charm_root(self): spec = self._charm_spec if vroot := self._charm_root: - cleanup = False + vroot_is_custom = True virtual_charm_root = Path(vroot) else: vroot = tempfile.TemporaryDirectory() virtual_charm_root = Path(vroot.name) - cleanup = True + vroot_is_custom = False metadata_yaml = virtual_charm_root / "metadata.yaml" config_yaml = virtual_charm_root / "config.yaml" actions_yaml = virtual_charm_root / "actions.yaml" - if any((file.exists() for file in (metadata_yaml, config_yaml, actions_yaml))): + metadata_files_present = any( + (file.exists() for file in (metadata_yaml, config_yaml, actions_yaml)) + ) + + if spec.is_autoloaded and vroot_is_custom: + # since the spec is autoloaded, in theory the metadata contents won't differ, so we can + # overwrite away even if the custom vroot is the real charm root (the local repo). + # Still, log it for clarity. + if metadata_files_present: + logger.info( + f"metadata files found in custom vroot {vroot}. " + f"The spec was autoloaded so the contents should be identical. " + f"Proceeding..." + ) + + elif not spec.is_autoloaded and metadata_files_present: logger.error( - f"Some metadata files found in custom user-provided vroot {vroot}. " + f"Some metadata files found in custom user-provided vroot {vroot} " + f"while you have passed meta, config or actions to trigger(). " "We don't want to risk overwriting them mindlessly, so we abort. " "You should not include any metadata files in the charm_root. " - "Single source of truth are the arguments passed to trigger()." + "Single source of truth are the arguments passed to trigger(). " ) raise DirtyVirtualCharmRootError(vroot) @@ -204,7 +220,7 @@ def virtual_charm_root(self): yield virtual_charm_root - if cleanup: + if not vroot_is_custom: vroot.cleanup() @staticmethod diff --git a/scenario/state.py b/scenario/state.py index 5bc82b2e7..c2e505b46 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -555,6 +555,10 @@ class _CharmSpec(_DCBase): actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None + # autoloaded means: trigger() is being invoked on a 'real' charm class, living in some /src/charm.py, + # and the metadata files are 'real' metadata files. + is_autoloaded: bool = False + @staticmethod def autoload(charm_type: Type["CharmType"]): charm_source_path = Path(inspect.getfile(charm_type)) @@ -574,7 +578,11 @@ def autoload(charm_type: Type["CharmType"]): actions = yaml.safe_load(actions_path.open()) return _CharmSpec( - charm_type=charm_type, meta=meta, actions=actions, config=config + charm_type=charm_type, + meta=meta, + actions=actions, + config=config, + is_autoloaded=True, ) From a5c5c4fe7a2c170a6a713a0774097a0e8a730b66 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 11:48:31 +0100 Subject: [PATCH 144/546] normalize event names including origin --- scenario/state.py | 79 ++++++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index c8c263ebb..abed657e3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -124,6 +124,11 @@ def remove_event(self): _RELATION_IDS_CTR = 0 +def _normalize_event_name(s: str): + """Event names need underscores instead of dashes.""" + return s.replace('-', '_') + + @dataclasses.dataclass class Relation(_DCBase): endpoint: str @@ -173,27 +178,37 @@ def __post_init__(self): @property def changed_event(self): """Sugar to generate a -relation-changed event.""" - return Event(name=self.endpoint + "_relation_changed", relation=self) + return Event( + name=_normalize_event_name(self.endpoint + "-relation-changed"), + relation=self) @property def joined_event(self): """Sugar to generate a -relation-joined event.""" - return Event(name=self.endpoint + "_relation_joined", relation=self) + return Event( + name=_normalize_event_name(self.endpoint + "-relation-joined"), + relation=self) @property def created_event(self): """Sugar to generate a -relation-created event.""" - return Event(name=self.endpoint + "_relation_created", relation=self) + return Event( + name=_normalize_event_name(self.endpoint + "-relation-created"), + relation=self) @property def departed_event(self): """Sugar to generate a -relation-departed event.""" - return Event(name=self.endpoint + "_relation_departed", relation=self) + return Event( + name=_normalize_event_name(self.endpoint + "-relation-departed"), + relation=self) @property def broken_event(self): """Sugar to generate a -relation-broken event.""" - return Event(name=self.endpoint + "_relation_broken", relation=self) + return Event( + name=_normalize_event_name(self.endpoint + "-relation-broken"), + relation=self) def _random_model_name(): @@ -346,7 +361,8 @@ def pebble_ready_event(self): "you **can** fire pebble-ready while the container cannot connect, " "but that's most likely not what you want." ) - return Event(name=self.name + "_pebble_ready", container=self) + return Event(name=_normalize_event_name(self.name + "-pebble-ready"), + container=self) @dataclasses.dataclass @@ -393,15 +409,15 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -569,17 +585,17 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, + self, + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional["PathLike"] = None, ): """Fluent API for trigger. See runtime.trigger's docstring.""" return _runtime_trigger( @@ -718,11 +734,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def deferred( - event: Union[str, Event], - handler: Callable, - event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -768,7 +784,6 @@ def _derive_args(event_name: str): return tuple(args) - # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass From bb8e7d183975f1ffae6552ebb3bc3750bbdd721b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 12:32:35 +0100 Subject: [PATCH 145/546] fixed tests, added docs --- README.md | 54 +++++++++++++ scenario/mocking.py | 14 +--- scenario/state.py | 105 ++++++++++++++++--------- tests/test_e2e/test_play_assertions.py | 20 +++-- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_state.py | 20 +++-- tests/test_e2e/test_status.py | 73 +++++++++++++++++ 7 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 tests/test_e2e/test_status.py diff --git a/README.md b/README.md index 3792f55af..b6c3a4d1c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,60 @@ def test_status_leader(leader): By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... + +## Statuses + +One of the simplest types of black-box testing available to charmers is to execute the charm and verify that the charm sets the expected unit/application status. +We have seen a simple example above including leadership. +But what if the charm transitions through a sequence of statuses? + +```python +from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedStatus + +# charm code: +def _on_event(self, _event): + self.unit.status = MaintenanceStatus('determining who the ruler is...') + try: + if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = WaitingStatus('checking this is right...') + self._check_that_takes_some_more_time() + self.unit.status = ActiveStatus('I am ruled') + except: + self.unit.status = BlockedStatus('something went wrong') +``` + +You can verify that the charm has followed the expected path by checking the **unit status history** like so: + +```python +from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownStatus +from scenario import State + +def test_statuses(): + out = State(leader=False).trigger( + 'start', + MyCharm, + meta={"name": "foo"}) + assert out.status.unit_history == [ + UnknownStatus(), + MaintenanceStatus('determining who the ruler is...'), + WaitingStatus('checking this is right...'), + ActiveStatus('I am ruled') + ] +``` + +Note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen". + +If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' in State. + +```python +from ops.model import ActiveStatus +from scenario import State, Status +State(leader=False, status=Status(unit=ActiveStatus('foo'))) +``` + + ## Relations You can write scenario tests to verify the shape of relation data: diff --git a/scenario/mocking.py b/scenario/mocking.py index 8d5902f6e..cc312611e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -148,20 +148,14 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): return network.hook_tool_output_fmt() # setter methods: these can mutate the state. - def application_version_set(self, *args, **kwargs): - self._state.status.app_version = args[0] - return None + def application_version_set(self, version: str): + self._state.status._update_app_version(version) # noqa - def status_set(self, *args, **kwargs): - if kwargs.get("is_app"): - self._state.status.app = args - else: - self._state.status.unit = args - return None + def status_set(self, status: str, message: str = "", *, is_app: bool = False): + self._state.status._update_status(status, message, is_app) # noqa def juju_log(self, level: str, message: str): self._state.juju_log.append((level, message)) - return None def relation_set(self, relation_id: int, key: str, value: str, is_app: bool): relation = self._get_relation_by_id(relation_id) diff --git a/scenario/state.py b/scenario/state.py index abed657e3..fbc071da6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -126,7 +126,7 @@ def remove_event(self): def _normalize_event_name(s: str): """Event names need underscores instead of dashes.""" - return s.replace('-', '_') + return s.replace("-", "_") @dataclasses.dataclass @@ -180,35 +180,40 @@ def changed_event(self): """Sugar to generate a -relation-changed event.""" return Event( name=_normalize_event_name(self.endpoint + "-relation-changed"), - relation=self) + relation=self, + ) @property def joined_event(self): """Sugar to generate a -relation-joined event.""" return Event( name=_normalize_event_name(self.endpoint + "-relation-joined"), - relation=self) + relation=self, + ) @property def created_event(self): """Sugar to generate a -relation-created event.""" return Event( name=_normalize_event_name(self.endpoint + "-relation-created"), - relation=self) + relation=self, + ) @property def departed_event(self): """Sugar to generate a -relation-departed event.""" return Event( name=_normalize_event_name(self.endpoint + "-relation-departed"), - relation=self) + relation=self, + ) @property def broken_event(self): """Sugar to generate a -relation-broken event.""" return Event( name=_normalize_event_name(self.endpoint + "-relation-broken"), - relation=self) + relation=self, + ) def _random_model_name(): @@ -361,8 +366,9 @@ def pebble_ready_event(self): "you **can** fire pebble-ready while the container cannot connect, " "but that's most likely not what you want." ) - return Event(name=_normalize_event_name(self.name + "-pebble-ready"), - container=self) + return Event( + name=_normalize_event_name(self.name + "-pebble-ready"), container=self + ) @dataclasses.dataclass @@ -409,15 +415,15 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -467,15 +473,23 @@ def __iter__(self): return iter([self.name, self.message]) def __repr__(self): - return f"" + return f"" @dataclasses.dataclass class Status(_DCBase): - app: _EntityStatus = _EntityStatus("unknown") - unit: _EntityStatus = _EntityStatus("unknown") + """Represents the 'juju statuses' of the application/unit being tested.""" + + # the current statuses. Will be cast to _EntitiyStatus in __post_init__ + app: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") app_version: str = "" + # most to least recent statuses; do NOT include the current one. + app_history: List[_EntityStatus] = dataclasses.field(default_factory=list) + unit_history: List[_EntityStatus] = dataclasses.field(default_factory=list) + previous_app_version: Optional[str] = None + def __post_init__(self): for name in ["app", "unit"]: val = getattr(self, name) @@ -493,6 +507,24 @@ def __post_init__(self): else: raise TypeError(f"Invalid status.{name}: {val!r}") + def _update_app_version(self, new_app_version: str): + """Update the current app version and record the previous one.""" + # We don't keep a full history because we don't expect the app version to change more + # than once per hook. + self.previous_app_version = self.app_version + self.app_version = new_app_version + + def _update_status( + self, new_status: str, new_message: str = "", is_app: bool = False + ): + """Update the current app/unit status and add the previous one to the history.""" + if is_app: + self.app_history.append(self.app) + self.app = _EntityStatus(new_status, new_message) + else: + self.unit_history.append(self.unit) + self.unit = _EntityStatus(new_status, new_message) + @dataclasses.dataclass class StoredState(_DCBase): @@ -585,17 +617,17 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, + self, + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional["PathLike"] = None, ): """Fluent API for trigger. See runtime.trigger's docstring.""" return _runtime_trigger( @@ -734,11 +766,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def deferred( - event: Union[str, Event], - handler: Callable, - event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -784,6 +816,7 @@ def _derive_args(event_name: str): return tuple(args) + # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 5f1cfeffc..ca699ac0e 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -44,7 +44,7 @@ def post_event(charm): mycharm._call = call initial_state = State( - config={"foo": "bar"}, leader=True, status=Status(unit=("blocked", "foo")) + config={"foo": "bar"}, leader=True, status=Status(unit=BlockedStatus("foo")) ) out = initial_state.trigger( @@ -55,16 +55,26 @@ def post_event(charm): pre_event=pre_event, ) - assert out.status.unit == ("active", "yabadoodle") + assert out.status.unit == ActiveStatus("yabadoodle") out.juju_log = [] # exclude juju log from delta out.stored_state = initial_state.stored_state # ignore stored state in delta. assert out.jsonpatch_delta(initial_state) == [ { "op": "replace", - "path": "/status/unit", - "value": ("active", "yabadoodle"), - } + "path": "/status/unit/message", + "value": "yabadoodle", + }, + { + "op": "replace", + "path": "/status/unit/name", + "value": "active", + }, + { + "op": "add", + "path": "/status/unit_history/0", + "value": {"message": "foo", "name": "blocked"}, + }, ] diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 4a1507a4d..6380daf16 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -4,7 +4,7 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, Framework -from scenario.state import Event, Relation, State, _CharmSpec +from scenario.state import Relation, State @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 2b9872ab7..695a55181 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -89,23 +89,27 @@ def call(charm: CharmBase, _): mycharm, meta={"name": "foo"}, ) - assert out.status.unit == ("active", "foo test") - assert out.status.app == ("waiting", "foo barz") + assert out.status.unit == ActiveStatus("foo test") + assert out.status.app == WaitingStatus("foo barz") assert out.status.app_version == "" out.juju_log = [] # ignore logging output in the delta out.stored_state = state.stored_state # ignore stored state in delta. assert out.jsonpatch_delta(state) == sort_patch( [ + {"op": "replace", "path": "/status/app/message", "value": "foo barz"}, + {"op": "replace", "path": "/status/app/name", "value": "waiting"}, { - "op": "replace", - "path": "/status/app", - "value": ("waiting", "foo barz"), + "op": "add", + "path": "/status/app_history/0", + "value": {"message": "", "name": "unknown"}, }, + {"op": "replace", "path": "/status/unit/message", "value": "foo test"}, + {"op": "replace", "path": "/status/unit/name", "value": "active"}, { - "op": "replace", - "path": "/status/unit", - "value": ("active", "foo test"), + "op": "add", + "path": "/status/unit_history/0", + "value": {"message": "", "name": "unknown"}, }, ] ) diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py new file mode 100644 index 000000000..a43c11665 --- /dev/null +++ b/tests/test_e2e/test_status.py @@ -0,0 +1,73 @@ +import pytest +from ops.charm import CharmBase +from ops.framework import Framework +from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus + +from scenario.state import State, Status + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + pass + + return MyCharm + + +def test_initial_status(mycharm): + def post_event(charm: CharmBase): + assert charm.unit.status == UnknownStatus() + + out = State(leader=True).trigger( + "update-status", mycharm, meta={"name": "local"}, post_event=post_event + ) + + assert out.status.unit == UnknownStatus() + + +def test_status_history(mycharm): + def post_event(charm: CharmBase): + for obj in [charm.unit, charm.app]: + obj.status = ActiveStatus("1") + obj.status = BlockedStatus("2") + obj.status = WaitingStatus("3") + + out = State(leader=True).trigger( + "update-status", mycharm, meta={"name": "local"}, post_event=post_event + ) + + assert out.status.unit == WaitingStatus("3") + assert out.status.unit_history == [ + UnknownStatus(), + ActiveStatus("1"), + BlockedStatus("2"), + ] + + assert out.status.app == WaitingStatus("3") + assert out.status.app_history == [ + UnknownStatus(), + ActiveStatus("1"), + BlockedStatus("2"), + ] + + +def test_status_history_preservation(mycharm): + def post_event(charm: CharmBase): + for obj in [charm.unit, charm.app]: + obj.status = WaitingStatus("3") + + out = State( + leader=True, status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")) + ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + + assert out.status.unit == WaitingStatus("3") + assert out.status.unit_history == [ActiveStatus("foo")] + + assert out.status.app == WaitingStatus("3") + assert out.status.app_history == [ActiveStatus("bar")] From 297f5ea7f9796843414a2517a40eb1a1fb16e1b2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 16:18:01 +0100 Subject: [PATCH 146/546] final round, to be tested --- scenario/scripts/snapshot.py | 350 ++++++++++++++++++++--------------- 1 file changed, 197 insertions(+), 153 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 5cd87677d..a33e76b80 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -32,34 +32,38 @@ JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) JUJU_CONFIG_KEYS = frozenset({}) + +# TODO: allow passing a custom data dir, else put it in a tempfile in /tmp/. SNAPSHOT_TEMPDIR_ROOT = (Path(os.getcwd()).parent / "snapshot_storage").absolute() class SnapshotError(RuntimeError): - pass + """Base class for errors raised by snapshot.""" + +class InvalidTargetUnitName(SnapshotError): + """Raised if the unit name passed to snapshot is invalid.""" -class InvalidTarget(SnapshotError): - pass +class InvalidTargetModelName(SnapshotError): + """Raised if the model name passed to snapshot is invalid.""" -class InvalidModel(SnapshotError): - pass +class JujuUnitName(str): + """This class represents the name of a juju unit that can be snapshotted.""" -class Target(str): def __init__(self, unit_name: str): super().__init__() app_name, _, unit_id = unit_name.rpartition("/") if not app_name or not unit_id: - raise InvalidTarget(f"invalid unit name: {unit_name!r}") + raise InvalidTargetUnitName(f"invalid unit name: {unit_name!r}") self.unit_name = unit_name self.app_name = app_name self.unit_id = int(unit_id) self.normalized = f"{app_name}-{unit_id}" -def _try_format(string): +def _try_format(string: str): try: import black @@ -73,46 +77,49 @@ def _try_format(string): return string -def format_state(state): +def format_state(state: State): + """Pretty-print this State as-is.""" return _try_format(repr(state)) -def format_test_case(state, charm_type_name=None, event_name=None): +def format_test_case(state: State, charm_type_name: str = None, event_name: str = None): + """Format this State as a pytest test case.""" ct = charm_type_name or "CHARM_TYPE # TODO: replace with charm type name" en = event_name or "EVENT_NAME, # TODO: replace with event name" return _try_format( dedent( f""" -from scenario.state import * -from charm import {ct} - -def test_case(): - state = {state} - out = state.trigger( - {en} - {ct} - ) - -""" + from scenario.state import * + from charm import {ct} + + def test_case(): + state = {state} + out = state.trigger( + {en} + {ct} + ) + + """ ) ) -def _juju_run(cmd, model=None) -> Dict[str, Any]: +def _juju_run(cmd: str, model=None) -> Dict[str, Any]: + """Execute juju {command} in a given model.""" _model = f" -m {model}" if model else "" cmd = f"juju {cmd}{_model} --format json" raw = run(shlex.split(cmd), capture_output=True).stdout.decode("utf-8") return json.loads(raw) -def _juju_ssh(target: Target, cmd, model: Optional[str] = None) -> str: +def _juju_ssh(target: JujuUnitName, cmd: str, model: Optional[str] = None) -> str: _model = f" -m {model}" if model else "" command = f"juju ssh{_model} {target.unit_name} {cmd}" raw = run(shlex.split(command), capture_output=True).stdout.decode("utf-8") return raw -def _juju_exec(target: Target, model: Optional[str], cmd: str) -> str: +def _juju_exec(target: JujuUnitName, model: Optional[str], cmd: str) -> str: """Execute a juju command. Notes: @@ -126,13 +133,14 @@ def _juju_exec(target: Target, model: Optional[str], cmd: str) -> str: ).stdout.decode("utf-8") -def get_leader(target: Target, model: Optional[str]): +def get_leader(target: JujuUnitName, model: Optional[str]): # could also get it from _juju_run('status')... logger.info("getting leader...") return _juju_exec(target, model, "is-leader") == "True" -def get_network(target: Target, model: Optional[str], endpoint: str) -> Network: +def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Network: + """Get the Network data structure for this endpoint.""" raw = _juju_exec(target, model, f"network-get {endpoint}") jsn = yaml.safe_load(raw) @@ -164,12 +172,13 @@ def get_network(target: Target, model: Optional[str], endpoint: str) -> Network: def get_networks( - target: Target, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: + """Get all Networks from this unit.""" logger.info("getting networks...") networks = [] networks.append(get_network(target, model, "juju-info")) @@ -188,7 +197,8 @@ def get_networks( return networks -def get_metadata(target: Target, model: Optional[str]): +def get_metadata(target: JujuUnitName, model: Optional[str]): + """Get metadata.yaml from this target.""" logger.info("fetching metadata...") raw_meta = _juju_ssh( @@ -206,7 +216,9 @@ class RemotePebbleClient: # " j ssh --container traefik traefik/0 cat var/lib/pebble/default/.pebble.state | jq" # figure out what it's for. - def __init__(self, container: str, target: Target, model: Optional[str] = None): + def __init__( + self, container: str, target: JujuUnitName, model: Optional[str] = None + ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container self.target = target @@ -240,19 +252,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -263,12 +275,13 @@ def get_checks( def fetch_file( - target: Target, - remote_path: str, - container_name: str, - local_path: Path = None, - model: str = None, + target: JujuUnitName, + remote_path: str, + container_name: str, + local_path: Path = None, + model: Optional[str] = None, ) -> Optional[str]: + """Download a file from a live unit to a local path.""" # copied from jhack model_arg = f" -m {model}" if model else "" cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" @@ -286,13 +299,14 @@ def fetch_file( def get_mounts( - target: Target, - model, - container_name: str, - container_meta, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Dict[str, Mount]: + """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") if fetch_files and not mount_meta: @@ -349,13 +363,14 @@ def get_mounts( def get_container( - target: Target, - model, - container_name: str, - container_meta, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Container: + """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) plan = remote_client.get_plan() @@ -375,12 +390,13 @@ def get_container( def get_containers( - target: Target, - model, - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> List[Container]: + """Get all containers from this unit.""" fetch_files = fetch_files or {} logger.info("getting containers...") @@ -403,8 +419,9 @@ def get_containers( def get_status_and_endpoints( - target: Target, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Tuple[Status, Tuple[str, ...]]: + """Parse `juju status` to get the Status data structure and some relation information.""" logger.info("getting status...") status = _juju_run(f"status --relations {target}", model=model) @@ -421,39 +438,50 @@ def get_status_and_endpoints( return Status(app=app_status, unit=unit_status, app_version=app_version), relations -def _cast(value: str, _type): - if _type == "string": - return value - elif _type == "integer": - return int(value) - elif _type == "number": - return float(value) - elif _type == "boolean": - return value == "true" - elif _type == "attrs": # TODO: WOT? - return value - else: - raise ValueError(_type) +dispatch = { + "string": str, + "integer": int, + "number": float, + "boolean": lambda x: x == True, + "attrs": lambda x: x, +} def get_config( - target: Target, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: + """Get config dict from target.""" + logger.info("getting config...") _model = f" -m {model}" if model else "" jsn = _juju_run(f"config {target.app_name}", model=model) + # dispatch table for builtin config options + converters = { + "string": str, + "integer": int, + "number": float, + "boolean": lambda x: x == "true", + "attrs": lambda x: x, + } + cfg = {} for name, option in jsn.get("settings", ()).items(): - if not option.get("value"): + if value := option.get("value"): + try: + converter = converters[option["type"]] + except KeyError: + raise ValueError(f'unrecognized type {option["type"]}') + cfg[name] = converter(value) + + else: logger.debug(f"skipped {name}: no value.") - continue - cfg[name] = _cast(option["value"], option["type"]) return cfg -def _get_interface_from_metadata(endpoint, metadata): +def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str]: + """Get the name of the interface used by endpoint.""" for role in ["provides", "requires"]: for ep, ep_meta in metadata.get(role, {}).items(): if ep == endpoint: @@ -464,18 +492,19 @@ def _get_interface_from_metadata(endpoint, metadata): def get_relations( - target: Target, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: + """Get the list of relations active for this target.""" logger.info("getting relations...") _model = f" -m {model}" if model else "" try: jsn = _juju_run(f"show-unit {target}", model=model) except json.JSONDecodeError as e: - raise InvalidTarget(target) from e + raise InvalidTargetUnitName(target) from e def _clean(relation_data: dict): if include_juju_relation_data: @@ -490,7 +519,9 @@ def _clean(relation_data: dict): logger.debug( f" getting relation data for endpoint {raw_relation.get('endpoint')!r}" ) - related_units = raw_relation["related-units"] + related_units = raw_relation.get("related-units") + if not related_units: + continue # related-units: # owner/0: # in-scope: true @@ -512,7 +543,7 @@ def _clean(relation_data: dict): ) local_app_data = json.loads(local_app_data_raw) - some_remote_unit_id = Target(next(iter(related_units))) + some_remote_unit_id = JujuUnitName(next(iter(related_units))) relations.append( Relation( endpoint=raw_relation["endpoint"], @@ -523,7 +554,7 @@ def _clean(relation_data: dict): remote_app_data=raw_relation["application-data"], remote_app_name=some_remote_unit_id.app_name, remote_units_data={ - Target(tgt).unit_id: _clean(val["data"]) + JujuUnitName(tgt).unit_id: _clean(val["data"]) for tgt, val in related_units.items() }, local_app_data=local_app_data, @@ -534,6 +565,7 @@ def _clean(relation_data: dict): def get_model(name: str = None) -> Model: + """Get the Model data structure.""" logger.info("getting model...") jsn = _juju_run("models") @@ -543,7 +575,7 @@ def get_model(name: str = None) -> Model: filter(lambda m: m["short-name"] == model_name, jsn["models"]) ) except StopIteration as e: - raise InvalidModel(name) from e + raise InvalidTargetModelName(name) from e model_uuid = model_info["model-uuid"] model_type = model_info["type"] @@ -551,7 +583,8 @@ def get_model(name: str = None) -> Model: return Model(name=model_name, uuid=model_uuid, type=model_type) -def try_guess_charm_type_name(): +def try_guess_charm_type_name() -> Optional[str]: + """If we are running this from a charm project root, get the charm type name charm.py is using.""" try: charm_path = Path(os.getcwd()) / "src" / "charm.py" if charm_path.exists(): @@ -567,26 +600,31 @@ def try_guess_charm_type_name(): return None -class FormatOption(str, Enum): - state = "state" +class FormatOption( + str, Enum +): # Enum for typer support, str for native comparison and ==. + """Output formatting options for snapshot.""" + + state = "state" # the default: will print the python repr of the State dataclass. json = "json" pytest = "pytest" def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ): + """see snapshot's docstring""" try: - target = Target(target) - except InvalidTarget: + target = JujuUnitName(target) + except InvalidTargetUnitName: logger.critical( f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." @@ -649,11 +687,11 @@ def ifinclude(key, get_value, null_value): ) # todo: these errors should surface earlier. - except InvalidTarget: + except InvalidTargetUnitName: _model = f"model {model}" or "the current model" logger.critical(f"invalid target: {target!r} not found in {_model}") exit(1) - except InvalidModel: + except InvalidTargetModelName: logger.critical(f"invalid model: {model!r} not found.") exit(1) @@ -676,39 +714,41 @@ def ifinclude(key, get_value, null_value): def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed!). " - "``json``: Outputs a Jsonified State object. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. ", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " + "else it will be ugly but valid python code). " + "``json``: Outputs a Jsonified State object. Perfect for storage. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + "Pipe it to a file and fill in the blanks.", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), ) -> State: """Gather and output the State of a remote target unit. @@ -726,20 +766,24 @@ def snapshot( ) +# for the benefit of script usage +_snapshot.__doc__ = snapshot.__doc__ + if __name__ == "__main__": - print( - _snapshot( - "trfk/0", - model="foo", - format=FormatOption.json, - include="k", - fetch_files={ - "traefik": [ - Path("/opt/traefik/juju/certificates.yaml"), - Path("/opt/traefik/juju/certificate.cert"), - Path("/opt/traefik/juju/certificate.key"), - Path("/etc/traefik/traefik.yaml"), - ] - }, - ) - ) + print(_snapshot("prom/0", model="foo", format=FormatOption.pytest)) + + # print( + # _snapshot( + # "traefik/0", + # model="cos", + # format=FormatOption.json, + # fetch_files={ + # "traefik": [ + # Path("/opt/traefik/juju/certificates.yaml"), + # Path("/opt/traefik/juju/certificate.cert"), + # Path("/opt/traefik/juju/certificate.key"), + # Path("/etc/traefik/traefik.yaml"), + # ] + # }, + # ) + # ) From 750388c1cd0787c9a20ef34b6d891e23895fd463 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 16:28:25 +0100 Subject: [PATCH 147/546] added todos for missing state pieces --- scenario/scripts/snapshot.py | 69 ++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index a33e76b80..eb905d0b1 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -20,12 +20,15 @@ Address, BindAddress, Container, + DeferredEvent, Model, Mount, Network, Relation, + Secret, State, Status, + StoredState, ) logger = logging.getLogger("snapshot") @@ -171,6 +174,37 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne ) +def get_secrets( + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + relations: Tuple[str, ...] = (), +) -> List[Secret]: + """Get Secret list from the charm.""" + logger.error("Secrets snapshotting not implemented yet. Also, are you *sure*?") + return [] + + +def get_stored_state( + target: JujuUnitName, + model: Optional[str], + metadata: Dict, +) -> List[StoredState]: + """Get StoredState list from the charm.""" + logger.error("StoredState snapshotting not implemented yet.") + return [] + + +def get_deferred_events( + target: JujuUnitName, + model: Optional[str], + metadata: Dict, +) -> List[DeferredEvent]: + """Get DeferredEvent list from the charm.""" + logger.error("DeferredEvent snapshotting not implemented yet.") + return [] + + def get_networks( target: JujuUnitName, model: Optional[str], @@ -646,6 +680,9 @@ def ifinclude(key, get_value, null_value): try: status, endpoints = get_status_and_endpoints(target, model) state = State( + juju_version=get_juju_version(), + unit_id=target.unit_id, + app_name=target.app_name, leader=get_leader(target, model), model=get_model(model), status=status, @@ -660,8 +697,6 @@ def ifinclude(key, get_value, null_value): ), [], ), - app_name=target.app_name, - unit_id=target.unit_id, containers=ifinclude( "k", lambda: get_containers( @@ -684,6 +719,34 @@ def ifinclude(key, get_value, null_value): ), [], ), + secrets=ifinclude( + "s", + lambda: get_secrets( + target, + model, + metadata, + relations=endpoints, + ), + [], + ), + deferred=ifinclude( + "d", + lambda: get_deferred_events( + target, + model, + metadata, + ), + [], + ), + stored_state=ifinclude( + "t", + lambda: get_stored_state( + target, + model, + metadata, + ), + [], + ), ) # todo: these errors should surface earlier. @@ -734,7 +797,7 @@ def snapshot( "--include", "-i", help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers ``n``: networks.", + "``r``: relation, ``c``: config, ``k``: containers, ``n``: networks, ``s``: secrets(!).", ), include_dead_relation_networks: bool = typer.Option( False, From dc39b158ae369a7f07b6d06af826aab9dc6e66e3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Mar 2023 16:31:43 +0100 Subject: [PATCH 148/546] degraded to warnings --- scenario/scripts/snapshot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index eb905d0b1..b1f85a614 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -181,7 +181,7 @@ def get_secrets( relations: Tuple[str, ...] = (), ) -> List[Secret]: """Get Secret list from the charm.""" - logger.error("Secrets snapshotting not implemented yet. Also, are you *sure*?") + logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") return [] @@ -191,7 +191,7 @@ def get_stored_state( metadata: Dict, ) -> List[StoredState]: """Get StoredState list from the charm.""" - logger.error("StoredState snapshotting not implemented yet.") + logger.warning("StoredState snapshotting not implemented yet.") return [] @@ -201,7 +201,7 @@ def get_deferred_events( metadata: Dict, ) -> List[DeferredEvent]: """Get DeferredEvent list from the charm.""" - logger.error("DeferredEvent snapshotting not implemented yet.") + logger.warning("DeferredEvent snapshotting not implemented yet.") return [] From c920fd776d701b7a79be7d1477632270af41a16b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 10:52:52 +0100 Subject: [PATCH 149/546] lxd models metadata --- scenario/runtime.py | 167 ++++++++-------- scenario/scripts/snapshot.py | 356 ++++++++++++++++++++--------------- scenario/state.py | 2 - 3 files changed, 280 insertions(+), 245 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 6a60319be..45ad9eb62 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -7,7 +7,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, List import yaml from ops.framework import _event_regex @@ -19,11 +19,9 @@ if TYPE_CHECKING: from ops.charm import CharmBase - from ops.framework import EventBase from ops.testing import CharmType - from scenario.state import Event, State, _CharmSpec - + from scenario.state import Event, State, _CharmSpec, DeferredEvent, StoredState _CT = TypeVar("_CT", bound=Type[CharmType]) logger = scenario_logger.getChild("runtime") @@ -37,11 +35,72 @@ class UncaughtCharmError(RuntimeError): """Error raised if the charm raises while handling the event being dispatched.""" -@dataclasses.dataclass -class RuntimeRunResult: - charm: "CharmBase" - scene: "Scene" - event: "EventBase" +class UnitStateDB: + """Represents the unit-state.db.""" + def __init__(self, db_path: Union[Path, str]): + self._db_path = db_path + self._state_file = Path(self._db_path) + + @property + def _has_state(self): + return self._state_file.exists() + + def _open_db(self) -> Optional[SQLiteStorage]: + if not self._has_state: + return None + return SQLiteStorage(self._state_file) + + def get_stored_state(self) -> List["StoredState"]: + from scenario.state import StoredState # avoid cyclic import + db = self._open_db() + + stored_state = [] + sst_regex = re.compile(_stored_state_regex) + for handle_path in db.list_snapshots(): + if match := sst_regex.match(handle_path): + stored_state_snapshot = db.load_snapshot(handle_path) + kwargs = match.groupdict() + sst = StoredState(content=stored_state_snapshot, **kwargs) + stored_state.append(sst) + + db.close() + return stored_state + + def get_deferred_events(self) -> List["DeferredEvent"]: + from scenario.state import DeferredEvent # avoid cyclic import + db = self._open_db() + + deferred = [] + event_regex = re.compile(_event_regex) + for handle_path in db.list_snapshots(): + if event_regex.match(handle_path): + notices = db.notices(handle_path) + for handle, owner, observer in notices: + event = DeferredEvent( + handle_path=handle, owner=owner, observer=observer + ) + deferred.append(event) + + db.close() + return deferred + + def apply_state(self, state: "State"): + """Add deferred and storedstate from this State instance to the storage.""" + db = self._open_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_state: + db.save_snapshot(stored_state.handle_path, stored_state.content) + + db.close() class Runtime: @@ -60,42 +119,12 @@ def __init__( # TODO consider cleaning up venv on __delete__, but ideally you should be # running this in a clean venv or a container anyway. - @staticmethod - def from_local_file( - local_charm_src: Path, - charm_cls_name: str, - ) -> "Runtime": - sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) - - ldict = {} - - try: - exec( - f"from charm import {charm_cls_name} as my_charm_type", globals(), ldict - ) - except ModuleNotFoundError as e: - raise RuntimeError( - f"Failed to load charm {charm_cls_name}. " - f"Probably some dependency is missing. " - f"Try `pip install -r {local_charm_src / 'requirements.txt'}`" - ) from e - - my_charm_type: Type["CharmBase"] = ldict["my_charm_type"] - return Runtime(_CharmSpec(my_charm_type)) # TODO add meta, options,... - @staticmethod def _cleanup_env(env): # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. for key in env: os.unsetenv(key) - @property - def unit_name(self): - meta = self._charm_spec.meta - if not meta: - return "local/0" - return meta["name"] + "/0" # todo allow override - def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names are always dashes? @@ -105,7 +134,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env = { "JUJU_VERSION": self._juju_version, - "JUJU_UNIT_NAME": self.unit_name, + "JUJU_UNIT_NAME": state.unit_name, "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{event.name}", "JUJU_MODEL_NAME": state.model.name, @@ -168,64 +197,20 @@ def virtual_charm_root(self): yield temppath @staticmethod - def _get_store(temporary_charm_root: Path): + def _get_state_db(temporary_charm_root: Path): charm_state_path = temporary_charm_root / ".unit-state.db" - store = SQLiteStorage(charm_state_path) - return store + return UnitStateDB(charm_state_path) def _initialize_storage(self, state: "State", temporary_charm_root: Path): """Before we start processing this event, expose the relevant parts of State through the storage.""" - store = self._get_store(temporary_charm_root) - - for event in state.deferred: - store.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 - store.save_snapshot(event.handle_path, event.snapshot_data) - - for stored_state in state.stored_state: - store.save_snapshot(stored_state.handle_path, stored_state.content) - - store.close() + store = self._get_state_db(temporary_charm_root) + store.apply_state(state) def _close_storage(self, state: "State", temporary_charm_root: Path): """Now that we're done processing this event, read the charm state and expose it via State.""" - from scenario.state import DeferredEvent, StoredState # avoid cyclic import - - store = self._get_store(temporary_charm_root) - - deferred = [] - stored_state = [] - event_regex = re.compile(_event_regex) - sst_regex = re.compile(_stored_state_regex) - for handle_path in store.list_snapshots(): - if event_regex.match(handle_path): - notices = store.notices(handle_path) - for handle, owner, observer in notices: - event = DeferredEvent( - handle_path=handle, owner=owner, observer=observer - ) - deferred.append(event) - - else: - # it's a StoredState. TODO: No other option, right? - stored_state_snapshot = store.load_snapshot(handle_path) - match = sst_regex.match(handle_path) - if not match: - logger.warning( - f"could not parse handle path {handle_path!r} as stored state" - ) - continue - - kwargs = match.groupdict() - sst = StoredState(content=stored_state_snapshot, **kwargs) - stored_state.append(sst) - - store.close() + store = self._get_state_db(temporary_charm_root) + deferred = store.get_deferred_events() + stored_state = store.get_stored_state() return state.replace(deferred=deferred, stored_state=stored_state) def exec( diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index b1f85a614..6d64aca3e 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -10,12 +10,14 @@ from pathlib import Path from subprocess import CalledProcessError, check_output, run from textwrap import dedent -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union, Literal import ops.pebble import typer import yaml +from ops.storage import SQLiteStorage +from scenario.runtime import UnitStateDB from scenario.state import ( Address, BindAddress, @@ -175,42 +177,22 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne def get_secrets( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + relations: Tuple[str, ...] = (), ) -> List[Secret]: """Get Secret list from the charm.""" logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") return [] -def get_stored_state( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, -) -> List[StoredState]: - """Get StoredState list from the charm.""" - logger.warning("StoredState snapshotting not implemented yet.") - return [] - - -def get_deferred_events( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, -) -> List[DeferredEvent]: - """Get DeferredEvent list from the charm.""" - logger.warning("DeferredEvent snapshotting not implemented yet.") - return [] - - def get_networks( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: """Get all Networks from this unit.""" logger.info("getting networks...") @@ -231,13 +213,38 @@ def get_networks( return networks +# ripped out of jhack +def get_substrate(model: str = None) -> Literal["k8s", "machine"]: + """Attempts to guess whether we're talking k8s or machine.""" + cmd = f'juju show-model{f" {model}" if model else ""} --format=json' + proc = run(cmd.split(), capture_output=True) + raw = proc.stdout.decode("utf-8") + model_info = json.loads(raw) + + if not model: + model = list(model_info)[0] + + model_type = model_info[model]["model-type"] + if model_type == "iaas": + return "machine" + elif model_type == "caas": + return "k8s" + else: + raise ValueError(f"unrecognized model type: {model_type}") + + def get_metadata(target: JujuUnitName, model: Optional[str]): """Get metadata.yaml from this target.""" logger.info("fetching metadata...") + if get_substrate(model) == 'lxd': + meta_path = f"./agents/unit-{target.normalized}/charm/metadata.yaml" + else: + meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" + raw_meta = _juju_ssh( target, - f"cat ./agents/unit-{target.normalized}/charm/metadata.yaml", + f"cat {meta_path}", model=model, ) return yaml.safe_load(raw_meta) @@ -251,7 +258,7 @@ class RemotePebbleClient: # figure out what it's for. def __init__( - self, container: str, target: JujuUnitName, model: Optional[str] = None + self, container: str, target: JujuUnitName, model: Optional[str] = None ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container @@ -286,19 +293,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -309,11 +316,11 @@ def get_checks( def fetch_file( - target: JujuUnitName, - remote_path: str, - container_name: str, - local_path: Path = None, - model: Optional[str] = None, + target: JujuUnitName, + remote_path: str, + container_name: str, + local_path: Path = None, + model: Optional[str] = None, ) -> Optional[str]: """Download a file from a live unit to a local path.""" # copied from jhack @@ -333,12 +340,12 @@ def fetch_file( def get_mounts( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Dict[str, Mount]: """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") @@ -397,12 +404,12 @@ def get_mounts( def get_container( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Container: """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) @@ -424,11 +431,11 @@ def get_container( def get_containers( - target: JujuUnitName, - model: Optional[str], - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> List[Container]: """Get all containers from this unit.""" fetch_files = fetch_files or {} @@ -452,14 +459,15 @@ def get_containers( return containers -def get_status_and_endpoints( - target: JujuUnitName, model: Optional[str] -) -> Tuple[Status, Tuple[str, ...]]: - """Parse `juju status` to get the Status data structure and some relation information.""" +def get_juju_status(model: Optional[str]) -> Dict: + """Return juju status as json.""" logger.info("getting status...") + return _juju_run(f"status --relations", model=model) - status = _juju_run(f"status --relations {target}", model=model) - app = status["applications"][target.app_name] + +def get_status(juju_status: Dict, target: JujuUnitName) -> Status: + """Parse `juju status` to get the Status data structure and some relation information.""" + app = juju_status["applications"][target.app_name] app_status_raw = app["application-status"] app_status = app_status_raw["current"], app_status_raw.get("message", "") @@ -467,9 +475,15 @@ def get_status_and_endpoints( unit_status_raw = app["units"][target]["workload-status"] unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") - relations = tuple(app["relations"].keys()) app_version = app.get("version", "") - return Status(app=app_status, unit=unit_status, app_version=app_version), relations + return Status(app=app_status, unit=unit_status, app_version=app_version) + + +def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: + """Parse `juju status` to get the relation names owned by the target.""" + app = juju_status["applications"][target.app_name] + relations = tuple(app["relations"].keys()) + return relations dispatch = { @@ -482,7 +496,7 @@ def get_status_and_endpoints( def get_config( - target: JujuUnitName, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: """Get config dict from target.""" @@ -493,10 +507,11 @@ def get_config( # dispatch table for builtin config options converters = { "string": str, - "integer": int, + "int": int, + "integer": int, # fixme: which one is it? "number": float, "boolean": lambda x: x == "true", - "attrs": lambda x: x, + "attrs": lambda x: x, # fixme: wot? } cfg = {} @@ -526,10 +541,10 @@ def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str] def get_relations( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: """Get the list of relations active for this target.""" logger.info("getting relations...") @@ -644,18 +659,43 @@ class FormatOption( pytest = "pytest" +def get_juju_version(juju_status: Dict) -> str: + """Get juju agent version from juju status output.""" + return juju_status['model']['version'] + + +class RemoteUnitStateDB(UnitStateDB): + """Represents a remote unit's state db.""" + def __init__(self, model: Optional[str], target: JujuUnitName): + self._tempfile = tempfile.NamedTemporaryFile() + super().__init__(self._tempfile.name) + + self._model = model + self._target = target + + def _fetch_state(self): + fetch_file(self._target, remote_path="./unit-state.db", container_name='charm', + local_path=self._state_file, model=self._model) + + def _open_db(self) -> Optional[SQLiteStorage]: + if not self._has_state: + self._fetch_state() + return super()._open_db() + + def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ): """see snapshot's docstring""" + try: target = JujuUnitName(target) except InvalidTargetUnitName: @@ -678,14 +718,17 @@ def ifinclude(key, get_value, null_value): exit(1) try: - status, endpoints = get_status_and_endpoints(target, model) + unit_state_db = RemoteUnitStateDB(model, target) + juju_status = get_juju_status(model) + endpoints = get_endpoints(juju_status, target) state = State( - juju_version=get_juju_version(), + juju_version=get_juju_version(juju_status), unit_id=target.unit_id, app_name=target.app_name, + leader=get_leader(target, model), model=get_model(model), - status=status, + status=get_status(juju_status, target=target), config=ifinclude("c", lambda: get_config(target, model), {}), relations=ifinclude( "r", @@ -730,22 +773,10 @@ def ifinclude(key, get_value, null_value): [], ), deferred=ifinclude( - "d", - lambda: get_deferred_events( - target, - model, - metadata, - ), - [], + "d", unit_state_db.get_deferred_events, [], ), stored_state=ifinclude( - "t", - lambda: get_stored_state( - target, - model, - metadata, - ), - [], + "t", unit_state_db.get_stored_state, [], ), ) @@ -777,41 +808,57 @@ def ifinclude(key, get_value, null_value): def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). " - "``json``: Outputs a Jsonified State object. Perfect for storage. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - "Pipe it to a file and fill in the blanks.", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers, ``n``: networks, ``s``: secrets(!).", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " + "else it will be ugly but valid python code). " + "``json``: Outputs a Jsonified State object. Perfect for storage. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + "Pipe it to a file and fill in the blanks.", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``n``: networks, ``s``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + fetch: Path = typer.Option( + None, + "--fetch", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be fetched from the existing containers.", + ), + # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. + output_dir: Path = typer.Option( + SNAPSHOT_TEMPDIR_ROOT, + "--output-dir", + help="Directory in which to store any files fetched as part of the state. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), ) -> State: """Gather and output the State of a remote target unit. @@ -819,6 +866,9 @@ def snapshot( Usage: snapshot myapp/0 > ./tests/scenario/case1.py """ + + fetch_files = json.loads(fetch.read_text()) if fetch else None + return _snapshot( target=target, model=model, @@ -826,6 +876,8 @@ def snapshot( include=include, include_juju_relation_data=include_juju_relation_data, include_dead_relation_networks=include_dead_relation_networks, + temp_dir_base_path=output_dir, + fetch_files=fetch_files, ) @@ -833,20 +885,20 @@ def snapshot( _snapshot.__doc__ = snapshot.__doc__ if __name__ == "__main__": - print(_snapshot("prom/0", model="foo", format=FormatOption.pytest)) - - # print( - # _snapshot( - # "traefik/0", - # model="cos", - # format=FormatOption.json, - # fetch_files={ - # "traefik": [ - # Path("/opt/traefik/juju/certificates.yaml"), - # Path("/opt/traefik/juju/certificate.cert"), - # Path("/opt/traefik/juju/certificate.key"), - # Path("/etc/traefik/traefik.yaml"), - # ] - # }, - # ) - # ) + # print(_snapshot("zoo/0", model="default", format=FormatOption.pytest)) + + print( + _snapshot( + "trfk/0", + model="foo", + format=FormatOption.json, + # fetch_files={ + # "traefik": [ + # Path("/opt/traefik/juju/certificates.yaml"), + # Path("/opt/traefik/juju/certificate.cert"), + # Path("/opt/traefik/juju/certificate.key"), + # Path("/etc/traefik/traefik.yaml"), + # ] + # }, + ) + ) diff --git a/scenario/state.py b/scenario/state.py index 5470f0ea3..f53532f9d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -4,8 +4,6 @@ import inspect import re import typing -from itertools import chain -from operator import attrgetter from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 From b14e5c7302449f08cdfd1c337a4dcaddcd81156c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 10:59:22 +0100 Subject: [PATCH 150/546] k8s models also work --- scenario/scripts/snapshot.py | 42 ++++++++++++++---------------------- scenario/state.py | 2 +- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 6d64aca3e..0f52a3282 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -213,39 +213,22 @@ def get_networks( return networks -# ripped out of jhack -def get_substrate(model: str = None) -> Literal["k8s", "machine"]: - """Attempts to guess whether we're talking k8s or machine.""" - cmd = f'juju show-model{f" {model}" if model else ""} --format=json' - proc = run(cmd.split(), capture_output=True) - raw = proc.stdout.decode("utf-8") - model_info = json.loads(raw) - - if not model: - model = list(model_info)[0] - - model_type = model_info[model]["model-type"] - if model_type == "iaas": - return "machine" - elif model_type == "caas": - return "k8s" - else: - raise ValueError(f"unrecognized model type: {model_type}") - - -def get_metadata(target: JujuUnitName, model: Optional[str]): +def get_metadata(target: JujuUnitName, model: Model): """Get metadata.yaml from this target.""" logger.info("fetching metadata...") - if get_substrate(model) == 'lxd': + if model.type == 'lxd': + meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" + elif model.type == 'kubernetes': meta_path = f"./agents/unit-{target.normalized}/charm/metadata.yaml" else: + logger.warning(f"unrecognized model type {model.type}: guessing it's machine-like.") meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" raw_meta = _juju_ssh( target, f"cat {meta_path}", - model=model, + model=model.name, ) return yaml.safe_load(raw_meta) @@ -712,7 +695,13 @@ def ifinclude(key, get_value, null_value): return get_value() return null_value - metadata = get_metadata(target, model) + try: + state_model = get_model(model) + except Exception: + logger.critical(f"unable to get Model from name {model}.", exc_info=True) + exit(1) + + metadata = get_metadata(target, state_model) if not metadata: logger.critical(f"could not fetch metadata from {target}.") exit(1) @@ -727,7 +716,7 @@ def ifinclude(key, get_value, null_value): app_name=target.app_name, leader=get_leader(target, model), - model=get_model(model), + model=state_model, status=get_status(juju_status, target=target), config=ifinclude("c", lambda: get_config(target, model), {}), relations=ifinclude( @@ -889,9 +878,10 @@ def snapshot( print( _snapshot( - "trfk/0", + "prom/0", model="foo", format=FormatOption.json, + include='td' # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), diff --git a/scenario/state.py b/scenario/state.py index f53532f9d..a4fae299f 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -204,7 +204,7 @@ def _random_model_name(): class Model(_DCBase): name: str = _random_model_name() uuid: str = str(uuid4()) - type: Literal["kubernetes", "lxd"] = "kubernetes" + type: Literal["kubernetes", "lxd"] = "kubernetes" # todo other options? # for now, proc mock allows you to map one command to one mocked output. From 1d16c05a5f100e29cab1a918372187f30bbfed93 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 11:01:05 +0100 Subject: [PATCH 151/546] docstrings --- scenario/runtime.py | 24 ++- scenario/scripts/snapshot.py | 274 +++++++++++++++++++---------------- 2 files changed, 169 insertions(+), 129 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 45ad9eb62..1e789408a 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -7,7 +7,17 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union, List +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Type, + TypeVar, + Union, +) import yaml from ops.framework import _event_regex @@ -21,7 +31,8 @@ from ops.charm import CharmBase from ops.testing import CharmType - from scenario.state import Event, State, _CharmSpec, DeferredEvent, StoredState + from scenario.state import DeferredEvent, Event, State, StoredState, _CharmSpec + _CT = TypeVar("_CT", bound=Type[CharmType]) logger = scenario_logger.getChild("runtime") @@ -37,21 +48,26 @@ class UncaughtCharmError(RuntimeError): class UnitStateDB: """Represents the unit-state.db.""" + def __init__(self, db_path: Union[Path, str]): self._db_path = db_path self._state_file = Path(self._db_path) @property def _has_state(self): + """Check that the state file exists.""" return self._state_file.exists() def _open_db(self) -> Optional[SQLiteStorage]: + """Open the db.""" if not self._has_state: return None return SQLiteStorage(self._state_file) def get_stored_state(self) -> List["StoredState"]: + """Load any StoredState data structures from the db.""" from scenario.state import StoredState # avoid cyclic import + db = self._open_db() stored_state = [] @@ -67,7 +83,9 @@ def get_stored_state(self) -> List["StoredState"]: return stored_state def get_deferred_events(self) -> List["DeferredEvent"]: + """Load any DeferredEvent data structures from the db.""" from scenario.state import DeferredEvent # avoid cyclic import + db = self._open_db() deferred = [] @@ -85,7 +103,7 @@ def get_deferred_events(self) -> List["DeferredEvent"]: return deferred def apply_state(self, state: "State"): - """Add deferred and storedstate from this State instance to the storage.""" + """Add DeferredEvent and StoredState from this State instance to the storage.""" db = self._open_db() for event in state.deferred: db.save_notice(event.handle_path, event.owner, event.observer) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 0f52a3282..fe6cf9cfc 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -10,7 +10,18 @@ from pathlib import Path from subprocess import CalledProcessError, check_output, run from textwrap import dedent -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union, Literal +from typing import ( + Any, + BinaryIO, + Dict, + Iterable, + List, + Literal, + Optional, + TextIO, + Tuple, + Union, +) import ops.pebble import typer @@ -177,10 +188,10 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne def get_secrets( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + relations: Tuple[str, ...] = (), ) -> List[Secret]: """Get Secret list from the charm.""" logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") @@ -188,11 +199,11 @@ def get_secrets( def get_networks( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: """Get all Networks from this unit.""" logger.info("getting networks...") @@ -217,12 +228,14 @@ def get_metadata(target: JujuUnitName, model: Model): """Get metadata.yaml from this target.""" logger.info("fetching metadata...") - if model.type == 'lxd': + if model.type == "lxd": meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" - elif model.type == 'kubernetes': + elif model.type == "kubernetes": meta_path = f"./agents/unit-{target.normalized}/charm/metadata.yaml" else: - logger.warning(f"unrecognized model type {model.type}: guessing it's machine-like.") + logger.warning( + f"unrecognized model type {model.type}: guessing it's machine-like." + ) meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" raw_meta = _juju_ssh( @@ -241,7 +254,7 @@ class RemotePebbleClient: # figure out what it's for. def __init__( - self, container: str, target: JujuUnitName, model: Optional[str] = None + self, container: str, target: JujuUnitName, model: Optional[str] = None ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container @@ -276,19 +289,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -299,11 +312,11 @@ def get_checks( def fetch_file( - target: JujuUnitName, - remote_path: str, - container_name: str, - local_path: Path = None, - model: Optional[str] = None, + target: JujuUnitName, + remote_path: str, + container_name: str, + local_path: Path = None, + model: Optional[str] = None, ) -> Optional[str]: """Download a file from a live unit to a local path.""" # copied from jhack @@ -323,12 +336,12 @@ def fetch_file( def get_mounts( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Dict[str, Mount]: """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") @@ -387,12 +400,12 @@ def get_mounts( def get_container( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> Container: """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) @@ -414,11 +427,11 @@ def get_container( def get_containers( - target: JujuUnitName, - model: Optional[str], - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: JujuUnitName, + model: Optional[str], + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ) -> List[Container]: """Get all containers from this unit.""" fetch_files = fetch_files or {} @@ -479,7 +492,7 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: def get_config( - target: JujuUnitName, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: """Get config dict from target.""" @@ -524,10 +537,10 @@ def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str] def get_relations( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: """Get the list of relations active for this target.""" logger.info("getting relations...") @@ -644,11 +657,12 @@ class FormatOption( def get_juju_version(juju_status: Dict) -> str: """Get juju agent version from juju status output.""" - return juju_status['model']['version'] + return juju_status["model"]["version"] class RemoteUnitStateDB(UnitStateDB): """Represents a remote unit's state db.""" + def __init__(self, model: Optional[str], target: JujuUnitName): self._tempfile = tempfile.NamedTemporaryFile() super().__init__(self._tempfile.name) @@ -657,8 +671,13 @@ def __init__(self, model: Optional[str], target: JujuUnitName): self._target = target def _fetch_state(self): - fetch_file(self._target, remote_path="./unit-state.db", container_name='charm', - local_path=self._state_file, model=self._model) + fetch_file( + self._target, + remote_path="./unit-state.db", + container_name="charm", + local_path=self._state_file, + model=self._model, + ) def _open_db(self) -> Optional[SQLiteStorage]: if not self._has_state: @@ -667,15 +686,15 @@ def _open_db(self) -> Optional[SQLiteStorage]: def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, ): """see snapshot's docstring""" @@ -714,7 +733,6 @@ def ifinclude(key, get_value, null_value): juju_version=get_juju_version(juju_status), unit_id=target.unit_id, app_name=target.app_name, - leader=get_leader(target, model), model=state_model, status=get_status(juju_status, target=target), @@ -762,10 +780,14 @@ def ifinclude(key, get_value, null_value): [], ), deferred=ifinclude( - "d", unit_state_db.get_deferred_events, [], + "d", + unit_state_db.get_deferred_events, + [], ), stored_state=ifinclude( - "t", unit_state_db.get_stored_state, [], + "t", + unit_state_db.get_stored_state, + [], ), ) @@ -797,57 +819,57 @@ def ifinclude(key, get_value, null_value): def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). " - "``json``: Outputs a Jsonified State object. Perfect for storage. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - "Pipe it to a file and fill in the blanks.", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``s``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - fetch: Path = typer.Option( - None, - "--fetch", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be fetched from the existing containers.", - ), - # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. - output_dir: Path = typer.Option( - SNAPSHOT_TEMPDIR_ROOT, - "--output-dir", - help="Directory in which to store any files fetched as part of the state. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " + "else it will be ugly but valid python code). " + "``json``: Outputs a Jsonified State object. Perfect for storage. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + "Pipe it to a file and fill in the blanks.", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``n``: networks, ``s``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + fetch: Path = typer.Option( + None, + "--fetch", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be fetched from the existing containers.", + ), + # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. + output_dir: Path = typer.Option( + SNAPSHOT_TEMPDIR_ROOT, + "--output-dir", + help="Directory in which to store any files fetched as part of the state. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), ) -> State: """Gather and output the State of a remote target unit. @@ -877,18 +899,18 @@ def snapshot( # print(_snapshot("zoo/0", model="default", format=FormatOption.pytest)) print( - _snapshot( - "prom/0", - model="foo", - format=FormatOption.json, - include='td' - # fetch_files={ - # "traefik": [ - # Path("/opt/traefik/juju/certificates.yaml"), - # Path("/opt/traefik/juju/certificate.cert"), - # Path("/opt/traefik/juju/certificate.key"), - # Path("/etc/traefik/traefik.yaml"), - # ] - # }, - ) + _snapshot( + "prom/0", + model="foo", + format=FormatOption.json, + include="td" + # fetch_files={ + # "traefik": [ + # Path("/opt/traefik/juju/certificates.yaml"), + # Path("/opt/traefik/juju/certificate.cert"), + # Path("/opt/traefik/juju/certificate.key"), + # Path("/etc/traefik/traefik.yaml"), + # ] + # }, ) + ) From 14d15c84e174961cd5c0128ddb20817b11b83bdc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 11:01:21 +0100 Subject: [PATCH 152/546] imports --- scenario/scripts/snapshot.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index fe6cf9cfc..6d53fffc3 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -16,7 +16,6 @@ Dict, Iterable, List, - Literal, Optional, TextIO, Tuple, @@ -33,7 +32,6 @@ Address, BindAddress, Container, - DeferredEvent, Model, Mount, Network, @@ -41,7 +39,6 @@ Secret, State, Status, - StoredState, ) logger = logging.getLogger("snapshot") From 873929334b1d688d19dc991eba966eb6ec8c41e6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 13:24:42 +0100 Subject: [PATCH 153/546] imports --- scenario/mocking.py | 5 ----- scenario/runtime.py | 5 ----- scenario/scripts/snapshot.py | 12 +----------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index a11087d3b..afd012f0b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,11 +1,9 @@ import datetime import pathlib import random -import urllib.request from io import StringIO from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union -import yaml from ops import pebble from ops.model import SecretInfo, SecretRotate, _ModelBackend from ops.pebble import Client, ExecError @@ -350,9 +348,6 @@ class _MockPebbleClient(_TestingPebbleClient): def __init__( self, socket_path: str, - opener: Optional[urllib.request.OpenerDirector] = None, - base_url: str = "http://localhost", - timeout: float = 5.0, *, state: "State", event: "Event", diff --git a/scenario/runtime.py b/scenario/runtime.py index 1e789408a..d8bf29609 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,9 +1,6 @@ -import dataclasses -import logging import marshal import os import re -import sys import tempfile from contextlib import contextmanager from pathlib import Path @@ -21,14 +18,12 @@ import yaml from ops.framework import _event_regex -from ops.log import JujuLogHandler from ops.storage import SQLiteStorage from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError if TYPE_CHECKING: - from ops.charm import CharmBase from ops.testing import CharmType from scenario.state import DeferredEvent, Event, State, StoredState, _CharmSpec diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 6d53fffc3..a82dcc769 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -10,17 +10,7 @@ from pathlib import Path from subprocess import CalledProcessError, check_output, run from textwrap import dedent -from typing import ( - Any, - BinaryIO, - Dict, - Iterable, - List, - Optional, - TextIO, - Tuple, - Union, -) +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union import ops.pebble import typer From b9fab1fb39a6ddd87fc7556cee76db75613ee657 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 13 Mar 2023 13:27:35 +0100 Subject: [PATCH 154/546] rstring --- scenario/runtime.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index d8bf29609..b9ee54b2d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -31,8 +31,7 @@ _CT = TypeVar("_CT", bound=Type[CharmType]) logger = scenario_logger.getChild("runtime") -# _stored_state_regex = "(.*)\/(\D+)\[(.*)\]" -_stored_state_regex = "((?P.*)\/)?(?P\D+)\[(?P.*)\]" +_stored_state_regex = r"((?P.*)\/)?(?P\D+)\[(?P.*)\]" RUNTIME_MODULE = Path(__file__).parent From fa7c0747f3659b70e1f2ad0e8c6a69a5ec090fcb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Mar 2023 09:10:42 +0100 Subject: [PATCH 155/546] consistency checker basic --- scenario/mocking.py | 6 +- scenario/runtime.py | 129 +++++++++++++++++++++++------- scenario/state.py | 100 +++++++++++++++-------- tests/test_consistency_checker.py | 5 ++ 4 files changed, 172 insertions(+), 68 deletions(-) create mode 100644 tests/test_consistency_checker.py diff --git a/scenario/mocking.py b/scenario/mocking.py index 8d5902f6e..0841c1629 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -48,7 +48,7 @@ def send_signal(self, sig: Union[int, str]): class _MockModelBackend(_ModelBackend): def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec"): - super().__init__(state.unit_name, state.model.name, state.model.uuid) + super().__init__() self._state = state self._event = event self._charm_spec = charm_spec @@ -94,11 +94,11 @@ def _generate_secret_id(): def relation_get(self, rel_id, obj_name, app): relation = self._get_relation_by_id(rel_id) - if app and obj_name == self._state.app_name: + if app and obj_name == self.app_name: return relation.local_app_data elif app: return relation.remote_app_data - elif obj_name == self._state.unit_name: + elif obj_name == self.unit_name: return relation.local_unit_data else: unit_id = obj_name.split("/")[-1] diff --git a/scenario/runtime.py b/scenario/runtime.py index 14ef3dde3..8041691b6 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,9 +1,6 @@ -import dataclasses -import logging import marshal import os import re -import shutil import sys import tempfile from contextlib import contextmanager @@ -13,16 +10,14 @@ Any, Callable, Dict, - List, Optional, Type, TypeVar, - Union, + Union, Iterable, ) import yaml from ops.framework import _event_regex -from ops.log import JujuLogHandler from ops.storage import SQLiteStorage from scenario.logger import logger as scenario_logger @@ -30,10 +25,9 @@ if TYPE_CHECKING: from ops.charm import CharmBase - from ops.framework import EventBase from ops.testing import CharmType - from scenario.state import Event, State, _CharmSpec + from scenario.state import Event, State, _CharmSpec, RELATION_EVENTS_SUFFIX, is_relation_event, is_workload_event _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -58,6 +52,77 @@ class DirtyVirtualCharmRootError(ScenarioRuntimeError): """Error raised when the runtime can't initialize the vroot without overwriting existing metadata files.""" +class InconsistentScenarioError(ScenarioRuntimeError): + """Error raised when the combination of state and event is inconsistent.""" + + +class ConsistencyChecker: + def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version: str): + self.state = state + self.event = event + self.charm_spec = charm_spec + self.juju_version = juju_version + + def run(self): + errors = [] + + for check in ( + self._check_containers, + self._check_config, + ): + try: + results = check() + except: + logger.error(f'error encountered processing check {check}', exc_info=True) + errors.append('unknown error; see the logs') + continue + + errors.extend(results) + + if errors: + err_fmt = '\n'.join(errors) + logger.error(f"Inconsistent scenario. The following errors were found: {err_fmt}") + raise InconsistentScenarioError(errors) + + def _check_config(self) -> Iterable[str]: + state_config = self.state.config + meta_config = (self.charm_spec.config or {}).get('options', {}) + errors = [] + + for key, value in state_config.items(): + if key not in meta_config: + errors.append(f"config option {key!r} in state.config but not specified in config.yaml.") + continue + + expected_type = meta_config[key].get('type', None) + + + return errors + + def _check_containers(self) -> Iterable[str]: + meta_containers = list(self.charm_spec.meta['containers']) + state_containers = [c.name for c in self.state.containers] + errors = [] + + # it's fine if you have containers in meta that are not in state.containers (yet), but it's not fine if: + # - you're processing a pebble-ready event and that container is not in state.containers or meta.containers + if is_workload_event(self.event.name): + evt_container_name = self.event.name[:-len('-pebble-ready')] + if evt_container_name not in meta_containers: + errors.append(f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not declared in the charm metadata") + if evt_container_name not in state_containers: + errors.append(f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not present in the state. It's odd, but consistent, if it cannot " + f"connect; but it should at least be there.") + + # - a container in state.containers is not in meta.containers + if diff := (set(state_containers).difference(set(meta_containers))): + errors.append(f"some containers declared in the state are not specified in metadata. That's not possible. " + f"Missing from metadata: {diff}.") + return errors + + class Runtime: """Charm runtime wrapper. @@ -65,10 +130,10 @@ class Runtime: """ def __init__( - self, - charm_spec: "_CharmSpec", - charm_root: Optional["PathLike"] = None, - juju_version: str = "3.0.0", + self, + charm_spec: "_CharmSpec", + charm_root: Optional["PathLike"] = None, + juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -78,8 +143,8 @@ def __init__( @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -285,11 +350,11 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): return state.replace(deferred=deferred, stored_state=stored_state) def exec( - self, - state: "State", - event: "Event", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + self, + state: "State", + event: "Event", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -298,6 +363,8 @@ def exec( This will set the environment up and call ops.main.main(). After that it's up to ops. """ + ConsistencyChecker(state, event).run() + charm_type = self._charm_spec.charm_type logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") @@ -352,16 +419,17 @@ def exec( def trigger( - state: "State", - event: Union["Event", str], - charm_type: Type["CharmType"], - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + juju_version: str = "3.0", ) -> "State": """Trigger a charm execution with an Event and a State. @@ -382,6 +450,7 @@ def trigger( If none is provided, we will search for a ``actions.yaml`` file in the charm root. :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). If none is provided, we will search for a ``config.yaml`` file in the charm root. + :arg juju_version: Juju agent version to simulate. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. @@ -408,7 +477,7 @@ def trigger( runtime = Runtime( charm_spec=spec, - juju_version=state.juju_version, + juju_version=juju_version, charm_root=charm_root, ) diff --git a/scenario/state.py b/scenario/state.py index abed657e3..e47ee3644 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -40,6 +40,18 @@ "-relation-departed", "-relation-created", } +STORAGE_EVENTS_SUFFIX = { + "-storage-detaching", + "-storage-attached", +} + +SECRET_EVENTS_SUFFIX = { + "-secret-changed", + "-secret-removed", + "-secret-rotate", + "-secret-expired", +} + META_EVENTS = { "CREATE_ALL_RELATIONS": "-relation-created", "BREAK_ALL_RELATIONS": "-relation-broken", @@ -48,6 +60,26 @@ } +def is_relation_event(name: str) -> bool: + """Whether the event name indicates that this is a relation event.""" + return any(map(name.endswith, RELATION_EVENTS_SUFFIX)) + + +def is_secret_event(name: str) -> bool: + """Whether the event name indicates that this is a secret event.""" + return any(map(name.endswith, SECRET_EVENTS_SUFFIX)) + + +def is_storage_event(name: str) -> bool: + """Whether the event name indicates that this is a storage event.""" + return any(map(name.endswith, STORAGE_EVENTS_SUFFIX)) + + +def is_workload_event(name: str) -> bool: + """Whether the event name indicates that this is a workload event.""" + return name.endswith('-pebble-ready') + + @dataclasses.dataclass class _DCBase: def replace(self, *args, **kwargs): @@ -459,10 +491,6 @@ def __eq__(self, other): ) return super().__eq__(other) - @classmethod - def _from_statusbase(cls, obj: StatusBase): - return _EntityStatus(obj.name, obj.message) - def __iter__(self): return iter([self.name, self.message]) @@ -470,6 +498,11 @@ def __repr__(self): return f"" +def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: + """Convert StatusBase to _EntityStatus.""" + return _EntityStatus(obj.name, obj.message) + + @dataclasses.dataclass class Status(_DCBase): app: _EntityStatus = _EntityStatus("unknown") @@ -482,7 +515,7 @@ def __post_init__(self): if isinstance(val, _EntityStatus): pass elif isinstance(val, StatusBase): - setattr(self, name, _EntityStatus._from_statusbase(val)) + setattr(self, name, _status_to_entitystatus(val)) elif isinstance(val, tuple): logger.warning( "Initializing Status.[app/unit] with Tuple[str, str] is deprecated " @@ -512,6 +545,13 @@ def handle_path(self): @dataclasses.dataclass class State(_DCBase): + """Represents the juju-owned portion of a unit's state. + + Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its lifecycle. + For example, status-get will return data from `State.status`, is-leader will return data from + `State.leader`, and so on. + """ + config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( default_factory=dict ) @@ -524,11 +564,6 @@ class State(_DCBase): juju_log: List[Tuple[str, str]] = dataclasses.field(default_factory=list) secrets: List[Secret] = dataclasses.field(default_factory=list) - # meta stuff: actually belongs in event data structure. - juju_version: str = "3.0.0" - unit_id: int = 0 - app_name: str = "local" - # represents the OF's event queue. These events will be emitted before the event being dispatched, # and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended @@ -539,11 +574,7 @@ class State(_DCBase): # todo: # actions? - @property - def unit_name(self): - return f"{self.app_name}/{self.unit_id}" - - def with_can_connect(self, container_name: str, can_connect: bool): + def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: return container.replace(can_connect=can_connect) @@ -552,12 +583,12 @@ def replacer(container: Container): ctrs = tuple(map(replacer, self.containers)) return self.replace(containers=ctrs) - def with_leadership(self, leader: bool): + def with_leadership(self, leader: bool) -> "State": return self.replace(leader=leader) - def with_unit_status(self, status: str, message: str): + def with_unit_status(self, status: StatusBase) -> "State": return self.replace( - status=dataclasses.replace(self.status, unit=(status, message)) + status=dataclasses.replace(self.status, unit=_status_to_entitystatus(status)) ) def get_container(self, container: Union[str, Container]) -> Container: @@ -596,7 +627,8 @@ def trigger( actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: Optional["PathLike"] = None, - ): + juju_version: str = "3.0", + ) -> "State": """Fluent API for trigger. See runtime.trigger's docstring.""" return _runtime_trigger( state=self, @@ -608,6 +640,7 @@ def trigger( actions=actions, config=config, charm_root=charm_root, + juju_version=juju_version ) trigger.__doc__ = _runtime_trigger.__doc__ @@ -696,6 +729,17 @@ def __post_init__(self): logger.warning(f"Only use underscores in event names. {self.name!r}") self.name = self.name.replace("-", "_") + if not self.relation and is_relation_event(self.name): + raise ValueError( + "cannot construct a relation event without the relation instance. " + "Please pass one." + ) + if not self.container and is_workload_event(self.name): + raise ValueError( + "cannot construct a workload event without the container instance. " + "Please pass one." + ) + def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) @@ -710,13 +754,13 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = {} - if self.container: + if is_workload_event(self.name): # this is a WorkloadEvent. The snapshot: snapshot_data = { "container_name": self.container.name, } - elif self.relation: + elif is_relation_event(self.name): # this is a RelationEvent. The snapshot: snapshot_data = { "relation_name": self.relation.endpoint, @@ -742,20 +786,6 @@ def deferred( ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - norm_evt = event.replace("_", "-") - - if not relation: - if any(map(norm_evt.endswith, RELATION_EVENTS_SUFFIX)): - raise ValueError( - "cannot construct a deferred relation event without the relation instance. " - "Please pass one." - ) - if not container and norm_evt.endswith("_pebble_ready"): - raise ValueError( - "cannot construct a deferred workload event without the container instance. " - "Please pass one." - ) - event = Event(event, relation=relation, container=container) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py new file mode 100644 index 000000000..c4d4c37fc --- /dev/null +++ b/tests/test_consistency_checker.py @@ -0,0 +1,5 @@ + + +from scenario.runtime import ConsistencyChecker +from scenario.state import State, Event, _CharmSpec + From 314675d073671ff0fd2f8b68d12dad9b6693ff91 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Mar 2023 09:11:40 +0100 Subject: [PATCH 156/546] removed dispatch --- scenario/scripts/snapshot.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index a82dcc769..75ec18117 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -469,14 +469,6 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: return relations -dispatch = { - "string": str, - "integer": int, - "number": float, - "boolean": lambda x: x == True, - "attrs": lambda x: x, -} - def get_config( target: JujuUnitName, model: Optional[str] From 9370e56e6e9baddb18a48ecb215d7543b5033a64 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Mar 2023 10:23:12 +0100 Subject: [PATCH 157/546] consistency checks and tests --- scenario/runtime.py | 192 +++++++++++++++++++------ scenario/state.py | 125 ++++++++-------- tests/test_consistency_checker.py | 152 +++++++++++++++++++- tests/test_e2e/test_builtin_scenes.py | 5 +- tests/test_e2e/test_config.py | 6 +- tests/test_e2e/test_play_assertions.py | 3 +- tests/test_e2e/test_relations.py | 1 + tests/test_e2e/test_rubbish_events.py | 13 +- 8 files changed, 383 insertions(+), 114 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 8041691b6..18f9b1c2f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,10 +10,12 @@ Any, Callable, Dict, + Iterable, Optional, + Tuple, Type, TypeVar, - Union, Iterable, + Union, ) import yaml @@ -27,7 +29,7 @@ from ops.charm import CharmBase from ops.testing import CharmType - from scenario.state import Event, State, _CharmSpec, RELATION_EVENTS_SUFFIX, is_relation_event, is_workload_event + from scenario.state import Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -57,69 +59,171 @@ class InconsistentScenarioError(ScenarioRuntimeError): class ConsistencyChecker: - def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version: str): + def __init__( + self, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version: str, + ): self.state = state self.event = event self.charm_spec = charm_spec - self.juju_version = juju_version + self.juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) def run(self): + if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"): + logger.info("skipping consistency checks.") + return + errors = [] for check in ( self._check_containers, self._check_config, + self._check_event, + self._check_secrets, ): try: results = check() - except: - logger.error(f'error encountered processing check {check}', exc_info=True) - errors.append('unknown error; see the logs') + except Exception as e: + logger.error( + f"error encountered processing check {check}", exc_info=True + ) + errors.append( + f"an unexpected error occurred processing check {check} ({e}); see the logs" + ) continue errors.extend(results) if errors: - err_fmt = '\n'.join(errors) - logger.error(f"Inconsistent scenario. The following errors were found: {err_fmt}") + err_fmt = "\n".join(errors) + logger.error( + f"Inconsistent scenario. The following errors were found: {err_fmt}" + ) raise InconsistentScenarioError(errors) + def _check_event(self) -> Iterable[str]: + from scenario.state import ( # avoid cycles + is_relation_event, + is_workload_event, + normalize_name, + ) + + event = self.event + errors = [] + if not event.relation and is_relation_event(event.name): + errors.append( + "cannot construct a relation event without the relation instance. " + "Please pass one." + ) + if is_relation_event(event.name) and not event.name.startswith( + normalize_name(event.relation.endpoint) + ): + errors.append( + f"relation event should start with relation endpoint name. {event.name} does " + f"not start with {event.relation.endpoint}." + ) + + if not event.container and is_workload_event(event.name): + errors.append( + "cannot construct a workload event without the container instance. " + "Please pass one." + ) + if is_workload_event(event.name) and not event.name.startswith( + normalize_name(event.container.name) + ): + errors.append( + f"workload event should start with container name. {event.name} does " + f"not start with {event.container.name}." + ) + return errors + def _check_config(self) -> Iterable[str]: state_config = self.state.config - meta_config = (self.charm_spec.config or {}).get('options', {}) + meta_config = (self.charm_spec.config or {}).get("options", {}) errors = [] for key, value in state_config.items(): if key not in meta_config: - errors.append(f"config option {key!r} in state.config but not specified in config.yaml.") + errors.append( + f"config option {key!r} in state.config but not specified in config.yaml." + ) + continue + + # todo unify with snapshot's when merged. + converters = { + "string": str, + "int": int, + "integer": int, # fixme: which one is it? + "number": float, + "boolean": bool, + "attrs": NotImplemented, # fixme: wot? + } + + expected_type_name = meta_config[key].get("type", None) + if not expected_type_name: + errors.append(f"config.yaml invalid; option {key!r} has no 'type'.") continue - expected_type = meta_config[key].get('type', None) + expected_type = converters.get(expected_type_name) + if not isinstance(value, expected_type): + errors.append( + f"config invalid; option {key!r} should be of type {expected_type} " + f"but is of type {type(value)}." + ) + + return errors + def _check_secrets(self) -> Iterable[str]: + from scenario.state import is_secret_event # avoid cycles + + errors = [] + if is_secret_event(self.event.name) and not self.state.secrets: + errors.append( + "the event being processed is a secret event; but the state has no secrets." + ) + + if ( + is_secret_event(self.event.name) or self.state.secrets + ) and self.juju_version < (3,): + errors.append( + f"secrets are not supported in the specified juju version {self.juju_version}. " + f"Should be at least 3.0." + ) return errors def _check_containers(self) -> Iterable[str]: - meta_containers = list(self.charm_spec.meta['containers']) + from scenario.state import is_workload_event # avoid cycles + + meta_containers = list(self.charm_spec.meta.get("containers", {})) state_containers = [c.name for c in self.state.containers] errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's not fine if: # - you're processing a pebble-ready event and that container is not in state.containers or meta.containers if is_workload_event(self.event.name): - evt_container_name = self.event.name[:-len('-pebble-ready')] + evt_container_name = self.event.name[: -len("-pebble-ready")] if evt_container_name not in meta_containers: - errors.append(f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not declared in the charm metadata") + errors.append( + f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not declared in the charm metadata" + ) if evt_container_name not in state_containers: - errors.append(f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not present in the state. It's odd, but consistent, if it cannot " - f"connect; but it should at least be there.") + errors.append( + f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not present in the state. It's odd, but consistent, if it cannot " + f"connect; but it should at least be there." + ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): - errors.append(f"some containers declared in the state are not specified in metadata. That's not possible. " - f"Missing from metadata: {diff}.") + errors.append( + f"some containers declared in the state are not specified in metadata. That's not possible. " + f"Missing from metadata: {diff}." + ) return errors @@ -130,10 +234,10 @@ class Runtime: """ def __init__( - self, - charm_spec: "_CharmSpec", - charm_root: Optional["PathLike"] = None, - juju_version: str = "3.0.0", + self, + charm_spec: "_CharmSpec", + charm_root: Optional["PathLike"] = None, + juju_version: str = "3.0.0", ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -143,8 +247,8 @@ def __init__( @staticmethod def from_local_file( - local_charm_src: Path, - charm_cls_name: str, + local_charm_src: Path, + charm_cls_name: str, ) -> "Runtime": sys.path.extend((str(local_charm_src / "src"), str(local_charm_src / "lib"))) @@ -350,11 +454,11 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): return state.replace(deferred=deferred, stored_state=stored_state) def exec( - self, - state: "State", - event: "Event", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + self, + state: "State", + event: "Event", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -363,7 +467,7 @@ def exec( This will set the environment up and call ops.main.main(). After that it's up to ops. """ - ConsistencyChecker(state, event).run() + ConsistencyChecker(state, event, self._charm_spec, self._juju_version).run() charm_type = self._charm_spec.charm_type logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") @@ -419,17 +523,17 @@ def exec( def trigger( - state: "State", - event: Union["Event", str], - charm_type: Type["CharmType"], - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional[Dict["PathLike", "PathLike"]] = None, - juju_version: str = "3.0", + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + juju_version: str = "3.0", ) -> "State": """Trigger a charm execution with an Event and a State. diff --git a/scenario/state.py b/scenario/state.py index e47ee3644..eda1f9e0c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -62,22 +62,22 @@ def is_relation_event(name: str) -> bool: """Whether the event name indicates that this is a relation event.""" - return any(map(name.endswith, RELATION_EVENTS_SUFFIX)) + return any(map(name.replace("_", "-").endswith, RELATION_EVENTS_SUFFIX)) def is_secret_event(name: str) -> bool: """Whether the event name indicates that this is a secret event.""" - return any(map(name.endswith, SECRET_EVENTS_SUFFIX)) + return any(map(name.replace("_", "-").endswith, SECRET_EVENTS_SUFFIX)) def is_storage_event(name: str) -> bool: """Whether the event name indicates that this is a storage event.""" - return any(map(name.endswith, STORAGE_EVENTS_SUFFIX)) + return any(map(name.replace("_", "-").endswith, STORAGE_EVENTS_SUFFIX)) def is_workload_event(name: str) -> bool: """Whether the event name indicates that this is a workload event.""" - return name.endswith('-pebble-ready') + return name.replace("_", "-").endswith("-pebble-ready") @dataclasses.dataclass @@ -122,7 +122,7 @@ def changed_event(self): raise ValueError( "This unit will never receive secret-changed for a secret it owns." ) - return Event(name="secret-changed", secret=self) + return Event(name="secret_changed", secret=self) # owner-only events @property @@ -132,7 +132,7 @@ def rotate_event(self): raise ValueError( "This unit will never receive secret-rotate for a secret it does not own." ) - return Event(name="secret-rotate", secret=self) + return Event(name="secret_rotate", secret=self) @property def expired_event(self): @@ -141,7 +141,7 @@ def expired_event(self): raise ValueError( "This unit will never receive secret-expire for a secret it does not own." ) - return Event(name="secret-expire", secret=self) + return Event(name="secret_expire", secret=self) @property def remove_event(self): @@ -150,15 +150,15 @@ def remove_event(self): raise ValueError( "This unit will never receive secret-removed for a secret it does not own." ) - return Event(name="secret-removed", secret=self) + return Event(name="secret_removed", secret=self) _RELATION_IDS_CTR = 0 -def _normalize_event_name(s: str): +def normalize_name(s: str): """Event names need underscores instead of dashes.""" - return s.replace('-', '_') + return s.replace("-", "_") @dataclasses.dataclass @@ -211,36 +211,36 @@ def __post_init__(self): def changed_event(self): """Sugar to generate a -relation-changed event.""" return Event( - name=_normalize_event_name(self.endpoint + "-relation-changed"), - relation=self) + name=normalize_name(self.endpoint + "-relation-changed"), relation=self + ) @property def joined_event(self): """Sugar to generate a -relation-joined event.""" return Event( - name=_normalize_event_name(self.endpoint + "-relation-joined"), - relation=self) + name=normalize_name(self.endpoint + "-relation-joined"), relation=self + ) @property def created_event(self): """Sugar to generate a -relation-created event.""" return Event( - name=_normalize_event_name(self.endpoint + "-relation-created"), - relation=self) + name=normalize_name(self.endpoint + "-relation-created"), relation=self + ) @property def departed_event(self): """Sugar to generate a -relation-departed event.""" return Event( - name=_normalize_event_name(self.endpoint + "-relation-departed"), - relation=self) + name=normalize_name(self.endpoint + "-relation-departed"), relation=self + ) @property def broken_event(self): """Sugar to generate a -relation-broken event.""" return Event( - name=_normalize_event_name(self.endpoint + "-relation-broken"), - relation=self) + name=normalize_name(self.endpoint + "-relation-broken"), relation=self + ) def _random_model_name(): @@ -393,8 +393,7 @@ def pebble_ready_event(self): "you **can** fire pebble-ready while the container cannot connect, " "but that's most likely not what you want." ) - return Event(name=_normalize_event_name(self.name + "-pebble-ready"), - container=self) + return Event(name=normalize_name(self.name + "-pebble-ready"), container=self) @dataclasses.dataclass @@ -441,15 +440,15 @@ def hook_tool_output_fmt(self): @classmethod def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + cls, + name, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( @@ -505,8 +504,10 @@ def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: @dataclasses.dataclass class Status(_DCBase): - app: _EntityStatus = _EntityStatus("unknown") - unit: _EntityStatus = _EntityStatus("unknown") + + # the real type of these is _EntityStatus, but the user needs not know about it. + app: Union[StatusBase] = _EntityStatus("unknown") + unit: Union[StatusBase] = _EntityStatus("unknown") app_version: str = "" def __post_init__(self): @@ -588,7 +589,9 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return self.replace( - status=dataclasses.replace(self.status, unit=_status_to_entitystatus(status)) + status=dataclasses.replace( + self.status, unit=_status_to_entitystatus(status) + ) ) def get_container(self, container: Union[str, Container]) -> Container: @@ -616,18 +619,18 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = "3.0", + self, + event: Union["Event", str], + charm_type: Type["CharmType"], + # callbacks + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional["PathLike"] = None, + juju_version: str = "3.0", ) -> "State": """Fluent API for trigger. See runtime.trigger's docstring.""" return _runtime_trigger( @@ -640,7 +643,7 @@ def trigger( actions=actions, config=config, charm_root=charm_root, - juju_version=juju_version + juju_version=juju_version, ) trigger.__doc__ = _runtime_trigger.__doc__ @@ -727,18 +730,7 @@ class Event(_DCBase): def __post_init__(self): if "-" in self.name: logger.warning(f"Only use underscores in event names. {self.name!r}") - self.name = self.name.replace("-", "_") - - if not self.relation and is_relation_event(self.name): - raise ValueError( - "cannot construct a relation event without the relation instance. " - "Please pass one." - ) - if not self.container and is_workload_event(self.name): - raise ValueError( - "cannot construct a workload event without the container instance. " - "Please pass one." - ) + self.name = normalize_name(self.name) def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" @@ -761,6 +753,10 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: } elif is_relation_event(self.name): + if not self.relation: + raise ValueError( + "this is a relation event; expected relation attribute" + ) # this is a RelationEvent. The snapshot: snapshot_data = { "relation_name": self.relation.endpoint, @@ -778,11 +774,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def deferred( - event: Union[str, Event], - handler: Callable, - event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + event: Union[str, Event], + handler: Callable, + event_id: int = 1, + relation: "Relation" = None, + container: "Container" = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): @@ -814,6 +810,7 @@ def _derive_args(event_name: str): return tuple(args) + # todo: consider # def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: # pass diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index c4d4c37fc..71cc09fde 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,5 +1,153 @@ +import pytest +from ops.charm import CharmBase +from scenario.runtime import ConsistencyChecker, InconsistentScenarioError +from scenario.state import ( + RELATION_EVENTS_SUFFIX, + Container, + Event, + Relation, + Secret, + State, + _CharmSpec, +) -from scenario.runtime import ConsistencyChecker -from scenario.state import State, Event, _CharmSpec +class MyCharm(CharmBase): + pass + + +def assert_inconsistent(state, event, spec, juju_version="3.0"): + cc = ConsistencyChecker(state, event, spec, juju_version) + with pytest.raises(InconsistentScenarioError): + cc.run() + + +def assert_consistent(state, event, spec, juju_version="3.0"): + cc = ConsistencyChecker(state, event, spec, juju_version) + cc.run() + + +def test_base(): + state = State() + event = Event("update-status") + spec = _CharmSpec(MyCharm, {}) + assert_consistent(state, event, spec) + + +def test_workload_event_without_container(): + assert_inconsistent( + State(), + Event("foo-pebble-ready", container=Container("foo")), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(containers=[Container("foo")]), + Event("foo-pebble-ready", container=Container("foo")), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + + +def test_container_meta_mismatch(): + assert_inconsistent( + State(containers=[Container("bar")]), + Event("foo"), + _CharmSpec(MyCharm, {"containers": {"baz": {}}}), + ) + assert_consistent( + State(containers=[Container("bar")]), + Event("foo"), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + + +def test_container_in_state_but_no_container_in_meta(): + assert_inconsistent( + State(containers=[Container("bar")]), Event("foo"), _CharmSpec(MyCharm, {}) + ) + assert_consistent( + State(containers=[Container("bar")]), + Event("foo"), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + + +def test_evt_bad_container_name(): + assert_inconsistent( + State(), + Event("foo-pebble-ready", container=Container("bar")), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(containers=[Container("bar")]), + Event("bar-pebble-ready", container=Container("bar")), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + + +@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +def test_evt_bad_relation_name(suffix): + assert_inconsistent( + State(), + Event(f"foo{suffix}", relation=Relation("bar")), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(relations=[Relation("bar")]), + Event(f"bar{suffix}", relation=Relation("bar")), + _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), + ) + + +@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +def test_evt_no_relation(suffix): + assert_inconsistent(State(), Event("foo-relation-created"), _CharmSpec(MyCharm, {})) + assert_consistent( + State(relations=[Relation("bar")]), + Event(f"bar{suffix}", relation=Relation("bar")), + _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), + ) + + +def test_config_key_missing_from_meta(): + assert_inconsistent( + State(config={"foo": True}), Event("bar"), _CharmSpec(MyCharm, {}) + ) + assert_consistent( + State(config={"foo": True}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), + ) + + +def test_bad_config_option_type(): + assert_inconsistent( + State(config={"foo": True}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}), + ) + assert_consistent( + State(config={"foo": True}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), + ) + + +@pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99")) +def test_secrets_jujuv_bad(bad_v): + assert_inconsistent( + State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), + Event("bar"), + _CharmSpec(MyCharm, {}), + bad_v, + ) + + +@pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) +def test_secrets_jujuv_bad(good_v): + assert_consistent( + State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), + Event("bar"), + _CharmSpec(MyCharm, {}), + good_v, + ) diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index 195f29a22..2d64a291f 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -45,6 +45,9 @@ def test_builtin_scenes(mycharm): def test_builtin_scenes_template(mycharm): mycharm.require_config = True check_builtin_sequences( - mycharm, meta={"name": "foo"}, template_state=State(config={"foo": "bar"}) + mycharm, + meta={"name": "foo"}, + config={"options": {"foo": {"type": "string"}}}, + template_state=State(config={"foo": "bar"}), ) assert CHARM_CALLED == 12 diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index 55a6a15d9..b2ed07ba0 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -32,6 +32,7 @@ def check_cfg(charm: CharmBase): "update-status", mycharm, meta={"name": "foo"}, + config={"options": {"foo": {"type": "string"}, "baz": {"type": "integer"}}}, post_event=check_cfg, ) @@ -49,7 +50,10 @@ def check_cfg(charm: CharmBase): mycharm, meta={"name": "foo"}, config={ - "options": {"baz": {"type": "integer", "default": 2}}, + "options": { + "foo": {"type": "string"}, + "baz": {"type": "integer", "default": 2}, + }, }, post_event=check_cfg, ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 5f1cfeffc..670dd9d76 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -44,12 +44,13 @@ def post_event(charm): mycharm._call = call initial_state = State( - config={"foo": "bar"}, leader=True, status=Status(unit=("blocked", "foo")) + config={"foo": "bar"}, leader=True, status=Status(unit=BlockedStatus("foo")) ) out = initial_state.trigger( charm_type=mycharm, meta={"name": "foo"}, + config={"options": {"foo": {"type": "string"}}}, event="start", post_event=post_event, pre_event=pre_event, diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 4a1507a4d..a81c487ac 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -62,6 +62,7 @@ def pre_event(charm: CharmBase): "zoo": {"interface": "zoo"}, }, }, + config={"options": {"foo": {"type": "string"}}}, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index e1e21575f..441836339 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -1,9 +1,11 @@ +import os + import pytest from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource, Framework, Object from scenario.ops_main_mock import NoObserverError -from scenario.state import State +from scenario.state import Container, State class QuxEvent(EventBase): @@ -44,8 +46,17 @@ def _on_event(self, e): @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): with pytest.raises(NoObserverError): + + if evt_name.startswith("kazoo"): + os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" + # will whine about the container not being in state and meta; but if we put the container in meta, + # it will actually register an event! + State().trigger(evt_name, mycharm, meta={"name": "foo"}) + if evt_name.startswith("kazoo"): + os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "false" + @pytest.mark.parametrize("evt_name", ("qux",)) def test_custom_events_pass(mycharm, evt_name): From d9a2681d234064b9e0de63301d54bdc4e4ef0df7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Mar 2023 10:23:32 +0100 Subject: [PATCH 158/546] removed todos --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 3792f55af..ca1d85794 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,5 @@ be overwritten by Scenario, and therefore ignored. # TODOS: -- State-State consistency checks. -- State-Metadata consistency checks. - When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. - Recorder From ba4768e69040c0af260ca9e139cabdcfa003db16 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 14 Mar 2023 10:27:44 +0100 Subject: [PATCH 159/546] docstrings --- scenario/runtime.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scenario/runtime.py b/scenario/runtime.py index 18f9b1c2f..c7af72661 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -59,6 +59,14 @@ class InconsistentScenarioError(ScenarioRuntimeError): class ConsistencyChecker: + """This class is responsible for validating the combination of a state, an event, a charm spec, and a juju version. + + Upon calling .run(), it performs a series of checks that validate that the state is consistent with itself, with + the event being emitted, the charm metadata, etc... + For example, if someone tries to emit a foo-pebble-ready but there is no container 'foo' in metadata or in State, + this is where we surface the issue. + """ + def __init__( self, state: "State", @@ -72,6 +80,7 @@ def __init__( self.juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) def run(self): + """Run all consistency checks and raise if any of them fails.""" if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"): logger.info("skipping consistency checks.") return @@ -105,6 +114,7 @@ def run(self): raise InconsistentScenarioError(errors) def _check_event(self) -> Iterable[str]: + """Check the internal consistency of the Event data structure.""" from scenario.state import ( # avoid cycles is_relation_event, is_workload_event, @@ -141,6 +151,7 @@ def _check_event(self) -> Iterable[str]: return errors def _check_config(self) -> Iterable[str]: + """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" state_config = self.state.config meta_config = (self.charm_spec.config or {}).get("options", {}) errors = [] @@ -177,6 +188,7 @@ def _check_config(self) -> Iterable[str]: return errors def _check_secrets(self) -> Iterable[str]: + """Check the consistency of Secret-related stuff.""" from scenario.state import is_secret_event # avoid cycles errors = [] @@ -196,6 +208,7 @@ def _check_secrets(self) -> Iterable[str]: return errors def _check_containers(self) -> Iterable[str]: + """Check the consistency of state.containers vs. charm_spec.meta (metadata.yaml/containers).""" from scenario.state import is_workload_event # avoid cycles meta_containers = list(self.charm_spec.meta.get("containers", {})) From 224b0dcc6e1eba469d5093f5736fb8de7515e9a6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 11:35:31 +0100 Subject: [PATCH 160/546] pr comments --- README.md | 23 +- scenario/__init__.py | 1 + scenario/consistency_checker.py | 212 +++++++++++++++++++ scenario/runtime.py | 199 +---------------- scenario/state.py | 126 +++++++---- tests/test_consistency_checker.py | 21 +- tests/test_e2e/test_custom_event_triggers.py | 46 +++- tests/test_e2e/test_rubbish_events.py | 25 ++- 8 files changed, 396 insertions(+), 257 deletions(-) create mode 100644 scenario/consistency_checker.py diff --git a/README.md b/README.md index 59fab65a4..52a3d4561 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Ops-Scenario +Scenario ============ This is a state transition testing framework for Operator Framework charms. @@ -520,6 +520,25 @@ as verify its contents after the charm has run. Do keep in mind that the metadat be overwritten by Scenario, and therefore ignored. +# Consistency checks + +A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. +For example, Juju can't emit a `foo-relation-changed` event on your charm unless your charm has declared a `foo` relation +endpoint in its `metadata.yaml`. If that happens, that's a juju bug. +Scenario however assumes that Juju is bug-free, therefore, so far as we're concerned, that can't happen, and therefore we +help you verify that the scenarios you create are consistent and raise an exception if that isn't so. + +That happens automatically behind the scenes whenever you trigger an event; `scenario.consistency_checker.check_consistency` +is called and verifies that the scenario makes sense. + +## Caveats: +- False positives: not all checks are implemented yet; more will come. +- False negatives: it is possible that a scenario you know to be consistent is seen as inconsistent. That is probably a bug in the consistency checker itself, please report it. +- Inherent limitations: if you have a custom event whose name conflicts with a builtin one, the consistency constraints of the builtin one will apply. For example: if you decide to name your custom event `bar-pebble-ready`, but you are working on a machine charm or don't have either way a `bar` container in your `metadata.yaml`, Scenario will flag that as inconsistent. + +## Bypassing the checker +If you have a clear false negative, are explicitly testing 'edge', inconsistent situations, or for whatever reason the checker is in your way, you can set the `SCENARIO_SKIP_CONSISTENCY_CHECKS` envvar and skip it altogether. Hopefully you don't need that. + + # TODOS: -- When ops supports namespace packages, allow `pip install ops[scenario]` and nest the whole package under `/ops`. - Recorder diff --git a/scenario/__init__.py b/scenario/__init__.py index 753885e0d..1d71eb28c 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,2 +1,3 @@ +from scenario.consistency_checker import check_consistency from scenario.runtime import trigger from scenario.state import * diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py new file mode 100644 index 000000000..0c1b6759a --- /dev/null +++ b/scenario/consistency_checker.py @@ -0,0 +1,212 @@ +import os +from typing import TYPE_CHECKING, Iterable, NamedTuple, Tuple + +from scenario.runtime import InconsistentScenarioError +from scenario.runtime import logger as scenario_logger +from scenario.state import _CharmSpec, normalize_name + +if TYPE_CHECKING: + from scenario.state import Event, State + +logger = scenario_logger.getChild("consistency_checker") + + +class Results(NamedTuple): + """Consistency checkers return type.""" + + errors: Iterable[str] + warnings: Iterable[str] + + +def check_consistency( + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version: str, +): + """Validate the combination of a state, an event, a charm spec, and a juju version. + + When invoked, it performs a series of checks that validate that the state is consistent with itself, with + the event being emitted, the charm metadata, etc... + + This function performs some basic validation of the combination of inputs that goes into a scenario test and + determines if the scenario is a realistic/plausible/consistent one. + + A scenario is inconsistent if it can practically never occur because it contradicts the juju model. + For example: juju guarantees that upon calling config-get, a charm will only ever get the keys it declared + in its config.yaml. So a State declaring some config keys that are not in the charm's config.yaml is nonsense, + and the combination of the two is inconsistent. + """ + juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) + + if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"): + logger.info("skipping consistency checks.") + return + + errors = [] + warnings = [] + + for check in ( + check_containers_consistency, + check_config_consistency, + check_event_consistency, + check_secrets_consistency, + ): + results = check( + state=state, event=event, charm_spec=charm_spec, juju_version=juju_version + ) + errors.extend(results.errors) + warnings.extend(results.warnings) + + if errors: + err_fmt = "\n".join(errors) + raise InconsistentScenarioError( + f"Inconsistent scenario. The following errors were found: {err_fmt}" + ) + if warnings: + err_fmt = "\n".join(warnings) + logger.warning( + f"This scenario is probably inconsistent. Double check, and ignore this warning if you're sure. " + f"The following warnings were found: {err_fmt}" + ) + + +def check_event_consistency( + *, event: "Event", charm_spec: "_CharmSpec", **_kwargs +) -> Results: + """Check the internal consistency of the Event data structure. + + For example, it checks that a relation event has a relation instance, and that the relation endpoint + name matches the event prefix. + """ + errors = [] + warnings = [] + + # custom event: can't make assumptions about its name and its semantics + if not event._is_builtin_event(charm_spec): # noqa + warnings.append( + "this is a custom event; if its name makes it look like a builtin one " + "(e.g. a relation event, or a workload event), you might get some false-negative " + "consistency checks." + ) + + if event._is_relation_event: # noqa + if not event.relation: + errors.append( + "cannot construct a relation event without the relation instance. " + "Please pass one." + ) + else: + if not event.name.startswith(normalize_name(event.relation.endpoint)): + errors.append( + f"relation event should start with relation endpoint name. {event.name} does " + f"not start with {event.relation.endpoint}." + ) + + if event._is_workload_event: # noqa + if not event.container: + errors.append( + "cannot construct a workload event without the container instance. " + "Please pass one." + ) + else: + if not event.name.startswith(normalize_name(event.container.name)): + errors.append( + f"workload event should start with container name. {event.name} does " + f"not start with {event.container.name}." + ) + return Results(errors, warnings) + + +def check_config_consistency( + *, state: "State", charm_spec: "_CharmSpec", **_kwargs +) -> Results: + """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" + state_config = state.config + meta_config = (charm_spec.config or {}).get("options", {}) + errors = [] + + for key, value in state_config.items(): + if key not in meta_config: + errors.append( + f"config option {key!r} in state.config but not specified in config.yaml." + ) + continue + + # todo unify with snapshot's when merged. + converters = { + "string": str, + "int": int, + "integer": int, # fixme: which one is it? + "number": float, + "boolean": bool, + "attrs": NotImplemented, # fixme: wot? + } + + expected_type_name = meta_config[key].get("type", None) + if not expected_type_name: + errors.append(f"config.yaml invalid; option {key!r} has no 'type'.") + continue + + expected_type = converters.get(expected_type_name) + if not isinstance(value, expected_type): + errors.append( + f"config invalid; option {key!r} should be of type {expected_type} " + f"but is of type {type(value)}." + ) + + return Results(errors, []) + + +def check_secrets_consistency( + *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs +) -> Results: + """Check the consistency of Secret-related stuff.""" + errors = [] + if not event._is_secret_event: # noqa + return Results(errors, []) + + if not state.secrets: + errors.append( + "the event being processed is a secret event; but the state has no secrets." + ) + elif juju_version < (3,): + errors.append( + f"secrets are not supported in the specified juju version {juju_version}. " + f"Should be at least 3.0." + ) + + return Results(errors, []) + + +def check_containers_consistency( + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs +) -> Results: + """Check the consistency of `state.containers` vs. `charm_spec.meta` (metadata.yaml/containers).""" + meta_containers = list(charm_spec.meta.get("containers", {})) + state_containers = [c.name for c in state.containers] + errors = [] + + # it's fine if you have containers in meta that are not in state.containers (yet), but it's not fine if: + # - you're processing a pebble-ready event and that container is not in state.containers or meta.containers + if event._is_workload_event: # noqa + evt_container_name = event.name[: -len("-pebble-ready")] + if evt_container_name not in meta_containers: + errors.append( + f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not declared in the charm metadata" + ) + if evt_container_name not in state_containers: + errors.append( + f"the event being processed concerns container {evt_container_name!r}, but a container " + f"with that name is not present in the state. It's odd, but consistent, if it cannot " + f"connect; but it should at least be there." + ) + + # - a container in state.containers is not in meta.containers + if diff := (set(state_containers).difference(set(meta_containers))): + errors.append( + f"some containers declared in the state are not specified in metadata. That's not possible. " + f"Missing from metadata: {diff}." + ) + return Results(errors, []) diff --git a/scenario/runtime.py b/scenario/runtime.py index c7af72661..bf3c371ec 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -5,18 +5,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Iterable, - Optional, - Tuple, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union import yaml from ops.framework import _event_regex @@ -58,188 +47,6 @@ class InconsistentScenarioError(ScenarioRuntimeError): """Error raised when the combination of state and event is inconsistent.""" -class ConsistencyChecker: - """This class is responsible for validating the combination of a state, an event, a charm spec, and a juju version. - - Upon calling .run(), it performs a series of checks that validate that the state is consistent with itself, with - the event being emitted, the charm metadata, etc... - For example, if someone tries to emit a foo-pebble-ready but there is no container 'foo' in metadata or in State, - this is where we surface the issue. - """ - - def __init__( - self, - state: "State", - event: "Event", - charm_spec: "_CharmSpec", - juju_version: str, - ): - self.state = state - self.event = event - self.charm_spec = charm_spec - self.juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) - - def run(self): - """Run all consistency checks and raise if any of them fails.""" - if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"): - logger.info("skipping consistency checks.") - return - - errors = [] - - for check in ( - self._check_containers, - self._check_config, - self._check_event, - self._check_secrets, - ): - try: - results = check() - except Exception as e: - logger.error( - f"error encountered processing check {check}", exc_info=True - ) - errors.append( - f"an unexpected error occurred processing check {check} ({e}); see the logs" - ) - continue - - errors.extend(results) - - if errors: - err_fmt = "\n".join(errors) - logger.error( - f"Inconsistent scenario. The following errors were found: {err_fmt}" - ) - raise InconsistentScenarioError(errors) - - def _check_event(self) -> Iterable[str]: - """Check the internal consistency of the Event data structure.""" - from scenario.state import ( # avoid cycles - is_relation_event, - is_workload_event, - normalize_name, - ) - - event = self.event - errors = [] - if not event.relation and is_relation_event(event.name): - errors.append( - "cannot construct a relation event without the relation instance. " - "Please pass one." - ) - if is_relation_event(event.name) and not event.name.startswith( - normalize_name(event.relation.endpoint) - ): - errors.append( - f"relation event should start with relation endpoint name. {event.name} does " - f"not start with {event.relation.endpoint}." - ) - - if not event.container and is_workload_event(event.name): - errors.append( - "cannot construct a workload event without the container instance. " - "Please pass one." - ) - if is_workload_event(event.name) and not event.name.startswith( - normalize_name(event.container.name) - ): - errors.append( - f"workload event should start with container name. {event.name} does " - f"not start with {event.container.name}." - ) - return errors - - def _check_config(self) -> Iterable[str]: - """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" - state_config = self.state.config - meta_config = (self.charm_spec.config or {}).get("options", {}) - errors = [] - - for key, value in state_config.items(): - if key not in meta_config: - errors.append( - f"config option {key!r} in state.config but not specified in config.yaml." - ) - continue - - # todo unify with snapshot's when merged. - converters = { - "string": str, - "int": int, - "integer": int, # fixme: which one is it? - "number": float, - "boolean": bool, - "attrs": NotImplemented, # fixme: wot? - } - - expected_type_name = meta_config[key].get("type", None) - if not expected_type_name: - errors.append(f"config.yaml invalid; option {key!r} has no 'type'.") - continue - - expected_type = converters.get(expected_type_name) - if not isinstance(value, expected_type): - errors.append( - f"config invalid; option {key!r} should be of type {expected_type} " - f"but is of type {type(value)}." - ) - - return errors - - def _check_secrets(self) -> Iterable[str]: - """Check the consistency of Secret-related stuff.""" - from scenario.state import is_secret_event # avoid cycles - - errors = [] - if is_secret_event(self.event.name) and not self.state.secrets: - errors.append( - "the event being processed is a secret event; but the state has no secrets." - ) - - if ( - is_secret_event(self.event.name) or self.state.secrets - ) and self.juju_version < (3,): - errors.append( - f"secrets are not supported in the specified juju version {self.juju_version}. " - f"Should be at least 3.0." - ) - - return errors - - def _check_containers(self) -> Iterable[str]: - """Check the consistency of state.containers vs. charm_spec.meta (metadata.yaml/containers).""" - from scenario.state import is_workload_event # avoid cycles - - meta_containers = list(self.charm_spec.meta.get("containers", {})) - state_containers = [c.name for c in self.state.containers] - errors = [] - - # it's fine if you have containers in meta that are not in state.containers (yet), but it's not fine if: - # - you're processing a pebble-ready event and that container is not in state.containers or meta.containers - if is_workload_event(self.event.name): - evt_container_name = self.event.name[: -len("-pebble-ready")] - if evt_container_name not in meta_containers: - errors.append( - f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not declared in the charm metadata" - ) - if evt_container_name not in state_containers: - errors.append( - f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not present in the state. It's odd, but consistent, if it cannot " - f"connect; but it should at least be there." - ) - - # - a container in state.containers is not in meta.containers - if diff := (set(state_containers).difference(set(meta_containers))): - errors.append( - f"some containers declared in the state are not specified in metadata. That's not possible. " - f"Missing from metadata: {diff}." - ) - return errors - - class Runtime: """Charm runtime wrapper. @@ -480,7 +287,9 @@ def exec( This will set the environment up and call ops.main.main(). After that it's up to ops. """ - ConsistencyChecker(state, event, self._charm_spec, self._juju_version).run() + from scenario.consistency_checker import check_consistency # avoid cycles + + check_consistency(state, event, self._charm_spec, self._juju_version) charm_type = self._charm_spec.charm_type logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") diff --git a/scenario/state.py b/scenario/state.py index f6f2903a2..5263e8df7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -5,13 +5,13 @@ import re import typing from itertools import chain -from operator import attrgetter from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 import yaml from ops import pebble +from ops.charm import CharmEvents from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger @@ -27,59 +27,39 @@ PathLike = Union[str, Path] -logger = scenario_logger.getChild("structs") +logger = scenario_logger.getChild("state") ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" RELATION_EVENTS_SUFFIX = { - "-relation-changed", - "-relation-broken", - "-relation-joined", - "-relation-departed", - "-relation-created", + "_relation_changed", + "_relation_broken", + "_relation_joined", + "_relation_departed", + "_relation_created", } STORAGE_EVENTS_SUFFIX = { - "-storage-detaching", - "-storage-attached", + "_storage_detaching", + "_storage_attached", } SECRET_EVENTS_SUFFIX = { - "-secret-changed", - "-secret-removed", - "-secret-rotate", - "-secret-expired", + "_secret_changed", + "_secret_removed", + "_secret_rotate", + "_secret_expired", } META_EVENTS = { - "CREATE_ALL_RELATIONS": "-relation-created", - "BREAK_ALL_RELATIONS": "-relation-broken", - "DETACH_ALL_STORAGES": "-storage-detaching", - "ATTACH_ALL_STORAGES": "-storage-attached", + "CREATE_ALL_RELATIONS": "_relation_created", + "BREAK_ALL_RELATIONS": "_relation_broken", + "DETACH_ALL_STORAGES": "_storage_detaching", + "ATTACH_ALL_STORAGES": "_storage_attached", } -def is_relation_event(name: str) -> bool: - """Whether the event name indicates that this is a relation event.""" - return any(map(name.replace("_", "-").endswith, RELATION_EVENTS_SUFFIX)) - - -def is_secret_event(name: str) -> bool: - """Whether the event name indicates that this is a secret event.""" - return any(map(name.replace("_", "-").endswith, SECRET_EVENTS_SUFFIX)) - - -def is_storage_event(name: str) -> bool: - """Whether the event name indicates that this is a storage event.""" - return any(map(name.replace("_", "-").endswith, STORAGE_EVENTS_SUFFIX)) - - -def is_workload_event(name: str) -> bool: - """Whether the event name indicates that this is a workload event.""" - return name.replace("_", "-").endswith("-pebble-ready") - - @dataclasses.dataclass class _DCBase: def replace(self, *args, **kwargs): @@ -756,6 +736,71 @@ def __post_init__(self): logger.warning(f"Only use underscores in event names. {self.name!r}") self.name = normalize_name(self.name) + @property + def _is_relation_event(self) -> bool: + """Whether the event name indicates that this is a relation event.""" + return any(self.name.endswith(suffix) for suffix in RELATION_EVENTS_SUFFIX) + + @property + def _is_secret_event(self) -> bool: + """Whether the event name indicates that this is a secret event.""" + return any(self.name.endswith(suffix) for suffix in SECRET_EVENTS_SUFFIX) + + @property + def _is_storage_event(self) -> bool: + """Whether the event name indicates that this is a storage event.""" + return any(self.name.endswith(suffix) for suffix in STORAGE_EVENTS_SUFFIX) + + @property + def _is_workload_event(self) -> bool: + """Whether the event name indicates that this is a workload event.""" + return self.name.endswith("_pebble_ready") + + # this method is private because _CharmSpec is not quite user-facing; also, the user should know. + def _is_builtin_event(self, charm_spec: "_CharmSpec"): + """Determine whether the event is a custom-defined one or a builtin one.""" + evt_name = self.name + + # simple case: this is an event type owned by our charm base.on + if hasattr(charm_spec.charm_type.on, evt_name): + return hasattr(CharmEvents, evt_name) + + # this could be an event defined on some other Object, e.g. a charm lib. + # We don't support (yet) directly emitting those, but they COULD have names that conflict with + # events owned by the base charm. E.g. if the charm has a `foo` relation, the charm will get a + # charm.on.foo_relation_created. Your charm lib is free to define its own `foo_relation_created` + # custom event, because its handle will be `charm.lib.on.foo_relation_created` and therefore be + # unique and the Framework is happy. However, our Event data structure ATM has no knowledge + # of which Object/Handle it is owned by. So the only thing we can do right now is: check whether + # the event name, assuming it is owned by the charm, is that of a builtin event or not. + builtins = [] + for relation_name in chain( + charm_spec.meta.get("requires", ()), + charm_spec.meta.get("provides", ()), + charm_spec.meta.get("peers", ()), + ): + relation_name = relation_name.replace("-", "_") + builtins.append(relation_name + "_relation_created") + builtins.append(relation_name + "_relation_joined") + builtins.append(relation_name + "_relation_changed") + builtins.append(relation_name + "_relation_departed") + builtins.append(relation_name + "_relation_broken") + + for storage_name in charm_spec.meta.get("storages", ()): + storage_name = storage_name.replace("-", "_") + builtins.append(storage_name + "_storage_attached") + builtins.append(storage_name + "_storage_detaching") + + for action_name in charm_spec.actions or (): + action_name = action_name.replace("-", "_") + builtins.append(action_name + "_action") + + for container_name in charm_spec.meta.get("containers", ()): + container_name = container_name.replace("-", "_") + builtins.append(container_name + "_pebble_ready") + + return evt_name in builtins + def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) @@ -770,13 +815,16 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = {} - if is_workload_event(self.name): + # fixme: at this stage we can't determine if the event is a builtin one or not; if it is not, + # then the coming checks are meaningless: the custom event could be named like a relation event but + # not *be* one. + if self._is_workload_event: # this is a WorkloadEvent. The snapshot: snapshot_data = { "container_name": self.container.name, } - elif is_relation_event(self.name): + elif self._is_relation_event: if not self.relation: raise ValueError( "this is a relation event; expected relation attribute" diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 71cc09fde..6b25447d8 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,7 +1,8 @@ import pytest from ops.charm import CharmBase -from scenario.runtime import ConsistencyChecker, InconsistentScenarioError +from scenario.consistency_checker import check_consistency +from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, Container, @@ -17,15 +18,17 @@ class MyCharm(CharmBase): pass -def assert_inconsistent(state, event, spec, juju_version="3.0"): - cc = ConsistencyChecker(state, event, spec, juju_version) +def assert_inconsistent( + state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version="3.0" +): with pytest.raises(InconsistentScenarioError): - cc.run() + check_consistency(state, event, charm_spec, juju_version) -def assert_consistent(state, event, spec, juju_version="3.0"): - cc = ConsistencyChecker(state, event, spec, juju_version) - cc.run() +def assert_consistent( + state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version="3.0" +): + check_consistency(state, event, charm_spec, juju_version) def test_base(): @@ -90,7 +93,7 @@ def test_evt_bad_relation_name(suffix): assert_inconsistent( State(), Event(f"foo{suffix}", relation=Relation("bar")), - _CharmSpec(MyCharm, {}), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "xxx"}}}), ) assert_consistent( State(relations=[Relation("bar")]), @@ -101,7 +104,7 @@ def test_evt_bad_relation_name(suffix): @pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) def test_evt_no_relation(suffix): - assert_inconsistent(State(), Event("foo-relation-created"), _CharmSpec(MyCharm, {})) + assert_inconsistent(State(), Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) assert_consistent( State(relations=[Relation("bar")]), Event(f"bar{suffix}", relation=Relation("bar")), diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 73fd33613..6b7633dab 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -1,16 +1,17 @@ +import os + import pytest from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource from scenario import State +from scenario.runtime import InconsistentScenarioError -class FooEvent(EventBase): - pass - +def test_custom_event_emitted(): + class FooEvent(EventBase): + pass -@pytest.fixture -def mycharm(): class MyCharmEvents(CharmEvents): foo = EventSource(FooEvent) @@ -26,9 +27,36 @@ def __init__(self, *args, **kwargs): def _on_foo(self, e): MyCharm._foo_called = True - return MyCharm + State().trigger("foo", MyCharm, meta=MyCharm.META) + assert MyCharm._foo_called + + +def test_funky_named_event_emitted(): + class FooRelationChangedEvent(EventBase): + pass + + class MyCharmEvents(CharmEvents): + foo_relation_changed = EventSource(FooRelationChangedEvent) + + class MyCharm(CharmBase): + META = {"name": "mycharm"} + on = MyCharmEvents() + _foo_called = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.framework.observe(self.on.foo_relation_changed, self._on_foo) + + def _on_foo(self, e): + MyCharm._foo_called = True + + # we called our custom event like a builtin one. Trouble! + with pytest.raises(InconsistentScenarioError): + State().trigger("foo-relation-changed", MyCharm, meta=MyCharm.META) + assert not MyCharm._foo_called -def test_custom_event_emitted(mycharm): - State().trigger("foo", mycharm, meta=mycharm.META) - assert mycharm._foo_called + os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "1" + State().trigger("foo-relation-changed", MyCharm, meta=MyCharm.META) + assert MyCharm._foo_called + os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 441836339..fbdb8fe75 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, EventSource, Framework, Object from scenario.ops_main_mock import NoObserverError -from scenario.state import Container, State +from scenario.state import Container, Event, State, _CharmSpec class QuxEvent(EventBase): @@ -49,8 +49,8 @@ def test_rubbish_event_raises(mycharm, evt_name): if evt_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" - # will whine about the container not being in state and meta; but if we put the container in meta, - # it will actually register an event! + # else it will whine about the container not being in state and meta; + # but if we put the container in meta, it will actually register an event! State().trigger(evt_name, mycharm, meta={"name": "foo"}) @@ -68,3 +68,22 @@ def test_custom_events_pass(mycharm, evt_name): def test_custom_events_sub_raise(mycharm, evt_name): with pytest.raises(RuntimeError): State().trigger(evt_name, mycharm, meta={"name": "foo"}) + + +@pytest.mark.parametrize( + "evt_name, expected", + ( + ("qux", False), + ("sub", False), + ("start", True), + ("install", True), + ("config-changed", True), + ("foo-relation-changed", True), + ("bar-relation-changed", False), + ), +) +def test_is_custom_event(mycharm, evt_name, expected): + spec = _CharmSpec( + charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}} + ) + assert Event(evt_name)._is_builtin_event(spec) is expected From 93ec980e404ead3f56ab4dab258dd4b798e1e285 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 11:35:59 +0100 Subject: [PATCH 161/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 07d30b05d..c32adbd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2.4" +version = "2.1.2.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From f54ff8f88f6232dc0b754afaa32ea87e0cc98244 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 12:00:54 +0100 Subject: [PATCH 162/546] pr comments --- scenario/scripts/__init__.py | 0 scenario/scripts/main.py | 4 ++++ scenario/scripts/snapshot.py | 34 +++++++++++++++++++--------------- scenario/state.py | 5 ++++- 4 files changed, 27 insertions(+), 16 deletions(-) delete mode 100644 scenario/scripts/__init__.py diff --git a/scenario/scripts/__init__.py b/scenario/scripts/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index ab1f90533..990734b2e 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + import logging import os diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 75ec18117..0ca118e3f 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,8 +1,12 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import json import logging import os import re import shlex +import sys import tempfile from dataclasses import asdict from enum import Enum @@ -36,8 +40,7 @@ JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) JUJU_CONFIG_KEYS = frozenset({}) -# TODO: allow passing a custom data dir, else put it in a tempfile in /tmp/. -SNAPSHOT_TEMPDIR_ROOT = (Path(os.getcwd()).parent / "snapshot_storage").absolute() +SNAPSHOT_OUTPUT_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() class SnapshotError(RuntimeError): @@ -236,8 +239,8 @@ def get_metadata(target: JujuUnitName, model: Model): class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" - # TODO: there is a .pebble.state - # " j ssh --container traefik traefik/0 cat var/lib/pebble/default/.pebble.state | jq" + # TODO: there is a .pebble.state in kubernetes containers at + # /var/lib/pebble/default/.pebble.state # figure out what it's for. def __init__( @@ -307,6 +310,8 @@ def fetch_file( ) -> Optional[str]: """Download a file from a live unit to a local path.""" # copied from jhack + # can't recall the path that lead to this solution instead of the more straightforward `juju scp`, + # but it was long and painful. Does juju scp even support --container? model_arg = f" -m {model}" if model else "" cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" try: @@ -328,7 +333,7 @@ def get_mounts( container_name: str, container_meta: Dict, fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Dict[str, Mount]: """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") @@ -392,7 +397,7 @@ def get_container( container_name: str, container_meta: Dict, fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Container: """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) @@ -418,7 +423,7 @@ def get_containers( model: Optional[str], metadata: Optional[Dict], fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> List[Container]: """Get all containers from this unit.""" fetch_files = fetch_files or {} @@ -469,7 +474,6 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: return relations - def get_config( target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: @@ -673,7 +677,7 @@ def _snapshot( include_dead_relation_networks=False, format: FormatOption = "state", fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_TEMPDIR_ROOT, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ): """see snapshot's docstring""" @@ -684,7 +688,7 @@ def _snapshot( f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." ) - exit(1) + sys.exit(1) logger.info(f'beginning snapshot of {target} in model {model or ""}...') @@ -697,12 +701,12 @@ def ifinclude(key, get_value, null_value): state_model = get_model(model) except Exception: logger.critical(f"unable to get Model from name {model}.", exc_info=True) - exit(1) + sys.exit(1) metadata = get_metadata(target, state_model) if not metadata: logger.critical(f"could not fetch metadata from {target}.") - exit(1) + sys.exit(1) try: unit_state_db = RemoteUnitStateDB(model, target) @@ -774,10 +778,10 @@ def ifinclude(key, get_value, null_value): except InvalidTargetUnitName: _model = f"model {model}" or "the current model" logger.critical(f"invalid target: {target!r} not found in {_model}") - exit(1) + sys.exit(1) except InvalidTargetModelName: logger.critical(f"invalid model: {model!r} not found.") - exit(1) + sys.exit(1) logger.info(f"snapshot done.") @@ -844,7 +848,7 @@ def snapshot( ), # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. output_dir: Path = typer.Option( - SNAPSHOT_TEMPDIR_ROOT, + SNAPSHOT_OUTPUT_DIR, "--output-dir", help="Directory in which to store any files fetched as part of the state. In the case " "of k8s charms, this might mean files obtained through Mounts,", diff --git a/scenario/state.py b/scenario/state.py index a4fae299f..df286bcc1 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -204,7 +204,10 @@ def _random_model_name(): class Model(_DCBase): name: str = _random_model_name() uuid: str = str(uuid4()) - type: Literal["kubernetes", "lxd"] = "kubernetes" # todo other options? + + # whatever juju models --format=json | jq '.models[].type' gives back. + # TODO: make this exhaustive. + type: Literal["kubernetes", "lxd"] = "kubernetes" # for now, proc mock allows you to map one command to one mocked output. From a9bbb2d6e93ff159b821655e9d5229333f27c443 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 12:01:37 +0100 Subject: [PATCH 163/546] copyright headers --- scenario/__init__.py | 3 +++ scenario/logger.py | 3 +++ scenario/mocking.py | 3 +++ scenario/ops_main_mock.py | 6 +++--- scenario/runtime.py | 3 +++ scenario/sequences.py | 3 +++ scenario/state.py | 3 +++ 7 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index 753885e0d..65a70b89a 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,2 +1,5 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. from scenario.runtime import trigger from scenario.state import * diff --git a/scenario/logger.py b/scenario/logger.py index e12fdd893..92262d1c5 100644 --- a/scenario/logger.py +++ b/scenario/logger.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import logging import os diff --git a/scenario/mocking.py b/scenario/mocking.py index afd012f0b..7da1b34b1 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import datetime import pathlib import random diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index a4fce8f53..e7723ee33 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -1,6 +1,6 @@ -### This file contains stuff that ideally should be in ops. -# see https://github.com/canonical/operator/pull/862 - +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import inspect import os from typing import TYPE_CHECKING, Callable, Optional diff --git a/scenario/runtime.py b/scenario/runtime.py index b9ee54b2d..cc084a530 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import marshal import os import re diff --git a/scenario/sequences.py b/scenario/sequences.py index 2125d9756..040441269 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import typing from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union diff --git a/scenario/state.py b/scenario/state.py index df286bcc1..724c7c646 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import copy import dataclasses import datetime From f9352e7b0191c0c5546663084ac81657b793644c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 12:08:27 +0100 Subject: [PATCH 164/546] added lint env --- tox.ini | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tox.ini b/tox.ini index ba3ed4b68..7089a7467 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,18 @@ commands = coverage report +[testenv:lint] +description = lint +deps = + coverage[toml] + pytest + jsonpatch + -r{toxinidir}/requirements.txt +commands = + black --check tests scenario + isort --check-only --profile black tests scenario + + [testenv:fmt] description = Format code deps = From 5c667661afe93eeb54f56620f9df933d98fda65d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 12:25:10 +0100 Subject: [PATCH 165/546] todo cleanup --- scenario/runtime.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 0ff20c017..289159f5e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -152,13 +152,13 @@ def __init__( if not app_name: raise ValueError('invalid metadata: mandatory "name" field is missing.') - # todo: consider parametrizing unit-id + # todo: consider parametrizing unit-id? cfr https://github.com/canonical/ops-scenario/issues/11 self._unit_name = f"{app_name}/0" - # TODO consider cleaning up venv on __delete__, but ideally you should be - # running this in a clean venv or a container anyway. @staticmethod def _cleanup_env(env): + # TODO consider cleaning up env on __delete__, but ideally you should be + # running this in a clean venv or a container anyway. # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. for key in env: os.unsetenv(key) From fa0a5517e17ea259dc631395734890d31cedb6e9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Mar 2023 16:01:00 +0100 Subject: [PATCH 166/546] text no decode --- scenario/scripts/snapshot.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 0ca118e3f..2f2e05ce5 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -114,14 +114,14 @@ def _juju_run(cmd: str, model=None) -> Dict[str, Any]: """Execute juju {command} in a given model.""" _model = f" -m {model}" if model else "" cmd = f"juju {cmd}{_model} --format json" - raw = run(shlex.split(cmd), capture_output=True).stdout.decode("utf-8") + raw = run(shlex.split(cmd), capture_output=True, text=True).stdout return json.loads(raw) def _juju_ssh(target: JujuUnitName, cmd: str, model: Optional[str] = None) -> str: _model = f" -m {model}" if model else "" command = f"juju ssh{_model} {target.unit_name} {cmd}" - raw = run(shlex.split(command), capture_output=True).stdout.decode("utf-8") + raw = run(shlex.split(command), capture_output=True, text=True).stdout return raw @@ -135,8 +135,8 @@ def _juju_exec(target: JujuUnitName, model: Optional[str], cmd: str) -> str: _model = f" -m {model}" if model else "" _target = f" -u {target}" if target else "" return run( - shlex.split(f"juju exec{_model}{_target} -- {cmd}"), capture_output=True - ).stdout.decode("utf-8") + shlex.split(f"juju exec{_model}{_target} -- {cmd}"), capture_output=True, text=True + ).stdout def get_leader(target: JujuUnitName, model: Optional[str]): @@ -254,9 +254,9 @@ def __init__( def _run(self, cmd: str) -> str: _model = f" -m {self.model}" if self.model else "" command = f"juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}" - proc = run(shlex.split(command), capture_output=True) + proc = run(shlex.split(command), capture_output=True, text=True) if proc.returncode == 0: - return proc.stdout.decode("utf-8") + return proc.stdout raise RuntimeError( f"error wrapping pebble call with {command}: " f"process exited with {proc.returncode}; " @@ -315,16 +315,16 @@ def fetch_file( model_arg = f" -m {model}" if model else "" cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" try: - raw = check_output(shlex.split(cmd)) + raw = check_output(shlex.split(cmd), text=True) except CalledProcessError as e: raise RuntimeError( f"Failed to fetch {remote_path} from {target.unit_name}." ) from e if not local_path: - return raw.decode("utf-8") + return raw - local_path.write_bytes(raw) + local_path.write_text(raw) def get_mounts( From f05e22b9efb6461b75d654ad5d09b7c63fdb2f2f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 11:29:31 +0100 Subject: [PATCH 167/546] cleaned up and manual testing --- scenario/runtime.py | 20 ++- scenario/scripts/snapshot.py | 244 ++++++++++++++++-------------- scenario/state.py | 3 - tests/test_consistency_checker.py | 2 +- tests/test_e2e/test_config.py | 4 +- tests/test_e2e/test_network.py | 2 +- tests/test_e2e/test_secrets.py | 10 +- tests/test_e2e/test_status.py | 6 +- 8 files changed, 153 insertions(+), 138 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 289159f5e..9c86912eb 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -36,7 +36,10 @@ PathLike = Union[str, Path] logger = scenario_logger.getChild("runtime") -_stored_state_regex = r"((?P.*)\/)?(?P\D+)\[(?P.*)\]" +STORED_STATE_REGEX = re.compile( + r"((?P.*)\/)?(?P\D+)\[(?P.*)\]" +) +EVENT_REGEX = re.compile(_event_regex) RUNTIME_MODULE = Path(__file__).parent @@ -82,9 +85,8 @@ def get_stored_state(self) -> List["StoredState"]: db = self._open_db() stored_state = [] - sst_regex = re.compile(_stored_state_regex) for handle_path in db.list_snapshots(): - if match := sst_regex.match(handle_path): + if 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) @@ -100,9 +102,8 @@ def get_deferred_events(self) -> List["DeferredEvent"]: db = self._open_db() deferred = [] - event_regex = re.compile(_event_regex) for handle_path in db.list_snapshots(): - if event_regex.match(handle_path): + if EVENT_REGEX.match(handle_path): notices = db.notices(handle_path) for handle, owner, observer in notices: event = DeferredEvent( @@ -396,13 +397,16 @@ def trigger( If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. :arg charm_root: virtual charm root the charm will be executed with. - If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to use this. + If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the + execution cwd, you need to use this. E.g.: + >>> virtual_root = tempfile.TemporaryDirectory() >>> local_path = Path(local_path.name) >>> (local_path / 'foo').mkdir() >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - >>> scenario.State().trigger(..., charm_root = virtual_root) + >>> scenario.State().trigger(... charm_root=virtual_root) + + """ from scenario.state import Event, _CharmSpec diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 2f2e05ce5..c2cf07d7d 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import datetime import json import logging import os @@ -32,7 +33,7 @@ Relation, Secret, State, - Status, + Status, _EntityStatus, ) logger = logging.getLogger("snapshot") @@ -41,6 +42,7 @@ JUJU_CONFIG_KEYS = frozenset({}) SNAPSHOT_OUTPUT_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() +CHARM_SUBCLASS_REGEX = re.compile(r"class (\D+)\(CharmBase\):") class SnapshotError(RuntimeError): @@ -88,10 +90,12 @@ def format_state(state: State): return _try_format(repr(state)) -def format_test_case(state: State, charm_type_name: str = None, event_name: str = None): +def format_test_case(state: State, charm_type_name: str = None, + event_name: str = None, juju_version: str = None): """Format this State as a pytest test case.""" - ct = charm_type_name or "CHARM_TYPE # TODO: replace with charm type name" + ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name" en = event_name or "EVENT_NAME, # TODO: replace with event name" + jv = juju_version or "3.0, # TODO: check juju version is correct" return _try_format( dedent( f""" @@ -103,6 +107,7 @@ def test_case(): out = state.trigger( {en} {ct} + juju_version="{jv}" ) """ @@ -135,7 +140,9 @@ def _juju_exec(target: JujuUnitName, model: Optional[str], cmd: str) -> str: _model = f" -m {model}" if model else "" _target = f" -u {target}" if target else "" return run( - shlex.split(f"juju exec{_model}{_target} -- {cmd}"), capture_output=True, text=True + shlex.split(f"juju exec{_model}{_target} -- {cmd}"), + capture_output=True, + text=True, ).stdout @@ -178,10 +185,10 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne def get_secrets( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + relations: Tuple[str, ...] = (), ) -> List[Secret]: """Get Secret list from the charm.""" logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") @@ -189,11 +196,11 @@ def get_secrets( def get_networks( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: """Get all Networks from this unit.""" logger.info("getting networks...") @@ -244,7 +251,7 @@ class RemotePebbleClient: # figure out what it's for. def __init__( - self, container: str, target: JujuUnitName, model: Optional[str] = None + self, container: str, target: JujuUnitName, model: Optional[str] = None ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container @@ -279,19 +286,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -302,11 +309,11 @@ def get_checks( def fetch_file( - target: JujuUnitName, - remote_path: str, - container_name: str, - local_path: Path = None, - model: Optional[str] = None, + target: JujuUnitName, + remote_path: str, + container_name: str, + local_path: Path = None, + model: Optional[str] = None, ) -> Optional[str]: """Download a file from a live unit to a local path.""" # copied from jhack @@ -328,12 +335,12 @@ def fetch_file( def get_mounts( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Dict[str, Mount]: """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") @@ -392,12 +399,12 @@ def get_mounts( def get_container( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Container: """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) @@ -419,11 +426,11 @@ def get_container( def get_containers( - target: JujuUnitName, - model: Optional[str], - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> List[Container]: """Get all containers from this unit.""" fetch_files = fetch_files or {} @@ -464,7 +471,10 @@ def get_status(juju_status: Dict, target: JujuUnitName) -> Status: unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") app_version = app.get("version", "") - return Status(app=app_status, unit=unit_status, app_version=app_version) + return Status( + app=_EntityStatus(*app_status), + unit=_EntityStatus(*unit_status), + app_version=app_version) def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: @@ -475,7 +485,7 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: def get_config( - target: JujuUnitName, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: """Get config dict from target.""" @@ -520,10 +530,10 @@ def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str] def get_relations( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: """Get the list of relations active for this target.""" logger.info("getting relations...") @@ -617,7 +627,7 @@ def try_guess_charm_type_name() -> Optional[str]: charm_path = Path(os.getcwd()) / "src" / "charm.py" if charm_path.exists(): source = charm_path.read_text() - charms = re.compile(r"class (\D+)\(CharmBase\):").findall(source) + charms = CHARM_SUBCLASS_REGEX.findall(source) if len(charms) < 1: raise RuntimeError(f"Not enough charms at {charm_path}.") elif len(charms) > 1: @@ -669,15 +679,15 @@ def _open_db(self) -> Optional[SQLiteStorage]: def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ): """see snapshot's docstring""" @@ -703,6 +713,9 @@ def ifinclude(key, get_value, null_value): logger.critical(f"unable to get Model from name {model}.", exc_info=True) sys.exit(1) + # todo: what about controller? + model = state_model.name + metadata = get_metadata(target, state_model) if not metadata: logger.critical(f"could not fetch metadata from {target}.") @@ -713,9 +726,6 @@ def ifinclude(key, get_value, null_value): juju_status = get_juju_status(model) endpoints = get_endpoints(juju_status, target) state = State( - juju_version=get_juju_version(juju_status), - unit_id=target.unit_id, - app_name=target.app_name, leader=get_leader(target, model), model=state_model, status=get_status(juju_status, target=target), @@ -786,9 +796,11 @@ def ifinclude(key, get_value, null_value): logger.info(f"snapshot done.") if pprint: + juju_version = get_juju_version(juju_status) if format == FormatOption.pytest: charm_type_name = try_guess_charm_type_name() - txt = format_test_case(state, charm_type_name=charm_type_name) + txt = format_test_case(state, charm_type_name=charm_type_name, + juju_version=juju_version) elif format == FormatOption.state: txt = format_state(state) elif format == FormatOption.json: @@ -796,63 +808,67 @@ def ifinclude(key, get_value, null_value): else: raise ValueError(f"unknown format {format}") + timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") + print(f'# Generated by scenario.snapshot. \n' + f'# Snapshot of {target.unit_name}{state_model.name} at {timestamp}. \n' + f'# Juju version := {juju_version} \n') print(txt) return state def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). " - "``json``: Outputs a Jsonified State object. Perfect for storage. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - "Pipe it to a file and fill in the blanks.", - ), - include: str = typer.Option( - "rckn", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``s``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - fetch: Path = typer.Option( - None, - "--fetch", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be fetched from the existing containers.", - ), - # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. - output_dir: Path = typer.Option( - SNAPSHOT_OUTPUT_DIR, - "--output-dir", - help="Directory in which to store any files fetched as part of the state. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " + "else it will be ugly but valid python code). " + "``json``: Outputs a Jsonified State object. Perfect for storage. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + "Pipe it to a file and fill in the blanks.", + ), + include: str = typer.Option( + "rckn", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``n``: networks, ``s``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + fetch: Path = typer.Option( + None, + "--fetch", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be fetched from the existing containers.", + ), + # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. + output_dir: Path = typer.Option( + SNAPSHOT_OUTPUT_DIR, + "--output-dir", + help="Directory in which to store any files fetched as part of the state. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), ) -> State: """Gather and output the State of a remote target unit. @@ -879,14 +895,12 @@ def snapshot( _snapshot.__doc__ = snapshot.__doc__ if __name__ == "__main__": - # print(_snapshot("zoo/0", model="default", format=FormatOption.pytest)) + # print(_snapshot("zookeeper/0", model="foo", format=FormatOption.pytest)) print( _snapshot( "prom/0", - model="foo", format=FormatOption.json, - include="td" # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), diff --git a/scenario/state.py b/scenario/state.py index 3dc2af8b3..50160cb10 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -478,9 +478,6 @@ def __eq__(self, other): def __iter__(self): return iter([self.name, self.message]) - def __repr__(self): - return f"" - def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: """Convert StatusBase to _EntityStatus.""" diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 6b25447d8..6e82119a0 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -33,7 +33,7 @@ def assert_consistent( def test_base(): state = State() - event = Event("update-status") + event = Event("update_status") spec = _CharmSpec(MyCharm, {}) assert_consistent(state, event, spec) diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index b2ed07ba0..367ebd7a3 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -29,7 +29,7 @@ def check_cfg(charm: CharmBase): State( config={"foo": "bar", "baz": 1}, ), - "update-status", + "update_status", mycharm, meta={"name": "foo"}, config={"options": {"foo": {"type": "string"}, "baz": {"type": "integer"}}}, @@ -46,7 +46,7 @@ def check_cfg(charm: CharmBase): State( config={"foo": "bar"}, ), - "update-status", + "update_status", mycharm, meta={"name": "foo"}, config={ diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index cc6ee742c..b0718b5c8 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -45,7 +45,7 @@ def fetch_unit_address(charm: CharmBase): ], networks=[Network.default("metrics-endpoint")], ), - "update-status", + "update_status", mycharm, meta={ "name": "foo", diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 5a669a9d1..7c74f49fc 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -95,7 +95,7 @@ def post_event(charm: CharmBase): charm.unit.add_secret({"foo": "bar"}, label="mylabel") out = State().trigger( - "update-status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) assert out.secrets secret = out.secrets[0] @@ -127,7 +127,7 @@ def post_event(charm: CharmBase): }, ) ] - ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) def test_meta_nonowner(mycharm): @@ -148,7 +148,7 @@ def post_event(charm: CharmBase): }, ) ] - ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) @pytest.mark.parametrize("app", (True, False)) @@ -176,7 +176,7 @@ def post_event(charm: CharmBase): ) ], ).trigger( - "update-status", + "update_status", mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, post_event=post_event, @@ -208,7 +208,7 @@ def post_event(charm: CharmBase): ) ], ).trigger( - "update-status", + "update_status", mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, post_event=post_event, diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index a43c11665..d628c6bac 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -25,7 +25,7 @@ def post_event(charm: CharmBase): assert charm.unit.status == UnknownStatus() out = State(leader=True).trigger( - "update-status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) assert out.status.unit == UnknownStatus() @@ -39,7 +39,7 @@ def post_event(charm: CharmBase): obj.status = WaitingStatus("3") out = State(leader=True).trigger( - "update-status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) assert out.status.unit == WaitingStatus("3") @@ -64,7 +64,7 @@ def post_event(charm: CharmBase): out = State( leader=True, status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")) - ).trigger("update-status", mycharm, meta={"name": "local"}, post_event=post_event) + ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) assert out.status.unit == WaitingStatus("3") assert out.status.unit_history == [ActiveStatus("foo")] From 4010a4ba7007668f2d29e12d37c5c42588594226 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 11:42:00 +0100 Subject: [PATCH 168/546] docs --- README.md | 15 +++++++++++++++ scenario/scripts/snapshot.py | 24 +++++++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 52a3d4561..2a154683e 100644 --- a/README.md +++ b/README.md @@ -540,5 +540,20 @@ is called and verifies that the scenario makes sense. If you have a clear false negative, are explicitly testing 'edge', inconsistent situations, or for whatever reason the checker is in your way, you can set the `SCENARIO_SKIP_CONSISTENCY_CHECKS` envvar and skip it altogether. Hopefully you don't need that. +# Snapshot + +Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to reach the entry point by typing `scenario snapshot` in a shell. + +Snapshot's purpose is to gather the State data structure from a real, live charm running in some cloud your local juju client has access to. This is handy in case: +- you want to write a test about the state the charm you're developing is currently in +- your charm is bork or in some inconsistent state, and you want to write a test to check the charm will handle it correctly the next time around (aka regression testing) +- you are new to Scenario and want to quickly get started with a real-life example. + +Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type `scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import all you need from `scenario`, and you have a working `State` that you can `.trigger()` events from. + +You can also pass a `--format json | pytest | state (default=state)` flag to obtain +- jsonified `State` data structure, for portability +- a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event that you wish to trigger. + # TODOS: - Recorder diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index c2cf07d7d..761d964ed 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -103,12 +103,18 @@ def format_test_case(state: State, charm_type_name: str = None, from charm import {ct} def test_case(): + # Arrange: prepare the state state = {state} + + #Act: trigger an event on the state out = state.trigger( {en} {ct} juju_version="{jv}" ) + + # Assert: verify that the output state is the way you want it to be + # TODO: add assertions """ ) @@ -702,14 +708,14 @@ def _snapshot( logger.info(f'beginning snapshot of {target} in model {model or ""}...') - def ifinclude(key, get_value, null_value): + def if_include(key, get_value, null_value): if include is None or key in include: return get_value() return null_value try: state_model = get_model(model) - except Exception: + except InvalidTargetModelName: logger.critical(f"unable to get Model from name {model}.", exc_info=True) sys.exit(1) @@ -729,8 +735,8 @@ def ifinclude(key, get_value, null_value): leader=get_leader(target, model), model=state_model, status=get_status(juju_status, target=target), - config=ifinclude("c", lambda: get_config(target, model), {}), - relations=ifinclude( + config=if_include("c", lambda: get_config(target, model), {}), + relations=if_include( "r", lambda: get_relations( target, @@ -740,7 +746,7 @@ def ifinclude(key, get_value, null_value): ), [], ), - containers=ifinclude( + containers=if_include( "k", lambda: get_containers( target, @@ -751,7 +757,7 @@ def ifinclude(key, get_value, null_value): ), [], ), - networks=ifinclude( + networks=if_include( "n", lambda: get_networks( target, @@ -762,7 +768,7 @@ def ifinclude(key, get_value, null_value): ), [], ), - secrets=ifinclude( + secrets=if_include( "s", lambda: get_secrets( target, @@ -772,12 +778,12 @@ def ifinclude(key, get_value, null_value): ), [], ), - deferred=ifinclude( + deferred=if_include( "d", unit_state_db.get_deferred_events, [], ), - stored_state=ifinclude( + stored_state=if_include( "t", unit_state_db.get_stored_state, [], From 24732d8824f2cbe368bb7b498e59ef91626bd2f4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 11:59:04 +0100 Subject: [PATCH 169/546] cleaned up remoteunitstatedb --- scenario/runtime.py | 7 ------- scenario/scripts/snapshot.py | 14 ++++++++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 9c86912eb..93039613d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -67,15 +67,8 @@ def __init__(self, db_path: Union[Path, str]): self._db_path = db_path self._state_file = Path(self._db_path) - @property - def _has_state(self): - """Check that the state file exists.""" - return self._state_file.exists() - def _open_db(self) -> Optional[SQLiteStorage]: """Open the db.""" - # if not self._has_state: - # return None return SQLiteStorage(self._state_file) def get_stored_state(self) -> List["StoredState"]: diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 761d964ed..5061c59cd 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -678,7 +678,12 @@ def _fetch_state(self): model=self._model, ) - def _open_db(self) -> Optional[SQLiteStorage]: + @property + def _has_state(self): + """Whether the state file exists.""" + return self._state_file.exists() + + def _open_db(self) -> SQLiteStorage: if not self._has_state: self._fetch_state() return super()._open_db() @@ -816,7 +821,7 @@ def if_include(key, get_value, null_value): timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") print(f'# Generated by scenario.snapshot. \n' - f'# Snapshot of {target.unit_name}{state_model.name} at {timestamp}. \n' + f'# Snapshot of {state_model.name}:{target.unit_name} at {timestamp}. \n' f'# Juju version := {juju_version} \n') print(txt) @@ -840,7 +845,7 @@ def snapshot( "Pipe it to a file and fill in the blanks.", ), include: str = typer.Option( - "rckn", + "rckndt", "--include", "-i", help="What data to include in the state. " @@ -906,7 +911,8 @@ def snapshot( print( _snapshot( "prom/0", - format=FormatOption.json, + format=FormatOption.pytest, + include='rckndt', # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), From 76e5ab00f1ccfe4340ea398e7447eae84f854f3c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 11:59:20 +0100 Subject: [PATCH 170/546] fmt --- scenario/scripts/snapshot.py | 233 ++++++++++++++++++----------------- 1 file changed, 121 insertions(+), 112 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 5061c59cd..1dfa2200a 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -33,7 +33,8 @@ Relation, Secret, State, - Status, _EntityStatus, + Status, + _EntityStatus, ) logger = logging.getLogger("snapshot") @@ -90,8 +91,12 @@ def format_state(state: State): return _try_format(repr(state)) -def format_test_case(state: State, charm_type_name: str = None, - event_name: str = None, juju_version: str = None): +def format_test_case( + state: State, + charm_type_name: str = None, + event_name: str = None, + juju_version: str = None, +): """Format this State as a pytest test case.""" ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name" en = event_name or "EVENT_NAME, # TODO: replace with event name" @@ -191,10 +196,10 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne def get_secrets( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + relations: Tuple[str, ...] = (), ) -> List[Secret]: """Get Secret list from the charm.""" logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") @@ -202,11 +207,11 @@ def get_secrets( def get_networks( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_dead: bool = False, + relations: Tuple[str, ...] = (), ) -> List[Network]: """Get all Networks from this unit.""" logger.info("getting networks...") @@ -257,7 +262,7 @@ class RemotePebbleClient: # figure out what it's for. def __init__( - self, container: str, target: JujuUnitName, model: Optional[str] = None + self, container: str, target: JujuUnitName, model: Optional[str] = None ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container @@ -292,19 +297,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, path: str, *, encoding: Optional[str] = "utf-8" ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, path: str, *, pattern: Optional[str] = None, itself: bool = False ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, + self, + level: Optional[ops.pebble.CheckLevel] = None, + names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" _names = (" " + f" ".join(names)) if names else "" @@ -315,11 +320,11 @@ def get_checks( def fetch_file( - target: JujuUnitName, - remote_path: str, - container_name: str, - local_path: Path = None, - model: Optional[str] = None, + target: JujuUnitName, + remote_path: str, + container_name: str, + local_path: Path = None, + model: Optional[str] = None, ) -> Optional[str]: """Download a file from a live unit to a local path.""" # copied from jhack @@ -341,12 +346,12 @@ def fetch_file( def get_mounts( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Dict[str, Mount]: """Get named Mounts from a container's metadata, and download specified files from the target unit.""" mount_meta = container_meta.get("mounts") @@ -405,12 +410,12 @@ def get_mounts( def get_container( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + container_name: str, + container_meta: Dict, + fetch_files: Optional[List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Container: """Get container data structure from the target.""" remote_client = RemotePebbleClient(container_name, target, model) @@ -432,11 +437,11 @@ def get_container( def get_containers( - target: JujuUnitName, - model: Optional[str], - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: JujuUnitName, + model: Optional[str], + metadata: Optional[Dict], + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> List[Container]: """Get all containers from this unit.""" fetch_files = fetch_files or {} @@ -480,7 +485,8 @@ def get_status(juju_status: Dict, target: JujuUnitName) -> Status: return Status( app=_EntityStatus(*app_status), unit=_EntityStatus(*unit_status), - app_version=app_version) + app_version=app_version, + ) def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: @@ -491,7 +497,7 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: def get_config( - target: JujuUnitName, model: Optional[str] + target: JujuUnitName, model: Optional[str] ) -> Dict[str, Union[str, int, float, bool]]: """Get config dict from target.""" @@ -536,10 +542,10 @@ def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str] def get_relations( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, + target: JujuUnitName, + model: Optional[str], + metadata: Dict, + include_juju_relation_data=False, ) -> List[Relation]: """Get the list of relations active for this target.""" logger.info("getting relations...") @@ -690,15 +696,15 @@ def _open_db(self) -> SQLiteStorage: def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: str = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, + target: str, + model: Optional[str] = None, + pprint: bool = True, + include: str = None, + include_juju_relation_data=False, + include_dead_relation_networks=False, + format: FormatOption = "state", + fetch_files: Dict[str, List[Path]] = None, + temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ): """see snapshot's docstring""" @@ -810,8 +816,9 @@ def if_include(key, get_value, null_value): juju_version = get_juju_version(juju_status) if format == FormatOption.pytest: charm_type_name = try_guess_charm_type_name() - txt = format_test_case(state, charm_type_name=charm_type_name, - juju_version=juju_version) + txt = format_test_case( + state, charm_type_name=charm_type_name, juju_version=juju_version + ) elif format == FormatOption.state: txt = format_state(state) elif format == FormatOption.json: @@ -820,66 +827,68 @@ def if_include(key, get_value, null_value): raise ValueError(f"unknown format {format}") timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") - print(f'# Generated by scenario.snapshot. \n' - f'# Snapshot of {state_model.name}:{target.unit_name} at {timestamp}. \n' - f'# Juju version := {juju_version} \n') + print( + f"# Generated by scenario.snapshot. \n" + f"# Snapshot of {state_model.name}:{target.unit_name} at {timestamp}. \n" + f"# Juju version := {juju_version} \n" + ) print(txt) return state def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). " - "``json``: Outputs a Jsonified State object. Perfect for storage. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - "Pipe it to a file and fill in the blanks.", - ), - include: str = typer.Option( - "rckndt", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``s``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - fetch: Path = typer.Option( - None, - "--fetch", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be fetched from the existing containers.", - ), - # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. - output_dir: Path = typer.Option( - SNAPSHOT_OUTPUT_DIR, - "--output-dir", - help="Directory in which to store any files fetched as part of the state. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), + target: str = typer.Argument(..., help="Target unit."), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + format: FormatOption = typer.Option( + "state", + "-f", + "--format", + help="How to format the output. " + "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " + "else it will be ugly but valid python code). " + "``json``: Outputs a Jsonified State object. Perfect for storage. " + "``pytest``: Outputs a full-blown pytest scenario test based on this State. " + "Pipe it to a file and fill in the blanks.", + ), + include: str = typer.Option( + "rckndt", + "--include", + "-i", + help="What data to include in the state. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``n``: networks, ``s``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_dead_relation_networks: bool = typer.Option( + False, + "--include-dead-relation-networks", + help="Whether to gather networks of inactive relation endpoints.", + is_flag=True, + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + fetch: Path = typer.Option( + None, + "--fetch", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be fetched from the existing containers.", + ), + # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. + output_dir: Path = typer.Option( + SNAPSHOT_OUTPUT_DIR, + "--output-dir", + help="Directory in which to store any files fetched as part of the state. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), ) -> State: """Gather and output the State of a remote target unit. @@ -912,7 +921,7 @@ def snapshot( _snapshot( "prom/0", format=FormatOption.pytest, - include='rckndt', + include="rckndt", # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), From 77aab0bf4e60a96b568ab7e6773d1700b3266f67 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 12:36:38 +0100 Subject: [PATCH 171/546] fixed stored state and deferred events --- scenario/runtime.py | 2 +- scenario/scripts/snapshot.py | 26 ++++++++++---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 93039613d..c5dbb38a8 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -79,7 +79,7 @@ def get_stored_state(self) -> List["StoredState"]: stored_state = [] for handle_path in db.list_snapshots(): - if match := STORED_STATE_REGEX.match(handle_path): + if (match := STORED_STATE_REGEX.match(handle_path)) and not EVENT_REGEX.match(handle_path): stored_state_snapshot = db.load_snapshot(handle_path) kwargs = match.groupdict() sst = StoredState(content=stored_state_snapshot, **kwargs) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 1dfa2200a..fc30552ad 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -70,6 +70,7 @@ def __init__(self, unit_name: str): self.app_name = app_name self.unit_id = int(unit_id) self.normalized = f"{app_name}-{unit_id}" + self.remote_charm_root = Path(f"/var/lib/juju/agents/unit-{self.normalized}/charm") def _try_format(string: str): @@ -236,15 +237,7 @@ def get_metadata(target: JujuUnitName, model: Model): """Get metadata.yaml from this target.""" logger.info("fetching metadata...") - if model.type == "lxd": - meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" - elif model.type == "kubernetes": - meta_path = f"./agents/unit-{target.normalized}/charm/metadata.yaml" - else: - logger.warning( - f"unrecognized model type {model.type}: guessing it's machine-like." - ) - meta_path = f"/var/lib/juju/agents/unit-{target.normalized}/charm/metadata.yaml" + meta_path = target.remote_charm_root / "metadata.yaml" raw_meta = _juju_ssh( target, @@ -321,9 +314,9 @@ def get_checks( def fetch_file( target: JujuUnitName, - remote_path: str, + remote_path: Union[Path, str], container_name: str, - local_path: Path = None, + local_path: Union[Path, str] = None, model: Optional[str] = None, ) -> Optional[str]: """Download a file from a live unit to a local path.""" @@ -333,16 +326,17 @@ def fetch_file( model_arg = f" -m {model}" if model else "" cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" try: - raw = check_output(shlex.split(cmd), text=True) + raw = check_output(shlex.split(cmd)) except CalledProcessError as e: raise RuntimeError( - f"Failed to fetch {remote_path} from {target.unit_name}." + f"Failed to fetch {remote_path} from {target.unit_name}. Cmd:={cmd!r}" ) from e if not local_path: return raw - local_path.write_text(raw) + # don't make assumptions about encoding + Path(local_path).write_bytes(raw) def get_mounts( @@ -678,7 +672,7 @@ def __init__(self, model: Optional[str], target: JujuUnitName): def _fetch_state(self): fetch_file( self._target, - remote_path="./unit-state.db", + remote_path=self._target.remote_charm_root / ".unit-state.db", container_name="charm", local_path=self._state_file, model=self._model, @@ -687,7 +681,7 @@ def _fetch_state(self): @property def _has_state(self): """Whether the state file exists.""" - return self._state_file.exists() + return self._state_file.exists() and self._state_file.read_bytes() def _open_db(self) -> SQLiteStorage: if not self._has_state: From 51fcab9c8abc052bb9612255a5c492020fb7b3e4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 13:20:21 +0100 Subject: [PATCH 172/546] b64 encode ssh'd files --- scenario/runtime.py | 4 +++- scenario/scripts/snapshot.py | 25 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index c5dbb38a8..eb59e5a8d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -79,7 +79,9 @@ def get_stored_state(self) -> List["StoredState"]: stored_state = [] for handle_path in db.list_snapshots(): - if (match := STORED_STATE_REGEX.match(handle_path)) and not EVENT_REGEX.match(handle_path): + if ( + match := STORED_STATE_REGEX.match(handle_path) + ) and not EVENT_REGEX.match(handle_path): stored_state_snapshot = db.load_snapshot(handle_path) kwargs = match.groupdict() sst = StoredState(content=stored_state_snapshot, **kwargs) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index fc30552ad..be083398e 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import base64 import datetime import json import logging @@ -70,7 +71,9 @@ def __init__(self, unit_name: str): self.app_name = app_name self.unit_id = int(unit_id) self.normalized = f"{app_name}-{unit_id}" - self.remote_charm_root = Path(f"/var/lib/juju/agents/unit-{self.normalized}/charm") + self.remote_charm_root = Path( + f"/var/lib/juju/agents/unit-{self.normalized}/charm" + ) def _try_format(string: str): @@ -318,25 +321,27 @@ def fetch_file( container_name: str, local_path: Union[Path, str] = None, model: Optional[str] = None, -) -> Optional[str]: +) -> Optional[bytes]: """Download a file from a live unit to a local path.""" # copied from jhack # can't recall the path that lead to this solution instead of the more straightforward `juju scp`, # but it was long and painful. Does juju scp even support --container? model_arg = f" -m {model}" if model else "" - cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path}" + cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path} | base64" try: - raw = check_output(shlex.split(cmd)) + raw = check_output(shlex.split(cmd), text=True) except CalledProcessError as e: raise RuntimeError( f"Failed to fetch {remote_path} from {target.unit_name}. Cmd:={cmd!r}" ) from e + decoded = base64.b64decode(raw) + if not local_path: - return raw + return decoded # don't make assumptions about encoding - Path(local_path).write_bytes(raw) + Path(local_path).write_bytes(decoded) def get_mounts( @@ -686,6 +691,9 @@ def _has_state(self): def _open_db(self) -> SQLiteStorage: if not self._has_state: self._fetch_state() + bout = Path('/home/pietro/good_out') + bout.touch() + bout.write_bytes(self._state_file.read_bytes()) return super()._open_db() @@ -702,6 +710,9 @@ def _snapshot( ): """see snapshot's docstring""" + print(target, model, pprint, include, include_juju_relation_data, include_dead_relation_networks, + format, fetch_files, temp_dir_base_path) + try: target = JujuUnitName(target) except InvalidTargetUnitName: @@ -915,7 +926,7 @@ def snapshot( _snapshot( "prom/0", format=FormatOption.pytest, - include="rckndt", + include="t", # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), From f2f0c2afc1ec482ad56170312da009b5a88c5496 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 13:20:31 +0100 Subject: [PATCH 173/546] fmt --- scenario/scripts/snapshot.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index be083398e..2265cdd72 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -691,7 +691,7 @@ def _has_state(self): def _open_db(self) -> SQLiteStorage: if not self._has_state: self._fetch_state() - bout = Path('/home/pietro/good_out') + bout = Path("/home/pietro/good_out") bout.touch() bout.write_bytes(self._state_file.read_bytes()) return super()._open_db() @@ -710,8 +710,17 @@ def _snapshot( ): """see snapshot's docstring""" - print(target, model, pprint, include, include_juju_relation_data, include_dead_relation_networks, - format, fetch_files, temp_dir_base_path) + print( + target, + model, + pprint, + include, + include_juju_relation_data, + include_dead_relation_networks, + format, + fetch_files, + temp_dir_base_path, + ) try: target = JujuUnitName(target) From 42958b897ad68d6849b7d78b6a310ff8ad0512db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 13:21:19 +0100 Subject: [PATCH 174/546] removed print --- scenario/scripts/snapshot.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 2265cdd72..aa41dc87a 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -710,18 +710,6 @@ def _snapshot( ): """see snapshot's docstring""" - print( - target, - model, - pprint, - include, - include_juju_relation_data, - include_dead_relation_networks, - format, - fetch_files, - temp_dir_base_path, - ) - try: target = JujuUnitName(target) except InvalidTargetUnitName: From 477dfe27322c81f9d3f103db00efe75be9e6f653 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 15:48:46 +0100 Subject: [PATCH 175/546] refactored ssh + cat + base64 fetch_file to use juju scp instead --- scenario/runtime.py | 4 ++-- scenario/scripts/snapshot.py | 24 ++++-------------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index eb59e5a8d..35757f3b3 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -79,9 +79,9 @@ def get_stored_state(self) -> List["StoredState"]: stored_state = [] for handle_path in db.list_snapshots(): - if ( + if not EVENT_REGEX.match(handle_path) and ( match := STORED_STATE_REGEX.match(handle_path) - ) and not EVENT_REGEX.match(handle_path): + ): stored_state_snapshot = db.load_snapshot(handle_path) kwargs = match.groupdict() sst = StoredState(content=stored_state_snapshot, **kwargs) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index aa41dc87a..ce5a8e9d0 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -319,29 +319,13 @@ def fetch_file( target: JujuUnitName, remote_path: Union[Path, str], container_name: str, - local_path: Union[Path, str] = None, + local_path: Union[Path, str], model: Optional[str] = None, -) -> Optional[bytes]: +) -> None: """Download a file from a live unit to a local path.""" - # copied from jhack - # can't recall the path that lead to this solution instead of the more straightforward `juju scp`, - # but it was long and painful. Does juju scp even support --container? model_arg = f" -m {model}" if model else "" - cmd = f"juju ssh --container {container_name}{model_arg} {target.unit_name} cat {remote_path} | base64" - try: - raw = check_output(shlex.split(cmd), text=True) - except CalledProcessError as e: - raise RuntimeError( - f"Failed to fetch {remote_path} from {target.unit_name}. Cmd:={cmd!r}" - ) from e - - decoded = base64.b64decode(raw) - - if not local_path: - return decoded - - # don't make assumptions about encoding - Path(local_path).write_bytes(decoded) + scp_cmd = f"juju scp --container {container_name}{model_arg} {target.unit_name}:{remote_path} {local_path}" + run(shlex.split(scp_cmd)) def get_mounts( From e6e446804a97b9bd3a526c6b0c6dda46dfccf7ee Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 16:26:53 +0100 Subject: [PATCH 176/546] cleaned up dependencies --- pyproject.toml | 4 ++-- requirements.txt | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 607d33a03..98d618983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ keywords = ["juju", "test"] dependencies = [ "ops>=2.0", - "PyYAML==6.0" + "PyYAML==6.0", + "typer==0.7.0", ] readme = "README.md" requires-python = ">=3.8" @@ -26,7 +27,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] - [project.urls] "Homepage" = "https://github.com/PietroPasotti/ops-scenario" "Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2fe5dcba4..000000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -ops==2.0.0 -typer \ No newline at end of file From bf947f1aa630d3402c62ba0a241c74bafb42df4b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 16:30:55 +0100 Subject: [PATCH 177/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 98d618983..d18f8ba43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.2.5" +version = "2.1.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 33f3175430fa15e83b0ec01a1dcc263cab4e5fe3 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 16 Mar 2023 17:11:20 +0100 Subject: [PATCH 178/546] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a154683e..d17c1161e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ event on the charm and execute its logic. This puts scenario tests somewhere in between unit and integration tests. Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the -union of an `event` (why am I being executed) and a `context` (am I leader? what is my relation data? what is my +union of an `Event` (why am I being executed) and a `State` (am I leader? what is my relation data? what is my config?...). The output is another context instance: the context after the charm has had a chance to interact with the mocked juju model. From 9feabfba32f4e2a04e8f6febc47662e30456ba01 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 17:16:43 +0100 Subject: [PATCH 179/546] mount meta bugfix --- pyproject.toml | 2 +- scenario/scripts/snapshot.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d18f8ba43..2eb0f5d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3" +version = "2.1.3.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index ce5a8e9d0..7e148b558 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -347,7 +347,7 @@ def get_mounts( return {} mount_spec = {} - for mt in mount_meta: + for mt in mount_meta or (): if name := mt.get("storage"): mount_spec[name] = mt["location"] else: From cd73f5652594936409474d26f9621d52b5ebbf7f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 17:34:05 +0100 Subject: [PATCH 180/546] removed good out --- pyproject.toml | 2 +- scenario/scripts/snapshot.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2eb0f5d1a..8051aff00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.1" +version = "2.1.3.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 7e148b558..3fcac9163 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -675,9 +675,6 @@ def _has_state(self): def _open_db(self) -> SQLiteStorage: if not self._has_state: self._fetch_state() - bout = Path("/home/pietro/good_out") - bout.touch() - bout.write_bytes(self._state_file.read_bytes()) return super()._open_db() From 0f8832724758d5a4e89d044cbeee91b9b6d3c7d4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Mar 2023 17:59:10 +0100 Subject: [PATCH 181/546] link to gh --- scenario/scripts/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 990734b2e..8ec19861f 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -20,7 +20,9 @@ def _setup_logging(verbosity: int): def main(): app = typer.Typer( name="scenario", - help="Scenario utilities.", + help="Scenario utilities. " + "For docs, issues and feature requests, visit " + "the github repo --> https://github.com/canonical/ops-scenario", no_args_is_help=True, rich_markup_mode="markdown", ) From 46e1042779ef1a3c78e7337bdb56e526697d2497 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Mar 2023 08:37:02 +0100 Subject: [PATCH 182/546] state-apply --- scenario/scripts/errors.py | 17 ++ scenario/scripts/main.py | 7 +- scenario/scripts/snapshot.py | 44 +--- scenario/scripts/state_apply.py | 230 +++++++++++++++++++ scenario/scripts/utils.py | 23 ++ tests/test_e2e/test_custom_event_triggers.py | 13 +- 6 files changed, 290 insertions(+), 44 deletions(-) create mode 100644 scenario/scripts/errors.py create mode 100644 scenario/scripts/state_apply.py create mode 100644 scenario/scripts/utils.py diff --git a/scenario/scripts/errors.py b/scenario/scripts/errors.py new file mode 100644 index 000000000..f713ef601 --- /dev/null +++ b/scenario/scripts/errors.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +class SnapshotError(RuntimeError): + """Base class for errors raised by snapshot.""" + + +class InvalidTargetUnitName(SnapshotError): + """Raised if the unit name passed to snapshot is invalid.""" + + +class InvalidTargetModelName(SnapshotError): + """Raised if the model name passed to snapshot is invalid.""" + + +class StateApplyError(SnapshotError): + """Raised when the state-apply juju command fails.""" diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 8ec19861f..ad656f050 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -8,6 +8,7 @@ import typer from scenario.scripts.snapshot import snapshot +from scenario.scripts.state_apply import state_apply def _setup_logging(verbosity: int): @@ -27,12 +28,8 @@ def main(): rich_markup_mode="markdown", ) - # trick to prevent 'snapshot' from being the toplevel command. - # We want to do `scenario snapshot`, not just `snapshot`. - # TODO remove if/when scenario has more subcommands. - app.command(name="_", hidden=True)(lambda: None) - app.command(name="snapshot", no_args_is_help=True)(snapshot) + app.command(name="state-apply", no_args_is_help=True)(state_apply) @app.callback() def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 3fcac9163..7253d8238 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -import base64 import datetime import json import logging @@ -14,7 +13,7 @@ from enum import Enum from itertools import chain from pathlib import Path -from subprocess import CalledProcessError, check_output, run +from subprocess import run from textwrap import dedent from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union @@ -24,6 +23,8 @@ from ops.storage import SQLiteStorage from scenario.runtime import UnitStateDB +from scenario.scripts.errors import InvalidTargetUnitName, InvalidTargetModelName +from scenario.scripts.utils import JujuUnitName from scenario.state import ( Address, BindAddress, @@ -47,35 +48,6 @@ CHARM_SUBCLASS_REGEX = re.compile(r"class (\D+)\(CharmBase\):") -class SnapshotError(RuntimeError): - """Base class for errors raised by snapshot.""" - - -class InvalidTargetUnitName(SnapshotError): - """Raised if the unit name passed to snapshot is invalid.""" - - -class InvalidTargetModelName(SnapshotError): - """Raised if the model name passed to snapshot is invalid.""" - - -class JujuUnitName(str): - """This class represents the name of a juju unit that can be snapshotted.""" - - def __init__(self, unit_name: str): - super().__init__() - app_name, _, unit_id = unit_name.rpartition("/") - if not app_name or not unit_id: - raise InvalidTargetUnitName(f"invalid unit name: {unit_name!r}") - self.unit_name = unit_name - self.app_name = app_name - self.unit_id = int(unit_id) - self.normalized = f"{app_name}-{unit_id}" - self.remote_charm_root = Path( - f"/var/lib/juju/agents/unit-{self.normalized}/charm" - ) - - def _try_format(string: str): try: import black @@ -702,10 +674,10 @@ def _snapshot( logger.info(f'beginning snapshot of {target} in model {model or ""}...') - def if_include(key, get_value, null_value): + def if_include(key, fn, default): if include is None or key in include: - return get_value() - return null_value + return fn() + return default try: state_model = get_model(model) @@ -763,7 +735,7 @@ def if_include(key, get_value, null_value): [], ), secrets=if_include( - "s", + "S", lambda: get_secrets( target, model, @@ -842,7 +814,7 @@ def snapshot( "-i", help="What data to include in the state. " "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``s``: secrets(!), " + "``n``: networks, ``S``: secrets(!), " "``d``: deferred events, ``t``: stored state.", ), include_dead_relation_networks: bool = typer.Option( diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py new file mode 100644 index 000000000..c7c083d65 --- /dev/null +++ b/scenario/scripts/state_apply.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import json +import logging +import os +import sys +from pathlib import Path +from subprocess import CalledProcessError, run +from typing import Dict, Iterable, List, Optional + +import typer + +from scenario.scripts.errors import InvalidTargetUnitName, StateApplyError +from scenario.scripts.utils import JujuUnitName +from scenario.state import ( + Container, + Relation, + Secret, + State, + Status, + DeferredEvent, StoredState, +) + +SNAPSHOT_DATA_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() + +logger = logging.getLogger("snapshot") + + +def set_status(status: Status) -> List[str]: + logger.info('preparing status...') + cmds = [] + + cmds.append(f'status-set {status.unit.name} {status.unit.message}') + cmds.append(f'status-set --application {status.app.name} {status.app.message}') + cmds.append(f'application-version-set {status.app_version}') + + return cmds + + +def set_relations(relations: Iterable[Relation]) -> List[str]: + logger.info('preparing relations...') + logger.warning("set_relations not implemented yet") + return [] + + +def set_config(config: Dict[str, str]) -> List[str]: + logger.info("preparing config...") + logger.warning("set_config not implemented yet") + return [] + + +def set_containers(containers: Iterable[Container]) -> List[str]: + logger.info("preparing containers...") + logger.warning("set_containers not implemented yet") + return [] + + +def set_secrets(secrets: Iterable[Secret]) -> List[str]: + logger.info("preparing secrets...") + logger.warning("set_secrets not implemented yet") + return [] + + +def set_deferred_events(deferred_events: Iterable[DeferredEvent]) -> List[str]: + logger.info("preparing deferred_events...") + logger.warning("set_deferred_events not implemented yet") + return [] + + +def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: + logger.info("preparing stored_state...") + logger.warning("set_stored_state not implemented yet") + return [] + + +def exec_in_unit( + target: JujuUnitName, + model: str, + cmds: List[str] +): + logger.info("Running juju exec...") + + _model = f" -m {model}" if model else "" + cmd_fmt = "; ".join(cmds) + try: + run(f'juju exec -u {target}{_model} -- "{cmd_fmt}"') + except CalledProcessError as e: + raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " + f"stdout = {e.stdout}; " + f"stderr = {e.stderr}.") + + +def run_commands( + cmds: List[str] +): + logger.info("Applying remaining state...") + for cmd in cmds: + try: + run(cmd) + except CalledProcessError as e: + # todo: should we log and continue instead? + raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " + f"stdout = {e.stdout}; " + f"stderr = {e.stderr}.") + + +def _state_apply( + target: str, + state: State, + model: Optional[str] = None, + include: str = None, + include_juju_relation_data=False, + push_files: Dict[str, List[Path]] = None, + snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, +): + """see state_apply's docstring""" + logger.info('Starting state-apply...') + + try: + target = JujuUnitName(target) + except InvalidTargetUnitName: + logger.critical( + f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" + f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." + ) + sys.exit(1) + + logger.info(f'beginning snapshot of {target} in model {model or ""}...') + + def if_include(key, fn): + if include is None or key in include: + return fn() + return [] + + j_exec_cmds: List[str] = [] + + j_exec_cmds += if_include("s", lambda: set_status(state.status)) + j_exec_cmds += if_include("r", lambda: set_relations(state.relations)) + j_exec_cmds += if_include("S", lambda: set_secrets(state.secrets)) + + cmds: List[str] = [] + + # todo: config is a bit special because it's not owned by the unit but by the cloud admin. + # should it be included in state-apply? + # if_include("c", lambda: set_config(state.config)) + cmds += if_include("k", lambda: set_containers(state.containers)) + cmds += if_include("d", lambda: set_deferred_events(state.deferred)) + cmds += if_include("t", lambda: set_stored_state(state.stored_state)) + + # we gather juju-exec commands to run them all at once in the unit. + exec_in_unit(target, model, j_exec_cmds) + # non-juju-exec commands are ran one by one, individually + run_commands(cmds) + + logger.info('Done!') + + +def state_apply( + target: str = typer.Argument(..., help="Target unit."), + state: Path = typer.Argument( + ..., + help="Source State to apply. Json file containing a State data structure; " + "the same you would obtain by running snapshot." + ), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + include: str = typer.Option( + "scrkSdt", + "--include", + "-i", + help="What parts of the state to apply. Defaults to: all of them. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``s``: status, ``S``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + push_files: Path = typer.Option( + None, + "--push-files", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be pushed to the each container.", + ), + # TODO: generalize "push_files" to allow passing '.' for the 'charm' container or 'the machine'. + data_dir: Path = typer.Option( + SNAPSHOT_DATA_DIR, + "--data-dir", + help="Directory in which to any files associated with the state are stored. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), +): + """Gather and output the State of a remote target unit. + + If black is available, the output will be piped through it for formatting. + + Usage: state-apply myapp/0 > ./tests/scenario/case1.py + """ + push_files_ = json.loads(push_files.read_text()) if push_files else None + state_ = json.loads(state.read_text()) + + return _state_apply( + target=target, + state=state_, + model=model, + include=include, + include_juju_relation_data=include_juju_relation_data, + snapshot_data_dir=data_dir, + push_files=push_files_, + ) + + +# for the benefit of script usage +_state_apply.__doc__ = state_apply.__doc__ + +if __name__ == "__main__": + from scenario import State + + _state_apply( + "zookeeper/0", model="foo", + state=State() + ) diff --git a/scenario/scripts/utils.py b/scenario/scripts/utils.py new file mode 100644 index 000000000..23672305a --- /dev/null +++ b/scenario/scripts/utils.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +from pathlib import Path + +from scenario.scripts.errors import InvalidTargetUnitName + + +class JujuUnitName(str): + """This class represents the name of a juju unit that can be snapshotted.""" + + def __init__(self, unit_name: str): + super().__init__() + app_name, _, unit_id = unit_name.rpartition("/") + if not app_name or not unit_id: + raise InvalidTargetUnitName(f"invalid unit name: {unit_name!r}") + self.unit_name = unit_name + self.app_name = app_name + self.unit_id = int(unit_id) + self.normalized = f"{app_name}-{unit_id}" + self.remote_charm_root = Path( + f"/var/lib/juju/agents/unit-{self.normalized}/charm" + ) diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 6b7633dab..b707b04e7 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -18,17 +18,24 @@ class MyCharmEvents(CharmEvents): class MyCharm(CharmBase): META = {"name": "mycharm"} on = MyCharmEvents() - _foo_called = False + _foo_called = 0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.framework.observe(self.on.foo, self._on_foo) + self.framework.observe(self.on.start, self._on_start) def _on_foo(self, e): - MyCharm._foo_called = True + MyCharm._foo_called += 1 + + def _on_start(self, e): + self.on.foo.emit() State().trigger("foo", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called + assert MyCharm._foo_called == 1 + + State().trigger("start", MyCharm, meta=MyCharm.META) + assert MyCharm._foo_called == 2 def test_funky_named_event_emitted(): From b9be3b4f4a72a78095f209eef35f4ff7dafc5687 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Mar 2023 09:37:36 +0100 Subject: [PATCH 183/546] added deferred events collection and tests --- scenario/scripts/snapshot.py | 3 +- scenario/utils.py | 53 ++++++++++++++++++ tests/test_emitted_events_util.py | 92 +++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 scenario/utils.py create mode 100644 tests/test_emitted_events_util.py diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 3fcac9163..a2e348c55 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -import base64 import datetime import json import logging @@ -14,7 +13,7 @@ from enum import Enum from itertools import chain from pathlib import Path -from subprocess import CalledProcessError, check_output, run +from subprocess import run from textwrap import dedent from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union diff --git a/scenario/utils.py b/scenario/utils.py new file mode 100644 index 000000000..0ffb4417f --- /dev/null +++ b/scenario/utils.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +import re +import typing +from typing import List, Literal, NamedTuple, Optional + +if typing.TYPE_CHECKING: + from scenario.state import State + + +JUJU_EVT_REGEX = re.compile(r"Emitting Juju event (?P\S+)\.") +_EVENT_REPR = ( + r"<(?P\S+) via (?P\S+)/on/(?P\S+)\[(?P\d+)\]>\." +) + +REEMITTING_EVT_REGEX_NEW = re.compile(f"Re-emitting deferred event {_EVENT_REPR}") # ops >= 2.1 +REEMITTING_EVT_REGEX_OLD = re.compile(f"Re-emitting {_EVENT_REPR}") # ops < 2.1 +CUSTOM_EVT_REGEX = re.compile(f"Emitting custom event {_EVENT_REPR}") # ops >= 2.1 +OPERATOR_EVT_REGEX = re.compile(r"Charm called itself via hooks/(?P\S+)\.") + +class EventEmissionLog(NamedTuple): + name: str + source: Literal['juju', 'custom', 'deferral', 'framework'] + raw: str + + +def match_line(line: str) -> Optional[EventEmissionLog]: + if grps := JUJU_EVT_REGEX.findall(line): + return EventEmissionLog(grps[0], 'juju', line) + elif grps := CUSTOM_EVT_REGEX.findall(line): + _type_name, source, name, _id = grps[0] + return EventEmissionLog(name, 'custom', line) + elif grps := (REEMITTING_EVT_REGEX_OLD.findall(line) or + REEMITTING_EVT_REGEX_NEW.findall(line)): + _type_name, source, name, _id = grps[0] + return EventEmissionLog(name, 'deferral', line) + elif grps := OPERATOR_EVT_REGEX.findall(line): + return EventEmissionLog(grps[0], 'framework', line) + else: + return None + + +def emitted_events(state: "State") -> List[EventEmissionLog]: + """Scrapes the juju-log for event-emission log messages. + + Most messages only get printed with loglevel >= DEBUG, so beware. + """ + evts: List[EventEmissionLog] = [] + for _, line in state.juju_log: + if match := match_line(line): + evts.append(match) + return evts diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py new file mode 100644 index 000000000..c93dca7a6 --- /dev/null +++ b/tests/test_emitted_events_util.py @@ -0,0 +1,92 @@ +import pytest +from ops.charm import CharmEvents, CharmBase +from ops.framework import EventBase, EventSource + +from scenario import State, Event +from scenario.utils import match_line, emitted_events + + +@pytest.mark.parametrize( + 'line, expected_source, expected_name', + ( + ('Re-emitting .', 'deferral', 'bar'), + ('Re-emitting .', 'deferral', 'foo'), + ('Re-emitting deferred event .', 'deferral', 'bar'), # ops >= 2.1 + ('Re-emitting deferred event .', 'deferral', 'foo'), # ops >= 2.1 + ('Emitting custom event .', 'custom', 'bar'), + ('Emitting custom event .', 'custom', 'foo'), + ('Emitting Juju event foo.', 'juju', 'foo'), + ('Emitting Juju event bar.', 'juju', 'bar'), + ('Charm called itself via hooks/foo.', 'framework', 'foo'), + ('Charm called itself via hooks/bar.', 'framework', 'bar'), + ('Foobarbaz', None, None), + ) +) +def test_line_matcher(line, expected_source, expected_name): + match = match_line(line) + if expected_source is expected_name is None: + assert not match + else: + assert match.raw == line + assert match.source == expected_source + assert match.name == expected_name + + +class Foo(EventBase): + pass + + +class MyCharmEvents(CharmEvents): + foo = EventSource(Foo) + + +class MyCharm(CharmBase): + META = {"name": "mycharm"} + on = MyCharmEvents() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.framework.observe(self.on.start, self._on_start) + self.framework.observe(self.on.foo, self._on_foo) + + def _on_start(self, e): + self.on.foo.emit() + + def _on_foo(self, e): + pass + + +def test_collection_custom_as_juju_evt(): + out = State().trigger("foo", MyCharm, meta=MyCharm.META) + emitted = emitted_events(out) + + assert len(emitted) == 1 + assert emitted[0].source == 'juju' + assert emitted[0].name == 'foo' + + +def test_collection_juju_evt(): + out = State().trigger("start", MyCharm, meta=MyCharm.META) + emitted = emitted_events(out) + + assert len(emitted) == 2 + assert emitted[0].source == 'juju' + assert emitted[0].name == 'start' + assert emitted[1].source == 'custom' + assert emitted[1].name == 'foo' + + +def test_collection_deferred(): + # todo: this test should pass with ops < 2.1 as well + out = State(deferred=[ + Event('foo').deferred(handler=MyCharm._on_foo) + ]).trigger("start", MyCharm, meta=MyCharm.META) + emitted = emitted_events(out) + + assert len(emitted) == 3 + assert emitted[0].source == 'deferral' + assert emitted[0].name == 'foo' + assert emitted[1].source == 'juju' + assert emitted[1].name == 'start' + assert emitted[2].source == 'custom' + assert emitted[2].name == 'foo' From e6294403d861a86c8f3f2e59b9cd37e262f593b6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Mar 2023 10:33:48 +0100 Subject: [PATCH 184/546] docs --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index d17c1161e..b8cec3d6d 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,29 @@ And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on the output side the same as any other bit of state. +# Emitted events +If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. +In these situations it can be useful to verify that, as a result of a given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. One important source of information at debug time is the `debug-log`, which `scenario.utils.emitted_events` leverages to allow you to peep into that black box. + +Usage: + +```python +from scenario import State, DeferredEvent +from scenario.utils import emitted_events +state_out = State(deferred=[DeferredEvent('foo')]).trigger('start', ...) +emitted = emitted_events(state_out) +data = [(e.name, e.source) for e in emitted] + +assert data == [ + ('foo', 'deferral'), # deferred events get reemitted first + ('start', 'juju'), # then comes the 'main' juju event we're triggering + ('bar', 'custom'), # then come the tail of custom events that the charm (and its libs) emit while handling the 'main' juju event. + ('baz', 'custom'), + ('qux', 'custom') +] +``` + + # The virtual charm root Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can From fab128a0b2a6dd5887105f900f0504c0cb4c57cb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 22 Mar 2023 08:55:23 +0100 Subject: [PATCH 185/546] pr comments --- README.md | 7 ++++ scenario/scripts/main.py | 4 +- scenario/utils.py | 30 ++++++------- tests/test_emitted_events_util.py | 70 +++++++++++++++++-------------- 4 files changed, 63 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b8cec3d6d..1e718a6fc 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,13 @@ assert data == [ ('baz', 'custom'), ('qux', 'custom') ] + +# or you can count the number of events of a certain type: +from collections import Counter + +counter = Counter(map(lambda t: t.name, emitted)) +assert counter['relation-changed'] == 2 +assert counter.total() == 5 ``` diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 8ec19861f..ba1915610 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -21,8 +21,8 @@ def main(): app = typer.Typer( name="scenario", help="Scenario utilities. " - "For docs, issues and feature requests, visit " - "the github repo --> https://github.com/canonical/ops-scenario", + "For docs, issues and feature requests, visit " + "the github repo --> https://github.com/canonical/ops-scenario", no_args_is_help=True, rich_markup_mode="markdown", ) diff --git a/scenario/utils.py b/scenario/utils.py index 0ffb4417f..7d46903ce 100644 --- a/scenario/utils.py +++ b/scenario/utils.py @@ -14,29 +14,33 @@ r"<(?P\S+) via (?P\S+)/on/(?P\S+)\[(?P\d+)\]>\." ) -REEMITTING_EVT_REGEX_NEW = re.compile(f"Re-emitting deferred event {_EVENT_REPR}") # ops >= 2.1 +REEMITTING_EVT_REGEX_NEW = re.compile( + f"Re-emitting deferred event {_EVENT_REPR}" +) # ops >= 2.1 REEMITTING_EVT_REGEX_OLD = re.compile(f"Re-emitting {_EVENT_REPR}") # ops < 2.1 CUSTOM_EVT_REGEX = re.compile(f"Emitting custom event {_EVENT_REPR}") # ops >= 2.1 OPERATOR_EVT_REGEX = re.compile(r"Charm called itself via hooks/(?P\S+)\.") + class EventEmissionLog(NamedTuple): name: str - source: Literal['juju', 'custom', 'deferral', 'framework'] + source: Literal["juju", "custom", "deferral", "framework"] raw: str def match_line(line: str) -> Optional[EventEmissionLog]: if grps := JUJU_EVT_REGEX.findall(line): - return EventEmissionLog(grps[0], 'juju', line) + return EventEmissionLog(grps[0], "juju", line) elif grps := CUSTOM_EVT_REGEX.findall(line): - _type_name, source, name, _id = grps[0] - return EventEmissionLog(name, 'custom', line) - elif grps := (REEMITTING_EVT_REGEX_OLD.findall(line) or - REEMITTING_EVT_REGEX_NEW.findall(line)): - _type_name, source, name, _id = grps[0] - return EventEmissionLog(name, 'deferral', line) + _type_name, source, name, _id = grps[0] + return EventEmissionLog(name, "custom", line) + elif grps := ( + REEMITTING_EVT_REGEX_OLD.findall(line) or REEMITTING_EVT_REGEX_NEW.findall(line) + ): + _type_name, source, name, _id = grps[0] + return EventEmissionLog(name, "deferral", line) elif grps := OPERATOR_EVT_REGEX.findall(line): - return EventEmissionLog(grps[0], 'framework', line) + return EventEmissionLog(grps[0], "framework", line) else: return None @@ -46,8 +50,4 @@ def emitted_events(state: "State") -> List[EventEmissionLog]: Most messages only get printed with loglevel >= DEBUG, so beware. """ - evts: List[EventEmissionLog] = [] - for _, line in state.juju_log: - if match := match_line(line): - evts.append(match) - return evts + return [match for _, line in state.juju_log if (match := match_line(line))] diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index c93dca7a6..bb2eb87e4 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,26 +1,34 @@ import pytest -from ops.charm import CharmEvents, CharmBase +from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource -from scenario import State, Event -from scenario.utils import match_line, emitted_events +from scenario import Event, State +from scenario.utils import emitted_events, match_line @pytest.mark.parametrize( - 'line, expected_source, expected_name', + "line, expected_source, expected_name", ( - ('Re-emitting .', 'deferral', 'bar'), - ('Re-emitting .', 'deferral', 'foo'), - ('Re-emitting deferred event .', 'deferral', 'bar'), # ops >= 2.1 - ('Re-emitting deferred event .', 'deferral', 'foo'), # ops >= 2.1 - ('Emitting custom event .', 'custom', 'bar'), - ('Emitting custom event .', 'custom', 'foo'), - ('Emitting Juju event foo.', 'juju', 'foo'), - ('Emitting Juju event bar.', 'juju', 'bar'), - ('Charm called itself via hooks/foo.', 'framework', 'foo'), - ('Charm called itself via hooks/bar.', 'framework', 'bar'), - ('Foobarbaz', None, None), - ) + ("Re-emitting .", "deferral", "bar"), + ("Re-emitting .", "deferral", "foo"), + ( + "Re-emitting deferred event .", + "deferral", + "bar", + ), # ops >= 2.1 + ( + "Re-emitting deferred event .", + "deferral", + "foo", + ), # ops >= 2.1 + ("Emitting custom event .", "custom", "bar"), + ("Emitting custom event .", "custom", "foo"), + ("Emitting Juju event foo.", "juju", "foo"), + ("Emitting Juju event bar.", "juju", "bar"), + ("Charm called itself via hooks/foo.", "framework", "foo"), + ("Charm called itself via hooks/bar.", "framework", "bar"), + ("Foobarbaz", None, None), + ), ) def test_line_matcher(line, expected_source, expected_name): match = match_line(line) @@ -61,8 +69,8 @@ def test_collection_custom_as_juju_evt(): emitted = emitted_events(out) assert len(emitted) == 1 - assert emitted[0].source == 'juju' - assert emitted[0].name == 'foo' + assert emitted[0].source == "juju" + assert emitted[0].name == "foo" def test_collection_juju_evt(): @@ -70,23 +78,23 @@ def test_collection_juju_evt(): emitted = emitted_events(out) assert len(emitted) == 2 - assert emitted[0].source == 'juju' - assert emitted[0].name == 'start' - assert emitted[1].source == 'custom' - assert emitted[1].name == 'foo' + assert emitted[0].source == "juju" + assert emitted[0].name == "start" + assert emitted[1].source == "custom" + assert emitted[1].name == "foo" def test_collection_deferred(): # todo: this test should pass with ops < 2.1 as well - out = State(deferred=[ - Event('foo').deferred(handler=MyCharm._on_foo) - ]).trigger("start", MyCharm, meta=MyCharm.META) + out = State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( + "start", MyCharm, meta=MyCharm.META + ) emitted = emitted_events(out) assert len(emitted) == 3 - assert emitted[0].source == 'deferral' - assert emitted[0].name == 'foo' - assert emitted[1].source == 'juju' - assert emitted[1].name == 'start' - assert emitted[2].source == 'custom' - assert emitted[2].name == 'foo' + assert emitted[0].source == "deferral" + assert emitted[0].name == "foo" + assert emitted[1].source == "juju" + assert emitted[1].name == "start" + assert emitted[2].source == "custom" + assert emitted[2].name == "foo" From 772ab6f84fb5adb51894e9fa1c8082da4b555e0a Mon Sep 17 00:00:00 2001 From: Ghislain Bourgeois Date: Mon, 27 Mar 2023 16:09:01 -0400 Subject: [PATCH 186/546] Update README for pebble push and unit status history --- README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d17c1161e..068cda659 100644 --- a/README.md +++ b/README.md @@ -141,18 +141,19 @@ from scenario import State def test_statuses(): out = State(leader=False).trigger( - 'start', + 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit_history == [ UnknownStatus(), MaintenanceStatus('determining who the ruler is...'), WaitingStatus('checking this is right...'), - ActiveStatus('I am ruled') ] ``` -Note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen". +Note that the current status is not in the **unit status history**. + +Also note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen". If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' in State. @@ -265,23 +266,28 @@ from scenario.state import State, Container, Mount class MyCharm(CharmBase): - def _on_start(self, _): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) + + def _on_pebble_ready(self, _): foo = self.unit.get_container('foo') foo.push('/local/share/config.yaml', "TEST", make_dirs=True) def test_pebble_push(): - local_file = tempfile.TemporaryFile() - container = Container(name='foo', - mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) - out = State( - containers=[container] - ).trigger( - container.pebble_ready_event, - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}, - ) - assert local_file.open().read() == "TEST" + with tempfile.NamedTemporaryFile() as local_file: + container = Container(name='foo', + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) + out = State( + containers=[container] + ).trigger( + container.pebble_ready_event, + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, + ) + assert local_file.read().decode() == "TEST" ``` `container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event is that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. From d176ab040168726d053eb920cd655f55f4585401 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:03:42 +0200 Subject: [PATCH 187/546] capture_events from harness extensions --- README.md | 47 ++++++++----- scenario/__init__.py | 1 + scenario/capture_events.py | 81 ++++++++++++++++++++++ scenario/utils.py | 53 -------------- tests/test_emitted_events_util.py | 110 ++++++++++++++---------------- 5 files changed, 161 insertions(+), 131 deletions(-) create mode 100644 scenario/capture_events.py delete mode 100644 scenario/utils.py diff --git a/README.md b/README.md index 1e718a6fc..380b874d4 100644 --- a/README.md +++ b/README.md @@ -482,33 +482,44 @@ Also, you can run assertions on it on the output side the same as any other bit # Emitted events If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. -In these situations it can be useful to verify that, as a result of a given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. One important source of information at debug time is the `debug-log`, which `scenario.utils.emitted_events` leverages to allow you to peep into that black box. +In these situations it can be useful to verify that, as a result of a given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. `scenario.capture_events` allows you to open a peephole and intercept any events emitted by the framework. Usage: ```python +from ops.charm import StartEvent, UpdateStatusEvent from scenario import State, DeferredEvent -from scenario.utils import emitted_events -state_out = State(deferred=[DeferredEvent('foo')]).trigger('start', ...) -emitted = emitted_events(state_out) -data = [(e.name, e.source) for e in emitted] - -assert data == [ - ('foo', 'deferral'), # deferred events get reemitted first - ('start', 'juju'), # then comes the 'main' juju event we're triggering - ('bar', 'custom'), # then come the tail of custom events that the charm (and its libs) emit while handling the 'main' juju event. - ('baz', 'custom'), - ('qux', 'custom') -] +from scenario import capture_events +with capture_events() as emitted: + state_out = State(deferred=[DeferredEvent('start', ...)]).trigger('update-status', ...) + +# deferred events get reemitted first +assert isinstance(emitted[0], StartEvent) +# the main juju event gets emitted next +assert isinstance(emitted[1], UpdateStatusEvent) +# possibly followed by a tail of all custom events that the main juju event triggered in turn +# assert isinstance(emitted[2], MyFooEvent) +# ... +``` -# or you can count the number of events of a certain type: -from collections import Counter -counter = Counter(map(lambda t: t.name, emitted)) -assert counter['relation-changed'] == 2 -assert counter.total() == 5 +You can filter events by type like so: + +```python +from ops.charm import StartEvent, RelationEvent +from scenario import capture_events +with capture_events(StartEvent, RelationEvent) as emitted: + # capture all `start` and `*-relation-*` events. + pass ``` +Passing no event types, like: `capture_events()`, is equivalent to `capture_events(EventBase)`. + +By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. + +By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by passing: +`capture_events(include_deferred=True)`. + # The virtual charm root Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. diff --git a/scenario/__init__.py b/scenario/__init__.py index 65a70b89a..03482443f 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -2,4 +2,5 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. from scenario.runtime import trigger +from scenario.capture_events import capture_events from scenario.state import * diff --git a/scenario/capture_events.py b/scenario/capture_events.py new file mode 100644 index 000000000..75819a12a --- /dev/null +++ b/scenario/capture_events.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +import typing +from contextlib import contextmanager +from typing import Type, TypeVar, ContextManager, List + +from ops.framework import EventBase, Framework, PreCommitEvent, CommitEvent, Handle, NoTypeError + +_T = TypeVar("_T", bound=EventBase) + + +@contextmanager +def capture_events(*types: Type[EventBase], + include_framework=False, + include_deferred=True) -> ContextManager[List[EventBase]]: + """Capture all events of type `*types` (using instance checks). + + Example:: + >>> from ops.charm import StartEvent + >>> from scenario import Event, State + >>> from charm import MyCustomEvent, MyCharm # noqa + >>> + >>> def test_my_event(): + >>> with capture_events(StartEvent, MyCustomEvent) as captured: + >>> State().trigger("start", MyCharm, meta=MyCharm.META) + >>> + >>> assert len(captured) == 2 + >>> e1, e2 = captured + >>> assert isinstance(e2, MyCustomEvent) + >>> assert e2.custom_attr == 'foo' + """ + allowed_types = types or (EventBase,) + + captured = [] + _real_emit = Framework._emit + _real_reemit = Framework.reemit + + def _wrapped_emit(self, evt): + if not include_framework and isinstance(evt, (PreCommitEvent, CommitEvent)): + return _real_emit(self, evt) + + if isinstance(evt, allowed_types): + captured.append(evt) + + return _real_emit(self, evt) + + def _wrapped_reemit(self): + # Framework calls reemit() before emitting the main juju event. We intercept that call + # and capture all events in storage. + + if not include_deferred: + return _real_reemit(self) + + # load all notices from storage as events. + for event_path, observer_path, method_name in self._storage.notices(): + event_handle = Handle.from_path(event_path) + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + continue + event = typing.cast(EventBase, event) + event.deferred = False + self._forget(event) # prevent tracking conflicts + + if not include_framework and isinstance(event, (PreCommitEvent, CommitEvent)): + continue + + if isinstance(event, allowed_types): + captured.append(event) + + return _real_reemit(self) + + Framework._emit = _wrapped_emit # type: ignore # noqa # ugly + Framework.reemit = _wrapped_reemit # type: ignore # noqa # ugly + + yield captured + + Framework._emit = _real_emit # type: ignore # noqa # ugly + Framework.reemit = _real_reemit # type: ignore # noqa # ugly + diff --git a/scenario/utils.py b/scenario/utils.py deleted file mode 100644 index 7d46903ce..000000000 --- a/scenario/utils.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import re -import typing -from typing import List, Literal, NamedTuple, Optional - -if typing.TYPE_CHECKING: - from scenario.state import State - - -JUJU_EVT_REGEX = re.compile(r"Emitting Juju event (?P\S+)\.") -_EVENT_REPR = ( - r"<(?P\S+) via (?P\S+)/on/(?P\S+)\[(?P\d+)\]>\." -) - -REEMITTING_EVT_REGEX_NEW = re.compile( - f"Re-emitting deferred event {_EVENT_REPR}" -) # ops >= 2.1 -REEMITTING_EVT_REGEX_OLD = re.compile(f"Re-emitting {_EVENT_REPR}") # ops < 2.1 -CUSTOM_EVT_REGEX = re.compile(f"Emitting custom event {_EVENT_REPR}") # ops >= 2.1 -OPERATOR_EVT_REGEX = re.compile(r"Charm called itself via hooks/(?P\S+)\.") - - -class EventEmissionLog(NamedTuple): - name: str - source: Literal["juju", "custom", "deferral", "framework"] - raw: str - - -def match_line(line: str) -> Optional[EventEmissionLog]: - if grps := JUJU_EVT_REGEX.findall(line): - return EventEmissionLog(grps[0], "juju", line) - elif grps := CUSTOM_EVT_REGEX.findall(line): - _type_name, source, name, _id = grps[0] - return EventEmissionLog(name, "custom", line) - elif grps := ( - REEMITTING_EVT_REGEX_OLD.findall(line) or REEMITTING_EVT_REGEX_NEW.findall(line) - ): - _type_name, source, name, _id = grps[0] - return EventEmissionLog(name, "deferral", line) - elif grps := OPERATOR_EVT_REGEX.findall(line): - return EventEmissionLog(grps[0], "framework", line) - else: - return None - - -def emitted_events(state: "State") -> List[EventEmissionLog]: - """Scrapes the juju-log for event-emission log messages. - - Most messages only get printed with loglevel >= DEBUG, so beware. - """ - return [match for _, line in state.juju_log if (match := match_line(line))] diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index bb2eb87e4..10bcb101b 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,43 +1,9 @@ import pytest -from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, EventSource +from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.framework import EventBase, EventSource, CommitEvent, PreCommitEvent from scenario import Event, State -from scenario.utils import emitted_events, match_line - - -@pytest.mark.parametrize( - "line, expected_source, expected_name", - ( - ("Re-emitting .", "deferral", "bar"), - ("Re-emitting .", "deferral", "foo"), - ( - "Re-emitting deferred event .", - "deferral", - "bar", - ), # ops >= 2.1 - ( - "Re-emitting deferred event .", - "deferral", - "foo", - ), # ops >= 2.1 - ("Emitting custom event .", "custom", "bar"), - ("Emitting custom event .", "custom", "foo"), - ("Emitting Juju event foo.", "juju", "foo"), - ("Emitting Juju event bar.", "juju", "bar"), - ("Charm called itself via hooks/foo.", "framework", "foo"), - ("Charm called itself via hooks/bar.", "framework", "bar"), - ("Foobarbaz", None, None), - ), -) -def test_line_matcher(line, expected_source, expected_name): - match = match_line(line) - if expected_source is expected_name is None: - assert not match - else: - assert match.raw == line - assert match.source == expected_source - assert match.name == expected_name +from scenario import capture_events class Foo(EventBase): @@ -64,37 +30,61 @@ def _on_foo(self, e): pass -def test_collection_custom_as_juju_evt(): - out = State().trigger("foo", MyCharm, meta=MyCharm.META) - emitted = emitted_events(out) +def test_capture_custom_evt(): + with capture_events(Foo) as emitted: + State().trigger("foo", MyCharm, meta=MyCharm.META) assert len(emitted) == 1 - assert emitted[0].source == "juju" - assert emitted[0].name == "foo" + assert isinstance(emitted[0], Foo) -def test_collection_juju_evt(): - out = State().trigger("start", MyCharm, meta=MyCharm.META) - emitted = emitted_events(out) +def test_capture_custom_evt_nonspecific_capture(): + with capture_events() as emitted: + State().trigger("foo", MyCharm, meta=MyCharm.META) + + assert len(emitted) == 1 + assert isinstance(emitted[0], Foo) + + +def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): + with capture_events(include_framework=True) as emitted: + State().trigger("foo", MyCharm, meta=MyCharm.META) + + assert len(emitted) == 3 + assert isinstance(emitted[0], Foo) + assert isinstance(emitted[1], PreCommitEvent) + assert isinstance(emitted[2], CommitEvent) + + +def test_capture_juju_evt(): + with capture_events() as emitted: + State().trigger("start", MyCharm, meta=MyCharm.META) assert len(emitted) == 2 - assert emitted[0].source == "juju" - assert emitted[0].name == "start" - assert emitted[1].source == "custom" - assert emitted[1].name == "foo" + assert isinstance(emitted[0], StartEvent) + assert isinstance(emitted[1], Foo) -def test_collection_deferred(): +def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well - out = State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( - "start", MyCharm, meta=MyCharm.META - ) - emitted = emitted_events(out) + with capture_events() as emitted: + State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( + "start", MyCharm, meta=MyCharm.META + ) assert len(emitted) == 3 - assert emitted[0].source == "deferral" - assert emitted[0].name == "foo" - assert emitted[1].source == "juju" - assert emitted[1].name == "start" - assert emitted[2].source == "custom" - assert emitted[2].name == "foo" + assert isinstance(emitted[0], Foo) + assert isinstance(emitted[1], StartEvent) + assert isinstance(emitted[2], Foo) + + +def test_capture_no_deferred_evt(): + # todo: this test should pass with ops < 2.1 as well + with capture_events(include_deferred=False) as emitted: + State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( + "start", MyCharm, meta=MyCharm.META + ) + + assert len(emitted) == 2 + assert isinstance(emitted[0], StartEvent) + assert isinstance(emitted[1], Foo) From fe8744f0dba789915bb5917c8823e2518793a17f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:04:47 +0200 Subject: [PATCH 188/546] black --- scenario/__init__.py | 2 +- scenario/capture_events.py | 22 +++++++++++++++------- scenario/scripts/snapshot.py | 1 - tests/test_e2e/test_relations.py | 12 ++++++++++-- tests/test_e2e/test_rubbish_events.py | 1 - tests/test_emitted_events_util.py | 5 ++--- 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index 03482443f..aa462f091 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from scenario.runtime import trigger from scenario.capture_events import capture_events +from scenario.runtime import trigger from scenario.state import * diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 75819a12a..3f0f65d5c 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -3,17 +3,24 @@ # See LICENSE file for licensing details. import typing from contextlib import contextmanager -from typing import Type, TypeVar, ContextManager, List +from typing import ContextManager, List, Type, TypeVar -from ops.framework import EventBase, Framework, PreCommitEvent, CommitEvent, Handle, NoTypeError +from ops.framework import ( + CommitEvent, + EventBase, + Framework, + Handle, + NoTypeError, + PreCommitEvent, +) _T = TypeVar("_T", bound=EventBase) @contextmanager -def capture_events(*types: Type[EventBase], - include_framework=False, - include_deferred=True) -> ContextManager[List[EventBase]]: +def capture_events( + *types: Type[EventBase], include_framework=False, include_deferred=True +) -> ContextManager[List[EventBase]]: """Capture all events of type `*types` (using instance checks). Example:: @@ -63,7 +70,9 @@ def _wrapped_reemit(self): event.deferred = False self._forget(event) # prevent tracking conflicts - if not include_framework and isinstance(event, (PreCommitEvent, CommitEvent)): + if not include_framework and isinstance( + event, (PreCommitEvent, CommitEvent) + ): continue if isinstance(event, allowed_types): @@ -78,4 +87,3 @@ def _wrapped_reemit(self): Framework._emit = _real_emit # type: ignore # noqa # ugly Framework.reemit = _real_reemit # type: ignore # noqa # ugly - diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index a2e348c55..b844f6d70 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -173,7 +173,6 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne bind_addresses = [] for raw_bind in jsn["bind-addresses"]: - addresses = [] for raw_adds in raw_bind["addresses"]: addresses.append( diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 61a1a245d..58dd3205c 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -74,7 +74,11 @@ def test_relation_events(mycharm, evt_name): mycharm._call = lambda self, evt: None - State(relations=[relation,],).trigger( + State( + relations=[ + relation, + ], + ).trigger( getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -106,7 +110,11 @@ def callback(charm: CharmBase, _): mycharm._call = callback - State(relations=[relation,],).trigger( + State( + relations=[ + relation, + ], + ).trigger( getattr(relation, f"{evt_name}_event"), mycharm, meta={ diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index fbdb8fe75..f82a94284 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -46,7 +46,6 @@ def _on_event(self, e): @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): with pytest.raises(NoObserverError): - if evt_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" # else it will whine about the container not being in state and meta; diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index 10bcb101b..184c97f2d 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,9 +1,8 @@ import pytest from ops.charm import CharmBase, CharmEvents, StartEvent -from ops.framework import EventBase, EventSource, CommitEvent, PreCommitEvent +from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent -from scenario import Event, State -from scenario import capture_events +from scenario import Event, State, capture_events class Foo(EventBase): From 91675734c8ece417af61ba18b3ff6e4e9142b734 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:19:08 +0200 Subject: [PATCH 189/546] lint and fix tests --- pyproject.toml | 7 +++++-- tests/test_e2e/test_pebble.py | 2 +- tox.ini | 10 +++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8051aff00..d0c514c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ [build-system] -requires = ["setuptools"] +requires = [ + "setuptools >= 35.0.2", + "setuptools_scm >= 2.0.0, <3" +] build-backend = "setuptools.build_meta" [project] @@ -46,4 +49,4 @@ include = '\.pyi?$' profile = "black" [bdist_wheel] -universal=1 +universal = 1 diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 5921032af..3cff2e171 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -104,7 +104,7 @@ def callback(self: CharmBase): container.push("/foo/bar/baz.txt", text, make_dirs=make_dirs) # check that nothing was changed - with pytest.raises(FileNotFoundError): + with pytest.raises((FileNotFoundError, pebble.PathError)): container.pull("/foo/bar/baz.txt") td = tempfile.TemporaryDirectory() diff --git a/tox.ini b/tox.ini index 7089a7467..c01260c55 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,11 @@ [tox] skipsdist=True +envlist = + {py36,py37,py38} + unit, lint +isolated_build = True skip_missing_interpreters = True -envlist = unit, fmt [vars] @@ -18,7 +21,7 @@ deps = coverage[toml] pytest jsonpatch - -r{toxinidir}/requirements.txt + ops==2.0 commands = coverage run \ --source={[vars]src_path} \ @@ -32,7 +35,8 @@ deps = coverage[toml] pytest jsonpatch - -r{toxinidir}/requirements.txt + black + isort commands = black --check tests scenario isort --check-only --profile black tests scenario From 0f3d5853abdfa3dbf8532fa714ed46592662d49e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:21:24 +0200 Subject: [PATCH 190/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d0c514c2a..f93c900ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.2" +version = "2.1.3.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 1a9235a5348ad22383fb249b5ec4150b55c31f5e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:28:14 +0200 Subject: [PATCH 191/546] qc --- .github/workflows/quality_checks.yaml | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/quality_checks.yaml diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml new file mode 100644 index 000000000..bc3a367c7 --- /dev/null +++ b/.github/workflows/quality_checks.yaml @@ -0,0 +1,40 @@ +name: Tests + +on: + pull-request: + branches: + - main + + +jobs: + linting: + name: Linting + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 1 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Install dependencies + run: python -m pip install tox + - name: Run tests + run: tox -vve lint + + unit-test: + name: Unit Tests + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 1 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Install dependencies + run: python -m pip install tox + - name: Run tests + run: tox -vve unit From 4c3f77968ca25f488ba5af293e860091e8fdb892 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 09:41:48 +0200 Subject: [PATCH 192/546] deps cleaned up --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c01260c55..4ddb505e7 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ deps = coverage[toml] pytest jsonpatch - ops==2.0 + .[dependencies] commands = coverage run \ --source={[vars]src_path} \ From 54d0d9af7f63fc080072607c4ebcbba9344a1191 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 28 Mar 2023 13:15:53 +0200 Subject: [PATCH 193/546] fixed tox --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 4ddb505e7..8689e828d 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,6 @@ # See LICENSE file for licensing details. [tox] -skipsdist=True envlist = {py36,py37,py38} unit, lint @@ -21,7 +20,6 @@ deps = coverage[toml] pytest jsonpatch - .[dependencies] commands = coverage run \ --source={[vars]src_path} \ From 24d4de9d6905a0d5f74887d9e3d22a89b0fe9acc Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Tue, 28 Mar 2023 13:21:19 +0200 Subject: [PATCH 194/546] Update quality_checks.yaml --- .github/workflows/quality_checks.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index bc3a367c7..6e7c20b7a 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -1,7 +1,7 @@ name: Tests on: - pull-request: + pull_request: branches: - main @@ -9,6 +9,7 @@ on: jobs: linting: name: Linting + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 @@ -25,6 +26,7 @@ jobs: unit-test: name: Unit Tests + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 From aa30e4ffc92edaa498231ae33eba74dd2a46eb49 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 14:33:05 +0200 Subject: [PATCH 195/546] added optional event params for relation events --- scenario/runtime.py | 31 ++++++++++++- scenario/state.py | 47 ++++++++++++++++---- tests/test_e2e/test_relations.py | 76 +++++++++++++++++++++++++++++++- tox.ini | 2 + 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 35757f3b3..b55fc83ca 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -157,7 +157,8 @@ def _cleanup_env(env): # running this in a clean venv or a container anyway. # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. for key in env: - os.unsetenv(key) + # os.unsetenv does not work !? + del os.environ[key] def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event.name.endswith("_action"): @@ -183,9 +184,34 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): { "JUJU_RELATION": relation.endpoint, "JUJU_RELATION_ID": str(relation.relation_id), + "JUJU_REMOTE_APP": relation.remote_app_name, } ) + if event._is_relation_event: # noqa + remote_unit_id = event.relation_remote_unit_id + if not remote_unit_id: + if len(relation.remote_unit_ids) == 1: + remote_unit_id = relation.remote_unit_ids[0] + logger.info( + "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, " + "but you probably should be parametrizing the event with `remote_unit` " + "to be explicit." + ) + else: + logger.warning( + "unable to determine remote unit ID; which means JUJU_REMOTE_UNIT will " + "be unset and you might get error if charm code attempts to access " + "`event.unit` in event handlers. \n" + "If that is the case, pass `remote_unit` to the Event constructor." + ) + + if remote_unit_id: + remote_unit = f"{relation.remote_app_name}/{remote_unit_id}" + env["JUJU_REMOTE_UNIT"] = remote_unit + if event.name.endswith("_relation_departed"): + env["JUJU_DEPARTING_UNIT"] = remote_unit + if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) @@ -348,8 +374,9 @@ def exec( finally: logger.info(" - Exited ops.main.") - logger.info(" - clearing env") + logger.info(" - Clearing env") self._cleanup_env(env) + assert not os.getenv("JUJU_DEPARTING_UNIT") logger.info(" - closing storage") output_state = self._close_storage(output_state, temporary_charm_root) diff --git a/scenario/state.py b/scenario/state.py index 50160cb10..1d218aed4 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -144,6 +144,27 @@ def normalize_name(s: str): return s.replace("-", "_") +class ParametrizedEvent: + def __init__(self, accept_params: Tuple[str], category: str, *args, **kwargs): + self._accept_params = accept_params + self._category = category + self._args = args + self._kwargs = kwargs + + def __call__(self, remote_unit: Optional[str] = None) -> "Event": + """Construct an Event object using the arguments provided at init and any extra params.""" + if remote_unit and "remote_unit" not in self._accept_params: + raise ValueError( + f"cannot pass param `remote_unit` to a " + f"{self._category} event constructor." + ) + + return Event(*self._args, *self._kwargs, relation_remote_unit=remote_unit) + + def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": + return self().deferred(handler=handler, event_id=event_id) + + @dataclasses.dataclass class Relation(_DCBase): endpoint: str @@ -191,35 +212,35 @@ def __post_init__(self): self.remote_units_data = {0: {}} @property - def changed_event(self): + def changed_event(self) -> "Event": """Sugar to generate a -relation-changed event.""" return Event( name=normalize_name(self.endpoint + "-relation-changed"), relation=self ) @property - def joined_event(self): + def joined_event(self) -> "Event": """Sugar to generate a -relation-joined event.""" return Event( name=normalize_name(self.endpoint + "-relation-joined"), relation=self ) @property - def created_event(self): + def created_event(self) -> "Event": """Sugar to generate a -relation-created event.""" return Event( name=normalize_name(self.endpoint + "-relation-created"), relation=self ) @property - def departed_event(self): + def departed_event(self) -> "Event": """Sugar to generate a -relation-departed event.""" return Event( name=normalize_name(self.endpoint + "-relation-departed"), relation=self ) @property - def broken_event(self): + def broken_event(self) -> "Event": """Sugar to generate a -relation-broken event.""" return Event( name=normalize_name(self.endpoint + "-relation-broken"), relation=self @@ -721,6 +742,8 @@ class Event(_DCBase): # if this is a relation event, the relation it refers to relation: Optional[Relation] = None + # and the name of the remote unit this relation event is about + relation_remote_unit_id: Optional[int] = None # if this is a secret event, the secret it refers to secret: Optional[Secret] = None @@ -733,6 +756,14 @@ class Event(_DCBase): # - pebble? # - action? + def __call__(self, remote_unit: Optional[int] = None) -> "Event": + if remote_unit and not self._is_relation_event: + raise ValueError( + "cannot pass param `remote_unit` to a " + "non-relation event constructor." + ) + return self.replace(relation_remote_unit_id=remote_unit) + def __post_init__(self): if "-" in self.name: logger.warning(f"Only use underscores in event names. {self.name!r}") @@ -834,9 +865,9 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: # this is a RelationEvent. The snapshot: snapshot_data = { "relation_name": self.relation.endpoint, - "relation_id": self.relation.relation_id - # 'app_name': local app name - # 'unit_name': local unit name + "relation_id": self.relation.relation_id, + "app_name": self.relation.remote_app_name, + "unit_name": f"{self.relation.remote_app_name}/{self.relation_remote_unit_id}", } return DeferredEvent( diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 58dd3205c..9bbc8502a 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -1,7 +1,8 @@ +import os from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents +from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent from ops.framework import EventBase, Framework from scenario.state import Relation, State @@ -124,3 +125,76 @@ def callback(charm: CharmBase, _): }, }, ) + + +@pytest.mark.parametrize( + "evt_name", + ("changed", "broken", "departed", "joined", "created"), +) +@pytest.mark.parametrize( + "remote_app_name", + ("remote", "prometheus", "aodeok123"), +) +def test_relation_events_attrs(mycharm, evt_name, remote_app_name): + relation = Relation( + endpoint="foo", interface="foo", remote_app_name=remote_app_name + ) + + def callback(charm: CharmBase, event): + assert event.app + assert event.unit + if isinstance(event, RelationDepartedEvent): + assert event.departing_unit + + mycharm._call = callback + + State( + relations=[ + relation, + ], + ).trigger( + getattr(relation, f"{evt_name}_event")(remote_unit=1), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + }, + ) + + +@pytest.mark.parametrize( + "evt_name", + ("changed", "broken", "departed", "joined", "created"), +) +@pytest.mark.parametrize( + "remote_app_name", + ("remote", "prometheus", "aodeok123"), +) +def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name): + relation = Relation( + endpoint="foo", interface="foo", remote_app_name=remote_app_name + ) + + def callback(charm: CharmBase, event): + assert event.app # that's always present + assert not event.unit + assert not getattr(event, "departing_unit", False) + + mycharm._call = callback + + State( + relations=[ + relation, + ], + ).trigger( + getattr(relation, f"{evt_name}_event"), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + }, + ) diff --git a/tox.ini b/tox.ini index 8689e828d..a25b8f0f3 100644 --- a/tox.ini +++ b/tox.ini @@ -28,6 +28,7 @@ commands = [testenv:lint] +skip_install=True description = lint deps = coverage[toml] @@ -41,6 +42,7 @@ commands = [testenv:fmt] +skip_install=True description = Format code deps = black From e640ec9edbeb981b4016547a7466e43c26f3a69a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 14:37:58 +0200 Subject: [PATCH 196/546] fixed truthiness --- scenario/runtime.py | 4 ++-- tests/test_e2e/test_relations.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index b55fc83ca..cbf89e837 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -190,7 +190,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event._is_relation_event: # noqa remote_unit_id = event.relation_remote_unit_id - if not remote_unit_id: + if remote_unit_id is None: # don't check truthiness because it could be int(0) if len(relation.remote_unit_ids) == 1: remote_unit_id = relation.remote_unit_ids[0] logger.info( @@ -206,7 +206,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "If that is the case, pass `remote_unit` to the Event constructor." ) - if remote_unit_id: + if remote_unit_id is not None: remote_unit = f"{relation.remote_app_name}/{remote_unit_id}" env["JUJU_REMOTE_UNIT"] = remote_unit if event.name.endswith("_relation_departed"): diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 9bbc8502a..4551de0c4 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -135,7 +135,11 @@ def callback(charm: CharmBase, _): "remote_app_name", ("remote", "prometheus", "aodeok123"), ) -def test_relation_events_attrs(mycharm, evt_name, remote_app_name): +@pytest.mark.parametrize( + "remote_unit_id", + (0, 1), +) +def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id): relation = Relation( endpoint="foo", interface="foo", remote_app_name=remote_app_name ) @@ -153,7 +157,7 @@ def callback(charm: CharmBase, event): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event")(remote_unit=1), + getattr(relation, f"{evt_name}_event")(remote_unit=remote_unit_id), mycharm, meta={ "name": "local", From 33675481b200e7c2b02a798679cf128ab1a4f9a9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 14:40:59 +0200 Subject: [PATCH 197/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f93c900ef..f89368737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.3" +version = "2.1.3.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From 339d332b41eeabe71a08e1e638c034a646de1a11 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 14:49:59 +0200 Subject: [PATCH 198/546] readme --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 62dbcf0f9..0ab90774e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,40 @@ def test_relation_data(): # which is very idiomatic and superbly explicit. Noice. ``` +If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the event from one of its aptly-named properties: + +```python +from scenario import Relation +relation = Relation(endpoint="foo", interface="bar") +changed_event = relation.changed_event +joined_event = relation.joined_event +# ... +``` + +This is in fact syntactic sugar for: +```python +from scenario import Relation, Event +relation = Relation(endpoint="foo", interface="bar") +changed_event = Event('foo-relation-changed', relation=relation) +``` + +The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. + +### Additional event parameters +All relation events have some additional metadata that does not belong in the Relation object, such as, for a relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. + +In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit` the id of the remote unit that the event is about. + +```python +from scenario import Relation, Event +relation = Relation(endpoint="foo", interface="bar") +remote_unit_2_is_joining_event = relation.joined_event(remote_unit=2) + +# which is syntactic sugar for: +remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) +``` + + ## Containers When testing a kubernetes charm, you can mock container interactions. From 3e86fb4931ba6c5ca5f1f97492addd9d2cf70720 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 15:06:34 +0200 Subject: [PATCH 199/546] lint --- scenario/runtime.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index cbf89e837..b8668b598 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -190,7 +190,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event._is_relation_event: # noqa remote_unit_id = event.relation_remote_unit_id - if remote_unit_id is None: # don't check truthiness because it could be int(0) + if ( + remote_unit_id is None + ): # don't check truthiness because it could be int(0) if len(relation.remote_unit_ids) == 1: remote_unit_id = relation.remote_unit_ids[0] logger.info( From e9b0b3e254d89c56d3c547511bda36a9e6740dfc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Mar 2023 15:35:59 +0200 Subject: [PATCH 200/546] utest fix --- tests/test_e2e/test_relations.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 4551de0c4..38663e42b 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -176,9 +176,12 @@ def callback(charm: CharmBase, event): "remote_app_name", ("remote", "prometheus", "aodeok123"), ) -def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name): +def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): relation = Relation( - endpoint="foo", interface="foo", remote_app_name=remote_app_name + endpoint="foo", + interface="foo", + remote_app_name=remote_app_name, + remote_units_data={0: {}, 1: {}}, # 2 units ) def callback(charm: CharmBase, event): @@ -202,3 +205,5 @@ def callback(charm: CharmBase, event): }, }, ) + + assert "unable to determine remote unit ID" in caplog.text From 6d8cd70367df8d19ecfcb65cd39e5da2c19a33eb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Mar 2023 12:31:44 +0200 Subject: [PATCH 201/546] added databag validators --- scenario/state.py | 26 +++++++++++++++++++++++++- tests/test_e2e/test_relations.py | 17 ++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 1d218aed4..bab77dda3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -63,6 +63,13 @@ } +class StateValidationError(RuntimeError): + """Raised when individual parts of the State are inconsistent.""" + + # as opposed to InconsistentScenario error where the + # **combination** of several parts of the State are. + + @dataclasses.dataclass class _DCBase: def replace(self, *args, **kwargs): @@ -200,7 +207,7 @@ def __post_init__(self): if self.remote_unit_ids and self.remote_units_data: if not set(self.remote_unit_ids) == set(self.remote_units_data): - raise ValueError( + raise StateValidationError( f"{self.remote_unit_ids} should include any and all IDs from {self.remote_units_data}" ) elif self.remote_unit_ids: @@ -211,6 +218,23 @@ def __post_init__(self): self.remote_unit_ids = [0] self.remote_units_data = {0: {}} + for databag in ( + self.local_unit_data, + self.local_app_data, + self.remote_app_data, + *self.remote_units_data.values(), + ): + if not isinstance(databag, dict): + raise StateValidationError( + f"all databags should be dicts, not {type(databag)}" + ) + for k, v in databag.items(): + if not isinstance(v, str): + raise StateValidationError( + f"all databags should be Dict[str,str]; " + f"found a value of type {type(v)}" + ) + @property def changed_event(self) -> "Event": """Sugar to generate a -relation-changed event.""" diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 38663e42b..41541e34a 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -5,7 +5,8 @@ from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent from ops.framework import EventBase, Framework -from scenario.state import Relation, State +from scenario.runtime import InconsistentScenarioError +from scenario.state import Relation, State, StateValidationError @pytest.fixture(scope="function") @@ -207,3 +208,17 @@ def callback(charm: CharmBase, event): ) assert "unable to determine remote unit ID" in caplog.text + + +@pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) +def test_relation_unit_data_bad_types(mycharm, data): + with pytest.raises(StateValidationError): + relation = Relation( + endpoint="foo", interface="foo", remote_units_data={0: {"a": data}} + ) + + +@pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) +def test_relation_app_data_bad_types(mycharm, data): + with pytest.raises(StateValidationError): + relation = Relation(endpoint="foo", interface="foo", local_app_data={"a": data}) From 7d6e5a24062cf3db59327680d1239cbd6254c55c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Mar 2023 14:42:18 +0200 Subject: [PATCH 202/546] added sub and peer relation types --- scenario/consistency_checker.py | 16 ++- scenario/mocking.py | 34 +++++- scenario/state.py | 185 ++++++++++++++++++++++++------- tests/test_e2e/test_relations.py | 21 +++- 4 files changed, 206 insertions(+), 50 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 0c1b6759a..520fcf43e 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -3,7 +3,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger -from scenario.state import _CharmSpec, normalize_name +from scenario.state import SubordinateRelation, _CharmSpec, normalize_name if TYPE_CHECKING: from scenario.state import Event, State @@ -51,6 +51,7 @@ def check_consistency( check_config_consistency, check_event_consistency, check_secrets_consistency, + check_relation_consistency, ): results = check( state=state, event=event, charm_spec=charm_spec, juju_version=juju_version @@ -179,6 +180,19 @@ def check_secrets_consistency( return Results(errors, []) +def check_relation_consistency( + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs +) -> Results: + errors = [] + for relation in state.relations: + if isinstance(relation, SubordinateRelation): + # todo: verify that this unit's id is not in: + # relation.remote_unit_id + pass + + return Results(errors, []) + + def check_containers_consistency( *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: diff --git a/scenario/mocking.py b/scenario/mocking.py index 800698043..04f0e9b04 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -16,7 +16,15 @@ if TYPE_CHECKING: from scenario.state import Container as ContainerSpec - from scenario.state import Event, ExecOutput, State, _CharmSpec + from scenario.state import ( + Event, + ExecOutput, + PeerRelation, + Relation, + State, + SubordinateRelation, + _CharmSpec, + ) logger = scenario_logger.getChild("mocking") @@ -62,7 +70,9 @@ def get_pebble(self, socket_path: str) -> "Client": charm_spec=self._charm_spec, ) - def _get_relation_by_id(self, rel_id): + def _get_relation_by_id( + self, rel_id + ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( filter(lambda r: r.relation_id == rel_id, self._state.relations) @@ -121,10 +131,22 @@ def relation_ids(self, relation_name): def relation_list(self, relation_id: int): relation = self._get_relation_by_id(relation_id) - return tuple( - f"{relation.remote_app_name}/{unit_id}" - for unit_id in relation.remote_unit_ids - ) + relation_type = getattr(relation, "__type__", "") + if relation_type == "regular": + return tuple( + f"{relation.remote_app_name}/{unit_id}" + for unit_id in relation.remote_unit_ids + ) + elif relation_type == "peer": + return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) + + elif relation_type == "subordinate": + return tuple(f"{relation.primary_name}") + else: + raise RuntimeError( + f"Invalid relation type: {relation_type}; should be one of " + f"scenario.state.RelationType" + ) def config_get(self): state_config = self._state.config diff --git a/scenario/state.py b/scenario/state.py index bab77dda3..20b384ec2 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,6 +7,7 @@ import inspect import re import typing +from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union @@ -172,19 +173,18 @@ def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) -@dataclasses.dataclass -class Relation(_DCBase): - endpoint: str - remote_app_name: str = "remote" - remote_unit_ids: List[int] = dataclasses.field(default_factory=list) +class RelationType(str, Enum): + subordinate = "subordinate" + regular = "regular" + peer = "peer" - # local limit - limit: int = 1 - # scale of the remote application; number of units, leader ID? - # TODO figure out if this is relevant - scale: int = 1 - leader_id: int = 0 +@dataclasses.dataclass +class RelationBase(_DCBase): + if typing.TYPE_CHECKING: + __type__: RelationType + + endpoint: str # we can derive this from the charm's metadata interface: str = None @@ -193,11 +193,12 @@ class Relation(_DCBase): relation_id: int = -1 local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( - default_factory=dict - ) + + @property + def __databags__(self): + yield self.local_app_data + yield self.local_unit_data def __post_init__(self): global _RELATION_IDS_CTR @@ -205,35 +206,20 @@ def __post_init__(self): _RELATION_IDS_CTR += 1 self.relation_id = _RELATION_IDS_CTR - if self.remote_unit_ids and self.remote_units_data: - if not set(self.remote_unit_ids) == set(self.remote_units_data): - raise StateValidationError( - f"{self.remote_unit_ids} should include any and all IDs from {self.remote_units_data}" - ) - elif self.remote_unit_ids: - self.remote_units_data = {x: {} for x in self.remote_unit_ids} - elif self.remote_units_data: - self.remote_unit_ids = [x for x in self.remote_units_data] - else: - self.remote_unit_ids = [0] - self.remote_units_data = {0: {}} - - for databag in ( - self.local_unit_data, - self.local_app_data, - self.remote_app_data, - *self.remote_units_data.values(), - ): - if not isinstance(databag, dict): + for databag in self.__databags__: + self._validate_databag(databag) + + def _validate_databag(self, databag: dict): + if not isinstance(databag, dict): + raise StateValidationError( + f"all databags should be dicts, not {type(databag)}" + ) + for k, v in databag.items(): + if not isinstance(v, str): raise StateValidationError( - f"all databags should be dicts, not {type(databag)}" + f"all databags should be Dict[str,str]; " + f"found a value of type {type(v)}" ) - for k, v in databag.items(): - if not isinstance(v, str): - raise StateValidationError( - f"all databags should be Dict[str,str]; " - f"found a value of type {type(v)}" - ) @property def changed_event(self) -> "Event": @@ -271,6 +257,121 @@ def broken_event(self) -> "Event": ) +def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): + """Unify and validate a list of unit IDs and a mapping from said ids to databag contents. + + This allows the user to pass equivalently: + ids = [] + data = {1: {}} + + or + + ids = [1] + data = {} + + or + + ids = [1] + data = {1: {}} + + but catch the inconsistent: + + ids = [1] + data = {2: {}} + + or + + ids = [2] + data = {1: {}} + """ + if ids and data: + if not set(ids) == set(data): + raise StateValidationError( + f"{ids} should include any and all IDs from {data}" + ) + elif ids: + data = {x: {} for x in ids} + elif data: + ids = [x for x in data] + else: + ids = [0] + data = {0: {}} + return ids, data + + +@dataclasses.dataclass +class Relation(RelationBase): + __type__ = RelationType.regular + remote_app_name: str = "remote" + remote_unit_ids: List[int] = dataclasses.field(default_factory=list) + + # local limit + limit: int = 1 + + remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( + default_factory=dict + ) + + @property + def __databags__(self): + yield self.local_app_data + yield self.local_unit_data + yield self.remote_app_data + yield from self.remote_units_data.values() + + def __post_init__(self): + super().__post_init__() + self.remote_unit_ids, self.remote_units_data = unify_ids_and_remote_units_data( + self.remote_unit_ids, self.remote_units_data + ) + + +@dataclasses.dataclass +class SubordinateRelation(RelationBase): + __type__ = RelationType.subordinate + remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) + + # app name and ID of the primary that *this unit* is attached to. + primary_app_name: str = "remote" + primary_id: int = 0 + + # IDs of the peers. Consistency checks will validate that *this unit*'s ID is not in here. + peers_ids: List[int] = dataclasses.field(default_factory=list) + + @property + def __databags__(self): + yield self.local_app_data + yield self.local_unit_data + yield self.remote_app_data + yield self.remote_unit_data + + @property + def primary_name(self) -> str: + return f"{self.primary_app_name}/{self.primary_id}" + + +@dataclasses.dataclass +class PeerRelation(RelationBase): + __type__ = RelationType.peer + peers_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) + + # IDs of the peers. Consistency checks will validate that *this unit*'s ID is not in here. + peers_ids: List[int] = dataclasses.field(default_factory=list) + + @property + def __databags__(self): + yield self.local_app_data + yield self.local_unit_data + yield from self.peers_data.values() + + def __post_init__(self): + self.peers_ids, self.peers_data = unify_ids_and_remote_units_data( + self.peers_ids, self.peers_data + ) + + def _random_model_name(): import random import string diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 41541e34a..1d659703a 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -6,7 +6,14 @@ from ops.framework import EventBase, Framework from scenario.runtime import InconsistentScenarioError -from scenario.state import Relation, State, StateValidationError +from scenario.state import ( + PeerRelation, + Relation, + RelationType, + State, + StateValidationError, + SubordinateRelation, +) @pytest.fixture(scope="function") @@ -222,3 +229,15 @@ def test_relation_unit_data_bad_types(mycharm, data): def test_relation_app_data_bad_types(mycharm, data): with pytest.raises(StateValidationError): relation = Relation(endpoint="foo", interface="foo", local_app_data={"a": data}) + + +@pytest.mark.parametrize( + "relation, expected_type", + ( + (Relation("a"), RelationType.regular), + (PeerRelation("b"), RelationType.peer), + (SubordinateRelation("b"), RelationType.subordinate), + ), +) +def test_relation_type(relation, expected_type): + assert relation.__type__ == expected_type From 80ba34386687265326b02db6eda7f54159710243 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Mar 2023 15:03:31 +0200 Subject: [PATCH 203/546] fixed emission for peer/sub --- scenario/mocking.py | 1 + scenario/runtime.py | 88 ++++++++++++++++++++++---------- scenario/state.py | 9 ++-- tests/test_e2e/test_relations.py | 26 ++++++++++ 4 files changed, 92 insertions(+), 32 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 04f0e9b04..db9370b2d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -104,6 +104,7 @@ def _generate_secret_id(): return f"secret:{id}" def relation_get(self, rel_id, obj_name, app): + # fixme: this WILL definitely bork with peer and sub relation types. relation = self._get_relation_by_id(rel_id) if app and obj_name == self.app_name: return relation.local_app_data diff --git a/scenario/runtime.py b/scenario/runtime.py index b8668b598..964e216e5 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -29,7 +29,14 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.state import DeferredEvent, Event, State, StoredState, _CharmSpec + from scenario.state import ( + AnyRelation, + DeferredEvent, + Event, + State, + StoredState, + _CharmSpec, + ) _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -148,6 +155,7 @@ def __init__( if not app_name: raise ValueError('invalid metadata: mandatory "name" field is missing.') + self._app_name = app_name # todo: consider parametrizing unit-id? cfr https://github.com/canonical/ops-scenario/issues/11 self._unit_name = f"{app_name}/0" @@ -179,40 +187,64 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): # todo consider setting pwd, (python)path } - if relation := event.relation: + relation: "AnyRelation" + from scenario.state import RelationType # avoid cyclic import # todo refactor + + if event._is_relation_event and (relation := event.relation): # noqa + if relation.__type__ == RelationType.regular: + remote_app_name = relation.remote_app_name + elif relation.__type__ == RelationType.peer: + remote_app_name = self._app_name + elif relation.__type__ == RelationType.subordinate: + remote_app_name = relation.primary_app_name + else: + raise TypeError( + f"Invalid relation type for {relation}: {relation.__type__}" + ) + env.update( { "JUJU_RELATION": relation.endpoint, "JUJU_RELATION_ID": str(relation.relation_id), - "JUJU_REMOTE_APP": relation.remote_app_name, + "JUJU_REMOTE_APP": remote_app_name, } ) - if event._is_relation_event: # noqa - remote_unit_id = event.relation_remote_unit_id - if ( - remote_unit_id is None - ): # don't check truthiness because it could be int(0) - if len(relation.remote_unit_ids) == 1: - remote_unit_id = relation.remote_unit_ids[0] - logger.info( - "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, " - "but you probably should be parametrizing the event with `remote_unit` " - "to be explicit." - ) - else: - logger.warning( - "unable to determine remote unit ID; which means JUJU_REMOTE_UNIT will " - "be unset and you might get error if charm code attempts to access " - "`event.unit` in event handlers. \n" - "If that is the case, pass `remote_unit` to the Event constructor." - ) - - if remote_unit_id is not None: - remote_unit = f"{relation.remote_app_name}/{remote_unit_id}" - env["JUJU_REMOTE_UNIT"] = remote_unit - if event.name.endswith("_relation_departed"): - env["JUJU_DEPARTING_UNIT"] = remote_unit + remote_unit_id = event.relation_remote_unit_id + if ( + remote_unit_id is None + ): # don't check truthiness because it could be int(0) + if relation.__type__ == RelationType.regular: + remote_unit_ids = relation.remote_unit_ids + elif relation.__type__ == RelationType.peer: + remote_unit_ids = relation.peers_ids + elif relation.__type__ == RelationType.subordinate: + remote_unit_ids = [relation.primary_id] + else: + raise TypeError( + f"Invalid relation type for {relation}: {relation.__type__}" + ) + + if len(remote_unit_ids) == 1: + remote_unit_id = remote_unit_ids[0] + logger.info( + "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, " + "but you probably should be parametrizing the event with `remote_unit` " + "to be explicit." + ) + else: + logger.warning( + "unable to determine remote unit ID; which means JUJU_REMOTE_UNIT will " + "be unset and you might get error if charm code attempts to access " + "`event.unit` in event handlers. \n" + "If that is the case, pass `remote_unit` to the Event constructor." + ) + + if remote_unit_id is not None: + remote_unit = f"{remote_app_name}/{remote_unit_id}" + env["JUJU_REMOTE_UNIT"] = remote_unit + if event.name.endswith("_relation_departed"): + env["JUJU_DEPARTING_UNIT"] = remote_unit if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) diff --git a/scenario/state.py b/scenario/state.py index 20b384ec2..59c1c7925 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -30,6 +30,8 @@ from ops.testing import CharmType PathLike = Union[str, Path] + AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] + logger = scenario_logger.getChild("state") @@ -330,6 +332,8 @@ def __post_init__(self): @dataclasses.dataclass class SubordinateRelation(RelationBase): __type__ = RelationType.subordinate + + # todo: consider renaming them to primary_*_data remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -337,9 +341,6 @@ class SubordinateRelation(RelationBase): primary_app_name: str = "remote" primary_id: int = 0 - # IDs of the peers. Consistency checks will validate that *this unit*'s ID is not in here. - peers_ids: List[int] = dataclasses.field(default_factory=list) - @property def __databags__(self): yield self.local_app_data @@ -708,7 +709,7 @@ class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( default_factory=dict ) - relations: List[Relation] = dataclasses.field(default_factory=list) + relations: List[RelationBase] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) status: Status = dataclasses.field(default_factory=Status) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 1d659703a..66633d2e1 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -241,3 +241,29 @@ def test_relation_app_data_bad_types(mycharm, data): ) def test_relation_type(relation, expected_type): assert relation.__type__ == expected_type + + +@pytest.mark.parametrize( + "evt_name", + ("changed", "broken", "departed", "joined", "created"), +) +@pytest.mark.parametrize( + "relation", + (Relation("a"), PeerRelation("b"), SubordinateRelation("b")), +) +def test_relation_event_trigger(relation, evt_name, mycharm): + meta = { + "name": "mycharm", + "requires": {"a": {"interface": "i1"}}, + "provides": { + "c": { + "interface": "i3", + # this is a subordinate relation. + "scope": "container", + } + }, + "peers": {"b": {"interface": "i2"}}, + } + state = State(relations=[relation]).trigger( + getattr(relation, evt_name + "_event"), mycharm, meta=meta + ) From 007facdda55203285b15367c393ddb3e1ad35181 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Mar 2023 15:06:06 +0200 Subject: [PATCH 204/546] fixed relation-list for subs --- scenario/mocking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index db9370b2d..e2e27763e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -130,7 +130,7 @@ def relation_ids(self, relation_name): if rel.endpoint == relation_name ] - def relation_list(self, relation_id: int): + def relation_list(self, relation_id: int) -> Tuple[str]: relation = self._get_relation_by_id(relation_id) relation_type = getattr(relation, "__type__", "") if relation_type == "regular": @@ -142,7 +142,7 @@ def relation_list(self, relation_id: int): return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) elif relation_type == "subordinate": - return tuple(f"{relation.primary_name}") + return f"{relation.primary_name}", else: raise RuntimeError( f"Invalid relation type: {relation_type}; should be one of " From ca3fde95feed601cd8986975cfd217afce13a1f5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Mar 2023 15:37:57 +0200 Subject: [PATCH 205/546] more tests for subs --- scenario/consistency_checker.py | 36 +++++++++++++++++++++++++++----- scenario/mocking.py | 19 ++++++++++++++--- tests/test_e2e/test_relations.py | 30 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 520fcf43e..2d9a3e8b8 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -1,4 +1,6 @@ import os +from collections import Counter +from itertools import chain from typing import TYPE_CHECKING, Iterable, NamedTuple, Tuple from scenario.runtime import InconsistentScenarioError @@ -184,11 +186,35 @@ def check_relation_consistency( *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: errors = [] - for relation in state.relations: - if isinstance(relation, SubordinateRelation): - # todo: verify that this unit's id is not in: - # relation.remote_unit_id - pass + # check endpoint unicity + seen_endpoints = set() + for rel in chain( + charm_spec.meta.get("requires", ()), + charm_spec.meta.get("provides", ()), + charm_spec.meta.get("peers", ()), + ): + if rel in seen_endpoints: + errors.append("duplicate endpoint name in metadata.") + break + seen_endpoints.add(rel) + + subs = list(filter(lambda x: isinstance(x, SubordinateRelation), state.relations)) + + # check subordinate relation consistency + seen_sub_primaries = set() + sub: SubordinateRelation + for sub in subs: + sig = (sub.primary_name, sub.endpoint) + if sig in seen_sub_primaries: + errors.append( + "cannot have multiple subordinate relations on the same endpoint with the same primary." + ) + break + seen_sub_primaries.add(sig) + + for sub in subs: + # todo: verify that *this unit*'s id is not in {relation.remote_unit_id} + pass return Results(errors, []) diff --git a/scenario/mocking.py b/scenario/mocking.py index e2e27763e..bfb89e956 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -112,9 +112,21 @@ def relation_get(self, rel_id, obj_name, app): return relation.remote_app_data elif obj_name == self.unit_name: return relation.local_unit_data + + unit_id = int(obj_name.split("/")[-1]) + + relation_type = getattr(relation, "__type__", "") + # todo replace with enum value once cyclic import is fixed + if relation_type == "regular": + return relation.remote_units_data[unit_id] + elif relation_type == "peer": + return relation.peers_data[unit_id] + elif relation_type == "subordinate": + return relation.remote_unit_data else: - unit_id = obj_name.split("/")[-1] - return relation.remote_units_data[int(unit_id)] + raise TypeError( + f"Invalid relation type for {relation}: {relation.__type__}" + ) def is_leader(self): return self._state.leader @@ -132,6 +144,7 @@ def relation_ids(self, relation_name): def relation_list(self, relation_id: int) -> Tuple[str]: relation = self._get_relation_by_id(relation_id) + # todo replace with enum value once cyclic import is fixed relation_type = getattr(relation, "__type__", "") if relation_type == "regular": return tuple( @@ -142,7 +155,7 @@ def relation_list(self, relation_id: int) -> Tuple[str]: return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) elif relation_type == "subordinate": - return f"{relation.primary_name}", + return (f"{relation.primary_name}",) else: raise RuntimeError( f"Invalid relation type: {relation_type}; should be one of " diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 66633d2e1..515ff0135 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -267,3 +267,33 @@ def test_relation_event_trigger(relation, evt_name, mycharm): state = State(relations=[relation]).trigger( getattr(relation, evt_name + "_event"), mycharm, meta=meta ) + + +def test_trigger_sub_relation(mycharm): + meta = { + "name": "mycharm", + "provides": { + "foo": { + "interface": "bar", + # this is a subordinate relation. + "scope": "container", + } + }, + } + + sub1 = SubordinateRelation( + "foo", remote_unit_data={"1": "2"}, primary_app_name="primary1" + ) + sub2 = SubordinateRelation( + "foo", remote_unit_data={"3": "4"}, primary_app_name="primary2" + ) + + def post_event(charm: CharmBase): + b_relations = charm.model.relations["foo"] + assert len(b_relations) == 2 + for relation in b_relations: + assert len(relation.units) == 1 + + State(relations=[sub1, sub2]).trigger( + "update-status", mycharm, meta=meta, post_event=post_event + ) From 45168a78f4192a50619555fdccc33a8fc8745dc6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 31 Mar 2023 09:00:09 +0200 Subject: [PATCH 206/546] wip: commented out sub rel consistency check --- scenario/consistency_checker.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 2d9a3e8b8..de6fc1351 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -201,16 +201,17 @@ def check_relation_consistency( subs = list(filter(lambda x: isinstance(x, SubordinateRelation), state.relations)) # check subordinate relation consistency - seen_sub_primaries = set() - sub: SubordinateRelation - for sub in subs: - sig = (sub.primary_name, sub.endpoint) - if sig in seen_sub_primaries: - errors.append( - "cannot have multiple subordinate relations on the same endpoint with the same primary." - ) - break - seen_sub_primaries.add(sig) + # todo determine what this rule should be + # seen_sub_primaries = {} + # sub: SubordinateRelation + # for sub in subs: + # if seen_primary := seen_sub_primaries.get(sub.endpoint): + # if sub.primary_name != seen_primary.primary_name: + # errors.append( + # "cannot have multiple subordinate relations on the same " + # "endpoint with different primaries." + # ) + # break for sub in subs: # todo: verify that *this unit*'s id is not in {relation.remote_unit_id} From 32cb3a251369385d0a741e4e5349d4773f83c9cc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 31 Mar 2023 10:38:16 +0200 Subject: [PATCH 207/546] better consistency checks for subs and peers --- scenario/consistency_checker.py | 75 +++++++++++++++++++++---------- scenario/state.py | 11 ++++- tests/test_consistency_checker.py | 47 ++++++++++++++++++- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index de6fc1351..923094898 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -5,7 +5,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger -from scenario.state import SubordinateRelation, _CharmSpec, normalize_name +from scenario.state import SubordinateRelation, _CharmSpec, normalize_name, RelationType if TYPE_CHECKING: from scenario.state import Event, State @@ -21,10 +21,10 @@ class Results(NamedTuple): def check_consistency( - state: "State", - event: "Event", - charm_spec: "_CharmSpec", - juju_version: str, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version: str, ): """Validate the combination of a state, an event, a charm spec, and a juju version. @@ -49,11 +49,11 @@ def check_consistency( warnings = [] for check in ( - check_containers_consistency, - check_config_consistency, - check_event_consistency, - check_secrets_consistency, - check_relation_consistency, + check_containers_consistency, + check_config_consistency, + check_event_consistency, + check_secrets_consistency, + check_relation_consistency, ): results = check( state=state, event=event, charm_spec=charm_spec, juju_version=juju_version @@ -75,7 +75,7 @@ def check_consistency( def check_event_consistency( - *, event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the internal consistency of the Event data structure. @@ -122,7 +122,7 @@ def check_event_consistency( def check_config_consistency( - *, state: "State", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" state_config = state.config @@ -162,7 +162,7 @@ def check_config_consistency( def check_secrets_consistency( - *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs + *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs ) -> Results: """Check the consistency of Secret-related stuff.""" errors = [] @@ -183,20 +183,49 @@ def check_secrets_consistency( def check_relation_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: errors = [] - # check endpoint unicity + nonpeer_relations_meta = list(chain(charm_spec.meta.get("requires", {}).items(), + charm_spec.meta.get("provides", {}).items())) + peer_relations_meta = charm_spec.meta.get("peers", {}).items() + all_relations_meta = list(chain(nonpeer_relations_meta, + peer_relations_meta)) + + def _get_relations(r): + try: + return state.get_relations(r) + except ValueError: + return () + + # check relation types + for endpoint, _ in peer_relations_meta: + for relation in _get_relations(endpoint): + if relation.__type__ is not RelationType.peer: + errors.append(f"endpoint {endpoint} is a peer relation; " + f"expecting relation to be of type PeerRelation, gotten {type(relation)}") + + for endpoint, relation_meta in all_relations_meta: + expected_sub = relation_meta.get('scope', '') == 'container' + relations = _get_relations(endpoint) + for relation in relations: + is_sub = relation.__type__ is RelationType.subordinate + if is_sub and not expected_sub: + errors.append(f"endpoint {endpoint} is not a subordinate relation; " + f"expecting relation to be of type Relation, " + f"gotten {type(relation)}") + if expected_sub and not is_sub: + errors.append(f"endpoint {endpoint} is a subordinate relation; " + f"expecting relation to be of type SubordinateRelation, " + f"gotten {type(relation)}") + + # check for duplicate endpoint names seen_endpoints = set() - for rel in chain( - charm_spec.meta.get("requires", ()), - charm_spec.meta.get("provides", ()), - charm_spec.meta.get("peers", ()), - ): - if rel in seen_endpoints: + for endpoint, relation_meta in all_relations_meta: + if endpoint in seen_endpoints: errors.append("duplicate endpoint name in metadata.") break - seen_endpoints.add(rel) + seen_endpoints.add(endpoint) subs = list(filter(lambda x: isinstance(x, SubordinateRelation), state.relations)) @@ -221,7 +250,7 @@ def check_relation_consistency( def check_containers_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the consistency of `state.containers` vs. `charm_spec.meta` (metadata.yaml/containers).""" meta_containers = list(charm_spec.meta.get("containers", {})) diff --git a/scenario/state.py b/scenario/state.py index 59c1c7925..9006413df 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -709,7 +709,7 @@ class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( default_factory=dict ) - relations: List[RelationBase] = dataclasses.field(default_factory=list) + relations: List["AnyRelation"] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) status: Status = dataclasses.field(default_factory=Status) @@ -755,7 +755,14 @@ def get_container(self, container: Union[str, Container]) -> Container: except StopIteration as e: raise ValueError(f"container: {name}") from e - # FIXME: not a great way to obtain a delta, but is "complete" todo figure out a better way. + def get_relations(self, endpoint: str) -> Tuple["AnyRelation"]: + """Get relation from this State, based on an input relation or its endpoint name.""" + try: + return tuple(filter(lambda c: c.endpoint == endpoint, self.relations)) + except StopIteration as e: + raise ValueError(f"relation: {endpoint}") from e + + # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. def jsonpatch_delta(self, other: "State"): try: import jsonpatch diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 6e82119a0..67881e014 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -10,7 +10,7 @@ Relation, Secret, State, - _CharmSpec, + _CharmSpec, PeerRelation, SubordinateRelation, ) @@ -154,3 +154,48 @@ def test_secrets_jujuv_bad(good_v): _CharmSpec(MyCharm, {}), good_v, ) + + +def test_peer_relation_consistency(): + assert_inconsistent( + State(relations=[Relation('foo')]), + Event("bar"), + _CharmSpec(MyCharm, { + 'peers': {'foo': {'interface': 'bar'}} + }), + ) + assert_consistent( + State(relations=[PeerRelation('foo')]), + Event("bar"), + _CharmSpec(MyCharm, { + 'peers': {'foo': {'interface': 'bar'}} + }), + ) + + +def test_sub_relation_consistency(): + assert_inconsistent( + State(relations=[Relation('foo')]), + Event("bar"), + _CharmSpec(MyCharm, { + 'requires': {'foo': {'interface': 'bar', 'scope': 'container'}} + }), + ) + assert_consistent( + State(relations=[SubordinateRelation('foo')]), + Event("bar"), + _CharmSpec(MyCharm, { + 'requires': {'foo': {'interface': 'bar', 'scope': 'container'}} + }), + ) + + +def test_relation_sub_inconsistent(): + assert_inconsistent( + State(relations=[SubordinateRelation('foo')]), + Event("bar"), + _CharmSpec(MyCharm, { + 'requires': {'foo': {'interface': 'bar'}} + }), + ) + From 0c99ab9e9e7213b874e928b830206e706c8d61a3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 31 Mar 2023 12:46:13 +0200 Subject: [PATCH 208/546] dupe container inconsistency --- README.md | 6 +++ scenario/consistency_checker.py | 71 +++++++++++++++++++------------ tests/test_consistency_checker.py | 45 +++++++++++--------- 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 0ab90774e..96d6f9bee 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,13 @@ def test_relation_data(): # which is very idiomatic and superbly explicit. Noice. ``` +## Relation types +When you use `Relation`, you are specifying a 'normal' relation. But that is not the only type of relation. There are also +peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app' is, because it's the same application. + + +## Triggering Relation Events If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the event from one of its aptly-named properties: ```python diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 923094898..23a938d6e 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -5,7 +5,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger -from scenario.state import SubordinateRelation, _CharmSpec, normalize_name, RelationType +from scenario.state import RelationType, SubordinateRelation, _CharmSpec, normalize_name if TYPE_CHECKING: from scenario.state import Event, State @@ -21,10 +21,10 @@ class Results(NamedTuple): def check_consistency( - state: "State", - event: "Event", - charm_spec: "_CharmSpec", - juju_version: str, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version: str, ): """Validate the combination of a state, an event, a charm spec, and a juju version. @@ -49,11 +49,11 @@ def check_consistency( warnings = [] for check in ( - check_containers_consistency, - check_config_consistency, - check_event_consistency, - check_secrets_consistency, - check_relation_consistency, + check_containers_consistency, + check_config_consistency, + check_event_consistency, + check_secrets_consistency, + check_relation_consistency, ): results = check( state=state, event=event, charm_spec=charm_spec, juju_version=juju_version @@ -75,7 +75,7 @@ def check_consistency( def check_event_consistency( - *, event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the internal consistency of the Event data structure. @@ -122,7 +122,7 @@ def check_event_consistency( def check_config_consistency( - *, state: "State", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" state_config = state.config @@ -162,7 +162,7 @@ def check_config_consistency( def check_secrets_consistency( - *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs + *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs ) -> Results: """Check the consistency of Secret-related stuff.""" errors = [] @@ -183,14 +183,17 @@ def check_secrets_consistency( def check_relation_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: errors = [] - nonpeer_relations_meta = list(chain(charm_spec.meta.get("requires", {}).items(), - charm_spec.meta.get("provides", {}).items())) + nonpeer_relations_meta = list( + chain( + charm_spec.meta.get("requires", {}).items(), + charm_spec.meta.get("provides", {}).items(), + ) + ) peer_relations_meta = charm_spec.meta.get("peers", {}).items() - all_relations_meta = list(chain(nonpeer_relations_meta, - peer_relations_meta)) + all_relations_meta = list(chain(nonpeer_relations_meta, peer_relations_meta)) def _get_relations(r): try: @@ -202,22 +205,28 @@ def _get_relations(r): for endpoint, _ in peer_relations_meta: for relation in _get_relations(endpoint): if relation.__type__ is not RelationType.peer: - errors.append(f"endpoint {endpoint} is a peer relation; " - f"expecting relation to be of type PeerRelation, gotten {type(relation)}") + errors.append( + f"endpoint {endpoint} is a peer relation; " + f"expecting relation to be of type PeerRelation, gotten {type(relation)}" + ) for endpoint, relation_meta in all_relations_meta: - expected_sub = relation_meta.get('scope', '') == 'container' + expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: is_sub = relation.__type__ is RelationType.subordinate if is_sub and not expected_sub: - errors.append(f"endpoint {endpoint} is not a subordinate relation; " - f"expecting relation to be of type Relation, " - f"gotten {type(relation)}") + errors.append( + f"endpoint {endpoint} is not a subordinate relation; " + f"expecting relation to be of type Relation, " + f"gotten {type(relation)}" + ) if expected_sub and not is_sub: - errors.append(f"endpoint {endpoint} is a subordinate relation; " - f"expecting relation to be of type SubordinateRelation, " - f"gotten {type(relation)}") + errors.append( + f"endpoint {endpoint} is a subordinate relation; " + f"expecting relation to be of type SubordinateRelation, " + f"gotten {type(relation)}" + ) # check for duplicate endpoint names seen_endpoints = set() @@ -250,7 +259,7 @@ def _get_relations(r): def check_containers_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: """Check the consistency of `state.containers` vs. `charm_spec.meta` (metadata.yaml/containers).""" meta_containers = list(charm_spec.meta.get("containers", {})) @@ -279,4 +288,10 @@ def check_containers_consistency( f"some containers declared in the state are not specified in metadata. That's not possible. " f"Missing from metadata: {diff}." ) + + # guard against duplicate container names + names = Counter(state_containers) + if dupes := [n for n in names if names[n] > 1]: + errors.append(f"Duplicate container name(s): {dupes}.") + return Results(errors, []) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 67881e014..3a8511f94 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -7,10 +7,12 @@ RELATION_EVENTS_SUFFIX, Container, Event, + PeerRelation, Relation, Secret, State, - _CharmSpec, PeerRelation, SubordinateRelation, + SubordinateRelation, + _CharmSpec, ) @@ -158,44 +160,45 @@ def test_secrets_jujuv_bad(good_v): def test_peer_relation_consistency(): assert_inconsistent( - State(relations=[Relation('foo')]), + State(relations=[Relation("foo")]), Event("bar"), - _CharmSpec(MyCharm, { - 'peers': {'foo': {'interface': 'bar'}} - }), + _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) assert_consistent( - State(relations=[PeerRelation('foo')]), + State(relations=[PeerRelation("foo")]), Event("bar"), - _CharmSpec(MyCharm, { - 'peers': {'foo': {'interface': 'bar'}} - }), + _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) def test_sub_relation_consistency(): assert_inconsistent( - State(relations=[Relation('foo')]), + State(relations=[Relation("foo")]), Event("bar"), - _CharmSpec(MyCharm, { - 'requires': {'foo': {'interface': 'bar', 'scope': 'container'}} - }), + _CharmSpec( + MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}} + ), ) assert_consistent( - State(relations=[SubordinateRelation('foo')]), + State(relations=[SubordinateRelation("foo")]), Event("bar"), - _CharmSpec(MyCharm, { - 'requires': {'foo': {'interface': 'bar', 'scope': 'container'}} - }), + _CharmSpec( + MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}} + ), ) def test_relation_sub_inconsistent(): assert_inconsistent( - State(relations=[SubordinateRelation('foo')]), + State(relations=[SubordinateRelation("foo")]), Event("bar"), - _CharmSpec(MyCharm, { - 'requires': {'foo': {'interface': 'bar'}} - }), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) + +def test_dupe_containers_inconsistent(): + assert_inconsistent( + State(containers=[Container("foo"), Container("foo")]), + Event("bar"), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) From 8f93d1d17e4194e24b5d14e1d862dcafbebe579c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 3 Apr 2023 10:37:28 +0200 Subject: [PATCH 209/546] added todo --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 96d6f9bee..80b2e163b 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,9 @@ When you use `Relation`, you are specifying a 'normal' relation. But that is not peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app' is, because it's the same application. +TODO: describe peer/sub API. + + ## Triggering Relation Events If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the event from one of its aptly-named properties: From 823e7619c172c4b779cf911e8dc75ed01a79fb47 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 3 Apr 2023 16:03:42 +0200 Subject: [PATCH 210/546] defaulted remote_unit --- README.md | 4 ++-- scenario/runtime.py | 12 ++++++------ scenario/state.py | 10 +++++----- tests/test_e2e/test_relations.py | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 80b2e163b..78b83af5f 100644 --- a/README.md +++ b/README.md @@ -243,12 +243,12 @@ The reason for this construction is that the event is associated with some relat ### Additional event parameters All relation events have some additional metadata that does not belong in the Relation object, such as, for a relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. -In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit` the id of the remote unit that the event is about. +In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the remote unit that the event is about. ```python from scenario import Relation, Event relation = Relation(endpoint="foo", interface="bar") -remote_unit_2_is_joining_event = relation.joined_event(remote_unit=2) +remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) # which is syntactic sugar for: remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) diff --git a/scenario/runtime.py b/scenario/runtime.py index 964e216e5..595f376cd 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -168,6 +168,7 @@ def _cleanup_env(env): # os.unsetenv does not work !? del os.environ[key] + def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names are always dashes? @@ -229,15 +230,15 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): remote_unit_id = remote_unit_ids[0] logger.info( "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, " - "but you probably should be parametrizing the event with `remote_unit` " + "but you probably should be parametrizing the event with `remote_unit_id` " "to be explicit." ) else: + remote_unit_id = remote_unit_ids[0] logger.warning( - "unable to determine remote unit ID; which means JUJU_REMOTE_UNIT will " - "be unset and you might get error if charm code attempts to access " - "`event.unit` in event handlers. \n" - "If that is the case, pass `remote_unit` to the Event constructor." + "remote unit ID unset, and multiple remote unit IDs are present; " + "We will pick the first one and hope for the best. You should be passing " + "`remote_unit_id` to the Event constructor." ) if remote_unit_id is not None: @@ -410,7 +411,6 @@ def exec( logger.info(" - Clearing env") self._cleanup_env(env) - assert not os.getenv("JUJU_DEPARTING_UNIT") logger.info(" - closing storage") output_state = self._close_storage(output_state, temporary_charm_root) diff --git a/scenario/state.py b/scenario/state.py index 9006413df..9e6db986a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -169,7 +169,7 @@ def __call__(self, remote_unit: Optional[str] = None) -> "Event": f"{self._category} event constructor." ) - return Event(*self._args, *self._kwargs, relation_remote_unit=remote_unit) + return Event(*self._args, *self._kwargs, relation_remote_unit_id=remote_unit) def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) @@ -889,13 +889,13 @@ class Event(_DCBase): # - pebble? # - action? - def __call__(self, remote_unit: Optional[int] = None) -> "Event": - if remote_unit and not self._is_relation_event: + def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": + if remote_unit_id and not self._is_relation_event: raise ValueError( - "cannot pass param `remote_unit` to a " + "cannot pass param `remote_unit_id` to a " "non-relation event constructor." ) - return self.replace(relation_remote_unit_id=remote_unit) + return self.replace(relation_remote_unit_id=remote_unit_id) def __post_init__(self): if "-" in self.name: diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 515ff0135..36c574325 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -165,7 +165,7 @@ def callback(charm: CharmBase, event): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event")(remote_unit=remote_unit_id), + getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id), mycharm, meta={ "name": "local", @@ -194,8 +194,8 @@ def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): def callback(charm: CharmBase, event): assert event.app # that's always present - assert not event.unit - assert not getattr(event, "departing_unit", False) + assert event.unit + assert (evt_name == 'departed') is bool(getattr(event, "departing_unit", False)) mycharm._call = callback @@ -214,7 +214,7 @@ def callback(charm: CharmBase, event): }, ) - assert "unable to determine remote unit ID" in caplog.text + assert "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) @@ -249,7 +249,7 @@ def test_relation_type(relation, expected_type): ) @pytest.mark.parametrize( "relation", - (Relation("a"), PeerRelation("b"), SubordinateRelation("b")), + (Relation("a"), PeerRelation("b"), SubordinateRelation("c")), ) def test_relation_event_trigger(relation, evt_name, mycharm): meta = { From b67bb1b320da396f49c946252c82dad82554e35a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 4 Apr 2023 11:19:28 +0200 Subject: [PATCH 211/546] databags dedundered --- README.md | 7 +++++-- scenario/consistency_checker.py | 35 +++++++------------------------- scenario/mocking.py | 1 - scenario/runtime.py | 1 - scenario/state.py | 18 +++++++++++----- tests/test_e2e/test_relations.py | 6 ++++-- 6 files changed, 29 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 78b83af5f..dbe44e9b1 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,8 @@ def test_relation_data(): ``` ## Relation types -When you use `Relation`, you are specifying a 'normal' relation. But that is not the only type of relation. There are also -peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app' is, because it's the same application. +When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of relation. There are also +peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app', because it's the same application. TODO: describe peer/sub API. @@ -244,6 +244,9 @@ The reason for this construction is that the event is associated with some relat All relation events have some additional metadata that does not belong in the Relation object, such as, for a relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the remote unit that the event is about. +The reason that this parameter is not supplied to `Relation` but to relation events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. + +The `remote_unit_id` will default to the first ID found in the relation's `remote_unit_ids`, but if the test you are writing is close to that domain, you should probably override it and pass it manually. ```python from scenario import Relation, Event diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 23a938d6e..c5b403fc5 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -186,11 +186,9 @@ def check_relation_consistency( *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs ) -> Results: errors = [] - nonpeer_relations_meta = list( - chain( - charm_spec.meta.get("requires", {}).items(), - charm_spec.meta.get("provides", {}).items(), - ) + nonpeer_relations_meta = chain( + charm_spec.meta.get("requires", {}).items(), + charm_spec.meta.get("provides", {}).items(), ) peer_relations_meta = charm_spec.meta.get("peers", {}).items() all_relations_meta = list(chain(nonpeer_relations_meta, peer_relations_meta)) @@ -207,7 +205,7 @@ def _get_relations(r): if relation.__type__ is not RelationType.peer: errors.append( f"endpoint {endpoint} is a peer relation; " - f"expecting relation to be of type PeerRelation, gotten {type(relation)}" + f"expecting relation to be of type PeerRelation, got {type(relation)}" ) for endpoint, relation_meta in all_relations_meta: @@ -219,13 +217,13 @@ def _get_relations(r): errors.append( f"endpoint {endpoint} is not a subordinate relation; " f"expecting relation to be of type Relation, " - f"gotten {type(relation)}" + f"got {type(relation)}" ) if expected_sub and not is_sub: errors.append( - f"endpoint {endpoint} is a subordinate relation; " + f"endpoint {endpoint} is not a subordinate relation; " f"expecting relation to be of type SubordinateRelation, " - f"gotten {type(relation)}" + f"got {type(relation)}" ) # check for duplicate endpoint names @@ -236,25 +234,6 @@ def _get_relations(r): break seen_endpoints.add(endpoint) - subs = list(filter(lambda x: isinstance(x, SubordinateRelation), state.relations)) - - # check subordinate relation consistency - # todo determine what this rule should be - # seen_sub_primaries = {} - # sub: SubordinateRelation - # for sub in subs: - # if seen_primary := seen_sub_primaries.get(sub.endpoint): - # if sub.primary_name != seen_primary.primary_name: - # errors.append( - # "cannot have multiple subordinate relations on the same " - # "endpoint with different primaries." - # ) - # break - - for sub in subs: - # todo: verify that *this unit*'s id is not in {relation.remote_unit_id} - pass - return Results(errors, []) diff --git a/scenario/mocking.py b/scenario/mocking.py index bfb89e956..b3ae87fdc 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -104,7 +104,6 @@ def _generate_secret_id(): return f"secret:{id}" def relation_get(self, rel_id, obj_name, app): - # fixme: this WILL definitely bork with peer and sub relation types. relation = self._get_relation_by_id(rel_id) if app and obj_name == self.app_name: return relation.local_app_data diff --git a/scenario/runtime.py b/scenario/runtime.py index 595f376cd..0219781a0 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -168,7 +168,6 @@ def _cleanup_env(env): # os.unsetenv does not work !? del os.environ[key] - def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names are always dashes? diff --git a/scenario/state.py b/scenario/state.py index 9e6db986a..f09925e65 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -198,7 +198,8 @@ class RelationBase(_DCBase): local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @property - def __databags__(self): + def _databags(self): + """Yield all databags in this relation.""" yield self.local_app_data yield self.local_unit_data @@ -206,9 +207,13 @@ def __post_init__(self): global _RELATION_IDS_CTR if self.relation_id == -1: _RELATION_IDS_CTR += 1 + logger.info( + f"relation ID unset; automatically assigning {_RELATION_IDS_CTR}. " + f"If there are problems, pass one manually." + ) self.relation_id = _RELATION_IDS_CTR - for databag in self.__databags__: + for databag in self._databags: self._validate_databag(databag) def _validate_databag(self, databag: dict): @@ -316,7 +321,8 @@ class Relation(RelationBase): ) @property - def __databags__(self): + def _databags(self): + """Yield all databags in this relation.""" yield self.local_app_data yield self.local_unit_data yield self.remote_app_data @@ -342,7 +348,8 @@ class SubordinateRelation(RelationBase): primary_id: int = 0 @property - def __databags__(self): + def _databags(self): + """Yield all databags in this relation.""" yield self.local_app_data yield self.local_unit_data yield self.remote_app_data @@ -362,7 +369,8 @@ class PeerRelation(RelationBase): peers_ids: List[int] = dataclasses.field(default_factory=list) @property - def __databags__(self): + def _databags(self): + """Yield all databags in this relation.""" yield self.local_app_data yield self.local_unit_data yield from self.peers_data.values() diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 36c574325..cd44b28bd 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -195,7 +195,7 @@ def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): def callback(charm: CharmBase, event): assert event.app # that's always present assert event.unit - assert (evt_name == 'departed') is bool(getattr(event, "departing_unit", False)) + assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False)) mycharm._call = callback @@ -214,7 +214,9 @@ def callback(charm: CharmBase, event): }, ) - assert "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text + assert ( + "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text + ) @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) From 14f3baf50daab59f98c16273aea95bca9edabbb9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 5 Apr 2023 10:36:42 +0200 Subject: [PATCH 212/546] cyclic imports fixed --- scenario/consistency_checker.py | 6 +-- scenario/fs_mocks.py | 35 +++++++++++++ scenario/mocking.py | 69 ++++--------------------- scenario/ops_main_mock.py | 4 +- scenario/runtime.py | 26 ++-------- scenario/state.py | 89 ++++++++++++++++++++++++-------- tests/test_e2e/test_relations.py | 21 +++----- 7 files changed, 128 insertions(+), 122 deletions(-) create mode 100644 scenario/fs_mocks.py diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index c5b403fc5..98ee93ad6 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -5,7 +5,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger -from scenario.state import RelationType, SubordinateRelation, _CharmSpec, normalize_name +from scenario.state import PeerRelation, SubordinateRelation, _CharmSpec, normalize_name if TYPE_CHECKING: from scenario.state import Event, State @@ -202,7 +202,7 @@ def _get_relations(r): # check relation types for endpoint, _ in peer_relations_meta: for relation in _get_relations(endpoint): - if relation.__type__ is not RelationType.peer: + if not isinstance(relation, PeerRelation): errors.append( f"endpoint {endpoint} is a peer relation; " f"expecting relation to be of type PeerRelation, got {type(relation)}" @@ -212,7 +212,7 @@ def _get_relations(r): expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: - is_sub = relation.__type__ is RelationType.subordinate + is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( f"endpoint {endpoint} is not a subordinate relation; " diff --git a/scenario/fs_mocks.py b/scenario/fs_mocks.py new file mode 100644 index 000000000..38548aec8 --- /dev/null +++ b/scenario/fs_mocks.py @@ -0,0 +1,35 @@ +import pathlib +from typing import Dict + +from ops.testing import _TestingFilesystem, _TestingStorageMount # noqa + + +# todo consider duplicating the filesystem on State.copy() to be able to diff and have true state snapshots +class _MockStorageMount(_TestingStorageMount): + def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path): + """Creates a new simulated storage mount. + + Args: + location: The path within simulated filesystem at which this storage will be mounted. + src: The temporary on-disk location where the simulated storage will live. + """ + self._src = src + self._location = location + + try: + # for some reason this fails if src exists, even though exists_ok=True. + super().__init__(location=location, src=src) + except FileExistsError: + pass + + +class _MockFileSystem(_TestingFilesystem): + def __init__(self, mounts: Dict[str, _MockStorageMount]): + super().__init__() + self._mounts = mounts + + def add_mount(self, *args, **kwargs): + raise NotImplementedError("Cannot mutate mounts; declare them all in State.") + + def remove_mount(self, *args, **kwargs): + raise NotImplementedError("Cannot mutate mounts; declare them all in State.") diff --git a/scenario/mocking.py b/scenario/mocking.py index b3ae87fdc..8bfda1c4f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -2,7 +2,6 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import datetime -import pathlib import random from io import StringIO from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union @@ -10,9 +9,10 @@ from ops import pebble from ops.model import SecretInfo, SecretRotate, _ModelBackend from ops.pebble import Client, ExecError -from ops.testing import _TestingFilesystem, _TestingPebbleClient, _TestingStorageMount +from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger +from scenario.state import PeerRelation if TYPE_CHECKING: from scenario.state import Container as ContainerSpec @@ -113,19 +113,7 @@ def relation_get(self, rel_id, obj_name, app): return relation.local_unit_data unit_id = int(obj_name.split("/")[-1]) - - relation_type = getattr(relation, "__type__", "") - # todo replace with enum value once cyclic import is fixed - if relation_type == "regular": - return relation.remote_units_data[unit_id] - elif relation_type == "peer": - return relation.peers_data[unit_id] - elif relation_type == "subordinate": - return relation.remote_unit_data - else: - raise TypeError( - f"Invalid relation type for {relation}: {relation.__type__}" - ) + return relation._get_databag_for_remote(unit_id) # noqa def is_leader(self): return self._state.leader @@ -143,23 +131,13 @@ def relation_ids(self, relation_name): def relation_list(self, relation_id: int) -> Tuple[str]: relation = self._get_relation_by_id(relation_id) - # todo replace with enum value once cyclic import is fixed - relation_type = getattr(relation, "__type__", "") - if relation_type == "regular": - return tuple( - f"{relation.remote_app_name}/{unit_id}" - for unit_id in relation.remote_unit_ids - ) - elif relation_type == "peer": - return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) - elif relation_type == "subordinate": - return (f"{relation.primary_name}",) - else: - raise RuntimeError( - f"Invalid relation type: {relation_type}; should be one of " - f"scenario.state.RelationType" - ) + if isinstance(relation, PeerRelation): + return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) + return tuple( + f"{relation._remote_app_name}/{unit_id}" # noqa + for unit_id in relation._remote_unit_ids # noqa + ) def config_get(self): state_config = self._state.config @@ -352,35 +330,6 @@ def planned_units(self, *args, **kwargs): raise NotImplementedError("planned_units") -class _MockStorageMount(_TestingStorageMount): - def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path): - """Creates a new simulated storage mount. - - Args: - location: The path within simulated filesystem at which this storage will be mounted. - src: The temporary on-disk location where the simulated storage will live. - """ - self._src = src - self._location = location - if ( - not src.exists() - ): # we need to add this guard because the directory might exist already. - src.mkdir(exist_ok=True, parents=True) - - -# todo consider duplicating the filesystem on State.copy() to be able to diff and have true state snapshots -class _MockFileSystem(_TestingFilesystem): - def __init__(self, mounts: Dict[str, _MockStorageMount]): - super().__init__() - self._mounts = mounts - - def add_mount(self, *args, **kwargs): - raise NotImplementedError("Cannot mutate mounts; declare them all in State.") - - def remove_mount(self, *args, **kwargs): - raise NotImplementedError("Cannot mutate mounts; declare them all in State.") - - class _MockPebbleClient(_TestingPebbleClient): def __init__( self, diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index e7723ee33..85b34a784 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -14,7 +14,6 @@ from ops.main import CHARM_STATE_FILE, _Dispatcher, _emit_charm_event, _get_charm_dir from scenario.logger import logger as scenario_logger -from scenario.mocking import _MockModelBackend if TYPE_CHECKING: from ops.testing import CharmType @@ -38,6 +37,9 @@ def main( """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() + + from scenario.mocking import _MockModelBackend + model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false state=state, event=event, charm_spec=charm_spec ) diff --git a/scenario/runtime.py b/scenario/runtime.py index 0219781a0..647035df7 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -25,6 +25,7 @@ from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError +from scenario.state import DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: from ops.testing import CharmType @@ -80,7 +81,6 @@ def _open_db(self) -> Optional[SQLiteStorage]: def get_stored_state(self) -> List["StoredState"]: """Load any StoredState data structures from the db.""" - from scenario.state import StoredState # avoid cyclic import db = self._open_db() @@ -99,7 +99,6 @@ def get_stored_state(self) -> List["StoredState"]: def get_deferred_events(self) -> List["DeferredEvent"]: """Load any DeferredEvent data structures from the db.""" - from scenario.state import DeferredEvent # avoid cyclic import db = self._open_db() @@ -188,20 +187,12 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): } relation: "AnyRelation" - from scenario.state import RelationType # avoid cyclic import # todo refactor if event._is_relation_event and (relation := event.relation): # noqa - if relation.__type__ == RelationType.regular: - remote_app_name = relation.remote_app_name - elif relation.__type__ == RelationType.peer: + if isinstance(relation, PeerRelation): remote_app_name = self._app_name - elif relation.__type__ == RelationType.subordinate: - remote_app_name = relation.primary_app_name else: - raise TypeError( - f"Invalid relation type for {relation}: {relation.__type__}" - ) - + remote_app_name = relation._remote_app_name # noqa env.update( { "JUJU_RELATION": relation.endpoint, @@ -214,16 +205,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if ( remote_unit_id is None ): # don't check truthiness because it could be int(0) - if relation.__type__ == RelationType.regular: - remote_unit_ids = relation.remote_unit_ids - elif relation.__type__ == RelationType.peer: - remote_unit_ids = relation.peers_ids - elif relation.__type__ == RelationType.subordinate: - remote_unit_ids = [relation.primary_id] - else: - raise TypeError( - f"Invalid relation type for {relation}: {relation.__type__}" - ) + remote_unit_ids = relation._remote_unit_ids # noqa if len(remote_unit_ids) == 1: remote_unit_id = remote_unit_ids[0] diff --git a/scenario/state.py b/scenario/state.py index f09925e65..4ec81857a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,7 +7,6 @@ import inspect import re import typing -from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union @@ -18,9 +17,8 @@ from ops.charm import CharmEvents from ops.model import SecretRotate, StatusBase +from scenario.fs_mocks import _MockFileSystem, _MockStorageMount from scenario.logger import logger as scenario_logger -from scenario.mocking import _MockFileSystem, _MockStorageMount -from scenario.runtime import trigger as _runtime_trigger if typing.TYPE_CHECKING: try: @@ -32,7 +30,6 @@ PathLike = Union[str, Path] AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] - logger = scenario_logger.getChild("state") ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" @@ -175,17 +172,8 @@ def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) -class RelationType(str, Enum): - subordinate = "subordinate" - regular = "regular" - peer = "peer" - - @dataclasses.dataclass class RelationBase(_DCBase): - if typing.TYPE_CHECKING: - __type__: RelationType - endpoint: str # we can derive this from the charm's metadata @@ -203,7 +191,27 @@ def _databags(self): yield self.local_app_data yield self.local_unit_data + @property + def _remote_app_name(self) -> str: + """Who is on the other end of this relation?""" + raise NotImplementedError() + + @property + def _remote_unit_ids(self) -> Tuple[int]: + """Ids of the units on the other end of this relation.""" + raise NotImplementedError() + + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + """Return the databag for some remote unit ID.""" + raise NotImplementedError() + def __post_init__(self): + if type(self) is RelationBase: + raise RuntimeError( + "RelationBase cannot be instantiated directly; " + "please use Relation, PeerRelation, or SubordinateRelation" + ) + global _RELATION_IDS_CTR if self.relation_id == -1: _RELATION_IDS_CTR += 1 @@ -308,7 +316,6 @@ def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): @dataclasses.dataclass class Relation(RelationBase): - __type__ = RelationType.regular remote_app_name: str = "remote" remote_unit_ids: List[int] = dataclasses.field(default_factory=list) @@ -320,6 +327,20 @@ class Relation(RelationBase): default_factory=dict ) + @property + def _remote_app_name(self) -> str: + """Who is on the other end of this relation?""" + return self.remote_app_name + + @property + def _remote_unit_ids(self) -> Tuple[int]: + """Ids of the units on the other end of this relation.""" + return tuple(self.remote_unit_ids) + + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + """Return the databag for some remote unit ID.""" + return self.remote_units_data[unit_id] + @property def _databags(self): """Yield all databags in this relation.""" @@ -337,8 +358,6 @@ def __post_init__(self): @dataclasses.dataclass class SubordinateRelation(RelationBase): - __type__ = RelationType.subordinate - # todo: consider renaming them to primary_*_data remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -347,6 +366,20 @@ class SubordinateRelation(RelationBase): primary_app_name: str = "remote" primary_id: int = 0 + @property + def _remote_app_name(self) -> str: + """Who is on the other end of this relation?""" + return self.primary_app_name + + @property + def _remote_unit_ids(self) -> Tuple[int]: + """Ids of the units on the other end of this relation.""" + return (self.primary_id,) + + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + """Return the databag for some remote unit ID.""" + return self.remote_unit_data + @property def _databags(self): """Yield all databags in this relation.""" @@ -362,7 +395,6 @@ def primary_name(self) -> str: @dataclasses.dataclass class PeerRelation(RelationBase): - __type__ = RelationType.peer peers_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) # IDs of the peers. Consistency checks will validate that *this unit*'s ID is not in here. @@ -375,6 +407,21 @@ def _databags(self): yield self.local_unit_data yield from self.peers_data.values() + @property + def _remote_app_name(self) -> str: + """Who is on the other end of this relation?""" + # surprise! It's myself. + raise ValueError("peer relations don't quite have a remote end.") + + @property + def _remote_unit_ids(self) -> Tuple[int]: + """Ids of the units on the other end of this relation.""" + return tuple(self.peers_ids) + + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + """Return the databag for some remote unit ID.""" + return self.peers_data[unit_id] + def __post_init__(self): self.peers_ids, self.peers_data = unify_ids_and_remote_units_data( self.peers_ids, self.peers_data @@ -516,7 +563,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: return infos @property - def filesystem(self) -> _MockFileSystem: + def filesystem(self) -> "_MockFileSystem": mounts = { name: _MockStorageMount( src=Path(spec.src), location=PurePosixPath(spec.location) @@ -801,6 +848,8 @@ def trigger( juju_version: str = "3.0", ) -> "State": """Fluent API for trigger. See runtime.trigger's docstring.""" + from scenario.runtime import trigger as _runtime_trigger + return _runtime_trigger( state=self, event=event, @@ -814,8 +863,6 @@ def trigger( juju_version=juju_version, ) - trigger.__doc__ = _runtime_trigger.__doc__ - @dataclasses.dataclass class _CharmSpec(_DCBase): @@ -882,7 +929,7 @@ class Event(_DCBase): kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) # if this is a relation event, the relation it refers to - relation: Optional[Relation] = None + relation: Optional["AnyRelation"] = None # and the name of the remote unit this relation event is about relation_remote_unit_id: Optional[int] = None diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index cd44b28bd..7d429aa99 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -1,15 +1,13 @@ -import os from typing import Type import pytest from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent from ops.framework import EventBase, Framework -from scenario.runtime import InconsistentScenarioError from scenario.state import ( PeerRelation, Relation, - RelationType, + RelationBase, State, StateValidationError, SubordinateRelation, @@ -233,18 +231,6 @@ def test_relation_app_data_bad_types(mycharm, data): relation = Relation(endpoint="foo", interface="foo", local_app_data={"a": data}) -@pytest.mark.parametrize( - "relation, expected_type", - ( - (Relation("a"), RelationType.regular), - (PeerRelation("b"), RelationType.peer), - (SubordinateRelation("b"), RelationType.subordinate), - ), -) -def test_relation_type(relation, expected_type): - assert relation.__type__ == expected_type - - @pytest.mark.parametrize( "evt_name", ("changed", "broken", "departed", "joined", "created"), @@ -299,3 +285,8 @@ def post_event(charm: CharmBase): State(relations=[sub1, sub2]).trigger( "update-status", mycharm, meta=meta, post_event=post_event ) + + +def test_cannot_instantiate_relationbase(): + with pytest.raises(RuntimeError): + RelationBase("") From cb9f43217f1aa69a045d6383abe97cd31b49b3b9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 5 Apr 2023 11:01:36 +0200 Subject: [PATCH 213/546] docs --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++--- scenario/runtime.py | 8 ++++-- scenario/sequences.py | 2 ++ scenario/state.py | 2 ++ tests/test_runtime.py | 6 +++-- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dbe44e9b1..c618c9d68 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ def _on_event(self, _event): You can verify that the charm has followed the expected path by checking the **unit status history** like so: ```python +from charm import MyCharm from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownStatus from scenario import State @@ -148,6 +149,7 @@ def test_statuses(): UnknownStatus(), MaintenanceStatus('determining who the ruler is...'), WaitingStatus('checking this is right...'), + ActiveStatus("I am ruled"), ] ``` @@ -155,7 +157,7 @@ Note that the current status is not in the **unit status history**. Also note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen". -If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' in State. +If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python from ops.model import ActiveStatus @@ -211,13 +213,63 @@ def test_relation_data(): # which is very idiomatic and superbly explicit. Noice. ``` -## Relation types + +The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be derived from the charm's `metadata.yaml`. When fully defaulted, a relation is 'empty'. There are no remote units, the remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the databags yet. + +That is typically the state of a relation when the first unit joins it. When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of relation. There are also peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app', because it's the same application. +### PeerRelation +To declare a peer relation, you should use `scenario.state.PeerRelation`. +The core difference with regular relations is that peer relations do not have a "remote app" (it's this app, in fact). +So unlike `Relation`, a `PeerRelation` does not have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`: +- `Relation.remote_unit_ids` maps to `PeerRelation.peers_ids` +- `Relation.remote_units_data` maps to `PeerRelation.peers_data` + +```python +from scenario.state import PeerRelation + +relation = PeerRelation( + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, +) +``` + +be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `peers_data` or `peers_ids`, as that would be flagged by the Consistency Checker: +```python +from scenario import State, PeerRelation + +State(relations=[ + PeerRelation( + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, + )]).trigger("start", ..., unit_id=1) # invalid: this unit's id cannot be the ID of a peer. + + +``` + +### SubordinateRelation +To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`. +The core difference with regular relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit that this unit can see). +So unlike `Relation`, a `SubordinateRelation` does not have a `remote_units_data` argument. Instead, it has a `remote_unit_data` taking a single `Dict[str:str]`, and takes the primary unit ID as a separate argument. +Also, it talks in terms of `primary`: +- `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs) +- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) +- `Relation.remote_app_name` maps to `SubordinateRelation.primary_app_name` -TODO: describe peer/sub API. +```python +from scenario.state import SubordinateRelation + +relation = SubordinateRelation( + endpoint="peers", + remote_unit_data={"foo": "bar"}, + primary_app_name="zookeeper", + primary_id=42 +) +relation.primary_name # "zookeeper/42" +``` ## Triggering Relation Events diff --git a/scenario/runtime.py b/scenario/runtime.py index 647035df7..680f945d6 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -145,6 +145,7 @@ def __init__( charm_spec: "_CharmSpec", charm_root: Optional["PathLike"] = None, juju_version: str = "3.0.0", + unit_id: int = 0, ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -155,8 +156,8 @@ def __init__( raise ValueError('invalid metadata: mandatory "name" field is missing.') self._app_name = app_name - # todo: consider parametrizing unit-id? cfr https://github.com/canonical/ops-scenario/issues/11 - self._unit_name = f"{app_name}/0" + self._unit_id = unit_id + self._unit_name = f"{app_name}/{unit_id}" @staticmethod def _cleanup_env(env): @@ -412,6 +413,7 @@ def trigger( config: Optional[Dict[str, Any]] = None, charm_root: Optional[Dict["PathLike", "PathLike"]] = None, juju_version: str = "3.0", + unit_id: int = 0, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -433,6 +435,7 @@ def trigger( :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. + :arg unit_id: The ID of the Juju unit that is charm execution is running on. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -464,6 +467,7 @@ def trigger( charm_spec=spec, juju_version=juju_version, charm_root=charm_root, + unit_id=unit_id, ) return runtime.exec( diff --git a/scenario/sequences.py b/scenario/sequences.py index 040441269..fa30b4dca 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -96,6 +96,7 @@ def check_builtin_sequences( template_state: State = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + unit_id: int = 0, ): """Test that all the builtin startup and teardown events can fire without errors. @@ -124,4 +125,5 @@ def check_builtin_sequences( config=config, pre_event=pre_event, post_event=post_event, + unit_id=unit_id, ) diff --git a/scenario/state.py b/scenario/state.py index 4ec81857a..9b453d8d5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -846,6 +846,7 @@ def trigger( config: Optional[Dict[str, Any]] = None, charm_root: Optional["PathLike"] = None, juju_version: str = "3.0", + unit_id: int = 0, ) -> "State": """Fluent API for trigger. See runtime.trigger's docstring.""" from scenario.runtime import trigger as _runtime_trigger @@ -861,6 +862,7 @@ def trigger( config=config, charm_root=charm_root, juju_version=juju_version, + unit_id=unit_id, ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d6ef9be12..954ec9341 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -88,7 +88,8 @@ class MyEvt(EventBase): @pytest.mark.parametrize("app_name", ("foo", "bar-baz", "QuX2")) -def test_unit_name(app_name): +@pytest.mark.parametrize("unit_id", (1, 2, 42)) +def test_unit_name(app_name, unit_id): meta = { "name": app_name, } @@ -100,9 +101,10 @@ def test_unit_name(app_name): my_charm_type, meta=meta, ), + unit_id=unit_id, ) def post_event(charm: CharmBase): - assert charm.unit.name == f"{app_name}/0" + assert charm.unit.name == f"{app_name}/{unit_id}" runtime.exec(state=State(), event=Event("start"), post_event=post_event) From d6f72ce0c96c4e914335a40353119e4e211d8dc5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 20 Apr 2023 10:41:33 +0200 Subject: [PATCH 214/546] gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d4e11a999..63f5dbd51 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ *.py[cod] .idea *.egg-info -dist/ \ No newline at end of file +dist/ +*.pytest_cache \ No newline at end of file From 27f2c9cc1f16113aa9ad1b1078dc449787b50403 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 20 Apr 2023 11:19:59 +0200 Subject: [PATCH 215/546] fixed loglevel --- pyproject.toml | 4 ++-- scenario/scripts/logger.py | 17 +++++++++++++++++ scenario/scripts/main.py | 13 ++----------- scenario/scripts/snapshot.py | 8 +++++--- scenario/scripts/state_apply.py | 2 +- 5 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 scenario/scripts/logger.py diff --git a/pyproject.toml b/pyproject.toml index 8051aff00..f4ed33d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [build-system] -requires = ["setuptools"] +requires = ["setuptools>=62"] build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.2" +version = "2.1.3.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/scripts/logger.py b/scenario/scripts/logger.py new file mode 100644 index 000000000..98cadfb1f --- /dev/null +++ b/scenario/scripts/logger.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +import os + +logger = logging.getLogger(__file__) + + +def setup_logging(verbosity: int): + base_loglevel = int(os.getenv("LOGLEVEL", 30)) + verbosity = min(verbosity, 2) + loglevel = base_loglevel - (verbosity * 10) + logging.basicConfig(format="%(message)s") + logging.getLogger().setLevel(logging.WARNING) + logger.setLevel(loglevel) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index ad656f050..c4b61e6f6 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -2,22 +2,13 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -import logging -import os - import typer from scenario.scripts.snapshot import snapshot +from scenario.scripts import logger from scenario.scripts.state_apply import state_apply -def _setup_logging(verbosity: int): - base_loglevel = int(os.getenv("LOGLEVEL", 30)) - verbosity = min(verbosity, 2) - loglevel = base_loglevel - (verbosity * 10) - logging.basicConfig(level=loglevel, format="%(message)s") - - def main(): app = typer.Typer( name="scenario", @@ -33,7 +24,7 @@ def main(): @app.callback() def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): - _setup_logging(verbose) + logger.setup_logging(verbose) app() diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 7253d8238..8c73a4d08 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + import datetime import json -import logging import os import re import shlex @@ -24,6 +24,7 @@ from scenario.runtime import UnitStateDB from scenario.scripts.errors import InvalidTargetUnitName, InvalidTargetModelName +from scenario.scripts.logger import logger as root_scripts_logger from scenario.scripts.utils import JujuUnitName from scenario.state import ( Address, @@ -39,7 +40,7 @@ _EntityStatus, ) -logger = logging.getLogger("snapshot") +logger = root_scripts_logger.getChild(__file__) JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) JUJU_CONFIG_KEYS = frozenset({}) @@ -803,7 +804,8 @@ def snapshot( "--format", help="How to format the output. " "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). " + "else it will be ugly but valid python code). All you need to do then is import the necessary " + "objects from scenario.state, and you should have a valid State object." "``json``: Outputs a Jsonified State object. Perfect for storage. " "``pytest``: Outputs a full-blown pytest scenario test based on this State. " "Pipe it to a file and fill in the blanks.", diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index c7c083d65..94f1e9e0b 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -218,7 +218,7 @@ def state_apply( ) -# for the benefit of script usage +# for the benefit of scripted usage _state_apply.__doc__ = state_apply.__doc__ if __name__ == "__main__": From a720f3471f8702efddd7f0c52d06a5ee06db428d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Apr 2023 15:13:04 +0200 Subject: [PATCH 216/546] state-apply nearly there --- scenario/scripts/state_apply.py | 140 ++++++++++++++++---------------- 1 file changed, 68 insertions(+), 72 deletions(-) diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index 94f1e9e0b..564a44822 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -16,11 +16,12 @@ from scenario.scripts.utils import JujuUnitName from scenario.state import ( Container, + DeferredEvent, Relation, Secret, State, Status, - DeferredEvent, StoredState, + StoredState, ) SNAPSHOT_DATA_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() @@ -29,18 +30,18 @@ def set_status(status: Status) -> List[str]: - logger.info('preparing status...') + logger.info("preparing status...") cmds = [] - cmds.append(f'status-set {status.unit.name} {status.unit.message}') - cmds.append(f'status-set --application {status.app.name} {status.app.message}') - cmds.append(f'application-version-set {status.app_version}') + cmds.append(f"status-set {status.unit.name} {status.unit.message}") + cmds.append(f"status-set --application {status.app.name} {status.app.message}") + cmds.append(f"application-version-set {status.app_version}") return cmds def set_relations(relations: Iterable[Relation]) -> List[str]: - logger.info('preparing relations...') + logger.info("preparing relations...") logger.warning("set_relations not implemented yet") return [] @@ -75,11 +76,7 @@ def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: return [] -def exec_in_unit( - target: JujuUnitName, - model: str, - cmds: List[str] -): +def exec_in_unit(target: JujuUnitName, model: str, cmds: List[str]): logger.info("Running juju exec...") _model = f" -m {model}" if model else "" @@ -87,36 +84,38 @@ def exec_in_unit( try: run(f'juju exec -u {target}{_model} -- "{cmd_fmt}"') except CalledProcessError as e: - raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.") + raise StateApplyError( + f"Failed to apply state: process exited with {e.returncode}; " + f"stdout = {e.stdout}; " + f"stderr = {e.stderr}." + ) -def run_commands( - cmds: List[str] -): +def run_commands(cmds: List[str]): logger.info("Applying remaining state...") for cmd in cmds: try: run(cmd) except CalledProcessError as e: # todo: should we log and continue instead? - raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.") + raise StateApplyError( + f"Failed to apply state: process exited with {e.returncode}; " + f"stdout = {e.stdout}; " + f"stderr = {e.stderr}." + ) def _state_apply( - target: str, - state: State, - model: Optional[str] = None, - include: str = None, - include_juju_relation_data=False, - push_files: Dict[str, List[Path]] = None, - snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, + target: str, + state: State, + model: Optional[str] = None, + include: str = None, + include_juju_relation_data=False, + push_files: Dict[str, List[Path]] = None, + snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, ): """see state_apply's docstring""" - logger.info('Starting state-apply...') + logger.info("Starting state-apply...") try: target = JujuUnitName(target) @@ -154,49 +153,49 @@ def if_include(key, fn): # non-juju-exec commands are ran one by one, individually run_commands(cmds) - logger.info('Done!') + logger.info("Done!") def state_apply( - target: str = typer.Argument(..., help="Target unit."), - state: Path = typer.Argument( - ..., - help="Source State to apply. Json file containing a State data structure; " - "the same you would obtain by running snapshot." - ), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - include: str = typer.Option( - "scrkSdt", - "--include", - "-i", - help="What parts of the state to apply. Defaults to: all of them. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``s``: status, ``S``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - push_files: Path = typer.Option( - None, - "--push-files", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be pushed to the each container.", - ), - # TODO: generalize "push_files" to allow passing '.' for the 'charm' container or 'the machine'. - data_dir: Path = typer.Option( - SNAPSHOT_DATA_DIR, - "--data-dir", - help="Directory in which to any files associated with the state are stored. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), + target: str = typer.Argument(..., help="Target unit."), + state: Path = typer.Argument( + ..., + help="Source State to apply. Json file containing a State data structure; " + "the same you would obtain by running snapshot.", + ), + model: Optional[str] = typer.Option( + None, "-m", "--model", help="Which model to look at." + ), + include: str = typer.Option( + "scrkSdt", + "--include", + "-i", + help="What parts of the state to apply. Defaults to: all of them. " + "``r``: relation, ``c``: config, ``k``: containers, " + "``s``: status, ``S``: secrets(!), " + "``d``: deferred events, ``t``: stored state.", + ), + include_juju_relation_data: bool = typer.Option( + False, + "--include-juju-relation-data", + help="Whether to include in the relation data the default juju keys (egress-subnets," + "ingress-address, private-address).", + is_flag=True, + ), + push_files: Path = typer.Option( + None, + "--push-files", + help="Path to a local file containing a json spec of files to be fetched from the unit. " + "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " + "the files that need to be pushed to the each container.", + ), + # TODO: generalize "push_files" to allow passing '.' for the 'charm' container or 'the machine'. + data_dir: Path = typer.Option( + SNAPSHOT_DATA_DIR, + "--data-dir", + help="Directory in which to any files associated with the state are stored. In the case " + "of k8s charms, this might mean files obtained through Mounts,", + ), ): """Gather and output the State of a remote target unit. @@ -224,7 +223,4 @@ def state_apply( if __name__ == "__main__": from scenario import State - _state_apply( - "zookeeper/0", model="foo", - state=State() - ) + _state_apply("zookeeper/0", model="foo", state=State()) From d53e0763962c8934da0c2bb3aac79f94f6fcdf7f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Apr 2023 15:15:27 +0200 Subject: [PATCH 217/546] fixed main --- scenario/scripts/main.py | 3 +- scenario/scripts/state_apply.py | 230 -------------------------------- 2 files changed, 1 insertion(+), 232 deletions(-) delete mode 100644 scenario/scripts/state_apply.py diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 43d654bc9..943938b20 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -6,7 +6,6 @@ from scenario.scripts.snapshot import snapshot from scenario.scripts import logger -from scenario.scripts.state_apply import state_apply def main(): @@ -20,7 +19,7 @@ def main(): ) app.command(name="snapshot", no_args_is_help=True)(snapshot) - app.command(name="state-apply", no_args_is_help=True)(state_apply) + app.command(name="_", hidden=True)(lambda: None) @app.callback() def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py deleted file mode 100644 index 94f1e9e0b..000000000 --- a/scenario/scripts/state_apply.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import json -import logging -import os -import sys -from pathlib import Path -from subprocess import CalledProcessError, run -from typing import Dict, Iterable, List, Optional - -import typer - -from scenario.scripts.errors import InvalidTargetUnitName, StateApplyError -from scenario.scripts.utils import JujuUnitName -from scenario.state import ( - Container, - Relation, - Secret, - State, - Status, - DeferredEvent, StoredState, -) - -SNAPSHOT_DATA_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() - -logger = logging.getLogger("snapshot") - - -def set_status(status: Status) -> List[str]: - logger.info('preparing status...') - cmds = [] - - cmds.append(f'status-set {status.unit.name} {status.unit.message}') - cmds.append(f'status-set --application {status.app.name} {status.app.message}') - cmds.append(f'application-version-set {status.app_version}') - - return cmds - - -def set_relations(relations: Iterable[Relation]) -> List[str]: - logger.info('preparing relations...') - logger.warning("set_relations not implemented yet") - return [] - - -def set_config(config: Dict[str, str]) -> List[str]: - logger.info("preparing config...") - logger.warning("set_config not implemented yet") - return [] - - -def set_containers(containers: Iterable[Container]) -> List[str]: - logger.info("preparing containers...") - logger.warning("set_containers not implemented yet") - return [] - - -def set_secrets(secrets: Iterable[Secret]) -> List[str]: - logger.info("preparing secrets...") - logger.warning("set_secrets not implemented yet") - return [] - - -def set_deferred_events(deferred_events: Iterable[DeferredEvent]) -> List[str]: - logger.info("preparing deferred_events...") - logger.warning("set_deferred_events not implemented yet") - return [] - - -def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: - logger.info("preparing stored_state...") - logger.warning("set_stored_state not implemented yet") - return [] - - -def exec_in_unit( - target: JujuUnitName, - model: str, - cmds: List[str] -): - logger.info("Running juju exec...") - - _model = f" -m {model}" if model else "" - cmd_fmt = "; ".join(cmds) - try: - run(f'juju exec -u {target}{_model} -- "{cmd_fmt}"') - except CalledProcessError as e: - raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.") - - -def run_commands( - cmds: List[str] -): - logger.info("Applying remaining state...") - for cmd in cmds: - try: - run(cmd) - except CalledProcessError as e: - # todo: should we log and continue instead? - raise StateApplyError(f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.") - - -def _state_apply( - target: str, - state: State, - model: Optional[str] = None, - include: str = None, - include_juju_relation_data=False, - push_files: Dict[str, List[Path]] = None, - snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, -): - """see state_apply's docstring""" - logger.info('Starting state-apply...') - - try: - target = JujuUnitName(target) - except InvalidTargetUnitName: - logger.critical( - f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." - ) - sys.exit(1) - - logger.info(f'beginning snapshot of {target} in model {model or ""}...') - - def if_include(key, fn): - if include is None or key in include: - return fn() - return [] - - j_exec_cmds: List[str] = [] - - j_exec_cmds += if_include("s", lambda: set_status(state.status)) - j_exec_cmds += if_include("r", lambda: set_relations(state.relations)) - j_exec_cmds += if_include("S", lambda: set_secrets(state.secrets)) - - cmds: List[str] = [] - - # todo: config is a bit special because it's not owned by the unit but by the cloud admin. - # should it be included in state-apply? - # if_include("c", lambda: set_config(state.config)) - cmds += if_include("k", lambda: set_containers(state.containers)) - cmds += if_include("d", lambda: set_deferred_events(state.deferred)) - cmds += if_include("t", lambda: set_stored_state(state.stored_state)) - - # we gather juju-exec commands to run them all at once in the unit. - exec_in_unit(target, model, j_exec_cmds) - # non-juju-exec commands are ran one by one, individually - run_commands(cmds) - - logger.info('Done!') - - -def state_apply( - target: str = typer.Argument(..., help="Target unit."), - state: Path = typer.Argument( - ..., - help="Source State to apply. Json file containing a State data structure; " - "the same you would obtain by running snapshot." - ), - model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." - ), - include: str = typer.Option( - "scrkSdt", - "--include", - "-i", - help="What parts of the state to apply. Defaults to: all of them. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``s``: status, ``S``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - push_files: Path = typer.Option( - None, - "--push-files", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be pushed to the each container.", - ), - # TODO: generalize "push_files" to allow passing '.' for the 'charm' container or 'the machine'. - data_dir: Path = typer.Option( - SNAPSHOT_DATA_DIR, - "--data-dir", - help="Directory in which to any files associated with the state are stored. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), -): - """Gather and output the State of a remote target unit. - - If black is available, the output will be piped through it for formatting. - - Usage: state-apply myapp/0 > ./tests/scenario/case1.py - """ - push_files_ = json.loads(push_files.read_text()) if push_files else None - state_ = json.loads(state.read_text()) - - return _state_apply( - target=target, - state=state_, - model=model, - include=include, - include_juju_relation_data=include_juju_relation_data, - snapshot_data_dir=data_dir, - push_files=push_files_, - ) - - -# for the benefit of scripted usage -_state_apply.__doc__ = state_apply.__doc__ - -if __name__ == "__main__": - from scenario import State - - _state_apply( - "zookeeper/0", model="foo", - state=State() - ) From 2ac131c8a432838bcac51e43044d760d4ab30903 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Apr 2023 15:16:01 +0200 Subject: [PATCH 218/546] fixed snapshot if no relations --- scenario/scripts/main.py | 2 +- scenario/scripts/snapshot.py | 94 ++++++++++++++++++++++-------------- scenario/state.py | 8 +-- 3 files changed, 63 insertions(+), 41 deletions(-) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 943938b20..16130f649 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -4,8 +4,8 @@ import typer -from scenario.scripts.snapshot import snapshot from scenario.scripts import logger +from scenario.scripts.snapshot import snapshot def main(): diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 2482cd883..86db7f55a 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -14,7 +14,6 @@ from itertools import chain from pathlib import Path from subprocess import run -from textwrap import dedent from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union import ops.pebble @@ -23,7 +22,7 @@ from ops.storage import SQLiteStorage from scenario.runtime import UnitStateDB -from scenario.scripts.errors import InvalidTargetUnitName, InvalidTargetModelName +from scenario.scripts.errors import InvalidTargetModelName, InvalidTargetUnitName from scenario.scripts.logger import logger as root_scripts_logger from scenario.scripts.utils import JujuUnitName from scenario.state import ( @@ -64,10 +63,30 @@ def _try_format(string: str): def format_state(state: State): - """Pretty-print this State as-is.""" + """Stringify this State as nicely as possible.""" return _try_format(repr(state)) +PYTEST_TEST_TEMPLATE = """ +from scenario.state import * +from charm import {ct} + +def test_case(): + # Arrange: prepare the state + state = {state} + + #Act: trigger an event on the state + out = state.trigger( + {en} + {ct} + juju_version="{jv}" + ) + + # Assert: verify that the output state is the way you want it to be + # TODO: add assertions +""" + + def format_test_case( state: State, charm_type_name: str = None, @@ -78,28 +97,9 @@ def format_test_case( ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name" en = event_name or "EVENT_NAME, # TODO: replace with event name" jv = juju_version or "3.0, # TODO: check juju version is correct" + state_fmt = repr(state) return _try_format( - dedent( - f""" - from scenario.state import * - from charm import {ct} - - def test_case(): - # Arrange: prepare the state - state = {state} - - #Act: trigger an event on the state - out = state.trigger( - {en} - {ct} - juju_version="{jv}" - ) - - # Assert: verify that the output state is the way you want it to be - # TODO: add assertions - - """ - ) + PYTEST_TEST_TEMPLATE.format(state=state_fmt, ct=ct, en=en, jv=jv) ) @@ -191,8 +191,7 @@ def get_networks( ) -> List[Network]: """Get all Networks from this unit.""" logger.info("getting networks...") - networks = [] - networks.append(get_network(target, model, "juju-info")) + networks = [get_network(target, model, "juju-info")] endpoints = relations # only alive relations if include_dead: @@ -224,11 +223,6 @@ def get_metadata(target: JujuUnitName, model: Model): class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" - - # TODO: there is a .pebble.state in kubernetes containers at - # /var/lib/pebble/default/.pebble.state - # figure out what it's for. - def __init__( self, container: str, target: JujuUnitName, model: Optional[str] = None ): @@ -447,7 +441,10 @@ def get_status(juju_status: Dict, target: JujuUnitName) -> Status: def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: """Parse `juju status` to get the relation names owned by the target.""" app = juju_status["applications"][target.app_name] - relations = tuple(app["relations"].keys()) + relations_raw = app.get("relations", None) + if not relations_raw: + return () + relations = tuple(relations_raw.keys()) return relations @@ -549,6 +546,11 @@ def _clean(relation_data: dict): local_app_data = json.loads(local_app_data_raw) some_remote_unit_id = JujuUnitName(next(iter(related_units))) + + # fixme: at the moment the juju CLI offers no way to see what type of relation this is; + # if it's a peer relation or a subordinate, we should use the corresponding + # scenario.state types instead of a regular Relation. + relations.append( Relation( endpoint=raw_relation["endpoint"], @@ -620,6 +622,20 @@ def get_juju_version(juju_status: Dict) -> str: return juju_status["model"]["version"] +def get_charm_version(target: JujuUnitName, juju_status: Dict) -> str: + """Get charm version info from juju status output.""" + app_info = juju_status["applications"][target.app_name] + channel = app_info["charm-channel"] + charm_name = app_info["charm-name"] + app_version = app_info["version"] + charm_rev = app_info["charm-rev"] + charm_origin = app_info["charm-origin"] + return ( + f"charm {charm_name!r} ({channel}/{charm_rev}); " + f"origin := {charm_origin}; app version := {app_version}." + ) + + class RemoteUnitStateDB(UnitStateDB): """Represents a remote unit's state db.""" @@ -768,6 +784,7 @@ def if_include(key, fn, default): logger.info(f"snapshot done.") if pprint: + charm_version = get_charm_version(target, juju_status) juju_version = get_juju_version(juju_status) if format == FormatOption.pytest: charm_type_name = try_guess_charm_type_name() @@ -781,11 +798,14 @@ def if_include(key, fn, default): else: raise ValueError(f"unknown format {format}") - timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") + controller_timestamp = juju_status["controller"]["timestamp"] + local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") print( f"# Generated by scenario.snapshot. \n" - f"# Snapshot of {state_model.name}:{target.unit_name} at {timestamp}. \n" + f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" + f"# Controller timestamp := {controller_timestamp}. \n" f"# Juju version := {juju_version} \n" + f"# Charm fingerprint := {charm_version} \n" ) print(txt) @@ -875,9 +895,9 @@ def snapshot( print( _snapshot( - "prom/0", - format=FormatOption.pytest, - include="t", + "traefik/0", + format=FormatOption.state, + include="r", # fetch_files={ # "traefik": [ # Path("/opt/traefik/juju/certificates.yaml"), diff --git a/scenario/state.py b/scenario/state.py index 9b453d8d5..a308ec790 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -660,7 +660,7 @@ class _EntityStatus(_DCBase): # Why not use StatusBase directly? Because that's not json-serializable. - name: str + name: Literal["waiting", "blocked", "active", "unknown", "error", "maintenance"] message: str = "" def __eq__(self, other): @@ -680,6 +680,10 @@ def __eq__(self, other): def __iter__(self): return iter([self.name, self.message]) + def __repr__(self): + status_type_name = self.name.title() + "Status" + return f"{status_type_name}('{self.message}')" + def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: """Convert StatusBase to _EntityStatus.""" @@ -1087,8 +1091,6 @@ class Inject(_DCBase): to inject instances that can't be retrieved in advance in event args or kwargs. """ - pass - @dataclasses.dataclass class InjectRelation(Inject): From 2abc0f01b19a99d6a8a46dd93e839f00b9d2f2c3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Apr 2023 15:17:12 +0200 Subject: [PATCH 219/546] lint --- scenario/scripts/snapshot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 86db7f55a..b15aece41 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -223,6 +223,7 @@ def get_metadata(target: JujuUnitName, model: Model): class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" + def __init__( self, container: str, target: JujuUnitName, model: Optional[str] = None ): From 5f6db5c723edad24a13f8f109609c6974e8188fb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Apr 2023 15:29:49 +0200 Subject: [PATCH 220/546] vbump --- pyproject.toml | 2 +- scenario/scripts/main.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eeb262dde..fa47852ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.5" +version = "2.1.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 16130f649..29ed8811a 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -6,6 +6,7 @@ from scenario.scripts import logger from scenario.scripts.snapshot import snapshot +from scenario.scripts.state_apply import state_apply def main(): @@ -19,7 +20,7 @@ def main(): ) app.command(name="snapshot", no_args_is_help=True)(snapshot) - app.command(name="_", hidden=True)(lambda: None) + app.command(name="state-apply", no_args_is_help=True)(state_apply) @app.callback() def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): From 3231860787708980cb0c144db5d19df6d35bc51c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 24 Apr 2023 09:38:40 +0200 Subject: [PATCH 221/546] fixed dataclass error --- scenario/state.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index a308ec790..1086df7e4 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -695,8 +695,12 @@ class Status(_DCBase): """Represents the 'juju statuses' of the application/unit being tested.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ - app: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") - unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + app: Union[StatusBase, _EntityStatus] = dataclasses.field( + default_factory=lambda: _EntityStatus("unknown") + ) + unit: Union[StatusBase, _EntityStatus] = dataclasses.field( + default_factory=lambda: _EntityStatus("unknown") + ) app_version: str = "" # most to least recent statuses; do NOT include the current one. From cf337fc4c699a8aa7fc8ea37e9dc135a37209054 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 24 Apr 2023 10:13:38 +0200 Subject: [PATCH 222/546] frozen dataclasses --- pyproject.toml | 2 +- scenario/mocking.py | 9 +- scenario/state.py | 137 +++++++++++++++---------- tests/test_e2e/test_pebble.py | 5 +- tests/test_e2e/test_play_assertions.py | 5 +- tests/test_e2e/test_state.py | 14 +-- tox.ini | 2 +- 7 files changed, 99 insertions(+), 75 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eeb262dde..ff4d68ac8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.1.3.5" +version = "2.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/mocking.py b/scenario/mocking.py index 8bfda1c4f..8b294c585 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -220,7 +220,7 @@ def secret_get( if peek or refresh: revision = max(secret.contents.keys()) if refresh: - secret.revision = revision + secret._set_revision(revision) return secret.contents[revision] @@ -298,6 +298,10 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None): else: secret.contents.clear() + def relation_remote_app_name(self, relation_id: int): + relation = self._get_relation_by_id(relation_id) + return relation.remote_app_name + # TODO: def action_set(self, *args, **kwargs): raise NotImplementedError("action_set") @@ -314,9 +318,6 @@ def storage_add(self, *args, **kwargs): def action_get(self): raise NotImplementedError("action_get") - def relation_remote_app_name(self, *args, **kwargs): - raise NotImplementedError("relation_remote_app_name") - def resource_get(self, *args, **kwargs): raise NotImplementedError("resource_get") diff --git a/scenario/state.py b/scenario/state.py index 1086df7e4..cef188e0a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -70,7 +70,7 @@ class StateValidationError(RuntimeError): # **combination** of several parts of the State are. -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class _DCBase: def replace(self, *args, **kwargs): return dataclasses.replace(self, *args, **kwargs) @@ -79,7 +79,7 @@ def copy(self) -> "Self": return copy.deepcopy(self) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Secret(_DCBase): id: str @@ -142,6 +142,11 @@ def remove_event(self): ) return Event(name="secret_removed", secret=self) + def _set_revision(self, revision: int): + """Set a new tracked revision.""" + # bypass frozen dataclass + object.__setattr__(self, "revision", revision) + _RELATION_IDS_CTR = 0 @@ -172,7 +177,17 @@ def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) -@dataclasses.dataclass +def _generate_new_relation_id(): + global _RELATION_IDS_CTR + _RELATION_IDS_CTR += 1 + logger.info( + f"relation ID unset; automatically assigning {_RELATION_IDS_CTR}. " + f"If there are problems, pass one manually." + ) + return _RELATION_IDS_CTR + + +@dataclasses.dataclass(frozen=True) class RelationBase(_DCBase): endpoint: str @@ -180,7 +195,7 @@ class RelationBase(_DCBase): interface: str = None # Every new Relation instance gets a new one, if there's trouble, override. - relation_id: int = -1 + relation_id: int = dataclasses.field(default_factory=_generate_new_relation_id) local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -212,15 +227,6 @@ def __post_init__(self): "please use Relation, PeerRelation, or SubordinateRelation" ) - global _RELATION_IDS_CTR - if self.relation_id == -1: - _RELATION_IDS_CTR += 1 - logger.info( - f"relation ID unset; automatically assigning {_RELATION_IDS_CTR}. " - f"If there are problems, pass one manually." - ) - self.relation_id = _RELATION_IDS_CTR - for databag in self._databags: self._validate_databag(databag) @@ -314,9 +320,11 @@ def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): return ids, data -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Relation(RelationBase): remote_app_name: str = "remote" + + # fixme: simplify API by deriving remote_unit_ids from remote_units_data. remote_unit_ids: List[int] = dataclasses.field(default_factory=list) # local limit @@ -327,6 +335,16 @@ class Relation(RelationBase): default_factory=dict ) + def __post_init__(self): + super().__post_init__() + + remote_unit_ids, remote_units_data = unify_ids_and_remote_units_data( + self.remote_unit_ids, self.remote_units_data + ) + # bypass frozen dataclass + object.__setattr__(self, "remote_unit_ids", remote_unit_ids) + object.__setattr__(self, "remote_units_data", remote_units_data) + @property def _remote_app_name(self) -> str: """Who is on the other end of this relation?""" @@ -349,14 +367,8 @@ def _databags(self): yield self.remote_app_data yield from self.remote_units_data.values() - def __post_init__(self): - super().__post_init__() - self.remote_unit_ids, self.remote_units_data = unify_ids_and_remote_units_data( - self.remote_unit_ids, self.remote_units_data - ) - -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): # todo: consider renaming them to primary_*_data remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -393,7 +405,7 @@ def primary_name(self) -> str: return f"{self.primary_app_name}/{self.primary_id}" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class PeerRelation(RelationBase): peers_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) @@ -423,9 +435,12 @@ def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: return self.peers_data[unit_id] def __post_init__(self): - self.peers_ids, self.peers_data = unify_ids_and_remote_units_data( + peers_ids, peers_data = unify_ids_and_remote_units_data( self.peers_ids, self.peers_data ) + # bypass frozen dataclass guards + object.__setattr__(self, "peers_ids", peers_ids) + object.__setattr__(self, "peers_data", peers_data) def _random_model_name(): @@ -436,7 +451,7 @@ def _random_model_name(): return "".join(random.choice(space) for _ in range(20)) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Model(_DCBase): name: str = _random_model_name() uuid: str = str(uuid4()) @@ -453,31 +468,39 @@ class Model(_DCBase): _CHANGE_IDS = 0 -@dataclasses.dataclass +def _generate_new_change_id(): + global _CHANGE_IDS + _CHANGE_IDS += 1 + logger.info( + f"change ID unset; automatically assigning {_CHANGE_IDS}. " + f"If there are problems, pass one manually." + ) + return _CHANGE_IDS + + +@dataclasses.dataclass(frozen=True) class ExecOutput: return_code: int = 0 stdout: str = "" stderr: str = "" # change ID: used internally to keep track of mocked processes - _change_id: int = -1 + _change_id: int = dataclasses.field(default_factory=_generate_new_change_id) def _run(self) -> int: - global _CHANGE_IDS - _CHANGE_IDS = self._change_id = _CHANGE_IDS + 1 - return _CHANGE_IDS + return self._change_id _ExecMock = Dict[Tuple[str, ...], ExecOutput] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Mount(_DCBase): location: Union[str, PurePosixPath] src: Union[str, Path] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Container(_DCBase): name: str can_connect: bool = False @@ -583,7 +606,7 @@ def pebble_ready_event(self): return Event(name=normalize_name(self.name + "-pebble-ready"), container=self) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Address(_DCBase): hostname: str value: str @@ -591,7 +614,7 @@ class Address(_DCBase): address: str = "" # legacy -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class BindAddress(_DCBase): interface_name: str addresses: List[Address] @@ -609,7 +632,7 @@ def hook_tool_output_fmt(self): return dct -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Network(_DCBase): name: str @@ -654,7 +677,7 @@ def default( ) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class _EntityStatus(_DCBase): """This class represents StatusBase and should not be interacted with directly.""" @@ -690,17 +713,13 @@ def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: return _EntityStatus(obj.name, obj.message) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Status(_DCBase): """Represents the 'juju statuses' of the application/unit being tested.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ - app: Union[StatusBase, _EntityStatus] = dataclasses.field( - default_factory=lambda: _EntityStatus("unknown") - ) - unit: Union[StatusBase, _EntityStatus] = dataclasses.field( - default_factory=lambda: _EntityStatus("unknown") - ) + app: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") app_version: str = "" # most to least recent statuses; do NOT include the current one. @@ -714,14 +733,14 @@ def __post_init__(self): if isinstance(val, _EntityStatus): pass elif isinstance(val, StatusBase): - setattr(self, name, _status_to_entitystatus(val)) + object.__setattr__(self, name, _status_to_entitystatus(val)) elif isinstance(val, tuple): logger.warning( "Initializing Status.[app/unit] with Tuple[str, str] is deprecated " "and will be removed soon. \n" f"Please pass a StatusBase instance: `StatusBase(*{val})`" ) - setattr(self, name, _EntityStatus(*val)) + object.__setattr__(self, name, _EntityStatus(*val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") @@ -729,8 +748,10 @@ def _update_app_version(self, new_app_version: str): """Update the current app version and record the previous one.""" # We don't keep a full history because we don't expect the app version to change more # than once per hook. - self.previous_app_version = self.app_version - self.app_version = new_app_version + + # bypass frozen dataclass + object.__setattr__(self, "previous_app_version", self.app_version) + object.__setattr__(self, "app_version", new_app_version) def _update_status( self, new_status: str, new_message: str = "", is_app: bool = False @@ -738,13 +759,15 @@ def _update_status( """Update the current app/unit status and add the previous one to the history.""" if is_app: self.app_history.append(self.app) - self.app = _EntityStatus(new_status, new_message) + # bypass frozen dataclass + object.__setattr__(self, "app", _EntityStatus(new_status, new_message)) else: self.unit_history.append(self.unit) - self.unit = _EntityStatus(new_status, new_message) + # bypass frozen dataclass + object.__setattr__(self, "unit", _EntityStatus(new_status, new_message)) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class StoredState(_DCBase): # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. @@ -760,7 +783,7 @@ def handle_path(self): return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class State(_DCBase): """Represents the juju-owned portion of a unit's state. @@ -874,7 +897,7 @@ def trigger( ) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class _CharmSpec(_DCBase): """Charm spec.""" @@ -918,7 +941,7 @@ def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): return sorted(patch, key=key) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class DeferredEvent(_DCBase): handle_path: str owner: str @@ -932,7 +955,7 @@ def name(self): return self.handle_path.split("/")[-1].split("[")[0] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Event(_DCBase): name: str args: Tuple[Any] = () @@ -965,7 +988,9 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": def __post_init__(self): if "-" in self.name: logger.warning(f"Only use underscores in event names. {self.name!r}") - self.name = normalize_name(self.name) + + # bypass frozen dataclass + object.__setattr__(self, "name", normalize_name(self.name)) @property def _is_relation_event(self) -> bool: @@ -1089,14 +1114,14 @@ def deferred( return event.deferred(handler=handler, event_id=event_id) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Inject(_DCBase): """Base class for injectors: special placeholders used to tell harness_ctx to inject instances that can't be retrieved in advance in event args or kwargs. """ -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class InjectRelation(Inject): relation_name: str relation_id: Optional[int] = None diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 3cff2e171..d6fb3889c 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -128,9 +128,8 @@ def callback(self: CharmBase): assert file.read() == text else: # nothing has changed - out.juju_log = [] - out.stored_state = state.stored_state # ignore stored state in delta. - assert not out.jsonpatch_delta(state) + out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + assert not out_purged.jsonpatch_delta(state) LS = """ diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index ddcd05e52..a7ee4175c 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -58,9 +58,8 @@ def post_event(charm): assert out.status.unit == ActiveStatus("yabadoodle") - out.juju_log = [] # exclude juju log from delta - out.stored_state = initial_state.stored_state # ignore stored state in delta. - assert out.jsonpatch_delta(initial_state) == [ + out_purged = out.replace(juju_log=[], stored_state=initial_state.stored_state) + assert out_purged.jsonpatch_delta(initial_state) == [ { "op": "replace", "path": "/status/unit/message", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 695a55181..9aebbab71 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -60,9 +60,8 @@ def state(): def test_bare_event(state, mycharm): out = state.trigger("start", mycharm, meta={"name": "foo"}) - out.juju_log = [] # ignore logging output in the delta - out.stored_state = state.stored_state # ignore stored state in delta. - assert state.jsonpatch_delta(out) == [] + out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + assert state.jsonpatch_delta(out_purged) == [] def test_leader_get(state, mycharm): @@ -88,14 +87,15 @@ def call(charm: CharmBase, _): "start", mycharm, meta={"name": "foo"}, + config={"options": {"foo": {"type": "string"}}}, ) assert out.status.unit == ActiveStatus("foo test") assert out.status.app == WaitingStatus("foo barz") assert out.status.app_version == "" - out.juju_log = [] # ignore logging output in the delta - out.stored_state = state.stored_state # ignore stored state in delta. - assert out.jsonpatch_delta(state) == sort_patch( + # ignore logging output and stored state in the delta + out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + assert out_purged.jsonpatch_delta(state) == sort_patch( [ {"op": "replace", "path": "/status/app/message", "value": "foo barz"}, {"op": "replace", "path": "/status/app/name", "value": "waiting"}, @@ -123,7 +123,7 @@ def pre_event(charm: CharmBase): assert container.name == "foo" assert container.can_connect() is connect - State(containers=(Container(name="foo", can_connect=connect),)).trigger( + State(containers=[Container(name="foo", can_connect=connect)]).trigger( "start", mycharm, meta={ diff --git a/tox.ini b/tox.ini index a25b8f0f3..ecd71dd11 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ [tox] envlist = - {py36,py37,py38} + {py36,py37,py38,py311} unit, lint isolated_build = True skip_missing_interpreters = True From ebfc2fb358f079b9f0748d9c80d70e546371e473 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 May 2023 12:03:02 +0200 Subject: [PATCH 223/546] better error message on charm error --- scenario/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 680f945d6..e9a506bb6 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -386,7 +386,7 @@ def exec( raise # propagate along except Exception as e: raise UncaughtCharmError( - f"Uncaught error in operator/charm code: {e}." + f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}" ) from e finally: logger.info(" - Exited ops.main.") From 4da1031d6d9686ecaf78ce790cc28a141673f9b8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 May 2023 17:17:51 +0200 Subject: [PATCH 224/546] reworked trigger api and removed State.trigger --- README.md | 26 +-- pyproject.toml | 4 + scenario/__init__.py | 52 +++++- scenario/context.py | 105 +++++++++++ .../{capture_events.py => emitted_events.py} | 12 +- scenario/runtime.py | 11 +- scenario/scripts/snapshot.py | 2 +- scenario/sequences.py | 6 +- scenario/state.py | 33 +--- tests/test_e2e/test_custom_event_triggers.py | 10 +- tests/test_e2e/test_deferred.py | 61 +++++-- tests/test_e2e/test_juju_log.py | 3 +- tests/test_e2e/test_observers.py | 17 +- tests/test_e2e/test_pebble.py | 55 +++--- tests/test_e2e/test_play_assertions.py | 10 +- tests/test_e2e/test_relations.py | 77 ++++---- tests/test_e2e/test_rubbish_events.py | 7 +- tests/test_e2e/test_secrets.py | 171 ++++++++++-------- tests/test_e2e/test_state.py | 18 +- tests/test_e2e/test_status.py | 30 ++- tests/test_e2e/test_stored_state.py | 18 +- tests/test_e2e/test_vroot.py | 8 +- tests/test_emitted_events_util.py | 24 ++- tests/test_plugin.py | 67 +++++++ tests/test_runtime.py | 5 +- 25 files changed, 565 insertions(+), 267 deletions(-) create mode 100644 scenario/context.py rename scenario/{capture_events.py => emitted_events.py} (90%) create mode 100644 tests/test_plugin.py diff --git a/README.md b/README.md index c618c9d68..ae0623782 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ class MyCharm(CharmBase): def test_scenario_base(): - out = State().trigger( + out = trigger(State(), ( 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit == UnknownStatus() @@ -100,7 +100,7 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - out = State(leader=leader).trigger( + out = State(leader=leader), 'start', MyCharm, meta={"name": "foo"}) @@ -141,7 +141,7 @@ from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownSta from scenario import State def test_statuses(): - out = State(leader=False).trigger( + out = State(leader=False), 'start', MyCharm, meta={"name": "foo"}) @@ -197,7 +197,7 @@ def test_relation_data(): remote_app_data={"cde": "baz!"}, ), ] - ).trigger("start", MyCharm, meta={"name": "foo"}) + ), "start", MyCharm, meta={"name": "foo"}) assert out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: @@ -245,7 +245,7 @@ State(relations=[ PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )]).trigger("start", ..., unit_id=1) # invalid: this unit's id cannot be the ID of a peer. + )]), "start", ..., unit_id=1) # invalid: this unit's id cannot be the ID of a peer. ``` @@ -380,7 +380,7 @@ def test_pebble_push(): mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) out = State( containers=[container] - ).trigger( + ), container.pebble_ready_event, MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, @@ -425,7 +425,7 @@ def test_pebble_exec(): ) out = State( containers=[container] - ).trigger( + ), container.pebble_ready_event, MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, @@ -456,7 +456,7 @@ def test_start_on_deferred_update_status(MyCharm): deferred('update_status', handler=MyCharm._on_update_status) ] - ).trigger('start', MyCharm) + ), 'start', MyCharm) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` @@ -491,7 +491,7 @@ class MyCharm(...): def test_defer(MyCharm): - out = State().trigger('start', MyCharm) + out = trigger(State(), ('start', MyCharm) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` @@ -595,7 +595,7 @@ from ops.charm import StartEvent, UpdateStatusEvent from scenario import State, DeferredEvent from scenario import capture_events with capture_events() as emitted: - state_out = State(deferred=[DeferredEvent('start', ...)]).trigger('update-status', ...) + state_out = State(deferred=[DeferredEvent('start', ...)]), 'update-status', ...) # deferred events get reemitted first assert isinstance(emitted[0], StartEvent) @@ -638,7 +638,7 @@ from scenario import State class MyCharmType(CharmBase): pass -state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start') +state = trigger(State(), (charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start') ``` A consequence of this fact is that you have no direct control over the tempdir that we are @@ -656,7 +656,7 @@ class MyCharmType(CharmBase): td = tempfile.TemporaryDirectory() -state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start', +state = trigger(State(), (charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start', charm_root=td.name) ``` @@ -694,7 +694,7 @@ Snapshot's purpose is to gather the State data structure from a real, live charm - your charm is bork or in some inconsistent state, and you want to write a test to check the charm will handle it correctly the next time around (aka regression testing) - you are new to Scenario and want to quickly get started with a real-life example. -Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type `scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import all you need from `scenario`, and you have a working `State` that you can `.trigger()` events from. +Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type `scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import all you need from `scenario`, and you have a working `State` that you can `, )` events from. You can also pass a `--format json | pytest | state (default=state)` flag to obtain - jsonified `State` data structure, for portability diff --git a/pyproject.toml b/pyproject.toml index ff4d68ac8..4b12060a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,12 @@ classifiers = [ "Development Status :: 3 - Alpha", "Topic :: Utilities", "License :: OSI Approved :: Apache Software License", + "Testing :: Pytest" ] +[project.entry-points.pytest11] +emitted_events = "scenario" + [project.urls] "Homepage" = "https://github.com/PietroPasotti/ops-scenario" "Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" diff --git a/scenario/__init__.py b/scenario/__init__.py index aa462f091..e9efa21da 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,6 +1,54 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from scenario.capture_events import capture_events +from scenario.context import Context +from scenario.emitted_events import capture_events, emitted_events from scenario.runtime import trigger -from scenario.state import * +from scenario.state import ( + Address, + BindAddress, + Container, + DeferredEvent, + Event, + ExecOutput, + InjectRelation, + Model, + Mount, + Network, + ParametrizedEvent, + PeerRelation, + Relation, + RelationBase, + Secret, + State, + StateValidationError, + Status, + StoredState, + SubordinateRelation, +) + +__all__ = [ + emitted_events, + capture_events, + Context, + StateValidationError, + Secret, + ParametrizedEvent, + RelationBase, + Relation, + SubordinateRelation, + PeerRelation, + Model, + ExecOutput, + Mount, + Container, + Address, + BindAddress, + Network, + Status, + StoredState, + State, + DeferredEvent, + Event, + InjectRelation, +] diff --git a/scenario/context.py b/scenario/context.py new file mode 100644 index 000000000..cc5c9474e --- /dev/null +++ b/scenario/context.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, Union + +from scenario.logger import logger as scenario_logger +from scenario.runtime import Runtime +from scenario.state import Event, _CharmSpec + +if TYPE_CHECKING: + from pathlib import Path + + from ops.testing import CharmType + + from scenario.state import State + + PathLike = Union[str, Path] + +logger = scenario_logger.getChild("runtime") + + +class Context: + """Scenario test execution context.""" + + def __init__( + self, + charm_type: Type["CharmType"], + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + juju_version: str = "3.0", + ): + """Initializer. + + :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a python dict). + If none is provided, we will search for a ``metadata.yaml`` file in the charm root. + :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a python dict). + If none is provided, we will search for a ``actions.yaml`` file in the charm root. + :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). + If none is provided, we will search for a ``config.yaml`` file in the charm root. + :arg juju_version: Juju agent version to simulate. + :arg charm_root: virtual charm root the charm will be executed with. + If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the + execution cwd, you need to use this. E.g.: + + >>> virtual_root = tempfile.TemporaryDirectory() + >>> local_path = Path(local_path.name) + >>> (local_path / 'foo').mkdir() + >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + >>> scenario, State(), (... charm_root=virtual_root) + + """ + + if not any((meta, actions, config)): + logger.debug("Autoloading charmspec...") + spec = _CharmSpec.autoload(charm_type) + else: + if not meta: + meta = {"name": str(charm_type.__name__)} + spec = _CharmSpec( + charm_type=charm_type, meta=meta, actions=actions, config=config + ) + + self.charm_spec = spec + self.charm_root = charm_root + self.juju_version = juju_version + + def run( + self, + event: Union["Event", str], + state: "State", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> "State": + """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. + :arg state: the State instance to use as data source for the hook tool calls that the charm will + invoke when handling the Event. + :arg pre_event: callback to be invoked right before emitting the event on the newly + instantiated charm. Will receive the charm instance as only positional argument. + :arg post_event: callback to be invoked right after emitting the event on the charm instance. + Will receive the charm instance as only positional argument. + """ + + runtime = Runtime( + charm_spec=self.charm_spec, + juju_version=self.juju_version, + charm_root=self.charm_root, + ) + + if isinstance(event, str): + event = Event(event) + + return runtime.exec( + state=state, + event=event, + pre_event=pre_event, + post_event=post_event, + ) diff --git a/scenario/capture_events.py b/scenario/emitted_events.py similarity index 90% rename from scenario/capture_events.py rename to scenario/emitted_events.py index 3f0f65d5c..e274bda10 100644 --- a/scenario/capture_events.py +++ b/scenario/emitted_events.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + import typing from contextlib import contextmanager from typing import ContextManager, List, Type, TypeVar +import pytest from ops.framework import ( CommitEvent, EventBase, @@ -23,6 +25,8 @@ def capture_events( ) -> ContextManager[List[EventBase]]: """Capture all events of type `*types` (using instance checks). + Arguments exposed so that you can define your own fixtures if you want to. + Example:: >>> from ops.charm import StartEvent >>> from scenario import Event, State @@ -30,7 +34,7 @@ def capture_events( >>> >>> def test_my_event(): >>> with capture_events(StartEvent, MyCustomEvent) as captured: - >>> State().trigger("start", MyCharm, meta=MyCharm.META) + >>> trigger(State(), ("start", MyCharm, meta=MyCharm.META) >>> >>> assert len(captured) == 2 >>> e1, e2 = captured @@ -87,3 +91,9 @@ def _wrapped_reemit(self): Framework._emit = _real_emit # type: ignore # noqa # ugly Framework.reemit = _real_reemit # type: ignore # noqa # ugly + + +@pytest.fixture(scope="function") +def emitted_events(): + with capture_events() as captured: + yield captured diff --git a/scenario/runtime.py b/scenario/runtime.py index e9a506bb6..44e2e5d69 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -145,7 +145,6 @@ def __init__( charm_spec: "_CharmSpec", charm_root: Optional["PathLike"] = None, juju_version: str = "3.0.0", - unit_id: int = 0, ): self._charm_spec = charm_spec self._juju_version = juju_version @@ -156,8 +155,6 @@ def __init__( raise ValueError('invalid metadata: mandatory "name" field is missing.') self._app_name = app_name - self._unit_id = unit_id - self._unit_name = f"{app_name}/{unit_id}" @staticmethod def _cleanup_env(env): @@ -177,7 +174,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env = { "JUJU_VERSION": self._juju_version, - "JUJU_UNIT_NAME": self._unit_name, + "JUJU_UNIT_NAME": f"{self._app_name}/{state.unit_id}", "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{event.name}", "JUJU_MODEL_NAME": state.model.name, @@ -413,7 +410,6 @@ def trigger( config: Optional[Dict[str, Any]] = None, charm_root: Optional[Dict["PathLike", "PathLike"]] = None, juju_version: str = "3.0", - unit_id: int = 0, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -435,7 +431,6 @@ def trigger( :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. - :arg unit_id: The ID of the Juju unit that is charm execution is running on. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -444,8 +439,7 @@ def trigger( >>> local_path = Path(local_path.name) >>> (local_path / 'foo').mkdir() >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - >>> scenario.State().trigger(... charm_root=virtual_root) - + >>> scenario, State(), (... charm_root=virtual_root) """ from scenario.state import Event, _CharmSpec @@ -467,7 +461,6 @@ def trigger( charm_spec=spec, juju_version=juju_version, charm_root=charm_root, - unit_id=unit_id, ) return runtime.exec( diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index b15aece41..62f850bf2 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -76,7 +76,7 @@ def test_case(): state = {state} #Act: trigger an event on the state - out = state.trigger( + out = trigger(state, {en} {ct} juju_version="{jv}" diff --git a/scenario/sequences.py b/scenario/sequences.py index fa30b4dca..35ba425f0 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -5,6 +5,7 @@ from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union +from scenario import trigger from scenario.logger import logger as scenario_logger from scenario.state import ( ATTACH_ALL_STORAGES, @@ -96,7 +97,6 @@ def check_builtin_sequences( template_state: State = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - unit_id: int = 0, ): """Test that all the builtin startup and teardown events can fire without errors. @@ -117,7 +117,8 @@ def check_builtin_sequences( template.replace(leader=False), ) ): - state.trigger( + trigger( + state, event=event, charm_type=charm_type, meta=meta, @@ -125,5 +126,4 @@ def check_builtin_sequences( config=config, pre_event=pre_event, post_event=post_event, - unit_id=unit_id, ) diff --git a/scenario/state.py b/scenario/state.py index cef188e0a..b8c835d3e 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -804,6 +804,7 @@ class State(_DCBase): juju_log: List[Tuple[str, str]] = dataclasses.field(default_factory=list) secrets: List[Secret] = dataclasses.field(default_factory=list) + unit_id: int = 0 # represents the OF's event queue. These events will be emitted before the event being dispatched, # and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended @@ -864,38 +865,6 @@ def jsonpatch_delta(self, other: "State"): ).patch return sort_patch(patch) - def trigger( - self, - event: Union["Event", str], - charm_type: Type["CharmType"], - # callbacks - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = "3.0", - unit_id: int = 0, - ) -> "State": - """Fluent API for trigger. See runtime.trigger's docstring.""" - from scenario.runtime import trigger as _runtime_trigger - - return _runtime_trigger( - state=self, - event=event, - charm_type=charm_type, - pre_event=pre_event, - post_event=post_event, - meta=meta, - actions=actions, - config=config, - charm_root=charm_root, - juju_version=juju_version, - unit_id=unit_id, - ) - @dataclasses.dataclass(frozen=True) class _CharmSpec(_DCBase): diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index b707b04e7..05c1196b2 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, EventSource from scenario import State -from scenario.runtime import InconsistentScenarioError +from scenario.runtime import InconsistentScenarioError, trigger def test_custom_event_emitted(): @@ -31,10 +31,10 @@ def _on_foo(self, e): def _on_start(self, e): self.on.foo.emit() - State().trigger("foo", MyCharm, meta=MyCharm.META) + trigger(State(), "foo", MyCharm, meta=MyCharm.META) assert MyCharm._foo_called == 1 - State().trigger("start", MyCharm, meta=MyCharm.META) + trigger(State(), "start", MyCharm, meta=MyCharm.META) assert MyCharm._foo_called == 2 @@ -59,11 +59,11 @@ def _on_foo(self, e): # we called our custom event like a builtin one. Trouble! with pytest.raises(InconsistentScenarioError): - State().trigger("foo-relation-changed", MyCharm, meta=MyCharm.META) + trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) assert not MyCharm._foo_called os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "1" - State().trigger("foo-relation-changed", MyCharm, meta=MyCharm.META) + trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) assert MyCharm._foo_called os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 11c898d05..5b43205c0 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -10,6 +10,7 @@ ) from ops.framework import Framework +from scenario import trigger from scenario.state import Container, DeferredEvent, Relation, State, deferred CHARM_CALLED = 0 @@ -42,7 +43,7 @@ def _on_event(self, event): def test_defer(mycharm): mycharm.defer_next = True - out = State().trigger("start", mycharm, meta=mycharm.META) + out = trigger(State(), "start", mycharm, meta=mycharm.META) assert len(out.deferred) == 1 assert out.deferred[0].name == "start" @@ -50,9 +51,12 @@ def test_defer(mycharm): def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 - out = State( - deferred=[deferred(event="update_status", handler=mycharm._on_event)] - ).trigger("start", mycharm, meta=mycharm.META) + out = trigger( + State(deferred=[deferred(event="update_status", handler=mycharm._on_event)]), + "start", + mycharm, + meta=mycharm.META, + ) # we deferred the first 2 events we saw: update-status, start. assert len(out.deferred) == 2 @@ -94,14 +98,21 @@ def test_deferred_relation_event(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") - out = State( - relations=[rel], - deferred=[ - deferred( - event="foo_relation_changed", handler=mycharm._on_event, relation=rel - ) - ], - ).trigger("start", mycharm, meta=mycharm.META) + out = trigger( + State( + relations=[rel], + deferred=[ + deferred( + event="foo_relation_changed", + handler=mycharm._on_event, + relation=rel, + ) + ], + ), + "start", + mycharm, + meta=mycharm.META, + ) # we deferred the first 2 events we saw: relation-changed, start. assert len(out.deferred) == 2 @@ -118,10 +129,15 @@ def test_deferred_relation_event(mycharm): def test_deferred_relation_event_from_relation(mycharm): mycharm.defer_next = 2 rel = Relation(endpoint="foo", remote_app_name="remote") - out = State( - relations=[rel], - deferred=[rel.changed_event.deferred(handler=mycharm._on_event)], - ).trigger("start", mycharm, meta=mycharm.META) + out = trigger( + State( + relations=[rel], + deferred=[rel.changed_event.deferred(handler=mycharm._on_event)], + ), + "start", + mycharm, + meta=mycharm.META, + ) # we deferred the first 2 events we saw: foo_relation_changed, start. assert len(out.deferred) == 2 @@ -140,10 +156,15 @@ def test_deferred_workload_event(mycharm): ctr = Container("foo") - out = State( - containers=[ctr], - deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)], - ).trigger("start", mycharm, meta=mycharm.META) + out = trigger( + State( + containers=[ctr], + deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)], + ), + "start", + mycharm, + meta=mycharm.META, + ) # we deferred the first 2 events we saw: foo_pebble_ready, start. assert len(out.deferred) == 2 diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index 782a3b6cf..c9e49e0ed 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -3,6 +3,7 @@ import pytest from ops.charm import CharmBase +from scenario import trigger from scenario.state import State logger = logging.getLogger("testing logger") @@ -26,7 +27,7 @@ def _on_event(self, event): def test_juju_log(mycharm): - out = State().trigger("start", mycharm, meta=mycharm.META) + out = trigger(State(), "start", mycharm, meta=mycharm.META) assert out.juju_log[16] == ("DEBUG", "Emitting Juju event start.") # prints are not juju-logged. assert out.juju_log[17] == ("WARNING", "bar!") diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 1005145a1..4348e3d45 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -2,6 +2,7 @@ from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework +from scenario import trigger from scenario.state import Event, State, _CharmSpec @@ -25,7 +26,8 @@ def _on_event(self, event): def test_start_event(charm_evts): charm, evts = charm_evts - State().trigger( + trigger( + State(), event="start", charm_type=charm, meta={"name": "foo"}, @@ -33,16 +35,3 @@ def test_start_event(charm_evts): ) assert len(evts) == 1 assert isinstance(evts[0], StartEvent) - - -@pytest.mark.xfail(reason="actions not implemented yet") -def test_action_event(charm_evts): - charm, evts = charm_evts - - scenario = Scenario( - _CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) - ) - scene = Scene(Event("show_proxied_endpoints_action"), state=State()) - scenario.play(scene) - assert len(evts) == 1 - assert isinstance(evts[0], ActionEvent) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index d6fb3889c..a5e922f4b 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -8,6 +8,7 @@ from ops.framework import Framework from ops.pebble import ServiceStartup, ServiceStatus +from scenario import trigger from scenario.state import Container, ExecOutput, Mount, State @@ -29,7 +30,8 @@ def test_no_containers(charm_cls): def callback(self: CharmBase): assert not self.unit.containers - State().trigger( + trigger( + State(), charm_type=charm_cls, meta={"name": "foo"}, event="start", @@ -42,7 +44,8 @@ def callback(self: CharmBase): assert self.unit.containers assert self.unit.get_container("foo") - State().trigger( + trigger( + State(), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -55,7 +58,8 @@ def test_connectivity(charm_cls, can_connect): def callback(self: CharmBase): assert can_connect == self.unit.get_container("foo").can_connect() - State(containers=[Container(name="foo", can_connect=can_connect)]).trigger( + trigger( + State(containers=[Container(name="foo", can_connect=can_connect)]), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -74,13 +78,16 @@ def callback(self: CharmBase): baz = container.pull("/bar/baz.txt") assert baz.read() == text - State( - containers=[ - Container( - name="foo", can_connect=True, mounts={"bar": Mount("/bar/baz.txt", pth)} - ) - ] - ).trigger( + trigger( + State( + containers=[ + Container( + name="foo", + can_connect=True, + mounts={"bar": Mount("/bar/baz.txt", pth)}, + ) + ] + ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -116,7 +123,8 @@ def callback(self: CharmBase): ] ) - out = state.trigger( + out = trigger( + state, charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -167,15 +175,16 @@ def callback(self: CharmBase): proc.wait() assert proc.stdout.read() == "hello pebble" - State( - containers=[ - Container( - name="foo", - can_connect=True, - exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, - ) - ] - ).trigger( + trigger( + State( + containers=[ + Container( + name="foo", + can_connect=True, + exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, + ) + ] + ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -190,7 +199,8 @@ def callback(self: CharmBase): container = Container(name="foo", can_connect=True) - State(containers=[container]).trigger( + trigger( + State(containers=[container]), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event=container.pebble_ready_event, @@ -251,7 +261,8 @@ def callback(self: CharmBase): }, ) - out = State(containers=[container]).trigger( + out = trigger( + State(containers=[container]), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event=container.pebble_ready_event, diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index a7ee4175c..cc41ec084 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -3,6 +3,7 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus +from scenario import trigger from scenario.state import Event, Relation, State, Status, _CharmSpec @@ -47,7 +48,8 @@ def post_event(charm): config={"foo": "bar"}, leader=True, status=Status(unit=BlockedStatus("foo")) ) - out = initial_state.trigger( + out = trigger( + initial_state, charm_type=mycharm, meta={"name": "foo"}, config={"options": {"foo": {"type": "string"}}}, @@ -100,7 +102,7 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} - State( + state_in = State( relations=[ Relation( endpoint="relation_test", @@ -111,7 +113,9 @@ def check_relation_data(charm): remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, ) ] - ).trigger( + ) + trigger( + state_in, charm_type=mycharm, meta={ "name": "foo", diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 7d429aa99..40b684030 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -4,6 +4,7 @@ from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent from ops.framework import EventBase, Framework +from scenario import trigger from scenario.state import ( PeerRelation, Relation, @@ -48,14 +49,15 @@ def pre_event(charm: CharmBase): assert charm.model.get_relation("qux") assert charm.model.get_relation("zoo") is None - State( - config={"foo": "bar"}, - leader=True, - relations=[ - Relation(endpoint="foo", interface="foo", remote_app_name="remote"), - Relation(endpoint="qux", interface="qux", remote_app_name="remote"), - ], - ).trigger( + trigger( + State( + config={"foo": "bar"}, + leader=True, + relations=[ + Relation(endpoint="foo", interface="foo", remote_app_name="remote"), + Relation(endpoint="qux", interface="qux", remote_app_name="remote"), + ], + ), "start", mycharm, meta={ @@ -81,11 +83,12 @@ def test_relation_events(mycharm, evt_name): mycharm._call = lambda self, evt: None - State( - relations=[ - relation, - ], - ).trigger( + trigger( + State( + relations=[ + relation, + ], + ), getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -117,11 +120,12 @@ def callback(charm: CharmBase, _): mycharm._call = callback - State( - relations=[ - relation, - ], - ).trigger( + trigger( + State( + relations=[ + relation, + ], + ), getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -158,11 +162,12 @@ def callback(charm: CharmBase, event): mycharm._call = callback - State( - relations=[ - relation, - ], - ).trigger( + trigger( + State( + relations=[ + relation, + ], + ), getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id), mycharm, meta={ @@ -197,11 +202,12 @@ def callback(charm: CharmBase, event): mycharm._call = callback - State( - relations=[ - relation, - ], - ).trigger( + trigger( + State( + relations=[ + relation, + ], + ), getattr(relation, f"{evt_name}_event"), mycharm, meta={ @@ -252,8 +258,11 @@ def test_relation_event_trigger(relation, evt_name, mycharm): }, "peers": {"b": {"interface": "i2"}}, } - state = State(relations=[relation]).trigger( - getattr(relation, evt_name + "_event"), mycharm, meta=meta + state = trigger( + State(relations=[relation]), + getattr(relation, evt_name + "_event"), + mycharm, + meta=meta, ) @@ -282,8 +291,12 @@ def post_event(charm: CharmBase): for relation in b_relations: assert len(relation.units) == 1 - State(relations=[sub1, sub2]).trigger( - "update-status", mycharm, meta=meta, post_event=post_event + trigger( + State(relations=[sub1, sub2]), + "update-status", + mycharm, + meta=meta, + post_event=post_event, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index f82a94284..7420d723f 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -4,6 +4,7 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource, Framework, Object +from scenario import trigger from scenario.ops_main_mock import NoObserverError from scenario.state import Container, Event, State, _CharmSpec @@ -51,7 +52,7 @@ def test_rubbish_event_raises(mycharm, evt_name): # else it will whine about the container not being in state and meta; # but if we put the container in meta, it will actually register an event! - State().trigger(evt_name, mycharm, meta={"name": "foo"}) + trigger(State(), evt_name, mycharm, meta={"name": "foo"}) if evt_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "false" @@ -59,14 +60,14 @@ def test_rubbish_event_raises(mycharm, evt_name): @pytest.mark.parametrize("evt_name", ("qux",)) def test_custom_events_pass(mycharm, evt_name): - State().trigger(evt_name, mycharm, meta={"name": "foo"}) + trigger(State(), evt_name, mycharm, meta={"name": "foo"}) # cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961 @pytest.mark.parametrize("evt_name", ("sub",)) def test_custom_events_sub_raise(mycharm, evt_name): with pytest.raises(RuntimeError): - State().trigger(evt_name, mycharm, meta={"name": "foo"}) + trigger(State(), evt_name, mycharm, meta={"name": "foo"}) @pytest.mark.parametrize( diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 7c74f49fc..5aa5babac 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,6 +3,7 @@ from ops.framework import Framework from ops.model import SecretRotate +from scenario import trigger from scenario.state import Relation, Secret, State @@ -27,8 +28,8 @@ def post_event(charm: CharmBase): with pytest.raises(RuntimeError): assert charm.model.get_secret(label="foo") - State().trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + trigger( + State(), "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) @@ -36,8 +37,12 @@ def test_get_secret(mycharm): def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + trigger( + State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) @@ -50,17 +55,23 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" assert charm.model.get_secret(id="foo").get_content()["a"] == "c" - State( - secrets=[ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - ) - ] - ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) + trigger( + State( + secrets=[ + Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + ) + ] + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) def test_secret_changed_owner_evt_fails(mycharm): @@ -94,8 +105,8 @@ def test_add(mycharm): def post_event(charm: CharmBase): charm.unit.add_secret({"foo": "bar"}, label="mylabel") - out = State().trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + out = trigger( + State(), "update_status", mycharm, meta={"name": "local"}, post_event=post_event ) assert out.secrets secret = out.secrets[0] @@ -114,20 +125,26 @@ def post_event(charm: CharmBase): assert info.label == "mylabel" assert info.rotation == SecretRotate.HOURLY - State( - secrets=[ - Secret( - owner="unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - ] - ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) + trigger( + State( + secrets=[ + Secret( + owner="unit", + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) def test_meta_nonowner(mycharm): @@ -136,19 +153,25 @@ def post_event(charm: CharmBase): with pytest.raises(RuntimeError): info = secret.get_info() - State( - secrets=[ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - ] - ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) + trigger( + State( + secrets=[ + Secret( + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) @pytest.mark.parametrize("app", (True, False)) @@ -161,21 +184,22 @@ def post_event(charm: CharmBase): else: secret.grant(relation=foo, unit=foo.units.pop()) - out = State( - relations=[Relation("foo", "remote")], - secrets=[ - Secret( - owner="unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - ], - ).trigger( + out = trigger( + State( + relations=[Relation("foo", "remote")], + secrets=[ + Secret( + owner="unit", + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ], + ), "update_status", mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, @@ -194,20 +218,21 @@ def post_event(charm: CharmBase): foo = charm.model.get_relation("foo") secret.grant(relation=foo) - out = State( - relations=[Relation("foo", "remote")], - secrets=[ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - ], - ).trigger( + out = trigger( + State( + relations=[Relation("foo", "remote")], + secrets=[ + Secret( + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ], + ), "update_status", mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 9aebbab71..3bf0218d9 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,6 +6,7 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus +from scenario import trigger from scenario.state import Container, Relation, State, sort_patch # from tests.setup_tests import setup_tests @@ -59,7 +60,7 @@ def state(): def test_bare_event(state, mycharm): - out = state.trigger("start", mycharm, meta={"name": "foo"}) + out = trigger(state, "start", mycharm, meta={"name": "foo"}) out_purged = out.replace(juju_log=[], stored_state=state.stored_state) assert state.jsonpatch_delta(out_purged) == [] @@ -68,7 +69,8 @@ def test_leader_get(state, mycharm): def pre_event(charm): assert charm.unit.is_leader() - state.trigger( + trigger( + state, "start", mycharm, meta={"name": "foo"}, @@ -83,7 +85,8 @@ def call(charm: CharmBase, _): charm.app.status = WaitingStatus("foo barz") mycharm._call = call - out = state.trigger( + out = trigger( + state, "start", mycharm, meta={"name": "foo"}, @@ -123,7 +126,8 @@ def pre_event(charm: CharmBase): assert container.name == "foo" assert container.can_connect() is connect - State(containers=[Container(name="foo", can_connect=connect)]).trigger( + trigger( + State(containers=[Container(name="foo", can_connect=connect)]), "start", mycharm, meta={ @@ -165,7 +169,8 @@ def pre_event(charm: CharmBase): ) ] ) - state.trigger( + trigger( + state, "start", mycharm, meta={ @@ -218,7 +223,8 @@ def pre_event(charm: CharmBase): ) assert not mycharm.called - out = state.trigger( + out = trigger( + state, event="start", charm_type=mycharm, meta={ diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index d628c6bac..2b44939ae 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -3,6 +3,7 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus +from scenario import trigger from scenario.state import State, Status @@ -24,8 +25,12 @@ def test_initial_status(mycharm): def post_event(charm: CharmBase): assert charm.unit.status == UnknownStatus() - out = State(leader=True).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + out = trigger( + State(leader=True), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) assert out.status.unit == UnknownStatus() @@ -38,8 +43,12 @@ def post_event(charm: CharmBase): obj.status = BlockedStatus("2") obj.status = WaitingStatus("3") - out = State(leader=True).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + out = trigger( + State(leader=True), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) assert out.status.unit == WaitingStatus("3") @@ -62,9 +71,16 @@ def post_event(charm: CharmBase): for obj in [charm.unit, charm.app]: obj.status = WaitingStatus("3") - out = State( - leader=True, status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")) - ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) + out = trigger( + State( + leader=True, + status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")), + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) assert out.status.unit == WaitingStatus("3") assert out.status.unit_history == [ActiveStatus("foo")] diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 6a328f742..857aa9470 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -3,6 +3,7 @@ from ops.framework import Framework from ops.framework import StoredState as ops_storedstate +from scenario import trigger from scenario.state import State, StoredState @@ -30,16 +31,21 @@ def _on_event(self, event): def test_stored_state_default(mycharm): - out = State().trigger("start", mycharm, meta=mycharm.META) + out = trigger(State(), "start", mycharm, meta=mycharm.META) assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} def test_stored_state_initialized(mycharm): - out = State( - stored_state=[ - StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), - ] - ).trigger("start", mycharm, meta=mycharm.META) + out = trigger( + State( + stored_state=[ + StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), + ] + ), + "start", + mycharm, + meta=mycharm.META, + ) # todo: ordering is messy? assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index eacc8e511..634da0d67 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -7,7 +7,7 @@ from ops.model import ActiveStatus from scenario import State -from scenario.runtime import DirtyVirtualCharmRootError +from scenario.runtime import DirtyVirtualCharmRootError, trigger class MyCharm(CharmBase): @@ -34,7 +34,8 @@ def test_vroot(): quxcos = baz / "qux.kaboodle" quxcos.write_text("world") - out = State().trigger( + out = trigger( + State(), "start", charm_type=MyCharm, meta=MyCharm.META, @@ -52,7 +53,8 @@ def test_dirty_vroot_raises(meta_overwrite): meta_file.touch() with pytest.raises(DirtyVirtualCharmRootError): - State().trigger( + trigger( + State(), "start", charm_type=MyCharm, meta=MyCharm.META, diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index 184c97f2d..cd963b832 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -2,7 +2,7 @@ from ops.charm import CharmBase, CharmEvents, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent -from scenario import Event, State, capture_events +from scenario import Event, State, capture_events, trigger class Foo(EventBase): @@ -31,7 +31,7 @@ def _on_foo(self, e): def test_capture_custom_evt(): with capture_events(Foo) as emitted: - State().trigger("foo", MyCharm, meta=MyCharm.META) + trigger(State(), "foo", MyCharm, meta=MyCharm.META) assert len(emitted) == 1 assert isinstance(emitted[0], Foo) @@ -39,7 +39,7 @@ def test_capture_custom_evt(): def test_capture_custom_evt_nonspecific_capture(): with capture_events() as emitted: - State().trigger("foo", MyCharm, meta=MyCharm.META) + trigger(State(), "foo", MyCharm, meta=MyCharm.META) assert len(emitted) == 1 assert isinstance(emitted[0], Foo) @@ -47,7 +47,7 @@ def test_capture_custom_evt_nonspecific_capture(): def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): with capture_events(include_framework=True) as emitted: - State().trigger("foo", MyCharm, meta=MyCharm.META) + trigger(State(), "foo", MyCharm, meta=MyCharm.META) assert len(emitted) == 3 assert isinstance(emitted[0], Foo) @@ -57,7 +57,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): def test_capture_juju_evt(): with capture_events() as emitted: - State().trigger("start", MyCharm, meta=MyCharm.META) + trigger(State(), "start", MyCharm, meta=MyCharm.META) assert len(emitted) == 2 assert isinstance(emitted[0], StartEvent) @@ -67,8 +67,11 @@ def test_capture_juju_evt(): def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events() as emitted: - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( - "start", MyCharm, meta=MyCharm.META + trigger( + State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + "start", + MyCharm, + meta=MyCharm.META, ) assert len(emitted) == 3 @@ -80,8 +83,11 @@ def test_capture_deferred_evt(): def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events(include_deferred=False) as emitted: - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( - "start", MyCharm, meta=MyCharm.META + trigger( + State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + "start", + MyCharm, + meta=MyCharm.META, ) assert len(emitted) == 2 diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 000000000..c8b85b263 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,67 @@ +import sys + +pytest_plugins = "pytester" +sys.path.append(".") + + +def test_emitted_events_fixture(pytester): + """Make sure that pytest accepts our fixture.""" + + # create a temporary pytest test module + pytester.makepyfile( + """ + from scenario import State + def test_sth(emitted_events): + assert emitted_events == [] + """ + ) + + # run pytest with the following cmd args + result = pytester.runpytest("-v") + + # fnmatch_lines does an assertion internally + result.stdout.fnmatch_lines( + [ + "*::test_sth PASSED*", + ] + ) + + # make sure that we get a '0' exit code for the testsuite + assert result.ret == 0 + + +def test_context(pytester): + """Make sure that pytest accepts our fixture.""" + + # create a temporary pytest test module + pytester.makepyfile( + """ + import pytest + from scenario import State + from scenario import Context + import ops + + class MyCharm(ops.CharmBase): + pass + + @pytest.fixture + def context(): + return Context(charm_type=MyCharm, meta={"name": "foo"}) + + def test_sth(context): + context.run('start', State()) + """ + ) + + # run pytest with the following cmd args + result = pytester.runpytest("-v") + + # fnmatch_lines does an assertion internally + result.stdout.fnmatch_lines( + [ + "*::test_sth PASSED*", + ] + ) + + # make sure that we get a '0' exit code for the testsuite + assert result.ret == 0 diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 954ec9341..6da936b99 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -101,10 +101,11 @@ def test_unit_name(app_name, unit_id): my_charm_type, meta=meta, ), - unit_id=unit_id, ) def post_event(charm: CharmBase): assert charm.unit.name == f"{app_name}/{unit_id}" - runtime.exec(state=State(), event=Event("start"), post_event=post_event) + runtime.exec( + state=State(unit_id=unit_id), event=Event("start"), post_event=post_event + ) From cc984f67f3979081914fe8ccb59a3f701bf4afba Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 May 2023 17:21:30 +0200 Subject: [PATCH 225/546] fixed pytest template for snapshot --- scenario/scripts/snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 62f850bf2..d7a4fbb23 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -68,7 +68,7 @@ def format_state(state: State): PYTEST_TEST_TEMPLATE = """ -from scenario.state import * +from scenario import * from charm import {ct} def test_case(): From 22d824a63952898a7fb06a416c10855dc9100563 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 May 2023 17:28:35 +0200 Subject: [PATCH 226/546] renamed app-version -> workload-version in State.Status --- pyproject.toml | 2 +- scenario/mocking.py | 2 +- scenario/scripts/snapshot.py | 8 ++++---- scenario/state.py | 10 +++++----- tests/test_e2e/test_state.py | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff4d68ac8..ace3d144b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.2" +version = "2.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] diff --git a/scenario/mocking.py b/scenario/mocking.py index 8b294c585..d38d0061e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,7 +163,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): # setter methods: these can mutate the state. def application_version_set(self, version: str): - self._state.status._update_app_version(version) # noqa + self._state.status._update_workload_version(version) # noqa def status_set(self, status: str, message: str = "", *, is_app: bool = False): self._state.status._update_status(status, message, is_app) # noqa diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index b15aece41..e55e993e3 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -431,11 +431,11 @@ def get_status(juju_status: Dict, target: JujuUnitName) -> Status: unit_status_raw = app["units"][target]["workload-status"] unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") - app_version = app.get("version", "") + workload_version = app.get("version", "") return Status( app=_EntityStatus(*app_status), unit=_EntityStatus(*unit_status), - app_version=app_version, + workload_version=workload_version, ) @@ -628,12 +628,12 @@ def get_charm_version(target: JujuUnitName, juju_status: Dict) -> str: app_info = juju_status["applications"][target.app_name] channel = app_info["charm-channel"] charm_name = app_info["charm-name"] - app_version = app_info["version"] + workload_version = app_info["version"] charm_rev = app_info["charm-rev"] charm_origin = app_info["charm-origin"] return ( f"charm {charm_name!r} ({channel}/{charm_rev}); " - f"origin := {charm_origin}; app version := {app_version}." + f"origin := {charm_origin}; app version := {workload_version}." ) diff --git a/scenario/state.py b/scenario/state.py index cef188e0a..45784fb17 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -720,12 +720,12 @@ class Status(_DCBase): # the current statuses. Will be cast to _EntitiyStatus in __post_init__ app: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") - app_version: str = "" + workload_version: str = "" # most to least recent statuses; do NOT include the current one. app_history: List[_EntityStatus] = dataclasses.field(default_factory=list) unit_history: List[_EntityStatus] = dataclasses.field(default_factory=list) - previous_app_version: Optional[str] = None + previous_workload_version: Optional[str] = None def __post_init__(self): for name in ["app", "unit"]: @@ -744,14 +744,14 @@ def __post_init__(self): else: raise TypeError(f"Invalid status.{name}: {val!r}") - def _update_app_version(self, new_app_version: str): + def _update_workload_version(self, new_workload_version: str): """Update the current app version and record the previous one.""" # We don't keep a full history because we don't expect the app version to change more # than once per hook. # bypass frozen dataclass - object.__setattr__(self, "previous_app_version", self.app_version) - object.__setattr__(self, "app_version", new_app_version) + object.__setattr__(self, "previous_workload_version", self.workload_version) + object.__setattr__(self, "workload_version", new_workload_version) def _update_status( self, new_status: str, new_message: str = "", is_app: bool = False diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 9aebbab71..779ac36af 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -91,7 +91,7 @@ def call(charm: CharmBase, _): ) assert out.status.unit == ActiveStatus("foo test") assert out.status.app == WaitingStatus("foo barz") - assert out.status.app_version == "" + assert out.status.workload_version == "" # ignore logging output and stored state in the delta out_purged = out.replace(juju_log=[], stored_state=state.stored_state) From f04cbe127fb83ae5641f311fb631d6500047ced7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 08:39:49 +0200 Subject: [PATCH 227/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ff4d68ac8..720b4a021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "2.2" +version = "3.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] From e2a9373945610109f9d9adebb025e767797e76cd Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 08:57:54 +0200 Subject: [PATCH 228/546] cleanup --- tests/test_e2e/test_state.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 9aebbab71..d8d731607 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -8,10 +8,6 @@ from scenario.state import Container, Relation, State, sort_patch -# from tests.setup_tests import setup_tests -# -# setup_tests() # noqa & keep this on top - CUSTOM_EVT_SUFFIXES = { "relation_created", From c9581da506ab26182c2448420caed6489ba62454 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 10:49:55 +0200 Subject: [PATCH 229/546] pre commit --- .pre_commit_config.yaml | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .pre_commit_config.yaml diff --git a/.pre_commit_config.yaml b/.pre_commit_config.yaml new file mode 100644 index 000000000..e10104e75 --- /dev/null +++ b/.pre_commit_config.yaml @@ -0,0 +1,86 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-ast + - id: check-builtin-literals + - id: check-docstring-first + - id: check-merge-conflict + - id: check-yaml + - id: check-toml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/asottile/add-trailing-comma + rev: v2.4.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: ["--py37-plus"] + exclude: "^(tests/demo_pkg_inline/build.py)$" + - id: pyupgrade + files: "^(tests/demo_pkg_inline/build.py)$" + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + args: [--safe] + - repo: https://github.com/asottile/blacken-docs + rev: 1.13.0 + hooks: + - id: blacken-docs + additional_dependencies: [black==23.3] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-backticks + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: "1.3.0" + hooks: + - id: tox-ini-fmt + args: ["-p", "fix"] + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear==23.3.23 + - flake8-comprehensions==3.12 + - flake8-pytest-style==1.7.2 + - flake8-spellcheck==0.28 + - flake8-unused-arguments==0.0.13 + - flake8-noqa==1.3.1 + - pep8-naming==0.13.3 + - flake8-pyproject==1.2.3 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v2.7.1" + hooks: + - id: prettier + additional_dependencies: + - prettier@2.7.1 + - "@prettier/plugin-xml@2.2" + args: ["--print-width=120", "--prose-wrap=always"] + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.33.0 + hooks: + - id: markdownlint + - repo: local + hooks: + - id: changelogs-rst + name: changelog filenames + language: fail + entry: "changelog files must be named ####.(feature|bugfix|doc|removal|misc).rst" + exclude: ^docs/changelog/(\d+\.(feature|bugfix|doc|removal|misc).rst|template.jinja2) + files: ^docs/changelog/ + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes \ No newline at end of file From 09632089a128f4edd8ba36f0733a78e0e93d1244 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 10:54:10 +0200 Subject: [PATCH 230/546] pre commit --- .pre_commit_config.yaml => .pre-commit-config.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .pre_commit_config.yaml => .pre-commit-config.yaml (100%) diff --git a/.pre_commit_config.yaml b/.pre-commit-config.yaml similarity index 100% rename from .pre_commit_config.yaml rename to .pre-commit-config.yaml From f41b3bfc849f2ffcbfe6bc5e746393f1573eae98 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 10:54:18 +0200 Subject: [PATCH 231/546] pre commit --- .github/workflows/build_wheels.yaml | 7 +- .github/workflows/quality_checks.yaml | 1 - .gitignore | 2 +- .pre-commit-config.yaml | 2 +- README.md | 316 +++++++++++++++---------- scenario/capture_events.py | 7 +- scenario/consistency_checker.py | 67 ++++-- scenario/mocking.py | 14 +- scenario/ops_main_mock.py | 9 +- scenario/runtime.py | 37 +-- scenario/scripts/snapshot.py | 71 ++++-- scenario/scripts/utils.py | 2 +- scenario/sequences.py | 4 +- scenario/state.py | 89 ++++--- tests/test_consistency_checker.py | 24 +- tests/test_e2e/test_deferred.py | 12 +- tests/test_e2e/test_network.py | 2 +- tests/test_e2e/test_observers.py | 2 +- tests/test_e2e/test_pebble.py | 58 ++--- tests/test_e2e/test_play_assertions.py | 8 +- tests/test_e2e/test_relations.py | 36 ++- tests/test_e2e/test_rubbish_events.py | 3 +- tests/test_e2e/test_secrets.py | 31 ++- tests/test_e2e/test_state.py | 9 +- tests/test_e2e/test_status.py | 13 +- tests/test_e2e/test_stored_state.py | 2 +- tests/test_emitted_events_util.py | 8 +- tests/test_runtime.py | 2 +- tox.ini | 56 +++-- 29 files changed, 549 insertions(+), 345 deletions(-) diff --git a/.github/workflows/build_wheels.yaml b/.github/workflows/build_wheels.yaml index f6414e093..a5c951849 100644 --- a/.github/workflows/build_wheels.yaml +++ b/.github/workflows/build_wheels.yaml @@ -5,7 +5,6 @@ on: branches: - main - jobs: build_wheel: name: Build wheel on ubuntu (where else???) @@ -42,8 +41,8 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} -# - name: Setup upterm session -# uses: lhotari/action-upterm@v1 + # - name: Setup upterm session + # uses: lhotari/action-upterm@v1 - name: upload wheel uses: actions/upload-release-asset@v1 @@ -58,4 +57,4 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 6e7c20b7a..f6ce33eaf 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -5,7 +5,6 @@ on: branches: - main - jobs: linting: name: Linting diff --git a/.gitignore b/.gitignore index 63f5dbd51..1be459dd7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ __pycache__/ .idea *.egg-info dist/ -*.pytest_cache \ No newline at end of file +*.pytest_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e10104e75..43128a4ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,4 +83,4 @@ repos: - repo: meta hooks: - id: check-hooks-apply - - id: check-useless-excludes \ No newline at end of file + - id: check-useless-excludes diff --git a/README.md b/README.md index c618c9d68..749a5514b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Scenario -============ +# Scenario This is a state transition testing framework for Operator Framework charms. @@ -11,9 +10,8 @@ This puts scenario tests somewhere in between unit and integration tests. Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the union of an `Event` (why am I being executed) and a `State` (am I leader? what is my relation data? what is my -config?...). -The output is another context instance: the context after the charm has had a chance to interact with the mocked juju -model. +config?...). The output is another context instance: the context after the charm has had a chance to interact with the +mocked juju model. ![state transition model depiction](resources/state-transition-model.png) @@ -22,39 +20,52 @@ Scenario-testing a charm, then, means verifying that: - the charm does not raise uncaught exceptions while handling the scene - the output state (or the diff with the input state) is as expected. - # Core concepts as a metaphor + I like metaphors, so here we go: + - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the stage (relation-departed), or the content of one of the books changes). -- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. - + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what + other actors are there around it, what is written in those pebble-shaped books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the + stage (relation-departed), or the content of one of the books changes). +- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a + container), or write something down into one of the books. # Core concepts not as a metaphor -Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black box. -An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. -Scenario tests are about validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm author's expectations. + +Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black +box. An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. Scenario tests are about +validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm +author's expectations. Comparing scenario tests with `Harness` tests: -- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired state, then verify its validity by calling charm methods or inspecting the raw data. -- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). -- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. -- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part of this flow, and even share context data across charms, codebases, teams... + +- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired + state, then verify its validity by calling charm methods or inspecting the raw data. +- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground + for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. + This ensures that the execution environment is as clean as possible (for a unit test). +- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the + level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more + lightweight and portable. +- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part + of this flow, and even share context data across charms, codebases, teams... # Writing scenario tests + A scenario test consists of three broad steps: - Arrange: - - declare the input state - - select an event to fire + - declare the input state + - select an event to fire - Act: - - run the state (i.e. obtain the output state) + - run the state (i.e. obtain the output state) - Assert: - - verify that the output state is how you expect it to be - - verify that the delta with the input state is what you expect it to be + - verify that the output state is how you expect it to be + - verify that the delta with the input state is what you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -72,13 +83,12 @@ class MyCharm(CharmBase): def test_scenario_base(): out = State().trigger( - 'start', + 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit == UnknownStatus() ``` -Now let's start making it more complicated. -Our charm sets a special state if it has leadership on 'start': +Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python import pytest @@ -101,20 +111,20 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): out = State(leader=leader).trigger( - 'start', + 'start', MyCharm, meta={"name": "foo"}) assert out.status.unit == ActiveStatus('I rule' if leader else 'I am ruled') ``` -By defining the right state we can programmatically define what answers will the charm get to all the questions it can ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... - +By defining the right state we can programmatically define what answers will the charm get to all the questions it can +ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... ## Statuses -One of the simplest types of black-box testing available to charmers is to execute the charm and verify that the charm sets the expected unit/application status. -We have seen a simple example above including leadership. -But what if the charm transitions through a sequence of statuses? +One of the simplest types of black-box testing available to charmers is to execute the charm and verify that the charm +sets the expected unit/application status. We have seen a simple example above including leadership. But what if the +charm transitions through a sequence of statuses? ```python from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedStatus @@ -155,9 +165,12 @@ def test_statuses(): Note that the current status is not in the **unit status history**. -Also note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen". +Also note that, unless you initialize the State with a preexisting status, the first status in the history will always +be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever +seen". -If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. +If you want to simulate a situation in which the charm already has seen some event, and is in a status other than +Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python from ops.model import ActiveStatus @@ -165,7 +178,6 @@ from scenario import State, Status State(leader=False, status=Status(unit=ActiveStatus('foo'))) ``` - ## Relations You can write scenario tests to verify the shape of relation data: @@ -214,19 +226,26 @@ def test_relation_data(): # which is very idiomatic and superbly explicit. Noice. ``` -The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be derived from the charm's `metadata.yaml`. When fully defaulted, a relation is 'empty'. There are no remote units, the remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the databags yet. +The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be +derived from the charm's `metadata.yaml`. When fully defaulted, a relation is 'empty'. There are no remote units, the +remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the +databags yet. That is typically the state of a relation when the first unit joins it. -When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of relation. There are also -peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app', because it's the same application. +When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of +relation. There are also peer relations and subordinate relations. While in the background the data model is the same, +the data access rules and the consistency constraints on them are very different. For example, it does not make sense +for a peer relation to have a different 'remote app' than its 'local app', because it's the same application. ### PeerRelation -To declare a peer relation, you should use `scenario.state.PeerRelation`. -The core difference with regular relations is that peer relations do not have a "remote app" (it's this app, in fact). -So unlike `Relation`, a `PeerRelation` does not have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`: -- `Relation.remote_unit_ids` maps to `PeerRelation.peers_ids` -- `Relation.remote_units_data` maps to `PeerRelation.peers_data` + +To declare a peer relation, you should use `scenario.state.PeerRelation`. The core difference with regular relations is +that peer relations do not have a "remote app" (it's this app, in fact). So unlike `Relation`, a `PeerRelation` does not +have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`: + +- `Relation.remote_unit_ids` maps to `PeerRelation.peers_ids` +- `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python from scenario.state import PeerRelation @@ -237,7 +256,9 @@ relation = PeerRelation( ) ``` -be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `peers_data` or `peers_ids`, as that would be flagged by the Consistency Checker: +be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `peers_data` or `peers_ids`, as that would +be flagged by the Consistency Checker: + ```python from scenario import State, PeerRelation @@ -251,12 +272,16 @@ State(relations=[ ``` ### SubordinateRelation -To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`. -The core difference with regular relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit that this unit can see). -So unlike `Relation`, a `SubordinateRelation` does not have a `remote_units_data` argument. Instead, it has a `remote_unit_data` taking a single `Dict[str:str]`, and takes the primary unit ID as a separate argument. -Also, it talks in terms of `primary`: -- `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs) -- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) + +To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`. The core difference with regular +relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit +that this unit can see). So unlike `Relation`, a `SubordinateRelation` does not have a `remote_units_data` argument. +Instead, it has a `remote_unit_data` taking a single `Dict[str:str]`, and takes the primary unit ID as a separate +argument. Also, it talks in terms of `primary`: + +- `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs) +- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping + from unit IDs to databags) - `Relation.remote_app_name` maps to `SubordinateRelation.primary_app_name` ```python @@ -271,9 +296,10 @@ relation = SubordinateRelation( relation.primary_name # "zookeeper/42" ``` - ## Triggering Relation Events -If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the event from one of its aptly-named properties: + +If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the +event from one of its aptly-named properties: ```python from scenario import Relation @@ -284,21 +310,29 @@ joined_event = relation.joined_event ``` This is in fact syntactic sugar for: + ```python from scenario import Relation, Event relation = Relation(endpoint="foo", interface="bar") changed_event = Event('foo-relation-changed', relation=relation) ``` -The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. +The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario +needs to set up the process that will run `ops.main` with the right environment variables. ### Additional event parameters -All relation events have some additional metadata that does not belong in the Relation object, such as, for a relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. -In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the remote unit that the event is about. -The reason that this parameter is not supplied to `Relation` but to relation events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. +All relation events have some additional metadata that does not belong in the Relation object, such as, for a +relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what +`ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. + +In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the +remote unit that the event is about. The reason that this parameter is not supplied to `Relation` but to relation +events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), +but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. -The `remote_unit_id` will default to the first ID found in the relation's `remote_unit_ids`, but if the test you are writing is close to that domain, you should probably override it and pass it manually. +The `remote_unit_id` will default to the first ID found in the relation's `remote_unit_ids`, but if the test you are +writing is close to that domain, you should probably override it and pass it manually. ```python from scenario import Relation, Event @@ -309,16 +343,16 @@ remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) ``` - ## Containers -When testing a kubernetes charm, you can mock container interactions. -When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. +When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will +be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. To give the charm access to some containers, you need to pass them to the input state, like so: `State(containers=[...])` An example of a scene including some containers: + ```python from scenario.state import Container, State state = State(containers=[ @@ -347,13 +381,15 @@ state = State(containers=[ ``` In this case, if the charm were to: + ```python def _on_start(self, _): foo = self.unit.get_container('foo') content = foo.pull('/local/share/config.yaml').read() ``` -then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping strings and passing them to the charm via the container. +then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping +strings and passing them to the charm via the container. `container.push` works similarly, so you can write a test like: @@ -388,10 +424,14 @@ def test_pebble_push(): assert local_file.read().decode() == "TEST" ``` -`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we need to associate the container with the event is that the Framework uses an envvar to determine which container the pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. +`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we +need to associate the container with the event is that the Framework uses an envvar to determine which container the +pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting +that envvar into the charm's runtime. -`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. -You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. +`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far +worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the +result of that would be: its return code, what will be written to stdout/stderr. ```python from ops.charm import CharmBase @@ -399,10 +439,10 @@ from ops.charm import CharmBase from scenario.state import State, Container, ExecOutput LS_LL = """ -.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml -.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml -.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md -drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib +.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml +.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml +.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md +drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib """ @@ -432,10 +472,12 @@ def test_pebble_exec(): ) ``` - # Deferred events -Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. -On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is valid. + +Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for +keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that +event in its queue (they would be there because they had been deferred in the previous run), then the output state is +valid. ```python from scenario import State, deferred @@ -448,12 +490,12 @@ class MyCharm(...): def _on_start(self, e): e.defer() - + def test_start_on_deferred_update_status(MyCharm): """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" out = State( deferred=[ - deferred('update_status', + deferred('update_status', handler=MyCharm._on_update_status) ] ).trigger('start', MyCharm) @@ -461,7 +503,8 @@ def test_start_on_deferred_update_status(MyCharm): assert out.deferred[0].name == 'start' ``` -You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the handler): +You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the +handler): ```python from scenario import Event, Relation @@ -474,11 +517,14 @@ deferred_install = Event('install').deferred(MyCharm._on_start) ``` ## relation events: -```python -foo_relation = Relation('foo') + +```python +foo_relation = Relation('foo') deferred_relation_changed_evt = foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` -On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. + +On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed +been deferred. ```python from scenario import State @@ -489,15 +535,18 @@ class MyCharm(...): def _on_start(self, e): e.defer() - + def test_defer(MyCharm): out = State().trigger('start', MyCharm) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` - + ## Deferring relation events -If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data structure: + +If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the +Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data +structure: ```python from scenario import State, Relation, deferred @@ -508,18 +557,19 @@ class MyCharm(...): def _on_foo_relation_changed(self, e): e.defer() - + def test_start_on_deferred_update_status(MyCharm): - foo_relation = Relation('foo') + foo_relation = Relation('foo') State( relations=[foo_relation], deferred=[ - deferred('foo_relation_changed', + deferred('foo_relation_changed', handler=MyCharm._on_foo_relation_changed, relation=foo_relation) ] ) ``` + but you can also use a shortcut from the relation event itself, as mentioned above: ```python @@ -529,13 +579,14 @@ from scenario import Relation class MyCharm(...): ... -foo_relation = Relation('foo') +foo_relation = Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` ### Fine-tuning -The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by charm libraries or objects other than the main charm class. +The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by +charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. @@ -549,11 +600,9 @@ my_deferred_event = DeferredEvent( ) ``` - # StoredState -Scenario can simulate StoredState. -You can define it on the input side as: +Scenario can simulate StoredState. You can define it on the input side as: ```python from ops.charm import CharmBase @@ -580,15 +629,18 @@ state = State(stored_state=[ ]) ``` -And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. -Also, you can run assertions on it on the output side the same as any other bit of state. - +And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on +the output side the same as any other bit of state. # Emitted events -If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. -In these situations it can be useful to verify that, as a result of a given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. `scenario.capture_events` allows you to open a peephole and intercept any events emitted by the framework. -Usage: +If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it +can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a +given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The +resulting state, black-box as it is, gives little insight into how exactly it was obtained. `scenario.capture_events` +allows you to open a peephole and intercept any events emitted by the framework. + +Usage: ```python from ops.charm import StartEvent, UpdateStatusEvent @@ -603,10 +655,9 @@ assert isinstance(emitted[0], StartEvent) assert isinstance(emitted[1], UpdateStatusEvent) # possibly followed by a tail of all custom events that the main juju event triggered in turn # assert isinstance(emitted[2], MyFooEvent) -# ... +# ... ``` - You can filter events by type like so: ```python @@ -614,21 +665,22 @@ from ops.charm import StartEvent, RelationEvent from scenario import capture_events with capture_events(StartEvent, RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. - pass + pass ``` Passing no event types, like: `capture_events()`, is equivalent to `capture_events(EventBase)`. -By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. - -By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by passing: -`capture_events(include_deferred=True)`. +By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if +they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. +By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by +passing: `capture_events(include_deferred=True)`. # The virtual charm root -Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. -The charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can -be either inferred from the charm type being passed to `trigger()` or be passed to it as an argument, thereby overriding + +Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The +charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can be +either inferred from the charm type being passed to `trigger()` or be passed to it as an argument, thereby overriding the inferred one. This also allows you to test with charms defined on the fly, as in: ```python @@ -641,9 +693,8 @@ class MyCharmType(CharmBase): state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, event='start') ``` -A consequence of this fact is that you have no direct control over the tempdir that we are -creating to put the metadata you are passing to trigger (because `ops` expects it to be a file...). -That is, unless you pass your own: +A consequence of this fact is that you have no direct control over the tempdir that we are creating to put the metadata +you are passing to trigger (because `ops` expects it to be a file...). That is, unless you pass your own: ```python from ops.charm import CharmBase @@ -660,45 +711,60 @@ state = State().trigger(charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name) ``` -Do this, and you will be able to set up said directory as you like before the charm is run, as well -as verify its contents after the charm has run. Do keep in mind that the metadata files will -be overwritten by Scenario, and therefore ignored. - +Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its +contents after the charm has run. Do keep in mind that the metadata files will be overwritten by Scenario, and therefore +ignored. # Consistency checks -A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. -For example, Juju can't emit a `foo-relation-changed` event on your charm unless your charm has declared a `foo` relation -endpoint in its `metadata.yaml`. If that happens, that's a juju bug. -Scenario however assumes that Juju is bug-free, therefore, so far as we're concerned, that can't happen, and therefore we -help you verify that the scenarios you create are consistent and raise an exception if that isn't so. +A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. For +example, Juju can't emit a `foo-relation-changed` event on your charm unless your charm has declared a `foo` relation +endpoint in its `metadata.yaml`. If that happens, that's a juju bug. Scenario however assumes that Juju is bug-free, +therefore, so far as we're concerned, that can't happen, and therefore we help you verify that the scenarios you create +are consistent and raise an exception if that isn't so. -That happens automatically behind the scenes whenever you trigger an event; `scenario.consistency_checker.check_consistency` -is called and verifies that the scenario makes sense. +That happens automatically behind the scenes whenever you trigger an event; +`scenario.consistency_checker.check_consistency` is called and verifies that the scenario makes sense. ## Caveats: + - False positives: not all checks are implemented yet; more will come. -- False negatives: it is possible that a scenario you know to be consistent is seen as inconsistent. That is probably a bug in the consistency checker itself, please report it. -- Inherent limitations: if you have a custom event whose name conflicts with a builtin one, the consistency constraints of the builtin one will apply. For example: if you decide to name your custom event `bar-pebble-ready`, but you are working on a machine charm or don't have either way a `bar` container in your `metadata.yaml`, Scenario will flag that as inconsistent. +- False negatives: it is possible that a scenario you know to be consistent is seen as inconsistent. That is probably a + bug in the consistency checker itself, please report it. +- Inherent limitations: if you have a custom event whose name conflicts with a builtin one, the consistency constraints + of the builtin one will apply. For example: if you decide to name your custom event `bar-pebble-ready`, but you are + working on a machine charm or don't have either way a `bar` container in your `metadata.yaml`, Scenario will flag that + as inconsistent. ## Bypassing the checker -If you have a clear false negative, are explicitly testing 'edge', inconsistent situations, or for whatever reason the checker is in your way, you can set the `SCENARIO_SKIP_CONSISTENCY_CHECKS` envvar and skip it altogether. Hopefully you don't need that. +If you have a clear false negative, are explicitly testing 'edge', inconsistent situations, or for whatever reason the +checker is in your way, you can set the `SCENARIO_SKIP_CONSISTENCY_CHECKS` envvar and skip it altogether. Hopefully you +don't need that. # Snapshot -Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to reach the entry point by typing `scenario snapshot` in a shell. +Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to +reach the entry point by typing `scenario snapshot` in a shell. + +Snapshot's purpose is to gather the State data structure from a real, live charm running in some cloud your local juju +client has access to. This is handy in case: -Snapshot's purpose is to gather the State data structure from a real, live charm running in some cloud your local juju client has access to. This is handy in case: - you want to write a test about the state the charm you're developing is currently in -- your charm is bork or in some inconsistent state, and you want to write a test to check the charm will handle it correctly the next time around (aka regression testing) +- your charm is bork or in some inconsistent state, and you want to write a test to check the charm will handle it + correctly the next time around (aka regression testing) - you are new to Scenario and want to quickly get started with a real-life example. -Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type `scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import all you need from `scenario`, and you have a working `State` that you can `.trigger()` events from. +Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type +`scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import +all you need from `scenario`, and you have a working `State` that you can `.trigger()` events from. You can also pass a `--format json | pytest | state (default=state)` flag to obtain + - jsonified `State` data structure, for portability -- a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event that you wish to trigger. +- a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event + that you wish to trigger. # TODOS: + - Recorder diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 3f0f65d5c..d2a8f20ba 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -19,7 +19,9 @@ @contextmanager def capture_events( - *types: Type[EventBase], include_framework=False, include_deferred=True + *types: Type[EventBase], + include_framework=False, + include_deferred=True, ) -> ContextManager[List[EventBase]]: """Capture all events of type `*types` (using instance checks). @@ -71,7 +73,8 @@ def _wrapped_reemit(self): self._forget(event) # prevent tracking conflicts if not include_framework and isinstance( - event, (PreCommitEvent, CommitEvent) + event, + (PreCommitEvent, CommitEvent), ): continue diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 98ee93ad6..83d79e59f 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -56,7 +56,10 @@ def check_consistency( check_relation_consistency, ): results = check( - state=state, event=event, charm_spec=charm_spec, juju_version=juju_version + state=state, + event=event, + charm_spec=charm_spec, + juju_version=juju_version, ) errors.extend(results.errors) warnings.extend(results.warnings) @@ -64,18 +67,21 @@ def check_consistency( if errors: err_fmt = "\n".join(errors) raise InconsistentScenarioError( - f"Inconsistent scenario. The following errors were found: {err_fmt}" + f"Inconsistent scenario. The following errors were found: {err_fmt}", ) if warnings: err_fmt = "\n".join(warnings) logger.warning( f"This scenario is probably inconsistent. Double check, and ignore this warning if you're sure. " - f"The following warnings were found: {err_fmt}" + f"The following warnings were found: {err_fmt}", ) def check_event_consistency( - *, event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, + event: "Event", + charm_spec: "_CharmSpec", + **_kwargs, ) -> Results: """Check the internal consistency of the Event data structure. @@ -90,39 +96,42 @@ def check_event_consistency( warnings.append( "this is a custom event; if its name makes it look like a builtin one " "(e.g. a relation event, or a workload event), you might get some false-negative " - "consistency checks." + "consistency checks.", ) if event._is_relation_event: # noqa if not event.relation: errors.append( "cannot construct a relation event without the relation instance. " - "Please pass one." + "Please pass one.", ) else: if not event.name.startswith(normalize_name(event.relation.endpoint)): errors.append( f"relation event should start with relation endpoint name. {event.name} does " - f"not start with {event.relation.endpoint}." + f"not start with {event.relation.endpoint}.", ) if event._is_workload_event: # noqa if not event.container: errors.append( "cannot construct a workload event without the container instance. " - "Please pass one." + "Please pass one.", ) else: if not event.name.startswith(normalize_name(event.container.name)): errors.append( f"workload event should start with container name. {event.name} does " - f"not start with {event.container.name}." + f"not start with {event.container.name}.", ) return Results(errors, warnings) def check_config_consistency( - *, state: "State", charm_spec: "_CharmSpec", **_kwargs + *, + state: "State", + charm_spec: "_CharmSpec", + **_kwargs, ) -> Results: """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" state_config = state.config @@ -132,7 +141,7 @@ def check_config_consistency( for key, value in state_config.items(): if key not in meta_config: errors.append( - f"config option {key!r} in state.config but not specified in config.yaml." + f"config option {key!r} in state.config but not specified in config.yaml.", ) continue @@ -155,14 +164,18 @@ def check_config_consistency( if not isinstance(value, expected_type): errors.append( f"config invalid; option {key!r} should be of type {expected_type} " - f"but is of type {type(value)}." + f"but is of type {type(value)}.", ) return Results(errors, []) def check_secrets_consistency( - *, event: "Event", state: "State", juju_version: Tuple[int, ...], **_kwargs + *, + event: "Event", + state: "State", + juju_version: Tuple[int, ...], + **_kwargs, ) -> Results: """Check the consistency of Secret-related stuff.""" errors = [] @@ -171,19 +184,23 @@ def check_secrets_consistency( if not state.secrets: errors.append( - "the event being processed is a secret event; but the state has no secrets." + "the event being processed is a secret event; but the state has no secrets.", ) elif juju_version < (3,): errors.append( f"secrets are not supported in the specified juju version {juju_version}. " - f"Should be at least 3.0." + f"Should be at least 3.0.", ) return Results(errors, []) def check_relation_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + **_kwargs, ) -> Results: errors = [] nonpeer_relations_meta = chain( @@ -205,7 +222,7 @@ def _get_relations(r): if not isinstance(relation, PeerRelation): errors.append( f"endpoint {endpoint} is a peer relation; " - f"expecting relation to be of type PeerRelation, got {type(relation)}" + f"expecting relation to be of type PeerRelation, got {type(relation)}", ) for endpoint, relation_meta in all_relations_meta: @@ -217,13 +234,13 @@ def _get_relations(r): errors.append( f"endpoint {endpoint} is not a subordinate relation; " f"expecting relation to be of type Relation, " - f"got {type(relation)}" + f"got {type(relation)}", ) if expected_sub and not is_sub: errors.append( f"endpoint {endpoint} is not a subordinate relation; " f"expecting relation to be of type SubordinateRelation, " - f"got {type(relation)}" + f"got {type(relation)}", ) # check for duplicate endpoint names @@ -238,7 +255,11 @@ def _get_relations(r): def check_containers_consistency( - *, state: "State", event: "Event", charm_spec: "_CharmSpec", **_kwargs + *, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + **_kwargs, ) -> Results: """Check the consistency of `state.containers` vs. `charm_spec.meta` (metadata.yaml/containers).""" meta_containers = list(charm_spec.meta.get("containers", {})) @@ -252,20 +273,20 @@ def check_containers_consistency( if evt_container_name not in meta_containers: errors.append( f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not declared in the charm metadata" + f"with that name is not declared in the charm metadata", ) if evt_container_name not in state_containers: errors.append( f"the event being processed concerns container {evt_container_name!r}, but a container " f"with that name is not present in the state. It's odd, but consistent, if it cannot " - f"connect; but it should at least be there." + f"connect; but it should at least be there.", ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): errors.append( f"some containers declared in the state are not specified in metadata. That's not possible. " - f"Missing from metadata: {diff}." + f"Missing from metadata: {diff}.", ) # guard against duplicate container names diff --git a/scenario/mocking.py b/scenario/mocking.py index 8b294c585..617253af6 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -71,11 +71,12 @@ def get_pebble(self, socket_path: str) -> "Client": ) def _get_relation_by_id( - self, rel_id + self, + rel_id, ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( - filter(lambda r: r.relation_id == rel_id, self._state.relations) + filter(lambda r: r.relation_id == rel_id, self._state.relations), ) except StopIteration as e: raise RuntimeError(f"Not found: relation with id={rel_id}.") from e @@ -225,7 +226,10 @@ def secret_get( return secret.contents[revision] def secret_info_get( - self, *, id: Optional[str] = None, label: Optional[str] = None + self, + *, + id: Optional[str] = None, + label: Optional[str] = None, ) -> SecretInfo: secret = self._get_secret(id, label) if not secret.owner: @@ -353,13 +357,13 @@ def _container(self) -> "ContainerSpec": container_name = self.socket_path.split("/")[-2] try: return next( - filter(lambda x: x.name == container_name, self._state.containers) + filter(lambda x: x.name == container_name, self._state.containers), ) except StopIteration: raise RuntimeError( f"container with name={container_name!r} not found. " f"Did you forget a Container, or is the socket path " - f"{self.socket_path!r} wrong?" + f"{self.socket_path!r} wrong?", ) @property diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 85b34a784..53bfd893c 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -41,12 +41,15 @@ def main( from scenario.mocking import _MockModelBackend model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false - state=state, event=event, charm_spec=charm_spec + state=state, + event=event, + charm_spec=charm_spec, ) debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) logger.debug( - "Operator Framework %s up and running.", ops.__version__ + "Operator Framework %s up and running.", + ops.__version__, ) # type:ignore dispatcher = _Dispatcher(charm_dir) @@ -86,7 +89,7 @@ def main( if not getattr(charm.on, dispatcher.event_name, None): raise NoObserverError( f"Charm has no registered observers for {dispatcher.event_name!r}. " - f"This is probably not what you were looking for." + f"This is probably not what you were looking for.", ) _emit_charm_event(charm, dispatcher.event_name) diff --git a/scenario/runtime.py b/scenario/runtime.py index e9a506bb6..40a00cdd1 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -45,7 +45,7 @@ logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( - r"((?P.*)\/)?(?P\D+)\[(?P.*)\]" + r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", ) EVENT_REGEX = re.compile(_event_regex) @@ -108,7 +108,9 @@ def get_deferred_events(self) -> List["DeferredEvent"]: notices = db.notices(handle_path) for handle, owner, observer in notices: event = DeferredEvent( - handle_path=handle, owner=owner, observer=observer + handle_path=handle, + owner=owner, + observer=observer, ) deferred.append(event) @@ -124,7 +126,7 @@ def apply_state(self, state: "State"): 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." + 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) @@ -183,7 +185,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_MODEL_NAME": state.model.name, "JUJU_ACTION_NAME": action_name, "JUJU_MODEL_UUID": state.model.uuid, - "JUJU_CHARM_DIR": str(charm_root.absolute()) + "JUJU_CHARM_DIR": str(charm_root.absolute()), # todo consider setting pwd, (python)path } @@ -199,7 +201,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_RELATION": relation.endpoint, "JUJU_RELATION_ID": str(relation.relation_id), "JUJU_REMOTE_APP": remote_app_name, - } + }, ) remote_unit_id = event.relation_remote_unit_id @@ -213,14 +215,14 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): logger.info( "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, " "but you probably should be parametrizing the event with `remote_unit_id` " - "to be explicit." + "to be explicit.", ) else: remote_unit_id = remote_unit_ids[0] logger.warning( "remote unit ID unset, and multiple remote unit IDs are present; " "We will pick the first one and hope for the best. You should be passing " - "`remote_unit_id` to the Event constructor." + "`remote_unit_id` to the Event constructor.", ) if remote_unit_id is not None: @@ -237,7 +239,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): { "JUJU_SECRET_ID": secret.id, "JUJU_SECRET_LABEL": secret.label or "", - } + }, ) return env @@ -280,7 +282,7 @@ def virtual_charm_root(self): actions_yaml = virtual_charm_root / "actions.yaml" metadata_files_present = any( - (file.exists() for file in (metadata_yaml, config_yaml, actions_yaml)) + file.exists() for file in (metadata_yaml, config_yaml, actions_yaml) ) if spec.is_autoloaded and vroot_is_custom: @@ -291,7 +293,7 @@ def virtual_charm_root(self): logger.info( f"metadata files found in custom vroot {vroot}. " f"The spec was autoloaded so the contents should be identical. " - f"Proceeding..." + f"Proceeding...", ) elif not spec.is_autoloaded and metadata_files_present: @@ -300,7 +302,7 @@ def virtual_charm_root(self): f"while you have passed meta, config or actions to trigger(). " "We don't want to risk overwriting them mindlessly, so we abort. " "You should not include any metadata files in the charm_root. " - "Single source of truth are the arguments passed to trigger(). " + "Single source of truth are the arguments passed to trigger(). ", ) raise DirtyVirtualCharmRootError(vroot) @@ -364,7 +366,9 @@ def exec( logger.info(" - preparing env") env = self._get_event_env( - state=state, event=event, charm_root=temporary_charm_root + state=state, + event=event, + charm_root=temporary_charm_root, ) os.environ.update(env) @@ -379,14 +383,14 @@ def exec( state=output_state, event=event, charm_spec=self._charm_spec.replace( - charm_type=self._wrap(charm_type) + charm_type=self._wrap(charm_type), ), ) except NoObserverError: raise # propagate along except Exception as e: raise UncaughtCharmError( - f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}" + f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}", ) from e finally: logger.info(" - Exited ops.main.") @@ -460,7 +464,10 @@ def trigger( if not meta: meta = {"name": str(charm_type.__name__)} spec = _CharmSpec( - charm_type=charm_type, meta=meta, actions=actions, config=config + charm_type=charm_type, + meta=meta, + actions=actions, + config=config, ) runtime = Runtime( diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index b15aece41..3b750baa6 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -74,14 +74,14 @@ def format_state(state: State): def test_case(): # Arrange: prepare the state state = {state} - - #Act: trigger an event on the state + + #Act: trigger an event on the state out = state.trigger( {en} {ct} juju_version="{jv}" ) - + # Assert: verify that the output state is the way you want it to be # TODO: add assertions """ @@ -99,7 +99,7 @@ def format_test_case( jv = juju_version or "3.0, # TODO: check juju version is correct" state_fmt = repr(state) return _try_format( - PYTEST_TEST_TEMPLATE.format(state=state_fmt, ct=ct, en=en, jv=jv) + PYTEST_TEST_TEMPLATE.format(state=state_fmt, ct=ct, en=en, jv=jv), ) @@ -155,13 +155,14 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne value=raw_adds["value"], cidr=raw_adds["cidr"], address=raw_adds.get("address", ""), - ) + ), ) bind_addresses.append( BindAddress( - interface_name=raw_bind.get("interface-name", ""), addresses=addresses - ) + interface_name=raw_bind.get("interface-name", ""), + addresses=addresses, + ), ) return Network( name=endpoint, @@ -225,7 +226,10 @@ class RemotePebbleClient: """Clever little class that wraps calls to a remote pebble client.""" def __init__( - self, container: str, target: JujuUnitName, model: Optional[str] = None + self, + container: str, + target: JujuUnitName, + model: Optional[str] = None, ): self.socket_path = f"/charm/containers/{container}/pebble.socket" self.container = container @@ -242,7 +246,7 @@ def _run(self, cmd: str) -> str: f"error wrapping pebble call with {command}: " f"process exited with {proc.returncode}; " f"stdout = {proc.stdout}; " - f"stderr = {proc.stderr}" + f"stderr = {proc.stderr}", ) def can_connect(self) -> bool: @@ -260,12 +264,19 @@ def get_plan(self) -> dict: return yaml.safe_load(plan_raw) def pull( - self, path: str, *, encoding: Optional[str] = "utf-8" + self, + path: str, + *, + encoding: Optional[str] = "utf-8", ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( - self, path: str, *, pattern: Optional[str] = None, itself: bool = False + self, + path: str, + *, + pattern: Optional[str] = None, + itself: bool = False, ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() @@ -309,7 +320,7 @@ def get_mounts( if fetch_files and not mount_meta: logger.error( f"No mounts defined for container {container_name} in metadata.yaml. " - f"Cannot fetch files {fetch_files} for this container." + f"Cannot fetch files {fetch_files} for this container.", ) return {} @@ -329,7 +340,7 @@ def get_mounts( if not found: logger.error( - f"could not find mount corresponding to requested remote_path {remote_path}: skipping..." + f"could not find mount corresponding to requested remote_path {remote_path}: skipping...", ) continue @@ -450,7 +461,8 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: def get_config( - target: JujuUnitName, model: Optional[str] + target: JujuUnitName, + model: Optional[str], ) -> Dict[str, Union[str, int, float, bool]]: """Get config dict from target.""" @@ -520,7 +532,7 @@ def _clean(relation_data: dict): relations = [] for raw_relation in jsn[target].get("relation-info", ()): logger.debug( - f" getting relation data for endpoint {raw_relation.get('endpoint')!r}" + f" getting relation data for endpoint {raw_relation.get('endpoint')!r}", ) related_units = raw_relation.get("related-units") if not related_units: @@ -536,7 +548,9 @@ def _clean(relation_data: dict): relation_id = raw_relation["relation-id"] local_unit_data_raw = _juju_exec( - target, model, f"relation-get -r {relation_id} - {target} --format json" + target, + model, + f"relation-get -r {relation_id} - {target} --format json", ) local_unit_data = json.loads(local_unit_data_raw) local_app_data_raw = _juju_exec( @@ -556,7 +570,8 @@ def _clean(relation_data: dict): Relation( endpoint=raw_relation["endpoint"], interface=_get_interface_from_metadata( - raw_relation["endpoint"], metadata + raw_relation["endpoint"], + metadata, ), relation_id=relation_id, remote_app_data=raw_relation["application-data"], @@ -567,7 +582,7 @@ def _clean(relation_data: dict): }, local_app_data=local_app_data, local_unit_data=_clean(local_unit_data), - ) + ), ) return relations @@ -580,7 +595,7 @@ def get_model(name: str = None) -> Model: model_name = name or jsn["current-model"] try: model_info = next( - filter(lambda m: m["short-name"] == model_name, jsn["models"]) + filter(lambda m: m["short-name"] == model_name, jsn["models"]), ) except StopIteration as e: raise InvalidTargetModelName(name) from e @@ -609,7 +624,8 @@ def try_guess_charm_type_name() -> Optional[str]: class FormatOption( - str, Enum + str, + Enum, ): # Enum for typer support, str for native comparison and ==. """Output formatting options for snapshot.""" @@ -685,7 +701,7 @@ def _snapshot( except InvalidTargetUnitName: logger.critical( f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." + f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.", ) sys.exit(1) @@ -790,7 +806,9 @@ def if_include(key, fn, default): if format == FormatOption.pytest: charm_type_name = try_guess_charm_type_name() txt = format_test_case( - state, charm_type_name=charm_type_name, juju_version=juju_version + state, + charm_type_name=charm_type_name, + juju_version=juju_version, ) elif format == FormatOption.state: txt = format_state(state) @@ -806,7 +824,7 @@ def if_include(key, fn, default): f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" f"# Controller timestamp := {controller_timestamp}. \n" f"# Juju version := {juju_version} \n" - f"# Charm fingerprint := {charm_version} \n" + f"# Charm fingerprint := {charm_version} \n", ) print(txt) @@ -816,7 +834,10 @@ def if_include(key, fn, default): def snapshot( target: str = typer.Argument(..., help="Target unit."), model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." + None, + "-m", + "--model", + help="Which model to look at.", ), format: FormatOption = typer.Option( "state", @@ -907,5 +928,5 @@ def snapshot( # Path("/etc/traefik/traefik.yaml"), # ] # }, - ) + ), ) diff --git a/scenario/scripts/utils.py b/scenario/scripts/utils.py index 23672305a..de9dc01e6 100644 --- a/scenario/scripts/utils.py +++ b/scenario/scripts/utils.py @@ -19,5 +19,5 @@ def __init__(self, unit_name: str): self.unit_id = int(unit_id) self.normalized = f"{app_name}-{unit_id}" self.remote_charm_root = Path( - f"/var/lib/juju/agents/unit-{self.normalized}/charm" + f"/var/lib/juju/agents/unit-{self.normalized}/charm", ) diff --git a/scenario/sequences.py b/scenario/sequences.py index fa30b4dca..ba6be1d4d 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -59,7 +59,7 @@ def generate_startup_sequence(state_template: State): Event( "leader_elected" if state_template.leader - else "leader_settings_changed" + else "leader_settings_changed", ), state_template.copy(), ), @@ -115,7 +115,7 @@ def check_builtin_sequences( ( template.replace(leader=True), template.replace(leader=False), - ) + ), ): state.trigger( event=event, diff --git a/scenario/state.py b/scenario/state.py index cef188e0a..f3dcbd98a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -110,7 +110,7 @@ def changed_event(self): """Sugar to generate a secret-changed event.""" if self.owner: raise ValueError( - "This unit will never receive secret-changed for a secret it owns." + "This unit will never receive secret-changed for a secret it owns.", ) return Event(name="secret_changed", secret=self) @@ -120,7 +120,7 @@ def rotate_event(self): """Sugar to generate a secret-rotate event.""" if not self.owner: raise ValueError( - "This unit will never receive secret-rotate for a secret it does not own." + "This unit will never receive secret-rotate for a secret it does not own.", ) return Event(name="secret_rotate", secret=self) @@ -129,7 +129,7 @@ def expired_event(self): """Sugar to generate a secret-expired event.""" if not self.owner: raise ValueError( - "This unit will never receive secret-expire for a secret it does not own." + "This unit will never receive secret-expire for a secret it does not own.", ) return Event(name="secret_expire", secret=self) @@ -138,7 +138,7 @@ def remove_event(self): """Sugar to generate a secret-remove event.""" if not self.owner: raise ValueError( - "This unit will never receive secret-removed for a secret it does not own." + "This unit will never receive secret-removed for a secret it does not own.", ) return Event(name="secret_removed", secret=self) @@ -168,7 +168,7 @@ def __call__(self, remote_unit: Optional[str] = None) -> "Event": if remote_unit and "remote_unit" not in self._accept_params: raise ValueError( f"cannot pass param `remote_unit` to a " - f"{self._category} event constructor." + f"{self._category} event constructor.", ) return Event(*self._args, *self._kwargs, relation_remote_unit_id=remote_unit) @@ -182,7 +182,7 @@ def _generate_new_relation_id(): _RELATION_IDS_CTR += 1 logger.info( f"relation ID unset; automatically assigning {_RELATION_IDS_CTR}. " - f"If there are problems, pass one manually." + f"If there are problems, pass one manually.", ) return _RELATION_IDS_CTR @@ -224,7 +224,7 @@ def __post_init__(self): if type(self) is RelationBase: raise RuntimeError( "RelationBase cannot be instantiated directly; " - "please use Relation, PeerRelation, or SubordinateRelation" + "please use Relation, PeerRelation, or SubordinateRelation", ) for databag in self._databags: @@ -233,48 +233,53 @@ def __post_init__(self): def _validate_databag(self, databag: dict): if not isinstance(databag, dict): raise StateValidationError( - f"all databags should be dicts, not {type(databag)}" + f"all databags should be dicts, not {type(databag)}", ) for k, v in databag.items(): if not isinstance(v, str): raise StateValidationError( f"all databags should be Dict[str,str]; " - f"found a value of type {type(v)}" + f"found a value of type {type(v)}", ) @property def changed_event(self) -> "Event": """Sugar to generate a -relation-changed event.""" return Event( - name=normalize_name(self.endpoint + "-relation-changed"), relation=self + name=normalize_name(self.endpoint + "-relation-changed"), + relation=self, ) @property def joined_event(self) -> "Event": """Sugar to generate a -relation-joined event.""" return Event( - name=normalize_name(self.endpoint + "-relation-joined"), relation=self + name=normalize_name(self.endpoint + "-relation-joined"), + relation=self, ) @property def created_event(self) -> "Event": """Sugar to generate a -relation-created event.""" return Event( - name=normalize_name(self.endpoint + "-relation-created"), relation=self + name=normalize_name(self.endpoint + "-relation-created"), + relation=self, ) @property def departed_event(self) -> "Event": """Sugar to generate a -relation-departed event.""" return Event( - name=normalize_name(self.endpoint + "-relation-departed"), relation=self + name=normalize_name(self.endpoint + "-relation-departed"), + relation=self, ) @property def broken_event(self) -> "Event": """Sugar to generate a -relation-broken event.""" return Event( - name=normalize_name(self.endpoint + "-relation-broken"), relation=self + name=normalize_name(self.endpoint + "-relation-broken"), + relation=self, ) @@ -308,7 +313,7 @@ def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): if ids and data: if not set(ids) == set(data): raise StateValidationError( - f"{ids} should include any and all IDs from {data}" + f"{ids} should include any and all IDs from {data}", ) elif ids: data = {x: {} for x in ids} @@ -332,14 +337,15 @@ class Relation(RelationBase): remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( - default_factory=dict + default_factory=dict, ) def __post_init__(self): super().__post_init__() remote_unit_ids, remote_units_data = unify_ids_and_remote_units_data( - self.remote_unit_ids, self.remote_units_data + self.remote_unit_ids, + self.remote_units_data, ) # bypass frozen dataclass object.__setattr__(self, "remote_unit_ids", remote_unit_ids) @@ -436,7 +442,8 @@ def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: def __post_init__(self): peers_ids, peers_data = unify_ids_and_remote_units_data( - self.peers_ids, self.peers_data + self.peers_ids, + self.peers_data, ) # bypass frozen dataclass guards object.__setattr__(self, "peers_ids", peers_ids) @@ -473,7 +480,7 @@ def _generate_new_change_id(): _CHANGE_IDS += 1 logger.info( f"change ID unset; automatically assigning {_CHANGE_IDS}. " - f"If there are problems, pass one manually." + f"If there are problems, pass one manually.", ) return _CHANGE_IDS @@ -516,7 +523,7 @@ class Container(_DCBase): layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( - default_factory=dict + default_factory=dict, ) # this is how you specify the contents of the filesystem: suppose you want to express that your @@ -580,7 +587,9 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: else: startup = pebble.ServiceStartup(service.startup) info = pebble.ServiceInfo( - name, startup=startup, current=pebble.ServiceStatus(status) + name, + startup=startup, + current=pebble.ServiceStatus(status), ) infos[name] = info return infos @@ -589,7 +598,8 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: def filesystem(self) -> "_MockFileSystem": mounts = { name: _MockStorageMount( - src=Path(spec.src), location=PurePosixPath(spec.location) + src=Path(spec.src), + location=PurePosixPath(spec.location), ) for name, spec in self.mounts.items() } @@ -601,7 +611,7 @@ def pebble_ready_event(self): if not self.can_connect: logger.warning( "you **can** fire pebble-ready while the container cannot connect, " - "but that's most likely not what you want." + "but that's most likely not what you want.", ) return Event(name=normalize_name(self.name + "-pebble-ready"), container=self) @@ -668,9 +678,9 @@ def default( interface_name=interface_name, mac_address=mac_address, addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr) + Address(hostname=hostname, value=private_address, cidr=cidr), ], - ) + ), ], egress_subnets=list(egress_subnets), ingress_addresses=list(ingress_addresses), @@ -689,14 +699,14 @@ class _EntityStatus(_DCBase): def __eq__(self, other): if isinstance(other, Tuple): logger.warning( - "Comparing Status with Tuples is deprecated and will be removed soon." + "Comparing Status with Tuples is deprecated and will be removed soon.", ) return (self.name, self.message) == other if isinstance(other, StatusBase): return (self.name, self.message) == (other.name, other.message) logger.warning( f"Comparing Status with {other} is not stable and will be forbidden soon." - f"Please compare with StatusBase directly." + f"Please compare with StatusBase directly.", ) return super().__eq__(other) @@ -738,7 +748,7 @@ def __post_init__(self): logger.warning( "Initializing Status.[app/unit] with Tuple[str, str] is deprecated " "and will be removed soon. \n" - f"Please pass a StatusBase instance: `StatusBase(*{val})`" + f"Please pass a StatusBase instance: `StatusBase(*{val})`", ) object.__setattr__(self, name, _EntityStatus(*val)) else: @@ -754,7 +764,10 @@ def _update_app_version(self, new_app_version: str): object.__setattr__(self, "app_version", new_app_version) def _update_status( - self, new_status: str, new_message: str = "", is_app: bool = False + self, + new_status: str, + new_message: str = "", + is_app: bool = False, ): """Update the current app/unit status and add the previous one to the history.""" if is_app: @@ -793,7 +806,7 @@ class State(_DCBase): """ config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( - default_factory=dict + default_factory=dict, ) relations: List["AnyRelation"] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) @@ -829,8 +842,9 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return self.replace( status=dataclasses.replace( - self.status, unit=_status_to_entitystatus(status) - ) + self.status, + unit=_status_to_entitystatus(status), + ), ) def get_container(self, container: Union[str, Container]) -> Container: @@ -856,11 +870,12 @@ def jsonpatch_delta(self, other: "State"): logger.error( "cannot import jsonpatch: using the .delta() " "extension requires jsonpatch to be installed." - "Fetch it with pip install jsonpatch." + "Fetch it with pip install jsonpatch.", ) return NotImplemented patch = jsonpatch.make_patch( - dataclasses.asdict(other), dataclasses.asdict(self) + dataclasses.asdict(other), + dataclasses.asdict(self), ).patch return sort_patch(patch) @@ -981,7 +996,7 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": if remote_unit_id and not self._is_relation_event: raise ValueError( "cannot pass param `remote_unit_id` to a " - "non-relation event constructor." + "non-relation event constructor.", ) return self.replace(relation_remote_unit_id=remote_unit_id) @@ -1064,7 +1079,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: match = handler_re.match(handler_repr) if not match: raise ValueError( - f"cannot construct DeferredEvent from {handler}; please create one manually." + f"cannot construct DeferredEvent from {handler}; please create one manually.", ) owner_name, handler_name = match.groups()[0].split(".")[-2:] handle_path = f"{owner_name}/on/{self.name}[{event_id}]" @@ -1083,7 +1098,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: elif self._is_relation_event: if not self.relation: raise ValueError( - "this is a relation event; expected relation attribute" + "this is a relation event; expected relation attribute", ) # this is a RelationEvent. The snapshot: snapshot_data = { diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 3a8511f94..21c736cb2 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -21,14 +21,20 @@ class MyCharm(CharmBase): def assert_inconsistent( - state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version="3.0" + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version="3.0", ): with pytest.raises(InconsistentScenarioError): check_consistency(state, event, charm_spec, juju_version) def assert_consistent( - state: "State", event: "Event", charm_spec: "_CharmSpec", juju_version="3.0" + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + juju_version="3.0", ): check_consistency(state, event, charm_spec, juju_version) @@ -68,7 +74,9 @@ def test_container_meta_mismatch(): def test_container_in_state_but_no_container_in_meta(): assert_inconsistent( - State(containers=[Container("bar")]), Event("foo"), _CharmSpec(MyCharm, {}) + State(containers=[Container("bar")]), + Event("foo"), + _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("bar")]), @@ -116,7 +124,9 @@ def test_evt_no_relation(suffix): def test_config_key_missing_from_meta(): assert_inconsistent( - State(config={"foo": True}), Event("bar"), _CharmSpec(MyCharm, {}) + State(config={"foo": True}), + Event("bar"), + _CharmSpec(MyCharm, {}), ) assert_consistent( State(config={"foo": True}), @@ -176,14 +186,16 @@ def test_sub_relation_consistency(): State(relations=[Relation("foo")]), Event("bar"), _CharmSpec( - MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}} + MyCharm, + {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, ), ) assert_consistent( State(relations=[SubordinateRelation("foo")]), Event("bar"), _CharmSpec( - MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}} + MyCharm, + {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, ), ) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 11c898d05..6e7950e1e 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -51,7 +51,7 @@ def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 out = State( - deferred=[deferred(event="update_status", handler=mycharm._on_event)] + deferred=[deferred(event="update_status", handler=mycharm._on_event)], ).trigger("start", mycharm, meta=mycharm.META) # we deferred the first 2 events we saw: update-status, start. @@ -75,7 +75,9 @@ def test_deferred_relation_evt(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") evt1 = rel.changed_event.deferred(handler=mycharm._on_event) evt2 = deferred( - event="foo_relation_changed", handler=mycharm._on_event, relation=rel + event="foo_relation_changed", + handler=mycharm._on_event, + relation=rel, ) assert asdict(evt2) == asdict(evt1) @@ -98,8 +100,10 @@ def test_deferred_relation_event(mycharm): relations=[rel], deferred=[ deferred( - event="foo_relation_changed", handler=mycharm._on_event, relation=rel - ) + event="foo_relation_changed", + handler=mycharm._on_event, + relation=rel, + ), ], ).trigger("start", mycharm, meta=mycharm.META) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index b0718b5c8..514297c8e 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -41,7 +41,7 @@ def fetch_unit_address(charm: CharmBase): remote_app_name="remote", endpoint="metrics-endpoint", relation_id=1, - ) + ), ], networks=[Network.default("metrics-endpoint")], ), diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 1005145a1..10ec8b88f 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -40,7 +40,7 @@ def test_action_event(charm_evts): charm, evts = charm_evts scenario = Scenario( - _CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}) + _CharmSpec(charm, meta={"name": "foo"}, actions={"show_proxied_endpoints": {}}), ) scene = Scene(Event("show_proxied_endpoints_action"), state=State()) scenario.play(scene) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index d6fb3889c..bf60fc58c 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -77,9 +77,11 @@ def callback(self: CharmBase): State( containers=[ Container( - name="foo", can_connect=True, mounts={"bar": Mount("/bar/baz.txt", pth)} - ) - ] + name="foo", + can_connect=True, + mounts={"bar": Mount("/bar/baz.txt", pth)}, + ), + ], ).trigger( charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -111,9 +113,11 @@ def callback(self: CharmBase): state = State( containers=[ Container( - name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} - ) - ] + name="foo", + can_connect=True, + mounts={"foo": Mount("/foo", td.name)}, + ), + ], ) out = state.trigger( @@ -133,23 +137,23 @@ def callback(self: CharmBase): LS = """ -.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml -.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml -.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md -drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib -.rw-rw-r-- 11k ubuntu ubuntu 18 jan 12:05 -- LICENSE -.rw-rw-r-- 1,6k ubuntu ubuntu 18 jan 12:05 -- metadata.yaml -.rw-rw-r-- 845 ubuntu ubuntu 18 jan 12:05 -- pyproject.toml -.rw-rw-r-- 831 ubuntu ubuntu 18 jan 12:05 -- README.md -.rw-rw-r-- 13 ubuntu ubuntu 18 jan 12:05 -- requirements.txt -drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- src -drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- tests -.rw-rw-r-- 1,9k ubuntu ubuntu 18 jan 12:05 -- tox.ini +.rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml +.rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml +.rw-rw-r-- 900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md +drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib +.rw-rw-r-- 11k ubuntu ubuntu 18 jan 12:05 -- LICENSE +.rw-rw-r-- 1,6k ubuntu ubuntu 18 jan 12:05 -- metadata.yaml +.rw-rw-r-- 845 ubuntu ubuntu 18 jan 12:05 -- pyproject.toml +.rw-rw-r-- 831 ubuntu ubuntu 18 jan 12:05 -- README.md +.rw-rw-r-- 13 ubuntu ubuntu 18 jan 12:05 -- requirements.txt +drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- src +drwxrwxr-x - ubuntu ubuntu 18 jan 12:05 -- tests +.rw-rw-r-- 1,9k ubuntu ubuntu 18 jan 12:05 -- tox.ini """ PS = """ - PID TTY TIME CMD - 298238 pts/3 00:00:04 zsh -1992454 pts/3 00:00:00 ps + PID TTY TIME CMD + 298238 pts/3 00:00:04 zsh +1992454 pts/3 00:00:00 ps """ @@ -173,8 +177,8 @@ def callback(self: CharmBase): name="foo", can_connect=True, exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, - ) - ] + ), + ], ).trigger( charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -204,7 +208,7 @@ def callback(self: CharmBase): foo = self.unit.get_container("foo") assert foo.get_plan().to_dict() == { - "services": {"fooserv": {"startup": "enabled"}} + "services": {"fooserv": {"startup": "enabled"}}, } fooserv = foo.get_services("fooserv")["fooserv"] assert fooserv.startup == ServiceStartup.ENABLED @@ -224,7 +228,7 @@ def callback(self: CharmBase): "services": { "barserv": {"startup": "disabled"}, "fooserv": {"startup": "enabled"}, - } + }, } assert foo.get_service("barserv").current == starting_service_status @@ -241,8 +245,8 @@ def callback(self: CharmBase): "summary": "bla", "description": "deadbeef", "services": {"fooserv": {"startup": "enabled"}}, - } - ) + }, + ), }, service_status={ "fooserv": pebble.ServiceStatus.ACTIVE, diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index a7ee4175c..5ea1e09db 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -44,7 +44,9 @@ def post_event(charm): mycharm._call = call initial_state = State( - config={"foo": "bar"}, leader=True, status=Status(unit=BlockedStatus("foo")) + config={"foo": "bar"}, + leader=True, + status=Status(unit=BlockedStatus("foo")), ) out = initial_state.trigger( @@ -109,8 +111,8 @@ def check_relation_data(charm): remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, - ) - ] + ), + ], ).trigger( charm_type=mycharm, meta={ diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 7d429aa99..2df2edf2c 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -74,7 +74,8 @@ def pre_event(charm: CharmBase): @pytest.mark.parametrize( - "evt_name", ("changed", "broken", "departed", "joined", "created") + "evt_name", + ("changed", "broken", "departed", "joined", "created"), ) def test_relation_events(mycharm, evt_name): relation = Relation(endpoint="foo", interface="foo", remote_app_name="remote") @@ -109,7 +110,9 @@ def test_relation_events(mycharm, evt_name): ) def test_relation_events(mycharm, evt_name, remote_app_name): relation = Relation( - endpoint="foo", interface="foo", remote_app_name=remote_app_name + endpoint="foo", + interface="foo", + remote_app_name=remote_app_name, ) def callback(charm: CharmBase, _): @@ -147,7 +150,9 @@ def callback(charm: CharmBase, _): ) def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id): relation = Relation( - endpoint="foo", interface="foo", remote_app_name=remote_app_name + endpoint="foo", + interface="foo", + remote_app_name=remote_app_name, ) def callback(charm: CharmBase, event): @@ -221,7 +226,9 @@ def callback(charm: CharmBase, event): def test_relation_unit_data_bad_types(mycharm, data): with pytest.raises(StateValidationError): relation = Relation( - endpoint="foo", interface="foo", remote_units_data={0: {"a": data}} + endpoint="foo", + interface="foo", + remote_units_data={0: {"a": data}}, ) @@ -248,12 +255,14 @@ def test_relation_event_trigger(relation, evt_name, mycharm): "interface": "i3", # this is a subordinate relation. "scope": "container", - } + }, }, "peers": {"b": {"interface": "i2"}}, } state = State(relations=[relation]).trigger( - getattr(relation, evt_name + "_event"), mycharm, meta=meta + getattr(relation, evt_name + "_event"), + mycharm, + meta=meta, ) @@ -265,15 +274,19 @@ def test_trigger_sub_relation(mycharm): "interface": "bar", # this is a subordinate relation. "scope": "container", - } + }, }, } sub1 = SubordinateRelation( - "foo", remote_unit_data={"1": "2"}, primary_app_name="primary1" + "foo", + remote_unit_data={"1": "2"}, + primary_app_name="primary1", ) sub2 = SubordinateRelation( - "foo", remote_unit_data={"3": "4"}, primary_app_name="primary2" + "foo", + remote_unit_data={"3": "4"}, + primary_app_name="primary2", ) def post_event(charm: CharmBase): @@ -283,7 +296,10 @@ def post_event(charm: CharmBase): assert len(relation.units) == 1 State(relations=[sub1, sub2]).trigger( - "update-status", mycharm, meta=meta, post_event=post_event + "update-status", + mycharm, + meta=meta, + post_event=post_event, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index f82a94284..df1d6b886 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -83,6 +83,7 @@ def test_custom_events_sub_raise(mycharm, evt_name): ) def test_is_custom_event(mycharm, evt_name, expected): spec = _CharmSpec( - charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}} + charm_type=mycharm, + meta={"name": "mycharm", "requires": {"foo": {}}}, ) assert Event(evt_name)._is_builtin_event(spec) is expected diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 7c74f49fc..254f6d1dc 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -28,7 +28,10 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(label="foo") State().trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) @@ -37,7 +40,10 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content()["a"] == "b" State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) @@ -58,8 +64,8 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, 1: {"a": "c"}, }, - ) - ] + ), + ], ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) @@ -95,7 +101,10 @@ def post_event(charm: CharmBase): charm.unit.add_secret({"foo": "bar"}, label="mylabel") out = State().trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) assert out.secrets secret = out.secrets[0] @@ -125,8 +134,8 @@ def post_event(charm: CharmBase): contents={ 0: {"a": "b"}, }, - ) - ] + ), + ], ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) @@ -146,8 +155,8 @@ def post_event(charm: CharmBase): contents={ 0: {"a": "b"}, }, - ) - ] + ), + ], ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) @@ -173,7 +182,7 @@ def post_event(charm: CharmBase): contents={ 0: {"a": "b"}, }, - ) + ), ], ).trigger( "update_status", @@ -205,7 +214,7 @@ def post_event(charm: CharmBase): contents={ 0: {"a": "b"}, }, - ) + ), ], ).trigger( "update_status", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index d8d731607..0812b3d4a 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -8,7 +8,6 @@ from scenario.state import Container, Relation, State, sort_patch - CUSTOM_EVT_SUFFIXES = { "relation_created", "relation_joined", @@ -107,7 +106,7 @@ def call(charm: CharmBase, _): "path": "/status/unit_history/0", "value": {"message": "", "name": "unknown"}, }, - ] + ], ) @@ -158,8 +157,8 @@ def pre_event(charm: CharmBase): remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, - ) - ] + ), + ], ) state.trigger( "start", @@ -229,7 +228,7 @@ def pre_event(charm: CharmBase): relation.replace( local_app_data={"a": "b"}, local_unit_data={"c": "d"}, - ) + ), ) assert out.relations[0].local_app_data == {"a": "b"} diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index d628c6bac..7926bd4bd 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -25,7 +25,10 @@ def post_event(charm: CharmBase): assert charm.unit.status == UnknownStatus() out = State(leader=True).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) assert out.status.unit == UnknownStatus() @@ -39,7 +42,10 @@ def post_event(charm: CharmBase): obj.status = WaitingStatus("3") out = State(leader=True).trigger( - "update_status", mycharm, meta={"name": "local"}, post_event=post_event + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, ) assert out.status.unit == WaitingStatus("3") @@ -63,7 +69,8 @@ def post_event(charm: CharmBase): obj.status = WaitingStatus("3") out = State( - leader=True, status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")) + leader=True, + status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")), ).trigger("update_status", mycharm, meta={"name": "local"}, post_event=post_event) assert out.status.unit == WaitingStatus("3") diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 6a328f742..9a89a63cb 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -38,7 +38,7 @@ def test_stored_state_initialized(mycharm): out = State( stored_state=[ StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), - ] + ], ).trigger("start", mycharm, meta=mycharm.META) # todo: ordering is messy? assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index 184c97f2d..2bb069d08 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -68,7 +68,9 @@ def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events() as emitted: State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( - "start", MyCharm, meta=MyCharm.META + "start", + MyCharm, + meta=MyCharm.META, ) assert len(emitted) == 3 @@ -81,7 +83,9 @@ def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events(include_deferred=False) as emitted: State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]).trigger( - "start", MyCharm, meta=MyCharm.META + "start", + MyCharm, + meta=MyCharm.META, ) assert len(emitted) == 2 diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 954ec9341..7aa1f4d9b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -44,7 +44,7 @@ def test_event_hooks(): _CharmSpec( charm_type(), meta=meta, - ) + ), ) pre_event = MagicMock(return_value=None) diff --git a/tox.ini b/tox.ini index ecd71dd11..b671921ad 100644 --- a/tox.ini +++ b/tox.ini @@ -1,52 +1,60 @@ -# Copyright 2022 Canonical -# See LICENSE file for licensing details. - [tox] -envlist = - {py36,py37,py38,py311} - unit, lint -isolated_build = True -skip_missing_interpreters = True - - -[vars] -src_path = {toxinidir}/scenario -tst_path = {toxinidir}/tests - +requires = + tox>=4.2 +env_list = + fix + py311 + py38 + py37 + py36 + unit + lint +skip_missing_interpreters = true + +[testenv:fix] +description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. +skip_install = true +deps = + pre-commit>=3.2.2 +commands = + pre-commit run --all-files --show-diff-on-failure {posargs} + python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' [testenv:unit] -description = unit tests +description = unit tests deps = coverage[toml] - pytest jsonpatch + pytest commands = coverage run \ --source={[vars]src_path} \ -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} coverage report - [testenv:lint] -skip_install=True -description = lint +description = lint +skip_install = true deps = - coverage[toml] - pytest - jsonpatch black + coverage[toml] isort + jsonpatch + pytest commands = black --check tests scenario isort --check-only --profile black tests scenario - [testenv:fmt] -skip_install=True description = Format code +skip_install = true deps = black isort commands = black tests scenario isort --profile black tests scenario + +[vars] +src_path = {toxinidir}/scenario +tst_path = {toxinidir}/tests From 605877850965382defbe9a421a3e47a100cd29d4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 11:42:41 +0200 Subject: [PATCH 232/546] lint++ --- .pre-commit-config.yaml | 1 + pyproject.toml | 5 ++ scenario/__init__.py | 6 +- scenario/capture_events.py | 10 +-- scenario/consistency_checker.py | 68 ++++++++++--------- scenario/fs_mocks.py | 7 +- scenario/mocking.py | 53 +++++++-------- scenario/runtime.py | 42 ++++++------ scenario/scripts/snapshot.py | 69 ++++++++++--------- scenario/sequences.py | 3 +- scenario/state.py | 95 +++++++++++++++++---------- tests/test_e2e/test_relations.py | 34 +++++----- tests/test_e2e/test_rubbish_events.py | 28 ++++---- tox.ini | 2 +- 14 files changed, 231 insertions(+), 192 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43128a4ab..b83e0b33e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +files: ^scenario/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 diff --git a/pyproject.toml b/pyproject.toml index 720b4a021..3e238f046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,11 @@ scenario = "scenario" include = '\.pyi?$' +[tool.flake8] +dictionaries = ["en_US","python","technical","django"] +max-line-length = 100 +ignore = ["SC100", "SC200", "B008"] + [tool.isort] profile = "black" diff --git a/scenario/__init__.py b/scenario/__init__.py index aa462f091..1ddb8f12e 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from scenario.capture_events import capture_events -from scenario.runtime import trigger -from scenario.state import * +from scenario.capture_events import capture_events # noqa: F401 +from scenario.runtime import trigger # noqa: F401 +from scenario.state import * # noqa: F401, F403 diff --git a/scenario/capture_events.py b/scenario/capture_events.py index d2a8f20ba..cf7ce4b76 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -62,7 +62,7 @@ def _wrapped_reemit(self): return _real_reemit(self) # load all notices from storage as events. - for event_path, observer_path, method_name in self._storage.notices(): + for event_path, _, _ in self._storage.notices(): event_handle = Handle.from_path(event_path) try: event = self.load_snapshot(event_handle) @@ -83,10 +83,10 @@ def _wrapped_reemit(self): return _real_reemit(self) - Framework._emit = _wrapped_emit # type: ignore # noqa # ugly - Framework.reemit = _wrapped_reemit # type: ignore # noqa # ugly + Framework._emit = _wrapped_emit # type: ignore + Framework.reemit = _wrapped_reemit # type: ignore yield captured - Framework._emit = _real_emit # type: ignore # noqa # ugly - Framework.reemit = _real_reemit # type: ignore # noqa # ugly + Framework._emit = _real_emit # type: ignore + Framework.reemit = _real_reemit # type: ignore diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 83d79e59f..f5224afa3 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -28,15 +28,17 @@ def check_consistency( ): """Validate the combination of a state, an event, a charm spec, and a juju version. - When invoked, it performs a series of checks that validate that the state is consistent with itself, with - the event being emitted, the charm metadata, etc... + When invoked, it performs a series of checks that validate that the state is consistent with + itself, with the event being emitted, the charm metadata, etc... - This function performs some basic validation of the combination of inputs that goes into a scenario test and - determines if the scenario is a realistic/plausible/consistent one. + This function performs some basic validation of the combination of inputs that goes into a + scenario test and determines if the scenario is a realistic/plausible/consistent one. - A scenario is inconsistent if it can practically never occur because it contradicts the juju model. - For example: juju guarantees that upon calling config-get, a charm will only ever get the keys it declared - in its config.yaml. So a State declaring some config keys that are not in the charm's config.yaml is nonsense, + A scenario is inconsistent if it can practically never occur because it contradicts + the juju model. + For example: juju guarantees that upon calling config-get, a charm will only ever get the keys + it declared in its config.yaml. + So a State declaring some config keys that are not in the charm's config.yaml is nonsense, and the combination of the two is inconsistent. """ juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) @@ -72,7 +74,8 @@ def check_consistency( if warnings: err_fmt = "\n".join(warnings) logger.warning( - f"This scenario is probably inconsistent. Double check, and ignore this warning if you're sure. " + f"This scenario is probably inconsistent. Double check, and ignore this " + f"warning if you're sure. " f"The following warnings were found: {err_fmt}", ) @@ -81,25 +84,25 @@ def check_event_consistency( *, event: "Event", charm_spec: "_CharmSpec", - **_kwargs, + **_kwargs, # noqa: U101 ) -> Results: """Check the internal consistency of the Event data structure. - For example, it checks that a relation event has a relation instance, and that the relation endpoint - name matches the event prefix. + For example, it checks that a relation event has a relation instance, and that + the relation endpoint name matches the event prefix. """ errors = [] warnings = [] # custom event: can't make assumptions about its name and its semantics - if not event._is_builtin_event(charm_spec): # noqa + if not event._is_builtin_event(charm_spec): warnings.append( "this is a custom event; if its name makes it look like a builtin one " "(e.g. a relation event, or a workload event), you might get some false-negative " "consistency checks.", ) - if event._is_relation_event: # noqa + if event._is_relation_event: if not event.relation: errors.append( "cannot construct a relation event without the relation instance. " @@ -112,7 +115,7 @@ def check_event_consistency( f"not start with {event.relation.endpoint}.", ) - if event._is_workload_event: # noqa + if event._is_workload_event: if not event.container: errors.append( "cannot construct a workload event without the container instance. " @@ -131,7 +134,7 @@ def check_config_consistency( *, state: "State", charm_spec: "_CharmSpec", - **_kwargs, + **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" state_config = state.config @@ -175,11 +178,11 @@ def check_secrets_consistency( event: "Event", state: "State", juju_version: Tuple[int, ...], - **_kwargs, + **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of Secret-related stuff.""" errors = [] - if not event._is_secret_event: # noqa + if not event._is_secret_event: return Results(errors, []) if not state.secrets: @@ -198,9 +201,9 @@ def check_secrets_consistency( def check_relation_consistency( *, state: "State", - event: "Event", + event: "Event", # noqa: U100 charm_spec: "_CharmSpec", - **_kwargs, + **_kwargs, # noqa: U101 ) -> Results: errors = [] nonpeer_relations_meta = chain( @@ -245,7 +248,7 @@ def _get_relations(r): # check for duplicate endpoint names seen_endpoints = set() - for endpoint, relation_meta in all_relations_meta: + for endpoint, _ in all_relations_meta: if endpoint in seen_endpoints: errors.append("duplicate endpoint name in metadata.") break @@ -259,33 +262,36 @@ def check_containers_consistency( state: "State", event: "Event", charm_spec: "_CharmSpec", - **_kwargs, + **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of `state.containers` vs. `charm_spec.meta` (metadata.yaml/containers).""" + """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" meta_containers = list(charm_spec.meta.get("containers", {})) state_containers = [c.name for c in state.containers] errors = [] - # it's fine if you have containers in meta that are not in state.containers (yet), but it's not fine if: - # - you're processing a pebble-ready event and that container is not in state.containers or meta.containers - if event._is_workload_event: # noqa + # it's fine if you have containers in meta that are not in state.containers (yet), but it's + # not fine if: + # - you're processing a pebble-ready event and that container is not in state.containers or + # meta.containers + if event._is_workload_event: evt_container_name = event.name[: -len("-pebble-ready")] if evt_container_name not in meta_containers: errors.append( - f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not declared in the charm metadata", + f"the event being processed concerns container {evt_container_name!r}, but a " + f"container with that name is not declared in the charm metadata", ) if evt_container_name not in state_containers: errors.append( - f"the event being processed concerns container {evt_container_name!r}, but a container " - f"with that name is not present in the state. It's odd, but consistent, if it cannot " - f"connect; but it should at least be there.", + f"the event being processed concerns container {evt_container_name!r}, but a " + f"container with that name is not present in the state. It's odd, but consistent, " + f"if it cannot connect; but it should at least be there.", ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): errors.append( - f"some containers declared in the state are not specified in metadata. That's not possible. " + f"some containers declared in the state are not specified in metadata. " + f"That's not possible. " f"Missing from metadata: {diff}.", ) diff --git a/scenario/fs_mocks.py b/scenario/fs_mocks.py index 38548aec8..a47460b89 100644 --- a/scenario/fs_mocks.py +++ b/scenario/fs_mocks.py @@ -4,7 +4,8 @@ from ops.testing import _TestingFilesystem, _TestingStorageMount # noqa -# todo consider duplicating the filesystem on State.copy() to be able to diff and have true state snapshots +# todo consider duplicating the filesystem on State.copy() to be able to diff +# and have true state snapshots class _MockStorageMount(_TestingStorageMount): def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path): """Creates a new simulated storage mount. @@ -28,8 +29,8 @@ def __init__(self, mounts: Dict[str, _MockStorageMount]): super().__init__() self._mounts = mounts - def add_mount(self, *args, **kwargs): + def add_mount(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("Cannot mutate mounts; declare them all in State.") - def remove_mount(self, *args, **kwargs): + def remove_mount(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("Cannot mutate mounts; declare them all in State.") diff --git a/scenario/mocking.py b/scenario/mocking.py index 617253af6..b7d1f5857 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -19,7 +19,6 @@ from scenario.state import ( Event, ExecOutput, - PeerRelation, Relation, State, SubordinateRelation, @@ -51,8 +50,8 @@ def wait_output(self): raise ExecError(list(self._command), exit_code, None, None) return out.stdout, out.stderr - def send_signal(self, sig: Union[int, str]): - pass + def send_signal(self, sig: Union[int, str]): # noqa: U100 + raise NotImplementedError() class _MockModelBackend(_ModelBackend): @@ -97,7 +96,7 @@ def _get_secret(self, id=None, label=None): except StopIteration: raise RuntimeError(f"not found: secret with label={label}.") else: - raise RuntimeError(f"need id or label.") + raise RuntimeError("need id or label.") @staticmethod def _generate_secret_id(): @@ -136,8 +135,8 @@ def relation_list(self, relation_id: int) -> Tuple[str]: if isinstance(relation, PeerRelation): return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) return tuple( - f"{relation._remote_app_name}/{unit_id}" # noqa - for unit_id in relation._remote_unit_ids # noqa + f"{relation._remote_app_name}/{unit_id}" + for unit_id in relation._remote_unit_ids ) def config_get(self): @@ -164,10 +163,10 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): # setter methods: these can mutate the state. def application_version_set(self, version: str): - self._state.status._update_app_version(version) # noqa + self._state.status._update_app_version(version) def status_set(self, status: str, message: str = "", *, is_app: bool = False): - self._state.status._update_status(status, message, is_app) # noqa + self._state.status._update_status(status, message, is_app) def juju_log(self, level: str, message: str): self._state.juju_log.append((level, message)) @@ -258,19 +257,13 @@ def secret_set( if not secret.owner: raise RuntimeError(f"not the owner of {secret}") - revision = max(secret.contents.keys()) - secret.contents[revision + 1] = content - if label: - secret.label = label - if description: - secret.description = description - if expire: - if isinstance(expire, datetime.timedelta): - expire = datetime.datetime.now() + expire - secret.expire = expire - if rotate: - secret.rotate = rotate - raise NotImplementedError("secret_set") + secret._update_metadata( + content=content, + label=label, + description=description, + expire=expire, + rotate=rotate, + ) def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) @@ -307,31 +300,31 @@ def relation_remote_app_name(self, relation_id: int): return relation.remote_app_name # TODO: - def action_set(self, *args, **kwargs): + def action_set(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("action_set") - def action_fail(self, *args, **kwargs): + def action_fail(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("action_fail") - def action_log(self, *args, **kwargs): + def action_log(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("action_log") - def storage_add(self, *args, **kwargs): + def storage_add(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("storage_add") def action_get(self): raise NotImplementedError("action_get") - def resource_get(self, *args, **kwargs): + def resource_get(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("resource_get") - def storage_list(self, *args, **kwargs): + def storage_list(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("storage_list") - def storage_get(self, *args, **kwargs): + def storage_get(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("storage_get") - def planned_units(self, *args, **kwargs): + def planned_units(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("planned_units") @@ -378,7 +371,7 @@ def _layers(self) -> Dict[str, pebble.Layer]: def _service_status(self) -> Dict[str, pebble.ServiceStatus]: return self._container.service_status - def exec(self, *args, **kwargs): + def exec(self, *args, **kwargs): # noqa: U100 cmd = tuple(args[0]) out = self._container.exec_mock.get(cmd) if not out: diff --git a/scenario/runtime.py b/scenario/runtime.py index 40a00cdd1..40daa7b01 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -20,7 +20,7 @@ ) import yaml -from ops.framework import _event_regex # noqa +from ops.framework import _event_regex from ops.storage import SQLiteStorage from scenario.logger import logger as scenario_logger @@ -30,14 +30,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.state import ( - AnyRelation, - DeferredEvent, - Event, - State, - StoredState, - _CharmSpec, - ) + from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -61,7 +54,7 @@ class UncaughtCharmError(ScenarioRuntimeError): class DirtyVirtualCharmRootError(ScenarioRuntimeError): - """Error raised when the runtime can't initialize the vroot without overwriting existing metadata files.""" + """Error raised when the runtime can't initialize the vroot without overwriting metadata.""" class InconsistentScenarioError(ScenarioRuntimeError): @@ -172,7 +165,8 @@ def _cleanup_env(env): def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if event.name.endswith("_action"): - # todo: do we need some special metadata, or can we assume action names are always dashes? + # todo: do we need some special metadata, or can we assume action names + # are always dashes? action_name = event.name[: -len("_action")].replace("_", "-") else: action_name = "" @@ -191,11 +185,11 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): relation: "AnyRelation" - if event._is_relation_event and (relation := event.relation): # noqa + if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): remote_app_name = self._app_name else: - remote_app_name = relation._remote_app_name # noqa + remote_app_name = relation._remote_app_name env.update( { "JUJU_RELATION": relation.endpoint, @@ -208,7 +202,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if ( remote_unit_id is None ): # don't check truthiness because it could be int(0) - remote_unit_ids = relation._remote_unit_ids # noqa + remote_unit_ids = relation._remote_unit_ids # pyright: ignore if len(remote_unit_ids) == 1: remote_unit_id = remote_unit_ids[0] @@ -262,11 +256,11 @@ class WrappedCharm(charm_type): # type: ignore @contextmanager def virtual_charm_root(self): - # If we are using runtime on a real charm, we can make some assumptions about the directory structure - # we are going to find. - # If we're, say, dynamically defining charm types and doing tests on them, we'll have to generate - # the metadata files ourselves. To be sure, we ALWAYS use a tempdir. Ground truth is what the user - # passed via the CharmSpec + # If we are using runtime on a real charm, we can make some assumptions about the + # directory structure we are going to find. + # If we're, say, dynamically defining charm types and doing tests on them, we'll have to + # generate the metadata files ourselves. To be sure, we ALWAYS use a tempdir. Ground truth + # is what the user passed via the CharmSpec spec = self._charm_spec if vroot := self._charm_root: @@ -321,12 +315,12 @@ def _get_state_db(temporary_charm_root: Path): return UnitStateDB(charm_state_path) def _initialize_storage(self, state: "State", temporary_charm_root: Path): - """Before we start processing this event, expose the relevant parts of State through the storage.""" + """Before we start processing this event, store the relevant parts of State.""" store = self._get_state_db(temporary_charm_root) store.apply_state(state) def _close_storage(self, state: "State", temporary_charm_root: Path): - """Now that we're done processing this event, read the charm state and expose it via State.""" + """Now that we're done processing this event, read the charm state and expose it.""" store = self._get_state_db(temporary_charm_root) deferred = store.get_deferred_events() stored_state = store.get_stored_state() @@ -341,7 +335,8 @@ def exec( ) -> "State": """Runs an event with this state as initial state on a charm. - Returns the 'output state', that is, the state as mutated by the charm during the event handling. + Returns the 'output state', that is, the state as mutated by the charm during the + event handling. This will set the environment up and call ops.main.main(). After that it's up to ops. @@ -373,7 +368,8 @@ def exec( os.environ.update(env) logger.info(" - Entering ops.main (mocked).") - # we don't import from ops.main because we need some extras, such as the pre/post_event hooks + # we don't import from ops.main because we need some extras, such as the + # pre/post_event hooks from scenario.ops_main_mock import main as mocked_main try: diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 3b750baa6..bdc98a921 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -143,10 +143,10 @@ def get_leader(target: JujuUnitName, model: Optional[str]): def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Network: """Get the Network data structure for this endpoint.""" raw = _juju_exec(target, model, f"network-get {endpoint}") - jsn = yaml.safe_load(raw) + json_data = yaml.safe_load(raw) bind_addresses = [] - for raw_bind in jsn["bind-addresses"]: + for raw_bind in json_data["bind-addresses"]: addresses = [] for raw_adds in raw_bind["addresses"]: addresses.append( @@ -167,16 +167,16 @@ def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Ne return Network( name=endpoint, bind_addresses=bind_addresses, - egress_subnets=jsn.get("egress-subnets", None), - ingress_addresses=jsn.get("ingress-addresses", None), + egress_subnets=json_data.get("egress-subnets", None), + ingress_addresses=json_data.get("ingress-addresses", None), ) def get_secrets( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - relations: Tuple[str, ...] = (), + target: JujuUnitName, # noqa: U100 + model: Optional[str], # noqa: U100 + metadata: Dict, # noqa: U100 + relations: Tuple[str, ...] = (), # noqa: U100 ) -> List[Secret]: """Get Secret list from the charm.""" logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") @@ -238,7 +238,10 @@ def __init__( def _run(self, cmd: str) -> str: _model = f" -m {self.model}" if self.model else "" - command = f"juju ssh{_model} --container {self.container} {self.target.unit_name} /charm/bin/pebble {cmd}" + command = ( + f"juju ssh{_model} --container {self.container} {self.target.unit_name} " + f"/charm/bin/pebble {cmd}" + ) proc = run(shlex.split(command), capture_output=True, text=True) if proc.returncode == 0: return proc.stdout @@ -265,18 +268,18 @@ def get_plan(self) -> dict: def pull( self, - path: str, + path: str, # noqa: U100 *, - encoding: Optional[str] = "utf-8", + encoding: Optional[str] = "utf-8", # noqa: U100 ) -> Union[BinaryIO, TextIO]: raise NotImplementedError() def list_files( self, - path: str, + path: str, # noqa: U100 *, - pattern: Optional[str] = None, - itself: bool = False, + pattern: Optional[str] = None, # noqa: U100 + itself: bool = False, # noqa: U100 ) -> List[ops.pebble.FileInfo]: raise NotImplementedError() @@ -286,7 +289,7 @@ def get_checks( names: Optional[Iterable[str]] = None, ) -> List[ops.pebble.CheckInfo]: _level = f" --level={level}" if level else "" - _names = (" " + f" ".join(names)) if names else "" + _names = (" " + " ".join(names)) if names else "" out = self._run(f"checks{_level}{_names}") if out == "Plan has no health checks.": return [] @@ -302,7 +305,10 @@ def fetch_file( ) -> None: """Download a file from a live unit to a local path.""" model_arg = f" -m {model}" if model else "" - scp_cmd = f"juju scp --container {container_name}{model_arg} {target.unit_name}:{remote_path} {local_path}" + scp_cmd = ( + f"juju scp --container {container_name}{model_arg} " + f"{target.unit_name}:{remote_path} {local_path}" + ) run(shlex.split(scp_cmd)) @@ -314,7 +320,7 @@ def get_mounts( fetch_files: Optional[List[Path]] = None, temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ) -> Dict[str, Mount]: - """Get named Mounts from a container's metadata, and download specified files from the target unit.""" + """Get named Mounts from a container's metadata, and download specified files from the unit.""" mount_meta = container_meta.get("mounts") if fetch_files and not mount_meta: @@ -340,7 +346,8 @@ def get_mounts( if not found: logger.error( - f"could not find mount corresponding to requested remote_path {remote_path}: skipping...", + "could not find mount corresponding to requested remote_path " + f"{remote_path}: skipping...", ) continue @@ -429,7 +436,7 @@ def get_containers( def get_juju_status(model: Optional[str]) -> Dict: """Return juju status as json.""" logger.info("getting status...") - return _juju_run(f"status --relations", model=model) + return _juju_run("status --relations", model=model) def get_status(juju_status: Dict, target: JujuUnitName) -> Status: @@ -467,8 +474,7 @@ def get_config( """Get config dict from target.""" logger.info("getting config...") - _model = f" -m {model}" if model else "" - jsn = _juju_run(f"config {target.app_name}", model=model) + json_data = _juju_run(f"config {target.app_name}", model=model) # dispatch table for builtin config options converters = { @@ -481,7 +487,7 @@ def get_config( } cfg = {} - for name, option in jsn.get("settings", ()).items(): + for name, option in json_data.get("settings", ()).items(): if value := option.get("value"): try: converter = converters[option["type"]] @@ -515,9 +521,8 @@ def get_relations( """Get the list of relations active for this target.""" logger.info("getting relations...") - _model = f" -m {model}" if model else "" try: - jsn = _juju_run(f"show-unit {target}", model=model) + json_data = _juju_run(f"show-unit {target}", model=model) except json.JSONDecodeError as e: raise InvalidTargetUnitName(target) from e @@ -530,7 +535,7 @@ def _clean(relation_data: dict): return relation_data relations = [] - for raw_relation in jsn[target].get("relation-info", ()): + for raw_relation in json_data[target].get("relation-info", ()): logger.debug( f" getting relation data for endpoint {raw_relation.get('endpoint')!r}", ) @@ -591,11 +596,11 @@ def get_model(name: str = None) -> Model: """Get the Model data structure.""" logger.info("getting model...") - jsn = _juju_run("models") - model_name = name or jsn["current-model"] + json_data = _juju_run("models") + model_name = name or json_data["current-model"] try: model_info = next( - filter(lambda m: m["short-name"] == model_name, jsn["models"]), + filter(lambda m: m["short-name"] == model_name, json_data["models"]), ) except StopIteration as e: raise InvalidTargetModelName(name) from e @@ -607,7 +612,7 @@ def get_model(name: str = None) -> Model: def try_guess_charm_type_name() -> Optional[str]: - """If we are running this from a charm project root, get the charm type name charm.py is using.""" + """If we are running this from a charm project root, get the charm type name from charm.py.""" try: charm_path = Path(os.getcwd()) / "src" / "charm.py" if charm_path.exists(): @@ -798,7 +803,7 @@ def if_include(key, fn, default): logger.critical(f"invalid model: {model!r} not found.") sys.exit(1) - logger.info(f"snapshot done.") + logger.info("snapshot done.") if pprint: charm_version = get_charm_version(target, juju_status) @@ -845,8 +850,8 @@ def snapshot( "--format", help="How to format the output. " "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). All you need to do then is import the necessary " - "objects from scenario.state, and you should have a valid State object." + "else it will be ugly but valid python code). All you need to do then is import the " + "necessary objects from scenario.state, and you should have a valid State object. " "``json``: Outputs a Jsonified State object. Perfect for storage. " "``pytest``: Outputs a full-blown pytest scenario test based on this State. " "Pipe it to a file and fill in the blanks.", diff --git a/scenario/sequences.py b/scenario/sequences.py index ba6be1d4d..907b80ddb 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -37,7 +37,8 @@ def decompose_meta_event(meta_event: Event, state: State): event = Event( relation.endpoint + META_EVENTS[meta_event.name], args=( - # right now, the Relation object hasn't been created by ops yet, so we can't pass it down. + # right now, the Relation object hasn't been created by ops yet, so we + # can't pass it down. # this will be replaced by a Relation instance before the event is fired. InjectRelation(relation.endpoint, relation.relation_id), ), diff --git a/scenario/state.py b/scenario/state.py index f3dcbd98a..8084affe0 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -147,6 +147,29 @@ def _set_revision(self, revision: int): # bypass frozen dataclass object.__setattr__(self, "revision", revision) + def _update_metadata( + self, + content: Optional[Dict[str, str]] = None, + label: Optional[str] = None, + description: Optional[str] = None, + expire: Optional[datetime.datetime] = None, + rotate: Optional[SecretRotate] = None, + ): + """Update the metadata.""" + revision = max(self.contents.keys()) + # bypass frozen dataclass + object.__setattr__(self, "contents"[revision + 1], content) + if label: + object.__setattr__(self, "label", label) + if description: + object.__setattr__(self, "description", description) + if expire: + if isinstance(expire, datetime.timedelta): + expire = datetime.datetime.now() + expire + object.__setattr__(self, "expire", expire) + if rotate: + object.__setattr__(self, "rotate", rotate) + _RELATION_IDS_CTR = 0 @@ -216,7 +239,7 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" raise NotImplementedError() - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: # noqa: U100 """Return the databag for some remote unit ID.""" raise NotImplementedError() @@ -235,7 +258,7 @@ def _validate_databag(self, databag: dict): raise StateValidationError( f"all databags should be dicts, not {type(databag)}", ) - for k, v in databag.items(): + for v in databag.values(): if not isinstance(v, str): raise StateValidationError( f"all databags should be Dict[str,str]; " @@ -318,7 +341,7 @@ def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): elif ids: data = {x: {} for x in ids} elif data: - ids = [x for x in data] + ids = list(data) else: ids = [0] data = {0: {}} @@ -394,7 +417,7 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" return (self.primary_id,) - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: # noqa: U100 """Return the databag for some remote unit ID.""" return self.remote_unit_data @@ -513,10 +536,10 @@ class Container(_DCBase): can_connect: bool = False # This is the base plan. On top of it, one can add layers. - # We need to model pebble in this way because it's impossible to retrieve the layers from pebble - # or derive them from the resulting plan (which one CAN get from pebble). - # So if we are instantiating Container by fetching info from a 'live' charm, the 'layers' will be unknown. - # all that we can know is the resulting plan (the 'computed plan'). + # We need to model pebble in this way because it's impossible to retrieve the layers from + # pebble or derive them from the resulting plan (which one CAN get from pebble). + # So if we are instantiating Container by fetching info from a 'live' charm, the 'layers' + # will be unknown. all that we can know is the resulting plan (the 'computed plan'). _base_plan: dict = dataclasses.field(default_factory=dict) # We expect most of the user-facing testing to be covered by this 'layers' attribute, # as all will be known when unit-testing. @@ -555,9 +578,11 @@ def _render_services(self): @property def plan(self) -> pebble.Plan: - """This is the 'computed' pebble plan; i.e. the base plan plus the layers that have been added on top. + """The 'computed' pebble plan. - You should run your assertions on the plan, not so much on the layers, as those are input data. + i.e. the base plan plus the layers that have been added on top. + You should run your assertions on this plan, not so much on the layers, as those are + input data. """ # copied over from ops.testing._TestingPebbleClient.get_plan(). @@ -571,6 +596,7 @@ def plan(self) -> pebble.Plan: @property def services(self) -> Dict[str, pebble.ServiceInfo]: + """The pebble services as rendered in the plan.""" services = self._render_services() infos = {} # type: Dict[str, pebble.ServiceInfo] names = sorted(services.keys()) @@ -596,6 +622,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: @property def filesystem(self) -> "_MockFileSystem": + """Simulated pebble filesystem.""" mounts = { name: _MockStorageMount( src=Path(spec.src), @@ -800,9 +827,9 @@ def handle_path(self): class State(_DCBase): """Represents the juju-owned portion of a unit's state. - Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its lifecycle. - For example, status-get will return data from `State.status`, is-leader will return data from - `State.leader`, and so on. + Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its + lifecycle. For example, status-get will return data from `State.status`, is-leader will + return data from `State.leader`, and so on. """ config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( @@ -817,8 +844,8 @@ class State(_DCBase): juju_log: List[Tuple[str, str]] = dataclasses.field(default_factory=list) secrets: List[Secret] = dataclasses.field(default_factory=list) - # represents the OF's event queue. These events will be emitted before the event being dispatched, - # and represent the events that had been deferred during the previous run. + # represents the OF's event queue. These events will be emitted before the event + # being dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) @@ -921,8 +948,8 @@ class _CharmSpec(_DCBase): actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None - # autoloaded means: trigger() is being invoked on a 'real' charm class, living in some /src/charm.py, - # and the metadata files are 'real' metadata files. + # autoloaded means: trigger() is being invoked on a 'real' charm class, living in some + # /src/charm.py, and the metadata files are 'real' metadata files. is_autoloaded: bool = False @staticmethod @@ -1027,23 +1054,25 @@ def _is_workload_event(self) -> bool: """Whether the event name indicates that this is a workload event.""" return self.name.endswith("_pebble_ready") - # this method is private because _CharmSpec is not quite user-facing; also, the user should know. + # this method is private because _CharmSpec is not quite user-facing; also, + # the user should know. def _is_builtin_event(self, charm_spec: "_CharmSpec"): """Determine whether the event is a custom-defined one or a builtin one.""" - evt_name = self.name + event_name = self.name # simple case: this is an event type owned by our charm base.on - if hasattr(charm_spec.charm_type.on, evt_name): - return hasattr(CharmEvents, evt_name) + if hasattr(charm_spec.charm_type.on, event_name): + return hasattr(CharmEvents, event_name) # this could be an event defined on some other Object, e.g. a charm lib. - # We don't support (yet) directly emitting those, but they COULD have names that conflict with - # events owned by the base charm. E.g. if the charm has a `foo` relation, the charm will get a - # charm.on.foo_relation_created. Your charm lib is free to define its own `foo_relation_created` - # custom event, because its handle will be `charm.lib.on.foo_relation_created` and therefore be - # unique and the Framework is happy. However, our Event data structure ATM has no knowledge - # of which Object/Handle it is owned by. So the only thing we can do right now is: check whether - # the event name, assuming it is owned by the charm, is that of a builtin event or not. + # We don't support (yet) directly emitting those, but they COULD have names that conflict + # with events owned by the base charm. E.g. if the charm has a `foo` relation, the charm + # will get a charm.on.foo_relation_created. Your charm lib is free to define its own + # `foo_relation_created` custom event, because its handle will be + # `charm.lib.on.foo_relation_created` and therefore be unique and the Framework is happy. + # However, our Event data structure ATM has no knowledge of which Object/Handle it is + # owned by. So the only thing we can do right now is: check whether the event name, + # assuming it is owned by the charm, is that of a builtin event or not. builtins = [] for relation_name in chain( charm_spec.meta.get("requires", ()), @@ -1070,7 +1099,7 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): container_name = container_name.replace("-", "_") builtins.append(container_name + "_pebble_ready") - return evt_name in builtins + return event_name in builtins def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" @@ -1086,9 +1115,9 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = {} - # fixme: at this stage we can't determine if the event is a builtin one or not; if it is not, - # then the coming checks are meaningless: the custom event could be named like a relation event but - # not *be* one. + # fixme: at this stage we can't determine if the event is a builtin one or not; if it is + # not, then the coming checks are meaningless: the custom event could be named like a + # relation event but not *be* one. if self._is_workload_event: # this is a WorkloadEvent. The snapshot: snapshot_data = { @@ -1145,7 +1174,7 @@ class InjectRelation(Inject): def _derive_args(event_name: str): args = [] for term in RELATION_EVENTS_SUFFIX: - # fixme: we can't disambiguate between relation IDs. + # fixme: we can't disambiguate between relation id-s. if event_name.endswith(term): args.append(InjectRelation(relation_name=event_name[: -len(term)])) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 2df2edf2c..d7ce07bed 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -74,10 +74,10 @@ def pre_event(charm: CharmBase): @pytest.mark.parametrize( - "evt_name", + "event_name", ("changed", "broken", "departed", "joined", "created"), ) -def test_relation_events(mycharm, evt_name): +def test_relation_events(mycharm, event_name): relation = Relation(endpoint="foo", interface="foo", remote_app_name="remote") mycharm._call = lambda self, evt: None @@ -87,7 +87,7 @@ def test_relation_events(mycharm, evt_name): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event"), + getattr(relation, f"{event_name}_event"), mycharm, meta={ "name": "local", @@ -101,14 +101,14 @@ def test_relation_events(mycharm, evt_name): @pytest.mark.parametrize( - "evt_name", + "event_name", ("changed", "broken", "departed", "joined", "created"), ) @pytest.mark.parametrize( "remote_app_name", ("remote", "prometheus", "aodeok123"), ) -def test_relation_events(mycharm, evt_name, remote_app_name): +def test_relation_events(mycharm, event_name, remote_app_name): relation = Relation( endpoint="foo", interface="foo", @@ -125,7 +125,7 @@ def callback(charm: CharmBase, _): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event"), + getattr(relation, f"{event_name}_event"), mycharm, meta={ "name": "local", @@ -137,7 +137,7 @@ def callback(charm: CharmBase, _): @pytest.mark.parametrize( - "evt_name", + "event_name", ("changed", "broken", "departed", "joined", "created"), ) @pytest.mark.parametrize( @@ -148,7 +148,7 @@ def callback(charm: CharmBase, _): "remote_unit_id", (0, 1), ) -def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id): +def test_relation_events_attrs(mycharm, event_name, remote_app_name, remote_unit_id): relation = Relation( endpoint="foo", interface="foo", @@ -168,7 +168,7 @@ def callback(charm: CharmBase, event): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id), + getattr(relation, f"{event_name}_event")(remote_unit_id=remote_unit_id), mycharm, meta={ "name": "local", @@ -180,14 +180,14 @@ def callback(charm: CharmBase, event): @pytest.mark.parametrize( - "evt_name", + "event_name", ("changed", "broken", "departed", "joined", "created"), ) @pytest.mark.parametrize( "remote_app_name", ("remote", "prometheus", "aodeok123"), ) -def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): +def test_relation_events_no_attrs(mycharm, event_name, remote_app_name, caplog): relation = Relation( endpoint="foo", interface="foo", @@ -198,7 +198,9 @@ def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): def callback(charm: CharmBase, event): assert event.app # that's always present assert event.unit - assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False)) + assert (event_name == "departed") is bool( + getattr(event, "departing_unit", False) + ) mycharm._call = callback @@ -207,7 +209,7 @@ def callback(charm: CharmBase, event): relation, ], ).trigger( - getattr(relation, f"{evt_name}_event"), + getattr(relation, f"{event_name}_event"), mycharm, meta={ "name": "local", @@ -239,14 +241,14 @@ def test_relation_app_data_bad_types(mycharm, data): @pytest.mark.parametrize( - "evt_name", + "event_name", ("changed", "broken", "departed", "joined", "created"), ) @pytest.mark.parametrize( "relation", (Relation("a"), PeerRelation("b"), SubordinateRelation("c")), ) -def test_relation_event_trigger(relation, evt_name, mycharm): +def test_relation_event_trigger(relation, event_name, mycharm): meta = { "name": "mycharm", "requires": {"a": {"interface": "i1"}}, @@ -260,7 +262,7 @@ def test_relation_event_trigger(relation, evt_name, mycharm): "peers": {"b": {"interface": "i2"}}, } state = State(relations=[relation]).trigger( - getattr(relation, evt_name + "_event"), + getattr(relation, event_name + "_event"), mycharm, meta=meta, ) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index df1d6b886..6ccd7e32e 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -43,34 +43,34 @@ def _on_event(self, e): return MyCharm -@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) -def test_rubbish_event_raises(mycharm, evt_name): +@pytest.mark.parametrize("event_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) +def test_rubbish_event_raises(mycharm, event_name): with pytest.raises(NoObserverError): - if evt_name.startswith("kazoo"): + if event_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" # else it will whine about the container not being in state and meta; # but if we put the container in meta, it will actually register an event! - State().trigger(evt_name, mycharm, meta={"name": "foo"}) + State().trigger(event_name, mycharm, meta={"name": "foo"}) - if evt_name.startswith("kazoo"): + if event_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "false" -@pytest.mark.parametrize("evt_name", ("qux",)) -def test_custom_events_pass(mycharm, evt_name): - State().trigger(evt_name, mycharm, meta={"name": "foo"}) +@pytest.mark.parametrize("event_name", ("qux",)) +def test_custom_events_pass(mycharm, event_name): + State().trigger(event_name, mycharm, meta={"name": "foo"}) # cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961 -@pytest.mark.parametrize("evt_name", ("sub",)) -def test_custom_events_sub_raise(mycharm, evt_name): +@pytest.mark.parametrize("event_name", ("sub",)) +def test_custom_events_sub_raise(mycharm, event_name): with pytest.raises(RuntimeError): - State().trigger(evt_name, mycharm, meta={"name": "foo"}) + State().trigger(event_name, mycharm, meta={"name": "foo"}) @pytest.mark.parametrize( - "evt_name, expected", + "event_name, expected", ( ("qux", False), ("sub", False), @@ -81,9 +81,9 @@ def test_custom_events_sub_raise(mycharm, evt_name): ("bar-relation-changed", False), ), ) -def test_is_custom_event(mycharm, evt_name, expected): +def test_is_custom_event(mycharm, event_name, expected): spec = _CharmSpec( charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}}, ) - assert Event(evt_name)._is_builtin_event(spec) is expected + assert Event(event_name)._is_builtin_event(spec) is expected diff --git a/tox.ini b/tox.ini index b671921ad..1b288fead 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ skip_install = true deps = pre-commit>=3.2.2 commands = - pre-commit run --all-files --show-diff-on-failure {posargs} + pre-commit run --all-files {posargs} python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' [testenv:unit] From cbd947787ff74844ca1e0ec166c8648b298b53b9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 13:37:37 +0200 Subject: [PATCH 233/546] cleaned up tox --- tox.ini | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tox.ini b/tox.ini index 1b288fead..0ba7826bb 100644 --- a/tox.ini +++ b/tox.ini @@ -2,16 +2,21 @@ requires = tox>=4.2 env_list = - fix py311 py38 py37 py36 unit lint + lint-tests skip_missing_interpreters = true -[testenv:fix] +[vars] +src_path = {toxinidir}/scenario +tst_path = {toxinidir}/tests +all_path = {[vars]src_path}, {[vars]tst_path} + +[testenv:lint] description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. skip_install = true deps = @@ -32,21 +37,19 @@ commands = -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} coverage report -[testenv:lint] -description = lint +[testenv:lint-tests] +description = Lint test files. skip_install = true deps = black coverage[toml] isort - jsonpatch - pytest commands = black --check tests scenario - isort --check-only --profile black tests scenario + isort --check-only --profile black {[vars]tst_path} [testenv:fmt] -description = Format code +description = Format code. skip_install = true deps = black @@ -54,7 +57,3 @@ deps = commands = black tests scenario isort --profile black tests scenario - -[vars] -src_path = {toxinidir}/scenario -tst_path = {toxinidir}/tests From 977822b56abb345e712c95b95566d57036b871e5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 17:18:42 +0200 Subject: [PATCH 234/546] fixed bug in snapshot --- scenario/scripts/snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index bdc98a921..357bbe44c 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -647,7 +647,7 @@ def get_juju_version(juju_status: Dict) -> str: def get_charm_version(target: JujuUnitName, juju_status: Dict) -> str: """Get charm version info from juju status output.""" app_info = juju_status["applications"][target.app_name] - channel = app_info["charm-channel"] + channel = app_info.get("charm-channel", "") charm_name = app_info["charm-name"] app_version = app_info["version"] charm_rev = app_info["charm-rev"] From 9ba7ec6b6018cb6fdf384de74ddfca8f642e918c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 May 2023 17:19:40 +0200 Subject: [PATCH 235/546] fixed bug in snapshot --- scenario/scripts/snapshot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 357bbe44c..0dc0005aa 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -648,10 +648,10 @@ def get_charm_version(target: JujuUnitName, juju_status: Dict) -> str: """Get charm version info from juju status output.""" app_info = juju_status["applications"][target.app_name] channel = app_info.get("charm-channel", "") - charm_name = app_info["charm-name"] - app_version = app_info["version"] - charm_rev = app_info["charm-rev"] - charm_origin = app_info["charm-origin"] + charm_name = app_info.get("charm-name", "n/a") + app_version = app_info.get("version", "n/a") + charm_rev = app_info.get("charm-rev", "n/a") + charm_origin = app_info.get("charm-origin", "n/a") return ( f"charm {charm_name!r} ({channel}/{charm_rev}); " f"origin := {charm_origin}; app version := {app_version}." From 5a516ff7c33cdaca30e7269119612b733f3c345f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Sat, 6 May 2023 08:47:24 +0200 Subject: [PATCH 236/546] comment out --- tests/test_e2e/test_state.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 3bf0218d9..7fab9e85e 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -9,10 +9,6 @@ from scenario import trigger from scenario.state import Container, Relation, State, sort_patch -# from tests.setup_tests import setup_tests -# -# setup_tests() # noqa & keep this on top - CUSTOM_EVT_SUFFIXES = { "relation_created", From de300e6767dc772b9416932b7ae5512481db57be Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 09:31:03 +0200 Subject: [PATCH 237/546] docs --- CODEOWNERS | 1 + CONTRIBUTING.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 28 ++++++++++++++++----------- 3 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..dd4fb103a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @pietropasotti diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..29ecabeb7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +![GitHub License](https://img.shields.io/github/license/canonical/ops-scenario) +![GitHub Commit Activity](https://img.shields.io/github/commit-activity/y/canonical/ops-scenario) +![GitHub Lines of Code](https://img.shields.io/tokei/lines/github/canonical/ops-scenario) +![GitHub Issues](https://img.shields.io/github/issues/canonical/ops-scenario) +![GitHub PRs](https://img.shields.io/github/issues-pr/canonical/ops-scenario) +![GitHub Contributors](https://img.shields.io/github/contributors/canonical/ops-scenario) +![GitHub Watchers](https://img.shields.io/github/watchers/canonical/ops-scenario?style=social) + +This documents explains the processes and practices recommended for contributing enhancements to this project. + +- Generally, before developing enhancements to this project, you should consider [opening an issue](https://github.com/canonical/ops-scenario/issues) explaining your use case. +- If you would like to chat with us about your use-cases or proposed implementation, you can reach us at [Canonical Mattermost public channel](https://chat.charmhub.io/charmhub/channels/charm-dev) or [Discourse](https://discourse.charmhub.io/). +- Familiarising yourself with the [Charmed Operator Framework](https://juju.is/docs/sdk) library will help you a lot when working on new features or bug fixes. +- All enhancements require review before being merged. Code review typically examines: + - code quality + - test coverage + - user experience +- When evaluating design decisions, we optimize for the following personas, in descending order of priority: + - charm authors and maintainers + - the contributors to this codebase + - juju developers +- Please help us out in ensuring easy to review branches by rebasing your pull request branch onto the `main` branch. This also avoids merge commits and creates a linear Git commit history. + +## Notable design decisions + +- The `State` object is immutable from the perspective of the test writer. +At the moment there is some hackery here and there (`object.__setattr__`...) to bypass the read-only dataclass for when the charm code mutates the state; at some point it would be nice to refactor the code to make that unnecessary. + +- At the moment the mocking operates at the level of `ops.ModelBackend`-mediated hook tool calls. `ModelBackend` would `Popen` hook tool calls, but `Scenario` patches the methods that would call `Popen`, which is therefore never called. Instead, values are returned according to the `State`. We could consider allowing to operate in increasing levels of stricter confinement: + - Actually generate hook tool scripts that read/write from/to `State`, making patching `ModelBackend` unnecessary. + - On top of that, run the whole simulation in a container. + +## Developing + +To set up the dependencies you can run: +`pip install . ; pip uninstall ops-scenario` + +We recommend using the provided `pre-commit` config. For how to set up git pre-commit: [see here](https://pre-commit.com/). +If you dislike that, you can always manually remember to `tox -e lint` before you push. + +### Testing +```shell +tox -e fmt # auto-fix your code as much as possible, including formatting and linting +tox -e lint # code style +tox -e unit # unit tests +tox -e lint-tests # lint testing code +tox # runs 'lint', 'lint-tests' and 'unit' environments +``` + diff --git a/README.md b/README.md index 749a5514b..a15955620 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,22 @@ # Scenario -This is a state transition testing framework for Operator Framework charms. +[![CharmHub Badge](https://charmhub.io/traefik-k8s/badge.svg)](https://charmhub.io/traefik-k8s) +[![Release Edge](https://github.com/canonical/traefik-k8s-operator/actions/workflows/release-edge.yaml/badge.svg)](https://github.com/canonical/traefik-k8s-operator/actions/workflows/release-edge.yaml) +[![Release Libraries](https://github.com/canonical/traefik-k8s-operator/actions/workflows/release-libs.yaml/badge.svg)](https://github.com/canonical/traefik-k8s-operator/actions/workflows/release-libs.yaml) +[![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) +) [![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) +[![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) -Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow -you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single -event on the charm and execute its logic. +Scenario is a state-transition, functional testing framework for Operator Framework charms. -This puts scenario tests somewhere in between unit and integration tests. +Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single event on the charm and execute its logic. -Scenario tests nudge you into thinking of charms as an input->output function. Input is what we call a `Scene`: the +This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract'. + +Scenario tests nudge you into thinking of a charm as an input->output function. Input is what we call a `Scene`: the union of an `Event` (why am I being executed) and a `State` (am I leader? what is my relation data? what is my config?...). The output is another context instance: the context after the charm has had a chance to interact with the -mocked juju model. +mocked juju model and affect the state back. ![state transition model depiction](resources/state-transition-model.png) @@ -58,14 +63,15 @@ Comparing scenario tests with `Harness` tests: A scenario test consists of three broad steps: -- Arrange: +- **Arrange**: - declare the input state - select an event to fire -- Act: +- **Act**: - run the state (i.e. obtain the output state) -- Assert: + - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal APIs +- **Assert**: - verify that the output state is how you expect it to be - - verify that the delta with the input state is what you expect it to be + - optionally, verify that the delta with the input state is what you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. From ffef91f245e258270b1240b45161f2be9f5b96a1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 09:34:17 +0200 Subject: [PATCH 238/546] readme --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 250a3b8fa..957d32781 100644 --- a/README.md +++ b/README.md @@ -790,9 +790,9 @@ don't need that. # Snapshot Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to -reach the entry point by typing `scenario snapshot` in a shell. +reach the entry point by typing `scenario snapshot` in a shell so long as the install dir is in your `PATH`. -Snapshot's purpose is to gather the State data structure from a real, live charm running in some cloud your local juju +Snapshot's purpose is to gather the `State` data structure from a real, live charm running in some cloud your local juju client has access to. This is handy in case: - you want to write a test about the state the charm you're developing is currently in @@ -801,15 +801,10 @@ client has access to. This is handy in case: - you are new to Scenario and want to quickly get started with a real-life example. Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type -`scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Copy-paste that in some file, import +`scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Pipe that out into some file, import all you need from `scenario`, and you have a working `State` that you can `Context.run` events with. -You can also pass a `--format json | pytest | state (default=state)` flag to obtain - -- jsonified `State` data structure, for portability +You can also pass a `--format` flag to obtain instead: +- a jsonified `State` data structure, for portability - a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event - that you wish to trigger. - -# TODOS: - -- Recorder + that you wish to trigger. \ No newline at end of file From 2eab49d3a0e934f0ed39a3408707a064ca6a139e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 09:49:08 +0200 Subject: [PATCH 239/546] fixed badges --- README.md | 6 +++++- pyproject.toml | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c618c9d68..8e478a3ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ Scenario ============ -This is a state transition testing framework for Operator Framework charms. +[![Build](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml) +[![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml) +[![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) +) [![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) +[![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single diff --git a/pyproject.toml b/pyproject.toml index ff4d68ac8..4c1d9dd76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,17 @@ requires-python = ">=3.8" classifiers = [ "Development Status :: 3 - Alpha", - "Topic :: Utilities", "License :: OSI Approved :: Apache Software License", + 'Framework :: Pytest', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Quality Assurance', + 'Topic :: Software Development :: Testing', + 'Topic :: Utilities', ] +[project.entry-points.pytest11] +emitted_events = "scenario" + [project.urls] "Homepage" = "https://github.com/PietroPasotti/ops-scenario" "Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" From 98d3e4646078e99e70f1bce4e0b6dcf187e69770 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 09:51:27 +0200 Subject: [PATCH 240/546] fixed readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a13b4400..edca9d12f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml) [![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml) [![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) -) [![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) +[![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) Scenario is a state-transition, functional testing framework for Operator Framework charms. From 47743cb3280d4ce648c5a67832d8c48044f50e74 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 13:09:28 +0200 Subject: [PATCH 241/546] fixed links, vbump --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a573faa02..8df7691e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "3.0" +version = "3.0a1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } @@ -39,8 +39,8 @@ classifiers = [ emitted_events = "scenario" [project.urls] -"Homepage" = "https://github.com/PietroPasotti/ops-scenario" -"Bug Tracker" = "https://github.com/PietroPasotti/ops-scenario/issues" +"Homepage" = "https://github.com/canonical/ops-scenario" +"Bug Tracker" = "https://github.com/canonical/ops-scenario/issues" [project.scripts] scenario = "scenario.scripts.main:main" From 43b94863047f1a35e35f6502aeadd357f68239aa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 May 2023 13:13:13 +0200 Subject: [PATCH 242/546] stable releasE --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8df7691e8..6a0887f03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "3.0a1" +version = "3.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From cb75672b924f55214764d18af54554eeb916ff66 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 10 May 2023 13:53:07 +0200 Subject: [PATCH 243/546] fixed badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edca9d12f..b138b9cd6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Scenario [![Build](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml) -[![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml) +[![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg?event=pull_request)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml?event=pull_request) [![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) [![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) From cc51f0468e41971ea106517500d4c19985fec6d3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 11 May 2023 15:17:15 +0200 Subject: [PATCH 244/546] fixed consistency checker --- README.md | 129 ++++++++++++++++++------------ scenario/consistency_checker.py | 6 +- tests/test_consistency_checker.py | 9 +++ 3 files changed, 90 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b138b9cd6..65edeb657 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,14 @@ [![Build](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml) [![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg?event=pull_request)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml?event=pull_request) [![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) -[![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) +[![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) Scenario is a state-transition, functional testing framework for Operator Framework charms. -Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single event on the charm and execute its logic. +Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow +you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single +event on the charm and execute its logic. This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract'. @@ -31,10 +33,10 @@ I like metaphors, so here we go: - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what - other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the - stage (relation-departed), or the content of one of the books changes). + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what + other actors are there around it, what is written in those pebble-shaped books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the + stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. @@ -63,14 +65,15 @@ Comparing scenario tests with `Harness` tests: A scenario test consists of three broad steps: - **Arrange**: - - declare the input state - - select an event to fire + - declare the input state + - select an event to fire - **Act**: - - run the state (i.e. obtain the output state) - - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal APIs + - run the state (i.e. obtain the output state) + - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal + APIs - **Assert**: - - verify that the output state is how you expect it to be - - optionally, verify that the delta with the input state is what you expect it to be + - verify that the output state is how you expect it to be + - optionally, verify that the delta with the input state is what you expect it to be The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -82,13 +85,14 @@ from scenario import State, Context from ops.charm import CharmBase from ops.model import UnknownStatus + class MyCharm(CharmBase): pass def test_scenario_base(): - ctx = Context(MyCharm, - meta={"name": "foo"}) + ctx = Context(MyCharm, + meta={"name": "foo"}) out = ctx.run('start', State()) assert out.status.unit == UnknownStatus() ``` @@ -115,9 +119,9 @@ class MyCharm(CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', State(leader=leader) assert out.status.unit == ActiveStatus('I rule' if leader else 'I am ruled') ``` @@ -134,6 +138,7 @@ charm transitions through a sequence of statuses? ```python from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedStatus + # charm code: def _on_event(self, _event): self.unit.status = MaintenanceStatus('determining who the ruler is...') @@ -155,16 +160,17 @@ from charm import MyCharm from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownStatus from scenario import State, Context + def test_statuses(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', - State(leader=False)) + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', + State(leader=False)) assert out.status.unit_history == [ - UnknownStatus(), - MaintenanceStatus('determining who the ruler is...'), - WaitingStatus('checking this is right...'), - ActiveStatus("I am ruled"), + UnknownStatus(), + MaintenanceStatus('determining who the ruler is...'), + WaitingStatus('checking this is right...'), + ActiveStatus("I am ruled"), ] ``` @@ -180,6 +186,7 @@ Unknown (the default status every charm is born with), you will have to pass the ```python from ops.model import ActiveStatus from scenario import State, Status + State(leader=False, status=Status(unit=ActiveStatus('foo'))) ``` @@ -214,10 +221,10 @@ def test_relation_data(): remote_app_data={"cde": "baz!"}, ), ]) - ctx = Context(MyCharm, - meta={"name": "foo"}) - - state_out = ctx.run('start', state_in) + ctx = Context(MyCharm, + meta={"name": "foo"}) + + state_out = ctx.run('start', state_in) assert state_out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: @@ -314,6 +321,7 @@ event from one of its aptly-named properties: ```python from scenario import Relation + relation = Relation(endpoint="foo", interface="bar") changed_event = relation.changed_event joined_event = relation.joined_event @@ -324,6 +332,7 @@ This is in fact syntactic sugar for: ```python from scenario import Relation, Event + relation = Relation(endpoint="foo", interface="bar") changed_event = Event('foo-relation-changed', relation=relation) ``` @@ -347,6 +356,7 @@ writing is close to that domain, you should probably override it and pass it man ```python from scenario import Relation, Event + relation = Relation(endpoint="foo", interface="bar") remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) @@ -366,6 +376,7 @@ An example of a scene including some containers: ```python from scenario.state import Container, State + state = State(containers=[ Container(name="foo", can_connect=True), Container(name="bar", can_connect=False) @@ -528,9 +539,11 @@ handler): ```python from scenario import Event, Relation + class MyCharm(...): ... + deferred_start = Event('start').deferred(MyCharm._on_start) deferred_install = Event('install').deferred(MyCharm._on_start) ``` @@ -551,6 +564,7 @@ from scenario import State, Context class MyCharm(...): ... + def _on_start(self, e): e.defer() @@ -573,6 +587,7 @@ from scenario import State, Relation, deferred class MyCharm(...): ... + def _on_foo_relation_changed(self, e): e.defer() @@ -580,8 +595,8 @@ class MyCharm(...): def test_start_on_deferred_update_status(MyCharm): foo_relation = Relation('foo') State( - relations=[foo_relation], - deferred=[ + relations=[foo_relation], + deferred=[ deferred('foo_relation_changed', handler=MyCharm._on_foo_relation_changed, relation=foo_relation) @@ -595,9 +610,11 @@ but you can also use a shortcut from the relation event itself, as mentioned abo from scenario import Relation + class MyCharm(...): ... + foo_relation = Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -613,9 +630,9 @@ For general-purpose usage, you will need to instantiate DeferredEvent directly. from scenario import DeferredEvent my_deferred_event = DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' ) ``` @@ -638,13 +655,13 @@ class MyCharmType(CharmBase): state = State(stored_state=[ - StoredState( - owner_path="MyCharmType", - name="my_stored_state", - content={ - 'foo': 'bar', - 'baz': {42: 42}, - }) + StoredState( + owner_path="MyCharmType", + name="my_stored_state", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) ]) ``` @@ -656,25 +673,27 @@ the output side the same as any other bit of state. If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The -resulting state, black-box as it is, gives little insight into how exactly it was obtained. +resulting state, black-box as it is, gives little insight into how exactly it was obtained. -`scenario`, among many other great things, is also a pytest plugin. It exposes a fixture called `emitted_events` that you can use like so: +`scenario`, among many other great things, is also a pytest plugin. It exposes a fixture called `emitted_events` that +you can use like so: ```python from scenario import Context from ops.charm import StartEvent -def test_foo(emitted_events): - Context(...).run('start', ...) +def test_foo(emitted_events): + Context(...).run('start', ...) - assert len(emitted_events) == 1 - assert isinstance(emitted_events[0], StartEvent) + assert len(emitted_events) == 1 + assert isinstance(emitted_events[0], StartEvent) ``` - ## Customizing: capture_events -If you need more control over what events are captured (or you're not into pytest), you can use directly the context manager that powers the `emitted_events` fixture: `scenario.capture_events`. + +If you need more control over what events are captured (or you're not into pytest), you can use directly the context +manager that powers the `emitted_events` fixture: `scenario.capture_events`. This context manager allows you to intercept any events emitted by the framework. Usage: @@ -682,11 +701,12 @@ Usage: ```python from ops.charm import StartEvent, UpdateStatusEvent from scenario import State, Context, DeferredEvent, capture_events + with capture_events() as emitted: ctx = Context(...) state_out = ctx.run( - "update-status", - State(deferred=[DeferredEvent("start", ...)]) + "update-status", + State(deferred=[DeferredEvent("start", ...)]) ) # deferred events get reemitted first @@ -703,6 +723,7 @@ You can filter events by type like so: ```python from ops.charm import StartEvent, RelationEvent from scenario import capture_events + with capture_events(StartEvent, RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. pass @@ -727,9 +748,12 @@ the inferred one. This also allows you to test with charms defined on the fly, a from ops.charm import CharmBase from scenario import State, Context + class MyCharmType(CharmBase): pass -ctx = Context(charm_type=MyCharmType, + + +ctx = Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) ctx.run('start', State()) ``` @@ -804,6 +828,7 @@ Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `promethe all you need from `scenario`, and you have a working `State` that you can `Context.run` events with. You can also pass a `--format` flag to obtain instead: + - a jsonified `State` data structure, for portability - a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event that you wish to trigger. \ No newline at end of file diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index f5224afa3..e1535f81d 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -265,8 +265,10 @@ def check_containers_consistency( **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" - meta_containers = list(charm_spec.meta.get("containers", {})) - state_containers = [c.name for c in state.containers] + + # event names will be normalized; need to compare against normalized container names. + meta_containers = list(map(normalize_name, charm_spec.meta.get("containers", {}))) + state_containers = [normalize_name(c.name) for c in state.containers] errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 21c736cb2..815264109 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -214,3 +214,12 @@ def test_dupe_containers_inconsistent(): Event("bar"), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) + + +def test_container_pebble_evt_consistent(): + container = Container("foo-bar-baz") + assert_consistent( + State(containers=[container]), + container.pebble_ready_event, + _CharmSpec(MyCharm, {"containers": {"foo-bar-baz": {}}}), + ) From 3acd646c7033a42f2260878eded418cae50a7323 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 11 May 2023 15:18:12 +0200 Subject: [PATCH 245/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6a0887f03..aa5f8daf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "3.0.1" +version = "3.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 18f237c26d9bc13fd98e1996d2101195e8037aa8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 16 May 2023 08:38:14 +0200 Subject: [PATCH 246/546] refactored subordinate relation names --- README.md | 10 ++--- pyproject.toml | 2 +- resources/state-transition-model.png | Bin 19480 -> 50386 bytes scenario/__init__.py | 4 +- .../{emitted_events.py => capture_events.py} | 7 ---- scenario/mocking.py | 2 +- scenario/pytest_plugin.py | 13 +++++++ scenario/runtime.py | 2 +- scenario/sequences.py | 2 +- scenario/state.py | 36 ++++++------------ tests/test_e2e/test_relations.py | 4 +- 11 files changed, 38 insertions(+), 44 deletions(-) rename scenario/{emitted_events.py => capture_events.py} (95%) create mode 100644 scenario/pytest_plugin.py diff --git a/README.md b/README.md index 65edeb657..91718d9b6 100644 --- a/README.md +++ b/README.md @@ -306,12 +306,12 @@ argument. Also, it talks in terms of `primary`: from scenario.state import SubordinateRelation relation = SubordinateRelation( - endpoint="peers", - remote_unit_data={"foo": "bar"}, - primary_app_name="zookeeper", - primary_id=42 + endpoint="peers", + remote_unit_data={"foo": "bar"}, + remote_app_name="zookeeper", + remote_unit_id=42 ) -relation.primary_name # "zookeeper/42" +relation.remote_unit_name # "zookeeper/42" ``` ## Triggering Relation Events diff --git a/pyproject.toml b/pyproject.toml index aa5f8daf6..da599aad4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ ] [project.entry-points.pytest11] -emitted_events = "scenario" +emitted_events = "scenario.pytest_plugin" [project.urls] "Homepage" = "https://github.com/canonical/ops-scenario" diff --git a/resources/state-transition-model.png b/resources/state-transition-model.png index 08228fe9635ba6901e3a271f443fe2189671b78e..dca511e051668ea4edf1283364425b06f188efe3 100644 GIT binary patch literal 50386 zcmeFZc{tSj-#<=TNKz3+8A4-M3fT$SikdJXOGUOU*%^bfWzQZ$S(9b5j%{Wv$(DT! zV#hstZXS$(} zl<6U-O5dD#s$$RkDeUM4k?$aK!@kTH<gJ)^@+m|++Al0w1*!wUH|V_ z{zER&J6j06EXlQ;!IWyBZe;q+@OJ2P3{FJ(t>7n2XlmRi`y23%&I>`I*$-C;tERhq2o#;2%O;OC33ucV123Ydeduj*MLM`kEFmyvxk4v+$Y2_T)SE+fq7Y zKxWR8zh$<->2|xnwpjn9%eSBrC0p-}ugDT3lP1%#;q&1d=9n-)+eHaIcjZqHw88WE z6pe5xOTxw2O$R_BZhvUZG*EY`_DcK3H10P0gV{a=&V)K49GqAzc5=r7a=m2$=_tlP z`WO<|=BW*6*rNKkZgQ-8WsJp+O+_{;_&Wv8@cM64xghq($=TgrLr7jXgBE8Jn+zzPHOweev0&|n9@Ac% zbR^1PQCKI}OFLsalGOIDlxr=rC$YYu1yWn9Ydqe?Xj7uE^Wf(Dn6AWr${5D|SWan~ z+DYO7kuqY&3)VYnovcGKnzCRIVUo+D$28)J?ntg#aaW_hwChA$% zb8Q@J1}{td#r>p0+9S4%SB=ee$kScI>tx4cy`AymR-vyUH-!5GxgShC6pD(@^!ftb ztMsN(mgPA#=2#CE@FU)q#i5<{nSOcYofm$kYT6maZ&TsL=dSUSd;5e0Gv4uG?PgLQ zi5O^fF%E}`55pRS`Rm4W96cQ6Zxu>5f+g9nbTaW^L!+T!bL2~J2&B;L3A5JJulgO? zNb$JEdGeyJzUGq`=?ZZN^?ndOH9!XqcYpvDz}qi2zbu}JeROMEv+=Xx#-%{^-XnWQ z`P-s96Ps%M1kDY^s5QZHg}6GUQnf{dK&MB+q#5H(c{smyk%r6F^V(~Xx7#1;DcdC0 z3G_evsa-zk@F}iL!)0oql|9_wv{K*cQ_|l2(9ZUqH9R}>1nz`R&QlZAO{qbxp{%AI zFFNA@glu6E6c(RwFI+r%!z*gDbYb)GD}Ni8+_>hYPHAGf97TFBrcT7K>95*o_)F9UvW#E>x+Unq*mPK9q*TH zHJR9JSABEk?l6-=Xo!ig&)+)-3mzDPVB93w^#F;dpZYjcO}!&Oik&GJQDwhU#9YTAS?B@%6)zGD5VXpetLCvxaEKVJWJ=_>7+ z@NLZ|l&modhw^uS-B4(2Ty)e;FIHaz@gc1VPWW}F*+sT_$P;eFFAioRnfA?fF_&$! zocN}D!Wiw87ZpD18S+y5A)v(mEZAv<#jg? zW_rsLG8J|fnE0jscJ{2iifFS?28>y-Lz`9(t4xx|xek-X3u z$&bRt5Obp|yHkm~^1BcZ0jbcL2VPXlw}(d^7N@2*Y0L|b?!<<`D(f&f-iFmrasw8= z&s=l7A~>oNSuSxQrn@CF-r1NIyR~A#>laYvLl9Q7SUtpsOS$(9p;Z$Xr-CKC&Fe1% zvSj~pph)@o#*Im~F$B`Cb8{a1D8}oXkw(}Eoy!xe^0inOTG3wJ`?D95!Riqmxli3| zcC6V28qF|v=2!1^WG~<0AkcA!C%mF<)$AUG9EOCM%GeQGC)NX0$6W7-!^>cO!09C% zChGg#In#u5$qDi2T7`4CwzV7TruFdMKm5=So zn>*}dFLIlX1NmCv3{U_>YtDUO!J6~5*wGLW-{y4M^9+~De@T9v8v4cgQ?fo@CznuU z+juqvCS#Jgn&Gt->+><&0CJP3z1Fr7J+D3!q@KLC=DGKbIChF|%XR?Vnh(oM5T4lR zLdtEH<1TWi13NWP{xBwZFT=4MTa%guMKgh27T2$Z%yH7gytwy|+x>rlsYCAlFBaDw z`*2sc68!`-Qo%J-3coHE9>cG}JChx35|p#bkMJ^)Qk7L}SoOLYF!*;b&DzuT1^7+w zB3yHg>@vh-o3v_v4I$c=G97NCS-ZvJQ;|dC^tb!#KQ}mAp&z3pKysCp;F=kKg3C*p3%>clsNSn| zaCkUXnxBRy?y%r|;Yr?h@ZK7NT78SWm~p+Yl^MnOV_y9DqQWNfh}(blgulg)kmp^g{FvP?r|%K8G-p(Si25U| zwDbX?j);DAp=K@vHl1Rf5zDI){m%N>wE#wTv9?r6`)P6NFY%}2gVu^07k6V0c9=Jc zXlQ~CrNF!WD?+RMY zt@!C;Bu=8|h((aBtZb9%M)tU|pC8(rjYc_%7m!Uz@uyD$Y*N%o5VcYMVnA0%N5|9g z_hvLSYA^L^Xin+4ySs~6ycnx!m%peSeo4tszG~$>J9`?Kj;4$ih)dTjPcU;u1~X=~ zGDV_wlEqr8RN#?}G&F#V2JrNOMz?l*{1q<=`E6!{QowW<_+cbM)Uka9*!H`_6;2(l zz~@i$^k+ZKQf}QNrFZ07$7buy!uS+BW=GwdG~wA5PfKHT80jxmbfrm$27aZG@E`)) zsRr-8Fe!N5V?fv5=V-Vi4x>-tHh@nQE?qG!dh{gYXaf(1x`k*SpUK>B&ujw}_~8guVJ61L-{Wa-&D)!kjlIVA*JAu%WE`bwI|rn`h7W3WRNZvhA$f*?=B$`J z-=D8ee2=H0G5Y=c`!E=6`7?^^3Qctnh|&iNM)uX#%5Lz{D6??`GCiK7^jw&OKwCXf zX4Tm=KJSSkoBsaKpHL1O3*|H^hm)ZpVJzTOFs*Y~vYC%yeL9tbKR_K`gz@kqbbEa< zeypdN2J-c!^^W{JSLd;E(kXv0TZoosH1zNSy-EigN};OFS?UqMsj9zDHitHkFAym> zVhWg+ri^CN?4uGmDXafuK;L$kli!U?9A>_2>cAPqUJ|f1KYm>3rJt=%% z`6lbkY=5TJsmQ8Okl!sVm4k!ha8)&HYwP7lFXwxCHY{nBLwK>mS(9eWdaN_wu3(!5 z`?fWDVp(e;qUsrFi&^iSJFjOK8o1S(6~={VEJ01E+f1Z@@m|GAz9xzQH@5jWAk;Rq zAlR`<2FE+(pm=&xc7QW8CvaPC*jd8)AaM1?6&Bqq*q9?t3NHXq60odZ4Ly_b^Ld5d ztuSY$lk}u7w1Bmgu}_+*c)d>R`#&tKI*1c;FZ>HPya6iRdYPIyC6ro6)lIDro%I11 znkhvb3=0QrHVL0wS|H9&(Oz4&Ku*R&xmzL%b8SDgz6%}8d1;!mlgF-!JBsjX>a<_F z&ZBpdOE7WWp^K8KeCq+vu}Swe&BjHk70~Pc&8K0kc1x2N3yblQi$BV?HeB|Ok6NC%4|2@pP@s=1N)@=3FZi+Z9gDbx@7)HYW~o> zFH>%(retvDhpEu+RM9}Qq!d)!V-q$;OZUbv=0zdRTR1)GWD^TEa+?8smT+R>MJ1~S zT@4s|k{zlwLA`3LI3{wht}yq-oeZxllbv$8w~dE{ceJZXijdF2uA*vsBIgc5d~9XM zgo?qbxusRNedFubzQ`lc#FLoMs0aI`JFh8^5Ys)yy~7`kjC+pftW<1sjCbVjpuJXm zOt3caC}m&Mb+$yEf-13R^<#4>rb5&fKf&Ti@qvn4=LibHklJcxd7S)O@#ok(@bnmI zElMp!@B)aLkjwcwN0WSjVouU*IlWRR(E%j+QQ*YC392 z%S}G>4?ASObAR!i^1~}zd6ry1^jR6(Yh0Jkd7_}^&+7FSzuzbTa}c`X-!rs8;7u+kxkcCrTO7&Y zT=IAEyyAGj86}CB)~T~Sfiny~rz*5s66UqSdeMYX)jQ}J~37byRV>d?zO@($jh-? zmF{Wg+FasESq174zusG-Vtno~8a^~O#2M+H{F2TK2v!Gex2&uf4g#ut?;`!`?fNwT zo$?i({ZV7+jE0YtR|}GNOw$g3SaqTnWx6N={QB6ekv`c*MXDLyZnBZ-@A0xtluK_Q%MB!k=?ng+lp(>#Mob= zli-b~zRb>LKQUsgsifqLSPs%|t1t(AD!C&V6|H`)pdL?NP?hstbH?zD zN(WH?BkVAXY-?5n__MZ0-u+C->U;95X83($PyILk97(JF-G2K>!iKh0u1d&qBWZHP7BEJgH#9^!1#}DiG5m@zO6I=$tBpF znN)Z0#wmMKdXnRR#`_B@1!Y1iBeAw)D-_#B6C(mt6{Q6MS7x(mGSo zLTf#35$4JLa4A?pp=zl62;Q~WHW3d6C$evkSGc7{7S$|g!Ze3+Y# z!eN>8Ue%9NkMJ;5Y8cu)TDo2BNe7@sOYtVu>(vC6(|i2Vdv~ojV(>TBeTjq{=QSvZfHrV=9lY)hC z6Xnb?lUzsl0#?0tMq)&37^$5MKht*F` z1+=E|dOr-$b*o&vaK=U;{+E`_uQi(`PrVFQu>v9t|D>3462}CTcW+TZ?ND)A@1tRX zpFD2jhLQJGHl3lDOX(l>P#+{?VC2~pQ|pXdR~(j3l7}6DsBn(*sd9XQW35LxHm=6F zOE_MZB4m)Nv$P#n0kH4=bbn^|SoEYIUsOaE-A(n5wBMBH+LWSTh8ef_)IHh*^if_J z^Mcr?PL{RBljwUwd1|$vq7AEilo!SoV;Sg4%07SjPrsD>v8j)b#o-n?&s{+s+lB=h z=NDuziXnFFqHY{}8gsu)2Ae66{Cb{}8AN`fGrk=b37FyE$EccISdnOtz?mz4<87&& z)mc9awac+|dBxhjs@48WqF@Z~sq}0-B35i8tizYt3RXC%LE!DWjKB{dhP+V8%3%pk z_L8_{Zy-oNTe1?1VMW{2E9!?j)yzPWSEXL_#Z9$SICjk%~B7eQzKBhB1Zj9$tNrDJgxaSqu zlwjQAK-90ygdG$LV3G=Td^mC($ZB^0DK7B%>_0U*7O<>Z4b5@zIteg0d$)M?N?1h; zV)M#p(Ys;6(IYxrAt%=+*!`J$0&^jiAC1W*SzB~g&-u5D8hgjb8IzI9+Cug@B~^Dm zvz`Ciy#C|8m*;_S7XH6-X)f}?(W|2B`-knbY#{XYF-O)sF_oIBu*_j23fT*KM$1> zW=W*o*d+PZcdsfI3$ITtCH%}FE4%T*Q7*m^cEyx4=i|N|TZ^aj?vW~BAxr;oMkIsH zwK;#GjvT2}Y<44J_xSMR^ICJ;VVj3mt-AT6rRwBi)sE04uCgY4GiZsE)@3MW=#VSD zuWdM@lg3|s8i@V(mE`;k@cXBxrY9A-hXxW!@SJZPIi$p|2pk@IwJUJps#1`1=8Ya! zTwQ9eFT}Uj+AhvoA|`9oF;mm?k2{~QWy;si*3O6I1e9+T;40||1tA5LQ2gP^J zpoCe68Hhx-^7_^3H=ggo@;i^K6TU2ae|bDuM~x351KBd&!W83Y|G0GUR9^D_hh`Mi zSMGa0%WVj$dfoJ{lpj~Vr5agn3_#U@c`W~B9+r>27EfxAk4Hf^o~E$1ZA6jc>=TjI zS~X7GN5Tw41%f4hUVY@a4(fKqC**PCl^U8 zWwJAmg@_T_o8WC{aIKLfE!5;LQKi6J&By~FM+ZgCF>{K$;0rmHw2Gq@^gvt%==Xo= zwR3fFNW^LpDA5wN;&<8B-@Uyl2I10??gqP;ddCODrapKksjS@23&;?`>a7Z+j=ANH zNQL^F3JAQiz<`8G|JQ|kR$1fntP=~@p2^3_Wj|ijOKWn5-cLm)Xrud&;cirFUp$7p zX@`39mHc+_JLJfYSLR?#2{tsxy>eYZ4n>d*IT$wBU0d*RqHWG5+>L4COyg}a`4y9U zl{8JT@FW>F>{SkuoyJ-CSc`JykevX6t zt8+gU;z(Y>gUg>{Keq%=!q!~#**K1rJDH{rVkX#|0cP^lq>{B=K>|W3d3~&HArNRa z6fP-=>4mdFHHSik1k+5DCiSZL9svv}zJ1jcZn`f+A#LS0F3emL)z zo}g>k|GnwA| zVlDBmO$fQ?@1ZW>^V;o6UabdTfFQcz8^)^JKVr&^gCic^s=I4<{DMI56{7$>DN%r> zxKJaC^`Dc&U)MWw=RA;Ge@gumdLI53+q*k2dwo^z%-o{C^*UGs8X$w z=h8{#(hS;8x%4-EfV^~7LFw)BI+Y@k?lpI<^;bJO^XFR}q(M z_}CG_O<=CF|6Su}~yXJ+Y98|27-s^RFiIjFr@&U7{=@xgzyqQTW1=?md556==N`2b@n2O6{lkIq{2 zOk~ikTVYFO{&e=w%Nwno7yfD0R#w#*IJv5Rf4#HJqVdS?IEG@fTdqVd|Izq1t3Oxq zV7I3ezu$##tM}e$YQ*H`wqOTo$E#;7QWqwv9pLn`Wp<*;YnR&LUn9EhrLNLlpc}ku zq!wTcj=vno%AgvLbjtjmsf0flm$jAezn4FGT=jvY7%Dw=+_B=2Zmz7XoXum7e23e{ zI$!IWc1FYmAefV^Pq!_YZ6MR%l9bR6)M{JtT0B3NccA)DpA!QB1GE2*YI{$QbkGNc z-&WyzHi)8W23wJo@mPLWfkdXU(>>O4Lip4TjU#Z;AfI_2RWh{$iav)ZM{r>@MNj(9kpaQ-3TBHxsl}boV{s7|CJbV-ZXI5578?Kn1wq8NdWZ znJcy@dnQiF5N4`}P5?>keOLYnluIA{@mzvqZ|Bo=nWT|=(rrJS{inV$)f`aC&p2y( z=Hih!pH6wsw=aPH9_(623cV*jrvG4M z%f!=aX);D+qpogW!$Q?T3Ge)ZfVW%jktRBNLQRQ$W9>k62ed(Z{AH_N z`X7+-(mh8k>!D`e>Wbvio^Mrwnfue=S)%|I*UrY951Zl5Yr;v_iiVj<_2Lr>?I149 zOmAoj<~62l65iY%=EgRGJV%JN1>&T1MDU!ma}R(pq!?0W8J0J6UilocfYjV9q@}9W zu+C5HY}tAMW#-j2Jg4_W5j}~gAT6C`H-mxX8254#U0f_$rLsipKT=`}>|XToV1AH@ ziE(5xFfceg&CB1Xp@amoi>ll*#_zTNEb%h8CngYwGs;AWd3$+=#AA*rPz6zSQsJNe z5_KE0p*bC1XBOWP`O&nmBJ2KoKC9N+GGq<$Io^)_eVD3zaU+TCA$1yfC~4l%TXdeJ>bYs7&K z=fo|x5ami%b-f@P{DZZm4^}cdhw~+rebeP%-P;RX7ug948R+Ar20G?wkE4m(`Ci=7(a>hlC2(>iy~5PONdY zt%>(jrs8E4>`xC{G{d2V`bu&3%QzVzVzg*a1p}En zg@bUAOHk0Ek2c(DWSku(53P<*Pmm|>cP`tG&R>zH8H`qQGreEA0sGJaPj_HWA7M%p{uUewzFHx@o^8E7M`HEvP<8& z4O337($^XK)#TN{l_uOG{J9BXf)XF&fLjhn_tfRK z@5xHJ+LPTPA{j{H^{cmwUYXXq0gbVM*(LJ>A1}^6y&ziE$)rHryb!XwlH3!9#=fy_ zJSfGQIKl}=7$td{C?Gc~u(k2ObmX$R_tFA^T4^eYf@(?h5llJ5oKVV<3~(a}GuV+7b z#CVWPj|Fx{J8J>LZdlTC*i7@nNrEzv9fN5ndq8a4zJ zJe)c*l$qR)+V1A>Tz4$YH;3t!;L-MGTNNyniNl)fWhYfJwl|DJ5PnT-!~-QS6Q@*o z*A7=@Mytm~h-Y*Z3O64j*{B%7cWIm8OP}|5%4GP5OO_fr1fxT&^gimROWxD_u+q}T zOWEW|15;IJWh#jFo^EO|R6V3#w6;Hpd^hpzq+dsZ;H1(z&@%;kR`2G)4NY<`F&a1+ zQDMW9o9B-cr_p^e#^bP8K>q!*X?TN z6ANlurT{6k&|#0Wr-(e^kqnAC3vdYvB3g^)_C7lmkVWY6f=r08VxkYJ%ITk900LoL}e^C6k{l zoYbUT$z`k;^t#GKnuYzyPtu}>y-$mZ8 z#@y!*4kA~?4^Nu;=RhkSWXQOF1NzP9%y&DWP2=5p_tF5}GX8m#TqRzrxko)%W6ZH_ zqJ(QZ-d%?AHer>lQO79$ZLjp?t*z=q- zZO$k)Pth@u7OoUGqZGN{QCrhxT=Tro1<>jn8ipW2@S|`GZUE&xcdhP8__v;*$B?_f zCT3cFDnF#~-8~-pO3GhdzKWxXauma3$$1=Ae#(x0u<6UWyiiuc)`%fjX|hGFTQJ{+ zH>7hHPIU(Os z3TG~$(k1%i4?z=oQ~Y1bfzhEl_jhdG4R)At=cc9dTht!mk{uVAxsfo}9@7=u4_{NZ zr(c~K)M!p=E?et>&wo&&0z85|&_0uoh(wMYOtovX#m_aIyXMd*f z3x(8QJh7Z4K0HqUTwg0~4_+ayHENl=L_PW}z{yb0&889arA*Spg~zS_cDPGIPufi^ zCrl$JRd_r@2&n|)p2Ns#>WcisbRJ=~k z=PF%0!(`3Io$J3+^aYNUHY~;Ns}Dgih#K*nw5B@ir?ERw8-$4ws3utf$Q?r(JKoJP zONEc1{O5JT`Ni4#D@WjH@3+uZ)&8Pw`d+1;Kt(|l1D^9e$=ZD=?JuCTXDcffd-Xms zZ?J;u5P6icOXW{Ox|v9DDT@$LCFX>XudeCoCvE+no|=QBjZ^O`&@9gmZ!fUfauT+L zqxz1!1JxtYZV!B$iT51d9)g+!K6&al%FXvbOL!<;Yo_Z|sA&#>U6W(Ew(hahpP{{E z(U@uZ$5*55M!Y9Rg#`6WJT!zoUuMq-4i0^X&{ac7tXe}a9cq1u^=2&E zM0rR?`^*|_H1Hlou`+XnIay=<`N#zXnpuE5O6Oz|SYdE|TKj=j7Rzzkg0$jIm-|Z@ zPYV?L1aet^Eg4p*tmvMJ`=-v2G~@);d})23RHf&sIqr-ww;eqAf+5#o>@zIuy1VQ8 z&(`uHzQ@V(7dkIg1VaiF+4{y(ajob8E1m^~0`dAPwZ z-71{n?CBw@3EL{%35Lic?tUg!^3#{Yk1)+Zbe=M-Gy(z%};H14~O~Nm3S$PRjk!Ot|4XFaH{#0wgVe{8pIO99|f9^l+TP}mN54{ z&i!9$pS$L^g6bw=E-w83cft}zsyJ#FnblYCMc zNEMhHTnQ%G;X|n@n>})Mj~9DFn!q9*gosdb6FV>dfj+EUUtA!eehr@43y@{&uHa!B<)XYuopAyK46IA;z9bQu;M0FBKx632Mr#3Ig~1X z{Rasqi$1G)HSN)wpR0H+{{&k^xtCqB5+{g#0zjlWOSlgC2MGfVzji$lGWH?uZ?Fs6 zZFXRfXXlbcN_B*QpI`kWFUz%Ds@KQ{oLjxRRr4RzPcP9y>O_GsG|&ZUui0#Pq-vf( zVDM~_Mz%A8%%pl`Ulja^ew*^!E5^& zr5La^18MtzKC}k5cIXMc-d=!@f`Y>AD2;~ih3cm^nIkW1NjSm^eM%=Fcj*waiD&K1 z(`$T0vwgMH?xR-3HDQS7bX<{=SKpS^X-j8(^+Z9D$b^dKi3U5L;{|boQRs(5cl_;knjBR19h3l6{rao z^PCUNekSQJla(_@Um8&EgY#?MM5135!Clq}~7n_eZW7?sXN@>f6t zg~?5*QlB1q!G1q|#@&gv5nZ1Dc5Z;2y9l(o0jxOK?hLAasgXqVHb#?KAUFN?jrS`b9+HcHvx9JeR&~5n9XbGL()|k7leF1kk{>u(LYn=c&4hw*5O{~k zleG!>2)%zXc!~hKDb0&*wgsYw>{){UkdMLai%C0_sU*jwnK9MaM||ijp7+?l*&K~C z1Pvq7b))ScN?K1|ns*`UEHmg%HYvQHO$uDF34u|lW_r7+5Ad;`0ESN#uxTrPG7(c@wd!R^t9 zcGHrdC(R0Rj0eyhB$({zeu>w7X-W21+X!F^dtoZ6lAxM zS>$6vS^gb4Y^PJcCW87#KJDzkh|L&oEGe6uPz0Cghi5nwFZ?BzAaXfWc3RG4Av*Ij z2L%Z3D1Vh@G%4fXTKxJcgxqT&Q*WOp;_XrMeRJXCsQ5s}BQeGUhq`6Ed&3GSRb5Pt z#;}OFC|ZV!ZlL^dU7yd=HZnw+pFWWQ(s>}w{KlLJ0ilubf+B?yiV5kSEw0jNUrzx% zP+>bTYeO_G)j0jB(-Ya2DzgUr)g|dLB$uUVId8l~Yh-RD=$bKT_7Iayy^i_rYgZ}a z?Txu^TaGg#g{`oC}ZD0M0Mm2TW$IL;h(EwtD{B`>A9dq$)!q zc-3;!uX^c{>%-HSGUL3IIRm@rqh_`Y1)Yx*$I5ox%-tg{eS+W~GLS}2JF;b#5yBSx z)+sI}n2poT%70kaJ@;yVV!x=)h1_LPjV6>C$SDSs5480ENug^*YF87{+$I$5Fa9qI zM2f})P3%28QY9qo6~Uc2{gR)a=82}ALl#7K{CZaz+9T#g5l&!Z=!~L$O_5TG*(>Rg z`QSpclPA_vB805MaCFKx5S-AwozFE6Ob_Hb9SoDM_jz<0b(sCz%o6>=&+oyZsx$oklp!A+Oh6%W@v9 zy!JQX7^K12Sc{xOVk#fhNDz{DO?o(*ijJOcJIAlDD?Bd#YX0WK`_W>crHda#m!L_a zzAkkYpLq6vNO|9s6>U@?<$GfIHYkPUMcDXs!1~vZ-AHL+9$4;+7ZG|l5K!Q{Iqqnf z_VD)zy|nTn)-QBqt0$Y4pu)Hi3%A#7gk)hvfoHkDUHQ%T3uI1r-10oRJ4PVW7U!V~ zg{M=IK5pBPHBnc_ZN#fqvpDHNjU&uw2=TSm(An0KMJ2E> zo`BI%>33F2a92c|8sTTksFAhr37-f3#P`PhjMkrPOyj#I{c4IF?)4(f6rQa8uru-( z8NI-;&GSbB$dkaDqrEk#J+o|w23<^Ckrzc01mw1&9~xI5;FGvB;%aMosc2p`AcsIm zB-WWKmrmkY)D!$C9O*w!@7oH!>ovO9by7aHJ~>BILf_4RtT zc&3Edry4`^f}k;>(erhwqJfO0WIZj1t#9bY8DA=zc5CxK!fj+^#Ma0y-3^$vP&Kk{ z#xQ63ruE!0>tiK!DO|{Ued4QAt*FK9!^ASn6_3$c;=78YSztzB$n|&jyJuCc1oUh8 z-$1rU*7u|q2oLU*VBGKK%s3UP$e(wA8RbB?1cYotccv1EdU^FLi(Z>5vjX2#YfMZf zC|oG?1G4`j5Zj4mDo74P$t@?e{MbNweNMXL6Sjsm-8^n4O=9 zil3CXl5g^}aQ@mkItpz}=9{7XlUxha+biSii-m}e$cr~bc(f3D@WqGg0OcT4-;*zZ z95$y6nQy@)78%WVZtsvBTwTj+cCC*!!PORsq z#9Nvdig<#^1je#8TmB#21#!i#CEBpfaO6i5Dv+_y4DY_@AXILd!ggN2cU;1Bw{Q(u zpCon0lZtGV+%<=x`r!yX3ujN5Khhl)8Ub8q!Qm^@D;|SMG!mM#ImD90tvzZER=l}l zs@m(RLo58N7eO#OzO@3wC$dQ{T`+jGSF{N5;duM$KGiZM1GSHN+vukn+mv@YgQ4J!+IN}t{A z+E_F(VOLL-J<}}0EN;yCtrXKIX}P`tpIO@3&z8_-@2zI$jwgA}my$4P#MNoV>wC)dIJW_A_71o^SJ&;KLdEKaB`&O@=R<^{C$7NgEQleRScPWLF zh}DJk{;Z5s54eJ*9k#WNK%8g&uT7#=Pdk;w0m4bNE#EJ5M(&X!s=YP$``o2TZWm~Q z1e64{{-B~z(I*LI$jt}qP9AP8m`$A6f&y85cr|?yh{QVcDvD!VF0QUKQPQ|;husJEW``l=e=_-!F_JK?UMUyHMr1nr4VL0Rl-)o z)_-LGlGWIQ^-r5c+=SZz2?{Q0?HciZzpVm_fR;F0DUjTgQ0RyQvBGfYF+_mDZ_uohSjvBq5DK0M?Iz*aH>y6&zD zdUC(&R4TiuX!YXUneLU(H;*ptEQusqIhiFkI1ad>>@jEseiMY{z?Ha?a<)O{`g(~q zA9cI20U%dTHj#feq1?<9L&9g0M#=|S3?Spr(Zr`aKw)TnHI$r@VYW;9=9;9eJ^Bzo z9Bmr~zSjflBUUOQu^hhvZ=3VsgP^A?7JXUjv+c^mKEU&mrt#p`sq1NwB9+Fb-o3?Bhob%D8 zQvS1eA!3PCMG9WDTuOXl{8f3W2m$@s=4ZsYThg?#Ks;F1DSHl$I4Uy5c1i*p>fTm^ z-0nyh8Iz%1VUDNi|5g|g{6 zYXHS?7{RUW3zzL{o>FUbkyDXL%Fm5eNjAWM%f-cxL}3Zr{+w0(0nm=A^_HON%GF7J zN^WO2^JeUh1v---0E+zw-i%p{CeEb%`8_X~pn$+NJ#C;5 z0gRVnf#+NKRxZ_;++%(IMYfee26jJ%;9EiaJ5W#?l?6KUZKcj-#LWq=?=-Kvps@Rn ztt9_Z^R^8;AOf4=BBMzRc`62YK5&%#RhqO@2k2l86pBnc?4N`4MVtN$a!V)x(?R)! z(^W z8<_`KfSp&$@cI?D>XD7_j}xRP0V7erX@JgYXM9O8xyHm;1W0 zH3!3be!`(Wrz)aIb_XLY1&(u{!F*YGJ^z*De%LnfN{nhgd~F5j!|(<^#`r1hleWF& zU3S;40LGT?4VBIB_bajfbCZf$yl$;$GB!_6EZpol9$8Ds;M)D^+t4fJgsr#AaCo$g zi+)=pAp_K(GfQ;MDXLX0TNKlLGR7DWH}MIKx&ktMQz6PKGs7aYMk1hY6NV3Wyd>ko zw@>Qs?7RUylcP65Ix;z_ngiutS3Izz*-Pp#e${~5oxaxop&WQZpC5X*SA_!lc_VeID!uQt7GnF?&St z0q&3IXV>UMnL($WRDFoaca^K5*lOE)gpoKUd?B$=rN(cHrPvWN_$fj0_}4^x>6xa` zWKoCpDdBCb6GErOBj|fV`y^Et8Y2%hKKWn7JXd%~$~fwXy^ct2>r%a9JsT#>#gi~tP!@S$D`c=R(8DUHqE^3Zsbb*ETXmY``pXX@%&F;rb359jwkIp1DC>U?8bcwnw z*Dstx^akh{V{+IFIYY%hxH_{KEX5A zzv7MP0M-L=ViicKlavzI*edK+Bk_E=wpGgb2eUb`qfMydmALSgHo+iRu;Trcnzear zF`|ciiuEx_NnIUS{!2#9y~V)g(cgv%w5xmzPLe{$9Ur~+{M>mkR^D98Dq%nzc!2x14`9rnWVrtTyb5?E71JB2FT}}o z?QvN5hw-7y?SJyNX#HR^BUDagQf_F=e!zf?Gs#ysMn7i)Fz})ImBR4YjGx>3a}d(q0mQ3Jm^0|5&iKm|&QsO`lWd^lB|zMeOe3I>FuB{aE}gH$4JN6M(3tO| z*ryCX3qFH^>8gf%Rsr9E3j45pI|L0t#6MF<>Y9bEi2J{&b*FcJgNq;Y{UvBQpgiE= zqyan-(s-Bo5>)LwB+vNs0%Z;KEwEUjr}iH^m?7<0L7^l{W@c=(?u=if9J{*iUF7ix z9XLDDa|`ts4saU46Cmj?F#IJrpIgNyE-=94@&5cXRdPikG;Cr#7ILjB`4*fa5&eC~4;QL*N+X=FuWjZ~6C^J+F-}g8aAy76x|AY5L>xcYy{H7&qQ2;$}%?eIQ zVPU0B0Cpf3BBzQbVIT$|&o&U?uAdJ{aQ#X>pfLbEY~(Ey!-DW)F>D9%tRVoAWW;zk81JDcMvd&j+e5YJ>L{xzC-3Jf5vaf%n4mizm z4`SNG^BHe{)Qlyr;akvMF3K0J&l~>p#9cRXjsR>Pkn4WS?=%Ty#%%Q3ODNn~X>SNN zg^9%P(gVfRUn^ah^t1V=m#_Bby%briWCrWSy>7zU^tgKgji; zI_}jY6f!61QB(DVdNpW9HoGzzCpP7&2wdk+S?1pbUVjP>Y~s5%s&vZl@$8RHDwN_| zghxPc2c%GrdD{Qbm{Z0RFIhZJHmkZuMUf9Yu^SD zun-g!X%Oj>?hph732A90rKP)6y1PNTI|mpLkS+dYFQHCqC>Bhe(-T294T+Dk3xXsR1-Ow=yuN3F6w z3fu0Mx@vpCE`Ia%44Cxk!451>Av3@sX8^1E%1t;gbS2gxn*RMneK5i@5~kS+p8-yI z{k8IHC#$mlbn$v8jlwG?0j}_~2D?=v`b^9K$OwL^FCY;oi@} zp02_4T4#=BKn+fBHy6vTDz3}$Vql8ezn#Ceb=D5*0upyrv5xT#UPw2ZPFZI2Ix(I) zUO_jNFBh{c&>{4iB_ob7&-BuTcb}>hEe}t5Y+kI5s#G2wR^5gwO}OkPj5VJrUc$|F z^zeX9=cpq-mY{@e%Arp){W7OwjogGIth4L>G;nXuRj{WN?!RT9?YGNy3hquigb1VcO&F)S=g2APK4h*o-9D#5r z`YQ!TET(l+<_fon&)t>}3Iy4NE8Dr_bXP2d%%>8y`Rmo)(VE~{%gAM|C<9sgzl_cR z*)#*B&QKYg{%zW}^EG97jc$PUP|X$R^gDwk){srn?H_t4{Y0qNyE7PVzcc6AvY1If z<1W+L*NM`$%q6qvs_pPX3>HTD1v6DTD(FsfPV}>FBXl?nvAG~lx>4H8(of{yjHb;o zuY?1KvBD4PN+5g;nqD|?1#_mBey^O4V@>z(Cs|_m)yhKBpG&Ss&&Qhs`Qk4@107ud6;ZE z?$YsBO2)%uv;l0~x~W+*PN2Y|S{@#Vb@qVAR>bj#b+D8ANUvIjnj^UM+-&80hN1c14#jJ-2;Sov1^Z=1jnC(vD#*200Q!??-~*I&BZ{hV z@a;zsiFm!dgWxl;PiQB65=iQQrTb4`QFn`4b&%k?y+_-KxpB({FX`J5p>4#pxDn{f zksA}+dpjr`lv7!53&0z*xG>Yx{8J*^w8t?&$V)C@sWlpL#Ume&i&wuP1h<{^r)(2V zZ|w~ARB}^-b*$5VcwUl5gqnRw*QPVy#QyBHNE)DZ$g5VTagti-I~|YcBf^A6`b`h8 zN#0&+jt~7LMRONHd1_g7z9RIdpcsHvQ=yUVk{U~)d=9DAL#}U5GBFZ``LJkSSGTmE z3Mv+PkYSa<<3r8W7@&D^tYI@wMvQ+FV4c4kIJqyYD06lmE{!Z{#-e&dMgU@OnzxGK zzGrfif9ZpYd;5_}dZ@hgLzp}qfNhX!h#!dB9@HGSXg)oi`Qq5h3ML`h1}G~GhY zo-;$Zh^ zQc8N!3EJUv-FwzuUUzl)>xZ9v7%#Jm-4Z@8+~XhJ)7&lOC=NZd8ZX+5n7^UiXKVyc zpEQ2!_t-R8@=v5@h-$%#2-(MGSblwi3hh8H{~?zyIqI^p!EbxFJ? z1^v5fy_Ki0{R$`}3y(|@j!VzlJU(x>pQjA?Ma)Xx?Gp|dVlRO}2DC$34+~OvB`u!a zMo4KZW8l$w%BeP&Y%xkagbi(RXw{_a#_LJq2t4y#J~UAkcRF4n`Jx-gku-*>4gTV&cS0TOCsG?-HJdfft-sPh^77@*QV(>X)@N2qz{MuN6FHt; zow(|tf@h!l$Bf6s2+Md*m1N|`4+9T;l>S5LSsIypJ=wJNjNQhm89^re=xs-d*e(oF z6;lKpxFdVYR7OklIBl95Gb%JvFY8ZIfsn!mOmL7ZypP;+&%SI_#4qs2__UobU#5ed zP=grw;U3z{-w5~}r)YTPa-)=uiRGysv^)(LvTai*xhE=9Ka~x=qc*r@!ym+N``HLR z)P|2u@pDIYnZ_=8YFaDBi^}z3s(0B>iZj_?Xqnzou9`%Dy&vBIyCN>t4r2v1+z`Qsr`v~wnfKk4_;tTdKWh(K@<26Pi>E}e z(}04)Eq_vYl157fjoBjLGJ>Y|%HFZ>g6^Cf@75La2*gMmB>UeoPw1`lH+52?scGR5 z`GiZr^^Ea(gxgQ91`cp*s99wB4K`{{EHXdgO!T1JHeD$Ibdj9}O*X}$lRKA%@Etky zfQQ2chEuA@5>I7Zde0k9Yel_Wup-a+i#vp6atpY(AlQ8mEQCMl(Hw>tUhppm7oO2I z5`%6p&51gJhInMPBaoFI9vlK}X_UE2UEM~}Zd^=(WF!b~B_xVdx`9S>sV%&iShiBt zn{LH#Fi~F1L!HcGOyOt8Jum+oC?HI$tk>>(F!X}$A?ppisqa~DPBJu{Cv;P~u+!zZ2%8eb!vSOQHpM6(rs zWXDLvH1k8eqUdf9YDTr`iA&G9>-ATe6CG+jNc~EHd(=Nyb1WH!PRKtan*q-F_F&`) zxjS#3-EHLJQ2qD!WzsHtx&$QEuk#R0Kujn#sa7Kp_eRr*%@>z@&XaPNgEuoA)1*uN zds}Ywzf3Wf@S}Cyivb}E7Xt*K6dMEON3S@4yDwk2;1GBjY42d{(|j4NX+Mo~QPX*- z{*E+4(^hz&Mf?Fn9rC@A=cE9%;XCEQ)IUBkEOf5&z?z-4R{C;mLH}-xI+QOi%Sr^6 z*pw$xK8h^I9qt?hqeminI*v_GlG%$vL!BCDl(@U#by9BV1-5ax+v=fS8LZK}s4P2@W0!~OF$Hof2vcyrMX zZE{I>$E<~0Il?P>;tOw8sn{-*9yS?5Nxys_S0=pMr9n5(jtmRQ9g(r^KHoDuRd5bd zFRB`YZVBAT@!j7)V-Q*7HZ#xN*gjM?K9k^NF`k?>ikrz1Rzg=tAxC_*v^PB_M?|45 zVJ(rad2cnS#|q@U{WAGNF0+U*)bkNdz`ox%>(_@9lUApJ&HBL8Wzhx;PH^*5O)AwS zK|&U_p4L$iTR4t07p3>66+dS4M!~uwz2%^@uIEl~#pR=ASj&nud(U)TPBp>9=9L#+ zVlj#AkxSMpwV^W)yqGQ?kmVeCgf2lfOb$}T@IX<_$6;6qHY}l)z&{@Qb~){_e|{jt zWOCt0JRbL;Oqx^LhxctvMyl-QLDJ7Psp@!s@?;v0t~}>V6_5wBI4HFrd4n8t{oLp^ zn)8BPk^b*PRPINx2}npDUEVJK$c#k>6ul7PZ8=6G2xm?H-x-1?GSM@u zg@#ycE1(dE#F^KFYkA~V2w{EXxr~Bp-=H?WRsS$tHbN6-n#r;~Y4|wsB6~O-G8D{# ze_S|j|+ETzp%2A%;=8!O$J8Qd|4hM!zEm>-7Epp8`<94;R9PF3Cs zbWwW{?UA+4?-{%OuB0n(v+Mpccu%2+rsDvkw!oAW)>d65sa!&fN_ps06Wl7yl!>x-;m3Ie zm*R^x2*2)C>}So%^JK+CI^ZGabM~x)(b{JjfBWHgIT-3sZ%ALq#azota*KLYYODMA zocT`>w`b&tURo8<TCg-39sv0>ENh z@PSM`{!b@gUbka{5U)gFu?oGm))VWJVZ;xj1%t2i9x6H7-{}_}$cFcpbeNkUGYZoG zey;>V;v;g0BE0 za5vcj1RqECCeO*PlRYWmLTuy+Ea0E{Af=sc*_HKILQ(l&zTJDx5M?8*j z`@$aK1~QCt!`Qqxt;DBpQeITl7pc>`HnQ^q#u?Bm|3t9ZSR?ULKGV(aWnXZ0&@F(n)^^ZO zB6-oo%49NKzh$e;Xc+D0H%Z`rx}MkZTKeI&8T)Kbl&aa%9YyS;VsnV9ZBM+ip)?pv zIs_3&4A#Kr9bcAbUih_jLGf`qJ*CMsdB()&p_+BN_$;VjIdvNO*1Oa9_wO!HxlsJ- z_w?WGmQq%gzin!Krh_5Us^09CuH#|Z7Re3o9}JFP_PtugQj{AJixbBFi+De5TewxR zjF9z{GmqU-e!Z;YgRHJmn8VHA@9$XxPBqD1a9NNvt8Lg%IqdhBs7rbooK43#-$p5Y ze%UFSRMfGE_|~^4dfl}M`X6(wZ5$HW!{;iU2~f~|Wn1wXs;H!56N7A(?-h%~2rNj1 zpajG~#$=%*0Jg7*0pS(R{B^qHQp(<%Nr_zTz0q0XJ%kcR zBel6%jGt^Z(3#efqnuP$DxWqC=^BSK;Ih)L=HMNbskeeiyUQ{PL>+GA@$A7avP?T%dSqEoIz>@l`wk~LEy2J zM>-qV;gD{!?78p?sGvfEUiKJTeNg9bj9 z?BEZ&-woC&mV{Yu)qb_Y47MR@E^KhQ2<@mAx&^@RGT(ogbH6t-67R1h5>?@~Me7Em zBrim!MdI8NTo~V0XYI4Pb@kT0NH?Np_O%?@L{+hPzo9DI%T6OPSK&nFHo}hHFS{pk z6MNn^zN)3ZqcHi-a55T=k=FtHsJ3PJC*6Xd2EJ9s>s0W8C*yPq3X`Xzlq;}5imW}f zW@HRfcNEK~m$X6wt?DIn1|)WcO?1)>gc9Qfi25dAZYM76+ zlXAYZ41ZEKxQSa)IbZYJHwxUrU1I#F?F3gY(2KKA9$c||+pe~TQ8|XP&KqK9I9=`| z2nvBV(&*grwvCI2ClhT3x+tzxaM1TEu$C);OafA0l$EtlR;d3OSqYw4XeIobN-k8gHnp7pHiF{H z532cNI>re4F@bEv(kF;J7dy|A+gEPIsw~A&=Nc&6vdTJCN9IC*?nI zp3H~L1h^HgHD9G_f%tbWh5ZDdP?myekGCjd7!7|_kA9*@@d@vCK0jQ$!QU4>l+GSC zKG(-A+3}GPR0kYPY>wKxCj?ZD>0g;>Xw-J79+#5vtYzf$+pf1KyQ_gZZb@rq0c*YS zqa*B{%fi{QIRv|!_*B6ABdCB-!0o#r0kXZLeo!f3g^rpdCGI8#ofo_9#)`q%X^*Ka zIhcOWzygb&`eqtUH&G_^R^9oCGZ&K+!wOxd*{k)wYB`qaQ}Bf%wa@WEG+>F9mP)$gUa?dEbV_4XWgD| za7S+_{Gs-ReG((S>^;w@wHl0}KtVJh9t)S|I;~jL%X8EkwystR^kx59@RfRPhn(^a z{R`#33{(vXCA+hv{z|uMX14MSh`_kg;ofew(GKgy|xI9i5x-NZt#d^Nyo!qma3TjJrLU&>xEO3QbCO?1U$T_kJ9lZ& zYQ$MteJ+H})NBTO^wXiBuEOY{ZHp)1l6~=$t1$$=JO!Tz+7`&(2%H~`E>NM1UH3%x z=(f6Ae_kl91Cwm%(KS_Oi={9l!^I8830FypKrX~xH90~T$BR9KdX|6P^U2>FtmFR+w^+|>blIF{uh)=2>KCANOoP#{|~>(`Pi}UGcys`eis7}DC`wm(3I^G#ehUKf!{j=xTW2> zne3nLHKrQEf~_#hRiwW!O?4BbiwjIdcbcfLbKb;!)u0}0@m22q1VPjGhOCCiFQv6^ zeDJi66}>q6{YdGhx!`|2@;wEjiuF!8oq#cwlDuquh?izsPVg|-9gDIoHp-HIvh1W! zvv|n?!A>;l4SXWoO#b=P4JJxP-t@Kp1sU7KSbOZW@tEu0P|ubF+L=o+R~udO6X->) z4b1pc{f8Df&fy?GOCs+Bxg7X9W!Jk`!O?j9o-vN_dy2k8uJXEqCypeUXkD1^Wt^F! zFK)$7m8m_-NZ4hUN!iX$vJ4zjw`)87$h#~gXE6LcmPVK6r7Zqq;&pa3Rx`dQ)R(G@ z+{LcFXIOo8ezYITgEQmr3S&L8g{R7W37sS`B!aJpTs7sSYd3%$zOoY6-M%?Z-wwMW zK^mRRo7baAZ+z?csj{%Gb%=3@r;V2z`w3T7vwFZ&%E8SE2xjeDZ5aOhV;OGWkD*KZ zm=DX=izs!!B;L1w;_*WqHll+3v1%Hq9bXl07VQj08%I)~7C9(av>!8Tkfc>;GvH0&Jz`lY7bEt=d#Ra( zFl4DXY?YaKS;&cG+cWTz0zbcnGse|Bf^{Q*Wio%i?#;cWIn7iNqPk!C4ikjgfC3sK zW9b$A^ke;u&%Fi8qVtsGw>9S-<(i$+GK2H5ZO%pVAZ10^7LiJ@cpAC}me<0Npvq~v zjXFUWtn&@dp7S#t@uS?+A%ZOWR~hV?xB3valdr%%DlkXP!5e`})Tb>QXp{#dy$l3C?Y85frQN|L+gbGve@@bgJC z2w{fV#k>#28dF$vJ_W)}X??j>OxeJFu#zbhZ*oHBho(}M z&A{aR8I#`$bQ=09<46h5qvv>w#c>oB#tm5t=RvNZzx;bB&d7o!?@bMc;>bVI@6Dx{ zTA$%FeXdT6pN~jXHhNQ=7fzgP-6>h%i@W_sSdD<^v+7$SJhCql=V^X2b-FdUkMq0I zDq93aGg8Mn&&H5bc6jA2^0vL6Da@p`Tm-DT?5{A1l@s!wTDlXt>q6=QY zee8qGh|#gpu~$D(8QNBf%XqsF@uz-Hhs)+SfrkkRfMG>>O;DJ%Xw{=K zll~2M_{Q0`fPAM!Oy`D^R zL?S%zAZUN&+2PhA{e|`{M}Jv#7^e49?m!zH>jnZ2SX$K{Ww<}Dgjy#$vvjV-AayP5;-0V#5tH+89 z5|pObmr*+C+8%fmCl`W-)Mq0zqPXhc3o||!j#e<8#>PPhVK>DX=|n6x@DZPI13t)+P*+*H_qsar(8W=e++-h{hyF-^@ln1ize1z4qw|sNR#_AlB1C6=c%5 zwU1N?bkkO61oz+No5|`U4S2ZJT$}u@&hbJO)y$cfGk|llSCB(bK}G%o{DANKoFo`+ zkt3_sIsR{8NmLRv^sLz~?}Wy8J_fS?s7K=?8J2Fz!-oEnMic(nhcsu!nMM=x2MVEJ z*c>D^+UvBtqz+%m*LdLuEXY zWl~CzGEumWo*c2q|FWT#&)WhFo<%j>+;I@SLSL}$QEk(ObDkkAT!n~gOrQv0!i*B4 zeEr)khRKS9-dIz&LPW@T8X;Y#TQ95Z$nq^HUc*R;%!S35=NVHW)jrIP^7P#vx&COl zdG#;2@*tyII!9xDe%=}{60Blb!QukDh%s^4$W>VA)VsaLx~7CYo{BDlT{8! zj(J)gV*{a?SHR_UX-rp)MCPS?@|_^`pz0ku^w+Id49nDVvH_aM&*C!SN3XYu4xecb zPF<6Q$dTOT*O6{{m@!7NNDB{B1=vuDJd7ZCbc{?;x)Dt)T|H!5QiosoGdE2JJskDO=zBf1EWWo zCswKiJPq5tu_P=Yf@?wf0CqN1sh#(pQHin@H$A7K4{Z$B$i$OR@?ez9LgCiZTw|&T zSCB#YM)~1U0~g~zN>c-bJ34nV5stK%S+c3Uqtf?{w(#X}xtp@Ku-G7h{Y|8j41eQp* zE?2?9pFAy_CYk^FsXo<9_htMqvyyz#v9vA8)JO|sBPCO1Wy^w~E(O$btiyVP9X6|g zuCVWm;e7KG_l$~g#4EMx?0vkZdj{)=_SS58?Tol>J{E0FG5X1bhTI@W%x&GQeBj!+ z-!*O+78Vv1|Na`l{apwc5yhk|OEQeVn;MI~Z9QP;EXv?1FM6#tx^TFf4czpWBaqde z4sk+e)0YOloEBH{iz;Xd(Z)>`nay&k^W`U$oI zi?D%I!eF(jCOap|X6`Hd=PK`Rb%i`Dts0-1VYJ(XZU3B@nSlt&V?ox=bLnee0R0yk zJU91KsPdpk+!af=#E?MzDD#unLF1nrsNGgNtVH!Lq|3vsuGs0F$XFob*(B-@tC z10;)T!}`!XQlJsw^O0HDmdH+Mj{nEBK#Q;%_!jjmefLm9O_!!H2TxBS1#cL8&1Ui! zw_G;XpPznA4!xXvz;($TNlNb3#~Y;mM85mL?0iGZLph1-W@`Ek>!za%Ip8;v#3S4N zyN3`R9`80j^xv8EhOpdM7_A)xFZKfdDwam&K(marl9Jbt&2 zELR};G0JK5Czp|x^?;p6q$o`E0jOT32XK*C_`Ri3%)yJ((XlGswbAK$>)f@;V!K+D zU?xdic0RRDwbiXG!v1i@MzJ0CZ!6Dhv~c3%0OL-myBCn}6zg z8KAQPHkD9Z16KgkGR;Cz{{KH4*J4|5-!szLAi;UCo_g}M8WZJ;R8ifnY(H?|y3~sG`!i}I&5z39GJpexh<1^Egu9eYpHr#KgnC9AvwkD_pvbLhvW5$q^zZ(&{jEnjnXpMi|MD z%QRqjJ&2>sfOKO4@|u~M^Ao8jyoRF6ub`-2*Hi^fO#nkX5`?4${%?j20Nl!MAl-^K zZdP_?eD9(t0Hhy7ac(z75Ag&PjQ9a;jrFZU4V00#S1XW`Cu z#rRLU6`06AZvo|>^#4bp#s36U(>L@Wa^k;Q`)EgA4|C+`{+UV6BCVs+DgCsaQEwVKwpX|M|M^IJaG%XX<4|xPShM&N|Hv23msm80)Vee^vc2UQqw& z&_&W1WNb)^V*k!{C5+N*E+KBTH9|Wq2tCoZtk33E;jUskrgyv#ggZdWg<|mA2r#Vd zFO7v{wb*PPZ7xuu)OVVz(U6?jd3wHRddBh_yJlpZb1;1TIM^xvTOSH7Pagt4T2v;> zv$v8u<+5AZbp3pF+p^JrKYvp+CGc>oY6vKhzFQ40E91pkEv%lF(g5S?=1ypTc#75FWUbzG&--Ir?$6-Zsi^E;nJc=&up4Xz>|^I^EHV@w3|lBU zbqT?dcbu-S?!A@_;Nh(8dK0ozRp_YQ@i{rpNqIdfM!(?hw8g<8EAHJVO3 z-1?7wXkV2Jl3NY-C&~AB2)HcfrW_vKd$gSYWHJD<=?ZvW)7TE`Pa}Xt)gMX1k>{g} zOMFYev2y#JSu>sDg!Pm2D1OuPM>j=HA)d2ah@g{5-{R{BbOVUz4czF_xG~+lNVT%z zA_6wt8w!*4ac7qXi_wZE(Eu^i5M2h@bpQ7|3U}BmNWIQ>Gm`>yGOd~$@th9CBo<)CV`3RS{&6s@cvIA0&rPiRr3&vzs{2x!$}!(n!eQZbM{y?T z_V|<#^N4!aakew_iHSZy5v@Ys%4~ij2Yw9nrZ(8|ld7ufH^xQ^46=jlaDuL7*!nDHKXW{Uf|r)UI_IS|HWx_ zW050>EvhhB_a}F!kF!CMXg3t{arVEE*hPh%>j4a0$5+=S9fA7=cpYi;IeyqEENCEW zoahlf$+&620Iq8pnQ2!BcpUEUc;L@|poViRmSW|_V}nK96$^9qUliVIclA3oH(P=h zC;28klx0R1C^z>w?tXT^fvIv#mcAa7t**ttm(9B~ebtPyC&L&?Tly9TQ+XS_E}NZs zQF`BfDpMjLwglqD*>nzIK(6F5dQWrMMo}VI64So)C51g}Bz|GG_Q69ae2KdthYp(13nIsiuU{>e!(403O0cR81l3}%;i-ACuh8rrrY3OWHVA< z@V*r1pO_Amkdzd38wmtpV=Z%G2Lp+#@X%u~=)jZ@j^av+`z6(+NV5@Ux7A0H-yO@RJ^`%zk4Z->)JDiX>Pp^_rT-$vjfMChA1Ss6{g(LF;wID_C{|^6+0m!y6j9bb{yixEr9jYu zfo=ck%`0B}o10((++QQx|I#d6xb2T}Rx}Io|1v=iGs0r!kuYvwoJZOJW>0>}CAN2N z5cIhKKO`9{_40*s+uxr|2Pif^36>kJPPzTU5&yeRfwi1Lh~B^d!w1q5c;#(TiN90! zSCURX(dzY{yodAVS(BB{;x>QqR6wT|79d8^yv_$x6;Ys024<3@hB_%~oEAYGy> zdC@iHAz}h(pj?ioOav8t@E=~sW1?alQZeQC%R1V=1;3*=c-mAG&HNa2iyeUe#eP$g z*QyjiHyckVgwiBa(Cbkk(~b`i{eW(CK`)8VQRC@7(8R;P2f1G=mVf>IF9V9VPSO>Y zeuiv@{Ks}-L;lL2x*FF#$Y{e___N&3s{Eigj8C$$LjFF3LQYu$vrS%qtn1>+f_c06jCM6=-7dhfz zbYp66D#1bA#_s9r8`jp;P-90g089_uwCy|RI233H502kDl(XyUG{4_GCZ3^3d2&_& zE!US!Ak;wSd$pYi-uX{~%>Ve_Cp^-RYCouQDGmL2VK)8X!>wwPWw+D!jIy3s!-69T zt|Ac>y@-@d;IiYdX0}j=Fi}rr%Vxnk+ojIku}0lvi%yfIc@|Lbz^$EKY|I4?o7~Vd znKkyNGTkEvRA78d_r&PlQ#PQ|HGHsD96VDJfgchrwl)4GiWgjY=d8D`9Hb#Vk zDcu1?TKrfqNZ~)^MLsjm1@mZM`$S-`?_T#^8u;;iyddlBS>$HuWeFv;)cDTUZAFmS zx6SlL;J-kI?| z$jaY^S5&BPOvN{pt%zGW^9#MgWZY$`qMF6G34yVs3dTgempfa<;8SA1@%EUgAfqOE zn!jP~>)e;c`IjfnPh_)oqB_HDMw%%_v1%Zj#V=qcgA(~|p736Bk~Jsk)906`ovU|h z(lggycfxn|>vpH=p*>_7Z&A;6{sw!0DWEW|7Jruj7N%E{g&c+NI#Ck?lAuRzr*bh$f6gF?tbK6)tOv&&MkF;CEk(S4QwP*45j`YVf% zh+em1<<}aF3BO`eJ0>D%swWg33_V?qa%+G{3GrJ> za${+l;wrp8WgsdzS0qC(9{zC2ip==9>*=SU9|+_oNC$bC061-0;PT|mm-2si*H=>3zj~S+pcMmT zz$JJ6A(;8|MmB~#a^Rs4n-KFx(|&1yY;$}CH=8#nl5IAV1L<;PCs?X>jZ6l#@FsvG zl1i<$grAV*#chvsT14|Bh1_CNG_8g0Die{!3w)l*Qb9YT+fHfHp7N{R9+Wk@4v>Uz zHrgryW{$R-YjvbK!3*-{twUU|Ae#TgAETFre*Awe*W@9T=aAbbs<#I$_I(0pS@u(p z+5jDb25CR~Gi6>Y4Fx|C z{ncQZQd~3rn{5<`R^U8>Qj*x%n6O+s<&=R1pHT;BIW5rEY}UiUJ}Iug;Pj~B05p2E z;&M1p8e)ES)e^B8~S;n2l{;7whf zDKph*uCMz#sMG^S--~ivMBGYE%g!lyf*wV*At-)9nKdYg#m34n`>8Aiu!`7`d8*`6 zj-^IE&i_$#?kzP}|2o*d0~) zBi-GsUZ%!LMsLFUK5CB{mL)o+Sj(^2dkD8==t%lu?dBqh0i2kaRGNC33emH$L#hsU+UOFg81jf}7 zlu|4x(OswY&Vd;YPs?5f2|xz*?f3^>mQY&8iOVYQ4AAoZoqsG|!hExKxPCYelediW zJK^a}t>Dyr=&I+oTUuh6n{$=S3ejoS@#ZlmdIXe4j@7-b5#X)hG%O!S6?Fv&PbEl{`~0!n1`m z6G2dscmMG%T6>e&kHZRhPjY=Yu4U-v$8ifbNhwKE(M&hnO6ncYsb|5O`OH?1nnkx8E~mDO~+utp1eZGmG*pGcH2Y=?M)aa zit$a_ov3{}5x`Ypo-l|=-Xc$vtY#aY8Nc{8i8kJ}3cMZ)0Ej%5%th6Aml~`Vo?Y_) zP|An8z5?;Ql!d^RNdw;W?LIk75t0<7LDG&q%ne!!`N(I+qgn5t{Ey)ANLz|Ja32*R zygFRM$La1AzrevIv`&kA>ofCiCy{Bp7CNBh_^T(G`~s;`fKSf9Nz&U_P<4yZ9}P?H zSMcy;Y9KkYcW$tcL0ZTg&E2zJz*E^DVJ@y)P2_QlIfINL4{k-k^+Gy7&)7-v%MWf& zCV5`ugSS)dhYOpp0HjdxO$zgy-CoXLc`0A8{$I-jplSph+J)!-d3<-ne1gsso|fQn z7m#26?dk78@>=XR8R1DsoYcATRhxwy-F5poBmwtFUdEYxU@x;zChCl>5Gu6s=m;wZ z-PVrk*dO~UjB*7)GUkoXCR3Ew;lE8H%v_7S^TY9*N?b+jRgg9ia0J7%fap@FHgi4OKczdMVBbffV(xM+0 zv}fc~(%8~c`BdkQ$O{mw$4n%P`nN?`$eh?;y~%&~+CYjVArz{OQ^)tY;q5QzOh}ae zVQFA$!mhtv<Wq2ZzSeKq^+XR*U^aHASKvhoh*C!p=Wi$fPYrhn zE$6kW>}FH&eJp6n%%E$MtS^QLzI{)61oC%H{_hb4I0)qLCy(!PgB-VENxs|TcPjr+ z(H7d(IWMvHykG&;h}+tGH2-t)042VYxSSN}g7V#WonTc%PCZp(YWbEkK41jxrQ6;z zYf-`!*D;!yD$=%U5Lbr!-FKo?j&E=E?Chhnl+N$5^X=QF&2C1<$8hGrc+@u^;^5Fc zHC;Pco4bkXAaj3Z--8`yJ`VSWZroQNc0UMOy6zRcw18)}8tn#+h{m7Y{*}bo>x@ZG zod2ap?%`vV{0}LzP9Q?nd$XMGSl_7jSpl1pLYO!AaK`+oc$&zKZZPiOUwSPsURTt? z_-brQm$qWcW}*(r6@i4gTF<_zHPQbuG@%m1?IKj%)d1CpR;%3MXzO3nVpQwQYwMXx zx<}l%kb33#bE!C?@Sc&a@QCb%^dcvN*q9JC^+quB2`&)&`T5B-p9@YHOSN0xk;0uf z2N`cD)ISF}!~fI~Pl9^%S6-0Y2UhG#6;w$95XMhI`}fE8kusLa<41 zQ|sW`rsH3^f%vSu6CJF(kJ4abw=K{KRHMa9qMiL@-v3wbdi27Z5h# zz;fqUJ=K2l9Kz&1;;MJZay;c~<8v_zvN%OK0a*&xn-k!4iomG2pcpUdttW^YZr`33 zDm~AZy^_#Q1ixha{w`-Xcy}gcJ@-xd@Y%)Ig={n59(vNcNEb>dlWjaR_6Vw%AN-y8 z)Wv=nlo1Zk!a?kt006jYMxm92@RJO?^5r5nK;8}c@_xA)xT^n*vI#j0e;l1{gRp{F z6qm`%B`5PVIXc|&p?xEhMC4mexnmeUz7PBeM$+=y)xB#8H@(q-SVAnD#$~!Gwym3! zWX`|m;iXCo56VSN3JzC7mX77S(r>&+tlOh%69x~%N)~Fgl+o$z9j6;yw{3-;GNR9# z?@df9sLMCX%TWtUK3v?tS$WH8YLI#3{U8N!WhdTjSB^=*wYtd5&!O964l2nCTOGRj z%Xb(_u?HkGg+oN}n7;{h>sz?DrC2iEeYD)o6r>9C+KWzqMpSMT=%s}s9T@JhWnfI; zdU3Ma@eG9+NTVPYc|QnTR?5(22j|C=D0PJy86WKz^o!_K^fI!NZOW-%ftu9J-)LPIXjQ5n*f7jG?EpXY zM0n}x8elvrzK@1i$u+~S>*NNUzD>=WOcXMk>g%c7b;%0DEd%Bmd2eKYCt`9!Dsqi3 zsQPn3X0J07Sv+$$Fhfbe5cye2?H*%pM*@fT)x6&zPR`v?N9qmE)5nmKcQ7Hbkr{&( z#5zb%aI71N%D#lnYn#-BINSSuc)w{oxWW4Nt+LWc~zed5g$7gcelhz!6*Ro<=xXZO_1DIdHygb>}8w!F0%T zab}x7H{+pUE7)8TE(EOB(A_1-k$(&OEtI{rKQX|r^P?CnuOP3-Q=YPIwcC~S9owX_ zWkQ#JpF%^o@X{4H;HZ(yi8%30zwy)aLI&kmVzslg&XyX32(&!Ah2n&Ro-X(~3OG5k zL@myM2$3Z77&{Yd+UsQiVptEW>3rLo`(I!90#=kn*X#9pU8r#WbHZ(adV-ycnyI-_ zUt}1LE~h+rQwvWK%a%QMJDJdyh1pdINXtUlP@s{+h7XBcyk-~H90#QEzG35mNqXE+0zr^MU+eRex*rN4mOdmKXjOPmW65(Cn)kduT^RRwPvZQH zL(>b}n_26T97i-~(MzCoMQ6q#udZA?6DxnRE71nH^(n0dI9^y zx_XPBzmsXAyTZUQJsMfB^j$5qS7M{wgc7h()u%D#zOvLIQb6*{wK+pClUnyGO>zjB z-E0W?hh-jpv@i)d?Df$3*JAb$1Cztt&rTskSFDKNFDOk)p-*rTjqKR_qzm{FiO10> zDC!9*DKd^I6~*Yq=+}u#D#q>IX%j8NGRu7c z;f1X8GQVIgEG+!?3`OMeePSNHxa1+vqbk4^34oezr<;b?BiI-YMHL!NLRtU^onAQh zeXRI)|Fq}e#U0DRXYFl3FFm@4ZJZW2w3G_nZC z0TPnQ$tU3bRQR`B&rmjhej+>jqHY{UCd^EY8u8$l86qLUWsNoL9@*|TKE}qQ_xS4| zU6{nC%L8qsC<|f7C1O3<6=6TP#_T-RbYNX!zV&YHWAi*wR#&<%Z1j7e@RbO+iq(SQWbFj?gK2lGEG3^tcV!U-gwqxASP&aiPA|W2OGdeug4@LIuSzu`X@R5^Wfm_ zXGqmB^8dB>S}YaWvP8CQS+bR-6qOJvA(S=QLUvUYxz2T6=epk4aS;F9wdpn^ zQK&Yxuux=R#eJoLzQF`40(R0&&6Vs(cU^JxRA@}@G0m}MzQE+!w3|{sw8^ocX@MSN=7nlm)BL6Y_LNEGr&-Cc%bg3OwK@EiZtARu!E{|?A1y|3=e?8 zs`FP|VPAmL=NYK`HTa$Z<2!qK?BI}$mL=LRk^0D=goLMQ^KFfA3`GLL5}A*jI@=)h zhR3s|&<3$pvWysp9C7w&S$EN!AWc9*c(>w>fF=>l3 zTh5ANjqYk@aF%rV+e;r!(tmE%<4$SRU}-l>?Yc26DgdT4M>^2TG7>9-+cYs^*LKcW zcGpbV1@@Sg7nBZNaeB@b;M9p)mJrXEg`D>Sd6osmNV%t)*|l82k* z;{{-7qgx_%DM(9!M@A|hSSuVjvo_*biZkJ z=%dp}-F=$-aw*QH#&q}IlosrKyiT({zj>+Yf1?3t~UTlTz_~3zyZC5SjWLW`o>@viJRr zGpod6@|i5Y^vsx!%6G==p7|J#Njvr1@H)Vy{x~!BTJ{|uLmQ1nq#pKfI6<>b19t+7>*Ru8`@h|}>iTdgT<<`)c1RKCHm~ z-}+erJX6Ul#OAvLwS@$ESy^Jt3b8#ciHs8)~~P z6!GA}pOn2l?9x&O zII6`5`m3h|ZQpmm&<^X>7#Ij5dZkX)2b{jAp>dV^%CdrJf&6L{XV37kAaHsOfDJdt zp$?h+^|Ah~e_RaEjFFL%)7vA&_SuoatU=Dz3;OH*#-Tsh_I|Kwu+zM&^P^6H-#dJ5 zW~<$~#+cOeN`4J>fHChpr%&`?$Ca8=5O&a-_eSh zxy8kFb&v7WRE*cyt7wSbMdzBrIX_jkrYMtnojq`YAkp_K_)kNJC0j1=p8YSG1-I4E z*1o>JS-H7ER4g*ri9O}BGBbVgTcL#h-3bmF8S(^A0`Z={8vI5~&7IV$(%Jt6IQ|}KVeSwFN*8 zv$%)KN;$jcX$zy*3bYptF0GATnt&$}(9?C?f6xH~^K#BRH6J$aeKEpm1lpZ;UR5QX z`vY5(Ku}(w8YP>ayL`meEE)>!iTm8RJ`0eIA<}Y&_Fo@az8NMVyM2!%2gJ$78@m-` z{-99%E7z*~)+>tKVNzN=>DI_>TRwEz4;6c6eW^6$nx(*9WcNHaOh)tm-<+OWfG22dI|l3U;ab4G#$|He-*hu6Zqeh)c^FM zfurX=c!NLl#TPBpNJf9OIH3LA)c3?2x^TAE=+UQ3C$L| zm#9d&eV7I46?%G1=FuM2*i}(TLPcfelLpT6v}8le>Oo=bzbJD%QYR2Sl|DkB+F2+U zm@K7CKaldoH8EAtn)cc4>yyzsD`Zzf{{y|rys3}8>TEVM3vxmZr#}9ow9qhc-l<8~ zw&Siw2nwVOK+xm+`)*skgj+_5gPRRm7wEfrtRwL+`cj43~46 z4N-zB^$HN`hM?M<19i+50HSOk(#Gky{u%uszvc7=kP}lbC#}lQ&u8t;x{)j6;LY6GjLP+q{N!W}AnO>BzDy62o z*4tgZHyE95+GuOaALw2O@hj@$SWSodOMA-MaGfeW_`#t$?vR2@+{SwTdQD%SPFd{Y z-2A9d;5*%+Uk^U=?Obq~3|DTo7r%Isotuv3fMX6-#J2Xh%6zOlwf06s`O=c2IM0Zr z{Ppl^N5=e_RALoW_t)TJ+TqC0jeRoLjd>EfC7X<&P~g0^7bjj%HtlsiWFy;UL`$Yx znM-c4THe_k>Q!R`I2I*v8@CdW+std2yhG?u`k5z1M!8&QKr>|gmI=b0${?b#P5i(BFhDamzafh;3%-N}3F0%62>e8!d z$wS125Kmcr^`0k^=--mo)zvk-Ik;9(Ftd>yh$=|s@L}Q8j-efv8HB~Bm`SP1R@Gu= zi_RxJMNd&kcCo&j-_)4NbUH0ESS`)fX$n#UIO3i*!7fId9diSA)R_;H4R(QxlZUH{ z!UqSoHzaaDRCI7*74;``Cr7YxYvPf&GmhFF`LYS%l_NwYRb5>@J&H4C!@b>~5ok1R z#<;VcLU0wy=KZZ=nyLlPWSO(^{Ls5Rm&#tG5{sKvaM0t>Rr}GfT@S0qRp555X^R^Y zjltn@di{5$OVBEavPq1)%+h?WWT$VDeQRY4CKBsdFijL?|fq`EcRSmh^2z%aTNE%Pf&A>(_U_p zE%(|VcrzufjM+dFpMV}R>rEDkX_4QHDz{I0WEoWvSyu#0uZJFZ~;HZO#<0p z6cdZg6C-RG67Js-y8VYW14?dx_FumHPGATY8A2# zCyTUFGbuZp5tO=UFrPZVbal0`-_BpAwPEF}uUmqcbrwA$ND!srcE62QmDL#&gE}4T z_(~Jg5;ChYbk~-GdH&X6d&BrEYpr6_qkDT8IFaR>YLRYeoV7(Ngp z{4J=VaglB9Y0Cz;nWRLHVqm^QULgsM$xgH*g?8UGTz;qHyAPPbvN>l^k;EK@n#man zv@9=3*gvHFtC=|5?$$SXHT+VU?WhnpDRj7hCEP$j_|KA>QqO(AEEV@5?;lmr$!aq> zOhfQe+(l4cov9zCc~1COPJ6%}w_!iiN|~JoI`$i%W=pkXC0w1k51U5rZP+Njz!qgV;^La|Jn^hSp-Q2RnW|ZcJ&=uP_$~PD+c}3%>V7X>P!Im^IUPb4|Ro$*E<= zOn|{&{zOFwbCc{=@aPt$81(@>4)I!KW@bh>PR1g4O+^L(JVrdmj!m*=ajF%Xms@PR z@0_(<4I(2Rnyjb_2c9_UexV%*O8xTXi%G>+Ex8;!xBHQQrtVXva~62AYBDq-o#^T( z$D}t;UMz=&IN7QN@8AX-ljd%`Bp&0`48q7=dr{%tSi-8|tUP`~zFr05WduIpwu!6D z@)z<36LpZhLo}Jg+{5G}duk%}PTi$TS6JYimv#toOHBez*d?D~EHj^mC*Mq0!M{S0 zGE2N90Dp*Ut)BS3z*uO^A`^j_Q!(7pEIP3`SfWx@@9<}FRt0wns{RMM1b`E7c{A z=O48y+kdfmqQ^Zi_@H8(=|e$IS|H1lXHs3t^oVlG-9Vas@|ldY;eWA3xcW2*6IO*2 zX50e0(Xz?MOOsL3Cqi49psgXcNwds5O!zg<`5~F zo12@Tf2G|--Lt|z-WNyWu zKA9wM>;V)?_=CE)0lEs>53F?{RD<>Sb(7?OuX1RG%pbSVP(%e3CS z@sBG<16|`9I(Az-I*FR{$5cyfLn@>&GRuxF!$?h8*yk`%-ytnu^4D zVk_63;aKPOjRN@aSzd6v3s0*In|J3Wxs7b~I5E}!J5jctYYEdfTf@V{;*yd)W-AeO z-S%(-9(L^Ywe=9^Do)_61Bfww-&t2DtgBD&1oauh^{XhoQlC6|;;p2l)Or#J`u6@P zz|R-RTu)Ke{_M0A@4^f%_+HAiIbMg_eW{rI@S__m#KM4-m}*LJHXEKZxdw#k-HVkx zRPh|c?oJRje5>vN3OE6EtNT1xk zCnRA!cm}dy8@Yxc_QI>vZ+FgVU~2)1w*-jJ{U5~UQ%id{kG#Le{wP>GZqX1i&^OZ{ zpm&#eUflicqJ+ln>_kuw=IdA1n?SddIh8}sx7<`w2?FSz+KUMH(wOa^eHV>FJH0Yb&J0b&c7@E@II-57|LzKSg+Au)uEnMyYabWj%2B74Nd2W^GaIx@hdJ#s5{i3L7 zgn*b7>#00CmPtH*kIvy+aLfmUnj3Hw~TW<$qTvk`ydMKX5?sWtn)w_4cLf`tf}M=C1uQo zM2HALzwF1$Y=%>vt0q{?2BckA(*ZS^WSaJtE;~@U+OG-NLD%co(6;U64z$8VpXYWg z2!G6^z(=fVS=|>!fIac?m|m%sNK1#7x-1tUhA)*p929O)xf*G`C+R#m5PrWF7KW7X$UKC z1W%{r&IH8;_Z93777*!oNmED1A|~y*lmIu$7-Wwji6PH=KhHO~Zo-ihsvfBAMfq<3 z@BqS~`N^IzjPdNVs`4aY;f6#5Hk^3*)RaF7trqO@%>dt<1#b3i&u;i z=1HJcV*-B9OnR`&wU`)1Zi0nhBxW8cj_hY1qzi^>eaZ;FrW)X1CCdK@k~la60Sl{u z$o&@|^0vm7nYI1tzO+Bwdu*~sis8zsJ8xReBi!Hp8IiuJy02p3HUUc-;Oi;7l3akl z>%ld4^=?S5t~ZLFq(!mkQ(L?fb%n4LI1wsYoQv}gLC|w@@D`s&1>3ft>NWUaLoRdVsg*`mvay?gsxrrZ3U$BkHqj0Nslk3A z0s2DnXB*crsx2+cxdz@LcydhZLT~A`=d}GB-LGw+1#9e9)gr9yHOL7_O>T+HTvgX} zXW#gYH=bxjHdv%06PTRHvJ?`btY__Gx2SstGBI&h-{f-{TLn;SI!|=T{(E)ujsUgoog1Zu*Bh>ydGjTfX*akTc zB)8|MP&p&Gc@s?SU^5C*3MYaSKQh_7m4~`@BqNeTBvx@!jB;0^!fX=)7|2`-v67mz zHzi9zqGIi)!u%+xw=Dl|^EM+0VN7dH>44EzuN(@7UuH~8KHeSB2NF;1n9yVL7_ECX z6ErMO)vGWzcv##Rn|a!r1PJ<1p^vksGELy3tu4}PYs_*+zK>eg1LxL2ZMa(edDMl) zPv6~gEaoz)HY?OOHPW-gzYi>uh{@hmjV)(99uH7lU_1f-KLpyZF-PHAaPkpvN7~1| zLpy8O0<RdCYy5ldvW{ z_qc;1qwV&T{F-{TAHFEGy76!l6+B#`nsC*vk8}}FP)d-%q@7Mf4HIhoSaI1s&W??w zpXT&9vn{=UZ{LQfs;uwZ2jqDaDQQQA96z=VZiO_&q`eT~tF)W9omRr-T6vefWqH>b z6UD3j=|m>$390kab@8}I_VO%g5VrQchb>#M-66FVyJw>fw|1r#dlC}u9s{@9vA`7$ zx>`)`ceSW+=V#7N!l~7vY&D3TH{R84$qKPjX;vVUcfBuON~AGop7q(cRDqNppB!1R zx{GE>8yh(6x+1!rDG-EychENhD#udJPWKqB&!t+Em@&ry|$K)MXW@ z@zdOHt8klCg4w7!9KnJ(O?}$NAbn!gCQ}w(Up)gi#SGm3Flv)=I<{+?BI*jS^5YL6 zW!Bh?S4q$+vfSG79{Tu0<_4Ycs3(f(C5`1mo1E#coV4DkosuvRv*mLNOF@Gier{w9 zaW4lvKBPT($&M`inoHd0X_xu=tCT;%NnK88_<~+oW?IadRslt!3>WG)zWn{Yxj_E zmRG`er<&mJAk7WbCZqPcuizW(KOBOLe9A+ecStu*MV{YAMqFkgZH)2XIP0wFJ=L|F z{z8Y4YbyG522OnpPH^8VYO|UpbrtP#a;=ulC!M-5I5Z8bJI?hY^=3o0;8>(deQMIL zVx0V8LP~_-+8}IWBgofDiu`i#$%kUVqHfhZA@&u1+6L^91~Ih1w$r z)DR|4J+4}VN>{j9vSbh`SMqf9=TSklgC!@M2Yvsu?ar^P*!eTQ@u{c~wk%0LJyCnf z?=ikMyE^q=TAS)?Tl6g@#~^>oak-H>JGO>p%8uiHrK^hX!4l3d3AiVNb(lmaBavS$$VRk z0<=Nk`_PAMmrg8#$A3dU%)bvQSkYit@+HR&s&#Elwz_sF`vkB(n6~4iGszck?pC%2 z9M{}NEcd;TJ{4iQ6O(J0)3E1NRvI@de*SUIHv`I1DIMzyZLjiB)&+_m9Oz2rhRTiR zjUCxV@0^#=L6E!_J2I4Ohw3B+4mY(*g_-s3bv0}->)hob!$q(ksbJsL@3Try$y^M+@Wwth- z`Pn_ie+}-J6g&8JaTygvrrG;6V5FGbbbE2RF9$K_cWuxK=~+5SPzjn+8Z8*7>$;+6`BO+&-HSzvvClWy(R$P>ItJc%lEMYE=Sc(DI7vr0YpWTV1q&!&oIxz6 z)XPFH^h9Fwqf5kA6zLBu;>}f-alW^cr~LX#TUH~#h7XU(Vp-0iaVHLDI@hVxO!0ct z@Vp~hH@hDnY?b|xU(E0*9+${5kz%%}reC7+47-au(-5r3)pBZ(PwV)3_|gMDetvp_ zRSP4W4&RGpWnPM1N3;rsN2gdtbmjP17ql!0pfm1!Zl%{c9o=<-XXoWSox!Sh#3Wkx zBDS#X=%4pb^iqpu(%teLn9(e;j zbH3CP!xi(iBqWGt?J_VzYZ|`@RKj7kT(?##gn-t7LC1ewJ?4 z?sjCte&-w53q6FhoLd)}gRN+@_KsKc*s=8#sXu5h^mOUOGZffdm14$=BJBP(Kl+Ig zDJ@&MVL6c-UEku(r+r8aXc@;!wSQ%g;yM!Q3W|?E%zUOx@;PLNUTdi~*QuQy@^f^Pz`P67 zLQH*ctt!#nLHbR-Q560rDQ!wPk0zRX(_!W`l8E&Z&xpXb~?RozK3%dHRTU1l&rkhGb&&y6UTiz| zB0AWQpkt6>9!_ic|E9{TxN!N3{C$%hN{nEPf>WvuXTACZ<(N!Su$Y>qohS^ zoJf;5XU{j^lA93qFkFQgcXnSi=Vq1Z=Zu!Zs3+ujKXR#ev8kL%`MfAEa_h*I2##*h zFpRALG~d*%m+h1WGB>><9b-uc(JnD~8krg_H>`Fmv0HWn^{g9wkcJzk8vDjEDlRTtokT*-9~Tq3 z=_2FS>f(NRjG~Q4ZE&;0Fu)yBU~{lW*#dk?%7v0q^m@u6nv6aJ_qK=1LG;4C2#A@d zelM(`Y<?3dy~3zw8Cy`uH`_32~_b%MV;v@E3k z4W_0RVh6c{nBF=cr2p{qi|j(r#t@cVb&`{br?9u(u_Iy(YK#cAl%LK6ayRyekwF72 z-R5kqO~uq1)||sHx`AJBnwK&@ZUj5#KbBsu-JXPGS5*#CdPV1RGIs@&X*jta=dhDM zIP-hVYWq`UOo2QnH&;;}k`Nc4?N&I9XsEWI`cAmj(kx~_DgTZ0TF{r- z+#fF{->xXgZqzFCuoFe;5gRrmBct)EfQX2OCvHg+#-wmr=?%k^yo3senRzXR%0ve2 zgIHlh5muYbHw>ybV0S!=QA3rcK$X6#Vf7X#KJHEF4rljckuWK--SsFX)a#G^% z)6v@%fG)h>q~3C!Ki{Nn2+L3>O?IB|;d2Ej=Nm^+vsMQ1IPIUuCG85{FiW;ey&HxT z_Uy-MNnlLNdBx@(-vJW@Z-_h}QxN*5Ty_%cb@^Z(blA4GHl_YtpTQ4hBNy)9zrVxn zHM2O1H1s{ecxuPn7G&k`a4W?6yVtT1shnRP=$?_C&9|#!;cC!RVzw*%O{w`Z;FbF)?=_Ot5Yq(Vz`CA0`rTH z#2(~5JU1{fAZq%ZmQl{5+?w#fxbY3+YPY)loXVkcZlE-%h-yI_^{pBNI2c6I4M;MT zX?#u!8~1;F5|NO=?~XrWQL(Yiv9rjHUcJ>W6=6pC za#03=6~1VDd-2Ij6I0V$1_sGdF)_D|jnis!NvoAUP`@O3E=QbxOC;FqS=N761N0^I zf=^JejpE42l|xc}0jGJc*}-Q!&%u}=8X~tV#G19mn;x$F`uaG~MO1dnV_)u~469r; zQjS3N(%_4oTpPV7=}tuQr~@qn>eEz%3)OH~Oo2KHbj6GK_3ZNUe*YsIN=KmfbejZ? zg%>?p8auR5E$RN(C9L`%UsptW?g2KY(s=mrXmI>0BjC+A4=^S9K7H>>==8J=I76!Kw~jTJ51ksYMsL~O$t>7fLUK}^WYkZ z;?n}N5-EE2^MbK{2&=4a$|1;w0?^NY(0LR_@AfhI*fFTE#P}Q4t=qRndndoK!SmEf zp)0S5%Wg@%5%eak{Fg7Y31*}kj3Kw)#2L!4VE6UaQR4EAiOVkwW)y3r2=pc;pVxwR zZtCjB7gAxW*3>L)Y<9@MNnU>M_DOTs64(Lg!JYUpxGO? zHQQ!S+hoBN5kzwMDqdWyQ#K>zAMP0uWl2{6`h3wp5UpH=yBq4h*;1 zJYS=4pY?MeKOj*dHB zhoE=*U~yxgwaj^c`fLUU1(|+%N!FOV*45SZMlAY-Z|t{nyNSu4+J|ik?sa57b&>tZfPW?Q+O}fd*Azh zj_-N@z26U99CF2&V~#QAd5%~iit-X@$VA9bo;*R5k`z^b^5iKD`27s=Iq>h|Y>&p1 zCo;=YqVH7Q_4nI+9B`)-E)HipgyKVa@{hk-ZM;^twyj1QnA2i%`;$H5v*E#e3 z-BXjOF73czgd^-$97-lbrW&k}fZVcU`c~dpTN*_B?g31Q=BwDm;3ps8g%yIULaOvJ zL59@NDRSP~d;49S%F1vo8auZX7MgRu$tqE4xZXH-kRQGpYV*0taGR{Np_jNBR&VT)8}H0$`=|wf+9gbG=@x z&J`v=ZM`H7=Iai@AGdzLpxYX7yi5k z(&&#O2xXQXMJxsl)B{xz8r}t)CQfkr$p}sN8dvlcBWdXR{A&e{8LojV=Z8$ zU83Fw9y2ds6!|X}d8X@H(&yI;?PZ=}Q^`;yZHJP9I2P4-GyMMO6mz z&BdL5n;b)Kw7;=n9m|?WF4#6|r}j)vo17I}>G6D&#|)6?;dWIPY;f0BSf6NZBVNLf zxEPSaDJbBr`g>|h%LKQv$U-Ahv!zPGE?ME@AMO1DrvmI=NXSGMP3NuaByr57ur>I?wSzQ}o+naIBhBw7_e!qYLD`3y=ZhZ^16=c&!?#B{xp< zFW;Ia_+=6UHG24d{AlCQ|BP6`E_1GS$gD*aE9W^DQX;?Xh>!EHRn{VY?|N8v2${|B zcV@Wbs)s$xeKF6$9`;dS4=|SGZRmM8jPe!6O{mdJT2DYZ|3BRl8@@0y^6~c8Ti|A| z*8Mm1h2t>z;%7z6fikvWnvb|3-LeWQJw7f@C|Vcb-ERsI#y37t@bD01-QR8<^|1P4 zm1SIYbwgpbK_El;`M|BVT3Kn2)cK8D50*7 zFL+k0K*7O*L+<_i(>W&XK=wiS|IXu!{f;k)#T|T;>;_Cd$=b8 zI_GV~lTF1H9&UDj$$g&lQl-p;5)-&`pHKtztNvN4R6mY)0XnXAC|}2iP7o@G;2JV* zTd71Pxn_S}lwgTYN}$}*cfXZDWBF=xU~f;i;p5al=MXHE`wqv(3Yw7d-%79*&YXn4 z1>%&avsQAVVxF2&S#P92`Hz_mp-}MRvxm_7>MOJCZe*ZIGk*)VZ<%q@J^i^e#zUS8eVDbNvxOlRN0z3`&@~PHkrq_WI zGG@>-ejHzB+K3f!^XM+f0p} z1aFP}J?_ozR?opMn{SmNyR0hqX9!X{3$8Jb4|;kUlVhD2lV)M{O3ath%(c;^wl<}W zq6TAFb5ggw7fDDKFN?^j_AdoDG<5;aDe)(T+=iQLJ;K}wK^c;n8otCvT!9&PRD11Y zYeAC`zAwPx98PGg43hdOf5`AH6d_l_-JWFifB@VX!R5dZLZt{|TmK}nACAstOp3H( zH0UTIv63F+@8TP_%=sAJd#llW=eJrnbXs-O8xL0a=-Ez4R6n=f)!uumMCPcgniW!b zXbY^auEy|MBZWp?`D$Om*BUAyCBmddy>&@e3k$zpwI6fHLQjnN(g8JLk_Yi`-%60s z^9o$YlhG*jQt-9FBb>xyk{`a^h$g0S>S?Iopw|+7!9k9>PfJZv{+nLgn&;b*CVOmw z5g?ESIDJ?!MrN!| zVn07h?0?RcaJ|6a6W{ps5S_gRRu9i+7ab+9ere`%ge~6H-gv4+^lHRmU3|li&-&1{ zW+yf|mm>Vlze?|u251dCn$w=NM7#S=zuM0Od$r*8JG4JdI6`~e0wh8H2sFHMM86z! z#M2dS+J<3^^jTYYvv_!gD+T6Q?Y52=!|$L{hpeL0I!^vC>0VvsW7je6kAcYxJ1PkS zm4d-g-2VK6)grg;iKwZpf>*xpXwJZY{1s2>dsuDz(CZ~$mIbARqv4eO`Z?POFXbcy zSNS^D;J6I!RWUlk{OwlP!LA%bpjFkX?~#aA?Q+JL7m->=k?2~HVJNv>y-ACuyU?h8 z^3<9ubYCcEZ{j)i^6P_9mD3;v5^~n_76PAn{7W!pwv)eM1uw5>^zk;uK|i((85Kyj z49g25)z(Uglq@1rwZl9+L{yfR@G<3TiOdXICK^L6(B7RO@MdDi(+?r`Q0^Nx&8JJ! zLV?GVl50tneO>tdIW{imr2_MZybnsNGY)0QA^Xo(iEx7VMQ1jJ$XyNWrB?uG($eG8 zpwQCQ=ROQdu;uU>o)*Ko;r@2yUc){X^V7?Rk%dJOw8l|#OZ3}LM)P3W%ZA~bEVfpM z*U9!Ab;JleY96*11Znvs!-F#AGEgi5IA|-6!Cj}>j%%{i3u(c1gXFnKg_e?Eg|Pag zzR+_RSnK26nDHtuZTjh!E6j&5;1R4X}rnp+wuQ_Sj@!yljmzPG$&ez%S^)mvLzOnw(< zXO) z58H1P4Gb!QcVZp$TrFCSqHgF1<7-)#GK%(lE711wk`=r`u=?foI#*?z8=NsffVBS9s z&!lSoKQe6)4w?Y##gZk$|B{Iw_TKbWN8EimNhX&Ut0)e0a`g+80=cHh-1SMC z)dTsrW4d(G^4FrwZ$SuT7y>29F8EX%`Y%fAAXhn<-z|k-N$^LO)4MZcF`<|EmAYpc zjn&GOyeIKqc=zpZ$Xpvv1=oC3x1w*XGfwmkzx)oBBTa_l4#NL4-*%^W;(&{Fuvl1w$H;8BM=0xg`8ih?0;R<91chiMAejPvLz1OPYB;hxqB}wb^k{CY(%PzMh zAC1SxB308J;~e{{Pa$@{R7_qmafHa(|1~4ZpEtd zJjMOHp&EY-H9q{w)Sxfwg>H}HG!3e&FX}M4(A)cvHw(P#5$#`n*5)Fvi8fvi7YiBw z_9hN&5rV!RM*EslH>mIi$d1$kVO0lSg;d)VX4LJ+eiRKYai#1x#e?bafSWuT2-|{u zKOZzMcW;pUz95*+&vWY0b^an0dcM^W*ih&9txv(nSJ(PxQ)GaRlocE}q*miubJFn6cL5i1fS`)55k88S{3?Ra#MDsP03{!)P}52UJ`6?I5OY% z<@yQla`;6>Z4CjB_IpPc+PjCc+CbC?!7HdV5R=3Z+uZ8!lPo+ z-azWGes&y`M%MyL- zI5~3;oReH7y8m4VwKjs&?`xM+QofH&LV8g{2~MNjLRxfY4XNAEh7J%8LB~C1MIxSY z_Mc%TrgY}ya4oW#ilD}%x>6==!WH7Dh%L&TvZUuV!Y4mxsdp&HCD zt0A?I_YV|YAxOH{jCq$zXdn;i}JA7sQ^_+NhS>=GCn6UH#+6k0TPSKz?50W;Yq^e4XHePOW9eL-@ zjb8*1TG1h;;IRU3Rf3IBpXQkly_KX{hEsUKYxt%VZIZgRD^F?&#?9wHi63R`;|r&! zZgvms4_a8xp={+&O0w?4BNcqe#8m1F%qZfWVXm0<7pV5TORKjK7!Q1NBqOMHOY9~C z>-g_&1srQ6n*DT6?^vXc^|nO+OYX!_rRQ_-&5}x>m4w~m*j+6p++T)5-~*ZcO7Kf~ zn}o)8sRNueGBXlxmW0{NBh?z(7pOM8=P!b2aeY3-mZMWkKw77Oc$lbW;>Xh=lFQ3Y z%|9~D>Oax=@~ue9*t0=FPIqFc>M*gW4_gT5pM9Lg03D_BTg(g67Apenz`mhUMTAi& z54>bSB@K#yZrmy5eU)$rR$ z*VBa0^ZqwK#qIHhO%sqJg&7Cj=PG~{_AuKTCN;G^q-yg}@OJ&9Uo(odUHdqaep9BX zI4QO+LSD4qG0l*fX;;5(v#P`dl^c2nxh9#p2T7=WhahbVA4W5>&B-Sno&=wmu{wK3 z%qc}qW=Tx%@zy8WO^w*M(< z`I~IB2~vCx>e>3W4f&C;jq$vf&p!O!X{Nmh7g|5^D6HbwG+B06@crNMH&zk|Qhwxi zB(qUN?-h1>%&~QLTVEtB(Y_9Ju;%SfsFGvHgO3ZPSG3iU-c$V)e3z(InU?bMnCdF- zR{D-ch?s#4+Xo^w46>x>YI~V4ky>q^c1XC1bZ?$qUu6F#a%{TBpMJxHKcZRwRr2fi z!rP+!d|s)eW7;q1z{}qIPl$#`*0at+4Ow3Ef3}M%GlW~@;!L4OVQS>bxtegq-PknR zXrMifbRv6$ZkR7ZqC*=Ao)76QQHB{r2c%QsU^o#T{YT|UP?GC)%8K?IlRAE~Q6za$ z8$0Ydd~YqbML`_#+}UP!ytexj>jk3dmt@t+ngsuVzd~iqOeCRoa{!0w1#aWWLf`hW z*=Q&2@}$*`^T;%izgBy{WF(hL(_+OWO+2Ah14VH9S%z8WOvH}2IzRIHn*C6AZuW;i zD2w-V=mIlLKRIX&V*?@1tbfY>VlWmjtFPLQu0)UeVV!X}L_x4X<6^?2`_Tf6SdHSGskoKe^c-B|1Uh`F@I ziW<+SwF&$)H1TDn=PnlA$ai*k>syjR!gsVCKZ4Cvk!f*)|Mi&$q|I`jmme~^dNkVe zaq#)e)lO+yd9%cZ?R|gg@t<=q9G|Xe zJ;!c*nhKjF?RnX1H*A^>Jn~;)i1-|3llt3#Y!aIvaEwp7use-I?eaCmP(4@QGl7NM zA}OrSC*pw#E>``{!6p(y_q?_PsT!Pq1-4-3?p(t$8@bevouXu+B2}}s4T!W!7wj0o zkAfzG3Sr>BDr1NR0yXxO+Hu~u%#Giec6V1%O~Z;}aL$wxqvzAABfkxnEXylfbe317L@T(k z9Hu0PtHC6fPAmpui>vXOAY#H#TlbS|y1h7``bKNqWbVXLo~&aG?&WW#%yCC(-#Fa| z@80<3m)YR57%PE_H8@6VIeN+H(V?C<4Tzfn6suEWJxO=7h#CACGwgr(J&67L-|=q+ zTXv^kiLk>zH=SmOzI0{_w=H)Xzp4vyDfZ!%9ab&bg0>D;2!U zx5zu!F!`XRAE)exTFU6z2br1Rb4?UY&ln+gEPTXUVEO(rot5pRz}Yx*aOp+mn& z`11%BiQP1|SKx$65+Y>_i6p&o^gZKo?kk>Z|83+h|HtUmJ(WvFO}c0y>0FaT{Ku0q zbHo>bzW#!Uyg`9x;vX!k+-E401*omj$zstW91zX?b_xF|n)~mM1>!x;a?D;E-j=*K zUvw-P^~ycp#hvl{oU>e&Ik5KisQ2$DJ40Ug zu|mW8|00!YY0!%x@pvvx5iN0+bl?U3L*|0zWvHUAM>njYYnIsKm$g=SY5dRjhlQ{W z+3&V5r-VERRI3p2VTkOHNYWx@TTN&2>rzZ#@I9o*#0sV0=4B1R8wsXBR9(T(U#SKy zs?8HvE&sH}(eX(;!`|9o4AIR1D`Xp}6_)I*l3}dR}r5uQ_kLtmmVYAEpN0$75tj z4l;|!q&5boRWs<37$}DA*e+Sg7pLUUEV2cE5>Xvc98CHP*~;c)`AloZhwWVqxmV^C zGmvm<X4CUNbKD;b!N)#J7?*Udr%5`)In-we|^N9wMt za_8tHrhoPi`Jlf{v&W>%8j0tzFG{DonpwO={~cE3*iyc1VJRGcwAztHJK~hxGsrDH{m@c4b4KWXP@6L}-9SS1r^}tUW_lX@C8asI*_uD>v72)Vh5qf@*0ZQuB1H zUfGM1m&W-8=AW4)8E~(%s#mXIl$*OT=2?&Pn#cWtJFCL&y(jOFop_^=CCrZHf4Q1J z5LA4P-F~G}RMryN0mh~}+r4c~lLOSO2MN;)qz|7#=0-LVg)UUv;BXxRxe#4%QOHma7M% zB&zF(GUrD=bwN|h@si6Z6Unn#Jyi1w_-KhR&R^HU*~KqVvOZ}D;j^na^&xZ2Fg*+IEAYQ?jan7D z$NvGeEt+h>mIsk4Adf}6BQq<4NKM7u(Fd~L6nshZM^J$?uuJb4OlrA)qok)6w8OD@ zO}1%ifN=uuAluyDn|cMAbwZW*bY+Ni3GsO*A^rIR)Hj%5g`wh|8Cgwo@Zl_2w_NA} zzK42Mrv{pe-3|Q%-vQ@8My3UKsrnb8wxrD|cC_HiWauc3mSDlUmfo`X6cP^7p}(Sw zvrE>s!3MW~!qI1Q!6g@2^B7dL`^Ev=96h?d5|c9-2H88|OyE`a?Y++**s{vmDwv3T z12cmAuYJRY=}`QgYk;ZT!BMdd-o^qmBYF9ixEja7PmH@;lzx0 zjS}i0v5JbEeo-yW<~F3k!pJ;&9t@_&h%A#)$0tmrt26A5d$aPK>Iueokg`#91%R{v zwVM#^&)aR~dWRWo37J9Zi%ThnPdm3PyUrJM-;2-OYPjYeoU*M4v;5iX;a6N94}UFB zWadKYb8}g1+IVT!U%)$M@_uv%m1TbmY1>HO4*S!^5LsRWX(ghP5Wl0M{eQ)@97Ng> z^z!Bsxyw-tafI6av~!I967imrVhQVqV?ccQRrW#=jCTKYFm_);MlHon6M+8}bD?9; zT@q~^pd`FZ@JtzjvH_Rer`hc={?MS8H~bG&A)#Ii9o8MqO!x=!D80Z98-`^Ey&bgo&0EBEHEp*R|L`R5@!bE6;Dl}XPg+d1{i5w~ zEp)^Y3k}EqG=JET z_TINP=nBRAB#1`GzRX3%_i@yu+Yh>G6lVCrlITQJE43F2Bz`056_WQ4@59U}o>WxJR)?X)oiKuRmeR?3EnW z1VeL}SbpUn)w!;CMLs#=66QrvX_F}aL!j^eR6*bfUYDi$P>&v1HE&RA-vEgfAij$-AU1Z0_5GPsaX^pU>;iQ@orHC*>+SA6 z5f^q-gLwyJ$~;^Hm5nM{!}m@E6)gfOYHNZVQeiPc6M5kmbZqt`Yoz*;ZPAf^s(DWe zweH@d?lqOa3aI{qK4fe8Zhv|8_ez(qgjUplA?8P3G0OVIX~~W)?I~3j(sB&O`{+ji zl()LvrFwO_7R6>g#cXP73UAYh%mI5=mMw2VwUz-N%$qOtyNQD@aGlad`^P zEMIyxE#IUliC!MZ@3Xj3C%eTxiRn$ef)k_yIB!AmZ?5(Ru=Re~=Yvn97LTWe1(NtDCij zBw@uDnyaF|Vq0?o4yxq3;E%2iJ0lw?O${fnoy`QxTT7*6;PjW)RXwv*-|C2AO}}m) z$laN9&ENY)xcz6}?@CXLtOX&isIsz;p#3+3_x;WC^g#`L$|2F#$Ox?DVS|qyAbe$H z@*s%Xe$fI8VX@6j)zhq-g)K!LozlhkL>v&ly@ML>&f#X7dkAq~Nqy2WJNqiv{^qV- zei6r@#biSv7a+dt`xA^_AG%o2S?WEiKLc=CZv^Q4qRz!?o%adZJP*fFV=!fNTUv+} zz-RI7KjznBG{Xj;0LDI)vsv{fXpS=Z_S>9*x?mOegBb8K;*1^Fla-hR1VOg*4VG>x zo#BS)&2L7JR_~{1+-t?#KyE$i?`f^Rxv*sQMFcLdT>IS@8fR{TZrDc>$+bhf$ zOu>+8L-dC$e>m)NcNU49vcC`%wDqis>hMuc&bQSIl|(8Cee>gHg2BZlZY1Wa!V#S- zDuHhTAFA5Y6VCS!XNU}yWv7%+=l%48%>9Y=bj9QWJK%Gz9-H@C0Jvro=SSrq@qJz~ z-ChpswoSW_Hg+B?oezu8%ts!aX;arKu~xA*Lx?N0UC|Iz;E>=mZ_TV2_1lFhj)gb~>{mtrmslqA+id?hjrE28+tV=?0&fjB5<+lo>-o*{ za}q)4ViiPdha(mExQR*g36yv|*_x>d>i093arfVQBTPEfy5nf!7n_`wQi7drr};y_ z=`^eUY!2^xc9&5j`q>a$edsvxs)DfpX*e0x9SVNb48E)e4&(Vv$;Y68qbdG6;C~-z zNX@R{UW`2y}7| z9G_wg(W4fKoWiNi;NuRIzGl6bRR{<#6Bx~+gNdK`%B^k`mE># zFDK@afC9&^u{OvuW_gxE6)EKlQY{k91guvw(tujb+X33@%QhPNiz8$Na^euab@n?IZX$%Z?>~NU zfPd(0wwO`B2p@h`!lOLI)OB9t{pF1}pG>*cYU!Jrd}yh&HgwN+E^JS&UALhOQ)j2g zG0DecBFUWe{hEe1b_=Fen7p2I!lf(S&YF&62qi4y`TM?z0&m6r0E2K^cEDC zyZS&hvu4GDf+gHaq&RsQ|Bh2FrdJ(-48!6bpoyKMnn(qE!Zlj<;opXE2)KdpqZ zCjEabeA=ni6XF2R_*k}$CYojSZpy9h({(1iv~l14MS zouc!6m0Leg!W}Spdq$;f0_v>ZkdtQ(l9XHFC=#Ogk9i}|zEJL+iJ&FPS|Z}p_0)8n z3*U>M4WP!^Z!&Zu>vEcl(fM-MR%WJbO1j=X1|(TBsA`Zpz){{6bojIl$7@*SY)2o? zOstxPPkm4s=5Mi9#U*Y;s$OW4K`g-;`V8K#z&75&~0uWTfRU*xXDQyvtnso}+(AbQE@=$f3RPfpzd7IzH>VCoRrc$2ii3Ayg(H zlo~9FLi_sDv`#Hk_8$jC+QEi1gRT0;YabPvy;)1@fy)UC*Rfl!1-$^zvpB8?K)C)ja@CQZ$GUn8>A_2)Sp> z-94&L^0INxRjgFT^5*)0ZtV+ZxP-`0f5{0!Xr_Ntj=c8j4f_6Xd@Hp;=5z;}GO^ka zx6Fvgf*)W?=IoT`hs%|+zmke|ETlmge|p)WHhu@>qi#)%*?&UdYuEDoM%+$cJ)ajO zsYkKSnkFSMRPcLRL2jDe=-N1T*X);8E<`)9w=hNrcS|W#heGRkv?_LcW^i9$BOHzT z7{v^|dsFNkje{`x2XvMeq`GS2OaGzLeMSQ&j5NbBQiuhWfASMdsF32F+IsTNvgop_ z>b6^L{5UX*ccSe*1w$i~$>X>lxw!y@Pap61)B2!hg24)wCe__svTSkCD3$MGa2ll> zQ#y)Mwb*U&Ay_kfHbp0y=>u6tzzinrzXBM6~BGMzZ_^q z6pOZ)lfs{WkX7pki24tW&pNnnRAuWZ+{5}sMD;2^)qRnvHxNi?Va66W; z%?*GXs!T2=aZ_6`Sv<7qq!a2#)-GWp3TI4ArkF$E*zrofd#FOmq>ip63ANn7IW4{g zhfpF}7P3=IQ#mU`ig%5a(ebY)0ixZSVOa7H%`Q#gqaW-Q77Be#39jSAj7B3^v7tK2 zLAfI4Bf1>~vqRqNlhJ)GibtWmc1mmwJkGgZiu;@6l?m(HbIgTngHY)1sT z%7i}Je0ANF=frr9BWpjRrxuXQlNyjN0?xHfeG(s#@LtYB@UL1D!L*V+Gu2=9jA+}w z3R6+0GHN zt`fdM@VR+%#lriizA-eZ2;+OwYJc0nDI5ZD8KM%QD^GonuNy<+o5tK+HLBzwnFOVV z{6o&XtL~@krpgK&1ZkpC_CNhKWJ!DaM%?u|;(-)ot%vhn|1D)IZt=Z<31l|v>*`r@ zTbb&W?(5Tq_&KDNh!>BTLW(8%_fP@0o?9w7O}MrybZ2Ch_j%dz0E<7do_a<-X|Im7_1%0|OoPI{WasZUat4rZ49q7EiXl zq50GqFrPJNT3OsFzO5690|bdQtkKt+XBLcb80Vo8dvcK3=x_gp4A7A5=cd z%0d0sXJ_ESnbj{w>U3j`Q9?4VVfKm48(Rr^S6V=t$ul`AO_92yIGt|JwUtuzPubB> z;G?}roC3A#?|&Pzv?zLzNMp^gyIH(qsc14bjO?O&7?oxa7QLs61+z||d77n_Xn_mU z^v2+rdQ5y7{->lBZ5yP1n4{)h$8=*6q5kZcb%-BJeS!j5t!mp^T<760>{h}&F5^Me z{@$;K4T2|6u6@mKU*H<4gMRrYvQb?Wsh#P*|G(1g@Y{jHwXz~-dvTEE)8kV9P#3AC z@OmKyk(wL0RC6eGhc{ZL5GlqbtR@*tRx5lVuQhRRz4IYS*@X{XALHDtuHlS$phE$^ z%?hWx8Q3c*tgRpEGTq@=k`_96UYfzH6k~qK#xA$K&z3nAALOV00hTRysqlRMd21d{ zu$FlRS2o2f9`r`XixNl%q>BuiyhpDW12tb3^OdpqTEUmjDW%X8`dqFAUCvFn2f|>o z`z+&tES*kYm9*^S)3zgsP&RoBS~`_ z6y;ZXqYljM+jsNMe;a-tC`uh76nwGpIqP==E}-NeTq+HSzXZ${F#h64HcuFgaxm zb#=T}b00lG8)7}Cr>0%{mx>%QRP1d}I>OtR4>2xVA2J6^2V`h24d~l{d1EGB)2;TZX;3?8 zo$)_UO6}TtB!$ncPc*N-%{{RG)vRy}VGOyw93;UNXJZlT zHtlOuebUm7-q*EUD2uIbfKDdy)2vDQ6?|9%lLliHl6n~;$tL>$$k0IlyHkp>&U?n- zZo@!2NuUOzh!7X}o^_*qg2q>igM(UTO7EHq6*U5_4QQ{JJ3m?=eA*ZD>OwVSX+tvm zqb|#3Ma7^b@mX}GTHw&;$ z5AhNMXto6kPk%L)90e6@&PxHg+tRz!sn)250E}Jux4p1apsx1%iU74P6P?$7z1QWG z=Z;Lzeq&|8&)Wb?QQ_q1<1~le;#Y*>tUJSg{x9nDSCc0r%d_0)80=EK2yuvge{Ckf z`_zR|a);4#q`!Ap8Sa3NjR?X2RSRHFhO8X?X!_}`PWOb}OdZr;f!h%nqhIeIN%C1Q z>8I!Z{MQCbJB8u58vPiS=K<$b#Iy?#LZrt;-p5EI`^BHL2=&r5v?V{|Q?)`DN!zEH zi&j+t4#e-RmcZBs9=Kj*wm&kf3DEgnOU{>&8ZVttRH0q=MLC3!e?-#nf>ffxf~RdY zwsBQOs=Dzfr<%9+Bmcq6I{z>JLm*ZNhv1Z*FhHr?3}Spr`=&|4wKFAUqr4zCCaSfB zJD;{t>5-ZNEz3{;U7aRHvgq*Az9&{Rs8*!LwETojRoXD(^t)U@Vg4pdTTDpQ7VWRO z(7FsAW>47ur(vzZPKYI3eRvLLQTjl^=-2q#Wr2!`{4&-8$cP91o3W71lJevZLoj#7 z%kGb*NMfZ$`kUygS^J%Q*!%_zq9rjYsqJLNC@0k!U$Y9`Lb-c9-FuCb)Ezv|UIB?S!Cv*qxaSg40YdMue|-EQObHCzJ2ZUK0)1jfalw!HMxM%QX zIk+s-&2_qnp4$$`xY;oYQWiUMZy#al4>^4GODJ zBBv>Wq(trMj^^pr;oTIG(XQ}ClI)ySFm#=Z3|ct;cr5@TdxY9R2m6zMGyG*hhQCW> z+x|@nuKyIWFbG8rBr)%C&a|z|M)1(v$8v8GVVuNmy1L4=EWA}iH+nueLJ*3Pr1#zP z9pX*G6LNnzlmaAGsCPN~k`}xmg>F5;>-L-p+#N?2XT}0l=;l~8P=h=@zf=#+G$|er zN$_IHX{Sdi7LzN&xmOdNUbAMsnAsN1SxPNWemw`{6DVQ@l_S&Y#Ra+(tmkgmF@}HM zer&b!z6lt4!#$Nd+dbg_zpg`eb?Td(?o~(S4m?G2{m@P9vFuSCxu5;S(EW|~qGPp{ zYH#V`#eKQUd&w%p)VFxDV(>^pP!dJ2WUtY^9f(Xl%g$3PcwlBWiAj{}FF3tXhS~MG zCpn`TZ#K7Yhu&JH!li}GY}5k7i3F;v8NNs=Hxs`?8jF^0${DKQg?VmxXQ<8u88Yk=K-4d_dU0HrShde4yE0K<)(?GZxG z=|iyw4ss7JPL{cVP>pWKu+ZwZ9Gpc;4cFGBt$n1>1uX?qXk?pa$*E#(^z>U|&SB6l z#;df7jAop{c3u8_ykPf`%I8yi!y^NN|4+vj>wJHdqF|eCekHS~mu_^ zCFA#Vr)OM_K@A!$eO)J-rEEAMtO=6CR0XYM(A=11vU_p&RFlbs{SV;EP_k$&GL8;Y z!11&Hjn-UZdqew-VT5_sHo$>7cVEk)wE<^x%cs_q4pSDj!APcn*B&Sod0vmU7UIhI zh|~H~!n+D`k&w1yah(}#b%fI}uB#UOSFQ_lyHT2AVDfE52cweGkJIB3$WJr6Jt zyFYsKqbK&EjSwi2Kv)0Ai4{Z53}JZLG!iLO$=cUfjMLHiE)Hp)S_ zw@zcT3xrL&Rq7s#G`hevo7p2!tpczbXM{%?vz!Tl)k3I&hM)i)s_oMGAlE)B&z0m{ zSEK!tmlijYy9_e_-RKe*mIZ*6)j|L-0sV>UP+ZiI%hS6GWYS+zJKZMIDcf3^25dZK zV3~ws#$*5%8o4~cLVI-7$U9+U4!d>!;~dn#JE*0a5aO~^DyLoBI9$X|ezDTi^j<6Y zH-4`fP{otIzH3xM2Nw=w*x(* z9qEC35UG*hd-Vn#VM7~E@%l;Xn#P0Mq$(N*%7-n9-0W%yaY3eoSS}(p53?%{x0jCl zX0&jMB=ltJh7bMqd$_WY#NoW0eoceB$nIkR+8b>fw@e`znU^3xbQdY8%3W)-Q!Tc>JL;OmQb0;%Tt}<@zH)>5EW-_oWp{h+T zGQVuUy0z!4vW$i*aq8?YP`_vBxLfP!Do@J{Mul1#{o@KoA%@t%{0hrK%b_5?TpER6 z+;acDI`%*t=4;Ua!|Ut|F0h}~H=0ouYXeRhE7G~(nQ;R}WP_|9?!s7C6w`d~p;s24 z{^@uM0$RKj#|kphF^1=!U8UUG)@x!vjNiQFL@GdT^Bn_sOD>wCe`PwFzBScMmf| zsTR0^yEHoNnh8DU*IB8U2^{kCQl`^))du=rSFWMTr}rysmtpx@1acg`o1REqCI8 z2iuF5fhy4J#0uHX6w&{L3P3l#H24Pr{j|wX!^7#t3WFB)nyyx)-j{1%cnPWCqrIZY zpGyS>AJ!IR?#mD_lN{P=far3Y)<9qcxn6pO{@PBLF#pKqeyVi z_XxqtGySgL31NBMV@@tMSydM9Xx&ztpu7N}{Yy6@9_R*t?fbbIfSxq3QPSjt^|}UU z;#tniOW#9H3!Yayuy%hjlz04(djLya!$!G)`+GL!ai=~b#1STGHWBFj zfoW2a4`4Er8DYVq1g{Zyrz`uYq68qrzUQ^lEjCjx|89p4Lisvn8ic@c9?S)_5faAS zRVZ~9>SLo6w#HO`H?J|4f)5MYfLvu9dP_*{EdM!hbKD-GXj-(}r;tUA2HL&>OXaxw zcesse3O+8IhSj5OfBWO=+oLAZM5N6C6oU9n1#$7`VPE8;v-+3U-HtmF{6DaYK8P&A zj7(dOL0UnH9L`3MjvF;^^e}DayB5JymqtkZIQ2yx??s_)2n`26^qnTdsl77(yQB4= z@h}{|@AvBH!>Mfqxk$it4i!EE(58;CI|({}=ZL6|#Api4P0^h)M+6yBpj^}A;Ui<~|hTLX!6u&m| zOo(-9lH;NSkC{GhsXokc3$LO&!n!PH97g7yoRO4dJ&T_punt1g`(k_AY3S{-_^qZX z_$bgF$74Yp^G$1A0cuA;x&!z^3QZ$vB-%*6nA49q5<#tgU;SbN{Sh`CFu07nx)pG<6bhqx1%&d-{ z!t9Tp0l=KP_bb9(`LSxdcveixW|32BAzU~K^my&G=$t{PXIui?tUU;31^Q23%e6-Qc~$ojXGL z`9dlsojV@Kv1|Q}TPQPE)%);9zKqM26u}p?W zF^{nJhJ<4hVMh;Ckq z2x#7>y1!L5-*?}?nwO@;09+z3sD(J_LSe?wpFbn^R&D*ijPvJ<#r+i#W?TX7$(pE^ z-UOa}qF?v1``_#(KhBl!Ywqdcd7F034Oob;HZJ0d(P( z+3GFx=I;G?O#0T@9^MJ}*H)By2E2JGmjLuF(?!m-ygWT4W8-YNO>VOGJJuh zOf=)sdG8p3Myd(SP1qHqSMw@({vqHwCtG$eS&-;hHO);pAmf@^_wudKa(Q#;;SDTD z=Op=mX8;|&(&1;&xn_-yPV_dP-H}qWDx5Z(u6*w72`m88Hy=9zY!iH1FDhK~XW@+C zH=sjMY#(!_)z{nGecyRL@&CWSw_<&IoHi~)NUCiFp0MIG-%d7secaV|Nl83ihDT>5 zodX7Q&-q;7G%)aR72x?POYfdJA|aC8WeRjbadi`y+Q&!T`i~HYryQLDJO_mH9Pmh$ z0H>vK9Q@~x@rihMX?Cjuizt~OU}JfLtnKqhoeq=byA+R3YEJ~Z>?C^xf#QlOK5E7lWs(pUXO@geCy%%#nQn diff --git a/scenario/__init__.py b/scenario/__init__.py index a7c9d20fd..7c297c3c6 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +from scenario.capture_events import capture_events from scenario.context import Context -from scenario.emitted_events import capture_events, emitted_events +from scenario.pytest_plugin import emitted_events # noqa: F401 from scenario.runtime import trigger # noqa: F401 from scenario.state import ( Address, @@ -28,7 +29,6 @@ ) __all__ = [ - emitted_events, capture_events, Context, StateValidationError, diff --git a/scenario/emitted_events.py b/scenario/capture_events.py similarity index 95% rename from scenario/emitted_events.py rename to scenario/capture_events.py index bc6980254..9d53a591f 100644 --- a/scenario/emitted_events.py +++ b/scenario/capture_events.py @@ -6,7 +6,6 @@ from contextlib import contextmanager from typing import ContextManager, List, Type, TypeVar -import pytest from ops.framework import ( CommitEvent, EventBase, @@ -94,9 +93,3 @@ def _wrapped_reemit(self): Framework._emit = _real_emit # type: ignore Framework.reemit = _real_reemit # type: ignore - - -@pytest.fixture() -def emitted_events(): - with capture_events() as captured: - yield captured diff --git a/scenario/mocking.py b/scenario/mocking.py index a1f0b0bb1..e4fb361db 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -135,7 +135,7 @@ def relation_list(self, relation_id: int) -> Tuple[str]: if isinstance(relation, PeerRelation): return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) return tuple( - f"{relation._remote_app_name}/{unit_id}" + f"{relation.remote_app_name}/{unit_id}" for unit_id in relation._remote_unit_ids ) diff --git a/scenario/pytest_plugin.py b/scenario/pytest_plugin.py new file mode 100644 index 000000000..eb97a61f9 --- /dev/null +++ b/scenario/pytest_plugin.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import pytest + +from scenario import capture_events + + +@pytest.fixture() +def emitted_events(): + with capture_events() as captured: + yield captured diff --git a/scenario/runtime.py b/scenario/runtime.py index dcba7d010..6e355aaf2 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -186,7 +186,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if isinstance(relation, PeerRelation): remote_app_name = self._app_name else: - remote_app_name = relation._remote_app_name + remote_app_name = relation.remote_app_name env.update( { "JUJU_RELATION": relation.endpoint, diff --git a/scenario/sequences.py b/scenario/sequences.py index aaeaa6806..f6fb2e78d 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -98,7 +98,7 @@ def check_builtin_sequences( template_state: State = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, -): +) -> object: """Test that all the builtin startup and teardown events can fire without errors. This will play both scenarios with and without leadership, and raise any exceptions. diff --git a/scenario/state.py b/scenario/state.py index 18f2ea3b0..30b67df6f 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -229,11 +229,6 @@ def _databags(self): yield self.local_app_data yield self.local_unit_data - @property - def _remote_app_name(self) -> str: - """Who is on the other end of this relation?""" - raise NotImplementedError() - @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" @@ -399,26 +394,25 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): - # todo: consider renaming them to primary_*_data remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) - # app name and ID of the primary that *this unit* is attached to. - primary_app_name: str = "remote" - primary_id: int = 0 - - @property - def _remote_app_name(self) -> str: - """Who is on the other end of this relation?""" - return self.primary_app_name + # app name and ID of the remote unit that *this unit* is attached to. + remote_app_name: str = "remote" + remote_unit_id: int = 0 @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" - return (self.primary_id,) + return (self.remote_unit_id,) - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: # noqa: U100 + def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: """Return the databag for some remote unit ID.""" + if unit_id is not self.remote_unit_id: + raise ValueError( + f"invalid unit id ({unit_id}): subordinate relation only has one " + f"remote and that has id {self.remote_unit_id}", + ) return self.remote_unit_data @property @@ -430,8 +424,8 @@ def _databags(self): yield self.remote_unit_data @property - def primary_name(self) -> str: - return f"{self.primary_app_name}/{self.primary_id}" + def remote_unit_name(self) -> str: + return f"{self.remote_app_name}/{self.remote_unit_id}" @dataclasses.dataclass(frozen=True) @@ -448,12 +442,6 @@ def _databags(self): yield self.local_unit_data yield from self.peers_data.values() - @property - def _remote_app_name(self) -> str: - """Who is on the other end of this relation?""" - # surprise! It's myself. - raise ValueError("peer relations don't quite have a remote end.") - @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 40b684030..6970d7130 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -279,10 +279,10 @@ def test_trigger_sub_relation(mycharm): } sub1 = SubordinateRelation( - "foo", remote_unit_data={"1": "2"}, primary_app_name="primary1" + "foo", remote_unit_data={"1": "2"}, remote_app_name="primary1" ) sub2 = SubordinateRelation( - "foo", remote_unit_data={"3": "4"}, primary_app_name="primary2" + "foo", remote_unit_data={"3": "4"}, remote_app_name="primary2" ) def post_event(charm: CharmBase): From d210d7aafbcb66530490498990bd914c85dfe962 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 May 2023 13:58:54 +0200 Subject: [PATCH 247/546] added secret docs --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 91718d9b6..fde144477 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,61 @@ def test_pebble_exec(): ) ``` +# Secrets + +Scenario has secrets. Here's how you use them. + +```python +from scenario import State, Secret + +state = State( + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}} + ) + ] +) +``` + +The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping from revision numbers (integers) to a str:str dict representing the payload of the revision. + +By default, the secret is not owned by **this charm** nor is it granted to it. +Therefore, if charm code attempted to get that secret revision, it would get a permission error: we didn't grant it to this charm, nor we specified that the secret is owned by it. + +To specify a secret owned by this unit (or app): +```python +from scenario import State, Secret + +state = State( + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + owner='unit', # or 'app' + remote_grants = {0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 + ) + ] +) +``` + +To specify a secret owned by some other application and give this unit (or app) access to it: +```python +from scenario import State, Secret + +state = State( + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + # owner=None, which is the default + granted="unit", # or "app", + revision=0, # the revision that this unit (or app) is currently tracking + ) + ] +) +``` + # Deferred events Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for From fd78972a492962c04f12273343e3fae69fa75097 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 1 Jun 2023 10:44:15 +0200 Subject: [PATCH 248/546] ignored u100 --- scenario/scripts/state_apply.py | 41 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index 564a44822..db3c486f6 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -29,6 +29,12 @@ logger = logging.getLogger("snapshot") +def set_relations(relations: Iterable[Relation]) -> List[str]: # noqa: U100 + logger.info("preparing relations...") + logger.warning("set_relations not implemented yet") + return [] + + def set_status(status: Status) -> List[str]: logger.info("preparing status...") cmds = [] @@ -40,37 +46,33 @@ def set_status(status: Status) -> List[str]: return cmds -def set_relations(relations: Iterable[Relation]) -> List[str]: - logger.info("preparing relations...") - logger.warning("set_relations not implemented yet") - return [] - - -def set_config(config: Dict[str, str]) -> List[str]: +def set_config(config: Dict[str, str]) -> List[str]: # noqa: U100 logger.info("preparing config...") logger.warning("set_config not implemented yet") return [] -def set_containers(containers: Iterable[Container]) -> List[str]: +def set_containers(containers: Iterable[Container]) -> List[str]: # noqa: U100 logger.info("preparing containers...") logger.warning("set_containers not implemented yet") return [] -def set_secrets(secrets: Iterable[Secret]) -> List[str]: +def set_secrets(secrets: Iterable[Secret]) -> List[str]: # noqa: U100 logger.info("preparing secrets...") logger.warning("set_secrets not implemented yet") return [] -def set_deferred_events(deferred_events: Iterable[DeferredEvent]) -> List[str]: +def set_deferred_events( + deferred_events: Iterable[DeferredEvent], # noqa: U100 +) -> List[str]: logger.info("preparing deferred_events...") logger.warning("set_deferred_events not implemented yet") return [] -def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: +def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: # noqa: U100 logger.info("preparing stored_state...") logger.warning("set_stored_state not implemented yet") return [] @@ -87,7 +89,7 @@ def exec_in_unit(target: JujuUnitName, model: str, cmds: List[str]): raise StateApplyError( f"Failed to apply state: process exited with {e.returncode}; " f"stdout = {e.stdout}; " - f"stderr = {e.stderr}." + f"stderr = {e.stderr}.", ) @@ -101,7 +103,7 @@ def run_commands(cmds: List[str]): raise StateApplyError( f"Failed to apply state: process exited with {e.returncode}; " f"stdout = {e.stdout}; " - f"stderr = {e.stderr}." + f"stderr = {e.stderr}.", ) @@ -110,9 +112,9 @@ def _state_apply( state: State, model: Optional[str] = None, include: str = None, - include_juju_relation_data=False, - push_files: Dict[str, List[Path]] = None, - snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, + include_juju_relation_data=False, # noqa: U100 + push_files: Dict[str, List[Path]] = None, # noqa: U100 + snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, # noqa: U100 ): """see state_apply's docstring""" logger.info("Starting state-apply...") @@ -122,7 +124,7 @@ def _state_apply( except InvalidTargetUnitName: logger.critical( f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`." + f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.", ) sys.exit(1) @@ -164,7 +166,10 @@ def state_apply( "the same you would obtain by running snapshot.", ), model: Optional[str] = typer.Option( - None, "-m", "--model", help="Which model to look at." + None, + "-m", + "--model", + help="Which model to look at.", ), include: str = typer.Option( "scrkSdt", From b6b4e66670b16861a815fce9cd322be74cadd83b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 5 Jun 2023 14:03:24 +0200 Subject: [PATCH 249/546] actions --- scenario/capture_events.py | 2 + scenario/consistency_checker.py | 60 ++++++++++++++++++ scenario/mocking.py | 42 ++++++++---- scenario/state.py | 79 +++++++++++++++++++---- tests/test_consistency_checker.py | 36 +++++++++++ tests/test_e2e/test_actions.py | 102 ++++++++++++++++++++++++++++++ tests/test_e2e/test_pebble.py | 57 +++++++++++++++++ 7 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 tests/test_e2e/test_actions.py diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 9d53a591f..86abe3e9b 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -53,6 +53,8 @@ def _wrapped_emit(self, evt): return _real_emit(self, evt) if isinstance(evt, allowed_types): + # dump/undump the event to ensure any custom attributes are (re)set by restore() + evt.restore(evt.snapshot()) captured.append(evt) return _real_emit(self, evt) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index e1535f81d..3d0e6c74c 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -1,6 +1,8 @@ import os from collections import Counter +from collections.abc import Sequence from itertools import chain +from numbers import Number from typing import TYPE_CHECKING, Iterable, NamedTuple, Tuple from scenario.runtime import InconsistentScenarioError @@ -127,6 +129,64 @@ def check_event_consistency( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", ) + + if event._is_action_event: + action = event.action + if not action: + errors.append( + "cannot construct a workload event without the container instance. " + "Please pass one.", + ) + else: + if not event.name.startswith(normalize_name(action.name)): + errors.append( + f"action event should start with action name. {event.name} does " + f"not start with {action.name}.", + ) + if action.name not in charm_spec.actions: + errors.append( + f"action event {event.name} refers to action {action.name} " + f"which is not declared in the charm metadata (actions.yaml).", + ) + to_python_type = { + "string": str, + "boolean": bool, + "number": Number, + "array": Sequence, + "object": dict, + } + expected_param_type = {} + for par_name, par_spec in ( + charm_spec.actions[action.name].get("params", {}).items() + ): + if value := par_spec.get("type"): + try: + expected_param_type[par_name] = to_python_type[value] + except KeyError: + warnings.append( + f"unknown data type declared for parameter {par_name}: type={value}. " + f"Cannot consistency-check.", + ) + else: + errors.append( + f"action parameter {par_name} has no type. " + f"Charmcraft will be unhappy about this. ", + ) + + for provided_param_name, provided_param_value in action.params.items(): + if expected_type := expected_param_type.get(provided_param_name): + if not isinstance(provided_param_value, expected_type): + errors.append( + f"param {provided_param_name} is of type {type(provided_param_value)}: " + f"expecting {expected_type}", + ) + + else: + errors.append( + f"param {provided_param_name} is not a valid parameter for {action.name}: " + "missing from action specification", + ) + return Results(errors, warnings) diff --git a/scenario/mocking.py b/scenario/mocking.py index e4fb361db..39f409a0f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -4,10 +4,15 @@ import datetime import random from io import StringIO -from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union from ops import pebble -from ops.model import SecretInfo, SecretRotate, _ModelBackend +from ops.model import ( + SecretInfo, + SecretRotate, + _format_action_result_dict, + _ModelBackend, +) from ops.pebble import Client, ExecError from ops.testing import _TestingPebbleClient @@ -15,6 +20,7 @@ from scenario.state import PeerRelation if TYPE_CHECKING: + from scenario.state import Action from scenario.state import Container as ContainerSpec from scenario.state import ( Event, @@ -80,6 +86,14 @@ def _get_relation_by_id( except StopIteration as e: raise RuntimeError(f"Not found: relation with id={rel_id}.") from e + def _get_action( + self, + ) -> "Action": + action = self._event.action + if not action: + raise RuntimeError("not in the context of an action event") + return action + def _get_secret(self, id=None, label=None): # cleanup id: if id and id.startswith("secret:"): @@ -299,22 +313,26 @@ def relation_remote_app_name(self, relation_id: int): relation = self._get_relation_by_id(relation_id) return relation.remote_app_name - # TODO: - def action_set(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("action_set") + def action_set(self, results: Dict[str, Any]): + action = self._get_action() + # let ops validate the results dict + _format_action_result_dict(results) + # but then we will store it in its unformatted, original form + action._set_results(results) + + def action_fail(self, message: str = ""): + self._get_action()._set_failed(message) - def action_fail(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("action_fail") + def action_log(self, message: str): + self._get_action()._log_message(message) - def action_log(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("action_log") + def action_get(self): + return self._get_action().params + # TODO: def storage_add(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("storage_add") - def action_get(self): - raise NotImplementedError("action_get") - def resource_get(self, *args, **kwargs): # noqa: U100 raise NotImplementedError("resource_get") diff --git a/scenario/state.py b/scenario/state.py index 30b67df6f..855e77dfa 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -29,6 +29,7 @@ PathLike = Union[str, Path] AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] + AnyJson = Union[str, bool, dict, int, float, list] logger = scenario_logger.getChild("state") @@ -36,6 +37,9 @@ CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS" BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" + +ACTION_EVENT_SUFFIX = "_action" +PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", @@ -971,6 +975,9 @@ class Event(_DCBase): # if this is a workload (container) event, the container it refers to container: Optional[Container] = None + # if this is an action event, the Action instance + action: Optional["Action"] = None + # todo add other meta for # - secret events # - pebble? @@ -996,6 +1003,11 @@ def _is_relation_event(self) -> bool: """Whether the event name indicates that this is a relation event.""" return any(self.name.endswith(suffix) for suffix in RELATION_EVENTS_SUFFIX) + @property + def _is_action_event(self) -> bool: + """Whether the event name indicates that this is a relation event.""" + return self.name.endswith(ACTION_EVENT_SUFFIX) + @property def _is_secret_event(self) -> bool: """Whether the event name indicates that this is a secret event.""" @@ -1037,24 +1049,21 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): charm_spec.meta.get("peers", ()), ): relation_name = relation_name.replace("-", "_") - builtins.append(relation_name + "_relation_created") - builtins.append(relation_name + "_relation_joined") - builtins.append(relation_name + "_relation_changed") - builtins.append(relation_name + "_relation_departed") - builtins.append(relation_name + "_relation_broken") + for relation_evt_suffix in RELATION_EVENTS_SUFFIX: + builtins.append(relation_name + relation_evt_suffix) for storage_name in charm_spec.meta.get("storages", ()): storage_name = storage_name.replace("-", "_") - builtins.append(storage_name + "_storage_attached") - builtins.append(storage_name + "_storage_detaching") + for storage_evt_suffix in STORAGE_EVENTS_SUFFIX: + builtins.append(storage_name + storage_evt_suffix) for action_name in charm_spec.actions or (): action_name = action_name.replace("-", "_") - builtins.append(action_name + "_action") + builtins.append(action_name + ACTION_EVENT_SUFFIX) for container_name in charm_spec.meta.get("containers", ()): container_name = container_name.replace("-", "_") - builtins.append(container_name + "_pebble_ready") + builtins.append(container_name + PEBBLE_READY_EVENT_SUFFIX) return event_name in builtins @@ -1082,10 +1091,6 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: } elif self._is_relation_event: - if not self.relation: - raise ValueError( - "this is a relation event; expected relation attribute", - ) # this is a RelationEvent. The snapshot: snapshot_data = { "relation_name": self.relation.endpoint, @@ -1102,6 +1107,54 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: ) +@dataclasses.dataclass(frozen=True) +class Action(_DCBase): + name: str + + params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) + + _results: Dict[str, Any] = None + _logs: List[str] = dataclasses.field(default_factory=list) + _failure_message: str = "" + + @property + def results(self) -> Dict[str, Any]: + """Read-only: action results as set by the charm.""" + return self._results + + @property + def logs(self) -> List[str]: + """Read-only: action logs as set by the charm.""" + return self._logs + + @property + def failed(self) -> bool: + """Read-only: action failure as set by the charm.""" + return bool(self._failure_message) + + @property + def failure_message(self) -> str: + """Read-only: action failure as set by the charm.""" + return self._failure_message + + @property + def event(self) -> Event: + """Helper to generate an action event from this action.""" + return Event(self.name + ACTION_EVENT_SUFFIX, action=self) + + def _set_results(self, results: Dict[str, Any]): + # bypass frozen dataclass + object.__setattr__(self, "_results", results) + + def _set_failed(self, message: str): + # bypass frozen dataclass + object.__setattr__(self, "_failure_message", message) + + def _log_message(self, message: str): + # bypass frozen dataclass + object.__setattr__(self, "_logs", self._logs + [message]) + + def deferred( event: Union[str, Event], handler: Callable, diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 815264109..9b9b92b11 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -5,6 +5,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, + Action, Container, Event, PeerRelation, @@ -223,3 +224,38 @@ def test_container_pebble_evt_consistent(): container.pebble_ready_event, _CharmSpec(MyCharm, {"containers": {"foo-bar-baz": {}}}), ) + + +def test_action_name(): + action = Action("foo", params={"bar": "baz"}) + + assert_consistent( + State(), + action.event, + _CharmSpec( + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}} + ), + ) + assert_inconsistent( + State(), + Event("box_action", action=action), + _CharmSpec(MyCharm, meta={}, actions={"foo": {}}), + ) + + +def test_action_params_type(): + action = Action("foo", params={"bar": "baz"}) + assert_consistent( + State(), + action.event, + _CharmSpec( + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}} + ), + ) + assert_inconsistent( + State(), + action.event, + _CharmSpec( + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "boolean"}}}} + ), + ) diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py new file mode 100644 index 000000000..9ffc61f71 --- /dev/null +++ b/tests/test_e2e/test_actions.py @@ -0,0 +1,102 @@ +import pytest +from ops.charm import CharmBase +from ops.framework import Framework + +from scenario import trigger +from scenario.state import Action, Event, Network, Relation, State, _CharmSpec + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + _evt_handler = None + + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + if handler := self._evt_handler: + handler(event) + + return MyCharm + + +@pytest.mark.parametrize("baz_value", (True, False)) +def test_action_event(mycharm, baz_value, emitted_events): + trigger( + State(), + Action("foo", params={"baz": baz_value, "bar": 10}).event, + mycharm, + meta={"name": "foo"}, + actions={ + "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} + }, + ) + + evt = emitted_events[0] + + assert evt.params["bar"] == 10 + assert evt.params["baz"] is baz_value + + +@pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) +def test_action_event_results_invalid(mycharm, res_value): + def handle_evt(charm: CharmBase, evt: Event): + with pytest.raises((TypeError, AttributeError)): + evt.set_results(res_value) + + mycharm._evt_handler = handle_evt + + action = Action("foo") + trigger( + State(), + action.event, + mycharm, + meta={"name": "foo"}, + actions={"foo": {}}, + ) + + +@pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) +def test_action_event_results_valid(mycharm, res_value): + def handle_evt(charm: CharmBase, evt: Event): + evt.set_results(res_value) + + mycharm._evt_handler = handle_evt + + action = Action("foo") + trigger( + State(), + action.event, + mycharm, + meta={"name": "foo"}, + actions={"foo": {}}, + ) + + assert action.results == res_value + + +@pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) +def test_action_event_outputs(mycharm, res_value): + def handle_evt(charm: CharmBase, evt: Event): + evt.set_results({"my-res": res_value}) + evt.log("log1") + evt.log("log2") + evt.fail("failed becozz") + + mycharm._evt_handler = handle_evt + + action = Action("foo") + trigger( + State(), + action.event, + mycharm, + meta={"name": "foo"}, + actions={"foo": {}}, + ) + + assert action.failed + assert action.failure_message == "failed becozz" + assert action.logs == ["log1", "log2"] diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index a5e922f4b..d9b16728d 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -280,3 +280,60 @@ def callback(self: CharmBase): assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED + + +@pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus) +def test_pebble_restart_replan(charm_cls, starting_service_status): + def callback(self: CharmBase): + foo = self.unit.get_container("foo") + foo.restart("fooserv") + foo.add_layer( + "bar", + { + "services": {"barserv": {"env": {"qux": "liz"}}}, + }, + combine=True, + ) + + # we've touched barserv: that should be started as well + foo.replan() + + for service_name in ("fooserv", "barserv"): + serv = foo.get_services(service_name)[service_name] + assert serv.startup == ServiceStartup.ENABLED + assert serv.current == ServiceStatus.ACTIVE + + container = Container( + name="foo", + can_connect=True, + layers={ + "foo": pebble.Layer( + { + "summary": "bla", + "description": "deadbeefy", + "services": { + "fooserv": {"startup": "enabled"}, + "barserv": {"startup": "enabled"}, + }, + } + ) + }, + service_status={ + "fooserv": pebble.ServiceStatus.ACTIVE, + "barserv": pebble.ServiceStatus.ACTIVE, + }, + ) + + out = trigger( + State(containers=[container]), + charm_type=charm_cls, + meta={"name": "foo", "containers": {"foo": {}}}, + event=container.pebble_ready_event, + post_event=callback, + ) + + serv = lambda name, obj: pebble.Service(name, raw=obj) + container = out.containers[0] + + assert not container.services["fooserv"].restarted + assert container.services["barserv"].restarted From 34565e7d5de2209c80cedcd90efdee3aa4d17700 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 5 Jun 2023 15:15:29 +0200 Subject: [PATCH 250/546] refactored ctxvar --- README.md | 55 ++++++++++++++++++++++++++++---- scenario/__init__.py | 4 ++- scenario/mocking.py | 39 ++++++++++++++--------- scenario/outputs.py | 47 ++++++++++++++++++++++++++++ scenario/pytest_plugin.py | 14 +++++++++ scenario/runtime.py | 15 +++++++++ scenario/state.py | 43 +++---------------------- tests/test_e2e/test_actions.py | 8 ++--- tests/test_e2e/test_pebble.py | 57 ---------------------------------- 9 files changed, 162 insertions(+), 120 deletions(-) create mode 100644 scenario/outputs.py diff --git a/README.md b/README.md index fde144477..e2bb358a2 100644 --- a/README.md +++ b/README.md @@ -306,10 +306,10 @@ argument. Also, it talks in terms of `primary`: from scenario.state import SubordinateRelation relation = SubordinateRelation( - endpoint="peers", - remote_unit_data={"foo": "bar"}, - remote_app_name="zookeeper", - remote_unit_id=42 + endpoint="peers", + remote_unit_data={"foo": "bar"}, + remote_app_name="zookeeper", + remote_unit_id=42 ) relation.remote_unit_name # "zookeeper/42" ``` @@ -516,9 +516,9 @@ state = State( ) ``` -The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping from revision numbers (integers) to a str:str dict representing the payload of the revision. +The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping from revision numbers (integers) to a str:str dict representing the payload of the revision. -By default, the secret is not owned by **this charm** nor is it granted to it. +By default, the secret is not owned by **this charm** nor is it granted to it. Therefore, if charm code attempted to get that secret revision, it would get a permission error: we didn't grant it to this charm, nor we specified that the secret is owned by it. To specify a secret owned by this unit (or app): @@ -554,6 +554,49 @@ state = State( ) ``` +# Actions + +How to test actions with scenario: + +```python +from scenario import Action, Context, State +from charm import MyCharm + +def test_backup_action(): + # define an action + action = Action('do_backup') + ctx = Context(MyCharm) + + # obtain an Event from the action and fire it in the context on a state of your choosing. + + # If you didn't declare do_backup in the charm's `actions.yaml`, the `ConsistencyChecker` will slap you on both wrists + # and refuse to proceed. + ctx.run(action.event, State()) + + # you can assert action results, logs, failure using the action.output interface + assert action.output.results == {'foo': 'bar'} + assert action.output.logs == {'foo': 'bar'} + assert action.output.failed + assert action.output.failure_message == 'boo-hoo' +``` + +It doesn't quite make sense to have this action output data in the `State`, hence the choice to make it an external object attached to a global context var: `scenario.outputs.ACTION_OUTPUT`. +If you are using pytest, the `action_output` fixture will set up and tear down that contextvar for you. So you can also write assertions like so: + + +```python +def test_backup_action(action_output): + # ... run test as above + assert action_output.results == {'foo': 'bar'} + assert action_output.logs == {'foo': 'bar'} + assert action_output.failed + assert action_output.failure_message == 'boo-hoo' +``` + +If you are not using pytest, you'll need to `scenario.outputs.ACTION_OUTPUT.set(ActionOutput())` before you can run your test. +And don't forget to clean up after yourself! + + # Deferred events Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for diff --git a/scenario/__init__.py b/scenario/__init__.py index 7c297c3c6..e15350430 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -3,9 +3,10 @@ # See LICENSE file for licensing details. from scenario.capture_events import capture_events from scenario.context import Context -from scenario.pytest_plugin import emitted_events # noqa: F401 +from scenario.pytest_plugin import action_output, emitted_events # noqa: F401 from scenario.runtime import trigger # noqa: F401 from scenario.state import ( + Action, Address, BindAddress, Container, @@ -29,6 +30,7 @@ ) __all__ = [ + Action, capture_events, Context, StateValidationError, diff --git a/scenario/mocking.py b/scenario/mocking.py index 39f409a0f..64b5a272b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -17,10 +17,10 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger +from scenario.outputs import ACTION_OUTPUT from scenario.state import PeerRelation if TYPE_CHECKING: - from scenario.state import Action from scenario.state import Container as ContainerSpec from scenario.state import ( Event, @@ -67,6 +67,11 @@ def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec"): self._event = event self._charm_spec = charm_spec + @property + def _action_output(self): + """This should only be accessed if we are in an action event context.""" + return ACTION_OUTPUT.get() + def get_pebble(self, socket_path: str) -> "Client": return _MockPebbleClient( socket_path=socket_path, @@ -86,14 +91,6 @@ def _get_relation_by_id( except StopIteration as e: raise RuntimeError(f"Not found: relation with id={rel_id}.") from e - def _get_action( - self, - ) -> "Action": - action = self._event.action - if not action: - raise RuntimeError("not in the context of an action event") - return action - def _get_secret(self, id=None, label=None): # cleanup id: if id and id.startswith("secret:"): @@ -314,20 +311,34 @@ def relation_remote_app_name(self, relation_id: int): return relation.remote_app_name def action_set(self, results: Dict[str, Any]): - action = self._get_action() + if not self._event.action: + raise RuntimeError( + "not in the context of an action event: cannot action-set", + ) # let ops validate the results dict _format_action_result_dict(results) # but then we will store it in its unformatted, original form - action._set_results(results) + self._action_output.results = results def action_fail(self, message: str = ""): - self._get_action()._set_failed(message) + if not self._event.action: + raise RuntimeError( + "not in the context of an action event: cannot action-fail", + ) + self._action_output.failure_message = message def action_log(self, message: str): - self._get_action()._log_message(message) + if not self._event.action: + raise RuntimeError( + "not in the context of an action event: cannot action-log", + ) + + self._action_output.logs.append(message) def action_get(self): - return self._get_action().params + if action := self._event.action: + return action.params + raise RuntimeError("not in the context of an action event: cannot action-get") # TODO: def storage_add(self, *args, **kwargs): # noqa: U100 diff --git a/scenario/outputs.py b/scenario/outputs.py new file mode 100644 index 000000000..737ec83db --- /dev/null +++ b/scenario/outputs.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +""" +Output types. +Objects that wrap trigger outcomes which don't quite fit in State, as they can't be used as +input for the next trigger. +""" + +from contextvars import ContextVar + +# If you are using pytest, the scenario.pytest_plugin.action_output fixture should take care +# of managing this variable and set/reset it once per test. +# If you are not using pytest, then you'll need to .set() this var to a new ActionOutput +# instance before each Context.run() and reset it when you're done. +ACTION_OUTPUT = ContextVar("ACTION_OUTPUT") + + +class ActionOutput: + """Object wrapping the results of executing an action.""" + + def __init__(self): + self.logs = [] + self.results = {} + self.failure_message = "" + + @property + def failed(self): + return bool(self.failure_message) + + def __enter__(self): + self.logs = [] + self.results = {} + self.failure_message = "" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 + pass + + @staticmethod + def is_set() -> bool: + try: + ACTION_OUTPUT.get() + except LookupError: + return False + return True diff --git a/scenario/pytest_plugin.py b/scenario/pytest_plugin.py index eb97a61f9..95203c713 100644 --- a/scenario/pytest_plugin.py +++ b/scenario/pytest_plugin.py @@ -1,13 +1,27 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import logging import pytest from scenario import capture_events +from scenario.outputs import ACTION_OUTPUT, ActionOutput + +logger = logging.getLogger(__name__) @pytest.fixture() def emitted_events(): with capture_events() as captured: yield captured + + +@pytest.fixture(autouse=True) +def action_output(): + logger.info("setting up action context") + ao = ActionOutput() + tok = ACTION_OUTPUT.set(ao) + yield ao + logger.info("resetting action context") + ACTION_OUTPUT.reset(tok) diff --git a/scenario/runtime.py b/scenario/runtime.py index 6e355aaf2..0b1aec7b8 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -25,6 +25,7 @@ from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError +from scenario.outputs import ACTION_OUTPUT, ActionOutput from scenario.state import DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: @@ -356,6 +357,16 @@ def exec( logger.info(" - initializing storage") self._initialize_storage(state, temporary_charm_root) + action_token = None + if not ActionOutput.is_set(): + logger.warning( + "ActionOutput is not initialized; " + "Runtime.exec called outside of a pytest scope. " + "We'll set up one for you, but it will likely be unretrievable. " + "Please manage scenario.outputs.ACTION_OUTPUT yourself.", + ) + action_token = ACTION_OUTPUT.set(ActionOutput()) + logger.info(" - preparing env") env = self._get_event_env( state=state, @@ -391,6 +402,10 @@ def exec( logger.info(" - Clearing env") self._cleanup_env(env) + if action_token: + logger.info(" - Discarding action output context") + ACTION_OUTPUT.reset(action_token) + logger.info(" - closing storage") output_state = self._close_storage(output_state, temporary_charm_root) diff --git a/scenario/state.py b/scenario/state.py index 855e77dfa..3ad9a2cdc 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -19,6 +19,7 @@ from scenario.fs_mocks import _MockFileSystem, _MockStorageMount from scenario.logger import logger as scenario_logger +from scenario.outputs import ACTION_OUTPUT, ActionOutput if typing.TYPE_CHECKING: try: @@ -844,9 +845,6 @@ class State(_DCBase): deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) stored_state: List["StoredState"] = dataclasses.field(default_factory=dict) - # todo: - # actions? - def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: @@ -1113,46 +1111,15 @@ class Action(_DCBase): params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) - _results: Dict[str, Any] = None - _logs: List[str] = dataclasses.field(default_factory=list) - _failure_message: str = "" - - @property - def results(self) -> Dict[str, Any]: - """Read-only: action results as set by the charm.""" - return self._results - - @property - def logs(self) -> List[str]: - """Read-only: action logs as set by the charm.""" - return self._logs - - @property - def failed(self) -> bool: - """Read-only: action failure as set by the charm.""" - return bool(self._failure_message) - - @property - def failure_message(self) -> str: - """Read-only: action failure as set by the charm.""" - return self._failure_message - @property def event(self) -> Event: """Helper to generate an action event from this action.""" return Event(self.name + ACTION_EVENT_SUFFIX, action=self) - def _set_results(self, results: Dict[str, Any]): - # bypass frozen dataclass - object.__setattr__(self, "_results", results) - - def _set_failed(self, message: str): - # bypass frozen dataclass - object.__setattr__(self, "_failure_message", message) - - def _log_message(self, message: str): - # bypass frozen dataclass - object.__setattr__(self, "_logs", self._logs + [message]) + @property + def output(self) -> ActionOutput: + """Helper to access the outputs of this action.""" + return ACTION_OUTPUT.get() def deferred( diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 9ffc61f71..fe800e7ee 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -75,7 +75,7 @@ def handle_evt(charm: CharmBase, evt: Event): actions={"foo": {}}, ) - assert action.results == res_value + assert action.output.results == res_value @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) @@ -97,6 +97,6 @@ def handle_evt(charm: CharmBase, evt: Event): actions={"foo": {}}, ) - assert action.failed - assert action.failure_message == "failed becozz" - assert action.logs == ["log1", "log2"] + assert action.output.failed + assert action.output.failure_message == "failed becozz" + assert action.output.logs == ["log1", "log2"] diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index d9b16728d..a5e922f4b 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -280,60 +280,3 @@ def callback(self: CharmBase): assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED - - -@pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus) -def test_pebble_restart_replan(charm_cls, starting_service_status): - def callback(self: CharmBase): - foo = self.unit.get_container("foo") - foo.restart("fooserv") - foo.add_layer( - "bar", - { - "services": {"barserv": {"env": {"qux": "liz"}}}, - }, - combine=True, - ) - - # we've touched barserv: that should be started as well - foo.replan() - - for service_name in ("fooserv", "barserv"): - serv = foo.get_services(service_name)[service_name] - assert serv.startup == ServiceStartup.ENABLED - assert serv.current == ServiceStatus.ACTIVE - - container = Container( - name="foo", - can_connect=True, - layers={ - "foo": pebble.Layer( - { - "summary": "bla", - "description": "deadbeefy", - "services": { - "fooserv": {"startup": "enabled"}, - "barserv": {"startup": "enabled"}, - }, - } - ) - }, - service_status={ - "fooserv": pebble.ServiceStatus.ACTIVE, - "barserv": pebble.ServiceStatus.ACTIVE, - }, - ) - - out = trigger( - State(containers=[container]), - charm_type=charm_cls, - meta={"name": "foo", "containers": {"foo": {}}}, - event=container.pebble_ready_event, - post_event=callback, - ) - - serv = lambda name, obj: pebble.Service(name, raw=obj) - container = out.containers[0] - - assert not container.services["fooserv"].restarted - assert container.services["barserv"].restarted From a4019cee8ba37c7ca1ff23aea03b9eea30a2669b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 5 Jun 2023 15:18:11 +0200 Subject: [PATCH 251/546] fixed itest --- tests/test_e2e/test_deferred.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index efb804ea3..77666db4f 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -71,7 +71,7 @@ def test_deferred_evt_emitted(mycharm): def test_deferred_relation_event_without_relation_raises(mycharm): - with pytest.raises(ValueError): + with pytest.raises(AttributeError): deferred(event="foo_relation_changed", handler=mycharm._on_event) From 5c079d5a890e2a4f75c73cf8fa0bf5c0db77f04d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 7 Jun 2023 09:55:16 +0200 Subject: [PATCH 252/546] new api --- README.md | 259 +++++++++++-------- pyproject.toml | 5 +- scenario/__init__.py | 9 +- scenario/context.py | 113 +++++++- scenario/mocking.py | 41 +-- scenario/ops_main_mock.py | 3 + scenario/outputs.py | 47 ---- scenario/pytest_plugin.py | 27 -- scenario/runtime.py | 72 ++---- scenario/sequences.py | 2 +- scenario/state.py | 27 +- tests/helpers.py | 72 ++++++ tests/test_e2e/test_actions.py | 83 +++--- tests/test_e2e/test_config.py | 2 +- tests/test_e2e/test_custom_event_triggers.py | 3 +- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_juju_log.py | 10 +- tests/test_e2e/test_network.py | 2 +- tests/test_e2e/test_observers.py | 2 +- tests/test_e2e/test_pebble.py | 5 +- tests/test_e2e/test_play_assertions.py | 9 +- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_rubbish_events.py | 2 +- tests/test_e2e/test_secrets.py | 2 +- tests/test_e2e/test_state.py | 18 +- tests/test_e2e/test_status.py | 54 +++- tests/test_e2e/test_stored_state.py | 2 +- tests/test_e2e/test_vroot.py | 3 +- tests/test_emitted_events_util.py | 4 +- tests/test_plugin.py | 28 -- tests/test_runtime.py | 14 +- 31 files changed, 530 insertions(+), 394 deletions(-) delete mode 100644 scenario/outputs.py delete mode 100644 scenario/pytest_plugin.py create mode 100644 tests/helpers.py diff --git a/README.md b/README.md index e2bb358a2..bea9f127d 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,8 @@ A scenario test consists of three broad steps: - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal APIs - **Assert**: - - verify that the output state is how you expect it to be - - optionally, verify that the delta with the input state is what you expect it to be + - verify that the output state (or the delta with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -153,7 +153,24 @@ def _on_event(self, _event): self.unit.status = BlockedStatus('something went wrong') ``` -You can verify that the charm has followed the expected path by checking the **unit status history** like so: +# Context and State + +Consider the following tests. Suppose we want to verify that while handling a given toplevel juju event: + +- a specific chain of (custom) events was emitted on the charm +- the charm `juju-log`ged these specific strings +- the charm went through this sequence of app/unit statuses (e.g. `maintenance`, then `waiting`, then `active`) + +These types of test have a place in Scenario, but that is not State: the contents of the juju log or the status history +are side effects of executing a charm, but are not persisted in a charm-accessible "state" in any meaningful way. +In other words: those data streams are, from the charm's perspective, write-only. + +As such, they do not belong in `scenario.State` but in `scenario.Context`: the object representing the charm's execution +context. + +## Status history + +You can verify that the charm has followed the expected path by checking the unit/app status history like so: ```python from charm import MyCharm @@ -164,14 +181,18 @@ from scenario import State, Context def test_statuses(): ctx = Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', - State(leader=False)) - assert out.status.unit_history == [ + ctx.run('start', State(leader=False)) + assert ctx.unit_status_history == [ UnknownStatus(), MaintenanceStatus('determining who the ruler is...'), WaitingStatus('checking this is right...'), ActiveStatus("I am ruled"), ] + + assert ctx.app_status_history == [ + UnknownStatus(), + ActiveStatus(""), + ] ``` Note that the current status is not in the **unit status history**. @@ -187,9 +208,96 @@ Unknown (the default status every charm is born with), you will have to pass the from ops.model import ActiveStatus from scenario import State, Status -State(leader=False, status=Status(unit=ActiveStatus('foo'))) +# ... +ctx.run('start', State(status=Status(unit=ActiveStatus('foo')))) +assert ctx.unit_status_history == [ + ActiveStatus('foo'), # now the first status is active: 'foo'! + # ... +] + +``` + +## Workload version history + +Using a similar api to `*_status_history`, you can assert that the charm has set one or more workload versions during a +hook execution: + +```python +from scenario import Context + +# ... +ctx: Context +assert ctx.workload_version_history == ['1', '1.2', '1.5'] +# ... +``` + +## Emitted events + +If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it +can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a +given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The +resulting state, black-box as it is, gives little insight into how exactly it was obtained. + +```python +from scenario import Context +from ops.charm import StartEvent + + +def test_foo(): + ctx = Context(...) + ctx.run('start', ...) + + assert len(ctx.emitted_events) == 1 + assert isinstance(ctx.emitted_events[0], StartEvent) +``` + +### Low-level access: using directly `capture_events` + +If you need more control over what events are captured (or you're not into pytest), you can use directly the context +manager that powers the `emitted_events` fixture: `scenario.capture_events`. +This context manager allows you to intercept any events emitted by the framework. + +Usage: + +```python +from ops.charm import StartEvent, UpdateStatusEvent +from scenario import State, Context, DeferredEvent, capture_events + +with capture_events() as emitted: + ctx = Context(...) + state_out = ctx.run( + "update-status", + State(deferred=[DeferredEvent("start", ...)]) + ) + +# deferred events get reemitted first +assert isinstance(emitted[0], StartEvent) +# the main juju event gets emitted next +assert isinstance(emitted[1], UpdateStatusEvent) +# possibly followed by a tail of all custom events that the main juju event triggered in turn +# assert isinstance(emitted[2], MyFooEvent) +# ... +``` + +You can filter events by type like so: + +```python +from ops.charm import StartEvent, RelationEvent +from scenario import capture_events + +with capture_events(StartEvent, RelationEvent) as emitted: + # capture all `start` and `*-relation-*` events. + pass ``` +Configuration: + +- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(EventBase)`. +- By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if + they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. +- By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by + passing: `capture_events(include_deferred=True)`. + ## Relations You can write scenario tests to verify the shape of relation data: @@ -516,12 +624,15 @@ state = State( ) ``` -The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping from revision numbers (integers) to a str:str dict representing the payload of the revision. +The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping +from revision numbers (integers) to a str:str dict representing the payload of the revision. By default, the secret is not owned by **this charm** nor is it granted to it. -Therefore, if charm code attempted to get that secret revision, it would get a permission error: we didn't grant it to this charm, nor we specified that the secret is owned by it. +Therefore, if charm code attempted to get that secret revision, it would get a permission error: we didn't grant it to +this charm, nor we specified that the secret is owned by it. To specify a secret owned by this unit (or app): + ```python from scenario import State, Secret @@ -531,13 +642,15 @@ state = State( id='foo', contents={0: {'key': 'public'}}, owner='unit', # or 'app' - remote_grants = {0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 + remote_grants={0: {"remote"}} + # the secret owner has granted access to the "remote" app over some relation with ID 0 ) ] ) ``` To specify a secret owned by some other application and give this unit (or app) access to it: + ```python from scenario import State, Secret @@ -556,46 +669,53 @@ state = State( # Actions +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: +## Actions without parameters + ```python -from scenario import Action, Context, State +from scenario import Context, State, ActionOutput from charm import MyCharm + def test_backup_action(): - # define an action - action = Action('do_backup') ctx = Context(MyCharm) - # obtain an Event from the action and fire it in the context on a state of your choosing. - - # If you didn't declare do_backup in the charm's `actions.yaml`, the `ConsistencyChecker` will slap you on both wrists - # and refuse to proceed. - ctx.run(action.event, State()) - - # you can assert action results, logs, failure using the action.output interface - assert action.output.results == {'foo': 'bar'} - assert action.output.logs == {'foo': 'bar'} - assert action.output.failed - assert action.output.failure_message == 'boo-hoo' + # If you didn't declare do_backup in the charm's `actions.yaml`, + # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. + out: ActionOutput = ctx.run_action("do_backup_action", State()) + + # you can assert action results, logs, failure using the ActionOutput interface + assert out.results == {'foo': 'bar'} + assert out.logs == {'foo': 'bar'} + assert out.failure == 'boo-hoo' ``` -It doesn't quite make sense to have this action output data in the `State`, hence the choice to make it an external object attached to a global context var: `scenario.outputs.ACTION_OUTPUT`. -If you are using pytest, the `action_output` fixture will set up and tear down that contextvar for you. So you can also write assertions like so: +## Parametrized Actions +If the action takes parameters, you'll need to instantiate an `Action`. ```python -def test_backup_action(action_output): - # ... run test as above - assert action_output.results == {'foo': 'bar'} - assert action_output.logs == {'foo': 'bar'} - assert action_output.failed - assert action_output.failure_message == 'boo-hoo' -``` +from scenario import Action, Context, State, ActionOutput +from charm import MyCharm -If you are not using pytest, you'll need to `scenario.outputs.ACTION_OUTPUT.set(ActionOutput())` before you can run your test. -And don't forget to clean up after yourself! +def test_backup_action(): + # define an action + action = Action('do_backup', params={'a': 'b'}) + ctx = Context(MyCharm) + + # if the parameters (or their type) don't match what declared in actions.yaml, + # the `ConsistencyChecker` will slap you on the other wrist. + out: ActionOutput = ctx.run_action(action, State()) + + # ... +``` # Deferred events @@ -766,75 +886,6 @@ state = State(stored_state=[ And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on the output side the same as any other bit of state. -# Emitted events - -If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it -can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a -given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The -resulting state, black-box as it is, gives little insight into how exactly it was obtained. - -`scenario`, among many other great things, is also a pytest plugin. It exposes a fixture called `emitted_events` that -you can use like so: - -```python -from scenario import Context -from ops.charm import StartEvent - - -def test_foo(emitted_events): - Context(...).run('start', ...) - - assert len(emitted_events) == 1 - assert isinstance(emitted_events[0], StartEvent) -``` - -## Customizing: capture_events - -If you need more control over what events are captured (or you're not into pytest), you can use directly the context -manager that powers the `emitted_events` fixture: `scenario.capture_events`. -This context manager allows you to intercept any events emitted by the framework. - -Usage: - -```python -from ops.charm import StartEvent, UpdateStatusEvent -from scenario import State, Context, DeferredEvent, capture_events - -with capture_events() as emitted: - ctx = Context(...) - state_out = ctx.run( - "update-status", - State(deferred=[DeferredEvent("start", ...)]) - ) - -# deferred events get reemitted first -assert isinstance(emitted[0], StartEvent) -# the main juju event gets emitted next -assert isinstance(emitted[1], UpdateStatusEvent) -# possibly followed by a tail of all custom events that the main juju event triggered in turn -# assert isinstance(emitted[2], MyFooEvent) -# ... -``` - -You can filter events by type like so: - -```python -from ops.charm import StartEvent, RelationEvent -from scenario import capture_events - -with capture_events(StartEvent, RelationEvent) as emitted: - # capture all `start` and `*-relation-*` events. - pass -``` - -Passing no event types, like: `capture_events()`, is equivalent to `capture_events(EventBase)`. - -By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if -they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. - -By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by -passing: `capture_events(include_deferred=True)`. - # The virtual charm root Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The diff --git a/pyproject.toml b/pyproject.toml index e7f76cf46..f9bcc34da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "3.1" +version = "4.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } @@ -35,9 +35,6 @@ classifiers = [ 'Topic :: Utilities', ] -[project.entry-points.pytest11] -emitted_events = "scenario.pytest_plugin" - [project.urls] "Homepage" = "https://github.com/canonical/ops-scenario" "Bug Tracker" = "https://github.com/canonical/ops-scenario/issues" diff --git a/scenario/__init__.py b/scenario/__init__.py index e15350430..60aea6f61 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,10 +1,7 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from scenario.capture_events import capture_events -from scenario.context import Context -from scenario.pytest_plugin import action_output, emitted_events # noqa: F401 -from scenario.runtime import trigger # noqa: F401 +from scenario.context import ActionOutput, Context from scenario.state import ( Action, Address, @@ -27,12 +24,14 @@ Status, StoredState, SubordinateRelation, + deferred, ) __all__ = [ Action, - capture_events, + ActionOutput, Context, + deferred, StateValidationError, Secret, ParametrizedEvent, diff --git a/scenario/context.py b/scenario/context.py index ce447d019..1b30da875 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,23 +1,36 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, Union +from collections import namedtuple +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union + +from ops import EventBase from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime -from scenario.state import Event, _CharmSpec +from scenario.state import Action, Event, _CharmSpec if TYPE_CHECKING: from pathlib import Path from ops.testing import CharmType - from scenario.state import State + from scenario.state import JujuLogLine, State, Status, _EntityStatus PathLike = Union[str, Path] logger = scenario_logger.getChild("runtime") +ActionOutput = namedtuple("ActionOutput", ("state", "logs", "results", "failure")) + + +class InvalidEventError(RuntimeError): + """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""" + class Context: """Scenario test execution context.""" @@ -72,6 +85,25 @@ def __init__( self.charm_root = charm_root self.juju_version = juju_version + # streaming side effects from running an event + self.juju_log: List["JujuLogLine"] = [] + self.app_status_history: List["_EntityStatus"] = [] + self.unit_status_history: List["_EntityStatus"] = [] + self.workload_version_history: List[str] = [] + self.emitted_events: List[EventBase] = [] + + # ephemeral side effects from running an action + self._action_logs = [] + self._action_results = None + self._action_failure = "" + + def _record_status(self, status: "Status", is_app: bool): + """Record the previous status before a status change.""" + if is_app: + self.app_status_history.append(status.app) + else: + self.unit_status_history.append(status.unit) + def run( self, event: Union["Event", str], @@ -92,19 +124,90 @@ def run( :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. """ + if isinstance(event, str): + event = Event(event) + + if not isinstance(event, Event): + raise InvalidEventError(f"Expected Event | str, got {type(event)}") + + if event._is_action_event: + raise InvalidEventError( + "Cannot Context.run() action events. " + "Use Context.run_action instead.", + ) + + return self._run(event, state=state, pre_event=pre_event, post_event=post_event) + + def run_action( + self, + action: Union["Action", str], + state: "State", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> ActionOutput: + """Trigger a charm execution with an Action 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. Can be a string or an Action instance. + :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). + :arg pre_event: callback to be invoked right before emitting the event on the newly + instantiated charm. Will receive the charm instance as only positional argument. + :arg post_event: callback to be invoked right after emitting the event on the charm. + Will receive the charm instance as only positional argument. + """ + + if isinstance(action, str): + action = Action(action) + + if not isinstance(action, Action): + raise InvalidActionError( + f"Expected Action or action name; got {type(action)}", + ) + + state_out = self._run( + action.event, + state=state, + pre_event=pre_event, + post_event=post_event, + ) + + ao = ActionOutput( + state_out, + self._action_logs, + self._action_results, + self._action_failure, + ) + # reset all action-related state + self._action_logs = [] + self._action_results = None + self._action_failure = "" + + return ao + + def _run( + self, + event: "Event", + state: "State", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + ) -> "State": runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, charm_root=self.charm_root, ) - if isinstance(event, str): - event = Event(event) + if not isinstance(event, Event): + raise InvalidEventError(f"Expected Event, got {type(event)}") return runtime.exec( state=state, event=event, pre_event=pre_event, post_event=post_event, + context=self, ) diff --git a/scenario/mocking.py b/scenario/mocking.py index 64b5a272b..dfa0fc943 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -17,10 +17,10 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.outputs import ACTION_OUTPUT -from scenario.state import PeerRelation +from scenario.state import JujuLogLine, PeerRelation if TYPE_CHECKING: + from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( Event, @@ -61,17 +61,19 @@ def send_signal(self, sig: Union[int, str]): # noqa: U100 class _MockModelBackend(_ModelBackend): - def __init__(self, state: "State", event: "Event", charm_spec: "_CharmSpec"): + def __init__( + self, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + context: "Context", + ): super().__init__() self._state = state self._event = event + self._context = context self._charm_spec = charm_spec - @property - def _action_output(self): - """This should only be accessed if we are in an action event context.""" - return ACTION_OUTPUT.get() - def get_pebble(self, socket_path: str) -> "Client": return _MockPebbleClient( socket_path=socket_path, @@ -174,13 +176,18 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): # setter methods: these can mutate the state. def application_version_set(self, version: str): + if workload_version := self._state.status.workload_version: + # do not record if empty = unset + self._context.workload_version_history.append(workload_version) + self._state.status._update_workload_version(version) def status_set(self, status: str, message: str = "", *, is_app: bool = False): + self._context._record_status(self._state.status, is_app) self._state.status._update_status(status, message, is_app) def juju_log(self, level: str, message: str): - self._state.juju_log.append((level, message)) + self._context.juju_log.append(JujuLogLine(level, message)) def relation_set(self, relation_id: int, key: str, value: str, is_app: bool): relation = self._get_relation_by_id(relation_id) @@ -318,27 +325,29 @@ def action_set(self, results: Dict[str, Any]): # let ops validate the results dict _format_action_result_dict(results) # but then we will store it in its unformatted, original form - self._action_output.results = results + self._context._action_results = results def action_fail(self, message: str = ""): if not self._event.action: raise RuntimeError( "not in the context of an action event: cannot action-fail", ) - self._action_output.failure_message = message + self._context._action_failure = message def action_log(self, message: str): if not self._event.action: raise RuntimeError( "not in the context of an action event: cannot action-log", ) - - self._action_output.logs.append(message) + self._context._action_logs.append(message) def action_get(self): - if action := self._event.action: - return action.params - raise RuntimeError("not in the context of an action event: cannot action-get") + action = self._event.action + if not action: + raise RuntimeError( + "not in the context of an action event: cannot action-get", + ) + return action.params # TODO: def storage_add(self, *args, **kwargs): # noqa: U100 diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 53bfd893c..9bf79c829 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from ops.testing import CharmType + from scenario.context import Context from scenario.state import Event, State, _CharmSpec logger = scenario_logger.getChild("ops_main_mock") @@ -32,6 +33,7 @@ def main( post_event: Optional[Callable[["CharmType"], None]] = None, state: "State" = None, event: "Event" = None, + context: "Context" = None, charm_spec: "_CharmSpec" = None, ): """Set up the charm and dispatch the observed event.""" @@ -43,6 +45,7 @@ def main( model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false state=state, event=event, + context=context, charm_spec=charm_spec, ) debug = "JUJU_DEBUG" in os.environ diff --git a/scenario/outputs.py b/scenario/outputs.py deleted file mode 100644 index 737ec83db..000000000 --- a/scenario/outputs.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -""" -Output types. -Objects that wrap trigger outcomes which don't quite fit in State, as they can't be used as -input for the next trigger. -""" - -from contextvars import ContextVar - -# If you are using pytest, the scenario.pytest_plugin.action_output fixture should take care -# of managing this variable and set/reset it once per test. -# If you are not using pytest, then you'll need to .set() this var to a new ActionOutput -# instance before each Context.run() and reset it when you're done. -ACTION_OUTPUT = ContextVar("ACTION_OUTPUT") - - -class ActionOutput: - """Object wrapping the results of executing an action.""" - - def __init__(self): - self.logs = [] - self.results = {} - self.failure_message = "" - - @property - def failed(self): - return bool(self.failure_message) - - def __enter__(self): - self.logs = [] - self.results = {} - self.failure_message = "" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 - pass - - @staticmethod - def is_set() -> bool: - try: - ACTION_OUTPUT.get() - except LookupError: - return False - return True diff --git a/scenario/pytest_plugin.py b/scenario/pytest_plugin.py deleted file mode 100644 index 95203c713..000000000 --- a/scenario/pytest_plugin.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import logging - -import pytest - -from scenario import capture_events -from scenario.outputs import ACTION_OUTPUT, ActionOutput - -logger = logging.getLogger(__name__) - - -@pytest.fixture() -def emitted_events(): - with capture_events() as captured: - yield captured - - -@pytest.fixture(autouse=True) -def action_output(): - logger.info("setting up action context") - ao = ActionOutput() - tok = ACTION_OUTPUT.set(ao) - yield ao - logger.info("resetting action context") - ACTION_OUTPUT.reset(tok) diff --git a/scenario/runtime.py b/scenario/runtime.py index 0b1aec7b8..8c44dd7c9 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -23,14 +23,15 @@ from ops.framework import _event_regex from ops.storage import SQLiteStorage +from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.outputs import ACTION_OUTPUT, ActionOutput from scenario.state import DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: from ops.testing import CharmType + from scenario.context import Context from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -328,6 +329,7 @@ def exec( self, state: "State", event: "Event", + context: "Context", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -339,6 +341,9 @@ def exec( This will set the environment up and call ops.main.main(). After that it's up to ops. """ + # todo consider forking out a real subprocess and do the mocking by + # mocking hook tool executables + from scenario.consistency_checker import check_consistency # avoid cycles check_consistency(state, event, self._charm_spec, self._juju_version) @@ -350,23 +355,14 @@ def exec( output_state = state.copy() logger.info(" - generating virtual charm root") - with self.virtual_charm_root() as temporary_charm_root: - # todo consider forking out a real subprocess and do the mocking by - # generating hook tool executables - + with ( + self.virtual_charm_root() as temporary_charm_root, + # todo allow customizing capture_events + capture_events() as captured, + ): logger.info(" - initializing storage") self._initialize_storage(state, temporary_charm_root) - action_token = None - if not ActionOutput.is_set(): - logger.warning( - "ActionOutput is not initialized; " - "Runtime.exec called outside of a pytest scope. " - "We'll set up one for you, but it will likely be unretrievable. " - "Please manage scenario.outputs.ACTION_OUTPUT yourself.", - ) - action_token = ACTION_OUTPUT.set(ActionOutput()) - logger.info(" - preparing env") env = self._get_event_env( state=state, @@ -386,6 +382,7 @@ def exec( post_event=post_event, state=output_state, event=event, + context=context, charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type), ), @@ -402,13 +399,11 @@ def exec( logger.info(" - Clearing env") self._cleanup_env(env) - if action_token: - logger.info(" - Discarding action output context") - ACTION_OUTPUT.reset(action_token) - logger.info(" - closing storage") output_state = self._close_storage(output_state, temporary_charm_root) + context.emitted_events.extend(captured) + logger.info("event dispatched. done.") return output_state @@ -457,33 +452,18 @@ def trigger( >>> scenario, State(), (... charm_root=virtual_root) """ - from scenario.state import Event, _CharmSpec - - if isinstance(event, str): - event = Event(event) - - if not any((meta, actions, config)): - logger.debug("Autoloading charmspec...") - spec = _CharmSpec.autoload(charm_type) - else: - if not meta: - meta = {"name": str(charm_type.__name__)} - spec = _CharmSpec( - charm_type=charm_type, - meta=meta, - actions=actions, - config=config, - ) - - runtime = Runtime( - charm_spec=spec, - juju_version=juju_version, - charm_root=charm_root, + logger.warning( + "DEPRECATION NOTICE: scenario.runtime.trigger() is deprecated and " + "will be removed soon; please use the scenario.context.Context api.", ) + from scenario.context import Context - return runtime.exec( - state=state, - event=event, - pre_event=pre_event, - post_event=post_event, + ctx = Context( + charm_type=charm_type, + meta=meta, + actions=actions, + config=config, + charm_root=charm_root, + juju_version=juju_version, ) + return ctx.run(event, state=state, pre_event=pre_event, post_event=post_event) diff --git a/scenario/sequences.py b/scenario/sequences.py index f6fb2e78d..f217bd58f 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -5,7 +5,6 @@ from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union -from scenario import trigger from scenario.logger import logger as scenario_logger from scenario.state import ( ATTACH_ALL_STORAGES, @@ -17,6 +16,7 @@ InjectRelation, State, ) +from tests.helpers import trigger if typing.TYPE_CHECKING: from ops.testing import CharmType diff --git a/scenario/state.py b/scenario/state.py index 3ad9a2cdc..21bd5cd1a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,6 +7,7 @@ import inspect import re import typing +from collections import namedtuple from itertools import chain from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union @@ -19,7 +20,8 @@ from scenario.fs_mocks import _MockFileSystem, _MockStorageMount from scenario.logger import logger as scenario_logger -from scenario.outputs import ACTION_OUTPUT, ActionOutput + +JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) if typing.TYPE_CHECKING: try: @@ -752,11 +754,6 @@ class Status(_DCBase): unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") workload_version: str = "" - # most to least recent statuses; do NOT include the current one. - app_history: List[_EntityStatus] = dataclasses.field(default_factory=list) - unit_history: List[_EntityStatus] = dataclasses.field(default_factory=list) - previous_workload_version: Optional[str] = None - def __post_init__(self): for name in ["app", "unit"]: val = getattr(self, name) @@ -780,7 +777,6 @@ def _update_workload_version(self, new_workload_version: str): # than once per hook. # bypass frozen dataclass - object.__setattr__(self, "previous_workload_version", self.workload_version) object.__setattr__(self, "workload_version", new_workload_version) def _update_status( @@ -790,14 +786,9 @@ def _update_status( is_app: bool = False, ): """Update the current app/unit status and add the previous one to the history.""" - if is_app: - self.app_history.append(self.app) - # bypass frozen dataclass - object.__setattr__(self, "app", _EntityStatus(new_status, new_message)) - else: - self.unit_history.append(self.unit) - # bypass frozen dataclass - object.__setattr__(self, "unit", _EntityStatus(new_status, new_message)) + name = "app" if is_app else "unit" + # bypass frozen dataclass + object.__setattr__(self, name, _EntityStatus(new_status, new_message)) @dataclasses.dataclass(frozen=True) @@ -834,7 +825,6 @@ class State(_DCBase): status: Status = dataclasses.field(default_factory=Status) leader: bool = False model: Model = Model() - juju_log: List[Tuple[str, str]] = dataclasses.field(default_factory=list) secrets: List[Secret] = dataclasses.field(default_factory=list) unit_id: int = 0 @@ -1116,11 +1106,6 @@ def event(self) -> Event: """Helper to generate an action event from this action.""" return Event(self.name + ACTION_EVENT_SUFFIX, action=self) - @property - def output(self) -> ActionOutput: - """Helper to access the outputs of this action.""" - return ACTION_OUTPUT.get() - def deferred( event: Union[str, Event], diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..40386d4d7 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,72 @@ +import logging +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union + +from scenario.context import Context + +if TYPE_CHECKING: + from ops.testing import CharmType + + from scenario.state import Event, State + + _CT = TypeVar("_CT", bound=Type[CharmType]) + + PathLike = Union[str, Path] + +logger = logging.getLogger() + + +def trigger( + state: "State", + event: Union["Event", str], + charm_type: Type["CharmType"], + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + # if not provided, will be autoloaded from charm_type. + meta: Optional[Dict[str, Any]] = None, + actions: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + juju_version: str = "3.0", +) -> "State": + """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. + :arg state: the State instance to use as data source for the hook tool calls that the charm will + invoke when handling the Event. + :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + :arg pre_event: callback to be invoked right before emitting the event on the newly + instantiated charm. Will receive the charm instance as only positional argument. + :arg post_event: callback to be invoked right after emitting the event on the charm instance. + Will receive the charm instance as only positional argument. + :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a python dict). + If none is provided, we will search for a ``metadata.yaml`` file in the charm root. + :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a python dict). + If none is provided, we will search for a ``actions.yaml`` file in the charm root. + :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). + If none is provided, we will search for a ``config.yaml`` file in the charm root. + :arg juju_version: Juju agent version to simulate. + :arg charm_root: virtual charm root the charm will be executed with. + If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the + execution cwd, you need to use this. E.g.: + + >>> virtual_root = tempfile.TemporaryDirectory() + >>> local_path = Path(local_path.name) + >>> (local_path / 'foo').mkdir() + >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + >>> scenario, State(), (... charm_root=virtual_root) + + """ + ctx = Context( + charm_type=charm_type, + meta=meta, + actions=actions, + config=config, + charm_root=charm_root, + juju_version=juju_version, + ) + return ctx.run(event, state=state, pre_event=pre_event, post_event=post_event) diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index fe800e7ee..3e203bf3a 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -1,9 +1,11 @@ import pytest -from ops.charm import CharmBase +from ops.charm import ActionEvent, CharmBase from ops.framework import Framework -from scenario import trigger -from scenario.state import Action, Event, Network, Relation, State, _CharmSpec +from scenario import Context +from scenario.context import InvalidEventError +from scenario.state import Action, Event, State +from tests.helpers import trigger @pytest.fixture(scope="function") @@ -24,18 +26,18 @@ def _on_event(self, event): @pytest.mark.parametrize("baz_value", (True, False)) -def test_action_event(mycharm, baz_value, emitted_events): - trigger( - State(), - Action("foo", params={"baz": baz_value, "bar": 10}).event, +def test_action_event(mycharm, baz_value): + ctx = Context( mycharm, meta={"name": "foo"}, actions={ "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} }, ) + action = Action("foo", params={"baz": baz_value, "bar": 10}) + ctx.run_action(action, State()) - evt = emitted_events[0] + evt = ctx.emitted_events[0] assert evt.params["bar"] == 10 assert evt.params["baz"] is baz_value @@ -43,44 +45,59 @@ def test_action_event(mycharm, baz_value, emitted_events): @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: Event): + def handle_evt(charm: CharmBase, evt: ActionEvent): with pytest.raises((TypeError, AttributeError)): evt.set_results(res_value) mycharm._evt_handler = handle_evt action = Action("foo") - trigger( - State(), - action.event, - mycharm, - meta={"name": "foo"}, - actions={"foo": {}}, - ) + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + ctx.run_action(action, State()) + + +def test_cannot_run_action(mycharm): + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + action = Action("foo") + + with pytest.raises(InvalidEventError): + ctx.run(action, state=State()) + + +def test_cannot_run_action_name(mycharm): + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + action = Action("foo") + with pytest.raises(InvalidEventError): + ctx.run(action.event.name, state=State()) + + +def test_cannot_run_action_event(mycharm): + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + action = Action("foo") + with pytest.raises(InvalidEventError): + ctx.run(action.event, state=State()) @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_results_valid(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: Event): + def handle_evt(charm: CharmBase, evt: ActionEvent): evt.set_results(res_value) + evt.log("foo") + evt.log("bar") mycharm._evt_handler = handle_evt action = Action("foo") - trigger( - State(), - action.event, - mycharm, - meta={"name": "foo"}, - actions={"foo": {}}, - ) + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + + out = ctx.run_action(action, State()) - assert action.output.results == res_value + assert out.results == res_value @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_outputs(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: Event): + def handle_evt(charm: CharmBase, evt: ActionEvent): evt.set_results({"my-res": res_value}) evt.log("log1") evt.log("log2") @@ -89,14 +106,8 @@ def handle_evt(charm: CharmBase, evt: Event): mycharm._evt_handler = handle_evt action = Action("foo") - trigger( - State(), - action.event, - mycharm, - meta={"name": "foo"}, - actions={"foo": {}}, - ) + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + out = ctx.run_action(action, State()) - assert action.output.failed - assert action.output.failure_message == "failed becozz" - assert action.output.logs == ["log1", "log2"] + assert out.failure == "failed becozz" + assert out.logs == ["log1", "log2"] diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index 367ebd7a3..3a9fe5e5f 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -2,8 +2,8 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario import trigger from scenario.state import Event, Network, Relation, State, _CharmSpec +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 05c1196b2..a241578b5 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -5,7 +5,8 @@ from ops.framework import EventBase, EventSource from scenario import State -from scenario.runtime import InconsistentScenarioError, trigger +from scenario.runtime import InconsistentScenarioError +from tests.helpers import trigger def test_custom_event_emitted(): diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 77666db4f..6593d9b99 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -10,8 +10,8 @@ ) from ops.framework import Framework -from scenario import trigger from scenario.state import Container, DeferredEvent, Relation, State, deferred +from tests.helpers import trigger CHARM_CALLED = 0 diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index c9e49e0ed..ae43ed23f 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -3,8 +3,9 @@ import pytest from ops.charm import CharmBase -from scenario import trigger +from scenario import Context from scenario.state import State +from tests.helpers import trigger logger = logging.getLogger("testing logger") @@ -27,7 +28,8 @@ def _on_event(self, event): def test_juju_log(mycharm): - out = trigger(State(), "start", mycharm, meta=mycharm.META) - assert out.juju_log[16] == ("DEBUG", "Emitting Juju event start.") + ctx = Context(mycharm, meta=mycharm.META) + ctx.run("start", State()) + assert ctx.juju_log[16] == ("DEBUG", "Emitting Juju event start.") # prints are not juju-logged. - assert out.juju_log[17] == ("WARNING", "bar!") + assert ctx.juju_log[17] == ("WARNING", "bar!") diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 514297c8e..fccd60290 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -2,8 +2,8 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario import trigger from scenario.state import Event, Network, Relation, State, _CharmSpec +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py index 4348e3d45..51202daf3 100644 --- a/tests/test_e2e/test_observers.py +++ b/tests/test_e2e/test_observers.py @@ -2,8 +2,8 @@ from ops.charm import ActionEvent, CharmBase, StartEvent from ops.framework import Framework -from scenario import trigger from scenario.state import Event, State, _CharmSpec +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index a5e922f4b..15c957ca2 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -2,14 +2,13 @@ from pathlib import Path import pytest -import yaml from ops import pebble from ops.charm import CharmBase from ops.framework import Framework from ops.pebble import ServiceStartup, ServiceStatus -from scenario import trigger from scenario.state import Container, ExecOutput, Mount, State +from tests.helpers import trigger @pytest.fixture(scope="function") @@ -136,7 +135,7 @@ def callback(self: CharmBase): assert file.read() == text else: # nothing has changed - out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + out_purged = out.replace(stored_state=state.stored_state) assert not out_purged.jsonpatch_delta(state) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index cc41ec084..16d7e105c 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -3,8 +3,8 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus -from scenario import trigger from scenario.state import Event, Relation, State, Status, _CharmSpec +from tests.helpers import trigger @pytest.fixture(scope="function") @@ -60,7 +60,7 @@ def post_event(charm): assert out.status.unit == ActiveStatus("yabadoodle") - out_purged = out.replace(juju_log=[], stored_state=initial_state.stored_state) + out_purged = out.replace(stored_state=initial_state.stored_state) assert out_purged.jsonpatch_delta(initial_state) == [ { "op": "replace", @@ -72,11 +72,6 @@ def post_event(charm): "path": "/status/unit/name", "value": "active", }, - { - "op": "add", - "path": "/status/unit_history/0", - "value": {"message": "foo", "name": "blocked"}, - }, ] diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 6970d7130..ddf00e439 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -4,7 +4,6 @@ from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent from ops.framework import EventBase, Framework -from scenario import trigger from scenario.state import ( PeerRelation, Relation, @@ -13,6 +12,7 @@ StateValidationError, SubordinateRelation, ) +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 7420d723f..ad95fbf7f 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -4,9 +4,9 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource, Framework, Object -from scenario import trigger from scenario.ops_main_mock import NoObserverError from scenario.state import Container, Event, State, _CharmSpec +from tests.helpers import trigger class QuxEvent(EventBase): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 5aa5babac..0f0743111 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,8 +3,8 @@ from ops.framework import Framework from ops.model import SecretRotate -from scenario import trigger from scenario.state import Relation, Secret, State +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 8ba7b77d6..184c60b15 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,8 +6,8 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario import trigger from scenario.state import Container, Relation, State, sort_patch +from tests.helpers import trigger CUSTOM_EVT_SUFFIXES = { "relation_created", @@ -56,7 +56,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) - out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + out_purged = out.replace(stored_state=state.stored_state) assert state.jsonpatch_delta(out_purged) == [] @@ -91,24 +91,14 @@ def call(charm: CharmBase, _): assert out.status.app == WaitingStatus("foo barz") assert out.status.workload_version == "" - # ignore logging output and stored state in the delta - out_purged = out.replace(juju_log=[], stored_state=state.stored_state) + # ignore stored state in the delta + out_purged = out.replace(stored_state=state.stored_state) assert out_purged.jsonpatch_delta(state) == sort_patch( [ {"op": "replace", "path": "/status/app/message", "value": "foo barz"}, {"op": "replace", "path": "/status/app/name", "value": "waiting"}, - { - "op": "add", - "path": "/status/app_history/0", - "value": {"message": "", "name": "unknown"}, - }, {"op": "replace", "path": "/status/unit/message", "value": "foo test"}, {"op": "replace", "path": "/status/unit/name", "value": "active"}, - { - "op": "add", - "path": "/status/unit_history/0", - "value": {"message": "", "name": "unknown"}, - }, ] ) diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index 2b44939ae..f8667836e 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -3,8 +3,9 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus -from scenario import trigger +from scenario import Context from scenario.state import State, Status +from tests.helpers import trigger @pytest.fixture(scope="function") @@ -43,23 +44,26 @@ def post_event(charm: CharmBase): obj.status = BlockedStatus("2") obj.status = WaitingStatus("3") - out = trigger( - State(leader=True), - "update_status", + ctx = Context( mycharm, meta={"name": "local"}, + ) + + out = ctx.run( + "update_status", + State(leader=True), post_event=post_event, ) assert out.status.unit == WaitingStatus("3") - assert out.status.unit_history == [ + assert ctx.unit_status_history == [ UnknownStatus(), ActiveStatus("1"), BlockedStatus("2"), ] assert out.status.app == WaitingStatus("3") - assert out.status.app_history == [ + assert ctx.app_status_history == [ UnknownStatus(), ActiveStatus("1"), BlockedStatus("2"), @@ -71,19 +75,45 @@ def post_event(charm: CharmBase): for obj in [charm.unit, charm.app]: obj.status = WaitingStatus("3") - out = trigger( + ctx = Context( + mycharm, + meta={"name": "local"}, + ) + + out = ctx.run( + "update_status", State( leader=True, status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")), ), - "update_status", - mycharm, - meta={"name": "local"}, post_event=post_event, ) assert out.status.unit == WaitingStatus("3") - assert out.status.unit_history == [ActiveStatus("foo")] + assert ctx.unit_status_history == [ActiveStatus("foo")] assert out.status.app == WaitingStatus("3") - assert out.status.app_history == [ActiveStatus("bar")] + assert ctx.app_status_history == [ActiveStatus("bar")] + + +def test_workload_history(mycharm): + def post_event(charm: CharmBase): + charm.unit.set_workload_version("1") + charm.unit.set_workload_version("1.1") + charm.unit.set_workload_version("1.2") + + ctx = Context( + mycharm, + meta={"name": "local"}, + ) + + out = ctx.run( + "update_status", + State( + leader=True, + ), + post_event=post_event, + ) + + assert ctx.workload_version_history == ["1", "1.1"] + assert out.status.workload_version == "1.2" diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 857aa9470..22a6235e7 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -3,8 +3,8 @@ from ops.framework import Framework from ops.framework import StoredState as ops_storedstate -from scenario import trigger from scenario.state import State, StoredState +from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index 634da0d67..ccd71405e 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -7,7 +7,8 @@ from ops.model import ActiveStatus from scenario import State -from scenario.runtime import DirtyVirtualCharmRootError, trigger +from scenario.runtime import DirtyVirtualCharmRootError +from tests.helpers import trigger class MyCharm(CharmBase): diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index cd963b832..a3169930e 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -2,7 +2,9 @@ from ops.charm import CharmBase, CharmEvents, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent -from scenario import Event, State, capture_events, trigger +from scenario import Event, State +from scenario.capture_events import capture_events +from tests.helpers import trigger class Foo(EventBase): diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c8b85b263..9eaa29458 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,35 +4,7 @@ sys.path.append(".") -def test_emitted_events_fixture(pytester): - """Make sure that pytest accepts our fixture.""" - - # create a temporary pytest test module - pytester.makepyfile( - """ - from scenario import State - def test_sth(emitted_events): - assert emitted_events == [] - """ - ) - - # run pytest with the following cmd args - result = pytester.runpytest("-v") - - # fnmatch_lines does an assertion internally - result.stdout.fnmatch_lines( - [ - "*::test_sth PASSED*", - ] - ) - - # make sure that we get a '0' exit code for the testsuite - assert result.ret == 0 - - def test_context(pytester): - """Make sure that pytest accepts our fixture.""" - # create a temporary pytest test module pytester.makepyfile( """ diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1344b091b..9ee2d74c1 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -7,6 +7,7 @@ from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase +from scenario import Context from scenario.runtime import Runtime from scenario.state import Event, State, _CharmSpec @@ -40,9 +41,10 @@ def test_event_hooks(): meta_file = temppath / "metadata.yaml" meta_file.write_text(yaml.safe_dump(meta)) + my_charm_type = charm_type() runtime = Runtime( _CharmSpec( - charm_type(), + my_charm_type, meta=meta, ), ) @@ -54,6 +56,7 @@ def test_event_hooks(): event=Event("update_status"), pre_event=pre_event, post_event=post_event, + context=Context(my_charm_type, meta=meta), ) assert pre_event.called @@ -81,7 +84,9 @@ class MyEvt(EventBase): ), ) - runtime.exec(state=State(), event=Event("bar")) + runtime.exec( + state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta) + ) assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) @@ -107,5 +112,8 @@ def post_event(charm: CharmBase): assert charm.unit.name == f"{app_name}/{unit_id}" runtime.exec( - state=State(unit_id=unit_id), event=Event("start"), post_event=post_event + state=State(unit_id=unit_id), + event=Event("start"), + post_event=post_event, + context=Context(my_charm_type, meta=meta), ) From 6ad9f3a1ad79d03381cd025f01aab6120151adc1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 7 Jun 2023 10:16:55 +0200 Subject: [PATCH 253/546] fix ci --- .github/workflows/quality_checks.yaml | 8 ++++---- scenario/runtime.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index f6ce33eaf..29fed7e3f 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -17,10 +17,10 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.11 - name: Install dependencies run: python -m pip install tox - - name: Run tests + - name: Run lint tests run: tox -vve lint unit-test: @@ -34,8 +34,8 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.11 - name: Install dependencies run: python -m pip install tox - - name: Run tests + - name: Run unit tests run: tox -vve unit diff --git a/scenario/runtime.py b/scenario/runtime.py index 8c44dd7c9..318936330 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -254,7 +254,7 @@ class WrappedCharm(charm_type): # type: ignore return WrappedCharm @contextmanager - def virtual_charm_root(self): + def _virtual_charm_root(self): # If we are using runtime on a real charm, we can make some assumptions about the # directory structure we are going to find. # If we're, say, dynamically defining charm types and doing tests on them, we'll have to @@ -356,7 +356,7 @@ def exec( logger.info(" - generating virtual charm root") with ( - self.virtual_charm_root() as temporary_charm_root, + self._virtual_charm_root() as temporary_charm_root, # todo allow customizing capture_events capture_events() as captured, ): From 91c1ea1253f9cdaa637619170b2e62bffa85bb66 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 8 Jun 2023 09:45:55 +0200 Subject: [PATCH 254/546] out.logs type fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bea9f127d..e2a2566e3 100644 --- a/README.md +++ b/README.md @@ -692,7 +692,7 @@ def test_backup_action(): # you can assert action results, logs, failure using the ActionOutput interface assert out.results == {'foo': 'bar'} - assert out.logs == {'foo': 'bar'} + assert out.logs == ['baz', 'qux'] assert out.failure == 'boo-hoo' ``` From b4390f68ae92f7738e6bb051acaabe119f23d93b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 8 Jun 2023 14:08:19 +0200 Subject: [PATCH 255/546] simplified consistency checker code --- .github/workflows/quality_checks.yaml | 4 +- scenario/consistency_checker.py | 192 ++++++++++++++++---------- 2 files changed, 119 insertions(+), 77 deletions(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 29fed7e3f..657285cc9 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -17,7 +17,7 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.10 - name: Install dependencies run: python -m pip install tox - name: Run lint tests @@ -34,7 +34,7 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.10 - name: Install dependencies run: python -m pip install tox - name: Run unit tests diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 3d0e6c74c..230d26e87 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -3,11 +3,17 @@ from collections.abc import Sequence from itertools import chain from numbers import Number -from typing import TYPE_CHECKING, Iterable, NamedTuple, Tuple +from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger -from scenario.state import PeerRelation, SubordinateRelation, _CharmSpec, normalize_name +from scenario.state import ( + Action, + PeerRelation, + SubordinateRelation, + _CharmSpec, + normalize_name, +) if TYPE_CHECKING: from scenario.state import Event, State @@ -97,6 +103,7 @@ def check_event_consistency( warnings = [] # custom event: can't make assumptions about its name and its semantics + # todo: should we then just skip the other checks? if not event._is_builtin_event(charm_spec): warnings.append( "this is a custom event; if its name makes it look like a builtin one " @@ -105,89 +112,124 @@ def check_event_consistency( ) if event._is_relation_event: - if not event.relation: - errors.append( - "cannot construct a relation event without the relation instance. " - "Please pass one.", - ) - else: - if not event.name.startswith(normalize_name(event.relation.endpoint)): - errors.append( - f"relation event should start with relation endpoint name. {event.name} does " - f"not start with {event.relation.endpoint}.", - ) + _check_relation_event(charm_spec, event, errors, warnings) if event._is_workload_event: - if not event.container: - errors.append( - "cannot construct a workload event without the container instance. " - "Please pass one.", - ) - else: - if not event.name.startswith(normalize_name(event.container.name)): - errors.append( - f"workload event should start with container name. {event.name} does " - f"not start with {event.container.name}.", - ) + _check_workload_event(charm_spec, event, errors, warnings) if event._is_action_event: - action = event.action - if not action: + _check_action_event(charm_spec, event, errors, warnings) + + return Results(errors, warnings) + + +def _check_relation_event( + charm_spec: _CharmSpec, # noqa: U100 + event: "Event", + errors: List[str], + warnings: List[str], # noqa: U100 +): + if not event.relation: + errors.append( + "cannot construct a relation event without the relation instance. " + "Please pass one.", + ) + else: + if not event.name.startswith(normalize_name(event.relation.endpoint)): errors.append( - "cannot construct a workload event without the container instance. " - "Please pass one.", + f"relation event should start with relation endpoint name. {event.name} does " + f"not start with {event.relation.endpoint}.", ) - else: - if not event.name.startswith(normalize_name(action.name)): - errors.append( - f"action event should start with action name. {event.name} does " - f"not start with {action.name}.", - ) - if action.name not in charm_spec.actions: + + +def _check_workload_event( + charm_spec: _CharmSpec, # noqa: U100 + event: "Event", + errors: List[str], + warnings: List[str], # noqa: U100 +): + if not event.container: + errors.append( + "cannot construct a workload event without the container instance. " + "Please pass one.", + ) + elif not event.name.startswith(normalize_name(event.container.name)): + errors.append( + f"workload event should start with container name. {event.name} does " + f"not start with {event.container.name}.", + ) + + +def _check_action_event( + charm_spec: _CharmSpec, + event: "Event", + errors: List[str], + warnings: List[str], +): + action = event.action + if not action: + errors.append( + "cannot construct a workload event without the container instance. " + "Please pass one.", + ) + elif not event.name.startswith(normalize_name(action.name)): + errors.append( + f"action event should start with action name. {event.name} does " + f"not start with {action.name}.", + ) + if action.name not in charm_spec.actions: + errors.append( + f"action event {event.name} refers to action {action.name} " + f"which is not declared in the charm metadata (actions.yaml).", + ) + + _check_action_param_types(charm_spec, action, errors, warnings) + + +def _check_action_param_types( + charm_spec: _CharmSpec, + action: Action, + errors: List[str], + warnings: List[str], +): + to_python_type = { + "string": str, + "boolean": bool, + "number": Number, + "array": Sequence, + "object": dict, + } + expected_param_type = {} + for par_name, par_spec in charm_spec.actions[action.name].get("params", {}).items(): + value = par_spec.get("type") + if not value: errors.append( - f"action event {event.name} refers to action {action.name} " - f"which is not declared in the charm metadata (actions.yaml).", + f"action parameter {par_name} has no type. " + f"Charmcraft will be unhappy about this. ", ) - to_python_type = { - "string": str, - "boolean": bool, - "number": Number, - "array": Sequence, - "object": dict, - } - expected_param_type = {} - for par_name, par_spec in ( - charm_spec.actions[action.name].get("params", {}).items() - ): - if value := par_spec.get("type"): - try: - expected_param_type[par_name] = to_python_type[value] - except KeyError: - warnings.append( - f"unknown data type declared for parameter {par_name}: type={value}. " - f"Cannot consistency-check.", - ) - else: - errors.append( - f"action parameter {par_name} has no type. " - f"Charmcraft will be unhappy about this. ", - ) - - for provided_param_name, provided_param_value in action.params.items(): - if expected_type := expected_param_type.get(provided_param_name): - if not isinstance(provided_param_value, expected_type): - errors.append( - f"param {provided_param_name} is of type {type(provided_param_value)}: " - f"expecting {expected_type}", - ) + continue - else: - errors.append( - f"param {provided_param_name} is not a valid parameter for {action.name}: " - "missing from action specification", - ) + try: + expected_param_type[par_name] = to_python_type[value] + except KeyError: + warnings.append( + f"unknown data type declared for parameter {par_name}: type={value}. " + f"Cannot consistency-check.", + ) - return Results(errors, warnings) + for provided_param_name, provided_param_value in action.params.items(): + expected_type = expected_param_type.get(provided_param_name) + if not expected_type: + errors.append( + f"param {provided_param_name} is not a valid parameter for {action.name}: " + "missing from action specification", + ) + continue + if not isinstance(provided_param_value, expected_type): + errors.append( + f"param {provided_param_name} is of type {type(provided_param_value)}: " + f"expecting {expected_type}", + ) def check_config_consistency( From 27806dca711e697aa4be64a316de3f50df91d474 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 8 Jun 2023 14:09:24 +0200 Subject: [PATCH 256/546] simplified consistency checker code --- .github/workflows/quality_checks.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 657285cc9..9488abc2f 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -17,7 +17,7 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.10 + python-version: "3.10" - name: Install dependencies run: python -m pip install tox - name: Run lint tests @@ -34,7 +34,7 @@ jobs: - name: Set up python uses: actions/setup-python@v4 with: - python-version: 3.10 + python-version: "3.10" - name: Install dependencies run: python -m pip install tox - name: Run unit tests From 1b68e559d34a23aa81d2e5c945ebdf1205b487d0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 22 Jun 2023 09:54:52 +0200 Subject: [PATCH 257/546] fixed spacing --- scenario/ops_main_mock.py | 3 +- tests/test_e2e/test_custom_event_triggers.py | 53 +++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 9bf79c829..16acd581e 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -91,7 +91,8 @@ def main( if not getattr(charm.on, dispatcher.event_name, None): raise NoObserverError( - f"Charm has no registered observers for {dispatcher.event_name!r}. " + f"Cannot fire {dispatcher.event_name!r} on {charm}: " + f"invalid event (not on charm.on). " f"This is probably not what you were looking for.", ) diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index a241578b5..8b049b784 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -1,10 +1,12 @@ import os +from unittest.mock import MagicMock, Mock import pytest from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, EventSource +from ops.framework import EventBase, EventSource, Object -from scenario import State +from scenario import Event, State +from scenario.ops_main_mock import NoObserverError from scenario.runtime import InconsistentScenarioError from tests.helpers import trigger @@ -68,3 +70,50 @@ def _on_foo(self, e): trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) assert MyCharm._foo_called os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") + + +def test_child_object_event_emitted(): + class FooEvent(EventBase): + pass + + class MyObjEvents(CharmEvents): + foo = EventSource(FooEvent) + + class MyObject(Object): + on = MyObjEvents() + + class MyCharm(CharmBase): + META = {"name": "mycharm"} + _foo_called = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.obj = MyObject(self, "child") + self.framework.observe(self.obj.on.foo, self._on_foo) + + def _on_foo(self, e): + MyCharm._foo_called = True + + with pytest.raises(NoObserverError): + # this will fail because "foo" isn't registered on MyCharm but on MyCharm.foo + trigger(State(), "foo", MyCharm, meta=MyCharm.META) + assert MyCharm._foo_called + + # workaround: we can use pre_event to have Scenario set up the simulation for us and run our + # test code before it eventually fails. pre_event gets called with the set-up charm instance. + def pre_event(charm: MyCharm): + event_mock = MagicMock() + charm._on_foo(event_mock) + assert charm.unit.name == "mycharm/0" + + # make sure you only filter out NoObserverError, else if pre_event raises, + # they will also be caught while you want them to bubble up. + with pytest.raises(NoObserverError): + trigger( + State(), + "rubbish", # you can literally put anything here + MyCharm, + pre_event=pre_event, + meta=MyCharm.META, + ) + assert MyCharm._foo_called From b272b2808daff3ef448f0acff3615470eae9e166 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 22 Jun 2023 10:31:53 +0200 Subject: [PATCH 258/546] flattened status --- README.md | 11 ++-- scenario/__init__.py | 2 - scenario/context.py | 19 ++++-- scenario/mocking.py | 10 +-- scenario/scripts/snapshot.py | 16 ++++- scenario/state.py | 85 +++++++++++--------------- tests/test_e2e/test_play_assertions.py | 10 +-- tests/test_e2e/test_state.py | 14 ++--- tests/test_e2e/test_status.py | 17 +++--- tests/test_e2e/test_vroot.py | 2 +- 10 files changed, 98 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index e2a2566e3..906d5ad23 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ def test_scenario_base(): ctx = Context(MyCharm, meta={"name": "foo"}) out = ctx.run('start', State()) - assert out.status.unit == UnknownStatus() + assert out.unit_status == UnknownStatus() ``` Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': @@ -121,9 +121,8 @@ class MyCharm(CharmBase): def test_status_leader(leader): ctx = Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', - State(leader=leader) - assert out.status.unit == ActiveStatus('I rule' if leader else 'I am ruled') + out = ctx.run('start', State(leader=leader)) + assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can @@ -153,6 +152,8 @@ def _on_event(self, _event): self.unit.status = BlockedStatus('something went wrong') ``` +More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its relationship with the `State`. + # Context and State Consider the following tests. Suppose we want to verify that while handling a given toplevel juju event: @@ -209,7 +210,7 @@ from ops.model import ActiveStatus from scenario import State, Status # ... -ctx.run('start', State(status=Status(unit=ActiveStatus('foo')))) +ctx.run('start', State(unit_status=ActiveStatus('foo')))) assert ctx.unit_status_history == [ ActiveStatus('foo'), # now the first status is active: 'foo'! # ... diff --git a/scenario/__init__.py b/scenario/__init__.py index 60aea6f61..a8e39ced6 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -21,7 +21,6 @@ Secret, State, StateValidationError, - Status, StoredState, SubordinateRelation, deferred, @@ -46,7 +45,6 @@ Address, BindAddress, Network, - Status, StoredState, State, DeferredEvent, diff --git a/scenario/context.py b/scenario/context.py index 1b30da875..015168730 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -15,7 +15,7 @@ from ops.testing import CharmType - from scenario.state import JujuLogLine, State, Status, _EntityStatus + from scenario.state import JujuLogLine, State, _EntityStatus PathLike = Union[str, Path] @@ -97,12 +97,23 @@ def __init__( self._action_results = None self._action_failure = "" - def _record_status(self, status: "Status", is_app: bool): + def clear(self): + """Cleanup side effects histories.""" + self.juju_log = [] + self.app_status_history = [] + self.unit_status_history = [] + self.workload_version_history = [] + self.emitted_events = [] + self._action_logs = [] + self._action_results = None + self._action_failure = "" + + def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: - self.app_status_history.append(status.app) + self.app_status_history.append(state.app_status) else: - self.unit_status_history.append(status.unit) + self.unit_status_history.append(state.unit_status) def run( self, diff --git a/scenario/mocking.py b/scenario/mocking.py index dfa0fc943..1c8a56dea 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -132,7 +132,7 @@ def is_leader(self): return self._state.leader def status_get(self, *, is_app: bool = False): - status, message = self._state.status.app if is_app else self._state.status.unit + status, message = self._state.app_status if is_app else self._state.unit_status return {"status": status, "message": message} def relation_ids(self, relation_name): @@ -176,15 +176,15 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): # setter methods: these can mutate the state. def application_version_set(self, version: str): - if workload_version := self._state.status.workload_version: + if workload_version := self._state.workload_version: # do not record if empty = unset self._context.workload_version_history.append(workload_version) - self._state.status._update_workload_version(version) + self._state._update_workload_version(version) def status_set(self, status: str, message: str = "", *, is_app: bool = False): - self._context._record_status(self._state.status, is_app) - self._state.status._update_status(status, message, is_app) + self._context._record_status(self._state, is_app) + self._state._update_status(status, message, is_app) def juju_log(self, level: str, message: str): self._context.juju_log.append(JujuLogLine(level, message)) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 0ea524893..bab6727dc 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -9,7 +9,7 @@ import shlex import sys import tempfile -from dataclasses import asdict +from dataclasses import asdict, dataclass from enum import Enum from itertools import chain from pathlib import Path @@ -35,7 +35,6 @@ Relation, Secret, State, - Status, _EntityStatus, ) @@ -442,6 +441,13 @@ def get_juju_status(model: Optional[str]) -> Dict: return _juju_run("status --relations", model=model) +@dataclass +class Status: + app: _EntityStatus + unit: _EntityStatus + workload_version: str + + def get_status(juju_status: Dict, target: JujuUnitName) -> Status: """Parse `juju status` to get the Status data structure and some relation information.""" app = juju_status["applications"][target.app_name] @@ -738,10 +744,14 @@ def if_include(key, fn, default): unit_state_db = RemoteUnitStateDB(model, target) juju_status = get_juju_status(model) endpoints = get_endpoints(juju_status, target) + status = get_status(juju_status, target=target) + state = State( leader=get_leader(target, model), + unit_status=status.unit, + app_status=status.app, + workload_version=status.workload_version, model=state_model, - status=get_status(juju_status, target=target), config=if_include("c", lambda: get_config(target, model), {}), relations=if_include( "r", diff --git a/scenario/state.py b/scenario/state.py index 21bd5cd1a..8d9a9cd02 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -745,52 +745,6 @@ def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: return _EntityStatus(obj.name, obj.message) -@dataclasses.dataclass(frozen=True) -class Status(_DCBase): - """Represents the 'juju statuses' of the application/unit being tested.""" - - # the current statuses. Will be cast to _EntitiyStatus in __post_init__ - app: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") - unit: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") - workload_version: str = "" - - def __post_init__(self): - for name in ["app", "unit"]: - val = getattr(self, name) - if isinstance(val, _EntityStatus): - pass - elif isinstance(val, StatusBase): - object.__setattr__(self, name, _status_to_entitystatus(val)) - elif isinstance(val, tuple): - logger.warning( - "Initializing Status.[app/unit] with Tuple[str, str] is deprecated " - "and will be removed soon. \n" - f"Please pass a StatusBase instance: `StatusBase(*{val})`", - ) - object.__setattr__(self, name, _EntityStatus(*val)) - else: - raise TypeError(f"Invalid status.{name}: {val!r}") - - def _update_workload_version(self, new_workload_version: str): - """Update the current app version and record the previous one.""" - # We don't keep a full history because we don't expect the app version to change more - # than once per hook. - - # bypass frozen dataclass - object.__setattr__(self, "workload_version", new_workload_version) - - def _update_status( - self, - new_status: str, - new_message: str = "", - is_app: bool = False, - ): - """Update the current app/unit status and add the previous one to the history.""" - name = "app" if is_app else "unit" - # bypass frozen dataclass - object.__setattr__(self, name, _EntityStatus(new_status, new_message)) - - @dataclasses.dataclass(frozen=True) class StoredState(_DCBase): # /-separated Object names. E.g. MyCharm/MyCharmLib. @@ -822,7 +776,6 @@ class State(_DCBase): relations: List["AnyRelation"] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) - status: Status = dataclasses.field(default_factory=Status) leader: bool = False model: Model = Model() secrets: List[Secret] = dataclasses.field(default_factory=list) @@ -835,6 +788,42 @@ class State(_DCBase): deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) stored_state: List["StoredState"] = dataclasses.field(default_factory=dict) + """Represents the 'juju statuses' of the application/unit being tested.""" + + # the current statuses. Will be cast to _EntitiyStatus in __post_init__ + app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + workload_version: str = "" + + def __post_init__(self): + for name in ["app_status", "unit_status"]: + val = getattr(self, name) + if isinstance(val, _EntityStatus): + pass + elif isinstance(val, StatusBase): + object.__setattr__(self, name, _status_to_entitystatus(val)) + else: + raise TypeError(f"Invalid status.{name}: {val!r}") + + def _update_workload_version(self, new_workload_version: str): + """Update the current app version and record the previous one.""" + # We don't keep a full history because we don't expect the app version to change more + # than once per hook. + + # bypass frozen dataclass + object.__setattr__(self, "workload_version", new_workload_version) + + def _update_status( + self, + new_status: str, + new_message: str = "", + is_app: bool = False, + ): + """Update the current app/unit status and add the previous one to the history.""" + name = "app_status" if is_app else "unit_status" + # bypass frozen dataclass + object.__setattr__(self, name, _EntityStatus(new_status, new_message)) + def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: @@ -850,7 +839,7 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return self.replace( status=dataclasses.replace( - self.status, + self.unit_status, unit=_status_to_entitystatus(status), ), ) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 16d7e105c..b8b92d5a0 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -3,7 +3,7 @@ from ops.framework import Framework from ops.model import ActiveStatus, BlockedStatus -from scenario.state import Event, Relation, State, Status, _CharmSpec +from scenario.state import Relation, State from tests.helpers import trigger @@ -45,7 +45,7 @@ def post_event(charm): mycharm._call = call initial_state = State( - config={"foo": "bar"}, leader=True, status=Status(unit=BlockedStatus("foo")) + config={"foo": "bar"}, leader=True, unit_status=BlockedStatus("foo") ) out = trigger( @@ -58,18 +58,18 @@ def post_event(charm): pre_event=pre_event, ) - assert out.status.unit == ActiveStatus("yabadoodle") + assert out.unit_status == ActiveStatus("yabadoodle") out_purged = out.replace(stored_state=initial_state.stored_state) assert out_purged.jsonpatch_delta(initial_state) == [ { "op": "replace", - "path": "/status/unit/message", + "path": "/unit_status/message", "value": "yabadoodle", }, { "op": "replace", - "path": "/status/unit/name", + "path": "/unit_status/name", "value": "active", }, ] diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 184c60b15..f080eafe4 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -87,18 +87,18 @@ def call(charm: CharmBase, _): meta={"name": "foo"}, config={"options": {"foo": {"type": "string"}}}, ) - assert out.status.unit == ActiveStatus("foo test") - assert out.status.app == WaitingStatus("foo barz") - assert out.status.workload_version == "" + assert out.unit_status == ActiveStatus("foo test") + assert out.app_status == WaitingStatus("foo barz") + assert out.workload_version == "" # ignore stored state in the delta out_purged = out.replace(stored_state=state.stored_state) assert out_purged.jsonpatch_delta(state) == sort_patch( [ - {"op": "replace", "path": "/status/app/message", "value": "foo barz"}, - {"op": "replace", "path": "/status/app/name", "value": "waiting"}, - {"op": "replace", "path": "/status/unit/message", "value": "foo test"}, - {"op": "replace", "path": "/status/unit/name", "value": "active"}, + {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, + {"op": "replace", "path": "/app_status/name", "value": "waiting"}, + {"op": "replace", "path": "/unit_status/message", "value": "foo test"}, + {"op": "replace", "path": "/unit_status/name", "value": "active"}, ] ) diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index f8667836e..e54192949 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -4,7 +4,7 @@ from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus from scenario import Context -from scenario.state import State, Status +from scenario.state import State from tests.helpers import trigger @@ -34,7 +34,7 @@ def post_event(charm: CharmBase): post_event=post_event, ) - assert out.status.unit == UnknownStatus() + assert out.unit_status == UnknownStatus() def test_status_history(mycharm): @@ -55,14 +55,14 @@ def post_event(charm: CharmBase): post_event=post_event, ) - assert out.status.unit == WaitingStatus("3") + assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ UnknownStatus(), ActiveStatus("1"), BlockedStatus("2"), ] - assert out.status.app == WaitingStatus("3") + assert out.app_status == WaitingStatus("3") assert ctx.app_status_history == [ UnknownStatus(), ActiveStatus("1"), @@ -84,15 +84,16 @@ def post_event(charm: CharmBase): "update_status", State( leader=True, - status=Status(unit=ActiveStatus("foo"), app=ActiveStatus("bar")), + unit_status=ActiveStatus("foo"), + app_status=ActiveStatus("bar"), ), post_event=post_event, ) - assert out.status.unit == WaitingStatus("3") + assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ActiveStatus("foo")] - assert out.status.app == WaitingStatus("3") + assert out.app_status == WaitingStatus("3") assert ctx.app_status_history == [ActiveStatus("bar")] @@ -116,4 +117,4 @@ def post_event(charm: CharmBase): ) assert ctx.workload_version_history == ["1", "1.1"] - assert out.status.workload_version == "1.2" + assert out.workload_version == "1.2" diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index ccd71405e..5af9534c4 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -43,7 +43,7 @@ def test_vroot(): charm_root=t, ) - assert out.status.unit == ("active", "hello world") + assert out.unit_status == ("active", "hello world") @pytest.mark.parametrize("meta_overwrite", ["metadata", "actions", "config"]) From eed1b087c7549198a9ddd5e66cda6f62dde04614 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 22 Jun 2023 11:18:32 +0200 Subject: [PATCH 259/546] support for custom events --- scenario/context.py | 65 +++++++++++------ scenario/ops_main_mock.py | 73 ++++++++++++++++---- scenario/runtime.py | 3 + tests/helpers.py | 54 +++++---------- tests/test_e2e/test_custom_event_triggers.py | 43 ++++++++++-- tests/test_e2e/test_juju_log.py | 8 ++- 6 files changed, 168 insertions(+), 78 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 1b30da875..c72e7f5a8 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -2,7 +2,17 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. from collections import namedtuple -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Type, + Union, +) from ops import EventBase @@ -104,17 +114,34 @@ def _record_status(self, status: "Status", is_app: bool): else: self.unit_status_history.append(status.unit) + def _check_event(self, event: Union["Event", str], allow_action=False) -> "Event": + """Validate the event and cast to Event.""" + if isinstance(event, str): + event = Event(event) + + if not isinstance(event, Event): + raise InvalidEventError(f"Expected Event | str, got {type(event)}") + + if not allow_action and event._is_action_event: + raise InvalidEventError( + "Cannot Context.run() action events. " + "Use Context.run_action instead.", + ) + + return event + def run( self, event: Union["Event", str], state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + owner_path: Sequence[str] = None, ) -> "State": """Trigger a charm execution with an 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. + 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 Event that the charm will respond to. Can be a string or an Event instance. :arg state: the State instance to use as data source for the hook tool calls that the @@ -123,20 +150,17 @@ def run( instantiated charm. Will receive the charm instance as only positional argument. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. + :arg owner_path: Path to the ``Object`` that owns this event. E.g. ('foo', 'bar') -> + will emit the event it can find at ``.foo.bar.on``. """ - if isinstance(event, str): - event = Event(event) - - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event | str, got {type(event)}") - - if event._is_action_event: - raise InvalidEventError( - "Cannot Context.run() action events. " - "Use Context.run_action instead.", - ) - - return self._run(event, state=state, pre_event=pre_event, post_event=post_event) + event = self._check_event(event, allow_action=False) + return self._run( + event, + state=state, + pre_event=pre_event, + post_event=post_event, + owner_path=owner_path, + ) def run_action( self, @@ -147,8 +171,8 @@ def run_action( ) -> ActionOutput: """Trigger a charm execution with an Action 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. + 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. Can be a string or an Action instance. :arg state: the State instance to use as data source for the hook tool calls that the @@ -194,6 +218,7 @@ def _run( state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + owner_path: Sequence[str] = None, ) -> "State": runtime = Runtime( charm_spec=self.charm_spec, @@ -201,13 +226,11 @@ def _run( charm_root=self.charm_root, ) - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event, got {type(event)}") - return runtime.exec( state=state, event=event, pre_event=pre_event, post_event=post_event, context=self, + owner_path=owner_path, ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 16acd581e..52b8ad182 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,17 +3,19 @@ # See LICENSE file for licensing details. import inspect import os -from typing import TYPE_CHECKING, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence import ops.charm import ops.framework import ops.model import ops.storage +from ops import CharmBase from ops.charm import CharmMeta from ops.log import setup_root_logging -from ops.main import CHARM_STATE_FILE, _Dispatcher, _emit_charm_event, _get_charm_dir -from scenario.logger import logger as scenario_logger +# use logger from ops.main so that juju_log will be triggered +from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_charm_dir, _get_event_args +from ops.main import logger as ops_logger if TYPE_CHECKING: from ops.testing import CharmType @@ -21,13 +23,62 @@ from scenario.context import Context from scenario.state import Event, State, _CharmSpec -logger = scenario_logger.getChild("ops_main_mock") - class NoObserverError(RuntimeError): """Error raised when the event being dispatched has no registered observers.""" +class BadOwnerPath(RuntimeError): + """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" + + +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"owner_path {path!r} invalid: {step!r} leads to nowhere.", + ) + if not isinstance(obj, ops.ObjectEvents): + raise BadOwnerPath( + f"owner_path {path!r} invalid: does not lead to " + f"an ObjectEvents instance.", + ) + return obj + + +def _emit_charm_event( + charm: "CharmBase", + event_name: str, + owner_path: Sequence[str] = None, +): + """Emits a charm event based on a Juju event name. + + Args: + charm: A charm instance to emit an event from. + event_name: A Juju event name to emit on a charm. + owner_path: Event source lookup path. + """ + owner = _get_owner(charm, owner_path) if owner_path else charm.on + + try: + event_to_emit = getattr(owner, event_name) + except AttributeError: + ops_logger.debug("Event %s not defined for %s.", event_name, charm) + raise NoObserverError( + f"Cannot fire {event_name!r} on {owner}: " + f"invalid event (not on charm.on). " + f"Use Context.run_custom instead.", + ) + + args, kwargs = _get_event_args(charm, event_to_emit) + ops_logger.debug("Emitting Juju event %s.", event_name) + event_to_emit.emit(*args, **kwargs) + + def main( pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, @@ -35,6 +86,7 @@ def main( event: "Event" = None, context: "Context" = None, charm_spec: "_CharmSpec" = None, + owner_path: Sequence[str] = None, ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type @@ -50,7 +102,7 @@ def main( ) debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) - logger.debug( + ops_logger.debug( "Operator Framework %s up and running.", ops.__version__, ) # type:ignore @@ -89,14 +141,7 @@ def main( if pre_event: pre_event(charm) - if not getattr(charm.on, dispatcher.event_name, None): - raise NoObserverError( - f"Cannot fire {dispatcher.event_name!r} on {charm}: " - f"invalid event (not on charm.on). " - f"This is probably not what you were looking for.", - ) - - _emit_charm_event(charm, dispatcher.event_name) + _emit_charm_event(charm, dispatcher.event_name, owner_path) if post_event: post_event(charm) diff --git a/scenario/runtime.py b/scenario/runtime.py index 318936330..9484e95b8 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -14,6 +14,7 @@ Dict, List, Optional, + Sequence, Type, TypeVar, Union, @@ -332,6 +333,7 @@ def exec( context: "Context", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + owner_path: Sequence[str] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -386,6 +388,7 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type), ), + owner_path=owner_path, ) except NoObserverError: raise # propagate along diff --git a/tests/helpers.py b/tests/helpers.py index 40386d4d7..61e7b58e3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,7 +1,16 @@ import logging -import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Optional, + Sequence, + Type, + TypeVar, + Union, +) from scenario.context import Context @@ -23,44 +32,13 @@ def trigger( charm_type: Type["CharmType"], pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: Optional[Dict["PathLike", "PathLike"]] = None, juju_version: str = "3.0", + owner_path: Sequence[str] = None, ) -> "State": - """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. - :arg state: the State instance to use as data source for the hook tool calls that the charm will - invoke when handling the Event. - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. - :arg pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - :arg post_event: callback to be invoked right after emitting the event on the charm instance. - Will receive the charm instance as only positional argument. - :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a python dict). - If none is provided, we will search for a ``metadata.yaml`` file in the charm root. - :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a python dict). - If none is provided, we will search for a ``actions.yaml`` file in the charm root. - :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). - If none is provided, we will search for a ``config.yaml`` file in the charm root. - :arg juju_version: Juju agent version to simulate. - :arg charm_root: virtual charm root the charm will be executed with. - If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to use this. E.g.: - - >>> virtual_root = tempfile.TemporaryDirectory() - >>> local_path = Path(local_path.name) - >>> (local_path / 'foo').mkdir() - >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - >>> scenario, State(), (... charm_root=virtual_root) - - """ ctx = Context( charm_type=charm_type, meta=meta, @@ -69,4 +47,10 @@ def trigger( charm_root=charm_root, juju_version=juju_version, ) - return ctx.run(event, state=state, pre_event=pre_event, post_event=post_event) + return ctx.run( + event, + state=state, + pre_event=pre_event, + post_event=post_event, + owner_path=owner_path, + ) diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 8b049b784..876948a4b 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -1,11 +1,11 @@ import os -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock import pytest from ops.charm import CharmBase, CharmEvents from ops.framework import EventBase, EventSource, Object -from scenario import Event, State +from scenario import State from scenario.ops_main_mock import NoObserverError from scenario.runtime import InconsistentScenarioError from tests.helpers import trigger @@ -72,7 +72,7 @@ def _on_foo(self, e): os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") -def test_child_object_event_emitted(): +def test_child_object_event_emitted_no_path_raises(): class FooEvent(EventBase): pass @@ -80,7 +80,7 @@ class MyObjEvents(CharmEvents): foo = EventSource(FooEvent) class MyObject(Object): - on = MyObjEvents() + my_on = MyObjEvents() class MyCharm(CharmBase): META = {"name": "mycharm"} @@ -89,7 +89,7 @@ class MyCharm(CharmBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.obj = MyObject(self, "child") - self.framework.observe(self.obj.on.foo, self._on_foo) + self.framework.observe(self.obj.my_on.foo, self._on_foo) def _on_foo(self, e): MyCharm._foo_called = True @@ -117,3 +117,36 @@ def pre_event(charm: MyCharm): meta=MyCharm.META, ) assert MyCharm._foo_called + + +def test_child_object_event(): + class FooEvent(EventBase): + pass + + class MyObjEvents(CharmEvents): + foo = EventSource(FooEvent) + + class MyObject(Object): + my_on = MyObjEvents() + + class MyCharm(CharmBase): + META = {"name": "mycharm"} + _foo_called = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.obj = MyObject(self, "child") + self.framework.observe(self.obj.my_on.foo, self._on_foo) + + def _on_foo(self, e): + MyCharm._foo_called = True + + trigger( + State(), + "foo", + MyCharm, + meta=MyCharm.META, + owner_path=('obj', 'my_on') + ) + + assert MyCharm._foo_called diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index ae43ed23f..d78c37319 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -4,7 +4,7 @@ from ops.charm import CharmBase from scenario import Context -from scenario.state import State +from scenario.state import JujuLogLine, State from tests.helpers import trigger logger = logging.getLogger("testing logger") @@ -30,6 +30,8 @@ def _on_event(self, event): def test_juju_log(mycharm): ctx = Context(mycharm, meta=mycharm.META) ctx.run("start", State()) - assert ctx.juju_log[16] == ("DEBUG", "Emitting Juju event start.") + assert ctx.juju_log[-2] == JujuLogLine( + level="DEBUG", message="Emitting Juju event start." + ) + assert ctx.juju_log[-1] == JujuLogLine(level="WARNING", message="bar!") # prints are not juju-logged. - assert ctx.juju_log[17] == ("WARNING", "bar!") From df145e38db0cdf0e35a5db78fd7efb2d6a864ea4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 22 Jun 2023 11:38:02 +0200 Subject: [PATCH 260/546] docs --- README.md | 28 +++++++++++- scenario/context.py | 46 ++++++++++---------- scenario/ops_main_mock.py | 14 +++--- scenario/runtime.py | 6 +-- scenario/state.py | 4 +- tests/helpers.py | 4 +- tests/test_e2e/test_custom_event_triggers.py | 6 +-- 7 files changed, 66 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e2a2566e3..aa81b170b 100644 --- a/README.md +++ b/README.md @@ -874,7 +874,7 @@ class MyCharmType(CharmBase): state = State(stored_state=[ StoredState( - owner_path="MyCharmType", + event_owner_path="MyCharmType", name="my_stored_state", content={ 'foo': 'bar', @@ -886,6 +886,32 @@ state = State(stored_state=[ And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on the output side the same as any other bit of state. +# Emitting custom events + +While the main use case of Scenario is to emit juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. + +Suppose your charm uses a charm library providing an `ingress_provided` event. +The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever that may be, for example a `foo-relation-changed`. + +However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and the event is emitted at all. + +However if you attempt to run that event directly you will get an error: +```python +from scenario import Context, State +Context(...).run("ingress_provided", State()) # raises scenario.ops_main_mock.NoObserverError +``` +This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but since the event is defined on another Object, it will fail to find it. + +You can pass an `event_owner_path` argument to tell Scenario where to find the event source. + +```python +from scenario import Context, State +Context(...).run("ingress_provided", State(), event_owner_path=("my_charm_lib", "on")) +``` + +This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. + + # The virtual charm root Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The diff --git a/scenario/context.py b/scenario/context.py index c72e7f5a8..2e0c5779f 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -114,29 +114,13 @@ def _record_status(self, status: "Status", is_app: bool): else: self.unit_status_history.append(status.unit) - def _check_event(self, event: Union["Event", str], allow_action=False) -> "Event": - """Validate the event and cast to Event.""" - if isinstance(event, str): - event = Event(event) - - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event | str, got {type(event)}") - - if not allow_action and event._is_action_event: - raise InvalidEventError( - "Cannot Context.run() action events. " - "Use Context.run_action instead.", - ) - - return event - def run( self, event: Union["Event", str], state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -150,16 +134,34 @@ def run( instantiated charm. Will receive the charm instance as only positional argument. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - :arg owner_path: Path to the ``Object`` that owns this event. E.g. ('foo', 'bar') -> + :arg event_owner_path: Path to the ``Object`` that owns this event. E.g. ('foo', 'bar') -> will emit the event it can find at ``.foo.bar.on``. """ - event = self._check_event(event, allow_action=False) + """Validate the event and cast to Event.""" + if isinstance(event_owner_path, str): + raise TypeError( + "event_owner_path cannot be a string, " + "it should be any other Sequence type of strings", + ) + + if isinstance(event, str): + event = Event(event) + + if not isinstance(event, Event): + raise InvalidEventError(f"Expected Event | str, got {type(event)}") + + if event._is_action_event: + raise InvalidEventError( + "Cannot Context.run() action events. " + "Use Context.run_action instead.", + ) + return self._run( event, state=state, pre_event=pre_event, post_event=post_event, - owner_path=owner_path, + event_owner_path=event_owner_path, ) def run_action( @@ -218,7 +220,7 @@ def _run( state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ) -> "State": runtime = Runtime( charm_spec=self.charm_spec, @@ -232,5 +234,5 @@ def _run( pre_event=pre_event, post_event=post_event, context=self, - owner_path=owner_path, + event_owner_path=event_owner_path, ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 52b8ad182..27e95ef2b 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -40,11 +40,11 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: obj = getattr(obj, step) except AttributeError: raise BadOwnerPath( - f"owner_path {path!r} invalid: {step!r} leads to nowhere.", + f"event_owner_path {path!r} invalid: {step!r} leads to nowhere.", ) if not isinstance(obj, ops.ObjectEvents): raise BadOwnerPath( - f"owner_path {path!r} invalid: does not lead to " + f"event_owner_path {path!r} invalid: does not lead to " f"an ObjectEvents instance.", ) return obj @@ -53,16 +53,16 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: def _emit_charm_event( charm: "CharmBase", event_name: str, - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ): """Emits a charm event based on a Juju event name. Args: charm: A charm instance to emit an event from. event_name: A Juju event name to emit on a charm. - owner_path: Event source lookup path. + event_owner_path: Event source lookup path. """ - owner = _get_owner(charm, owner_path) if owner_path else charm.on + owner = _get_owner(charm, event_owner_path) if event_owner_path else charm.on try: event_to_emit = getattr(owner, event_name) @@ -86,7 +86,7 @@ def main( event: "Event" = None, context: "Context" = None, charm_spec: "_CharmSpec" = None, - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type @@ -141,7 +141,7 @@ def main( if pre_event: pre_event(charm) - _emit_charm_event(charm, dispatcher.event_name, owner_path) + _emit_charm_event(charm, dispatcher.event_name, event_owner_path) if post_event: post_event(charm) diff --git a/scenario/runtime.py b/scenario/runtime.py index 9484e95b8..277eb226d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -41,7 +41,7 @@ logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( - r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", + r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", ) EVENT_REGEX = re.compile(_event_regex) @@ -333,7 +333,7 @@ def exec( context: "Context", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -388,7 +388,7 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type), ), - owner_path=owner_path, + event_owner_path=event_owner_path, ) except NoObserverError: raise # propagate along diff --git a/scenario/state.py b/scenario/state.py index 21bd5cd1a..da7b54ba3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -795,7 +795,7 @@ def _update_status( class StoredState(_DCBase): # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. - owner_path: Optional[str] + event_owner_path: Optional[str] name: str = "_stored" content: Dict[str, Any] = dataclasses.field(default_factory=dict) @@ -804,7 +804,7 @@ class StoredState(_DCBase): @property def handle_path(self): - return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" + return f"{self.event_owner_path or ''}/{self.data_type_name}[{self.name}]" @dataclasses.dataclass(frozen=True) diff --git a/tests/helpers.py b/tests/helpers.py index 61e7b58e3..f4e656f97 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -37,7 +37,7 @@ def trigger( config: Optional[Dict[str, Any]] = None, charm_root: Optional[Dict["PathLike", "PathLike"]] = None, juju_version: str = "3.0", - owner_path: Sequence[str] = None, + event_owner_path: Sequence[str] = None, ) -> "State": ctx = Context( charm_type=charm_type, @@ -52,5 +52,5 @@ def trigger( state=state, pre_event=pre_event, post_event=post_event, - owner_path=owner_path, + event_owner_path=event_owner_path, ) diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 876948a4b..58158c4c2 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -142,11 +142,7 @@ def _on_foo(self, e): MyCharm._foo_called = True trigger( - State(), - "foo", - MyCharm, - meta=MyCharm.META, - owner_path=('obj', 'my_on') + State(), "foo", MyCharm, meta=MyCharm.META, event_owner_path=("obj", "my_on") ) assert MyCharm._foo_called From fe16419de8d1bbfef3d04fd06ac403450d7fba0a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 22 Jun 2023 16:53:06 +0200 Subject: [PATCH 261/546] reverted overzealous storedstate owner_path replace --- README.md | 2 +- scenario/runtime.py | 2 +- scenario/state.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aa81b170b..c85192ad2 100644 --- a/README.md +++ b/README.md @@ -874,7 +874,7 @@ class MyCharmType(CharmBase): state = State(stored_state=[ StoredState( - event_owner_path="MyCharmType", + owner_path="MyCharmType", name="my_stored_state", content={ 'foo': 'bar', diff --git a/scenario/runtime.py b/scenario/runtime.py index 277eb226d..6c2841cf7 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -41,7 +41,7 @@ logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( - r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", + r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", ) EVENT_REGEX = re.compile(_event_regex) diff --git a/scenario/state.py b/scenario/state.py index da7b54ba3..21bd5cd1a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -795,7 +795,7 @@ def _update_status( class StoredState(_DCBase): # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. - event_owner_path: Optional[str] + owner_path: Optional[str] name: str = "_stored" content: Dict[str, Any] = dataclasses.field(default_factory=dict) @@ -804,7 +804,7 @@ class StoredState(_DCBase): @property def handle_path(self): - return f"{self.event_owner_path or ''}/{self.data_type_name}[{self.name}]" + return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" @dataclasses.dataclass(frozen=True) From 8a706ed8def8967bbcc300c46edf534c0d8bb460 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 23 Jun 2023 09:19:11 +0200 Subject: [PATCH 262/546] ben's nit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 906d5ad23..6f6b2546d 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ from ops.model import ActiveStatus from scenario import State, Status # ... -ctx.run('start', State(unit_status=ActiveStatus('foo')))) +ctx.run('start', State(unit_status=ActiveStatus('foo'))) assert ctx.unit_status_history == [ ActiveStatus('foo'), # now the first status is active: 'foo'! # ... From 2343cbb79a65e0b53a07a07c33045640e287e21a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 23 Jun 2023 12:28:40 +0200 Subject: [PATCH 263/546] stored state default --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 21bd5cd1a..0b1d2cf8a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -833,7 +833,7 @@ class State(_DCBase): # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) - stored_state: List["StoredState"] = dataclasses.field(default_factory=dict) + stored_state: List["StoredState"] = dataclasses.field(default_factory=list) def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): From 9dbb6b6643609590566037d3f0a4a28dfc9fa25a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 23 Jun 2023 13:24:39 +0200 Subject: [PATCH 264/546] type error in __init__.__all__ --- scenario/__init__.py | 46 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index a8e39ced6..fe2252ef2 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -27,27 +27,27 @@ ) __all__ = [ - Action, - ActionOutput, - Context, - deferred, - StateValidationError, - Secret, - ParametrizedEvent, - RelationBase, - Relation, - SubordinateRelation, - PeerRelation, - Model, - ExecOutput, - Mount, - Container, - Address, - BindAddress, - Network, - StoredState, - State, - DeferredEvent, - Event, - InjectRelation, + "Action", + "ActionOutput", + "Context", + "deferred", + "StateValidationError", + "Secret", + "ParametrizedEvent", + "RelationBase", + "Relation", + "SubordinateRelation", + "PeerRelation", + "Model", + "ExecOutput", + "Mount", + "Container", + "Address", + "BindAddress", + "Network", + "StoredState", + "State", + "DeferredEvent", + "Event", + "InjectRelation", ] From 128bd213a1b44926b5c89fe82f34cacb5386eed8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 23 Jun 2023 15:42:57 +0200 Subject: [PATCH 265/546] missing headers --- scenario/consistency_checker.py | 3 +++ scenario/fs_mocks.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 230d26e87..9a2eb4794 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import os from collections import Counter from collections.abc import Sequence diff --git a/scenario/fs_mocks.py b/scenario/fs_mocks.py index a47460b89..c1b54cf40 100644 --- a/scenario/fs_mocks.py +++ b/scenario/fs_mocks.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. import pathlib from typing import Dict From 68819c78f113d9b569f5b3e165d58db03bf6c1a5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 27 Jun 2023 11:06:43 +0200 Subject: [PATCH 266/546] refactored to use dotted event path syntax --- README.md | 5 +-- scenario/context.py | 24 +------------ scenario/ops_main_mock.py | 7 ++-- scenario/runtime.py | 3 -- scenario/state.py | 36 ++++++++++++++------ tests/helpers.py | 2 -- tests/test_e2e/test_custom_event_triggers.py | 4 +-- 7 files changed, 34 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index c85192ad2..f3836e138 100644 --- a/README.md +++ b/README.md @@ -902,15 +902,16 @@ Context(...).run("ingress_provided", State()) # raises scenario.ops_main_mock.N ``` This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but since the event is defined on another Object, it will fail to find it. -You can pass an `event_owner_path` argument to tell Scenario where to find the event source. +You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: ```python from scenario import Context, State -Context(...).run("ingress_provided", State(), event_owner_path=("my_charm_lib", "on")) +Context(...).run("my_charm_lib.on.ingress_provided", State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. +(always omit the 'root', i.e. the charm framework key, from the path) # The virtual charm root diff --git a/scenario/context.py b/scenario/context.py index 2e0c5779f..80600d98a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -2,17 +2,7 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. from collections import namedtuple -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - List, - Optional, - Sequence, - Type, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union from ops import EventBase @@ -120,7 +110,6 @@ def run( state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - event_owner_path: Sequence[str] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -134,16 +123,8 @@ def run( instantiated charm. Will receive the charm instance as only positional argument. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - :arg event_owner_path: Path to the ``Object`` that owns this event. E.g. ('foo', 'bar') -> - will emit the event it can find at ``.foo.bar.on``. """ """Validate the event and cast to Event.""" - if isinstance(event_owner_path, str): - raise TypeError( - "event_owner_path cannot be a string, " - "it should be any other Sequence type of strings", - ) - if isinstance(event, str): event = Event(event) @@ -161,7 +142,6 @@ def run( state=state, pre_event=pre_event, post_event=post_event, - event_owner_path=event_owner_path, ) def run_action( @@ -220,7 +200,6 @@ def _run( state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - event_owner_path: Sequence[str] = None, ) -> "State": runtime = Runtime( charm_spec=self.charm_spec, @@ -234,5 +213,4 @@ def _run( pre_event=pre_event, post_event=post_event, context=self, - event_owner_path=event_owner_path, ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 27e95ef2b..87e149e30 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -53,7 +53,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: def _emit_charm_event( charm: "CharmBase", event_name: str, - event_owner_path: Sequence[str] = None, + event: "Event" = None, ): """Emits a charm event based on a Juju event name. @@ -62,7 +62,7 @@ def _emit_charm_event( event_name: A Juju event name to emit on a charm. event_owner_path: Event source lookup path. """ - owner = _get_owner(charm, event_owner_path) if event_owner_path else charm.on + owner = _get_owner(charm, event.owner_path) if event else charm.on try: event_to_emit = getattr(owner, event_name) @@ -86,7 +86,6 @@ def main( event: "Event" = None, context: "Context" = None, charm_spec: "_CharmSpec" = None, - event_owner_path: Sequence[str] = None, ): """Set up the charm and dispatch the observed event.""" charm_class = charm_spec.charm_type @@ -141,7 +140,7 @@ def main( if pre_event: pre_event(charm) - _emit_charm_event(charm, dispatcher.event_name, event_owner_path) + _emit_charm_event(charm, dispatcher.event_name, event) if post_event: post_event(charm) diff --git a/scenario/runtime.py b/scenario/runtime.py index 6c2841cf7..318936330 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -14,7 +14,6 @@ Dict, List, Optional, - Sequence, Type, TypeVar, Union, @@ -333,7 +332,6 @@ def exec( context: "Context", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - event_owner_path: Sequence[str] = None, ) -> "State": """Runs an event with this state as initial state on a charm. @@ -388,7 +386,6 @@ def exec( charm_spec=self._charm_spec.replace( charm_type=self._wrap(charm_type), ), - event_owner_path=event_owner_path, ) except NoObserverError: raise # propagate along diff --git a/scenario/state.py b/scenario/state.py index 21bd5cd1a..b8f7bbbdb 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -271,7 +271,7 @@ def _validate_databag(self, databag: dict): def changed_event(self) -> "Event": """Sugar to generate a -relation-changed event.""" return Event( - name=normalize_name(self.endpoint + "-relation-changed"), + path=normalize_name(self.endpoint + "-relation-changed"), relation=self, ) @@ -279,7 +279,7 @@ def changed_event(self) -> "Event": def joined_event(self) -> "Event": """Sugar to generate a -relation-joined event.""" return Event( - name=normalize_name(self.endpoint + "-relation-joined"), + path=normalize_name(self.endpoint + "-relation-joined"), relation=self, ) @@ -287,7 +287,7 @@ def joined_event(self) -> "Event": def created_event(self) -> "Event": """Sugar to generate a -relation-created event.""" return Event( - name=normalize_name(self.endpoint + "-relation-created"), + path=normalize_name(self.endpoint + "-relation-created"), relation=self, ) @@ -295,7 +295,7 @@ def created_event(self) -> "Event": def departed_event(self) -> "Event": """Sugar to generate a -relation-departed event.""" return Event( - name=normalize_name(self.endpoint + "-relation-departed"), + path=normalize_name(self.endpoint + "-relation-departed"), relation=self, ) @@ -303,7 +303,7 @@ def departed_event(self) -> "Event": def broken_event(self) -> "Event": """Sugar to generate a -relation-broken event.""" return Event( - name=normalize_name(self.endpoint + "-relation-broken"), + path=normalize_name(self.endpoint + "-relation-broken"), relation=self, ) @@ -635,7 +635,7 @@ def pebble_ready_event(self): "you **can** fire pebble-ready while the container cannot connect, " "but that's most likely not what you want.", ) - return Event(name=normalize_name(self.name + "-pebble-ready"), container=self) + return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) @dataclasses.dataclass(frozen=True) @@ -948,7 +948,7 @@ def name(self): @dataclasses.dataclass(frozen=True) class Event(_DCBase): - name: str + path: str args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) @@ -971,6 +971,8 @@ class Event(_DCBase): # - pebble? # - action? + _owner_path: List[str] = dataclasses.field(default_factory=list) + def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": if remote_unit_id and not self._is_relation_event: raise ValueError( @@ -980,11 +982,25 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": return self.replace(relation_remote_unit_id=remote_unit_id) def __post_init__(self): - if "-" in self.name: - logger.warning(f"Only use underscores in event names. {self.name!r}") + if "-" in self.path: + logger.warning(f"Only use underscores in event paths. {self.path!r}") + path = normalize_name(self.path) # bypass frozen dataclass - object.__setattr__(self, "name", normalize_name(self.name)) + object.__setattr__(self, "path", path) + + @property + def name(self) -> str: + """Event name.""" + return self.path.split(".")[-1] + + @property + def owner_path(self) -> List[str]: + """Path to the ObjectEvents instance owning this event. + + If this event is defined on the toplevel charm class, it should be ['on']. + """ + return self.path.split(".")[:-1] or ["on"] @property def _is_relation_event(self) -> bool: diff --git a/tests/helpers.py b/tests/helpers.py index f4e656f97..5f85fb3b5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -37,7 +37,6 @@ def trigger( config: Optional[Dict[str, Any]] = None, charm_root: Optional[Dict["PathLike", "PathLike"]] = None, juju_version: str = "3.0", - event_owner_path: Sequence[str] = None, ) -> "State": ctx = Context( charm_type=charm_type, @@ -52,5 +51,4 @@ def trigger( state=state, pre_event=pre_event, post_event=post_event, - event_owner_path=event_owner_path, ) diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py index 58158c4c2..1c6f07cd4 100644 --- a/tests/test_e2e/test_custom_event_triggers.py +++ b/tests/test_e2e/test_custom_event_triggers.py @@ -141,8 +141,6 @@ def __init__(self, *args, **kwargs): def _on_foo(self, e): MyCharm._foo_called = True - trigger( - State(), "foo", MyCharm, meta=MyCharm.META, event_owner_path=("obj", "my_on") - ) + trigger(State(), "obj.my_on.foo", MyCharm, meta=MyCharm.META) assert MyCharm._foo_called From 6423b15032c3116a953ce6f9f6fa4fb49ec6ede1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 27 Jun 2023 12:28:33 +0200 Subject: [PATCH 267/546] secret events no suffix --- scenario/state.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index e954dc976..e33c5d213 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -55,11 +55,11 @@ "_storage_attached", } -SECRET_EVENTS_SUFFIX = { - "_secret_changed", - "_secret_removed", - "_secret_rotate", - "_secret_expired", +SECRET_EVENTS = { + "secret_changed", + "secret_removed", + "secret_rotate", + "secret_expired", } META_EVENTS = { @@ -1004,7 +1004,7 @@ def _is_action_event(self) -> bool: @property def _is_secret_event(self) -> bool: """Whether the event name indicates that this is a secret event.""" - return any(self.name.endswith(suffix) for suffix in SECRET_EVENTS_SUFFIX) + return self.name in SECRET_EVENTS @property def _is_storage_event(self) -> bool: From 530faea856f6ca7f0effe8e7d21b078becdfdb7c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 28 Jun 2023 08:17:27 +0200 Subject: [PATCH 268/546] fixed tox env, python 3.8 compat fix --- scenario/runtime.py | 21 ++++++++++++++------- tox.ini | 24 +++++++++++++----------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 318936330..4da17094f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -5,22 +5,25 @@ import os import re import tempfile +import typing from contextlib import contextmanager from pathlib import Path from typing import ( TYPE_CHECKING, Any, Callable, + ContextManager, Dict, List, Optional, + Tuple, Type, TypeVar, Union, ) import yaml -from ops.framework import _event_regex +from ops.framework import EventBase, _event_regex from ops.storage import SQLiteStorage from scenario.capture_events import capture_events @@ -254,7 +257,7 @@ class WrappedCharm(charm_type): # type: ignore return WrappedCharm @contextmanager - def _virtual_charm_root(self): + def _virtual_charm_root(self) -> typing.ContextManager[Path]: # If we are using runtime on a real charm, we can make some assumptions about the # directory structure we are going to find. # If we're, say, dynamically defining charm types and doing tests on them, we'll have to @@ -325,6 +328,14 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): stored_state = store.get_stored_state() return state.replace(deferred=deferred, stored_state=stored_state) + @contextmanager + def _exec_ctx(self) -> ContextManager[Tuple[Path, List[EventBase]]]: + """python 3.8 compatibility shim""" + with self._virtual_charm_root() as temporary_charm_root: + # todo allow customizing capture_events + with capture_events() as captured: + yield (temporary_charm_root, captured) + def exec( self, state: "State", @@ -355,11 +366,7 @@ def exec( output_state = state.copy() logger.info(" - generating virtual charm root") - with ( - self._virtual_charm_root() as temporary_charm_root, - # todo allow customizing capture_events - capture_events() as captured, - ): + with self._exec_ctx() as (temporary_charm_root, captured): logger.info(" - initializing storage") self._initialize_storage(state, temporary_charm_root) diff --git a/tox.ini b/tox.ini index 0ba7826bb..c8c87c188 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,6 @@ env_list = py38 py37 py36 - unit lint lint-tests skip_missing_interpreters = true @@ -16,16 +15,10 @@ src_path = {toxinidir}/scenario tst_path = {toxinidir}/tests all_path = {[vars]src_path}, {[vars]tst_path} -[testenv:lint] -description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. -skip_install = true -deps = - pre-commit>=3.2.2 -commands = - pre-commit run --all-files {posargs} - python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' - -[testenv:unit] +[testenv] +# don't install as a sdist, instead, install as wheel (create wheel once), then install in all envs +package = wheel +wheel_build_env = .pkg description = unit tests deps = coverage[toml] @@ -37,6 +30,15 @@ commands = -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} coverage report +[testenv:lint] +description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. +skip_install = true +deps = + pre-commit>=3.2.2 +commands = + pre-commit run --all-files {posargs} + python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' + [testenv:lint-tests] description = Lint test files. skip_install = true From c56e01a0d54aaae1ff7f2ec836fdeb99a80f2d9a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 28 Jun 2023 08:18:32 +0200 Subject: [PATCH 269/546] py3.8 badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ba65fc999..679bfd0c2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io) [![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack) [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) +[![Python >= 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) Scenario is a state-transition, functional testing framework for Operator Framework charms. From 3a7a4bf7ae724aff1a9883303b68d494f6ccd727 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 28 Jun 2023 15:08:45 +0200 Subject: [PATCH 270/546] removed dependency from trigger in sequences.py --- scenario/sequences.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scenario/sequences.py b/scenario/sequences.py index f217bd58f..3230ded94 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -5,6 +5,7 @@ from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union +from scenario import Context from scenario.logger import logger as scenario_logger from scenario.state import ( ATTACH_ALL_STORAGES, @@ -16,7 +17,6 @@ InjectRelation, State, ) -from tests.helpers import trigger if typing.TYPE_CHECKING: from ops.testing import CharmType @@ -111,6 +111,7 @@ def check_builtin_sequences( """ template = template_state if template_state else State() + out = [] for event, state in generate_builtin_sequences( ( @@ -118,13 +119,13 @@ def check_builtin_sequences( template.replace(leader=False), ), ): - trigger( - state, - event=event, - charm_type=charm_type, - meta=meta, - actions=actions, - config=config, - pre_event=pre_event, - post_event=post_event, + ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) + out.append( + ctx.run( + event, + state=state, + pre_event=pre_event, + post_event=post_event, + ), ) + return out From eee541b116d70596e7fd87942bbc2e1cb1b549a4 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 29 Jun 2023 07:57:31 +0200 Subject: [PATCH 271/546] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 679bfd0c2..ab0979e2b 100644 --- a/README.md +++ b/README.md @@ -897,7 +897,7 @@ The 'proper' way to emit it is to run the event that causes that custom event to However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and the event is emitted at all. -However if you attempt to run that event directly you will get an error: +If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: ```python from scenario import Context, State Context(...).run("ingress_provided", State()) # raises scenario.ops_main_mock.NoObserverError @@ -1009,4 +1009,4 @@ You can also pass a `--format` flag to obtain instead: - a jsonified `State` data structure, for portability - a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event - that you wish to trigger. \ No newline at end of file + that you wish to trigger. From 617be0a27dc373c10f689ae239c19059104a4c1d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 11 Jul 2023 08:17:29 +0200 Subject: [PATCH 272/546] public next_relation_id and fix succession --- scenario/state.py | 28 ++++++++++++++-------------- tests/test_e2e/test_relations.py | 7 +++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index e33c5d213..b91e33db6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -178,9 +178,6 @@ def _update_metadata( object.__setattr__(self, "rotate", rotate) -_RELATION_IDS_CTR = 0 - - def normalize_name(s: str): """Event names need underscores instead of dashes.""" return s.replace("-", "_") @@ -207,16 +204,6 @@ def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) -def _generate_new_relation_id(): - global _RELATION_IDS_CTR - _RELATION_IDS_CTR += 1 - logger.info( - f"relation ID unset; automatically assigning {_RELATION_IDS_CTR}. " - f"If there are problems, pass one manually.", - ) - return _RELATION_IDS_CTR - - @dataclasses.dataclass(frozen=True) class RelationBase(_DCBase): endpoint: str @@ -224,8 +211,21 @@ class RelationBase(_DCBase): # we can derive this from the charm's metadata interface: str = None + NEXT_RELATION_ID = 1 + + @staticmethod + def _next_relation_id(): + cur = RelationBase.NEXT_RELATION_ID + RelationBase.NEXT_RELATION_ID += 1 + logger.info( + f"relation ID unset; automatically assigning " + f"{cur}. " + f"If there are problems, pass one manually.", + ) + return cur + # Every new Relation instance gets a new one, if there's trouble, override. - relation_id: int = dataclasses.field(default_factory=_generate_new_relation_id) + relation_id: int = dataclasses.field(default_factory=_next_relation_id) local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index ddf00e439..f84ebf0f8 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -303,3 +303,10 @@ def post_event(charm: CharmBase): def test_cannot_instantiate_relationbase(): with pytest.raises(RuntimeError): RelationBase("") + + +def test_relation_ids(): + initial_id = RelationBase.NEXT_RELATION_ID + for i in range(10): + rel = Relation("foo") + assert rel.relation_id == initial_id + i From f822e3df32ab804d33280cad1268b05c0df60917 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 19 Jul 2023 17:06:14 +0200 Subject: [PATCH 273/546] dedup dcbase.replace --- scenario/state.py | 4 +++- tests/test_dcbase.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/test_dcbase.py diff --git a/scenario/state.py b/scenario/state.py index e33c5d213..1aca605e6 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -80,9 +80,11 @@ class StateValidationError(RuntimeError): @dataclasses.dataclass(frozen=True) class _DCBase: def replace(self, *args, **kwargs): - return dataclasses.replace(self, *args, **kwargs) + """Produce a deep copy of this class, with some arguments replaced with new ones.""" + return dataclasses.replace(self.copy(), *args, **kwargs) def copy(self) -> "Self": + """Produce a deep copy of this object.""" return copy.deepcopy(self) diff --git a/tests/test_dcbase.py b/tests/test_dcbase.py new file mode 100644 index 000000000..fd5ff872c --- /dev/null +++ b/tests/test_dcbase.py @@ -0,0 +1,41 @@ +import dataclasses +from typing import Dict, List + +from scenario.state import _DCBase + + +@dataclasses.dataclass(frozen=True) +class Foo(_DCBase): + a: int + b: List[int] + c: Dict[int, List[int]] + + +def test_base_case(): + l = [1, 2] + l1 = [1, 2, 3] + d = {1: l1} + f = Foo(1, l, d) + g = f.replace(a=2) + + assert g.a == 2 + assert g.b == l + assert g.c == d + assert g.c[1] == l1 + + +def test_dedup_on_replace(): + l = [1, 2] + l1 = [1, 2, 3] + d = {1: l1} + f = Foo(1, l, d) + g = f.replace(a=2) + + l.append(3) + l1.append(4) + d[2] = "foobar" + + assert g.a == 2 + assert g.b == [1, 2] + assert g.c == {1: [1, 2, 3]} + assert g.c[1] == [1, 2, 3] From aa1ad7cba71f3a7b90ecd784fccbeebd6a961a95 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 19 Jul 2023 17:06:40 +0200 Subject: [PATCH 274/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f9bcc34da..e7e576b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0" +version = "4.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 19e50d4ff7be0b6eee7304f1f61f1adfda3f39d7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 20 Jul 2023 09:44:23 +0200 Subject: [PATCH 275/546] extended consistency checks --- README.md | 42 ++++++++++++++++++++++++++++++- pyproject.toml | 2 +- scenario/consistency_checker.py | 13 ++++++++++ scenario/state.py | 11 ++++---- tests/test_consistency_checker.py | 38 ++++++++++++++++++++++++++++ tests/test_e2e/test_relations.py | 2 +- 6 files changed, 100 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ab0979e2b..96023dced 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ relation = SubordinateRelation( relation.remote_unit_name # "zookeeper/42" ``` -## Triggering Relation Events +### Triggering Relation Events If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the event from one of its aptly-named properties: @@ -450,6 +450,30 @@ changed_event = Event('foo-relation-changed', relation=relation) The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. + +### Working with relation IDs + +Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. +To inspect the ID the next relation instance will have, you can call `Relation.next_relation_id`. + +```python +from scenario import Relation +next_id = Relation.next_relation_id(update=False) +rel = Relation('foo') +assert rel.relation_id == next_id +``` + +This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: + +```python +from scenario import Relation +rel = Relation('foo') +rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=Relation.next_relation_id()) +assert rel2.relation_id == rel.relation_id + 1 +``` + +If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. + ### Additional event parameters All relation events have some additional metadata that does not belong in the Relation object, such as, for a @@ -961,6 +985,22 @@ Do this, and you will be able to set up said directory as you like before the ch contents after the charm has run. Do keep in mind that the metadata files will be overwritten by Scenario, and therefore ignored. +# Immutability + +All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen dataclasses). + +This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the state that you obtain in return is a different instance, and all parts of it have been (deep)copied. +This ensures that you can do delta-based comparison of states without worrying about them being mutated by scenario. + +If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use the `replace` api. + +```python +from scenario import Relation +relation = Relation('foo', remote_app_data={"1":"2"}) +# make a copy of relation, but with remote_app_data set to {"3", "4"} +relation2 = relation.replace(remote_app_data={"3", "4"}) +``` + # Consistency checks A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. For diff --git a/pyproject.toml b/pyproject.toml index f9bcc34da..e7e576b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0" +version = "4.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 9a2eb4794..daa992e5d 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -333,10 +333,23 @@ def _get_relations(r): f"expecting relation to be of type PeerRelation, got {type(relation)}", ) + known_endpoints = [a[0] for a in all_relations_meta] + for relation in state.relations: + if not (ep := relation.endpoint) in known_endpoints: + errors.append(f"relation endpoint {ep} is not declared in metadata.") + + seen_ids = set() for endpoint, relation_meta in all_relations_meta: expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: + if relation.relation_id in seen_ids: + errors.append( + f"duplicate relation ID: {relation.relation_id} is claimed " + f"by multiple Relation instances", + ) + + seen_ids.add(relation.relation_id) is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( diff --git a/scenario/state.py b/scenario/state.py index b91e33db6..024721d80 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -211,12 +211,13 @@ class RelationBase(_DCBase): # we can derive this from the charm's metadata interface: str = None - NEXT_RELATION_ID = 1 + _next_relation_id_counter = 1 @staticmethod - def _next_relation_id(): - cur = RelationBase.NEXT_RELATION_ID - RelationBase.NEXT_RELATION_ID += 1 + def next_relation_id(update=True): + cur = RelationBase._next_relation_id_counter + if update: + RelationBase._next_relation_id_counter += 1 logger.info( f"relation ID unset; automatically assigning " f"{cur}. " @@ -225,7 +226,7 @@ def _next_relation_id(): return cur # Every new Relation instance gets a new one, if there's trouble, override. - relation_id: int = dataclasses.field(default_factory=_next_relation_id) + relation_id: int = dataclasses.field(default_factory=next_relation_id) local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 9b9b92b11..c5000d44b 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -259,3 +259,41 @@ def test_action_params_type(): MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "boolean"}}}} ), ) + + +def test_duplicate_relation_ids(): + assert_inconsistent( + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "requires": {"foo": {"interface": "foo"}, "bar": {"interface": "bar"}} + }, + ), + ) + + +def test_relation_without_endpoint(): + assert_inconsistent( + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + ), + Event("start"), + _CharmSpec(MyCharm, meta={}), + ) + + assert_consistent( + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "requires": {"foo": {"interface": "foo"}, "bar": {"interface": "bar"}} + }, + ), + ) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index f84ebf0f8..7460e4169 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -306,7 +306,7 @@ def test_cannot_instantiate_relationbase(): def test_relation_ids(): - initial_id = RelationBase.NEXT_RELATION_ID + initial_id = RelationBase._next_relation_id_counter for i in range(10): rel = Relation("foo") assert rel.relation_id == initial_id + i From 4c900c896dc508ddb448be60e1e4d968aa9623fd Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 20 Jul 2023 09:46:15 +0200 Subject: [PATCH 276/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e7e576b0e..c67ea9119 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0.1" +version = "4.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 7d4208dcbee9df85fa04d6f92d9ceae9876cfde1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 20 Jul 2023 09:47:35 +0200 Subject: [PATCH 277/546] fixed itest --- tests/test_consistency_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index c5000d44b..a1bf26b4d 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -287,7 +287,7 @@ def test_relation_without_endpoint(): assert_consistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)] ), Event("start"), _CharmSpec( From 7ad5df1fdd65a895febb98e7121b6ef275168cf6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jul 2023 11:43:01 +0200 Subject: [PATCH 278/546] relation id staticmethod fix --- scenario/state.py | 25 +++++++++++-------------- tests/test_e2e/test_relations.py | 4 +++- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 42ee70ddb..078ab7780 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -206,6 +206,17 @@ def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": return self().deferred(handler=handler, event_id=event_id) +_next_relation_id_counter = 1 + + +def next_relation_id(update=True): + global _next_relation_id_counter + cur = _next_relation_id_counter + if update: + _next_relation_id_counter += 1 + return cur + + @dataclasses.dataclass(frozen=True) class RelationBase(_DCBase): endpoint: str @@ -213,20 +224,6 @@ class RelationBase(_DCBase): # we can derive this from the charm's metadata interface: str = None - _next_relation_id_counter = 1 - - @staticmethod - def next_relation_id(update=True): - cur = RelationBase._next_relation_id_counter - if update: - RelationBase._next_relation_id_counter += 1 - logger.info( - f"relation ID unset; automatically assigning " - f"{cur}. " - f"If there are problems, pass one manually.", - ) - return cur - # Every new Relation instance gets a new one, if there's trouble, override. relation_id: int = dataclasses.field(default_factory=next_relation_id) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 7460e4169..00f4926f6 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -306,7 +306,9 @@ def test_cannot_instantiate_relationbase(): def test_relation_ids(): - initial_id = RelationBase._next_relation_id_counter + from scenario.state import _next_relation_id_counter + + initial_id = _next_relation_id_counter for i in range(10): rel = Relation("foo") assert rel.relation_id == initial_id + i From c4c231cc4f2bd1aed289f8a652f3a3527a93c6d3 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jul 2023 11:43:43 +0200 Subject: [PATCH 279/546] updated readme --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 96023dced..1fc521035 100644 --- a/README.md +++ b/README.md @@ -454,11 +454,12 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. -To inspect the ID the next relation instance will have, you can call `Relation.next_relation_id`. +To inspect the ID the next relation instance will have, you can call `state.next_relation_id`. ```python from scenario import Relation -next_id = Relation.next_relation_id(update=False) +from scenario.state import next_relation_id +next_id = next_relation_id(update=False) rel = Relation('foo') assert rel.relation_id == next_id ``` @@ -467,8 +468,9 @@ This can be handy when using `replace` to create new relations, to avoid relatio ```python from scenario import Relation +from scenario.state import next_relation_id rel = Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=Relation.next_relation_id()) +rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=next_relation_id()) assert rel2.relation_id == rel.relation_id + 1 ``` From 9bf6084c9844d563599959374e8dffeb05d4c219 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jul 2023 11:44:12 +0200 Subject: [PATCH 280/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c67ea9119..f2053f4c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0.2" +version = "4.0.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From abcf442b9b9fcc5ab6046198e4e2091fb1fc4c07 Mon Sep 17 00:00:00 2001 From: Ghislain Bourgeois Date: Fri, 21 Jul 2023 07:42:35 -0400 Subject: [PATCH 281/546] Bump PyYAML dependency and make it less strict --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2053f4c2..6c50e2521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ keywords = ["juju", "test"] dependencies = [ "ops>=2.0", - "PyYAML==6.0", + "PyYAML>=6.0.1", "typer==0.7.0", ] readme = "README.md" From 5836778829084fab86f05e6548b383bf42456ffb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jul 2023 14:05:56 +0200 Subject: [PATCH 282/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2053f4c2..ebce48281 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0.3" +version = "4.0.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 2e2aca9b62f1504c08102d701df3098e13a8bccc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 28 Jul 2023 14:38:11 +0200 Subject: [PATCH 283/546] fixed env cleanup on charm error --- scenario/runtime.py | 6 +++--- tests/test_runtime.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 4da17094f..9a2662c0e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -159,7 +159,7 @@ def __init__( @staticmethod def _cleanup_env(env): # TODO consider cleaning up env on __delete__, but ideally you should be - # running this in a clean venv or a container anyway. + # running this in a clean env or a container anyway. # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. for key in env: # os.unsetenv does not work !? @@ -403,8 +403,8 @@ def exec( finally: logger.info(" - Exited ops.main.") - logger.info(" - Clearing env") - self._cleanup_env(env) + logger.info(" - Clearing env") + self._cleanup_env(env) logger.info(" - closing storage") output_state = self._close_storage(output_state, temporary_charm_root) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 9ee2d74c1..9ec8ec946 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import MagicMock @@ -8,8 +9,8 @@ from ops.framework import EventBase from scenario import Context -from scenario.runtime import Runtime -from scenario.state import Event, State, _CharmSpec +from scenario.runtime import Runtime, UncaughtCharmError +from scenario.state import Event, Relation, State, _CharmSpec def charm_type(): @@ -117,3 +118,30 @@ def post_event(charm: CharmBase): post_event=post_event, context=Context(my_charm_type, meta=meta), ) + + +def test_env_cleanup_on_charm_error(): + meta = {"name": "frank", "requires": {"box": {"interface": "triangle"}}} + + my_charm_type = charm_type() + + runtime = Runtime( + _CharmSpec( + my_charm_type, + meta=meta, + ), + ) + + def post_event(charm: CharmBase): + assert os.getenv("JUJU_REMOTE_APP") + raise TypeError + + with pytest.raises(UncaughtCharmError): + runtime.exec( + state=State(), + event=Event("box_relation_changed", relation=Relation("box")), + post_event=post_event, + context=Context(my_charm_type, meta=meta), + ) + + assert os.getenv("JUJU_REMOTE_APP", None) is None From 8176ad3600df41f1882239139e37d8771f43acd8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 28 Jul 2023 14:38:59 +0200 Subject: [PATCH 284/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 21bf7e5c4..a2ce24162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0.4" +version = "4.0.4.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From dc42a986b834e8aad761a28e8e54fe4f4a089fb0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Sep 2023 12:01:29 +0200 Subject: [PATCH 285/546] removed dependency from ops testing in container storage mounts --- README.md | 10 +++------ scenario/context.py | 9 ++++++-- scenario/fs_mocks.py | 39 ----------------------------------- scenario/mocking.py | 30 ++++++++++++++++++++++----- scenario/state.py | 18 ++++++---------- tests/test_e2e/test_pebble.py | 38 +++++++++++++++++++++++++--------- 6 files changed, 69 insertions(+), 75 deletions(-) delete mode 100644 scenario/fs_mocks.py diff --git a/README.md b/README.md index 1fc521035..b21c1c017 100644 --- a/README.md +++ b/README.md @@ -530,12 +530,8 @@ from scenario.state import Container, State, Mount local_file = Path('/path/to/local/real/file.txt') -state = State(containers=[ - Container(name="foo", - can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file)}) -] -) +container = Container(name="foo", can_connect=True, mounts={'local': Mount('/local/share/config.yaml', local_file)}) +state = State(containers=[container]) ``` In this case, if the charm were to: @@ -547,7 +543,7 @@ def _on_start(self, _): ``` then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping -strings and passing them to the charm via the container. +data and passing it to the charm via the container. `container.push` works similarly, so you can write a test like: diff --git a/scenario/context.py b/scenario/context.py index 3e4e14613..3449a7912 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import tempfile from collections import namedtuple +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union from ops import EventBase @@ -11,8 +13,6 @@ from scenario.state import Action, Event, _CharmSpec if TYPE_CHECKING: - from pathlib import Path - from ops.testing import CharmType from scenario.state import JujuLogLine, State, _EntityStatus @@ -84,6 +84,7 @@ def __init__( self.charm_spec = spec self.charm_root = charm_root self.juju_version = juju_version + self._tmp = tempfile.TemporaryDirectory() # streaming side effects from running an event self.juju_log: List["JujuLogLine"] = [] @@ -97,6 +98,10 @@ def __init__( self._action_results = None self._action_failure = "" + def _get_container_root(self, container_name: str): + """Get the path to a tempdir where this container's simulated root will live.""" + return Path(self._tmp.name) / "containers" / container_name + def clear(self): """Cleanup side effects histories.""" self.juju_log = [] diff --git a/scenario/fs_mocks.py b/scenario/fs_mocks.py deleted file mode 100644 index c1b54cf40..000000000 --- a/scenario/fs_mocks.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import pathlib -from typing import Dict - -from ops.testing import _TestingFilesystem, _TestingStorageMount # noqa - - -# todo consider duplicating the filesystem on State.copy() to be able to diff -# and have true state snapshots -class _MockStorageMount(_TestingStorageMount): - def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path): - """Creates a new simulated storage mount. - - Args: - location: The path within simulated filesystem at which this storage will be mounted. - src: The temporary on-disk location where the simulated storage will live. - """ - self._src = src - self._location = location - - try: - # for some reason this fails if src exists, even though exists_ok=True. - super().__init__(location=location, src=src) - except FileExistsError: - pass - - -class _MockFileSystem(_TestingFilesystem): - def __init__(self, mounts: Dict[str, _MockStorageMount]): - super().__init__() - self._mounts = mounts - - def add_mount(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("Cannot mutate mounts; declare them all in State.") - - def remove_mount(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("Cannot mutate mounts; declare them all in State.") diff --git a/scenario/mocking.py b/scenario/mocking.py index 1c8a56dea..9146b86f0 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -4,6 +4,7 @@ import datetime import random from io import StringIO +from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union from ops import pebble @@ -17,7 +18,7 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, PeerRelation +from scenario.state import JujuLogLine, Mount, PeerRelation if TYPE_CHECKING: from scenario.context import Context @@ -75,8 +76,20 @@ def __init__( self._charm_spec = charm_spec def get_pebble(self, socket_path: str) -> "Client": + container_name = socket_path.split("/")[ + 3 + ] # /charm/containers//pebble.socket + container_root = self._context._get_container_root(container_name) + try: + mounts = self._state.get_container(container_name).mounts + except ValueError: + # container not defined in state. + mounts = {} + return _MockPebbleClient( socket_path=socket_path, + container_root=container_root, + mounts=mounts, state=self._state, event=self._event, charm_spec=self._charm_spec, @@ -370,6 +383,8 @@ class _MockPebbleClient(_TestingPebbleClient): def __init__( self, socket_path: str, + container_root: Path, + mounts: Dict[str, Mount], *, state: "State", event: "Event", @@ -380,6 +395,15 @@ def __init__( self._event = event self._charm_spec = charm_spec + # initialize simulated filesystem + container_root.mkdir(parents=True) + for _, mount in mounts.items(): + mounting_dir = container_root / mount.location[1:] + mounting_dir.parent.mkdir(parents=True, exist_ok=True) + mounting_dir.symlink_to(mount.src) + + self._root = container_root + def get_plan(self) -> pebble.Plan: return self._container.plan @@ -397,10 +421,6 @@ def _container(self) -> "ContainerSpec": f"{self.socket_path!r} wrong?", ) - @property - def _fs(self): - return self._container.filesystem - @property def _layers(self) -> Dict[str, pebble.Layer]: return self._container.layers diff --git a/scenario/state.py b/scenario/state.py index 078ab7780..14934356a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -18,7 +18,7 @@ from ops.charm import CharmEvents from ops.model import SecretRotate, StatusBase -from scenario.fs_mocks import _MockFileSystem, _MockStorageMount +# from scenario.fs_mocks import _MockFileSystem, _MockStorageMount from scenario.logger import logger as scenario_logger JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) @@ -30,6 +30,8 @@ from typing_extensions import Self from ops.testing import CharmType + from scenario import Context + PathLike = Union[str, Path] AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] AnyJson = Union[str, bool, dict, int, float, list] @@ -615,17 +617,9 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: infos[name] = info return infos - @property - def filesystem(self) -> "_MockFileSystem": - """Simulated pebble filesystem.""" - mounts = { - name: _MockStorageMount( - src=Path(spec.src), - location=PurePosixPath(spec.location), - ) - for name, spec in self.mounts.items() - } - return _MockFileSystem(mounts=mounts) + def get_filesystem(self, ctx: "Context") -> Path: + """Simulated pebble filesystem in this context.""" + return ctx._get_container_root(self.name) @property def pebble_ready_event(self): diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 15c957ca2..dc792402a 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -7,6 +7,7 @@ from ops.framework import Framework from ops.pebble import ServiceStartup, ServiceStatus +from scenario import Context from scenario.state import Container, ExecOutput, Mount, State from tests.helpers import trigger @@ -114,25 +115,42 @@ def callback(self: CharmBase): container.pull("/foo/bar/baz.txt") td = tempfile.TemporaryDirectory() - state = State( - containers=[ - Container( - name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} - ) - ] + container = Container( + name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} ) + state = State(containers=[container]) - out = trigger( - state, + ctx = Context( charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, + ) + out = ctx.run( event="start", + state=state, post_event=callback, ) if make_dirs: - file = out.get_container("foo").filesystem.open("/foo/bar/baz.txt") - assert file.read() == text + # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt") + + # this is one way to retrieve the file + file = Path(td.name + "/bar/baz.txt") + + # another is: + assert ( + file == Path(out.get_container("foo").mounts["foo"].src) / "bar" / "baz.txt" + ) + + # but that is actually a symlink to the context's root tmp folder: + assert ( + Path(ctx._tmp.name) / "containers" / "foo" / "foo" / "bar" / "baz.txt" + ).read_text() == text + assert file.read_text() == text + + # shortcut for API niceness purposes: + file = container.get_filesystem(ctx) / "foo" / "bar" / "baz.txt" + assert file.read_text() == text + else: # nothing has changed out_purged = out.replace(stored_state=state.stored_state) From 7508f5fe4d60d506da1b47f57b5c4f35c6943661 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Sep 2023 12:10:40 +0200 Subject: [PATCH 286/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 21bf7e5c4..bf7ee2561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "4.0.4" +version = "5.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From da69f65a50490ff151d47c7c506d7ab0f4e27ff0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Sep 2023 12:34:56 +0200 Subject: [PATCH 287/546] added ports api --- scenario/mocking.py | 15 +++++++++++++-- scenario/state.py | 19 +++++++++++++++++++ tests/test_e2e/test_ports.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/test_e2e/test_ports.py diff --git a/scenario/mocking.py b/scenario/mocking.py index 1c8a56dea..ad36dfc73 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -4,7 +4,7 @@ import datetime import random from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union from ops import pebble from ops.model import ( @@ -17,7 +17,7 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, PeerRelation +from scenario.state import JujuLogLine, PeerRelation, Port if TYPE_CHECKING: from scenario.context import Context @@ -74,6 +74,17 @@ def __init__( self._context = context self._charm_spec = charm_spec + def opened_ports(self) -> Set[Port]: + return self._state.opened_ports + + def open_port(self, protocol: str, port: Optional[int] = None): + # fixme: the charm will get hit with a StateValidationError + # here, not the expected ModelError... + self._state.opened_ports.add(Port(protocol, port)) + + def close_port(self, protocol: str, port: Optional[int] = None): + self._state.opened_ports.discard(Port(protocol, port)) + def get_pebble(self, socket_path: str) -> "Client": return _MockPebbleClient( socket_path=socket_path, diff --git a/scenario/state.py b/scenario/state.py index 078ab7780..56b34394e 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -761,6 +761,24 @@ def handle_path(self): return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" +@dataclasses.dataclass(frozen=True) +class Port(_DCBase): + protocol: Literal["tcp", "udp", "icmp"] + port: Optional[int] = None + """The port to open. Required for TCP and UDP; not allowed for ICMP.""" + + def __post_init__(self): + port = self.port + if self.protocol == "icmp" and port: + raise StateValidationError("`port` arg not supported with `icmp` protocol") + elif not port: + raise StateValidationError( + f"`port` arg required with `{self.protocol}` protocol", + ) + if port and not (1 <= port <= 65535): + raise StateValidationError(f"`port` outside bounds [1:65535], got {port}") + + @dataclasses.dataclass(frozen=True) class State(_DCBase): """Represents the juju-owned portion of a unit's state. @@ -776,6 +794,7 @@ class State(_DCBase): relations: List["AnyRelation"] = dataclasses.field(default_factory=list) networks: List[Network] = dataclasses.field(default_factory=list) containers: List[Container] = dataclasses.field(default_factory=list) + opened_ports: Set[Port] = dataclasses.field(default_factory=set) leader: bool = False model: Model = Model() secrets: List[Secret] = dataclasses.field(default_factory=list) diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py new file mode 100644 index 000000000..a8cd05bf1 --- /dev/null +++ b/tests/test_e2e/test_ports.py @@ -0,0 +1,34 @@ +import pytest +from ops import CharmBase + +from scenario import Context, State +from scenario.state import Port + + +class MyCharm(CharmBase): + pass + + +@pytest.fixture +def ctx(): + return Context(MyCharm) + + +def test_open_port(ctx): + def post_event(charm: CharmBase): + charm.unit.open_port("tcp", 12) + + out = ctx.run("start", State(), post_event=post_event) + port = out.opened_ports.pop() + + assert port.protocol == "tcp" + assert port.port == 12 + + +def test_close_port(ctx): + def post_event(charm: CharmBase): + assert charm.unit.opened_ports() + charm.unit.close_port("tcp", 42) + + out = ctx.run("start", State(opened_ports={Port("tcp", 42)}), post_event=post_event) + assert not out.opened_ports From 7932b12698073207bfee9cc5aa00988f99134d14 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Sep 2023 13:33:19 +0200 Subject: [PATCH 288/546] fixed tox env --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c8c87c188..8d5b23e23 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ env_list = py38 py37 py36 + unit lint lint-tests skip_missing_interpreters = true @@ -15,7 +16,7 @@ src_path = {toxinidir}/scenario tst_path = {toxinidir}/tests all_path = {[vars]src_path}, {[vars]tst_path} -[testenv] +[testenv:unit] # don't install as a sdist, instead, install as wheel (create wheel once), then install in all envs package = wheel wheel_build_env = .pkg From ba433a2ffbac9526c901312fb79a40b9c06df189 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 4 Sep 2023 09:08:42 +0200 Subject: [PATCH 289/546] better error on meta not found --- scenario/context.py | 15 +++++++++++++-- scenario/state.py | 9 +++++++++ tests/test_e2e/test_ports.py | 4 ++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 3449a7912..0d6d8c330 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -10,7 +10,7 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime -from scenario.state import Action, Event, _CharmSpec +from scenario.state import Action, Event, MetadataNotFoundError, _CharmSpec if TYPE_CHECKING: from ops.testing import CharmType @@ -32,6 +32,10 @@ class InvalidActionError(InvalidEventError): """raised when something is wrong with the action passed to Context.run_action""" +class ContextSetupError(RuntimeError): + """Raised by Context when setup fails.""" + + class Context: """Scenario test execution context.""" @@ -70,7 +74,14 @@ def __init__( if not any((meta, actions, config)): logger.debug("Autoloading charmspec...") - spec = _CharmSpec.autoload(charm_type) + try: + spec = _CharmSpec.autoload(charm_type) + except MetadataNotFoundError as e: + raise ContextSetupError( + f"Cannot setup scenario with `charm_type`={charm_type}. " + f"Did you forget to pass `meta` to this Context?", + ) from e + else: if not meta: meta = {"name": str(charm_type.__name__)} diff --git a/scenario/state.py b/scenario/state.py index 8de37d110..e8c832881 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -79,6 +79,10 @@ class StateValidationError(RuntimeError): # **combination** of several parts of the State are. +class MetadataNotFoundError(RuntimeError): + """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" + + @dataclasses.dataclass(frozen=True) class _DCBase: def replace(self, *args, **kwargs): @@ -911,6 +915,11 @@ def autoload(charm_type: Type["CharmType"]): charm_root = charm_source_path.parent.parent metadata_path = charm_root / "metadata.yaml" + if not metadata_path.exists(): + raise MetadataNotFoundError( + f"invalid charm root {charm_root!r}; " + f"expected to contain at least a `metadata.yaml` file.", + ) meta = yaml.safe_load(metadata_path.open()) actions = config = None diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index a8cd05bf1..ccff9366d 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -6,12 +6,12 @@ class MyCharm(CharmBase): - pass + META = {"name": "edgar"} @pytest.fixture def ctx(): - return Context(MyCharm) + return Context(MyCharm, meta=MyCharm.META) def test_open_port(ctx): From 81720b80f2a31958ae060f2997807858bc6ac2f1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 4 Sep 2023 14:53:49 +0200 Subject: [PATCH 290/546] pinned ops dep to >2.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4ba4a1bee..a71f5e961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.0", + "ops>=2.6", "PyYAML>=6.0.1", "typer==0.7.0", ] From 0cd067fe800df6f8d0d4bd1fb420126fcb010fd4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 4 Sep 2023 16:15:17 +0200 Subject: [PATCH 291/546] added opened-ports to state-apply --- scenario/scripts/snapshot.py | 31 +++++++++++++++++++++++++++++-- scenario/scripts/state_apply.py | 33 +++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index bab6727dc..4b5e4913d 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -32,6 +32,7 @@ Model, Mount, Network, + Port, Relation, Secret, State, @@ -476,6 +477,27 @@ def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: return relations +def get_opened_ports( + target: JujuUnitName, + model: Optional[str], +) -> List[Port]: + """Get opened ports list from target.""" + logger.info("getting opened ports...") + + opened_ports_raw = _juju_exec( + target, + model, + "opened-ports --format json", + ) + ports = [] + + for raw_port in json.loads(opened_ports_raw): + _port_n, _proto = raw_port.split("/") + ports.append(Port(_proto, int(_port_n))) + + return ports + + def get_config( target: JujuUnitName, model: Optional[str], @@ -753,6 +775,11 @@ def if_include(key, fn, default): workload_version=status.workload_version, model=state_model, config=if_include("c", lambda: get_config(target, model), {}), + opened_ports=if_include( + "p", + lambda: get_opened_ports(target, model), + [], + ), relations=if_include( "r", lambda: get_relations( @@ -870,12 +897,12 @@ def snapshot( "Pipe it to a file and fill in the blanks.", ), include: str = typer.Option( - "rckndt", + "rckndtp", "--include", "-i", help="What data to include in the state. " "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``S``: secrets(!), " + "``n``: networks, ``S``: secrets(!), ``p``: opened ports, " "``d``: deferred events, ``t``: stored state.", ), include_dead_relation_networks: bool = typer.Option( diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index db3c486f6..cd6a20131 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -17,11 +17,12 @@ from scenario.state import ( Container, DeferredEvent, + Port, Relation, Secret, State, - Status, StoredState, + _EntityStatus, ) SNAPSHOT_DATA_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() @@ -35,13 +36,17 @@ def set_relations(relations: Iterable[Relation]) -> List[str]: # noqa: U100 return [] -def set_status(status: Status) -> List[str]: +def set_status( + unit_status: _EntityStatus, + app_status: _EntityStatus, + app_version: str, +) -> List[str]: logger.info("preparing status...") cmds = [] - cmds.append(f"status-set {status.unit.name} {status.unit.message}") - cmds.append(f"status-set --application {status.app.name} {status.app.message}") - cmds.append(f"application-version-set {status.app_version}") + cmds.append(f"status-set {unit_status.name} {unit_status.message}") + cmds.append(f"status-set --application {app_status.name} {app_status.message}") + cmds.append(f"application-version-set {app_version}") return cmds @@ -52,6 +57,18 @@ def set_config(config: Dict[str, str]) -> List[str]: # noqa: U100 return [] +def set_opened_ports(opened_ports: List[Port]) -> List[str]: + logger.info("preparing opened ports...") + # fixme: this will only open new ports, it will not close all already-open ports. + + cmds = [] + + for port in opened_ports: + cmds.append(f"open-port {port.port}/{port.protocol}") + + return cmds + + def set_containers(containers: Iterable[Container]) -> List[str]: # noqa: U100 logger.info("preparing containers...") logger.warning("set_containers not implemented yet") @@ -137,7 +154,11 @@ def if_include(key, fn): j_exec_cmds: List[str] = [] - j_exec_cmds += if_include("s", lambda: set_status(state.status)) + j_exec_cmds += if_include( + "s", + lambda: set_status(state.unit_status, state.app_status, state.workload_version), + ) + j_exec_cmds += if_include("p", lambda: set_opened_ports(state.opened_ports)) j_exec_cmds += if_include("r", lambda: set_relations(state.relations)) j_exec_cmds += if_include("S", lambda: set_secrets(state.secrets)) From c8a09687cff113108c46573b7c7e1ef5588c0e26 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 4 Sep 2023 16:15:36 +0200 Subject: [PATCH 292/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a71f5e961..0766989b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.0.1" +version = "5.0.1.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 0977f56c116182062d87e08dbb9d1f562efd67fc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 09:51:53 +0200 Subject: [PATCH 293/546] description --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0766989b2..a400a0225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ version = "5.0.1.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } ] -description = "Python library providing a Scenario-based testing API for Operator Framework charms." +description = "Python library providing a state-transition testing API for Operator Framework charms." license.text = "Apache-2.0" keywords = ["juju", "test"] From 41d59e857f6205545c2d68128196f1bdf6a2fac9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 12:13:31 +0200 Subject: [PATCH 294/546] emitter implementation --- scenario/context.py | 149 ++++++++++++++++++++++++++++----- scenario/ops_main_mock.py | 6 +- scenario/runtime.py | 11 ++- tests/test_e2e/test_emitter.py | 65 ++++++++++++++ 4 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 tests/test_e2e/test_emitter.py diff --git a/scenario/context.py b/scenario/context.py index 3449a7912..4bbc0c917 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -32,6 +32,79 @@ class InvalidActionError(InvalidEventError): """raised when something is wrong with the action passed to Context.run_action""" +class AlreadyEmittedError(RuntimeError): + """Raised when _Emitter.emit() is called more than once.""" + + +class _Emitter: + def __init__( + self, + ctx: "Context", + arg: Union[str, Action, Event], + state_in: "State", + ): + self._ctx = ctx + self._arg = arg + self._state_in = state_in + + self._emitted: bool = False + self._run = None + + self.charm: Optional[CharmType] = None + self.output: Optional[Union["State", ActionOutput]] = None + + def setup(self, charm: "CharmType"): + self.charm = charm + + def _runner(self): + raise NotImplementedError("override in subclass") + + def __enter__(self): + self._run = self._runner() + next(self._run) + return self + + def emit(self) -> "State": + """Emit the event and proceed with charm execution. + + This can only be done once. + """ + if self._emitted: + raise AlreadyEmittedError("Can only _Emitter.emit() once.") + self._emitted = True + + try: + out = next(self._run) + except StopIteration as e: + out = e.value + self.output = out + return out + + def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 + if not self._emitted: + logger.debug("emitter not invoked. Doing so implicitly...") + self.emit() + + +class _EventEmitter(_Emitter): + if TYPE_CHECKING: + output: State + + def _runner(self): + return self._ctx.run(self._arg, self._state_in, _emitter=self) + + +class _ActionEmitter(_Emitter): + if TYPE_CHECKING: + output: ActionOutput + + def emit(self) -> ActionOutput: + return self._ctx._finalize_action(super().emit()) + + def _runner(self): + return self._ctx.run_action(self._arg, self._state_in, _emitter=self) + + class Context: """Scenario test execution context.""" @@ -120,10 +193,52 @@ def _record_status(self, state: "State", is_app: bool): else: self.unit_status_history.append(state.unit_status) + @staticmethod + def _coalesce_action(action: Union[str, Action]): + if isinstance(action, str): + return Action(action) + + if not isinstance(action, Action): + raise InvalidActionError( + f"Expected Action or action name; got {type(action)}", + ) + return action + + @staticmethod + def _coalesce_event(event: Union[str, Event]): + # Validate the event and cast to Event. + if isinstance(event, str): + event = Event(event) + + if not isinstance(event, Event): + raise InvalidEventError(f"Expected Event | str, got {type(event)}") + + if event._is_action_event: + raise InvalidEventError( + "Cannot Context.run() action events. " + "Use Context.run_action instead.", + ) + return event + + def emitter( + self, + event: Union["Event", str], + state: "State", + ): + return _EventEmitter(self, event, state) + + def action_emitter( + self, + action: Union["Action", str], + state: "State", + ): + return _ActionEmitter(self, action, state) + def run( self, event: Union["Event", str], state: "State", + _emitter: "_Emitter" = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -140,22 +255,10 @@ def run( :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. """ - """Validate the event and cast to Event.""" - if isinstance(event, str): - event = Event(event) - - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event | str, got {type(event)}") - - if event._is_action_event: - raise InvalidEventError( - "Cannot Context.run() action events. " - "Use Context.run_action instead.", - ) - return self._run( - event, + self._coalesce_event(event), state=state, + emitter=_emitter, pre_event=pre_event, post_event=post_event, ) @@ -164,6 +267,7 @@ def run_action( self, action: Union["Action", str], state: "State", + _emitter: _Emitter, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> ActionOutput: @@ -181,21 +285,21 @@ def run_action( Will receive the charm instance as only positional argument. """ - if isinstance(action, str): - action = Action(action) - - if not isinstance(action, Action): - raise InvalidActionError( - f"Expected Action or action name; got {type(action)}", - ) + action = self._coalesce_action(action) state_out = self._run( action.event, state=state, + emitter=_emitter, pre_event=pre_event, post_event=post_event, ) + if _emitter: + return state_out + return self._finalize_action(state_out) + + def _finalize_action(self, state_out: "State"): ao = ActionOutput( state_out, self._action_logs, @@ -214,6 +318,7 @@ def _run( self, event: "Event", state: "State", + emitter: _Emitter = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -222,10 +327,10 @@ def _run( juju_version=self.juju_version, charm_root=self.charm_root, ) - return runtime.exec( state=state, event=event, + emitter=emitter, pre_event=pre_event, post_event=post_event, context=self, diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 87e149e30..8f01cbef4 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.context import Context + from scenario.context import Context, Emitter from scenario.state import Event, State, _CharmSpec @@ -80,6 +80,7 @@ def _emit_charm_event( def main( + emitter: "Emitter" = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, state: "State" = None, @@ -137,6 +138,9 @@ def main( if not dispatcher.is_restricted_context(): framework.reemit() + if emitter: + yield emitter.setup(charm) + if pre_event: pre_event(charm) diff --git a/scenario/runtime.py b/scenario/runtime.py index 9a2662c0e..3fda47031 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.context import Context + from scenario.context import Context, Emitter from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -341,6 +341,7 @@ def exec( state: "State", event: "Event", context: "Context", + emitter: "Emitter" = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -384,7 +385,8 @@ def exec( from scenario.ops_main_mock import main as mocked_main try: - mocked_main( + main = mocked_main( + emitter=emitter, pre_event=pre_event, post_event=post_event, state=output_state, @@ -394,6 +396,11 @@ def exec( charm_type=self._wrap(charm_type), ), ) + + # if we are passing an emitter, main is a generator and this is a generator too + if emitter: + yield next(main) + except NoObserverError: raise # propagate along except Exception as e: diff --git a/tests/test_e2e/test_emitter.py b/tests/test_e2e/test_emitter.py new file mode 100644 index 000000000..ee9d6cd55 --- /dev/null +++ b/tests/test_e2e/test_emitter.py @@ -0,0 +1,65 @@ +import pytest +from ops.charm import CharmBase + +from scenario import Action, Context, State +from scenario.context import AlreadyEmittedError, _EventEmitter + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(CharmBase): + META = {"name": "mycharm"} + ACTIONS = {"do-x": {}} + + def __init__(self, framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, e): + print("event!") + + return MyCharm + + +def test_emitter(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + with _EventEmitter(ctx, "start", State()) as emitter: + print("charm before", emitter.charm) + state_out = emitter.emit() + print("charm after", emitter.charm) + + assert state_out + + +def test_emitter_implicit(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + with _EventEmitter(ctx, "start", State()) as emitter: + print("charm before", emitter.charm) + + assert emitter.output + + +def test_emitter_reemit_fails(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + with _EventEmitter(ctx, "start", State()) as emitter: + print("charm before", emitter.charm) + emitter.emit() + with pytest.raises(AlreadyEmittedError): + emitter.emit() + + assert emitter.output + + +def test_context_emitter(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + with ctx.emitter("start", State()) as emitter: + state_out = emitter.emit() + assert state_out.model.name + + +def test_context_action_emitter(mycharm): + ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) + with ctx.action_emitter(Action("do-x"), State()) as emitter: + state_out = emitter.emit() + assert state_out.state.model.name From 049b3bc77d78d66255251b6f74e0b5fdd0e9dcc4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 14:36:56 +0200 Subject: [PATCH 295/546] emitter and legacy working --- scenario/context.py | 109 +++++++++++++++++++++++++-------- scenario/ops_main_mock.py | 6 +- scenario/runtime.py | 16 +++-- scenario/utils.py | 11 ++++ tests/test_e2e/test_emitter.py | 7 ++- tests/test_runtime.py | 10 ++- 6 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 scenario/utils.py diff --git a/scenario/context.py b/scenario/context.py index 4bbc0c917..bdb4860eb 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -4,13 +4,24 @@ import tempfile from collections import namedtuple from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Generator, + List, + Optional, + Type, + Union, +) from ops import EventBase from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import Action, Event, _CharmSpec +from scenario.utils import exhaust if TYPE_CHECKING: from ops.testing import CharmType @@ -53,7 +64,7 @@ def __init__( self.charm: Optional[CharmType] = None self.output: Optional[Union["State", ActionOutput]] = None - def setup(self, charm: "CharmType"): + def _setup(self, charm: "CharmType"): self.charm = charm def _runner(self): @@ -73,11 +84,7 @@ def emit(self) -> "State": raise AlreadyEmittedError("Can only _Emitter.emit() once.") self._emitted = True - try: - out = next(self._run) - except StopIteration as e: - out = e.value - self.output = out + self.output = out = exhaust(self._run) return out def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 @@ -91,7 +98,7 @@ class _EventEmitter(_Emitter): output: State def _runner(self): - return self._ctx.run(self._arg, self._state_in, _emitter=self) + return self._ctx._run_event(self._arg, self._state_in, emitter=self) class _ActionEmitter(_Emitter): @@ -102,7 +109,12 @@ def emit(self) -> ActionOutput: return self._ctx._finalize_action(super().emit()) def _runner(self): - return self._ctx.run_action(self._arg, self._state_in, _emitter=self) + return self._ctx._run_action(self._arg, self._state_in, emitter=self) + + +class _LegacyEmitter(_Emitter): + def _runner(self): + pass class Context: @@ -234,11 +246,27 @@ def action_emitter( ): return _ActionEmitter(self, action, state) + def _run_event( + self, + event: Union["Event", str], + state: "State", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + emitter: "_Emitter" = None, + ) -> Generator["State", None, None]: + runner = self._run( + self._coalesce_event(event), + state=state, + emitter=emitter, + pre_event=pre_event, + post_event=post_event, + ) + return runner + def run( self, event: Union["Event", str], state: "State", - _emitter: "_Emitter" = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, ) -> "State": @@ -255,10 +283,26 @@ def run( :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. """ + runner = self._run_event(event, state, pre_event, post_event) + + # return the output + # step it once to get to the point before the event is emitted + # step it twice to let Runtime terminate + return exhaust(runner) + + def _run_action( + self, + action: Union["Action", str], + state: "State", + pre_event: Optional[Callable[["CharmType"], None]] = None, + post_event: Optional[Callable[["CharmType"], None]] = None, + emitter: _Emitter = None, + ) -> Generator["State", None, None]: + action = self._coalesce_action(action) return self._run( - self._coalesce_event(event), + action.event, state=state, - emitter=_emitter, + emitter=emitter, pre_event=pre_event, post_event=post_event, ) @@ -267,9 +311,9 @@ def run_action( self, action: Union["Action", str], state: "State", - _emitter: _Emitter, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, + _emitter: _Emitter = None, ) -> ActionOutput: """Trigger a charm execution with an Action and a State. @@ -284,20 +328,14 @@ def run_action( :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. """ - - action = self._coalesce_action(action) - - state_out = self._run( - action.event, - state=state, + runner = self._run_action( + action, + state, + pre_event, + post_event, emitter=_emitter, - pre_event=pre_event, - post_event=post_event, ) - - if _emitter: - return state_out - return self._finalize_action(state_out) + return self._finalize_action(exhaust(runner)) def _finalize_action(self, state_out: "State"): ao = ActionOutput( @@ -318,15 +356,16 @@ def _run( self, event: "Event", state: "State", - emitter: _Emitter = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> "State": + emitter: _Emitter = None, + ) -> Generator["State", None, None]: runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, charm_root=self.charm_root, ) + return runtime.exec( state=state, event=event, @@ -335,3 +374,19 @@ def _run( post_event=post_event, context=self, ) + + def _coalesce_emitter( + self, + emitter: _Emitter, + pre_event, + post_event, + event: "Event", + state: "State", + ): + if emitter and (pre_event or post_event): + raise ValueError("cannot call Context with emitter AND [pre/post]-event") + + if emitter: + return emitter + + return _LegacyEmitter(self, pre_event, post_event, event, state) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 8f01cbef4..5856747bd 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -139,7 +139,9 @@ def main( framework.reemit() if emitter: - yield emitter.setup(charm) + emitter._setup(charm) + + yield if pre_event: pre_event(charm) @@ -152,3 +154,5 @@ def main( framework.commit() finally: framework.close() + + return None diff --git a/scenario/runtime.py b/scenario/runtime.py index 3fda47031..74e7e48c3 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -14,6 +14,7 @@ Callable, ContextManager, Dict, + Generator, List, Optional, Tuple, @@ -344,7 +345,7 @@ def exec( emitter: "Emitter" = None, pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - ) -> "State": + ) -> Generator["State", None, None]: """Runs an event with this state as initial state on a charm. Returns the 'output state', that is, the state as mutated by the charm during the @@ -397,9 +398,15 @@ def exec( ), ) - # if we are passing an emitter, main is a generator and this is a generator too - if emitter: - yield next(main) + # main is a generator, let's step it up until its yield + # statement = right before firing the event + yield next(main) + + # exhaust the iterator = allow ops to tear down + try: + next(main) + except StopIteration: + pass except NoObserverError: raise # propagate along @@ -407,6 +414,7 @@ def exec( raise UncaughtCharmError( f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}", ) from e + finally: logger.info(" - Exited ops.main.") diff --git a/scenario/utils.py b/scenario/utils.py new file mode 100644 index 000000000..29310a764 --- /dev/null +++ b/scenario/utils.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + + +def exhaust(generator): + while True: + try: + next(generator) + except StopIteration as e: + return e.value diff --git a/tests/test_e2e/test_emitter.py b/tests/test_e2e/test_emitter.py index ee9d6cd55..3f2e29a79 100644 --- a/tests/test_e2e/test_emitter.py +++ b/tests/test_e2e/test_emitter.py @@ -1,4 +1,5 @@ import pytest +from ops import ActiveStatus from ops.charm import CharmBase from scenario import Action, Context, State @@ -18,6 +19,7 @@ def __init__(self, framework): def _on_event(self, e): print("event!") + self.unit.status = ActiveStatus(e.handle.kind) return MyCharm @@ -38,6 +40,7 @@ def test_emitter_implicit(mycharm): print("charm before", emitter.charm) assert emitter.output + assert emitter.output.unit_status == ActiveStatus("start") def test_emitter_reemit_fails(mycharm): @@ -61,5 +64,5 @@ def test_context_emitter(mycharm): def test_context_action_emitter(mycharm): ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) with ctx.action_emitter(Action("do-x"), State()) as emitter: - state_out = emitter.emit() - assert state_out.state.model.name + ao = emitter.emit() + assert ao.state.model.name diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 9ec8ec946..263d0e338 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -11,6 +11,7 @@ from scenario import Context from scenario.runtime import Runtime, UncaughtCharmError from scenario.state import Event, Relation, State, _CharmSpec +from scenario.utils import exhaust def charm_type(): @@ -52,13 +53,14 @@ def test_event_hooks(): pre_event = MagicMock(return_value=None) post_event = MagicMock(return_value=None) - runtime.exec( + runner = runtime.exec( state=State(), event=Event("update_status"), pre_event=pre_event, post_event=post_event, context=Context(my_charm_type, meta=meta), ) + exhaust(runner) assert pre_event.called assert post_event.called @@ -85,9 +87,10 @@ class MyEvt(EventBase): ), ) - runtime.exec( + runner = runtime.exec( state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta) ) + exhaust(runner) assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) @@ -137,11 +140,12 @@ def post_event(charm: CharmBase): raise TypeError with pytest.raises(UncaughtCharmError): - runtime.exec( + runner = runtime.exec( state=State(), event=Event("box_relation_changed", relation=Relation("box")), post_event=post_event, context=Context(my_charm_type, meta=meta), ) + exhaust(runner) assert os.getenv("JUJU_REMOTE_APP", None) is None From fed510a85d62b334cd194179b94926dc21cbef6f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 14:58:12 +0200 Subject: [PATCH 296/546] cleaned up most of the pre/post event in runtime --- scenario/context.py | 29 +++++++++---- scenario/ops_main_mock.py | 13 +----- scenario/runtime.py | 76 +++------------------------------- tests/test_e2e/test_emitter.py | 15 ++++++- tests/test_runtime.py | 8 ++-- 5 files changed, 45 insertions(+), 96 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index bdb4860eb..2f9d93308 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -92,6 +92,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 logger.debug("emitter not invoked. Doing so implicitly...") self.emit() + def _finalize(self): + """Compatibility shim for _LegacyEmitter.""" + pass + class _EventEmitter(_Emitter): if TYPE_CHECKING: @@ -112,9 +116,20 @@ def _runner(self): return self._ctx._run_action(self._arg, self._state_in, emitter=self) -class _LegacyEmitter(_Emitter): - def _runner(self): - pass +class _LegacyEmitter: + def __init__(self, pre=None, post=None): + self.pre = pre + self.post = post + self.charm = None + + def _setup(self, charm): + self.charm = charm + if self.pre: + self.pre(charm) + + def _finalize(self): + if self.post: + self.post(self.charm) class Context: @@ -369,9 +384,7 @@ def _run( return runtime.exec( state=state, event=event, - emitter=emitter, - pre_event=pre_event, - post_event=post_event, + emitter=self._coalesce_emitter(emitter, pre_event, post_event), context=self, ) @@ -380,8 +393,6 @@ def _coalesce_emitter( emitter: _Emitter, pre_event, post_event, - event: "Event", - state: "State", ): if emitter and (pre_event or post_event): raise ValueError("cannot call Context with emitter AND [pre/post]-event") @@ -389,4 +400,4 @@ def _coalesce_emitter( if emitter: return emitter - return _LegacyEmitter(self, pre_event, post_event, event, state) + return _LegacyEmitter(pre_event, post_event) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 5856747bd..06335fcbf 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,7 +3,7 @@ # See LICENSE file for licensing details. import inspect import os -from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Sequence import ops.charm import ops.framework @@ -18,8 +18,6 @@ from ops.main import logger as ops_logger if TYPE_CHECKING: - from ops.testing import CharmType - from scenario.context import Context, Emitter from scenario.state import Event, State, _CharmSpec @@ -81,8 +79,6 @@ def _emit_charm_event( def main( emitter: "Emitter" = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, state: "State" = None, event: "Event" = None, context: "Context" = None, @@ -143,15 +139,10 @@ def main( yield - if pre_event: - pre_event(charm) - _emit_charm_event(charm, dispatcher.event_name, event) - if post_event: - post_event(charm) - framework.commit() + finally: framework.close() diff --git a/scenario/runtime.py b/scenario/runtime.py index 74e7e48c3..ed20aa3bb 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,10 +10,7 @@ from pathlib import Path from typing import ( TYPE_CHECKING, - Any, - Callable, ContextManager, - Dict, Generator, List, Optional, @@ -35,7 +32,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.context import Context, Emitter + from scenario.context import Context, _Emitter, _LegacyEmitter from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -342,9 +339,7 @@ def exec( state: "State", event: "Event", context: "Context", - emitter: "Emitter" = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + emitter: Optional[Union["_Emitter", "_LegacyEmitter"]] = None, ) -> Generator["State", None, None]: """Runs an event with this state as initial state on a charm. @@ -388,8 +383,6 @@ def exec( try: main = mocked_main( emitter=emitter, - pre_event=pre_event, - post_event=post_event, state=output_state, event=event, context=context, @@ -408,6 +401,10 @@ def exec( except StopIteration: pass + logger.info(" - Finalizing emitter (legacy)") + if emitter: + emitter._finalize() + except NoObserverError: raise # propagate along except Exception as e: @@ -428,64 +425,3 @@ def exec( logger.info("event dispatched. done.") return output_state - - -def trigger( - state: "State", - event: Union["Event", str], - charm_type: Type["CharmType"], - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, - # if not provided, will be autoloaded from charm_type. - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - charm_root: Optional[Dict["PathLike", "PathLike"]] = None, - juju_version: str = "3.0", -) -> "State": - """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. - :arg state: the State instance to use as data source for the hook tool calls that the charm will - invoke when handling the Event. - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. - :arg pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - :arg post_event: callback to be invoked right after emitting the event on the charm instance. - Will receive the charm instance as only positional argument. - :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a python dict). - If none is provided, we will search for a ``metadata.yaml`` file in the charm root. - :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a python dict). - If none is provided, we will search for a ``actions.yaml`` file in the charm root. - :arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict). - If none is provided, we will search for a ``config.yaml`` file in the charm root. - :arg juju_version: Juju agent version to simulate. - :arg charm_root: virtual charm root the charm will be executed with. - If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to use this. E.g.: - - >>> virtual_root = tempfile.TemporaryDirectory() - >>> local_path = Path(local_path.name) - >>> (local_path / 'foo').mkdir() - >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - >>> scenario, State(), (... charm_root=virtual_root) - - """ - logger.warning( - "DEPRECATION NOTICE: scenario.runtime.trigger() is deprecated and " - "will be removed soon; please use the scenario.context.Context api.", - ) - from scenario.context import Context - - ctx = Context( - charm_type=charm_type, - meta=meta, - actions=actions, - config=config, - charm_root=charm_root, - juju_version=juju_version, - ) - return ctx.run(event, state=state, pre_event=pre_event, post_event=post_event) diff --git a/tests/test_e2e/test_emitter.py b/tests/test_e2e/test_emitter.py index 3f2e29a79..8d20d6c55 100644 --- a/tests/test_e2e/test_emitter.py +++ b/tests/test_e2e/test_emitter.py @@ -27,13 +27,24 @@ def _on_event(self, e): def test_emitter(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventEmitter(ctx, "start", State()) as emitter: - print("charm before", emitter.charm) + assert isinstance(emitter.charm, mycharm) state_out = emitter.emit() - print("charm after", emitter.charm) assert state_out +def test_emitter_legacy(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + + def pre_event(charm): + print(1) + + def post_event(charm): + print(2) + + ctx.run("start", State(), pre_event=pre_event, post_event=post_event) + + def test_emitter_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventEmitter(ctx, "start", State()) as emitter: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 263d0e338..364f710c7 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -9,6 +9,7 @@ from ops.framework import EventBase from scenario import Context +from scenario.context import _LegacyEmitter from scenario.runtime import Runtime, UncaughtCharmError from scenario.state import Event, Relation, State, _CharmSpec from scenario.utils import exhaust @@ -56,8 +57,7 @@ def test_event_hooks(): runner = runtime.exec( state=State(), event=Event("update_status"), - pre_event=pre_event, - post_event=post_event, + emitter=_LegacyEmitter(pre_event, post_event), context=Context(my_charm_type, meta=meta), ) exhaust(runner) @@ -118,7 +118,7 @@ def post_event(charm: CharmBase): runtime.exec( state=State(unit_id=unit_id), event=Event("start"), - post_event=post_event, + emitter=_LegacyEmitter(None, post_event), context=Context(my_charm_type, meta=meta), ) @@ -143,7 +143,7 @@ def post_event(charm: CharmBase): runner = runtime.exec( state=State(), event=Event("box_relation_changed", relation=Relation("box")), - post_event=post_event, + emitter=_LegacyEmitter(post=post_event), context=Context(my_charm_type, meta=meta), ) exhaust(runner) From a7888f0fd115798420565900a7ccf1ed22d2895a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 15:10:20 +0200 Subject: [PATCH 297/546] docstrings --- scenario/context.py | 94 ++++++++++++++++++++++++++------------- scenario/ops_main_mock.py | 1 + 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 2f9d93308..b7aaca9e6 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -48,6 +48,8 @@ class AlreadyEmittedError(RuntimeError): class _Emitter: + """Context manager to offer test code some runtime charm object introspection.""" + def __init__( self, ctx: "Context", @@ -117,6 +119,8 @@ def _runner(self): class _LegacyEmitter: + """Compatibility shim to keep using the [pre/post]-event syntax while we're deprecating it.""" + def __init__(self, pre=None, post=None): self.pre = pre self.post = post @@ -247,11 +251,48 @@ def _coalesce_event(event: Union[str, Event]): ) return event + def _coalesce_emitter( + self, + emitter: Optional[_Emitter], + pre_event: Optional[Callable], + post_event: Optional[Callable], + ) -> Union[_LegacyEmitter, _Emitter]: + # validate emitter and pre/post event arguments, cast to emitter + legacy_mode = pre_event or post_event + if legacy_mode: + logger.warning( + "The [pre/post]_event syntax is deprecated and " + "will be removed in a future release. " + "Please start using the Context.[event/action]_runner context manager.", + ) + + if emitter and legacy_mode: + raise ValueError( + "cannot call Context with emitter AND legacy [pre/post]-event", + ) + + if emitter: + return emitter + + return _LegacyEmitter(pre_event, post_event) + def emitter( self, event: Union["Event", str], state: "State", ): + """Context manager to introspect live charm object before and after the event is emitted. + + Usage: + >>> with Context.action_emitter("start", State()) as emitter: + >>> assert emitter.charm._some_private_attribute == "foo" + >>> emitter.emit() # this will fire the event + >>> assert emitter.charm._some_private_attribute == "bar" + + :arg event: the Event that the charm will respond to. Can be a string or an Event instance. + :arg state: the State instance to use as data source for the hook tool calls that the + charm will invoke when handling the Event. + """ return _EventEmitter(self, event, state) def action_emitter( @@ -259,22 +300,30 @@ def action_emitter( action: Union["Action", str], state: "State", ): + """Context manager to introspect live charm object before and after the event is emitted. + + Usage: + >>> with Context.action_emitter("foo-action", State()) as emitter: + >>> assert emitter.charm._some_private_attribute == "foo" + >>> emitter.emit() # this will fire the event + >>> assert emitter.charm._some_private_attribute == "bar" + + :arg action: the Action that the charm will execute. Can be a string or an Action instance. + :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 _ActionEmitter(self, action, state) def _run_event( self, event: Union["Event", str], state: "State", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, emitter: "_Emitter" = None, ) -> Generator["State", None, None]: runner = self._run( self._coalesce_event(event), state=state, emitter=emitter, - pre_event=pre_event, - post_event=post_event, ) return runner @@ -295,10 +344,16 @@ def run( charm will invoke when handling the Event. :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. + This argument is deprecated. Please use Context.event_emitter instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. + This argument is deprecated. Please use Context.event_emitter instead. """ - runner = self._run_event(event, state, pre_event, post_event) + runner = self._run_event( + event, + state, + emitter=self._coalesce_emitter(None, pre_event, post_event), + ) # return the output # step it once to get to the point before the event is emitted @@ -309,8 +364,6 @@ def _run_action( self, action: Union["Action", str], state: "State", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, emitter: _Emitter = None, ) -> Generator["State", None, None]: action = self._coalesce_action(action) @@ -318,8 +371,6 @@ def _run_action( action.event, state=state, emitter=emitter, - pre_event=pre_event, - post_event=post_event, ) def run_action( @@ -328,7 +379,6 @@ def run_action( state: "State", pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, - _emitter: _Emitter = None, ) -> ActionOutput: """Trigger a charm execution with an Action and a State. @@ -340,15 +390,15 @@ def run_action( charm will invoke when handling the Action (event). :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. + This argument is deprecated. Please use Context.event_emitter instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. + This argument is deprecated. Please use Context.event_emitter instead. """ runner = self._run_action( action, state, - pre_event, - post_event, - emitter=_emitter, + emitter=self._coalesce_emitter(None, pre_event, post_event), ) return self._finalize_action(exhaust(runner)) @@ -371,8 +421,6 @@ def _run( self, event: "Event", state: "State", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, emitter: _Emitter = None, ) -> Generator["State", None, None]: runtime = Runtime( @@ -384,20 +432,6 @@ def _run( return runtime.exec( state=state, event=event, - emitter=self._coalesce_emitter(emitter, pre_event, post_event), + emitter=emitter, context=self, ) - - def _coalesce_emitter( - self, - emitter: _Emitter, - pre_event, - post_event, - ): - if emitter and (pre_event or post_event): - raise ValueError("cannot call Context with emitter AND [pre/post]-event") - - if emitter: - return emitter - - return _LegacyEmitter(pre_event, post_event) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 06335fcbf..04af2c3cf 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -137,6 +137,7 @@ def main( if emitter: emitter._setup(charm) + # give control back to the emitter to do any setup and pre-event assertions yield _emit_charm_event(charm, dispatcher.event_name, event) From f4c884b3b519f26f27fe99cb0a01d756e866ec8f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 15:29:19 +0200 Subject: [PATCH 298/546] readme --- README.md | 543 +++++++++++++++++++++++++++++------------------------- 1 file changed, 295 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index b21c1c017..b6c1014ff 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ I like metaphors, so here we go: - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what - other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the - stage (relation-departed), or the content of one of the books changes). + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what + other actors are there around it, what is written in those pebble-shaped books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the + stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. @@ -66,15 +66,15 @@ Comparing scenario tests with `Harness` tests: A scenario test consists of three broad steps: - **Arrange**: - - declare the input state - - select an event to fire + - declare the input state + - select an event to fire - **Act**: - - run the state (i.e. obtain the output state) - - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal - APIs + - run the state (i.e. obtain the output state) + - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal + APIs - **Assert**: - - verify that the output state (or the delta with the input state) is how you expect it to be - - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - verify that the output state (or the delta with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -88,14 +88,14 @@ from ops.model import UnknownStatus class MyCharm(CharmBase): - pass + pass def test_scenario_base(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', State()) - assert out.unit_status == UnknownStatus() + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', State()) + assert out.unit_status == UnknownStatus() ``` Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': @@ -108,22 +108,22 @@ from ops.model import ActiveStatus class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I am ruled') + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I am ruled') @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', State(leader=leader)) - assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', State(leader=leader)) + assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can @@ -141,16 +141,16 @@ from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedSta # charm code: def _on_event(self, _event): - self.unit.status = MaintenanceStatus('determining who the ruler is...') - try: - if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = WaitingStatus('checking this is right...') - self._check_that_takes_some_more_time() - self.unit.status = ActiveStatus('I am ruled') - except: - self.unit.status = BlockedStatus('something went wrong') + self.unit.status = MaintenanceStatus('determining who the ruler is...') + try: + if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = WaitingStatus('checking this is right...') + self._check_that_takes_some_more_time() + self.unit.status = ActiveStatus('I am ruled') + except: + self.unit.status = BlockedStatus('something went wrong') ``` More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its relationship with the `State`. @@ -181,20 +181,20 @@ from scenario import State, Context def test_statuses(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - ctx.run('start', State(leader=False)) - assert ctx.unit_status_history == [ - UnknownStatus(), - MaintenanceStatus('determining who the ruler is...'), - WaitingStatus('checking this is right...'), - ActiveStatus("I am ruled"), - ] - - assert ctx.app_status_history == [ - UnknownStatus(), - ActiveStatus(""), - ] + ctx = Context(MyCharm, + meta={"name": "foo"}) + ctx.run('start', State(leader=False)) + assert ctx.unit_status_history == [ + UnknownStatus(), + MaintenanceStatus('determining who the ruler is...'), + WaitingStatus('checking this is right...'), + ActiveStatus("I am ruled"), + ] + + assert ctx.app_status_history == [ + UnknownStatus(), + ActiveStatus(""), + ] ``` Note that the current status is not in the **unit status history**. @@ -213,8 +213,8 @@ from scenario import State, Status # ... ctx.run('start', State(unit_status=ActiveStatus('foo'))) assert ctx.unit_status_history == [ - ActiveStatus('foo'), # now the first status is active: 'foo'! - # ... + ActiveStatus('foo'), # now the first status is active: 'foo'! + # ... ] ``` @@ -246,11 +246,11 @@ from ops.charm import StartEvent def test_foo(): - ctx = Context(...) - ctx.run('start', ...) + ctx = Context(...) + ctx.run('start', ...) - assert len(ctx.emitted_events) == 1 - assert isinstance(ctx.emitted_events[0], StartEvent) + assert len(ctx.emitted_events) == 1 + assert isinstance(ctx.emitted_events[0], StartEvent) ``` ### Low-level access: using directly `capture_events` @@ -266,11 +266,11 @@ from ops.charm import StartEvent, UpdateStatusEvent from scenario import State, Context, DeferredEvent, capture_events with capture_events() as emitted: - ctx = Context(...) - state_out = ctx.run( - "update-status", - State(deferred=[DeferredEvent("start", ...)]) - ) + ctx = Context(...) + state_out = ctx.run( + "update-status", + State(deferred=[DeferredEvent("start", ...)]) + ) # deferred events get reemitted first assert isinstance(emitted[0], StartEvent) @@ -288,8 +288,8 @@ from ops.charm import StartEvent, RelationEvent from scenario import capture_events with capture_events(StartEvent, RelationEvent) as emitted: - # capture all `start` and `*-relation-*` events. - pass + # capture all `start` and `*-relation-*` events. + pass ``` Configuration: @@ -312,41 +312,41 @@ from scenario import Relation, State, Context # This charm copies over remote app data to local unit data class MyCharm(CharmBase): - ... + ... - def _on_event(self, e): - rel = e.relation - assert rel.app.name == 'remote' - assert rel.data[self.unit]['abc'] == 'foo' - rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] + def _on_event(self, e): + rel = e.relation + assert rel.app.name == 'remote' + assert rel.data[self.unit]['abc'] == 'foo' + rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] def test_relation_data(): - state_in = State(relations=[ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ]) - ctx = Context(MyCharm, - meta={"name": "foo"}) - - state_out = ctx.run('start', state_in) - - assert state_out.relations[0].local_unit_data == {"abc": "baz!"} - # you can do this to check that there are no other differences: - assert state_out.relations == [ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] + state_in = State(relations=[ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ]) + ctx = Context(MyCharm, + meta={"name": "foo"}) + + state_out = ctx.run('start', state_in) + + assert state_out.relations[0].local_unit_data == {"abc": "baz!"} + # you can do this to check that there are no other differences: + assert state_out.relations == [ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] # which is very idiomatic and superbly explicit. Noice. ``` @@ -376,8 +376,8 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o from scenario.state import PeerRelation relation = PeerRelation( - endpoint="peers", - peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, ) ``` @@ -388,11 +388,11 @@ be flagged by the Consistency Checker: from scenario import State, PeerRelation, Context state_in = State(relations=[ - PeerRelation( - endpoint="peers", - peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )], - unit_id=1) + PeerRelation( + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, + )], + unit_id=1) Context(...).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. @@ -416,10 +416,10 @@ argument. Also, it talks in terms of `primary`: from scenario.state import SubordinateRelation relation = SubordinateRelation( - endpoint="peers", - remote_unit_data={"foo": "bar"}, - remote_app_name="zookeeper", - remote_unit_id=42 + endpoint="peers", + remote_unit_data={"foo": "bar"}, + remote_app_name="zookeeper", + remote_unit_id=42 ) relation.remote_unit_name # "zookeeper/42" ``` @@ -514,8 +514,8 @@ An example of a scene including some containers: from scenario.state import Container, State state = State(containers=[ - Container(name="foo", can_connect=True), - Container(name="bar", can_connect=False) + Container(name="foo", can_connect=True), + Container(name="bar", can_connect=False) ]) ``` @@ -538,8 +538,8 @@ In this case, if the charm were to: ```python def _on_start(self, _): - foo = self.unit.get_container('foo') - content = foo.pull('/local/share/config.yaml').read() + foo = self.unit.get_container('foo') + content = foo.pull('/local/share/config.yaml').read() ``` then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping @@ -554,30 +554,30 @@ from scenario import State, Container, Mount, Context class MyCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) - def _on_pebble_ready(self, _): - foo = self.unit.get_container('foo') - foo.push('/local/share/config.yaml', "TEST", make_dirs=True) + def _on_pebble_ready(self, _): + foo = self.unit.get_container('foo') + foo.push('/local/share/config.yaml', "TEST", make_dirs=True) def test_pebble_push(): - with tempfile.NamedTemporaryFile() as local_file: - container = Container(name='foo', - can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) - state_in = State( - containers=[container] - ) - Context( - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}).run( - "start", - state_in, - ) - assert local_file.read().decode() == "TEST" + with tempfile.NamedTemporaryFile() as local_file: + container = Container(name='foo', + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) + state_in = State( + containers=[container] + ) + Context( + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}).run( + "start", + state_in, + ) + assert local_file.read().decode() == "TEST" ``` `container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we @@ -603,32 +603,32 @@ drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib class MyCharm(CharmBase): - def _on_start(self, _): - foo = self.unit.get_container('foo') - proc = foo.exec(['ls', '-ll']) - stdout, _ = proc.wait_output() - assert stdout == LS_LL + def _on_start(self, _): + foo = self.unit.get_container('foo') + proc = foo.exec(['ls', '-ll']) + stdout, _ = proc.wait_output() + assert stdout == LS_LL def test_pebble_exec(): - container = Container( - name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) - } - ) - state_in = State( - containers=[container] - ) - state_out = Context( - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}, - ).run( - container.pebble_ready_event, - state_in, - ) + container = Container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + ) + state_in = State( + containers=[container] + ) + state_out = Context( + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, + ).run( + container.pebble_ready_event, + state_in, + ) ``` # Secrets @@ -639,12 +639,12 @@ Scenario has secrets. Here's how you use them. from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}} - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}} + ) + ] ) ``` @@ -661,15 +661,15 @@ To specify a secret owned by this unit (or app): from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}}, - owner='unit', # or 'app' - remote_grants={0: {"remote"}} - # the secret owner has granted access to the "remote" app over some relation with ID 0 - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + owner='unit', # or 'app' + remote_grants={0: {"remote"}} + # the secret owner has granted access to the "remote" app over some relation with ID 0 + ) + ] ) ``` @@ -679,15 +679,15 @@ To specify a secret owned by some other application and give this unit (or app) from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}}, - # owner=None, which is the default - granted="unit", # or "app", - revision=0, # the revision that this unit (or app) is currently tracking - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + # owner=None, which is the default + granted="unit", # or "app", + revision=0, # the revision that this unit (or app) is currently tracking + ) + ] ) ``` @@ -708,16 +708,16 @@ from charm import MyCharm def test_backup_action(): - ctx = Context(MyCharm) + ctx = Context(MyCharm) - # If you didn't declare do_backup in the charm's `actions.yaml`, - # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. - out: ActionOutput = ctx.run_action("do_backup_action", State()) + # If you didn't declare do_backup in the charm's `actions.yaml`, + # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. + out: ActionOutput = ctx.run_action("do_backup_action", State()) - # you can assert action results, logs, failure using the ActionOutput interface - assert out.results == {'foo': 'bar'} - assert out.logs == ['baz', 'qux'] - assert out.failure == 'boo-hoo' + # you can assert action results, logs, failure using the ActionOutput interface + assert out.results == {'foo': 'bar'} + assert out.logs == ['baz', 'qux'] + assert out.failure == 'boo-hoo' ``` ## Parametrized Actions @@ -730,15 +730,15 @@ from charm import MyCharm def test_backup_action(): - # define an action - action = Action('do_backup', params={'a': 'b'}) - ctx = Context(MyCharm) + # define an action + action = Action('do_backup', params={'a': 'b'}) + ctx = Context(MyCharm) - # if the parameters (or their type) don't match what declared in actions.yaml, - # the `ConsistencyChecker` will slap you on the other wrist. - out: ActionOutput = ctx.run_action(action, State()) + # if the parameters (or their type) don't match what declared in actions.yaml, + # the `ConsistencyChecker` will slap you on the other wrist. + out: ActionOutput = ctx.run_action(action, State()) - # ... + # ... ``` # Deferred events @@ -753,26 +753,26 @@ from scenario import State, deferred, Context class MyCharm(...): - ... + ... - def _on_update_status(self, e): - e.defer() + def _on_update_status(self, e): + e.defer() - def _on_start(self, e): - e.defer() + def _on_start(self, e): + e.defer() def test_start_on_deferred_update_status(MyCharm): - """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" - state_in = State( - deferred=[ - deferred('update_status', - handler=MyCharm._on_update_status) - ] - ) - state_out = Context(MyCharm).run('start', state_in) - assert len(state_out.deferred) == 1 - assert state_out.deferred[0].name == 'start' + """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" + state_in = State( + deferred=[ + deferred('update_status', + handler=MyCharm._on_update_status) + ] + ) + state_out = Context(MyCharm).run('start', state_in) + assert len(state_out.deferred) == 1 + assert state_out.deferred[0].name == 'start' ``` You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the @@ -783,7 +783,7 @@ from scenario import Event, Relation class MyCharm(...): - ... + ... deferred_start = Event('start').deferred(MyCharm._on_start) @@ -805,16 +805,16 @@ from scenario import State, Context class MyCharm(...): - ... + ... - def _on_start(self, e): - e.defer() + def _on_start(self, e): + e.defer() def test_defer(MyCharm): - out = Context(MyCharm).run('start', State()) - assert len(out.deferred) == 1 - assert out.deferred[0].name == 'start' + out = Context(MyCharm).run('start', State()) + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' ``` ## Deferring relation events @@ -828,22 +828,22 @@ from scenario import State, Relation, deferred class MyCharm(...): - ... + ... - def _on_foo_relation_changed(self, e): - e.defer() + def _on_foo_relation_changed(self, e): + e.defer() def test_start_on_deferred_update_status(MyCharm): - foo_relation = Relation('foo') - State( - relations=[foo_relation], - deferred=[ - deferred('foo_relation_changed', - handler=MyCharm._on_foo_relation_changed, - relation=foo_relation) - ] - ) + foo_relation = Relation('foo') + State( + relations=[foo_relation], + deferred=[ + deferred('foo_relation_changed', + handler=MyCharm._on_foo_relation_changed, + relation=foo_relation) + ] + ) ``` but you can also use a shortcut from the relation event itself, as mentioned above: @@ -854,7 +854,7 @@ from scenario import Relation class MyCharm(...): - ... + ... foo_relation = Relation('foo') @@ -872,9 +872,9 @@ For general-purpose usage, you will need to instantiate DeferredEvent directly. from scenario import DeferredEvent my_deferred_event = DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' ) ``` @@ -889,21 +889,21 @@ from scenario import State, StoredState class MyCharmType(CharmBase): - my_stored_state = Ops_StoredState() + my_stored_state = Ops_StoredState() - def __init__(self, framework: Framework): - super().__init__(framework) - assert self.my_stored_state.foo == 'bar' # this will pass! + def __init__(self, framework: Framework): + super().__init__(framework) + assert self.my_stored_state.foo == 'bar' # this will pass! state = State(stored_state=[ - StoredState( - owner_path="MyCharmType", - name="my_stored_state", - content={ - 'foo': 'bar', - 'baz': {42: 42}, - }) + StoredState( + owner_path="MyCharmType", + name="my_stored_state", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) ]) ``` @@ -937,6 +937,53 @@ This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. (always omit the 'root', i.e. the charm framework key, from the path) +# Live charm introspection + +Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to B, but not to assert that, in the context of this charm execution, with this state, a certain method call would return a given piece of data. + +Scenario offers a context manager for this use case specifically: + +```python +from ops.charm import CharmBase + +from scenario import Context, State + + +class MyCharm(CharmBase): + META = {"name": "mycharm"} + + def __init__(self, framework): + super().__init__(framework) + self.a = "a" + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event): + self.a = "b" + + +def test_live_charm_introspection(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + # If you want to do this with actions, you can use `Context.action_emitter` instead. + with ctx.emitter("start", State()) as emitter: + # this is your charm instance, after ops has set it up + charm = emitter.charm + assert isinstance(charm, MyCharm) + assert charm.a == "a" + + # this will tell ops.main to proceed with normal execution and emit the "start" event on the charm + state_out = emitter.emit() + + # after that is done, we are handed back control and we can again do some introspection + assert charm.a == "b" + + # state_out is, as in regular scenario tests, a State object you can assert on: + assert state_out.unit_status == ... +``` + +Note that you can't `emitter.emit()` multiple times: the emitter 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 Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The @@ -950,7 +997,7 @@ from scenario import State, Context class MyCharmType(CharmBase): - pass + pass ctx = Context(charm_type=MyCharmType, @@ -968,14 +1015,14 @@ import tempfile class MyCharmType(CharmBase): - pass + pass td = tempfile.TemporaryDirectory() state = Context( - charm_type=MyCharmType, - meta={'name': 'my-charm-name'}, - charm_root=td.name + charm_type=MyCharmType, + meta={'name': 'my-charm-name'}, + charm_root=td.name ).run('start', State()) ``` @@ -985,12 +1032,12 @@ ignored. # Immutability -All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen dataclasses). +All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen dataclasses). This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the state that you obtain in return is a different instance, and all parts of it have been (deep)copied. This ensures that you can do delta-based comparison of states without worrying about them being mutated by scenario. -If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use the `replace` api. +If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use the `replace` api. ```python from scenario import Relation From d00e5adb61333d01fbe11d8891c6740e6a71271a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Sep 2023 15:41:26 +0200 Subject: [PATCH 299/546] lint --- pyproject.toml | 2 +- scenario/state.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a71f5e961..161f6b664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.0.1" +version = "5.1.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/state.py b/scenario/state.py index e8c832881..414f4cde4 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -761,20 +761,28 @@ def handle_path(self): @dataclasses.dataclass(frozen=True) class Port(_DCBase): + """Represents a port on the charm host.""" + protocol: Literal["tcp", "udp", "icmp"] port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" def __post_init__(self): port = self.port - if self.protocol == "icmp" and port: - raise StateValidationError("`port` arg not supported with `icmp` protocol") - elif not port: + is_icmp = self.protocol == "icmp" + if port: + if is_icmp: + raise StateValidationError( + "`port` arg not supported with `icmp` protocol", + ) + if not (1 <= port <= 65535): + raise StateValidationError( + f"`port` outside bounds [1:65535], got {port}", + ) + elif not is_icmp: raise StateValidationError( f"`port` arg required with `{self.protocol}` protocol", ) - if port and not (1 <= port <= 65535): - raise StateValidationError(f"`port` outside bounds [1:65535], got {port}") @dataclasses.dataclass(frozen=True) From 47fc752b20efc7e4cdd04f70f13d0199019d0081 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 6 Sep 2023 09:37:07 +0200 Subject: [PATCH 300/546] bugfix --- pyproject.toml | 2 +- scenario/mocking.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf7ee2561..4ba4a1bee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.0" +version = "5.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index 9146b86f0..12a2438e6 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -395,6 +395,10 @@ def __init__( self._event = event self._charm_spec = charm_spec + # wipe just in case + if container_root.exists(): + container_root.rmdir() + # initialize simulated filesystem container_root.mkdir(parents=True) for _, mount in mounts.items(): From 58d4c81c45a8972eebc2828d8a8cf0e3f48ed816 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 7 Sep 2023 11:10:17 +0200 Subject: [PATCH 301/546] emitter -> manager --- README.md | 10 +- scenario/context.py | 94 +++++++++---------- scenario/ops_main_mock.py | 10 +- scenario/runtime.py | 12 +-- .../{test_emitter.py => test_manager.py} | 46 ++++----- tests/test_runtime.py | 8 +- 6 files changed, 90 insertions(+), 90 deletions(-) rename tests/test_e2e/{test_emitter.py => test_manager.py} (55%) diff --git a/README.md b/README.md index b6c1014ff..85e2d4c6d 100644 --- a/README.md +++ b/README.md @@ -963,15 +963,15 @@ class MyCharm(CharmBase): def test_live_charm_introspection(mycharm): ctx = Context(mycharm, meta=mycharm.META) - # If you want to do this with actions, you can use `Context.action_emitter` instead. - with ctx.emitter("start", State()) as emitter: + # If you want to do this with actions, you can use `Context.action_manager` instead. + with ctx.manager("start", State()) as manager: # this is your charm instance, after ops has set it up - charm = emitter.charm + charm = manager.charm assert isinstance(charm, MyCharm) assert charm.a == "a" # this will tell ops.main to proceed with normal execution and emit the "start" event on the charm - state_out = emitter.emit() + state_out = runner.run() # after that is done, we are handed back control and we can again do some introspection assert charm.a == "b" @@ -980,7 +980,7 @@ def test_live_charm_introspection(mycharm): assert state_out.unit_status == ... ``` -Note that you can't `emitter.emit()` multiple times: the emitter 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. +Note that you can't `runner.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 can't emit multiple events in a single charm execution. diff --git a/scenario/context.py b/scenario/context.py index b7aaca9e6..644b1a51c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -44,10 +44,10 @@ class InvalidActionError(InvalidEventError): class AlreadyEmittedError(RuntimeError): - """Raised when _Emitter.emit() is called more than once.""" + """Raised when _runner.run() is called more than once.""" -class _Emitter: +class _Manager: """Context manager to offer test code some runtime charm object introspection.""" def __init__( @@ -77,13 +77,13 @@ def __enter__(self): next(self._run) return self - def emit(self) -> "State": + def run(self) -> "State": """Emit the event and proceed with charm execution. This can only be done once. """ if self._emitted: - raise AlreadyEmittedError("Can only _Emitter.emit() once.") + raise AlreadyEmittedError("Can only _runner.run() once.") self._emitted = True self.output = out = exhaust(self._run) @@ -91,34 +91,34 @@ def emit(self) -> "State": def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 if not self._emitted: - logger.debug("emitter not invoked. Doing so implicitly...") - self.emit() + logger.debug("manager not invoked. Doing so implicitly...") + self.run() def _finalize(self): - """Compatibility shim for _LegacyEmitter.""" + """Compatibility shim for _Legacymanager.""" pass -class _EventEmitter(_Emitter): +class _EventManager(_Manager): if TYPE_CHECKING: output: State def _runner(self): - return self._ctx._run_event(self._arg, self._state_in, emitter=self) + return self._ctx._run_event(self._arg, self._state_in, manager=self) -class _ActionEmitter(_Emitter): +class _ActionManager(_Manager): if TYPE_CHECKING: output: ActionOutput - def emit(self) -> ActionOutput: - return self._ctx._finalize_action(super().emit()) + def run(self) -> ActionOutput: + return self._ctx._finalize_action(super().run()) def _runner(self): - return self._ctx._run_action(self._arg, self._state_in, emitter=self) + return self._ctx._run_action(self._arg, self._state_in, manager=self) -class _LegacyEmitter: +class _LegacyManager: """Compatibility shim to keep using the [pre/post]-event syntax while we're deprecating it.""" def __init__(self, pre=None, post=None): @@ -251,13 +251,13 @@ def _coalesce_event(event: Union[str, Event]): ) return event - def _coalesce_emitter( + def _coalesce_manager( self, - emitter: Optional[_Emitter], + manager: Optional[_Manager], pre_event: Optional[Callable], post_event: Optional[Callable], - ) -> Union[_LegacyEmitter, _Emitter]: - # validate emitter and pre/post event arguments, cast to emitter + ) -> Union[_LegacyManager, _Manager]: + # validate manager and pre/post event arguments, cast to manager legacy_mode = pre_event or post_event if legacy_mode: logger.warning( @@ -266,17 +266,17 @@ def _coalesce_emitter( "Please start using the Context.[event/action]_runner context manager.", ) - if emitter and legacy_mode: + if manager and legacy_mode: raise ValueError( - "cannot call Context with emitter AND legacy [pre/post]-event", + "cannot call Context with manager AND legacy [pre/post]-event", ) - if emitter: - return emitter + if manager: + return manager - return _LegacyEmitter(pre_event, post_event) + return _LegacyManager(pre_event, post_event) - def emitter( + def manager( self, event: Union["Event", str], state: "State", @@ -284,18 +284,18 @@ def emitter( """Context manager to introspect live charm object before and after the event is emitted. Usage: - >>> with Context.action_emitter("start", State()) as emitter: - >>> assert emitter.charm._some_private_attribute == "foo" - >>> emitter.emit() # this will fire the event - >>> assert emitter.charm._some_private_attribute == "bar" + >>> with context.action_manager("start", State()) as manager: + >>> assert manager.charm._some_private_attribute == "foo" + >>> runner.run() # this will fire the event + >>> assert manager.charm._some_private_attribute == "bar" :arg event: the Event that the charm will respond to. Can be a string or an Event instance. :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Event. """ - return _EventEmitter(self, event, state) + return _EventManager(self, event, state) - def action_emitter( + def action_manager( self, action: Union["Action", str], state: "State", @@ -303,27 +303,27 @@ def action_emitter( """Context manager to introspect live charm object before and after the event is emitted. Usage: - >>> with Context.action_emitter("foo-action", State()) as emitter: - >>> assert emitter.charm._some_private_attribute == "foo" - >>> emitter.emit() # this will fire the event - >>> assert emitter.charm._some_private_attribute == "bar" + >>> with context.action_manager("foo-action", State()) as manager: + >>> assert manager.charm._some_private_attribute == "foo" + >>> runner.run() # this will fire the event + >>> assert manager.charm._some_private_attribute == "bar" :arg action: the Action that the charm will execute. Can be a string or an Action instance. :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 _ActionEmitter(self, action, state) + return _ActionManager(self, action, state) def _run_event( self, event: Union["Event", str], state: "State", - emitter: "_Emitter" = None, + manager: "_Manager" = None, ) -> Generator["State", None, None]: runner = self._run( self._coalesce_event(event), state=state, - emitter=emitter, + manager=manager, ) return runner @@ -344,15 +344,15 @@ def run( charm will invoke when handling the Event. :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_emitter instead. + This argument is deprecated. Please use Context.event_manager instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_emitter instead. + This argument is deprecated. Please use Context.event_manager instead. """ runner = self._run_event( event, state, - emitter=self._coalesce_emitter(None, pre_event, post_event), + manager=self._coalesce_manager(None, pre_event, post_event), ) # return the output @@ -364,13 +364,13 @@ def _run_action( self, action: Union["Action", str], state: "State", - emitter: _Emitter = None, + manager: _Manager = None, ) -> Generator["State", None, None]: action = self._coalesce_action(action) return self._run( action.event, state=state, - emitter=emitter, + manager=manager, ) def run_action( @@ -390,15 +390,15 @@ def run_action( charm will invoke when handling the Action (event). :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_emitter instead. + This argument is deprecated. Please use Context.event_manager instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_emitter instead. + This argument is deprecated. Please use Context.event_manager instead. """ runner = self._run_action( action, state, - emitter=self._coalesce_emitter(None, pre_event, post_event), + manager=self._coalesce_manager(None, pre_event, post_event), ) return self._finalize_action(exhaust(runner)) @@ -421,7 +421,7 @@ def _run( self, event: "Event", state: "State", - emitter: _Emitter = None, + manager: _Manager = None, ) -> Generator["State", None, None]: runtime = Runtime( charm_spec=self.charm_spec, @@ -432,6 +432,6 @@ def _run( return runtime.exec( state=state, event=event, - emitter=emitter, + manager=manager, context=self, ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 04af2c3cf..ca7697bf1 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -18,7 +18,7 @@ from ops.main import logger as ops_logger if TYPE_CHECKING: - from scenario.context import Context, Emitter + from scenario.context import Context, manager from scenario.state import Event, State, _CharmSpec @@ -78,7 +78,7 @@ def _emit_charm_event( def main( - emitter: "Emitter" = None, + manager: "manager" = None, state: "State" = None, event: "Event" = None, context: "Context" = None, @@ -134,10 +134,10 @@ def main( if not dispatcher.is_restricted_context(): framework.reemit() - if emitter: - emitter._setup(charm) + if manager: + manager._setup(charm) - # give control back to the emitter to do any setup and pre-event assertions + # give control back to the manager to do any setup and pre-event assertions yield _emit_charm_event(charm, dispatcher.event_name, event) diff --git a/scenario/runtime.py b/scenario/runtime.py index ed20aa3bb..f1dc447d4 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.context import Context, _Emitter, _LegacyEmitter + from scenario.context import Context, _LegacyManager, _Manager from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -339,7 +339,7 @@ def exec( state: "State", event: "Event", context: "Context", - emitter: Optional[Union["_Emitter", "_LegacyEmitter"]] = None, + manager: Optional[Union["_Manager", "_LegacyManager"]] = None, ) -> Generator["State", None, None]: """Runs an event with this state as initial state on a charm. @@ -382,7 +382,7 @@ def exec( try: main = mocked_main( - emitter=emitter, + manager=manager, state=output_state, event=event, context=context, @@ -401,9 +401,9 @@ def exec( except StopIteration: pass - logger.info(" - Finalizing emitter (legacy)") - if emitter: - emitter._finalize() + logger.info(" - Finalizing manager (legacy)") + if manager: + manager._finalize() except NoObserverError: raise # propagate along diff --git a/tests/test_e2e/test_emitter.py b/tests/test_e2e/test_manager.py similarity index 55% rename from tests/test_e2e/test_emitter.py rename to tests/test_e2e/test_manager.py index 8d20d6c55..030f835de 100644 --- a/tests/test_e2e/test_emitter.py +++ b/tests/test_e2e/test_manager.py @@ -3,7 +3,7 @@ from ops.charm import CharmBase from scenario import Action, Context, State -from scenario.context import AlreadyEmittedError, _EventEmitter +from scenario.context import AlreadyEmittedError, _EventManager @pytest.fixture(scope="function") @@ -24,16 +24,16 @@ def _on_event(self, e): return MyCharm -def test_emitter(mycharm): +def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventEmitter(ctx, "start", State()) as emitter: - assert isinstance(emitter.charm, mycharm) - state_out = emitter.emit() + with _EventManager(ctx, "start", State()) as manager: + assert isinstance(manager.charm, mycharm) + state_out = manager.run() assert state_out -def test_emitter_legacy(mycharm): +def test_manager_legacy(mycharm): ctx = Context(mycharm, meta=mycharm.META) def pre_event(charm): @@ -45,35 +45,35 @@ def post_event(charm): ctx.run("start", State(), pre_event=pre_event, post_event=post_event) -def test_emitter_implicit(mycharm): +def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventEmitter(ctx, "start", State()) as emitter: - print("charm before", emitter.charm) + with _EventManager(ctx, "start", State()) as manager: + print("charm before", manager.charm) - assert emitter.output - assert emitter.output.unit_status == ActiveStatus("start") + assert manager.output + assert manager.output.unit_status == ActiveStatus("start") -def test_emitter_reemit_fails(mycharm): +def test_manager_reemit_fails(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventEmitter(ctx, "start", State()) as emitter: - print("charm before", emitter.charm) - emitter.emit() + with _EventManager(ctx, "start", State()) as manager: + print("charm before", manager.charm) + manager.run() with pytest.raises(AlreadyEmittedError): - emitter.emit() + manager.run() - assert emitter.output + assert manager.output -def test_context_emitter(mycharm): +def test_context_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with ctx.emitter("start", State()) as emitter: - state_out = emitter.emit() + with ctx.manager("start", State()) as manager: + state_out = manager.run() assert state_out.model.name -def test_context_action_emitter(mycharm): +def test_context_action_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) - with ctx.action_emitter(Action("do-x"), State()) as emitter: - ao = emitter.emit() + with ctx.action_manager(Action("do-x"), State()) as manager: + ao = manager.run() assert ao.state.model.name diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 364f710c7..00ab2191f 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -9,7 +9,7 @@ from ops.framework import EventBase from scenario import Context -from scenario.context import _LegacyEmitter +from scenario.context import _LegacyManager from scenario.runtime import Runtime, UncaughtCharmError from scenario.state import Event, Relation, State, _CharmSpec from scenario.utils import exhaust @@ -57,7 +57,7 @@ def test_event_hooks(): runner = runtime.exec( state=State(), event=Event("update_status"), - emitter=_LegacyEmitter(pre_event, post_event), + manager=_LegacyManager(pre_event, post_event), context=Context(my_charm_type, meta=meta), ) exhaust(runner) @@ -118,7 +118,7 @@ def post_event(charm: CharmBase): runtime.exec( state=State(unit_id=unit_id), event=Event("start"), - emitter=_LegacyEmitter(None, post_event), + manager=_LegacyManager(None, post_event), context=Context(my_charm_type, meta=meta), ) @@ -143,7 +143,7 @@ def post_event(charm: CharmBase): runner = runtime.exec( state=State(), event=Event("box_relation_changed", relation=Relation("box")), - emitter=_LegacyEmitter(post=post_event), + manager=_LegacyManager(post=post_event), context=Context(my_charm_type, meta=meta), ) exhaust(runner) From f76597f3c4762859a77c9f5bf31de3a5b7e81dad Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 8 Sep 2023 11:36:25 +0200 Subject: [PATCH 302/546] refactored to use contextmanagers instead of abusing generators --- README.md | 595 +++++++++++++++++---------------- scenario/context.py | 193 +++++------ scenario/ops_main_mock.py | 125 +++++-- scenario/runtime.py | 55 +-- scenario/utils.py | 11 - tests/test_context.py | 41 +++ tests/test_e2e/test_manager.py | 36 +- tests/test_runtime.py | 60 +--- 8 files changed, 609 insertions(+), 507 deletions(-) delete mode 100644 scenario/utils.py create mode 100644 tests/test_context.py diff --git a/README.md b/README.md index 85e2d4c6d..4273433e5 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ I like metaphors, so here we go: - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what - other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the - stage (relation-departed), or the content of one of the books changes). + - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what + other actors are there around it, what is written in those pebble-shaped books on the table? + - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the + stage (relation-departed), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. @@ -66,15 +66,15 @@ Comparing scenario tests with `Harness` tests: A scenario test consists of three broad steps: - **Arrange**: - - declare the input state - - select an event to fire + - declare the input state + - select an event to fire - **Act**: - - run the state (i.e. obtain the output state) - - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal - APIs + - run the state (i.e. obtain the output state) + - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal + APIs - **Assert**: - - verify that the output state (or the delta with the input state) is how you expect it to be - - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - verify that the output state (or the delta with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, and no leadership. @@ -88,14 +88,14 @@ from ops.model import UnknownStatus class MyCharm(CharmBase): - pass + pass def test_scenario_base(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', State()) - assert out.unit_status == UnknownStatus() + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', State()) + assert out.unit_status == UnknownStatus() ``` Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': @@ -108,22 +108,22 @@ from ops.model import ActiveStatus class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) + def __init__(self, ...): + self.framework.observe(self.on.start, self._on_start) - def _on_start(self, _): - if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = ActiveStatus('I am ruled') + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = ActiveStatus('I am ruled') @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', State(leader=leader)) - assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') + ctx = Context(MyCharm, + meta={"name": "foo"}) + out = ctx.run('start', State(leader=leader)) + assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can @@ -141,19 +141,21 @@ from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedSta # charm code: def _on_event(self, _event): - self.unit.status = MaintenanceStatus('determining who the ruler is...') - try: - if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: - self.unit.status = ActiveStatus('I rule') - else: - self.unit.status = WaitingStatus('checking this is right...') - self._check_that_takes_some_more_time() - self.unit.status = ActiveStatus('I am ruled') - except: - self.unit.status = BlockedStatus('something went wrong') + self.unit.status = MaintenanceStatus('determining who the ruler is...') + try: + if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: + self.unit.status = ActiveStatus('I rule') + else: + self.unit.status = WaitingStatus('checking this is right...') + self._check_that_takes_some_more_time() + self.unit.status = ActiveStatus('I am ruled') + except: + self.unit.status = BlockedStatus('something went wrong') ``` -More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its relationship with the `State`. +More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what +statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its +relationship with the `State`. # Context and State @@ -181,20 +183,20 @@ from scenario import State, Context def test_statuses(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - ctx.run('start', State(leader=False)) - assert ctx.unit_status_history == [ - UnknownStatus(), - MaintenanceStatus('determining who the ruler is...'), - WaitingStatus('checking this is right...'), - ActiveStatus("I am ruled"), - ] - - assert ctx.app_status_history == [ - UnknownStatus(), - ActiveStatus(""), - ] + ctx = Context(MyCharm, + meta={"name": "foo"}) + ctx.run('start', State(leader=False)) + assert ctx.unit_status_history == [ + UnknownStatus(), + MaintenanceStatus('determining who the ruler is...'), + WaitingStatus('checking this is right...'), + ActiveStatus("I am ruled"), + ] + + assert ctx.app_status_history == [ + UnknownStatus(), + ActiveStatus(""), + ] ``` Note that the current status is not in the **unit status history**. @@ -213,8 +215,8 @@ from scenario import State, Status # ... ctx.run('start', State(unit_status=ActiveStatus('foo'))) assert ctx.unit_status_history == [ - ActiveStatus('foo'), # now the first status is active: 'foo'! - # ... + ActiveStatus('foo'), # now the first status is active: 'foo'! + # ... ] ``` @@ -246,11 +248,11 @@ from ops.charm import StartEvent def test_foo(): - ctx = Context(...) - ctx.run('start', ...) + ctx = Context(...) + ctx.run('start', ...) - assert len(ctx.emitted_events) == 1 - assert isinstance(ctx.emitted_events[0], StartEvent) + assert len(ctx.emitted_events) == 1 + assert isinstance(ctx.emitted_events[0], StartEvent) ``` ### Low-level access: using directly `capture_events` @@ -266,11 +268,11 @@ from ops.charm import StartEvent, UpdateStatusEvent from scenario import State, Context, DeferredEvent, capture_events with capture_events() as emitted: - ctx = Context(...) - state_out = ctx.run( - "update-status", - State(deferred=[DeferredEvent("start", ...)]) - ) + ctx = Context(...) + state_out = ctx.run( + "update-status", + State(deferred=[DeferredEvent("start", ...)]) + ) # deferred events get reemitted first assert isinstance(emitted[0], StartEvent) @@ -288,8 +290,8 @@ from ops.charm import StartEvent, RelationEvent from scenario import capture_events with capture_events(StartEvent, RelationEvent) as emitted: - # capture all `start` and `*-relation-*` events. - pass + # capture all `start` and `*-relation-*` events. + pass ``` Configuration: @@ -312,41 +314,41 @@ from scenario import Relation, State, Context # This charm copies over remote app data to local unit data class MyCharm(CharmBase): - ... + ... - def _on_event(self, e): - rel = e.relation - assert rel.app.name == 'remote' - assert rel.data[self.unit]['abc'] == 'foo' - rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] + def _on_event(self, e): + rel = e.relation + assert rel.app.name == 'remote' + assert rel.data[self.unit]['abc'] == 'foo' + rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] def test_relation_data(): - state_in = State(relations=[ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ]) - ctx = Context(MyCharm, - meta={"name": "foo"}) - - state_out = ctx.run('start', state_in) - - assert state_out.relations[0].local_unit_data == {"abc": "baz!"} - # you can do this to check that there are no other differences: - assert state_out.relations == [ - Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "baz!"}, - remote_app_data={"cde": "baz!"}, - ), - ] + state_in = State(relations=[ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ), + ]) + ctx = Context(MyCharm, + meta={"name": "foo"}) + + state_out = ctx.run('start', state_in) + + assert state_out.relations[0].local_unit_data == {"abc": "baz!"} + # you can do this to check that there are no other differences: + assert state_out.relations == [ + Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "baz!"}, + remote_app_data={"cde": "baz!"}, + ), + ] # which is very idiomatic and superbly explicit. Noice. ``` @@ -376,8 +378,8 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o from scenario.state import PeerRelation relation = PeerRelation( - endpoint="peers", - peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, ) ``` @@ -388,11 +390,11 @@ be flagged by the Consistency Checker: from scenario import State, PeerRelation, Context state_in = State(relations=[ - PeerRelation( - endpoint="peers", - peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )], - unit_id=1) + PeerRelation( + endpoint="peers", + peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, + )], + unit_id=1) Context(...).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. @@ -416,10 +418,10 @@ argument. Also, it talks in terms of `primary`: from scenario.state import SubordinateRelation relation = SubordinateRelation( - endpoint="peers", - remote_unit_data={"foo": "bar"}, - remote_app_name="zookeeper", - remote_unit_id=42 + endpoint="peers", + remote_unit_data={"foo": "bar"}, + remote_app_name="zookeeper", + remote_unit_id=42 ) relation.remote_unit_name # "zookeeper/42" ``` @@ -450,7 +452,6 @@ changed_event = Event('foo-relation-changed', relation=relation) The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. - ### Working with relation IDs Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. @@ -459,6 +460,7 @@ To inspect the ID the next relation instance will have, you can call `state.next ```python from scenario import Relation from scenario.state import next_relation_id + next_id = next_relation_id(update=False) rel = Relation('foo') assert rel.relation_id == next_id @@ -469,6 +471,7 @@ This can be handy when using `replace` to create new relations, to avoid relatio ```python from scenario import Relation from scenario.state import next_relation_id + rel = Relation('foo') rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=next_relation_id()) assert rel2.relation_id == rel.relation_id + 1 @@ -514,8 +517,8 @@ An example of a scene including some containers: from scenario.state import Container, State state = State(containers=[ - Container(name="foo", can_connect=True), - Container(name="bar", can_connect=False) + Container(name="foo", can_connect=True), + Container(name="bar", can_connect=False) ]) ``` @@ -538,8 +541,8 @@ In this case, if the charm were to: ```python def _on_start(self, _): - foo = self.unit.get_container('foo') - content = foo.pull('/local/share/config.yaml').read() + foo = self.unit.get_container('foo') + content = foo.pull('/local/share/config.yaml').read() ``` then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping @@ -554,30 +557,30 @@ from scenario import State, Container, Mount, Context class MyCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) - def _on_pebble_ready(self, _): - foo = self.unit.get_container('foo') - foo.push('/local/share/config.yaml', "TEST", make_dirs=True) + def _on_pebble_ready(self, _): + foo = self.unit.get_container('foo') + foo.push('/local/share/config.yaml', "TEST", make_dirs=True) def test_pebble_push(): - with tempfile.NamedTemporaryFile() as local_file: - container = Container(name='foo', - can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) - state_in = State( - containers=[container] - ) - Context( - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}).run( - "start", - state_in, - ) - assert local_file.read().decode() == "TEST" + with tempfile.NamedTemporaryFile() as local_file: + container = Container(name='foo', + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) + state_in = State( + containers=[container] + ) + Context( + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}).run( + "start", + state_in, + ) + assert local_file.read().decode() == "TEST" ``` `container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we @@ -603,32 +606,32 @@ drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib class MyCharm(CharmBase): - def _on_start(self, _): - foo = self.unit.get_container('foo') - proc = foo.exec(['ls', '-ll']) - stdout, _ = proc.wait_output() - assert stdout == LS_LL + def _on_start(self, _): + foo = self.unit.get_container('foo') + proc = foo.exec(['ls', '-ll']) + stdout, _ = proc.wait_output() + assert stdout == LS_LL def test_pebble_exec(): - container = Container( - name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) - } - ) - state_in = State( - containers=[container] - ) - state_out = Context( - MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}, - ).run( - container.pebble_ready_event, - state_in, - ) + container = Container( + name='foo', + exec_mock={ + ('ls', '-ll'): # this is the command we're mocking + ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) + } + ) + state_in = State( + containers=[container] + ) + state_out = Context( + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}, + ).run( + container.pebble_ready_event, + state_in, + ) ``` # Secrets @@ -639,12 +642,12 @@ Scenario has secrets. Here's how you use them. from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}} - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}} + ) + ] ) ``` @@ -661,15 +664,15 @@ To specify a secret owned by this unit (or app): from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}}, - owner='unit', # or 'app' - remote_grants={0: {"remote"}} - # the secret owner has granted access to the "remote" app over some relation with ID 0 - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + owner='unit', # or 'app' + remote_grants={0: {"remote"}} + # the secret owner has granted access to the "remote" app over some relation with ID 0 + ) + ] ) ``` @@ -679,15 +682,15 @@ To specify a secret owned by some other application and give this unit (or app) from scenario import State, Secret state = State( - secrets=[ - Secret( - id='foo', - contents={0: {'key': 'public'}}, - # owner=None, which is the default - granted="unit", # or "app", - revision=0, # the revision that this unit (or app) is currently tracking - ) - ] + secrets=[ + Secret( + id='foo', + contents={0: {'key': 'public'}}, + # owner=None, which is the default + granted="unit", # or "app", + revision=0, # the revision that this unit (or app) is currently tracking + ) + ] ) ``` @@ -708,16 +711,16 @@ from charm import MyCharm def test_backup_action(): - ctx = Context(MyCharm) + ctx = Context(MyCharm) - # If you didn't declare do_backup in the charm's `actions.yaml`, - # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. - out: ActionOutput = ctx.run_action("do_backup_action", State()) + # If you didn't declare do_backup in the charm's `actions.yaml`, + # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed. + out: ActionOutput = ctx.run_action("do_backup_action", State()) - # you can assert action results, logs, failure using the ActionOutput interface - assert out.results == {'foo': 'bar'} - assert out.logs == ['baz', 'qux'] - assert out.failure == 'boo-hoo' + # you can assert action results, logs, failure using the ActionOutput interface + assert out.results == {'foo': 'bar'} + assert out.logs == ['baz', 'qux'] + assert out.failure == 'boo-hoo' ``` ## Parametrized Actions @@ -730,15 +733,15 @@ from charm import MyCharm def test_backup_action(): - # define an action - action = Action('do_backup', params={'a': 'b'}) - ctx = Context(MyCharm) + # define an action + action = Action('do_backup', params={'a': 'b'}) + ctx = Context(MyCharm) - # if the parameters (or their type) don't match what declared in actions.yaml, - # the `ConsistencyChecker` will slap you on the other wrist. - out: ActionOutput = ctx.run_action(action, State()) + # if the parameters (or their type) don't match what declared in actions.yaml, + # the `ConsistencyChecker` will slap you on the other wrist. + out: ActionOutput = ctx.run_action(action, State()) - # ... + # ... ``` # Deferred events @@ -753,26 +756,26 @@ from scenario import State, deferred, Context class MyCharm(...): - ... + ... - def _on_update_status(self, e): - e.defer() + def _on_update_status(self, e): + e.defer() - def _on_start(self, e): - e.defer() + def _on_start(self, e): + e.defer() def test_start_on_deferred_update_status(MyCharm): - """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" - state_in = State( - deferred=[ - deferred('update_status', - handler=MyCharm._on_update_status) - ] - ) - state_out = Context(MyCharm).run('start', state_in) - assert len(state_out.deferred) == 1 - assert state_out.deferred[0].name == 'start' + """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" + state_in = State( + deferred=[ + deferred('update_status', + handler=MyCharm._on_update_status) + ] + ) + state_out = Context(MyCharm).run('start', state_in) + assert len(state_out.deferred) == 1 + assert state_out.deferred[0].name == 'start' ``` You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the @@ -783,7 +786,7 @@ from scenario import Event, Relation class MyCharm(...): - ... + ... deferred_start = Event('start').deferred(MyCharm._on_start) @@ -805,16 +808,16 @@ from scenario import State, Context class MyCharm(...): - ... + ... - def _on_start(self, e): - e.defer() + def _on_start(self, e): + e.defer() def test_defer(MyCharm): - out = Context(MyCharm).run('start', State()) - assert len(out.deferred) == 1 - assert out.deferred[0].name == 'start' + out = Context(MyCharm).run('start', State()) + assert len(out.deferred) == 1 + assert out.deferred[0].name == 'start' ``` ## Deferring relation events @@ -828,22 +831,22 @@ from scenario import State, Relation, deferred class MyCharm(...): - ... + ... - def _on_foo_relation_changed(self, e): - e.defer() + def _on_foo_relation_changed(self, e): + e.defer() def test_start_on_deferred_update_status(MyCharm): - foo_relation = Relation('foo') - State( - relations=[foo_relation], - deferred=[ - deferred('foo_relation_changed', - handler=MyCharm._on_foo_relation_changed, - relation=foo_relation) - ] - ) + foo_relation = Relation('foo') + State( + relations=[foo_relation], + deferred=[ + deferred('foo_relation_changed', + handler=MyCharm._on_foo_relation_changed, + relation=foo_relation) + ] + ) ``` but you can also use a shortcut from the relation event itself, as mentioned above: @@ -854,7 +857,7 @@ from scenario import Relation class MyCharm(...): - ... + ... foo_relation = Relation('foo') @@ -872,9 +875,9 @@ For general-purpose usage, you will need to instantiate DeferredEvent directly. from scenario import DeferredEvent my_deferred_event = DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' + handle_path='MyCharm/MyCharmLib/on/database_ready[1]', + owner='MyCharmLib', # the object observing the event. Could also be MyCharm. + observer='_on_database_ready' ) ``` @@ -889,21 +892,21 @@ from scenario import State, StoredState class MyCharmType(CharmBase): - my_stored_state = Ops_StoredState() + my_stored_state = Ops_StoredState() - def __init__(self, framework: Framework): - super().__init__(framework) - assert self.my_stored_state.foo == 'bar' # this will pass! + def __init__(self, framework: Framework): + super().__init__(framework) + assert self.my_stored_state.foo == 'bar' # this will pass! state = State(stored_state=[ - StoredState( - owner_path="MyCharmType", - name="my_stored_state", - content={ - 'foo': 'bar', - 'baz': {42: 42}, - }) + StoredState( + owner_path="MyCharmType", + name="my_stored_state", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) ]) ``` @@ -912,24 +915,32 @@ the output side the same as any other bit of state. # Emitting custom events -While the main use case of Scenario is to emit juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. +While the main use case of Scenario is to emit juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, +etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. Suppose your charm uses a charm library providing an `ingress_provided` event. -The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever that may be, for example a `foo-relation-changed`. +The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever +that may be, for example a `foo-relation-changed`. -However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and the event is emitted at all. +However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and +the event is emitted at all. If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: + ```python from scenario import Context, State + Context(...).run("ingress_provided", State()) # raises scenario.ops_main_mock.NoObserverError ``` -This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but since the event is defined on another Object, it will fail to find it. + +This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but +since the event is defined on another Object, it will fail to find it. You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: ```python from scenario import Context, State + Context(...).run("my_charm_lib.on.ingress_provided", State()) ``` @@ -939,50 +950,60 @@ This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. # Live charm introspection -Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to B, but not to assert that, in the context of this charm execution, with this state, a certain method call would return a given piece of data. +Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to +B, but not to assert that, in the context of this charm execution, with this state, a certain method call would return a +given piece of data. Scenario offers a context manager for this use case specifically: ```python -from ops.charm import CharmBase +from ops import CharmBase, StoredState +from charms.bar.lib_name.v1.charm_lib import CharmLib from scenario import Context, State class MyCharm(CharmBase): - META = {"name": "mycharm"} - - def __init__(self, framework): - super().__init__(framework) - self.a = "a" - framework.observe(self.on.start, self._on_start) + META = {"name": "mycharm"} + _stored = StoredState() + + def __init__(self, framework): + super().__init__(framework) + self._stored.set_default(a="a") + self.my_charm_lib = CharmLib() + framework.observe(self.on.start, self._on_start) - def _on_start(self, event): - self.a = "b" + def _on_start(self, event): + self._stored.a = "b" def test_live_charm_introspection(mycharm): - ctx = Context(mycharm, meta=mycharm.META) - # If you want to do this with actions, you can use `Context.action_manager` instead. - with ctx.manager("start", State()) as manager: - # this is your charm instance, after ops has set it up - charm = manager.charm - assert isinstance(charm, MyCharm) - assert charm.a == "a" - - # this will tell ops.main to proceed with normal execution and emit the "start" event on the charm - state_out = runner.run() - - # after that is done, we are handed back control and we can again do some introspection - assert charm.a == "b" - - # state_out is, as in regular scenario tests, a State object you can assert on: - assert state_out.unit_status == ... + ctx = Context(mycharm, meta=mycharm.META) + # If you want to do this with actions, you can use `Context.action_manager` instead. + with ctx.manager("start", State()) as manager: + # this is your charm instance, after ops has set it up + charm: MyCharm = manager.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() + + # after that is done, we are handed back control, and we can again do some introspection + assert charm.my_charm_lib.foo == "bar" + # and check that the charm's internal state is as we expect + assert charm._stored.a == "b" + + # state_out is, as in regular scenario tests, a State object you can assert on: + assert state_out.unit_status == ... ``` -Note that you can't `runner.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 can't emit multiple events in a single charm execution. - - +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 +can't emit multiple events in a single charm execution. # The virtual charm root @@ -997,7 +1018,7 @@ from scenario import State, Context class MyCharmType(CharmBase): - pass + pass ctx = Context(charm_type=MyCharmType, @@ -1015,14 +1036,14 @@ import tempfile class MyCharmType(CharmBase): - pass + pass td = tempfile.TemporaryDirectory() state = Context( - charm_type=MyCharmType, - meta={'name': 'my-charm-name'}, - charm_root=td.name + charm_type=MyCharmType, + meta={'name': 'my-charm-name'}, + charm_root=td.name ).run('start', State()) ``` @@ -1032,16 +1053,20 @@ ignored. # Immutability -All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen dataclasses). +All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen +dataclasses). -This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the state that you obtain in return is a different instance, and all parts of it have been (deep)copied. +This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the +state that you obtain in return is a different instance, and all parts of it have been (deep)copied. This ensures that you can do delta-based comparison of states without worrying about them being mutated by scenario. -If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use the `replace` api. +If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use +the `replace` api. ```python from scenario import Relation -relation = Relation('foo', remote_app_data={"1":"2"}) + +relation = Relation('foo', remote_app_data={"1": "2"}) # make a copy of relation, but with remote_app_data set to {"3", "4"} relation2 = relation.replace(remote_app_data={"3", "4"}) ``` diff --git a/scenario/context.py b/scenario/context.py index 644b1a51c..d922d36db 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -3,17 +3,19 @@ # See LICENSE file for licensing details. import tempfile from collections import namedtuple +from contextlib import contextmanager from pathlib import Path from typing import ( TYPE_CHECKING, Any, Callable, + ContextManager, Dict, - Generator, List, Optional, Type, Union, + cast, ) from ops import EventBase @@ -21,11 +23,11 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import Action, Event, _CharmSpec -from scenario.utils import exhaust if TYPE_CHECKING: from ops.testing import CharmType + from scenario.ops_main_mock import Ops from scenario.state import JujuLogLine, State, _EntityStatus PathLike = Union[str, Path] @@ -63,30 +65,39 @@ def __init__( self._emitted: bool = False self._run = None - self.charm: Optional[CharmType] = None + self.ops: Optional["Ops"] = None self.output: Optional[Union["State", ActionOutput]] = None - def _setup(self, charm: "CharmType"): - self.charm = charm + @property + def charm(self) -> "CharmType": + return self.ops.charm + @property def _runner(self): raise NotImplementedError("override in subclass") + def _get_output(self): + raise NotImplementedError("override in subclass") + def __enter__(self): - self._run = self._runner() - next(self._run) + self._wrapped_ctx = wrapped_ctx = self._runner()(self._arg, self._state_in) + ops = wrapped_ctx.__enter__() + self.ops = ops return self - def run(self) -> "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 _runner.run() once.") + raise AlreadyEmittedError("Can only context.manager.run() once.") self._emitted = True - self.output = out = exhaust(self._run) + # 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 def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 @@ -94,46 +105,33 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 logger.debug("manager not invoked. Doing so implicitly...") self.run() - def _finalize(self): - """Compatibility shim for _Legacymanager.""" - pass - class _EventManager(_Manager): if TYPE_CHECKING: output: State + def run(self) -> "State": + return cast("State", super().run()) + def _runner(self): - return self._ctx._run_event(self._arg, self._state_in, manager=self) + return self._ctx._run_event + + def _get_output(self): + return self._ctx._output_state class _ActionManager(_Manager): if TYPE_CHECKING: output: ActionOutput - def run(self) -> ActionOutput: - return self._ctx._finalize_action(super().run()) + def run(self) -> "ActionOutput": + return cast("ActionOutput", super().run()) def _runner(self): - return self._ctx._run_action(self._arg, self._state_in, manager=self) + return self._ctx._run_action - -class _LegacyManager: - """Compatibility shim to keep using the [pre/post]-event syntax while we're deprecating it.""" - - def __init__(self, pre=None, post=None): - self.pre = pre - self.post = post - self.charm = None - - def _setup(self, charm): - self.charm = charm - if self.pre: - self.pre(charm) - - def _finalize(self): - if self.post: - self.post(self.charm) + def _get_output(self): + return self._ctx._finalize_action(self._ctx._output_state) class Context: @@ -197,11 +195,18 @@ def __init__( self.workload_version_history: List[str] = [] self.emitted_events: List[EventBase] = [] + # set by Runtime.exec() in self._run() + self._output_state: Optional["State"] = None + # ephemeral side effects from running an action self._action_logs = [] self._action_results = None self._action_failure = "" + def _set_output_state(self, output_state: "State"): + """Hook for Runtime to set the output state.""" + self._output_state = output_state + def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name @@ -216,6 +221,7 @@ def clear(self): self._action_logs = [] self._action_results = None self._action_failure = "" + self._output_state = None def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" @@ -251,31 +257,20 @@ def _coalesce_event(event: Union[str, Event]): ) return event - def _coalesce_manager( - self, - manager: Optional[_Manager], + @staticmethod + def _warn_deprecation_if_pre_or_post_event( pre_event: Optional[Callable], post_event: Optional[Callable], - ) -> Union[_LegacyManager, _Manager]: - # validate manager and pre/post event arguments, cast to manager + ): + # warn if pre/post event arguments are passed legacy_mode = pre_event or post_event if legacy_mode: logger.warning( "The [pre/post]_event syntax is deprecated and " "will be removed in a future release. " - "Please start using the Context.[event/action]_runner context manager.", + "Please use the ``Context.[action_]manager`` context manager.", ) - if manager and legacy_mode: - raise ValueError( - "cannot call Context with manager AND legacy [pre/post]-event", - ) - - if manager: - return manager - - return _LegacyManager(pre_event, post_event) - def manager( self, event: Union["Event", str], @@ -284,9 +279,9 @@ def manager( """Context manager to introspect live charm object before and after the event is emitted. Usage: - >>> with context.action_manager("start", State()) as manager: + >>> with Context().manager("start", State()) as manager: >>> assert manager.charm._some_private_attribute == "foo" - >>> runner.run() # this will fire the event + >>> manager.run() # this will fire the event >>> assert manager.charm._some_private_attribute == "bar" :arg event: the Event that the charm will respond to. Can be a string or an Event instance. @@ -303,9 +298,9 @@ def action_manager( """Context manager to introspect live charm object before and after the event is emitted. Usage: - >>> with context.action_manager("foo-action", State()) as manager: + >>> with Context().action_manager("foo-action", State()) as manager: >>> assert manager.charm._some_private_attribute == "foo" - >>> runner.run() # this will fire the event + >>> manager.run() # this will fire the event >>> assert manager.charm._some_private_attribute == "bar" :arg action: the Action that the charm will execute. Can be a string or an Action instance. @@ -314,18 +309,17 @@ def action_manager( """ return _ActionManager(self, action, state) + @contextmanager def _run_event( self, event: Union["Event", str], state: "State", - manager: "_Manager" = None, - ) -> Generator["State", None, None]: - runner = self._run( - self._coalesce_event(event), + ) -> ContextManager["Ops"]: + with self._run( + event=self._coalesce_event(event), state=state, - manager=manager, - ) - return runner + ) as ops: + yield ops def run( self, @@ -349,29 +343,21 @@ def run( Will receive the charm instance as only positional argument. This argument is deprecated. Please use Context.event_manager instead. """ - runner = self._run_event( + self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) + + with self._run_event( event, state, - manager=self._coalesce_manager(None, pre_event, post_event), - ) + ) as ops: + if pre_event: + pre_event(ops.charm) - # return the output - # step it once to get to the point before the event is emitted - # step it twice to let Runtime terminate - return exhaust(runner) + ops.emit() - def _run_action( - self, - action: Union["Action", str], - state: "State", - manager: _Manager = None, - ) -> Generator["State", None, None]: - action = self._coalesce_action(action) - return self._run( - action.event, - state=state, - manager=manager, - ) + if post_event: + post_event(ops.charm) + + return self._output_state def run_action( self, @@ -395,12 +381,21 @@ def run_action( Will receive the charm instance as only positional argument. This argument is deprecated. Please use Context.event_manager instead. """ - runner = self._run_action( - action, - state, - manager=self._coalesce_manager(None, pre_event, post_event), - ) - return self._finalize_action(exhaust(runner)) + self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) + + with self._run_action( + action=self._coalesce_action(action), + state=state, + ) as ops: + if pre_event: + pre_event(ops.charm) + + ops.emit() + + if post_event: + post_event(ops.charm) + + return self._finalize_action(self._output_state) def _finalize_action(self, state_out: "State"): ao = ActionOutput( @@ -417,21 +412,33 @@ def _finalize_action(self, state_out: "State"): return ao + @contextmanager + def _run_action( + self, + action: Union["Action", str], + state: "State", + ) -> ContextManager["Ops"]: + action = self._coalesce_action(action) + with self._run( + event=action.event, + state=state, + ) as ops: + yield ops + + @contextmanager def _run( self, event: "Event", state: "State", - manager: _Manager = None, - ) -> Generator["State", None, None]: + ) -> ContextManager["Ops"]: runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, charm_root=self.charm_root, ) - - return runtime.exec( + with runtime.exec( state=state, event=event, - manager=manager, context=self, - ) + ) as ops: + yield ops diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index ca7697bf1..e50e093a5 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -18,7 +18,7 @@ from ops.main import logger as ops_logger if TYPE_CHECKING: - from scenario.context import Context, manager + from scenario.context import Context from scenario.state import Event, State, _CharmSpec @@ -77,17 +77,13 @@ def _emit_charm_event( event_to_emit.emit(*args, **kwargs) -def main( - manager: "manager" = None, - state: "State" = None, - event: "Event" = None, - context: "Context" = None, - charm_spec: "_CharmSpec" = None, +def setup_framework( + charm_dir, + state: "State", + event: "Event", + context: "Context", + charm_spec: "_CharmSpec", ): - """Set up the charm and dispatch the observed event.""" - charm_class = charm_spec.charm_type - charm_dir = _get_charm_dir() - from scenario.mocking import _MockModelBackend model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false @@ -103,9 +99,6 @@ def main( ops.__version__, ) # type:ignore - dispatcher = _Dispatcher(charm_dir) - dispatcher.run_any_legacy_hook() - metadata = (charm_dir / "metadata.yaml").read_text() actions_meta = charm_dir / "actions.yaml" if actions_meta.exists(): @@ -122,29 +115,97 @@ def main( store = ops.storage.SQLiteStorage(charm_state_path) framework = ops.framework.Framework(store, charm_dir, meta, model) framework.set_breakpointhook() - try: - sig = inspect.signature(charm_class) - sig.bind(framework) # signature check + return framework + + +def setup_charm(charm_class, framework, dispatcher): + sig = inspect.signature(charm_class) + sig.bind(framework) # signature check - charm = charm_class(framework) - dispatcher.ensure_event_links(charm) + charm = charm_class(framework) + dispatcher.ensure_event_links(charm) + return charm - # Skip reemission of deferred events for collect-metrics events because - # they do not have the full access to all hook tools. - if not dispatcher.is_restricted_context(): - framework.reemit() - if manager: - manager._setup(charm) +def setup(state: "State", event: "Event", context: "Context", charm_spec: "_CharmSpec"): + """Setup dispatcher, framework and charm objects.""" + charm_class = charm_spec.charm_type + charm_dir = _get_charm_dir() + + dispatcher = _Dispatcher(charm_dir) + dispatcher.run_any_legacy_hook() - # give control back to the manager to do any setup and pre-event assertions - yield + framework = setup_framework(charm_dir, state, event, context, charm_spec) + charm = setup_charm(charm_class, framework, dispatcher) + return dispatcher, framework, charm + + +class Ops: + """Class to manage stepping through ops setup, event emission and framework commit.""" + + def __init__( + self, + state: "State", + event: "Event", + context: "Context", + charm_spec: "_CharmSpec", + ): + self.state = state + self.event = event + self.context = context + self.charm_spec = charm_spec + + # set by setup() + self.dispatcher = None + self.framework = None + self.charm = None + + self._has_setup = False + self._has_emitted = False + self._has_committed = False + + def setup(self): + """Setup framework, charm and dispatcher.""" + self._has_setup = True + self.dispatcher, self.framework, self.charm = setup( + self.state, + self.event, + self.context, + self.charm_spec, + ) + + def emit(self): + """Emit the event on the charm.""" + if not self._has_setup: + raise RuntimeError("should .setup() before you .emit()") + self._has_emitted = True - _emit_charm_event(charm, dispatcher.event_name, event) + try: + if not self.dispatcher.is_restricted_context(): + self.framework.reemit() - framework.commit() + _emit_charm_event(self.charm, self.dispatcher.event_name, self.event) - finally: - framework.close() + except Exception: + self.framework.close() + raise - return None + def commit(self): + """Commit the framework and teardown.""" + if not self._has_emitted: + raise RuntimeError("should .emit() before you .commit()") + + self._has_committed = True + try: + self.framework.commit() + finally: + self.framework.close() + + def finalize(self): + """Step through all non-manually-called procedures and run them.""" + if not self._has_setup: + self.setup() + if not self._has_emitted: + self.emit() + if not self._has_committed: + self.commit() diff --git a/scenario/runtime.py b/scenario/runtime.py index f1dc447d4..f7bf264e7 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -11,7 +11,6 @@ from typing import ( TYPE_CHECKING, ContextManager, - Generator, List, Optional, Tuple, @@ -32,7 +31,8 @@ if TYPE_CHECKING: from ops.testing import CharmType - from scenario.context import Context, _LegacyManager, _Manager + from scenario.context import Context + from scenario.ops_main_mock import Ops from scenario.state import AnyRelation, Event, State, _CharmSpec _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -132,6 +132,28 @@ def apply_state(self, state: "State"): db.close() +class _OpsMainContext: + """Context manager representing ops.main execution context. + + When entered, ops.main sets up everything up until the charm. + When .emit() is called, ops.main proceeds with emitting the event. + When exited, if .emit has not been called manually, it is called automatically. + """ + + def __init__(self): + self._has_emitted = False + + def __enter__(self): + pass + + def emit(self): + self._has_emitted = True + + def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 + if not self._has_emitted: + self.emit() + + class Runtime: """Charm runtime wrapper. @@ -334,13 +356,13 @@ def _exec_ctx(self) -> ContextManager[Tuple[Path, List[EventBase]]]: with capture_events() as captured: yield (temporary_charm_root, captured) + @contextmanager def exec( self, state: "State", event: "Event", context: "Context", - manager: Optional[Union["_Manager", "_LegacyManager"]] = None, - ) -> Generator["State", None, None]: + ) -> ContextManager["Ops"]: """Runs an event with this state as initial state on a charm. Returns the 'output state', that is, the state as mutated by the charm during the @@ -376,13 +398,10 @@ def exec( os.environ.update(env) logger.info(" - Entering ops.main (mocked).") - # we don't import from ops.main because we need some extras, such as the - # pre/post_event hooks - from scenario.ops_main_mock import main as mocked_main + from scenario.ops_main_mock import Ops try: - main = mocked_main( - manager=manager, + ops = Ops( state=output_state, event=event, context=context, @@ -390,20 +409,12 @@ def exec( charm_type=self._wrap(charm_type), ), ) + ops.setup() - # main is a generator, let's step it up until its yield - # statement = right before firing the event - yield next(main) - - # exhaust the iterator = allow ops to tear down - try: - next(main) - except StopIteration: - pass + yield ops - logger.info(" - Finalizing manager (legacy)") - if manager: - manager._finalize() + # if the caller did not manually emit or commit: do that. + ops.finalize() except NoObserverError: raise # propagate along @@ -424,4 +435,4 @@ def exec( context.emitted_events.extend(captured) logger.info("event dispatched. done.") - return output_state + context._set_output_state(output_state) diff --git a/scenario/utils.py b/scenario/utils.py deleted file mode 100644 index 29310a764..000000000 --- a/scenario/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - - -def exhaust(generator): - while True: - try: - next(generator) - except StopIteration as e: - return e.value diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 000000000..1a1d3e19a --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,41 @@ +from unittest.mock import patch + +from ops import CharmBase + +from scenario import Action, Context, Event, State + + +class MyCharm(CharmBase): + pass + + +def test_run(): + ctx = Context(MyCharm, meta={"name": "foo"}) + state = State() + + with patch.object(ctx, "_run") as p: + ctx.run("start", state) + + assert p.called + e = p.call_args.kwargs["event"] + s = p.call_args.kwargs["state"] + + assert isinstance(e, Event) + assert e.name == "start" + assert s is state + + +def test_run_action(): + ctx = Context(MyCharm, meta={"name": "foo"}) + state = State() + + with patch.object(ctx, "_run_action") as p: + ctx.run_action("do-foo", state) + + assert p.called + a = p.call_args.kwargs["action"] + s = p.call_args.kwargs["state"] + + assert isinstance(a, Action) + assert a.event.name == "do_foo_action" + assert s is state diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 030f835de..2008b335d 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -1,9 +1,11 @@ +from unittest.mock import MagicMock + import pytest from ops import ActiveStatus from ops.charm import CharmBase from scenario import Action, Context, State -from scenario.context import AlreadyEmittedError, _EventManager +from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager @pytest.fixture(scope="function") @@ -28,28 +30,35 @@ def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventManager(ctx, "start", State()) as manager: assert isinstance(manager.charm, mycharm) + assert not manager.output state_out = manager.run() + assert manager.output is state_out - assert state_out + assert isinstance(state_out, State) + assert manager.output # still there! -def test_manager_legacy(mycharm): +def test_manager_legacy_pre_post_hooks(mycharm): ctx = Context(mycharm, meta=mycharm.META) - - def pre_event(charm): - print(1) - - def post_event(charm): - print(2) + post_event = MagicMock() + pre_event = MagicMock() ctx.run("start", State(), pre_event=pre_event, post_event=post_event) + assert post_event.called + assert isinstance(post_event.call_args.args[0], mycharm) + assert pre_event.called + assert isinstance(pre_event.call_args.args[0], mycharm) + def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventManager(ctx, "start", State()) as manager: - print("charm before", manager.charm) + 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") @@ -57,7 +66,6 @@ def test_manager_implicit(mycharm): def test_manager_reemit_fails(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventManager(ctx, "start", State()) as manager: - print("charm before", manager.charm) manager.run() with pytest.raises(AlreadyEmittedError): manager.run() @@ -69,11 +77,13 @@ def test_context_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) with ctx.manager("start", State()) as manager: state_out = manager.run() - assert state_out.model.name + 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(Action("do-x"), State()) as manager: ao = manager.run() - assert ao.state.model.name + assert isinstance(ao, ActionOutput) + assert ctx.emitted_events[0].handle.kind == "do_x_action" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 00ab2191f..976f16f53 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -9,10 +9,8 @@ from ops.framework import EventBase from scenario import Context -from scenario.context import _LegacyManager from scenario.runtime import Runtime, UncaughtCharmError from scenario.state import Event, Relation, State, _CharmSpec -from scenario.utils import exhaust def charm_type(): @@ -34,38 +32,6 @@ def _catchall(self, e): return MyCharm -def test_event_hooks(): - with TemporaryDirectory() as tempdir: - meta = { - "name": "foo", - "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}}, - } - temppath = Path(tempdir) - meta_file = temppath / "metadata.yaml" - meta_file.write_text(yaml.safe_dump(meta)) - - my_charm_type = charm_type() - runtime = Runtime( - _CharmSpec( - my_charm_type, - meta=meta, - ), - ) - - pre_event = MagicMock(return_value=None) - post_event = MagicMock(return_value=None) - runner = runtime.exec( - state=State(), - event=Event("update_status"), - manager=_LegacyManager(pre_event, post_event), - context=Context(my_charm_type, meta=meta), - ) - exhaust(runner) - - assert pre_event.called - assert post_event.called - - def test_event_emission(): with TemporaryDirectory() as tempdir: meta = { @@ -87,10 +53,10 @@ class MyEvt(EventBase): ), ) - runner = runtime.exec( + with runtime.exec( state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta) - ) - exhaust(runner) + ) as ops: + pass assert my_charm_type._event assert isinstance(my_charm_type._event, MyEvt) @@ -112,15 +78,12 @@ def test_unit_name(app_name, unit_id): ), ) - def post_event(charm: CharmBase): - assert charm.unit.name == f"{app_name}/{unit_id}" - - runtime.exec( + with runtime.exec( state=State(unit_id=unit_id), event=Event("start"), - manager=_LegacyManager(None, post_event), context=Context(my_charm_type, meta=meta), - ) + ) as charm: + assert charm.unit.name == f"{app_name}/{unit_id}" def test_env_cleanup_on_charm_error(): @@ -135,17 +98,12 @@ def test_env_cleanup_on_charm_error(): ), ) - def post_event(charm: CharmBase): - assert os.getenv("JUJU_REMOTE_APP") - raise TypeError - with pytest.raises(UncaughtCharmError): - runner = runtime.exec( + with runtime.exec( state=State(), event=Event("box_relation_changed", relation=Relation("box")), - manager=_LegacyManager(post=post_event), context=Context(my_charm_type, meta=meta), - ) - exhaust(runner) + ) as charm: + assert os.getenv("JUJU_REMOTE_APP") assert os.getenv("JUJU_REMOTE_APP", None) is None From fb9ce8de5ffa46212210f9d9d2a2b82627be1fb5 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 8 Sep 2023 15:14:18 +0200 Subject: [PATCH 303/546] fixed error because pathlib.Path.rmdir will bork on nonempty dir --- scenario/mocking.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 15dd25fd0..12d7c232f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -3,6 +3,7 @@ # See LICENSE file for licensing details. import datetime import random +import shutil from io import StringIO from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union @@ -414,7 +415,8 @@ def __init__( # wipe just in case if container_root.exists(): - container_root.rmdir() + # Path.rmdir will fail if root is nonempty + shutil.rmtree(container_root) # initialize simulated filesystem container_root.mkdir(parents=True) From 9d4aa7441ff23ccb302a72fb609aff678ac8e3f2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 8 Sep 2023 15:14:43 +0200 Subject: [PATCH 304/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 87f48a7f6..ea13d43cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.1.2" +version = "5.1.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 3cb7c404d3d6c626f7520493abebb32a179b5bb6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 8 Sep 2023 15:24:58 +0200 Subject: [PATCH 305/546] readme update --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 859e72512..84d0d1531 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,20 @@ [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649) [![Python >= 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) -Scenario is a state-transition, functional testing framework for Operator Framework charms. +Scenario is a state-transition testing SDK for Operator Framework charms. Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single event on the charm and execute its logic. -This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract'. +This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract', I prefer 'state-transition'. -Scenario tests nudge you into thinking of a charm as an input->output function. Input is what we call a `Scene`: the -union of an `Event` (why am I being executed) and a `State` (am I leader? what is my relation data? what is my -config?...). The output is another context instance: the context after the charm has had a chance to interact with the -mocked juju model and affect the state back. +Scenario tests nudge you into thinking of a charm as an input->output function. The input is the +union of an `Event` (why am I, charm, being executed) and a `State` (am I leader? what is my relation data? what is my +config?...). The output is another `State`: the state after the charm has had a chance to interact with the +mocked juju model and affect the initial state back. + +For example: a charm is executed with a `start` event, and based on whether it has leadership or not (according to its input state), it will decide to set `active` or `blocked` status (which will be reflected in the output state). ![state transition model depiction](resources/state-transition-model.png) @@ -34,10 +36,10 @@ I like metaphors, so here we go: - There is a theatre stage. - You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one. - You arrange the stage with content that the actor will have to interact with. This consists of selecting: - - An initial situation (State) in which the actor is, e.g. is the actor the main role or an NPC (is_leader), or what + - An initial situation (`State`) in which the actor is, e.g. is the actor the main role or an NPC (`is_leader`), or what other actors are there around it, what is written in those pebble-shaped books on the table? - - Something that has just happened (an Event) and to which the actor has to react (e.g. one of the NPCs leaves the - stage (relation-departed), or the content of one of the books changes). + - Something that has just happened (an `Event`) and to which the actor has to react (e.g. one of the NPCs leaves the + stage (`relation-departed`), or the content of one of the books changes). - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. @@ -77,7 +79,7 @@ A scenario test consists of three broad steps: - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is -available. The charm has no config, no relations, no networks, and no leadership. +available. The charm has no config, no relations, no networks, no leadership, and its status is `unknown`. With that, we can write the simplest possible scenario test: @@ -92,8 +94,7 @@ class MyCharm(CharmBase): def test_scenario_base(): - ctx = Context(MyCharm, - meta={"name": "foo"}) + ctx = Context(MyCharm, meta={"name": "foo"}) out = ctx.run('start', State()) assert out.unit_status == UnknownStatus() ``` From 76e15cc3a13cde488495e20e42099c45d0e228cd Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 11 Sep 2023 09:53:40 +0200 Subject: [PATCH 306/546] pr comments --- scenario/context.py | 39 +++++++++++++++------------------------ scenario/runtime.py | 11 ++++++----- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index d922d36db..fec45adb2 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -231,7 +231,8 @@ def _record_status(self, state: "State", is_app: bool): self.unit_status_history.append(state.unit_status) @staticmethod - def _coalesce_action(action: Union[str, Action]): + def _coalesce_action(action: Union[str, Action]) -> Action: + """Validate the action argument and cast to Action.""" if isinstance(action, str): return Action(action) @@ -242,8 +243,8 @@ def _coalesce_action(action: Union[str, Action]): return action @staticmethod - def _coalesce_event(event: Union[str, Event]): - # Validate the event and cast to Event. + def _coalesce_event(event: Union[str, Event]) -> Event: + """Validate the event argument and cast to Event.""" if isinstance(event, str): event = Event(event) @@ -315,10 +316,8 @@ def _run_event( event: Union["Event", str], state: "State", ) -> ContextManager["Ops"]: - with self._run( - event=self._coalesce_event(event), - state=state, - ) as ops: + _event = self._coalesce_event(event) + with self._run(event=_event, state=state) as ops: yield ops def run( @@ -338,17 +337,14 @@ def run( charm will invoke when handling the Event. :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_manager instead. + This argument is deprecated. Please use ``Context.manager`` instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_manager instead. + This argument is deprecated. Please use ``Context.manager`` instead. """ self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - with self._run_event( - event, - state, - ) as ops: + with self._run_event(event=event, state=state) as ops: if pre_event: pre_event(ops.charm) @@ -376,17 +372,15 @@ def run_action( charm will invoke when handling the Action (event). :arg pre_event: callback to be invoked right before emitting the event on the newly instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_manager instead. + This argument is deprecated. Please use ``Context.action_manager`` instead. :arg post_event: callback to be invoked right after emitting the event on the charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use Context.event_manager instead. + This argument is deprecated. Please use ``Context.action_manager`` instead. """ self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - with self._run_action( - action=self._coalesce_action(action), - state=state, - ) as ops: + _action = self._coalesce_action(action) + with self._run_action(action=_action, state=state) as ops: if pre_event: pre_event(ops.charm) @@ -418,11 +412,8 @@ def _run_action( action: Union["Action", str], state: "State", ) -> ContextManager["Ops"]: - action = self._coalesce_action(action) - with self._run( - event=action.event, - state=state, - ) as ops: + _action = self._coalesce_action(action) + with self._run(event=_action.event, state=state) as ops: yield ops @contextmanager diff --git a/scenario/runtime.py b/scenario/runtime.py index f7bf264e7..7ca7cbb46 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -180,12 +180,13 @@ def __init__( def _cleanup_env(env): # TODO consider cleaning up env on __delete__, but ideally you should be # running this in a clean env or a container anyway. - # cleanup env, in case we'll be firing multiple events, we don't want to accumulate. + # cleanup the env, in case we'll be firing multiple events, we don't want to pollute it. for key in env: - # os.unsetenv does not work !? + # os.unsetenv does not always seem to work !? del os.environ[key] def _get_event_env(self, state: "State", event: "Event", charm_root: Path): + """Build the simulated environment the operator framework expects.""" if event.name.endswith("_action"): # todo: do we need some special metadata, or can we assume action names # are always dashes? @@ -221,9 +222,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): ) remote_unit_id = event.relation_remote_unit_id - if ( - remote_unit_id is None - ): # don't check truthiness because it could be int(0) + + # don't check truthiness because remote_unit_id could be 0 + if remote_unit_id is None: remote_unit_ids = relation._remote_unit_ids # pyright: ignore if len(remote_unit_ids) == 1: From e4d0e9dccb33fea6b951be7f5772c7b159e78a47 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 11 Sep 2023 10:05:27 +0200 Subject: [PATCH 307/546] fixed test name conflict --- tests/test_plugin.py | 2 +- tests/test_runtime.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 9eaa29458..06873f17f 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,7 +4,7 @@ sys.path.append(".") -def test_context(pytester): +def test_plugin_ctx_run(pytester): # create a temporary pytest test module pytester.makepyfile( """ diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 976f16f53..0f37ce822 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -82,8 +82,8 @@ def test_unit_name(app_name, unit_id): state=State(unit_id=unit_id), event=Event("start"), context=Context(my_charm_type, meta=meta), - ) as charm: - assert charm.unit.name == f"{app_name}/{unit_id}" + ) as ops: + assert ops.charm.unit.name == f"{app_name}/{unit_id}" def test_env_cleanup_on_charm_error(): From cc95a20021eb86da676369e97731399f01fa0daa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 11 Sep 2023 10:08:27 +0200 Subject: [PATCH 308/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ea13d43cf..d93c81933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.1.3" +version = "5.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From e6552e31e08c09c28783aeb67599b33cdf644733 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 13 Sep 2023 13:07:17 +0200 Subject: [PATCH 309/546] snapshot event binding --- scenario/mocking.py | 7 ++++++- scenario/scripts/snapshot.py | 26 +++++++++++++++++++++--- scenario/state.py | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 12d7c232f..768709ca9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -456,7 +456,12 @@ def exec(self, *args, **kwargs): # noqa: U100 cmd = tuple(args[0]) out = self._container.exec_mock.get(cmd) if not out: - raise RuntimeError(f"mock for cmd {cmd} not found.") + raise RuntimeError( + f"mock for cmd {cmd} not found. Please pass to the Container " + f"{self._container.name} a scenario.ExecOutput mock for the " + f"command your charm is attempting to run, or patch " + f"out whatever leads to the call.", + ) change_id = out._run() return _MockExecProcess(change_id=change_id, command=cmd, out=out) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 4b5e4913d..14b67a1f7 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -28,7 +28,9 @@ from scenario.state import ( Address, BindAddress, + BindFailedError, Container, + Event, Model, Mount, Network, @@ -98,7 +100,15 @@ def format_test_case( ): """Format this State as a pytest test case.""" ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name" - en = event_name or "EVENT_NAME, # TODO: replace with event name" + en = "EVENT_NAME, # TODO: replace with event name" + if event_name: + try: + en = Event(event_name).bind(state) + except BindFailedError: + logger.error( + f"Failed to bind {event_name} to {state}; leaving placeholder instead", + ) + jv = juju_version or "3.0, # TODO: check juju version is correct" state_fmt = repr(state) return _try_format( @@ -723,11 +733,12 @@ def _snapshot( target: str, model: Optional[str] = None, pprint: bool = True, - include: str = None, + include: Optional[str] = None, include_juju_relation_data=False, include_dead_relation_networks=False, format: FormatOption = "state", - fetch_files: Dict[str, List[Path]] = None, + event_name: Optional[str] = None, + fetch_files: Optional[Dict[str, List[Path]]] = None, temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, ): """see snapshot's docstring""" @@ -852,6 +863,7 @@ def if_include(key, fn, default): charm_type_name = try_guess_charm_type_name() txt = format_test_case( state, + event_name=event_name, charm_type_name=charm_type_name, juju_version=juju_version, ) @@ -896,6 +908,13 @@ def snapshot( "``pytest``: Outputs a full-blown pytest scenario test based on this State. " "Pipe it to a file and fill in the blanks.", ), + event_name: str = typer.Option( + None, + "--event_name", + "-e", + help="Event to include in the generate test file; only applicable " + "if the output format is 'pytest'.", + ), include: str = typer.Option( "rckndtp", "--include", @@ -946,6 +965,7 @@ def snapshot( target=target, model=model, format=format, + event_name=event_name, include=include, include_juju_relation_data=include_juju_relation_data, include_dead_relation_networks=include_dead_relation_networks, diff --git a/scenario/state.py b/scenario/state.py index 414f4cde4..62ae3ce41 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -83,6 +83,10 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" +class BindFailedError(RuntimeError): + """Raised when Event.bind fails.""" + + @dataclasses.dataclass(frozen=True) class _DCBase: def replace(self, *args, **kwargs): @@ -1092,6 +1096,41 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): return event_name in builtins + def bind(self, state: State): + """Attach to this event the state component it needs. + + For example, a relation event initialized without a Relation instance will search for + a suitable relation in the provided state and return a copy of itself with that + relation attached. + """ + if self._is_workload_event and not self.container: + container = state.get_container(self.name.split("_")[0]) + return self.replace(container=container) + + if self._is_secret_event and not self.secret: + if len(state.secrets) < 1: + raise BindFailedError(f"no secrets found in state: cannot bind {self}") + if len(state.secrets) > 1: + raise BindFailedError( + f"too many secrets found in state: cannot automatically bind {self}", + ) + return self.replace(secret=state.secrets[0]) + + if self._is_relation_event and not self.relation: + ep_name = self.name.split("_")[0] + relations = state.get_relations(ep_name) + if len(relations) < 1: + raise BindFailedError(f"no relations on {ep_name} found in state") + if len(relations) > 1: + logger.warning(f"too many relations on {ep_name}: binding to first one") + return self.replace(relation=relations[0]) + + if self._is_action_event and not self.action: + raise BindFailedError( + "cannot automatically bind action events: if the action has mandatory parameters " + "this would probably result in horrible, undebuggable failures downstream.", + ) + def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) From e2ada8748fb0aeab1573064da5abcc0a91ab43f6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 13 Sep 2023 13:16:30 +0200 Subject: [PATCH 310/546] tests for bind --- scenario/state.py | 15 +++++++-- tests/test_e2e/test_event_bind.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/test_e2e/test_event_bind.py diff --git a/scenario/state.py b/scenario/state.py index 62ae3ce41..8c97fabb5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1103,8 +1103,13 @@ def bind(self, state: State): a suitable relation in the provided state and return a copy of itself with that relation attached. """ + entity_name = self.name.split("_")[0] + if self._is_workload_event and not self.container: - container = state.get_container(self.name.split("_")[0]) + try: + container = state.get_container(entity_name) + except ValueError: + raise BindFailedError(f"no container found with name {entity_name}") return self.replace(container=container) if self._is_secret_event and not self.secret: @@ -1117,7 +1122,7 @@ def bind(self, state: State): return self.replace(secret=state.secrets[0]) if self._is_relation_event and not self.relation: - ep_name = self.name.split("_")[0] + ep_name = entity_name relations = state.get_relations(ep_name) if len(relations) < 1: raise BindFailedError(f"no relations on {ep_name} found in state") @@ -1131,6 +1136,12 @@ def bind(self, state: State): "this would probably result in horrible, undebuggable failures downstream.", ) + else: + raise BindFailedError( + f"cannot bind {self}: only relation, secret, " + f"or workload events can be bound.", + ) + def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) diff --git a/tests/test_e2e/test_event_bind.py b/tests/test_e2e/test_event_bind.py new file mode 100644 index 000000000..816b1c8cd --- /dev/null +++ b/tests/test_e2e/test_event_bind.py @@ -0,0 +1,55 @@ +import pytest + +from scenario import Container, Event, Relation, Secret, State +from scenario.state import BindFailedError + + +def test_bind_relation(): + event = Event("foo-relation-changed") + foo_relation = Relation("foo") + state = State(relations=[foo_relation]) + assert event.bind(state).relation is foo_relation + + +def test_bind_relation_notfound(): + event = Event("foo-relation-changed") + state = State(relations=[]) + with pytest.raises(BindFailedError): + event.bind(state) + + +def test_bind_relation_toomany(caplog): + event = Event("foo-relation-changed") + foo_relation = Relation("foo") + foo_relation1 = Relation("foo") + state = State(relations=[foo_relation, foo_relation1]) + event.bind(state) + assert "too many relations" in caplog.text + + +def test_bind_secret(): + event = Event("secret-changed") + secret = Secret("foo", {"a": "b"}) + state = State(secrets=[secret]) + assert event.bind(state).secret is secret + + +def test_bind_secret_notfound(): + event = Event("secret-changed") + state = State(secrets=[]) + with pytest.raises(BindFailedError): + event.bind(state) + + +def test_bind_container(): + event = Event("foo-pebble-ready") + container = Container("foo") + state = State(containers=[container]) + assert event.bind(state).container is container + + +def test_bind_container_notfound(): + event = Event("foo-pebble-ready") + state = State(containers=[]) + with pytest.raises(BindFailedError): + event.bind(state) From 561c4146cd1d5775fe8c90b8514a2632a16e8ba9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 13 Sep 2023 17:28:30 +0200 Subject: [PATCH 311/546] vroot cleanup --- scenario/runtime.py | 36 +++++++++--------- tests/helpers.py | 2 +- tests/test_e2e/test_vroot.py | 72 +++++++++++++++++++++++------------- 3 files changed, 67 insertions(+), 43 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 7ca7cbb46..8c90cb5bb 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -11,6 +11,7 @@ from typing import ( TYPE_CHECKING, ContextManager, + Dict, List, Optional, Tuple, @@ -56,10 +57,6 @@ class UncaughtCharmError(ScenarioRuntimeError): """Error raised if the charm raises while handling the event being dispatched.""" -class DirtyVirtualCharmRootError(ScenarioRuntimeError): - """Error raised when the runtime can't initialize the vroot without overwriting metadata.""" - - class InconsistentScenarioError(ScenarioRuntimeError): """Error raised when the combination of state and event is inconsistent.""" @@ -298,30 +295,31 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: config_yaml = virtual_charm_root / "config.yaml" actions_yaml = virtual_charm_root / "actions.yaml" - metadata_files_present = any( - file.exists() for file in (metadata_yaml, config_yaml, actions_yaml) - ) + metadata_files_present: Dict[Path, bool] = { + file: file.exists() for file in (metadata_yaml, config_yaml, actions_yaml) + } + + any_metadata_files_present_in_vroot = any(metadata_files_present.values()) if spec.is_autoloaded and vroot_is_custom: # since the spec is autoloaded, in theory the metadata contents won't differ, so we can # overwrite away even if the custom vroot is the real charm root (the local repo). # Still, log it for clarity. - if metadata_files_present: - logger.info( + if any_metadata_files_present_in_vroot: + logger.debug( f"metadata files found in custom vroot {vroot}. " f"The spec was autoloaded so the contents should be identical. " f"Proceeding...", ) - elif not spec.is_autoloaded and metadata_files_present: - logger.error( + elif not spec.is_autoloaded and any_metadata_files_present_in_vroot: + logger.warn( f"Some metadata files found in custom user-provided vroot {vroot} " - f"while you have passed meta, config or actions to trigger(). " - "We don't want to risk overwriting them mindlessly, so we abort. " - "You should not include any metadata files in the charm_root. " - "Single source of truth are the arguments passed to trigger(). ", + f"while you have passed meta, config or actions to Context.run(). " + "Single source of truth are the arguments passed to Context.run(). " + "Vroot metadata files will be overwritten. " + "To avoid this, clean any metadata files from the vroot before calling run.", ) - raise DirtyVirtualCharmRootError(vroot) metadata_yaml.write_text(yaml.safe_dump(spec.meta)) config_yaml.write_text(yaml.safe_dump(spec.config or {})) @@ -329,7 +327,11 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: yield virtual_charm_root - if not vroot_is_custom: + if vroot_is_custom: + for file, present in metadata_files_present.items(): + if not present: + file.unlink() + else: vroot.cleanup() @staticmethod diff --git a/tests/helpers.py b/tests/helpers.py index 5f85fb3b5..04fc2f2b2 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -35,7 +35,7 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: Optional[Dict["PathLike", "PathLike"]] = None, + charm_root: Optional["PathLike"] = None, juju_version: str = "3.0", ) -> "State": ctx = Context( diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index 5af9534c4..e3bf91433 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -2,12 +2,12 @@ from pathlib import Path import pytest +import yaml from ops.charm import CharmBase from ops.framework import Framework from ops.model import ActiveStatus -from scenario import State -from scenario.runtime import DirtyVirtualCharmRootError +from scenario import Context, State from tests.helpers import trigger @@ -22,7 +22,8 @@ def __init__(self, framework: Framework): self.unit.status = ActiveStatus(f"{foo.read_text()} {baz.read_text()}") -def test_vroot(): +@pytest.fixture +def vroot(): with tempfile.TemporaryDirectory() as myvroot: t = Path(myvroot) src = t / "src" @@ -35,29 +36,50 @@ def test_vroot(): quxcos = baz / "qux.kaboodle" quxcos.write_text("world") - out = trigger( - State(), - "start", - charm_type=MyCharm, - meta=MyCharm.META, - charm_root=t, - ) + yield t + +def test_vroot(vroot): + out = trigger( + State(), + "start", + charm_type=MyCharm, + meta=MyCharm.META, + charm_root=vroot, + ) assert out.unit_status == ("active", "hello world") -@pytest.mark.parametrize("meta_overwrite", ["metadata", "actions", "config"]) -def test_dirty_vroot_raises(meta_overwrite): - with tempfile.TemporaryDirectory() as myvroot: - t = Path(myvroot) - meta_file = t / f"{meta_overwrite}.yaml" - meta_file.touch() - - with pytest.raises(DirtyVirtualCharmRootError): - trigger( - State(), - "start", - charm_type=MyCharm, - meta=MyCharm.META, - charm_root=t, - ) +def test_vroot_cleanup_if_exists(vroot): + meta_file = vroot / "metadata.yaml" + meta_file.write_text(yaml.safe_dump({"name": "karl"})) + + with Context(MyCharm, meta=MyCharm.META, charm_root=vroot).manager( + "start", + State(), + ) as mgr: + assert meta_file.exists() + assert ( + mgr.charm.meta.name == "my-charm" + ) # not karl! Context.meta takes precedence + mgr.run() + assert meta_file.exists() + + assert meta_file.exists() + + +def test_vroot_cleanup_if_not_exists(vroot): + meta_file = vroot / "metadata.yaml" + + assert not meta_file.exists() + + with Context(MyCharm, meta=MyCharm.META, charm_root=vroot).manager( + "start", + State(), + ) as mgr: + assert meta_file.exists() + assert meta_file.read_text() == yaml.safe_dump({"name": "my-charm"}) + mgr.run() + assert not meta_file.exists() + + assert not meta_file.exists() From 98ba3875f44974a6e6d96703d639a577e0420d44 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 13 Sep 2023 17:34:45 +0200 Subject: [PATCH 312/546] restore on cleanup --- scenario/runtime.py | 12 ++++++++---- tests/test_e2e/test_vroot.py | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 8c90cb5bb..f680f8bd3 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -295,8 +295,9 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: config_yaml = virtual_charm_root / "config.yaml" actions_yaml = virtual_charm_root / "actions.yaml" - metadata_files_present: Dict[Path, bool] = { - file: file.exists() for file in (metadata_yaml, config_yaml, actions_yaml) + metadata_files_present: Dict[Path, Union[str, False]] = { + file: file.read_text() if file.exists() else False + for file in (metadata_yaml, config_yaml, actions_yaml) } any_metadata_files_present_in_vroot = any(metadata_files_present.values()) @@ -328,9 +329,12 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: yield virtual_charm_root if vroot_is_custom: - for file, present in metadata_files_present.items(): - if not present: + for file, previous_content in metadata_files_present.items(): + if not previous_content: # False: file did not exist before file.unlink() + else: + file.write_text(previous_content) + else: vroot.cleanup() diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index e3bf91433..2e99e8f5e 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -52,19 +52,23 @@ def test_vroot(vroot): def test_vroot_cleanup_if_exists(vroot): meta_file = vroot / "metadata.yaml" - meta_file.write_text(yaml.safe_dump({"name": "karl"})) + raw_ori_meta = yaml.safe_dump({"name": "karl"}) + meta_file.write_text(raw_ori_meta) with Context(MyCharm, meta=MyCharm.META, charm_root=vroot).manager( "start", State(), ) as mgr: assert meta_file.exists() + assert meta_file.read_text() == yaml.safe_dump({"name": "my-charm"}) assert ( mgr.charm.meta.name == "my-charm" ) # not karl! Context.meta takes precedence mgr.run() assert meta_file.exists() + # meta file was restored to its previous contents + assert meta_file.read_text() == raw_ori_meta assert meta_file.exists() From 2fd35ecaf0b1a0b4cbc51d4c12a1f1ca9cd98c04 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 13 Sep 2023 17:35:39 +0200 Subject: [PATCH 313/546] better warn message --- scenario/runtime.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index f680f8bd3..78548f0c2 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -316,9 +316,10 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: elif not spec.is_autoloaded and any_metadata_files_present_in_vroot: logger.warn( f"Some metadata files found in custom user-provided vroot {vroot} " - f"while you have passed meta, config or actions to Context.run(). " + "while you have passed meta, config or actions to Context.run(). " "Single source of truth are the arguments passed to Context.run(). " - "Vroot metadata files will be overwritten. " + "Vroot metadata files will be overwritten for the " + "duration of this test, and restored afterwards. " "To avoid this, clean any metadata files from the vroot before calling run.", ) From 84ee6d6e0374c2d20ef9d24fe45045ff1b3083a9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 15 Sep 2023 10:06:32 +0200 Subject: [PATCH 314/546] version tool --- scenario/scripts/main.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py index 29ed8811a..ebee6084a 100644 --- a/scenario/scripts/main.py +++ b/scenario/scripts/main.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +from importlib import metadata +from importlib.metadata import PackageNotFoundError +from pathlib import Path import typer @@ -9,6 +12,26 @@ from scenario.scripts.state_apply import state_apply +def _version(): + """Print the scenario version and exit.""" + try: + print(metadata.version("ops-scenario")) + return + except PackageNotFoundError: + pass + + pyproject_toml = Path(__file__).parent.parent.parent / "pyproject.toml" + + if not pyproject_toml.exists(): + print("") + return + + for line in pyproject_toml.read_text().split("\n"): + if line.startswith("version"): + print(line.split("=")[1].strip("\"' ")) + return + + def main(): app = typer.Typer( name="scenario", @@ -19,6 +42,7 @@ def main(): rich_markup_mode="markdown", ) + app.command(name="version")(_version) app.command(name="snapshot", no_args_is_help=True)(snapshot) app.command(name="state-apply", no_args_is_help=True)(state_apply) From 6dbdccfaf75ccc87ed3d580d98402fc6cb45f2cc Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Fri, 15 Sep 2023 10:18:28 +0200 Subject: [PATCH 315/546] container filesystem docs --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 61330d5a8..dc1f1e63f 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,7 @@ state = State(containers=[ In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. +### Container filesystem setup You can configure a container to have some files in it: ```python @@ -589,6 +590,45 @@ need to associate the container with the event is that the Framework uses an env pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. +### Container filesystem post-mortem +If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. + +```python +from ops.charm import CharmBase +from scenario import State, Container, Mount, Context + + +class MyCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) + + def _on_pebble_ready(self, _): + foo = self.unit.get_container('foo') + foo.push('/local/share/config.yaml', "TEST", make_dirs=True) + + +def test_pebble_push(): + container = Container(name='foo', + can_connect=True) + state_in = State( + containers=[container] + ) + Context( + MyCharm, + meta={"name": "foo", "containers": {"foo": {}}}).run( + "start", + state_in, + ) + + # this is the root of the simulated container filesystem. Any mounts will be symlinks in it. + container_root_fs = container.get_filesystem(ctx) + cfg_file = container_root_fs / 'local' / 'share' / 'config.yaml' + assert cfg_file.read_text() == "TEST" +``` + +### `Container.exec` mocks + `container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. From 11aff053e728ae4cfe53260889f3c6a7f70b5d74 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 18 Sep 2023 10:54:40 +0200 Subject: [PATCH 316/546] removed headers from json output in snapshot --- scenario/scripts/snapshot.py | 22 +++++++++++++--------- scenario/scripts/state_apply.py | 6 +++++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 4b5e4913d..7813555d0 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -862,15 +862,19 @@ def if_include(key, fn, default): else: raise ValueError(f"unknown format {format}") - controller_timestamp = juju_status["controller"]["timestamp"] - local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") - print( - f"# Generated by scenario.snapshot. \n" - f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" - f"# Controller timestamp := {controller_timestamp}. \n" - f"# Juju version := {juju_version} \n" - f"# Charm fingerprint := {charm_version} \n", - ) + # json does not support comments, so it would be invalid output. + if format != FormatOption.json: + # print out some metadata + controller_timestamp = juju_status["controller"]["timestamp"] + local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") + print( + f"# Generated by scenario.snapshot. \n" + f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" + f"# Controller timestamp := {controller_timestamp}. \n" + f"# Juju version := {juju_version} \n" + f"# Charm fingerprint := {charm_version} \n", + ) + print(txt) return state diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index cd6a20131..8af5e905f 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -230,7 +230,11 @@ def state_apply( Usage: state-apply myapp/0 > ./tests/scenario/case1.py """ push_files_ = json.loads(push_files.read_text()) if push_files else None - state_ = json.loads(state.read_text()) + state_json = json.loads(state.read_text()) + + # TODO: state_json to State + raise NotImplementedError("WIP: implement State.from_json") + state_: State = State.from_json(state_json) return _state_apply( target=target, From 54c9c8a95daf64d7979bbfec9452d2c10bf228d1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 19 Sep 2023 10:02:09 +0200 Subject: [PATCH 317/546] pr comments --- README.md | 13 +++++++---- scenario/runtime.py | 45 ++++++++++++++++++++---------------- tests/test_e2e/test_vroot.py | 22 +++++++++--------- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 61330d5a8..24e4b2f82 100644 --- a/README.md +++ b/README.md @@ -1032,10 +1032,10 @@ can't emit multiple events in a single charm execution. # The virtual charm root -Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The +Before executing the charm, Scenario copies the charm's `/src`, any libs, the metadata, config, and actions `yaml`s to a temporary directory. The charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can be either inferred from the charm type being passed to `Context` or be passed to it as an argument, thereby overriding -the inferred one. This also allows you to test with charms defined on the fly, as in: +the inferred one. This also allows you to test charms defined on the fly, as in: ```python from ops.charm import CharmBase @@ -1052,7 +1052,7 @@ ctx.run('start', State()) ``` A consequence of this fact is that you have no direct control over the tempdir that we are creating to put the metadata -you are passing to trigger (because `ops` expects it to be a file...). That is, unless you pass your own: +you are passing to `.run()` (because `ops` expects it to be a file...). That is, unless you pass your own: ```python from ops.charm import CharmBase @@ -1073,8 +1073,11 @@ state = Context( ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its -contents after the charm has run. Do keep in mind that the metadata files will be overwritten by Scenario, and therefore -ignored. +contents after the charm has run. Do keep in mind that any metadata files you create in it will be overwritten by Scenario, and therefore +ignored, if you pass any metadata keys to `Context`. Omit `meta` in the call +above, and Scenario will instead attempt to read `metadata.yaml` from the +temporary directory. + # Immutability diff --git a/scenario/runtime.py b/scenario/runtime.py index 78548f0c2..863d0f1dd 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -283,44 +283,49 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: # is what the user passed via the CharmSpec spec = self._charm_spec - if vroot := self._charm_root: - vroot_is_custom = True - virtual_charm_root = Path(vroot) + if charm_virtual_root := self._charm_root: + charm_virtual_root_is_custom = True + virtual_charm_root = Path(charm_virtual_root) else: - vroot = tempfile.TemporaryDirectory() - virtual_charm_root = Path(vroot.name) - vroot_is_custom = False + charm_virtual_root = tempfile.TemporaryDirectory() + virtual_charm_root = Path(charm_virtual_root.name) + charm_virtual_root_is_custom = False metadata_yaml = virtual_charm_root / "metadata.yaml" config_yaml = virtual_charm_root / "config.yaml" actions_yaml = virtual_charm_root / "actions.yaml" - metadata_files_present: Dict[Path, Union[str, False]] = { - file: file.read_text() if file.exists() else False + metadata_files_present: Dict[Path, Optional[str]] = { + file: file.read_text() if file.exists() else None for file in (metadata_yaml, config_yaml, actions_yaml) } - any_metadata_files_present_in_vroot = any(metadata_files_present.values()) + any_metadata_files_present_in_charm_virtual_root = any( + v is not None for v in metadata_files_present.values() + ) - if spec.is_autoloaded and vroot_is_custom: + if spec.is_autoloaded and charm_virtual_root_is_custom: # since the spec is autoloaded, in theory the metadata contents won't differ, so we can # overwrite away even if the custom vroot is the real charm root (the local repo). # Still, log it for clarity. - if any_metadata_files_present_in_vroot: + if any_metadata_files_present_in_charm_virtual_root: logger.debug( - f"metadata files found in custom vroot {vroot}. " + f"metadata files found in custom charm_root {charm_virtual_root}. " f"The spec was autoloaded so the contents should be identical. " f"Proceeding...", ) - elif not spec.is_autoloaded and any_metadata_files_present_in_vroot: + elif ( + not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root + ): logger.warn( - f"Some metadata files found in custom user-provided vroot {vroot} " - "while you have passed meta, config or actions to Context.run(). " + f"Some metadata files found in custom user-provided charm_root " + f"{charm_virtual_root} while you have passed meta, config or actions to " + f"Context.run(). " "Single source of truth are the arguments passed to Context.run(). " - "Vroot metadata files will be overwritten for the " + "charm_root metadata files will be overwritten for the " "duration of this test, and restored afterwards. " - "To avoid this, clean any metadata files from the vroot before calling run.", + "To avoid this, clean any metadata files from the charm_root before calling run.", ) metadata_yaml.write_text(yaml.safe_dump(spec.meta)) @@ -329,15 +334,15 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: yield virtual_charm_root - if vroot_is_custom: + if charm_virtual_root_is_custom: for file, previous_content in metadata_files_present.items(): - if not previous_content: # False: file did not exist before + if previous_content is None: # None == file did not exist before file.unlink() else: file.write_text(previous_content) else: - vroot.cleanup() + charm_virtual_root.cleanup() @staticmethod def _get_state_db(temporary_charm_root: Path): diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index 2e99e8f5e..c6d59be54 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -23,9 +23,9 @@ def __init__(self, framework: Framework): @pytest.fixture -def vroot(): - with tempfile.TemporaryDirectory() as myvroot: - t = Path(myvroot) +def charm_virtual_root(): + with tempfile.TemporaryDirectory() as mycharm_virtual_root: + t = Path(mycharm_virtual_root) src = t / "src" src.mkdir() foobar = src / "foo.bar" @@ -39,23 +39,23 @@ def vroot(): yield t -def test_vroot(vroot): +def test_charm_virtual_root(charm_virtual_root): out = trigger( State(), "start", charm_type=MyCharm, meta=MyCharm.META, - charm_root=vroot, + charm_root=charm_virtual_root, ) assert out.unit_status == ("active", "hello world") -def test_vroot_cleanup_if_exists(vroot): - meta_file = vroot / "metadata.yaml" +def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): + meta_file = charm_virtual_root / "metadata.yaml" raw_ori_meta = yaml.safe_dump({"name": "karl"}) meta_file.write_text(raw_ori_meta) - with Context(MyCharm, meta=MyCharm.META, charm_root=vroot).manager( + with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( "start", State(), ) as mgr: @@ -72,12 +72,12 @@ def test_vroot_cleanup_if_exists(vroot): assert meta_file.exists() -def test_vroot_cleanup_if_not_exists(vroot): - meta_file = vroot / "metadata.yaml" +def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root): + meta_file = charm_virtual_root / "metadata.yaml" assert not meta_file.exists() - with Context(MyCharm, meta=MyCharm.META, charm_root=vroot).manager( + with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( "start", State(), ) as mgr: From cd07aaafe18933ce07ebda48b1a34ab9fd3b0ed1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 19 Sep 2023 10:12:49 +0200 Subject: [PATCH 318/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d93c81933..7fa96266b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.2" +version = "5.2.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 181f103379e2b29ec33c40e09a8a9f29cd2681a0 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 19 Sep 2023 10:41:10 +0200 Subject: [PATCH 319/546] better docstring for Context --- pyproject.toml | 2 +- scenario/context.py | 45 +++++++++++++++++++++++++++++++-- scenario/scripts/state_apply.py | 2 +- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7fa96266b..398f63b27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.2.1" +version = "5.2.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/context.py b/scenario/context.py index 51498b97c..ad985974a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -150,7 +150,49 @@ def __init__( charm_root: "PathLike" = None, juju_version: str = "3.0", ): - """Initializer. + """Represents a simulated charm's execution context. + + It is the main entry point to running a scenario test. + + 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. + 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: + - ``juju_log``: record of what the charm has sent to juju-log + - ``app_status_history``: record of the app statuses the charm has set + - ``unit_status_history``: record of the unit statuses the charm has set + - ``workload_version_history``: record of the workload versions the charm has set + - ``emitted_events``: record of the events (including custom ones) that the charm has + processed + + This allows you to write assertions not only on the output state, but also, to some + extent, on the path the charm took to get there. + + A typical scenario test will look like: + + >>> from scenario import Context, State + >>> from ops import ActiveStatus + >>> from charm import MyCharm, MyCustomEvent + >>> + >>> def test_foo(): + >>> # Arrange: set the context up + >>> c = Context(MyCharm) + >>> # Act: prepare the state and emit an event + >>> state_out = c.run('update-status', State()) + >>> # Assert: verify the output state is what you think it should be + >>> assert state_out.unit_status == ActiveStatus('foobar') + >>> # Assert: verify the Context contains what you think it should + >>> assert len(c.emitted_events) == 4 + >>> assert isinstance(c.emitted_events[3], MyCustomEvent) :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). @@ -171,7 +213,6 @@ def __init__( >>> (local_path / 'foo').mkdir() >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') >>> scenario.Context(... charm_root=virtual_root).run(...) - """ if not any((meta, actions, config)): diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index 8af5e905f..f864b1414 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -223,7 +223,7 @@ def state_apply( "of k8s charms, this might mean files obtained through Mounts,", ), ): - """Gather and output the State of a remote target unit. + """Apply a State to a remote target unit. If black is available, the output will be piped through it for formatting. From 62a46d35b33c61627822a199e793ef05d553a407 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 22 Sep 2023 13:52:07 +0200 Subject: [PATCH 320/546] fixed some outdated docs --- README.md | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8e1aef4fd..427e56da8 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,17 @@ event on the charm and execute its logic. This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract', I prefer 'state-transition'. Scenario tests nudge you into thinking of a charm as an input->output function. The input is the -union of an `Event` (why am I, charm, being executed) and a `State` (am I leader? what is my relation data? what is my -config?...). The output is another `State`: the state after the charm has had a chance to interact with the +union of an `Event` (why am I, charm, being executed), a `State` (am I leader? what is my relation data? what is my +config?...) and the charm's execution `Context` (what relations can I have? what containers can I have?...). The output is another `State`: the state after the charm has had a chance to interact with the mocked juju model and affect the initial state back. -For example: a charm is executed with a `start` event, and based on whether it has leadership or not (according to its input state), it will decide to set `active` or `blocked` status (which will be reflected in the output state). - ![state transition model depiction](resources/state-transition-model.png) +For example: a charm currently in `unknown` status is executed with a `start` event, and based on whether it has leadership or not (according to its input state), it will decide to set `active` or `blocked` status (which will be reflected in the output state). + Scenario-testing a charm, then, means verifying that: -- the charm does not raise uncaught exceptions while handling the scene +- the charm does not raise uncaught exceptions while handling the event - the output state (or the diff with the input state) is as expected. # Core concepts as a metaphor @@ -53,32 +53,30 @@ author's expectations. Comparing scenario tests with `Harness` tests: - Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired - state, then verify its validity by calling charm methods or inspecting the raw data. + state, then verify its validity by calling charm methods or inspecting the raw data. In contrast, Scenario is declarative. You fully specify an initial state, an execution context and an event, then you run the charm and inspect the results. - Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). - Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. -- TODO: Scenario can mock at the level of hook tools. Decoupling charm and context allows us to swap out easily any part - of this flow, and even share context data across charms, codebases, teams... # Writing scenario tests A scenario test consists of three broad steps: - **Arrange**: + - declare the context - declare the input state - select an event to fire - **Act**: - - run the state (i.e. obtain the output state) - - optionally, use pre-event and post-event hooks to get a hold of the charm instance and run assertions on internal - APIs + - run the context (i.e. obtain the output state, given the input state and the event) - **Assert**: - verify that the output state (or the delta with the input state) is how you expect it to be - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - optionally, you can use a context manager to get a hold of the charm instance and run assertions on internal APIs and the internal state of the charm and operator framework. -The most basic scenario is the so-called `null scenario`: one in which all is defaulted and barely any data is +The most basic scenario is one in which all is defaulted and barely any data is available. The charm has no config, no relations, no networks, no leadership, and its status is `unknown`. With that, we can write the simplest possible scenario test: @@ -405,14 +403,13 @@ Context(...).run("start", state_in) # invalid: this unit's id cannot be the ID ### SubordinateRelation To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`. The core difference with regular -relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit -that this unit can see). So unlike `Relation`, a `SubordinateRelation` does not have a `remote_units_data` argument. -Instead, it has a `remote_unit_data` taking a single `Dict[str:str]`, and takes the primary unit ID as a separate -argument. Also, it talks in terms of `primary`: +relations is that subordinate relations always have exactly one remote unit (there is always exactly one remote unit +that this unit can see). +Because of that, `SubordinateRelation`, compared to `Relation`, always talks in terms of `remote`: +- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` taking a single `Dict[str:str]`. The remote unit ID can be provided as a separate argument. - `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs) -- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping - from unit IDs to databags) +- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) - `Relation.remote_app_name` maps to `SubordinateRelation.primary_app_name` ```python @@ -512,7 +509,7 @@ be no containers. So if the charm were to `self.unit.containers`, it would get b To give the charm access to some containers, you need to pass them to the input state, like so: `State(containers=[...])` -An example of a scene including some containers: +An example of a state including some containers: ```python from scenario.state import Container, State @@ -1006,7 +1003,7 @@ You can prefix the event name with the path leading to its owner to tell Scenari ```python from scenario import Context, State -Context(...).run("my_charm_lib.on.ingress_provided", State()) +Context(...).run("my_charm_lib.on.foo", State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. @@ -1016,10 +1013,10 @@ This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. # Live charm introspection Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to -B, but not to assert that, in the context of this charm execution, with this state, a certain method call would return a -given piece of data. +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 context manager for this use case specifically: +Scenario offers a cheekily-named context manager for this use case specifically: ```python from ops import CharmBase, StoredState From 4a94f2928e0cb4b0140f031bcff7d3dc3a62a045 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Oct 2023 15:08:01 +0200 Subject: [PATCH 321/546] removed remote unit ids var from Relation --- README.md | 3 +- scenario/mocking.py | 4 +- scenario/state.py | 80 ++++-------------------------------- tests/test_e2e/test_state.py | 5 +-- 4 files changed, 13 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 427e56da8..97f0c5eb3 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,6 @@ To declare a peer relation, you should use `scenario.state.PeerRelation`. The co that peer relations do not have a "remote app" (it's this app, in fact). So unlike `Relation`, a `PeerRelation` does not have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`: -- `Relation.remote_unit_ids` maps to `PeerRelation.peers_ids` - `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python @@ -488,7 +487,7 @@ remote unit that the event is about. The reason that this parameter is not suppl events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. -The `remote_unit_id` will default to the first ID found in the relation's `remote_unit_ids`, but if the test you are +The `remote_unit_id` will default to the first ID found in the relation's `remote_units_data`, but if the test you are writing is close to that domain, you should probably override it and pass it manually. ```python diff --git a/scenario/mocking.py b/scenario/mocking.py index 768709ca9..16ae0893f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -177,7 +177,9 @@ def relation_list(self, relation_id: int) -> Tuple[str]: relation = self._get_relation_by_id(relation_id) if isinstance(relation, PeerRelation): - return tuple(f"{self.app_name}/{unit_id}" for unit_id in relation.peers_ids) + return tuple( + f"{self.app_name}/{unit_id}" for unit_id in relation.peers_data + ) return tuple( f"{relation.remote_app_name}/{unit_id}" for unit_id in relation._remote_unit_ids diff --git a/scenario/state.py b/scenario/state.py index 8c97fabb5..315160e44 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -318,74 +318,18 @@ def broken_event(self) -> "Event": ) -def unify_ids_and_remote_units_data(ids: List[int], data: Dict[int, Any]): - """Unify and validate a list of unit IDs and a mapping from said ids to databag contents. - - This allows the user to pass equivalently: - ids = [] - data = {1: {}} - - or - - ids = [1] - data = {} - - or - - ids = [1] - data = {1: {}} - - but catch the inconsistent: - - ids = [1] - data = {2: {}} - - or - - ids = [2] - data = {1: {}} - """ - if ids and data: - if not set(ids) == set(data): - raise StateValidationError( - f"{ids} should include any and all IDs from {data}", - ) - elif ids: - data = {x: {} for x in ids} - elif data: - ids = list(data) - else: - ids = [0] - data = {0: {}} - return ids, data - - @dataclasses.dataclass(frozen=True) class Relation(RelationBase): remote_app_name: str = "remote" - # fixme: simplify API by deriving remote_unit_ids from remote_units_data. - remote_unit_ids: List[int] = dataclasses.field(default_factory=list) - # local limit limit: int = 1 remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( - default_factory=dict, + default_factory=lambda: {0: {}}, ) - def __post_init__(self): - super().__post_init__() - - remote_unit_ids, remote_units_data = unify_ids_and_remote_units_data( - self.remote_unit_ids, - self.remote_units_data, - ) - # bypass frozen dataclass - object.__setattr__(self, "remote_unit_ids", remote_unit_ids) - object.__setattr__(self, "remote_units_data", remote_units_data) - @property def _remote_app_name(self) -> str: """Who is on the other end of this relation?""" @@ -394,7 +338,7 @@ def _remote_app_name(self) -> str: @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" - return tuple(self.remote_unit_ids) + return tuple(self.remote_units_data) def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: """Return the databag for some remote unit ID.""" @@ -447,10 +391,11 @@ def remote_unit_name(self) -> str: @dataclasses.dataclass(frozen=True) class PeerRelation(RelationBase): - peers_data: Dict[int, Dict[str, str]] = dataclasses.field(default_factory=dict) - - # IDs of the peers. Consistency checks will validate that *this unit*'s ID is not in here. - peers_ids: List[int] = dataclasses.field(default_factory=list) + peers_data: Dict[int, Dict[str, str]] = dataclasses.field( + default_factory=lambda: {0: {}}, + ) + # mapping from peer unit IDs to their databag contents. + # Consistency checks will validate that *this unit*'s ID is not in here. @property def _databags(self): @@ -462,21 +407,12 @@ def _databags(self): @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" - return tuple(self.peers_ids) + return tuple(self.peers_data) def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: """Return the databag for some remote unit ID.""" return self.peers_data[unit_id] - def __post_init__(self): - peers_ids, peers_data = unify_ids_and_remote_units_data( - self.peers_ids, - self.peers_data, - ) - # bypass frozen dataclass guards - object.__setattr__(self, "peers_ids", peers_ids) - object.__setattr__(self, "peers_data", peers_data) - def _random_model_name(): import random diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index f080eafe4..f06f70075 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -147,7 +147,6 @@ def pre_event(charm: CharmBase): interface="bar", local_app_data={"a": "because"}, remote_app_name="remote", - remote_unit_ids=[0, 1, 2], remote_app_data={"a": "b"}, local_unit_data={"c": "d"}, remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, @@ -198,9 +197,7 @@ def pre_event(charm: CharmBase): endpoint="foo", interface="bar", remote_app_name="remote", - remote_unit_ids=[1, 4], - local_app_data={}, - local_unit_data={}, + remote_units_data={1: {}, 4: {}}, ) state = State( leader=True, From 8b2daa12812d1fe30c00150637d9c06ea2950ecc Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Oct 2023 15:38:38 +0200 Subject: [PATCH 322/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 398f63b27..6b647aeb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.2.2" +version = "5.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 84effd6b109482d830522e7a914bfe4ace6dfa1d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Oct 2023 16:19:08 +0200 Subject: [PATCH 323/546] coverage 88%, shed some dead code --- .gitignore | 1 + scenario/__init__.py | 2 - scenario/consistency_checker.py | 1 + scenario/state.py | 21 ----------- tests/test_consistency_checker.py | 62 ++++++++++++++++++++++++++++++- tests/test_context.py | 11 ++++++ tests/test_e2e/test_actions.py | 17 +++++++++ tests/test_e2e/test_secrets.py | 40 ++++++++++++++++++++ tox.ini | 2 +- 9 files changed, 132 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 1be459dd7..046b96754 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ *.egg-info dist/ *.pytest_cache +htmlcov/ \ No newline at end of file diff --git a/scenario/__init__.py b/scenario/__init__.py index 8fa5398ca..82b89ad65 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -14,7 +14,6 @@ Model, Mount, Network, - ParametrizedEvent, PeerRelation, Port, Relation, @@ -34,7 +33,6 @@ "deferred", "StateValidationError", "Secret", - "ParametrizedEvent", "RelationBase", "Relation", "SubordinateRelation", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index daa992e5d..52d626494 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -185,6 +185,7 @@ def _check_action_event( f"action event {event.name} refers to action {action.name} " f"which is not declared in the charm metadata (actions.yaml).", ) + return _check_action_param_types(charm_spec, action, errors, warnings) diff --git a/scenario/state.py b/scenario/state.py index 315160e44..19d605404 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -195,27 +195,6 @@ def normalize_name(s: str): return s.replace("-", "_") -class ParametrizedEvent: - def __init__(self, accept_params: Tuple[str], category: str, *args, **kwargs): - self._accept_params = accept_params - self._category = category - self._args = args - self._kwargs = kwargs - - def __call__(self, remote_unit: Optional[str] = None) -> "Event": - """Construct an Event object using the arguments provided at init and any extra params.""" - if remote_unit and "remote_unit" not in self._accept_params: - raise ValueError( - f"cannot pass param `remote_unit` to a " - f"{self._category} event constructor.", - ) - - return Event(*self._args, *self._kwargs, relation_remote_unit_id=remote_unit) - - def deferred(self, handler: Callable, event_id: int = 1) -> "DeferredEvent": - return self().deferred(handler=handler, event_id=event_id) - - _next_relation_id_counter = 1 diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index a1bf26b4d..e26fb3ada 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -142,6 +142,11 @@ def test_bad_config_option_type(): Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}), ) + assert_inconsistent( + State(config={"foo": True}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {}}}), + ) assert_consistent( State(config={"foo": True}), Event("bar"), @@ -151,12 +156,26 @@ def test_bad_config_option_type(): @pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99")) def test_secrets_jujuv_bad(bad_v): + secret = Secret("secret:foo", {0: {"a": "b"}}) assert_inconsistent( - State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), + State(secrets=[secret]), Event("bar"), _CharmSpec(MyCharm, {}), bad_v, ) + assert_inconsistent( + State(secrets=[secret]), + secret.changed_event, + _CharmSpec(MyCharm, {}), + bad_v, + ) + + assert_inconsistent( + State(), + secret.changed_event, + _CharmSpec(MyCharm, {}), + bad_v, + ) @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) @@ -182,6 +201,20 @@ def test_peer_relation_consistency(): ) +def test_duplicate_endpoints_inconsistent(): + assert_inconsistent( + State(), + Event("bar"), + _CharmSpec( + MyCharm, + { + "requires": {"foo": {"interface": "bar"}}, + "provides": {"foo": {"interface": "baz"}}, + }, + ), + ) + + def test_sub_relation_consistency(): assert_inconsistent( State(relations=[Relation("foo")]), @@ -191,6 +224,7 @@ def test_sub_relation_consistency(): {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, ), ) + assert_consistent( State(relations=[SubordinateRelation("foo")]), Event("bar"), @@ -226,6 +260,32 @@ def test_container_pebble_evt_consistent(): ) +def test_action_not_in_meta_inconsistent(): + action = Action("foo", params={"bar": "baz"}) + assert_inconsistent( + State(), + action.event, + _CharmSpec(MyCharm, meta={}, actions={}), + ) + + +def test_action_meta_type_inconsistent(): + action = Action("foo", params={"bar": "baz"}) + assert_inconsistent( + State(), + action.event, + _CharmSpec( + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "zabazaba"}}}} + ), + ) + + assert_inconsistent( + State(), + action.event, + _CharmSpec(MyCharm, meta={}, actions={"foo": {"params": {"bar": {}}}}), + ) + + def test_action_name(): action = Action("foo", params={"bar": "baz"}) diff --git a/tests/test_context.py b/tests/test_context.py index 1a1d3e19a..76a24eec8 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -39,3 +39,14 @@ def test_run_action(): assert isinstance(a, Action) assert a.event.name == "do_foo_action" assert s is state + + +def test_clear(): + ctx = Context(MyCharm, meta={"name": "foo"}) + state = State() + + ctx.run("start", state) + assert ctx.emitted_events + + ctx.clear() + assert not ctx.emitted_events # and others... diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 3e203bf3a..eba284807 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -43,6 +43,23 @@ def test_action_event(mycharm, baz_value): assert evt.params["baz"] is baz_value +def test_action_pre_post(mycharm): + ctx = Context( + mycharm, + meta={"name": "foo"}, + actions={ + "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} + }, + ) + action = Action("foo", params={"baz": True, "bar": 10}) + ctx.run_action( + action, + State(), + pre_event=lambda charm: None, + post_event=lambda charm: None, + ) + + @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 0f0743111..2b08cae27 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -1,3 +1,5 @@ +import datetime + import pytest from ops.charm import CharmBase from ops.framework import Framework @@ -210,6 +212,44 @@ def post_event(charm: CharmBase): assert vals == [{"remote"}] if app else [{"remote/0"}] +def test_update_metadata(mycharm): + exp = datetime.datetime(2050, 12, 12) + + def post_event(charm: CharmBase): + secret = charm.model.get_secret(label="mylabel") + secret.set_info( + label="babbuccia", + description="blu", + expire=exp, + rotate=SecretRotate.DAILY, + ) + + out = trigger( + State( + secrets=[ + Secret( + owner="unit", + id="foo", + label="mylabel", + contents={ + 0: {"a": "b"}, + }, + ) + ], + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) + + secret_out = out.secrets[0] + assert secret_out.label == "babbuccia" + assert secret_out.rotate == SecretRotate.DAILY + assert secret_out.description == "blu" + assert secret_out.expire == exp + + def test_grant_nonowner(mycharm): def post_event(charm: CharmBase): secret = charm.model.get_secret(id="foo") diff --git a/tox.ini b/tox.ini index 8d5b23e23..23a1d22fb 100644 --- a/tox.ini +++ b/tox.ini @@ -29,7 +29,7 @@ commands = coverage run \ --source={[vars]src_path} \ -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} - coverage report + coverage html [testenv:lint] description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. From 6dc53f60319a12ef167db2d7df579c2198d354b8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Oct 2023 16:23:01 +0200 Subject: [PATCH 324/546] explicit types for raw databag contents --- scenario/state.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 19d605404..9b612e20d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -35,6 +35,8 @@ PathLike = Union[str, Path] AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] AnyJson = Union[str, bool, dict, int, float, list] + RawSecretRevisionContents = RawDataBagContents = Dict[str, str] + UnitID = int logger = scenario_logger.getChild("state") @@ -103,7 +105,7 @@ class Secret(_DCBase): id: str # mapping from revision IDs to each revision's contents - contents: Dict[int, Dict[str, str]] + contents: Dict[int, "RawSecretRevisionContents"] # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. owner: Literal["unit", "application", None] = None @@ -168,7 +170,7 @@ def _set_revision(self, revision: int): def _update_metadata( self, - content: Optional[Dict[str, str]] = None, + content: Optional["RawSecretRevisionContents"] = None, label: Optional[str] = None, description: Optional[str] = None, expire: Optional[datetime.datetime] = None, @@ -216,8 +218,8 @@ class RelationBase(_DCBase): # Every new Relation instance gets a new one, if there's trouble, override. relation_id: int = dataclasses.field(default_factory=next_relation_id) - local_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - local_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) + local_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + local_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) @property def _databags(self): @@ -230,7 +232,10 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" raise NotImplementedError() - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: # noqa: U100 + def _get_databag_for_remote( + self, + unit_id: int, # noqa: U100 + ) -> "RawDataBagContents": """Return the databag for some remote unit ID.""" raise NotImplementedError() @@ -304,8 +309,8 @@ class Relation(RelationBase): # local limit limit: int = 1 - remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_units_data: Dict[int, Dict[str, str]] = dataclasses.field( + remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( default_factory=lambda: {0: {}}, ) @@ -319,7 +324,7 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" return tuple(self.remote_units_data) - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents": """Return the databag for some remote unit ID.""" return self.remote_units_data[unit_id] @@ -334,8 +339,8 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): - remote_app_data: Dict[str, str] = dataclasses.field(default_factory=dict) - remote_unit_data: Dict[str, str] = dataclasses.field(default_factory=dict) + remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + remote_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) # app name and ID of the remote unit that *this unit* is attached to. remote_app_name: str = "remote" @@ -346,7 +351,7 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" return (self.remote_unit_id,) - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents": """Return the databag for some remote unit ID.""" if unit_id is not self.remote_unit_id: raise ValueError( @@ -370,7 +375,7 @@ def remote_unit_name(self) -> str: @dataclasses.dataclass(frozen=True) class PeerRelation(RelationBase): - peers_data: Dict[int, Dict[str, str]] = dataclasses.field( + peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( default_factory=lambda: {0: {}}, ) # mapping from peer unit IDs to their databag contents. @@ -388,7 +393,7 @@ def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" return tuple(self.peers_data) - def _get_databag_for_remote(self, unit_id: int) -> Dict[str, str]: + def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents": """Return the databag for some remote unit ID.""" return self.peers_data[unit_id] From 7d3bab67eaddb7bfb75c4cab6da851aff7745209 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 5 Oct 2023 17:01:18 +0200 Subject: [PATCH 325/546] test --- scenario/runtime.py | 7 ++++++- scenario/state.py | 5 +---- tests/test_e2e/test_relations.py | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 863d0f1dd..0de7b991f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -231,13 +231,18 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "but you probably should be parametrizing the event with `remote_unit_id` " "to be explicit.", ) - else: + elif len(remote_unit_ids) > 1: remote_unit_id = remote_unit_ids[0] logger.warning( "remote unit ID unset, and multiple remote unit IDs are present; " "We will pick the first one and hope for the best. You should be passing " "`remote_unit_id` to the Event constructor.", ) + else: + logger.warning( + "remote unit ID unset; no remote unit data present. " + "Is this a realistic scenario?", # TODO: is it? + ) if remote_unit_id is not None: remote_unit = f"{remote_app_name}/{remote_unit_id}" diff --git a/scenario/state.py b/scenario/state.py index 9b612e20d..69d7cb8fe 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -193,7 +193,7 @@ def _update_metadata( def normalize_name(s: str): - """Event names need underscores instead of dashes.""" + """Event names, in Scenario, uniformly use underscores instead of dashes.""" return s.replace("-", "_") @@ -927,9 +927,6 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": return self.replace(relation_remote_unit_id=remote_unit_id) def __post_init__(self): - if "-" in self.path: - logger.warning(f"Only use underscores in event paths. {self.path!r}") - path = normalize_name(self.path) # bypass frozen dataclass object.__setattr__(self, "path", path) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 00f4926f6..e5be3400d 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -223,6 +223,42 @@ def callback(charm: CharmBase, event): ) +@pytest.mark.parametrize( + "evt_name", + ("changed", "broken", "departed", "joined", "created"), +) +def test_relation_events_no_remote_units(mycharm, evt_name, caplog): + relation = Relation( + endpoint="foo", + interface="foo", + remote_units_data={}, # no units + ) + + def callback(charm: CharmBase, event): + assert event.app # that's always present + assert not event.unit + + mycharm._call = callback + + trigger( + State( + relations=[ + relation, + ], + ), + getattr(relation, f"{evt_name}_event"), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + }, + ) + + assert "remote unit ID unset; no remote unit data present" in caplog.text + + @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) def test_relation_unit_data_bad_types(mycharm, data): with pytest.raises(StateValidationError): From 518cb9e3505ee195810b45102d0a9e7c3fbb3325 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 5 Oct 2023 17:03:19 +0200 Subject: [PATCH 326/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6b647aeb5..9ac8cf8ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.3" +version = "5.3.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 9a7a706dd712e7b468e4d602fae13ebe4a9183d1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 10 Oct 2023 16:20:09 +0200 Subject: [PATCH 327/546] some more meta autoload tests --- tests/resources/__init__.py | 0 tests/resources/demo_decorate_class.py | 18 ------ tests/test_charm_spec_autoload.py | 80 ++++++++++++++++++++++++++ tox.ini | 15 +++-- 4 files changed, 87 insertions(+), 26 deletions(-) delete mode 100644 tests/resources/__init__.py delete mode 100644 tests/resources/demo_decorate_class.py create mode 100644 tests/test_charm_spec_autoload.py diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/resources/demo_decorate_class.py b/tests/resources/demo_decorate_class.py deleted file mode 100644 index 56e329f85..000000000 --- a/tests/resources/demo_decorate_class.py +++ /dev/null @@ -1,18 +0,0 @@ -class MyDemoClass: - _foo: int = 0 - - def get_foo(self, *args, **kwargs): - return self._foo - - def set_foo(self, foo): - self._foo = foo - - def unpatched(self, *args, **kwargs): - return self._foo - - -class MyOtherClass: - _foo: int = 0 - - def foo(self, *args, **kwargs): - return self._foo diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py new file mode 100644 index 000000000..536fdab24 --- /dev/null +++ b/tests/test_charm_spec_autoload.py @@ -0,0 +1,80 @@ +import importlib +import sys +import tempfile +from pathlib import Path +from typing import Type + +import pytest +import yaml +from ops.testing import CharmType + +from scenario import Context, Relation, State +from scenario.context import ContextSetupError + +CHARM = """ +from ops import CharmBase + +class MyCharm(CharmBase): pass +""" + + +def import_name(name: str, source: Path) -> Type[CharmType]: + pkg_path = str(source.parent) + sys.path.append(pkg_path) + charm = importlib.import_module("charm") + obj = getattr(charm, name) + sys.path.remove(pkg_path) + return obj + + +def create_tempcharm( + charm: str = CHARM, meta=None, actions=None, config=None, name: str = "MyCharm" +): + root = Path(tempfile.TemporaryDirectory().name) + + src = root / "src" + src.mkdir(parents=True) + charmpy = src / "charm.py" + charmpy.write_text(charm) + + if meta is not None: + (root / "metadata.yaml").write_text(yaml.safe_dump(meta)) + + if actions is not None: + (root / "actions.yaml").write_text(yaml.safe_dump(actions)) + + if config is not None: + (root / "config.yaml").write_text(yaml.safe_dump(config)) + + return import_name(name, charmpy) + + +def test_meta_autoload(tmp_path): + charm = create_tempcharm(meta={"name": "foo"}) + ctx = Context(charm) + ctx.run("start", State()) + + +def test_no_meta_raises(tmp_path): + charm = create_tempcharm() + with pytest.raises(ContextSetupError): + Context(charm) + + +def test_relations_ok(tmp_path): + charm = create_tempcharm( + meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} + ) + # this would fail if there were no 'cuddles' relation defined in meta + Context(charm).run("start", State(relations=[Relation("cuddles")])) + + +def test_config_defaults(tmp_path): + charm = create_tempcharm( + meta={"name": "josh"}, + config={"options": {"foo": {"type": "bool", "default": True}}}, + ) + # this would fail if there were no 'cuddles' relation defined in meta + with Context(charm).manager("start", State()) as mgr: + mgr.run() + assert mgr.charm.config["foo"] is True diff --git a/tox.ini b/tox.ini index 23a1d22fb..9e052fb5a 100644 --- a/tox.ini +++ b/tox.ini @@ -22,14 +22,13 @@ package = wheel wheel_build_env = .pkg description = unit tests deps = - coverage[toml] jsonpatch pytest + pytest-cov +setenv = + PYTHONPATH = {toxinidir} commands = - coverage run \ - --source={[vars]src_path} \ - -m pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} - coverage html + pytest --cov-report html -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} [testenv:lint] description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. @@ -48,7 +47,7 @@ deps = coverage[toml] isort commands = - black --check tests scenario + black --check {[vars]tst_path} {[vars]src_path} isort --check-only --profile black {[vars]tst_path} [testenv:fmt] @@ -58,5 +57,5 @@ deps = black isort commands = - black tests scenario - isort --profile black tests scenario + black {[vars]tst_path} {[vars]src_path} + isort --profile black {[vars]tst_path} {[vars]src_path} From af6739b3252621bbd220b9fc9bd754853c731823 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Oct 2023 09:15:23 +0200 Subject: [PATCH 328/546] storage and entitystatus subclass checks --- README.md | 77 ++++++++++++++++++++++++++-- scenario/__init__.py | 2 + scenario/context.py | 9 ++++ scenario/mocking.py | 52 ++++++++++++++----- scenario/runtime.py | 3 ++ scenario/state.py | 83 +++++++++++++++++++++++++++++-- tests/test_charm_spec_autoload.py | 56 +++++++++++++-------- tests/test_e2e/test_state.py | 2 + tests/test_e2e/test_status.py | 29 ++++++++++- tests/test_e2e/test_storage.py | 81 ++++++++++++++++++++++++++++++ 10 files changed, 350 insertions(+), 44 deletions(-) create mode 100644 tests/test_e2e/test_storage.py diff --git a/README.md b/README.md index 97f0c5eb3..e6e111d2e 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) ``` -## Containers +# Containers When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. @@ -586,7 +586,7 @@ need to associate the container with the event is that the Framework uses an env pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. -### Container filesystem post-mortem +## Container filesystem post-mortem If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python @@ -623,7 +623,7 @@ def test_pebble_push(): assert cfg_file.read_text() == "TEST" ``` -### `Container.exec` mocks +## `Container.exec` mocks `container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the @@ -671,6 +671,77 @@ def test_pebble_exec(): ) ``` +# Storage + +If your charm defines `storage` in its metadata, you can use `scenario.state.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime. + +Using the same `get_filesystem` API as `Container`, you can access the tempdir used by Scenario to mock the filesystem root before and after the scenario runs. + +```python +from scenario import Storage, Context, State +# some charm with a 'foo' filesystem-type storage defined in metadata.yaml +ctx = Context(MyCharm) +storage = Storage("foo") +# setup storage with some content +(storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") + +with ctx.manager("update-status", State(storage=[storage])) as mgr: + foo = mgr.charm.model.storages["foo"][0] + loc = foo.location + path = loc / "myfile.txt" + assert path.exists() + assert path.read_text() == "helloworld" + + myfile = loc / "path.py" + myfile.write_text("helloworlds") + +# post-mortem: inspect fs contents. +assert ( + storage.get_filesystem(ctx) / "path.py" +).read_text() == "helloworlds" +``` + +Note that State only wants to know about **attached** storages. A storage which is not attached to the charm can simply be omitted from State and the charm will be none the wiser. + +## Storage-add + +If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. + +```python +# in MyCharm._on_foo: +# the charm requests two new "foo" storage instances to be provisioned +self.model.storages.request("foo", 2) +``` + +From test code, you can inspect that: + +```python +from scenario import Context, State + +ctx = Context(MyCharm) +ctx.run('some-event-that-will-cause_on_foo-to-be-called', State()) + +# the charm has requested two 'foo' storages to be provisioned +assert ctx.requested_storages['foo'] == 2 +``` + +Requesting storages has no other consequence in Scenario. In real life, this request will trigger Juju to provision the storage and execute the charm again with `foo-storage-attached`. +So a natural follow-up Scenario test suite for this case would be: + +```python +from scenario import Context, State, Storage + +ctx = Context(MyCharm) +foo_0 = Storage('foo') +# the charm is notified that one of the storages it has requested is ready +ctx.run(foo_0.attached_event, State(storage=[foo_0])) + +foo_1 = Storage('foo') +# the charm is notified that the other storage is also ready +ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +``` + + # Ports Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host vm/container. Using the `State.opened_ports` api, you can: diff --git a/scenario/__init__.py b/scenario/__init__.py index 82b89ad65..f16a6791d 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -21,6 +21,7 @@ Secret, State, StateValidationError, + Storage, StoredState, SubordinateRelation, deferred, @@ -45,6 +46,7 @@ "BindAddress", "Network", "Port", + "Storage", "StoredState", "State", "DeferredEvent", diff --git a/scenario/context.py b/scenario/context.py index ad985974a..5450205ee 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -246,6 +246,7 @@ def __init__( self.unit_status_history: List["_EntityStatus"] = [] self.workload_version_history: List[str] = [] self.emitted_events: List[EventBase] = [] + self.requested_storages: Dict[str, int] = {} # set by Runtime.exec() in self._run() self._output_state: Optional["State"] = None @@ -263,6 +264,13 @@ def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name + def _get_storage_root(self, name: str, index: int) -> Path: + """Get the path to a tempdir where this storage's simulated root will live.""" + storage_root = Path(self._tmp.name) / "storages" / f"{name}-{index}" + # in the case of _get_container_root, _MockPebbleClient will ensure the dir exists. + storage_root.mkdir(parents=True, exist_ok=True) + return storage_root + def clear(self): """Cleanup side effects histories.""" self.juju_log = [] @@ -270,6 +278,7 @@ def clear(self): self.unit_status_history = [] self.workload_version_history = [] self.emitted_events = [] + self.requested_storages = {} self._action_logs = [] self._action_results = None self._action_failure = "" diff --git a/scenario/mocking.py b/scenario/mocking.py index 16ae0893f..4e5cb11a0 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -6,10 +6,11 @@ import shutil from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union from ops import pebble from ops.model import ( + ModelError, SecretInfo, SecretRotate, _format_action_result_dict, @@ -19,7 +20,7 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, Mount, PeerRelation, Port +from scenario.state import JujuLogLine, Mount, PeerRelation, Port, Storage if TYPE_CHECKING: from scenario.context import Context @@ -382,21 +383,46 @@ def action_get(self): ) return action.params - # TODO: - def storage_add(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_add") + def storage_add(self, name: str, count: int = 1): + if "/" in name: + raise ModelError('storage name cannot contain "/"') - def resource_get(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("resource_get") + self._context.requested_storages[name] = count - def storage_list(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_list") + def storage_list(self, name: str) -> List[int]: + return [ + storage.index for storage in self._state.storage if storage.name == name + ] - def storage_get(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("storage_get") + def storage_get(self, storage_name_id: str, attribute: str) -> str: + if attribute == "location": + name, index = storage_name_id.split("/") + index = int(index) + try: + storage: Storage = next( + filter( + lambda s: s.name == name and s.index == index, + self._state.storage, + ), + ) + except StopIteration as e: + raise RuntimeError( + f"Storage not found with name={name} and index={index}.", + ) from e + + fs_path = storage.get_filesystem(self._context) + return str(fs_path) + + raise NotImplementedError( + f"storage-get not implemented for attribute={attribute}", + ) + + def planned_units(self) -> int: + return self._state.planned_units - def planned_units(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("planned_units") + # TODO: + def resource_get(self, *args, **kwargs): # noqa: U100 + raise NotImplementedError("resource_get") class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/runtime.py b/scenario/runtime.py index 0de7b991f..dd64c4165 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -253,6 +253,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) + if storage := event.storage: + env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"}) + if secret := event.secret: env.update( { diff --git a/scenario/state.py b/scenario/state.py index 69d7cb8fe..782f8bd80 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -646,7 +646,7 @@ def __eq__(self, other): "Comparing Status with Tuples is deprecated and will be removed soon.", ) return (self.name, self.message) == other - if isinstance(other, StatusBase): + if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) logger.warning( f"Comparing Status with {other} is not stable and will be forbidden soon." @@ -659,12 +659,21 @@ def __iter__(self): def __repr__(self): status_type_name = self.name.title() + "Status" + if self.name == "unknown": + return f"{status_type_name}()" return f"{status_type_name}('{self.message}')" def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: """Convert StatusBase to _EntityStatus.""" - return _EntityStatus(obj.name, obj.message) + statusbase_subclass = type(StatusBase.from_name(obj.name, obj.message)) + + class _MyClass(_EntityStatus, statusbase_subclass): + # Custom type inheriting from a specific StatusBase subclass to support instance checks: + # isinstance(state.unit_status, ops.ActiveStatus) + pass + + return _MyClass(obj.name, obj.message) @dataclasses.dataclass(frozen=True) @@ -709,6 +718,52 @@ def __post_init__(self): ) +_next_storage_index_counter = 0 # storage indices start at 0 + + +def next_storage_index(update=True): + """Get the index (used to be called ID) the next Storage to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ + global _next_storage_index_counter + cur = _next_storage_index_counter + if update: + _next_storage_index_counter += 1 + return cur + + +@dataclasses.dataclass(frozen=True) +class Storage(_DCBase): + """Represents an (attached!) storage made available to the charm container.""" + + name: str + + index: int = dataclasses.field(default_factory=next_storage_index) + # Every new Storage instance gets a new one, if there's trouble, override. + + def get_filesystem(self, ctx: "Context") -> Path: + """Simulated filesystem root in this context.""" + return ctx._get_storage_root(self.name, self.index) + + @property + def attached_event(self) -> "Event": + """Sugar to generate a -storage-attached event.""" + return Event( + path=normalize_name(self.name + "-storage-attached"), + storage=self, + ) + + @property + def detached_event(self) -> "Event": + """Sugar to generate a -storage-detached event.""" + return Event( + path=normalize_name(self.name + "-storage-detached"), + storage=self, + ) + + @dataclasses.dataclass(frozen=True) class State(_DCBase): """Represents the juju-owned portion of a unit's state. @@ -721,30 +776,48 @@ class State(_DCBase): config: Dict[str, Union[str, int, float, bool]] = dataclasses.field( default_factory=dict, ) + """The present configuration of this charm.""" relations: List["AnyRelation"] = dataclasses.field(default_factory=list) + """All relations that currently exist for this charm.""" networks: List[Network] = dataclasses.field(default_factory=list) + """All networks currently provisioned for this charm.""" containers: List[Container] = dataclasses.field(default_factory=list) + """All containers (whether they can connect or not) that this charm is aware of.""" + storage: List[Storage] = dataclasses.field(default_factory=list) + """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: List[Port] = dataclasses.field(default_factory=list) + """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: List[Secret] = dataclasses.field(default_factory=list) + """The secrets this charm has access to (as an owner, or as a grantee).""" + planned_units: int = 1 + """Number of non-dying planned units that are expected to be running this application. + Use with caution.""" unit_id: int = 0 + """ID of the unit hosting this charm.""" # represents the OF's event queue. These events will be emitted before the event being # dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) + """Events that have been deferred on this charm by some previous execution.""" stored_state: List["StoredState"] = dataclasses.field(default_factory=list) - - """Represents the 'juju statuses' of the application/unit being tested.""" + """Contents of a charm's stored state.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + """Status of the application.""" unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + """Status of the unit.""" workload_version: str = "" + """Workload version.""" def __post_init__(self): for name in ["app_status", "unit_status"]: @@ -897,6 +970,8 @@ class Event(_DCBase): args: Tuple[Any] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) + # if this is a storage event, the storage it refers to + storage: Optional["Storage"] = None # if this is a relation event, the relation it refers to relation: Optional["AnyRelation"] = None # and the name of the remote unit this relation event is about diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 536fdab24..65ff83d2f 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -1,6 +1,7 @@ import importlib import sys import tempfile +from contextlib import contextmanager from pathlib import Path from typing import Type @@ -18,20 +19,26 @@ class MyCharm(CharmBase): pass """ +@contextmanager def import_name(name: str, source: Path) -> Type[CharmType]: pkg_path = str(source.parent) sys.path.append(pkg_path) charm = importlib.import_module("charm") obj = getattr(charm, name) sys.path.remove(pkg_path) - return obj + yield obj + del sys.modules["charm"] +@contextmanager def create_tempcharm( - charm: str = CHARM, meta=None, actions=None, config=None, name: str = "MyCharm" + root: Path, + charm: str = CHARM, + meta=None, + actions=None, + config=None, + name: str = "MyCharm", ): - root = Path(tempfile.TemporaryDirectory().name) - src = root / "src" src.mkdir(parents=True) charmpy = src / "charm.py" @@ -46,35 +53,40 @@ def create_tempcharm( if config is not None: (root / "config.yaml").write_text(yaml.safe_dump(config)) - return import_name(name, charmpy) + with import_name(name, charmpy) as charm: + yield charm def test_meta_autoload(tmp_path): - charm = create_tempcharm(meta={"name": "foo"}) - ctx = Context(charm) - ctx.run("start", State()) + with create_tempcharm(tmp_path, meta={"name": "foo"}) as charm: + ctx = Context(charm) + ctx.run("start", State()) def test_no_meta_raises(tmp_path): - charm = create_tempcharm() - with pytest.raises(ContextSetupError): - Context(charm) + with create_tempcharm( + tmp_path, + ) as charm: + # metadata not found: + with pytest.raises(ContextSetupError): + Context(charm) def test_relations_ok(tmp_path): - charm = create_tempcharm( - meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} - ) - # this would fail if there were no 'cuddles' relation defined in meta - Context(charm).run("start", State(relations=[Relation("cuddles")])) + with create_tempcharm( + tmp_path, meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} + ) as charm: + # this would fail if there were no 'cuddles' relation defined in meta + Context(charm).run("start", State(relations=[Relation("cuddles")])) def test_config_defaults(tmp_path): - charm = create_tempcharm( + with create_tempcharm( + tmp_path, meta={"name": "josh"}, config={"options": {"foo": {"type": "bool", "default": True}}}, - ) - # this would fail if there were no 'cuddles' relation defined in meta - with Context(charm).manager("start", State()) as mgr: - mgr.run() - assert mgr.charm.config["foo"] is True + ) as charm: + # this would fail if there were no 'cuddles' relation defined in meta + with Context(charm).manager("start", State()) as mgr: + mgr.run() + assert mgr.charm.config["foo"] is True diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index f06f70075..a0243973a 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -183,6 +183,7 @@ def event_handler(charm: CharmBase, _): def pre_event(charm: CharmBase): assert charm.model.get_relation("foo") + assert charm.model.app.planned_units() == 4 # this would NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. @@ -201,6 +202,7 @@ def pre_event(charm: CharmBase): ) state = State( leader=True, + planned_units=4, relations=[relation], ) diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index e54192949..0c28f7e6d 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -1,10 +1,17 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import ActiveStatus, BlockedStatus, UnknownStatus, WaitingStatus +from ops.model import ( + ActiveStatus, + BlockedStatus, + ErrorStatus, + MaintenanceStatus, + UnknownStatus, + WaitingStatus, +) from scenario import Context -from scenario.state import State +from scenario.state import State, _status_to_entitystatus from tests.helpers import trigger @@ -118,3 +125,21 @@ def post_event(charm: CharmBase): assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" + + +@pytest.mark.parametrize( + "status", + ( + ActiveStatus("foo"), + WaitingStatus("bar"), + BlockedStatus("baz"), + MaintenanceStatus("qux"), + ErrorStatus("fiz"), + UnknownStatus(), + ), +) +def test_status_comparison(status): + entitystatus = _status_to_entitystatus(status) + assert entitystatus == entitystatus == status + assert isinstance(entitystatus, type(status)) + assert repr(entitystatus) == repr(status) diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py new file mode 100644 index 000000000..a33893f71 --- /dev/null +++ b/tests/test_e2e/test_storage.py @@ -0,0 +1,81 @@ +import pytest +from ops import CharmBase, ModelError + +from scenario import Context, State, Storage + + +class MyCharmWithStorage(CharmBase): + META = {"name": "charlene", "storage": {"foo": {"type": "filesystem"}}} + + +class MyCharmWithoutStorage(CharmBase): + META = {"name": "patrick"} + + +@pytest.fixture +def storage_ctx(): + return Context(MyCharmWithStorage, meta=MyCharmWithStorage.META) + + +@pytest.fixture +def no_storage_ctx(): + return Context(MyCharmWithoutStorage, meta=MyCharmWithoutStorage.META) + + +def test_storage_get_null(no_storage_ctx): + with no_storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + assert not len(storages) + + +def test_storage_get_unknown_name(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # not in metadata + with pytest.raises(KeyError): + storages["bar"] + + +def test_storage_request_unknown_name(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # not in metadata + with pytest.raises(ModelError): + storages.request("bar") + + +def test_storage_get_some(storage_ctx): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + # known but none attached + assert storages["foo"] == [] + + +@pytest.mark.parametrize("n", (1, 3, 5)) +def test_storage_add(storage_ctx, n): + with storage_ctx.manager("update-status", State()) as mgr: + storages = mgr.charm.model.storages + storages.request("foo", n) + + assert storage_ctx.requested_storages["foo"] == n + + +def test_storage_usage(storage_ctx): + storage = Storage("foo") + # setup storage with some content + (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") + + with storage_ctx.manager("update-status", State(storage=[storage])) as mgr: + foo = mgr.charm.model.storages["foo"][0] + loc = foo.location + path = loc / "myfile.txt" + assert path.exists() + assert path.read_text() == "helloworld" + + myfile = loc / "path.py" + myfile.write_text("helloworlds") + + # post-mortem: inspect fs contents. + assert ( + storage.get_filesystem(storage_ctx) / "path.py" + ).read_text() == "helloworlds" From c71faeaf42c49555451a3bc13dadcc07f78119e1 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Oct 2023 09:17:43 +0200 Subject: [PATCH 329/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ac8cf8ba..5001306a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.3.1" +version = "5.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From e2bd31415a782aba1073dd5841188478429bdeea Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 13 Oct 2023 09:26:09 +0200 Subject: [PATCH 330/546] consistency checks --- scenario/consistency_checker.py | 27 +++++++++++++++++++++++++++ tests/test_consistency_checker.py | 27 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 52d626494..e360a5c79 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -123,6 +123,9 @@ def check_event_consistency( if event._is_action_event: _check_action_event(charm_spec, event, errors, warnings) + if event._is_storage_event: + _check_storage_event(charm_spec, event, errors, warnings) + return Results(errors, warnings) @@ -190,6 +193,30 @@ def _check_action_event( _check_action_param_types(charm_spec, action, errors, warnings) +def _check_storage_event( + charm_spec: _CharmSpec, + event: "Event", + errors: List[str], + warnings: List[str], # noqa: U100 +): + storage = event.storage + if not storage: + errors.append( + "cannot construct a storage event without the Storage instance. " + "Please pass one.", + ) + elif not event.name.startswith(normalize_name(storage.name)): + errors.append( + f"storage event should start with storage name. {event.name} does " + f"not start with {storage.name}.", + ) + elif storage.name not in charm_spec.meta["storage"]: + errors.append( + f"storage event {event.name} refers to storage {storage.name} " + f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", + ) + + def _check_action_param_types( charm_spec: _CharmSpec, action: Action, diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index e26fb3ada..513e0ffe3 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,6 +12,7 @@ Relation, Secret, State, + Storage, SubordinateRelation, _CharmSpec, ) @@ -342,7 +343,7 @@ def test_relation_without_endpoint(): relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] ), Event("start"), - _CharmSpec(MyCharm, meta={}), + _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( @@ -357,3 +358,27 @@ def test_relation_without_endpoint(): }, ), ) + + +def test_storage_event(): + storage = Storage("foo") + assert_inconsistent( + State(storage=[storage]), + Event("foo-storage-attached"), + _CharmSpec(MyCharm, meta={"name": "rupert"}), + ) + assert_inconsistent( + State(storage=[storage]), + Event("foo-storage-attached"), + _CharmSpec( + MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} + ), + ) + + assert_consistent( + State(storage=[storage]), + storage.attached_event, + _CharmSpec( + MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} + ), + ) From fb307e7b9421f4909ab677aa0e438cc4f96daf23 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 16 Oct 2023 16:50:42 +1300 Subject: [PATCH 331/546] Allow 'integer' as an action param type. --- scenario/consistency_checker.py | 1 + tests/test_consistency_checker.py | 33 +++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 52d626494..f4a89425c 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -199,6 +199,7 @@ def _check_action_param_types( to_python_type = { "string": str, "boolean": bool, + "integer": int, "number": Number, "array": Sequence, "object": dict, diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index e26fb3ada..159d4964b 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -303,22 +303,35 @@ def test_action_name(): ) -def test_action_params_type(): - action = Action("foo", params={"bar": "baz"}) +_ACTION_TYPE_CHECKS = [ + ("string", "baz", None), + ("boolean", True, "baz"), + ("integer", 42, 1.5), + ("number", 28.8, "baz"), + ("array", ["a", "b", "c"], 1.5), # A string is an acceptable array. + ("object", {"k": "v"}, "baz"), +] + + +@pytest.mark.parametrize("ptype,good,bad", _ACTION_TYPE_CHECKS) +def test_action_params_type(ptype, good, bad): + action = Action("foo", params={"bar": good}) assert_consistent( State(), action.event, _CharmSpec( - MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}} - ), - ) - assert_inconsistent( - State(), - action.event, - _CharmSpec( - MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "boolean"}}}} + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} ), ) + if bad is not None: + action = Action("foo", params={"bar": bad}) + assert_inconsistent( + State(), + action.event, + _CharmSpec( + MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} + ), + ) def test_duplicate_relation_ids(): From dadb8471fe90d75935691f00efb84c9304defee4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 18 Oct 2023 10:39:42 +0200 Subject: [PATCH 332/546] pr comments and consistency checks for storages --- scenario/consistency_checker.py | 32 +++++++++++++++++++++++ scenario/mocking.py | 43 ++++++++++++++++--------------- tests/test_consistency_checker.py | 33 +++++++++++++++++++++++- 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index e360a5c79..9a0ffdd0a 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -66,6 +66,7 @@ def check_consistency( check_config_consistency, check_event_consistency, check_secrets_consistency, + check_storages_consistency, check_relation_consistency, ): results = check( @@ -263,6 +264,37 @@ def _check_action_param_types( ) +def check_storages_consistency( + *, + state: "State", + charm_spec: "_CharmSpec", + **_kwargs, # noqa: U101 +) -> Results: + """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" + state_storage = state.storage + meta_storage = (charm_spec.meta or {}).get("storage", {}) + errors = [] + + if missing := {s.name for s in state.storage}.difference( + set(meta_storage.keys()), + ): + errors.append( + f"some storages passed to State were not defined in metadata.yaml: {missing}", + ) + + seen = [] + for s in state_storage: + tag = (s.name, s.index) + if tag in seen: + errors.append( + f"duplicate storage in State: storage {s.name} with index {s.index} " + f"occurs multiple times in State.storage.", + ) + seen.append(tag) + + return Results(errors, []) + + def check_config_consistency( *, state: "State", diff --git a/scenario/mocking.py b/scenario/mocking.py index 4e5cb11a0..fe0f0a30f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -395,27 +395,28 @@ def storage_list(self, name: str) -> List[int]: ] def storage_get(self, storage_name_id: str, attribute: str) -> str: - if attribute == "location": - name, index = storage_name_id.split("/") - index = int(index) - try: - storage: Storage = next( - filter( - lambda s: s.name == name and s.index == index, - self._state.storage, - ), - ) - except StopIteration as e: - raise RuntimeError( - f"Storage not found with name={name} and index={index}.", - ) from e - - fs_path = storage.get_filesystem(self._context) - return str(fs_path) - - raise NotImplementedError( - f"storage-get not implemented for attribute={attribute}", - ) + if attribute != "location": + raise NotImplementedError( + f"storage-get not implemented for attribute={attribute}", + ) + + name, index = storage_name_id.split("/") + index = int(index) + storages: List[Storage] = [ + s for s in self._state.storage if s.name == name and s.index == index + ] + if not storages: + raise RuntimeError(f"Storage with name={name} and index={index} not found.") + if len(storages) > 1: + # should not really happen: sanity check. + raise RuntimeError( + f"Multiple Storage instances with name={name} and index={index} found. " + f"Inconsistent state.", + ) + + storage = storages[0] + fs_path = storage.get_filesystem(self._context) + return str(fs_path) def planned_units(self) -> int: return self._state.planned_units diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 513e0ffe3..062f32d45 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -374,7 +374,6 @@ def test_storage_event(): MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} ), ) - assert_consistent( State(storage=[storage]), storage.attached_event, @@ -382,3 +381,35 @@ def test_storage_event(): MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} ), ) + + +def test_storage_states(): + storage1 = Storage("foo", index=1) + storage2 = Storage("foo", index=1) + + assert_inconsistent( + State(storage=[storage1, storage2]), + Event("start"), + _CharmSpec(MyCharm, meta={"name": "everett"}), + ) + assert_consistent( + State(storage=[storage1, storage2.replace(index=2)]), + Event("start"), + _CharmSpec( + MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} + ), + ) + assert_consistent( + State(storage=[storage1, storage2.replace(name="marx")]), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "engels", + "storage": { + "foo": {"type": "filesystem"}, + "marx": {"type": "filesystem"}, + }, + }, + ), + ) From da33305fd4310679a73c8375204397dc2bfe1f13 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 08:31:33 +0200 Subject: [PATCH 333/546] actionoutput class --- scenario/context.py | 22 ++++++++++++++++++++-- tests/test_e2e/test_actions.py | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 5450205ee..c9e5bfc26 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import dataclasses import tempfile -from collections import namedtuple from contextlib import contextmanager from pathlib import Path from typing import ( @@ -34,7 +34,25 @@ logger = scenario_logger.getChild("runtime") -ActionOutput = namedtuple("ActionOutput", ("state", "logs", "results", "failure")) + +@dataclasses.dataclass +class ActionOutput: + """Wraps the results of running an action event with `run_action`.""" + + 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] + """Any logs associated with the action output, set by the charm.""" + results: Dict[str, str] + """Key-value mapping assigned by the charm as a result of the action.""" + failure: Optional[str] + """If the action is not a successL: the message the charm set when failing the action.""" + + @property + def success(self) -> bool: + """Return whether this action was a success.""" + return self.failure is not None class InvalidEventError(RuntimeError): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index eba284807..18248a172 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -110,6 +110,7 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): out = ctx.run_action(action, State()) assert out.results == res_value + assert out.success is True @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) @@ -128,3 +129,4 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): assert out.failure == "failed becozz" assert out.logs == ["log1", "log2"] + assert out.success is False From e0c7883343632b4dbeb815e33133920b5e4d1d72 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 08:35:33 +0200 Subject: [PATCH 334/546] updated readme --- README.md | 10 ++++++++-- scenario/context.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e6e111d2e..43aa0e005 100644 --- a/README.md +++ b/README.md @@ -850,9 +850,15 @@ def test_backup_action(): out: ActionOutput = ctx.run_action("do_backup_action", State()) # you can assert action results, logs, failure using the ActionOutput interface - assert out.results == {'foo': 'bar'} assert out.logs == ['baz', 'qux'] - assert out.failure == 'boo-hoo' + + if out.success: + # 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' ``` ## Parametrized Actions diff --git a/scenario/context.py b/scenario/context.py index c9e5bfc26..1b99624af 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -47,7 +47,7 @@ class ActionOutput: results: Dict[str, str] """Key-value mapping assigned by the charm as a result of the action.""" failure: Optional[str] - """If the action is not a successL: the message the charm set when failing the action.""" + """If the action is not a success: the message the charm set when failing the action.""" @property def success(self) -> bool: From 5b3bd329929987344c80390b93b8174915c0934d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 08:36:14 +0200 Subject: [PATCH 335/546] quotes --- scenario/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/context.py b/scenario/context.py index 1b99624af..750b19361 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -39,7 +39,7 @@ class ActionOutput: """Wraps the results of running an action event with `run_action`.""" - state: State + 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] From 46f46093b34d4c032099548843fe4e57ca070ce2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 08:38:12 +0200 Subject: [PATCH 336/546] failure is None --- scenario/context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 750b19361..71ef53bff 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -46,13 +46,13 @@ class ActionOutput: """Any logs associated with the action output, set by the charm.""" results: Dict[str, str] """Key-value mapping assigned by the charm as a result of the action.""" - failure: Optional[str] + failure: Optional[str] = None """If the action is not a success: the message the charm set when failing the action.""" @property def success(self) -> bool: """Return whether this action was a success.""" - return self.failure is not None + return self.failure is None class InvalidEventError(RuntimeError): @@ -272,7 +272,7 @@ def __init__( # ephemeral side effects from running an action self._action_logs = [] self._action_results = None - self._action_failure = "" + self._action_failure = None def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" @@ -299,7 +299,7 @@ def clear(self): self.requested_storages = {} self._action_logs = [] self._action_results = None - self._action_failure = "" + self._action_failure = None self._output_state = None def _record_status(self, state: "State", is_app: bool): @@ -481,7 +481,7 @@ def _finalize_action(self, state_out: "State"): # reset all action-related state self._action_logs = [] self._action_results = None - self._action_failure = "" + self._action_failure = None return ao From a899d6d60e05db9e8c33efae29339beacb495774 Mon Sep 17 00:00:00 2001 From: Judit Novak Date: Thu, 19 Oct 2023 22:57:23 +0200 Subject: [PATCH 337/546] SecretNotFoundError should be raised when a secret is not found --- scenario/mocking.py | 5 +++-- tests/test_e2e/test_secrets.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index fe0f0a30f..1d4ff3a01 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -12,6 +12,7 @@ from ops.model import ( ModelError, SecretInfo, + SecretNotFoundError, SecretRotate, _format_action_result_dict, _ModelBackend, @@ -134,12 +135,12 @@ def _get_secret(self, id=None, label=None): try: return next(filter(lambda s: s.id == id, self._state.secrets)) except StopIteration: - raise RuntimeError(f"not found: secret with id={id}.") + raise SecretNotFoundError() elif label: try: return next(filter(lambda s: s.label == label, self._state.secrets)) except StopIteration: - raise RuntimeError(f"not found: secret with label={label}.") + raise SecretNotFoundError() else: raise RuntimeError("need id or label.") diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 2b08cae27..4ed469659 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,7 +3,7 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import SecretRotate +from ops.model import SecretNotFoundError, SecretRotate from scenario.state import Relation, Secret, State from tests.helpers import trigger @@ -25,9 +25,9 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): def post_event(charm: CharmBase): - with pytest.raises(RuntimeError): + with pytest.raises(SecretNotFoundError): assert charm.model.get_secret(id="foo") - with pytest.raises(RuntimeError): + with pytest.raises(SecretNotFoundError): assert charm.model.get_secret(label="foo") trigger( From dcb7ee652e02b2160d572f88bbcfce7c62dea852 Mon Sep 17 00:00:00 2001 From: Judit Novak Date: Fri, 20 Oct 2023 09:11:47 +0200 Subject: [PATCH 338/546] Version 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5001306a9..943a30fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.4" +version = "5.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 3a494afe2163e6b47221c8def6e164859d8342aa Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Fri, 20 Oct 2023 09:18:47 +0200 Subject: [PATCH 339/546] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 943a30fbf..ba5559245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.5" +version = "5.4.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From e65f8cbde22dd62b058199c0f2129b0ab0178b46 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 10:08:29 +0200 Subject: [PATCH 340/546] relation-get failures --- scenario/mocking.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index fe0f0a30f..f7cbab296 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -11,6 +11,7 @@ from ops import pebble from ops.model import ( ModelError, + RelationNotFoundError, SecretInfo, SecretRotate, _format_action_result_dict, @@ -37,6 +38,13 @@ logger = scenario_logger.getChild("mocking") +class ActionMissingFromContextError(Exception): + """Raised when the user attempts to action-related hook tools when not handling an action.""" + + # This is not an ops error: in ops, you'd have to go exceptionally out of your way to trigger + # this flow. + + class _MockExecProcess: def __init__(self, command: Tuple[str], change_id: int, out: "ExecOutput"): self._command = command @@ -123,7 +131,7 @@ def _get_relation_by_id( filter(lambda r: r.relation_id == rel_id, self._state.relations), ) except StopIteration as e: - raise RuntimeError(f"Not found: relation with id={rel_id}.") from e + raise RelationNotFoundError(f"Not found: relation with id={rel_id}.") from e def _get_secret(self, id=None, label=None): # cleanup id: @@ -353,7 +361,7 @@ def relation_remote_app_name(self, relation_id: int): def action_set(self, results: Dict[str, Any]): if not self._event.action: - raise RuntimeError( + raise ActionMissingFromContextError( "not in the context of an action event: cannot action-set", ) # let ops validate the results dict @@ -363,14 +371,14 @@ def action_set(self, results: Dict[str, Any]): def action_fail(self, message: str = ""): if not self._event.action: - raise RuntimeError( + raise ActionMissingFromContextError( "not in the context of an action event: cannot action-fail", ) self._context._action_failure = message def action_log(self, message: str): if not self._event.action: - raise RuntimeError( + raise ActionMissingFromContextError( "not in the context of an action event: cannot action-log", ) self._context._action_logs.append(message) @@ -378,7 +386,7 @@ def action_log(self, message: str): def action_get(self): action = self._event.action if not action: - raise RuntimeError( + raise ActionMissingFromContextError( "not in the context of an action event: cannot action-get", ) return action.params From 29e47ab9d0b51f81c01f825e53a5d795e0fb2986 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 13:55:53 +0200 Subject: [PATCH 341/546] fixed error types and added resource mocks --- README.md | 17 +++++ pyproject.toml | 2 +- scenario/consistency_checker.py | 22 ++++++ scenario/mocking.py | 110 +++++++++++++++++++++++++----- scenario/state.py | 35 +++++++--- tests/test_consistency_checker.py | 41 +++++++++++ tests/test_e2e/test_network.py | 31 +++++++++ tests/test_e2e/test_storage.py | 10 +++ tests/test_runtime.py | 3 +- 9 files changed, 244 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e6e111d2e..6742d19be 100644 --- a/README.md +++ b/README.md @@ -1045,6 +1045,23 @@ state = State(stored_state=[ And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on the output side the same as any other bit of state. +# Resources + +If your charm requires access to resources, you can make them available to it through `State.resources`. +From the perspective of a 'real' deployed charm, if your charm _has_ resources defined in `metadata.yaml`, they _must_ be made available to the charm. That is a Juju-enforced constraint: you can't deploy a charm without attaching all resources it needs to it. +However, when testing, this constraint is unnecessarily strict (and it would also mean the great majority of all existing tests would break) since a charm will only notice that a resource is not available when it explicitly asks for it, which not many charms do. + +So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in metadata. + +```python +from scenario import State, Context +ctx = Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) +with ctx.manager("start", State(resources={'foo': '/path/to/resource.tar'})) as mgr: + # if the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. + path = mgr.charm.model.resources.fetch('foo') + assert path == '/path/to/resource.tar' +``` + # Emitting custom events While the main use case of Scenario is to emit juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, diff --git a/pyproject.toml b/pyproject.toml index ba5559245..943a30fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.4.1" +version = "5.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index b48a16c6c..ae80a7cf0 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -64,6 +64,7 @@ def check_consistency( for check in ( check_containers_consistency, check_config_consistency, + check_resource_consistency, check_event_consistency, check_secrets_consistency, check_storages_consistency, @@ -92,6 +93,27 @@ def check_consistency( ) +def check_resource_consistency( + *, + state: "State", + charm_spec: "_CharmSpec", + **_kwargs, # noqa: U101 +) -> Results: + """Check the internal consistency of the resources from metadata and in State.""" + errors = [] + warnings = [] + + resources_from_meta = set(charm_spec.meta.get("resources", {}).keys()) + resources_from_state = set(state.resources.keys()) + if not resources_from_meta.issuperset(resources_from_state): + errors.append( + f"any and all resources passed to State.resources need to have been defined in " + f"metadata.yaml. Metadata resources: {resources_from_meta}; " + f"State.resources: {resources_from_state}.", + ) + return Results(errors, warnings) + + def check_event_consistency( *, event: "Event", diff --git a/scenario/mocking.py b/scenario/mocking.py index 714e5a691..e10def8e9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -6,9 +6,9 @@ import shutil from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union -from ops import pebble +from ops import JujuVersion, pebble from ops.model import ( ModelError, RelationNotFoundError, @@ -131,8 +131,8 @@ def _get_relation_by_id( return next( filter(lambda r: r.relation_id == rel_id, self._state.relations), ) - except StopIteration as e: - raise RelationNotFoundError(f"Not found: relation with id={rel_id}.") from e + except StopIteration: + raise RelationNotFoundError() def _get_secret(self, id=None, label=None): # cleanup id: @@ -150,6 +150,8 @@ def _get_secret(self, id=None, label=None): except StopIteration: raise SecretNotFoundError() else: + # if all goes well, this should never be reached. ops.model.Secret will check upon + # instantiation that either an id or a label are set, and raise a TypeError if not. raise RuntimeError("need id or label.") @staticmethod @@ -157,16 +159,28 @@ def _generate_secret_id(): id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) return f"secret:{id}" - def relation_get(self, rel_id, obj_name, app): - relation = self._get_relation_by_id(rel_id) - if app and obj_name == self.app_name: + def _check_app_data_access(self, is_app: bool): + if not isinstance(is_app, bool): + raise TypeError("is_app parameter to relation_get must be a boolean") + + if is_app: + version = JujuVersion(self._context.juju_version) + if not version.has_app_data(): + raise RuntimeError( + f"setting application data is not supported on Juju version {version}", + ) + + def relation_get(self, relation_id: int, member_name: str, is_app: bool): + self._check_app_data_access(is_app) + relation = self._get_relation_by_id(relation_id) + if is_app and member_name == self.app_name: return relation.local_app_data - elif app: + elif is_app: return relation.remote_app_data - elif obj_name == self.unit_name: + elif member_name == self.unit_name: return relation.local_unit_data - unit_id = int(obj_name.split("/")[-1]) + unit_id = int(member_name.split("/")[-1]) return relation._get_databag_for_remote(unit_id) # noqa def is_leader(self): @@ -214,6 +228,10 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): if relation_id: logger.warning("network-get -r not implemented") + relations = self._state.get_relations(binding_name) + if not relations: + raise RelationNotFoundError() + network = next(filter(lambda r: r.name == binding_name, self._state.networks)) return network.hook_tool_output_fmt() @@ -233,9 +251,12 @@ def juju_log(self, level: str, message: str): self._context.juju_log.append(JujuLogLine(level, message)) def relation_set(self, relation_id: int, key: str, value: str, is_app: bool): + self._check_app_data_access(is_app) relation = self._get_relation_by_id(relation_id) if is_app: if not self._state.leader: + # will in practice not be reached because RelationData will check leadership + # and raise RelationDataAccessError upstream on this path raise RuntimeError("needs leadership to set app data") tgt = relation.local_app_data else: @@ -356,8 +377,12 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None): else: secret.contents.clear() - def relation_remote_app_name(self, relation_id: int): - relation = self._get_relation_by_id(relation_id) + def relation_remote_app_name(self, relation_id: int) -> Optional[str]: + # ops catches relationnotfounderrors and returns None: + try: + relation = self._get_relation_by_id(relation_id) + except RelationNotFoundError: + return None return relation.remote_app_name def action_set(self, results: Dict[str, Any]): @@ -367,7 +392,8 @@ def action_set(self, results: Dict[str, Any]): ) # let ops validate the results dict _format_action_result_dict(results) - # but then we will store it in its unformatted, original form + # but then we will store it in its unformatted, + # original form for testing ease self._context._action_results = results def action_fail(self, message: str = ""): @@ -393,7 +419,13 @@ def action_get(self): return action.params def storage_add(self, name: str, count: int = 1): + if not isinstance(count, int) or isinstance(count, bool): + raise TypeError( + f"storage count must be integer, got: {count} ({type(count)})", + ) + if "/" in name: + # this error is raised by ops.testing but not by ops at runtime raise ModelError('storage name cannot contain "/"') self._context.requested_storages[name] = count @@ -403,8 +435,23 @@ def storage_list(self, name: str) -> List[int]: storage.index for storage in self._state.storage if storage.name == name ] + def _storage_event_details(self) -> Tuple[int, str]: + storage = self._event.storage + if not storage: + # only occurs if this method is called when outside the scope of a storage event + raise RuntimeError('unable to find storage key in ""') + fs_path = storage.get_filesystem(self._context) + return storage.index, str(fs_path) + def storage_get(self, storage_name_id: str, attribute: str) -> str: + if not len(attribute) > 0: # assume it's an empty string. + raise RuntimeError( + 'calling storage_get with `attribute=""` will return a dict ' + "and not a string. This usage is not supported.", + ) + if attribute != "location": + # this should not happen: in ops it's hardcoded to be "location" raise NotImplementedError( f"storage-get not implemented for attribute={attribute}", ) @@ -414,10 +461,11 @@ def storage_get(self, storage_name_id: str, attribute: str) -> str: storages: List[Storage] = [ s for s in self._state.storage if s.name == name and s.index == index ] + + # should not really happen: sanity checks. In practice, ops will guard against these paths. if not storages: raise RuntimeError(f"Storage with name={name} and index={index} not found.") if len(storages) > 1: - # should not really happen: sanity check. raise RuntimeError( f"Multiple Storage instances with name={name} and index={index} found. " f"Inconsistent state.", @@ -430,9 +478,37 @@ def storage_get(self, storage_name_id: str, attribute: str) -> str: def planned_units(self) -> int: return self._state.planned_units - # TODO: - def resource_get(self, *args, **kwargs): # noqa: U100 - raise NotImplementedError("resource_get") + # legacy ops API that we don't intend to mock: + def pod_spec_set( + self, + spec: Mapping[str, Any], # noqa: U100 + k8s_resources: Optional[Mapping[str, Any]] = None, # noqa: U100 + ): + raise NotImplementedError( + "pod-spec-set is not implemented in Scenario (and probably never will be: " + "it's deprecated API)", + ) + + def add_metrics( + self, + metrics: Mapping[str, Union[int, float]], # noqa: U100 + labels: Optional[Mapping[str, str]] = None, # noqa: U100 + ) -> None: + raise NotImplementedError( + "add-metrics is not implemented in Scenario (and probably never will be: " + "it's deprecated API)", + ) + + def resource_get(self, resource_name: str) -> str: + try: + return str(self._state.resources[resource_name]) + except KeyError: + # ops will not let us get there if the resource name is unknown from metadata. + # but if the user forgot to add it in State, then we remind you of that. + raise RuntimeError( + f"Inconsistent state: " + f"resource {resource_name} not found in State. please pass it.", + ) class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index 782f8bd80..3da31fa92 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -756,10 +756,10 @@ def attached_event(self) -> "Event": ) @property - def detached_event(self) -> "Event": + def detaching_event(self) -> "Event": """Sugar to generate a -storage-detached event.""" return Event( - path=normalize_name(self.name + "-storage-detached"), + path=normalize_name(self.name + "-storage-detaching"), storage=self, ) @@ -796,6 +796,8 @@ class State(_DCBase): """The model this charm lives in.""" secrets: List[Secret] = dataclasses.field(default_factory=list) """The secrets this charm has access to (as an owner, or as a grantee).""" + resources: Dict[str, "PathLike"] = dataclasses.field(default_factory=dict) + """Mapping from resource name to path at which the resource can be found.""" planned_units: int = 1 """Number of non-dying planned units that are expected to be running this application. @@ -876,12 +878,13 @@ def get_container(self, container: Union[str, Container]) -> Container: except StopIteration as e: raise ValueError(f"container: {name}") from e - def get_relations(self, endpoint: str) -> Tuple["AnyRelation"]: - """Get relation from this State, based on an input relation or its endpoint name.""" - try: - return tuple(filter(lambda c: c.endpoint == endpoint, self.relations)) - except StopIteration as e: - raise ValueError(f"relation: {endpoint}") from e + def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: + """Get all relations on this endpoint from the current state.""" + return tuple(filter(lambda c: c.endpoint == endpoint, self.relations)) + + def get_storages(self, name: str) -> Tuple["Storage", ...]: + """Get all storages with this name.""" + return tuple(filter(lambda s: s.name == name, self.storage)) # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. def jsonpatch_delta(self, other: "State"): @@ -1094,6 +1097,9 @@ def bind(self, state: State): For example, a relation event initialized without a Relation instance will search for a suitable relation in the provided state and return a copy of itself with that relation attached. + + In case of ambiguity (e.g. multiple relations found on 'foo' for event + 'foo-relation-changed', we pop a warning and bind the first one. Use with care! """ entity_name = self.name.split("_")[0] @@ -1113,6 +1119,19 @@ def bind(self, state: State): ) return self.replace(secret=state.secrets[0]) + if self._is_storage_event and not self.storage: + storages = state.get_storages(entity_name) + if len(storages) < 1: + raise BindFailedError( + f"no storages called {entity_name} found in state", + ) + if len(storages) > 1: + logger.warning( + f"too many storages called {entity_name}: binding to first one", + ) + storage = storages[0] + return self.replace(storage=storage) + if self._is_relation_event and not self.relation: ep_name = entity_name relations = state.get_relations(ep_name) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 8afc84c90..bcdc7df83 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -426,3 +426,44 @@ def test_storage_states(): }, ), ) + + +def test_resource_states(): + # happy path + assert_consistent( + State(resources={"foo": "/foo/bar.yaml"}), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, + ), + ) + + # no resources in state but some in meta: OK. Not realistic wrt juju but fine for testing + assert_consistent( + State(), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, + ), + ) + + # resource not defined in meta + assert_inconsistent( + State(resources={"bar": "/foo/bar.yaml"}), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, + ), + ) + + assert_inconsistent( + State(resources={"bar": "/foo/bar.yaml"}), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "yamlman"}, + ), + ) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index fccd60290..8c10c6cc0 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -1,4 +1,5 @@ import pytest +from ops import RelationNotFoundError from ops.charm import CharmBase from ops.framework import Framework @@ -53,3 +54,33 @@ def fetch_unit_address(charm: CharmBase): }, post_event=fetch_unit_address, ) + + +def test_no_relation_error(mycharm): + """Attempting to call get_binding on a non-existing relation -> RelationNotFoundError""" + mycharm._call = lambda *_: True + + def fetch_unit_address(charm: CharmBase): + with pytest.raises(RelationNotFoundError): + _ = charm.model.get_binding("foo").network + + trigger( + State( + relations=[ + Relation( + interface="foo", + remote_app_name="remote", + endpoint="metrics-endpoint", + relation_id=1, + ), + ], + networks=[Network.default("metrics-endpoint")], + ), + "update_status", + mycharm, + meta={ + "name": "foo", + "requires": {"metrics-endpoint": {"interface": "foo"}}, + }, + post_event=fetch_unit_address, + ) diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index a33893f71..87aa93700 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -79,3 +79,13 @@ def test_storage_usage(storage_ctx): assert ( storage.get_filesystem(storage_ctx) / "path.py" ).read_text() == "helloworlds" + + +def test_storage_attached_event(storage_ctx): + storage = Storage("foo") + storage_ctx.run(storage.attached_event, State(storage=[storage])) + + +def test_storage_detaching_event(storage_ctx): + storage = Storage("foo") + storage_ctx.run(storage.detaching_event, State(storage=[storage])) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 0f37ce822..60e6293e8 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -103,7 +103,8 @@ def test_env_cleanup_on_charm_error(): state=State(), event=Event("box_relation_changed", relation=Relation("box")), context=Context(my_charm_type, meta=meta), - ) as charm: + ): assert os.getenv("JUJU_REMOTE_APP") + _ = 1 / 0 # raise some error assert os.getenv("JUJU_REMOTE_APP", None) is None From 379738ccfa660d78a496c3adeee4e00379dceba2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 20 Oct 2023 14:49:05 +0200 Subject: [PATCH 342/546] fixed bug with event names --- scenario/state.py | 153 +++++++++++++++++++------- tests/test_e2e/test_event.py | 50 +++++++++ tests/test_e2e/test_event_bind.py | 7 ++ tests/test_e2e/test_rubbish_events.py | 2 +- 4 files changed, 173 insertions(+), 39 deletions(-) create mode 100644 tests/test_e2e/test_event.py diff --git a/scenario/state.py b/scenario/state.py index 3da31fa92..f8c7a80ad 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -8,7 +8,7 @@ import re import typing from collections import namedtuple -from itertools import chain +from enum import Enum from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 @@ -18,7 +18,6 @@ from ops.charm import CharmEvents from ops.model import SecretRotate, StatusBase -# from scenario.fs_mocks import _MockFileSystem, _MockStorageMount from scenario.logger import logger as scenario_logger JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) @@ -46,6 +45,26 @@ DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" ACTION_EVENT_SUFFIX = "_action" +# all builtin events except secret events. They're special because they carry secret metadata. +BUILTIN_EVENTS = { + "start", + "stop", + "install", + "install", + "start", + "stop", + "remove", + "update_status", + "config_changed", + "upgrade_charm", + "pre_series_upgrade", + "post_series_upgrade", + "leader_elected", + "leader_settings_changed", + "collect_metrics", + "collect_app_status", + "collect_unit_status", +} PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" RELATION_EVENTS_SUFFIX = { "_relation_changed", @@ -880,7 +899,19 @@ def get_container(self, container: Union[str, Container]) -> Container: def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: """Get all relations on this endpoint from the current state.""" - return tuple(filter(lambda c: c.endpoint == endpoint, self.relations)) + + # we rather normalize the endpoint than worry about cursed metadata situations such as: + # requires: + # foo-bar: ... + # foo_bar: ... + + normalized_endpoint = normalize_name(endpoint) + return tuple( + filter( + lambda c: normalize_name(c.endpoint) == normalized_endpoint, + self.relations, + ), + ) def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" @@ -967,6 +998,62 @@ def name(self): return self.handle_path.split("/")[-1].split("[")[0] +class _EventType(str, Enum): + builtin = "builtin" + relation = "relation" + action = "action" + secret = "secret" + storage = "storage" + workload = "workload" + custom = "custom" + + +class _EventPath(str): + def __new__(cls, string): + string = normalize_name(string) + instance = super().__new__(cls, string) + + instance.name = name = string.split(".")[-1] + instance.owner_path = string.split(".")[:-1] or ["on"] + + instance.suffix, instance.type = suffix, _ = _EventPath._get_suffix_and_type( + name, + ) + if suffix: + instance.prefix, _ = string.rsplit(suffix) + else: + instance.prefix = string + + instance.is_custom = suffix == "" + return instance + + @staticmethod + def _get_suffix_and_type(s: str): + for suffix in RELATION_EVENTS_SUFFIX: + if s.endswith(suffix): + return suffix, _EventType.relation + + if s.endswith(ACTION_EVENT_SUFFIX): + return ACTION_EVENT_SUFFIX, _EventType.action + + if s in SECRET_EVENTS: + return s, _EventType.secret + + # Whether the event name indicates that this is a storage event. + for suffix in STORAGE_EVENTS_SUFFIX: + if s.endswith(suffix): + return suffix, _EventType.storage + + # Whether the event name indicates that this is a workload event. + if s.endswith(PEBBLE_READY_EVENT_SUFFIX): + return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload + + if s in BUILTIN_EVENTS: + return "", _EventType.builtin + + return "", _EventType.custom + + @dataclasses.dataclass(frozen=True) class Event(_DCBase): path: str @@ -1005,14 +1092,27 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": return self.replace(relation_remote_unit_id=remote_unit_id) def __post_init__(self): - path = normalize_name(self.path) + path = _EventPath(self.path) # bypass frozen dataclass object.__setattr__(self, "path", path) + @property + def _path(self) -> _EventPath: + # we converted it in __post_init__, but the type checker doesn't know about that + return typing.cast(_EventPath, self.path) + @property def name(self) -> str: - """Event name.""" - return self.path.split(".")[-1] + """Full event name. + + Consists of a 'prefix' and a 'suffix'. The suffix denotes the type of the event, the + prefix the name of the entity the event is about. + + "foo-relation-changed": + - "foo"=prefix (name of a relation), + - "-relation-changed"=suffix (relation event) + """ + return self._path.name @property def owner_path(self) -> List[str]: @@ -1020,32 +1120,32 @@ def owner_path(self) -> List[str]: If this event is defined on the toplevel charm class, it should be ['on']. """ - return self.path.split(".")[:-1] or ["on"] + return self._path.owner_path @property def _is_relation_event(self) -> bool: """Whether the event name indicates that this is a relation event.""" - return any(self.name.endswith(suffix) for suffix in RELATION_EVENTS_SUFFIX) + return self._path.type is _EventType.relation @property def _is_action_event(self) -> bool: """Whether the event name indicates that this is a relation event.""" - return self.name.endswith(ACTION_EVENT_SUFFIX) + return self._path.type is _EventType.action @property def _is_secret_event(self) -> bool: """Whether the event name indicates that this is a secret event.""" - return self.name in SECRET_EVENTS + return self._path.type is _EventType.secret @property def _is_storage_event(self) -> bool: """Whether the event name indicates that this is a storage event.""" - return any(self.name.endswith(suffix) for suffix in STORAGE_EVENTS_SUFFIX) + return self._path.type is _EventType.storage @property def _is_workload_event(self) -> bool: """Whether the event name indicates that this is a workload event.""" - return self.name.endswith("_pebble_ready") + return self._path.type is _EventType.workload # this method is private because _CharmSpec is not quite user-facing; also, # the user should know. @@ -1065,31 +1165,8 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): # `charm.lib.on.foo_relation_created` and therefore be unique and the Framework is happy. # However, our Event data structure ATM has no knowledge of which Object/Handle it is # owned by. So the only thing we can do right now is: check whether the event name, - # assuming it is owned by the charm, is that of a builtin event or not. - builtins = [] - for relation_name in chain( - charm_spec.meta.get("requires", ()), - charm_spec.meta.get("provides", ()), - charm_spec.meta.get("peers", ()), - ): - relation_name = relation_name.replace("-", "_") - for relation_evt_suffix in RELATION_EVENTS_SUFFIX: - builtins.append(relation_name + relation_evt_suffix) - - for storage_name in charm_spec.meta.get("storages", ()): - storage_name = storage_name.replace("-", "_") - for storage_evt_suffix in STORAGE_EVENTS_SUFFIX: - builtins.append(storage_name + storage_evt_suffix) - - for action_name in charm_spec.actions or (): - action_name = action_name.replace("-", "_") - builtins.append(action_name + ACTION_EVENT_SUFFIX) - - for container_name in charm_spec.meta.get("containers", ()): - container_name = container_name.replace("-", "_") - builtins.append(container_name + PEBBLE_READY_EVENT_SUFFIX) - - return event_name in builtins + # assuming it is owned by the charm, LOOKS LIKE that of a builtin event or not. + return self._path.type is not _EventType.custom def bind(self, state: State): """Attach to this event the state component it needs. @@ -1101,7 +1178,7 @@ def bind(self, state: State): In case of ambiguity (e.g. multiple relations found on 'foo' for event 'foo-relation-changed', we pop a warning and bind the first one. Use with care! """ - entity_name = self.name.split("_")[0] + entity_name = self._path.prefix if self._is_workload_event and not self.container: try: diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py new file mode 100644 index 000000000..91fab82f1 --- /dev/null +++ b/tests/test_e2e/test_event.py @@ -0,0 +1,50 @@ +import pytest +from ops import CharmBase + +from scenario.state import _EventType, Event, _CharmSpec + + +@pytest.mark.parametrize( + "evt, expected_type", + ( + ("foo_relation_changed", _EventType.relation), + ("foo_relation_created", _EventType.relation), + ("foo_bar_baz_relation_created", _EventType.relation), + ("foo_storage_attached", _EventType.storage), + ("foo_storage_detaching", _EventType.storage), + ("foo_bar_baz_storage_detaching", _EventType.storage), + ("foo_pebble_ready", _EventType.workload), + ("foo_bar_baz_pebble_ready", _EventType.workload), + ("secret_removed", _EventType.secret), + ("foo", _EventType.custom), + ("kaboozle_bar_baz", _EventType.custom), + ), +) +def test_event_type(evt, expected_type): + event = Event(evt) + assert event._path.type is expected_type + + assert event._is_relation_event is (expected_type is _EventType.relation) + assert event._is_storage_event is (expected_type is _EventType.storage) + assert event._is_workload_event is (expected_type is _EventType.workload) + assert event._is_secret_event is (expected_type is _EventType.secret) + assert event._is_action_event is (expected_type is _EventType.action) + + class MyCharm(CharmBase): + pass + + spec = _CharmSpec( + MyCharm, + meta={ + "requires": { + "foo": {"interface": "bar"}, + "foo_bar_baz": {"interface": "bar"}, + }, + "storage": { + "foo": {"type": "filesystem"}, + "foo_bar_baz": {"type": "filesystem"}, + }, + "containers": {"foo": {}, "foo_bar_baz": {}}, + }, + ) + assert event._is_builtin_event(spec) is (expected_type is not _EventType.custom) diff --git a/tests/test_e2e/test_event_bind.py b/tests/test_e2e/test_event_bind.py index 816b1c8cd..4878e6ac3 100644 --- a/tests/test_e2e/test_event_bind.py +++ b/tests/test_e2e/test_event_bind.py @@ -11,6 +11,13 @@ def test_bind_relation(): assert event.bind(state).relation is foo_relation +def test_bind_relation_complex_name(): + event = Event("foo-bar-baz-relation-changed") + foo_relation = Relation("foo_bar_baz") + state = State(relations=[foo_relation]) + assert event.bind(state).relation is foo_relation + + def test_bind_relation_notfound(): event = Event("foo-relation-changed") state = State(relations=[]) diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index ad95fbf7f..10582d82d 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -79,7 +79,7 @@ def test_custom_events_sub_raise(mycharm, evt_name): ("install", True), ("config-changed", True), ("foo-relation-changed", True), - ("bar-relation-changed", False), + ("bar-relation-changed", True), ), ) def test_is_custom_event(mycharm, evt_name, expected): From c83bd8aae2ebc5f8395d589f3ebfe1513ccbc8db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 23 Oct 2023 10:51:37 +0200 Subject: [PATCH 343/546] docs fix --- scenario/mocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index e10def8e9..cea029040 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -40,7 +40,7 @@ class ActionMissingFromContextError(Exception): - """Raised when the user attempts to action-related hook tools when not handling an action.""" + """Raised when the user attempts to invoke action hook tools outside an action context.""" # This is not an ops error: in ops, you'd have to go exceptionally out of your way to trigger # this flow. From e03c08471049af3f741ab0038f02105fb6bb01db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 23 Oct 2023 15:39:48 +0200 Subject: [PATCH 344/546] fixed deferred bug --- README.md | 4 +-- scenario/runtime.py | 8 +++++- tests/test_e2e/test_deferred.py | 50 +++++++++++++++++++++++++++++++-- tests/test_e2e/test_event.py | 2 +- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6742d19be..53333c794 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ available. The charm has no config, no relations, no networks, no leadership, an With that, we can write the simplest possible scenario test: ```python -from scenario import State, Context +from scenario import State, Context, Event from ops.charm import CharmBase from ops.model import UnknownStatus @@ -93,7 +93,7 @@ class MyCharm(CharmBase): def test_scenario_base(): ctx = Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', State()) + out = ctx.run(Event("start"), State()) assert out.unit_status == UnknownStatus() ``` diff --git a/scenario/runtime.py b/scenario/runtime.py index dd64c4165..41619c669 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -22,7 +22,7 @@ import yaml from ops.framework import EventBase, _event_regex -from ops.storage import SQLiteStorage +from ops.storage import NoSnapshotError, SQLiteStorage from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger @@ -100,10 +100,16 @@ def get_deferred_events(self) -> List["DeferredEvent"]: 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 NoSnapshotError: + snapshot_data = {} + event = DeferredEvent( handle_path=handle, owner=owner, observer=observer, + snapshot_data=snapshot_data, ) deferred.append(event) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 6593d9b99..bdd70db04 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -10,6 +10,7 @@ ) from ops.framework import Framework +from scenario import Context from scenario.state import Container, DeferredEvent, Relation, State, deferred from tests.helpers import trigger @@ -34,7 +35,7 @@ def __init__(self, framework: Framework): def _on_event(self, event): self.captured.append(event) - if self.defer_next: + if self.defer_next > 0: self.defer_next -= 1 return event.defer() @@ -134,7 +135,9 @@ def test_deferred_relation_event_from_relation(mycharm): out = trigger( State( relations=[rel], - deferred=[rel.changed_event.deferred(handler=mycharm._on_event)], + deferred=[ + rel.changed_event(remote_unit_id=1).deferred(handler=mycharm._on_event) + ], ), "start", mycharm, @@ -144,6 +147,12 @@ def test_deferred_relation_event_from_relation(mycharm): # we deferred the first 2 events we saw: foo_relation_changed, start. assert len(out.deferred) == 2 assert out.deferred[0].name == "foo_relation_changed" + assert out.deferred[0].snapshot_data == { + "relation_name": rel.endpoint, + "relation_id": rel.relation_id, + "app_name": "remote", + "unit_name": "remote/1", + } assert out.deferred[1].name == "start" # we saw start and foo_relation_changed. @@ -178,3 +187,40 @@ def test_deferred_workload_event(mycharm): upstat, start = mycharm.captured assert isinstance(upstat, WorkloadEvent) assert isinstance(start, StartEvent) + + +def test_defer_reemit_lifecycle_event(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + + mycharm.defer_next = 1 + state_1 = ctx.run("update-status", State()) + + mycharm.defer_next = 0 + state_2 = ctx.run("start", state_1) + + assert [type(e).__name__ for e in ctx.emitted_events] == [ + "UpdateStatusEvent", + "UpdateStatusEvent", + "StartEvent", + ] + assert len(state_1.deferred) == 1 + assert not state_2.deferred + + +def test_defer_reemit_relation_event(mycharm): + ctx = Context(mycharm, meta=mycharm.META) + + rel = Relation("foo") + mycharm.defer_next = 1 + state_1 = ctx.run(rel.created_event, State(relations=[rel])) + + mycharm.defer_next = 0 + state_2 = ctx.run("start", state_1) + + assert [type(e).__name__ for e in ctx.emitted_events] == [ + "RelationCreatedEvent", + "RelationCreatedEvent", + "StartEvent", + ] + assert len(state_1.deferred) == 1 + assert not state_2.deferred diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index 91fab82f1..2ce9b5aac 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -1,7 +1,7 @@ import pytest from ops import CharmBase -from scenario.state import _EventType, Event, _CharmSpec +from scenario.state import Event, _CharmSpec, _EventType @pytest.mark.parametrize( From 90f7cc62a29d0edf0f3cf3fd96851a23b3c9c816 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 24 Oct 2023 08:16:06 +0200 Subject: [PATCH 345/546] fixed type --- scenario/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/context.py b/scenario/context.py index 71ef53bff..4fa773a96 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -44,7 +44,7 @@ class ActionOutput: In most cases, actions are not expected to be affecting it.""" logs: List[str] """Any logs associated with the action output, set by the charm.""" - results: Dict[str, str] + results: Dict[str, Any] """Key-value mapping assigned by the charm as a result of the action.""" failure: Optional[str] = None """If the action is not a success: the message the charm set when failing the action.""" From cbc31f7fedfa997df36031a09cf9d7ca69660fe6 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 25 Oct 2023 09:37:06 +0200 Subject: [PATCH 346/546] Update scenario/state.py Co-authored-by: Ben Hoyt --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index f8c7a80ad..24fcd0a89 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -915,7 +915,7 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" - return tuple(filter(lambda s: s.name == name, self.storage)) + return tuple(s for s in self.storage if s.name == name) # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. def jsonpatch_delta(self, other: "State"): From 0b57a02074d626dee041fff55bf98d2b1de92f94 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 25 Oct 2023 09:37:13 +0200 Subject: [PATCH 347/546] Update scenario/state.py Co-authored-by: Ben Hoyt --- scenario/state.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 24fcd0a89..dbcef9673 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -906,12 +906,7 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: # foo_bar: ... normalized_endpoint = normalize_name(endpoint) - return tuple( - filter( - lambda c: normalize_name(c.endpoint) == normalized_endpoint, - self.relations, - ), - ) + return tuple(r for r in self.relations if normalize_name(r.endpoint) == normalized_endpoint) def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" From 1e70a3c95c82af58ce66248aafad85688947b87e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Oct 2023 10:06:54 +0200 Subject: [PATCH 348/546] pr comments and lint --- scenario/consistency_checker.py | 4 ++-- scenario/state.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index ae80a7cf0..c08c4438d 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -103,8 +103,8 @@ def check_resource_consistency( errors = [] warnings = [] - resources_from_meta = set(charm_spec.meta.get("resources", {}).keys()) - resources_from_state = set(state.resources.keys()) + resources_from_meta = set(charm_spec.meta.get("resources", {})) + resources_from_state = set(state.resources) if not resources_from_meta.issuperset(resources_from_state): errors.append( f"any and all resources passed to State.resources need to have been defined in " diff --git a/scenario/state.py b/scenario/state.py index dbcef9673..df88724e0 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -906,7 +906,11 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: # foo_bar: ... normalized_endpoint = normalize_name(endpoint) - return tuple(r for r in self.relations if normalize_name(r.endpoint) == normalized_endpoint) + return tuple( + r + for r in self.relations + if normalize_name(r.endpoint) == normalized_endpoint + ) def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" From 517171fbd515b181b72915b2da5cd8a86893687e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Oct 2023 10:13:21 +0200 Subject: [PATCH 349/546] ruff fmt --- .pre-commit-config.yaml | 25 ++++++++----------------- tox.ini | 4 ++-- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b83e0b33e..b88092291 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,11 +29,15 @@ repos: rev: 5.12.0 hooks: - id: isort - - repo: https://github.com/psf/black - rev: 23.3.0 + # Run the Ruff linter. + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.2 hooks: - - id: black - args: [--safe] + # Run the Ruff linter. + - id: ruff + # Run the Ruff formatter. + - id: ruff-format - repo: https://github.com/asottile/blacken-docs rev: 1.13.0 hooks: @@ -48,19 +52,6 @@ repos: hooks: - id: tox-ini-fmt args: ["-p", "fix"] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-bugbear==23.3.23 - - flake8-comprehensions==3.12 - - flake8-pytest-style==1.7.2 - - flake8-spellcheck==0.28 - - flake8-unused-arguments==0.0.13 - - flake8-noqa==1.3.1 - - pep8-naming==0.13.3 - - flake8-pyproject==1.2.3 - repo: https://github.com/pre-commit/mirrors-prettier rev: "v2.7.1" hooks: diff --git a/tox.ini b/tox.ini index 9e052fb5a..da9c40ac7 100644 --- a/tox.ini +++ b/tox.ini @@ -54,8 +54,8 @@ commands = description = Format code. skip_install = true deps = - black + ruff isort commands = - black {[vars]tst_path} {[vars]src_path} + ruff format {[vars]tst_path} {[vars]src_path} isort --profile black {[vars]tst_path} {[vars]src_path} From b897d51868af35fc331a7349aa96690ad06be5cf Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Oct 2023 10:16:34 +0200 Subject: [PATCH 350/546] ruff black cfg --- pyproject.toml | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 943a30fbf..b728b1285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,10 +50,38 @@ scenario = "scenario" include = '\.pyi?$' -[tool.flake8] -dictionaries = ["en_US","python","technical","django"] -max-line-length = 100 -ignore = ["SC100", "SC200", "B008"] +[tool.ruff] +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.11 +target-version = "py311" + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E4", "E7", "E9", "F"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" [tool.isort] profile = "black" From 22c5238a2b7d3f7452bb6dbaffcbb1f7b6b1ded9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Oct 2023 10:56:22 +0200 Subject: [PATCH 351/546] not in --- scenario/consistency_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index c08c4438d..80636db8a 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -418,7 +418,7 @@ def _get_relations(r): known_endpoints = [a[0] for a in all_relations_meta] for relation in state.relations: - if not (ep := relation.endpoint) in known_endpoints: + if (ep := relation.endpoint) not in known_endpoints: errors.append(f"relation endpoint {ep} is not declared in metadata.") seen_ids = set() From 93b164bd89f78fefcebe3f95cbc2de83d1953388 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 25 Oct 2023 16:36:01 +0200 Subject: [PATCH 352/546] dedent 1 --- scenario/mocking.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index cea029040..872a8134d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,12 +163,14 @@ def _check_app_data_access(self, is_app: bool): if not isinstance(is_app, bool): raise TypeError("is_app parameter to relation_get must be a boolean") - if is_app: - version = JujuVersion(self._context.juju_version) - if not version.has_app_data(): - raise RuntimeError( - f"setting application data is not supported on Juju version {version}", - ) + if not is_app: + return + + version = JujuVersion(self._context.juju_version) + if not version.has_app_data(): + raise RuntimeError( + f"setting application data is not supported on Juju version {version}", + ) def relation_get(self, relation_id: int, member_name: str, is_app: bool): self._check_app_data_access(is_app) From 1695c29099604957b0ace2cfc2f55d9457574e25 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 10 Nov 2023 09:49:05 +0100 Subject: [PATCH 353/546] harness_to_scenario --- scenario/integrations/harness_to_scenario.py | 122 +++++++++++++++++++ tests/test_integrations/test_integrations.py | 16 +++ 2 files changed, 138 insertions(+) create mode 100644 scenario/integrations/harness_to_scenario.py create mode 100644 tests/test_integrations/test_integrations.py diff --git a/scenario/integrations/harness_to_scenario.py b/scenario/integrations/harness_to_scenario.py new file mode 100644 index 000000000..a4eb265ba --- /dev/null +++ b/scenario/integrations/harness_to_scenario.py @@ -0,0 +1,122 @@ +from typing import List + +from ops.testing import Harness + +from scenario import Container, Model, Network, Port, Relation, Secret, State + + +class Darkroom: + """Wrap a harness and capture its state.""" + + def __init__(self, harness: Harness): + self._harness = harness + + def capture(self) -> State: + h = self._harness + c = h.charm + + if not c: + raise RuntimeError("cannot capture: uninitialized harness.") + + state = State( + config=dict(c.config), + relations=self._get_relations(), + containers=self._get_containers(), + networks=self._get_networks(), + secrets=self._get_secrets(), + opened_ports=self._get_opened_ports(), + leader=c.unit.is_leader(), + unit_id=int(c.unit.name.split("/")[1]), + app_status=c.app.status, + unit_status=c.unit.status, + workload_version=h.get_workload_version(), + model=Model( + # todo: model = kubernetes or lxd? + uuid=h.model.uuid, + name=h.model.name, + ), + ) + return state + + def _get_opened_ports(self) -> List[Port]: + return [Port(p.protocol, p.port) for p in self._harness._backend.opened_ports()] + + def _get_relations(self) -> List[Relation]: + relations = [] + b = self._harness._backend + + def get_interface_name(endpoint: str): + return b._meta.relations[endpoint].interface_name + + local_unit_name = b.unit_name + local_app_name = b.unit_name.split("/")[0] + + for endpoint, ids in b._relation_ids_map.items(): + for r_id in ids: + # todo switch between peer and sub + rel_data = b._relation_data_raw[r_id] + remote_app_name = b._relation + app_and_units = b._relation_app_and_units[r_id] + relations.append( + Relation( + endpoint=endpoint, + interface=get_interface_name(endpoint), + relation_id=r_id, + local_app_data=rel_data[local_app_name], + local_unit_data=rel_data[local_unit_name], + remote_app_data=rel_data[remote_app_name], + remote_units_data={ + int(remote_unit_id.split("/")[1]): rel_data[remote_unit_id] + for remote_unit_id in app_and_units["units"] + }, + remote_app_name=app_and_units["app"], + ), + ) + return relations + + def _get_containers(self) -> List[Container]: + containers = [] + b = self._harness._backend + for name, c in b._meta.containers.items(): + containers.append(Container(name=name, mounts=c.mounts)) + return containers + + def _get_networks(self) -> List[Network]: + networks = [ + Network(name=nw_name, **nw) + for nw_name, nw in self._harness._backend._networks.items() + ] + return networks + + def _get_secrets(self) -> List[Secret]: + secrets = [] + h = self._harness + b = h._backend + + for s in b._secrets: + owner_app = s.owner_name.split("/")[0] + relation_id = b._relation_id_to(owner_app) + grants = s.grants.get(relation_id, set()) + + remote_grants = set() + granted = False + for grant in grants: + if grant in (h.charm.unit.name, h.charm.app.name): + granted = grant + else: + remote_grants.add(grant) + + secrets.append( + Secret( + id=s.id, + label=s.label, + contents=b.secret_get(s.id), + granted=granted, + remote_grants={relation_id: remote_grants}, + description=s.description, + owner=s.owner_name, + rotate=s.rotate_policy, + expire=s.expire_time, + ), + ) + return secrets diff --git a/tests/test_integrations/test_integrations.py b/tests/test_integrations/test_integrations.py new file mode 100644 index 000000000..618896fed --- /dev/null +++ b/tests/test_integrations/test_integrations.py @@ -0,0 +1,16 @@ +import pytest +from ops import CharmBase +from ops.testing import Harness + + +class MyCharm(CharmBase): + META = {"name": "joseph"} + + +@pytest.fixture +def harness(): + return Harness(MyCharm, meta=MyCharm.META) + + +def test_base(harness): + harness.begin() From acff42893200b5b2a56aa659db47096eedda1edd Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Sat, 11 Nov 2023 15:25:07 +0100 Subject: [PATCH 354/546] progress --- scenario/integrations/darkroom.py | 406 ++++++++++++++++++ scenario/integrations/harness_to_scenario.py | 122 ------ .../test_darkroom_harness.py | 20 + .../test_install_harness.py | 33 ++ .../test_integrations_harness.py | 78 ++++ .../test_darkroom_scenario.py | 20 + .../test_install_scenario.py | 33 ++ .../test_integrations_scenario.py | 78 ++++ .../test_darkroom_live.py | 2 + tests/test_integrations/test_integrations.py | 16 - 10 files changed, 670 insertions(+), 138 deletions(-) create mode 100644 scenario/integrations/darkroom.py delete mode 100644 scenario/integrations/harness_to_scenario.py create mode 100644 tests/test_darkroom/test_harness_integration/test_darkroom_harness.py create mode 100644 tests/test_darkroom/test_harness_integration/test_install_harness.py create mode 100644 tests/test_darkroom/test_harness_integration/test_integrations_harness.py create mode 100644 tests/test_darkroom/test_live_integration/test_darkroom_scenario.py create mode 100644 tests/test_darkroom/test_live_integration/test_install_scenario.py create mode 100644 tests/test_darkroom/test_live_integration/test_integrations_scenario.py create mode 100644 tests/test_darkroom/test_scenario_integration/test_darkroom_live.py delete mode 100644 tests/test_integrations/test_integrations.py diff --git a/scenario/integrations/darkroom.py b/scenario/integrations/darkroom.py new file mode 100644 index 000000000..6481de438 --- /dev/null +++ b/scenario/integrations/darkroom.py @@ -0,0 +1,406 @@ +"""Darkroom.""" + + +import logging +import os +from typing import TYPE_CHECKING, Callable, List, Literal, Sequence, Tuple, Union + +import yaml +from ops import CharmBase, EventBase +from ops.model import ModelError, SecretRotate, StatusBase, _ModelBackend +from ops.testing import _TestingModelBackend + +from scenario import Container, Event, Model, Network, Port, Relation, Secret, State +from scenario.mocking import _MockModelBackend +from scenario.state import _CharmSpec + +if TYPE_CHECKING: + from ops import Framework + +_Trace = Sequence[Tuple[Event, State]] +_SupportedBackends = Union[_TestingModelBackend, _ModelBackend, _MockModelBackend] + +logger = logging.getLogger("darkroom") + +# todo move those to Scenario.State and add an Event._is_framework_event() method. +FRAMEWORK_EVENT_NAMES = {"pre_commit", "commit"} + + +class _Unknown: + def __repr__(self): + return "" + + +UNKNOWN = _Unknown() +# Singleton representing missing information that cannot be retrieved, +# because of e.g. lack of leadership. +del _Unknown + + +class Darkroom: + """Darkroom. + + Can be used to "capture" the current State of a charm, given its backend + (testing, mocked, or live). + + Designed to work with multiple backends. + - "Live model backend": live charm with real juju and pebble backends + - "Harness testing backend": simulated backend provided by ops.testing. + - "Scenario backend": simulated backend provided by ops.scenario + + Usage:: + >>> harness = Harness(MyCharm) + >>> harness.begin_with_initial_hooks() + >>> state: State = Darkroom().capture(harness.model._backend) + + + Can be "attached" to a testing harness or scenario.Context to automatically capture + state and triggering event whenever an event is emitted. Result is a "Trace", i.e. a sequence + of events (and custom events), if the charm emits any. + + Can be "installed" in a testing suite or live charm. This will autoattach it to the + current context. + + >>> l = [] + >>> def register_trace(t): # this will be called with each generated trace + >>> l.append(t) + >>> Darkroom.install(register_trace) + >>> harness = Harness(MyCharm) + >>> h.begin_with_initial_hooks() + >>> assert l[0][0][0].name == "leader_settings_changed" + >>> assert l[0][0][1].unit_status == ActiveStatus("foo") + >>> # now that Darkroom is installed, regardless of the testing backend we use to emit + >>> # events, they will be captured + >>> scenario.Context(MyCharm).run("start") + >>> assert l[1][0][0].name == "start" + >>> assert l[1][0][1].unit_status == WaitingStatus("bar") + + Usage in live charms: + Edit charm.py to read: + >>> if __name__ == '__main__': + >>> from darkroom import Darkroom + >>> Darkroom.install(print, live=True) + >>> ops.main(MyCharm) + """ + + def __init__( + self, + skip_framework_events: bool = True, + skip_custom_events: bool = False, + ): + self._skip_framework_events = skip_framework_events + self._skip_custom_events = skip_custom_events + + def _listen_to(self, event: Event, framework: "Framework") -> bool: + """Whether this event should be captured or not. + + Depends on the init-provided skip config. + """ + if self._skip_framework_events and event.name in FRAMEWORK_EVENT_NAMES: + return False + if not self._skip_custom_events: + return True + + # derive the charmspec from the framework. + # Framework contains pointers to all observers. + # attempt to autoload: + try: + charm_type = next( + filter(lambda o: isinstance(o, CharmBase), framework._objects.values()), + ) + except StopIteration as e: + raise RuntimeError("unable to find charm in framework objects") from e + + try: + charm_root = framework.charm_dir + meta = charm_root / "metadata.yaml" + if not meta.exists(): + raise RuntimeError("metadata.yaml not found") + actions = charm_root / "actions.yaml" + config = charm_root / "config.yaml" + charm_spec = _CharmSpec( + charm_type, + meta=yaml.safe_load(meta.read_text()), + actions=yaml.safe_load(actions.read_text()) + if actions.exists() + else None, + config=yaml.safe_load(config.read_text()) if config.exists() else None, + ) + except Exception as e: + # todo: fall back to generating from framework._meta + raise RuntimeError("cannot autoload charm spec") from e + + if not event._is_builtin_event(charm_spec): + return False + + return True + + @staticmethod + def _get_mode( + backend: _SupportedBackends, + ) -> Literal["harness", "scenario", "live"]: + if isinstance(backend, _TestingModelBackend): + return "harness" + elif isinstance(backend, _MockModelBackend): + return "scenario" + elif isinstance(backend, _TestingModelBackend): + return "live" + else: + raise TypeError(backend) + + def capture(self, backend: _SupportedBackends) -> State: + mode = self._get_mode(backend) + logger.info(f"capturing in mode = `{mode}`.") + + if isinstance(backend, _MockModelBackend): + return backend._state + + state = State( + config=dict(backend.config_get()), + relations=self._get_relations(backend), + containers=self._get_containers(backend), + networks=self._get_networks(backend), + secrets=self._get_secrets(backend), + opened_ports=self._get_opened_ports(backend), + leader=self._get_leader(backend), + unit_id=self._get_unit_id(backend), + app_status=self._get_app_status(backend), + unit_status=self._get_unit_status(backend), + workload_version=self._get_workload_version(backend), + model=self._get_model(backend), + ) + + return state + + @staticmethod + def _get_unit_id(backend: _SupportedBackends) -> int: + return int(backend.unit_name.split("/")[1]) + + @staticmethod + def _get_workload_version(backend: _SupportedBackends) -> int: + # only available in testing: a live charm can't get its own workload version. + return getattr(backend, "_workload_version", UNKNOWN) + + @staticmethod + def _get_unit_status(backend: _SupportedBackends) -> StatusBase: + raw = backend.status_get() + return StatusBase.from_name(message=raw["message"], name=raw["status"]) + + @staticmethod + def _get_app_status(backend: _SupportedBackends) -> StatusBase: + try: + raw = backend.status_get(is_app=True) + return StatusBase.from_name(message=raw["message"], name=raw["status"]) + except ModelError: + return UNKNOWN + + @staticmethod + def _get_model(backend: _SupportedBackends) -> Model: + if backend._meta.containers: + # if we have containers we're definitely k8s. + model_type = "kubernetes" + else: + # guess k8s|lxd from envvars + model_type = "kubernetes" if "KUBERNETES" in os.environ else "lxd" + return Model(name=backend.model_name, uuid=backend.model_uuid, type=model_type) + + @staticmethod + def _get_leader(backend: _SupportedBackends): + return backend.is_leader() + + @staticmethod + def _get_opened_ports(backend: _SupportedBackends) -> List[Port]: + return [Port(p.protocol, p.port) for p in backend.opened_ports()] + + def _get_relations(self, backend: _SupportedBackends) -> List[Relation]: + relations = [] + + local_unit_name = backend.unit_name + local_app_name = backend.unit_name.split("/")[0] + + for endpoint, ids in backend._relation_ids_map.items(): + for r_id in ids: + relations.append( + self._get_relation( + backend, + r_id, + endpoint, + local_app_name, + local_unit_name, + ), + ) + + return relations + + def _get_relation( + self, + backend: _SupportedBackends, + r_id: int, + endpoint: str, + local_app_name: str, + local_unit_name: str, + ): + def get_interface_name(endpoint: str): + return backend._meta.relations[endpoint].interface_name + + def try_get(databag, owner): + try: + return databag[owner] + except ModelError: + return UNKNOWN + + # todo switch between peer and sub + rel_data = backend._relation_data_raw[r_id] + + app_and_units = backend._relation_app_and_units[r_id] + remote_app_name = app_and_units["app"] + return Relation( + endpoint=endpoint, + interface=get_interface_name(endpoint), + relation_id=r_id, + local_app_data=try_get(rel_data, local_app_name), + local_unit_data=try_get(rel_data, local_unit_name), + remote_app_data=try_get(rel_data, remote_app_name), + remote_units_data={ + int(remote_unit_id.split("/")[1]): try_get(rel_data, remote_unit_id) + for remote_unit_id in app_and_units["units"] + }, + remote_app_name=remote_app_name, + ) + + def _get_containers(self, backend: _SupportedBackends) -> List[Container]: + containers = [] + mode = self._get_mode(backend) + + for name, c in backend._meta.containers.items(): + if mode == "live": + # todo: real pebble socket address + pebble = backend.get_pebble("") + else: + # testing backends get the 3rd elem: + path = ["a", "b", "c", name, "bar.socket"] + pebble = backend.get_pebble("/".join(path)) + assert pebble + # todo: complete container snapshot + containers.append(Container(name=name, mounts=c.mounts)) + return containers + + def _get_networks(self, backend: _SupportedBackends) -> List[Network]: + networks = [ + Network(name=nw_name, **nw) for nw_name, nw in backend._networks.items() + ] + return networks + + def _get_secrets(self, backend: _SupportedBackends) -> List[Secret]: + secrets = [] + for s in backend._secrets: + owner_app = s.owner_name.split("/")[0] + relation_id = backend._relation_id_to(owner_app) + grants = s.grants.get(relation_id, set()) + + remote_grants = set() + granted = False + for grant in grants: + if grant in (backend.unit_name, backend.app_name): + granted = grant + else: + remote_grants.add(grant) + + secrets.append( + Secret( + id=s.id, + label=s.label, + contents=backend.secret_get(s.id), + granted=granted, + remote_grants={relation_id: remote_grants}, + description=s.description, + owner=s.owner_name, + rotate=s.rotate_policy or SecretRotate.NEVER, + expire=s.expire_time, + ), + ) + return secrets + + def _get_event(self, event: EventBase) -> Event: + return Event(event.handle.kind) + + def attach(self, listener: Callable[[Event, State], None]): + """Every time an event is emitted, record the event and capture the state after execution.""" + from ops import Framework + + if not getattr(Framework, "__orig_emit__", None): + Framework.__orig_emit__ = Framework._emit # noqa + # do not simply use Framework._emit because if we apply this patch multiple times + # the previous listeners will keep being called. + + def _darkroom_emit(instance: Framework, ops_event): + # proceed with framework._emit() + Framework.__orig_emit__(instance, ops_event) + event: Event = self._get_event(ops_event) + + if not self._listen_to(event, instance): + logger.debug(f"skipping event {ops_event}") + return + + backend = instance.model._backend # noqa + # todo should we automagically event.bind(state)? + state = self.capture(backend) + listener(event, state) + + Framework._emit = _darkroom_emit + + @staticmethod + def install(listener: Callable[[_Trace], None], live: bool = False): + """Patch Harness so that every time a new instance is created, a Darkroom is attached to it. + + Note that the trace will be initially empty and will be filled up as the harness emits events. + So only access the traces when you're sure the harness is done emitting. + """ + Darkroom._install_on_harness(listener) + Darkroom._install_on_scenario(listener) + + if live: + # if we are in a live event context, we attach and register a single trace + trace = [] + listener(trace) + + # we don't do this automatically, but instead do it on an explicit live=True, + # because otherwise listener will be called with an empty trace at the + # beginning of every run. + Darkroom().attach(lambda e, s: trace.append((e, s))) + + @staticmethod + def _install_on_scenario(listener: Callable[[_Trace], None]): + from scenario import Context + + if not getattr(Context, "__orig_init__", None): + Context.__orig__init__ = Context.__init__ + # do not simply use Context.__init__ because + # if we instantiate multiple Contexts we'll keep adding to the older harnesses' traces. + + def patch(context: Context, *args, **kwargs): + trace = [] + listener(trace) + Context.__orig__init__(context, *args, **kwargs) + dr = Darkroom() + dr.attach(listener=lambda event, state: trace.append((event, state))) + + Context.__init__ = patch + + @staticmethod + def _install_on_harness(listener: Callable[[_Trace], None]): + from ops.testing import Harness + + if not getattr(Harness, "__orig_init__", None): + Harness.__orig_init__ = Harness.__init__ + # do not simply use Harness.__init__ because + # if we instantiate multiple harnesses we'll keep adding to the older harnesses' traces. + + def patch(harness: Harness, *args, **kwargs): + trace = [] + listener(trace) + Harness.__orig_init__(harness, *args, **kwargs) + dr = Darkroom() + dr.attach(listener=lambda event, state: trace.append((event, state))) + + Harness.__init__ = patch diff --git a/scenario/integrations/harness_to_scenario.py b/scenario/integrations/harness_to_scenario.py deleted file mode 100644 index a4eb265ba..000000000 --- a/scenario/integrations/harness_to_scenario.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import List - -from ops.testing import Harness - -from scenario import Container, Model, Network, Port, Relation, Secret, State - - -class Darkroom: - """Wrap a harness and capture its state.""" - - def __init__(self, harness: Harness): - self._harness = harness - - def capture(self) -> State: - h = self._harness - c = h.charm - - if not c: - raise RuntimeError("cannot capture: uninitialized harness.") - - state = State( - config=dict(c.config), - relations=self._get_relations(), - containers=self._get_containers(), - networks=self._get_networks(), - secrets=self._get_secrets(), - opened_ports=self._get_opened_ports(), - leader=c.unit.is_leader(), - unit_id=int(c.unit.name.split("/")[1]), - app_status=c.app.status, - unit_status=c.unit.status, - workload_version=h.get_workload_version(), - model=Model( - # todo: model = kubernetes or lxd? - uuid=h.model.uuid, - name=h.model.name, - ), - ) - return state - - def _get_opened_ports(self) -> List[Port]: - return [Port(p.protocol, p.port) for p in self._harness._backend.opened_ports()] - - def _get_relations(self) -> List[Relation]: - relations = [] - b = self._harness._backend - - def get_interface_name(endpoint: str): - return b._meta.relations[endpoint].interface_name - - local_unit_name = b.unit_name - local_app_name = b.unit_name.split("/")[0] - - for endpoint, ids in b._relation_ids_map.items(): - for r_id in ids: - # todo switch between peer and sub - rel_data = b._relation_data_raw[r_id] - remote_app_name = b._relation - app_and_units = b._relation_app_and_units[r_id] - relations.append( - Relation( - endpoint=endpoint, - interface=get_interface_name(endpoint), - relation_id=r_id, - local_app_data=rel_data[local_app_name], - local_unit_data=rel_data[local_unit_name], - remote_app_data=rel_data[remote_app_name], - remote_units_data={ - int(remote_unit_id.split("/")[1]): rel_data[remote_unit_id] - for remote_unit_id in app_and_units["units"] - }, - remote_app_name=app_and_units["app"], - ), - ) - return relations - - def _get_containers(self) -> List[Container]: - containers = [] - b = self._harness._backend - for name, c in b._meta.containers.items(): - containers.append(Container(name=name, mounts=c.mounts)) - return containers - - def _get_networks(self) -> List[Network]: - networks = [ - Network(name=nw_name, **nw) - for nw_name, nw in self._harness._backend._networks.items() - ] - return networks - - def _get_secrets(self) -> List[Secret]: - secrets = [] - h = self._harness - b = h._backend - - for s in b._secrets: - owner_app = s.owner_name.split("/")[0] - relation_id = b._relation_id_to(owner_app) - grants = s.grants.get(relation_id, set()) - - remote_grants = set() - granted = False - for grant in grants: - if grant in (h.charm.unit.name, h.charm.app.name): - granted = grant - else: - remote_grants.add(grant) - - secrets.append( - Secret( - id=s.id, - label=s.label, - contents=b.secret_get(s.id), - granted=granted, - remote_grants={relation_id: remote_grants}, - description=s.description, - owner=s.owner_name, - rotate=s.rotate_policy, - expire=s.expire_time, - ), - ) - return secrets diff --git a/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py b/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py new file mode 100644 index 000000000..1e1798627 --- /dev/null +++ b/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py @@ -0,0 +1,20 @@ +import yaml +from ops import CharmBase +from ops.testing import Harness + +from scenario.integrations.darkroom import Darkroom + + +class MyCharm(CharmBase): + META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} + + +def test_attach(): + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + l = [] + d = Darkroom().attach(lambda e, s: l.append((e, s))) + h.begin() + h.add_relation("foo", "remote") + + assert len(l) == 1 + assert l[0][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_harness_integration/test_install_harness.py b/tests/test_darkroom/test_harness_integration/test_install_harness.py new file mode 100644 index 000000000..950ab5e44 --- /dev/null +++ b/tests/test_darkroom/test_harness_integration/test_install_harness.py @@ -0,0 +1,33 @@ +def test_install(): + from scenario.integrations.darkroom import Darkroom + + l = [] + + def register_trace(t): + l.append(t) + + Darkroom.install(register_trace) + + import yaml + from ops import CharmBase + from ops.testing import Harness + + class MyCharm(CharmBase): + META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + h.add_relation("foo", "remote") + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + h.add_relation("foo", "remote2") + + assert len(l) == 3 + assert [len(x) for x in l] == [4, 5, 5] + assert l[0][1][0].name == "leader_settings_changed" + assert l[1][-1][0].name == "foo_relation_created" + assert l[2][-1][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_harness_integration/test_integrations_harness.py b/tests/test_darkroom/test_harness_integration/test_integrations_harness.py new file mode 100644 index 000000000..fd017f49f --- /dev/null +++ b/tests/test_darkroom/test_harness_integration/test_integrations_harness.py @@ -0,0 +1,78 @@ +import ops +import pytest +import yaml +from ops import CharmBase, BlockedStatus, WaitingStatus +from ops.testing import Harness + +import scenario +from scenario import Model +from scenario.integrations.darkroom import Darkroom + + +class MyCharm(CharmBase): + META = {"name": "joseph"} + + +@pytest.fixture +def harness(): + return Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + + +def test_base(harness): + harness.begin() + state = Darkroom().capture(harness.model._backend) + assert state.unit_id == 0 + + +@pytest.mark.parametrize("leader", (True, False)) +@pytest.mark.parametrize("model_name", ("foo", "bar-baz")) +@pytest.mark.parametrize("model_uuid", ("qux", "fiz")) +def test_static_attributes(harness, leader, model_name, model_uuid): + harness.set_model_info(model_name, model_uuid) + harness.begin() + harness.charm.unit.set_workload_version("42.42") + harness.set_leader(leader) + + state = Darkroom().capture(harness.model._backend) + + assert state.leader is leader + assert state.model == Model(name=model_name, uuid=model_uuid, type="lxd") + assert state.workload_version == "42.42" + + +def test_status(harness): + harness.begin() + harness.set_leader(True) # so we can set app status + harness.charm.app.status = BlockedStatus("foo") + harness.charm.unit.status = WaitingStatus("hol' up") + + state = Darkroom().capture(harness.model._backend) + + assert state.unit_status == WaitingStatus("hol' up") + assert state.app_status == BlockedStatus("foo") + + +@pytest.mark.parametrize( + "ports", + ( + [ + ops.Port("tcp", 2032), + ops.Port("udp", 2033), + ], + [ + ops.Port("tcp", 2032), + ops.Port("tcp", 2035), + ops.Port("icmp", None), + ], + ), +) +def test_opened_ports(harness, ports): + harness.begin() + harness.charm.unit.set_ports(*ports) + state = Darkroom().capture(harness.model._backend) + assert set(state.opened_ports) == set( + scenario.Port(port.protocol, port.port) for port in ports + ) + + +# todo add tests for all other State components diff --git a/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py b/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py new file mode 100644 index 000000000..1e1798627 --- /dev/null +++ b/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py @@ -0,0 +1,20 @@ +import yaml +from ops import CharmBase +from ops.testing import Harness + +from scenario.integrations.darkroom import Darkroom + + +class MyCharm(CharmBase): + META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} + + +def test_attach(): + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + l = [] + d = Darkroom().attach(lambda e, s: l.append((e, s))) + h.begin() + h.add_relation("foo", "remote") + + assert len(l) == 1 + assert l[0][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_live_integration/test_install_scenario.py b/tests/test_darkroom/test_live_integration/test_install_scenario.py new file mode 100644 index 000000000..950ab5e44 --- /dev/null +++ b/tests/test_darkroom/test_live_integration/test_install_scenario.py @@ -0,0 +1,33 @@ +def test_install(): + from scenario.integrations.darkroom import Darkroom + + l = [] + + def register_trace(t): + l.append(t) + + Darkroom.install(register_trace) + + import yaml + from ops import CharmBase + from ops.testing import Harness + + class MyCharm(CharmBase): + META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + h.add_relation("foo", "remote") + + h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + h.begin_with_initial_hooks() + h.add_relation("foo", "remote2") + + assert len(l) == 3 + assert [len(x) for x in l] == [4, 5, 5] + assert l[0][1][0].name == "leader_settings_changed" + assert l[1][-1][0].name == "foo_relation_created" + assert l[2][-1][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_live_integration/test_integrations_scenario.py b/tests/test_darkroom/test_live_integration/test_integrations_scenario.py new file mode 100644 index 000000000..fd017f49f --- /dev/null +++ b/tests/test_darkroom/test_live_integration/test_integrations_scenario.py @@ -0,0 +1,78 @@ +import ops +import pytest +import yaml +from ops import CharmBase, BlockedStatus, WaitingStatus +from ops.testing import Harness + +import scenario +from scenario import Model +from scenario.integrations.darkroom import Darkroom + + +class MyCharm(CharmBase): + META = {"name": "joseph"} + + +@pytest.fixture +def harness(): + return Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) + + +def test_base(harness): + harness.begin() + state = Darkroom().capture(harness.model._backend) + assert state.unit_id == 0 + + +@pytest.mark.parametrize("leader", (True, False)) +@pytest.mark.parametrize("model_name", ("foo", "bar-baz")) +@pytest.mark.parametrize("model_uuid", ("qux", "fiz")) +def test_static_attributes(harness, leader, model_name, model_uuid): + harness.set_model_info(model_name, model_uuid) + harness.begin() + harness.charm.unit.set_workload_version("42.42") + harness.set_leader(leader) + + state = Darkroom().capture(harness.model._backend) + + assert state.leader is leader + assert state.model == Model(name=model_name, uuid=model_uuid, type="lxd") + assert state.workload_version == "42.42" + + +def test_status(harness): + harness.begin() + harness.set_leader(True) # so we can set app status + harness.charm.app.status = BlockedStatus("foo") + harness.charm.unit.status = WaitingStatus("hol' up") + + state = Darkroom().capture(harness.model._backend) + + assert state.unit_status == WaitingStatus("hol' up") + assert state.app_status == BlockedStatus("foo") + + +@pytest.mark.parametrize( + "ports", + ( + [ + ops.Port("tcp", 2032), + ops.Port("udp", 2033), + ], + [ + ops.Port("tcp", 2032), + ops.Port("tcp", 2035), + ops.Port("icmp", None), + ], + ), +) +def test_opened_ports(harness, ports): + harness.begin() + harness.charm.unit.set_ports(*ports) + state = Darkroom().capture(harness.model._backend) + assert set(state.opened_ports) == set( + scenario.Port(port.protocol, port.port) for port in ports + ) + + +# todo add tests for all other State components diff --git a/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py b/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py new file mode 100644 index 000000000..e837ea185 --- /dev/null +++ b/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py @@ -0,0 +1,2 @@ +def test_live_charm(): + pass diff --git a/tests/test_integrations/test_integrations.py b/tests/test_integrations/test_integrations.py deleted file mode 100644 index 618896fed..000000000 --- a/tests/test_integrations/test_integrations.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest -from ops import CharmBase -from ops.testing import Harness - - -class MyCharm(CharmBase): - META = {"name": "joseph"} - - -@pytest.fixture -def harness(): - return Harness(MyCharm, meta=MyCharm.META) - - -def test_base(harness): - harness.begin() From 119168335c883acd8db53e7419634452362fd609 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Nov 2023 11:53:19 +0100 Subject: [PATCH 355/546] removed scripts --- README.md | 23 +- pyproject.toml | 2 - scenario/integrations/darkroom.py | 406 ------- scenario/scripts/errors.py | 17 - scenario/scripts/logger.py | 17 - scenario/scripts/main.py | 57 - scenario/scripts/snapshot.py | 1001 ----------------- scenario/scripts/state_apply.py | 256 ----- scenario/scripts/utils.py | 23 - .../test_darkroom_harness.py | 20 - .../test_install_harness.py | 33 - .../test_integrations_harness.py | 78 -- .../test_darkroom_scenario.py | 20 - .../test_install_scenario.py | 33 - .../test_integrations_scenario.py | 78 -- .../test_darkroom_live.py | 2 - 16 files changed, 2 insertions(+), 2064 deletions(-) delete mode 100644 scenario/integrations/darkroom.py delete mode 100644 scenario/scripts/errors.py delete mode 100644 scenario/scripts/logger.py delete mode 100644 scenario/scripts/main.py delete mode 100644 scenario/scripts/snapshot.py delete mode 100644 scenario/scripts/state_apply.py delete mode 100644 scenario/scripts/utils.py delete mode 100644 tests/test_darkroom/test_harness_integration/test_darkroom_harness.py delete mode 100644 tests/test_darkroom/test_harness_integration/test_install_harness.py delete mode 100644 tests/test_darkroom/test_harness_integration/test_integrations_harness.py delete mode 100644 tests/test_darkroom/test_live_integration/test_darkroom_scenario.py delete mode 100644 tests/test_darkroom/test_live_integration/test_install_scenario.py delete mode 100644 tests/test_darkroom/test_live_integration/test_integrations_scenario.py delete mode 100644 tests/test_darkroom/test_scenario_integration/test_darkroom_live.py diff --git a/README.md b/README.md index 674d038f7..6c08886af 100644 --- a/README.md +++ b/README.md @@ -1256,25 +1256,6 @@ If you have a clear false negative, are explicitly testing 'edge', inconsistent checker is in your way, you can set the `SCENARIO_SKIP_CONSISTENCY_CHECKS` envvar and skip it altogether. Hopefully you don't need that. -# Snapshot +# Jhack integrations -Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to -reach the entry point by typing `scenario snapshot` in a shell so long as the install dir is in your `PATH`. - -Snapshot's purpose is to gather the `State` data structure from a real, live charm running in some cloud your local juju -client has access to. This is handy in case: - -- you want to write a test about the state the charm you're developing is currently in -- your charm is bork or in some inconsistent state, and you want to write a test to check the charm will handle it - correctly the next time around (aka regression testing) -- you are new to Scenario and want to quickly get started with a real-life example. - -Suppose you have a Juju model with a `prometheus-k8s` unit deployed as `prometheus-k8s/0`. If you type -`scenario snapshot prometheus-k8s/0`, you will get a printout of the State object. Pipe that out into some file, import -all you need from `scenario`, and you have a working `State` that you can `Context.run` events with. - -You can also pass a `--format` flag to obtain instead: - -- a jsonified `State` data structure, for portability -- a full-fledged pytest test case (with imports and all), where you only have to fill in the charm type and the event - that you wish to trigger. +The [`Jhack scenario`](todo link to jhack) subcommand offers some utilities to work with Scenario. diff --git a/pyproject.toml b/pyproject.toml index b728b1285..f548183e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,6 @@ classifiers = [ "Homepage" = "https://github.com/canonical/ops-scenario" "Bug Tracker" = "https://github.com/canonical/ops-scenario/issues" -[project.scripts] -scenario = "scenario.scripts.main:main" [tool.setuptools.package-dir] scenario = "scenario" diff --git a/scenario/integrations/darkroom.py b/scenario/integrations/darkroom.py deleted file mode 100644 index 6481de438..000000000 --- a/scenario/integrations/darkroom.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Darkroom.""" - - -import logging -import os -from typing import TYPE_CHECKING, Callable, List, Literal, Sequence, Tuple, Union - -import yaml -from ops import CharmBase, EventBase -from ops.model import ModelError, SecretRotate, StatusBase, _ModelBackend -from ops.testing import _TestingModelBackend - -from scenario import Container, Event, Model, Network, Port, Relation, Secret, State -from scenario.mocking import _MockModelBackend -from scenario.state import _CharmSpec - -if TYPE_CHECKING: - from ops import Framework - -_Trace = Sequence[Tuple[Event, State]] -_SupportedBackends = Union[_TestingModelBackend, _ModelBackend, _MockModelBackend] - -logger = logging.getLogger("darkroom") - -# todo move those to Scenario.State and add an Event._is_framework_event() method. -FRAMEWORK_EVENT_NAMES = {"pre_commit", "commit"} - - -class _Unknown: - def __repr__(self): - return "" - - -UNKNOWN = _Unknown() -# Singleton representing missing information that cannot be retrieved, -# because of e.g. lack of leadership. -del _Unknown - - -class Darkroom: - """Darkroom. - - Can be used to "capture" the current State of a charm, given its backend - (testing, mocked, or live). - - Designed to work with multiple backends. - - "Live model backend": live charm with real juju and pebble backends - - "Harness testing backend": simulated backend provided by ops.testing. - - "Scenario backend": simulated backend provided by ops.scenario - - Usage:: - >>> harness = Harness(MyCharm) - >>> harness.begin_with_initial_hooks() - >>> state: State = Darkroom().capture(harness.model._backend) - - - Can be "attached" to a testing harness or scenario.Context to automatically capture - state and triggering event whenever an event is emitted. Result is a "Trace", i.e. a sequence - of events (and custom events), if the charm emits any. - - Can be "installed" in a testing suite or live charm. This will autoattach it to the - current context. - - >>> l = [] - >>> def register_trace(t): # this will be called with each generated trace - >>> l.append(t) - >>> Darkroom.install(register_trace) - >>> harness = Harness(MyCharm) - >>> h.begin_with_initial_hooks() - >>> assert l[0][0][0].name == "leader_settings_changed" - >>> assert l[0][0][1].unit_status == ActiveStatus("foo") - >>> # now that Darkroom is installed, regardless of the testing backend we use to emit - >>> # events, they will be captured - >>> scenario.Context(MyCharm).run("start") - >>> assert l[1][0][0].name == "start" - >>> assert l[1][0][1].unit_status == WaitingStatus("bar") - - Usage in live charms: - Edit charm.py to read: - >>> if __name__ == '__main__': - >>> from darkroom import Darkroom - >>> Darkroom.install(print, live=True) - >>> ops.main(MyCharm) - """ - - def __init__( - self, - skip_framework_events: bool = True, - skip_custom_events: bool = False, - ): - self._skip_framework_events = skip_framework_events - self._skip_custom_events = skip_custom_events - - def _listen_to(self, event: Event, framework: "Framework") -> bool: - """Whether this event should be captured or not. - - Depends on the init-provided skip config. - """ - if self._skip_framework_events and event.name in FRAMEWORK_EVENT_NAMES: - return False - if not self._skip_custom_events: - return True - - # derive the charmspec from the framework. - # Framework contains pointers to all observers. - # attempt to autoload: - try: - charm_type = next( - filter(lambda o: isinstance(o, CharmBase), framework._objects.values()), - ) - except StopIteration as e: - raise RuntimeError("unable to find charm in framework objects") from e - - try: - charm_root = framework.charm_dir - meta = charm_root / "metadata.yaml" - if not meta.exists(): - raise RuntimeError("metadata.yaml not found") - actions = charm_root / "actions.yaml" - config = charm_root / "config.yaml" - charm_spec = _CharmSpec( - charm_type, - meta=yaml.safe_load(meta.read_text()), - actions=yaml.safe_load(actions.read_text()) - if actions.exists() - else None, - config=yaml.safe_load(config.read_text()) if config.exists() else None, - ) - except Exception as e: - # todo: fall back to generating from framework._meta - raise RuntimeError("cannot autoload charm spec") from e - - if not event._is_builtin_event(charm_spec): - return False - - return True - - @staticmethod - def _get_mode( - backend: _SupportedBackends, - ) -> Literal["harness", "scenario", "live"]: - if isinstance(backend, _TestingModelBackend): - return "harness" - elif isinstance(backend, _MockModelBackend): - return "scenario" - elif isinstance(backend, _TestingModelBackend): - return "live" - else: - raise TypeError(backend) - - def capture(self, backend: _SupportedBackends) -> State: - mode = self._get_mode(backend) - logger.info(f"capturing in mode = `{mode}`.") - - if isinstance(backend, _MockModelBackend): - return backend._state - - state = State( - config=dict(backend.config_get()), - relations=self._get_relations(backend), - containers=self._get_containers(backend), - networks=self._get_networks(backend), - secrets=self._get_secrets(backend), - opened_ports=self._get_opened_ports(backend), - leader=self._get_leader(backend), - unit_id=self._get_unit_id(backend), - app_status=self._get_app_status(backend), - unit_status=self._get_unit_status(backend), - workload_version=self._get_workload_version(backend), - model=self._get_model(backend), - ) - - return state - - @staticmethod - def _get_unit_id(backend: _SupportedBackends) -> int: - return int(backend.unit_name.split("/")[1]) - - @staticmethod - def _get_workload_version(backend: _SupportedBackends) -> int: - # only available in testing: a live charm can't get its own workload version. - return getattr(backend, "_workload_version", UNKNOWN) - - @staticmethod - def _get_unit_status(backend: _SupportedBackends) -> StatusBase: - raw = backend.status_get() - return StatusBase.from_name(message=raw["message"], name=raw["status"]) - - @staticmethod - def _get_app_status(backend: _SupportedBackends) -> StatusBase: - try: - raw = backend.status_get(is_app=True) - return StatusBase.from_name(message=raw["message"], name=raw["status"]) - except ModelError: - return UNKNOWN - - @staticmethod - def _get_model(backend: _SupportedBackends) -> Model: - if backend._meta.containers: - # if we have containers we're definitely k8s. - model_type = "kubernetes" - else: - # guess k8s|lxd from envvars - model_type = "kubernetes" if "KUBERNETES" in os.environ else "lxd" - return Model(name=backend.model_name, uuid=backend.model_uuid, type=model_type) - - @staticmethod - def _get_leader(backend: _SupportedBackends): - return backend.is_leader() - - @staticmethod - def _get_opened_ports(backend: _SupportedBackends) -> List[Port]: - return [Port(p.protocol, p.port) for p in backend.opened_ports()] - - def _get_relations(self, backend: _SupportedBackends) -> List[Relation]: - relations = [] - - local_unit_name = backend.unit_name - local_app_name = backend.unit_name.split("/")[0] - - for endpoint, ids in backend._relation_ids_map.items(): - for r_id in ids: - relations.append( - self._get_relation( - backend, - r_id, - endpoint, - local_app_name, - local_unit_name, - ), - ) - - return relations - - def _get_relation( - self, - backend: _SupportedBackends, - r_id: int, - endpoint: str, - local_app_name: str, - local_unit_name: str, - ): - def get_interface_name(endpoint: str): - return backend._meta.relations[endpoint].interface_name - - def try_get(databag, owner): - try: - return databag[owner] - except ModelError: - return UNKNOWN - - # todo switch between peer and sub - rel_data = backend._relation_data_raw[r_id] - - app_and_units = backend._relation_app_and_units[r_id] - remote_app_name = app_and_units["app"] - return Relation( - endpoint=endpoint, - interface=get_interface_name(endpoint), - relation_id=r_id, - local_app_data=try_get(rel_data, local_app_name), - local_unit_data=try_get(rel_data, local_unit_name), - remote_app_data=try_get(rel_data, remote_app_name), - remote_units_data={ - int(remote_unit_id.split("/")[1]): try_get(rel_data, remote_unit_id) - for remote_unit_id in app_and_units["units"] - }, - remote_app_name=remote_app_name, - ) - - def _get_containers(self, backend: _SupportedBackends) -> List[Container]: - containers = [] - mode = self._get_mode(backend) - - for name, c in backend._meta.containers.items(): - if mode == "live": - # todo: real pebble socket address - pebble = backend.get_pebble("") - else: - # testing backends get the 3rd elem: - path = ["a", "b", "c", name, "bar.socket"] - pebble = backend.get_pebble("/".join(path)) - assert pebble - # todo: complete container snapshot - containers.append(Container(name=name, mounts=c.mounts)) - return containers - - def _get_networks(self, backend: _SupportedBackends) -> List[Network]: - networks = [ - Network(name=nw_name, **nw) for nw_name, nw in backend._networks.items() - ] - return networks - - def _get_secrets(self, backend: _SupportedBackends) -> List[Secret]: - secrets = [] - for s in backend._secrets: - owner_app = s.owner_name.split("/")[0] - relation_id = backend._relation_id_to(owner_app) - grants = s.grants.get(relation_id, set()) - - remote_grants = set() - granted = False - for grant in grants: - if grant in (backend.unit_name, backend.app_name): - granted = grant - else: - remote_grants.add(grant) - - secrets.append( - Secret( - id=s.id, - label=s.label, - contents=backend.secret_get(s.id), - granted=granted, - remote_grants={relation_id: remote_grants}, - description=s.description, - owner=s.owner_name, - rotate=s.rotate_policy or SecretRotate.NEVER, - expire=s.expire_time, - ), - ) - return secrets - - def _get_event(self, event: EventBase) -> Event: - return Event(event.handle.kind) - - def attach(self, listener: Callable[[Event, State], None]): - """Every time an event is emitted, record the event and capture the state after execution.""" - from ops import Framework - - if not getattr(Framework, "__orig_emit__", None): - Framework.__orig_emit__ = Framework._emit # noqa - # do not simply use Framework._emit because if we apply this patch multiple times - # the previous listeners will keep being called. - - def _darkroom_emit(instance: Framework, ops_event): - # proceed with framework._emit() - Framework.__orig_emit__(instance, ops_event) - event: Event = self._get_event(ops_event) - - if not self._listen_to(event, instance): - logger.debug(f"skipping event {ops_event}") - return - - backend = instance.model._backend # noqa - # todo should we automagically event.bind(state)? - state = self.capture(backend) - listener(event, state) - - Framework._emit = _darkroom_emit - - @staticmethod - def install(listener: Callable[[_Trace], None], live: bool = False): - """Patch Harness so that every time a new instance is created, a Darkroom is attached to it. - - Note that the trace will be initially empty and will be filled up as the harness emits events. - So only access the traces when you're sure the harness is done emitting. - """ - Darkroom._install_on_harness(listener) - Darkroom._install_on_scenario(listener) - - if live: - # if we are in a live event context, we attach and register a single trace - trace = [] - listener(trace) - - # we don't do this automatically, but instead do it on an explicit live=True, - # because otherwise listener will be called with an empty trace at the - # beginning of every run. - Darkroom().attach(lambda e, s: trace.append((e, s))) - - @staticmethod - def _install_on_scenario(listener: Callable[[_Trace], None]): - from scenario import Context - - if not getattr(Context, "__orig_init__", None): - Context.__orig__init__ = Context.__init__ - # do not simply use Context.__init__ because - # if we instantiate multiple Contexts we'll keep adding to the older harnesses' traces. - - def patch(context: Context, *args, **kwargs): - trace = [] - listener(trace) - Context.__orig__init__(context, *args, **kwargs) - dr = Darkroom() - dr.attach(listener=lambda event, state: trace.append((event, state))) - - Context.__init__ = patch - - @staticmethod - def _install_on_harness(listener: Callable[[_Trace], None]): - from ops.testing import Harness - - if not getattr(Harness, "__orig_init__", None): - Harness.__orig_init__ = Harness.__init__ - # do not simply use Harness.__init__ because - # if we instantiate multiple harnesses we'll keep adding to the older harnesses' traces. - - def patch(harness: Harness, *args, **kwargs): - trace = [] - listener(trace) - Harness.__orig_init__(harness, *args, **kwargs) - dr = Darkroom() - dr.attach(listener=lambda event, state: trace.append((event, state))) - - Harness.__init__ = patch diff --git a/scenario/scripts/errors.py b/scenario/scripts/errors.py deleted file mode 100644 index f713ef601..000000000 --- a/scenario/scripts/errors.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -class SnapshotError(RuntimeError): - """Base class for errors raised by snapshot.""" - - -class InvalidTargetUnitName(SnapshotError): - """Raised if the unit name passed to snapshot is invalid.""" - - -class InvalidTargetModelName(SnapshotError): - """Raised if the model name passed to snapshot is invalid.""" - - -class StateApplyError(SnapshotError): - """Raised when the state-apply juju command fails.""" diff --git a/scenario/scripts/logger.py b/scenario/scripts/logger.py deleted file mode 100644 index 98cadfb1f..000000000 --- a/scenario/scripts/logger.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import logging -import os - -logger = logging.getLogger(__file__) - - -def setup_logging(verbosity: int): - base_loglevel = int(os.getenv("LOGLEVEL", 30)) - verbosity = min(verbosity, 2) - loglevel = base_loglevel - (verbosity * 10) - logging.basicConfig(format="%(message)s") - logging.getLogger().setLevel(logging.WARNING) - logger.setLevel(loglevel) diff --git a/scenario/scripts/main.py b/scenario/scripts/main.py deleted file mode 100644 index ebee6084a..000000000 --- a/scenario/scripts/main.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -from importlib import metadata -from importlib.metadata import PackageNotFoundError -from pathlib import Path - -import typer - -from scenario.scripts import logger -from scenario.scripts.snapshot import snapshot -from scenario.scripts.state_apply import state_apply - - -def _version(): - """Print the scenario version and exit.""" - try: - print(metadata.version("ops-scenario")) - return - except PackageNotFoundError: - pass - - pyproject_toml = Path(__file__).parent.parent.parent / "pyproject.toml" - - if not pyproject_toml.exists(): - print("") - return - - for line in pyproject_toml.read_text().split("\n"): - if line.startswith("version"): - print(line.split("=")[1].strip("\"' ")) - return - - -def main(): - app = typer.Typer( - name="scenario", - help="Scenario utilities. " - "For docs, issues and feature requests, visit " - "the github repo --> https://github.com/canonical/ops-scenario", - no_args_is_help=True, - rich_markup_mode="markdown", - ) - - app.command(name="version")(_version) - app.command(name="snapshot", no_args_is_help=True)(snapshot) - app.command(name="state-apply", no_args_is_help=True)(state_apply) - - @app.callback() - def setup_logging(verbose: int = typer.Option(0, "-v", count=True)): - logger.setup_logging(verbose) - - app() - - -if __name__ == "__main__": - main() diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py deleted file mode 100644 index f2c678b61..000000000 --- a/scenario/scripts/snapshot.py +++ /dev/null @@ -1,1001 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import datetime -import json -import os -import re -import shlex -import sys -import tempfile -from dataclasses import asdict, dataclass -from enum import Enum -from itertools import chain -from pathlib import Path -from subprocess import run -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, TextIO, Tuple, Union - -import ops.pebble -import typer -import yaml -from ops.storage import SQLiteStorage - -from scenario.runtime import UnitStateDB -from scenario.scripts.errors import InvalidTargetModelName, InvalidTargetUnitName -from scenario.scripts.logger import logger as root_scripts_logger -from scenario.scripts.utils import JujuUnitName -from scenario.state import ( - Address, - BindAddress, - BindFailedError, - Container, - Event, - Model, - Mount, - Network, - Port, - Relation, - Secret, - State, - _EntityStatus, -) - -logger = root_scripts_logger.getChild(__file__) - -JUJU_RELATION_KEYS = frozenset({"egress-subnets", "ingress-address", "private-address"}) -JUJU_CONFIG_KEYS = frozenset({}) - -SNAPSHOT_OUTPUT_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() -CHARM_SUBCLASS_REGEX = re.compile(r"class (\D+)\(CharmBase\):") - - -def _try_format(string: str): - try: - import black - - try: - return black.format_str(string, mode=black.Mode()) - except black.parsing.InvalidInput as e: - logger.error(f"error parsing {string}: {e}") - return string - except ModuleNotFoundError: - logger.warning("install black for formatting") - return string - - -def format_state(state: State): - """Stringify this State as nicely as possible.""" - return _try_format(repr(state)) - - -PYTEST_TEST_TEMPLATE = """ -from scenario import * -from charm import {ct} - -def test_case(): - # Arrange: prepare the state - state = {state} - - #Act: trigger an event on the state - ctx = Context( - {ct}, - juju_version="{jv}") - - out = ctx.run( - {en} - state, - ) - - # Assert: verify that the output state is the way you want it to be - # TODO: add assertions -""" - - -def format_test_case( - state: State, - charm_type_name: str = None, - event_name: str = None, - juju_version: str = None, -): - """Format this State as a pytest test case.""" - ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name" - en = "EVENT_NAME, # TODO: replace with event name" - if event_name: - try: - en = Event(event_name).bind(state) - except BindFailedError: - logger.error( - f"Failed to bind {event_name} to {state}; leaving placeholder instead", - ) - - jv = juju_version or "3.0, # TODO: check juju version is correct" - state_fmt = repr(state) - return _try_format( - PYTEST_TEST_TEMPLATE.format(state=state_fmt, ct=ct, en=en, jv=jv), - ) - - -def _juju_run(cmd: str, model=None) -> Dict[str, Any]: - """Execute juju {command} in a given model.""" - _model = f" -m {model}" if model else "" - cmd = f"juju {cmd}{_model} --format json" - raw = run(shlex.split(cmd), capture_output=True, text=True).stdout - return json.loads(raw) - - -def _juju_ssh(target: JujuUnitName, cmd: str, model: Optional[str] = None) -> str: - _model = f" -m {model}" if model else "" - command = f"juju ssh{_model} {target.unit_name} {cmd}" - raw = run(shlex.split(command), capture_output=True, text=True).stdout - return raw - - -def _juju_exec(target: JujuUnitName, model: Optional[str], cmd: str) -> str: - """Execute a juju command. - - Notes: - Visit the Juju documentation to view all possible Juju commands: - https://juju.is/docs/olm/juju-cli-commands - """ - _model = f" -m {model}" if model else "" - _target = f" -u {target}" if target else "" - return run( - shlex.split(f"juju exec{_model}{_target} -- {cmd}"), - capture_output=True, - text=True, - ).stdout - - -def get_leader(target: JujuUnitName, model: Optional[str]): - # could also get it from _juju_run('status')... - logger.info("getting leader...") - return _juju_exec(target, model, "is-leader") == "True" - - -def get_network(target: JujuUnitName, model: Optional[str], endpoint: str) -> Network: - """Get the Network data structure for this endpoint.""" - raw = _juju_exec(target, model, f"network-get {endpoint}") - json_data = yaml.safe_load(raw) - - bind_addresses = [] - for raw_bind in json_data["bind-addresses"]: - addresses = [] - for raw_adds in raw_bind["addresses"]: - addresses.append( - Address( - hostname=raw_adds["hostname"], - value=raw_adds["value"], - cidr=raw_adds["cidr"], - address=raw_adds.get("address", ""), - ), - ) - - bind_addresses.append( - BindAddress( - interface_name=raw_bind.get("interface-name", ""), - addresses=addresses, - ), - ) - return Network( - name=endpoint, - bind_addresses=bind_addresses, - egress_subnets=json_data.get("egress-subnets", None), - ingress_addresses=json_data.get("ingress-addresses", None), - ) - - -def get_secrets( - target: JujuUnitName, # noqa: U100 - model: Optional[str], # noqa: U100 - metadata: Dict, # noqa: U100 - relations: Tuple[str, ...] = (), # noqa: U100 -) -> List[Secret]: - """Get Secret list from the charm.""" - logger.warning("Secrets snapshotting not implemented yet. Also, are you *sure*?") - return [] - - -def get_networks( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_dead: bool = False, - relations: Tuple[str, ...] = (), -) -> List[Network]: - """Get all Networks from this unit.""" - logger.info("getting networks...") - networks = [get_network(target, model, "juju-info")] - - endpoints = relations # only alive relations - if include_dead: - endpoints = chain( - metadata.get("provides", ()), - metadata.get("requires", ()), - metadata.get("peers", ()), - ) - - for endpoint in endpoints: - logger.debug(f" getting network for endpoint {endpoint!r}") - networks.append(get_network(target, model, endpoint)) - return networks - - -def get_metadata(target: JujuUnitName, model: Model): - """Get metadata.yaml from this target.""" - logger.info("fetching metadata...") - - meta_path = target.remote_charm_root / "metadata.yaml" - - raw_meta = _juju_ssh( - target, - f"cat {meta_path}", - model=model.name, - ) - return yaml.safe_load(raw_meta) - - -class RemotePebbleClient: - """Clever little class that wraps calls to a remote pebble client.""" - - def __init__( - self, - container: str, - target: JujuUnitName, - model: Optional[str] = None, - ): - self.socket_path = f"/charm/containers/{container}/pebble.socket" - self.container = container - self.target = target - self.model = model - - def _run(self, cmd: str) -> str: - _model = f" -m {self.model}" if self.model else "" - command = ( - f"juju ssh{_model} --container {self.container} {self.target.unit_name} " - f"/charm/bin/pebble {cmd}" - ) - proc = run(shlex.split(command), capture_output=True, text=True) - if proc.returncode == 0: - return proc.stdout - raise RuntimeError( - f"error wrapping pebble call with {command}: " - f"process exited with {proc.returncode}; " - f"stdout = {proc.stdout}; " - f"stderr = {proc.stderr}", - ) - - def can_connect(self) -> bool: - try: - version = self.get_system_info() - except Exception: - return False - return bool(version) - - def get_system_info(self): - return self._run("version") - - def get_plan(self) -> dict: - plan_raw = self._run("plan") - return yaml.safe_load(plan_raw) - - def pull( - self, - path: str, # noqa: U100 - *, - encoding: Optional[str] = "utf-8", # noqa: U100 - ) -> Union[BinaryIO, TextIO]: - raise NotImplementedError() - - def list_files( - self, - path: str, # noqa: U100 - *, - pattern: Optional[str] = None, # noqa: U100 - itself: bool = False, # noqa: U100 - ) -> List[ops.pebble.FileInfo]: - raise NotImplementedError() - - def get_checks( - self, - level: Optional[ops.pebble.CheckLevel] = None, - names: Optional[Iterable[str]] = None, - ) -> List[ops.pebble.CheckInfo]: - _level = f" --level={level}" if level else "" - _names = (" " + " ".join(names)) if names else "" - out = self._run(f"checks{_level}{_names}") - if out == "Plan has no health checks.": - return [] - raise NotImplementedError() - - -def fetch_file( - target: JujuUnitName, - remote_path: Union[Path, str], - container_name: str, - local_path: Union[Path, str], - model: Optional[str] = None, -) -> None: - """Download a file from a live unit to a local path.""" - model_arg = f" -m {model}" if model else "" - scp_cmd = ( - f"juju scp --container {container_name}{model_arg} " - f"{target.unit_name}:{remote_path} {local_path}" - ) - run(shlex.split(scp_cmd)) - - -def get_mounts( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, -) -> Dict[str, Mount]: - """Get named Mounts from a container's metadata, and download specified files from the unit.""" - mount_meta = container_meta.get("mounts") - - if fetch_files and not mount_meta: - logger.error( - f"No mounts defined for container {container_name} in metadata.yaml. " - f"Cannot fetch files {fetch_files} for this container.", - ) - return {} - - mount_spec = {} - for mt in mount_meta or (): - if name := mt.get("storage"): - mount_spec[name] = mt["location"] - else: - logger.error(f"unknown mount type: {mt}") - - mounts = {} - for remote_path in fetch_files or (): - found = None - for mn, mt in mount_spec.items(): - if str(remote_path).startswith(mt): - found = mn, mt - - if not found: - logger.error( - "could not find mount corresponding to requested remote_path " - f"{remote_path}: skipping...", - ) - continue - - mount_name, src = found - mount = mounts.get(mount_name) - if not mount: - # create the mount obj and tempdir - location = tempfile.TemporaryDirectory(prefix=str(temp_dir_base_path)).name - mount = Mount(src=src, location=location) - mounts[mount_name] = mount - - # populate the local tempdir - filepath = Path(mount.location).joinpath(*remote_path.parts[1:]) - os.makedirs(os.path.dirname(filepath), exist_ok=True) - try: - fetch_file( - target, - container_name=container_name, - model=model, - remote_path=remote_path, - local_path=filepath, - ) - - except RuntimeError as e: - logger.error(e) - - return mounts - - -def get_container( - target: JujuUnitName, - model: Optional[str], - container_name: str, - container_meta: Dict, - fetch_files: Optional[List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, -) -> Container: - """Get container data structure from the target.""" - remote_client = RemotePebbleClient(container_name, target, model) - plan = remote_client.get_plan() - - return Container( - name=container_name, - _base_plan=plan, - can_connect=remote_client.can_connect(), - mounts=get_mounts( - target, - model, - container_name, - container_meta, - fetch_files, - temp_dir_base_path=temp_dir_base_path, - ), - ) - - -def get_containers( - target: JujuUnitName, - model: Optional[str], - metadata: Optional[Dict], - fetch_files: Dict[str, List[Path]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, -) -> List[Container]: - """Get all containers from this unit.""" - fetch_files = fetch_files or {} - logger.info("getting containers...") - - if not metadata: - logger.warning("no metadata: unable to get containers") - return [] - - containers = [] - for container_name, container_meta in metadata.get("containers", {}).items(): - container = get_container( - target, - model, - container_name, - container_meta, - fetch_files=fetch_files.get(container_name), - temp_dir_base_path=temp_dir_base_path, - ) - containers.append(container) - return containers - - -def get_juju_status(model: Optional[str]) -> Dict: - """Return juju status as json.""" - logger.info("getting status...") - return _juju_run("status --relations", model=model) - - -@dataclass -class Status: - app: _EntityStatus - unit: _EntityStatus - workload_version: str - - -def get_status(juju_status: Dict, target: JujuUnitName) -> Status: - """Parse `juju status` to get the Status data structure and some relation information.""" - app = juju_status["applications"][target.app_name] - - app_status_raw = app["application-status"] - app_status = app_status_raw["current"], app_status_raw.get("message", "") - - unit_status_raw = app["units"][target]["workload-status"] - unit_status = unit_status_raw["current"], unit_status_raw.get("message", "") - - workload_version = app.get("version", "") - return Status( - app=_EntityStatus(*app_status), - unit=_EntityStatus(*unit_status), - workload_version=workload_version, - ) - - -def get_endpoints(juju_status: Dict, target: JujuUnitName) -> Tuple[str, ...]: - """Parse `juju status` to get the relation names owned by the target.""" - app = juju_status["applications"][target.app_name] - relations_raw = app.get("relations", None) - if not relations_raw: - return () - relations = tuple(relations_raw.keys()) - return relations - - -def get_opened_ports( - target: JujuUnitName, - model: Optional[str], -) -> List[Port]: - """Get opened ports list from target.""" - logger.info("getting opened ports...") - - opened_ports_raw = _juju_exec( - target, - model, - "opened-ports --format json", - ) - ports = [] - - for raw_port in json.loads(opened_ports_raw): - _port_n, _proto = raw_port.split("/") - ports.append(Port(_proto, int(_port_n))) - - return ports - - -def get_config( - target: JujuUnitName, - model: Optional[str], -) -> Dict[str, Union[str, int, float, bool]]: - """Get config dict from target.""" - - logger.info("getting config...") - json_data = _juju_run(f"config {target.app_name}", model=model) - - # dispatch table for builtin config options - converters = { - "string": str, - "int": int, - "integer": int, # fixme: which one is it? - "number": float, - "boolean": lambda x: x == "true", - "attrs": lambda x: x, # fixme: wot? - } - - cfg = {} - for name, option in json_data.get("settings", ()).items(): - if value := option.get("value"): - try: - converter = converters[option["type"]] - except KeyError: - raise ValueError(f'unrecognized type {option["type"]}') - cfg[name] = converter(value) - - else: - logger.debug(f"skipped {name}: no value.") - - return cfg - - -def _get_interface_from_metadata(endpoint: str, metadata: Dict) -> Optional[str]: - """Get the name of the interface used by endpoint.""" - for role in ["provides", "requires"]: - for ep, ep_meta in metadata.get(role, {}).items(): - if ep == endpoint: - return ep_meta["interface"] - - logger.error(f"No interface for endpoint {endpoint} found in charm metadata.") - return None - - -def get_relations( - target: JujuUnitName, - model: Optional[str], - metadata: Dict, - include_juju_relation_data=False, -) -> List[Relation]: - """Get the list of relations active for this target.""" - logger.info("getting relations...") - - try: - json_data = _juju_run(f"show-unit {target}", model=model) - except json.JSONDecodeError as e: - raise InvalidTargetUnitName(target) from e - - def _clean(relation_data: dict): - if include_juju_relation_data: - return relation_data - else: - for key in JUJU_RELATION_KEYS: - del relation_data[key] - return relation_data - - relations = [] - for raw_relation in json_data[target].get("relation-info", ()): - logger.debug( - f" getting relation data for endpoint {raw_relation.get('endpoint')!r}", - ) - related_units = raw_relation.get("related-units") - if not related_units: - continue - # related-units: - # owner/0: - # in-scope: true - # data: - # egress-subnets: 10.152.183.130/32 - # ingress-address: 10.152.183.130 - # private-address: 10.152.183.130 - - relation_id = raw_relation["relation-id"] - - local_unit_data_raw = _juju_exec( - target, - model, - f"relation-get -r {relation_id} - {target} --format json", - ) - local_unit_data = json.loads(local_unit_data_raw) - local_app_data_raw = _juju_exec( - target, - model, - f"relation-get -r {relation_id} - {target} --format json --app", - ) - local_app_data = json.loads(local_app_data_raw) - - some_remote_unit_id = JujuUnitName(next(iter(related_units))) - - # fixme: at the moment the juju CLI offers no way to see what type of relation this is; - # if it's a peer relation or a subordinate, we should use the corresponding - # scenario.state types instead of a regular Relation. - - relations.append( - Relation( - endpoint=raw_relation["endpoint"], - interface=_get_interface_from_metadata( - raw_relation["endpoint"], - metadata, - ), - relation_id=relation_id, - remote_app_data=raw_relation["application-data"], - remote_app_name=some_remote_unit_id.app_name, - remote_units_data={ - JujuUnitName(tgt).unit_id: _clean(val["data"]) - for tgt, val in related_units.items() - }, - local_app_data=local_app_data, - local_unit_data=_clean(local_unit_data), - ), - ) - return relations - - -def get_model(name: str = None) -> Model: - """Get the Model data structure.""" - logger.info("getting model...") - - json_data = _juju_run("models") - model_name = name or json_data["current-model"] - try: - model_info = next( - filter(lambda m: m["short-name"] == model_name, json_data["models"]), - ) - except StopIteration as e: - raise InvalidTargetModelName(name) from e - - model_uuid = model_info["model-uuid"] - model_type = model_info["type"] - - return Model(name=model_name, uuid=model_uuid, type=model_type) - - -def try_guess_charm_type_name() -> Optional[str]: - """If we are running this from a charm project root, get the charm type name from charm.py.""" - try: - charm_path = Path(os.getcwd()) / "src" / "charm.py" - if charm_path.exists(): - source = charm_path.read_text() - charms = CHARM_SUBCLASS_REGEX.findall(source) - if len(charms) < 1: - raise RuntimeError(f"Not enough charms at {charm_path}.") - elif len(charms) > 1: - raise RuntimeError(f"Too many charms at {charm_path}.") - return charms[0] - except Exception as e: - logger.warning(f"unable to guess charm type: {e}") - return None - - -class FormatOption( - str, - Enum, -): # Enum for typer support, str for native comparison and ==. - """Output formatting options for snapshot.""" - - state = "state" # the default: will print the python repr of the State dataclass. - json = "json" - pytest = "pytest" - - -def get_juju_version(juju_status: Dict) -> str: - """Get juju agent version from juju status output.""" - return juju_status["model"]["version"] - - -def get_charm_version(target: JujuUnitName, juju_status: Dict) -> str: - """Get charm version info from juju status output.""" - app_info = juju_status["applications"][target.app_name] - channel = app_info.get("charm-channel", "") - charm_name = app_info.get("charm-name", "n/a") - workload_version = app_info.get("version", "n/a") - charm_rev = app_info.get("charm-rev", "n/a") - charm_origin = app_info.get("charm-origin", "n/a") - return ( - f"charm {charm_name!r} ({channel}/{charm_rev}); " - f"origin := {charm_origin}; app version := {workload_version}." - ) - - -class RemoteUnitStateDB(UnitStateDB): - """Represents a remote unit's state db.""" - - def __init__(self, model: Optional[str], target: JujuUnitName): - self._tempfile = tempfile.NamedTemporaryFile() - super().__init__(self._tempfile.name) - - self._model = model - self._target = target - - def _fetch_state(self): - fetch_file( - self._target, - remote_path=self._target.remote_charm_root / ".unit-state.db", - container_name="charm", - local_path=self._state_file, - model=self._model, - ) - - @property - def _has_state(self): - """Whether the state file exists.""" - return self._state_file.exists() and self._state_file.read_bytes() - - def _open_db(self) -> SQLiteStorage: - if not self._has_state: - self._fetch_state() - return super()._open_db() - - -def _snapshot( - target: str, - model: Optional[str] = None, - pprint: bool = True, - include: Optional[str] = None, - include_juju_relation_data=False, - include_dead_relation_networks=False, - format: FormatOption = "state", - event_name: Optional[str] = None, - fetch_files: Optional[Dict[str, List[Path]]] = None, - temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR, -): - """see snapshot's docstring""" - - try: - target = JujuUnitName(target) - except InvalidTargetUnitName: - logger.critical( - f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.", - ) - sys.exit(1) - - logger.info(f'beginning snapshot of {target} in model {model or ""}...') - - def if_include(key, fn, default): - if include is None or key in include: - return fn() - return default - - try: - state_model = get_model(model) - except InvalidTargetModelName: - logger.critical(f"unable to get Model from name {model}.", exc_info=True) - sys.exit(1) - - # todo: what about controller? - model = state_model.name - - metadata = get_metadata(target, state_model) - if not metadata: - logger.critical(f"could not fetch metadata from {target}.") - sys.exit(1) - - try: - unit_state_db = RemoteUnitStateDB(model, target) - juju_status = get_juju_status(model) - endpoints = get_endpoints(juju_status, target) - status = get_status(juju_status, target=target) - - state = State( - leader=get_leader(target, model), - unit_status=status.unit, - app_status=status.app, - workload_version=status.workload_version, - model=state_model, - config=if_include("c", lambda: get_config(target, model), {}), - opened_ports=if_include( - "p", - lambda: get_opened_ports(target, model), - [], - ), - relations=if_include( - "r", - lambda: get_relations( - target, - model, - metadata=metadata, - include_juju_relation_data=include_juju_relation_data, - ), - [], - ), - containers=if_include( - "k", - lambda: get_containers( - target, - model, - metadata, - fetch_files=fetch_files, - temp_dir_base_path=temp_dir_base_path, - ), - [], - ), - networks=if_include( - "n", - lambda: get_networks( - target, - model, - metadata, - include_dead=include_dead_relation_networks, - relations=endpoints, - ), - [], - ), - secrets=if_include( - "S", - lambda: get_secrets( - target, - model, - metadata, - relations=endpoints, - ), - [], - ), - deferred=if_include( - "d", - unit_state_db.get_deferred_events, - [], - ), - stored_state=if_include( - "t", - unit_state_db.get_stored_state, - [], - ), - ) - - # todo: these errors should surface earlier. - except InvalidTargetUnitName: - _model = f"model {model}" or "the current model" - logger.critical(f"invalid target: {target!r} not found in {_model}") - sys.exit(1) - except InvalidTargetModelName: - logger.critical(f"invalid model: {model!r} not found.") - sys.exit(1) - - logger.info("snapshot done.") - - if pprint: - charm_version = get_charm_version(target, juju_status) - juju_version = get_juju_version(juju_status) - if format == FormatOption.pytest: - charm_type_name = try_guess_charm_type_name() - txt = format_test_case( - state, - event_name=event_name, - charm_type_name=charm_type_name, - juju_version=juju_version, - ) - elif format == FormatOption.state: - txt = format_state(state) - elif format == FormatOption.json: - txt = json.dumps(asdict(state), indent=2) - else: - raise ValueError(f"unknown format {format}") - - # json does not support comments, so it would be invalid output. - if format != FormatOption.json: - # print out some metadata - controller_timestamp = juju_status["controller"]["timestamp"] - local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") - print( - f"# Generated by scenario.snapshot. \n" - f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" - f"# Controller timestamp := {controller_timestamp}. \n" - f"# Juju version := {juju_version} \n" - f"# Charm fingerprint := {charm_version} \n", - ) - - print(txt) - - return state - - -def snapshot( - target: str = typer.Argument(..., help="Target unit."), - model: Optional[str] = typer.Option( - None, - "-m", - "--model", - help="Which model to look at.", - ), - format: FormatOption = typer.Option( - "state", - "-f", - "--format", - help="How to format the output. " - "``state``: Outputs a black-formatted repr() of the State object (if black is installed! " - "else it will be ugly but valid python code). All you need to do then is import the " - "necessary objects from scenario.state, and you should have a valid State object. " - "``json``: Outputs a Jsonified State object. Perfect for storage. " - "``pytest``: Outputs a full-blown pytest scenario test based on this State. " - "Pipe it to a file and fill in the blanks.", - ), - event_name: str = typer.Option( - None, - "--event_name", - "-e", - help="Event to include in the generate test file; only applicable " - "if the output format is 'pytest'.", - ), - include: str = typer.Option( - "rckndtp", - "--include", - "-i", - help="What data to include in the state. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``n``: networks, ``S``: secrets(!), ``p``: opened ports, " - "``d``: deferred events, ``t``: stored state.", - ), - include_dead_relation_networks: bool = typer.Option( - False, - "--include-dead-relation-networks", - help="Whether to gather networks of inactive relation endpoints.", - is_flag=True, - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - fetch: Path = typer.Option( - None, - "--fetch", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be fetched from the existing containers.", - ), - # TODO: generalize "fetch" to allow passing '.' for the 'charm' container or 'the machine'. - output_dir: Path = typer.Option( - SNAPSHOT_OUTPUT_DIR, - "--output-dir", - help="Directory in which to store any files fetched as part of the state. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), -) -> State: - """Gather and output the State of a remote target unit. - - If black is available, the output will be piped through it for formatting. - - Usage: snapshot myapp/0 > ./tests/scenario/case1.py - """ - - fetch_files = json.loads(fetch.read_text()) if fetch else None - - return _snapshot( - target=target, - model=model, - format=format, - event_name=event_name, - include=include, - include_juju_relation_data=include_juju_relation_data, - include_dead_relation_networks=include_dead_relation_networks, - temp_dir_base_path=output_dir, - fetch_files=fetch_files, - ) - - -# for the benefit of script usage -_snapshot.__doc__ = snapshot.__doc__ - -if __name__ == "__main__": - # print(_snapshot("zookeeper/0", model="foo", format=FormatOption.pytest)) - - print( - _snapshot( - "traefik/0", - format=FormatOption.state, - include="r", - # fetch_files={ - # "traefik": [ - # Path("/opt/traefik/juju/certificates.yaml"), - # Path("/opt/traefik/juju/certificate.cert"), - # Path("/opt/traefik/juju/certificate.key"), - # Path("/etc/traefik/traefik.yaml"), - # ] - # }, - ), - ) diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py deleted file mode 100644 index f864b1414..000000000 --- a/scenario/scripts/state_apply.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import json -import logging -import os -import sys -from pathlib import Path -from subprocess import CalledProcessError, run -from typing import Dict, Iterable, List, Optional - -import typer - -from scenario.scripts.errors import InvalidTargetUnitName, StateApplyError -from scenario.scripts.utils import JujuUnitName -from scenario.state import ( - Container, - DeferredEvent, - Port, - Relation, - Secret, - State, - StoredState, - _EntityStatus, -) - -SNAPSHOT_DATA_DIR = (Path(os.getcwd()).parent / "snapshot_storage").absolute() - -logger = logging.getLogger("snapshot") - - -def set_relations(relations: Iterable[Relation]) -> List[str]: # noqa: U100 - logger.info("preparing relations...") - logger.warning("set_relations not implemented yet") - return [] - - -def set_status( - unit_status: _EntityStatus, - app_status: _EntityStatus, - app_version: str, -) -> List[str]: - logger.info("preparing status...") - cmds = [] - - cmds.append(f"status-set {unit_status.name} {unit_status.message}") - cmds.append(f"status-set --application {app_status.name} {app_status.message}") - cmds.append(f"application-version-set {app_version}") - - return cmds - - -def set_config(config: Dict[str, str]) -> List[str]: # noqa: U100 - logger.info("preparing config...") - logger.warning("set_config not implemented yet") - return [] - - -def set_opened_ports(opened_ports: List[Port]) -> List[str]: - logger.info("preparing opened ports...") - # fixme: this will only open new ports, it will not close all already-open ports. - - cmds = [] - - for port in opened_ports: - cmds.append(f"open-port {port.port}/{port.protocol}") - - return cmds - - -def set_containers(containers: Iterable[Container]) -> List[str]: # noqa: U100 - logger.info("preparing containers...") - logger.warning("set_containers not implemented yet") - return [] - - -def set_secrets(secrets: Iterable[Secret]) -> List[str]: # noqa: U100 - logger.info("preparing secrets...") - logger.warning("set_secrets not implemented yet") - return [] - - -def set_deferred_events( - deferred_events: Iterable[DeferredEvent], # noqa: U100 -) -> List[str]: - logger.info("preparing deferred_events...") - logger.warning("set_deferred_events not implemented yet") - return [] - - -def set_stored_state(stored_state: Iterable[StoredState]) -> List[str]: # noqa: U100 - logger.info("preparing stored_state...") - logger.warning("set_stored_state not implemented yet") - return [] - - -def exec_in_unit(target: JujuUnitName, model: str, cmds: List[str]): - logger.info("Running juju exec...") - - _model = f" -m {model}" if model else "" - cmd_fmt = "; ".join(cmds) - try: - run(f'juju exec -u {target}{_model} -- "{cmd_fmt}"') - except CalledProcessError as e: - raise StateApplyError( - f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.", - ) - - -def run_commands(cmds: List[str]): - logger.info("Applying remaining state...") - for cmd in cmds: - try: - run(cmd) - except CalledProcessError as e: - # todo: should we log and continue instead? - raise StateApplyError( - f"Failed to apply state: process exited with {e.returncode}; " - f"stdout = {e.stdout}; " - f"stderr = {e.stderr}.", - ) - - -def _state_apply( - target: str, - state: State, - model: Optional[str] = None, - include: str = None, - include_juju_relation_data=False, # noqa: U100 - push_files: Dict[str, List[Path]] = None, # noqa: U100 - snapshot_data_dir: Path = SNAPSHOT_DATA_DIR, # noqa: U100 -): - """see state_apply's docstring""" - logger.info("Starting state-apply...") - - try: - target = JujuUnitName(target) - except InvalidTargetUnitName: - logger.critical( - f"invalid target: {target!r} is not a valid unit name. Should be formatted like so:" - f"`foo/1`, or `database/0`, or `myapp-foo-bar/42`.", - ) - sys.exit(1) - - logger.info(f'beginning snapshot of {target} in model {model or ""}...') - - def if_include(key, fn): - if include is None or key in include: - return fn() - return [] - - j_exec_cmds: List[str] = [] - - j_exec_cmds += if_include( - "s", - lambda: set_status(state.unit_status, state.app_status, state.workload_version), - ) - j_exec_cmds += if_include("p", lambda: set_opened_ports(state.opened_ports)) - j_exec_cmds += if_include("r", lambda: set_relations(state.relations)) - j_exec_cmds += if_include("S", lambda: set_secrets(state.secrets)) - - cmds: List[str] = [] - - # todo: config is a bit special because it's not owned by the unit but by the cloud admin. - # should it be included in state-apply? - # if_include("c", lambda: set_config(state.config)) - cmds += if_include("k", lambda: set_containers(state.containers)) - cmds += if_include("d", lambda: set_deferred_events(state.deferred)) - cmds += if_include("t", lambda: set_stored_state(state.stored_state)) - - # we gather juju-exec commands to run them all at once in the unit. - exec_in_unit(target, model, j_exec_cmds) - # non-juju-exec commands are ran one by one, individually - run_commands(cmds) - - logger.info("Done!") - - -def state_apply( - target: str = typer.Argument(..., help="Target unit."), - state: Path = typer.Argument( - ..., - help="Source State to apply. Json file containing a State data structure; " - "the same you would obtain by running snapshot.", - ), - model: Optional[str] = typer.Option( - None, - "-m", - "--model", - help="Which model to look at.", - ), - include: str = typer.Option( - "scrkSdt", - "--include", - "-i", - help="What parts of the state to apply. Defaults to: all of them. " - "``r``: relation, ``c``: config, ``k``: containers, " - "``s``: status, ``S``: secrets(!), " - "``d``: deferred events, ``t``: stored state.", - ), - include_juju_relation_data: bool = typer.Option( - False, - "--include-juju-relation-data", - help="Whether to include in the relation data the default juju keys (egress-subnets," - "ingress-address, private-address).", - is_flag=True, - ), - push_files: Path = typer.Option( - None, - "--push-files", - help="Path to a local file containing a json spec of files to be fetched from the unit. " - "For k8s units, it's supposed to be a {container_name: List[Path]} mapping listing " - "the files that need to be pushed to the each container.", - ), - # TODO: generalize "push_files" to allow passing '.' for the 'charm' container or 'the machine'. - data_dir: Path = typer.Option( - SNAPSHOT_DATA_DIR, - "--data-dir", - help="Directory in which to any files associated with the state are stored. In the case " - "of k8s charms, this might mean files obtained through Mounts,", - ), -): - """Apply a State to a remote target unit. - - If black is available, the output will be piped through it for formatting. - - Usage: state-apply myapp/0 > ./tests/scenario/case1.py - """ - push_files_ = json.loads(push_files.read_text()) if push_files else None - state_json = json.loads(state.read_text()) - - # TODO: state_json to State - raise NotImplementedError("WIP: implement State.from_json") - state_: State = State.from_json(state_json) - - return _state_apply( - target=target, - state=state_, - model=model, - include=include, - include_juju_relation_data=include_juju_relation_data, - snapshot_data_dir=data_dir, - push_files=push_files_, - ) - - -# for the benefit of scripted usage -_state_apply.__doc__ = state_apply.__doc__ - -if __name__ == "__main__": - from scenario import State - - _state_apply("zookeeper/0", model="foo", state=State()) diff --git a/scenario/scripts/utils.py b/scenario/scripts/utils.py deleted file mode 100644 index de9dc01e6..000000000 --- a/scenario/scripts/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -from pathlib import Path - -from scenario.scripts.errors import InvalidTargetUnitName - - -class JujuUnitName(str): - """This class represents the name of a juju unit that can be snapshotted.""" - - def __init__(self, unit_name: str): - super().__init__() - app_name, _, unit_id = unit_name.rpartition("/") - if not app_name or not unit_id: - raise InvalidTargetUnitName(f"invalid unit name: {unit_name!r}") - self.unit_name = unit_name - self.app_name = app_name - self.unit_id = int(unit_id) - self.normalized = f"{app_name}-{unit_id}" - self.remote_charm_root = Path( - f"/var/lib/juju/agents/unit-{self.normalized}/charm", - ) diff --git a/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py b/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py deleted file mode 100644 index 1e1798627..000000000 --- a/tests/test_darkroom/test_harness_integration/test_darkroom_harness.py +++ /dev/null @@ -1,20 +0,0 @@ -import yaml -from ops import CharmBase -from ops.testing import Harness - -from scenario.integrations.darkroom import Darkroom - - -class MyCharm(CharmBase): - META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} - - -def test_attach(): - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - l = [] - d = Darkroom().attach(lambda e, s: l.append((e, s))) - h.begin() - h.add_relation("foo", "remote") - - assert len(l) == 1 - assert l[0][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_harness_integration/test_install_harness.py b/tests/test_darkroom/test_harness_integration/test_install_harness.py deleted file mode 100644 index 950ab5e44..000000000 --- a/tests/test_darkroom/test_harness_integration/test_install_harness.py +++ /dev/null @@ -1,33 +0,0 @@ -def test_install(): - from scenario.integrations.darkroom import Darkroom - - l = [] - - def register_trace(t): - l.append(t) - - Darkroom.install(register_trace) - - import yaml - from ops import CharmBase - from ops.testing import Harness - - class MyCharm(CharmBase): - META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - h.add_relation("foo", "remote") - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - h.add_relation("foo", "remote2") - - assert len(l) == 3 - assert [len(x) for x in l] == [4, 5, 5] - assert l[0][1][0].name == "leader_settings_changed" - assert l[1][-1][0].name == "foo_relation_created" - assert l[2][-1][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_harness_integration/test_integrations_harness.py b/tests/test_darkroom/test_harness_integration/test_integrations_harness.py deleted file mode 100644 index fd017f49f..000000000 --- a/tests/test_darkroom/test_harness_integration/test_integrations_harness.py +++ /dev/null @@ -1,78 +0,0 @@ -import ops -import pytest -import yaml -from ops import CharmBase, BlockedStatus, WaitingStatus -from ops.testing import Harness - -import scenario -from scenario import Model -from scenario.integrations.darkroom import Darkroom - - -class MyCharm(CharmBase): - META = {"name": "joseph"} - - -@pytest.fixture -def harness(): - return Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - - -def test_base(harness): - harness.begin() - state = Darkroom().capture(harness.model._backend) - assert state.unit_id == 0 - - -@pytest.mark.parametrize("leader", (True, False)) -@pytest.mark.parametrize("model_name", ("foo", "bar-baz")) -@pytest.mark.parametrize("model_uuid", ("qux", "fiz")) -def test_static_attributes(harness, leader, model_name, model_uuid): - harness.set_model_info(model_name, model_uuid) - harness.begin() - harness.charm.unit.set_workload_version("42.42") - harness.set_leader(leader) - - state = Darkroom().capture(harness.model._backend) - - assert state.leader is leader - assert state.model == Model(name=model_name, uuid=model_uuid, type="lxd") - assert state.workload_version == "42.42" - - -def test_status(harness): - harness.begin() - harness.set_leader(True) # so we can set app status - harness.charm.app.status = BlockedStatus("foo") - harness.charm.unit.status = WaitingStatus("hol' up") - - state = Darkroom().capture(harness.model._backend) - - assert state.unit_status == WaitingStatus("hol' up") - assert state.app_status == BlockedStatus("foo") - - -@pytest.mark.parametrize( - "ports", - ( - [ - ops.Port("tcp", 2032), - ops.Port("udp", 2033), - ], - [ - ops.Port("tcp", 2032), - ops.Port("tcp", 2035), - ops.Port("icmp", None), - ], - ), -) -def test_opened_ports(harness, ports): - harness.begin() - harness.charm.unit.set_ports(*ports) - state = Darkroom().capture(harness.model._backend) - assert set(state.opened_ports) == set( - scenario.Port(port.protocol, port.port) for port in ports - ) - - -# todo add tests for all other State components diff --git a/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py b/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py deleted file mode 100644 index 1e1798627..000000000 --- a/tests/test_darkroom/test_live_integration/test_darkroom_scenario.py +++ /dev/null @@ -1,20 +0,0 @@ -import yaml -from ops import CharmBase -from ops.testing import Harness - -from scenario.integrations.darkroom import Darkroom - - -class MyCharm(CharmBase): - META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} - - -def test_attach(): - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - l = [] - d = Darkroom().attach(lambda e, s: l.append((e, s))) - h.begin() - h.add_relation("foo", "remote") - - assert len(l) == 1 - assert l[0][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_live_integration/test_install_scenario.py b/tests/test_darkroom/test_live_integration/test_install_scenario.py deleted file mode 100644 index 950ab5e44..000000000 --- a/tests/test_darkroom/test_live_integration/test_install_scenario.py +++ /dev/null @@ -1,33 +0,0 @@ -def test_install(): - from scenario.integrations.darkroom import Darkroom - - l = [] - - def register_trace(t): - l.append(t) - - Darkroom.install(register_trace) - - import yaml - from ops import CharmBase - from ops.testing import Harness - - class MyCharm(CharmBase): - META = {"name": "joseph", "requires": {"foo": {"interface": "bar"}}} - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - h.add_relation("foo", "remote") - - h = Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - h.begin_with_initial_hooks() - h.add_relation("foo", "remote2") - - assert len(l) == 3 - assert [len(x) for x in l] == [4, 5, 5] - assert l[0][1][0].name == "leader_settings_changed" - assert l[1][-1][0].name == "foo_relation_created" - assert l[2][-1][0].name == "foo_relation_created" diff --git a/tests/test_darkroom/test_live_integration/test_integrations_scenario.py b/tests/test_darkroom/test_live_integration/test_integrations_scenario.py deleted file mode 100644 index fd017f49f..000000000 --- a/tests/test_darkroom/test_live_integration/test_integrations_scenario.py +++ /dev/null @@ -1,78 +0,0 @@ -import ops -import pytest -import yaml -from ops import CharmBase, BlockedStatus, WaitingStatus -from ops.testing import Harness - -import scenario -from scenario import Model -from scenario.integrations.darkroom import Darkroom - - -class MyCharm(CharmBase): - META = {"name": "joseph"} - - -@pytest.fixture -def harness(): - return Harness(MyCharm, meta=yaml.safe_dump(MyCharm.META)) - - -def test_base(harness): - harness.begin() - state = Darkroom().capture(harness.model._backend) - assert state.unit_id == 0 - - -@pytest.mark.parametrize("leader", (True, False)) -@pytest.mark.parametrize("model_name", ("foo", "bar-baz")) -@pytest.mark.parametrize("model_uuid", ("qux", "fiz")) -def test_static_attributes(harness, leader, model_name, model_uuid): - harness.set_model_info(model_name, model_uuid) - harness.begin() - harness.charm.unit.set_workload_version("42.42") - harness.set_leader(leader) - - state = Darkroom().capture(harness.model._backend) - - assert state.leader is leader - assert state.model == Model(name=model_name, uuid=model_uuid, type="lxd") - assert state.workload_version == "42.42" - - -def test_status(harness): - harness.begin() - harness.set_leader(True) # so we can set app status - harness.charm.app.status = BlockedStatus("foo") - harness.charm.unit.status = WaitingStatus("hol' up") - - state = Darkroom().capture(harness.model._backend) - - assert state.unit_status == WaitingStatus("hol' up") - assert state.app_status == BlockedStatus("foo") - - -@pytest.mark.parametrize( - "ports", - ( - [ - ops.Port("tcp", 2032), - ops.Port("udp", 2033), - ], - [ - ops.Port("tcp", 2032), - ops.Port("tcp", 2035), - ops.Port("icmp", None), - ], - ), -) -def test_opened_ports(harness, ports): - harness.begin() - harness.charm.unit.set_ports(*ports) - state = Darkroom().capture(harness.model._backend) - assert set(state.opened_ports) == set( - scenario.Port(port.protocol, port.port) for port in ports - ) - - -# todo add tests for all other State components diff --git a/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py b/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py deleted file mode 100644 index e837ea185..000000000 --- a/tests/test_darkroom/test_scenario_integration/test_darkroom_live.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_live_charm(): - pass From d8de7415216c130689400224f3a73f7f80796a47 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Nov 2023 13:43:23 +0100 Subject: [PATCH 356/546] added docs and more tests --- README.md | 31 +++++++++++++++-- scenario/capture_events.py | 6 +++- scenario/context.py | 6 ++++ scenario/ops_main_mock.py | 4 +++ scenario/runtime.py | 9 +++-- scenario/state.py | 8 +++++ tests/test_e2e/test_actions.py | 7 +++- tests/test_e2e/test_builtin_scenes.py | 5 ++- tests/test_e2e/test_deferred.py | 23 ++++++------ tests/test_e2e/test_event.py | 50 +++++++++++++++++++++++++-- tests/test_e2e/test_juju_log.py | 4 ++- tests/test_e2e/test_manager.py | 5 ++- tests/test_e2e/test_observers.py | 37 -------------------- tests/test_e2e/test_relations.py | 11 +++++- tests/test_e2e/test_state.py | 7 ++-- tests/test_emitted_events_util.py | 9 ++--- tests/test_runtime.py | 2 ++ 17 files changed, 158 insertions(+), 66 deletions(-) delete mode 100644 tests/test_e2e/test_observers.py diff --git a/README.md b/README.md index 674d038f7..e5b589b43 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ assert ctx.workload_version_history == ['1', '1.2', '1.5'] If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a -given juju event triggering (say, 'start'), a specific chain of deferred and custom events is emitted on the charm. The +given juju event triggering (say, 'start'), a specific chain of events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python @@ -254,6 +254,33 @@ def test_foo(): assert isinstance(ctx.emitted_events[0], StartEvent) ``` +You can configure what events will be captured by passing the following arguments to `Context`: +- `capture_deferred_events`: If you want to include re-emitted deferred events. +- `capture_deferred_events`: If you want to include framework events (`pre-commit`, `commit`, and `collect-status`). + +For example: +```python +from scenario import Context, Event, State + +def test_emitted_full(): + ctx = Context( + MyCharm, + capture_deferred_events=True, + capture_framework_events=True, + ) + ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)])) + + assert len(ctx.emitted_events) == 5 + assert [e.handle.kind for e in ctx.emitted_events] == [ + "update_status", + "start", + "collect_unit_status", + "pre_commit", + "commit", + ] +``` + + ### Low-level access: using directly `capture_events` If you need more control over what events are captured (or you're not into pytest), you can use directly the context @@ -299,7 +326,7 @@ Configuration: - By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. - By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by - passing: `capture_events(include_deferred=True)`. + passing: `capture_events(include_deferred=False)`. ## Relations diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 86abe3e9b..13e6ceacb 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -6,6 +6,7 @@ from contextlib import contextmanager from typing import ContextManager, List, Type, TypeVar +from ops import CollectStatusEvent from ops.framework import ( CommitEvent, EventBase, @@ -49,7 +50,10 @@ def capture_events( _real_reemit = Framework.reemit def _wrapped_emit(self, evt): - if not include_framework and isinstance(evt, (PreCommitEvent, CommitEvent)): + if not include_framework and isinstance( + evt, + (PreCommitEvent, CommitEvent, CollectStatusEvent), + ): return _real_emit(self, evt) if isinstance(evt, allowed_types): diff --git a/scenario/context.py b/scenario/context.py index 4fa773a96..ebdecb939 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -167,6 +167,8 @@ def __init__( config: Optional[Dict[str, Any]] = None, charm_root: "PathLike" = None, juju_version: str = "3.0", + capture_deferred_events: bool = False, + capture_framework_events: bool = False, ): """Represents a simulated charm's execution context. @@ -258,6 +260,10 @@ def __init__( self.juju_version = juju_version self._tmp = tempfile.TemporaryDirectory() + # config for what events to be captured in emitted_events. + self.capture_deferred_events = capture_deferred_events + self.capture_framework_events = capture_framework_events + # streaming side effects from running an event self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index e50e093a5..73780940c 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -195,7 +195,11 @@ def commit(self): if not self._has_emitted: raise RuntimeError("should .emit() before you .commit()") + # emit collect-status events + ops.charm._evaluate_status(self.charm) + self._has_committed = True + try: self.framework.commit() finally: diff --git a/scenario/runtime.py b/scenario/runtime.py index 41619c669..108526131 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -376,11 +376,14 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): return state.replace(deferred=deferred, stored_state=stored_state) @contextmanager - def _exec_ctx(self) -> ContextManager[Tuple[Path, List[EventBase]]]: + def _exec_ctx(self, ctx: "Context") -> ContextManager[Tuple[Path, List[EventBase]]]: """python 3.8 compatibility shim""" with self._virtual_charm_root() as temporary_charm_root: # todo allow customizing capture_events - with capture_events() as captured: + with capture_events( + include_deferred=ctx.capture_deferred_events, + include_framework=ctx.capture_framework_events, + ) as captured: yield (temporary_charm_root, captured) @contextmanager @@ -412,7 +415,7 @@ def exec( output_state = state.copy() logger.info(" - generating virtual charm root") - with self._exec_ctx() as (temporary_charm_root, captured): + with self._exec_ctx(context) as (temporary_charm_root, captured): logger.info(" - initializing storage") self._initialize_storage(state, temporary_charm_root) diff --git a/scenario/state.py b/scenario/state.py index df88724e0..4ce2c0d57 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -62,6 +62,10 @@ "leader_elected", "leader_settings_changed", "collect_metrics", +} +FRAMEWORK_EVENTS = { + "pre_commit", + "commit", "collect_app_status", "collect_unit_status", } @@ -998,6 +1002,7 @@ def name(self): class _EventType(str, Enum): + framework = "framework" builtin = "builtin" relation = "relation" action = "action" @@ -1038,6 +1043,9 @@ def _get_suffix_and_type(s: str): if s in SECRET_EVENTS: return s, _EventType.secret + if s in FRAMEWORK_EVENTS: + return s, _EventType.framework + # Whether the event name indicates that this is a storage event. for suffix in STORAGE_EVENTS_SUFFIX: if s.endswith(suffix): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 18248a172..a63c6ee3a 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -97,7 +97,9 @@ def test_cannot_run_action_event(mycharm): @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_results_valid(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: ActionEvent): + def handle_evt(charm: CharmBase, evt): + if not isinstance(evt, ActionEvent): + return evt.set_results(res_value) evt.log("foo") evt.log("bar") @@ -116,6 +118,9 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_outputs(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): + if not isinstance(evt, ActionEvent): + return + evt.set_results({"my-res": res_value}) evt.log("log1") evt.log("log2") diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py index 2d64a291f..587e41491 100644 --- a/tests/test_e2e/test_builtin_scenes.py +++ b/tests/test_e2e/test_builtin_scenes.py @@ -1,5 +1,5 @@ import pytest -from ops.charm import CharmBase +from ops.charm import CharmBase, CollectStatusEvent from ops.framework import Framework from scenario.sequences import check_builtin_sequences @@ -27,6 +27,9 @@ def __init__(self, framework: Framework): self.framework.observe(evt, self._on_event) def _on_event(self, event): + if isinstance(event, CollectStatusEvent): + return + global CHARM_CALLED CHARM_CALLED += 1 diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index bdd70db04..b084f6ffe 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -3,6 +3,7 @@ import pytest from ops.charm import ( CharmBase, + CollectStatusEvent, RelationChangedEvent, StartEvent, UpdateStatusEvent, @@ -65,8 +66,8 @@ def test_deferred_evt_emitted(mycharm): assert out.deferred[1].name == "update_status" # we saw start and update-status. - assert len(mycharm.captured) == 2 - upstat, start = mycharm.captured + assert len(mycharm.captured) == 3 + upstat, start, _ = mycharm.captured assert isinstance(upstat, UpdateStatusEvent) assert isinstance(start, StartEvent) @@ -123,8 +124,8 @@ def test_deferred_relation_event(mycharm): assert out.deferred[1].name == "start" # we saw start and relation-changed. - assert len(mycharm.captured) == 2 - upstat, start = mycharm.captured + assert len(mycharm.captured) == 3 + upstat, start, _ = mycharm.captured assert isinstance(upstat, RelationChangedEvent) assert isinstance(start, StartEvent) @@ -156,10 +157,11 @@ def test_deferred_relation_event_from_relation(mycharm): assert out.deferred[1].name == "start" # we saw start and foo_relation_changed. - assert len(mycharm.captured) == 2 - upstat, start = mycharm.captured + assert len(mycharm.captured) == 3 + upstat, start, collect_status = mycharm.captured assert isinstance(upstat, RelationChangedEvent) assert isinstance(start, StartEvent) + assert isinstance(collect_status, CollectStatusEvent) def test_deferred_workload_event(mycharm): @@ -183,14 +185,15 @@ def test_deferred_workload_event(mycharm): assert out.deferred[1].name == "start" # we saw start and foo_pebble_ready. - assert len(mycharm.captured) == 2 - upstat, start = mycharm.captured + assert len(mycharm.captured) == 3 + upstat, start, collect_status = mycharm.captured assert isinstance(upstat, WorkloadEvent) assert isinstance(start, StartEvent) + assert isinstance(collect_status, CollectStatusEvent) def test_defer_reemit_lifecycle_event(mycharm): - ctx = Context(mycharm, meta=mycharm.META) + ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True) mycharm.defer_next = 1 state_1 = ctx.run("update-status", State()) @@ -208,7 +211,7 @@ def test_defer_reemit_lifecycle_event(mycharm): def test_defer_reemit_relation_event(mycharm): - ctx = Context(mycharm, meta=mycharm.META) + ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True) rel = Relation("foo") mycharm.defer_next = 1 diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index 2ce9b5aac..0dd50077e 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -1,7 +1,9 @@ +import ops import pytest -from ops import CharmBase +from ops import CharmBase, StartEvent, UpdateStatusEvent -from scenario.state import Event, _CharmSpec, _EventType +from scenario import Context +from scenario.state import Event, State, _CharmSpec, _EventType @pytest.mark.parametrize( @@ -16,6 +18,10 @@ ("foo_pebble_ready", _EventType.workload), ("foo_bar_baz_pebble_ready", _EventType.workload), ("secret_removed", _EventType.secret), + ("pre_commit", _EventType.framework), + ("commit", _EventType.framework), + ("collect_unit_status", _EventType.framework), + ("collect_app_status", _EventType.framework), ("foo", _EventType.custom), ("kaboozle_bar_baz", _EventType.custom), ), @@ -48,3 +54,43 @@ class MyCharm(CharmBase): }, ) assert event._is_builtin_event(spec) is (expected_type is not _EventType.custom) + + +def test_emitted_framework(): + class MyCharm(CharmBase): + META = {"name": "joop"} + + ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) + ctx.run("update-status", State()) + assert len(ctx.emitted_events) == 4 + assert list(map(type, ctx.emitted_events)) == [ + ops.UpdateStatusEvent, + ops.CollectStatusEvent, + ops.PreCommitEvent, + ops.CommitEvent, + ] + + +def test_emitted_deferred(): + class MyCharm(CharmBase): + META = {"name": "joop"} + + def _foo(self, e): + pass + + ctx = Context( + MyCharm, + meta=MyCharm.META, + capture_deferred_events=True, + capture_framework_events=True, + ) + ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)])) + + assert len(ctx.emitted_events) == 5 + assert [e.handle.kind for e in ctx.emitted_events] == [ + "update_status", + "start", + "collect_unit_status", + "pre_commit", + "commit", + ] diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index d78c37319..5f58a973d 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -1,7 +1,7 @@ import logging import pytest -from ops.charm import CharmBase +from ops.charm import CharmBase, CollectStatusEvent from scenario import Context from scenario.state import JujuLogLine, State @@ -21,6 +21,8 @@ def __init__(self, framework): self.framework.observe(evt, self._on_event) def _on_event(self, event): + if isinstance(event, CollectStatusEvent): + return print("foo!") logger.warning("bar!") diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 2008b335d..e7fefb7a9 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -2,7 +2,7 @@ import pytest from ops import ActiveStatus -from ops.charm import CharmBase +from ops.charm import CharmBase, CollectStatusEvent from scenario import Action, Context, State from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager @@ -20,6 +20,9 @@ def __init__(self, framework): self.framework.observe(evt, self._on_event) def _on_event(self, e): + if isinstance(e, CollectStatusEvent): + return + print("event!") self.unit.status = ActiveStatus(e.handle.kind) diff --git a/tests/test_e2e/test_observers.py b/tests/test_e2e/test_observers.py deleted file mode 100644 index 51202daf3..000000000 --- a/tests/test_e2e/test_observers.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -from ops.charm import ActionEvent, CharmBase, StartEvent -from ops.framework import Framework - -from scenario.state import Event, State, _CharmSpec -from tests.helpers import trigger - - -@pytest.fixture(scope="function") -def charm_evts(): - events = [] - - class MyCharm(CharmBase): - def __init__(self, framework: Framework): - super().__init__(framework) - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) - - print(self.on.show_proxied_endpoints_action) - - def _on_event(self, event): - events.append(event) - - return MyCharm, events - - -def test_start_event(charm_evts): - charm, evts = charm_evts - trigger( - State(), - event="start", - charm_type=charm, - meta={"name": "foo"}, - actions={"show_proxied_endpoints": {}}, - ) - assert len(evts) == 1 - assert isinstance(evts[0], StartEvent) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index e5be3400d..ce73e086f 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -1,7 +1,7 @@ from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents, RelationDepartedEvent +from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, RelationDepartedEvent from ops.framework import EventBase, Framework from scenario.state import ( @@ -155,6 +155,9 @@ def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_i ) def callback(charm: CharmBase, event): + if isinstance(event, CollectStatusEvent): + return + assert event.app assert event.unit if isinstance(event, RelationDepartedEvent): @@ -196,6 +199,9 @@ def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog): ) def callback(charm: CharmBase, event): + if isinstance(event, CollectStatusEvent): + return + assert event.app # that's always present assert event.unit assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False)) @@ -235,6 +241,9 @@ def test_relation_events_no_remote_units(mycharm, evt_name, caplog): ) def callback(charm: CharmBase, event): + if isinstance(event, CollectStatusEvent): + return + assert event.app # that's always present assert not event.unit diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index a0243973a..14ee1d055 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -2,7 +2,7 @@ from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents +from ops.charm import CharmBase, CharmEvents, CollectStatusEvent from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus @@ -74,7 +74,10 @@ def pre_event(charm): def test_status_setting(state, mycharm): - def call(charm: CharmBase, _): + def call(charm: CharmBase, e): + if isinstance(e, CollectStatusEvent): + return + assert isinstance(charm.unit.status, UnknownStatus) charm.unit.status = ActiveStatus("foo test") charm.app.status = WaitingStatus("foo barz") diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index a3169930e..7fc0eb000 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,5 +1,5 @@ import pytest -from ops.charm import CharmBase, CharmEvents, StartEvent +from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent from scenario import Event, State @@ -51,10 +51,11 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): with capture_events(include_framework=True) as emitted: trigger(State(), "foo", MyCharm, meta=MyCharm.META) - assert len(emitted) == 3 + assert len(emitted) == 4 assert isinstance(emitted[0], Foo) - assert isinstance(emitted[1], PreCommitEvent) - assert isinstance(emitted[2], CommitEvent) + assert isinstance(emitted[1], CollectStatusEvent) + assert isinstance(emitted[2], PreCommitEvent) + assert isinstance(emitted[3], CommitEvent) def test_capture_juju_evt(): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 60e6293e8..6d3eee4da 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -27,6 +27,8 @@ def __init__(self, framework): self.framework.observe(evt, self._catchall) def _catchall(self, e): + if self._event: + return MyCharm._event = e return MyCharm From b080045011307be3e3c43030c6da74a1cf70db4c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 15 Nov 2023 14:24:36 +0100 Subject: [PATCH 357/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b728b1285..359d20809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.5" +version = "5.6" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From b2b68121aeb7832d37ffc0044c1befa283329249 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 16 Nov 2023 09:00:11 +0100 Subject: [PATCH 358/546] Update README.md Co-authored-by: Tony Meyer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5b589b43..f17e41eb1 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ def test_foo(): You can configure what events will be captured by passing the following arguments to `Context`: - `capture_deferred_events`: If you want to include re-emitted deferred events. -- `capture_deferred_events`: If you want to include framework events (`pre-commit`, `commit`, and `collect-status`). +- `capture_framework_events`: If you want to include framework events (`pre-commit`, `commit`, and `collect-status`). For example: ```python From 30930b53edadc0d6877dc730d710b4329dacaf06 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Thu, 16 Nov 2023 09:00:22 +0100 Subject: [PATCH 359/546] Update README.md Co-authored-by: Tony Meyer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f17e41eb1..82fffa9c4 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ assert ctx.workload_version_history == ['1', '1.2', '1.5'] If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a -given juju event triggering (say, 'start'), a specific chain of events is emitted on the charm. The +given Juju event triggering (say, 'start'), a specific chain of events is emitted on the charm. The resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python From a3dcd2c0f2c570fb361e5a66b9427d79e72430a7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Nov 2023 10:54:40 +0100 Subject: [PATCH 360/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 818712e2e..e2b5773cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.6" +version = "5.6.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 4eac4a5f1651813246b40a8e2460880559fe1e65 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Nov 2023 11:33:11 +0100 Subject: [PATCH 361/546] snapshot warning --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7eee5a2df..583dbaad1 100644 --- a/README.md +++ b/README.md @@ -1285,4 +1285,8 @@ don't need that. # Jhack integrations -The [`Jhack scenario`](todo link to jhack) subcommand offers some utilities to work with Scenario. +Up until `v5.6.0`, `scenario` shipped with a cli tool called `snapshot`, used to interact with a live charm's state. +The functionality [has been moved over to `jhack`](https://github.com/PietroPasotti/jhack/pull/111), +to allow us to keep working on it independently, and to streamline +the profile of `scenario` itself as it becomes more broadly adopted and ready for widespread usage. + From 1c0ff40dd5a40445b6d8c22c76f066ecbf45995e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 16 Nov 2023 14:58:41 +0100 Subject: [PATCH 362/546] fix falsy config defaults --- pyproject.toml | 2 +- scenario/mocking.py | 9 +++++++-- tests/test_e2e/test_config.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 359d20809..d3f7cfd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.6" +version = "5.6.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index 872a8134d..1ac515ef8 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -72,6 +72,9 @@ def send_signal(self, sig: Union[int, str]): # noqa: U100 raise NotImplementedError() +_NOT_GIVEN = object() # non-None default value sentinel + + class _MockModelBackend(_ModelBackend): def __init__( self, @@ -221,8 +224,10 @@ def config_get(self): for key, value in charm_config["options"].items(): # if it has a default, and it's not overwritten from State, use it: - if key not in state_config and (default_value := value.get("default")): - state_config[key] = default_value + if key not in state_config: + default_value = value.get("default", _NOT_GIVEN) + if default_value is not _NOT_GIVEN: # accept False as default value + state_config[key] = default_value return state_config # full config diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index 3a9fe5e5f..55c5b70d7 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -41,6 +41,7 @@ def test_config_get_default_from_meta(mycharm): def check_cfg(charm: CharmBase): assert charm.config["foo"] == "bar" assert charm.config["baz"] == 2 + assert charm.config["qux"] is False trigger( State( @@ -53,6 +54,7 @@ def check_cfg(charm: CharmBase): "options": { "foo": {"type": "string"}, "baz": {"type": "integer", "default": 2}, + "qux": {"type": "boolean", "default": False}, }, }, post_event=check_cfg, From 89bc81a7d73b832f7affa8df448041382f0102b2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Nov 2023 10:54:12 +0100 Subject: [PATCH 363/546] fix secret id canonicalization --- scenario/mocking.py | 18 ++++++++++++------ scenario/state.py | 4 ++++ tests/test_e2e/test_secrets.py | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 872a8134d..ea79bd51b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union from ops import JujuVersion, pebble +from ops.model import ModelError, RelationNotFoundError +from ops.model import Secret as Secret_Ops # lol from ops.model import ( - ModelError, - RelationNotFoundError, SecretInfo, SecretNotFoundError, SecretRotate, @@ -135,13 +135,19 @@ def _get_relation_by_id( raise RelationNotFoundError() def _get_secret(self, id=None, label=None): - # cleanup id: - if id and id.startswith("secret:"): - id = id[7:] + canonicalize_id = Secret_Ops._canonicalize_id if id: + # in scenario, you can create Secret(id="foo"), + # but ops.Secret will prepend a "secret:" prefix to that ID. + # we allow getting secret by either version. try: - return next(filter(lambda s: s.id == id, self._state.secrets)) + return next( + filter( + lambda s: canonicalize_id(s.id) == canonicalize_id(id), + self._state.secrets, + ), + ) except StopIteration: raise SecretNotFoundError() elif label: diff --git a/scenario/state.py b/scenario/state.py index 4ce2c0d57..e5fb1d1ce 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -126,6 +126,10 @@ def copy(self) -> "Self": @dataclasses.dataclass(frozen=True) class Secret(_DCBase): id: str + # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized + # secret id (`secret:` prefix) + # but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to + # add the prefix manually every time seems painful as well. # mapping from revision IDs to each revision's contents contents: Dict[int, "RawSecretRevisionContents"] diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 4ed469659..780ca9904 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -5,6 +5,7 @@ from ops.framework import Framework from ops.model import SecretNotFoundError, SecretRotate +from scenario import Context from scenario.state import Relation, Secret, State from tests.helpers import trigger @@ -278,3 +279,21 @@ def post_event(charm: CharmBase): meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, post_event=post_event, ) + + +class GrantingCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + secret = self.app.add_secret({"foo": "bar"}) + secret.grant(self.model.relations["bar"][0]) + + +def test_grant_after_add(): + context = Context( + GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} + ) + state = State(relations=[Relation("bar")]) + context.run("start", state) From 806a4f4f2101c0719ceeeaf41672cbeca82fefe7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Nov 2023 10:57:28 +0100 Subject: [PATCH 364/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 359d20809..d3f7cfd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.6" +version = "5.6.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From e0019f9eea52ce50b189b694650fe1afde44d793 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Nov 2023 11:41:47 +0100 Subject: [PATCH 365/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d3f7cfd13..9b96ffd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.6.1" +version = "5.6.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From ae4a8eea5f21792de324b58e63815610886ac276 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Nov 2023 16:07:19 +0100 Subject: [PATCH 366/546] added container fs temporary dir cleanup in Context.clear() --> Context.cleanup() --- scenario/context.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scenario/context.py b/scenario/context.py index ebdecb939..99c8a602a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -296,7 +296,18 @@ def _get_storage_root(self, name: str, index: int) -> Path: return storage_root def clear(self): - """Cleanup side effects histories.""" + """Deprecated. + + Use cleanup instead. + """ + logger.warning( + "Context.clear() is deprecated and will be nuked in v6. " + "Use Context.cleanup() instead.", + ) + self.cleanup() + + def cleanup(self): + """Cleanup side effects histories and reset the simulated filesystem state.""" self.juju_log = [] self.app_status_history = [] self.unit_status_history = [] @@ -308,6 +319,9 @@ def clear(self): self._action_failure = None self._output_state = None + self._tmp.cleanup() + self._tmp = tempfile.TemporaryDirectory() + def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: From b13405ba62376960ad40b5182760567ecda6fb54 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 17 Nov 2023 16:09:02 +0100 Subject: [PATCH 367/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9b96ffd42..1561341e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.6.2" +version = "5.6.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From a8bb04d98178534cc651e91beb06c4e30ae3f438 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 20 Nov 2023 10:25:49 +0100 Subject: [PATCH 368/546] uniformed owner 'application'-->'app' --- scenario/state.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index e5fb1d1ce..45120aad7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -135,7 +135,7 @@ class Secret(_DCBase): contents: Dict[int, "RawSecretRevisionContents"] # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. - owner: Literal["unit", "application", None] = None + owner: Literal["unit", "app", None] = None # has this secret been granted to this unit/app or neither? Only applicable if NOT owner granted: Literal["unit", "app", False] = False @@ -152,6 +152,14 @@ class Secret(_DCBase): expire: Optional[datetime.datetime] = None rotate: SecretRotate = SecretRotate.NEVER + def __post_init__(self): + if self.owner == "application": + logger.warning( + "Secret.owner='application' is deprecated in favour of 'app'.", + ) + # bypass frozen dataclass + object.__setattr__(self, "owner", "app") + # consumer-only events @property def changed_event(self): From 43e2b12605e5239fe8bf18b419a457b2e983e9da Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 20 Nov 2023 11:08:37 +0100 Subject: [PATCH 369/546] lint --- pyproject.toml | 2 +- scenario/mocking.py | 38 +++++++++++++++++++-------- tests/test_e2e/test_secrets.py | 47 ++++++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3d7b02899..84b93d883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.7" +version = "5.7.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index e5bd4298b..4423cadbc 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -31,6 +31,7 @@ Event, ExecOutput, Relation, + Secret, State, SubordinateRelation, _CharmSpec, @@ -302,6 +303,24 @@ def secret_add( self._state.secrets.append(secret) return id + @staticmethod + def _check_secret_data_access( + secret: "Secret", + read: bool = False, + write: bool = False, + ): + # FIXME: match real traceback + # TODO: different behaviours if ownership == 'app'/'unit'? + if read: + if secret.owner is None and secret.granted is False: + raise SecretNotFoundError( + f"You must own secret {secret.id!r} to perform this operation", + ) + + if write: + if secret.owner is None: + raise SecretNotFoundError("this secret is not owned by this unit/app") + def secret_get( self, *, @@ -311,6 +330,8 @@ def secret_get( peek: bool = False, ) -> Dict[str, str]: secret = self._get_secret(id, label) + self._check_secret_data_access(secret, read=True) + revision = secret.revision if peek or refresh: revision = max(secret.contents.keys()) @@ -326,8 +347,9 @@ def secret_info_get( label: Optional[str] = None, ) -> SecretInfo: secret = self._get_secret(id, label) - if not secret.owner: - raise RuntimeError(f"not the owner of {secret}") + + # only "manage"=write access level can read secret info + self._check_secret_data_access(secret, write=True) return SecretInfo( id=secret.id, @@ -349,8 +371,7 @@ def secret_set( rotate: Optional[SecretRotate] = None, ): secret = self._get_secret(id, label) - if not secret.owner: - raise RuntimeError(f"not the owner of {secret}") + self._check_secret_data_access(secret, write=True) secret._update_metadata( content=content, @@ -362,8 +383,7 @@ def secret_set( def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - if not secret.owner: - raise RuntimeError(f"not the owner of {secret}") + self._check_secret_data_access(secret, write=True) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name @@ -374,16 +394,14 @@ def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - if not secret.owner: - raise RuntimeError(f"not the owner of {secret}") + self._check_secret_data_access(secret, write=True) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name secret.remote_grants[relation_id].remove(grantee) def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) - if not secret.owner: - raise RuntimeError(f"not the owner of {secret}") + self._check_secret_data_access(secret, write=True) if revision: del secret.contents[revision] diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 780ca9904..17026cea4 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,9 +3,10 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import SecretNotFoundError, SecretRotate +from ops.model import ModelError, SecretNotFoundError, SecretRotate from scenario import Context +from scenario.runtime import UncaughtCharmError from scenario.state import Relation, Secret, State from tests.helpers import trigger @@ -41,7 +42,7 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content()["a"] == "b" trigger( - State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted="unit")]), "update_status", mycharm, meta={"name": "local"}, @@ -49,7 +50,23 @@ def post_event(charm: CharmBase): ) -def test_get_secret_peek_update(mycharm): +def test_get_secret_not_granted(mycharm): + def post_event(charm: CharmBase): + assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + + with pytest.raises(UncaughtCharmError) as e: + trigger( + State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) + + +@pytest.mark.parametrize("owner", ("app", "unit", "application")) +# "application" is deprecated but still supported +def test_get_secret_peek_update(mycharm, owner): def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content()["a"] == "b" assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" @@ -67,6 +84,7 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, 1: {"a": "c"}, }, + owner=owner, ) ] ), @@ -77,7 +95,9 @@ def post_event(charm: CharmBase): ) -def test_secret_changed_owner_evt_fails(mycharm): +@pytest.mark.parametrize("owner", ("app", "unit", "application")) +# "application" is deprecated but still supported +def test_secret_changed_owner_evt_fails(mycharm, owner): with pytest.raises(ValueError): _ = Secret( id="foo", @@ -85,7 +105,7 @@ def test_secret_changed_owner_evt_fails(mycharm): 0: {"a": "b"}, 1: {"a": "c"}, }, - owner="unit", + owner=owner, ).changed_event @@ -150,11 +170,12 @@ def post_event(charm: CharmBase): ) -def test_meta_nonowner(mycharm): +@pytest.mark.parametrize("granted", ("app", "unit")) +def test_meta_nonowner(mycharm, granted): def post_event(charm: CharmBase): secret = charm.model.get_secret(id="foo") - with pytest.raises(RuntimeError): - info = secret.get_info() + with pytest.raises(SecretNotFoundError): + secret.get_info() trigger( State( @@ -164,6 +185,7 @@ def post_event(charm: CharmBase): label="mylabel", description="foobarbaz", rotate=SecretRotate.HOURLY, + granted=granted, contents={ 0: {"a": "b"}, }, @@ -253,13 +275,10 @@ def post_event(charm: CharmBase): def test_grant_nonowner(mycharm): def post_event(charm: CharmBase): - secret = charm.model.get_secret(id="foo") - with pytest.raises(RuntimeError): - secret = charm.model.get_secret(label="mylabel") - foo = charm.model.get_relation("foo") - secret.grant(relation=foo) + with pytest.raises(SecretNotFoundError): + charm.model.get_secret(id="foo") - out = trigger( + trigger( State( relations=[Relation("foo", "remote")], secrets=[ From 33cf4adbff815080f0a5f4253c6d46e6a0874a41 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 20 Nov 2023 14:01:44 +0100 Subject: [PATCH 370/546] factored in ownership --- scenario/mocking.py | 26 ++++++++---- tests/test_e2e/test_secrets.py | 74 ++++++++++++++++++++-------------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 4423cadbc..a6c49199f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -303,23 +303,35 @@ def secret_add( self._state.secrets.append(secret) return id - @staticmethod def _check_secret_data_access( + self, secret: "Secret", read: bool = False, write: bool = False, ): - # FIXME: match real traceback - # TODO: different behaviours if ownership == 'app'/'unit'? + # FIXME: match real tracebacks + self_is_leader = self.is_leader() + if read: - if secret.owner is None and secret.granted is False: - raise SecretNotFoundError( - f"You must own secret {secret.id!r} to perform this operation", - ) + if secret.owner is None: + if secret.granted is False: + raise SecretNotFoundError( + f"You must own secret {secret.id!r} to perform this operation", + ) + if secret.granted == "app" and not self_is_leader: + raise SecretNotFoundError( + f"Only the leader can read secret {secret.id!r} since it was " + f"granted to this app.", + ) if write: if secret.owner is None: raise SecretNotFoundError("this secret is not owned by this unit/app") + if secret.owner == "app" and not self_is_leader: + raise SecretNotFoundError( + f"App-owned secret {secret.id!r} can only be " + f"managed by the leader.", + ) def secret_get( self, diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 17026cea4..54f334d85 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -170,33 +170,42 @@ def post_event(charm: CharmBase): ) +@pytest.mark.parametrize("leader", (True, False)) @pytest.mark.parametrize("granted", ("app", "unit")) -def test_meta_nonowner(mycharm, granted): +def test_meta_nonowner(mycharm, granted, leader): def post_event(charm: CharmBase): secret = charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): secret.get_info() - trigger( - State( - secrets=[ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - granted=granted, - contents={ - 0: {"a": "b"}, - }, - ) - ] - ), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) + try: + trigger( + State( + leader=leader, + secrets=[ + Secret( + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + granted=granted, + contents={ + 0: {"a": "b"}, + }, + ) + ], + ), + "update_status", + mycharm, + meta={"name": "local"}, + post_event=post_event, + ) + except UncaughtCharmError as e: + if not leader and granted == "app": + # expected failure + pass + else: + raise @pytest.mark.parametrize("app", (True, False)) @@ -300,19 +309,22 @@ def post_event(charm: CharmBase): ) -class GrantingCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.start, self._on_start) - - def _on_start(self, _): - secret = self.app.add_secret({"foo": "bar"}) - secret.grant(self.model.relations["bar"][0]) +@pytest.mark.parametrize("leader", (True, False)) +def test_grant_after_add(leader): + class GrantingCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self._on_start) + def _on_start(self, _): + if leader: + secret = self.app.add_secret({"foo": "bar"}) + else: + secret = self.unit.add_secret({"foo": "bar"}) + secret.grant(self.model.relations["bar"][0]) -def test_grant_after_add(): context = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) - state = State(relations=[Relation("bar")]) + state = State(leader=leader, relations=[Relation("bar")]) context.run("start", state) From 0aab2af523f248d6a7ac4c6c6ef282f6b125ccb7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 21 Nov 2023 09:51:18 +0100 Subject: [PATCH 371/546] some more tests and metadata rule fixes --- scenario/mocking.py | 19 ++- tests/test_e2e/test_secrets.py | 284 +++++++++++++++++---------------- 2 files changed, 161 insertions(+), 142 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index a6c49199f..42f7b073b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -307,7 +307,7 @@ def _check_secret_data_access( self, secret: "Secret", read: bool = False, - write: bool = False, + manage: bool = False, ): # FIXME: match real tracebacks self_is_leader = self.is_leader() @@ -324,7 +324,7 @@ def _check_secret_data_access( f"granted to this app.", ) - if write: + if manage: if secret.owner is None: raise SecretNotFoundError("this secret is not owned by this unit/app") if secret.owner == "app" and not self_is_leader: @@ -344,6 +344,11 @@ def secret_get( secret = self._get_secret(id, label) self._check_secret_data_access(secret, read=True) + if self._context.juju_version <= "3.2": + # in juju<3.2, secret owners always track the latest revision. + if secret.owner is not None: + refresh = True + revision = secret.revision if peek or refresh: revision = max(secret.contents.keys()) @@ -361,7 +366,7 @@ def secret_info_get( secret = self._get_secret(id, label) # only "manage"=write access level can read secret info - self._check_secret_data_access(secret, write=True) + self._check_secret_data_access(secret, read=True) return SecretInfo( id=secret.id, @@ -383,7 +388,7 @@ def secret_set( rotate: Optional[SecretRotate] = None, ): secret = self._get_secret(id, label) - self._check_secret_data_access(secret, write=True) + self._check_secret_data_access(secret, manage=True) secret._update_metadata( content=content, @@ -395,7 +400,7 @@ def secret_set( def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, write=True) + self._check_secret_data_access(secret, manage=True) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name @@ -406,14 +411,14 @@ def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, write=True) + self._check_secret_data_access(secret, manage=True) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name secret.remote_grants[relation_id].remove(grantee) def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, write=True) + self._check_secret_data_access(secret, manage=True) if revision: del secret.contents[revision] diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 54f334d85..ec9c035f0 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,12 +3,10 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import ModelError, SecretNotFoundError, SecretRotate +from ops.model import SecretNotFoundError, SecretRotate from scenario import Context -from scenario.runtime import UncaughtCharmError from scenario.state import Relation, Secret, State -from tests.helpers import trigger @pytest.fixture(scope="function") @@ -26,48 +24,75 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): - def post_event(charm: CharmBase): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", State() + ) as mgr: with pytest.raises(SecretNotFoundError): - assert charm.model.get_secret(id="foo") + assert mgr.charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): - assert charm.model.get_secret(label="foo") - - trigger( - State(), "update_status", mycharm, meta={"name": "local"}, post_event=post_event - ) + assert mgr.charm.model.get_secret(label="foo") def test_get_secret(mycharm): - def post_event(charm: CharmBase): - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - - trigger( - State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted="unit")]), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) + with Context(mycharm, meta={"name": "local"}).manager( + state=State( + secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted="unit")] + ), + event="update_status", + ) as mgr: + assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" def test_get_secret_not_granted(mycharm): - def post_event(charm: CharmBase): - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - - with pytest.raises(UncaughtCharmError) as e: - trigger( - State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) + with Context(mycharm, meta={"name": "local"}).manager( + state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + event="update_status", + ) as mgr: + with pytest.raises(SecretNotFoundError) as e: + assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @pytest.mark.parametrize("owner", ("app", "unit", "application")) # "application" is deprecated but still supported -def test_get_secret_peek_update(mycharm, owner): - def post_event(charm: CharmBase): +def test_get_secret_get_refresh(mycharm, owner): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State( + secrets=[ + Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + owner=owner, + ) + ] + ), + ) as mgr: + charm = mgr.charm + assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + + +@pytest.mark.parametrize("app", (True, False)) +def test_get_secret_nonowner_peek_update(mycharm, app): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State( + leader=app, + secrets=[ + Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + granted="app" if app else "unit", + ), + ], + ), + ) as mgr: + charm = mgr.charm assert charm.model.get_secret(id="foo").get_content()["a"] == "b" assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" assert charm.model.get_secret(id="foo").get_content()["a"] == "b" @@ -75,7 +100,12 @@ def post_event(charm: CharmBase): assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" assert charm.model.get_secret(id="foo").get_content()["a"] == "c" - trigger( + +@pytest.mark.parametrize("owner", ("app", "unit", "application")) +# "application" is deprecated but still supported +def test_get_secret_owner_peek_update(mycharm, owner): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", State( secrets=[ Secret( @@ -88,11 +118,11 @@ def post_event(charm: CharmBase): ) ] ), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) + ) as mgr: + charm = mgr.charm + assert charm.model.get_secret(id="foo").get_content()["a"] == "c" + assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" + assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" @pytest.mark.parametrize("owner", ("app", "unit", "application")) @@ -124,21 +154,44 @@ def test_consumer_events_failures(mycharm, evt_prefix): ) -def test_add(mycharm): - def post_event(charm: CharmBase): - charm.unit.add_secret({"foo": "bar"}, label="mylabel") +@pytest.mark.parametrize("app", (True, False)) +def test_add(mycharm, app): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State(leader=app), + ) as mgr: + charm = mgr.charm + if app: + charm.app.add_secret({"foo": "bar"}, label="mylabel") + else: + charm.unit.add_secret({"foo": "bar"}, label="mylabel") - out = trigger( - State(), "update_status", mycharm, meta={"name": "local"}, post_event=post_event - ) - assert out.secrets - secret = out.secrets[0] + assert mgr.output.secrets + secret = mgr.output.secrets[0] assert secret.contents[0] == {"foo": "bar"} assert secret.label == "mylabel" -def test_meta(mycharm): - def post_event(charm: CharmBase): +@pytest.mark.parametrize("app", (True, False)) +def test_meta(mycharm, app): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State( + secrets=[ + Secret( + owner="app" if app else "unit", + id="foo", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + contents={ + 0: {"a": "b"}, + }, + ) + ] + ), + ) as mgr: + charm = mgr.charm assert charm.model.get_secret(label="mylabel") secret = charm.model.get_secret(id="foo") @@ -148,77 +201,44 @@ def post_event(charm: CharmBase): assert info.label == "mylabel" assert info.rotation == SecretRotate.HOURLY - trigger( + +@pytest.mark.parametrize("leader", (True, False)) +@pytest.mark.parametrize("granted", ("app", "unit")) +def test_meta_nonowner(mycharm, granted, leader): + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", State( + leader=leader, secrets=[ Secret( - owner="unit", id="foo", label="mylabel", description="foobarbaz", rotate=SecretRotate.HOURLY, + granted=granted, contents={ 0: {"a": "b"}, }, ) - ] + ], ), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) - - -@pytest.mark.parametrize("leader", (True, False)) -@pytest.mark.parametrize("granted", ("app", "unit")) -def test_meta_nonowner(mycharm, granted, leader): - def post_event(charm: CharmBase): - secret = charm.model.get_secret(id="foo") - with pytest.raises(SecretNotFoundError): - secret.get_info() - - try: - trigger( - State( - leader=leader, - secrets=[ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - granted=granted, - contents={ - 0: {"a": "b"}, - }, - ) - ], - ), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) - except UncaughtCharmError as e: + ) as mgr: if not leader and granted == "app": - # expected failure - pass + with pytest.raises(SecretNotFoundError): + mgr.charm.model.get_secret(id="foo") + return else: - raise + secret = mgr.charm.model.get_secret(id="foo") + + secret.get_info() @pytest.mark.parametrize("app", (True, False)) def test_grant(mycharm, app): - def post_event(charm: CharmBase): - secret = charm.model.get_secret(label="mylabel") - foo = charm.model.get_relation("foo") - if app: - secret.grant(relation=foo) - else: - secret.grant(relation=foo, unit=foo.units.pop()) - - out = trigger( + with Context( + mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} + ).manager( + "update_status", State( relations=[Relation("foo", "remote")], secrets=[ @@ -234,29 +254,23 @@ def post_event(charm: CharmBase): ) ], ), - "update_status", - mycharm, - meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, - post_event=post_event, - ) - - vals = list(out.secrets[0].remote_grants.values()) + ) as mgr: + charm = mgr.charm + secret = charm.model.get_secret(label="mylabel") + foo = charm.model.get_relation("foo") + if app: + secret.grant(relation=foo) + else: + secret.grant(relation=foo, unit=foo.units.pop()) + vals = list(mgr.output.secrets[0].remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] def test_update_metadata(mycharm): exp = datetime.datetime(2050, 12, 12) - def post_event(charm: CharmBase): - secret = charm.model.get_secret(label="mylabel") - secret.set_info( - label="babbuccia", - description="blu", - expire=exp, - rotate=SecretRotate.DAILY, - ) - - out = trigger( + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", State( secrets=[ Secret( @@ -269,13 +283,16 @@ def post_event(charm: CharmBase): ) ], ), - "update_status", - mycharm, - meta={"name": "local"}, - post_event=post_event, - ) + ) as mgr: + secret = mgr.charm.model.get_secret(label="mylabel") + secret.set_info( + label="babbuccia", + description="blu", + expire=exp, + rotate=SecretRotate.DAILY, + ) - secret_out = out.secrets[0] + secret_out = mgr.output.secrets[0] assert secret_out.label == "babbuccia" assert secret_out.rotate == SecretRotate.DAILY assert secret_out.description == "blu" @@ -283,11 +300,10 @@ def post_event(charm: CharmBase): def test_grant_nonowner(mycharm): - def post_event(charm: CharmBase): - with pytest.raises(SecretNotFoundError): - charm.model.get_secret(id="foo") - - trigger( + with Context( + mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} + ).manager( + "update_status", State( relations=[Relation("foo", "remote")], secrets=[ @@ -302,11 +318,9 @@ def post_event(charm: CharmBase): ) ], ), - "update_status", - mycharm, - meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}, - post_event=post_event, - ) + ) as mgr: + with pytest.raises(SecretNotFoundError): + mgr.charm.model.get_secret(id="foo") @pytest.mark.parametrize("leader", (True, False)) From 9633380e1ca4605a3a75305c60470f709884df74 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 22 Nov 2023 12:16:59 +0100 Subject: [PATCH 372/546] simplified access control logic --- scenario/mocking.py | 10 +-- scenario/state.py | 5 +- tests/test_e2e/test_secrets.py | 127 ++++++++++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 18 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 42f7b073b..16248d948 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -314,16 +314,10 @@ def _check_secret_data_access( if read: if secret.owner is None: - if secret.granted is False: + if secret.granted is None: raise SecretNotFoundError( f"You must own secret {secret.id!r} to perform this operation", ) - if secret.granted == "app" and not self_is_leader: - raise SecretNotFoundError( - f"Only the leader can read secret {secret.id!r} since it was " - f"granted to this app.", - ) - if manage: if secret.owner is None: raise SecretNotFoundError("this secret is not owned by this unit/app") @@ -366,7 +360,7 @@ def secret_info_get( secret = self._get_secret(id, label) # only "manage"=write access level can read secret info - self._check_secret_data_access(secret, read=True) + self._check_secret_data_access(secret, manage=True) return SecretInfo( id=secret.id, diff --git a/scenario/state.py b/scenario/state.py index 45120aad7..249e010ee 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -138,7 +138,7 @@ class Secret(_DCBase): owner: Literal["unit", "app", None] = None # has this secret been granted to this unit/app or neither? Only applicable if NOT owner - granted: Literal["unit", "app", False] = False + granted: Literal["unit", "app", None] = None # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -213,8 +213,9 @@ def _update_metadata( ): """Update the metadata.""" revision = max(self.contents.keys()) + self.contents[revision + 1] = content + # bypass frozen dataclass - object.__setattr__(self, "contents"[revision + 1], content) if label: object.__setattr__(self, "label", label) if description: diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index ec9c035f0..59e5ebb43 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -3,6 +3,7 @@ import pytest from ops.charm import CharmBase from ops.framework import Framework +from ops.model import Secret as ops_Secret from ops.model import SecretNotFoundError, SecretRotate from scenario import Context @@ -172,11 +173,79 @@ def test_add(mycharm, app): assert secret.label == "mylabel" +def test_set(mycharm): + rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State(), + ) as mgr: + charm = mgr.charm + secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel") + assert ( + secret.get_content() + == secret.peek_content() + == secret.get_content(refresh=True) + == rev1 + ) + + secret.set_content(rev2) + assert ( + secret.get_content() + == secret.peek_content() + == secret.get_content(refresh=True) + == rev2 + ) + + secret.set_content(rev3) + state_out = mgr.run() + assert ( + secret.get_content() + == secret.peek_content() + == secret.get_content(refresh=True) + == rev3 + ) + + assert state_out.secrets[0].contents == { + 0: rev1, + 1: rev2, + 2: rev3, + } + + +def test_set_juju33(mycharm): + rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} + with Context(mycharm, meta={"name": "local"}, juju_version="3.3").manager( + "update_status", + State(), + ) as mgr: + charm = mgr.charm + secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel") + assert secret.get_content() == rev1 + + secret.set_content(rev2) + assert secret.get_content() == rev1 + assert secret.peek_content() == rev2 + assert secret.get_content(refresh=True) == rev2 + + secret.set_content(rev3) + state_out = mgr.run() + assert secret.get_content() == rev2 + assert secret.peek_content() == rev3 + assert secret.get_content(refresh=True) == rev3 + + assert state_out.secrets[0].contents == { + 0: rev1, + 1: rev2, + 2: rev3, + } + + @pytest.mark.parametrize("app", (True, False)) def test_meta(mycharm, app): with Context(mycharm, meta={"name": "local"}).manager( "update_status", State( + leader=True, secrets=[ Secret( owner="app" if app else "unit", @@ -188,7 +257,7 @@ def test_meta(mycharm, app): 0: {"a": "b"}, }, ) - ] + ], ), ) as mgr: charm = mgr.charm @@ -203,8 +272,29 @@ def test_meta(mycharm, app): @pytest.mark.parametrize("leader", (True, False)) -@pytest.mark.parametrize("granted", ("app", "unit")) -def test_meta_nonowner(mycharm, granted, leader): +@pytest.mark.parametrize("owner", ("app", "unit", None)) +@pytest.mark.parametrize("granted", ("app", "unit", None)) +def test_secret_permission_model(mycharm, granted, leader, owner): + if granted: + owner = None + + expect_view = bool( + # if you (or your app) owns the secret, you can always view it + (owner is not None) + # can read secrets you don't own if you've been granted them + or granted + ) + + expect_manage = bool( + # if you're the leader and own this app secret + (owner == "app" and leader) + # you own this secret + or (owner == "unit") + ) + + if expect_manage: + assert expect_view + with Context(mycharm, meta={"name": "local"}).manager( "update_status", State( @@ -216,6 +306,7 @@ def test_meta_nonowner(mycharm, granted, leader): description="foobarbaz", rotate=SecretRotate.HOURLY, granted=granted, + owner=owner, contents={ 0: {"a": "b"}, }, @@ -223,14 +314,34 @@ def test_meta_nonowner(mycharm, granted, leader): ], ), ) as mgr: - if not leader and granted == "app": + if expect_view: + secret = mgr.charm.model.get_secret(id="foo") + assert secret.get_content()["a"] == "b" + assert secret.peek_content() + assert secret.get_content(refresh=True) + + else: with pytest.raises(SecretNotFoundError): mgr.charm.model.get_secret(id="foo") - return - else: - secret = mgr.charm.model.get_secret(id="foo") - secret.get_info() + # nothing else to do directly if you can't get a hold of the Secret instance + # but we can try some raw backend calls + with pytest.raises(SecretNotFoundError): + mgr.charm.model._backend.secret_info_get(id="foo") + + with pytest.raises(SecretNotFoundError): + mgr.charm.model._backend.secret_set(id="foo", content={"bo": "fo"}) + + if expect_manage: + secret: ops_Secret = mgr.charm.model.get_secret(id="foo") + assert secret.get_content() + assert secret.peek_content() + assert secret.get_content(refresh=True) + + assert secret.get_info() + secret.set_content({"foo": "boo"}) + assert secret.get_content()["foo"] == "boo" + secret.remove_all_revisions() @pytest.mark.parametrize("app", (True, False)) From 25935dbc1366ca4cce4b49276a9ba1da7a12c6db Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 22 Nov 2023 13:19:18 +0100 Subject: [PATCH 373/546] stripped 'granted' --- scenario/mocking.py | 38 ++++++++---------------- scenario/state.py | 9 +++--- tests/test_e2e/test_secrets.py | 54 +++++++++++----------------------- 3 files changed, 34 insertions(+), 67 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 16248d948..11b18a9fb 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -303,29 +303,18 @@ def secret_add( self._state.secrets.append(secret) return id - def _check_secret_data_access( + def _check_can_manage_secret( self, secret: "Secret", - read: bool = False, - manage: bool = False, ): # FIXME: match real tracebacks - self_is_leader = self.is_leader() - - if read: - if secret.owner is None: - if secret.granted is None: - raise SecretNotFoundError( - f"You must own secret {secret.id!r} to perform this operation", - ) - if manage: - if secret.owner is None: - raise SecretNotFoundError("this secret is not owned by this unit/app") - if secret.owner == "app" and not self_is_leader: - raise SecretNotFoundError( - f"App-owned secret {secret.id!r} can only be " - f"managed by the leader.", - ) + if secret.owner is None: + raise SecretNotFoundError("this secret is not owned by this unit/app") + if secret.owner == "app" and not self.is_leader(): + raise SecretNotFoundError( + f"App-owned secret {secret.id!r} can only be " + f"managed by the leader.", + ) def secret_get( self, @@ -336,7 +325,6 @@ def secret_get( peek: bool = False, ) -> Dict[str, str]: secret = self._get_secret(id, label) - self._check_secret_data_access(secret, read=True) if self._context.juju_version <= "3.2": # in juju<3.2, secret owners always track the latest revision. @@ -360,7 +348,7 @@ def secret_info_get( secret = self._get_secret(id, label) # only "manage"=write access level can read secret info - self._check_secret_data_access(secret, manage=True) + self._check_can_manage_secret(secret) return SecretInfo( id=secret.id, @@ -382,7 +370,7 @@ def secret_set( rotate: Optional[SecretRotate] = None, ): secret = self._get_secret(id, label) - self._check_secret_data_access(secret, manage=True) + self._check_can_manage_secret(secret) secret._update_metadata( content=content, @@ -394,7 +382,7 @@ def secret_set( def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, manage=True) + self._check_can_manage_secret(secret) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name @@ -405,14 +393,14 @@ def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, manage=True) + self._check_can_manage_secret(secret) grantee = unit or self._get_relation_by_id(relation_id).remote_app_name secret.remote_grants[relation_id].remove(grantee) def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) - self._check_secret_data_access(secret, manage=True) + self._check_can_manage_secret(secret) if revision: del secret.contents[revision] diff --git a/scenario/state.py b/scenario/state.py index 249e010ee..7021a0c6d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -135,11 +135,9 @@ class Secret(_DCBase): contents: Dict[int, "RawSecretRevisionContents"] # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. + # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None - # has this secret been granted to this unit/app or neither? Only applicable if NOT owner - granted: Literal["unit", "app", None] = None - # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -831,10 +829,11 @@ class State(_DCBase): model: Model = Model() """The model this charm lives in.""" secrets: List[Secret] = dataclasses.field(default_factory=list) - """The secrets this charm has access to (as an owner, or as a grantee).""" + """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: Dict[str, "PathLike"] = dataclasses.field(default_factory=dict) """Mapping from resource name to path at which the resource can be found.""" - planned_units: int = 1 """Number of non-dying planned units that are expected to be running this application. Use with caution.""" diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 59e5ebb43..79bcf7255 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -36,9 +36,7 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): with Context(mycharm, meta={"name": "local"}).manager( - state=State( - secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted="unit")] - ), + state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted=True)]), event="update_status", ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @@ -88,7 +86,6 @@ def test_get_secret_nonowner_peek_update(mycharm, app): 0: {"a": "b"}, 1: {"a": "c"}, }, - granted="app" if app else "unit", ), ], ), @@ -273,18 +270,7 @@ def test_meta(mycharm, app): @pytest.mark.parametrize("leader", (True, False)) @pytest.mark.parametrize("owner", ("app", "unit", None)) -@pytest.mark.parametrize("granted", ("app", "unit", None)) -def test_secret_permission_model(mycharm, granted, leader, owner): - if granted: - owner = None - - expect_view = bool( - # if you (or your app) owns the secret, you can always view it - (owner is not None) - # can read secrets you don't own if you've been granted them - or granted - ) - +def test_secret_permission_model(mycharm, leader, owner): expect_manage = bool( # if you're the leader and own this app secret (owner == "app" and leader) @@ -292,9 +278,6 @@ def test_secret_permission_model(mycharm, granted, leader, owner): or (owner == "unit") ) - if expect_manage: - assert expect_view - with Context(mycharm, meta={"name": "local"}).manager( "update_status", State( @@ -305,7 +288,6 @@ def test_secret_permission_model(mycharm, granted, leader, owner): label="mylabel", description="foobarbaz", rotate=SecretRotate.HOURLY, - granted=granted, owner=owner, contents={ 0: {"a": "b"}, @@ -314,26 +296,15 @@ def test_secret_permission_model(mycharm, granted, leader, owner): ], ), ) as mgr: - if expect_view: - secret = mgr.charm.model.get_secret(id="foo") - assert secret.get_content()["a"] == "b" - assert secret.peek_content() - assert secret.get_content(refresh=True) - - else: - with pytest.raises(SecretNotFoundError): - mgr.charm.model.get_secret(id="foo") - - # nothing else to do directly if you can't get a hold of the Secret instance - # but we can try some raw backend calls - with pytest.raises(SecretNotFoundError): - mgr.charm.model._backend.secret_info_get(id="foo") + secret = mgr.charm.model.get_secret(id="foo") + assert secret.get_content()["a"] == "b" + assert secret.peek_content() + assert secret.get_content(refresh=True) - with pytest.raises(SecretNotFoundError): - mgr.charm.model._backend.secret_set(id="foo", content={"bo": "fo"}) + # can always view + secret: ops_Secret = mgr.charm.model.get_secret(id="foo") if expect_manage: - secret: ops_Secret = mgr.charm.model.get_secret(id="foo") assert secret.get_content() assert secret.peek_content() assert secret.get_content(refresh=True) @@ -343,6 +314,15 @@ def test_secret_permission_model(mycharm, granted, leader, owner): assert secret.get_content()["foo"] == "boo" secret.remove_all_revisions() + else: # cannot manage + # nothing else to do directly if you can't get a hold of the Secret instance + # but we can try some raw backend calls + with pytest.raises(SecretNotFoundError): + secret.get_info() + + with pytest.raises(SecretNotFoundError): + secret.set_content(content={"boo": "foo"}) + @pytest.mark.parametrize("app", (True, False)) def test_grant(mycharm, app): From 0b895434b7069b84263d187dce37ccdcb5504883 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 22 Nov 2023 13:24:04 +0100 Subject: [PATCH 374/546] Doc fixes --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 583dbaad1..fc9ee96f4 100644 --- a/README.md +++ b/README.md @@ -811,11 +811,16 @@ state = State( ``` The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping -from revision numbers (integers) to a str:str dict representing the payload of the revision. +from revision numbers (integers) to a `str:str` dict representing the payload of the revision. -By default, the secret is not owned by **this charm** nor is it granted to it. -Therefore, if charm code attempted to get that secret revision, it would get a permission error: we didn't grant it to -this charm, nor we specified that the secret is owned by it. +There are three cases: +- the secret is owned by this app, in which case only the leader unit can manage it +- the secret is owned by this unit, in which case this charm can always manage it (leader or not) +- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it + +Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights. + +The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists. To specify a secret owned by this unit (or app): @@ -826,7 +831,7 @@ state = State( secrets=[ Secret( id='foo', - contents={0: {'key': 'public'}}, + contents={0: {'key': 'private'}}, owner='unit', # or 'app' remote_grants={0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 @@ -846,7 +851,6 @@ state = State( id='foo', contents={0: {'key': 'public'}}, # owner=None, which is the default - granted="unit", # or "app", revision=0, # the revision that this unit (or app) is currently tracking ) ] From 9100d1ba532e3664199cc4a0bc61fcf24c3f6da8 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 22 Nov 2023 13:33:51 +0100 Subject: [PATCH 375/546] BC --- scenario/state.py | 12 +++++++++++- tests/test_e2e/test_secrets.py | 26 +------------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 7021a0c6d..6f641732c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -138,6 +138,9 @@ class Secret(_DCBase): # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None + # deprecated! + granted: Literal["unit", "app", False] = "" + # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -151,9 +154,16 @@ class Secret(_DCBase): rotate: SecretRotate = SecretRotate.NEVER def __post_init__(self): + if self.granted != "": + logger.warning( + "``state.Secret.granted`` is deprecated and will be removed in Scenario 6. " + "If a Secret is not owned by the app/unit you are testing, nor has been granted to " + "it by the (remote) owner, then omit it from ``State.secrets`` altogether.", + ) if self.owner == "application": logger.warning( - "Secret.owner='application' is deprecated in favour of 'app'.", + "Secret.owner='application' is deprecated in favour of 'app' " + "and will be removed in Scenario 6.", ) # bypass frozen dataclass object.__setattr__(self, "owner", "app") diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 79bcf7255..2d099bfb8 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -44,7 +44,7 @@ def test_get_secret(mycharm): def test_get_secret_not_granted(mycharm): with Context(mycharm, meta={"name": "local"}).manager( - state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + state=State(secrets=[]), event="update_status", ) as mgr: with pytest.raises(SecretNotFoundError) as e: @@ -390,30 +390,6 @@ def test_update_metadata(mycharm): assert secret_out.expire == exp -def test_grant_nonowner(mycharm): - with Context( - mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} - ).manager( - "update_status", - State( - relations=[Relation("foo", "remote")], - secrets=[ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - ], - ), - ) as mgr: - with pytest.raises(SecretNotFoundError): - mgr.charm.model.get_secret(id="foo") - - @pytest.mark.parametrize("leader", (True, False)) def test_grant_after_add(leader): class GrantingCharm(CharmBase): From 0dea321dd0784465c6bad38306dd894a8b6016c6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Nov 2023 13:53:45 +0100 Subject: [PATCH 376/546] extra bindings and network model rework --- README.md | 6 ++ scenario/consistency_checker.py | 35 +++++++ scenario/mocking.py | 32 ++++-- scenario/state.py | 162 ++++++++++++++++-------------- tests/test_consistency_checker.py | 38 +++++++ tests/test_e2e/test_network.py | 38 +++---- 6 files changed, 211 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 583dbaad1..677427e57 100644 --- a/README.md +++ b/README.md @@ -527,6 +527,12 @@ remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) ``` +### Networks + +Each relation a charm has will +A charm can define some `extra-bindings` + + # Containers When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 80636db8a..f939eeabe 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -24,6 +24,15 @@ logger = scenario_logger.getChild("consistency_checker") +def get_all_relations(charm_spec: "_CharmSpec"): + nonpeer_relations_meta = chain( + charm_spec.meta.get("requires", {}).items(), + charm_spec.meta.get("provides", {}).items(), + ) + peer_relations_meta = charm_spec.meta.get("peers", {}).items() + return list(chain(nonpeer_relations_meta, peer_relations_meta)) + + class Results(NamedTuple): """Consistency checkers return type.""" @@ -69,6 +78,7 @@ def check_consistency( check_secrets_consistency, check_storages_consistency, check_relation_consistency, + check_network_consistency, ): results = check( state=state, @@ -386,6 +396,31 @@ def check_secrets_consistency( return Results(errors, []) +def check_network_consistency( + *, + state: "State", + event: "Event", # noqa: U100 + charm_spec: "_CharmSpec", + **_kwargs, # noqa: U101 +) -> Results: + errors = [] + + meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) + state_bindings = set(state.extra_bindings) + if diff := state_bindings.difference(meta_bindings): + errors.append( + f"Some extra-bindings defined in State are not in metadata.yaml: {diff}.", + ) + + endpoints = {i[0] for i in get_all_relations(charm_spec)} + if collisions := endpoints.intersection(meta_bindings): + errors.append( + f"Extra bindings and integration endpoints cannot share the same name: {collisions}.", + ) + + return Results(errors, []) + + def check_relation_consistency( *, state: "State", diff --git a/scenario/mocking.py b/scenario/mocking.py index e5bd4298b..a6a21f424 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -22,7 +22,7 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, Mount, PeerRelation, Port, Storage +from scenario.state import JujuLogLine, Mount, Network, PeerRelation, Port, Storage if TYPE_CHECKING: from scenario.context import Context @@ -238,15 +238,31 @@ def config_get(self): return state_config # full config def network_get(self, binding_name: str, relation_id: Optional[int] = None): - if relation_id: - logger.warning("network-get -r not implemented") + # is this an extra-binding-provided network? + if binding_name in self._charm_spec.meta.get("extra-bindings", ()): + network = self._state.extra_bindings.get(binding_name, Network.default()) + return network.hook_tool_output_fmt() + # Is this a network attached to a relation? relations = self._state.get_relations(binding_name) - if not relations: - raise RelationNotFoundError() - - network = next(filter(lambda r: r.name == binding_name, self._state.networks)) - return network.hook_tool_output_fmt() + if relation_id: + try: + relation = next( + filter( + lambda r: r.relation_id == relation_id, + relations, + ), + ) + except StopIteration as e: + logger.error( + f"network-get error: " + f"No relation found with endpoint={binding_name} and id={relation_id}.", + ) + raise RelationNotFoundError() from e + else: + # TODO: is this accurate? + relation = relations[0] + return relation.network.hook_tool_output_fmt() # setter methods: these can mutate the state. def application_version_set(self, version: str): diff --git a/scenario/state.py b/scenario/state.py index e5fb1d1ce..398c587ae 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -224,6 +224,73 @@ def normalize_name(s: str): return s.replace("-", "_") +@dataclasses.dataclass(frozen=True) +class Address(_DCBase): + hostname: str + value: str + cidr: str + address: str = "" # legacy + + +@dataclasses.dataclass(frozen=True) +class BindAddress(_DCBase): + interface_name: str + addresses: List[Address] + mac_address: Optional[str] = None + + def hook_tool_output_fmt(self): + # dumps itself to dict in the same format the hook tool would + # todo support for legacy (deprecated `interfacename` and `macaddress` fields? + dct = { + "interface-name": self.interface_name, + "addresses": [dataclasses.asdict(addr) for addr in self.addresses], + } + if self.mac_address: + dct["mac-address"] = self.mac_address + return dct + + +@dataclasses.dataclass(frozen=True) +class Network(_DCBase): + bind_addresses: List[BindAddress] + ingress_addresses: List[str] + egress_subnets: List[str] + + def hook_tool_output_fmt(self): + # dumps itself to dict in the same format the hook tool would + return { + "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], + "egress-subnets": self.egress_subnets, + "ingress-addresses": self.ingress_addresses, + } + + @classmethod + def default( + cls, + private_address: str = "1.1.1.1", + hostname: str = "", + cidr: str = "", + interface_name: str = "", + mac_address: Optional[str] = None, + egress_subnets=("1.1.1.2/32",), + ingress_addresses=("1.1.1.2",), + ) -> "Network": + """Helper to create a minimal, heavily defaulted Network.""" + return cls( + bind_addresses=[ + BindAddress( + interface_name=interface_name, + mac_address=mac_address, + addresses=[ + Address(hostname=hostname, value=private_address, cidr=cidr), + ], + ), + ], + egress_subnets=list(egress_subnets), + ingress_addresses=list(ingress_addresses), + ) + + _next_relation_id_counter = 1 @@ -238,15 +305,27 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) class RelationBase(_DCBase): endpoint: str + """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" - # we can derive this from the charm's metadata interface: str = None + """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. + If left empty, it will be automatically derived from metadata.yaml.""" - # Every new Relation instance gets a new one, if there's trouble, override. relation_id: int = dataclasses.field(default_factory=next_relation_id) + """Juju relation ID. Every new Relation instance gets a unique one, + if there's trouble, override.""" local_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """This application's databag for this relation.""" + local_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """This unit's databag for this relation.""" + + # TODO should we parametrize/randomize this default value to make each + # relation have a different network? + network: Network = dataclasses.field(default=None) + """Network associated with this relation. + If left empty, a default network will be assigned automatically.""" @property def _databags(self): @@ -276,6 +355,9 @@ def __post_init__(self): for databag in self._databags: self._validate_databag(databag) + if self.network is None: + object.__setattr__(self, "network", Network.default()) + def _validate_databag(self, databag: dict): if not isinstance(databag, dict): raise StateValidationError( @@ -587,77 +669,6 @@ def pebble_ready_event(self): return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) -@dataclasses.dataclass(frozen=True) -class Address(_DCBase): - hostname: str - value: str - cidr: str - address: str = "" # legacy - - -@dataclasses.dataclass(frozen=True) -class BindAddress(_DCBase): - interface_name: str - addresses: List[Address] - mac_address: Optional[str] = None - - def hook_tool_output_fmt(self): - # dumps itself to dict in the same format the hook tool would - # todo support for legacy (deprecated `interfacename` and `macaddress` fields? - dct = { - "interface-name": self.interface_name, - "addresses": [dataclasses.asdict(addr) for addr in self.addresses], - } - if self.mac_address: - dct["mac-address"] = self.mac_address - return dct - - -@dataclasses.dataclass(frozen=True) -class Network(_DCBase): - name: str - - bind_addresses: List[BindAddress] - ingress_addresses: List[str] - egress_subnets: List[str] - - def hook_tool_output_fmt(self): - # dumps itself to dict in the same format the hook tool would - return { - "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], - "egress-subnets": self.egress_subnets, - "ingress-addresses": self.ingress_addresses, - } - - @classmethod - def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), - ) -> "Network": - """Helper to create a minimal, heavily defaulted Network.""" - return cls( - name=name, - bind_addresses=[ - BindAddress( - interface_name=interface_name, - mac_address=mac_address, - addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr), - ], - ), - ], - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) - - @dataclasses.dataclass(frozen=True) class _EntityStatus(_DCBase): """This class represents StatusBase and should not be interacted with directly.""" @@ -806,8 +817,9 @@ class State(_DCBase): """The present configuration of this charm.""" relations: List["AnyRelation"] = dataclasses.field(default_factory=list) """All relations that currently exist for this charm.""" - networks: List[Network] = dataclasses.field(default_factory=list) - """All networks currently provisioned for this charm.""" + extra_bindings: Dict[str, Network] = dataclasses.field(default_factory=dict) + """All extra bindings currently provisioned for this charm. + If a metadata-defined extra-binding is left empty, it will be defaulted.""" containers: List[Container] = dataclasses.field(default_factory=list) """All containers (whether they can connect or not) that this charm is aware of.""" storage: List[Storage] = dataclasses.field(default_factory=list) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index bcdc7df83..66415cf9b 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -15,6 +15,7 @@ Storage, SubordinateRelation, _CharmSpec, + Network, ) @@ -467,3 +468,40 @@ def test_resource_states(): meta={"name": "yamlman"}, ), ) + + +def test_networks_consistency(): + assert_inconsistent( + State(extra_bindings={"foo": Network.default()}), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "wonky"}, + ), + ) + + assert_inconsistent( + State(extra_bindings={"foo": Network.default()}), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "pinky", + "extra-bindings": {"foo": {}}, + "requires": {"foo": {"interface": "bar"}}, + }, + ), + ) + + assert_consistent( + State(extra_bindings={"foo": Network.default()}), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "pinky", + "extra-bindings": {"foo": {}}, + "requires": {"bar": {"interface": "bar"}}, + }, + ), + ) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 8c10c6cc0..ba7078108 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -3,7 +3,8 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario.state import Event, Network, Relation, State, _CharmSpec +from scenario import Context +from scenario.state import Network, Relation, State from tests.helpers import trigger @@ -28,13 +29,17 @@ def _on_event(self, event): def test_ip_get(mycharm): - mycharm._call = lambda *_: True - - def fetch_unit_address(charm: CharmBase): - rel = charm.model.get_relation("metrics-endpoint") - assert str(charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" + ctx = Context( + mycharm, + meta={ + "name": "foo", + "requires": {"metrics-endpoint": {"interface": "foo"}}, + "extra-bindings": {"foo": {}}, + }, + ) - trigger( + with ctx.manager( + "update_status", State( relations=[ Relation( @@ -44,16 +49,15 @@ def fetch_unit_address(charm: CharmBase): relation_id=1, ), ], - networks=[Network.default("metrics-endpoint")], + extra_bindings={"foo": Network.default(private_address="4.4.4.4")}, ), - "update_status", - mycharm, - meta={ - "name": "foo", - "requires": {"metrics-endpoint": {"interface": "foo"}}, - }, - post_event=fetch_unit_address, - ) + ) as mgr: + # we have a network for the relation + rel = mgr.charm.model.get_relation("metrics-endpoint") + assert str(mgr.charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" + + # and an extra binding + assert str(mgr.charm.model.get_binding("foo").network.bind_address) == "4.4.4.4" def test_no_relation_error(mycharm): @@ -74,7 +78,7 @@ def fetch_unit_address(charm: CharmBase): relation_id=1, ), ], - networks=[Network.default("metrics-endpoint")], + extra_bindings={"foo": Network.default()}, ), "update_status", mycharm, From f76cc742c16e9b6c530c59c30949968f95c78477 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 Nov 2023 14:25:54 +0100 Subject: [PATCH 377/546] tolerate dead relations --- README.md | 2 +- pyproject.toml | 2 +- scenario/mocking.py | 39 ++++++++++++++++++- scenario/state.py | 5 ++- tests/test_consistency_checker.py | 2 +- tests/test_e2e/test_network.py | 63 +++++++++++++++++++++++-------- 6 files changed, 91 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 677427e57..5d71117cd 100644 --- a/README.md +++ b/README.md @@ -529,7 +529,7 @@ remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation ### Networks -Each relation a charm has will +Each relation a charm has is associated with A charm can define some `extra-bindings` diff --git a/pyproject.toml b/pyproject.toml index 84b93d883..611b793cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.7.1" +version = "6.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index a6a21f424..af7dda9d6 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -5,6 +5,7 @@ import random import shutil from io import StringIO +from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union @@ -240,9 +241,30 @@ def config_get(self): def network_get(self, binding_name: str, relation_id: Optional[int] = None): # is this an extra-binding-provided network? if binding_name in self._charm_spec.meta.get("extra-bindings", ()): + if relation_id is not None: + # this should not happen + raise RuntimeError( + "cannot pass relation_id to network_get if the binding name is " + "that of an extra-binding. Extra-bindings are not mapped to relation IDs.", + ) network = self._state.extra_bindings.get(binding_name, Network.default()) return network.hook_tool_output_fmt() + # is binding_name a valid relation binding name? + meta = self._charm_spec.meta + if binding_name not in set( + chain( + meta.get("peers", ()), + meta.get("requires", ()), + meta.get("provides", ()), + ), + ): + logger.error( + f"cannot get network binding for {binding_name}: is not a valid relation " + f"endpoint name nor an extra-binding.", + ) + raise RelationNotFoundError() + # Is this a network attached to a relation? relations = self._state.get_relations(binding_name) if relation_id: @@ -260,8 +282,23 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): ) raise RelationNotFoundError() from e else: - # TODO: is this accurate? + if not relations: + logger.warning( + "Requesting the network for an endpoint with no active relations " + "will return a defaulted network.", + ) + return Network.default().hook_tool_output_fmt() + + # TODO: is this accurate? Any relation in particular? relation = relations[0] + + from scenario.state import SubordinateRelation # avoid cyclic imports + + if isinstance(relation, SubordinateRelation): + raise RuntimeError( + "Subordinate relation has no associated network binding.", + ) + return relation.network.hook_tool_output_fmt() # setter methods: these can mutate the state. diff --git a/scenario/state.py b/scenario/state.py index 398c587ae..18d18d478 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -325,7 +325,8 @@ class RelationBase(_DCBase): # relation have a different network? network: Network = dataclasses.field(default=None) """Network associated with this relation. - If left empty, a default network will be assigned automatically.""" + If left empty, a default network will be assigned automatically + (except for subordinate relations).""" @property def _databags(self): @@ -355,7 +356,7 @@ def __post_init__(self): for databag in self._databags: self._validate_databag(databag) - if self.network is None: + if type(self) is not SubordinateRelation and self.network is None: object.__setattr__(self, "network", Network.default()) def _validate_databag(self, databag: dict): diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 66415cf9b..bb8d75dab 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -8,6 +8,7 @@ Action, Container, Event, + Network, PeerRelation, Relation, Secret, @@ -15,7 +16,6 @@ Storage, SubordinateRelation, _CharmSpec, - Network, ) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index ba7078108..cdfcf530c 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -4,7 +4,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Network, Relation, State +from scenario.state import Network, Relation, State, SubordinateRelation from tests.helpers import trigger @@ -33,7 +33,10 @@ def test_ip_get(mycharm): mycharm, meta={ "name": "foo", - "requires": {"metrics-endpoint": {"interface": "foo"}}, + "requires": { + "metrics-endpoint": {"interface": "foo"}, + "deadnodead": {"interface": "bar"}, + }, "extra-bindings": {"foo": {}}, }, ) @@ -56,19 +59,52 @@ def test_ip_get(mycharm): rel = mgr.charm.model.get_relation("metrics-endpoint") assert str(mgr.charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" + # we have a network for a binding without relations on it + assert ( + str(mgr.charm.model.get_binding("deadnodead").network.bind_address) + == "1.1.1.1" + ) + # and an extra binding assert str(mgr.charm.model.get_binding("foo").network.bind_address) == "4.4.4.4" +def test_no_sub_binding(mycharm): + ctx = Context( + mycharm, + meta={ + "name": "foo", + "requires": {"bar": {"interface": "foo", "scope": "container"}}, + }, + ) + + with ctx.manager( + "update_status", + State( + relations=[ + SubordinateRelation("bar"), + ] + ), + ) as mgr: + with pytest.raises(RuntimeError): + # sub relations have no network + mgr.charm.model.get_binding("bar").network + + def test_no_relation_error(mycharm): """Attempting to call get_binding on a non-existing relation -> RelationNotFoundError""" - mycharm._call = lambda *_: True - def fetch_unit_address(charm: CharmBase): - with pytest.raises(RelationNotFoundError): - _ = charm.model.get_binding("foo").network + ctx = Context( + mycharm, + meta={ + "name": "foo", + "requires": {"metrics-endpoint": {"interface": "foo"}}, + "extra-bindings": {"bar": {}}, + }, + ) - trigger( + with ctx.manager( + "update_status", State( relations=[ Relation( @@ -78,13 +114,8 @@ def fetch_unit_address(charm: CharmBase): relation_id=1, ), ], - extra_bindings={"foo": Network.default()}, + extra_bindings={"bar": Network.default()}, ), - "update_status", - mycharm, - meta={ - "name": "foo", - "requires": {"metrics-endpoint": {"interface": "foo"}}, - }, - post_event=fetch_unit_address, - ) + ) as mgr: + with pytest.raises(RelationNotFoundError): + net = mgr.charm.model.get_binding("foo").network From d02b8bb0051df722af9e936e4ede657ceaa2ae93 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Nov 2023 10:25:04 +0100 Subject: [PATCH 378/546] re-rework --- README.md | 2 +- scenario/consistency_checker.py | 31 ++++------ scenario/mocking.py | 96 +++++++++++-------------------- scenario/state.py | 44 ++++++++------ tests/test_consistency_checker.py | 6 +- tests/test_e2e/test_network.py | 6 +- 6 files changed, 77 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 5d71117cd..919ed7952 100644 --- a/README.md +++ b/README.md @@ -529,7 +529,7 @@ remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation ### Networks -Each relation a charm has is associated with +Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is. A charm can define some `extra-bindings` diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index f939eeabe..c494e746e 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -4,7 +4,6 @@ import os from collections import Counter from collections.abc import Sequence -from itertools import chain from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple @@ -24,15 +23,6 @@ logger = scenario_logger.getChild("consistency_checker") -def get_all_relations(charm_spec: "_CharmSpec"): - nonpeer_relations_meta = chain( - charm_spec.meta.get("requires", {}).items(), - charm_spec.meta.get("provides", {}).items(), - ) - peer_relations_meta = charm_spec.meta.get("peers", {}).items() - return list(chain(nonpeer_relations_meta, peer_relations_meta)) - - class Results(NamedTuple): """Consistency checkers return type.""" @@ -406,13 +396,20 @@ def check_network_consistency( errors = [] meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) - state_bindings = set(state.extra_bindings) - if diff := state_bindings.difference(meta_bindings): + all_relations = charm_spec.get_all_relations() + non_sub_relations = { + endpoint + for endpoint, metadata in all_relations + if metadata.get("scope") != "container" # mark of a sub + } + + state_bindings = set(state.networks) + if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): errors.append( - f"Some extra-bindings defined in State are not in metadata.yaml: {diff}.", + f"Some network bindings defined in State are not in metadata.yaml: {diff}.", ) - endpoints = {i[0] for i in get_all_relations(charm_spec)} + endpoints = {endpoint for endpoint, metadata in all_relations} if collisions := endpoints.intersection(meta_bindings): errors.append( f"Extra bindings and integration endpoints cannot share the same name: {collisions}.", @@ -429,12 +426,8 @@ def check_relation_consistency( **_kwargs, # noqa: U101 ) -> Results: errors = [] - nonpeer_relations_meta = chain( - charm_spec.meta.get("requires", {}).items(), - charm_spec.meta.get("provides", {}).items(), - ) peer_relations_meta = charm_spec.meta.get("peers", {}).items() - all_relations_meta = list(chain(nonpeer_relations_meta, peer_relations_meta)) + all_relations_meta = charm_spec.get_all_relations() def _get_relations(r): try: diff --git a/scenario/mocking.py b/scenario/mocking.py index af7dda9d6..fa43fe262 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -5,7 +5,6 @@ import random import shutil from io import StringIO -from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union @@ -145,20 +144,21 @@ def _get_secret(self, id=None, label=None): # in scenario, you can create Secret(id="foo"), # but ops.Secret will prepend a "secret:" prefix to that ID. # we allow getting secret by either version. - try: - return next( - filter( - lambda s: canonicalize_id(s.id) == canonicalize_id(id), - self._state.secrets, - ), - ) - except StopIteration: - raise SecretNotFoundError() + secrets = [ + s + for s in self._state.secrets + if canonicalize_id(s.id) == canonicalize_id(id) + ] + if not secrets: + raise SecretNotFoundError(id) + return secrets[0] + elif label: - try: - return next(filter(lambda s: s.label == label, self._state.secrets)) - except StopIteration: - raise SecretNotFoundError() + secrets = [s for s in self._state.secrets if s.label == label] + if not secrets: + raise SecretNotFoundError(label) + return secrets[0] + else: # if all goes well, this should never be reached. ops.model.Secret will check upon # instantiation that either an id or a label are set, and raise a TypeError if not. @@ -239,67 +239,35 @@ def config_get(self): return state_config # full config def network_get(self, binding_name: str, relation_id: Optional[int] = None): - # is this an extra-binding-provided network? - if binding_name in self._charm_spec.meta.get("extra-bindings", ()): + # validation: + extra_bindings = self._charm_spec.meta.get("extra-bindings", ()) + all_endpoints = self._charm_spec.get_all_relations() + non_sub_relations = { + name for name, meta in all_endpoints if meta.get("scope") != "container" + } + + # - is binding_name a valid binding name? + if binding_name in extra_bindings: + logger.warning("extra-bindings is a deprecated feature") # fyi + + # - verify that if the binding is an extra binding, we're not ignoring a relation_id if relation_id is not None: # this should not happen - raise RuntimeError( + logger.error( "cannot pass relation_id to network_get if the binding name is " "that of an extra-binding. Extra-bindings are not mapped to relation IDs.", ) - network = self._state.extra_bindings.get(binding_name, Network.default()) - return network.hook_tool_output_fmt() - - # is binding_name a valid relation binding name? - meta = self._charm_spec.meta - if binding_name not in set( - chain( - meta.get("peers", ()), - meta.get("requires", ()), - meta.get("provides", ()), - ), - ): + # - verify that the binding is a relation endpoint name, but not a subordinate one + elif binding_name not in non_sub_relations: logger.error( f"cannot get network binding for {binding_name}: is not a valid relation " f"endpoint name nor an extra-binding.", ) raise RelationNotFoundError() - # Is this a network attached to a relation? - relations = self._state.get_relations(binding_name) - if relation_id: - try: - relation = next( - filter( - lambda r: r.relation_id == relation_id, - relations, - ), - ) - except StopIteration as e: - logger.error( - f"network-get error: " - f"No relation found with endpoint={binding_name} and id={relation_id}.", - ) - raise RelationNotFoundError() from e - else: - if not relations: - logger.warning( - "Requesting the network for an endpoint with no active relations " - "will return a defaulted network.", - ) - return Network.default().hook_tool_output_fmt() - - # TODO: is this accurate? Any relation in particular? - relation = relations[0] - - from scenario.state import SubordinateRelation # avoid cyclic imports - - if isinstance(relation, SubordinateRelation): - raise RuntimeError( - "Subordinate relation has no associated network binding.", - ) - - return relation.network.hook_tool_output_fmt() + # We look in State.networks for an override. If not given, we return a default network. + network = self._state.networks.get(binding_name, Network.default()) + return network.hook_tool_output_fmt() # setter methods: these can mutate the state. def application_version_set(self, version: str): diff --git a/scenario/state.py b/scenario/state.py index 18d18d478..7ec2f951a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -9,6 +9,7 @@ import typing from collections import namedtuple from enum import Enum +from itertools import chain from pathlib import Path, PurePosixPath from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union from uuid import uuid4 @@ -321,13 +322,6 @@ class RelationBase(_DCBase): local_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) """This unit's databag for this relation.""" - # TODO should we parametrize/randomize this default value to make each - # relation have a different network? - network: Network = dataclasses.field(default=None) - """Network associated with this relation. - If left empty, a default network will be assigned automatically - (except for subordinate relations).""" - @property def _databags(self): """Yield all databags in this relation.""" @@ -356,9 +350,6 @@ def __post_init__(self): for databag in self._databags: self._validate_databag(databag) - if type(self) is not SubordinateRelation and self.network is None: - object.__setattr__(self, "network", Network.default()) - def _validate_databag(self, databag: dict): if not isinstance(databag, dict): raise StateValidationError( @@ -818,9 +809,14 @@ class State(_DCBase): """The present configuration of this charm.""" relations: List["AnyRelation"] = dataclasses.field(default_factory=list) """All relations that currently exist for this charm.""" - extra_bindings: Dict[str, Network] = dataclasses.field(default_factory=dict) - """All extra bindings currently provisioned for this charm. - If a metadata-defined extra-binding is left empty, it will be defaulted.""" + networks: Dict[str, Network] = dataclasses.field(default_factory=dict) + """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: List[Container] = dataclasses.field(default_factory=list) """All containers (whether they can connect or not) that this charm is aware of.""" storage: List[Storage] = dataclasses.field(default_factory=list) @@ -912,11 +908,13 @@ def with_unit_status(self, status: StatusBase) -> "State": def get_container(self, container: Union[str, Container]) -> Container: """Get container from this State, based on an input container or its name.""" - name = container.name if isinstance(container, Container) else container - try: - return next(filter(lambda c: c.name == name, self.containers)) - except StopIteration as e: - raise ValueError(f"container: {name}") from e + container_name = ( + container.name if isinstance(container, Container) else container + ) + containers = [c for c in self.containers if c.name == container_name] + if not containers: + raise ValueError(f"container: {container_name} not found in the State") + return containers[0] def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: """Get all relations on this endpoint from the current state.""" @@ -999,6 +997,16 @@ def autoload(charm_type: Type["CharmType"]): is_autoloaded=True, ) + def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: + """A list of all relation endpoints defined in the metadata.""" + return list( + chain( + self.meta.get("requires", {}).items(), + self.meta.get("provides", {}).items(), + self.meta.get("peers", {}).items(), + ), + ) + def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): return sorted(patch, key=key) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index bb8d75dab..ef92e6d96 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -472,7 +472,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( - State(extra_bindings={"foo": Network.default()}), + State(networks={"foo": Network.default()}), Event("start"), _CharmSpec( MyCharm, @@ -481,7 +481,7 @@ def test_networks_consistency(): ) assert_inconsistent( - State(extra_bindings={"foo": Network.default()}), + State(networks={"foo": Network.default()}), Event("start"), _CharmSpec( MyCharm, @@ -494,7 +494,7 @@ def test_networks_consistency(): ) assert_consistent( - State(extra_bindings={"foo": Network.default()}), + State(networks={"foo": Network.default()}), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index cdfcf530c..997ad3315 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -52,7 +52,7 @@ def test_ip_get(mycharm): relation_id=1, ), ], - extra_bindings={"foo": Network.default(private_address="4.4.4.4")}, + networks={"foo": Network.default(private_address="4.4.4.4")}, ), ) as mgr: # we have a network for the relation @@ -86,7 +86,7 @@ def test_no_sub_binding(mycharm): ] ), ) as mgr: - with pytest.raises(RuntimeError): + with pytest.raises(RelationNotFoundError): # sub relations have no network mgr.charm.model.get_binding("bar").network @@ -114,7 +114,7 @@ def test_no_relation_error(mycharm): relation_id=1, ), ], - extra_bindings={"bar": Network.default()}, + networks={"bar": Network.default()}, ), ) as mgr: with pytest.raises(RelationNotFoundError): From 9cd044f4a29eb9758e607aae1c5edeebf32f9acb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Nov 2023 10:25:44 +0100 Subject: [PATCH 379/546] pyright precommit --- .pre-commit-config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b88092291..e53c414b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,3 +76,7 @@ repos: hooks: - id: check-hooks-apply - id: check-useless-excludes + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.338 + hooks: + - id: pyright From 9928aa9674bd38a9b93078bf36ab19489276d4ba Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 30 Nov 2023 10:32:48 +0100 Subject: [PATCH 380/546] readme --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 919ed7952..6a523b788 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ A scenario test consists of three broad steps: - optionally, you can use a context manager to get a hold of the charm instance and run assertions on internal APIs and the internal state of the charm and operator framework. The most basic scenario is one in which all is defaulted and barely any data is -available. The charm has no config, no relations, no networks, no leadership, and its status is `unknown`. +available. The charm has no config, no relations, no leadership, and its status is `unknown`. With that, we can write the simplest possible scenario test: @@ -530,8 +530,22 @@ remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation ### Networks Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is. -A charm can define some `extra-bindings` +If your charm has a relation `"foo"` (defined in metadata.yaml), then the charm will be able at runtime to do `self.model.get_binding("foo").network`. +The network you'll get by doing so is heavily defaulted (see `state.Network.default`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. + +On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata.yaml and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario. + +If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`. + +```python +from scenario import State, Network +state = State(networks={ + 'foo': Network.default(private_address='4.4.4.4') +}) +``` + +Where `foo` can either be the name of an `extra-bindings`-defined binding, or a relation endpoint. # Containers From d9c8dd0ace73fbb43cbfcf5dacbff82d32c248eb Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 4 Dec 2023 15:45:29 +0100 Subject: [PATCH 381/546] rolled back precommit config --- .github/workflows/quality_checks.yaml | 7 +- .pre-commit-config.yaml | 4 - pyproject.toml | 5 + scenario/capture_events.py | 4 +- scenario/consistency_checker.py | 39 +++++--- scenario/context.py | 92 ++++++++++------- scenario/mocking.py | 111 +++++++++++++++------ scenario/ops_main_mock.py | 37 ++++--- scenario/runtime.py | 45 ++++----- scenario/sequences.py | 1 - scenario/state.py | 137 +++++++++++++++++--------- tests/test_charm_spec_autoload.py | 2 + tests/test_context.py | 11 +++ tests/test_runtime.py | 4 +- tox.ini | 9 ++ 15 files changed, 330 insertions(+), 178 deletions(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 9488abc2f..5e1159aaf 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -20,8 +20,11 @@ jobs: python-version: "3.10" - name: Install dependencies run: python -m pip install tox - - name: Run lint tests + - name: Run linter run: tox -vve lint + - name: Run static checks + run: tox -vve static + unit-test: name: Unit Tests @@ -38,4 +41,4 @@ jobs: - name: Install dependencies run: python -m pip install tox - name: Run unit tests - run: tox -vve unit + run: tox -vve unit \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e53c414b8..b88092291 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,3 @@ repos: hooks: - id: check-hooks-apply - id: check-useless-excludes - - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.338 - hooks: - - id: pyright diff --git a/pyproject.toml b/pyproject.toml index 84b93d883..1170440fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,11 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" +[tool.pyright] +ignore = [ + "scenario/sequences.py", + "scenario/cpature_events.py" +] [tool.isort] profile = "black" diff --git a/scenario/capture_events.py b/scenario/capture_events.py index 13e6ceacb..3b0947978 100644 --- a/scenario/capture_events.py +++ b/scenario/capture_events.py @@ -4,7 +4,7 @@ import typing from contextlib import contextmanager -from typing import ContextManager, List, Type, TypeVar +from typing import Type, TypeVar from ops import CollectStatusEvent from ops.framework import ( @@ -24,7 +24,7 @@ def capture_events( *types: Type[EventBase], include_framework=False, include_deferred=True, -) -> ContextManager[List[EventBase]]: +): """Capture all events of type `*types` (using instance checks). Arguments exposed so that you can define your own fixtures if you want to. diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 80636db8a..c887a8258 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -52,7 +52,7 @@ def check_consistency( So a State declaring some config keys that are not in the charm's config.yaml is nonsense, and the combination of the two is inconsistent. """ - juju_version: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) + juju_version_: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"): logger.info("skipping consistency checks.") @@ -74,7 +74,7 @@ def check_consistency( state=state, event=event, charm_spec=charm_spec, - juju_version=juju_version, + juju_version=juju_version_, ) errors.extend(results.errors) warnings.extend(results.warnings) @@ -201,12 +201,14 @@ def _check_action_event( "cannot construct a workload event without the container instance. " "Please pass one.", ) + return + elif not event.name.startswith(normalize_name(action.name)): errors.append( f"action event should start with action name. {event.name} does " f"not start with {action.name}.", ) - if action.name not in charm_spec.actions: + if action.name not in (charm_spec.actions or ()): errors.append( f"action event {event.name} refers to action {action.name} " f"which is not declared in the charm metadata (actions.yaml).", @@ -223,6 +225,8 @@ def _check_storage_event( warnings: List[str], # noqa: U100 ): storage = event.storage + meta = charm_spec.meta + if not storage: errors.append( "cannot construct a storage event without the Storage instance. " @@ -233,7 +237,7 @@ def _check_storage_event( f"storage event should start with storage name. {event.name} does " f"not start with {storage.name}.", ) - elif storage.name not in charm_spec.meta["storage"]: + elif storage.name not in meta["storage"]: errors.append( f"storage event {event.name} refers to storage {storage.name} " f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", @@ -246,6 +250,10 @@ def _check_action_param_types( errors: List[str], warnings: List[str], ): + actions = charm_spec.actions + if not actions: + return + to_python_type = { "string": str, "boolean": bool, @@ -255,7 +263,7 @@ def _check_action_param_types( "object": dict, } expected_param_type = {} - for par_name, par_spec in charm_spec.actions[action.name].get("params", {}).items(): + for par_name, par_spec in actions[action.name].get("params", {}).items(): value = par_spec.get("type") if not value: errors.append( @@ -343,7 +351,7 @@ def check_config_consistency( "integer": int, # fixme: which one is it? "number": float, "boolean": bool, - "attrs": NotImplemented, # fixme: wot? + # "attrs": NotImplemented, # fixme: wot? } expected_type_name = meta_config[key].get("type", None) @@ -352,7 +360,12 @@ def check_config_consistency( continue expected_type = converters.get(expected_type_name) - if not isinstance(value, expected_type): + if not expected_type: + errors.append( + f"config invalid for option {key!r}: 'type' {expected_type_name} unknown", + ) + + elif not isinstance(value, expected_type): errors.append( f"config invalid; option {key!r} should be of type {expected_type} " f"but is of type {type(value)}.", @@ -394,11 +407,14 @@ def check_relation_consistency( **_kwargs, # noqa: U101 ) -> Results: errors = [] + + meta = charm_spec.meta + nonpeer_relations_meta = chain( - charm_spec.meta.get("requires", {}).items(), - charm_spec.meta.get("provides", {}).items(), + meta.get("requires", {}).items(), + meta.get("provides", {}).items(), ) - peer_relations_meta = charm_spec.meta.get("peers", {}).items() + peer_relations_meta = meta.get("peers", {}).items() all_relations_meta = list(chain(nonpeer_relations_meta, peer_relations_meta)) def _get_relations(r): @@ -468,7 +484,8 @@ def check_containers_consistency( """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" # event names will be normalized; need to compare against normalized container names. - meta_containers = list(map(normalize_name, charm_spec.meta.get("containers", {}))) + meta = charm_spec.meta + meta_containers = list(map(normalize_name, meta.get("containers", {}))) state_containers = [normalize_name(c.name) for c in state.containers] errors = [] diff --git a/scenario/context.py b/scenario/context.py index 99c8a602a..fb61e931c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -5,20 +5,9 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ContextManager, - Dict, - List, - Optional, - Type, - Union, - cast, -) - -from ops import EventBase +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast + +from ops import CharmBase, EventBase from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime @@ -44,8 +33,9 @@ class ActionOutput: In most cases, actions are not expected to be affecting it.""" logs: List[str] """Any logs associated with the action output, set by the charm.""" - results: Dict[str, Any] - """Key-value mapping assigned by the charm as a result of the action.""" + results: Optional[Dict[str, Any]] + """Key-value mapping assigned by the charm as a result of the action. + Will be None if the charm never calls action-set.""" failure: Optional[str] = None """If the action is not a success: the message the charm set when failing the action.""" @@ -91,8 +81,12 @@ def __init__( self.output: Optional[Union["State", ActionOutput]] = None @property - def charm(self) -> "CharmType": - return self.ops.charm + def charm(self) -> CharmBase: + if not self.ops: + raise RuntimeError( + "you should __enter__ this contextmanager before accessing this", + ) + return cast(CharmBase, self.ops.charm) @property def _runner(self): @@ -153,7 +147,7 @@ def _runner(self): return self._ctx._run_action def _get_output(self): - return self._ctx._finalize_action(self._ctx._output_state) + return self._ctx._finalize_action(self._ctx.output_state) class Context: @@ -165,10 +159,12 @@ def __init__( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: "PathLike" = None, + charm_root: Optional["PathLike"] = None, juju_version: str = "3.0", capture_deferred_events: bool = False, capture_framework_events: bool = False, + app_name: Optional[str] = None, + unit_id: Optional[int] = 0, ): """Represents a simulated charm's execution context. @@ -222,6 +218,9 @@ def __init__( :arg config: charm config to use. Needs to be a valid config.yaml format (as a dict). If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. + :arg app_name: App name that this charm is deployed as. Defaults to the charm name as + defined in metadata.yaml. + :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -258,6 +257,8 @@ def __init__( self.charm_spec = spec self.charm_root = charm_root self.juju_version = juju_version + self._app_name = app_name + self._unit_id = unit_id self._tmp = tempfile.TemporaryDirectory() # config for what events to be captured in emitted_events. @@ -276,14 +277,27 @@ def __init__( self._output_state: Optional["State"] = None # ephemeral side effects from running an action - self._action_logs = [] - self._action_results = None - self._action_failure = None + + self._action_logs: List[str] = [] + self._action_results: Optional[Dict[str, str]] = None + self._action_failure: Optional[str] = None def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" self._output_state = output_state + @property + def output_state(self) -> "State": + """The output state obtained by running an event on this context. + + Will raise an exception if this Context hasn't been run yet. + """ + if not self._output_state: + raise RuntimeError( + "No output state available. ``.run()`` this Context first.", + ) + return self._output_state + def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name @@ -325,9 +339,9 @@ def cleanup(self): def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: - self.app_status_history.append(state.app_status) + self.app_status_history.append(cast("_EntityStatus", state.app_status)) else: - self.unit_status_history.append(state.unit_status) + self.unit_status_history.append(cast("_EntityStatus", state.unit_status)) @staticmethod def _coalesce_action(action: Union[str, Action]) -> Action: @@ -414,7 +428,7 @@ def _run_event( self, event: Union["Event", str], state: "State", - ) -> ContextManager["Ops"]: + ): _event = self._coalesce_event(event) with self._run(event=_event, state=state) as ops: yield ops @@ -423,8 +437,8 @@ def run( self, event: Union["Event", str], state: "State", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + pre_event: Optional[Callable[[CharmBase], None]] = None, + post_event: Optional[Callable[[CharmBase], None]] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -445,21 +459,21 @@ def run( with self._run_event(event=event, state=state) as ops: if pre_event: - pre_event(ops.charm) + pre_event(cast(CharmBase, ops.charm)) ops.emit() if post_event: - post_event(ops.charm) + post_event(cast(CharmBase, ops.charm)) - return self._output_state + return self.output_state def run_action( self, action: Union["Action", str], state: "State", - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, + pre_event: Optional[Callable[[CharmBase], None]] = None, + post_event: Optional[Callable[[CharmBase], None]] = None, ) -> ActionOutput: """Trigger a charm execution with an Action and a State. @@ -481,14 +495,14 @@ def run_action( _action = self._coalesce_action(action) with self._run_action(action=_action, state=state) as ops: if pre_event: - pre_event(ops.charm) + pre_event(cast(CharmBase, ops.charm)) ops.emit() if post_event: - post_event(ops.charm) + post_event(cast(CharmBase, ops.charm)) - return self._finalize_action(self._output_state) + return self._finalize_action(self.output_state) def _finalize_action(self, state_out: "State"): ao = ActionOutput( @@ -510,7 +524,7 @@ def _run_action( self, action: Union["Action", str], state: "State", - ) -> ContextManager["Ops"]: + ): _action = self._coalesce_action(action) with self._run(event=_action.event, state=state) as ops: yield ops @@ -520,11 +534,13 @@ def _run( self, event: "Event", state: "State", - ) -> ContextManager["Ops"]: + ): runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, charm_root=self.charm_root, + app_name=self._app_name, + unit_id=self._unit_id, ) with runtime.exec( state=state, diff --git a/scenario/mocking.py b/scenario/mocking.py index e5bd4298b..30bc97d0b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -6,7 +6,19 @@ import shutil from io import StringIO from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Set, + Tuple, + Union, + cast, +) from ops import JujuVersion, pebble from ops.model import ModelError, RelationNotFoundError @@ -22,7 +34,15 @@ from ops.testing import _TestingPebbleClient from scenario.logger import logger as scenario_logger -from scenario.state import JujuLogLine, Mount, PeerRelation, Port, Storage +from scenario.state import ( + JujuLogLine, + Mount, + PeerRelation, + Port, + Storage, + _RawPortProtocolLiteral, + _RawStatusLiteral, +) if TYPE_CHECKING: from scenario.context import Context @@ -47,7 +67,7 @@ class ActionMissingFromContextError(Exception): class _MockExecProcess: - def __init__(self, command: Tuple[str], change_id: int, out: "ExecOutput"): + def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"): self._command = command self._change_id = change_id self._out = out @@ -92,19 +112,27 @@ def __init__( def opened_ports(self) -> Set[Port]: return set(self._state.opened_ports) - def open_port(self, protocol: str, port: Optional[int] = None): + def open_port( + self, + protocol: "_RawPortProtocolLiteral", + port: Optional[int] = None, + ): # fixme: the charm will get hit with a StateValidationError # here, not the expected ModelError... - port = Port(protocol, port) + port_ = Port(protocol, port) ports = self._state.opened_ports - if port not in ports: - ports.append(port) + if port_ not in ports: + ports.append(port_) - def close_port(self, protocol: str, port: Optional[int] = None): - port = Port(protocol, port) + def close_port( + self, + protocol: "_RawPortProtocolLiteral", + port: Optional[int] = None, + ): + _port = Port(protocol, port) ports = self._state.opened_ports - if port in ports: - ports.remove(port) + if _port in ports: + ports.remove(_port) def get_pebble(self, socket_path: str) -> "Client": container_name = socket_path.split("/")[ @@ -187,6 +215,8 @@ def relation_get(self, relation_id: int, member_name: str, is_app: bool): if is_app and member_name == self.app_name: return relation.local_app_data elif is_app: + if isinstance(relation, PeerRelation): + return relation.local_app_data return relation.remote_app_data elif member_name == self.unit_name: return relation.local_unit_data @@ -198,8 +228,8 @@ def is_leader(self): return self._state.leader def status_get(self, *, is_app: bool = False): - status, message = self._state.app_status if is_app else self._state.unit_status - return {"status": status, "message": message} + status = self._state.app_status if is_app else self._state.unit_status + return {"status": status.name, "message": status.message} def relation_ids(self, relation_name): return [ @@ -208,16 +238,16 @@ def relation_ids(self, relation_name): if rel.endpoint == relation_name ] - def relation_list(self, relation_id: int) -> Tuple[str]: + def relation_list(self, relation_id: int) -> Tuple[str, ...]: relation = self._get_relation_by_id(relation_id) if isinstance(relation, PeerRelation): return tuple( f"{self.app_name}/{unit_id}" for unit_id in relation.peers_data ) + remote_name = self.relation_remote_app_name(relation_id) return tuple( - f"{relation.remote_app_name}/{unit_id}" - for unit_id in relation._remote_unit_ids + f"{remote_name}/{unit_id}" for unit_id in relation._remote_unit_ids ) def config_get(self): @@ -256,7 +286,13 @@ def application_version_set(self, version: str): self._state._update_workload_version(version) - def status_set(self, status: str, message: str = "", *, is_app: bool = False): + def status_set( + self, + status: _RawStatusLiteral, + message: str = "", + *, + is_app: bool = False, + ): self._context._record_status(self._state, is_app) self._state._update_status(status, message, is_app) @@ -285,7 +321,7 @@ def secret_add( description: Optional[str] = None, expire: Optional[datetime.datetime] = None, rotate: Optional[SecretRotate] = None, - owner: Optional[str] = None, + owner: Optional[Literal["unit", "application"]] = None, ) -> str: from scenario.state import Secret @@ -305,8 +341,8 @@ def secret_add( def secret_get( self, *, - id: str = None, - label: str = None, + id: Optional[str] = None, + label: Optional[str] = None, refresh: bool = False, peek: bool = False, ) -> Dict[str, str]: @@ -365,20 +401,26 @@ def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) if not secret.owner: raise RuntimeError(f"not the owner of {secret}") - grantee = unit or self._get_relation_by_id(relation_id).remote_app_name + grantee = unit or self.relation_remote_app_name( + relation_id, + _raise_on_error=True, + ) if not secret.remote_grants.get(relation_id): secret.remote_grants[relation_id] = set() - secret.remote_grants[relation_id].add(grantee) + secret.remote_grants[relation_id].add(cast(str, grantee)) def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None): secret = self._get_secret(id) if not secret.owner: raise RuntimeError(f"not the owner of {secret}") - grantee = unit or self._get_relation_by_id(relation_id).remote_app_name - secret.remote_grants[relation_id].remove(grantee) + grantee = unit or self.relation_remote_app_name( + relation_id, + _raise_on_error=True, + ) + secret.remote_grants[relation_id].remove(cast(str, grantee)) def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) @@ -390,13 +432,23 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None): else: secret.contents.clear() - def relation_remote_app_name(self, relation_id: int) -> Optional[str]: + def relation_remote_app_name( + self, + relation_id: int, + _raise_on_error=False, + ) -> Optional[str]: # ops catches relationnotfounderrors and returns None: try: relation = self._get_relation_by_id(relation_id) except RelationNotFoundError: + if _raise_on_error: + raise return None - return relation.remote_app_name + + if isinstance(relation, PeerRelation): + return self.app_name + else: + return relation.remote_app_name def action_set(self, results: Dict[str, Any]): if not self._event.action: @@ -548,7 +600,8 @@ def __init__( # initialize simulated filesystem container_root.mkdir(parents=True) for _, mount in mounts.items(): - mounting_dir = container_root / mount.location[1:] + path = Path(mount.location).parts + mounting_dir = container_root.joinpath(*path[1:]) mounting_dir.parent.mkdir(parents=True, exist_ok=True) mounting_dir.symlink_to(mount.src) @@ -579,7 +632,7 @@ def _layers(self) -> Dict[str, pebble.Layer]: def _service_status(self) -> Dict[str, pebble.ServiceStatus]: return self._container.service_status - def exec(self, *args, **kwargs): # noqa: U100 + def exec(self, *args, **kwargs): # noqa: U100 type: ignore cmd = tuple(args[0]) out = self._container.exec_mock.get(cmd) if not out: @@ -594,7 +647,7 @@ def exec(self, *args, **kwargs): # noqa: U100 return _MockExecProcess(change_id=change_id, command=cmd, out=out) def _check_connection(self): - if not self._container.can_connect: # pyright: reportPrivateUsage=false + if not self._container.can_connect: msg = ( f"Cannot connect to Pebble; did you forget to set " f"can_connect=True for container {self._container.name}?" diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 73780940c..4e1b7e123 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,7 +3,7 @@ # See LICENSE file for licensing details. import inspect import os -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any, Optional, Sequence, cast import ops.charm import ops.framework @@ -21,6 +21,8 @@ from scenario.context import Context from scenario.state import Event, State, _CharmSpec +# pyright: reportPrivateUsage=false + class NoObserverError(RuntimeError): """Error raised when the event being dispatched has no registered observers.""" @@ -51,7 +53,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: def _emit_charm_event( charm: "CharmBase", event_name: str, - event: "Event" = None, + event: Optional["Event"] = None, ): """Emits a charm event based on a Juju event name. @@ -86,7 +88,7 @@ def setup_framework( ): from scenario.mocking import _MockModelBackend - model_backend = _MockModelBackend( # pyright: reportPrivateUsage=false + model_backend = _MockModelBackend( state=state, event=event, context=context, @@ -113,7 +115,7 @@ def setup_framework( # TODO: add use_juju_for_storage support store = ops.storage.SQLiteStorage(charm_state_path) - framework = ops.framework.Framework(store, charm_dir, meta, model) + framework = ops.Framework(store, charm_dir, meta, model) framework.set_breakpointhook() return framework @@ -156,9 +158,9 @@ def __init__( self.charm_spec = charm_spec # set by setup() - self.dispatcher = None - self.framework = None - self.charm = None + self.dispatcher: Optional[_Dispatcher] = None + self.framework: Optional[ops.Framework] = None + self.charm: Optional[ops.CharmBase] = None self._has_setup = False self._has_emitted = False @@ -180,14 +182,18 @@ def emit(self): raise RuntimeError("should .setup() before you .emit()") self._has_emitted = True + dispatcher = cast(_Dispatcher, self.dispatcher) + charm = cast(CharmBase, self.charm) + framework = cast(ops.Framework, self.framework) + try: - if not self.dispatcher.is_restricted_context(): - self.framework.reemit() + if not dispatcher.is_restricted_context(): + framework.reemit() - _emit_charm_event(self.charm, self.dispatcher.event_name, self.event) + _emit_charm_event(charm, dispatcher.event_name, self.event) except Exception: - self.framework.close() + framework.close() raise def commit(self): @@ -195,15 +201,18 @@ def commit(self): if not self._has_emitted: raise RuntimeError("should .emit() before you .commit()") + framework = cast(ops.Framework, self.framework) + charm = cast(CharmBase, self.charm) + # emit collect-status events - ops.charm._evaluate_status(self.charm) + ops.charm._evaluate_status(charm) self._has_committed = True try: - self.framework.commit() + framework.commit() finally: - self.framework.close() + framework.close() def finalize(self): """Step through all non-manually-called procedures and run them.""" diff --git a/scenario/runtime.py b/scenario/runtime.py index 108526131..48e47c4bd 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -8,20 +8,10 @@ import typing from contextlib import contextmanager from pathlib import Path -from typing import ( - TYPE_CHECKING, - ContextManager, - Dict, - List, - Optional, - Tuple, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union import yaml -from ops.framework import EventBase, _event_regex +from ops.framework import _event_regex from ops.storage import NoSnapshotError, SQLiteStorage from scenario.capture_events import capture_events @@ -33,10 +23,7 @@ from ops.testing import CharmType from scenario.context import Context - from scenario.ops_main_mock import Ops - from scenario.state import AnyRelation, Event, State, _CharmSpec - - _CT = TypeVar("_CT", bound=Type[CharmType]) + from scenario.state import Event, State, _CharmSpec PathLike = Union[str, Path] @@ -68,7 +55,7 @@ def __init__(self, db_path: Union[Path, str]): self._db_path = db_path self._state_file = Path(self._db_path) - def _open_db(self) -> Optional[SQLiteStorage]: + def _open_db(self) -> SQLiteStorage: """Open the db.""" return SQLiteStorage(self._state_file) @@ -168,16 +155,19 @@ def __init__( charm_spec: "_CharmSpec", charm_root: Optional["PathLike"] = None, juju_version: str = "3.0.0", + app_name: Optional[str] = None, + unit_id: Optional[int] = 0, ): self._charm_spec = charm_spec self._juju_version = juju_version self._charm_root = charm_root - app_name = self._charm_spec.meta.get("name") + app_name = app_name or self._charm_spec.meta.get("name") if not app_name: raise ValueError('invalid metadata: mandatory "name" field is missing.') self._app_name = app_name + self._unit_id = unit_id @staticmethod def _cleanup_env(env): @@ -199,7 +189,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env = { "JUJU_VERSION": self._juju_version, - "JUJU_UNIT_NAME": f"{self._app_name}/{state.unit_id}", + "JUJU_UNIT_NAME": f"{self._app_name}/{self._unit_id}", "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{event.name}", "JUJU_MODEL_NAME": state.model.name, @@ -209,8 +199,6 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): # todo consider setting pwd, (python)path } - relation: "AnyRelation" - if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): remote_app_name = self._app_name @@ -273,7 +261,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): return env @staticmethod - def _wrap(charm_type: "_CT") -> "_CT": + def _wrap(charm_type: Type["CharmType"]) -> Type["CharmType"]: # dark sorcery to work around framework using class attrs to hold on to event sources # todo this should only be needed if we call play multiple times on the same runtime. # can we avoid it? @@ -286,10 +274,10 @@ class WrappedCharm(charm_type): # type: ignore on = WrappedEvents() WrappedCharm.__name__ = charm_type.__name__ - return WrappedCharm + return typing.cast(Type["CharmType"], WrappedCharm) @contextmanager - def _virtual_charm_root(self) -> typing.ContextManager[Path]: + def _virtual_charm_root(self): # If we are using runtime on a real charm, we can make some assumptions about the # directory structure we are going to find. # If we're, say, dynamically defining charm types and doing tests on them, we'll have to @@ -356,7 +344,8 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]: file.write_text(previous_content) else: - charm_virtual_root.cleanup() + # charm_virtual_root is a tempdir + typing.cast(tempfile.TemporaryDirectory, charm_virtual_root).cleanup() @staticmethod def _get_state_db(temporary_charm_root: Path): @@ -376,7 +365,7 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): return state.replace(deferred=deferred, stored_state=stored_state) @contextmanager - def _exec_ctx(self, ctx: "Context") -> ContextManager[Tuple[Path, List[EventBase]]]: + def _exec_ctx(self, ctx: "Context"): """python 3.8 compatibility shim""" with self._virtual_charm_root() as temporary_charm_root: # todo allow customizing capture_events @@ -392,7 +381,7 @@ def exec( state: "State", event: "Event", context: "Context", - ) -> ContextManager["Ops"]: + ): """Runs an event with this state as initial state on a charm. Returns the 'output state', that is, the state as mutated by the charm during the @@ -428,7 +417,7 @@ def exec( os.environ.update(env) logger.info(" - Entering ops.main (mocked).") - from scenario.ops_main_mock import Ops + from scenario.ops_main_mock import Ops # noqa: F811 try: ops = Ops( diff --git a/scenario/sequences.py b/scenario/sequences.py index 3230ded94..5ba8a1d14 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -22,7 +22,6 @@ from ops.testing import CharmType CharmMeta = Optional[Union[str, TextIO, dict]] - logger = scenario_logger.getChild("scenario") diff --git a/scenario/state.py b/scenario/state.py index e5fb1d1ce..132f90daa 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -6,28 +6,41 @@ import datetime import inspect import re -import typing from collections import namedtuple from enum import Enum from pathlib import Path, PurePosixPath -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Generic, + List, + Literal, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) from uuid import uuid4 import yaml from ops import pebble -from ops.charm import CharmEvents +from ops.charm import CharmBase, CharmEvents from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) -if typing.TYPE_CHECKING: +if TYPE_CHECKING: try: - from typing import Self + from typing import Self # type: ignore except ImportError: from typing_extensions import Self - from ops.testing import CharmType from scenario import Context @@ -37,6 +50,8 @@ RawSecretRevisionContents = RawDataBagContents = Dict[str, str] UnitID = int +CharmType = TypeVar("CharmType", bound=CharmBase) + logger = scenario_logger.getChild("state") ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES" @@ -150,7 +165,7 @@ class Secret(_DCBase): label: Optional[str] = None description: Optional[str] = None expire: Optional[datetime.datetime] = None - rotate: SecretRotate = SecretRotate.NEVER + rotate: Optional[SecretRotate] = None # consumer-only events @property @@ -160,7 +175,7 @@ def changed_event(self): raise ValueError( "This unit will never receive secret-changed for a secret it owns.", ) - return Event(name="secret_changed", secret=self) + return Event("secret_changed", secret=self) # owner-only events @property @@ -170,7 +185,7 @@ def rotate_event(self): raise ValueError( "This unit will never receive secret-rotate for a secret it does not own.", ) - return Event(name="secret_rotate", secret=self) + return Event("secret_rotate", secret=self) @property def expired_event(self): @@ -179,7 +194,7 @@ def expired_event(self): raise ValueError( "This unit will never receive secret-expire for a secret it does not own.", ) - return Event(name="secret_expire", secret=self) + return Event("secret_expire", secret=self) @property def remove_event(self): @@ -188,7 +203,7 @@ def remove_event(self): raise ValueError( "This unit will never receive secret-removed for a secret it does not own.", ) - return Event(name="secret_removed", secret=self) + return Event("secret_removed", secret=self) def _set_revision(self, revision: int): """Set a new tracked revision.""" @@ -240,7 +255,7 @@ class RelationBase(_DCBase): endpoint: str # we can derive this from the charm's metadata - interface: str = None + interface: Optional[str] = None # Every new Relation instance gets a new one, if there's trouble, override. relation_id: int = dataclasses.field(default_factory=next_relation_id) @@ -293,7 +308,7 @@ def changed_event(self) -> "Event": """Sugar to generate a -relation-changed event.""" return Event( path=normalize_name(self.endpoint + "-relation-changed"), - relation=self, + relation=cast("AnyRelation", self), ) @property @@ -301,7 +316,7 @@ def joined_event(self) -> "Event": """Sugar to generate a -relation-joined event.""" return Event( path=normalize_name(self.endpoint + "-relation-joined"), - relation=self, + relation=cast("AnyRelation", self), ) @property @@ -309,7 +324,7 @@ def created_event(self) -> "Event": """Sugar to generate a -relation-created event.""" return Event( path=normalize_name(self.endpoint + "-relation-created"), - relation=self, + relation=cast("AnyRelation", self), ) @property @@ -317,7 +332,7 @@ def departed_event(self) -> "Event": """Sugar to generate a -relation-departed event.""" return Event( path=normalize_name(self.endpoint + "-relation-departed"), - relation=self, + relation=cast("AnyRelation", self), ) @property @@ -325,7 +340,7 @@ def broken_event(self) -> "Event": """Sugar to generate a -relation-broken event.""" return Event( path=normalize_name(self.endpoint + "-relation-broken"), - relation=self, + relation=cast("AnyRelation", self), ) @@ -347,11 +362,11 @@ def _remote_app_name(self) -> str: return self.remote_app_name @property - def _remote_unit_ids(self) -> Tuple[int]: + def _remote_unit_ids(self) -> Tuple["UnitID", ...]: """Ids of the units on the other end of this relation.""" return tuple(self.remote_units_data) - def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents": + def _get_databag_for_remote(self, unit_id: "UnitID") -> "RawDataBagContents": """Return the databag for some remote unit ID.""" return self.remote_units_data[unit_id] @@ -416,11 +431,11 @@ def _databags(self): yield from self.peers_data.values() @property - def _remote_unit_ids(self) -> Tuple[int]: + def _remote_unit_ids(self) -> Tuple["UnitID", ...]: """Ids of the units on the other end of this relation.""" return tuple(self.peers_data) - def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents": + def _get_databag_for_remote(self, unit_id: "UnitID") -> "RawDataBagContents": """Return the databag for some remote unit ID.""" return self.peers_data[unit_id] @@ -658,13 +673,23 @@ def default( ) +_RawStatusLiteral = Literal[ + "waiting", + "blocked", + "active", + "unknown", + "error", + "maintenance", +] + + @dataclasses.dataclass(frozen=True) class _EntityStatus(_DCBase): """This class represents StatusBase and should not be interacted with directly.""" # Why not use StatusBase directly? Because that's not json-serializable. - name: Literal["waiting", "blocked", "active", "unknown", "error", "maintenance"] + name: _RawStatusLiteral message: str = "" def __eq__(self, other): @@ -681,9 +706,6 @@ def __eq__(self, other): ) return super().__eq__(other) - def __iter__(self): - return iter([self.name, self.message]) - def __repr__(self): status_type_name = self.name.title() + "Status" if self.name == "unknown": @@ -700,7 +722,7 @@ class _MyClass(_EntityStatus, statusbase_subclass): # isinstance(state.unit_status, ops.ActiveStatus) pass - return _MyClass(obj.name, obj.message) + return _MyClass(cast(_RawStatusLiteral, obj.name), obj.message) @dataclasses.dataclass(frozen=True) @@ -719,11 +741,14 @@ def handle_path(self): return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" +_RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] + + @dataclasses.dataclass(frozen=True) class Port(_DCBase): """Represents a port on the charm host.""" - protocol: Literal["tcp", "udp", "icmp"] + protocol: _RawPortProtocolLiteral port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" @@ -829,8 +854,7 @@ class State(_DCBase): planned_units: int = 1 """Number of non-dying planned units that are expected to be running this application. Use with caution.""" - unit_id: int = 0 - """ID of the unit hosting this charm.""" + # represents the OF's event queue. These events will be emitted before the event being # dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended @@ -868,7 +892,7 @@ def _update_workload_version(self, new_workload_version: str): def _update_status( self, - new_status: str, + new_status: _RawStatusLiteral, new_message: str = "", is_app: bool = False, ): @@ -892,7 +916,7 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return self.replace( status=dataclasses.replace( - self.unit_status, + cast(_EntityStatus, self.unit_status), unit=_status_to_entitystatus(status), ), ) @@ -927,7 +951,7 @@ def get_storages(self, name: str) -> Tuple["Storage", ...]: # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. def jsonpatch_delta(self, other: "State"): try: - import jsonpatch + import jsonpatch # type: ignore except ModuleNotFoundError: logger.error( "cannot import jsonpatch: using the .delta() " @@ -943,20 +967,20 @@ def jsonpatch_delta(self, other: "State"): @dataclasses.dataclass(frozen=True) -class _CharmSpec(_DCBase): +class _CharmSpec(_DCBase, Generic[CharmType]): """Charm spec.""" - charm_type: Type["CharmType"] - meta: Optional[Dict[str, Any]] + charm_type: Type[CharmBase] + meta: Dict[str, Any] actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None - # autoloaded means: trigger() is being invoked on a 'real' charm class, living in some + # autoloaded means: we are running a 'real' charm class, living in some # /src/charm.py, and the metadata files are 'real' metadata files. is_autoloaded: bool = False @staticmethod - def autoload(charm_type: Type["CharmType"]): + def autoload(charm_type: Type[CharmBase]) -> "_CharmSpec[CharmType]": charm_source_path = Path(inspect.getfile(charm_type)) charm_root = charm_source_path.parent.parent @@ -1017,6 +1041,14 @@ class _EventType(str, Enum): class _EventPath(str): + if TYPE_CHECKING: + name: str + owner_path: List[str] + suffix: str + prefix: str + is_custom: bool + type: _EventType + def __new__(cls, string): string = normalize_name(string) instance = super().__new__(cls, string) @@ -1036,7 +1068,7 @@ def __new__(cls, string): return instance @staticmethod - def _get_suffix_and_type(s: str): + def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: for suffix in RELATION_EVENTS_SUFFIX: if s.endswith(suffix): return suffix, _EventType.relation @@ -1068,7 +1100,7 @@ def _get_suffix_and_type(s: str): @dataclasses.dataclass(frozen=True) class Event(_DCBase): path: str - args: Tuple[Any] = () + args: Tuple[Any, ...] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) # if this is a storage event, the storage it refers to @@ -1110,7 +1142,7 @@ def __post_init__(self): @property def _path(self) -> _EventPath: # we converted it in __post_init__, but the type checker doesn't know about that - return typing.cast(_EventPath, self.path) + return cast(_EventPath, self.path) @property def name(self) -> str: @@ -1260,17 +1292,26 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: # relation event but not *be* one. if self._is_workload_event: # this is a WorkloadEvent. The snapshot: + container = cast(Container, self.container) snapshot_data = { - "container_name": self.container.name, + "container_name": container.name, } elif self._is_relation_event: - # this is a RelationEvent. The snapshot: + # this is a RelationEvent. + relation = cast("AnyRelation", self.relation) + if isinstance(relation, PeerRelation): + # FIXME: relation.unit for peers should point to , but we + # don't have access to the local app name in this context. + remote_app = "local" + else: + remote_app = relation.remote_app_name + snapshot_data = { - "relation_name": self.relation.endpoint, - "relation_id": self.relation.relation_id, - "app_name": self.relation.remote_app_name, - "unit_name": f"{self.relation.remote_app_name}/{self.relation_remote_unit_id}", + "relation_name": relation.endpoint, + "relation_id": relation.relation_id, + "app_name": remote_app, + "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", } return DeferredEvent( @@ -1297,8 +1338,8 @@ def deferred( event: Union[str, Event], handler: Callable, event_id: int = 1, - relation: "Relation" = None, - container: "Container" = None, + relation: Optional["Relation"] = None, + container: Optional["Container"] = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 65ff83d2f..5fb2f9c10 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -7,10 +7,12 @@ import pytest import yaml +from ops import CharmBase from ops.testing import CharmType from scenario import Context, Relation, State from scenario.context import ContextSetupError +from scenario.state import _CharmSpec CHARM = """ from ops import CharmBase diff --git a/tests/test_context.py b/tests/test_context.py index 76a24eec8..cd00a5401 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,5 +1,6 @@ from unittest.mock import patch +import pytest from ops import CharmBase from scenario import Action, Context, Event, State @@ -50,3 +51,13 @@ def test_clear(): ctx.clear() assert not ctx.emitted_events # and others... + + +@pytest.mark.parametrize("app_name", ("foo", "bar", "george")) +@pytest.mark.parametrize("unit_id", (1, 2, 42)) +def test_app_name(app_name, unit_id): + with Context( + MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id + ).manager("start", State()) as mgr: + assert mgr.charm.app.name == app_name + assert mgr.charm.unit.name == f"{app_name}/{unit_id}" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 6d3eee4da..1fa0e8849 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -78,10 +78,12 @@ def test_unit_name(app_name, unit_id): my_charm_type, meta=meta, ), + unit_id=unit_id, + app_name=app_name, ) with runtime.exec( - state=State(unit_id=unit_id), + state=State(), event=Event("start"), context=Context(my_charm_type, meta=meta), ) as ops: diff --git a/tox.ini b/tox.ini index da9c40ac7..e3e93f3d3 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ env_list = unit lint lint-tests + static skip_missing_interpreters = true [vars] @@ -39,6 +40,14 @@ commands = pre-commit run --all-files {posargs} python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' +[testenv:static] +description = Static typing checks. +skip_install = true +deps = + pyright +commands = + pyright scenario + [testenv:lint-tests] description = Lint test files. skip_install = true From 175b91070837774657cca7e1467a686efc762130 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 10:05:56 +0100 Subject: [PATCH 382/546] fixed tests --- tests/test_context.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index cd00a5401..9fd174c05 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -15,7 +15,9 @@ def test_run(): state = State() with patch.object(ctx, "_run") as p: - ctx.run("start", state) + ctx._output_state = "foo" # would normally be set within the _run call scope + output = ctx.run("start", state) + assert output == "foo" assert p.called e = p.call_args.kwargs["event"] @@ -31,7 +33,9 @@ def test_run_action(): state = State() with patch.object(ctx, "_run_action") as p: - ctx.run_action("do-foo", state) + ctx._output_state = "foo" # would normally be set within the _run_action call scope + output = ctx.run_action("do-foo", state) + assert output.state == "foo" assert p.called a = p.call_args.kwargs["action"] From 4669fc1f5563c59897d9a8baec6d9e6ab44f5407 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 10:07:07 +0100 Subject: [PATCH 383/546] fixed tests --- tests/test_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_context.py b/tests/test_context.py index 9fd174c05..86d76d14b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -33,7 +33,9 @@ def test_run_action(): state = State() with patch.object(ctx, "_run_action") as p: - ctx._output_state = "foo" # would normally be set within the _run_action call scope + ctx._output_state = ( + "foo" # would normally be set within the _run_action call scope + ) output = ctx.run_action("do-foo", state) assert output.state == "foo" From bd28d7933a8e090d69104bc461f512678de4e6b2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 10:10:01 +0100 Subject: [PATCH 384/546] pr comments --- scenario/state.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 7ec2f951a..6c38076bd 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -241,7 +241,7 @@ class BindAddress(_DCBase): def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would - # todo support for legacy (deprecated `interfacename` and `macaddress` fields? + # todo support for legacy (deprecated) `interfacename` and `macaddress` fields? dct = { "interface-name": self.interface_name, "addresses": [dataclasses.asdict(addr) for addr in self.addresses], @@ -268,13 +268,13 @@ def hook_tool_output_fmt(self): @classmethod def default( cls, - private_address: str = "1.1.1.1", + private_address: str = "192.0.2.0", hostname: str = "", cidr: str = "", interface_name: str = "", mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), + egress_subnets=("192.0.2.0/24",), + ingress_addresses=("192.0.2.0",), ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( From 3af35d0e44a77b96999c872da9cb3e0fc906e201 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 10:25:03 +0100 Subject: [PATCH 385/546] fixed network tests --- tests/test_e2e/test_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 997ad3315..b0f7b8a4f 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -57,12 +57,12 @@ def test_ip_get(mycharm): ) as mgr: # we have a network for the relation rel = mgr.charm.model.get_relation("metrics-endpoint") - assert str(mgr.charm.model.get_binding(rel).network.bind_address) == "1.1.1.1" + assert str(mgr.charm.model.get_binding(rel).network.bind_address) == "192.0.2.0" # we have a network for a binding without relations on it assert ( str(mgr.charm.model.get_binding("deadnodead").network.bind_address) - == "1.1.1.1" + == "192.0.2.0" ) # and an extra binding From 3ea69aa1f8eb2dde814b55b5ef288e581d9731ad Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 10:34:49 +0100 Subject: [PATCH 386/546] install ops on type check tox env --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index e3e93f3d3..aac315e6a 100644 --- a/tox.ini +++ b/tox.ini @@ -44,6 +44,7 @@ commands = description = Static typing checks. skip_install = true deps = + ops pyright commands = pyright scenario From 904c77a13001f91f24f89557adb69f0049e69308 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 5 Dec 2023 11:32:52 +0100 Subject: [PATCH 387/546] better coverage --- scenario/consistency_checker.py | 2 +- scenario/context.py | 6 +-- scenario/mocking.py | 11 +++-- scenario/ops_main_mock.py | 2 +- scenario/runtime.py | 3 +- scenario/sequences.py | 2 +- scenario/state.py | 75 +-------------------------------- tests/helpers.py | 2 +- tests/test_e2e/test_network.py | 1 - tests/test_e2e/test_pebble.py | 66 ++++++++++++++++++++++++++++- tests/test_e2e/test_secrets.py | 53 +++++++++++++++++------ tox.ini | 2 +- 12 files changed, 124 insertions(+), 101 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 29cb1e5a3..ea9ad4897 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -17,7 +17,7 @@ normalize_name, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from scenario.state import Event, State logger = scenario_logger.getChild("consistency_checker") diff --git a/scenario/context.py b/scenario/context.py index fb61e931c..d26e5582b 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -13,7 +13,7 @@ from scenario.runtime import Runtime from scenario.state import Action, Event, MetadataNotFoundError, _CharmSpec -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType from scenario.ops_main_mock import Ops @@ -123,7 +123,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 class _EventManager(_Manager): - if TYPE_CHECKING: + if TYPE_CHECKING: # pragma: no cover output: State def run(self) -> "State": @@ -137,7 +137,7 @@ def _get_output(self): class _ActionManager(_Manager): - if TYPE_CHECKING: + if TYPE_CHECKING: # pragma: no cover output: ActionOutput def run(self) -> "ActionOutput": diff --git a/scenario/mocking.py b/scenario/mocking.py index fe986a7ca..81355fb07 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -37,6 +37,7 @@ from scenario.state import ( JujuLogLine, Mount, + Network, PeerRelation, Port, Storage, @@ -44,7 +45,7 @@ _RawStatusLiteral, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( @@ -346,9 +347,9 @@ def secret_add( ) -> str: from scenario.state import Secret - id = self._generate_secret_id() + secret_id = self._generate_secret_id() secret = Secret( - id=id, + id=secret_id, contents={0: content}, label=label, description=description, @@ -357,7 +358,7 @@ def secret_add( owner=owner, ) self._state.secrets.append(secret) - return id + return secret_id def secret_get( self, @@ -442,6 +443,8 @@ def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None _raise_on_error=True, ) secret.remote_grants[relation_id].remove(cast(str, grantee)) + if not secret.remote_grants[relation_id]: + del secret.remote_grants[relation_id] def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 4e1b7e123..95f678c67 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -17,7 +17,7 @@ from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_charm_dir, _get_event_args from ops.main import logger as ops_logger -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import Event, State, _CharmSpec diff --git a/scenario/runtime.py b/scenario/runtime.py index 48e47c4bd..489169b61 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -19,7 +19,7 @@ from scenario.ops_main_mock import NoObserverError from scenario.state import DeferredEvent, PeerRelation, StoredState -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType from scenario.context import Context @@ -452,6 +452,5 @@ def exec( output_state = self._close_storage(output_state, temporary_charm_root) context.emitted_events.extend(captured) - logger.info("event dispatched. done.") context._set_output_state(output_state) diff --git a/scenario/sequences.py b/scenario/sequences.py index 5ba8a1d14..448d5a2d9 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -18,7 +18,7 @@ State, ) -if typing.TYPE_CHECKING: +if typing.TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType CharmMeta = Optional[Union[str, TextIO, dict]] diff --git a/scenario/state.py b/scenario/state.py index 8d54d3490..e582b9a27 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -37,7 +37,7 @@ JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover try: from typing import Self # type: ignore except ImportError: @@ -676,77 +676,6 @@ def pebble_ready_event(self): return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) -@dataclasses.dataclass(frozen=True) -class Address(_DCBase): - hostname: str - value: str - cidr: str - address: str = "" # legacy - - -@dataclasses.dataclass(frozen=True) -class BindAddress(_DCBase): - interface_name: str - addresses: List[Address] - mac_address: Optional[str] = None - - def hook_tool_output_fmt(self): - # dumps itself to dict in the same format the hook tool would - # todo support for legacy (deprecated `interfacename` and `macaddress` fields? - dct = { - "interface-name": self.interface_name, - "addresses": [dataclasses.asdict(addr) for addr in self.addresses], - } - if self.mac_address: - dct["mac-address"] = self.mac_address - return dct - - -@dataclasses.dataclass(frozen=True) -class Network(_DCBase): - name: str - - bind_addresses: List[BindAddress] - ingress_addresses: List[str] - egress_subnets: List[str] - - def hook_tool_output_fmt(self): - # dumps itself to dict in the same format the hook tool would - return { - "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], - "egress-subnets": self.egress_subnets, - "ingress-addresses": self.ingress_addresses, - } - - @classmethod - def default( - cls, - name, - private_address: str = "1.1.1.1", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("1.1.1.2/32",), - ingress_addresses=("1.1.1.2",), - ) -> "Network": - """Helper to create a minimal, heavily defaulted Network.""" - return cls( - name=name, - bind_addresses=[ - BindAddress( - interface_name=interface_name, - mac_address=mac_address, - addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr), - ], - ), - ], - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) - - _RawStatusLiteral = Literal[ "waiting", "blocked", @@ -1133,7 +1062,7 @@ class _EventType(str, Enum): class _EventPath(str): - if TYPE_CHECKING: + if TYPE_CHECKING: # pragma: no cover name: str owner_path: List[str] suffix: str diff --git a/tests/helpers.py b/tests/helpers.py index 04fc2f2b2..47fbc9f43 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -14,7 +14,7 @@ from scenario.context import Context -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType from scenario.state import Event, State diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index b0f7b8a4f..07808e1de 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -5,7 +5,6 @@ from scenario import Context from scenario.state import Network, Relation, State, SubordinateRelation -from tests.helpers import trigger @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 5e2ee3fe2..b87b350df 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -5,7 +5,7 @@ from ops import pebble from ops.charm import CharmBase from ops.framework import Framework -from ops.pebble import ServiceStartup, ServiceStatus +from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context from scenario.state import Container, ExecOutput, Mount, Port, State @@ -301,3 +301,67 @@ def callback(self: CharmBase): assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED + + +def test_exec_wait_error(charm_cls): + state = State( + containers=[ + Container( + name="foo", + can_connect=True, + exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + ) + ] + ) + + with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( + "start", state + ) as mgr: + container = mgr.charm.unit.get_container("foo") + proc = container.exec(["foo"]) + with pytest.raises(ExecError): + proc.wait() + assert proc.stdout.read() == "hello pebble" + + +def test_exec_wait_output(charm_cls): + state = State( + containers=[ + Container( + name="foo", + can_connect=True, + exec_mock={ + ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie") + }, + ) + ] + ) + + with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( + "start", state + ) as mgr: + container = mgr.charm.unit.get_container("foo") + proc = container.exec(["foo"]) + out, err = proc.wait_output() + assert out == "hello pebble" + assert err == "oepsie" + + +def test_exec_wait_output_error(charm_cls): + state = State( + containers=[ + Container( + name="foo", + can_connect=True, + exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + ) + ] + ) + + with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( + "start", state + ) as mgr: + container = mgr.charm.unit.get_container("foo") + proc = container.exec(["foo"]) + with pytest.raises(ExecError): + proc.wait_output() diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 780ca9904..5f2fa9d09 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -281,19 +281,48 @@ def post_event(charm: CharmBase): ) -class GrantingCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.start, self._on_start) +def test_add_grant_revoke_remove(): + class GrantingCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) - def _on_start(self, _): - secret = self.app.add_secret({"foo": "bar"}) - secret.grant(self.model.relations["bar"][0]) - - -def test_grant_after_add(): context = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) - state = State(relations=[Relation("bar")]) - context.run("start", state) + relation_remote_app = "remote_secret_desirerer" + relation_id = 42 + + state = State( + relations=[ + Relation( + "bar", remote_app_name=relation_remote_app, relation_id=relation_id + ) + ] + ) + + with context.manager("start", state) as mgr: + charm = mgr.charm + secret = charm.app.add_secret({"foo": "bar"}, label="mylabel") + bar_relation = charm.model.relations["bar"][0] + + secret.grant(bar_relation) + + assert mgr.output.secrets + scenario_secret = mgr.output.secrets[0] + assert scenario_secret.granted is False + assert relation_remote_app in scenario_secret.remote_grants[relation_id] + + with context.manager("start", mgr.output) as mgr: + charm: GrantingCharm = mgr.charm + secret = charm.model.get_secret(label="mylabel") + secret.revoke(bar_relation) + + scenario_secret = mgr.output.secrets[0] + assert scenario_secret.remote_grants == {} + + with context.manager("start", mgr.output) as mgr: + charm: GrantingCharm = mgr.charm + secret = charm.model.get_secret(label="mylabel") + secret.remove_all_revisions() + + assert not mgr.output.secrets[0].contents # secret wiped diff --git a/tox.ini b/tox.ini index aac315e6a..1f362e023 100644 --- a/tox.ini +++ b/tox.ini @@ -29,7 +29,7 @@ deps = setenv = PYTHONPATH = {toxinidir} commands = - pytest --cov-report html -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} + pytest --cov-report html:.cov_html -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path} [testenv:lint] description = Format the code base to adhere to our styles, and complain about what we cannot do automatically. From 1d4613bbf8e4e481018793305f5eba697ab4e399 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 Jan 2024 11:57:54 +0100 Subject: [PATCH 388/546] pr comments --- scenario/context.py | 4 +- scenario/mocking.py | 22 ++++++++-- scenario/state.py | 28 ++++++++----- tests/test_e2e/test_secrets.py | 75 +++++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index ebdecb939..edf9ea97c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -34,6 +34,8 @@ logger = scenario_logger.getChild("runtime") +DEFAULT_JUJU_VERSION = "3.4" + @dataclasses.dataclass class ActionOutput: @@ -166,7 +168,7 @@ def __init__( actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: "PathLike" = None, - juju_version: str = "3.0", + juju_version: str = DEFAULT_JUJU_VERSION, capture_deferred_events: bool = False, capture_framework_events: bool = False, ): diff --git a/scenario/mocking.py b/scenario/mocking.py index 11b18a9fb..bdd18be3d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -139,6 +139,14 @@ def _get_relation_by_id( raise RelationNotFoundError() def _get_secret(self, id=None, label=None): + # FIXME: what error would a charm get IRL? + # ops 2.0 supports Secret, but juju only supports it from 3.0.2 + if self._context.juju_version < "3.0.2": + raise RuntimeError( + "secrets are only available in juju >= 3.0.2." + "Set ``Context.juju_version`` to 3.0.2+ to use them.", + ) + canonicalize_id = Secret_Ops._canonicalize_id if id: @@ -309,12 +317,16 @@ def _check_can_manage_secret( ): # FIXME: match real tracebacks if secret.owner is None: - raise SecretNotFoundError("this secret is not owned by this unit/app") - if secret.owner == "app" and not self.is_leader(): raise SecretNotFoundError( + "this secret is not owned by this unit/app or granted to it", + ) + if secret.owner == "app" and not self.is_leader(): + understandable_error = SecretNotFoundError( f"App-owned secret {secret.id!r} can only be " f"managed by the leader.", ) + # charm-facing side: respect ops error + raise ModelError("ERROR permission denied") from understandable_error def secret_get( self, @@ -326,8 +338,10 @@ def secret_get( ) -> Dict[str, str]: secret = self._get_secret(id, label) - if self._context.juju_version <= "3.2": - # in juju<3.2, secret owners always track the latest revision. + if self._context.juju_version < "3.1.7": + # in this medieval juju chapter, + # secret owners always used to track the latest revision. + # ref: https://bugs.launchpad.net/juju/+bug/2037120 if secret.owner is not None: refresh = True diff --git a/scenario/state.py b/scenario/state.py index 6f641732c..ecbf41953 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,6 +7,7 @@ import inspect import re import typing +import warnings from collections import namedtuple from enum import Enum from pathlib import Path, PurePosixPath @@ -138,7 +139,8 @@ class Secret(_DCBase): # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None - # deprecated! + # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether. + # this attribute will be removed in Scenario 7+ granted: Literal["unit", "app", False] = "" # what revision is currently tracked by this charm. Only meaningful if owner=False @@ -155,16 +157,22 @@ class Secret(_DCBase): def __post_init__(self): if self.granted != "": - logger.warning( - "``state.Secret.granted`` is deprecated and will be removed in Scenario 6. " + msg = ( + "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " "If a Secret is not owned by the app/unit you are testing, nor has been granted to " - "it by the (remote) owner, then omit it from ``State.secrets`` altogether.", + "it by the (remote) owner, then omit it from ``State.secrets`` altogether." ) + logger.warning(msg) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + if self.owner == "application": - logger.warning( + msg = ( "Secret.owner='application' is deprecated in favour of 'app' " - "and will be removed in Scenario 6.", + "and will be removed in Scenario 7+." ) + logger.warning(msg) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + # bypass frozen dataclass object.__setattr__(self, "owner", "app") @@ -176,7 +184,7 @@ def changed_event(self): raise ValueError( "This unit will never receive secret-changed for a secret it owns.", ) - return Event(name="secret_changed", secret=self) + return Event("secret_changed", secret=self) # owner-only events @property @@ -186,7 +194,7 @@ def rotate_event(self): raise ValueError( "This unit will never receive secret-rotate for a secret it does not own.", ) - return Event(name="secret_rotate", secret=self) + return Event("secret_rotate", secret=self) @property def expired_event(self): @@ -195,7 +203,7 @@ def expired_event(self): raise ValueError( "This unit will never receive secret-expire for a secret it does not own.", ) - return Event(name="secret_expire", secret=self) + return Event("secret_expire", secret=self) @property def remove_event(self): @@ -204,7 +212,7 @@ def remove_event(self): raise ValueError( "This unit will never receive secret-removed for a secret it does not own.", ) - return Event(name="secret_removed", secret=self) + return Event("secret_removed", secret=self) def _set_revision(self, revision: int): """Set a new tracked revision.""" diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 2d099bfb8..cb860e186 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -1,8 +1,10 @@ import datetime +import warnings import pytest from ops.charm import CharmBase from ops.framework import Framework +from ops.model import ModelError from ops.model import Secret as ops_Secret from ops.model import SecretNotFoundError, SecretRotate @@ -118,7 +120,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): ), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content()["a"] == "c" + assert charm.model.get_secret(id="foo").get_content()["a"] == "b" assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" @@ -170,9 +172,11 @@ def test_add(mycharm, app): assert secret.label == "mylabel" -def test_set(mycharm): +def test_set_legacy_behaviour(mycharm): + # in juju < 3.1.7, secret owners always used to track the latest revision. + # ref: https://bugs.launchpad.net/juju/+bug/2037120 rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}).manager( + with Context(mycharm, meta={"name": "local"}, juju_version="3.1.6").manager( "update_status", State(), ) as mgr: @@ -209,6 +213,37 @@ def test_set(mycharm): } +def test_set(mycharm): + rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} + with Context(mycharm, meta={"name": "local"}).manager( + "update_status", + State(), + ) as mgr: + charm = mgr.charm + secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel") + assert ( + secret.get_content() + == secret.peek_content() + == secret.get_content(refresh=True) + == rev1 + ) + + secret.set_content(rev2) + assert secret.get_content() == rev1 + assert secret.peek_content() == secret.get_content(refresh=True) == rev2 + + secret.set_content(rev3) + state_out = mgr.run() + assert secret.get_content() == rev2 + assert secret.peek_content() == secret.get_content(refresh=True) == rev3 + + assert state_out.secrets[0].contents == { + 0: rev1, + 1: rev2, + 2: rev3, + } + + def test_set_juju33(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} with Context(mycharm, meta={"name": "local"}, juju_version="3.3").manager( @@ -268,6 +303,32 @@ def test_meta(mycharm, app): assert info.rotation == SecretRotate.HOURLY +def test_secret_deprecation_application(mycharm): + with warnings.catch_warnings(record=True) as captured: + s = Secret("123", {}, owner="application") + assert s.owner == "app" + msg = captured[0].message + assert isinstance(msg, DeprecationWarning) + assert msg.args[0] == ( + "Secret.owner='application' is deprecated in favour of " + "'app' and will be removed in Scenario 7+." + ) + + +@pytest.mark.parametrize("granted", ("app", "unit", False)) +def test_secret_deprecation_granted(mycharm, granted): + with warnings.catch_warnings(record=True) as captured: + s = Secret("123", {}, granted=granted) + assert s.granted == granted + msg = captured[0].message + assert isinstance(msg, DeprecationWarning) + assert msg.args[0] == ( + "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " + "If a Secret is not owned by the app/unit you are testing, nor has been granted to " + "it by the (remote) owner, then omit it from ``State.secrets`` altogether." + ) + + @pytest.mark.parametrize("leader", (True, False)) @pytest.mark.parametrize("owner", ("app", "unit", None)) def test_secret_permission_model(mycharm, leader, owner): @@ -311,16 +372,18 @@ def test_secret_permission_model(mycharm, leader, owner): assert secret.get_info() secret.set_content({"foo": "boo"}) - assert secret.get_content()["foo"] == "boo" + assert secret.get_content() == {"a": "b"} # rev1! + assert secret.get_content(refresh=True) == {"foo": "boo"} + secret.remove_all_revisions() else: # cannot manage # nothing else to do directly if you can't get a hold of the Secret instance # but we can try some raw backend calls - with pytest.raises(SecretNotFoundError): + with pytest.raises(ModelError): secret.get_info() - with pytest.raises(SecretNotFoundError): + with pytest.raises(ModelError): secret.set_content(content={"boo": "foo"}) From b88efdc4875b868dd267d5a4d729b6317e872270 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 3 Jan 2024 13:41:46 +0100 Subject: [PATCH 389/546] added test fixes --- tests/helpers.py | 4 ++-- tests/test_e2e/test_secrets.py | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 47fbc9f43..230e18541 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,7 +12,7 @@ Union, ) -from scenario.context import Context +from scenario.context import Context, DEFAULT_JUJU_VERSION if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -36,7 +36,7 @@ def trigger( actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: Optional["PathLike"] = None, - juju_version: str = "3.0", + juju_version: str = DEFAULT_JUJU_VERSION, ) -> "State": ctx = Context( charm_type=charm_type, diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 92b5fab3c..013be6397 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -10,6 +10,7 @@ from scenario import Context from scenario.state import Relation, Secret, State +from tests.helpers import trigger @pytest.fixture(scope="function") @@ -467,13 +468,21 @@ def _on_start(self, _): secret = self.unit.add_secret({"foo": "bar"}) secret.grant(self.model.relations["bar"][0]) + state = State(leader=leader, relations=[Relation("bar")]) + context = Context( + GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} + ) + context.run("start", state) + def test_grant_nonowner(mycharm): def post_event(charm: CharmBase): secret = charm.model.get_secret(id="foo") - with pytest.raises(RuntimeError): - secret = charm.model.get_secret(label="mylabel") - foo = charm.model.get_relation("foo") + + secret = charm.model.get_secret(label="mylabel") + foo = charm.model.get_relation("foo") + + with pytest.raises(ModelError): secret.grant(relation=foo) out = trigger( @@ -510,11 +519,12 @@ def __init__(self, *args): relation_id = 42 state = State( + leader=True, relations=[ Relation( "bar", remote_app_name=relation_remote_app, relation_id=relation_id ) - ] + ], ) with context.manager("start", state) as mgr: @@ -526,7 +536,6 @@ def __init__(self, *args): assert mgr.output.secrets scenario_secret = mgr.output.secrets[0] - assert scenario_secret.granted is False assert relation_remote_app in scenario_secret.remote_grants[relation_id] with context.manager("start", mgr.output) as mgr: @@ -543,5 +552,3 @@ def __init__(self, *args): secret.remove_all_revisions() assert not mgr.output.secrets[0].contents # secret wiped - state = State(leader=leader, relations=[Relation("bar")]) - context.run("start", state) From 863db34aa694eddd4c63fae5ebd3b8d88c9048f7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 Jan 2024 11:40:42 +0100 Subject: [PATCH 390/546] readme fixes --- README.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f101b44b2..9ea76493e 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,11 @@ def test_statuses(): WaitingStatus('checking this is right...'), ActiveStatus("I am ruled"), ] - + + # similarly you can check the app status history: assert ctx.app_status_history == [ UnknownStatus(), - ActiveStatus(""), + ... ] ``` @@ -434,9 +435,8 @@ that this unit can see). Because of that, `SubordinateRelation`, compared to `Relation`, always talks in terms of `remote`: - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` taking a single `Dict[str:str]`. The remote unit ID can be provided as a separate argument. -- `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs) +- `Relation.remote_unit_ids` becomes `SubordinateRelation.remote_unit_id` (a single ID instead of a list of IDs) - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) -- `Relation.remote_app_name` maps to `SubordinateRelation.primary_app_name` ```python from scenario.state import SubordinateRelation @@ -657,12 +657,12 @@ def test_pebble_push(): state_in = State( containers=[container] ) - Context( + ctx = Context( MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}).run( - "start", - state_in, + meta={"name": "foo", "containers": {"foo": {}}} ) + + ctx.run("start", state_in) # this is the root of the simulated container filesystem. Any mounts will be symlinks in it. container_root_fs = container.get_filesystem(ctx) @@ -834,14 +834,19 @@ The only mandatory arguments to Secret are its secret ID (which should be unique from revision numbers (integers) to a `str:str` dict representing the payload of the revision. There are three cases: -- the secret is owned by this app, in which case only the leader unit can manage it +- the secret is owned by this app but not this unit, in which case this charm can only manage it if we are the leader - the secret is owned by this unit, in which case this charm can always manage it (leader or not) - (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights. + The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists. +[note] +If this charm does not own the secret, but also it was not granted view rights by the (remote) owner, you model this in Scenario by _not adding it to State.secrets_! The presence of a `Secret` in `State.secrets` means, in other words, that the charm has view rights (otherwise, why would we put it there?). If the charm owns the secret, or is leader, it will _also_ have manage rights on top of view ones. +[/note] + To specify a secret owned by this unit (or app): ```python From d72aac2034834698c9db8015b4ba61e89f1f501b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 Jan 2024 16:11:31 +0100 Subject: [PATCH 391/546] removed typer dep --- pyproject.toml | 1 - scenario/strategies.py | 0 tests/test_hypothesis.py | 0 3 files changed, 1 deletion(-) create mode 100644 scenario/strategies.py create mode 100644 tests/test_hypothesis.py diff --git a/pyproject.toml b/pyproject.toml index fdaaab4ea..e5a26b8dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ keywords = ["juju", "test"] dependencies = [ "ops>=2.6", "PyYAML>=6.0.1", - "typer==0.7.0", ] readme = "README.md" requires-python = ">=3.8" diff --git a/scenario/strategies.py b/scenario/strategies.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py new file mode 100644 index 000000000..e69de29bb From bd0baf3e2545ab56fd515add907cceb388667609 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 Jan 2024 17:44:03 +0100 Subject: [PATCH 392/546] support unified charmcraft model --- scenario/state.py | 43 ++++++++++++++++++++++++------- tests/test_charm_spec_autoload.py | 42 +++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index e5fb1d1ce..edbed6886 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -947,6 +947,8 @@ class _CharmSpec(_DCBase): """Charm spec.""" charm_type: Type["CharmType"] + + # TODO: consider unifying the data model since nowadays it's all in one file meta: Optional[Dict[str, Any]] actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None @@ -956,16 +958,10 @@ class _CharmSpec(_DCBase): is_autoloaded: bool = False @staticmethod - def autoload(charm_type: Type["CharmType"]): - charm_source_path = Path(inspect.getfile(charm_type)) - charm_root = charm_source_path.parent.parent - + def _load_metadata_legacy(charm_root: Path): + # back in the days, we used to have separate metadata.yaml, config.yaml and actions.yaml + # files for charm metadata. metadata_path = charm_root / "metadata.yaml" - if not metadata_path.exists(): - raise MetadataNotFoundError( - f"invalid charm root {charm_root!r}; " - f"expected to contain at least a `metadata.yaml` file.", - ) meta = yaml.safe_load(metadata_path.open()) actions = config = None @@ -978,6 +974,35 @@ def autoload(charm_type: Type["CharmType"]): if actions_path.exists(): actions = yaml.safe_load(actions_path.open()) + return meta, config, actions + + @staticmethod + def _load_metadata(charm_root: Path): + metadata_path = charm_root / "charmcraft.yaml" + meta = yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {} + config = meta.get("config", None) + actions = meta.get("actions", None) + return meta, config, actions + + @staticmethod + def autoload(charm_type: Type["CharmType"]): + charm_source_path = Path(inspect.getfile(charm_type)) + charm_root = charm_source_path.parent.parent + + metadata_path = charm_root / "metadata.yaml" + + if metadata_path.exists(): + meta, config, actions = _CharmSpec._load_metadata_legacy(charm_root) + else: + meta, config, actions = _CharmSpec._load_metadata(charm_root) + + if not meta: + raise MetadataNotFoundError( + f"invalid charm root {charm_root!r}; " + f"expected to contain at least a `charmcraft.yaml` file " + f"(or a `metadata.yaml` file if it's an old charm).", + ) + return _CharmSpec( charm_type=charm_type, meta=meta, diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 65ff83d2f..48ea90976 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -38,51 +38,69 @@ def create_tempcharm( actions=None, config=None, name: str = "MyCharm", + legacy: bool = True, ): src = root / "src" src.mkdir(parents=True) charmpy = src / "charm.py" charmpy.write_text(charm) - if meta is not None: - (root / "metadata.yaml").write_text(yaml.safe_dump(meta)) + if legacy: + if meta is not None: + (root / "metadata.yaml").write_text(yaml.safe_dump(meta)) - if actions is not None: - (root / "actions.yaml").write_text(yaml.safe_dump(actions)) + if actions is not None: + (root / "actions.yaml").write_text(yaml.safe_dump(actions)) - if config is not None: - (root / "config.yaml").write_text(yaml.safe_dump(config)) + if config is not None: + (root / "config.yaml").write_text(yaml.safe_dump(config)) + else: + unified_meta = meta or {} + if actions: + unified_meta["actions"] = actions + if config: + unified_meta["config"] = config + if unified_meta: + (root / "charmcraft.yaml").write_text(yaml.safe_dump(unified_meta)) with import_name(name, charmpy) as charm: yield charm -def test_meta_autoload(tmp_path): - with create_tempcharm(tmp_path, meta={"name": "foo"}) as charm: +@pytest.mark.parametrize("legacy", (True, False)) +def test_meta_autoload(tmp_path, legacy): + with create_tempcharm(tmp_path, legacy=legacy, meta={"name": "foo"}) as charm: ctx = Context(charm) ctx.run("start", State()) -def test_no_meta_raises(tmp_path): +@pytest.mark.parametrize("legacy", (True, False)) +def test_no_meta_raises(tmp_path, legacy): with create_tempcharm( tmp_path, + legacy=legacy, ) as charm: # metadata not found: with pytest.raises(ContextSetupError): Context(charm) -def test_relations_ok(tmp_path): +@pytest.mark.parametrize("legacy", (True, False)) +def test_relations_ok(tmp_path, legacy): with create_tempcharm( - tmp_path, meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}} + tmp_path, + legacy=legacy, + meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}}, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta Context(charm).run("start", State(relations=[Relation("cuddles")])) -def test_config_defaults(tmp_path): +@pytest.mark.parametrize("legacy", (True, False)) +def test_config_defaults(tmp_path, legacy): with create_tempcharm( tmp_path, + legacy=legacy, meta={"name": "josh"}, config={"options": {"foo": {"type": "bool", "default": True}}}, ) as charm: From b89930b30ddaf7e0e1d7ec0aa5d704af9f2a61ac Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 4 Jan 2024 17:45:17 +0100 Subject: [PATCH 393/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 84b93d883..3a295d1da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.7.1" +version = "5.8" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From e7fb016793ed03e032c86ff2c2058cae2781dc38 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 5 Jan 2024 08:33:48 +0100 Subject: [PATCH 394/546] addressed pr comments --- scenario/state.py | 35 ++++++++++++-------- tests/test_charm_spec_autoload.py | 53 ++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index edbed6886..cb63d19ea 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -959,44 +959,51 @@ class _CharmSpec(_DCBase): @staticmethod def _load_metadata_legacy(charm_root: Path): + """Load metadata from charm projects created with Charmcraft < 2.5.""" # back in the days, we used to have separate metadata.yaml, config.yaml and actions.yaml # files for charm metadata. metadata_path = charm_root / "metadata.yaml" - meta = yaml.safe_load(metadata_path.open()) - - actions = config = None + meta = yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {} config_path = charm_root / "config.yaml" - if config_path.exists(): - config = yaml.safe_load(config_path.open()) + config = yaml.safe_load(config_path.open()) if config_path.exists() else None actions_path = charm_root / "actions.yaml" - if actions_path.exists(): - actions = yaml.safe_load(actions_path.open()) - + actions = yaml.safe_load(actions_path.open()) if actions_path.exists() else None return meta, config, actions @staticmethod def _load_metadata(charm_root: Path): + """Load metadata from charm projects created with Charmcraft >= 2.5.""" metadata_path = charm_root / "charmcraft.yaml" meta = yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {} - config = meta.get("config", None) - actions = meta.get("actions", None) + if (config_type := meta.get("type")) != "charm": + logger.debug( + f"Not a charm: charmcraft yaml config ``.type`` is {config_type!r}.", + ) + meta = {} + config = meta.pop("config", None) + actions = meta.pop("actions", None) return meta, config, actions @staticmethod def autoload(charm_type: Type["CharmType"]): + """Construct a ``_CharmSpec`` object by looking up the metadata from the charm's repo root. + + Will attempt to load the metadata off the ``charmcraft.yaml`` file + """ charm_source_path = Path(inspect.getfile(charm_type)) charm_root = charm_source_path.parent.parent - metadata_path = charm_root / "metadata.yaml" + # attempt to load metadata from unified charmcraft.yaml + meta, config, actions = _CharmSpec._load_metadata(charm_root) - if metadata_path.exists(): + if not meta: + # try to load using legacy metadata.yaml/actions.yaml/config.yaml files meta, config, actions = _CharmSpec._load_metadata_legacy(charm_root) - else: - meta, config, actions = _CharmSpec._load_metadata(charm_root) if not meta: + # still no metadata? bug out raise MetadataNotFoundError( f"invalid charm root {charm_root!r}; " f"expected to contain at least a `charmcraft.yaml` file " diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 48ea90976..32d87f32e 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -11,6 +11,7 @@ from scenario import Context, Relation, State from scenario.context import ContextSetupError +from scenario.state import _CharmSpec, MetadataNotFoundError CHARM = """ from ops import CharmBase @@ -38,7 +39,7 @@ def create_tempcharm( actions=None, config=None, name: str = "MyCharm", - legacy: bool = True, + legacy: bool = False, ): src = root / "src" src.mkdir(parents=True) @@ -46,6 +47,12 @@ def create_tempcharm( charmpy.write_text(charm) if legacy: + # we add a charmcraft.yaml file to verify that _CharmSpec._load_metadata + # is able to tell that the presence of charmcraft.yaml ALONE is not enough + # to make this a valid charm + charmcraft = {"builds-on": "literally anywhere! isn't that awesome?"} + (root / "charmcraft.yaml").write_text(yaml.safe_dump(charmcraft)) + if meta is not None: (root / "metadata.yaml").write_text(yaml.safe_dump(meta)) @@ -56,6 +63,7 @@ def create_tempcharm( (root / "config.yaml").write_text(yaml.safe_dump(config)) else: unified_meta = meta or {} + if actions: unified_meta["actions"] = actions if config: @@ -67,9 +75,42 @@ def create_tempcharm( yield charm +def test_autoload_no_meta_fails(tmp_path): + with create_tempcharm(tmp_path) as charm: + with pytest.raises(MetadataNotFoundError): + _CharmSpec.autoload(charm) + + +def test_autoload_no_type_fails(tmp_path): + with create_tempcharm(tmp_path, meta={"name": "foo"}) as charm: + with pytest.raises(MetadataNotFoundError): + _CharmSpec.autoload(charm) + + +def test_autoload_legacy_no_meta_fails(tmp_path): + with create_tempcharm(tmp_path, legacy=True) as charm: + with pytest.raises(MetadataNotFoundError): + _CharmSpec.autoload(charm) + + +def test_autoload_legacy_no_type_passes(tmp_path): + with create_tempcharm(tmp_path, legacy=True, meta={"name": "foo"}) as charm: + _CharmSpec.autoload(charm) + + +@pytest.mark.parametrize("config_type", ("charm", "foo")) +def test_autoload_legacy_type_passes(tmp_path, config_type): + with create_tempcharm( + tmp_path, legacy=True, meta={"type": config_type, "name": "foo"} + ) as charm: + _CharmSpec.autoload(charm) + + @pytest.mark.parametrize("legacy", (True, False)) def test_meta_autoload(tmp_path, legacy): - with create_tempcharm(tmp_path, legacy=legacy, meta={"name": "foo"}) as charm: + with create_tempcharm( + tmp_path, legacy=legacy, meta={"type": "charm", "name": "foo"} + ) as charm: ctx = Context(charm) ctx.run("start", State()) @@ -90,7 +131,11 @@ def test_relations_ok(tmp_path, legacy): with create_tempcharm( tmp_path, legacy=legacy, - meta={"name": "josh", "requires": {"cuddles": {"interface": "arms"}}}, + meta={ + "type": "charm", + "name": "josh", + "requires": {"cuddles": {"interface": "arms"}}, + }, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta Context(charm).run("start", State(relations=[Relation("cuddles")])) @@ -101,7 +146,7 @@ def test_config_defaults(tmp_path, legacy): with create_tempcharm( tmp_path, legacy=legacy, - meta={"name": "josh"}, + meta={"type": "charm", "name": "josh"}, config={"options": {"foo": {"type": "bool", "default": True}}}, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta From 43123f32fe37e86b2d7a3db914ffc5d7551f7c9b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 5 Jan 2024 08:34:40 +0100 Subject: [PATCH 395/546] removed todo --- scenario/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index cb63d19ea..30981c919 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -948,7 +948,6 @@ class _CharmSpec(_DCBase): charm_type: Type["CharmType"] - # TODO: consider unifying the data model since nowadays it's all in one file meta: Optional[Dict[str, Any]] actions: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None From 0cfc1e4179bf25dc92f69c7da64a1d4fa7422f3f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 Jan 2024 08:42:56 +0100 Subject: [PATCH 396/546] loosened metadata checks --- pyproject.toml | 2 +- scenario/state.py | 18 ++++++++++++++---- tests/test_charm_spec_autoload.py | 27 ++++++++++++++++++--------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3a295d1da..100dd2e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.8" +version = "5.8.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/state.py b/scenario/state.py index 30981c919..03afc7d8d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -942,6 +942,19 @@ def jsonpatch_delta(self, other: "State"): return sort_patch(patch) +def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): + # Check whether the metadata has the expected mandatory fields + if (config_type := meta.get("type")) != "charm": + logger.debug( + f"Not a charm: charmcraft yaml config ``.type`` is {config_type!r}.", + ) + return False + if not all(field in meta for field in {"name", "summary", "description"}): + logger.debug("not a charm: charmcraft yaml misses some required") + return False + return True + + @dataclasses.dataclass(frozen=True) class _CharmSpec(_DCBase): """Charm spec.""" @@ -976,10 +989,7 @@ def _load_metadata(charm_root: Path): """Load metadata from charm projects created with Charmcraft >= 2.5.""" metadata_path = charm_root / "charmcraft.yaml" meta = yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {} - if (config_type := meta.get("type")) != "charm": - logger.debug( - f"Not a charm: charmcraft yaml config ``.type`` is {config_type!r}.", - ) + if not _is_valid_charmcraft_25_metadata(meta): meta = {} config = meta.pop("config", None) actions = meta.pop("actions", None) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 32d87f32e..e9c77f022 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -11,7 +11,7 @@ from scenario import Context, Relation, State from scenario.context import ContextSetupError -from scenario.state import _CharmSpec, MetadataNotFoundError +from scenario.state import MetadataNotFoundError, _CharmSpec CHARM = """ from ops import CharmBase @@ -46,13 +46,13 @@ def create_tempcharm( charmpy = src / "charm.py" charmpy.write_text(charm) - if legacy: - # we add a charmcraft.yaml file to verify that _CharmSpec._load_metadata - # is able to tell that the presence of charmcraft.yaml ALONE is not enough - # to make this a valid charm - charmcraft = {"builds-on": "literally anywhere! isn't that awesome?"} - (root / "charmcraft.yaml").write_text(yaml.safe_dump(charmcraft)) + # we add a charmcraft.yaml file to verify that _CharmSpec._load_metadata + # is able to tell that the presence of charmcraft.yaml ALONE is not enough + # to make this a valid charm + charmcraft = {"builds-on": "literally anywhere! isn't that awesome?"} + (root / "charmcraft.yaml").write_text(yaml.safe_dump(charmcraft)) + if legacy: if meta is not None: (root / "metadata.yaml").write_text(yaml.safe_dump(meta)) @@ -109,7 +109,9 @@ def test_autoload_legacy_type_passes(tmp_path, config_type): @pytest.mark.parametrize("legacy", (True, False)) def test_meta_autoload(tmp_path, legacy): with create_tempcharm( - tmp_path, legacy=legacy, meta={"type": "charm", "name": "foo"} + tmp_path, + legacy=legacy, + meta={"type": "charm", "name": "foo", "summary": "foo", "description": "foo"}, ) as charm: ctx = Context(charm) ctx.run("start", State()) @@ -133,6 +135,8 @@ def test_relations_ok(tmp_path, legacy): legacy=legacy, meta={ "type": "charm", + "summary": "foo", + "description": "foo", "name": "josh", "requires": {"cuddles": {"interface": "arms"}}, }, @@ -146,7 +150,12 @@ def test_config_defaults(tmp_path, legacy): with create_tempcharm( tmp_path, legacy=legacy, - meta={"type": "charm", "name": "josh"}, + meta={ + "type": "charm", + "name": "josh", + "summary": "foo", + "description": "foo", + }, config={"options": {"foo": {"type": "bool", "default": True}}}, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta From 7a2a83f43347fe15025eebb2f95040e4caab0344 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 Jan 2024 08:46:03 +0100 Subject: [PATCH 397/546] english --- scenario/state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 03afc7d8d..8ede83dc8 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -943,14 +943,15 @@ def jsonpatch_delta(self, other: "State"): def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): - # Check whether the metadata has the expected mandatory fields + # Check whether this dict has the expected mandatory metadata fields according to the + # charmcraft >2.5 charmcraft.yaml schema if (config_type := meta.get("type")) != "charm": logger.debug( f"Not a charm: charmcraft yaml config ``.type`` is {config_type!r}.", ) return False if not all(field in meta for field in {"name", "summary", "description"}): - logger.debug("not a charm: charmcraft yaml misses some required") + logger.debug("Not a charm: charmcraft yaml misses some required fields") return False return True From ba7972eef67fd00b539cac862b86448e661397aa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 9 Jan 2024 08:58:44 +0100 Subject: [PATCH 398/546] fixed once more jujuv check --- scenario/context.py | 25 +++++++++++++++---------- scenario/mocking.py | 10 +++++----- tests/test_e2e/test_secrets.py | 2 +- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 4d8f51ec7..7ca9e903c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -132,10 +132,10 @@ def run(self) -> "State": return cast("State", super().run()) def _runner(self): - return self._ctx._run_event + return self._ctx._run_event # noqa def _get_output(self): - return self._ctx._output_state + return self._ctx._output_state # noqa class _ActionManager(_Manager): @@ -146,10 +146,10 @@ def run(self) -> "ActionOutput": return cast("ActionOutput", super().run()) def _runner(self): - return self._ctx._run_action + return self._ctx._run_action # noqa def _get_output(self): - return self._ctx._finalize_action(self._ctx.output_state) + return self._ctx._finalize_action(self._ctx.output_state) # noqa class Context: @@ -199,7 +199,7 @@ def __init__( >>> from scenario import Context, State >>> from ops import ActiveStatus - >>> from charm import MyCharm, MyCustomEvent + >>> from charm import MyCharm, MyCustomEvent # noqa >>> >>> def test_foo(): >>> # Arrange: set the context up @@ -259,6 +259,11 @@ def __init__( self.charm_spec = spec self.charm_root = charm_root self.juju_version = juju_version + if juju_version.split(".")[0] == "2": + logger.warn( + "Juju 2.x is closed and unsupported. You may encounter inconsistencies.", + ) + self._app_name = app_name self._unit_id = unit_id self._tmp = tempfile.TemporaryDirectory() @@ -366,7 +371,7 @@ def _coalesce_event(event: Union[str, Event]) -> Event: if not isinstance(event, Event): raise InvalidEventError(f"Expected Event | str, got {type(event)}") - if event._is_action_event: + if event._is_action_event: # noqa raise InvalidEventError( "Cannot Context.run() action events. " "Use Context.run_action instead.", @@ -396,9 +401,9 @@ def manager( Usage: >>> with Context().manager("start", State()) as manager: - >>> assert manager.charm._some_private_attribute == "foo" + >>> assert manager.charm._some_private_attribute == "foo" # noqa >>> manager.run() # this will fire the event - >>> assert manager.charm._some_private_attribute == "bar" + >>> assert manager.charm._some_private_attribute == "bar" # noqa :arg event: the Event that the charm will respond to. Can be a string or an Event instance. :arg state: the State instance to use as data source for the hook tool calls that the @@ -415,9 +420,9 @@ def action_manager( Usage: >>> with Context().action_manager("foo-action", State()) as manager: - >>> assert manager.charm._some_private_attribute == "foo" + >>> assert manager.charm._some_private_attribute == "foo" # noqa >>> manager.run() # this will fire the event - >>> assert manager.charm._some_private_attribute == "bar" + >>> assert manager.charm._some_private_attribute == "bar" # noqa :arg action: the Action that the charm will execute. Can be a string or an Action instance. :arg state: the State instance to use as data source for the hook tool calls that the diff --git a/scenario/mocking.py b/scenario/mocking.py index 447b44fe9..b15394f02 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -169,7 +169,7 @@ def _get_relation_by_id( def _get_secret(self, id=None, label=None): # FIXME: what error would a charm get IRL? - # ops 2.0 supports Secret, but juju only supports it from 3.0.2 + # ops 2.0 supports secrets, but juju only supports it from 3.0.2 if self._context.juju_version < "3.0.2": raise RuntimeError( "secrets are only available in juju >= 3.0.2." @@ -373,10 +373,10 @@ def _check_can_manage_secret( self, secret: "Secret", ): - # FIXME: match real tracebacks if secret.owner is None: raise SecretNotFoundError( - "this secret is not owned by this unit/app or granted to it", + "this secret is not owned by this unit/app or granted to it. " + "Did you forget passing it to State.secrets?", ) if secret.owner == "app" and not self.is_leader(): understandable_error = SecretNotFoundError( @@ -395,8 +395,8 @@ def secret_get( peek: bool = False, ) -> Dict[str, str]: secret = self._get_secret(id, label) - - if self._context.juju_version < "3.1.7": + juju_version = self._context.juju_version + if not (juju_version == "3.1.7" or juju_version >= "3.3.1"): # in this medieval juju chapter, # secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 013be6397..e8e75f7b6 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -247,7 +247,7 @@ def test_set(mycharm): def test_set_juju33(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}, juju_version="3.3").manager( + with Context(mycharm, meta={"name": "local"}, juju_version="3.3.1").manager( "update_status", State(), ) as mgr: From 693a2627098d75b752eca01c8754347ac355f837 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 17 Jan 2024 16:45:01 +1300 Subject: [PATCH 399/546] Fix test in README for container.pebble_ready_event(). --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 583dbaad1..1438be2e6 100644 --- a/README.md +++ b/README.md @@ -601,8 +601,9 @@ def test_pebble_push(): ) Context( MyCharm, - meta={"name": "foo", "containers": {"foo": {}}}).run( - "start", + meta={"name": "foo", "containers": {"foo": {}}} + ).run( + container.pebble_ready_event(), state_in, ) assert local_file.read().decode() == "TEST" From a1e99ca3173cc8b829b853fc3ff477dd3da9b126 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 Jan 2024 08:43:11 +0100 Subject: [PATCH 400/546] static fixes --- scenario/mocking.py | 2 +- scenario/state.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index b15394f02..52c183467 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -352,7 +352,7 @@ def secret_add( description: Optional[str] = None, expire: Optional[datetime.datetime] = None, rotate: Optional[SecretRotate] = None, - owner: Optional[Literal["unit", "application"]] = None, + owner: Optional[Literal["unit", "app"]] = None, ) -> str: from scenario.state import Secret diff --git a/scenario/state.py b/scenario/state.py index 93b4bdf0f..27408ebdd 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -157,7 +157,7 @@ class Secret(_DCBase): # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether. # this attribute will be removed in Scenario 7+ - granted: Literal["unit", "app", False] = "" + granted = "" # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -245,7 +245,8 @@ def _update_metadata( ): """Update the metadata.""" revision = max(self.contents.keys()) - self.contents[revision + 1] = content + if content: + self.contents[revision + 1] = content # bypass frozen dataclass if label: From 95ec077af6f9539a8bf65dbd5c5299f6bf933990 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 Jan 2024 08:44:58 +0100 Subject: [PATCH 401/546] precommit for pyright --- .pre-commit-config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b88092291..f9b051cdc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,3 +76,7 @@ repos: hooks: - id: check-hooks-apply - id: check-useless-excludes + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.347 + hooks: + - id: pyright From 7cd7f852918191550cdd1c6f3cd148dc0eda5900 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 Jan 2024 08:46:17 +0100 Subject: [PATCH 402/546] precommit for pyright --- .pre-commit-config.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f9b051cdc..eb974a56d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,7 +67,7 @@ repos: - repo: local hooks: - id: changelogs-rst - name: changelog filenames + name: Changelog filenames language: fail entry: "changelog files must be named ####.(feature|bugfix|doc|removal|misc).rst" exclude: ^docs/changelog/(\d+\.(feature|bugfix|doc|removal|misc).rst|template.jinja2) @@ -79,4 +79,5 @@ repos: - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.347 hooks: - - id: pyright + - id: pyright + name: Static type checks From 1a41323b74af1c6945b8cadb2c43a003ab80d0e9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 Jan 2024 11:22:06 +0100 Subject: [PATCH 403/546] pyright vbump --- .pre-commit-config.yaml | 5 ----- scenario/context.py | 8 +++++--- scenario/mocking.py | 3 ++- scenario/state.py | 4 ++-- tests/helpers.py | 2 +- tox.ini | 2 +- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb974a56d..f5fcd9e89 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,8 +76,3 @@ repos: hooks: - id: check-hooks-apply - id: check-useless-excludes - - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.347 - hooks: - - id: pyright - name: Static type checks diff --git a/scenario/context.py b/scenario/context.py index 7ca9e903c..9de78b1cf 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -98,7 +98,7 @@ def _get_output(self): raise NotImplementedError("override in subclass") def __enter__(self): - self._wrapped_ctx = wrapped_ctx = self._runner()(self._arg, self._state_in) + self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in) ops = wrapped_ctx.__enter__() self.ops = ops return self @@ -126,11 +126,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 class _EventManager(_Manager): if TYPE_CHECKING: # pragma: no cover - output: State + output: State # pyright: ignore[reportIncompatibleVariableOverride] def run(self) -> "State": return cast("State", super().run()) + @property def _runner(self): return self._ctx._run_event # noqa @@ -140,11 +141,12 @@ def _get_output(self): class _ActionManager(_Manager): if TYPE_CHECKING: # pragma: no cover - output: ActionOutput + output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride] def run(self) -> "ActionOutput": return cast("ActionOutput", super().run()) + @property def _runner(self): return self._ctx._run_action # noqa diff --git a/scenario/mocking.py b/scenario/mocking.py index 52c183467..d4c7aab36 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -97,6 +97,7 @@ def send_signal(self, sig: Union[int, str]): # noqa: U100 _NOT_GIVEN = object() # non-None default value sentinel +# pyright: reportIncompatibleMethodOverride=false class _MockModelBackend(_ModelBackend): def __init__( self, @@ -116,7 +117,7 @@ def opened_ports(self) -> Set[Port]: def open_port( self, - protocol: "_RawPortProtocolLiteral", + protocol: "_RawPortProtocolLiteral", # pyright: ignore[reportIncompatibleMethodOverride] port: Optional[int] = None, ): # fixme: the charm will get hit with a StateValidationError diff --git a/scenario/state.py b/scenario/state.py index 27408ebdd..9b87d65e9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -157,7 +157,7 @@ class Secret(_DCBase): # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether. # this attribute will be removed in Scenario 7+ - granted = "" + granted: Any = "" # noqa # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -370,7 +370,7 @@ def _databags(self): yield self.local_unit_data @property - def _remote_unit_ids(self) -> Tuple[int]: + def _remote_unit_ids(self) -> Tuple["UnitID", ...]: """Ids of the units on the other end of this relation.""" raise NotImplementedError() diff --git a/tests/helpers.py b/tests/helpers.py index 230e18541..a8b2f5510 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,7 +12,7 @@ Union, ) -from scenario.context import Context, DEFAULT_JUJU_VERSION +from scenario.context import DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType diff --git a/tox.ini b/tox.ini index 1f362e023..ee7273f5d 100644 --- a/tox.ini +++ b/tox.ini @@ -45,7 +45,7 @@ description = Static typing checks. skip_install = true deps = ops - pyright + pyright==23.3.2 commands = pyright scenario From 828f98c1ec6eae9c9d4989757c38540d7f2b1970 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 17 Jan 2024 11:34:43 +0100 Subject: [PATCH 404/546] pyright vbump --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ee7273f5d..b670ca951 100644 --- a/tox.ini +++ b/tox.ini @@ -45,7 +45,7 @@ description = Static typing checks. skip_install = true deps = ops - pyright==23.3.2 + pyright==1.1.347 commands = pyright scenario From 82269f0d8d6fd8b9d99d09d365db640bd7b30dd9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 5 Feb 2024 10:53:16 +0100 Subject: [PATCH 405/546] support for broken_relation_id in ops --- scenario/ops_main_mock.py | 8 +++++++- tests/test_e2e/test_relations.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 95f678c67..c89bd3710 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -109,7 +109,13 @@ def setup_framework( actions_metadata = None meta = CharmMeta.from_yaml(metadata, actions_metadata) - model = ops.model.Model(meta, model_backend) + + # If we are in a RelationBroken event, we want to know which relation is + # broken within the model, not only in the event's `.relation` attribute. + broken_relation_id = ( + event.relation.relation_id if event.name.endswith("_relation_broken") else None + ) + model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id) charm_state_path = charm_dir / CHARM_STATE_FILE diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index ce73e086f..6222c7564 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -1,9 +1,16 @@ from typing import Type import pytest -from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, RelationDepartedEvent +from ops.charm import ( + CharmBase, + CharmEvents, + CollectStatusEvent, + RelationDepartedEvent, + RelationEvent, +) from ops.framework import EventBase, Framework +from scenario import Context from scenario.state import ( PeerRelation, Relation, @@ -115,8 +122,15 @@ def test_relation_events(mycharm, evt_name, remote_app_name): endpoint="foo", interface="foo", remote_app_name=remote_app_name ) - def callback(charm: CharmBase, _): - assert charm.model.get_relation("foo").app.name == remote_app_name + def callback(charm: CharmBase, e): + if not isinstance(e, RelationEvent): + return # filter out collect status events + + if evt_name == "broken": + assert charm.model.get_relation("foo") is None + assert e.relation.app.name == remote_app_name + else: + assert charm.model.get_relation("foo").app.name == remote_app_name mycharm._call = callback @@ -357,3 +371,15 @@ def test_relation_ids(): for i in range(10): rel = Relation("foo") assert rel.relation_id == initial_id + i + + +def test_broken_relation_not_in_model_relations(mycharm): + rel = Relation("foo") + + with Context( + mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} + ).manager(rel.broken_event, state=State(relations=[rel])) as mgr: + charm = mgr.charm + + assert charm.model.get_relation("foo") is None + assert charm.model.relations["foo"] == [] From 55d3c31aa04898e64fd38362f147e337eddf6e20 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 5 Feb 2024 10:58:48 +0100 Subject: [PATCH 406/546] static --- scenario/ops_main_mock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index c89bd3710..d4cd1344a 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -113,7 +113,7 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.relation_id if event.name.endswith("_relation_broken") else None + event.relation.relation_id if event.name.endswith("_relation_broken") else None # type: ignore ) model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id) From 75a09fd2c80d046c9d6673743b8716cd10fec148 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 7 Feb 2024 11:16:40 +0100 Subject: [PATCH 407/546] added default juju data to default remote unit databag --- scenario/state.py | 10 +++++++- tests/test_e2e/test_relations.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 9b87d65e9..f4e555b73 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -444,6 +444,14 @@ def broken_event(self) -> "Event": ) +_DEFAULT_IP = "42.42.42.42" +DEFAULT_JUJU_DATABAG = { + "egress-subnets": _DEFAULT_IP, + "ingress-address": _DEFAULT_IP, + "private-address": _DEFAULT_IP, +} + + @dataclasses.dataclass(frozen=True) class Relation(RelationBase): remote_app_name: str = "remote" @@ -453,7 +461,7 @@ class Relation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: {}}, + default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, # dedup ) @property diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index ce73e086f..55e0bd227 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -5,6 +5,8 @@ from ops.framework import EventBase, Framework from scenario.state import ( + _DEFAULT_IP, + DEFAULT_JUJU_DATABAG, PeerRelation, Relation, RelationBase, @@ -229,6 +231,44 @@ def callback(charm: CharmBase, event): ) +@pytest.mark.parametrize( + "evt_name", + ("changed", "broken", "departed", "joined", "created"), +) +def test_relation_events_remote_units_data_defaults(mycharm, evt_name, caplog): + relation = Relation( + endpoint="foo", + interface="foo", + ) + + def callback(charm: CharmBase, event): + if isinstance(event, CollectStatusEvent): + return + + assert event.app # that's always present + remote_unit = event.relation.units.pop() + + assert event.relation.data[remote_unit] == DEFAULT_JUJU_DATABAG + + mycharm._call = callback + + trigger( + State( + relations=[ + relation, + ], + ), + getattr(relation, f"{evt_name}_event"), + mycharm, + meta={ + "name": "local", + "requires": { + "foo": {"interface": "foo"}, + }, + }, + ) + + @pytest.mark.parametrize( "evt_name", ("changed", "broken", "departed", "joined", "created"), From 3196c5fa1a79595fae3fde7ccf292be8883cc475 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 7 Feb 2024 11:20:47 +0100 Subject: [PATCH 408/546] and now for sub and peers --- scenario/state.py | 10 +++++--- tests/test_e2e/test_relations.py | 44 ++++++++------------------------ 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index f4e555b73..939e04d84 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -360,7 +360,9 @@ class RelationBase(_DCBase): local_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) """This application's databag for this relation.""" - local_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + local_unit_data: "RawDataBagContents" = dataclasses.field( + default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + ) """This unit's databag for this relation.""" @property @@ -490,7 +492,9 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) - remote_unit_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + remote_unit_data: "RawDataBagContents" = dataclasses.field( + default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + ) # app name and ID of the remote unit that *this unit* is attached to. remote_app_name: str = "remote" @@ -526,7 +530,7 @@ def remote_unit_name(self) -> str: @dataclasses.dataclass(frozen=True) class PeerRelation(RelationBase): peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: {}}, + default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, ) # mapping from peer unit IDs to their databag contents. # Consistency checks will validate that *this unit*'s ID is not in here. diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 55e0bd227..4b9cd17b4 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -5,7 +5,6 @@ from ops.framework import EventBase, Framework from scenario.state import ( - _DEFAULT_IP, DEFAULT_JUJU_DATABAG, PeerRelation, Relation, @@ -231,42 +230,21 @@ def callback(charm: CharmBase, event): ) -@pytest.mark.parametrize( - "evt_name", - ("changed", "broken", "departed", "joined", "created"), -) -def test_relation_events_remote_units_data_defaults(mycharm, evt_name, caplog): - relation = Relation( - endpoint="foo", - interface="foo", - ) +def test_relation_default_unit_data_regular(): + relation = Relation("baz") + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} - def callback(charm: CharmBase, event): - if isinstance(event, CollectStatusEvent): - return - assert event.app # that's always present - remote_unit = event.relation.units.pop() +def test_relation_default_unit_data_sub(): + relation = SubordinateRelation("baz") + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG - assert event.relation.data[remote_unit] == DEFAULT_JUJU_DATABAG - mycharm._call = callback - - trigger( - State( - relations=[ - relation, - ], - ), - getattr(relation, f"{evt_name}_event"), - mycharm, - meta={ - "name": "local", - "requires": { - "foo": {"interface": "foo"}, - }, - }, - ) +def test_relation_default_unit_data_peer(): + relation = PeerRelation("baz") + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG @pytest.mark.parametrize( From 761b18bbf0dfc034be2dca8df4dae521d0f80f35 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 7 Feb 2024 11:28:36 +0100 Subject: [PATCH 409/546] fixed utest --- tests/test_e2e/test_state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 14ee1d055..d92e5b12f 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,7 +6,7 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import Container, Relation, State, sort_patch +from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State, sort_patch from tests.helpers import trigger CUSTOM_EVT_SUFFIXES = { @@ -225,9 +225,9 @@ def pre_event(charm: CharmBase): assert asdict(out.relations[0]) == asdict( relation.replace( local_app_data={"a": "b"}, - local_unit_data={"c": "d"}, + local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, ) ) assert out.relations[0].local_app_data == {"a": "b"} - assert out.relations[0].local_unit_data == {"c": "d"} + assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG} From 87c2c00a2a904103ebb89df472fc3f45c2b73345 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Thu, 8 Feb 2024 08:13:39 +0100 Subject: [PATCH 410/546] better default port --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 939e04d84..34e8e2b15 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -446,7 +446,7 @@ def broken_event(self) -> "Event": ) -_DEFAULT_IP = "42.42.42.42" +_DEFAULT_IP = " 192.0.2.0" DEFAULT_JUJU_DATABAG = { "egress-subnets": _DEFAULT_IP, "ingress-address": _DEFAULT_IP, From 63db45caf0b1c7a1bd9081c8682cd802c324c63f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 07:46:13 +0100 Subject: [PATCH 411/546] vbump --- README.md | 5 ++--- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 03e776a50..4f8b2a81c 100644 --- a/README.md +++ b/README.md @@ -419,10 +419,9 @@ state_in = State(relations=[ PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )], - unit_id=1) + )]) -Context(...).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. +Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. ``` diff --git a/pyproject.toml b/pyproject.toml index e5a26b8dc..d0a9a6970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0" +version = "6.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 99653d5de7108b64e7bc17afb5dcf0f6248b0249 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 09:20:41 +0100 Subject: [PATCH 412/546] compatibility with ops<2.10 --- scenario/ops_main_mock.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index d4cd1344a..b25387671 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -110,12 +110,23 @@ def setup_framework( meta = CharmMeta.from_yaml(metadata, actions_metadata) - # If we are in a RelationBroken event, we want to know which relation is - # broken within the model, not only in the event's `.relation` attribute. - broken_relation_id = ( - event.relation.relation_id if event.name.endswith("_relation_broken") else None # type: ignore - ) - model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id) + # ops >= 2.10 + if inspect.signature(ops.model.Model).parameters["broken_relation_id"]: + # If we are in a RelationBroken event, we want to know which relation is + # broken within the model, not only in the event's `.relation` attribute. + broken_relation_id = ( + event.relation.relation_id + if event.name.endswith("_relation_broken") + else None # type: ignore + ) + + model = ops.model.Model( + meta, + model_backend, + broken_relation_id=broken_relation_id, + ) + else: + model = ops.model.Model(meta, model_backend) charm_state_path = charm_dir / CHARM_STATE_FILE From 55eb93a79e206d69328dafdaa952c228202a768b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 09:21:00 +0100 Subject: [PATCH 413/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d0a9a6970..efac2dfc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.1" +version = "6.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 406f81befc953fb2c224479abcb6e23224f70c28 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 10:19:49 +0100 Subject: [PATCH 414/546] warn --- scenario/ops_main_mock.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b25387671..1e2dc3fc6 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -126,6 +126,10 @@ def setup_framework( broken_relation_id=broken_relation_id, ) else: + ops_logger.warning( + "It looks like this charm is using an older `ops` version. " + "You may experience weirdness. Please update ops.", + ) model = ops.model.Model(meta, model_backend) charm_state_path = charm_dir / CHARM_STATE_FILE From 4243b64c0cb3ed55b71a7d8802115ce6a06bdc18 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 10:29:18 +0100 Subject: [PATCH 415/546] static --- scenario/ops_main_mock.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 1e2dc3fc6..fb72a7356 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -115,9 +115,9 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.relation_id + event.relation.relation_id # type: ignore if event.name.endswith("_relation_broken") - else None # type: ignore + else None ) model = ops.model.Model( From 1ca86a5d0ad31da25c961e124ce275b03516e865 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 9 Feb 2024 11:00:39 +0100 Subject: [PATCH 416/546] get --- scenario/ops_main_mock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index fb72a7356..c2eee10ec 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -111,7 +111,7 @@ def setup_framework( meta = CharmMeta.from_yaml(metadata, actions_metadata) # ops >= 2.10 - if inspect.signature(ops.model.Model).parameters["broken_relation_id"]: + if inspect.signature(ops.model.Model).parameters.get("broken_relation_id"): # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( From 09746381631335e4c6cf930b930a2d2c7d8df3cc Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 16 Feb 2024 16:26:33 +1300 Subject: [PATCH 417/546] Add .id to ActionEvent. --- scenario/runtime.py | 15 ++++++------ scenario/sequences.py | 8 ++++--- scenario/state.py | 16 +++++++++++++ tests/test_context.py | 3 +++ tests/test_e2e/test_actions.py | 44 ++++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 489169b61..483e46bbf 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -180,25 +180,24 @@ def _cleanup_env(env): def _get_event_env(self, state: "State", event: "Event", charm_root: Path): """Build the simulated environment the operator framework expects.""" - if event.name.endswith("_action"): - # todo: do we need some special metadata, or can we assume action names - # are always dashes? - action_name = event.name[: -len("_action")].replace("_", "-") - else: - action_name = "" - env = { "JUJU_VERSION": self._juju_version, "JUJU_UNIT_NAME": f"{self._app_name}/{self._unit_id}", "_": "./dispatch", "JUJU_DISPATCH_PATH": f"hooks/{event.name}", "JUJU_MODEL_NAME": state.model.name, - "JUJU_ACTION_NAME": action_name, "JUJU_MODEL_UUID": state.model.uuid, "JUJU_CHARM_DIR": str(charm_root.absolute()), # todo consider setting pwd, (python)path } + if event.name.endswith("_action"): + # todo: do we need some special metadata, or can we assume action names + # are always dashes? + env["JUJU_ACTION_NAME"] = event.name[: -len("_action")].replace("_", "-") + assert event.action is not None + env["JUJU_ACTION_UUID"] = event.action.id + if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): remote_app_name = self._app_name diff --git a/scenario/sequences.py b/scenario/sequences.py index 448d5a2d9..00f503fbe 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -58,9 +58,11 @@ def generate_startup_sequence(state_template: State): ( ( Event( - "leader_elected" - if state_template.leader - else "leader_settings_changed", + ( + "leader_elected" + if state_template.leader + else "leader_settings_changed" + ), ), state_template.copy(), ), diff --git a/scenario/state.py b/scenario/state.py index 34e8e2b15..b76e80387 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1423,12 +1423,28 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: ) +_next_action_id_counter = 1 + + +def next_action_id(update=True): + global _next_action_id_counter + cur = _next_action_id_counter + if update: + _next_action_id_counter += 1 + # Juju currently uses numbers for the ID, but in the past used UUIDs, so + # we need these to be strings. + return str(cur) + + @dataclasses.dataclass(frozen=True) class Action(_DCBase): name: str params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) + id: str = dataclasses.field(default_factory=next_action_id) + """Juju action ID. Every action invocation gets a new unique one.""" + @property def event(self) -> Event: """Helper to generate an action event from this action.""" diff --git a/tests/test_context.py b/tests/test_context.py index 86d76d14b..ff9ef6902 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,6 +4,7 @@ from ops import CharmBase from scenario import Action, Context, Event, State +from scenario.state import next_action_id class MyCharm(CharmBase): @@ -31,6 +32,7 @@ def test_run(): def test_run_action(): ctx = Context(MyCharm, meta={"name": "foo"}) state = State() + expected_id = next_action_id(update=False) with patch.object(ctx, "_run_action") as p: ctx._output_state = ( @@ -46,6 +48,7 @@ def test_run_action(): assert isinstance(a, Action) assert a.event.name == "do_foo_action" assert s is state + assert a.id == expected_id def test_clear(): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index a63c6ee3a..5282c1955 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -1,4 +1,5 @@ import pytest +from ops import __version__ as ops_version from ops.charm import ActionEvent, CharmBase from ops.framework import Framework @@ -135,3 +136,46 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): assert out.failure == "failed becozz" assert out.logs == ["log1", "log2"] assert out.success is False + + +def _ops_less_than(wanted_major, wanted_minor): + major, minor = (int(v) for v in ops_version.split(".")[:2]) + if major < wanted_major: + return True + if major == wanted_major and minor < wanted_minor: + return True + return False + + +@pytest.mark.skipif( + _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id" +) +def test_action_event_has_id(mycharm): + def handle_evt(charm: CharmBase, evt: ActionEvent): + if not isinstance(evt, ActionEvent): + return + assert isinstance(evt.id, str) and evt.id != "" + + mycharm._evt_handler = handle_evt + + action = Action("foo") + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + ctx.run_action(action, State()) + + +@pytest.mark.skipif( + _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id" +) +def test_action_event_has_override_id(mycharm): + uuid = "0ddba11-cafe-ba1d-5a1e-dec0debad" + + def handle_evt(charm: CharmBase, evt: ActionEvent): + if not isinstance(evt, ActionEvent): + return + assert evt.id == uuid + + mycharm._evt_handler = handle_evt + + action = Action("foo", id=uuid) + ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) + ctx.run_action(action, State()) From e03b3ee1545e1deaf40d08636fab8a1861d49de8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 19 Feb 2024 09:47:43 +1300 Subject: [PATCH 418/546] Adjust per code review. --- scenario/runtime.py | 14 ++++++++------ scenario/state.py | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index 483e46bbf..a6aebab0e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -191,12 +191,14 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): # todo consider setting pwd, (python)path } - if event.name.endswith("_action"): - # todo: do we need some special metadata, or can we assume action names - # are always dashes? - env["JUJU_ACTION_NAME"] = event.name[: -len("_action")].replace("_", "-") - assert event.action is not None - env["JUJU_ACTION_UUID"] = event.action.id + if event._is_action_event and (action := event.action): + env.update( + { + # TODO: we should check we're doing the right thing here. + "JUJU_ACTION_NAME": action.name.replace("_", "-"), + "JUJU_ACTION_UUID": action.id, + }, + ) if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): diff --git a/scenario/state.py b/scenario/state.py index b76e80387..9aa88dc99 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1443,7 +1443,10 @@ class Action(_DCBase): params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) id: str = dataclasses.field(default_factory=next_action_id) - """Juju action ID. Every action invocation gets a new unique one.""" + """Juju action ID. + + 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: From 9592497ff77f2bca678d196372431589241754ec Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Mar 2024 08:49:30 +0100 Subject: [PATCH 419/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d0a9a6970..efac2dfc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.1" +version = "6.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From a7c84138ca7f010d7266b6b02e9612f04bc5063b Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 19 Mar 2024 11:33:15 +0100 Subject: [PATCH 420/546] removed rotten inject code --- pyproject.toml | 2 +- scenario/__init__.py | 2 -- scenario/sequences.py | 22 +++++++++------------- scenario/state.py | 28 ---------------------------- 4 files changed, 10 insertions(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efac2dfc0..dc5a4b13e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.2" +version = "6.0.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/__init__.py b/scenario/__init__.py index f16a6791d..b84248191 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -10,7 +10,6 @@ DeferredEvent, Event, ExecOutput, - InjectRelation, Model, Mount, Network, @@ -51,5 +50,4 @@ "State", "DeferredEvent", "Event", - "InjectRelation", ] diff --git a/scenario/sequences.py b/scenario/sequences.py index 00f503fbe..49c01b336 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -12,9 +12,7 @@ BREAK_ALL_RELATIONS, CREATE_ALL_RELATIONS, DETACH_ALL_STORAGES, - META_EVENTS, Event, - InjectRelation, State, ) @@ -32,20 +30,18 @@ def decompose_meta_event(meta_event: Event, state: State): logger.warning(f"meta-event {meta_event.name} not supported yet") return - if meta_event.name in [CREATE_ALL_RELATIONS, BREAK_ALL_RELATIONS]: + is_rel_created_meta_event = meta_event.name == CREATE_ALL_RELATIONS + is_rel_broken_meta_event = meta_event.name == BREAK_ALL_RELATIONS + if is_rel_broken_meta_event: for relation in state.relations: - event = Event( - relation.endpoint + META_EVENTS[meta_event.name], - args=( - # right now, the Relation object hasn't been created by ops yet, so we - # can't pass it down. - # this will be replaced by a Relation instance before the event is fired. - InjectRelation(relation.endpoint, relation.relation_id), - ), - ) + event = relation.broken_event + logger.debug(f"decomposed meta {meta_event.name}: {event}") + yield event, state.copy() + elif is_rel_created_meta_event: + for relation in state.relations: + event = relation.created_event logger.debug(f"decomposed meta {meta_event.name}: {event}") yield event, state.copy() - else: raise RuntimeError(f"unknown meta-event {meta_event.name}") diff --git a/scenario/state.py b/scenario/state.py index 9aa88dc99..85b0f3421 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1465,31 +1465,3 @@ def deferred( if isinstance(event, str): event = Event(event, relation=relation, container=container) return event.deferred(handler=handler, event_id=event_id) - - -@dataclasses.dataclass(frozen=True) -class Inject(_DCBase): - """Base class for injectors: special placeholders used to tell harness_ctx - to inject instances that can't be retrieved in advance in event args or kwargs. - """ - - -@dataclasses.dataclass(frozen=True) -class InjectRelation(Inject): - relation_name: str - relation_id: Optional[int] = None - - -def _derive_args(event_name: str): - args = [] - for term in RELATION_EVENTS_SUFFIX: - # fixme: we can't disambiguate between relation id-s. - if event_name.endswith(term): - args.append(InjectRelation(relation_name=event_name[: -len(term)])) - - return tuple(args) - - -# todo: consider -# def get_containers_from_metadata(CharmType, can_connect: bool = False) -> List[Container]: -# pass From 4519d06ad63ff51a3bd6ce7fb65c5999169c8bde Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 22 Mar 2024 12:58:38 +1300 Subject: [PATCH 421/546] Use default_factory to provide default UUID and name values for Model. --- scenario/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 85b0f3421..8d14e493a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -562,8 +562,8 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) class Model(_DCBase): - name: str = _random_model_name() - uuid: str = str(uuid4()) + name: str = dataclasses.field(default_factory=_random_model_name) + uuid: str = dataclasses.field(default_factory=lambda: str(uuid4())) # whatever juju models --format=json | jq '.models[].type' gives back. # TODO: make this exhaustive. From 48831d82e23161e266c2e104b86a492131cab092 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 26 Mar 2024 17:29:14 +1300 Subject: [PATCH 422/546] First pass. Various capitalisation type adjustments. Explicit framework arg in __init__. import scenario and import ops, not from x import y. Adjust headings so that the structure makes more sense. Move a couple of items so that the organisation is clearer. --- README.md | 698 ++++++++++++++++++++++++++---------------------------- 1 file changed, 342 insertions(+), 356 deletions(-) diff --git a/README.md b/README.md index 4f8b2a81c..d0d46048d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This puts scenario tests somewhere in between unit and integration tests: some s Scenario tests nudge you into thinking of a charm as an input->output function. The input is the union of an `Event` (why am I, charm, being executed), a `State` (am I leader? what is my relation data? what is my config?...) and the charm's execution `Context` (what relations can I have? what containers can I have?...). The output is another `State`: the state after the charm has had a chance to interact with the -mocked juju model and affect the initial state back. +mocked Juju model and affect the initial state back. ![state transition model depiction](resources/state-transition-model.png) @@ -29,7 +29,7 @@ Scenario-testing a charm, then, means verifying that: - the charm does not raise uncaught exceptions while handling the event - the output state (or the diff with the input state) is as expected. -# Core concepts as a metaphor +## Core concepts as a metaphor I like metaphors, so here we go: @@ -43,7 +43,7 @@ I like metaphors, so here we go: - How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a container), or write something down into one of the books. -# Core concepts not as a metaphor +## Core concepts not as a metaphor Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black box. An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. Scenario tests are about @@ -57,7 +57,7 @@ Comparing scenario tests with `Harness` tests: - Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time. This ensures that the execution environment is as clean as possible (for a unit test). -- Harness maintains a model of the juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the +- Harness maintains a model of the Juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more lightweight and portable. @@ -82,51 +82,49 @@ available. The charm has no config, no relations, no leadership, and its status With that, we can write the simplest possible scenario test: ```python -from scenario import State, Context, Event -from ops.charm import CharmBase -from ops.model import UnknownStatus +import ops +import scenario -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): pass def test_scenario_base(): - ctx = Context(MyCharm, meta={"name": "foo"}) - out = ctx.run(Event("start"), State()) - assert out.unit_status == UnknownStatus() + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + out = ctx.run(scenario.Event("start"), scenario.State()) + assert out.unit_status == ops.UnknownStatus() ``` Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python +import ops import pytest -from scenario import State, Context -from ops.charm import CharmBase -from ops.model import ActiveStatus +import scenario -class MyCharm(CharmBase): - def __init__(self, ...): - self.framework.observe(self.on.start, self._on_start) +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) def _on_start(self, _): if self.unit.is_leader(): - self.unit.status = ActiveStatus('I rule') + self.unit.status = ops.ActiveStatus('I rule') else: - self.unit.status = ActiveStatus('I am ruled') + self.unit.status = ops.ActiveStatus('I am ruled') @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - ctx = Context(MyCharm, - meta={"name": "foo"}) - out = ctx.run('start', State(leader=leader)) - assert out.unit_status == ActiveStatus('I rule' if leader else 'I am ruled') + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + out = ctx.run('start', scenario.State(leader=leader)) + assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can -ask the juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... +ask the Juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc... ## Statuses @@ -135,36 +133,36 @@ sets the expected unit/application status. We have seen a simple example above i charm transitions through a sequence of statuses? ```python -from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, BlockedStatus +import ops # charm code: def _on_event(self, _event): - self.unit.status = MaintenanceStatus('determining who the ruler is...') + self.unit.status = ops.MaintenanceStatus('determining who the ruler is...') try: - if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership: - self.unit.status = ActiveStatus('I rule') + if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership(): + self.unit.status = ops.ActiveStatus('I rule') else: - self.unit.status = WaitingStatus('checking this is right...') + self.unit.status = ops.WaitingStatus('checking this is right...') self._check_that_takes_some_more_time() - self.unit.status = ActiveStatus('I am ruled') + self.unit.status = ops.ActiveStatus('I am ruled') except: - self.unit.status = BlockedStatus('something went wrong') + self.unit.status = ops.BlockedStatus('something went wrong') ``` More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its relationship with the `State`. -# Context and State +## Context and State -Consider the following tests. Suppose we want to verify that while handling a given toplevel juju event: +Consider the following tests. Suppose we want to verify that while handling a given top-level Juju event: - a specific chain of (custom) events was emitted on the charm - the charm `juju-log`ged these specific strings - the charm went through this sequence of app/unit statuses (e.g. `maintenance`, then `waiting`, then `active`) -These types of test have a place in Scenario, but that is not State: the contents of the juju log or the status history +These types of test have a place in Scenario, but that is not State: the contents of the Juju log or the status history are side effects of executing a charm, but are not persisted in a charm-accessible "state" in any meaningful way. In other words: those data streams are, from the charm's perspective, write-only. @@ -176,49 +174,47 @@ context. You can verify that the charm has followed the expected path by checking the unit/app status history like so: ```python +import ops +import scenario from charm import MyCharm -from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownStatus -from scenario import State, Context def test_statuses(): - ctx = Context(MyCharm, - meta={"name": "foo"}) - ctx.run('start', State(leader=False)) + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + out = ctx.run('start', scenario.State(leader=False)) assert ctx.unit_status_history == [ - UnknownStatus(), - MaintenanceStatus('determining who the ruler is...'), - WaitingStatus('checking this is right...'), - ActiveStatus("I am ruled"), + ops.UnknownStatus(), + ops.MaintenanceStatus('determining who the ruler is...'), + ops.WaitingStatus('checking this is right...'), ] + assert out.unit_status == ops.ActiveStatus("I am ruled"), # similarly you can check the app status history: assert ctx.app_status_history == [ - UnknownStatus(), + ops.UnknownStatus(), ... ] ``` -Note that the current status is not in the **unit status history**. +Note that the *current* status is **not** in the unit status history. Also note that, unless you initialize the State with a preexisting status, the first status in the history will always -be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever +be `unknown`. That is because, so far as Scenario is concerned, each event is "the first event this charm has ever seen". If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python -from ops.model import ActiveStatus -from scenario import State, Status +import ops +import scenario # ... -ctx.run('start', State(unit_status=ActiveStatus('foo'))) +ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ - ActiveStatus('foo'), # now the first status is active: 'foo'! + ops.ActiveStatus('foo'), # now the first status is active: 'foo'! # ... ] - ``` ## Workload version history @@ -227,10 +223,10 @@ Using a similar api to `*_status_history`, you can assert that the charm has set hook execution: ```python -from scenario import Context +import scenario # ... -ctx: Context +ctx: scenario.Context assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -243,16 +239,16 @@ given Juju event triggering (say, 'start'), a specific chain of events is emitte resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python -from scenario import Context -from ops.charm import StartEvent +import ops +import scenario def test_foo(): - ctx = Context(...) + ctx = scenario.Context(...) ctx.run('start', ...) assert len(ctx.emitted_events) == 1 - assert isinstance(ctx.emitted_events[0], StartEvent) + assert isinstance(ctx.emitted_events[0], ops.StartEvent) ``` You can configure what events will be captured by passing the following arguments to `Context`: @@ -261,15 +257,15 @@ You can configure what events will be captured by passing the following argument For example: ```python -from scenario import Context, Event, State +import scenario def test_emitted_full(): - ctx = Context( + ctx = scenario.Context( MyCharm, capture_deferred_events=True, capture_framework_events=True, ) - ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)])) + ctx.run("start", scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ @@ -281,7 +277,6 @@ def test_emitted_full(): ] ``` - ### Low-level access: using directly `capture_events` If you need more control over what events are captured (or you're not into pytest), you can use directly the context @@ -291,21 +286,21 @@ This context manager allows you to intercept any events emitted by the framework Usage: ```python -from ops.charm import StartEvent, UpdateStatusEvent -from scenario import State, Context, DeferredEvent, capture_events +import ops +import scenario with capture_events() as emitted: - ctx = Context(...) + ctx = scenario.Context(...) state_out = ctx.run( "update-status", - State(deferred=[DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) ) # deferred events get reemitted first -assert isinstance(emitted[0], StartEvent) -# the main juju event gets emitted next -assert isinstance(emitted[1], UpdateStatusEvent) -# possibly followed by a tail of all custom events that the main juju event triggered in turn +assert isinstance(emitted[0], ops.StartEvent) +# the main Juju event gets emitted next +assert isinstance(emitted[1], ops.UpdateStatusEvent) +# possibly followed by a tail of all custom events that the main Juju event triggered in turn # assert isinstance(emitted[2], MyFooEvent) # ... ``` @@ -313,17 +308,16 @@ assert isinstance(emitted[1], UpdateStatusEvent) You can filter events by type like so: ```python -from ops.charm import StartEvent, RelationEvent -from scenario import capture_events +import ops -with capture_events(StartEvent, RelationEvent) as emitted: +with capture_events(ops.StartEvent, ops.RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. pass ``` Configuration: -- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(EventBase)`. +- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(ops.EventBase)`. - By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. - By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by @@ -334,25 +328,24 @@ Configuration: You can write scenario tests to verify the shape of relation data: ```python -from ops.charm import CharmBase - -from scenario import Relation, State, Context +import ops +import scenario # This charm copies over remote app data to local unit data -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): ... - def _on_event(self, e): - rel = e.relation + def _on_event(self, event): + rel = event.relation assert rel.app.name == 'remote' assert rel.data[self.unit]['abc'] == 'foo' - rel.data[self.unit]['abc'] = rel.data[e.app]['cde'] + rel.data[self.unit]['abc'] = rel.data[event.app]['cde'] def test_relation_data(): - state_in = State(relations=[ - Relation( + state_in = scenario.State(relations=[ + scenario.Relation( endpoint="foo", interface="bar", remote_app_name="remote", @@ -360,15 +353,14 @@ def test_relation_data(): remote_app_data={"cde": "baz!"}, ), ]) - ctx = Context(MyCharm, - meta={"name": "foo"}) + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_out = ctx.run('start', state_in) assert state_out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: assert state_out.relations == [ - Relation( + scenario.Relation( endpoint="foo", interface="bar", remote_app_name="remote", @@ -381,7 +373,7 @@ def test_relation_data(): ``` The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be -derived from the charm's `metadata.yaml`. When fully defaulted, a relation is 'empty'. There are no remote units, the +derived from the charm's metadata. When fully defaulted, a relation is 'empty'. There are no remote units, the remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the databags yet. @@ -394,16 +386,16 @@ for a peer relation to have a different 'remote app' than its 'local app', becau ### PeerRelation -To declare a peer relation, you should use `scenario.state.PeerRelation`. The core difference with regular relations is +To declare a peer relation, you should use `scenario.PeerRelation`. The core difference with regular relations is that peer relations do not have a "remote app" (it's this app, in fact). So unlike `Relation`, a `PeerRelation` does not have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`: - `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python -from scenario.state import PeerRelation +import scenario -relation = PeerRelation( +relation = scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, ) @@ -413,22 +405,22 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -from scenario import State, PeerRelation, Context +import scenario -state_in = State(relations=[ - PeerRelation( +state_in = scenario.State(relations=[ + scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, )]) -Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. +scenario.Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. ``` ### SubordinateRelation -To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`. The core difference with regular +To declare a subordinate relation, you should use `scenario.SubordinateRelation`. The core difference with regular relations is that subordinate relations always have exactly one remote unit (there is always exactly one remote unit that this unit can see). Because of that, `SubordinateRelation`, compared to `Relation`, always talks in terms of `remote`: @@ -438,9 +430,9 @@ Because of that, `SubordinateRelation`, compared to `Relation`, always talks in - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) ```python -from scenario.state import SubordinateRelation +import scenario -relation = SubordinateRelation( +relation = scenario.SubordinateRelation( endpoint="peers", remote_unit_data={"foo": "bar"}, remote_app_name="zookeeper", @@ -455,9 +447,9 @@ If you want to trigger relation events, the easiest way to do so is get a hold o event from one of its aptly-named properties: ```python -from scenario import Relation +import scenario -relation = Relation(endpoint="foo", interface="bar") +relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = relation.changed_event joined_event = relation.joined_event # ... @@ -466,10 +458,10 @@ joined_event = relation.joined_event This is in fact syntactic sugar for: ```python -from scenario import Relation, Event +import scenario -relation = Relation(endpoint="foo", interface="bar") -changed_event = Event('foo-relation-changed', relation=relation) +relation = scenario.Relation(endpoint="foo", interface="bar") +changed_event = scenario.Event('foo-relation-changed', relation=relation) ``` The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario @@ -478,25 +470,23 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. -To inspect the ID the next relation instance will have, you can call `state.next_relation_id`. +To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. ```python -from scenario import Relation -from scenario.state import next_relation_id +import scenario.state -next_id = next_relation_id(update=False) -rel = Relation('foo') +next_id = scenario.state.next_relation_id(update=False) +rel = scenario.Relation('foo') assert rel.relation_id == next_id ``` This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: ```python -from scenario import Relation -from scenario.state import next_relation_id +import scenario.state -rel = Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=next_relation_id()) +rel = scenario.Relation('foo') +rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id()) assert rel2.relation_id == rel.relation_id + 1 ``` @@ -517,38 +507,39 @@ The `remote_unit_id` will default to the first ID found in the relation's `remot writing is close to that domain, you should probably override it and pass it manually. ```python -from scenario import Relation, Event +import scenario -relation = Relation(endpoint="foo", interface="bar") +relation = scenario.Relation(endpoint="foo", interface="bar") remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) # which is syntactic sugar for: -remote_unit_2_is_joining_event = Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) +remote_unit_2_is_joining_event = scenario.Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) ``` -### Networks +## Networks Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is. -If your charm has a relation `"foo"` (defined in metadata.yaml), then the charm will be able at runtime to do `self.model.get_binding("foo").network`. +If your charm has a relation `"foo"` (defined in its metadata), then the charm will be able at runtime to do `self.model.get_binding("foo").network`. The network you'll get by doing so is heavily defaulted (see `state.Network.default`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. -On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata.yaml and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario. +On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario. If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`. ```python -from scenario import State, Network -state = State(networks={ - 'foo': Network.default(private_address='4.4.4.4') +import scenario + +state = scenario.State(networks={ + 'foo': scenario.Network.default(private_address='192.0.2.1') }) ``` Where `foo` can either be the name of an `extra-bindings`-defined binding, or a relation endpoint. -# Containers +## Containers -When testing a kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will +When testing a Kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. To give the charm access to some containers, you need to pass them to the input state, like so: @@ -557,28 +548,33 @@ To give the charm access to some containers, you need to pass them to the input An example of a state including some containers: ```python -from scenario.state import Container, State +import scenario -state = State(containers=[ - Container(name="foo", can_connect=True), - Container(name="bar", can_connect=False) +state = scenario.State(containers=[ + scenario.Container(name="foo", can_connect=True), + scenario.Container(name="bar", can_connect=False) ]) ``` In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. ### Container filesystem setup + You can configure a container to have some files in it: ```python -from pathlib import Path +import pathlib -from scenario.state import Container, State, Mount +import scenario -local_file = Path('/path/to/local/real/file.txt') +local_file = pathlib.Path('/path/to/local/real/file.txt') -container = Container(name="foo", can_connect=True, mounts={'local': Mount('/local/share/config.yaml', local_file)}) -state = State(containers=[container]) +container = scenario.Container( + name="foo", + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file)} + ) +state = scenario.State(containers=[container]) ``` In this case, if the charm were to: @@ -589,21 +585,22 @@ def _on_start(self, _): content = foo.pull('/local/share/config.yaml').read() ``` -then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempdir` for nicely wrapping +then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempfile` for nicely wrapping data and passing it to the charm via the container. `container.push` works similarly, so you can write a test like: ```python import tempfile -from ops.charm import CharmBase -from scenario import State, Container, Mount, Context + +import ops +import scenario -class MyCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) def _on_pebble_ready(self, _): foo = self.unit.get_container('foo') @@ -612,16 +609,17 @@ class MyCharm(CharmBase): def test_pebble_push(): with tempfile.NamedTemporaryFile() as local_file: - container = Container(name='foo', - can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file.name)}) - state_in = State( - containers=[container] + container = scenario,Container( + name='foo', + can_connect=True, + mounts={'local': Mount('/local/share/config.yaml', local_file.name)} ) - Context( + state_in = State(containers=[container]) + ctx = Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} - ).run( + ) + ctx.run( container.pebble_ready_event(), state_in, ) @@ -633,18 +631,19 @@ need to associate the container with the event is that the Framework uses an env pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting that envvar into the charm's runtime. -## Container filesystem post-mortem +### Container filesystem post-mortem + If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python -from ops.charm import CharmBase -from scenario import State, Container, Mount, Context +import ops +import scenario -class MyCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready) def _on_pebble_ready(self, _): foo = self.unit.get_container('foo') @@ -652,34 +651,30 @@ class MyCharm(CharmBase): def test_pebble_push(): - container = Container(name='foo', - can_connect=True) - state_in = State( - containers=[container] - ) - ctx = Context( + container = scenario.Container(name='foo', can_connect=True) + state_in = scenario.State(containers=[container]) + ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} ) ctx.run("start", state_in) - # this is the root of the simulated container filesystem. Any mounts will be symlinks in it. + # This is the root of the simulated container filesystem. Any mounts will be symlinks in it. container_root_fs = container.get_filesystem(ctx) cfg_file = container_root_fs / 'local' / 'share' / 'config.yaml' assert cfg_file.read_text() == "TEST" ``` -## `Container.exec` mocks +### `Container.exec` mocks `container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the result of that would be: its return code, what will be written to stdout/stderr. ```python -from ops.charm import CharmBase - -from scenario import State, Container, ExecOutput, Context +import ops +import scenario LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml @@ -689,7 +684,7 @@ drwxrwxr-x - ubuntu ubuntu 18 jan 12:06 -- lib """ -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): def _on_start(self, _): foo = self.unit.get_container('foo') proc = foo.exec(['ls', '-ll']) @@ -698,41 +693,42 @@ class MyCharm(CharmBase): def test_pebble_exec(): - container = Container( + container = scenario.Container( name='foo', exec_mock={ ('ls', '-ll'): # this is the command we're mocking - ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) + scenario.ExecOutput(return_code=0, # this data structure contains all we need to mock the call. + stdout=LS_LL) } ) - state_in = State( - containers=[container] - ) - state_out = Context( + state_in = scenario.State(containers=[container]) + ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, - ).run( + ) + state_out = ctx.run( container.pebble_ready_event, state_in, ) ``` -# Storage +## Storage -If your charm defines `storage` in its metadata, you can use `scenario.state.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime. +If your charm defines `storage` in its metadata, you can use `scenario.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime. -Using the same `get_filesystem` API as `Container`, you can access the tempdir used by Scenario to mock the filesystem root before and after the scenario runs. +Using the same `get_filesystem` API as `Container`, you can access the temporary directory used by Scenario to mock the filesystem root before and after the scenario runs. ```python -from scenario import Storage, Context, State -# some charm with a 'foo' filesystem-type storage defined in metadata.yaml -ctx = Context(MyCharm) -storage = Storage("foo") -# setup storage with some content +import scenario + +# Some charm with a 'foo' filesystem-type storage defined in its metadata: +ctx = scenario.Context(MyCharm) +storage = scenario.Storage("foo") + +# Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager("update-status", State(storage=[storage])) as mgr: +with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -750,25 +746,25 @@ assert ( Note that State only wants to know about **attached** storages. A storage which is not attached to the charm can simply be omitted from State and the charm will be none the wiser. -## Storage-add +### Storage-add If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. ```python -# in MyCharm._on_foo: -# the charm requests two new "foo" storage instances to be provisioned +# In MyCharm._on_foo: +# The charm requests two new "foo" storage instances to be provisioned: self.model.storages.request("foo", 2) ``` From test code, you can inspect that: ```python -from scenario import Context, State +import scenario -ctx = Context(MyCharm) -ctx.run('some-event-that-will-cause_on_foo-to-be-called', State()) +ctx = scenario.Context(MyCharm) +ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) -# the charm has requested two 'foo' storages to be provisioned +# the charm has requested two 'foo' storages to be provisioned: assert ctx.requested_storages['foo'] == 2 ``` @@ -776,53 +772,52 @@ Requesting storages has no other consequence in Scenario. In real life, this req So a natural follow-up Scenario test suite for this case would be: ```python -from scenario import Context, State, Storage +import scenario -ctx = Context(MyCharm) -foo_0 = Storage('foo') -# the charm is notified that one of the storages it has requested is ready +ctx = scenario.Context(MyCharm) +foo_0 = scenario.Storage('foo') +# The charm is notified that one of the storages it has requested is ready: ctx.run(foo_0.attached_event, State(storage=[foo_0])) -foo_1 = Storage('foo') -# the charm is notified that the other storage is also ready +foo_1 = scenario.Storage('foo') +# The charm is notified that the other storage is also ready: ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) ``` +## Ports -# Ports - -Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host vm/container. Using the `State.opened_ports` api, you can: +Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host VM/container. Using the `State.opened_ports` API, you can: - simulate a charm run with a port opened by some previous execution ```python -from scenario import State, Port, Context +import scenario -ctx = Context(MyCharm) -ctx.run("start", State(opened_ports=[Port("tcp", 42)])) +ctx = scenario.Context(MyCharm) +ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python -from scenario import State, Port, Context +import scenario -ctx = Context(MyCharm) -state1 = ctx.run("start", State()) -assert state1.opened_ports == [Port("tcp", 42)] +ctx = scenario.Context(MyCharm) +state1 = ctx.run("start", scenario.State()) +assert state1.opened_ports == [scenario.Port("tcp", 42)] state2 = ctx.run("stop", state1) assert state2.opened_ports == [] ``` -# Secrets +## Secrets Scenario has secrets. Here's how you use them. ```python -from scenario import State, Secret +import scenario -state = State( +state = scenario.State( secrets=[ - Secret( + scenario.Secret( id='foo', contents={0: {'key': 'public'}} ) @@ -840,7 +835,6 @@ There are three cases: Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights. - The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists. [note] @@ -850,11 +844,11 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python -from scenario import State, Secret +import scenario -state = State( +state = scenario.State( secrets=[ - Secret( + scenario.Secret( id='foo', contents={0: {'key': 'private'}}, owner='unit', # or 'app' @@ -868,11 +862,11 @@ state = State( To specify a secret owned by some other application and give this unit (or app) access to it: ```python -from scenario import State, Secret +import scenario -state = State( +state = scenario.State( secrets=[ - Secret( + scenario.Secret( id='foo', contents={0: {'key': 'public'}}, # owner=None, which is the default @@ -882,6 +876,57 @@ state = State( ) ``` +## StoredState + +Scenario can simulate StoredState. You can define it on the input side as: + +```python +import ops +import scenario + +from ops.charm import CharmBase + + +class MyCharmType(ops.CharmBase): + my_stored_state = ops.StoredState() + + def __init__(self, framework): + super().__init__(framework) + assert self.my_stored_state.foo == 'bar' # this will pass! + + +state = scenario.State(stored_state=[ + scenario.StoredState( + owner_path="MyCharmType", + name="my_stored_state", + content={ + 'foo': 'bar', + 'baz': {42: 42}, + }) +]) +``` + +And the charm's runtime will see `self.my_stored_state.foo` and `.baz` as expected. Also, you can run assertions on it on +the output side the same as any other bit of state. + +## Resources + +If your charm requires access to resources, you can make them available to it through `State.resources`. +From the perspective of a 'real' deployed charm, if your charm _has_ resources defined in its metadata, they _must_ be made available to the charm. That is a Juju-enforced constraint: you can't deploy a charm without attaching all resources it needs to it. +However, when testing, this constraint is unnecessarily strict (and it would also mean the great majority of all existing tests would break) since a charm will only notice that a resource is not available when it explicitly asks for it, which not many charms do. + +So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in the metadata. + +```python +import scenario + +ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) +with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: + # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. + path = mgr.charm.model.resources.fetch('foo') + assert path == '/path/to/resource.tar' +``` + # Actions An action is a special sort of event, even though `ops` handles them almost identically. @@ -894,26 +939,27 @@ How to test actions with scenario: ## Actions without parameters ```python -from scenario import Context, State, ActionOutput +import scenario + from charm import MyCharm def test_backup_action(): - ctx = Context(MyCharm) + ctx = scenario.Context(MyCharm) - # If you didn't declare do_backup in the charm's `actions.yaml`, + # 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: ActionOutput = ctx.run_action("do_backup_action", State()) + out: scenario.ActionOutput = ctx.run_action("do_backup_action", scenario.State()) - # you can assert action results, logs, failure using the ActionOutput interface + # 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: + # 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 + # If the action fails, we can read a failure message: assert out.failure == 'boo-hoo' ``` @@ -922,18 +968,19 @@ def test_backup_action(): If the action takes parameters, you'll need to instantiate an `Action`. ```python -from scenario import Action, Context, State, ActionOutput +import scenario + from charm import MyCharm def test_backup_action(): - # define an action - action = Action('do_backup', params={'a': 'b'}) - ctx = Context(MyCharm) + # 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 declared in actions.yaml, - # the `ConsistencyChecker` will slap you on the other wrist. - out: ActionOutput = ctx.run_action(action, State()) + # 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()) # ... ``` @@ -946,28 +993,27 @@ event in its queue (they would be there because they had been deferred in the pr valid. ```python -from scenario import State, deferred, Context +import scenario class MyCharm(...): ... - def _on_update_status(self, e): - e.defer() + def _on_update_status(self, event): + event.defer() - def _on_start(self, e): - e.defer() + def _on_start(self, event): + event.defer() def test_start_on_deferred_update_status(MyCharm): """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" - state_in = State( + state_in = scenario.State( deferred=[ - deferred('update_status', - handler=MyCharm._on_update_status) + scenario.deferred('update_status', handler=MyCharm._on_update_status) ] ) - state_out = Context(MyCharm).run('start', state_in) + state_out = scenario.Context(MyCharm).run('start', state_in) assert len(state_out.deferred) == 1 assert state_out.deferred[0].name == 'start' ``` @@ -976,40 +1022,33 @@ You can also generate the 'deferred' data structure (called a DeferredEvent) fro handler): ```python -from scenario import Event, Relation +import scenario class MyCharm(...): ... -deferred_start = Event('start').deferred(MyCharm._on_start) -deferred_install = Event('install').deferred(MyCharm._on_start) -``` - -## relation events: - -```python -foo_relation = Relation('foo') -deferred_relation_changed_evt = foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +deferred_start = scenario.Event('start').deferred(MyCharm._on_start) +deferred_install = scenario.Event('install').deferred(MyCharm._on_start) ``` On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. ```python -from scenario import State, Context +import scenario class MyCharm(...): ... - def _on_start(self, e): - e.defer() + def _on_start(self, event): + event.defer() def test_defer(MyCharm): - out = Context(MyCharm).run('start', State()) + out = scenario.Context(MyCharm).run('start', scenario.State()) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` @@ -1021,44 +1060,43 @@ Relation instance they are about. So do they in Scenario. You can use the deferr structure: ```python -from scenario import State, Relation, deferred +import scenario class MyCharm(...): ... - def _on_foo_relation_changed(self, e): - e.defer() + def _on_foo_relation_changed(self, event): + event.defer() def test_start_on_deferred_update_status(MyCharm): - foo_relation = Relation('foo') - State( + foo_relation = scenario.Relation('foo') + scenario.State( relations=[foo_relation], deferred=[ - deferred('foo_relation_changed', - handler=MyCharm._on_foo_relation_changed, - relation=foo_relation) + scenario.deferred('foo_relation_changed', + handler=MyCharm._on_foo_relation_changed, + relation=foo_relation) ] ) ``` -but you can also use a shortcut from the relation event itself, as mentioned above: +but you can also use a shortcut from the relation event itself: ```python - -from scenario import Relation +import scenario class MyCharm(...): ... -foo_relation = Relation('foo') +foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` -### Fine-tuning +## Fine-tuning The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by charm libraries or objects other than the main charm class. @@ -1066,67 +1104,18 @@ charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. ```python -from scenario import DeferredEvent +import scenario -my_deferred_event = DeferredEvent( +my_deferred_event = scenario.DeferredEvent( handle_path='MyCharm/MyCharmLib/on/database_ready[1]', owner='MyCharmLib', # the object observing the event. Could also be MyCharm. observer='_on_database_ready' ) ``` -# StoredState - -Scenario can simulate StoredState. You can define it on the input side as: - -```python -from ops.charm import CharmBase -from ops.framework import StoredState as Ops_StoredState, Framework -from scenario import State, StoredState - - -class MyCharmType(CharmBase): - my_stored_state = Ops_StoredState() - - def __init__(self, framework: Framework): - super().__init__(framework) - assert self.my_stored_state.foo == 'bar' # this will pass! - - -state = State(stored_state=[ - StoredState( - owner_path="MyCharmType", - name="my_stored_state", - content={ - 'foo': 'bar', - 'baz': {42: 42}, - }) -]) -``` - -And the charm's runtime will see `self.stored_State.foo` and `.baz` as expected. Also, you can run assertions on it on -the output side the same as any other bit of state. - -# Resources - -If your charm requires access to resources, you can make them available to it through `State.resources`. -From the perspective of a 'real' deployed charm, if your charm _has_ resources defined in `metadata.yaml`, they _must_ be made available to the charm. That is a Juju-enforced constraint: you can't deploy a charm without attaching all resources it needs to it. -However, when testing, this constraint is unnecessarily strict (and it would also mean the great majority of all existing tests would break) since a charm will only notice that a resource is not available when it explicitly asks for it, which not many charms do. - -So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in metadata. - -```python -from scenario import State, Context -ctx = Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) -with ctx.manager("start", State(resources={'foo': '/path/to/resource.tar'})) as mgr: - # if the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. - path = mgr.charm.model.resources.fetch('foo') - assert path == '/path/to/resource.tar' -``` - # Emitting custom events -While the main use case of Scenario is to emit juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, +While the main use case of Scenario is to emit Juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. Suppose your charm uses a charm library providing an `ingress_provided` event. @@ -1139,9 +1128,9 @@ the event is emitted at all. If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: ```python -from scenario import Context, State +import scenario -Context(...).run("ingress_provided", State()) # raises scenario.ops_main_mock.NoObserverError +scenario.Context(...).run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError ``` This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but @@ -1150,9 +1139,9 @@ since the event is defined on another Object, it will fail to find it. You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: ```python -from scenario import Context, State +import scenario -Context(...).run("my_charm_lib.on.foo", State()) +scenario.Context(...).run("my_charm_lib.on.foo", scenario.State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. @@ -1168,15 +1157,15 @@ 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: ```python -from ops import CharmBase, StoredState +import ops +import scenario from charms.bar.lib_name.v1.charm_lib import CharmLib -from scenario import Context, State -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): META = {"name": "mycharm"} - _stored = StoredState() + _stored = ops.StoredState() def __init__(self, framework): super().__init__(framework) @@ -1189,23 +1178,23 @@ class MyCharm(CharmBase): def test_live_charm_introspection(mycharm): - ctx = Context(mycharm, meta=mycharm.META) + 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", State()) as manager: - # this is your charm instance, after ops has set it up + with ctx.manager("start", scenario.State()) as manager: + # This is your charm instance, after ops has set it up: charm: MyCharm = manager.charm - # we can check attributes on nested Objects or the charm itself + # We can check attributes on nested Objects or the charm itself: assert charm.my_charm_lib.foo == "foo" - # such as stored state + # 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 + # This will tell ops.main to proceed with normal execution and emit the "start" event on the charm: state_out = manager.run() - # after that is done, we are handed back control, and we can again do some introspection + # After that is done, we are handed back control, and we can again do some introspection: assert charm.my_charm_lib.foo == "bar" - # and check that the charm's internal state is as we expect + # and check that the charm's internal state is as we expect: assert charm._stored.a == "b" # state_out is, as in regular scenario tests, a State object you can assert on: @@ -1219,68 +1208,66 @@ can't emit multiple events in a single charm execution. # The virtual charm root Before executing the charm, Scenario copies the charm's `/src`, any libs, the metadata, config, and actions `yaml`s to a temporary directory. The -charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can be +charm will see that temporary directory as its 'root'. This allows us to keep things simple when dealing with metadata that can be either inferred from the charm type being passed to `Context` or be passed to it as an argument, thereby overriding the inferred one. This also allows you to test charms defined on the fly, as in: ```python -from ops.charm import CharmBase -from scenario import State, Context +import ops +import scenario -class MyCharmType(CharmBase): +class MyCharmType(ops.CharmBase): pass -ctx = Context(charm_type=MyCharmType, - meta={'name': 'my-charm-name'}) +ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) ctx.run('start', State()) ``` -A consequence of this fact is that you have no direct control over the tempdir that we are creating to put the metadata +A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata you are passing to `.run()` (because `ops` expects it to be a file...). That is, unless you pass your own: ```python -from ops.charm import CharmBase -from scenario import State, Context import tempfile +import ops +import scenario -class MyCharmType(CharmBase): + +class MyCharmType(ops.CharmBase): pass td = tempfile.TemporaryDirectory() -state = Context( +state = scenario.Context( charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name -).run('start', State()) +).run('start', scenario.State()) ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its contents after the charm has run. Do keep in mind that any metadata files you create in it will be overwritten by Scenario, and therefore ignored, if you pass any metadata keys to `Context`. Omit `meta` in the call -above, and Scenario will instead attempt to read `metadata.yaml` from the +above, and Scenario will instead attempt to read metadata from the temporary directory. - # Immutability -All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are immutable (implemented as frozen -dataclasses). +All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are implemented as frozen dataclasses. This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the state that you obtain in return is a different instance, and all parts of it have been (deep)copied. -This ensures that you can do delta-based comparison of states without worrying about them being mutated by scenario. +This ensures that you can do delta-based comparison of states without worrying about them being mutated by Scenario. If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use the `replace` api. ```python -from scenario import Relation +import scenario -relation = Relation('foo', remote_app_data={"1": "2"}) +relation = scenario.Relation('foo', remote_app_data={"1": "2"}) # make a copy of relation, but with remote_app_data set to {"3", "4"} relation2 = relation.replace(remote_app_data={"3", "4"}) ``` @@ -1289,7 +1276,7 @@ relation2 = relation.replace(remote_app_data={"3", "4"}) A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. For example, Juju can't emit a `foo-relation-changed` event on your charm unless your charm has declared a `foo` relation -endpoint in its `metadata.yaml`. If that happens, that's a juju bug. Scenario however assumes that Juju is bug-free, +endpoint in its metadata. If that happens, that's a Juju bug. Scenario however assumes that Juju is bug-free, therefore, so far as we're concerned, that can't happen, and therefore we help you verify that the scenarios you create are consistent and raise an exception if that isn't so. @@ -1300,7 +1287,7 @@ That happens automatically behind the scenes whenever you trigger an event; - False positives: not all checks are implemented yet; more will come. - False negatives: it is possible that a scenario you know to be consistent is seen as inconsistent. That is probably a - bug in the consistency checker itself, please report it. + bug in the consistency checker itself; please report it. - Inherent limitations: if you have a custom event whose name conflicts with a builtin one, the consistency constraints of the builtin one will apply. For example: if you decide to name your custom event `bar-pebble-ready`, but you are working on a machine charm or don't have either way a `bar` container in your `metadata.yaml`, Scenario will flag that @@ -1314,8 +1301,7 @@ don't need that. # Jhack integrations -Up until `v5.6.0`, `scenario` shipped with a cli tool called `snapshot`, used to interact with a live charm's state. +Up until `v5.6.0`, Scenario shipped with a cli tool called `snapshot`, used to interact with a live charm's state. The functionality [has been moved over to `jhack`](https://github.com/PietroPasotti/jhack/pull/111), to allow us to keep working on it independently, and to streamline -the profile of `scenario` itself as it becomes more broadly adopted and ready for widespread usage. - +the profile of Scenario itself as it becomes more broadly adopted and ready for widespread usage. From 92de04cd803afc3e344c2fd0dbcf4782e3712271 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 10:41:29 +1300 Subject: [PATCH 423/546] Fix the config consistency checker type checking. --- scenario/consistency_checker.py | 27 ++++++++++++- tests/test_consistency_checker.py | 63 +++++++++++++++++++++++++++++++ tests/test_e2e/test_config.py | 2 +- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index ea9ad4897..aeff7c941 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -2,6 +2,7 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import os +import re from collections import Counter from collections.abc import Sequence from numbers import Number @@ -326,10 +327,20 @@ def check_storages_consistency( return Results(errors, []) +def _is_secret_identifier(value): + """Return true iff the value is in the form `secret:{secret id}`.""" + if not value.startswith("secret:"): + return False + secret_id = value.split(":", 1)[1] + # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48 + return re.match(r"^[0-9a-z]{20}$", secret_id) + + def check_config_consistency( *, state: "State", charm_spec: "_CharmSpec", + juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" @@ -348,11 +359,16 @@ def check_config_consistency( converters = { "string": str, "int": int, - "integer": int, # fixme: which one is it? - "number": float, + "float": float, "boolean": bool, # "attrs": NotImplemented, # fixme: wot? } + if juju_version >= (3, 4): + converters["secret"] = str + + validators = { + "secret": _is_secret_identifier, + } expected_type_name = meta_config[key].get("type", None) if not expected_type_name: @@ -371,6 +387,13 @@ def check_config_consistency( f"but is of type {type(value)}.", ) + elif expected_type_name in validators and not validators[expected_type_name]( + value, + ): + errors.append( + f"config invalid: option {key!r} value {value!r} is not valid.", + ) + return Results(errors, []) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index ef92e6d96..1a79f32da 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -156,6 +156,69 @@ def test_bad_config_option_type(): ) +@pytest.mark.parametrize( + "config_type", + ( + ("string", "foo", 1), + ("int", 1, "1"), + ("float", 1.0, 1), + ("boolean", False, "foo"), + ), +) +def test_config_types(config_type): + type_name, valid_value, invalid_value = config_type + assert_consistent( + State(config={"foo": valid_value}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), + ) + assert_inconsistent( + State(config={"foo": invalid_value}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), + ) + + +@pytest.mark.parametrize("juju_version", ("3.4", "3.5", "4.0")) +def test_config_secret(juju_version): + assert_consistent( + State(config={"foo": "secret:co28kefmp25c77utl3n0"}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + juju_version=juju_version, + ) + assert_inconsistent( + State(config={"foo": 1}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + ) + assert_inconsistent( + State(config={"foo": "co28kefmp25c77utl3n0"}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + ) + assert_inconsistent( + State(config={"foo": "secret:secret"}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + ) + assert_inconsistent( + State(config={"foo": "secret:co28kefmp25c77utl3n!"}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + ) + + +@pytest.mark.parametrize("juju_version", ("2.9", "3.3")) +def test_config_secret_old_juju(juju_version): + assert_inconsistent( + State(config={"foo": "secret:co28kefmp25c77utl3n0"}), + Event("bar"), + _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), + juju_version=juju_version, + ) + + @pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99")) def test_secrets_jujuv_bad(bad_v): secret = Secret("secret:foo", {0: {"a": "b"}}) diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index 55c5b70d7..27b25c29a 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -32,7 +32,7 @@ def check_cfg(charm: CharmBase): "update_status", mycharm, meta={"name": "foo"}, - config={"options": {"foo": {"type": "string"}, "baz": {"type": "integer"}}}, + config={"options": {"foo": {"type": "string"}, "baz": {"type": "int"}}}, post_event=check_cfg, ) From bb93896498d4494d28e48aeee05c1bf263fb9623 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 11:42:37 +1300 Subject: [PATCH 424/546] Update scenario/consistency_checker.py --- scenario/consistency_checker.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index aeff7c941..95ff775f2 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -329,11 +329,8 @@ def check_storages_consistency( def _is_secret_identifier(value): """Return true iff the value is in the form `secret:{secret id}`.""" - if not value.startswith("secret:"): - return False - secret_id = value.split(":", 1)[1] # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48 - return re.match(r"^[0-9a-z]{20}$", secret_id) + return re.match(r"secret:[0-9a-z]{20}$", secret_id) def check_config_consistency( From a7707264d537c2a93808f215f4d2a26d8ebbe44a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 12:49:48 +1300 Subject: [PATCH 425/546] Avoid unpleasant wrapping, as per review. --- scenario/consistency_checker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 95ff775f2..d4a87bfb2 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -384,9 +384,7 @@ def check_config_consistency( f"but is of type {type(value)}.", ) - elif expected_type_name in validators and not validators[expected_type_name]( - value, - ): + elif not validators.get(expected_type_name, lambda _: True)(value): errors.append( f"config invalid: option {key!r} value {value!r} is not valid.", ) From 4b7a783a54cbebcfcef5232c97d13818108bbbfa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 12:51:07 +1300 Subject: [PATCH 426/546] Update scenario/consistency_checker.py --- scenario/consistency_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index d4a87bfb2..a075f5989 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -327,7 +327,7 @@ def check_storages_consistency( return Results(errors, []) -def _is_secret_identifier(value): +def _is_secret_identifier(value: str): """Return true iff the value is in the form `secret:{secret id}`.""" # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48 return re.match(r"secret:[0-9a-z]{20}$", secret_id) From 259a97428b3c8d5dedcb3d22a0ac3241e3ffdfd1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 12:54:13 +1300 Subject: [PATCH 427/546] Add type hints. --- scenario/consistency_checker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index a075f5989..8dfbb8537 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -6,7 +6,7 @@ from collections import Counter from collections.abc import Sequence from numbers import Number -from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple +from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union from scenario.runtime import InconsistentScenarioError from scenario.runtime import logger as scenario_logger @@ -327,10 +327,10 @@ def check_storages_consistency( return Results(errors, []) -def _is_secret_identifier(value: str): +def _is_secret_identifier(value: Union[str, int, float, bool]): """Return true iff the value is in the form `secret:{secret id}`.""" # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48 - return re.match(r"secret:[0-9a-z]{20}$", secret_id) + return re.match(r"secret:[0-9a-z]{20}$", str(value)) def check_config_consistency( From 588a9a41c0d96bb930443e567e26d9d70fb0aa2f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 20:01:50 +1300 Subject: [PATCH 428/546] Add docs for Model. --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d0d46048d..f34abfe25 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,8 @@ assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` +Note that the *current* version is not in the version history, as with the status history. + ## Emitted events If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it @@ -807,7 +809,6 @@ state2 = ctx.run("stop", state1) assert state2.opened_ports == [] ``` - ## Secrets Scenario has secrets. Here's how you use them. @@ -927,6 +928,26 @@ with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.ta assert path == '/path/to/resource.tar' ``` +## Model + +Charms don't usually need to be aware of the model in which they are deployed, +but if you need to set the model name or UUID, you can provide a `scenario.Model` +to the state: + +```python +import ops +import scenario + +class MyCharm(ops.CharmBase): + pass + +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) +state_in = scenario.State(model=scenario.Model(name="my-model")) +out = ctx.run("start", state_in) +assert out.model.name == "my-model" +assert out.model.uuid == state_in.model.uuid +``` + # Actions An action is a special sort of event, even though `ops` handles them almost identically. From 3c513a43a9d953ccbc92eb1c6b94fa3a703e5b7c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 28 Mar 2024 22:59:32 +1300 Subject: [PATCH 429/546] Add support for Pebble notices. --- README.md | 39 +++++++++++++ pyproject.toml | 2 +- scenario/__init__.py | 2 + scenario/consistency_checker.py | 8 +-- scenario/mocking.py | 6 ++ scenario/runtime.py | 19 +++++- scenario/state.py | 97 +++++++++++++++++++++++++++++++ tests/test_consistency_checker.py | 10 ++++ tests/test_e2e/test_deferred.py | 22 ++++++- tests/test_e2e/test_event.py | 2 + tests/test_e2e/test_pebble.py | 22 ++++++- tests/test_e2e/test_state.py | 1 + 12 files changed, 222 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4f8b2a81c..fbbc90427 100644 --- a/README.md +++ b/README.md @@ -718,6 +718,45 @@ def test_pebble_exec(): ) ``` +### Pebble Notices + +Pebble can generate notices, which Juju will detect, and wake up the charm to +let it know that something has happened in the container. The most common +use-case is Pebble custom notices, which is a mechanism for the workload +application to trigger a charm event. + +When the charm is notified, there might be a queue of existing notices, or just +the one that has triggered the event: + +```python +import ops +import scenario + +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice) + + def _on_notice(self, event): + event.notice.key # == "example.com/c" + for notice in self.unit.get_container("cont").get_notices(): + ... + +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": "cont": {}}) +notices = [ + scenario.PebbleNotice(key="example.com/a", occurences=10), + scenario.PebbleNotice(key="example.com/b", last_data={"bar": "baz"}), + scenario.PebbleNotice(key="example.com/c"), +] +cont = scenario.Container(notices=notices) +ctx.run(cont.custom_notice_event, scenario.State(containers=[cont])) +``` + +Note that the `custom_notice_event` is accessed via the container, not the notice, +and is always for the last notice in the list. An `ops.pebble.Notice` does not +know which container it is in, but an `ops.PebbleCustomNoticeEvent` does know +which container did the notifying. + # Storage If your charm defines `storage` in its metadata, you can use `scenario.state.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime. diff --git a/pyproject.toml b/pyproject.toml index dc5a4b13e..5c46c574a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.6", + "ops>=2.10", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/__init__.py b/scenario/__init__.py index b84248191..7162772e2 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -13,6 +13,7 @@ Model, Mount, Network, + PebbleNotice, PeerRelation, Port, Relation, @@ -41,6 +42,7 @@ "ExecOutput", "Mount", "Container", + "PebbleNotice", "Address", "BindAddress", "Network", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index ea9ad4897..843f3ed28 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -517,10 +517,10 @@ def check_containers_consistency( # it's fine if you have containers in meta that are not in state.containers (yet), but it's # not fine if: - # - you're processing a pebble-ready event and that container is not in state.containers or + # - you're processing a Pebble event and that container is not in state.containers or # meta.containers if event._is_workload_event: - evt_container_name = event.name[: -len("-pebble-ready")] + evt_container_name = event.name.split("_pebble_")[0] if evt_container_name not in meta_containers: errors.append( f"the event being processed concerns container {evt_container_name!r}, but a " @@ -529,8 +529,8 @@ def check_containers_consistency( if evt_container_name not in state_containers: errors.append( f"the event being processed concerns container {evt_container_name!r}, but a " - f"container with that name is not present in the state. It's odd, but consistent, " - f"if it cannot connect; but it should at least be there.", + f"container with that name is not present in the state. It's odd, but " + f"consistent, if it cannot connect; but it should at least be there.", ) # - a container in state.containers is not in meta.containers diff --git a/scenario/mocking.py b/scenario/mocking.py index d4c7aab36..f19fcaf84 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -663,6 +663,12 @@ def __init__( self._root = container_root + # load any existing notices from the state + self._notices: Dict[Tuple[str, str], pebble.Notice] = {} + for container in state.containers: + for notice in container.notices: + self._notices[str(notice.type), notice.key] = notice._to_pebble_notice() + def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/runtime.py b/scenario/runtime.py index a6aebab0e..acafbe25e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -17,7 +17,12 @@ from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import DeferredEvent, PeerRelation, StoredState +from scenario.state import ( + PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, + DeferredEvent, + PeerRelation, + StoredState, +) if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -248,6 +253,18 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) + if event.name.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): + if not event.container or not event.container.notices: + raise RuntimeError("Pebble notice with no container or notice.") + notice = event.container.notices[-1] + env.update( + { + "JUJU_NOTICE_ID": notice.id, + "JUJU_NOTICE_TYPE": str(notice.type), + "JUJU_NOTICE_KEY": notice.key, + }, + ) + if storage := event.storage: env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"}) diff --git a/scenario/state.py b/scenario/state.py index 8d14e493a..a79dbeb3d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -87,6 +87,7 @@ "collect_unit_status", } PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" +PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", @@ -609,6 +610,72 @@ class Mount(_DCBase): src: Union[str, Path] +def _now_utc(): + return datetime.datetime.now(tz=datetime.timezone.utc) + + +# Ideally, this would be a subclass of pebble.Notice, but we want to make it +# easier to use in tests by providing sensible default values, but there's no +# default key value, so that would need to be first, and that's not the case +# in pebble.Notice, so it's easier to just be explicit and repetitive here. +@dataclasses.dataclass(frozen=True) +class PebbleNotice(_DCBase): + key: str + """The notice key, a string that differentiates notices of this type. + + This is in the format ``example.com/path``. + """ + + id: str = dataclasses.field(default_factory=lambda: str(uuid4())) + """Unique ID for this notice.""" + + user_id: Optional[int] = None + """UID of the user who may view this notice (None means notice is public).""" + + type: Union[pebble.NoticeType, str] = pebble.NoticeType.CUSTOM + """Type of the notice.""" + + first_occurred: datetime.datetime = dataclasses.field(default_factory=_now_utc) + """The first time one of these notices (type and key combination) occurs.""" + + last_occurred: datetime.datetime = dataclasses.field(default_factory=_now_utc) + """The last time one of these notices occurred.""" + + last_repeated: datetime.datetime = dataclasses.field(default_factory=_now_utc) + """The time this notice was last repeated. + + See Pebble's `Notices documentation `_ + for an explanation of what "repeated" means. + """ + + occurrences: int = 1 + """The number of times one of these notices has occurred.""" + + last_data: Dict[str, str] = dataclasses.field(default_factory=dict) + """Additional data captured from the last occurrence of one of these notices.""" + + repeat_after: Optional[datetime.timedelta] = None + """Minimum time after one of these was last repeated before Pebble will repeat it again.""" + + expire_after: Optional[datetime.timedelta] = None + """How long since one of these last occurred until Pebble will drop the notice.""" + + def _to_pebble_notice(self) -> pebble.Notice: + return pebble.Notice( + id=self.id, + user_id=self.user_id, + type=self.type, + key=self.key, + first_occurred=self.first_occurred, + last_occurred=self.last_occurred, + last_repeated=self.last_repeated, + occurrences=self.occurrences, + last_data=self.last_data, + repeat_after=self.repeat_after, + expire_after=self.expire_after, + ) + + @dataclasses.dataclass(frozen=True) class Container(_DCBase): name: str @@ -646,6 +713,8 @@ class Container(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + notices: List[PebbleNotice] = dataclasses.field(default_factory=list) + def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() services = {} # type: Dict[str, pebble.Service] @@ -713,6 +782,21 @@ def pebble_ready_event(self): ) return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) + @property + def custom_notice_event(self): + """Sugar to generate a -pebble-custom-notice event for the latest notice.""" + if not self.notices: + raise RuntimeError("This container does not have any notices.") + if not self.can_connect: + logger.warning( + "you **can** fire pebble-custom-notice while the container cannot connect, " + "but that's most likely not what you want.", + ) + return Event( + path=normalize_name(self.name + "-pebble-custom-notice"), + container=self, + ) + _RawStatusLiteral = Literal[ "waiting", @@ -1191,6 +1275,8 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: # Whether the event name indicates that this is a workload event. if s.endswith(PEBBLE_READY_EVENT_SUFFIX): return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload + if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): + return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload if s in BUILTIN_EVENTS: return "", _EventType.builtin @@ -1397,6 +1483,17 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "container_name": container.name, } + if self._path.suffix == PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX: + if not container.notices: + raise RuntimeError("Container has no notices.") + notice = container.notices[-1] + snapshot_data.update( + { + "notice_id": notice.id, + "notice_key": notice.key, + "notice_type": str(notice.type), + }, + ) elif self._is_relation_event: # this is a RelationEvent. diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index ef92e6d96..55b54ea88 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -60,6 +60,16 @@ def test_workload_event_without_container(): Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) + assert_inconsistent( + State(), + Event("foo-pebble-custom-notice", container=Container("foo")), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(containers=[Container("foo")]), + Event("foo-pebble-custom-notice", container=Container("foo")), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) def test_container_meta_mismatch(): diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index b084f6ffe..4461cd3b8 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,7 +12,14 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Container, DeferredEvent, Relation, State, deferred +from scenario.state import ( + Container, + DeferredEvent, + PebbleNotice, + Relation, + State, + deferred, +) from tests.helpers import trigger CHARM_CALLED = 0 @@ -97,6 +104,19 @@ def test_deferred_workload_evt(mycharm): assert asdict(evt2) == asdict(evt1) +def test_deferred_notice_evt(mycharm): + notice = PebbleNotice(key="example.com/bar") + ctr = Container("foo", notices=[notice]) + evt1 = ctr.custom_notice_event.deferred(handler=mycharm._on_event) + evt2 = deferred( + event="foo_pebble_custom_notice", + handler=mycharm._on_event, + container=ctr, + ) + + assert asdict(evt2) == asdict(evt1) + + def test_deferred_relation_event(mycharm): mycharm.defer_next = 2 diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index 0dd50077e..07c8d30a0 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -17,6 +17,8 @@ ("foo_bar_baz_storage_detaching", _EventType.storage), ("foo_pebble_ready", _EventType.workload), ("foo_bar_baz_pebble_ready", _EventType.workload), + ("foo_pebble_custom_notice", _EventType.workload), + ("foo_bar_baz_pebble_custom_notice", _EventType.workload), ("secret_removed", _EventType.secret), ("pre_commit", _EventType.framework), ("commit", _EventType.framework), diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index b87b350df..ec536e0e1 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -8,7 +8,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, Port, State +from scenario.state import Container, ExecOutput, Mount, PebbleNotice, Port, State from tests.helpers import trigger @@ -365,3 +365,23 @@ def test_exec_wait_output_error(charm_cls): proc = container.exec(["foo"]) with pytest.raises(ExecError): proc.wait_output() + + +def test_pebble_custom_notice(charm_cls): + notices = [ + PebbleNotice(key="example.com/foo"), + PebbleNotice(key="example.com/bar", last_data={"a": "b"}), + PebbleNotice(key="example.com/baz", occurrences=42), + ] + cont = Container( + name="foo", + can_connect=True, + notices=notices, + ) + + state = State(containers=[cont]) + with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( + cont.custom_notice_event, state + ) as mgr: + container = mgr.charm.unit.get_container("foo") + assert container.get_notices() == [n._to_pebble_notice() for n in notices] diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index d92e5b12f..838426aea 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -19,6 +19,7 @@ "storage_detaching", "action", "pebble_ready", + "pebble_custom_notice", } From af102aa7638fb7d407e1e959bf117324dd1897eb Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 12:00:36 +1300 Subject: [PATCH 430/546] Minor tweaks as per code review. --- scenario/consistency_checker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 8dfbb8537..9deb34045 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -327,10 +327,10 @@ def check_storages_consistency( return Results(errors, []) -def _is_secret_identifier(value: Union[str, int, float, bool]): +def _is_secret_identifier(value: Union[str, int, float, bool]) -> bool: """Return true iff the value is in the form `secret:{secret id}`.""" # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48 - return re.match(r"secret:[0-9a-z]{20}$", str(value)) + return bool(re.match(r"secret:[0-9a-z]{20}$", str(value))) def check_config_consistency( @@ -371,6 +371,7 @@ def check_config_consistency( if not expected_type_name: errors.append(f"config.yaml invalid; option {key!r} has no 'type'.") continue + validator = validators.get(expected_type_name) expected_type = converters.get(expected_type_name) if not expected_type: @@ -384,7 +385,7 @@ def check_config_consistency( f"but is of type {type(value)}.", ) - elif not validators.get(expected_type_name, lambda _: True)(value): + elif validator and not validator(value): errors.append( f"config invalid: option {key!r} value {value!r} is not valid.", ) From f8c48deac19d3fbe4e96159f06033dd1e5d40c33 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 12:17:13 +1300 Subject: [PATCH 431/546] Remove the mystery type:attrs disabled code. --- scenario/consistency_checker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 9deb34045..05efcba38 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -358,7 +358,6 @@ def check_config_consistency( "int": int, "float": float, "boolean": bool, - # "attrs": NotImplemented, # fixme: wot? } if juju_version >= (3, 4): converters["secret"] = str From db76e53e625ce75699ceb1b84b4ae7f53e16c9da Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 11:58:39 +1300 Subject: [PATCH 432/546] Adjust examples in doc. --- scenario/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index a79dbeb3d..8ffb8ceda 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -623,7 +623,8 @@ class PebbleNotice(_DCBase): key: str """The notice key, a string that differentiates notices of this type. - This is in the format ``example.com/path``. + This is in the format ``domain/path``; for example: + ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ id: str = dataclasses.field(default_factory=lambda: str(uuid4())) From 653e631e689bde776a52e3ede0b14553d619a84b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 12:03:58 +1300 Subject: [PATCH 433/546] Rename Notice.id to Notice.notice_id. --- scenario/runtime.py | 2 +- scenario/state.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index acafbe25e..d3d5bd845 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -259,7 +259,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): notice = event.container.notices[-1] env.update( { - "JUJU_NOTICE_ID": notice.id, + "JUJU_NOTICE_ID": notice.notice_id, "JUJU_NOTICE_TYPE": str(notice.type), "JUJU_NOTICE_KEY": notice.key, }, diff --git a/scenario/state.py b/scenario/state.py index 8ffb8ceda..634773f17 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -627,7 +627,7 @@ class PebbleNotice(_DCBase): ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ - id: str = dataclasses.field(default_factory=lambda: str(uuid4())) + notice_id: str = dataclasses.field(default_factory=lambda: str(uuid4())) """Unique ID for this notice.""" user_id: Optional[int] = None @@ -663,7 +663,7 @@ class PebbleNotice(_DCBase): def _to_pebble_notice(self) -> pebble.Notice: return pebble.Notice( - id=self.id, + id=self.notice_id, user_id=self.user_id, type=self.type, key=self.key, @@ -1490,7 +1490,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: notice = container.notices[-1] snapshot_data.update( { - "notice_id": notice.id, + "notice_id": notice.notice_id, "notice_key": notice.key, "notice_type": str(notice.type), }, From b9daa695b7ada9b8731fbdc41ef5a6dff7c49de9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 13:00:36 +1300 Subject: [PATCH 434/546] Go back to PebbleNotice.id. --- scenario/runtime.py | 2 +- scenario/state.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scenario/runtime.py b/scenario/runtime.py index d3d5bd845..acafbe25e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -259,7 +259,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): notice = event.container.notices[-1] env.update( { - "JUJU_NOTICE_ID": notice.notice_id, + "JUJU_NOTICE_ID": notice.id, "JUJU_NOTICE_TYPE": str(notice.type), "JUJU_NOTICE_KEY": notice.key, }, diff --git a/scenario/state.py b/scenario/state.py index 634773f17..8ffb8ceda 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -627,7 +627,7 @@ class PebbleNotice(_DCBase): ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ - notice_id: str = dataclasses.field(default_factory=lambda: str(uuid4())) + id: str = dataclasses.field(default_factory=lambda: str(uuid4())) """Unique ID for this notice.""" user_id: Optional[int] = None @@ -663,7 +663,7 @@ class PebbleNotice(_DCBase): def _to_pebble_notice(self) -> pebble.Notice: return pebble.Notice( - id=self.notice_id, + id=self.id, user_id=self.user_id, type=self.type, key=self.key, @@ -1490,7 +1490,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: notice = container.notices[-1] snapshot_data.update( { - "notice_id": notice.notice_id, + "notice_id": notice.id, "notice_key": notice.key, "notice_type": str(notice.type), }, From 38f7071608883f8f8be1d137fc38f6b93e5f388c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 13:50:43 +1300 Subject: [PATCH 435/546] Adjustments per code review. --- README.md | 16 ++++++++++-- scenario/consistency_checker.py | 6 +++++ scenario/mocking.py | 2 +- scenario/runtime.py | 12 ++------- scenario/state.py | 46 +++++++++++++++++++++------------ tests/test_e2e/test_deferred.py | 3 ++- tests/test_e2e/test_pebble.py | 7 +++-- 7 files changed, 58 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 7959113b2..f5053b6d1 100644 --- a/README.md +++ b/README.md @@ -745,13 +745,25 @@ notices = [ scenario.PebbleNotice(key="example.com/c"), ] cont = scenario.Container(notices=notices) -ctx.run(cont.custom_notice_event, scenario.State(containers=[cont])) +ctx.run(cont.notice_event, scenario.State(containers=[cont])) ``` Note that the `custom_notice_event` is accessed via the container, not the notice, and is always for the last notice in the list. An `ops.pebble.Notice` does not know which container it is in, but an `ops.PebbleCustomNoticeEvent` does know -which container did the notifying. +which container did the notifying. If you need to generate an event for a different +notice, you can override the notice: + +```python +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": "cont": {}}) +notices = [ + scenario.PebbleNotice(key="example.com/a", occurences=10), + scenario.PebbleNotice(key="example.com/b", last_data={"bar": "baz"}), + scenario.PebbleNotice(key="example.com/c"), +] +cont = scenario.Container(notices=notices) +ctx.run(cont.notice_event(notice=notices[0]), scenario.State(containers=[cont])) +``` ## Storage diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 843f3ed28..7803ce5e9 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -513,6 +513,7 @@ def check_containers_consistency( meta = charm_spec.meta meta_containers = list(map(normalize_name, meta.get("containers", {}))) state_containers = [normalize_name(c.name) for c in state.containers] + all_notices = [notice.id for c in state.containers for notice in c.notices] errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's @@ -532,6 +533,11 @@ def check_containers_consistency( f"container with that name is not present in the state. It's odd, but " f"consistent, if it cannot connect; but it should at least be there.", ) + if event.notice and event.notice.id not in all_notices: + errors.append( + f"the event being processed concerns notice {event.notice!r}, but that " + "notice is not in any of the containers present in the state.", + ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): diff --git a/scenario/mocking.py b/scenario/mocking.py index f19fcaf84..6e1f9d1ee 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -667,7 +667,7 @@ def __init__( self._notices: Dict[Tuple[str, str], pebble.Notice] = {} for container in state.containers: for notice in container.notices: - self._notices[str(notice.type), notice.key] = notice._to_pebble_notice() + self._notices[str(notice.type), notice.key] = notice._to_ops_notice() def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/runtime.py b/scenario/runtime.py index acafbe25e..1ded1b4fc 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -17,12 +17,7 @@ from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import ( - PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, - DeferredEvent, - PeerRelation, - StoredState, -) +from scenario.state import DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -253,10 +248,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) - if event.name.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): - if not event.container or not event.container.notices: - raise RuntimeError("Pebble notice with no container or notice.") - notice = event.container.notices[-1] + if notice := event.notice: env.update( { "JUJU_NOTICE_ID": notice.id, diff --git a/scenario/state.py b/scenario/state.py index 8ffb8ceda..5ab9810bc 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -614,10 +614,17 @@ def _now_utc(): return datetime.datetime.now(tz=datetime.timezone.utc) -# Ideally, this would be a subclass of pebble.Notice, but we want to make it -# easier to use in tests by providing sensible default values, but there's no -# default key value, so that would need to be first, and that's not the case -# in pebble.Notice, so it's easier to just be explicit and repetitive here. +_next_notice_id_counter = 1 + + +def next_notice_id(update=True): + global _next_notice_id_counter + cur = _next_notice_id_counter + if update: + _next_notice_id_counter += 1 + return str(cur) + + @dataclasses.dataclass(frozen=True) class PebbleNotice(_DCBase): key: str @@ -627,7 +634,7 @@ class PebbleNotice(_DCBase): ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ - id: str = dataclasses.field(default_factory=lambda: str(uuid4())) + id: str = dataclasses.field(default_factory=next_notice_id) """Unique ID for this notice.""" user_id: Optional[int] = None @@ -661,7 +668,7 @@ class PebbleNotice(_DCBase): expire_after: Optional[datetime.timedelta] = None """How long since one of these last occurred until Pebble will drop the notice.""" - def _to_pebble_notice(self) -> pebble.Notice: + def _to_ops_notice(self) -> pebble.Notice: return pebble.Notice( id=self.id, user_id=self.user_id, @@ -784,18 +791,24 @@ def pebble_ready_event(self): return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) @property - def custom_notice_event(self): + def notice_event(self): """Sugar to generate a -pebble-custom-notice event for the latest notice.""" if not self.notices: raise RuntimeError("This container does not have any notices.") + # We assume this event is about the most recent notice. + notice = self.notices[-1] + if notice.type != pebble.NoticeType.CUSTOM: + raise RuntimeError("Scenario only knows about custom notices at this time.") + suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX.replace("_", "-") if not self.can_connect: logger.warning( "you **can** fire pebble-custom-notice while the container cannot connect, " "but that's most likely not what you want.", ) return Event( - path=normalize_name(self.name + "-pebble-custom-notice"), + path=normalize_name(self.name + suffix), container=self, + notice=notice, ) @@ -1304,6 +1317,9 @@ class Event(_DCBase): # if this is a workload (container) event, the container it refers to container: Optional[Container] = None + # if this is a Pebble notice event, the notice it refers to + notice: Optional[PebbleNotice] = None + # if this is an action event, the Action instance action: Optional["Action"] = None @@ -1484,15 +1500,12 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "container_name": container.name, } - if self._path.suffix == PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX: - if not container.notices: - raise RuntimeError("Container has no notices.") - notice = container.notices[-1] + if self.notice: snapshot_data.update( { - "notice_id": notice.id, - "notice_key": notice.key, - "notice_type": str(notice.type), + "notice_id": self.notice.id, + "notice_key": self.notice.key, + "notice_type": str(self.notice.type), }, ) @@ -1558,8 +1571,9 @@ def deferred( event_id: int = 1, relation: Optional["Relation"] = None, container: Optional["Container"] = None, + notice: Optional["PebbleNotice"] = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - event = Event(event, relation=relation, container=container) + event = Event(event, relation=relation, container=container, notice=notice) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 4461cd3b8..d520cdaa4 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -107,11 +107,12 @@ def test_deferred_workload_evt(mycharm): def test_deferred_notice_evt(mycharm): notice = PebbleNotice(key="example.com/bar") ctr = Container("foo", notices=[notice]) - evt1 = ctr.custom_notice_event.deferred(handler=mycharm._on_event) + evt1 = ctr.notice_event.deferred(handler=mycharm._on_event) evt2 = deferred( event="foo_pebble_custom_notice", handler=mycharm._on_event, container=ctr, + notice=notice, ) assert asdict(evt2) == asdict(evt1) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index ec536e0e1..edc341d95 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -380,8 +380,7 @@ def test_pebble_custom_notice(charm_cls): ) state = State(containers=[cont]) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - cont.custom_notice_event, state - ) as mgr: + ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) + with ctx.manager(cont.notice_event, state) as mgr: container = mgr.charm.unit.get_container("foo") - assert container.get_notices() == [n._to_pebble_notice() for n in notices] + assert container.get_notices() == [n._to_ops_notice() for n in notices] From 8071e5ff6ba500afb84e72359f97bf0dac3f4a57 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 19:42:27 +1300 Subject: [PATCH 436/546] Update to more generic name. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f5053b6d1..508a6743b 100644 --- a/README.md +++ b/README.md @@ -731,7 +731,7 @@ import scenario class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) - framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice) + framework.observe(self.on["cont"].pebble_notice, self._on_notice) def _on_notice(self, event): event.notice.key # == "example.com/c" @@ -748,7 +748,7 @@ cont = scenario.Container(notices=notices) ctx.run(cont.notice_event, scenario.State(containers=[cont])) ``` -Note that the `custom_notice_event` is accessed via the container, not the notice, +Note that the `custom_event` is accessed via the container, not the notice, and is always for the last notice in the list. An `ops.pebble.Notice` does not know which container it is in, but an `ops.PebbleCustomNoticeEvent` does know which container did the notifying. If you need to generate an event for a different From 82da90682102c6dc1e5aa33d07fd68737f77a3c0 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 19:44:59 +1300 Subject: [PATCH 437/546] Notice IDs are unique, so use a set not a list for __contains__. --- scenario/consistency_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 7803ce5e9..9c5b2a83a 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -513,7 +513,7 @@ def check_containers_consistency( meta = charm_spec.meta meta_containers = list(map(normalize_name, meta.get("containers", {}))) state_containers = [normalize_name(c.name) for c in state.containers] - all_notices = [notice.id for c in state.containers for notice in c.notices] + all_notices = {notice.id for c in state.containers for notice in c.notices} errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's From 050ebac7fd98abb4f694c7d3712e342dba12a69f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 19:55:26 +1300 Subject: [PATCH 438/546] Expand the consistent check tests to match new checks. --- tests/test_consistency_checker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 55b54ea88..e9b1d46ed 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -9,6 +9,7 @@ Container, Event, Network, + PebbleNotice, PeerRelation, Relation, Secret, @@ -65,9 +66,15 @@ def test_workload_event_without_container(): Event("foo-pebble-custom-notice", container=Container("foo")), _CharmSpec(MyCharm, {}), ) + notice = PebbleNotice("example.com/foo") assert_consistent( + State(containers=[Container("foo", notices=[notice])]), + Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_inconsistent( State(containers=[Container("foo")]), - Event("foo-pebble-custom-notice", container=Container("foo")), + Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) From 726cc94687aef542f90d75178680d0b78425471f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 20:12:21 +1300 Subject: [PATCH 439/546] Rename Relation.relation_id to Relation.id. --- README.md | 8 ++++---- scenario/consistency_checker.py | 6 +++--- scenario/mocking.py | 6 ++---- scenario/ops_main_mock.py | 2 +- scenario/runtime.py | 2 +- scenario/state.py | 4 ++-- tests/test_consistency_checker.py | 12 +++--------- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_network.py | 4 ++-- tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_secrets.py | 4 +--- 12 files changed, 22 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4f8b2a81c..bcd6a3f93 100644 --- a/README.md +++ b/README.md @@ -477,7 +477,7 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. +Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. To inspect the ID the next relation instance will have, you can call `state.next_relation_id`. ```python @@ -486,7 +486,7 @@ from scenario.state import next_relation_id next_id = next_relation_id(update=False) rel = Relation('foo') -assert rel.relation_id == next_id +assert rel.id == next_id ``` This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: @@ -496,8 +496,8 @@ from scenario import Relation from scenario.state import next_relation_id rel = Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=next_relation_id()) -assert rel2.relation_id == rel.relation_id + 1 +rel2 = rel.replace(local_app_data={"foo": "bar"}, id=next_relation_id()) +assert rel2.id == rel.id + 1 ``` If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index ea9ad4897..a89402b61 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -468,13 +468,13 @@ def _get_relations(r): expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: - if relation.relation_id in seen_ids: + if relation.id in seen_ids: errors.append( - f"duplicate relation ID: {relation.relation_id} is claimed " + f"duplicate relation ID: {relation.id} is claimed " f"by multiple Relation instances", ) - seen_ids.add(relation.relation_id) + seen_ids.add(relation.id) is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( diff --git a/scenario/mocking.py b/scenario/mocking.py index d4c7aab36..841ca25a2 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,7 +163,7 @@ def _get_relation_by_id( ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( - filter(lambda r: r.relation_id == rel_id, self._state.relations), + filter(lambda r: r.id == rel_id, self._state.relations), ) except StopIteration: raise RelationNotFoundError() @@ -245,9 +245,7 @@ def status_get(self, *, is_app: bool = False): def relation_ids(self, relation_name): return [ - rel.relation_id - for rel in self._state.relations - if rel.endpoint == relation_name + rel.id for rel in self._state.relations if rel.endpoint == relation_name ] def relation_list(self, relation_id: int) -> Tuple[str, ...]: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index c2eee10ec..db300d8e6 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -115,7 +115,7 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.relation_id # type: ignore + event.relation.id # type: ignore if event.name.endswith("_relation_broken") else None ) diff --git a/scenario/runtime.py b/scenario/runtime.py index a6aebab0e..a315413fa 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -208,7 +208,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env.update( { "JUJU_RELATION": relation.endpoint, - "JUJU_RELATION_ID": str(relation.relation_id), + "JUJU_RELATION_ID": str(relation.id), "JUJU_REMOTE_APP": remote_app_name, }, ) diff --git a/scenario/state.py b/scenario/state.py index 8d14e493a..84965a62c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -353,7 +353,7 @@ class RelationBase(_DCBase): """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. If left empty, it will be automatically derived from metadata.yaml.""" - relation_id: int = dataclasses.field(default_factory=next_relation_id) + id: int = dataclasses.field(default_factory=next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -1410,7 +1410,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "relation_name": relation.endpoint, - "relation_id": relation.relation_id, + "relation_id": relation.id, "app_name": remote_app, "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", } diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index ef92e6d96..f673d54fd 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -338,9 +338,7 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), Event("start"), _CharmSpec( MyCharm, @@ -353,17 +351,13 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index b084f6ffe..34dcf5296 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -150,7 +150,7 @@ def test_deferred_relation_event_from_relation(mycharm): assert out.deferred[0].name == "foo_relation_changed" assert out.deferred[0].snapshot_data == { "relation_name": rel.endpoint, - "relation_id": rel.relation_id, + "relation_id": rel.id, "app_name": "remote", "unit_name": "remote/1", } diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 07808e1de..c3d271dfc 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -48,7 +48,7 @@ def test_ip_get(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"foo": Network.default(private_address="4.4.4.4")}, @@ -110,7 +110,7 @@ def test_no_relation_error(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"bar": Network.default()}, diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index b8b92d5a0..d4523b376 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -102,7 +102,7 @@ def check_relation_data(charm): Relation( endpoint="relation_test", interface="azdrubales", - relation_id=1, + id=1, remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 212e12a68..037c1acf4 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -388,7 +388,7 @@ def test_relation_ids(): initial_id = _next_relation_id_counter for i in range(10): rel = Relation("foo") - assert rel.relation_id == initial_id + i + assert rel.id == initial_id + i def test_broken_relation_not_in_model_relations(mycharm): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index e8e75f7b6..f0f87a4f9 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -521,9 +521,7 @@ def __init__(self, *args): state = State( leader=True, relations=[ - Relation( - "bar", remote_app_name=relation_remote_app, relation_id=relation_id - ) + Relation("bar", remote_app_name=relation_remote_app, id=relation_id) ], ) From b02cf85acaee20590fb1592926f04d6f5e03fac1 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 8 Apr 2024 08:38:38 +0800 Subject: [PATCH 440/546] feat: add support for cloud spec --- scenario/mocking.py | 5 ++++- scenario/state.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index d4c7aab36..e99006a0d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -21,7 +21,7 @@ ) from ops import JujuVersion, pebble -from ops.model import ModelError, RelationNotFoundError +from ops.model import CloudSpec, ModelError, RelationNotFoundError from ops.model import Secret as Secret_Ops # lol from ops.model import ( SecretInfo, @@ -631,6 +631,9 @@ def resource_get(self, resource_name: str) -> str: f"resource {resource_name} not found in State. please pass it.", ) + def credential_get(self) -> CloudSpec: + return self._state.cloud_spec + class _MockPebbleClient(_TestingPebbleClient): def __init__( diff --git a/scenario/state.py b/scenario/state.py index 8d14e493a..b75795abd 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -32,7 +32,7 @@ import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents -from ops.model import SecretRotate, StatusBase +from ops.model import CloudSpec, SecretRotate, StatusBase from scenario.logger import logger as scenario_logger @@ -919,6 +919,7 @@ class State(_DCBase): """Status of the unit.""" workload_version: str = "" """Workload version.""" + cloud_spec: Optional[CloudSpec] = None def __post_init__(self): for name in ["app_status", "unit_status"]: From 108b1ce9512b8486d8ab67e8a0e81dab62cbb0b5 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 8 Apr 2024 08:38:56 +0800 Subject: [PATCH 441/546] test: add e2e test for cloud spec --- tests/test_e2e/test_cloud_spec.py | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_e2e/test_cloud_spec.py diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py new file mode 100644 index 000000000..1870f497f --- /dev/null +++ b/tests/test_e2e/test_cloud_spec.py @@ -0,0 +1,40 @@ +import ops +import pytest + +import scenario + + +@pytest.fixture(scope="function") +def mycharm(): + class MyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + pass + + return MyCharm + + +def test_get_cloud_spec(mycharm): + cloud_spec = ops.model.CloudSpec.from_dict( + { + "name": "localhost", + "type": "lxd", + "endpoint": "https://127.0.0.1:8443", + "credential": { + "auth-type": "certificate", + "attrs": { + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, + }, + } + ) + + ctx = scenario.Context(mycharm, meta={"name": "foo"}) + with ctx.manager("start", scenario.State(cloud_spec=cloud_spec)) as mgr: + assert mgr.charm.model.get_cloud_spec() == cloud_spec From 9d586379cdcd858ff05ec2235df3543f041b26e2 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 8 Apr 2024 16:49:23 +0800 Subject: [PATCH 442/546] chore: fix linting --- scenario/mocking.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scenario/mocking.py b/scenario/mocking.py index e99006a0d..38d95b28f 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -632,6 +632,10 @@ def resource_get(self, resource_name: str) -> str: ) def credential_get(self) -> CloudSpec: + if not self._cloud_spec: + raise ModelError( + "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=...)`", + ) return self._state.cloud_spec From 65be11b92d0fda1049ee949f7feed4829bec52c4 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 8 Apr 2024 16:52:15 +0800 Subject: [PATCH 443/546] chore: fix linting --- scenario/mocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 38d95b28f..df074bc12 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -632,7 +632,7 @@ def resource_get(self, resource_name: str) -> str: ) def credential_get(self) -> CloudSpec: - if not self._cloud_spec: + if not self._state.cloud_spec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=...)`", ) From 8a058d11f910859fd9ba4b45e7e74182e7903072 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 9 Apr 2024 13:58:06 +0800 Subject: [PATCH 444/546] chore: refactor according to code review --- scenario/mocking.py | 3 +- scenario/state.py | 79 ++++++++++++++++++++++++++++++- tests/test_e2e/test_cloud_spec.py | 10 +++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index df074bc12..d16533c93 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -21,7 +21,7 @@ ) from ops import JujuVersion, pebble -from ops.model import CloudSpec, ModelError, RelationNotFoundError +from ops.model import ModelError, RelationNotFoundError from ops.model import Secret as Secret_Ops # lol from ops.model import ( SecretInfo, @@ -35,6 +35,7 @@ from scenario.logger import logger as scenario_logger from scenario.state import ( + CloudSpec, JujuLogLine, Mount, Network, diff --git a/scenario/state.py b/scenario/state.py index b75795abd..dabca49b9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -32,7 +32,7 @@ import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents -from ops.model import CloudSpec, SecretRotate, StatusBase +from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger @@ -140,6 +140,83 @@ def copy(self) -> "Self": return copy.deepcopy(self) +@dataclasses.dataclass(frozen=True) +class CloudCredential: + auth_type: str + """Authentication type.""" + + attributes: Dict[str, str] = dataclasses.field(default_factory=dict) + """A dictionary containing cloud credentials. + + For example, for AWS, it contains `access-key` and `secret-key`; + for Azure, `application-id`, `application-password` and `subscription-id` + can be found here. + """ + + redacted: List[str] = dataclasses.field(default_factory=list) + """A list of redacted secrets.""" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "CloudCredential": + """Create a new CloudCredential object from a dictionary.""" + return cls( + auth_type=d["auth-type"], + attributes=d.get("attrs") or {}, + redacted=d.get("redacted") or [], + ) + + +@dataclasses.dataclass(frozen=True) +class CloudSpec: + type: str + """Type of the cloud.""" + + name: str + """Juju cloud name.""" + + region: Optional[str] = None + """Region of the cloud.""" + + endpoint: Optional[str] = None + """Endpoint of the cloud.""" + + identity_endpoint: Optional[str] = None + """Identity endpoint of the cloud.""" + + storage_endpoint: Optional[str] = None + """Storage endpoint of the cloud.""" + + credential: Optional[CloudCredential] = None + """Cloud credentials with key-value attributes.""" + + ca_certificates: List[str] = dataclasses.field(default_factory=list) + """A list of CA certificates.""" + + skip_tls_verify: bool = False + """Whether to skip TLS verfication.""" + + is_controller_cloud: bool = False + """If this is the cloud used by the controller, defaults to False.""" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "CloudSpec": + """Create a new CloudSpec object from a dict parsed from JSON.""" + return cls( + type=d["type"], + name=d["name"], + region=d.get("region") or None, + endpoint=d.get("endpoint") or None, + identity_endpoint=d.get("identity-endpoint") or None, + storage_endpoint=d.get("storage-endpoint") or None, + credential=CloudCredential.from_dict(d["credential"]) + if d.get("credential") + else None, + ca_certificates=d.get("cacertificates") or [], + skip_tls_verify=d.get("skip-tls-verify") or False, + is_controller_cloud=d.get("is-controller-cloud") or False, + ) + + @dataclasses.dataclass(frozen=True) class Secret(_DCBase): id: str diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 1870f497f..fcde189f3 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -2,6 +2,7 @@ import pytest import scenario +from scenario.state import CloudSpec @pytest.fixture(scope="function") @@ -19,7 +20,7 @@ def _on_event(self, event): def test_get_cloud_spec(mycharm): - cloud_spec = ops.model.CloudSpec.from_dict( + cloud_spec = CloudSpec.from_dict( { "name": "localhost", "type": "lxd", @@ -38,3 +39,10 @@ def test_get_cloud_spec(mycharm): ctx = scenario.Context(mycharm, meta={"name": "foo"}) with ctx.manager("start", scenario.State(cloud_spec=cloud_spec)) as mgr: assert mgr.charm.model.get_cloud_spec() == cloud_spec + + +def test_get_cloud_spec(mycharm): + ctx = scenario.Context(mycharm, meta={"name": "foo"}) + with ctx.manager("start", scenario.State()) as mgr: + with pytest.raises(ops.ModelError): + mgr.charm.model.get_cloud_spec() From 56e56db401c4596aac3fb33c2722bcc33ad710cd Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 9 Apr 2024 13:59:14 +0800 Subject: [PATCH 445/546] chore: refactor according to code review --- scenario/state.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scenario/state.py b/scenario/state.py index dabca49b9..0c783a89a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -997,6 +997,7 @@ class State(_DCBase): workload_version: str = "" """Workload version.""" cloud_spec: Optional[CloudSpec] = None + """Cloud specification information (metadata) including credentials.""" def __post_init__(self): for name in ["app_status", "unit_status"]: From 0c25e0989b0cfbcfe4177854a04a39bc565d4b99 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 10 Apr 2024 07:45:16 +0800 Subject: [PATCH 446/546] Update scenario/mocking.py Co-authored-by: PietroPasotti --- scenario/mocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index d16533c93..662cc01fb 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -635,7 +635,7 @@ def resource_get(self, resource_name: str) -> str: def credential_get(self) -> CloudSpec: if not self._state.cloud_spec: raise ModelError( - "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=...)`", + "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.state.CloudSpec(...))`", ) return self._state.cloud_spec From da8dea3d568573bd7b03ccdf93e5976d9830667d Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 10 Apr 2024 07:53:51 +0800 Subject: [PATCH 447/546] Update scenario/state.py Co-authored-by: PietroPasotti --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 0c783a89a..438af8aee 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -200,7 +200,7 @@ class CloudSpec: @classmethod def from_dict(cls, d: Dict[str, Any]) -> "CloudSpec": - """Create a new CloudSpec object from a dict parsed from JSON.""" + """Create a new CloudSpec object from a dict.""" return cls( type=d["type"], name=d["name"], From 6e81250768abb940f7991acb073b2a54a941ef7a Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 10 Apr 2024 13:37:39 +0800 Subject: [PATCH 448/546] refactor: some updates according to code review and standup discussions --- scenario/consistency_checker.py | 20 ++++++++++++++ scenario/state.py | 46 +++++++++++-------------------- tests/test_consistency_checker.py | 37 +++++++++++++++++++++++++ tests/test_e2e/test_cloud_spec.py | 35 ++++++++++++----------- 4 files changed, 90 insertions(+), 48 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 05efcba38..2550358a2 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -70,6 +70,7 @@ def check_consistency( check_storages_consistency, check_relation_consistency, check_network_consistency, + check_cloudspec_consistency, ): results = check( state=state, @@ -565,3 +566,22 @@ def check_containers_consistency( errors.append(f"Duplicate container name(s): {dupes}.") return Results(errors, []) + + +def check_cloudspec_consistency( + *, + state: "State", + event: "Event", + charm_spec: "_CharmSpec", + **_kwargs, # noqa: U101 +) -> Results: + """Check that Kubernetes charms/models don't have `state.cloud_spec`.""" + + errors, warnings = [], [] + + if state.model.type == "kubernetes" and state.cloud_spec: + errors.append( + "CloudSpec is only available for machine charms, not Kubernetes charms.", + ) + + return Results(errors, warnings) diff --git a/scenario/state.py b/scenario/state.py index 438af8aee..f2cd4fec9 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -140,9 +140,22 @@ def copy(self) -> "Self": return copy.deepcopy(self) +class CloudAuthType(str, Enum): + access_key_auth_type = "access-key" + instance_role_auth_type = "instance-role" + user_pass_auth_type = "userpass" + o_auth1_auth_type = "oauth1" + o_auth2_auth_type = "oauth2" + json_file_auth_type = "jsonfile" + client_certificate_auth_type = "clientcertificate" + http_sig_auth_type = "httpsig" + interactive_auth_type = "interactive" + empty_auth_type = "empty" + + @dataclasses.dataclass(frozen=True) class CloudCredential: - auth_type: str + auth_type: CloudAuthType """Authentication type.""" attributes: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -156,22 +169,13 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted secrets.""" - @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "CloudCredential": - """Create a new CloudCredential object from a dictionary.""" - return cls( - auth_type=d["auth-type"], - attributes=d.get("attrs") or {}, - redacted=d.get("redacted") or [], - ) - @dataclasses.dataclass(frozen=True) class CloudSpec: type: str """Type of the cloud.""" - name: str + name: str = "localhost" """Juju cloud name.""" region: Optional[str] = None @@ -196,25 +200,7 @@ class CloudSpec: """Whether to skip TLS verfication.""" is_controller_cloud: bool = False - """If this is the cloud used by the controller, defaults to False.""" - - @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "CloudSpec": - """Create a new CloudSpec object from a dict.""" - return cls( - type=d["type"], - name=d["name"], - region=d.get("region") or None, - endpoint=d.get("endpoint") or None, - identity_endpoint=d.get("identity-endpoint") or None, - storage_endpoint=d.get("storage-endpoint") or None, - credential=CloudCredential.from_dict(d["credential"]) - if d.get("credential") - else None, - ca_certificates=d.get("cacertificates") or [], - skip_tls_verify=d.get("skip-tls-verify") or False, - is_controller_cloud=d.get("is-controller-cloud") or False, - ) + """If this is the cloud used by the controller.""" @dataclasses.dataclass(frozen=True) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 1a79f32da..4dc758c6d 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,11 +1,15 @@ import pytest from ops.charm import CharmBase +from scenario import Model from scenario.consistency_checker import check_consistency from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, Action, + CloudAuthType, + CloudCredential, + CloudSpec, Container, Event, Network, @@ -568,3 +572,36 @@ def test_networks_consistency(): }, ), ) + + +def test_cloudspec_consistency(): + cloud_spec = CloudSpec( + type="lxd", + endpoint="https://127.0.0.1:8443", + credential=CloudCredential( + auth_type=CloudAuthType.client_certificate_auth_type, + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, + ), + ) + + assert_consistent( + State(cloud_spec=cloud_spec, model=Model(name="lxd-model", type="lxd")), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "MyVMCharm"}, + ), + ) + + assert_inconsistent( + State(cloud_spec=cloud_spec, model=Model(name="k8s-model", type="kubernetes")), + Event("start"), + _CharmSpec( + MyCharm, + meta={"name": "MyVMCharm"}, + ), + ) diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index fcde189f3..8e8a73f29 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -2,7 +2,6 @@ import pytest import scenario -from scenario.state import CloudSpec @pytest.fixture(scope="function") @@ -20,29 +19,29 @@ def _on_event(self, event): def test_get_cloud_spec(mycharm): - cloud_spec = CloudSpec.from_dict( - { - "name": "localhost", - "type": "lxd", - "endpoint": "https://127.0.0.1:8443", - "credential": { - "auth-type": "certificate", - "attrs": { - "client-cert": "foo", - "client-key": "bar", - "server-cert": "baz", - }, + cloud_spec = scenario.state.CloudSpec( + type="lxd", + endpoint="https://127.0.0.1:8443", + credential=scenario.state.CloudCredential( + auth_type=scenario.state.CloudAuthType.client_certificate_auth_type, + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", }, - } + ), ) - ctx = scenario.Context(mycharm, meta={"name": "foo"}) - with ctx.manager("start", scenario.State(cloud_spec=cloud_spec)) as mgr: + state = scenario.State( + cloud_spec=cloud_spec, model=scenario.Model(name="lxd-model", type="lxd") + ) + with ctx.manager("start", state=state) as mgr: assert mgr.charm.model.get_cloud_spec() == cloud_spec -def test_get_cloud_spec(mycharm): +def test_get_cloud_spec_error(mycharm): ctx = scenario.Context(mycharm, meta={"name": "foo"}) - with ctx.manager("start", scenario.State()) as mgr: + state = scenario.State(model=scenario.Model(name="lxd-model", type="lxd")) + with ctx.manager("start", state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() From 8161af7408dd8bb674b17dfab8392f5b85df716a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 10 Apr 2024 22:25:35 +1200 Subject: [PATCH 449/546] Update README.md Co-authored-by: PietroPasotti --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 508a6743b..8e44c0569 100644 --- a/README.md +++ b/README.md @@ -738,7 +738,7 @@ class MyCharm(ops.CharmBase): for notice in self.unit.get_container("cont").get_notices(): ... -ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": "cont": {}}) +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) notices = [ scenario.PebbleNotice(key="example.com/a", occurences=10), scenario.PebbleNotice(key="example.com/b", last_data={"bar": "baz"}), From d738b94b32f091212113ab49d4c4b7a948ccd0e2 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Thu, 11 Apr 2024 11:02:18 +0800 Subject: [PATCH 450/546] chore: refactor according to code review and standup discussion --- scenario/__init__.py | 2 ++ scenario/mocking.py | 2 +- scenario/state.py | 15 +-------------- tests/test_consistency_checker.py | 5 ++--- tests/test_e2e/test_cloud_spec.py | 30 +++++++++++++----------------- 5 files changed, 19 insertions(+), 35 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index b84248191..2e0a3a06e 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -6,6 +6,7 @@ Action, Address, BindAddress, + CloudSpec, Container, DeferredEvent, Event, @@ -29,6 +30,7 @@ __all__ = [ "Action", "ActionOutput", + "CloudSpec", "Context", "deferred", "StateValidationError", diff --git a/scenario/mocking.py b/scenario/mocking.py index 662cc01fb..9ebcf3c2e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -635,7 +635,7 @@ def resource_get(self, resource_name: str) -> str: def credential_get(self) -> CloudSpec: if not self._state.cloud_spec: raise ModelError( - "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.state.CloudSpec(...))`", + "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", ) return self._state.cloud_spec diff --git a/scenario/state.py b/scenario/state.py index f2cd4fec9..f9650dff2 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -140,22 +140,9 @@ def copy(self) -> "Self": return copy.deepcopy(self) -class CloudAuthType(str, Enum): - access_key_auth_type = "access-key" - instance_role_auth_type = "instance-role" - user_pass_auth_type = "userpass" - o_auth1_auth_type = "oauth1" - o_auth2_auth_type = "oauth2" - json_file_auth_type = "jsonfile" - client_certificate_auth_type = "clientcertificate" - http_sig_auth_type = "httpsig" - interactive_auth_type = "interactive" - empty_auth_type = "empty" - - @dataclasses.dataclass(frozen=True) class CloudCredential: - auth_type: CloudAuthType + auth_type: str """Authentication type.""" attributes: Dict[str, str] = dataclasses.field(default_factory=dict) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 4dc758c6d..d5929d98c 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -7,7 +7,6 @@ from scenario.state import ( RELATION_EVENTS_SUFFIX, Action, - CloudAuthType, CloudCredential, CloudSpec, Container, @@ -579,7 +578,7 @@ def test_cloudspec_consistency(): type="lxd", endpoint="https://127.0.0.1:8443", credential=CloudCredential( - auth_type=CloudAuthType.client_certificate_auth_type, + auth_type="clientcertificate", attributes={ "client-cert": "foo", "client-key": "bar", @@ -602,6 +601,6 @@ def test_cloudspec_consistency(): Event("start"), _CharmSpec( MyCharm, - meta={"name": "MyVMCharm"}, + meta={"name": "MyK8sCharm"}, ), ) diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 8e8a73f29..b1a1488d0 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -4,26 +4,22 @@ import scenario -@pytest.fixture(scope="function") -def mycharm(): - class MyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework): - super().__init__(framework) - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) +class MyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) - def _on_event(self, event): - pass + def _on_event(self, event): + pass - return MyCharm - -def test_get_cloud_spec(mycharm): - cloud_spec = scenario.state.CloudSpec( +def test_get_cloud_spec(): + cloud_spec = scenario.CloudSpec( type="lxd", endpoint="https://127.0.0.1:8443", credential=scenario.state.CloudCredential( - auth_type=scenario.state.CloudAuthType.client_certificate_auth_type, + auth_type="clientcertificate", attributes={ "client-cert": "foo", "client-key": "bar", @@ -31,7 +27,7 @@ def test_get_cloud_spec(mycharm): }, ), ) - ctx = scenario.Context(mycharm, meta={"name": "foo"}) + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State( cloud_spec=cloud_spec, model=scenario.Model(name="lxd-model", type="lxd") ) @@ -39,8 +35,8 @@ def test_get_cloud_spec(mycharm): assert mgr.charm.model.get_cloud_spec() == cloud_spec -def test_get_cloud_spec_error(mycharm): - ctx = scenario.Context(mycharm, meta={"name": "foo"}) +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("start", state) as mgr: with pytest.raises(ops.ModelError): From db1f54f00045a3615ea54acfa490b86ae6bbfed1 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Fri, 12 Apr 2024 09:08:14 +0800 Subject: [PATCH 451/546] chore: refactor according to code review --- scenario/consistency_checker.py | 3 ++- scenario/mocking.py | 2 +- scenario/state.py | 24 ++++++++++++++++++++++++ tests/test_e2e/test_cloud_spec.py | 27 ++++++++++++++++++++++++--- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 2550358a2..b3c12aedf 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -577,7 +577,8 @@ def check_cloudspec_consistency( ) -> Results: """Check that Kubernetes charms/models don't have `state.cloud_spec`.""" - errors, warnings = [], [] + errors = [] + warnings = [] if state.model.type == "kubernetes" and state.cloud_spec: errors.append( diff --git a/scenario/mocking.py b/scenario/mocking.py index 9ebcf3c2e..e61a92e00 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -637,7 +637,7 @@ def credential_get(self) -> CloudSpec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", ) - return self._state.cloud_spec + return self._state.cloud_spec.to_ops_cloud_spec() class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index f9650dff2..c4a9c72cf 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -29,6 +29,7 @@ ) from uuid import uuid4 +import ops import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents @@ -156,6 +157,13 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted secrets.""" + def to_ops_cloud_credential(self): + return ops.CloudCredential( + auth_type=self.auth_type, + attributes=self.attributes, + redacted=self.redacted, + ) + @dataclasses.dataclass(frozen=True) class CloudSpec: @@ -189,6 +197,22 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" + def to_ops_cloud_spec(self) -> ops.CloudSpec: + return ops.CloudSpec( + type=self.type, + name=self.name, + region=self.region, + endpoint=self.endpoint, + identity_endpoint=self.identity_endpoint, + storage_endpoint=self.storage_endpoint, + credential=None + if not self.credential + else self.credential.to_ops_cloud_credential(), + ca_certificates=self.ca_certificates, + skip_tls_verify=self.skip_tls_verify, + is_controller_cloud=self.is_controller_cloud, + ) + @dataclasses.dataclass(frozen=True) class Secret(_DCBase): diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index b1a1488d0..2564ccaa4 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -15,8 +15,9 @@ def _on_event(self, event): def test_get_cloud_spec(): - cloud_spec = scenario.CloudSpec( + scenario_cloud_spec = scenario.CloudSpec( type="lxd", + name="localhost", endpoint="https://127.0.0.1:8443", credential=scenario.state.CloudCredential( auth_type="clientcertificate", @@ -27,12 +28,32 @@ def test_get_cloud_spec(): }, ), ) + expected_cloud_spec = ops.CloudSpec( + type="lxd", + name="localhost", + endpoint="https://127.0.0.1:8443", + credential=ops.CloudCredential( + auth_type="clientcertificate", + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, + ), + ) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State( - cloud_spec=cloud_spec, model=scenario.Model(name="lxd-model", type="lxd") + cloud_spec=scenario_cloud_spec, + model=scenario.Model(name="lxd-model", type="lxd"), ) with ctx.manager("start", state=state) as mgr: - assert mgr.charm.model.get_cloud_spec() == cloud_spec + print("-==============") + print(mgr.charm.model.get_cloud_spec()) + print("-==============") + print(expected_cloud_spec) + print("-==============") + + assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec def test_get_cloud_spec_error(): From abaaf648487d3249d127d28d25f454f6694c4754 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Fri, 12 Apr 2024 09:11:20 +0800 Subject: [PATCH 452/546] chore: fix static check --- scenario/mocking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index e61a92e00..e1a277192 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -20,6 +20,7 @@ cast, ) +import ops from ops import JujuVersion, pebble from ops.model import ModelError, RelationNotFoundError from ops.model import Secret as Secret_Ops # lol @@ -35,7 +36,6 @@ from scenario.logger import logger as scenario_logger from scenario.state import ( - CloudSpec, JujuLogLine, Mount, Network, @@ -632,7 +632,7 @@ def resource_get(self, resource_name: str) -> str: f"resource {resource_name} not found in State. please pass it.", ) - def credential_get(self) -> CloudSpec: + def credential_get(self) -> ops.CloudSpec: if not self._state.cloud_spec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", From 3a9a78d397d1cf9f3255d5c181c015e44f06cf5b Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Fri, 12 Apr 2024 09:30:43 +0800 Subject: [PATCH 453/546] chore: remove testing code --- tests/test_e2e/test_cloud_spec.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 2564ccaa4..b4215bf89 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -47,12 +47,6 @@ def test_get_cloud_spec(): model=scenario.Model(name="lxd-model", type="lxd"), ) with ctx.manager("start", state=state) as mgr: - print("-==============") - print(mgr.charm.model.get_cloud_spec()) - print("-==============") - print(expected_cloud_spec) - print("-==============") - assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec From 318273ea74a55b2f0212c8b26d28533abef58dd2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Apr 2024 16:59:32 +1200 Subject: [PATCH 454/546] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e44c0569..ee877e8d0 100644 --- a/README.md +++ b/README.md @@ -731,7 +731,7 @@ import scenario class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) - framework.observe(self.on["cont"].pebble_notice, self._on_notice) + framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice) def _on_notice(self, event): event.notice.key # == "example.com/c" From 115b5bc4ccdb8307aba3f5465c697fb15ab9a720 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 15 Apr 2024 10:06:39 +0800 Subject: [PATCH 455/546] chore: make CloudCredential.to_ops_cloud_credential a private function' --- scenario/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index c4a9c72cf..e98487f15 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -157,7 +157,7 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted secrets.""" - def to_ops_cloud_credential(self): + def _to_ops_cloud_credential(self): return ops.CloudCredential( auth_type=self.auth_type, attributes=self.attributes, @@ -207,7 +207,7 @@ def to_ops_cloud_spec(self) -> ops.CloudSpec: storage_endpoint=self.storage_endpoint, credential=None if not self.credential - else self.credential.to_ops_cloud_credential(), + else self.credential._to_ops_cloud_credential(), ca_certificates=self.ca_certificates, skip_tls_verify=self.skip_tls_verify, is_controller_cloud=self.is_controller_cloud, From d61839187b752c5f3b3e4c214397d0f9dd806b63 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 15 Apr 2024 10:28:07 +0800 Subject: [PATCH 456/546] docs: update readme with cloudspec --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index f34abfe25..8309411fe 100644 --- a/README.md +++ b/README.md @@ -948,6 +948,41 @@ assert out.model.name == "my-model" assert out.model.uuid == state_in.model.uuid ``` +## CloudSpec + +You can set CloudSpec information in the state. + +```python +import scenario + +state = scenario.State( + cloud_spec=scenario.CloudSpec( + type="lxd", + name="localhost", + endpoint="https://127.0.0.1:8443", + credential=scenario.state.CloudCredential( + auth_type="clientcertificate", + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, + ), + ), + model=scenario.Model(name="my-vm-model", type="lxd"), +) +``` + +The mandatory arguments of CloudSpec are `type` and `name`. + +Access CloudSpec by `Model.get_cloud_spec()`: + +```python +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) +with ctx.manager("start", state=state) as mgr: + mgr.charm.model.get_cloud_spec() +``` + # Actions An action is a special sort of event, even though `ops` handles them almost identically. From b27444036698f535e9845ecb775685f31e89c06f Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 15 Apr 2024 13:29:21 +0800 Subject: [PATCH 457/546] chore: make to ops methods private --- scenario/mocking.py | 2 +- scenario/state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index e1a277192..c722f56b6 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -637,7 +637,7 @@ def credential_get(self) -> ops.CloudSpec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", ) - return self._state.cloud_spec.to_ops_cloud_spec() + return self._state.cloud_spec._to_ops_cloud_spec() class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index e98487f15..233496c26 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -197,7 +197,7 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def to_ops_cloud_spec(self) -> ops.CloudSpec: + def _to_ops_cloud_spec(self) -> ops.CloudSpec: return ops.CloudSpec( type=self.type, name=self.name, From 1bc5e5c51ea6fe204b98aadff5d1cc5ea03d1f13 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 15 Apr 2024 13:32:39 +0800 Subject: [PATCH 458/546] chore: change import ops to import specific things --- scenario/mocking.py | 5 ++--- scenario/state.py | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index c722f56b6..839ac077a 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -20,8 +20,7 @@ cast, ) -import ops -from ops import JujuVersion, pebble +from ops import CloudSpec, JujuVersion, pebble from ops.model import ModelError, RelationNotFoundError from ops.model import Secret as Secret_Ops # lol from ops.model import ( @@ -632,7 +631,7 @@ def resource_get(self, resource_name: str) -> str: f"resource {resource_name} not found in State. please pass it.", ) - def credential_get(self) -> ops.CloudSpec: + def credential_get(self) -> CloudSpec: if not self._state.cloud_spec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", diff --git a/scenario/state.py b/scenario/state.py index 233496c26..4d1293dd5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -29,9 +29,8 @@ ) from uuid import uuid4 -import ops import yaml -from ops import pebble +from ops import CloudCredential, CloudSpec, pebble from ops.charm import CharmBase, CharmEvents from ops.model import SecretRotate, StatusBase @@ -158,7 +157,7 @@ class CloudCredential: """A list of redacted secrets.""" def _to_ops_cloud_credential(self): - return ops.CloudCredential( + return CloudCredential( auth_type=self.auth_type, attributes=self.attributes, redacted=self.redacted, @@ -197,8 +196,8 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def _to_ops_cloud_spec(self) -> ops.CloudSpec: - return ops.CloudSpec( + def _to_ops_cloud_spec(self) -> CloudSpec: + return CloudSpec( type=self.type, name=self.name, region=self.region, From 8e7dd971369866159bde130526d5b1b75af0ee3f Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 15 Apr 2024 13:48:46 +0800 Subject: [PATCH 459/546] chore: fix linting and ut --- scenario/state.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index 4d1293dd5..233496c26 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -29,8 +29,9 @@ ) from uuid import uuid4 +import ops import yaml -from ops import CloudCredential, CloudSpec, pebble +from ops import pebble from ops.charm import CharmBase, CharmEvents from ops.model import SecretRotate, StatusBase @@ -157,7 +158,7 @@ class CloudCredential: """A list of redacted secrets.""" def _to_ops_cloud_credential(self): - return CloudCredential( + return ops.CloudCredential( auth_type=self.auth_type, attributes=self.attributes, redacted=self.redacted, @@ -196,8 +197,8 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def _to_ops_cloud_spec(self) -> CloudSpec: - return CloudSpec( + def _to_ops_cloud_spec(self) -> ops.CloudSpec: + return ops.CloudSpec( type=self.type, name=self.name, region=self.region, From 82b109c6cad8ef4bb7a55b59b62e213a121d52b5 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 16 Apr 2024 07:12:56 +0800 Subject: [PATCH 460/546] chore: rename to ops method after discussion --- scenario/mocking.py | 2 +- scenario/state.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 839ac077a..1081b4ac8 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -636,7 +636,7 @@ def credential_get(self) -> CloudSpec: raise ModelError( "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", ) - return self._state.cloud_spec._to_ops_cloud_spec() + return self._state.cloud_spec._to_ops() class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index 233496c26..5958d4ecd 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -157,7 +157,7 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted secrets.""" - def _to_ops_cloud_credential(self): + def _to_ops(self): return ops.CloudCredential( auth_type=self.auth_type, attributes=self.attributes, @@ -197,7 +197,7 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def _to_ops_cloud_spec(self) -> ops.CloudSpec: + def _to_ops(self) -> ops.CloudSpec: return ops.CloudSpec( type=self.type, name=self.name, @@ -207,7 +207,7 @@ def _to_ops_cloud_spec(self) -> ops.CloudSpec: storage_endpoint=self.storage_endpoint, credential=None if not self.credential - else self.credential._to_ops_cloud_credential(), + else self.credential._to_ops(), ca_certificates=self.ca_certificates, skip_tls_verify=self.skip_tls_verify, is_controller_cloud=self.is_controller_cloud, From 77913d1eba0ebced0e936037298b5b56d6ed6884 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 16 Apr 2024 07:16:47 +0800 Subject: [PATCH 461/546] chore: fixing scenario imports --- README.md | 2 +- scenario/__init__.py | 2 ++ scenario/state.py | 4 +--- tests/test_e2e/test_cloud_spec.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8309411fe..76940a066 100644 --- a/README.md +++ b/README.md @@ -960,7 +960,7 @@ state = scenario.State( type="lxd", name="localhost", endpoint="https://127.0.0.1:8443", - credential=scenario.state.CloudCredential( + credential=scenario.CloudCredential( auth_type="clientcertificate", attributes={ "client-cert": "foo", diff --git a/scenario/__init__.py b/scenario/__init__.py index 2e0a3a06e..8b832ab05 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -6,6 +6,7 @@ Action, Address, BindAddress, + CloudCredential, CloudSpec, Container, DeferredEvent, @@ -30,6 +31,7 @@ __all__ = [ "Action", "ActionOutput", + "CloudCredential", "CloudSpec", "Context", "deferred", diff --git a/scenario/state.py b/scenario/state.py index 5958d4ecd..176f7b21c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -205,9 +205,7 @@ def _to_ops(self) -> ops.CloudSpec: endpoint=self.endpoint, identity_endpoint=self.identity_endpoint, storage_endpoint=self.storage_endpoint, - credential=None - if not self.credential - else self.credential._to_ops(), + credential=None if not self.credential else self.credential._to_ops(), ca_certificates=self.ca_certificates, skip_tls_verify=self.skip_tls_verify, is_controller_cloud=self.is_controller_cloud, diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index b4215bf89..357061d2e 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -19,7 +19,7 @@ def test_get_cloud_spec(): type="lxd", name="localhost", endpoint="https://127.0.0.1:8443", - credential=scenario.state.CloudCredential( + credential=scenario.CloudCredential( auth_type="clientcertificate", attributes={ "client-cert": "foo", From 389dd65d6420416beab733dac766a601a888596a Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 16 Apr 2024 07:17:46 +0800 Subject: [PATCH 462/546] chore: fix readability issue --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 176f7b21c..710566a90 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -205,7 +205,7 @@ def _to_ops(self) -> ops.CloudSpec: endpoint=self.endpoint, identity_endpoint=self.identity_endpoint, storage_endpoint=self.storage_endpoint, - credential=None if not self.credential else self.credential._to_ops(), + credential=self.credential._to_ops() if self.credential else None, ca_certificates=self.ca_certificates, skip_tls_verify=self.skip_tls_verify, is_controller_cloud=self.is_controller_cloud, From e1bb629a37df505426d70266cd79432c76c84268 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 16 Apr 2024 07:23:55 +0800 Subject: [PATCH 463/546] docs: update readme cloudspec code example --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 76940a066..48509b946 100644 --- a/README.md +++ b/README.md @@ -950,7 +950,9 @@ assert out.model.uuid == state_in.model.uuid ## CloudSpec -You can set CloudSpec information in the state. +You can set CloudSpec information in the state (only `type` and `name` are required). + +Example: ```python import scenario @@ -973,14 +975,17 @@ state = scenario.State( ) ``` -The mandatory arguments of CloudSpec are `type` and `name`. - -Access CloudSpec by `Model.get_cloud_spec()`: +Then you can access it by `Model.get_cloud_spec()`: ```python -ctx = scenario.Context(MyCharm, meta={"name": "foo"}) -with ctx.manager("start", state=state) as mgr: - mgr.charm.model.get_cloud_spec() +# charm.py +class MyVMCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event: ops.StartEvent): + self.cloud_spec = self.model.get_cloud_spec() ``` # Actions From 82e8e4c8e80620fd34b5cdf4876829c45b1052b5 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 16 Apr 2024 07:26:16 +0800 Subject: [PATCH 464/546] chore: add a missing type --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index 710566a90..e6a798912 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -157,7 +157,7 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted secrets.""" - def _to_ops(self): + def _to_ops(self) -> ops.CloudCredential: return ops.CloudCredential( auth_type=self.auth_type, attributes=self.attributes, From 52c2c93c0463b25b3711cc02457cebea0a10d342 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 5 Apr 2024 17:05:18 +1300 Subject: [PATCH 465/546] Rename PebbleNotice to Notice. --- README.md | 12 ++++++------ scenario/__init__.py | 4 ++-- scenario/state.py | 8 ++++---- tests/test_consistency_checker.py | 4 ++-- tests/test_e2e/test_deferred.py | 11 ++--------- tests/test_e2e/test_pebble.py | 8 ++++---- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ee877e8d0..ae68a4179 100644 --- a/README.md +++ b/README.md @@ -740,9 +740,9 @@ class MyCharm(ops.CharmBase): ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) notices = [ - scenario.PebbleNotice(key="example.com/a", occurences=10), - scenario.PebbleNotice(key="example.com/b", last_data={"bar": "baz"}), - scenario.PebbleNotice(key="example.com/c"), + scenario.Notice(key="example.com/a", occurences=10), + scenario.Notice(key="example.com/b", last_data={"bar": "baz"}), + scenario.Notice(key="example.com/c"), ] cont = scenario.Container(notices=notices) ctx.run(cont.notice_event, scenario.State(containers=[cont])) @@ -757,9 +757,9 @@ notice, you can override the notice: ```python ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": "cont": {}}) notices = [ - scenario.PebbleNotice(key="example.com/a", occurences=10), - scenario.PebbleNotice(key="example.com/b", last_data={"bar": "baz"}), - scenario.PebbleNotice(key="example.com/c"), + scenario.Notice(key="example.com/a", occurences=10), + scenario.Notice(key="example.com/b", last_data={"bar": "baz"}), + scenario.Notice(key="example.com/c"), ] cont = scenario.Container(notices=notices) ctx.run(cont.notice_event(notice=notices[0]), scenario.State(containers=[cont])) diff --git a/scenario/__init__.py b/scenario/__init__.py index 7162772e2..78224506e 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -13,7 +13,7 @@ Model, Mount, Network, - PebbleNotice, + Notice, PeerRelation, Port, Relation, @@ -42,7 +42,7 @@ "ExecOutput", "Mount", "Container", - "PebbleNotice", + "Notice", "Address", "BindAddress", "Network", diff --git a/scenario/state.py b/scenario/state.py index 5ab9810bc..62dc11bdf 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -626,7 +626,7 @@ def next_notice_id(update=True): @dataclasses.dataclass(frozen=True) -class PebbleNotice(_DCBase): +class Notice(_DCBase): key: str """The notice key, a string that differentiates notices of this type. @@ -721,7 +721,7 @@ class Container(_DCBase): exec_mock: _ExecMock = dataclasses.field(default_factory=dict) - notices: List[PebbleNotice] = dataclasses.field(default_factory=list) + notices: List[Notice] = dataclasses.field(default_factory=list) def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() @@ -1318,7 +1318,7 @@ class Event(_DCBase): container: Optional[Container] = None # if this is a Pebble notice event, the notice it refers to - notice: Optional[PebbleNotice] = None + notice: Optional[Notice] = None # if this is an action event, the Action instance action: Optional["Action"] = None @@ -1571,7 +1571,7 @@ def deferred( event_id: int = 1, relation: Optional["Relation"] = None, container: Optional["Container"] = None, - notice: Optional["PebbleNotice"] = None, + notice: Optional["Notice"] = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index e9b1d46ed..bbdecd27f 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -9,7 +9,7 @@ Container, Event, Network, - PebbleNotice, + Notice, PeerRelation, Relation, Secret, @@ -66,7 +66,7 @@ def test_workload_event_without_container(): Event("foo-pebble-custom-notice", container=Container("foo")), _CharmSpec(MyCharm, {}), ) - notice = PebbleNotice("example.com/foo") + notice = Notice("example.com/foo") assert_consistent( State(containers=[Container("foo", notices=[notice])]), Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index d520cdaa4..20a167204 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,14 +12,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import ( - Container, - DeferredEvent, - PebbleNotice, - Relation, - State, - deferred, -) +from scenario.state import Container, DeferredEvent, Notice, Relation, State, deferred from tests.helpers import trigger CHARM_CALLED = 0 @@ -105,7 +98,7 @@ def test_deferred_workload_evt(mycharm): def test_deferred_notice_evt(mycharm): - notice = PebbleNotice(key="example.com/bar") + notice = Notice(key="example.com/bar") ctr = Container("foo", notices=[notice]) evt1 = ctr.notice_event.deferred(handler=mycharm._on_event) evt2 = deferred( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index edc341d95..73791d079 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -8,7 +8,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, PebbleNotice, Port, State +from scenario.state import Container, ExecOutput, Mount, Notice, Port, State from tests.helpers import trigger @@ -369,9 +369,9 @@ def test_exec_wait_output_error(charm_cls): def test_pebble_custom_notice(charm_cls): notices = [ - PebbleNotice(key="example.com/foo"), - PebbleNotice(key="example.com/bar", last_data={"a": "b"}), - PebbleNotice(key="example.com/baz", occurrences=42), + Notice(key="example.com/foo"), + Notice(key="example.com/bar", last_data={"a": "b"}), + Notice(key="example.com/baz", occurrences=42), ] cont = Container( name="foo", From 1c37b67f85aa03d12965f62d90dad9fe3f0a7703 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 16 Apr 2024 12:21:56 +1200 Subject: [PATCH 466/546] Use a simpler name. --- scenario/mocking.py | 2 +- scenario/state.py | 2 +- tests/test_e2e/test_pebble.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 6e1f9d1ee..22b487b0b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -667,7 +667,7 @@ def __init__( self._notices: Dict[Tuple[str, str], pebble.Notice] = {} for container in state.containers: for notice in container.notices: - self._notices[str(notice.type), notice.key] = notice._to_ops_notice() + self._notices[str(notice.type), notice.key] = notice._to_ops() def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/state.py b/scenario/state.py index 62dc11bdf..7108c212d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -668,7 +668,7 @@ class Notice(_DCBase): expire_after: Optional[datetime.timedelta] = None """How long since one of these last occurred until Pebble will drop the notice.""" - def _to_ops_notice(self) -> pebble.Notice: + def _to_ops(self) -> pebble.Notice: return pebble.Notice( id=self.id, user_id=self.user_id, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 73791d079..04dd75f1f 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -383,4 +383,4 @@ def test_pebble_custom_notice(charm_cls): ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) with ctx.manager(cont.notice_event, state) as mgr: container = mgr.charm.unit.get_container("foo") - assert container.get_notices() == [n._to_ops_notice() for n in notices] + assert container.get_notices() == [n._to_ops() for n in notices] From f9928d0692aa9e0b164fbd76ede8e77aaf807f48 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 16 Apr 2024 14:11:22 +1200 Subject: [PATCH 467/546] str(x) only works for enum.StrEnum, not enum.Enum. --- scenario/mocking.py | 6 +++++- scenario/runtime.py | 7 ++++++- scenario/state.py | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index 22b487b0b..7fc8f4c9a 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -667,7 +667,11 @@ def __init__( self._notices: Dict[Tuple[str, str], pebble.Notice] = {} for container in state.containers: for notice in container.notices: - self._notices[str(notice.type), notice.key] = notice._to_ops() + if hasattr(notice.type, "value"): + notice_type = cast(pebble.NoticeType, notice.type).value + else: + notice_type = str(notice.type) + self._notices[notice_type, notice.key] = notice._to_ops() def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/runtime.py b/scenario/runtime.py index 1ded1b4fc..3cb67f0b9 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union import yaml +from ops import pebble from ops.framework import _event_regex from ops.storage import NoSnapshotError, SQLiteStorage @@ -249,10 +250,14 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env.update({"JUJU_WORKLOAD_NAME": container.name}) if notice := event.notice: + if hasattr(notice.type, "value"): + notice_type = typing.cast(pebble.NoticeType, notice.type).value + else: + notice_type = str(notice.type) env.update( { "JUJU_NOTICE_ID": notice.id, - "JUJU_NOTICE_TYPE": str(notice.type), + "JUJU_NOTICE_TYPE": notice_type, "JUJU_NOTICE_KEY": notice.key, }, ) diff --git a/scenario/state.py b/scenario/state.py index 7108c212d..f898c9bc2 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1501,11 +1501,15 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: "container_name": container.name, } if self.notice: + if hasattr(self.notice.type, "value"): + notice_type = cast(pebble.NoticeType, self.notice.type).value + else: + notice_type = str(self.notice.type) snapshot_data.update( { "notice_id": self.notice.id, "notice_key": self.notice.key, - "notice_type": str(self.notice.type), + "notice_type": notice_type, }, ) From a35cc2c7a15e5003d2ca5d272b8c6452b817f902 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 16 Apr 2024 14:11:42 +1200 Subject: [PATCH 468/546] Add tests that verify the notice in the event works as expected. --- tests/test_e2e/test_pebble.py | 63 ++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 04dd75f1f..a750a0707 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,8 +1,9 @@ +import datetime import tempfile from pathlib import Path import pytest -from ops import pebble +from ops import PebbleCustomNoticeEvent, pebble from ops.charm import CharmBase from ops.framework import Framework from ops.pebble import ExecError, ServiceStartup, ServiceStatus @@ -373,14 +374,68 @@ def test_pebble_custom_notice(charm_cls): Notice(key="example.com/bar", last_data={"a": "b"}), Notice(key="example.com/baz", occurrences=42), ] - cont = Container( + container = Container( name="foo", can_connect=True, notices=notices, ) - state = State(containers=[cont]) + state = State(containers=[container]) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(cont.notice_event, state) as mgr: + with ctx.manager(container.notice_event, state) as mgr: container = mgr.charm.unit.get_container("foo") assert container.get_notices() == [n._to_ops() for n in notices] + + +def test_pebble_custom_notice_in_charm(): + key = "example.com/test/charm" + data = {"foo": "bar"} + user_id = 100 + first_occurred = datetime.datetime(1979, 1, 25, 11, 0, 0) + last_occured = datetime.datetime(2006, 8, 28, 13, 28, 0) + last_repeated = datetime.datetime(2023, 9, 4, 9, 0, 0) + occurrences = 42 + repeat_after = datetime.timedelta(days=7) + expire_after = datetime.timedelta(days=365) + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_custom_notice, self._on_custom_notice) + + def _on_custom_notice(self, event: PebbleCustomNoticeEvent): + notice = event.notice + assert notice.type == pebble.NoticeType.CUSTOM + assert notice.key == key + assert notice.last_data == data + assert notice.user_id == user_id + assert notice.first_occurred == first_occurred + assert notice.last_occurred == last_occured + assert notice.last_repeated == last_repeated + assert notice.occurrences == occurrences + assert notice.repeat_after == repeat_after + assert notice.expire_after == expire_after + + notices = [ + Notice("example.com/test/other"), + Notice(key, last_data={"foo": "baz"}), + Notice( + key, + last_data=data, + user_id=user_id, + first_occurred=first_occurred, + last_occurred=last_occured, + last_repeated=last_repeated, + occurrences=occurrences, + repeat_after=repeat_after, + expire_after=expire_after, + ), + ] + container = Container( + name="foo", + can_connect=True, + notices=notices, + ) + state = State(containers=[container]) + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + ctx.run(container.notice_event, state) From 3e1acc9dac4417ac7051c734eca090e6f180e2a9 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 17 Apr 2024 10:15:32 +0800 Subject: [PATCH 469/546] Update scenario/consistency_checker.py Co-authored-by: PietroPasotti --- scenario/consistency_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index b3c12aedf..c8aebbf9d 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -582,7 +582,7 @@ def check_cloudspec_consistency( if state.model.type == "kubernetes" and state.cloud_spec: errors.append( - "CloudSpec is only available for machine charms, not Kubernetes charms.", + "CloudSpec is only available for machine charms, not Kubernetes charms. Tell Scenario to simulate a machine substrate with: `scenario.State(..., model=scenario.Model(type='lxd'))`.", ) return Results(errors, warnings) From 194f52d26fc6600c232d1aa543b7a1aa5a29ea2a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 18 Apr 2024 18:28:39 +1200 Subject: [PATCH 470/546] Reset sys.excepthook. --- scenario/ops_main_mock.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index c2eee10ec..b18c7f02f 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,6 +3,7 @@ # See LICENSE file for licensing details. import inspect import os +import sys from typing import TYPE_CHECKING, Any, Optional, Sequence, cast import ops.charm @@ -96,6 +97,9 @@ def setup_framework( ) debug = "JUJU_DEBUG" in os.environ setup_root_logging(model_backend, debug=debug) + # ops sets sys.excepthook to go to Juju's debug-log, but that's not useful + # in a testing context, so reset it. + sys.excepthook = sys.__excepthook__ ops_logger.debug( "Operator Framework %s up and running.", ops.__version__, From 0749de2a1c18af9bc8877a592d5cab4af3700a3e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 24 May 2024 12:40:16 +1200 Subject: [PATCH 471/546] Adjust the way that the notice event is selected. --- README.md | 19 +------------ scenario/state.py | 50 ++++++++++++++++++++------------- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_pebble.py | 6 ++-- 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ae68a4179..e470f29d6 100644 --- a/README.md +++ b/README.md @@ -745,24 +745,7 @@ notices = [ scenario.Notice(key="example.com/c"), ] cont = scenario.Container(notices=notices) -ctx.run(cont.notice_event, scenario.State(containers=[cont])) -``` - -Note that the `custom_event` is accessed via the container, not the notice, -and is always for the last notice in the list. An `ops.pebble.Notice` does not -know which container it is in, but an `ops.PebbleCustomNoticeEvent` does know -which container did the notifying. If you need to generate an event for a different -notice, you can override the notice: - -```python -ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": "cont": {}}) -notices = [ - scenario.Notice(key="example.com/a", occurences=10), - scenario.Notice(key="example.com/b", last_data={"bar": "baz"}), - scenario.Notice(key="example.com/c"), -] -cont = scenario.Container(notices=notices) -ctx.run(cont.notice_event(notice=notices[0]), scenario.State(containers=[cont])) +ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[cont])) ``` ## Storage diff --git a/scenario/state.py b/scenario/state.py index f898c9bc2..c7e056b51 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -684,6 +684,22 @@ def _to_ops(self) -> pebble.Notice: ) +@dataclasses.dataclass(frozen=True) +class BoundNotice(_DCBase): + notice: Notice + container: "Container" + + @property + def event(self): + """Sugar to generate a -pebble-custom-notice event for this notice.""" + suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX.replace("_", "-") + return Event( + path=normalize_name(self.container.name + suffix), + container=self.container, + notice=self.notice, + ) + + @dataclasses.dataclass(frozen=True) class Container(_DCBase): name: str @@ -790,25 +806,21 @@ def pebble_ready_event(self): ) return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) - @property - def notice_event(self): - """Sugar to generate a -pebble-custom-notice event for the latest notice.""" - if not self.notices: - raise RuntimeError("This container does not have any notices.") - # We assume this event is about the most recent notice. - notice = self.notices[-1] - if notice.type != pebble.NoticeType.CUSTOM: - raise RuntimeError("Scenario only knows about custom notices at this time.") - suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX.replace("_", "-") - if not self.can_connect: - logger.warning( - "you **can** fire pebble-custom-notice while the container cannot connect, " - "but that's most likely not what you want.", - ) - return Event( - path=normalize_name(self.name + suffix), - container=self, - notice=notice, + def get_notice( + self, + key: str, + notice_type: pebble.NoticeType = pebble.NoticeType.CUSTOM, + ) -> BoundNotice: + """Get a Pebble notice by key and type. + + Raises: + KeyError: if the notice is not found. + """ + for notice in self.notices: + if notice.key == key and notice.type == notice_type: + return BoundNotice(notice, self) + raise KeyError( + f"{self.name} does not have a notice with key {key} and type {notice_type}", ) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 20a167204..a96100bcd 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -100,7 +100,7 @@ def test_deferred_workload_evt(mycharm): def test_deferred_notice_evt(mycharm): notice = Notice(key="example.com/bar") ctr = Container("foo", notices=[notice]) - evt1 = ctr.notice_event.deferred(handler=mycharm._on_event) + evt1 = ctr.get_notice("example.com/bar").event.deferred(handler=mycharm._on_event) evt2 = deferred( event="foo_pebble_custom_notice", handler=mycharm._on_event, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index a750a0707..e5c16bf73 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -382,7 +382,7 @@ def test_pebble_custom_notice(charm_cls): state = State(containers=[container]) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(container.notice_event, state) as mgr: + with ctx.manager(container.get_notice("example.com/baz").event, state) as mgr: container = mgr.charm.unit.get_container("foo") assert container.get_notices() == [n._to_ops() for n in notices] @@ -418,7 +418,7 @@ def _on_custom_notice(self, event: PebbleCustomNoticeEvent): notices = [ Notice("example.com/test/other"), - Notice(key, last_data={"foo": "baz"}), + Notice("example.org/test/charm", last_data={"foo": "baz"}), Notice( key, last_data=data, @@ -438,4 +438,4 @@ def _on_custom_notice(self, event: PebbleCustomNoticeEvent): ) state = State(containers=[container]) ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) - ctx.run(container.notice_event, state) + ctx.run(container.get_notice(key).event, state) From 07390030da983d08e0ce0cb24facd8f75964120e Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 29 May 2024 11:51:54 +0800 Subject: [PATCH 472/546] chore: updating comment for redacted according to review suggestions --- scenario/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index e6a798912..a36a7a0a5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -155,7 +155,7 @@ class CloudCredential: """ redacted: List[str] = dataclasses.field(default_factory=list) - """A list of redacted secrets.""" + """A list of redacted generic cloud API secrets.""" def _to_ops(self) -> ops.CloudCredential: return ops.CloudCredential( From da30b05f3cce60d33452abac184d8d1f0e66c7f2 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 May 2024 09:30:23 +0200 Subject: [PATCH 473/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dc5a4b13e..55c83d144 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.3" +version = "6.0.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From fe070f5e00fffa7aa6f5fa6ef0dfd174719f1b71 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 22 May 2024 14:20:44 +0200 Subject: [PATCH 474/546] Revert "Merge pull request #113 from tonyandrewmeyer/rename-relation-id" This reverts commit e758e780b7d4c5aba375143ecfc330d2978e4ede. --- README.md | 8 ++++---- scenario/consistency_checker.py | 6 +++--- scenario/mocking.py | 6 ++++-- scenario/ops_main_mock.py | 2 +- scenario/runtime.py | 2 +- scenario/state.py | 4 ++-- tests/test_consistency_checker.py | 12 +++++++++--- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_network.py | 4 ++-- tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_secrets.py | 4 +++- 12 files changed, 32 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c26a15ef2..48509b946 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. +Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. ```python @@ -479,7 +479,7 @@ import scenario.state next_id = scenario.state.next_relation_id(update=False) rel = scenario.Relation('foo') -assert rel.id == next_id +assert rel.relation_id == next_id ``` This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: @@ -488,8 +488,8 @@ This can be handy when using `replace` to create new relations, to avoid relatio import scenario.state rel = scenario.Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) -assert rel2.id == rel.id + 1 +rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id()) +assert rel2.relation_id == rel.relation_id + 1 ``` If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 34aff084d..c8aebbf9d 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -487,13 +487,13 @@ def _get_relations(r): expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: - if relation.id in seen_ids: + if relation.relation_id in seen_ids: errors.append( - f"duplicate relation ID: {relation.id} is claimed " + f"duplicate relation ID: {relation.relation_id} is claimed " f"by multiple Relation instances", ) - seen_ids.add(relation.id) + seen_ids.add(relation.relation_id) is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( diff --git a/scenario/mocking.py b/scenario/mocking.py index f14a6bb9d..1081b4ac8 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,7 +163,7 @@ def _get_relation_by_id( ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( - filter(lambda r: r.id == rel_id, self._state.relations), + filter(lambda r: r.relation_id == rel_id, self._state.relations), ) except StopIteration: raise RelationNotFoundError() @@ -245,7 +245,9 @@ def status_get(self, *, is_app: bool = False): def relation_ids(self, relation_name): return [ - rel.id for rel in self._state.relations if rel.endpoint == relation_name + rel.relation_id + for rel in self._state.relations + if rel.endpoint == relation_name ] def relation_list(self, relation_id: int) -> Tuple[str, ...]: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b25c7e1c0..b18c7f02f 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -119,7 +119,7 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.id # type: ignore + event.relation.relation_id # type: ignore if event.name.endswith("_relation_broken") else None ) diff --git a/scenario/runtime.py b/scenario/runtime.py index a315413fa..a6aebab0e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -208,7 +208,7 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): env.update( { "JUJU_RELATION": relation.endpoint, - "JUJU_RELATION_ID": str(relation.id), + "JUJU_RELATION_ID": str(relation.relation_id), "JUJU_REMOTE_APP": remote_app_name, }, ) diff --git a/scenario/state.py b/scenario/state.py index 5c0c9e9d8..a36a7a0a5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -425,7 +425,7 @@ class RelationBase(_DCBase): """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. If left empty, it will be automatically derived from metadata.yaml.""" - id: int = dataclasses.field(default_factory=next_relation_id) + relation_id: int = dataclasses.field(default_factory=next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -1484,7 +1484,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "relation_name": relation.endpoint, - "relation_id": relation.id, + "relation_id": relation.relation_id, "app_name": remote_app, "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", } diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 31f9813ca..d5929d98c 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -404,7 +404,9 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + ), Event("start"), _CharmSpec( MyCharm, @@ -417,13 +419,17 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + ), Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), + State( + relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)] + ), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 34dcf5296..b084f6ffe 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -150,7 +150,7 @@ def test_deferred_relation_event_from_relation(mycharm): assert out.deferred[0].name == "foo_relation_changed" assert out.deferred[0].snapshot_data == { "relation_name": rel.endpoint, - "relation_id": rel.id, + "relation_id": rel.relation_id, "app_name": "remote", "unit_name": "remote/1", } diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index c3d271dfc..07808e1de 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -48,7 +48,7 @@ def test_ip_get(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - id=1, + relation_id=1, ), ], networks={"foo": Network.default(private_address="4.4.4.4")}, @@ -110,7 +110,7 @@ def test_no_relation_error(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - id=1, + relation_id=1, ), ], networks={"bar": Network.default()}, diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index d4523b376..b8b92d5a0 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -102,7 +102,7 @@ def check_relation_data(charm): Relation( endpoint="relation_test", interface="azdrubales", - id=1, + relation_id=1, remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 037c1acf4..212e12a68 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -388,7 +388,7 @@ def test_relation_ids(): initial_id = _next_relation_id_counter for i in range(10): rel = Relation("foo") - assert rel.id == initial_id + i + assert rel.relation_id == initial_id + i def test_broken_relation_not_in_model_relations(mycharm): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index f0f87a4f9..e8e75f7b6 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -521,7 +521,9 @@ def __init__(self, *args): state = State( leader=True, relations=[ - Relation("bar", remote_app_name=relation_remote_app, id=relation_id) + Relation( + "bar", remote_app_name=relation_remote_app, relation_id=relation_id + ) ], ) From d30205d7f4303335244dd5a8f1b78201d506a94e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 29 May 2024 12:08:04 +0200 Subject: [PATCH 475/546] rolled back relation id renaming --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 55c83d144..fcf9a915d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.4" +version = "6.0.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From d10a533bd6efb702cbf09aee5cfcaa740fe980dd Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Thu, 30 May 2024 14:52:15 +0900 Subject: [PATCH 476/546] docs: fix state transition image on PyPI PyPI uses a proxy (Camo) to serve images in a more secure way. This ensures that the images are served over HTTPS, even if the original source is not. However, the original image URL still needs to be accessible publicly for this to work correctly. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 48509b946..21939ae7a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ union of an `Event` (why am I, charm, being executed), a `State` (am I leader? w config?...) and the charm's execution `Context` (what relations can I have? what containers can I have?...). The output is another `State`: the state after the charm has had a chance to interact with the mocked Juju model and affect the initial state back. -![state transition model depiction](resources/state-transition-model.png) +![state transition model depiction](https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png) For example: a charm currently in `unknown` status is executed with a `start` event, and based on whether it has leadership or not (according to its input state), it will decide to set `active` or `blocked` status (which will be reflected in the output state). From b3bb6035a31df2385648c7cdf96843f37ea784da Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 31 May 2024 13:06:28 +1200 Subject: [PATCH 477/546] Update the add-trailing-comma hook. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5fcd9e89..947fcf5cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/add-trailing-comma - rev: v2.4.0 + rev: v3.1.0 hooks: - id: add-trailing-comma args: [--py36-plus] From 71d7f413911798a2fcdde65e30825a5fd064c125 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 31 May 2024 13:07:16 +1200 Subject: [PATCH 478/546] Update the add-trailing-comma hook. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5fcd9e89..947fcf5cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/add-trailing-comma - rev: v2.4.0 + rev: v3.1.0 hooks: - id: add-trailing-comma args: [--py36-plus] From 895200c087b80768b11bbeb8e0cc3259cc2b5c17 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 7 Jun 2024 10:45:05 +1200 Subject: [PATCH 479/546] Move the cloud spec to the model, use the ops CloudSpec and CloudCredential classes, add trustedness. --- README.md | 32 ++++++------- scenario/__init__.py | 4 -- scenario/consistency_checker.py | 6 ++- scenario/mocking.py | 11 +++-- scenario/state.py | 78 ++----------------------------- tests/test_consistency_checker.py | 8 ++-- tests/test_e2e/test_cloud_spec.py | 32 ++++++------- 7 files changed, 52 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 21939ae7a..e31ad014f 100644 --- a/README.md +++ b/README.md @@ -948,30 +948,30 @@ assert out.model.name == "my-model" assert out.model.uuid == state_in.model.uuid ``` -## CloudSpec +### CloudSpec -You can set CloudSpec information in the state (only `type` and `name` are required). +You can set CloudSpec information in the model (only `type` and `name` are required). Example: ```python import scenario -state = scenario.State( - cloud_spec=scenario.CloudSpec( - type="lxd", - name="localhost", - endpoint="https://127.0.0.1:8443", - credential=scenario.CloudCredential( - auth_type="clientcertificate", - attributes={ - "client-cert": "foo", - "client-key": "bar", - "server-cert": "baz", - }, - ), +cloud_spec=ops.CloudSpec( + type="lxd", + name="localhost", + endpoint="https://127.0.0.1:8443", + credential=scenario.CloudCredential( + auth_type="clientcertificate", + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, ), - model=scenario.Model(name="my-vm-model", type="lxd"), +) +state = scenario.State( + model=scenario.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), ) ``` diff --git a/scenario/__init__.py b/scenario/__init__.py index 8b832ab05..b84248191 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -6,8 +6,6 @@ Action, Address, BindAddress, - CloudCredential, - CloudSpec, Container, DeferredEvent, Event, @@ -31,8 +29,6 @@ __all__ = [ "Action", "ActionOutput", - "CloudCredential", - "CloudSpec", "Context", "deferred", "StateValidationError", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index c8aebbf9d..584bad676 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -580,9 +580,11 @@ def check_cloudspec_consistency( errors = [] warnings = [] - if state.model.type == "kubernetes" and state.cloud_spec: + if state.model.type == "kubernetes" and state.model.cloud_spec: errors.append( - "CloudSpec is only available for machine charms, not Kubernetes charms. Tell Scenario to simulate a machine substrate with: `scenario.State(..., model=scenario.Model(type='lxd'))`.", + "CloudSpec is only available for machine charms, not Kubernetes charms. " + "Tell Scenario to simulate a machine substrate with: " + "`scenario.State(..., model=scenario.Model(type='lxd'))`.", ) return Results(errors, warnings) diff --git a/scenario/mocking.py b/scenario/mocking.py index 1081b4ac8..27a4c7c69 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -632,11 +632,16 @@ def resource_get(self, resource_name: str) -> str: ) def credential_get(self) -> CloudSpec: - if not self._state.cloud_spec: + if not self._state.trusted: raise ModelError( - "ERROR cloud spec is empty, initialise it with `scenario.State(cloud_spec=scenario.CloudSpec(...))`", + "ERROR charm is not trusted, initialise State with trusted=True", ) - return self._state.cloud_spec._to_ops() + if not self._state.model.cloud_spec: + raise ModelError( + "ERROR cloud spec is empty, initialise it with " + "`State(model=Model(..., cloud_spec=ops.CloudSpec(...)))`", + ) + return self._state.model.cloud_spec class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index a36a7a0a5..2fee58a9d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -141,77 +141,6 @@ def copy(self) -> "Self": return copy.deepcopy(self) -@dataclasses.dataclass(frozen=True) -class CloudCredential: - auth_type: str - """Authentication type.""" - - attributes: Dict[str, str] = dataclasses.field(default_factory=dict) - """A dictionary containing cloud credentials. - - For example, for AWS, it contains `access-key` and `secret-key`; - for Azure, `application-id`, `application-password` and `subscription-id` - can be found here. - """ - - redacted: List[str] = dataclasses.field(default_factory=list) - """A list of redacted generic cloud API secrets.""" - - def _to_ops(self) -> ops.CloudCredential: - return ops.CloudCredential( - auth_type=self.auth_type, - attributes=self.attributes, - redacted=self.redacted, - ) - - -@dataclasses.dataclass(frozen=True) -class CloudSpec: - type: str - """Type of the cloud.""" - - name: str = "localhost" - """Juju cloud name.""" - - region: Optional[str] = None - """Region of the cloud.""" - - endpoint: Optional[str] = None - """Endpoint of the cloud.""" - - identity_endpoint: Optional[str] = None - """Identity endpoint of the cloud.""" - - storage_endpoint: Optional[str] = None - """Storage endpoint of the cloud.""" - - credential: Optional[CloudCredential] = None - """Cloud credentials with key-value attributes.""" - - ca_certificates: List[str] = dataclasses.field(default_factory=list) - """A list of CA certificates.""" - - skip_tls_verify: bool = False - """Whether to skip TLS verfication.""" - - is_controller_cloud: bool = False - """If this is the cloud used by the controller.""" - - def _to_ops(self) -> ops.CloudSpec: - return ops.CloudSpec( - type=self.type, - name=self.name, - region=self.region, - endpoint=self.endpoint, - identity_endpoint=self.identity_endpoint, - storage_endpoint=self.storage_endpoint, - credential=self.credential._to_ops() if self.credential else None, - ca_certificates=self.ca_certificates, - skip_tls_verify=self.skip_tls_verify, - is_controller_cloud=self.is_controller_cloud, - ) - - @dataclasses.dataclass(frozen=True) class Secret(_DCBase): id: str @@ -641,6 +570,9 @@ class Model(_DCBase): # TODO: make this exhaustive. type: Literal["kubernetes", "lxd"] = "kubernetes" + cloud_spec: Optional[ops.CloudSpec] = None + """Cloud specification information (metadata) including credentials.""" + # for now, proc mock allows you to map one command to one mocked output. # todo extend: one input -> multiple outputs, at different times @@ -965,6 +897,8 @@ class State(_DCBase): """Whether this charm has leadership.""" model: Model = Model() """The model this charm lives in.""" + trusted: bool = False + """Whether ``juju-trust`` has been run for this charm.""" secrets: List[Secret] = dataclasses.field(default_factory=list) """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. @@ -991,8 +925,6 @@ class State(_DCBase): """Status of the unit.""" workload_version: str = "" """Workload version.""" - cloud_spec: Optional[CloudSpec] = None - """Cloud specification information (metadata) including credentials.""" def __post_init__(self): for name in ["app_status", "unit_status"]: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index d5929d98c..bc1c22cbd 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,5 +1,6 @@ import pytest from ops.charm import CharmBase +from ops.model import CloudCredential, CloudSpec from scenario import Model from scenario.consistency_checker import check_consistency @@ -7,8 +8,6 @@ from scenario.state import ( RELATION_EVENTS_SUFFIX, Action, - CloudCredential, - CloudSpec, Container, Event, Network, @@ -575,6 +574,7 @@ def test_networks_consistency(): def test_cloudspec_consistency(): cloud_spec = CloudSpec( + name="localhost", type="lxd", endpoint="https://127.0.0.1:8443", credential=CloudCredential( @@ -588,7 +588,7 @@ def test_cloudspec_consistency(): ) assert_consistent( - State(cloud_spec=cloud_spec, model=Model(name="lxd-model", type="lxd")), + State(model=Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec)), Event("start"), _CharmSpec( MyCharm, @@ -597,7 +597,7 @@ def test_cloudspec_consistency(): ) assert_inconsistent( - State(cloud_spec=cloud_spec, model=Model(name="k8s-model", type="kubernetes")), + State(model=Model(name="k8s-model", type="kubernetes", cloud_spec=cloud_spec)), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 357061d2e..fe0e999c3 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -15,20 +15,7 @@ def _on_event(self, event): def test_get_cloud_spec(): - scenario_cloud_spec = scenario.CloudSpec( - type="lxd", - name="localhost", - endpoint="https://127.0.0.1:8443", - credential=scenario.CloudCredential( - auth_type="clientcertificate", - attributes={ - "client-cert": "foo", - "client-key": "bar", - "server-cert": "baz", - }, - ), - ) - expected_cloud_spec = ops.CloudSpec( + cloud_spec = ops.CloudSpec( type="lxd", name="localhost", endpoint="https://127.0.0.1:8443", @@ -43,11 +30,11 @@ def test_get_cloud_spec(): ) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State( - cloud_spec=scenario_cloud_spec, - model=scenario.Model(name="lxd-model", type="lxd"), + model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec), + trusted=True, ) with ctx.manager("start", state=state) as mgr: - assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec + assert mgr.charm.model.get_cloud_spec() == cloud_spec def test_get_cloud_spec_error(): @@ -56,3 +43,14 @@ def test_get_cloud_spec_error(): with ctx.manager("start", state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() + + +def test_get_cloud_spec_untrusted(): + cloud_spec = ops.CloudSpec(type="lxd", name="localhost") + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + state = scenario.State( + model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec), + ) + with ctx.manager("start", state) as mgr: + with pytest.raises(ops.ModelError): + mgr.charm.model.get_cloud_spec() From c7ed120fd3f320c457639df346a27802120ebd1a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 7 Jun 2024 14:48:01 +1200 Subject: [PATCH 480/546] Restore the Scenario CloudSpec and CloudCredential classes. --- README.md | 3 +- scenario/__init__.py | 4 ++ scenario/mocking.py | 2 +- scenario/state.py | 72 ++++++++++++++++++++++++++++++- tests/test_consistency_checker.py | 3 +- tests/test_e2e/test_cloud_spec.py | 21 +++++++-- 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e31ad014f..6a2cf135d 100644 --- a/README.md +++ b/README.md @@ -957,9 +957,8 @@ Example: ```python import scenario -cloud_spec=ops.CloudSpec( +cloud_spec=scenario.CloudSpec( type="lxd", - name="localhost", endpoint="https://127.0.0.1:8443", credential=scenario.CloudCredential( auth_type="clientcertificate", diff --git a/scenario/__init__.py b/scenario/__init__.py index b84248191..8b832ab05 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -6,6 +6,8 @@ Action, Address, BindAddress, + CloudCredential, + CloudSpec, Container, DeferredEvent, Event, @@ -29,6 +31,8 @@ __all__ = [ "Action", "ActionOutput", + "CloudCredential", + "CloudSpec", "Context", "deferred", "StateValidationError", diff --git a/scenario/mocking.py b/scenario/mocking.py index 27a4c7c69..8cd4d3c8b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -641,7 +641,7 @@ def credential_get(self) -> CloudSpec: "ERROR cloud spec is empty, initialise it with " "`State(model=Model(..., cloud_spec=ops.CloudSpec(...)))`", ) - return self._state.model.cloud_spec + return self._state.model.cloud_spec._to_ops() class _MockPebbleClient(_TestingPebbleClient): diff --git a/scenario/state.py b/scenario/state.py index 2fee58a9d..93bcadbcb 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -141,6 +141,76 @@ def copy(self) -> "Self": return copy.deepcopy(self) +@dataclasses.dataclass(frozen=True) +class CloudCredential: + auth_type: str + """Authentication type.""" + + attributes: Dict[str, str] = dataclasses.field(default_factory=dict) + """A dictionary containing cloud credentials. + For example, for AWS, it contains `access-key` and `secret-key`; + for Azure, `application-id`, `application-password` and `subscription-id` + can be found here. + """ + + redacted: List[str] = dataclasses.field(default_factory=list) + """A list of redacted generic cloud API secrets.""" + + def _to_ops(self) -> ops.CloudCredential: + return ops.CloudCredential( + auth_type=self.auth_type, + attributes=self.attributes, + redacted=self.redacted, + ) + + +@dataclasses.dataclass(frozen=True) +class CloudSpec: + type: str + """Type of the cloud.""" + + name: str = "localhost" + """Juju cloud name.""" + + region: Optional[str] = None + """Region of the cloud.""" + + endpoint: Optional[str] = None + """Endpoint of the cloud.""" + + identity_endpoint: Optional[str] = None + """Identity endpoint of the cloud.""" + + storage_endpoint: Optional[str] = None + """Storage endpoint of the cloud.""" + + credential: Optional[CloudCredential] = None + """Cloud credentials with key-value attributes.""" + + ca_certificates: List[str] = dataclasses.field(default_factory=list) + """A list of CA certificates.""" + + skip_tls_verify: bool = False + """Whether to skip TLS verfication.""" + + is_controller_cloud: bool = False + """If this is the cloud used by the controller.""" + + def _to_ops(self) -> ops.CloudSpec: + return ops.CloudSpec( + type=self.type, + name=self.name, + region=self.region, + endpoint=self.endpoint, + identity_endpoint=self.identity_endpoint, + storage_endpoint=self.storage_endpoint, + credential=self.credential._to_ops() if self.credential else None, + ca_certificates=self.ca_certificates, + skip_tls_verify=self.skip_tls_verify, + is_controller_cloud=self.is_controller_cloud, + ) + + @dataclasses.dataclass(frozen=True) class Secret(_DCBase): id: str @@ -570,7 +640,7 @@ class Model(_DCBase): # TODO: make this exhaustive. type: Literal["kubernetes", "lxd"] = "kubernetes" - cloud_spec: Optional[ops.CloudSpec] = None + cloud_spec: Optional[CloudSpec] = None """Cloud specification information (metadata) including credentials.""" diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index bc1c22cbd..78c851027 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,6 +1,5 @@ import pytest from ops.charm import CharmBase -from ops.model import CloudCredential, CloudSpec from scenario import Model from scenario.consistency_checker import check_consistency @@ -8,6 +7,8 @@ from scenario.state import ( RELATION_EVENTS_SUFFIX, Action, + CloudCredential, + CloudSpec, Container, Event, Network, diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index fe0e999c3..fc5fa83f8 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -15,7 +15,20 @@ def _on_event(self, event): def test_get_cloud_spec(): - cloud_spec = ops.CloudSpec( + scenario_cloud_spec = scenario.CloudSpec( + type="lxd", + name="localhost", + endpoint="https://127.0.0.1:8443", + credential=scenario.CloudCredential( + auth_type="clientcertificate", + attributes={ + "client-cert": "foo", + "client-key": "bar", + "server-cert": "baz", + }, + ), + ) + expected_cloud_spec = ops.CloudSpec( type="lxd", name="localhost", endpoint="https://127.0.0.1:8443", @@ -30,11 +43,13 @@ def test_get_cloud_spec(): ) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state = scenario.State( - model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec), + model=scenario.Model( + name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec + ), trusted=True, ) with ctx.manager("start", state=state) as mgr: - assert mgr.charm.model.get_cloud_spec() == cloud_spec + assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec def test_get_cloud_spec_error(): From 7ba3d3dd85beffe533c6e343a02912c8fb5c0078 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 7 Jun 2024 18:22:14 +1200 Subject: [PATCH 481/546] Make BoundNotice private. --- scenario/state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index c7e056b51..ee2bd10e3 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -685,7 +685,7 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class BoundNotice(_DCBase): +class _BoundNotice(_DCBase): notice: Notice container: "Container" @@ -810,7 +810,7 @@ def get_notice( self, key: str, notice_type: pebble.NoticeType = pebble.NoticeType.CUSTOM, - ) -> BoundNotice: + ) -> _BoundNotice: """Get a Pebble notice by key and type. Raises: @@ -818,7 +818,7 @@ def get_notice( """ for notice in self.notices: if notice.key == key and notice.type == notice_type: - return BoundNotice(notice, self) + return _BoundNotice(notice, self) raise KeyError( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) From b895d36db021a8559b5b545b99b59338374a38ec Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 7 Jun 2024 22:56:23 +1200 Subject: [PATCH 482/546] The trust is really more context than state, per review. --- scenario/context.py | 4 ++++ scenario/mocking.py | 4 ++-- scenario/state.py | 2 -- tests/test_e2e/test_cloud_spec.py | 3 +-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 9de78b1cf..a3e2a8ea2 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -169,6 +169,7 @@ def __init__( capture_framework_events: bool = False, app_name: Optional[str] = None, unit_id: Optional[int] = 0, + app_trusted: bool = False, ): """Represents a simulated charm's execution context. @@ -225,6 +226,8 @@ def __init__( :arg app_name: App name that this charm is deployed as. Defaults to the charm name as defined in metadata.yaml. :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. + :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with + ``juju trust``). Defaults to False :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -268,6 +271,7 @@ def __init__( self._app_name = app_name self._unit_id = unit_id + self.app_trusted = app_trusted self._tmp = tempfile.TemporaryDirectory() # config for what events to be captured in emitted_events. diff --git a/scenario/mocking.py b/scenario/mocking.py index 8cd4d3c8b..a55397d29 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -632,9 +632,9 @@ def resource_get(self, resource_name: str) -> str: ) def credential_get(self) -> CloudSpec: - if not self._state.trusted: + if not self._context.app_trusted: raise ModelError( - "ERROR charm is not trusted, initialise State with trusted=True", + "ERROR charm is not trusted, initialise Context with `app_trusted=True`", ) if not self._state.model.cloud_spec: raise ModelError( diff --git a/scenario/state.py b/scenario/state.py index 93bcadbcb..c0d573fa5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -967,8 +967,6 @@ class State(_DCBase): """Whether this charm has leadership.""" model: Model = Model() """The model this charm lives in.""" - trusted: bool = False - """Whether ``juju-trust`` has been run for this charm.""" secrets: List[Secret] = dataclasses.field(default_factory=list) """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. diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index fc5fa83f8..8ce413f8f 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -41,12 +41,11 @@ def test_get_cloud_spec(): }, ), ) - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + ctx = scenario.Context(MyCharm, meta={"name": "foo"}, app_trusted=True) state = scenario.State( model=scenario.Model( name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec ), - trusted=True, ) with ctx.manager("start", state=state) as mgr: assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec From f52d5d971894ce9492e37892a4b52a2d0bbfcbc3 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 11 Jun 2024 19:25:53 +1200 Subject: [PATCH 483/546] Add version bump. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c46c574a..5907ae715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.0.3" +version = "6.1.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From cb6cbb25615851c84ac2955319582a68de35e2ed Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 11 Jun 2024 19:28:02 +1200 Subject: [PATCH 484/546] Avoid unnecessary conversions, per review. --- .pre-commit-config.yaml | 3 +-- scenario/state.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5fcd9e89..0f8dfeca2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,9 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/add-trailing-comma - rev: v2.4.0 + rev: v3.1.0 hooks: - id: add-trailing-comma - args: [--py36-plus] - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: diff --git a/scenario/state.py b/scenario/state.py index ee2bd10e3..e290eaa4c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -692,9 +692,9 @@ class _BoundNotice(_DCBase): @property def event(self): """Sugar to generate a -pebble-custom-notice event for this notice.""" - suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX.replace("_", "-") + suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX return Event( - path=normalize_name(self.container.name + suffix), + path=normalize_name(self.container.name) + suffix, container=self.container, notice=self.notice, ) From 385bdf036a68d3a6005fe08b910807af5123d392 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jun 2024 11:19:05 +0200 Subject: [PATCH 485/546] fixed config mutation in state --- scenario/consistency_checker.py | 1 - scenario/mocking.py | 2 +- tests/test_e2e/test_config.py | 44 +++++++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index e73602a48..50eb939d6 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -353,7 +353,6 @@ def check_config_consistency( ) continue - # todo unify with snapshot's when merged. converters = { "string": str, "int": int, diff --git a/scenario/mocking.py b/scenario/mocking.py index af72a3618..02d920438 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -263,7 +263,7 @@ def relation_list(self, relation_id: int) -> Tuple[str, ...]: ) def config_get(self): - state_config = self._state.config + state_config = self._state.config.copy() # dedup or we'll mutate the state! # add defaults charm_config = self._charm_spec.config diff --git a/tests/test_e2e/test_config.py b/tests/test_e2e/test_config.py index 27b25c29a..00e46837a 100644 --- a/tests/test_e2e/test_config.py +++ b/tests/test_e2e/test_config.py @@ -2,7 +2,7 @@ from ops.charm import CharmBase from ops.framework import Framework -from scenario.state import Event, Network, Relation, State, _CharmSpec +from scenario.state import State from tests.helpers import trigger @@ -53,9 +53,49 @@ def check_cfg(charm: CharmBase): config={ "options": { "foo": {"type": "string"}, - "baz": {"type": "integer", "default": 2}, + "baz": {"type": "int", "default": 2}, "qux": {"type": "boolean", "default": False}, }, }, post_event=check_cfg, ) + + +@pytest.mark.parametrize( + "cfg_in", + ( + {"foo": "bar"}, + {"baz": 4, "foo": "bar"}, + {"baz": 4, "foo": "bar", "qux": True}, + ), +) +def test_config_in_not_mutated(mycharm, cfg_in): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + + def _on_event(self, event): + # access the config to trigger a config-get + foo_cfg = self.config["foo"] + baz_cfg = self.config["baz"] + qux_cfg = self.config["qux"] + + state_out = trigger( + State( + config=cfg_in, + ), + "update_status", + MyCharm, + meta={"name": "foo"}, + config={ + "options": { + "foo": {"type": "string"}, + "baz": {"type": "int", "default": 2}, + "qux": {"type": "boolean", "default": False}, + }, + }, + ) + # check config was not mutated by scenario + assert state_out.config == cfg_in From 3a5bda0d8c92616c5683a9306c02a7791717682d Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 21 Jun 2024 11:20:57 +0200 Subject: [PATCH 486/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5907ae715..7c2b19953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.0" +version = "6.1.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From c2253b7f6130e002cc01339cb1558dd9016feedd Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 10 Jul 2024 11:06:07 +1200 Subject: [PATCH 487/546] docs: basic API reference docs (#128) * Bump required ops version to 2.12 to get CloudSpec/CloudCredential. * Bump version. * Add generation of API reference documentation. * Remove the jujulogline doc, which doesn't work correctly, and confuses the linter, which thinks it's the module docstring. * Handle ops caching the content. --- .gitignore | 3 +- docs/.sphinx/_static/404.svg | 13 + docs/.sphinx/_static/custom.css | 361 ++++++++++++++++++++ docs/.sphinx/_static/favicon.png | Bin 0 -> 2258 bytes docs/.sphinx/_static/furo_colors.css | 89 +++++ docs/.sphinx/_static/github_issue_links.css | 24 ++ docs/.sphinx/_static/github_issue_links.js | 34 ++ docs/.sphinx/_static/header-nav.js | 10 + docs/.sphinx/_static/header.css | 167 +++++++++ docs/.sphinx/_static/tag.png | Bin 0 -> 6781 bytes docs/.sphinx/_templates/404.html | 17 + docs/.sphinx/_templates/base.html | 12 + docs/.sphinx/_templates/footer.html | 111 ++++++ docs/.sphinx/_templates/header.html | 36 ++ docs/.sphinx/_templates/page.html | 49 +++ docs/.sphinx/_templates/sidebar/search.html | 7 + docs/.sphinx/build_requirements.py | 124 +++++++ docs/.sphinx/pa11y.json | 9 + docs/.sphinx/spellingcheck.yaml | 29 ++ docs/_static/custom.css | 189 ++++++++++ docs/_static/favicon.png | Bin 0 -> 57806 bytes docs/_static/github_issue_links.css | 24 ++ docs/_static/github_issue_links.js | 26 ++ docs/_templates/base.html | 7 + docs/_templates/footer.html | 90 +++++ docs/_templates/page.html | 44 +++ docs/conf.py | 161 +++++++++ docs/custom_conf.py | 313 +++++++++++++++++ docs/index.rst | 38 +++ docs/requirements.txt | 148 ++++++++ pyproject.toml | 20 +- scenario/context.py | 106 +++++- scenario/state.py | 157 +++++++-- tests/test_e2e/test_secrets.py | 2 + tox.ini | 14 + 35 files changed, 2385 insertions(+), 49 deletions(-) create mode 100644 docs/.sphinx/_static/404.svg create mode 100644 docs/.sphinx/_static/custom.css create mode 100644 docs/.sphinx/_static/favicon.png create mode 100644 docs/.sphinx/_static/furo_colors.css create mode 100644 docs/.sphinx/_static/github_issue_links.css create mode 100644 docs/.sphinx/_static/github_issue_links.js create mode 100644 docs/.sphinx/_static/header-nav.js create mode 100644 docs/.sphinx/_static/header.css create mode 100644 docs/.sphinx/_static/tag.png create mode 100644 docs/.sphinx/_templates/404.html create mode 100644 docs/.sphinx/_templates/base.html create mode 100644 docs/.sphinx/_templates/footer.html create mode 100644 docs/.sphinx/_templates/header.html create mode 100644 docs/.sphinx/_templates/page.html create mode 100644 docs/.sphinx/_templates/sidebar/search.html create mode 100644 docs/.sphinx/build_requirements.py create mode 100644 docs/.sphinx/pa11y.json create mode 100644 docs/.sphinx/spellingcheck.yaml create mode 100644 docs/_static/custom.css create mode 100644 docs/_static/favicon.png create mode 100644 docs/_static/github_issue_links.css create mode 100644 docs/_static/github_issue_links.js create mode 100644 docs/_templates/base.html create mode 100644 docs/_templates/footer.html create mode 100644 docs/_templates/page.html create mode 100644 docs/conf.py create mode 100644 docs/custom_conf.py create mode 100644 docs/index.rst create mode 100644 docs/requirements.txt diff --git a/.gitignore b/.gitignore index 046b96754..c20b5a450 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv/ build/ +docs/build/ *.charm .tox/ .coverage @@ -9,4 +10,4 @@ __pycache__/ *.egg-info dist/ *.pytest_cache -htmlcov/ \ No newline at end of file +htmlcov/ diff --git a/docs/.sphinx/_static/404.svg b/docs/.sphinx/_static/404.svg new file mode 100644 index 000000000..b353cd339 --- /dev/null +++ b/docs/.sphinx/_static/404.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/docs/.sphinx/_static/custom.css b/docs/.sphinx/_static/custom.css new file mode 100644 index 000000000..86f5d09f7 --- /dev/null +++ b/docs/.sphinx/_static/custom.css @@ -0,0 +1,361 @@ +/** + Ubuntu variable font definitions. + Based on https://github.com/canonical/vanilla-framework/blob/main/scss/_base_fontfaces.scss + + When font files are updated in Vanilla, the links to font files will need to be updated here as well. +*/ + +/* default font set */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/f1ea362b-Ubuntu%5Bwdth,wght%5D-latin-v0.896a.woff2') format('woff2-variations'); +} + +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: italic; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/90b59210-Ubuntu-Italic%5Bwdth,wght%5D-latin-v0.896a.woff2') format('woff2-variations'); +} + +@font-face { + font-family: 'Ubuntu Mono variable'; + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/d5fc1819-UbuntuMono%5Bwght%5D-latin-v0.869.woff2') format('woff2-variations'); +} + +/* cyrillic-ext */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/77cd6650-Ubuntu%5Bwdth,wght%5D-cyrillic-extended-v0.896a.woff2') format('woff2-variations'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} + +/* cyrillic */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/2702fce5-Ubuntu%5Bwdth,wght%5D-cyrillic-v0.896a.woff2') format('woff2-variations'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* greek-ext */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/5c108b7d-Ubuntu%5Bwdth,wght%5D-greek-extended-v0.896a.woff2') format('woff2-variations'); + unicode-range: U+1F00-1FFF; +} + +/* greek */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/0a14c405-Ubuntu%5Bwdth,wght%5D-greek-v0.896a.woff2') format('woff2-variations'); + unicode-range: U+0370-03FF; +} + +/* latin-ext */ +@font-face { + font-family: 'Ubuntu variable'; + font-stretch: 100%; /* min and max value for the width axis, expressed as percentage */ + font-style: normal; + font-weight: 100 800; /* min and max value for the weight axis */ + src: url('https://assets.ubuntu.com/v1/19f68eeb-Ubuntu%5Bwdth,wght%5D-latin-extended-v0.896a.woff2') format('woff2-variations'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} + + +/** Define font-weights as per Vanilla + Based on: https://github.com/canonical/vanilla-framework/blob/main/scss/_base_typography-definitions.scss + + regular text: 400, + bold: 550, + thin: 300, + + h1: bold, + h2: 180; + h3: bold, + h4: 275, + h5: bold, + h6: regular +*/ + +/* default regular text */ +html { + font-weight: 400; +} + +/* heading specific definitions */ +h1, h3, h5 { font-weight: 550; } +h2 { font-weight: 180; } +h4 { font-weight: 275; } + +/* bold */ +.toc-tree li.scroll-current>.reference, +dl.glossary dt, +dl.simple dt, +dl:not([class]) dt { + font-weight: 550; +} + + +/** Table styling **/ + +th.head { + text-transform: uppercase; + font-size: var(--font-size--small); + text-align: initial; +} + +table.align-center th.head { + text-align: center +} + +table.docutils { + border: 0; + box-shadow: none; + width:100%; +} + +table.docutils td, table.docutils th, table.docutils td:last-child, table.docutils th:last-child, table.docutils td:first-child, table.docutils th:first-child { + border-right: none; + border-left: none; +} + +/* Allow to centre text horizontally in table data cells */ +table.align-center { + text-align: center !important; +} + +/** No rounded corners **/ + +.admonition, code.literal, .sphinx-tabs-tab, .sphinx-tabs-panel, .highlight { + border-radius: 0; +} + +/** Admonition styling **/ + +.admonition { + border-top: 1px solid #d9d9d9; + border-right: 1px solid #d9d9d9; + border-bottom: 1px solid #d9d9d9; +} + +/** Color for the "copy link" symbol next to headings **/ + +a.headerlink { + color: var(--color-brand-primary); +} + +/** Line to the left of the current navigation entry **/ + +.sidebar-tree li.current-page { + border-left: 2px solid var(--color-brand-primary); +} + +/** Some tweaks for Sphinx tabs **/ + +[role="tablist"] { + border-bottom: 1px solid var(--color-sidebar-item-background--hover); +} + +.sphinx-tabs-tab[aria-selected="true"], .sd-tab-set>input:checked+label{ + border: 0; + border-bottom: 2px solid var(--color-brand-primary); + font-weight: 400; + font-size: 1rem; + color: var(--color-brand-primary); +} + +body[data-theme="dark"] .sphinx-tabs-tab[aria-selected="true"] { + background: var(--color-background-primary); + border-bottom: 2px solid var(--color-brand-primary); +} + +button.sphinx-tabs-tab[aria-selected="false"]:hover, .sd-tab-set>input:not(:checked)+label:hover { + border-bottom: 2px solid var(--color-foreground-border); +} + +button.sphinx-tabs-tab[aria-selected="false"]{ + border-bottom: 2px solid var(--color-background-primary); +} + +body[data-theme="dark"] .sphinx-tabs-tab { + background: var(--color-background-primary); +} + +.sphinx-tabs-tab, .sd-tab-set>label{ + color: var(--color-brand-primary); + font-family: var(--font-stack); + font-weight: 400; + font-size: 1rem; + padding: 1em 1.25em .5em +} + +.sphinx-tabs-panel { + border: 0; + border-bottom: 1px solid var(--color-sidebar-item-background--hover); + background: var(--color-background-primary); + padding: 0.75rem 0 0.75rem 0; +} + +body[data-theme="dark"] .sphinx-tabs-panel { + background: var(--color-background-primary); +} + +/** A tweak for issue #190 **/ + +.highlight .hll { + background-color: var(--color-highlighted-background); +} + + +/** Custom classes to fix scrolling in tables by decreasing the + font size or breaking certain columns. + Specify the classes in the Markdown file with, for example: + ```{rst-class} break-col-4 min-width-4-8 + ``` +**/ + +table.dec-font-size { + font-size: smaller; +} +table.break-col-1 td.text-left:first-child { + word-break: break-word; +} +table.break-col-4 td.text-left:nth-child(4) { + word-break: break-word; +} +table.min-width-1-15 td.text-left:first-child { + min-width: 15em; +} +table.min-width-4-8 td.text-left:nth-child(4) { + min-width: 8em; +} + +/** Underline for abbreviations **/ + +abbr[title] { + text-decoration: underline solid #cdcdcd; +} + +/** Use the same style for right-details as for left-details **/ +.bottom-of-page .right-details { + font-size: var(--font-size--small); + display: block; +} + +/** Version switcher */ +button.version_select { + color: var(--color-foreground-primary); + background-color: var(--color-toc-background); + padding: 5px 10px; + border: none; +} + +.version_select:hover, .version_select:focus { + background-color: var(--color-sidebar-item-background--hover); +} + +.version_dropdown { + position: relative; + display: inline-block; + text-align: right; + font-size: var(--sidebar-item-font-size); +} + +.available_versions { + display: none; + position: absolute; + right: 0px; + background-color: var(--color-toc-background); + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 11; +} + +.available_versions a { + color: var(--color-foreground-primary); + padding: 12px 16px; + text-decoration: none; + display: block; +} + +.available_versions a:hover {background-color: var(--color-sidebar-item-background--current)} + +.show {display:block;} + +/** Fix for nested numbered list - the nested list is lettered **/ +ol.arabic ol.arabic { + list-style: lower-alpha; +} + +/** Make expandable sections look like links **/ +details summary { + color: var(--color-link); +} + +/** Fix the styling of the version box for readthedocs **/ + +#furo-readthedocs-versions .rst-versions, #furo-readthedocs-versions .rst-current-version, #furo-readthedocs-versions:focus-within .rst-current-version, #furo-readthedocs-versions:hover .rst-current-version { + background: var(--color-sidebar-item-background--hover); +} + +.rst-versions .rst-other-versions dd a { + color: var(--color-link); +} + +#furo-readthedocs-versions:focus-within .rst-current-version .fa-book, #furo-readthedocs-versions:hover .rst-current-version .fa-book, .rst-versions .rst-other-versions { + color: var(--color-sidebar-link-text); +} + +.rst-versions .rst-current-version { + color: var(--color-version-popup); + font-weight: bolder; +} + +/* Code-block copybutton invisible by default + (overriding Furo config to achieve default copybutton setting). */ +.highlight button.copybtn { + opacity: 0; +} + +/* Mimicking the 'Give feedback' button for UX consistency */ +.sidebar-search-container input[type=submit] { + color: #FFFFFF; + border: 2px solid #D6410D; + padding: var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal); + background: #D6410D; + font-weight: bold; + font-size: var(--font-size--small); + cursor: pointer; +} + +.sidebar-search-container input[type=submit]:hover { + text-decoration: underline; +} + +/* Make inline code the same size as code blocks */ +p code.literal { + border: 0; + font-size: var(--code-font-size); +} + +/* Use the general admonition font size for inline code */ +.admonition p code.literal { + font-size: var(--admonition-font-size); +} diff --git a/docs/.sphinx/_static/favicon.png b/docs/.sphinx/_static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..7f175e4617695f752022d2671a0f97fa95bfd895 GIT binary patch literal 2258 zcmZ{lc|6mPAICp7%w3UVp(MxT*yhYVXRb*j4aLln#hep4H@+(7Oqin_QSKbckz;;B z%25eL%jE0IeMC8b`t$e4@9}=T-_OV6^?E%2e#hHbU*_dH!vz2Uueq5q=9m(HmxKNI z28D%f9fS3j0oninYE!uPUD^JYZe|!X0EEc^0QnyP*gcNO^8gTx0Dzx30MMcVfUtjF z%T=A@f*o&p*%&zbI|Xge(~lDf$sBD0nd27ak%L`%S$!-(M$L^4?66%KSzrCcXP`aN zLmELsaM@(>AiV4=0V@z1lT%|vHMT5vehCr;6=j57|8)XZZuje%@IL2(K-h2`rsQ^s z5=OohD2uC$d%*O$!!MhhGWKQew}Q&%7G5Tme63S)8~^^(_xVr!r$TGx`N1}oys`&wj8M&n(&&WLVzdBqI&`yc9DyG9PlV!A+XjRmLg z)kJk%>nP|5;j?C;{0cr4$L0(^JypO+g6hwiFE?yzI8(7U34%Fg6btl zqIgT8yJWU_O@DSh2^zqFh)GNem$Hg=`t+3|qO{4d9CqC0)!>JujE3KIz^)g3cyTtM z_&()A+hUYMni_G%Ss@n=!+OloskcL{ zt;rqW2=Ky5g#8E26M3LP&~=1xk`j3@?ySM*eW<-YTVbc981j?^{UT> zuYlV5!s-el%<$0Au~~S}D&@?(6XSIYR!4t)UIgI7j1X=#kbWyK8NCMW*R07t3Qy`+ z#Fu>hQ6DnJU=C~$b~VJ zxmpga*5D)d>hq?l_Z}GLVm>u1I=B5}dU~tnjdXk?n>4eWS%*8s?o^Z19Oa9$;G$B& zofYbUtl%VEBo#$pcc`Gu*>Sr)PDth`VKp_OG29zBPA zT0E!05#yVAzw)OtE24~I?c<{6^9-*fkd6`y6q>lF*YC=NeE>(A2@&7WS^ck{Z!zMJ zWXg<6PprCceEXImP6%#5dlXyRS>v9zplK$3gq)MU#YJERVNvmku4cV2ka13TTI{7Q zJSYWvP67DlR6HT`LqUdRAaUuY3tw7eowIZ>#&u(+dBO_*_{#~JFUOtg->hBWOZb;$lA^wCX|!_mx&ZR5u@9i)z+a{5I_Mb-Z)QDd$W3d|r4m5e6b4h3JvnV` zxo{{#gJY>S`*gLa5H1{e`l8?0yLJ6Qr^~Ri+E%Fk%^2@;OxpPZVJ^Q4)XM?rDVqky zE$zCbW%yFt)3eE8kMdhmn_5OxeFdlDQIQ3Qzzwi{phuK1kut_T6z=jRuog2n#^i>7 z>Qj-P7H|+jA?JD5Sep^MzMYdSLIx_P!l^kCoU1H(kASAFSp9pYYBseuuMOV5H*Ywa zOc_hi^TXxbC?VoFL*FCP?^#^njAaD2v`1FD&<+zlVpWuHeJL`w?@3G@;+)AtPXhK_ zKsz`*vo(Lm$p=)=q|)>3R;&+ZiEy3hovb7f&V7e-TXI?PUMAltzrR{fd%1#>L5nLE zWrcUI^)1UbXNVKJyoZ)umqwubo6iH>WOgD*F20iQH_mSEirHQ{``~fOsca)T%XH6@ zj_WI=itgsUw)HFCkx#?)k#^==guP)E-7ZAFP`N)|oxD6X<=J1vsg1gQsKP4Rc5 zJk~Agll(QcvhuPQuN;Tu=b+Rpd%>1Ty{X%=AruRl$`>o&)ga{@VKHoT4k2t!WQEMb~V8h@oyGcv`>& zIHqlWT^Cmo)jT@modF|kx8t{X)?oR3Qa~CGRTI}t&K7>cy>WNSLn1!pU`p&db?bK4 z;y|F^Ohg{>T4y-bhz}IJVIIPx%Jn3XBZ#`E0WUvwv+sR*FJ+4OuY=M?wCsNZwK|sK z2kOTA|9CkcMQ@B~+2fjmGZ!1ehY!C8UiQ3-M>kbQ6p1U5t;l?_D?|ANK3g{ZwBafu zq6W6m@o>=6RS`2TU)C#8(H%|MRGw5y3hbTjwbSZzJhMm=DkB1bNtS&t3`z)8X2lR=1 z!kf(_N%g+coYXpXvctxpgIuW#ix%CZ(xLK1YRJPYpV1ozvn@CW!phe*7kIQI>L`CI zZf=Kh2hKfH1j965G9;ttHi|9WxFZuj=^5G^u6U%46&cH^GZ>M^xt!A-T1WBgM=aTI zYBc4=>ygJ#Uz%isCAqtjJha>bJ&pk=Ba~GXkw`^l4LhW=mMT&UsVnulJ7yz}&>zxY`gG^IwYM0!IJ< literal 0 HcmV?d00001 diff --git a/docs/.sphinx/_static/furo_colors.css b/docs/.sphinx/_static/furo_colors.css new file mode 100644 index 000000000..3cdbb48fd --- /dev/null +++ b/docs/.sphinx/_static/furo_colors.css @@ -0,0 +1,89 @@ +body { + --color-code-background: #f8f8f8; + --color-code-foreground: black; + --code-font-size: 1rem; + --font-stack: Ubuntu variable, Ubuntu, -apple-system, Segoe UI, Roboto, Oxygen, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + --font-stack--monospace: Ubuntu Mono variable, Ubuntu Mono, Consolas, Monaco, Courier, monospace; + --color-foreground-primary: #111; + --color-foreground-secondary: var(--color-foreground-primary); + --color-foreground-muted: #333; + --color-background-secondary: #FFF; + --color-background-hover: #f2f2f2; + --color-brand-primary: #111; + --color-brand-content: #06C; + --color-api-background: #cdcdcd; + --color-inline-code-background: rgba(0,0,0,.03); + --color-sidebar-link-text: #111; + --color-sidebar-item-background--current: #ebebeb; + --color-sidebar-item-background--hover: #f2f2f2; + --toc-font-size: var(--font-size--small); + --color-admonition-title-background--note: var(--color-background-primary); + --color-admonition-title-background--tip: var(--color-background-primary); + --color-admonition-title-background--important: var(--color-background-primary); + --color-admonition-title-background--caution: var(--color-background-primary); + --color-admonition-title--note: #24598F; + --color-admonition-title--tip: #24598F; + --color-admonition-title--important: #C7162B; + --color-admonition-title--caution: #F99B11; + --color-highlighted-background: #EBEBEB; + --color-link-underline: var(--color-background-primary); + --color-link-underline--hover: var(--color-background-primary); + --color-version-popup: #772953; +} + +@media not print { + body[data-theme="dark"] { + --color-code-background: #202020; + --color-code-foreground: #d0d0d0; + --color-foreground-secondary: var(--color-foreground-primary); + --color-foreground-muted: #CDCDCD; + --color-background-secondary: var(--color-background-primary); + --color-background-hover: #666; + --color-brand-primary: #fff; + --color-brand-content: #06C; + --color-sidebar-link-text: #f7f7f7; + --color-sidebar-item-background--current: #666; + --color-sidebar-item-background--hover: #333; + --color-admonition-background: transparent; + --color-admonition-title-background--note: var(--color-background-primary); + --color-admonition-title-background--tip: var(--color-background-primary); + --color-admonition-title-background--important: var(--color-background-primary); + --color-admonition-title-background--caution: var(--color-background-primary); + --color-admonition-title--note: #24598F; + --color-admonition-title--tip: #24598F; + --color-admonition-title--important: #C7162B; + --color-admonition-title--caution: #F99B11; + --color-highlighted-background: #666; + --color-link-underline: var(--color-background-primary); + --color-link-underline--hover: var(--color-background-primary); + --color-version-popup: #F29879; + } + @media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --color-code-background: #202020; + --color-code-foreground: #d0d0d0; + --color-foreground-secondary: var(--color-foreground-primary); + --color-foreground-muted: #CDCDCD; + --color-background-secondary: var(--color-background-primary); + --color-background-hover: #666; + --color-brand-primary: #fff; + --color-brand-content: #06C; + --color-sidebar-link-text: #f7f7f7; + --color-sidebar-item-background--current: #666; + --color-sidebar-item-background--hover: #333; + --color-admonition-background: transparent; + --color-admonition-title-background--note: var(--color-background-primary); + --color-admonition-title-background--tip: var(--color-background-primary); + --color-admonition-title-background--important: var(--color-background-primary); + --color-admonition-title-background--caution: var(--color-background-primary); + --color-admonition-title--note: #24598F; + --color-admonition-title--tip: #24598F; + --color-admonition-title--important: #C7162B; + --color-admonition-title--caution: #F99B11; + --color-highlighted-background: #666; + --color-link-underline: var(--color-background-primary); + --color-link-underline--hover: var(--color-background-primary); + --color-version-popup: #F29879; + } + } +} diff --git a/docs/.sphinx/_static/github_issue_links.css b/docs/.sphinx/_static/github_issue_links.css new file mode 100644 index 000000000..db166ed95 --- /dev/null +++ b/docs/.sphinx/_static/github_issue_links.css @@ -0,0 +1,24 @@ +.github-issue-link-container { + padding-right: 0.5rem; +} +.github-issue-link { + font-size: var(--font-size--small); + font-weight: bold; + background-color: #D6410D; + padding: 13px 23px; + text-decoration: none; +} +.github-issue-link:link { + color: #FFFFFF; +} +.github-issue-link:visited { + color: #FFFFFF +} +.muted-link.github-issue-link:hover { + color: #FFFFFF; + text-decoration: underline; +} +.github-issue-link:active { + color: #FFFFFF; + text-decoration: underline; +} diff --git a/docs/.sphinx/_static/github_issue_links.js b/docs/.sphinx/_static/github_issue_links.js new file mode 100644 index 000000000..f0706038b --- /dev/null +++ b/docs/.sphinx/_static/github_issue_links.js @@ -0,0 +1,34 @@ +// if we already have an onload function, save that one +var prev_handler = window.onload; + +window.onload = function() { + // call the previous onload function + if (prev_handler) { + prev_handler(); + } + + const link = document.createElement("a"); + link.classList.add("muted-link"); + link.classList.add("github-issue-link"); + link.text = "Give feedback"; + link.href = ( + github_url + + "/issues/new?" + + "title=docs%3A+TYPE+YOUR+QUESTION+HERE" + + "&body=*Please describe the question or issue you're facing with " + + `"${document.title}"` + + ".*" + + "%0A%0A%0A%0A%0A" + + "---" + + "%0A" + + `*Reported+from%3A+${location.href}*` + ); + link.target = "_blank"; + + const div = document.createElement("div"); + div.classList.add("github-issue-link-container"); + div.append(link) + + const container = document.querySelector(".article-container > .content-icon-container"); + container.prepend(div); +}; diff --git a/docs/.sphinx/_static/header-nav.js b/docs/.sphinx/_static/header-nav.js new file mode 100644 index 000000000..3608576e0 --- /dev/null +++ b/docs/.sphinx/_static/header-nav.js @@ -0,0 +1,10 @@ +$(document).ready(function() { + $(document).on("click", function () { + $(".more-links-dropdown").hide(); + }); + + $('.nav-more-links').click(function(event) { + $('.more-links-dropdown').toggle(); + event.stopPropagation(); + }); +}) diff --git a/docs/.sphinx/_static/header.css b/docs/.sphinx/_static/header.css new file mode 100644 index 000000000..0b9440903 --- /dev/null +++ b/docs/.sphinx/_static/header.css @@ -0,0 +1,167 @@ +.p-navigation { + border-bottom: 1px solid var(--color-sidebar-background-border); +} + +.p-navigation__nav { + background: #333333; + display: flex; +} + +.p-logo { + display: flex !important; + padding-top: 0 !important; + text-decoration: none; +} + +.p-logo-image { + height: 44px; + padding-right: 10px; +} + +.p-logo-text { + margin-top: 18px; + color: white; + text-decoration: none; +} + +ul.p-navigation__links { + display: flex; + list-style: none; + margin-left: 0; + margin-top: auto; + margin-bottom: auto; + max-width: 800px; + width: 100%; +} + +ul.p-navigation__links li { + margin: 0 auto; + text-align: center; + width: 100%; +} + +ul.p-navigation__links li a { + background-color: rgba(0, 0, 0, 0); + border: none; + border-radius: 0; + color: var(--color-sidebar-link-text); + display: block; + font-weight: 400; + line-height: 1.5rem; + margin: 0; + overflow: hidden; + padding: 1rem 0; + position: relative; + text-align: left; + text-overflow: ellipsis; + transition-duration: .1s; + transition-property: background-color, color, opacity; + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + white-space: nowrap; + width: 100%; +} + +ul.p-navigation__links .p-navigation__link { + color: #ffffff; + font-weight: 300; + text-align: center; + text-decoration: none; +} + +ul.p-navigation__links .p-navigation__link:hover { + background-color: #2b2b2b; +} + +ul.p-navigation__links .p-dropdown__link:hover { + background-color: var(--color-sidebar-item-background--hover); +} + +ul.p-navigation__links .p-navigation__sub-link { + background: var(--color-background-primary); + padding: .5rem 0 .5rem .5rem; + font-weight: 300; +} + +ul.p-navigation__links .more-links-dropdown li a { + border-left: 1px solid var(--color-sidebar-background-border); + border-right: 1px solid var(--color-sidebar-background-border); +} + +ul.p-navigation__links .more-links-dropdown li:first-child a { + border-top: 1px solid var(--color-sidebar-background-border); +} + +ul.p-navigation__links .more-links-dropdown li:last-child a { + border-bottom: 1px solid var(--color-sidebar-background-border); +} + +ul.p-navigation__links .p-navigation__logo { + padding: 0.5rem; +} + +ul.p-navigation__links .p-navigation__logo img { + width: 40px; +} + +ul.more-links-dropdown { + display: none; + overflow-x: visible; + height: 0; + z-index: 55; + padding: 0; + position: relative; + list-style: none; + margin-bottom: 0; + margin-top: 0; +} + +.nav-more-links::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath fill='%23111' d='M8.187 11.748l6.187-6.187-1.06-1.061-5.127 5.127L3.061 4.5 2 5.561z'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + content: ""; + display: block; + filter: invert(100%); + height: 1rem; + pointer-events: none; + position: absolute; + right: 1rem; + text-indent: calc(100% + 10rem); + top: calc(1rem + 0.25rem); + width: 1rem; +} + +.nav-ubuntu-com { + display: none; +} + +@media only screen and (min-width: 480px) { + ul.p-navigation__links li { + width: 100%; + } + + .nav-ubuntu-com { + display: inherit; + } +} + +@media only screen and (max-width: 800px) { + .nav-more-links { + margin-left: auto !important; + padding-right: 2rem !important; + width: 8rem !important; + } +} + +@media only screen and (min-width: 800px) { + ul.p-navigation__links li { + width: 100% !important; + } +} + +@media only screen and (min-width: 1310px) { + ul.p-navigation__links { + margin-left: calc(50% - 41em); + } +} diff --git a/docs/.sphinx/_static/tag.png b/docs/.sphinx/_static/tag.png new file mode 100644 index 0000000000000000000000000000000000000000..f6f6e5aa4bc55fb934c973726b10a0efc92445a8 GIT binary patch literal 6781 zcmeHM`8!nY{~uI}N+nyC>7i13l#&!Nin5iZQ9cYt$P{JBGS-zSs5rVXiZ0&Yb()@ArMbU(5T1o0;hE6FMRUfk5^d z8tB~yM;-*i_iFbp@a^7s1sxprxEok`LLkD2wm*E|$FXb(e zcDjDjtItlI?cv`u(#)Mv!p3bxaeJ{5DVt<|H&pX0qL~w_CvDHpD&ck?iIZPcBT?i~ z`dzvcy+=G!xOTVZJU^vvN&KZl~&2lD)w9M=o>#X+- zxpXm*gx`F(*3bZb5wCV2?gE)uUB6RrYJa=wvBNaQLlJb*J#CEe=MHSWYv-`??I*9lmCDD|I_lnyB!|y?3ZHD_Ef63l=8cwA)Vp|IR|c{4jAP8;2jH&85k7hjk{oF zp{wYU%9>}Zb3z+;Ek~=eCul5>lUAMq3I^i1E5U2HBf5FHP_8eg9}hn*R>Io4>_ffM zu{1xk-|hWwvLxYXu#?b?d`SpzJdXHoYx&J)>?df2aNg7xWgO35BV;Yaare3nnpqlC zFikGua4Ltb?7Y~eS`qYs@Uw?>_0NauoZpE&7WM->mYZgz?l4aeN=%Yd(60FnsS?M`!f)%+-c1X=rIQN_4DHQVF8quI1NgYvtK0A9Ma566h z;axGVe%34*ulKn2+t9M>fp+vESNFdMDAd)yx`XAn4@xHppWj@Xjn2I(0w6b$Snf=V_se0uQdQdd-sd zRgX!z4*r-XhT7qqsBd5bW@sG6^o{cCF>5%PS@RrC56yZRP2z`OAo?oUTVN%;?4+-u zsAiPdm5verK+*50!W7FcmBUQb2yU!A zC|GPc$vb7&iK`v82c_{X#niyx8#z@m^vdw1KEwn?W@_!a!^;@bsnH{9*R;q7Z=zaZ zyIUDz!a1r{?rdM|ccr@(luCT`yJSz>WaX*hr?`U6rX-szuuk z*NAUici1fwb81Z9n@xA~+SnH^$C+WVg}{W|{g&REPYQhIINSKT_ms~Zcy~Z5-913m zri~$c*dWK}r@lB0vHu@m{Xo^p-|onflxDtOm=>$vAwI*yY+B``ycxW zfrpYf(ZD!K2byP<`5?-?oTW&p5yi0$6-DcbDhu?ay-R}2&7UwE^L_b?(XuadS*PL z#m;9Z6zd;pbcXd}_;)Out_O!Fy^W&dn-f<~SF0^F_z~|svi=d-`m~OM=(CIB?WlP{ zU`@9*xu{(!s5JSxpdH1NtO-MQ7T!bo9bA4RA$6rZiVl76$k6OIHMjQv(A)PA?VYVW zzw4EC6z@P2$5fS(U?nhlh96*qD^3G8nq`oFZ7YC9&a}$7K3B!t?S)ex+(P zQXSPEvrD1)0Ou}#Jw68Ek}Y2$N9~wSJLuS4>3e@kvo;~wH++~;NPaTzZREw^o&pZIx84pw@YmBA_w&qV${T&k799(ksn)kD>jFu3`qMlEP-eN~b zmv6&a9P=C=0H!(>f59;&54vFdDVr*$H-)gglqxZtd_-kwlzXAJ7@rl7@C;B*amIMd z7ax=$NDBmJql6jjsb|Xxq2ws%q}8D&;wqee_G)+pHTt!a@EUyBT1EBMjfKJ@`^{cq zfTT&*`NIQ7t#%40u`+CIl@`}>8VWyH`x+yCY6f; zgGSfuQkmEE7&@HyPHS;r85ftb31(I{&jX?2(bp0^JQJ)$lfLK42-q`xo z#GDYw7bZZ}7lS5SH<3zt7p`zD|<6hhpYaQawHy zx$R3;Rj3fO<9YX5B-Set>Y)Ut*Zin5vhrL}Zt5Z5DuujDT49P3$ zj)(qYN(3lXFEnw+Jn5}XJ*8X@PtG7mX5{iCt%kGOfyVc+hhEzZy`DK0<8qvBui?4S zVjo8$thQbe{znB>sy9CdfE{cKpEW=om@6S{Er2{8o>mlloK`)DzFD)$)%!hit-sPL zC{FSWNn4YSX%c{~xq>YVZUbQZ4l1MRsc!~0ucJ%GErhe&{LTU&Z4=vnaDU``hO0tC zEl6VXRIqJ3E(uKFrxO%FIgGm1lVG}ZSvi?_R6{%0%UdSb`KpVTcg~Xyv5U)57dSyS z?F{K(Ak|XojB%636)nQ)YxNueRF^gQ9;gvw(tcgn&(Rh>2CuqOJFr4PuPj4om8W0b z{7XY4x_(ehTYi*({(C_wIxiok0Wh3Cklf5#FmAhQd^ajq%9tn`m{|NZ)XO`gE=(@11(tNDS>4E;@KWk}D z7HqEX&!hgY1JJlSmc63;n1G^F5y)qDfAkA~DFRJ{6}HU^-)Cb1GkH9mu7%y4)p3Sb z4;$po)STO7N56z!)P6C{_~g1A`aj3dy5wg| z{iL%h1oo8f(YH?m;9vQa1if!vUMFAV-o;nmZGtY}00E5g`8E{{idv<>}Rt=#|i{*%ZH@8_s5t7TT{IoAU`ibWP^B z7^C1Rv5B23V@uNB^i=n`;yWNpe)EuLLLyN|=(;(y!3yCn6OP{~8m=iZ>~1s=dYsUC zxxj>Tt7?gSf}0?2@GT8C5%f7p`fctf_tjhN)T0RkLLxC9f2d~betd&hmZTYpbo{AT zH_O*cY;(bs9Mk7AVWZszm$xu0UvU>jb9FSjgmJs_Ez-8;u{!c@Dv=O37a z=}D%IVilCo9&n@9i_o5xkZ+A9@%GSQapY%{-h{Uny|ptlaXeoQUfTuZ87-}}n}ZJt zM1sgtdodk(v($G=ya4@464)oEO zsJdPbLyY)-$gRL`|6jM8))^Qi%yQ$5cWu7Sj%QyV7IldDDx?^>MUz=!YopRRs6Kh@ z>-p@;ND1!VW0B%?%O_S@g556JncuVV23mJK7xPoZ$M#saia;n--2BFg3x#EW3`U#| z49FEYClRvvf(!QP{rQ}Hi{4`CdRnGN8fxUu^;8C*z3XJUhXSvSX;`TqER!); zACQLTxrpJ^c;aoL0dD9UEk-2qGVbJUnpe7)u2|tu!KVOS7XF5L2dEM)It%GuR9%Z+ z#r(BJFQx^#NcQ0BoScUg@kx#FGY@7`<-rC{Jg-Zdsi|i`Hq`u;t@Q5{N$L z7c&aOm9lfu2QtXk0NC~*NJ)Pq-&)OR^I=n2G&FA+axrIDnWRA8)X?X1Y5?gB2IG*M zRIx%@CBWg5bw-10C7&@#eET9iDE9XHO&ASh@bLG+izfs}wG@oA&!a9yO-P)~WbJun=+$Ac4`UMz>dQMs+ zv+3M(|02!R>i^oUsJai0_^Jofa*G(>}kkT_TclgzO62VchwZN`(qEOFCToXq@L>T@W6H7yWd!?=}9ZA$LL$}5KYvtBD_T6GpmdED(} z7=Bp!k^F@;(VgN^0nTJ_SKfPlA*Mllst~OV!*^d-o_`?~O_R%UUr5ai!^6M?5gVkt zw5iX7wS{Sl<`#16e4ZvuzII#=Kvp2&zV4B$zp-vk{Q$={wrnyHlYnmK7CV?tB_WE9 z1m8^vxt_3I}3 zDRGNxO(Bp${DhpIHRX)VyNI+%#UH#6+U8j}9zifZKMcB2rJ@myBrtC`B_+7@^*zkS z12GutA-K!5jmLd)y|o?ndc0-dx{ba{+N45D*q$8KE{Vwti;2*c;ipvMYUb()HdBVJ zN(5OKT7!3K6H<`st51LAGx*j&{@S9AcL~OP_0#N*?DB!+?B5YER|d`NfXd0hH@@$J zJQuuCvbj|q7Z6a%lt1Tn48C5HBudNxtH*GE@TvXO&}nK3-Ks;o6pZP!DnV*PQqE+Q z{n-r^!|ko0Oq%Drfzqr0IxK1YgJ0iBML_+HlS#6vkJ^6AKFyyLc)Hy2-l=yn+CAm$ zp_UF2J0-0xf%SuSFB=mm*%xJBx0}zfKIIjv9fsonod}CEN zbSSN>c4eoo5z2YzQ=Ls@)?KAcHjY>Lhn3t4H9e}KVM~}_RmTY;^}qI!_OEYbt&PqQ zYC|bezz4JO>^sK7UP)XIzGM@|8~H=7T|jF2O$m--{s=w=RkE@LUB^r*w1_@tY6{Mw z_(A>OTHXQdMU8X%g>n-ls3oLZ(9poWj7?MX_6 z>3OCIs}tO|etk4L6S;_E>8Bz~o&V_I+xqDOjYG7JPZhLSOqT0(c%G~du#IO(XUf+f z;8rWf9&9aBm#${o65s`X+FX!sN=2*XQNQaw`!h<>U;9|UOdkANCiG=slJNe{fgNjf z0i8*FN^OyA*mGH(pcsMr=E@!MmhQhdbSX&k*Q=Qzp|f#W+DDIZUATpd^EG#U{RDr+ zD!P}1SB>T?c#8omML}YQj!tZBQd9g*dH<3BDL4nKGIA??OeKBPd>UB^b@7PCC4u7F zJ!13R6Yc%0l^O^9FJ(!tJTjTVcOeLoYXvA5NTY0&o4}1Q#grPwr6lJih>V19p~a*5 zY{%M{5rnrCjlxyH*fp%y4RZr^uJ1J_>yXJB@ZJ+;>fs$8#i0@sOH%6Q`U-k&A_Jy8 zirUt;Gq1X|e)a}I=+RsS&|FVp>7UotUgXk7t*~?90b3mhC18*`*0k}j1gwnWD${bd z#&zP-(>W{jozhy`m+6V(si7-sHMqpD+n7wAXrDK*Z3FxCh_{seoH^BDa~6pU@|6u` z8k$BgL64uuW@vw*EY0I0!S!Z^rUrwaJlR1*BCm5|jkmlMC8;KeQ*CV*87Ss~?AL5? zbhXHIddQnuiz<`AkJq&3lD@d*n#I=3CQAr1Vh+i|Acvt;*Le;v3$y?nXr&-_JtkYA zccs}Jnnwtje2pkFIS9o8gzSAAS5e2oq{Ix|u}NX>-(Hifex=`4x-Lm?xPO}*fWlTN zkPK-IBxY`*HaJ#}{YG4qPg6K0IU|J5+fSofcHZCiBayO@6^hA^pNlVwWJ^8`M%O*d z|)w(D+% z^3HBIEI^-P5iL6R5{Dwt$LcsHpXFwvVoY59dZp*8W6Vh2kka9xHU3|NVja`vu%1W( zC)v(K)Ct-HF&YfmGkK-zM;s5EeHe(itG@f>G&ygYY;I?J6;Q(QH^0taPKyAZ`G~-` zAVGV2NA2WtE#HsInQaR_U=$i68!X|Rb{w^m!rMEvzp+;^*!rM>-BtZLrR@#`>-Ct3 z9JVM;5~r(F{r5#w&p4lq^UMg}S#1i@_&pW)d7$usn{;2dg(&(iPH3sc(kT|n_|_pB z3-CW8QOhUs(dMx;HID3C+t#{$AY*=6;6e*gp=c0ax9*%u=3XguVBad3`T|C21lH6I z9ii+~#Qeytys`AdqGg-18{ zOM2XrGO#OIfB8`jpY|JA?SrCT!%Ym?+r5M~V6PR3{0mnqTzgR{jbdUWMW}uGGq`UX z9ShNWMuUpS|F{D$J|WFTnFZ5Nn*nH6frSH5d*FA<9;00g{<}zWHi29FPyM#?O>JX{ zjUsHDz_^E}bIUZmD>U)8k8AB0G`!1i_YFU`jHXv^uL-t#{q0@N;FXN}{7=Tlv1KDZ zn!W=tDH>WK&1c)+A+orjEl{x+QJ)i!pdq4i?b&BO`|uNp+z?ks{s#BMGmncTKC`x} zhXmff7&L0DDDHZ6q>YUCCFU#iH^ z_*Yc`d&lbc%C7{1XOZt5_$?M%H{kOu;d|-MN6N|G;Xj|bMj_$}1p}72}hHU-crKi=yrrlDevrmM=1JS;nSRzYBoyHf*ULzZlD?P{E4sj6b!b zU&`x)>h2uXn1#I)y@7oL2y}zNURzbu#PqZanJTdR?1Yz(+ZpwZfOS?L3I#iHU|ip3 zpQvpWm$NISK~YXB{j-*ShA3D_Ak;2bp`f(Q^SCQ~JjFflC_F_onCm6X6t|)L1oC5U zFKAH#viJH>R8ck_{W*P%7R1guhkarPkY2t;w5y#T%-jLAE13~)u9C2P(SIA00Af+R zZWJh#lG3`b9o}gz3_~sCF&`D3k+_>`URGxRxWa#0z#Eo-$?Jm=U+}(NYBhi7TC7~; uQGMpg^`IwacBQr9q>cZpFE{3ReE)IZw-U<<8UpW=AcogX^op+8Kl>kb6xxdb literal 0 HcmV?d00001 diff --git a/docs/.sphinx/_templates/404.html b/docs/.sphinx/_templates/404.html new file mode 100644 index 000000000..4cb2d50d3 --- /dev/null +++ b/docs/.sphinx/_templates/404.html @@ -0,0 +1,17 @@ +{% extends "page.html" %} + +{% block content -%} +
+

Page not found

+
+
+
+ {{ body }} +
+
+ Penguin with a question mark +
+
+
+
+{%- endblock content %} diff --git a/docs/.sphinx/_templates/base.html b/docs/.sphinx/_templates/base.html new file mode 100644 index 000000000..33081547c --- /dev/null +++ b/docs/.sphinx/_templates/base.html @@ -0,0 +1,12 @@ +{% extends "furo/base.html" %} + +{% block theme_scripts %} + +{% endblock theme_scripts %} + +{# ru-fu: don't include the color variables from the conf.py file, but use a + separate CSS file to save space #} +{% block theme_styles %} +{% endblock theme_styles %} diff --git a/docs/.sphinx/_templates/footer.html b/docs/.sphinx/_templates/footer.html new file mode 100644 index 000000000..9b676c9c1 --- /dev/null +++ b/docs/.sphinx/_templates/footer.html @@ -0,0 +1,111 @@ +{# ru-fu: copied from Furo, with modifications as stated below. Modifications are marked 'mod:'. #} + +
+
+
+ {%- if show_copyright %} + + {%- endif %} + + {# mod: removed "Made with" #} + + {%- if last_updated -%} +
+ {% trans last_updated=last_updated|e -%} + Last updated on {{ last_updated }} + {%- endtrans -%} +
+ {%- endif %} + + {%- if show_source and has_source and sourcename %} + + {%- endif %} +
+
+ + {# mod: replaced RTD icons with our links #} + + {% if discourse %} + + {% endif %} + + {% if mattermost %} + + {% endif %} + + {% if matrix %} + + {% endif %} + + {% if github_url and github_version and github_folder %} + + {% if github_issues %} + + {% endif %} + + + {% endif %} + + +
+
+ diff --git a/docs/.sphinx/_templates/header.html b/docs/.sphinx/_templates/header.html new file mode 100644 index 000000000..1a128b6f8 --- /dev/null +++ b/docs/.sphinx/_templates/header.html @@ -0,0 +1,36 @@ + diff --git a/docs/.sphinx/_templates/page.html b/docs/.sphinx/_templates/page.html new file mode 100644 index 000000000..bda306109 --- /dev/null +++ b/docs/.sphinx/_templates/page.html @@ -0,0 +1,49 @@ +{% extends "furo/page.html" %} + +{% block footer %} + {% include "footer.html" %} +{% endblock footer %} + +{% block body -%} + {% include "header.html" %} + {{ super() }} +{%- endblock body %} + +{% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} + {% set furo_hide_toc_orig = furo_hide_toc %} + {% set furo_hide_toc=false %} +{% endif %} + +{% block right_sidebar %} +
+ {% if not furo_hide_toc_orig %} +
+ + {{ _("Contents") }} + +
+
+
+ {{ toc }} +
+
+ {% endif %} + {% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} + + + {% endif %} +
+{% endblock right_sidebar %} diff --git a/docs/.sphinx/_templates/sidebar/search.html b/docs/.sphinx/_templates/sidebar/search.html new file mode 100644 index 000000000..644a5ef6a --- /dev/null +++ b/docs/.sphinx/_templates/sidebar/search.html @@ -0,0 +1,7 @@ + + diff --git a/docs/.sphinx/build_requirements.py b/docs/.sphinx/build_requirements.py new file mode 100644 index 000000000..084d6ea99 --- /dev/null +++ b/docs/.sphinx/build_requirements.py @@ -0,0 +1,124 @@ +import sys + +sys.path.append('./') +from custom_conf import * + +# The file contains helper functions and the mechanism to build the +# .sphinx/requirements.txt file that is needed to set up the virtual +# environment. + +# You should not do any modifications to this file. Put your custom +# requirements into the custom_required_modules array in the custom_conf.py +# file. If you need to change this file, contribute the changes upstream. + +legacyCanonicalSphinxExtensionNames = [ + "youtube-links", + "related-links", + "custom-rst-roles", + "terminal-output" + ] + +def IsAnyCanonicalSphinxExtensionUsed(): + for extension in custom_extensions: + if (extension.startswith("canonical.") or + extension in legacyCanonicalSphinxExtensionNames): + return True + + return False + +def IsNotFoundExtensionUsed(): + return "notfound.extension" in custom_extensions + +def IsSphinxTabsUsed(): + for extension in custom_extensions: + if extension.startswith("sphinx_tabs."): + return True + + return False + +def AreRedirectsDefined(): + return ("sphinx_reredirects" in custom_extensions) or ( + ("redirects" in globals()) and \ + (redirects is not None) and \ + (len(redirects) > 0)) + +def IsOpenGraphConfigured(): + if "sphinxext.opengraph" in custom_extensions: + return True + + for global_variable_name in list(globals()): + if global_variable_name.startswith("ogp_"): + return True + + return False + +def IsMyStParserUsed(): + return ("myst_parser" in custom_extensions) or \ + ("custom_myst_extensions" in globals()) + +def DeduplicateExtensions(extensionNames: [str]): + extensionNames = dict.fromkeys(extensionNames) + resultList = [] + encounteredCanonicalExtensions = [] + + for extensionName in extensionNames: + if extensionName in legacyCanonicalSphinxExtensionNames: + extensionName = "canonical." + extensionName + + if extensionName.startswith("canonical."): + if extensionName not in encounteredCanonicalExtensions: + encounteredCanonicalExtensions.append(extensionName) + resultList.append(extensionName) + else: + resultList.append(extensionName) + + return resultList + +if __name__ == "__main__": + requirements = [ + "furo", + "pyspelling", + "sphinx", + "sphinx-autobuild", + "sphinx-copybutton", + "sphinx-design", + "sphinxcontrib-jquery" + ] + + requirements.extend(custom_required_modules) + + if IsAnyCanonicalSphinxExtensionUsed(): + requirements.append("canonical-sphinx-extensions") + + if IsNotFoundExtensionUsed(): + requirements.append("sphinx-notfound-page") + + if IsSphinxTabsUsed(): + requirements.append("sphinx-tabs") + + if AreRedirectsDefined(): + requirements.append("sphinx-reredirects") + + if IsOpenGraphConfigured(): + requirements.append("sphinxext-opengraph") + + if IsMyStParserUsed(): + requirements.append("myst-parser") + requirements.append("linkify-it-py") + + # removes duplicate entries + requirements = list(dict.fromkeys(requirements)) + requirements.sort() + + with open(".sphinx/requirements.txt", 'w') as requirements_file: + requirements_file.write( + "# DO NOT MODIFY THIS FILE DIRECTLY!\n" + "#\n" + "# This file is generated automatically.\n" + "# Add custom requirements to the custom_required_modules\n" + "# array in the custom_conf.py file and run:\n" + "# make clean && make install\n") + + for requirement in requirements: + requirements_file.write(requirement) + requirements_file.write('\n') diff --git a/docs/.sphinx/pa11y.json b/docs/.sphinx/pa11y.json new file mode 100644 index 000000000..8df0cb9cb --- /dev/null +++ b/docs/.sphinx/pa11y.json @@ -0,0 +1,9 @@ +{ + "chromeLaunchConfig": { + "args": [ + "--no-sandbox" + ] + }, + "reporter": "cli", + "standard": "WCAG2AA" +} \ No newline at end of file diff --git a/docs/.sphinx/spellingcheck.yaml b/docs/.sphinx/spellingcheck.yaml new file mode 100644 index 000000000..fc9d3c503 --- /dev/null +++ b/docs/.sphinx/spellingcheck.yaml @@ -0,0 +1,29 @@ +matrix: +- name: rST files + aspell: + lang: en + d: en_GB + dictionary: + wordlists: + - .wordlist.txt + - .custom_wordlist.txt + output: .sphinx/.wordlist.dic + sources: + - _build/**/*.html + pipeline: + - pyspelling.filters.html: + comments: false + attributes: + - title + - alt + ignores: + - code + - pre + - spellexception + - link + - title + - div.relatedlinks + - strong.command + - div.visually-hidden + - img + - a.p-navigation__link diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 000000000..cad94b74c --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,189 @@ +/** Fix the font weight (300 for normal, 400 for slightly bold) **/ + +div.page, h1, h2, h3, h4, h5, h6, .sidebar-tree .current-page>.reference, button, input, optgroup, select, textarea, th.head { + font-weight: 300 +} + +.toc-tree li.scroll-current>.reference, dl.glossary dt, dl.simple dt, dl:not([class]) dt { + font-weight: 400; +} + +/** Table styling **/ + +th.head { + text-transform: uppercase; + font-size: var(--font-size--small); +} + +table.docutils { + border: 0; + box-shadow: none; + width:100%; +} + +table.docutils td, table.docutils th, table.docutils td:last-child, table.docutils th:last-child, table.docutils td:first-child, table.docutils th:first-child { + border-right: none; + border-left: none; +} + +/* Allow to centre text horizontally in table data cells */ +table.align-center { + text-align: center !important; +} + +/** No rounded corners **/ + +.admonition, code.literal, .sphinx-tabs-tab, .sphinx-tabs-panel, .highlight { + border-radius: 0; +} + +/** Admonition styling **/ + +.admonition { + border-top: 1px solid #d9d9d9; + border-right: 1px solid #d9d9d9; + border-bottom: 1px solid #d9d9d9; +} + +/** Color for the "copy link" symbol next to headings **/ + +a.headerlink { + color: var(--color-brand-primary); +} + +/** Line to the left of the current navigation entry **/ + +.sidebar-tree li.current-page { + border-left: 2px solid var(--color-brand-primary); +} + +/** Some tweaks for issue #16 **/ + +[role="tablist"] { + border-bottom: 1px solid var(--color-sidebar-item-background--hover); +} + +.sphinx-tabs-tab[aria-selected="true"] { + border: 0; + border-bottom: 2px solid var(--color-brand-primary); + background-color: var(--color-sidebar-item-background--current); + font-weight:300; +} + +.sphinx-tabs-tab{ + color: var(--color-brand-primary); + font-weight:300; +} + +.sphinx-tabs-panel { + border: 0; + border-bottom: 1px solid var(--color-sidebar-item-background--hover); + background: var(--color-background-primary); +} + +button.sphinx-tabs-tab:hover { + background-color: var(--color-sidebar-item-background--hover); +} + +/** Custom classes to fix scrolling in tables by decreasing the + font size or breaking certain columns. + Specify the classes in the Markdown file with, for example: + ```{rst-class} break-col-4 min-width-4-8 + ``` +**/ + +table.dec-font-size { + font-size: smaller; +} +table.break-col-1 td.text-left:first-child { + word-break: break-word; +} +table.break-col-4 td.text-left:nth-child(4) { + word-break: break-word; +} +table.min-width-1-15 td.text-left:first-child { + min-width: 15em; +} +table.min-width-4-8 td.text-left:nth-child(4) { + min-width: 8em; +} + +/** Underline for abbreviations **/ + +abbr[title] { + text-decoration: underline solid #cdcdcd; +} + +/** Use the same style for right-details as for left-details **/ +.bottom-of-page .right-details { + font-size: var(--font-size--small); + display: block; +} + +/** Version switcher */ +button.version_select { + color: var(--color-foreground-primary); + background-color: var(--color-toc-background); + padding: 5px 10px; + border: none; +} + +.version_select:hover, .version_select:focus { + background-color: var(--color-sidebar-item-background--hover); +} + +.version_dropdown { + position: relative; + display: inline-block; + text-align: right; + font-size: var(--sidebar-item-font-size); +} + +.available_versions { + display: none; + position: absolute; + right: 0px; + background-color: var(--color-toc-background); + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 11; +} + +.available_versions a { + color: var(--color-foreground-primary); + padding: 12px 16px; + text-decoration: none; + display: block; +} + +.available_versions a:hover {background-color: var(--color-sidebar-item-background--current)} + +.show {display:block;} + +/** Fix for nested numbered list - the nested list is lettered **/ +ol.arabic ol.arabic { + list-style: lower-alpha; +} + +/** Make expandable sections look like links **/ +details summary { + color: var(--color-link); +} + +/** Fix the styling of the version box for readthedocs **/ + +#furo-readthedocs-versions .rst-versions, #furo-readthedocs-versions .rst-current-version, #furo-readthedocs-versions:focus-within .rst-current-version, #furo-readthedocs-versions:hover .rst-current-version { + background: var(--color-sidebar-item-background--hover); +} + +.rst-versions .rst-other-versions dd a { + color: var(--color-link); +} + +#furo-readthedocs-versions:focus-within .rst-current-version .fa-book, #furo-readthedocs-versions:hover .rst-current-version .fa-book, .rst-versions .rst-other-versions { + color: var(--color-sidebar-link-text); +} + +.rst-versions .rst-current-version { + color: var(--color-version-popup); + font-weight: bolder; +} diff --git a/docs/_static/favicon.png b/docs/_static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7109908f2af5c9bb0ad130c13ac143929643aa2 GIT binary patch literal 57806 zcmb^2V~`|48z}0rZF|SI%^lC|cy`cZ8#}gb+qP|c$F^Pw*${@c)fqWNZ=@@?x<{I?C7FzZv-S{Xag`J5;wCqakwO3Vh&A8C&jG~o5zucr=zbHMayRt2|QF9{ZA_Jq7oH~!0L9T^X zhoTcE=>|#y68 zImm5vzrRI)nev~gmH zcV2B<`UIAif-PEFc(s4yS8dxkv~=+HaNt^Id)*UvvS00;4ST;GU=kjmnRt7D)f0Yg zW4Y}haY;L zOTYO~3&cL%%X)|lyeW@&7k~70?$uiH2VQ-Y93LUxcAWqk@{bU((G!W?IYpub4 zt7R!-@8_WFsx3NuBKtKLlw`Pp#t#?b~d6oreFi1Kq#YA>5u3=e#BNKHMEgB{AEh#x_b;zEdAHfyWj z(>3(t-k3eNUO$8~VQ>(;+V0Kn@E+#r4_DmTdAug*h%-~HYnms}6<&bFMqdP7(MD%PRI2Q|Obc&@O2u#1yt_00+ci zzeM^qSq>0HwR;o}f#TV(JJb#70vJ~Jj^XU#l}LG_?zP@?V-+qm`EVx2Tf>`{%dB7_ z@tte_ip5Vhm)zmOf)Dy9LWr2U(_WLcR2N^h;7Ub!hda$%is92AZ$E!?t2#09`Vorv zRj%#}ZkcFjt+p3tm_bw^EOC?nb<~8{hIx*~W`2!5?UkruqyiSdHlLl*HYF&9xFCls za}Kk$g&r5uoDRqTbM=>7(hV@K=LyW>nsM)QR#{>vDhTesY!8pLwoZcExSbsmw~ zB&8Y>bWXqoG9TdE%uHJr6pzu)5F;NuBTp~T8H_9%6iHWhIOyM)179Jo!3t0M%Q^ch zwupj*on*+_SIq~Ku^qBLzoz#nlLU{>>w4E7Kk%a z4v2=c9|=w4Lup$dMLfZ6^U4#2mT1FvQp8#B)a+_>=x=9^i$OX$Z_k^lZP^9YgAw8o znJW75bkPm4;KJa!&TWoaLg|GR(AFl?N zeOC=MCs6QJ`Aah<i%$@$$VY5 zLUd^6`aFJ`>I`i>4S**c`E^FV>9V4pohS_X&r3bD+RiW;S1y5(;DOu)HAH1RpfthzOWMQXu`9yD*f02 z0a{+9;28Eq$n&_IO%Nm2sO5}3zeU3Zl1W6I*XO(b)E*+}v?b;9%hy2= zC0`sxfJRGbQ=q>nOf83c8Q93fxm~QN3(5ol zgk|`h_uxm@ff^wquky=O@WS}OyQ`LSWYl;4-aqCBA>6W_GW*3FLTraXWNb-L=nzZ- z<>sRY6NDAS7vin0`P(}M-@>aW z@#?&t74)8zdtEk0!~A@>O9(lJuHs(h7|ijD%V7Mq&D`hXIE98d;RqQgB;U9-s^YlN zwc1Q_(l6(Vk`+nCd?zLj@1Y!GIU1Bg^Dqp6``o4dlk7NYB*aA57JT!pP#H5pq)c8Y zT%E1QEI-$Hvbs$!$Ur@*0;N9v2d$llf7J!}v&DR0H_<5b;TM4NjO_SADnIL2Wc8bK z9fRu+P7)oI%f2X6HuYgna3f7Dpm!|&c}wE+DqB2jS+A5PlsYvZ*q`TI;m~6h?hL7s z8OCH6IwXW$!hi5yl*c*;pVl&K!UzuAW<175&BVy`Crxc}=4+xy9^{uS4_;(o2=jFP^{M zWD0rpaXIF4(p^5Cj+2WwCiEAs~2aj-#N6@Ub0-~ek5mRQI`R_cN_iu&y>^|s8vCg;ZZ5o^b4<2#}We3 zF?lqy8PiL141wMTBlNFHSY&xchLTCqPX@YA#1DqI=L}r?id{=^-g!S@->*oo(xlL5 z;;K7D+H3f}dE*QSIG|qF?DjPZDl10NpWHzuT|kpOPVMW3u{#HNOeW*H6=RS!c&wY` zd^szxVP7FtMzk)i%y}&97-rkG@}X4yInW+I{VIZU$R%}jsV!sA`5s_#n(D{jI4KW@ zGjK72+V4Z8^hn}m6iuO0nt0=3veuQhVp;E`Wad{IM8)`98!7@Y1r+YKQc@Jnx#Qoz zTA`4Rsf}7Q7OdmYTz+z9Id&UNh3pnVxBmRm;P+@&P@p(-@$S8aD8-wq*$3~!`AYUi zH(R^tm*VkE($>V_F;{kihgpC(Jt%_WB4{18n`6JiP9_JK5>Q~PKy9qB0z7&1dBB4hV@<>iWNR4n;^7~hd zB8*gY?Rd}dIFrFICdplk(2w}FWKX5f;t^sRwTSdJW?LlC!oW(TsbE}OSjt)Xru;cp z3f$&gp2=?6sxnk=2#n`ewlCxJ3Ccp8Nq&M$bmzgB?x+mZ3ozc93%NEYD z3I?^$Tiln@Ugv2g>KvJbPPhvWI3Mdo6Si^c`VXPmV@*DfJ$)6zbY<`Pw9 zLxDFmd~(P}D){-c9aBS_n}crUO_HMS2Ue}8yW}3TWqfpfl{*}gJvfq?ItQ#P#N*q0 zUL{5u#@PXVwP((mnQ^*PVNl8#o^8^+iB9u+VFv>_Q1wW(r zojM&I+dy(x-Frc$huciWX??cx1@|){E_kirl+@^%bBHZfRx?NPR>z zpRA!GObMd401{M}^1`T5{SOC0FMTk4jQ_)5WIWUP4aw)I-R6GnQKT7;9j149&@ z$(D%O|9bVyMTybqQSnJ2>aWxR4Wx$tja~-9kV1I)mMhlWVB%{#^8p_IR`=)TfuI6k zuC#QkFCzqN?D!XI5cEFKS%na3YOF?UAL!|qtbm=75i()oO$h$HMmO03JNX%UrRAim zCq0E1q5WqrG8V19XT4iZ$_1CwB(b4uSq*p#>!3j!G$J1u`g+|H7fsrSA5og5$ASy?ZtLLc2OH#2uQz{NeiX|5Xe!f(`fNY!W?=kcUp$iB+ zEXe3V3xCPsQHy{`ym|4Sn3rdSo=~;Lp5xQajrU4`myH-QK%}zyB zUZC&ob7KixaNg5m@jNjJdtcPwxHtbf4DW#cD5yuV>yygRHdx5Ox8Q-8_pGv!kN+Tt zCRU1vz}+M5Zdjk^jl{ctvVM`e@I%R=IcRfoIv{?%<3x$zVq zg7^Xk*Om&c{xbjB9jzrU)Zc@`^e*Gs%<{y${`{R=eF^e>o=u64IJjF+U!+@I;dwtC z)a&y%e|0}9-o+o9Ovf&Xu;aYwH7m!&mI&R+Ke5c2fRu;nI zubX3^|MKbY{r_mADqzV40^%ICviPO^ok)AGxvYuD@spzt$9l*g8WKK=9i4wpT~T3c zOI<6HiAh2KU;;B5|8w&_U0W1SVu`DWNDlsbb5`N7j)a&2s*chpH%D2ztS;-WDyxE! z^D^6-) zP)9(-^Ilvy5;=O0JGUl`qj`R|;Xi)5GPwYLnO`B?m`bootlg!uNod-oZXPS#os5>3 zA9)-$d}CYj5^dU|L^YB>iSJw*bL2nIirmS2D|Wr`R5CI!pI+P88&Bo3$&P80m-3}E zC+sXcU3SY}vh?@z_wV2*4;dm6H1cxmzHFXveusI31vH#sr6gXsI1qSH8zkL?$}(sS@_=-OU9o7) zP%)rknlM+;9nUYkdYvwhFU>}Ekhg6id$hN!tvTbp?oi3Aldb*(z^T&o{|O%#+mIa5OqX$;!c;g<&0NO1^aA z&^0xPqRlwaJ1Wq65wy!N5BkJrFnYBYkw3*A0H4c&gj>FsPob9>*rFk~N@L0SuA;PE z>+(ZKE$Ko@5<;N3E}9JoCoLT5{za;!Aa<(A-(JDia+Hou60zy@qd5w9LDHQh-@iGk z=sCJl0-XW*C_t$g;F7owZ3iqNzrtdZmlTEQ!kl2CnSxMjsI~)sHiG2LwV^vNl;Jx{ zK1S90>l~G(E^1a~(YYx6j?D7xXLJ)A^5Y@qmX#ai%qN|u_g6-JKE@p$#%`c`U=1P~HdBTimd$yWS8|-bo^zBB!IF5Et?u}l3J;Sy&d5Subib_8sT_xU znk4NTkXytX2TJzE<9Db+Mdqmt*uVFWMg5*?5s6qE2sW;IX2Xjm+IW@d9bfMB>I0;@?r%9ORI0Gd?Ey72!GJXt-gl z3d8*)&J?>1suDu1TxiyOrb-4HsVl;>il!fng_qiQuYX3QE`8T2 z!jv{E%y^(xl|G+fRESQV^5*Q(BSi4Q8C3Bn}^MZ(n6}5 zqYf&sQ*@&jEe-eWWMb;QYkz3kEvSYrMm`x%9^iY?wqs6;nwJZihXll~S28hX3>(l* zR~#4A5(^d1l%U}x3g;`C{*p4yf>8;X97<5jCR-{$%k~^4B@HL}{>oGYQoccTF1>+V zIcb?-Vf{255Kp0ni5Jtir;?!$97SLK(Y97{n+ zV@pi6R0x#wI-#dv$4lX|P%g1Ff1hs+I`5I>cb34?a~lScy^5)fTlW1MqAz{zqmX9a z7gOk^JQjoQEWRMN64KJHbiSVp<7OxjK&dtR{G3j48jI98RZux1QH>;{nVL0t@k$v7 zmP9QccWwsFOGrNPes(a`DlKPdC$fjrW(I|^jRkpz3$chTgUuM?Eb@eBNdwi4uAyfA zcOUH9I82Y3bBWnD&`$xE+HtIQfvgX^2Az%;BFS2L3z9F>({Wkh1qc50B}Dsj(z%C-zP<2}i67`_2*DB?p2i|A?ol)Em=yXu#rjs*hVvcG5xmhFD zyD*3fWA*Mgy@XG%SrqEMMc6TiPy7*d_=_Ee!@gvyOk0j(2{HHrHnX{LH?Zj>rjq^k z4jfd%(!Q4FZ?*%8x~Din9nR#042o(z&#AkQpUXf}VvXex2y-Lv!BE(FL2rg<_%9!% zFAzc8u0etEc;kbXtgyf*!t5`r*8*(w0j9} z6K?nMi}_bSeJJ;=>2ZKi%1h{lW84nx7$`F=_gKh4yYQG{-~4a0_N1}=s?Z7J>hxML z^h-3z2Ww&nX6S(RMM{E&^Mm9*RBO#a=wwierWTN-L8VvSaVy@&{4pr;GRa0=)Zi40 z59Y`Q^TVlaev@t$)#9WN#vkTZ_sOP#_LAt_hYNKpysc(2zu|Pjfxu<b@aXD7xt(31XvHJc4K6ufc;;JLP-=!Gmq0UZOblp7ME_UCb3gX?U z!5v)HOd3LhJUPmE^nmaIENz-Tr$!9Bo*~aQ_KH_9aCP26@%V91k_S*ye~k*SDcDJA z23KdnCGIiN{-G|Q!uEhWWvqZT;OBB(eEjb&+XbX)LVM`0tP~KjM77Bhi;MG9r`Mf^$_LIU;wfzcksnKoGEc<=&zo%jQS7*5z#R!Eqe{AD|6Tbl8M zS#OApHCAq9s6yK7utpL_fRU$uK0F*`ArJb8oQXm>j6;rZx* zXiuQ%$Z{o*=|6{A<|=b$iIR3$i^t%l(U?;bGAZnvgGCI1fBz7VPA)}zcp*Z`Mkj&JA*t=cb8_Vbx4-Qyht7I0*(oo zW}U9hKnPBH)Ie_Fu|v!v!>&rn`TfJDO9s`1JmgcKgTRQQS#k0xZMcZ};l+M%A8PNW znEN@E>TPJ&po6vJK1!z$0fACno8|#4bkf z?a$Doxv+&&E}KOz9Z>@T2n)16bO5TA!1S_?tSLKs+HcrOznLt?T>Ki7*>~Rp%x2vz zMyAjxP6pn*G)b*r1(liw=%TMNp$(A^waq(lm4y6AyX5Eo?~CTUwC>D2Z0KTd5-z}kdGTY* zRQevrm`*@%aW2z-NL8qd?hvT6uuYXF|B9VYwHJU8(>@hc>o#RZap0E{g;DFXs`R%u ztB|pD6Sr81IHA04G|0|tz%~dCj~f&e@xhBwq?uUS+s%W*R@&SEOJ=0y?l14U zJix3DACThGRDr=b*nP)7w zyZ5JzT~7^R2#GP8U zTEdMIkUC`2VVmep6PmgV3}Y)xF38sB+8uTyv%B%u@`dC6>F

&o&1XroP74a!84^ zr&3$0Q_CADVfP0>78w^ms7XSVnJ;@3M?v{6%hENcib09e;c}^}ye=3|mE!1nf!lOP*`5N625wWBINvvRcmM7Q~GTJp5`yH*Rdg z(m3roocC$8e>xR+7z}%Xv;D;TAaiz%k#`cg7`)CX0@xkl#gupbIO=ggur*?!V(KQ! zmy^HV;jv+=L+iM|z?p7`pJqfc;T;MV*`)3^A(-AGcB?T==Cc|bWK9Gr?#^d*t;nnI z!x@U=Xi2(?S=G+9Vx)`dt6z?#NMUmu&bsmlRTZ;M8p;$?v-si24d8cP6glXI`sAO_ z*!Rrd%?J84WHLJjk6(D<+4o?3WJ1gCzlHiJd#8D>Mf47oa1Eq6%nUsxe&ING?~ZlvP`OXbw}{f=l1OKh zSi<}5&;TZJRlxO~NTubfP0ff;&ajCr!Tlx~Vj4KWV&hpl?{IT&GIy?8X*E7VdAy6s zfq3%gS7ULUoGCXx{-3q|a`El09w%zkaxH>%xJ2%>wf0huy&D1Wbj81qPObG!{i`*_#*B8aB+1B~HtAArbHgOqM%=u#aW3OnoSidweMZZ&@ z1c;js(4N+T;W@IPt9F2&0^O3$_?@7JFObBq!3sc4-}8H}1>pN53r(9zr=i2!2Q^|? z!Lph%SR5p7B*hl{JdjX>@1df+TBC2$@Us=K%`Y- zwthdji-4EYt+P_x1iX6CDR$wfH;JCn?oH34{B7y_t(CX({?56p+&9D2fT?#(+}i(x zJO@=x}oZjA_kPea}9 zWAc}j{b!#__*?kR58wyb`yp|!r`a>mDV+)LdN57(Hyg7z!2&uJ$1Ch88nxwU6g6cp zKlz`a;fGv+H%odBv$g~v(00o z%xw=o=aYolF$XlJqt?Plex7GW6!@vbdz4NY?=7KzjCCCS{c)NK!OJ6<9E-knC+%(h1Ns^}dSiyV!D zpJRaAH&hp)^vvSVpA-p8;LDJ~=!9(ka;$2JUC6GcG*g!1-gj>@5_pMtb*l`eqR406 zMV-jrR&wC^!zA2$LCJK2rLMzw&#CN<@sP=Uzpl zEkzA8mFeH|Dt=@^&5U%fDHCH1x~$3)^=^r*!8m*6;t5rbgdFJoIwY;bTZa@+If&Sb zw|!r1wti1C^HwUlx^_)P#&olKO=C9Um$<6$FuG33unQaxA|NcL*oE3SwDd>FbBMOm zj%tz>v0`bGTNAyIHOGv~4EcaOK(Xprd)e^;a^u9>n5KWlca=Y~(45G(YBQRxyf(^e zBOf45{xdm@T+Q&%77dc5eX7DyxDh?_8IAR@8NL<(tJSS(`jMt(@?qTA+T>#a+w!k) zeWTspFoj)_Ub!}(WIBh|BqkRx;fv62PdS^}1uX0RZ3*>&%Fr6>7^51#LS#Nl6sz42 z4!Ha3;nK$f<_~0+Jpwx)IPB~t$TT#cECURa{P|(@>7z}kIw)hHbeYR0bYx`aES?y< zJe+}+C<$U4thhAHRc1>6YcLjTzjh^7eSO#IzJ>L5T7#EWJ-cC;Ho(*VKtM06)Q`+D z!8eL;SZsQZv@o zehl`I5h#OYI<2yzJBRO^!$Hi#8R_eNGN*hq=AtFntR34uVnk)Uu-)07(=+5M;!Qp% z%`U`+4CYafK#)VlRxHLxz$Oht@q~BmoJti+(ydxgIe1*O7X$Z{xfbt~c;!0WG{Q;=5Q^r| z2uurZ%AmvrE`GI*0iu5d80;U6nTJ?23e)5?N@&8^r^SK#KXkgD3~%&!(L0|;?yFC! zCgYDy!L$Y!5DsJ2{Z7rJl?QA+{hwg`1cqsf(a}}XlI|Xpk&nDxm1oD^fCWN|X8L`7 z(-g#01IN%qvqswqi=q^Uk*hVd&y~u}46>|PH=Vs#)HV}_38jj$kkQzY_1)N#l8f@A zfvebuJe-bR$z!^zVV$6-I2TXkGflj+&lrc#-zN9t79oo+pJ7EPxZ z47_)wG%t8pPSXzpzmD7b4TD^-SzDDwksB;FY9E}c*<=po8NNnEJvLh>%ta!3@=Y6w zm(>pH*5=C*PSJyV*EKqKucfu^TB6u=bxkTVksR!<;qpia&P68sVQ(3|r`WFDZ#JPDfj%)^*flsJ46Kf8Q z)n>`H+T3*CkV_?OyBj#b_{gqv;V(6Cct(n|?Wg>lqXtdom|XoZHc)2ipk(YDc1oP!R+A2 z8^j~eva_9;C$15Gl{&vHNh0~wG`n3E#wWXNMZ*lYX0XRx|dgo$qt~<}~ z1qtr=m>(Qh@WP?q;Vd>bc@zr)dx_a$_1Pwu*v`{KEcVHYZzy5@1Q=7J2Qn1h;>j#l zoBvBjKP&d;C2sTX!zqvuRwb41)tcRj6JucMj(u#k#Nk;qM97>J6hp*hAkkqBr1$O4ji5nIjJiiPPD= z)vNpVn-Z4Jjfl)h08AjSBOc%~C47LZ;K`0q}v+`i#>k(Ahuu?m7r zUjrzpQAe13ypOwJX97682P3Hp+Kx{*yPdH1z(!tffX(TV>~Rf)s6ctxNDOMVm^#Q| z)Q8$bbtFG-r(bS#5(b4+KS9U+=MnHYR<8`4AgXg(e1c5xCGS^NLiI0)fC~u6NuqS`nDr zGbaGF#qbDo*=r`)ee#GQS}I+4&j0Xk;p3OHKuGW$g@6Am8TTEh)Vg9~+uqHne#_2o zMX&{nS*&(xXy%IHe;#s=M(u1tR0XsA?4oT{h@>M5MSkL@KlIZH5!#>X`|Dv#UUyb3 zXz^~xO=E7^=s^3#!?FmSN3Z`Z54saLdxu_dpA!gSUUdcFW^zfknjnJqa!Z5E)Anu#4w?8aU+aUo1pEjD$98=8rqfRZ$_Z>UV5f{x=&c6Z|1~xFiC?3M+pYF5lA+(X)08p1iIDHd7)@B|x8m zTj2_e7e0R1{|)x0b`p%=9E4Cro`hASCi(=8IFrOsDL^JHhRX$_Td|azk=xCnZYP4h z-)_oQ1RIW!39dKwrCFFzM5(|eh+e6|gQyY1Uy0EDN6x(^%nxf_>=ZX?go$Ff2?V$L z`xBb&$ez~@`P^u9svnt(Z~y-&WT8+7r(ls7@Z3;_^$Q$HB4Gc(>6bz{U_)3qVh79; zhBH^G5~!rXIuO6n91~_8l9-+xh(fL(_a={nnKsdReJ62K0ZbK=mxC+12EMLF$JRdOtZ-=AvmfDD&(vZG? zA|++cz~n(r%DP009vmeo?N$=O6b4-eC`ovZE(Io!!ZE@QN)oY+9tY)SCfG23PG#b_*E zGb6(lVZOBFVjE*@F?^jKgou*{(#121a0fN>MRN;S)LEAlIcw0l?Y+mV!M0M0TwAm! zX}RGFOpnC#u}Tm$)9H|JWGZjn#vpmKnF z0Z%7mSPl3NbtRyM8n^IBIH3(|Isa}yn^f*K%@${8{f#i2PHt$NO_ zMtz)xhTF_iQh|zE0T{_Z zPNt2?u$Kr^bUP-a;v!6BG*bOyXZt+S{$3|dM%Yf;Bu?q3$`y^T9*NZih6q(yN)D%S z%25cXp>HDkJ0UWpon}yupe#p1e7v*{Ju-!Mrb}Nhvo}HLxnRDBuUo&oIIM!E+1wt) zl|_sZJHrCIMOdf=9-aF@-5g~HZaEAC15~=X^vXYLil5uE#3K^o!9#|UDMLMECmbt? zkWqpy>mcK>)kBm|3r8u$@IYqLl^$HhB*%>#Q#ZW^3mLx!mRBaP4NWzD$6Q&DFCFBJ z>AQSd+8Jkjjyv9VRD1pb#RwlXK@sSvgtO~7x5FJv=|{Aoxy?bYbnQUghV-%iXDLEG zbo87*9v}}#HbC%gQle>jl%hD0c0gF@o|5Rg7@HeNI&FO~IBKR+%L2Ux&M}s`t;*0I zM(2d#1`%ZSAY0ril*#bc{j`wQKpBU@OekOq48~zsgG`1=QN+XDlVR^PY%;YoSy%R4 zAaHUH89BPjqTRq5yz=T3(e){$5@UvQ;E}@cR5?>IiY#yNf)-sdaiEAMJs3zuf6Ah{-)`=IeP*bnnoAOsnvq=liGL!o%HTr(-+xSfnY ztg}J2VcX-^wCtAUj;uQgzX%LkrARk0PN?94=#Y2xrbeI+J?Z}V4DuHer?K>9W&V8{ z?^Pe7dP;pbrl^$IN(53I+R$bNB2!iT9YFLjwx^@OxiVxT3E{zIi17${z&D(HPbE?# zj)a#XjU1F_qy&d61-F5M#2(;A$!dPQV1r&xKGR30e<}lC%UV;=+1%S!&1>r0H!BtA z;WCKiq)9KU#M@SKyU2EHg;QRu!D~kFO1)mdg+#k6){S)!+gN=;RbRa`PSi64t=+BP z6U%x`9BdYYx!fOY28)EB4t>Nu)5W~$bmt|f?|%Et54PP@fi^M;)6$M!<|eZFiCSCcgu8;kd}&oZ z5*iWmZ#?^j%y$^kNOaw^XYs@B#PWa78vTde4oJxH^!Aeoz+=n?xsRYBv8<>`M64=f zzAuv$m-|$xXKir)hpg`#Pn@QN;T>QNYeYL|8%=O^0{Hse83WAXtt< z>T$to4f>16WR0cHOP_Z6A|M>+bJz|!4gP&|XB)WPbNzxm)*vh_+S+BdfmvGTLYiqrpK4krD1D?p3mB9+ zrcp=^FkQcsWMCHZ+)zhhcH`f-EWu(*&%RQ={KRAZ=Sy0TLF3zh^ojpZn6c?nU!k)F zQ-WrI;0=fXSFP|l>u!SYZ&|@Sb?h!<#Vo86Awx!V3Zqp=xa`Y%koI|PMl{W%VTRhy zx!Be94`2cUZLt5C;m(Q3zB|R^Ow}?y-SS{oH^`e#Kflw>nJGF0@;OO2a@!wa$LN$} z?q^nDM8e5T4Bc~dgN)~0_p;FGoLv_48x^E2W%4d!_c1)rW+SB#c@f3XD6VAAqBOnV z)auO`mf*dYK_4jOP(3}NJUm{Ow?xY>S@qRvK(f?=^%&bAd@Qcf1CR}wSeDw;XoL>* zb`!!0p^GH1mKa3KURkxBX$!Q}Y~>iMAi}sgttW$7oCo!slK+*& zT(;7gw8j|>Buzx$(9I?!D2mnpw3xA^W^KpN1kdHu7v|BM&U*osVzBP!)d!&9+v&K9 zvc)!)NNP1o(uCX-qnhC!0c`rQH3d9~24t5ZUn>6&94OlTX>6emv7m5-$vv7eTI>)Y zyx948F_mYVl`T0YZ#>4py3wJ!XWD!Z!TcXWsZ8Bq_HCLT4??Ui7~J6Px%k*y|NY3G z)Pe$vwZpmWy(~lR|4&}Hd&PHr8mcS4o1FU0mIuN)kmFieskEvIU2FnZOiUlmIZ(mQ z*%WC8YgYlM2yVu=iyjbsAFLAa#bUt#Vz0un!Z^kNSd~;z(J>&0_pH_jo)Zvr!Y*$+ zGw#`z_o(A(tsl%z4S$TYY53s=U!O$CXmKLPWl&DEh2h|{sr0)%an>cBpj?j$a^$y3 zzdGMhL%h9^j6}G)pjGLq`m|EYudB%U9HcRo4a0s;0RSiDl~dl8=Km%a7BSpW&G;wH zl6d;`Gg~&6_6LKqoD&*YSVlC$vEn{6wAN#1ai{i4galUhNhqN=wY}ml2K8vdTP=)O z)8UkYC^L8450WOXJF!R1)Av(7*67z&#|X^7B2y8R2!;g+UzoAF{sM$SQm}u}4(4-A z;k=G|&!M#q{@p_H%)yXejhmXeKR0K>6{#B@(KeADN=4{uzDBgpUX{49F=9nshl0K?>Pe zuusHBc#(w>r!yyJ3slt*@;5OmYQ4wk$%_+uH3&VkJ|}!T)CKNa)1(E05HY zwaNgmVrTQ59|LOMk>gVt2jTG4kF1VOu(ou1NXAm*KX$q@sdf3_7xKQ2w2$CgCVIKH zI^Fr7R*1fJ{Pt~u<6YCuq3*cPmpZQU3>_S6q+0jao?X|bIO&!ayKf#U9-~EJ)ib|F z`Ek7`QC&7dJDI%xC;qLwd%74%boCYfav5u%Xa*YI5+&SM-cWF7;w>&!U+3z@&D!CD zY^lTCx|ep-70=At?swS)7*e^Otg&Ue;AZKQO}R!O26w1e#8x4Y!MA%6roZ|a}F<34t7 zjqq(Q!u@8sAH!ZP`7Z;QepM@f9?mWQ zo@>`TOiuj(;EDL*tRsG}-Tnr#>a<+k|DYdB1DJ^SFxD#Z5<(W2c=YhQaA22R-Qjs! zz1rl8+ds2g%J|(^7G>xBpJo}kf2u0~y*Wc_E_m9znDM3=8;=bz&r+%j8_(C@6|T7{ zvE`TX@s4tAH(ac?Z{Yi`Y>FN9iNbPa9<>P(RrN1caka@r?2V6n^WPub|1#h=H8L|r zF;Lu986R8e9drMSX}L!K>4M9hhqL{=tACcrtoo1~|G%mrvdI6SyrAjpVmSQH{yo{< zl83rR>F3JAlzkTBrTXN)#TU(qvz&1am-f*5@Y{;L;W>&(@pFLvI+6gp^idiYos>g? z5K_%zp1Uvq#Sh2Aic4@763bsNyN;E;V%|x`7S3kBK`lhTA{sBK=9`{d`nB@6m_7Yd zfU!9~Z~qKI6f>bWH9wkJ6ESMR^ZhEpGQeInu5B~fw-8-2CdTUSHHoIkrXvy>W)sk!Rn)j8= zu@QEBMDBNVWQJP-NxbA~yu=to5*__I6~PcTL>m%8UxJShB0=4iIhZ{NG5#((vPS?LT{JwxS^XdTQ-SyMQmBTNBH%x z@g^Uc4J$t*$5HWuugI?M$(%dhe>;zO_3ZGyjO*&gTYj+4zl~XVIwpCX)n6t2eSL4g zu8h_dSb06duJkSkyILQsYpqAxo<{q7RSFcC$HR|GLhR5i0nIx1w8JOPL}C!wl`W^u zLoi$tG_x1I3 z88keV@GX<5n?a8wJ%SEVN4@oI&_y`Q*jgTuN|o50=ZN1=P_@bBM; z6|yH|*5`D7?r$$fZU)cXq6|^xuT% zXJ=Wewt<%4sSs5|_eI8!H^3{9m(=6wlbD=;5wEcJP&x0M`bfd{fFJL9LkZ4q@n2P( zZhJR<{jVx+(tfMr^HWj7Rr+&TISn!qrDxwpm(sbP2Uj4Qgyg>laPB6o?5BFByB|;KsfxCuku-t84FK#HK`&I{ni<#X*zL z>QKI!;pdtl4R8!&OhHu|buV^;9cm9+S2mPso0l-d*qVi{;Y-4$Y)1^C@OqZ7*gw|J z&cN9;F^d7j^I9RpwE0({ptvbgBG~G~XKN+UjBAFufS`z=RK>4?>&h~Q|SmIEkOZ6dhZYvA@tBg z4?Xli2m}(6JkB}y-22`eZ`^ms`2T;8HL^3t+I#I)_MB^e`&)A^6mHz*Pryz4cJh~u zb_A;J)`dHMi=_T)s=Ah0qgMZ?P(kp{O}!Yv{waX}K*O^o#v^Yo8W1a!<#i2j~!FB=^l2%vT1| zaTk;DW7F{2yDP}`m3Yn9Ms2$Ot-J3mW~M1#(ma;9yfP74V#iv&9~%2;=)Ny=$SY~b z0QyE)<{x15c;=VC(nd%ZYls?QJ2Gg@#Ny#b+&8Q3xU03GL+fJ|Dccv}a@?HFo#WKL zijspeiPzRK_Ok-sgYZ79=D(eOdR{-Tt+jqQb5qKlx2-9tYST6J5hUyHK|IgxQXv02T(=HNfsJpj9Ir1KdUS(6I+3KO|{UtCUp?RQ~Cea_gux5jeAUC!Ufc-FOR&xp3%`Z+C4 zJ_=!f^6+?0YKJPa`6qz8mGXC53*`}d3$1&XwObGPQt^u24*mQE8;JQFN4?~{R42b) zsTa?(w3kaun2$I^*UJS7vfB?pskR($y7dx3Tqu{XCf5Be!)lj<^l!JVPsRl^&2Cab zU7h%6ld=uVYb*ApZe-(s@oZlH_~an(f6;8kV>Vgpae0Ssk233DpFH~XB7-~H^|kVq z|1X{`mZ49!TmF*i4VEF}#wR}`Js>#|XK0vkMNG@;jg;k47dw?1D9_W1Eqe?Y-T|~!VS35dTi2E0 z_^Vqqfc2&W3X1$Rd8G)w3WTTF0VNJ}uAC97>*=@IBgN{qfUQzBVDaaPZYIFA741;* z9>s}ONcKL<1xYSO{TOAne!!S=iy+$p#+8okAlR(hbKnm|hp@d&Te40O_u_Fo9UpDLLYz$g4N%BBm5(f9d&y<( z@bER9>B@8{WT+sLE^Z!;T_`7uU%S-UpTav8$#H4%$L1;KcX^sLvsN@kIk%H$5V+0v zWWSmY(U7N{1Jv+rgk{T~n-LKFQDD&cq=0RPIH+*d#uCq$@))5IHhM|%(xWweEghVA ziK2KTyInp0+1Q=kCBbu_kM4c_wxj}IU##T1XHFevm(UoDSo+bNz=gF*WWIke%5eaG zMzJ2e;UgMq`L@QO@k5G=JhU)D2Js z6+XNrdd4} zw)K#m-bVAr^P9rAq$?YC#wM^ice&Mr10O1pNb~{s`4#Y+Oo*AZL4tx5ChfQI z<)wHkiA0jn@+%1j=2G90kPFmw0001^@XD|LEDDWo%p(6xR%@k}IS;{)n zIE9+>^~trQCD{@{fG_VxpiNa`7~bP`3hdn)bz2Dp)j5lVvu(a}7xALkfFMnNF)vh( zM;G!rQOowU1Oz5dTrUaoJFbxLVO2g)90MZn+uUJW>)WMCZdMbx%)-XAVs^JLG08xOQ)a#HVN}~T#R_pcnV`*xSIS1Iem6(+la_%_z zMFC#5Uf&jp>#Z0Da1g42KG{JDy`g1wdl0mSFwEemvV`P>Bv|U}FccL9Gm`kjEq9zi z!!z2(?&C&)2>5s>;J#5$Ah!okQFhnx;&R&V`^kVvX7++_Y+dXAujtj-R`;dLZ`Z^xl*tL@ouoygfK`0J=pX* zVc1-KR;L_;B2STiXL$bkx|#ZFEW9y~B!|Hb%W)$Km}fzU52qiaV{~{pfCtdjMnbLj z+jp&xRod|I{}9N3N~Mji(&hi{N0QQtKy+HXv)oUcyEHVk#{Z>+Og{Z@QbH4p@4{bR zjWT5Py3FXs&gI4ak3W?yVa)YEZ&E)qVZxqd>XWu~T?dVmZ$JNkC4pftUQ8QSAEVG$ z8$f_J=@UF`P(Hi}pNyXfrh*)2f_G8anL0ADpMZgr z;pIer6rq^#xNJOtl%+ln<3qXpXVr*=e?+_cpB=}Kffr9o8dP5VItzuzCh=e0`>E=G zR=|IM)tQdZW;TB@e(k-CZfT7#yRMF8jLXSj{c(Sa;bqmUob>07A3f+ROaGPXAyR5J zH)8d5+^jy}-E^N$xp#>9ygT+ql!DV>%VqYcMTPe5qiH7teoVh7!%A$!5rGVoPw;s& zF&(l~J7F85&#a~yb9dp*%-2pI-GDgs?c0|W1@l<6F|UJDGQP&qk2I#MT)NQnME3f% zBbbX0E7l&JHG37t)EMYnSr!+=nalv}M9c?fB{0c&8s5|ctsIJz@#5~}v6nw!(~XLI z-?V|HF~-2w;-t1Cd9Kj|1%S9~-78f%`<6ZP7zOarV_G3$C-a_YlN%6NoEDxr9A^N! z(klD$$7O2vjTctfE9b{Uku?jq#EVEyH3DLJQa!Y38TkQcfnspzf0BB-d z@5_Y~*PEa(4x``wzAN5@u0r_84*i3y9Whq^C1%`Xgp`MK{aKEpMsvOU|s zTO1J)R!aByQkqb1Y>Y-$p8ve`eCA_pGFkBW9@upK{PM`KbKkP}(&Z%h@JL!xfEe3 zkdbeuPVjB9q5!gATvOECG4)&*`;i$avu;TwU(Vp&l6zl!Hu)`o?Q$gtk!0pcKV{eN@4%h=_Kh5}Z?=`MV^AVa zTlUK6;jp-ImLjub_+yN(@$`9Tw9xgRHJ_epa<{Mf%uR|s@9MsGn{eo6duRu>zaz@v z2!4+udC2v{P27TPwOC~46-G*(_Ca)h`e-)$^aN%h3#E~d2a_6q9Tz{&m{@AS+|90g zSp*sFo$%C@*W5ZP6+SB|W1pkvyg)n3DGxacmb&Ki^l<57FH*D|#onxsa9B&x(>aO{ z54i)9_Zax5m@$16pQnrxCuLOYm+k?msCBvw<4_%>Y$qI?TNg@~{piV(V8R^X33j1= z^dzg4G~8${SH>!=Lv7%{Q9iYxQb4K|{Mqbrk2A<=OBpL)5j|g!twy@q(v>K0;C8|w zwX>7SmA(2>3<0au_k&4(-gKQZH|ip*x<1kaO4ra=lT+2{wZ@Q^N4qjBSF`JGUg`!p z3T<)3hnKT&ZE~*(trz$jtp_;s;w`gxX3`49FppPnc6-5Zo!3;GUvEnPsK{GApP&Br`~sxz~#Ryx)-0`>yF zK+7=|uV$BZ3BtIT4s`*Q29tq7TB-ySIz2k6z%ClQvOh^8Q;a#l-!8ncR@E#7AxWy^ z-D`AKKQVI&kTdRJOqu|Yrby#o19Bua%zZPxgY-C-Wj3v}N%$85>?l=%3J)u_ie+fBE3B-mU{rPgV~Vn7pp9%T4Ct4{xKX1g>P`2=MhKh&3pL zIk$)W9yGhAiyY3AZLi7r5oA!{2!0nI4vB3Ij^Ohg zGB+qvo#%EwKgnwhA;T7agw$N5nJONL8iALt?d4d4JOHour?s^ZVI8BWIJt_*eLJ`}|Ck zeO$cVOfK{7RqOGby9ZY}?G{mtNT6vEDScp^E8jNOmIKdB5UFSW>o~!YCK;Z>RPa1~ z@v5Ylz`cp{55Cbwq`Y$Ovvd0#_sf2>J8IQ0bOo(NQGPziQe;s;7Zf~qEX!4CTQTkT zJ)UKgrLeRoD^zU1K(D7F2R4l}%hW3DNFJjiLRvP@KJtuHUlvhOb+$v)1THJ;>%r?w zmn)>%6W;5xZ@r%cKOBQgwMEu)3zIz1-&RbhL0CY?1crH(V7Ja;@Fo8~s<@xBRalm- z0nCx5vIs9zZ#xpv&)=<;yUxD~dhB=v*VJs=Cd28}a0UU{yfzV0y-h0B7$a6^iNn!m z4s(ez+ZjVc&-l`1n@3cMDxTKpSZcjP-dn~^$N=>RRb!!_ldBk2bd$#moeB;%GfmhK z7rPc|ZSnjwB_Lvzw*5#69$XO5p9AGOa(`4uxru$*=9HYbE8@Kq5p&V-2DMeko}qTN zT-HbTm;FM2YLhGv4wy(d*#0erF36Xh@O)!`^9VV0P?#sq%V>M~0La=yLUnzi_Jm*+ zdP1&cpK84u{QeD$xub@2#8RUjRiGC!JAp{5kzKJftAK6V<@w~5mIhlRu7?79KJGuT z@BW@*`S>QSh0KIfT1l_+PwbTBgd8rcL9TCv>gYRe0c(KVYiPAu$2-YH&rqUe_yows z=Z*Xd*e)m-~!S_jFv51H?SR038#&P((ymXS0 zjE3J+zt<+Hjt1#zfl2WxPF9Y7HaG;^-yr;xI)2uWh-#?yu3fxpaZi3-emF?`^EB?# z(}_Kwj#J!dQ7G*?or(YZ@e4x;i?a26jXb}tT+YywYcXf^=>?MND*^76KysPbD1+NF z=E%@$FiZML~AWaiX6{dh>?3f z&xqtW09{^cgI$Ou0c$hPxj)lPWI{3=bZK?($i1Z!8f5c|*i`iuDCO%i!N=e7?Y ziLsVfdQ=pt@^kOzR=~=n|1K*C0^XYJc2mN~!~3#JSHHZh6z^DG4rfj4|VMbML{f^5|9T`~kB zjhtYfCUgQrCor~;;w>_j9ynWwLGY$V9pf(m2EH!TJbzP^zpTz$;!YjUWRMQ)5J+Ya zw)mWM?r)G<@54H|{w8 zPVCGhBPs2|8^PTVR2=3e6~^%q7P;4IYOjd$r~%eye}jj*fo{-3DJd(cO=$ zzwrTM&9s#<$y}>3o^db9-XuBDESeYNwU88jMv-?U!?d^aZhKJE z?U!IG{aRjknLf|jp(+r*@y)?ndC3gWRDifvxKGk|?eAzTv+Um~RLeACCR9{-wky=R z_xp}qg;Trm;CNzSf`QW5;Qoe-bKjzOfz<>@U)>1!L+GBT}pRg1{G-B&q+f-d~fWEroR0MudR0+ zXQ_p=jXY$qFjddb_?iGw)w~fp%Z{$UfK&9GuX&|vHyNF&ci4jXCM7x^h$*}|VPYso zw;(Na;e+7+!Q>sZ$3@Fp{lvqI+4U?8JnFxoGc54G2gU!h+KBv0u3`6b?;G`~C7EHw#1fzfTLmQ{v&) znWVxXp-YN{BFvIcehH1^G2u7WaO31NMLLv9A+L@W=>IcYD)a=mibjgJfk;P6*a|tv z^LUcRvoTT)G!?3U2tOw~Oes6EJ2WKvr~dqc+CiUW+q}S)tem&G(Q2FDSLU?S5(4ma z#uNu{Z40crp#2(aJVN+0*n;SbDCI$phw&sB5JDk@tc#71kN1}et$|TZg%Zq96fJ_+IX>hoWnd_)ys!^ zAyDKb2HOXrD8zowl2eH3n+fU$B)cW7pR(yuXlxp=%^PeA8Wvv)mly&Rg2R+ez5SaW z1A1=)37jU`9!;u*z8aqI|51Jz z&5>`n2~`7whh)EQsEz&_c*oU^JwgC+I;X4G2jt3bE=zCg+Zlp}V6=XR*iDb?Xx>Zgt+qhutki?i8@{~ZLsoMqh^^lsMHe$9k9it|_;eV}qV zm$|+A&%w=uUdrE{7uQm&M=kQ{YJuKwa849zFMkFH$t)|)XQH>&ly}PgWbE((%ydd% z*Zf3i^{NWB|5^7e8B#=tEKY``iCBHVQM~XB5AF=2*DSWyNdy10!h`xF(Ydj2+3+&Z zxmtMqb?DIx#)2a_=cdQPjg)}$P~$gTWu)2Pqp%c+2y9|QxVnB&={i@7;-kLtAahXFmI?98KWZ;EKj=u;D{Sg0eJ`La1(W2Y;glY)g4Lpw z+5V?&Ui{nk@g(bmpPgdOXZ>=`=xqXd`|Y#0m6yXu>c%AM>r%ZYtZ%=O)Ot=WXnA-ie# ze$}7S&<*@^$bozm;%zl90N3l#KjMc9=yfgKEyci&A5#}4ZaV(PdPKHuE>rQbGhZRO(RDTT)g&;t$RNwatB%M}L8Y9S4|fCw$`o+eZt%0OszClx;zSj<6vuh9`eS6GUyZy`J$& zn0J!=<(tGOemQY0qNG7P-3?3U^bJlR+X*aS_dCmPO&I4j9$hNH9N)JiMf$jhnt@(8 z-3_!o{Zt8!q9B@wFn$pP7|^_fmyKKoZL)0rF~tMsMwqz1WrE z&}M1rn`~}hy{+X;6k^>r(f30-hqFDi)7$(VXIIY!3~gAEN{E5wTbDEnv9N;wk!8bY9U?=Az02z}Fy7L}6*%PG5q!7eHv zDOU*Y?|2GaT=#Dr)Uz||qGngea~2)Kz2_}--fvBND8;j%RKNx$tDUM8JWXv=HEO|t zbSt~F>l^g+>x~I~*I}H}|DFq5Anlm-X{=#Ga($cCGC#HrHQCPO?+DSizUpEtPcxB{ zP)O&mOuJ?II=HF4YKGC8Hh%Hy4gGD;{P=SE*Vo$IZ-cMxflAsgAw1TDQ>D50hrhA9 zX)iOc7ccvpbPZB$Y@6TJ=rys;uQr(RsU9^8)er*Xw^E8X&LyomqkDb-9&Yn8dojmV zD>CN7k?JZ#tig9CBGFza!cXk?$1P}U)jNlG^1RY(%6fy0G{|D#Mf_OqvuRtik=~XV zj9$K%Kl{LT@_QBBwtTyM-)maM?TSiJsP+Nw*uS&b@$}P-HTj!&_vh?Kw6!pVfJ%Jk zeu&v-1947rghV=@t)wznzlsawo!;W`Bq@131`sn-y1#8Oys#dNCyVeusr zBlj_d%Q?MCAmldON@Ml>{s~_|VHRu`(R7Y)2EQC4wiNDxZ{?je=5s)$+Vw%PgQ)Oc z%CXey7@yL+7k6O+572`F#OV6;FNzFf3}+T9!UAHL;Ix><_S+qRRcpARjm9Y;oHlXI=QGcAm%jCANt;7UpDi1G8v0{I|<1uT3Z^6RzQdKGX|(TuZpEw=2nmsrjg) zh|V|IHC^VUh5EsAm1_`!%6E%T;hWOh#X%fTel>7QaGgB*n#=2r5PqjpOL-s>Iv8)8 zXwzrds5g`?=@qZhcp)x70MOAGvYNWcto*F#@ZcbB@V6-i=3OP#>OM8^>Oq~1n=C+t zR=U-BWY(R#x8(JDyL9KrXiVR(clJw1$o8h=n@TEY!n*9mhm3`;!O1Wm)p@(BmSXOT zUAB=xxyp&{`rHS*CZM?8Qlqjv2iHYf8{kjMI7&|8Gj&L z4SZ?queJdIsC^E@Aa0S?<5T47jZmGI$it>Nw2s=jYf(NWBa$WSP7f5C&@3uAU zIl*1R%NO|miYY@c1^jFgCnrdI^@CE`mZ6Y){zZO!0k=&nlS|xhd>Qs%#NDs*u4?*2 zZ77q8$4dQb{b;OY))3Q6b&0h@Y7Zk*4ocOsLbR1e z0u^zAJbO*_%3rrbAJ?(X*jN8Www7^57o3<97lM|$I2oJm&y?8XnXmE2`~d^zOA^?6KzdF$Gti3*MYafJys28v_BWxr&XpgbKpTd9!VQ2r> zb~)VsPn*!u)CQnl6zS}-SBeV=IUJ87^0P-z@XS?3>yXZ$s1&=It#9JGrTpyjj^awF z@{{FXS2tkJ9#CNOG*6WaWWMhy@feM+{C)&+Yl$M00~p5u_>N24LS;FiUo(BzP>1#E ziY43nGvD=C85lParTccDHgov{pAc3VTof`jZ!2l(O$7 z9wLQSW>DgX{-fB|<0zB#m0wt_VfrE2``!_ub=MjLDZPhD_RV@>(SQuVf9^jEIed_> zQaEcCI@$yFQnCBqMiW%jrrSKbkC~dH9Y7GimaiZZTqv}@1=!cdDpG5t(EO1~WKfOX zv>iyleAf|mlCqZ6I+Jj;K;HA!S;#V$g?YVvLfz?i=6yr1k^U;ww=r9B1_H(ZQIL_!M++igQtp3#ivD>kkvWA zuwKfh9IcyV4Ci{fp$*!=EL*@7!y`p9E{k6cwn63x!$y1rvdKw3GbrhyP8)yBwWQsm z*}1@g5h#96aXJk!9rXEZ)W7eueZ1}b1L$jVLPK-0_P-!*Irx7DV*mTA{snPo z|36j$X}Tw71{~}%YxY=_^UVZGL&rCx^DbQZL6b;4@!^ho^`9y5c^iaEnZY6w1gLz? z_e|nL(iW#q_7*!l;QzpjMTF@#C$gDB5laUttOdDrwD*1arycRQgTHNQp=L$Q_D%PSV71v0NNCz05rY!Dw5k%J=7A4{z(9qWj$!c7E&xG zh?=ewCHI^6po3}FNvTl3jv*i26CqTxiOm*3(~j`1J?PjX8R6e_bLP`w?5dsH-*+rj zBflkzPmO@hfO(diK!KeTN!Y?-UYL#Owq}D05>2Zrahnzlj*{%z%=nMK3{>YvpucET zpbDr59uim!+#HeLdefyz_OL84qHlf2nGw0>q?x_<^{B1+E8!sf6gjz{5E>N(CHS@d z6>m*FrAAQ>u31y?)vu5pKjU@pk^?qoI=EI`HiWW~iNxE$L7NjgU5rOwtaSppyG?XP zVh25)pN6X!STdY>+=~Xw3itqpL7xUi8wGh>+qN-Tc;-nbb3vhPe|n4|riSgykyAb2 zGz&ountlzpyFHa|+l%(Y7cPgDQR)2Nwl1`}0pmd-HnBtE;BL^6za4?9rL+}@@#uHb zI@-cp*d)(aZ$Tc{ZP1Bt7YnB^dU!#ed_9GK0fNwg#|V-+tbGvu4R=rMlxAhLhkC!& zED4A_B4$=qw#48&XSguL`3(mArx~WE&L(buN>>UTxwWF7)tintqGX{?Iw=0zR@y)- zRZu=qj2v*ZvsydmHawn01}STF+plUzq{1aZA6p9iR`&zfbb{x9^7ETeIv%q8BN^CR zZD*b;1K>{9Do5|H+rtIwK>O9YZ86NPoRE&w4kst4(aqC^38wW+hzQJVJ6=r9cI&{! z&I#C$%PD^b%*w4U7Nn-mq5L0)hEB#0@k~Ng5CxiWgRb2fCydW_{jvn&t-xf`{&QA@ zFL@Tsg$hZ+VTkUw1)#7xtmWD;E_W4^Ze3|TIW}#v%aoAAsLZjUs2_(JfARgjr{l2fKF=(*jEJtFfXG2LM zFp6tpanR@R)NX^JUI9quD>mq#Eti>Vwm#b^aXBb-e(D@|2j=ka*)Bq3$hl*f6U_<< z-q%~H6S4yQXTLmT!EoCxxJb9PW1;{x$;-#*U_3LkU0lb(D>sUJj?dYJXP+Xtj!&(P zDa*{E%4NdT*P&qvoxK>jw)TI&)b8T7slHoLysscvfUbWDwojH*(U<*QN=qhNg~}Gr zI>V8axGPMpkye(rMKR9s)HVKb*(-5yJ+X)UzFY6CEs~kF>H)dsh$AYCFZV)UX8zu^ zy)PZyk3$}<`(1?n{^>d+BHK(v``7GdDyK)G*gMLjS=(IXY^V~hqxL*jFUk7TM<+w> zpjH$Hml08nWlqxQMQ_DdGGw6s2Ky#nZm{)7uSf|)fJ$a=UEYT>*{6ISEA~eM)iBEQ z;B6qW?OQ^U{w^UnkPsWvcy|(UlN9OL7%Wctb=pkMJ{{#6>%28weg+*Nc-F=_$ncpNB?=21W{1^n2S4ZYT+x81 zo-&s&Yj4w(Ok@p)41`SepdGuh%%lvBR7jGf)COXvv>fSGUXcFI&+QFY+7|#^96nB& z1C^8wY~ixXz|dFQ{~oc53@g7Hc&5mn+_AC0S4jtUdu^ihC}=PebCItVIR7CDk^tha z`@WiW!~6sju+v$WH0|FHM6Xt$f9(;T?oq=MPAq4>?1_0vd-|&o_~UMAf%|}kl@jkx zfybI6tuW*WJ}z1jmEX)q_Pbm?sK5toK#X3!2+Fu2``=-D2$+f~gN*`sZ@c5|u~bxw;CNlpY0`iL)I znWhWM8m1%B*(p-aI?nTicxat=2~hvQ&8=osYvb z#cOSC*I#RrPl=Dc2Utorf4uay<#oBk#T>8Z~1AU2$uDNiWzOxBAe zJ@S%yul8n|kMO+>TWzY7HFyal1ld-Yjr_CjHu}vZ?|D93o_<2Lb2K>W zR{d^>fahA{(utHpznd8JK9y9BZ0p|DL zU(l=1jDDJpk$WI+fhpz@@mCxTa#X?}%lCnPf;Xshp^^1fA{|Ndk$MybNbbU1J18|o ztqUsT%TT`Y4HdYXxIA9!lVIKhcPWoGE)g)@$vl-9$29wCsd#L%XprH1$}!{3I`^o# zRR=mHI7POVr#4lxv(6=NveR)+I^VX=2WLx^i%hm!g~X?$`I@}<=APhn%Pde{Kf#OZ zR?WO22|+O}xn;P4pc_o~^xeU#@Pc`KGd^ivX>>OL8&O>O9-)v{!V%o#6Kn=gA!(Ln z9c&+%M!yP_rqbkmAPSbEa{tiKJmC5-19oih%rvF_@6FTy|J*YJ79`TJeShL8SyS}N zr@yz7G;d$Kes&g0GQWN0`lqBz;q>N_JwGkT^wDxTv&sT zb@t3W@|&%itEBMhlgkIGBW!FUV=goIcb^66-I722>R|As-%jCL_Xt3r;o)7^;ztb$ z&)q~QyIbyw0lPCATnXNt6~}Su3$)6HMd4mdY{OTXM9R~uA~nB&Scu=temj1z=Yx19 zG?szEAFPDMb7kzqKgo(t5F0w%e}8bh1m!#Lr=N3-EmZKdJ{~12OYpEs09#J$8}$Av zUV(nm{gX-WDO|@X$a(ZPON@7%U&^!n9$U1Zw%5yZZ8ZaS^$XK$?E>Qx4e2D=(?@*D zV46tg8h$xB<4vA0#G>BZxSAnqabo9StDrE&Pj`V3k`Pal>AoYb_EWFtGc&h~A~%yu zre1rCTbRNm+bQPUg95)Wlg?17!lUT8F>>uhn z(RBfukA)?C-@X0v=G);o({O`%6ri`4xA01zdS54QS(x+dm@=E_hU}5gqt3;4$b?L0 z@WSgoVBQZcfxU$zYaZq6`ngln?a6<*fb4}99&c&hKZA0eK9rSP3@a6o)q1v{reU=gNgv=ZoPm@B zit?^J4v4=P$KbBJfcQDb;nq*z>jD5CuJ`CnJ7d|ZIaS`zu}$9z2)F$BF8JAtK!z}O ze8la`VdX9cNAj8K^@(n{V zsmng+pUnWucN41mpFi)V@w0`$Q<74XRNM(94)%2hZxM?P1wbbHjxhLiR? z4M;^Z@*0cF`5}sQS1)(da{0+dRg*8=@QVuM1Fa~NTo;0&7TrwV-o*H@{skm1|Bybe z!_LpjTKyb$N-a!dG17^fj4LWUxm*h5s=vReElQFY% zRV;GpBa#qj7w`wil(RsBaPMnLq)ldvZMXY>%$|ELhV@k9-tRLvgtkhhiL8?stflIY1b60z6J2NJ>nD6&x#@X- zKeiNCYki-KEryUZLE=yea*C;m_ioiC<#K zFVg&Sn?o!8-ST6)3hCFwke{!A7Ud~fU$J}OuB7?lH?d3onp#(76yJosD;#`0xLb)~ z?aQ_bkC*Uy1#@b6&dH^IL26Yrm~KRJn#(tgM={w&r)v~5_?zMTzz%PB`USSt+!zhv zHQuq1D{{Radwrem4u!{g>-Bq!sUeTO4-LKn*t^cWiDO+*t!>|#g&Npg%(>nKC0&_N zM-Vc)Reuer{ZaVir^{Y3GyljyrP1#EsB3a!my=mS*<|#bUh36xp|5uC>CtDQ>^U*G`gq5L!>>wkZ$EK9!SsX1y_1*GvpHoaD?2 zzPb1T!N}ytsreqTOFj7M=b2G8eRTL@D!17~+Ccc@agEi2?YQjn#Pq@POF^*=hr3r6 zAE?yb`P<(6#v)l6ZkrrFS^v^bHoFkr9r~89p?`~Bdh$EqeT0vLS(RT?=~Al*FwlK%F63vbpb2S>z3WY05(UM|+vbfyvFa6a{`cju($S9XEE->mJ7kG?>Lv?)ZB%3#Izm@$CLFyNub_cuu*}r{NbM*2h5v<+?K08jSZMYAkJ?%`n z7|vTW$y7Y&dUgIs{0-{h*hIp;_et2;^vL@l<^>O?svSj}^^$@a*@9~nkS{}!a z1b2nVHW}EZ16gS6Sk{yyX(p~#>7L_vcpRsVb?6qz6AIvx*72o-Pd5GR#>8grZv1G zL;@aO7S;9ojasC-R&cfjs!TO zG2wdnpPQ5f1c@rXtcERHm>lj`RVjoXV_GUL()(CKgU{pl6yJQ}5&BaC@p#)PW$qky zjA(ftDmgLvPMSmYe&|VoKl>ZWPCAbK306Jr_a8HK+6RhnOpC*|Mz???-cvRmUTVfU zwn84)a1?RZ_jk?*pIcfAi(xJQORyMO2EY5bk`iuEM)T2&oqx;kuH5|ydfpQgCAD+k zlU)UapKS%A;u)&Le#Gcz68dn=qM-(FSWFKma*kX+B;Cu@Yr?zTSXg4qtlz_?UybGGEz2b8mpXeF zvh@w`|2p=&rLa7qHW}<0-d{4|@0wrgs=^%3p1{x9(_pc^jtGKr&bG<;(vc&7j$CF& zxv7VT1w%zni}PiZkSUadfiNjof9689IL>nAts^sOXC!&m1e2#>$ zN{M`Og?Dc!VJLw_`|0D3p@%T$uz0BZ+|j3lox7Kb(|(6W!GAg{?C zzU=sBAKTdF%aS~(Y@lUH#m84X4o|=)o^;Pr*ZICTUwf3T9a@$k^zsrHi0c*1<+739 z5;f;u8u+=%%|9+UGuroIIs(zXTqwje+ml+1n(TqE75x(u- z&=uHaE>xC_X3>wSSM1*DwIaC5`tA51#m^y>q~q6WRk}}i1Nb%y0%G0y*Gc8nV`4(u z`Mf-7Pi@Me?vK2)#jb_LAqV0#zRYE}zSAAJ`)1q#ax84cIlSAic-{X*w)#M&qF0@% z`g#t3DgXGTNYVHCJi}%B-50B}4j(qYrT9npogs{5C4QAA?4hpdoqUcXLS}E#NAJ@D zn)a1HkFqtnS^4C>SEj}ow728l_XIZWsPf_!d!<;XFFUr2|0 zgsze;{Sw8N6Krl@(Hj4r_7&}ob$>byO?tmuix^hP&j&7gC~fkK4?xk6QyA}XmEGC+ zD07hP6E$`-*_mdV#Aj%(LhD`WuWliaZrUhN7n@5_vs#bbH?B@{$Y_oFLs-b#C$DRNW7(PrX+?{dV_nJ*!r)TD$ud_b@(B!Czd4By4E5CATi%E_|7+a_ru?BW1`wucrEJC9~HtvK}<$!Z~Oj=w

x=_8 zXy$8Wu-H$ey>3W@ijWG(1DPm?*xrRHnD$hB5m}`EV zFqx_veXa{zXa#kF*qC0hUP!&sl1*O_n#F<>@-R&^EDCeYLp>YIn?j(-D$xAIwn}qH z5qdje$Z>=%!Ug`QosB_U31(M)ncjC$V#2rAaHzanj}q%Yb1+9YZs~Z^WfvJ(2fIwc zM~Um?!73P;2V3eX2TfzgiE5ZO0pWABzAHFkkZ-XehF+~^4eJw+1)kG<5~`k(1<|HC zC&24`lR=YjWeZ6|uD04csGJTgGg4{>T_k#FwK=N00P;(@*~3uvO7TjWWp;5c+i}8C zYbMh;=L9mrRP1utLn7*CtMaY!SPPUN$YwJ3J{>iOs>kjHZD*8dt+Lx(<=h}4G_)#$ z@sl}wUBz|V4X4tAq71Jl!y1m(Q`X5XWa%nzg&H+UW8Xj34wrdbu8e6UE|y~A#N||t zeIOEK4^PUrfp*$+4%HzEp@jrR_2C{8{fAk5?t z(F)a)_6`oe8+BNTmc8xek=B1;u|R1_4y8fF!qf0U6PDWGhw+xsvH+DkTqXu|;PfYy z((=mEc?j(NkecFV4$aunHZD(n{BDj#GKkGcb#TD4o-eZdJ1P{!BXNe}>&DTTd%>S~ z>bekT@WMQT0N7j@-kAY&CK;T}&mvTpFbEGW@<23V++iqjo??)<%<#{)}> z9{g73oM~;?vYX{?uIh@nb`RPv$Abw&FF7D;7=K^Qoz}x$M9Hz%4ob=>o%C6U%OsP1 zu_r`6OC$=@Ra=0nH1}%)xH{but!KlV3&B7XmX5NXn4l4JJjr<`$79#D7e`3(x*f%w z3lc+eIcY%$Xzh~5!QT~Bf1oEGD*N=K)+CuM=AHYuO$w={VBtNhg#m0-r44IRR_8me zl{r5*^yPg)biGWq-@DB;0m6o&W0$*^^0%Z8?yZ3ax%uqMzXpL*DS4HlsN@|Oe2;9b zZZQiSWZyBf$CMuTMlY5FME(2~p*CA71?se7n#kcqz$6EInwLFXa!gjjqFj3H`tRiS&I>qpjvE7^blM4YZmGTd6-4d z)~@yQLwVS6R*uwE=?K;t?ArMfu~@5pvku)7j>*UONoUD=B@7JNX#wBh=YAR$6MU)M z+4j}FX)XA4ftG6j3*e2qk0KhoESm6~c#|obz?~BfYbF-5yP@na`kq_u0?3Fip1KfP~ut2NnAR82Zs<0|ulfLzDTrmH8Pl9PvwH`46@4 z?{$+;ow{PKTe*#?KnA&m5!k6yL`4RRr3C+zG1@ofa0YjbNr)Gj_7t=>C=3RKQJqQWkh@j%%Dwi>=) z85DK+(EpSS&XKN?Zf4fspNJ0=*$bg~v6|*6-B1uVrbgLfcD)3P&KrStC>OgtJAFc? z>zDI9&=}NsNQ>rpw8T$wfCP*JFs;~b?kuR(`*0sj1GIS-Ov;v;u1OZW>lL`uH^%^n z%TY0va96~^l$_CAia7>$CYL>@$H6;Sq3k5wwvOLq=iE}>U&=r+jLtiU=R0F@Z!N%x z7=t(^Lq4aqOwCY!(?c#Q&ma{&%d-Z@G5dr>^32Q@_RFW%P5Bz4z{~T=eztPSYUJbq zeD-d@u*{JM+_Zs;Y2?Ab4T*5QsZxmaNr$37V8NYyn!igr-?2xZHN{7b#KfI1NUk)A+8oUH9{HCqa`bdzcAG!$rE z?wB3HV?)L=z0O{12yUXS1FL<8(Env&!vkcn{K>XHLUG*3TESI@GwNXXCD2hp7?`dB z{Uov8leHoBL>nMP@U(S50Q&G8X_z&EFmN$amos<%isu9+sDCM-vs1Uh*|Bsu-Cm@g zi?%&#$J|!}6iNJ3F_lAd9UBP;DY?EatP!k+oq`T~y6v<=^VyMG}S zjG7`QsADSY@tK3xUwMNs*$eKu*y45DyV|4d8M_EpeE&Q+vL9c-3O{#Tof z_U`4VPKSt$=9euXL=%4Gr$02Ygu%fQU;9k0k_U9!QGKWqkL_<2A|3N!&cyy4i26-K zt$3!x*5n6$aX8iY2uZ4j)@%K3heyEB+T0Fi0D1c0i$9f=lkpZ&&@~!jmVM$iMF>aC zyF8OY6}6J*LWmsm8txWq#MXhXis~Uns z4*-^_1G>^n)@d1w`_t`_4=57(SVQRJCliZbGh*g@z(>t5d-v)#&xPXh!S^Mx(mg1VX@vHl2&BteMpHfY@(!h<#t_be5)bQo_-{7dA3 zPgpxTGF_V^#`t9CQT*GAJ@LwLPG68>nATb~fX}MvXi=_HVCgwUnZk45e`qA>*b!XC zx!c;L{W9J#UD!*wZhz|#D|4{w39$Z%&)g>LLEI;EoA4(UA7i|s$;omQ=B#JL80(W1 zzeDDGRODO*CA0JUo^!oVhILAndAo?eGxt%f1xwaq6EkT@e@qo?{&4H zgs{?06#Y!?BxLuX#xj0gfpqR+J_hoWOp#CmRzMAgMVg>Oi5lL4-c5x4^!vWcA<Ssl(va^H;xc;J`ZdGNnQam_O6GW+96PL zISn187>A;XB>-FCEs-s3LL|psd2HunKIS_; z+0WCzC;_YkA9g-uycAOp!wcwRR|jGcq_^i>L44s|@~W9Ap-`6>Z1!fMyj`3jII4pY zcd)d~Ma9V_4B`s?0`DgLr+WjN_LmyUd(@mt^f2GzF%+rTG1Sy+s041N`a)-aLz+uJ z%}Q$=o_z$z)h48R60QnZ_O>?Z%X9(nXNxMouYR#3=c!VBu~r#}NF6nZp|knxy1B~J zpmeQ>9hizKMS2Vd-W@vod7n%NHEuuIO};6C$TiqG=sCc zIz>=MZxl}BZm%;zn}K3)Co%rsJTU&iEuj78_xT^~_eL^I*zfxb zH6$h)=VG6NqtHg(DUHYwXnUyXmq&dTo@?C>KW5oer@O(VYF#!}5ZLQ)8f0^-cQq}p zc%wYGKjpV5l@*LIZS{GW$D0S84v>8_yXnM3<#&ZmeR-qFzMo794G$E&8{d5%sEKn% zlOr_uSTMszKED%mvj3hLYI8`xnd_XZb*<5HK4!H@o>L21b(H(jbFsvll@bQtRv+%N1FN< zS1bm~AtEe|!Vh9>65Mg69!ArC15>3vFVBOmMc%yJAG-uDODA`}KtHmSAz1K3 z!|InL_E61y0n;sm3#TC_dMAJyOu~n+b~`)Q+g{ft0KmnfICalg?%|xm0bf~jxj!2`{^ z272ZXX1e}Jp?EYgS2Fv#IdccTwXkK}%Dr9nFp%`Uztbg?wao=l%q%U+d|N<3@cE2& zH_SXsL(D4HOmv+Z@=rZVN5J=qq^OH&1(@9n2TJ$sNTS-es&0GXY^ss8&THq zC5t2&)-;B~M^IyM&rAsKTHCdeT03?mEl?XGbxJVpIcB=mC4fgB_VO32kZB8XK*yNU zFlD#N{MqF>A?9(9&AVhRo7u0TFo7X^?55b%4+yeT8&k7#7Lcvc8tNmUks|bhtx5hu zHaNE~NgVk&zKu*~&-9pW9|CmNi-G|Z*a5BQE0Om+KE{AJGR;}}4|Fo1Ym0$+{$t0~ zP3gKP-wCsCp}h$2yq7Jsl-#L;ahlR$x+O3($;(~hQoQB_3Ix@>?TE`AhqDDUpRFpF zgs-P%Ivsvjcl{o+5=xdj@f6SnojBGBK59>%=+leg?`&KS#Df<{{(SjvUkjZ>EejL6iglxIs>j7g;YkFB&ZT#d7G{U0a1IC#T0aU@dup?O z$%31j5N;~SmigG!$kTi_;D?-`mp75GaK-WE-Mv_uhSOMD_2OVilqL+}jbBN_nBc}|}iMy3c1;*bR)Q_O&>FAj3=4tn8 zK~6O0ZZ&oBg}fG9e8@=r=GyWIR(GTbzi?k>Sf8I*Whm`NTt!NGdlJ8L&4|A+uUw%c zKzvh4QQgP8ISXuVgo?;;{XxdWR=G44^YW_NawOWKsIT?TL%uQrs==riSgw-gEkFRX0=kuZefg5O-o>UZWE6uQiF?$cz+H`7qv;Bq&DR8V^ zn`Am`X!vk_-^`gFWvHpPF5Q)Z3F~5+b-v)X;q*}M=i!<<^%o<=favix+e=wgv6DE* zKe)LDwd4=v%pOLF?Y(U$6xGgRYN8_G=mna-!n=c-9F6odG28c`M|f&YijZH`Guy7d zqiw6JrBxE zO=i!KoiX}1+=)LJbzyX}VmhZ!$V%asuvs`r0I|du+%gC{LMJ;INRYX?s8;&f8SpA0BSVyI zzkK)W3iygmG9a9#O^JP1p8)qP7)9O%a{zolf5<#%ViMI{m0;{`8*jf^^P`*;jI^gh zL06JSpvAU&zc-Gttg8t&ZlZ=?(ybAfvr4&&K^CGJZuRAndBNqDXH+wUnzmeMj~?i` zKn}aaPIk*O5(gh*U2Q|2@~9d%`Uk1;;L+q^D1Yd4@KCQIwrng$K>lFryK76~0V7W6 z7ENNf4IKe^wq`TKE4VJq$J0y4_bh5kj+ioLkcS*guv^=RRE}Ks#Q5%~Dk@ruN?){G zyTAr%q#{zdZ`eTv(F)#gLky!l*1b*>03E`=y%+mJ?$R zDJ2WGREzN?RvHPkQ1AU$jT~S*2fz7+^WDWqpI;`XU6;C>5H0~ zCA7J+l)5TXLL3=Dm|dL6$AJxc8qMQjbzxt4BW2gxGRw72Ieq1&QpU{D}Tg)v>t_GKv)C^Bidyh)>JtJh<@wie8W9!9#t z!_3U9&&8W&PX`%$bjy)c(Di2LTK!9y-_}klkwl94&jM>w&gfv(uT!3)z*Q{MLp^E&(0yr zY7HA{J>gCtN3u+P<@24-rdD+wb_yo zWvwl3?@WZ4$l2ZM%0EA0HO<~e%e%naE7g$Xp}R_gi?iM@`YrR@KF07q=zCkmD-A=s ziY^ZyRwO!id|Su0%CY>Iitc$IW0$Hstcqzt$+Ewd! z(V@RG$<9A4L3VYl`tfJL=TH8$du;i$#lIaM3rjSl3%V1*>W{g`UYe5ul@yK(ooL0L z4N0Z7OlXMcV5xlnww4(SlNPi6d^aj%|E`Y_Vzt>TX_!Tss4`_!SbHZyL;LHp8-GvHiG<~K%fN2W^?{Tyj2>D9<-{zE-^&B378P=Yv!o%e# ze5i1__5GgTDoFk7cpK2#xzh9i3ue2HOHsGoqFEu&ts5lp0(+lD!o0WWYwF8zv-?x^ zo+(Y!HYP3YPMOqZC!+qi8i3u(lQgCOlte&d?VG=UY5|whPwhQ(p1H+rysQK44d(aV z*TVJ=UqAb7(&^p)QENF%D);>VzliDk46~Syo90UM?HUY>g~CEb-AP^c6OWOt4Wogv zt)U4c$j0t({{-a|1lbuFS(!MI7@C+_0Qt#|8(YapER6Zd)YxU2W$i>w%q=9{9ZZzn z7$+Hl&M)p@j|2+ONE*ralHUxD12fG)F zOdtb0CIBNd(?27>ROEYM!XxTnV&G)!pkixlE%5h|i(5FDxY{~cIg*Gfvoiun059Wa zWM*VxW+#y~FtPwT**co@G5u%Ve|6?xVNJvgoJ<5>V6ihWb1*P-s{ojJn7MdZxi$Z- z{@+FaQC-&7*uvE9|5BZmg9pI%kLv#>`bTv>roZ_78?V1zjK7BZALsvH9F2|siP6s4 z!TN7k%h-s?#M;Eh1nA`Wf)en5qBJ(*F|~EDF>n&FurV+*VX_07@iG1DezxVpZ zEiXo8`lnO>yX*hF!v8CUe{ug`3IF$?Z;iYP?i;Q*T(2VVD)F1U-f+E&z^lY>?s~)Z zDgv((zq#uT*Q*G;O8n-oH(akG@G9|}yWViUiomPHZ|-`-^(q3d62H0Y4cDs(yh{A$ zt~Xq-BJe8lo4ej{y^6rA#Bc6;!}TfxuM)qx>kZed2)s)C=B_tfuOjd&@teEeaJ`Da ztHf{adc*Z90?s~)ZDgv((|Bvp1 z``6kJ6X45&57(EK9T0vFOJHD#5>jHqDxkSZt@qk0n^;+66VfuI>=@t-nHI%G=Hc+B zz=H^Xe3H~`MU7;6sUHK?74EZiUM3okb852l0|zwazxOWZMAI&ZkRU4}J2{xQGtPCe zLh4;tbfJ5XukXEgG8#R%JjHkGLx%+*~=o0jbE2UHln7C0E`9zrSY)mscMFYwaS zZwzj=w*tu2zxjQis8=24I<{4U2$Gc{ zByB8-!tiy^ZqKvM3UX#(ZRXR_uoxng?1BOe%Ne@y&Y9!!!Qp)hdDG4*@BMyqbc@~> z=1-#VTkp-0e;t06MIbf(fWW$grPKPpmHX%Xd)IpHlx(^3$!?hRo1nYfXmdr=ljV^v zwOW zKPzzHZR%o%9O!i2Rr`ky*P3KyrYq+u_8ja(i4JJde*fseiYEv(zKkw1g7&;r&=?*0 zdGH86NSO(IGqPU@cN8*l+#{ToxIFKko^6SpYND{qO8E&Z5GXtTqU}Qe1slDxfFBdE ze!s48`+Hu0F&+9(TC*^FtzacM(eU6oWEbJ@Z&6_eE#PeU3I1^)=M?l9USITerOoK?LrgRjT_yB<8;|6$~(^^aok;KG_Rz6zDV&2 z;=5Ma1u*iIbD@pNq@iK-OIo$w$mz>2%X2Y@pGncgpSKmt5$ofq*8tXd4rbsf9KpL) z4tFLBph^g7z1htq@F6r-M;}X5_$D8{TH{1E?BdqLtuV{2?ML2&Omj{^kzX!!2MYn8r9KYp)u3^lIRWCBvsHWU&!GE;Ha{!96_omA|su}Kf z*B#tOQ!JVl3pFr8?@GUp!Vhixz$(G;J8p@;!#S#Zx7Q!e?_JL20MyA zO*gC-s5h#HPOMIEbscxpK~uLgIiB z2q^9yF0J_FZCZCM;_5Z@!NK3pbC1h!b|`c)KNzCRE}&3- zKN!I}9mdJZk0$!C`|*!PN`L?z0cL}6B51tfodTBzDZczj<%c~)RTo9d*l9)atGF^~ zTmuV+wJg4e_rcxi-*0Z^(CWJ$(F%LWD6WgWYxE%{D_DaoYCFmK|R8QJl%!3 z*|bZIo4yr_ND)L*WTTh4;*S?gGxwYPR&r2n_4L3S0hw*4KS+3-6+e|OxyyHk6zhB| z@fVyC&=?L{Ac=@pXqUQT0G7|GI7h#ZKYjtGa>uL_b)l(BXNKA9OnUtY`NG0KaEZp$ zj&Rv)sPIpI6YJI-t(Tlww7;dZXZ;`LfiUG4+7hkWOV%?(K98t7b|HPWSrYnV?x{~i z!=kFhR8ono?g93P+QQHi%H{<4BdL^DoXY$4nCMqD7j#sFmqn9tdff`+r;C7gPxbT3<{Ao1?!w8T}lGsvaj?ShfXpQ+jK=N`|UOh!ue`LMwSXhvDKK;&&8H)#0vGqnbi^SX8!3bQQ8t9;}!Cyag8^sDR@@jt_-< zvI>5Tn+9Plgu}A3nZbZ?^Q#iKt<&~Q`h&$rk|gcTKx~$AXFrawUmvFkt-cpmCmrOU zCGYUx``hQpjfKS@+I7Qr**{D@ZF*2&ndN-Gr<(Zg6M{Ec{^=5jEb%);8ae?Sqa#^T zgZ7UE80oI{4`YV!rJOGmpwRt*ebIM+9O=LBvytPV`!1sZ{7Zma6vFAK4>A68O6q3f z`@VrLEbh!QN)49Z@diC1xt=`ySHaPI?y+s%8rts-)fGFpfheaTr$e(Hz@HP+;L*mM zp_%LNf=JCYX{!rBp$y=pBEC_ZH0z7!qm!Qu4aIS?H`)R^gm!u(_)x;n_)%y*wk_gf z@8Nf6$ldKQQ*c|P6)z;^s4q7_K^^9H>3=1nWnM$0b^}H*V~8iU2|;F^l46J}i0@G* zvsWCp>LFp&kYQxD!H9+lkNJ}thXVT$LlH`X3Rc!rcrl=BGtH<_5xhTh;e2<6$kj*| zjm1ImT3*ocAGyLUPX59L6g-VCO8LoGhcP5~CCn9>Yr7Onk|`pgKXG*DtJO!pahoiy zuqD=aD2dM4YCe%$yL5o1NOXuioe#c515TUMH#Q@e+L+SVFzv9~! zKRxo=eSJb8SoVgSZ1sYd`=XIJvnSXh0s9~TKV0mueKCXQrXIi)nW*V%e2ESWl|&p& z$f&h47D@Q<^DB5gGlcB0AV~?^=i6KSfkm{vd_$U~Uf82QaQ0qF5>N#Bvjud(XvP4Id;So_|0mzk}kj>Lq2hc?i2kg`y$GBqo=QrUaP2gj$ zf-aH;t7Y6vTBa!5<@*Y5p5jd3*6H0vL6(B2Wfx~$-``=1AkLx!*?(_IVvqv7B=*F2 zlcVPrOnrPSJElaxU_6AF!+iFj z8$|_O+`qIq-{{4_!i}n|@f^jZX{-QOXTtfbjIn3e$o$|Fk?Mx&ba3#<)A9Ao916X3 zo@Gw5_o4T@@}Wgs4jx&p!`P3p^!%~zgDV}~)?f4JX~IX*{cvU`tmj)Q9J2elLRsu# zK~;&0Wbi&^QIy5*_{$$AU0@Glf}J)5H>&#>8jfW2YWo0}Bno1O_Pr<^_ zB2A)^UzEGfiaB+uXnnxKPOpv>zn2ZQDDXEwqwr76sPAt7fYLS8#%Ay4%vi%m?_`rA ztzk@p5=q@T?$b^5`HB49`!+nNI2);8 z_;oQu^l!wCyDp`brkVYyeXN`08`JzcDpzK*#`S}B9WVXds7!qqeKI$(7H2C>{cYkj z4Hiqz81yFy2~$ai(cyip7*C~|>J&F~%Nh>S9W`CNhV>DZ^)f1GOp@NTWd^Dq%TMXL z3ul09BOI@?{pi8dz`PvZkoX&+-#tv%yH5cLF=a?X;s+BWx9#6vHl=a4WAQFh{espo z4zm5^yH?I&?Qz_Um)A!xrboq0R_e(6aqcLua=K1XRICS@>olXVuF9T!N~iX+l?zFJ zn<8XqtovJ@$x=-GDI%JIh>_|?3K}M0xgRYDs9Ylf1%wAa=SN};6E#-E Tci{bhEwz{WC?{4XqVNA-TM;k( literal 0 HcmV?d00001 diff --git a/docs/_static/github_issue_links.css b/docs/_static/github_issue_links.css new file mode 100644 index 000000000..af4be86ce --- /dev/null +++ b/docs/_static/github_issue_links.css @@ -0,0 +1,24 @@ +.github-issue-link-container { + padding-right: 0.5rem; +} +.github-issue-link { + font-size: var(--font-size--small); + font-weight: bold; + background-color: #DD4814; + padding: 13px 23px; + text-decoration: none; +} +.github-issue-link:link { + color: #FFFFFF; +} +.github-issue-link:visited { + color: #FFFFFF +} +.muted-link.github-issue-link:hover { + color: #FFFFFF; + text-decoration: underline; +} +.github-issue-link:active { + color: #FFFFFF; + text-decoration: underline; +} diff --git a/docs/_static/github_issue_links.js b/docs/_static/github_issue_links.js new file mode 100644 index 000000000..980609cd0 --- /dev/null +++ b/docs/_static/github_issue_links.js @@ -0,0 +1,26 @@ +window.onload = function() { + const link = document.createElement("a"); + link.classList.add("muted-link"); + link.classList.add("github-issue-link"); + link.text = "Give feedback"; + link.href = ( + github_url + + "/issues/new?" + + "title=docs%3A+TYPE+YOUR+QUESTION+HERE" + + "&body=*Please describe the question or issue you're facing with " + + `"${document.title}"` + + ".*" + + "%0A%0A%0A%0A%0A" + + "---" + + "%0A" + + `*Reported+from%3A+${location.href}*` + ); + link.target = "_blank"; + + const div = document.createElement("div"); + div.classList.add("github-issue-link-container"); + div.append(link) + + const container = document.querySelector(".article-container > .content-icon-container"); + container.prepend(div); +}; diff --git a/docs/_templates/base.html b/docs/_templates/base.html new file mode 100644 index 000000000..62ffe6b8c --- /dev/null +++ b/docs/_templates/base.html @@ -0,0 +1,7 @@ +{% extends "furo/base.html" %} + +{% block theme_scripts %} + +{% endblock theme_scripts %} diff --git a/docs/_templates/footer.html b/docs/_templates/footer.html new file mode 100644 index 000000000..759448687 --- /dev/null +++ b/docs/_templates/footer.html @@ -0,0 +1,90 @@ +{# ru-fu: copied from Furo, with modifications as stated below #} + +

+
+
+ {%- if show_copyright %} + + {%- endif %} + + {# ru-fu: removed "Made with" #} + + {%- if last_updated -%} +
+ {% trans last_updated=last_updated|e -%} + Last updated on {{ last_updated }} + {%- endtrans -%} +
+ {%- endif %} + + {%- if show_source and has_source and sourcename %} + + {%- endif %} +
+
+ + {# ru-fu: replaced RTD icons with our links #} + + {% if discourse %} + + {% endif %} + + {% if github_url and github_version and github_folder %} + + {% if github_issues %} + + {% endif %} + + + {% endif %} + + +
+
+ diff --git a/docs/_templates/page.html b/docs/_templates/page.html new file mode 100644 index 000000000..ede3495c8 --- /dev/null +++ b/docs/_templates/page.html @@ -0,0 +1,44 @@ +{% extends "furo/page.html" %} + +{% block footer %} + {% include "footer.html" %} +{% endblock footer %} + +{% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} + {% set furo_hide_toc_orig = furo_hide_toc %} + {% set furo_hide_toc=false %} +{% endif %} + +{% block right_sidebar %} +
+ {% if not furo_hide_toc_orig %} +
+ + {{ _("Contents") }} + +
+
+
+ {{ toc }} +
+
+ {% endif %} + {% if meta and ((meta.discourse and discourse_prefix) or meta.relatedlinks) %} + + + {% endif %} +
+{% endblock right_sidebar %} diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..e0cceed92 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,161 @@ +# ruff: noqa +import sys +import os + +sys.path.append('./') +from custom_conf import * +sys.path.append('.sphinx/') +from build_requirements import * + +# Configuration file for the Sphinx documentation builder. +# You should not do any modifications to this file. Put your custom +# configuration into the custom_conf.py file. +# If you need to change this file, contribute the changes upstream. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +############################################################ +### Extensions +############################################################ + +extensions = [ + 'sphinx_design', + 'sphinx_copybutton', + 'sphinxcontrib.jquery', +] + +# Only add redirects extension if any redirects are specified. +if AreRedirectsDefined(): + extensions.append('sphinx_reredirects') + +# Only add myst extensions if any configuration is present. +if IsMyStParserUsed(): + extensions.append('myst_parser') + + # Additional MyST syntax + myst_enable_extensions = [ + 'substitution', + 'deflist', + 'linkify' + ] + myst_enable_extensions.extend(custom_myst_extensions) + +# Only add Open Graph extension if any configuration is present. +if IsOpenGraphConfigured(): + extensions.append('sphinxext.opengraph') + +extensions.extend(custom_extensions) +extensions = DeduplicateExtensions(extensions) + +### Configuration for extensions + +# Used for related links +if not 'discourse_prefix' in html_context and 'discourse' in html_context: + html_context['discourse_prefix'] = html_context['discourse'] + '/t/' + +# The URL prefix for the notfound extension depends on whether the documentation uses versions. +# For documentation on documentation.ubuntu.com, we also must add the slug. +url_version = '' +url_lang = '' + +# Determine if the URL uses versions and language +if 'READTHEDOCS_CANONICAL_URL' in os.environ and os.environ['READTHEDOCS_CANONICAL_URL']: + url_parts = os.environ['READTHEDOCS_CANONICAL_URL'].split('/') + + if len(url_parts) >= 2 and 'READTHEDOCS_VERSION' in os.environ and os.environ['READTHEDOCS_VERSION'] == url_parts[-2]: + url_version = url_parts[-2] + '/' + + if len(url_parts) >= 3 and 'READTHEDOCS_LANGUAGE' in os.environ and os.environ['READTHEDOCS_LANGUAGE'] == url_parts[-3]: + url_lang = url_parts[-3] + '/' + +# Set notfound_urls_prefix to the slug (if defined) and the version/language affix +if slug: + notfound_urls_prefix = '/' + slug + '/' + url_lang + url_version +elif len(url_lang + url_version) > 0: + notfound_urls_prefix = '/' + url_lang + url_version +else: + notfound_urls_prefix = '' + +notfound_context = { + 'title': 'Page not found', + 'body': '

Sorry, but the documentation page that you are looking for was not found.

\n\n

Documentation changes over time, and pages are moved around. We try to redirect you to the updated content where possible, but unfortunately, that didn\'t work this time (maybe because the content you were looking for does not exist in this version of the documentation).

\n

You can try to use the navigation to locate the content you\'re looking for, or search for a similar page.

\n', +} + +# Default image for OGP (to prevent font errors, see +# https://github.com/canonical/sphinx-docs-starter-pack/pull/54 ) +if not 'ogp_image' in locals(): + ogp_image = 'https://assets.ubuntu.com/v1/253da317-image-document-ubuntudocs.svg' + +############################################################ +### General configuration +############################################################ + +exclude_patterns = [ + '_build', + 'Thumbs.db', + '.DS_Store', + '.sphinx', +] +exclude_patterns.extend(custom_excludes) + +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + +if not 'conf_py_path' in html_context and 'github_folder' in html_context: + html_context['conf_py_path'] = html_context['github_folder'] + +# For ignoring specific links +linkcheck_anchors_ignore_for_url = [ + r'https://github\.com/.*' +] +linkcheck_anchors_ignore_for_url.extend(custom_linkcheck_anchors_ignore_for_url) + +# Tags cannot be added directly in custom_conf.py, so add them here +for tag in custom_tags: + tags.add(tag) + +############################################################ +### Styling +############################################################ + +# Find the current builder +builder = 'dirhtml' +if '-b' in sys.argv: + builder = sys.argv[sys.argv.index('-b')+1] + +# Setting templates_path for epub makes the build fail +if builder == 'dirhtml' or builder == 'html': + templates_path = ['.sphinx/_templates'] + notfound_template = '404.html' + +# Theme configuration +html_theme = 'furo' +html_last_updated_fmt = '' +html_permalinks_icon = '¶' + +if html_title == '': + html_theme_options = { + 'sidebar_hide_name': True + } + +############################################################ +### Additional files +############################################################ + +html_static_path = ['.sphinx/_static'] + +html_css_files = [ + 'custom.css', + 'header.css', + 'github_issue_links.css', + 'furo_colors.css' +] +html_css_files.extend(custom_html_css_files) + +html_js_files = ['header-nav.js'] +if 'github_issues' in html_context and html_context['github_issues'] and not disable_feedback_button: + html_js_files.append('github_issue_links.js') +html_js_files.extend(custom_html_js_files) diff --git a/docs/custom_conf.py b/docs/custom_conf.py new file mode 100644 index 000000000..37ad32efa --- /dev/null +++ b/docs/custom_conf.py @@ -0,0 +1,313 @@ +# ruff: noqa +import datetime +import pathlib +import sys + +import furo +import furo.navigation + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +# Furo patch to get local TOC to show in sidebar (as sphinx-rtd-theme did) +# See https://github.com/pradyunsg/furo/blob/490527b2aef00b1198770c3389a1979911ee1fcb/src/furo/__init__.py#L115-L128 + +_old_compute_navigation_tree = furo._compute_navigation_tree + + +def _compute_navigation_tree(context): + tree_html = _old_compute_navigation_tree(context) + if not tree_html and context.get("toc"): + tree_html = furo.navigation.get_navigation_tree(context["toc"]) + return tree_html + + +furo._compute_navigation_tree = _compute_navigation_tree + +# Pull in fix from https://github.com/sphinx-doc/sphinx/pull/11222/files to fix +# "invalid signature for autoattribute ('ops.pebble::ServiceDict.backoff-delay')" +import re # noqa: E402 +import sphinx.ext.autodoc # noqa: E402 +sphinx.ext.autodoc.py_ext_sig_re = re.compile( + r'''^ ([\w.]+::)? # explicit module name + ([\w.]+\.)? # module and/or class name(s) + ([^.()]+) \s* # thing name + (?: \((.*)\) # optional: arguments + (?:\s* -> \s* (.*))? # return annotation + )? $ # and nothing more + ''', re.VERBOSE) + +# Custom configuration for the Sphinx documentation builder. +# All configuration specific to your project should be done in this file. +# +# The file is included in the common conf.py configuration file. +# You can modify any of the settings below or add any configuration that +# is not covered by the common conf.py file. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +# +# If you're not familiar with Sphinx and don't want to use advanced +# features, it is sufficient to update the settings in the "Project +# information" section. + +############################################################ +### Project information +############################################################ + +# Product name +project = 'Scenario' +author = 'Canonical Ltd.' + +# The title you want to display for the documentation in the sidebar. +# You might want to include a version number here. +# To not display any title, set this option to an empty string. +html_title = project + ' documentation' + +# The default value uses the current year as the copyright year. +# +# For static works, it is common to provide the year of first publication. +# Another option is to give the first year and the current year +# for documentation that is often changed, e.g. 2022–2023 (note the en-dash). +# +# A way to check a GitHub repo's creation date is to obtain a classic GitHub +# token with 'repo' permissions here: https://github.com/settings/tokens +# Next, use 'curl' and 'jq' to extract the date from the GitHub API's output: +# +# curl -H 'Authorization: token ' \ +# -H 'Accept: application/vnd.github.v3.raw' \ +# https://api.github.com/repos/canonical/ | jq '.created_at' + +copyright = '%s, %s' % (datetime.date.today().year, author) + +## Open Graph configuration - defines what is displayed as a link preview +## when linking to the documentation from another website (see https://ogp.me/) +# The URL where the documentation will be hosted (leave empty if you +# don't know yet) +# NOTE: If no ogp_* variable is defined (e.g. if you remove this section) the +# sphinxext.opengraph extension will be disabled. +ogp_site_url = '' +# The documentation website name (usually the same as the product name) +ogp_site_name = project +# The URL of an image or logo that is used in the preview +ogp_image = '' + +# Update with the local path to the favicon for your product +# (default is the circle of friends) +html_favicon = '.sphinx/_static/favicon.png' + +# (Some settings must be part of the html_context dictionary, while others +# are on root level. Don't move the settings.) +html_context = { + + # Change to the link to the website of your product (without "https://") + # For example: "ubuntu.com/lxd" or "microcloud.is" + # If there is no product website, edit the header template to remove the + # link (see the readme for instructions). + 'product_page': 'juju.is/docs/sdk', + + # Add your product tag (the orange part of your logo, will be used in the + # header) to ".sphinx/_static" and change the path here (start with "_static") + # (default is the circle of friends) + 'product_tag': '_static/tag.png', + + # Change to the discourse instance you want to be able to link to + # using the :discourse: metadata at the top of a file + # (use an empty value if you don't want to link) + 'discourse': 'https://discourse.charmhub.io/', + + # Change to the Mattermost channel you want to link to + # (use an empty value if you don't want to link) + 'mattermost': '', + + # Change to the Matrix channel you want to link to + # (use an empty value if you don't want to link) + 'matrix': 'https://matrix.to/#/#charmhub-charmdev:ubuntu.com', + + # Change to the GitHub URL for your project + 'github_url': 'https://github.com/canonical/ops-scenario', + + # Change to the branch for this version of the documentation + 'github_version': 'main', + + # Change to the folder that contains the documentation + # (usually "/" or "/docs/") + 'github_folder': '/docs/', + + # Change to an empty value if your GitHub repo doesn't have issues enabled. + # This will disable the feedback button and the issue link in the footer. + 'github_issues': 'enabled', + + # Controls the existence of Previous / Next buttons at the bottom of pages + # Valid options: none, prev, next, both + 'sequential_nav': "none" +} + +# If your project is on documentation.ubuntu.com, specify the project +# slug (for example, "lxd") here. +slug = "" + +############################################################ +### Redirects +############################################################ + +# Set up redirects (https://documatt.gitlab.io/sphinx-reredirects/usage.html) +# For example: 'explanation/old-name.html': '../how-to/prettify.html', +# You can also configure redirects in the Read the Docs project dashboard +# (see https://docs.readthedocs.io/en/stable/guides/redirects.html). +# NOTE: If this variable is not defined, set to None, or the dictionary is empty, +# the sphinx_reredirects extension will be disabled. +redirects = {} + +############################################################ +### Link checker exceptions +############################################################ + +# Links to ignore when checking links +linkcheck_ignore = [ + 'http://127.0.0.1:8000' + ] + +# Pages on which to ignore anchors +# (This list will be appended to linkcheck_anchors_ignore_for_url) +custom_linkcheck_anchors_ignore_for_url = [] + +############################################################ +### Additions to default configuration +############################################################ + +## The following settings are appended to the default configuration. +## Use them to extend the default functionality. +# NOTE: Remove this variable to disable the MyST parser extensions. +custom_myst_extensions = [] + +# Add custom Sphinx extensions as needed. +# This array contains recommended extensions that should be used. +# NOTE: The following extensions are handled automatically and do +# not need to be added here: myst_parser, sphinx_copybutton, sphinx_design, +# sphinx_reredirects, sphinxcontrib.jquery, sphinxext.opengraph +custom_extensions = [ + 'sphinx_tabs.tabs', + 'canonical.youtube-links', + 'canonical.related-links', + 'canonical.custom-rst-roles', + 'canonical.terminal-output', + 'notfound.extension', + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.napoleon', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + ] + +# Add custom required Python modules that must be added to the +# .sphinx/requirements.txt file. +# NOTE: The following modules are handled automatically and do not need to be +# added here: canonical-sphinx-extensions, furo, linkify-it-py, myst-parser, +# pyspelling, sphinx, sphinx-autobuild, sphinx-copybutton, sphinx-design, +# sphinx-notfound-page, sphinx-reredirects, sphinx-tabs, sphinxcontrib-jquery, +# sphinxext-opengraph +custom_required_modules = [] + +# Add files or directories that should be excluded from processing. +custom_excludes = [ + 'doc-cheat-sheet*', + ] + +# Add CSS files (located in .sphinx/_static/) +custom_html_css_files = [] + +# Add JavaScript files (located in .sphinx/_static/) +custom_html_js_files = [] + +## The following settings override the default configuration. + +# Specify a reST string that is included at the end of each file. +# If commented out, use the default (which pulls the reuse/links.txt +# file into each reST file). +# custom_rst_epilog = '' + +# By default, the documentation includes a feedback button at the top. +# You can disable it by setting the following configuration to True. +disable_feedback_button = False + +# Add tags that you want to use for conditional inclusion of text +# (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#tags) +custom_tags = [] + +############################################################ +### Additional configuration +############################################################ + +## Add any configuration that is not covered by the common conf.py file. + +# Define a :center: role that can be used to center the content of table cells. +rst_prolog = ''' +.. role:: center + :class: align-center +''' + + +# -- Options for sphinx.ext.todo --------------------------------------------- + +# If this is True, todo and todolist produce output, else they +# produce nothing. The default is False. +todo_include_todos = False + + +# -- Options for sphinx.ext.autodoc ------------------------------------------ + +# This value controls how to represents typehints. The setting takes the +# following values: +# 'signature' – Show typehints as its signature (default) +# 'description' – Show typehints as content of function or method +# 'none' – Do not show typehints +autodoc_typehints = 'signature' + +# This value selects what content will be inserted into the main body of an +# autoclass directive. The possible values are: +# 'class' - Only the class’ docstring is inserted. This is the +# default. You can still document __init__ as a separate method +# using automethod or the members option to autoclass. +# 'both' - Both the class’ and the __init__ method’s docstring are +# concatenated and inserted. +# 'init' - Only the __init__ method’s docstring is inserted. +autoclass_content = 'class' + +# This value selects if automatically documented members are sorted +# alphabetical (value 'alphabetical'), by member type (value +# 'groupwise') or by source order (value 'bysource'). The default is +# alphabetical. +autodoc_member_order = 'alphabetical' + +autodoc_default_options = { + 'members': None, # None here means "yes" + 'undoc-members': None, + 'show-inheritance': None, +} + +# -- Options for sphinx.ext.intersphinx -------------------------------------- + +# This config value contains the locations and names of other projects +# that should be linked to in this documentation. +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'ops': ('https://ops.readthedocs.io/en/latest/', None), +} + +# -- General configuration --------------------------------------------------- + +# If true, Sphinx will warn about all references where the target +# cannot be found. +nitpicky = True + +# A list of (type, target) tuples (by default empty) that should be ignored when +# generating warnings in “nitpicky mode”. Note that type should include the +# domain name if present. Example entries would be ('py:func', 'int') or +# ('envvar', 'LD_LIBRARY_PATH'). +nitpick_ignore = [ + # Please keep this list sorted alphabetically. + ('py:class', 'AnyJson'), + ('py:class', '_CharmSpec'), + ('py:class', 'scenario.state._DCBase'), + ('py:class', 'scenario.state._EntityStatus'), +] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..1a261b3d6 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,38 @@ + +Scenario API reference +====================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +scenario.State +============== + +.. automodule:: scenario.state + + +scenario.Context +================ + +.. automodule:: scenario.context + + +scenario.consistency_checker +============================ + +.. automodule:: scenario.consistency_checker + + +scenario.capture_events +======================= + +.. automodule:: scenario.capture_events + + +Indices +======= + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..7b02bdf09 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,148 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml +# +alabaster==0.7.13 + # via sphinx +babel==2.14.0 + # via sphinx +beautifulsoup4==4.12.3 + # via + # canonical-sphinx-extensions + # furo + # pyspelling +bracex==2.4 + # via wcmatch +canonical-sphinx-extensions==0.0.19 + # via ops-scenario (pyproject.toml) +certifi==2024.2.2 + # via requests +charset-normalizer==3.3.2 + # via requests +colorama==0.4.6 + # via sphinx-autobuild +docutils==0.19 + # via + # canonical-sphinx-extensions + # myst-parser + # sphinx + # sphinx-tabs +furo==2024.1.29 + # via ops-scenario (pyproject.toml) +html5lib==1.1 + # via pyspelling +idna==3.6 + # via requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.3 + # via + # myst-parser + # sphinx +linkify-it-py==2.0.3 + # via ops-scenario (pyproject.toml) +livereload==2.6.3 + # via sphinx-autobuild +lxml==5.2.1 + # via pyspelling +markdown==3.6 + # via pyspelling +markdown-it-py==3.0.0 + # via + # mdit-py-plugins + # myst-parser +markupsafe==2.1.5 + # via jinja2 +mdit-py-plugins==0.4.0 + # via myst-parser +mdurl==0.1.2 + # via markdown-it-py +myst-parser==2.0.0 + # via ops-scenario (pyproject.toml) +ops==2.12.0 + # via ops-scenario (pyproject.toml) +packaging==24.0 + # via sphinx +pygments==2.17.2 + # via + # furo + # sphinx + # sphinx-tabs +pyspelling==2.10 + # via ops-scenario (pyproject.toml) +pyyaml==6.0.1 + # via + # myst-parser + # ops + # ops-scenario (pyproject.toml) + # pyspelling +requests==2.31.0 + # via + # canonical-sphinx-extensions + # sphinx +six==1.16.0 + # via + # html5lib + # livereload +snowballstemmer==2.2.0 + # via sphinx +soupsieve==2.5 + # via + # beautifulsoup4 + # pyspelling +sphinx==6.2.1 + # via + # canonical-sphinx-extensions + # furo + # myst-parser + # ops-scenario (pyproject.toml) + # sphinx-autobuild + # sphinx-basic-ng + # sphinx-copybutton + # sphinx-design + # sphinx-notfound-page + # sphinx-tabs + # sphinxcontrib-jquery + # sphinxext-opengraph +sphinx-autobuild==2024.2.4 + # via ops-scenario (pyproject.toml) +sphinx-basic-ng==1.0.0b2 + # via furo +sphinx-copybutton==0.5.2 + # via ops-scenario (pyproject.toml) +sphinx-design==0.5.0 + # via ops-scenario (pyproject.toml) +sphinx-notfound-page==1.0.0 + # via ops-scenario (pyproject.toml) +sphinx-tabs==3.4.5 + # via ops-scenario (pyproject.toml) +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-jquery==4.1 + # via ops-scenario (pyproject.toml) +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +sphinxext-opengraph==0.9.1 + # via ops-scenario (pyproject.toml) +tornado==6.4 + # via livereload +uc-micro-py==1.0.3 + # via linkify-it-py +urllib3==2.2.1 + # via requests +wcmatch==8.5.1 + # via pyspelling +webencodings==0.5.1 + # via html5lib +websocket-client==1.7.0 + # via ops diff --git a/pyproject.toml b/pyproject.toml index 7c2b19953..1c1ae2ae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.1" +version = "6.1.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.10", + "ops>=2.12", "PyYAML>=6.0.1", ] readme = "README.md" @@ -38,6 +38,22 @@ classifiers = [ "Homepage" = "https://github.com/canonical/ops-scenario" "Bug Tracker" = "https://github.com/canonical/ops-scenario/issues" +[project.optional-dependencies] +docs = [ + "canonical-sphinx-extensions", + "furo", + "linkify-it-py", + "myst-parser", + "pyspelling", + "sphinx==6.2.1", + "sphinx-autobuild", + "sphinx-copybutton", + "sphinx-design", + "sphinx-notfound-page", + "sphinx-tabs", + "sphinxcontrib-jquery", + "sphinxext-opengraph" +] [tool.setuptools.package-dir] scenario = "scenario" diff --git a/scenario/context.py b/scenario/context.py index a3e2a8ea2..6e07576c6 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -28,22 +28,25 @@ @dataclasses.dataclass class ActionOutput: - """Wraps the results of running an action event with `run_action`.""" + """Wraps the results of running an action event with ``run_action``.""" 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] - """Any logs associated with the action output, set by the charm.""" + """Any logs associated with the action output, set by the charm with + :meth:`ops.ActionEvent.log`.""" results: Optional[Dict[str, Any]] """Key-value mapping assigned by the charm as a result of the action. - Will be None if the charm never calls action-set.""" + Will be None if the charm never calls :meth:`ops.ActionEvent.set_results`.""" failure: Optional[str] = None - """If the action is not a success: the message the charm set when failing the action.""" + """None if the action was successful, otherwise the message the charm set with + :meth:`ops.ActionEvent.fail`.""" @property def success(self) -> bool: - """Return whether this action was a success.""" + """True if this action was a success, False otherwise.""" return self.failure is None @@ -60,7 +63,7 @@ class ContextSetupError(RuntimeError): class AlreadyEmittedError(RuntimeError): - """Raised when _runner.run() is called more than once.""" + """Raised when ``run()`` is called more than once.""" class _Manager: @@ -155,7 +158,76 @@ def _get_output(self): class Context: - """Scenario test execution context.""" + """Represents a simulated charm's execution context. + + It is the main entry point to running a scenario test. + + 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``. + + 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``: + + - :attr:`juju_log`: record of what the charm has sent to juju-log + - :attr:`app_status_history`: record of the app statuses the charm has set + - :attr:`unit_status_history`: record of the unit statuses the charm has set + - :attr:`workload_version_history`: record of the workload versions the charm has set + - :attr:`emitted_events`: record of the events (including custom) that the charm has processed + + This allows you to write assertions not only on the output state, but also, to some + extent, on the path the charm took to get there. + + A typical scenario test will look like:: + + from scenario import Context, State + from ops import ActiveStatus + from charm import MyCharm, MyCustomEvent # noqa + + def test_foo(): + # Arrange: set the context up + c = Context(MyCharm) + # Act: prepare the state and emit an event + state_out = c.run('update-status', State()) + # Assert: verify the output state is what you think it should be + assert state_out.unit_status == ActiveStatus('foobar') + # Assert: verify the Context contains what you think it should + assert len(c.emitted_events) == 4 + assert isinstance(c.emitted_events[3], MyCustomEvent) + + If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the + execution cwd, you need to use the ``charm_root`` argument. For example:: + + import scenario + import tempfile + virtual_root = tempfile.TemporaryDirectory() + local_path = Path(local_path.name) + (local_path / 'foo').mkdir() + (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + scenario.Context(... charm_root=virtual_root).run(...) + + Args: + charm_type: the CharmBase subclass to call :meth:`ops.main` on. + meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). + If none is provided, we will search for a ``metadata.yaml`` file in the charm root. + actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). + If none is provided, we will search for a ``actions.yaml`` file in the charm root. + config: charm config to use. Needs to be a valid config.yaml format (as a dict). + If none is provided, we will search for a ``config.yaml`` file in the charm root. + juju_version: Juju agent version to simulate. + app_name: App name that this charm is deployed as. Defaults to the charm name as + defined in its metadata + unit_id: Unit ID that this charm is deployed as. Defaults to 0. + charm_root: virtual charm root the charm will be executed with. + """ def __init__( self, @@ -303,7 +375,8 @@ def _set_output_state(self, output_state: "State"): def output_state(self) -> "State": """The output state obtained by running an event on this context. - Will raise an exception if this Context hasn't been run yet. + Raises: + RuntimeError: if this ``Context`` hasn't been :meth:`run` yet. """ if not self._output_state: raise RuntimeError( @@ -405,15 +478,16 @@ def manager( ): """Context manager to introspect live charm object before and after the event is emitted. - Usage: - >>> with Context().manager("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 + Usage:: - :arg event: the Event that the charm will respond to. Can be a string or an Event instance. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Event. + with Context().manager("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 + + Args: + event: the :class:`Event` that the charm will respond to. + state: the :class:`State` instance to use when handling the Event. """ return _EventManager(self, event, state) diff --git a/scenario/state.py b/scenario/state.py index 3fb0a26a5..70c8e9856 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +"""The core Scenario State object, and the components inside it.""" + import copy import dataclasses import datetime @@ -47,11 +50,11 @@ from scenario import Context - PathLike = Union[str, Path] - AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] - AnyJson = Union[str, bool, dict, int, float, list] - RawSecretRevisionContents = RawDataBagContents = Dict[str, str] - UnitID = int +PathLike = Union[str, Path] +AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] +AnyJson = Union[str, bool, dict, int, float, list] +RawSecretRevisionContents = RawDataBagContents = Dict[str, str] +UnitID = int CharmType = TypeVar("CharmType", bound=CharmBase) @@ -340,14 +343,29 @@ def normalize_name(s: str): @dataclasses.dataclass(frozen=True) class Address(_DCBase): + """An address in a Juju network space.""" + hostname: str + """A host name that maps to the address in :attr:`value`.""" value: str + """The IP address in the space.""" cidr: str - address: str = "" # legacy + """The CIDR of the address in :attr:`value`.""" + + @property + def address(self): + """A deprecated alias for :attr:`value`.""" + return self.value + + @address.setter + def address(self, value): + object.__setattr__(self, "value", value) @dataclasses.dataclass(frozen=True) class BindAddress(_DCBase): + """An address bound to a network interface in a Juju space.""" + interface_name: str addresses: List[Address] mac_address: Optional[str] = None @@ -528,15 +546,21 @@ def broken_event(self) -> "Event": @dataclasses.dataclass(frozen=True) class Relation(RelationBase): + """An integration between the charm and another application.""" + remote_app_name: str = "remote" + """The name of the remote application, as in the charm's metadata.""" # local limit limit: int = 1 + """The maximum number of integrations on this endpoint.""" remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """The current content of the application databag.""" remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, # dedup ) + """The current content of the databag for each unit in the relation.""" @property def _remote_app_name(self) -> str: @@ -601,10 +625,12 @@ def remote_unit_name(self) -> str: @dataclasses.dataclass(frozen=True) class PeerRelation(RelationBase): + """A relation to share data between units of the charm.""" + peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, ) - # mapping from peer unit IDs to their databag contents. + """Current contents of the peer databags.""" # Consistency checks will validate that *this unit*'s ID is not in here. @property @@ -634,12 +660,17 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) class Model(_DCBase): + """The Juju model in which the charm is deployed.""" + name: str = dataclasses.field(default_factory=_random_model_name) + """The name of the model.""" uuid: str = dataclasses.field(default_factory=lambda: str(uuid4())) + """A unique identifier for the model, typically generated by Juju.""" # whatever juju models --format=json | jq '.models[].type' gives back. # TODO: make this exhaustive. type: Literal["kubernetes", "lxd"] = "kubernetes" + """The type of Juju model.""" cloud_spec: Optional[CloudSpec] = None """Cloud specification information (metadata) including credentials.""" @@ -664,9 +695,14 @@ def _generate_new_change_id(): @dataclasses.dataclass(frozen=True) class ExecOutput: + """Mock data for simulated :meth:`ops.Container.exec` calls.""" + return_code: int = 0 + """The return code of the process (0 is success).""" stdout: str = "" + """Any content written to stdout by the process.""" stderr: str = "" + """Any content written to stderr by the process.""" # change ID: used internally to keep track of mocked processes _change_id: int = dataclasses.field(default_factory=_generate_new_change_id) @@ -680,8 +716,12 @@ def _run(self) -> int: @dataclasses.dataclass(frozen=True) class Mount(_DCBase): + """Maps local files to a :class:`Container` filesystem.""" + location: Union[str, PurePosixPath] + """The location inside of the container.""" src: Union[str, Path] + """The content to provide when the charm does :meth:`ops.Container.pull`.""" def _now_utc(): @@ -776,8 +816,13 @@ def event(self): @dataclasses.dataclass(frozen=True) class Container(_DCBase): + """A Kubernetes container where a charm's workload runs.""" + name: str + """Name of the container, as found in the charm metadata.""" + can_connect: bool = False + """When False, all Pebble operations will fail.""" # This is the base plan. On top of it, one can add layers. # We need to model pebble in this way because it's impossible to retrieve the layers from @@ -788,28 +833,50 @@ class Container(_DCBase): # We expect most of the user-facing testing to be covered by this 'layers' attribute, # as all will be known when unit-testing. layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) + """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( default_factory=dict, ) + """The current status of each Pebble service running in the container.""" - # this is how you specify the contents of the filesystem: suppose you want to express that your - # container has: - # - /home/foo/bar.py - # - /bin/bash - # - /bin/baz - # - # this becomes: - # mounts = { - # 'foo': Mount('/home/foo/', Path('/path/to/local/dir/containing/bar/py/')) - # 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')) - # } - # when the charm runs `pebble.pull`, it will return .open() from one of those paths. + # when the charm runs `pebble.pull`, it will return .open() from one of these paths. # when the charm pushes, it will either overwrite one of those paths (careful!) or it will # create a tempfile and insert its path in the mock filesystem tree mounts: Dict[str, Mount] = dataclasses.field(default_factory=dict) + """Provides access to the contents of the simulated container filesystem. + + For example, suppose you want to express that your container has: + + * ``/home/foo/bar.py`` + * ``/bin/bash`` + * ``/bin/baz`` + + this becomes:: + + mounts = { + 'foo': scenario.Mount('/home/foo', Path('/path/to/local/dir/containing/bar/py/')), + 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')), + } + """ exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + """Simulate executing commands in the container. + + Specify each command the charm might run in the container and a :class:`ExecOutput` + containing its return code and any stdout/stderr. + + For example:: + + container = scenario.Container( + name='foo', + exec_mock={ + ('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu') + ('dig', '+short', 'canonical.com'): + scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21') + } + ) + """ notices: List[Notice] = dataclasses.field(default_factory=list) @@ -824,7 +891,7 @@ def _render_services(self): @property def plan(self) -> pebble.Plan: - """The 'computed' pebble plan. + """The 'computed' Pebble plan. i.e. the base plan plus the layers that have been added on top. You should run your assertions on this plan, not so much on the layers, as those are @@ -842,7 +909,7 @@ def plan(self) -> pebble.Plan: @property def services(self) -> Dict[str, pebble.ServiceInfo]: - """The pebble services as rendered in the plan.""" + """The Pebble services as rendered in the plan.""" services = self._render_services() infos = {} # type: Dict[str, pebble.ServiceInfo] names = sorted(services.keys()) @@ -867,7 +934,12 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: return infos def get_filesystem(self, ctx: "Context") -> Path: - """Simulated pebble filesystem in this context.""" + """Simulated Pebble filesystem in this context. + + Returns: + A temporary filesystem containing any files or directories the + charm pushed to the container. + """ return ctx._get_container_root(self.name) @property @@ -974,6 +1046,7 @@ class Port(_DCBase): """Represents a port on the charm host.""" protocol: _RawPortProtocolLiteral + """The protocol that data transferred over the port will use.""" port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" @@ -1302,6 +1375,11 @@ def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): @dataclasses.dataclass(frozen=True) class DeferredEvent(_DCBase): + """An event that has been deferred to run prior to the next Juju event. + + In most cases, the :func:`deferred` function should be used to create a + ``DeferredEvent`` instance.""" + handle_path: str owner: str observer: str @@ -1386,30 +1464,37 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: @dataclasses.dataclass(frozen=True) class Event(_DCBase): + """A Juju, ops, or custom event that can be run against a charm. + + Typically, for simple events, the string name (e.g. ``install``) can be used, + and for more complex events, an ``event`` property will be available on the + related object (e.g. ``relation.joined_event``). + """ + path: str args: Tuple[Any, ...] = () kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict) - # if this is a storage event, the storage it refers to storage: Optional["Storage"] = None - # if this is a relation event, the relation it refers to + """If this is a storage event, the storage it refers to.""" relation: Optional["AnyRelation"] = None - # and the name of the remote unit this relation event is about + """If this is a relation event, the relation it refers to.""" relation_remote_unit_id: Optional[int] = None + """If this is a relation event, the name of the remote unit the event is about.""" - # if this is a secret event, the secret it refers to secret: Optional[Secret] = None + """If this is a secret event, the secret it refers to.""" - # if this is a workload (container) event, the container it refers to container: Optional[Container] = None + """If this is a workload (container) event, the container it refers to.""" - # if this is a Pebble notice event, the notice it refers to notice: Optional[Notice] = None + """If this is a Pebble notice event, the notice it refers to.""" - # if this is an action event, the Action instance action: Optional["Action"] = None + """If this is an action event, the :class:`Action` it refers to.""" - # todo add other meta for + # TODO: add other meta for # - secret events # - pebble? # - action? @@ -1639,9 +1724,21 @@ def next_action_id(update=True): @dataclasses.dataclass(frozen=True) class Action(_DCBase): + """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()) + """ + name: str + """Juju action name, as found in the charm metadata.""" params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) + """Parameter values passed to the action.""" id: str = dataclasses.field(default_factory=next_action_id) """Juju action ID. diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index e8e75f7b6..9ff80d293 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -191,6 +191,7 @@ def test_set_legacy_behaviour(mycharm): ) secret.set_content(rev2) + secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() @@ -200,6 +201,7 @@ def test_set_legacy_behaviour(mycharm): secret.set_content(rev3) state_out = mgr.run() + secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() diff --git a/tox.ini b/tox.ini index b670ca951..30f40fcad 100644 --- a/tox.ini +++ b/tox.ini @@ -69,3 +69,17 @@ deps = commands = ruff format {[vars]tst_path} {[vars]src_path} isort --profile black {[vars]tst_path} {[vars]src_path} + +[testenv:docs-deps] +description = Compile the requirements.txt file for docs +deps = pip-tools +commands = + pip-compile --extra=docs -o docs/requirements.txt pyproject.toml + +[testenv:docs] +description = Build the Sphinx docs +deps = pip-tools +commands_pre = + pip-sync {toxinidir}/docs/requirements.txt +commands = + sphinx-build -W --keep-going docs/ docs/_build/html From 58e514e7342996acf26adc2627d615dfc17553aa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 17 Jul 2024 12:04:18 +1200 Subject: [PATCH 488/546] fix: minimal secret fixing, so that secret-remove and secret-expired events work. (#158) --- pyproject.toml | 2 +- scenario/runtime.py | 2 ++ scenario/state.py | 10 +++++----- tests/test_e2e/test_event.py | 2 +- tests/test_e2e/test_secrets.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1c1ae2ae7..ac35451c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.2" +version = "6.1.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/runtime.py b/scenario/runtime.py index 3cb67f0b9..71b109a55 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -272,6 +272,8 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_SECRET_LABEL": secret.label or "", }, ) + if event.name in ("secret_remove", "secret_expired"): + env["JUJU_SECRET_REVISION"] = str(secret.revision) return env diff --git a/scenario/state.py b/scenario/state.py index 70c8e9856..d8be99cbe 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -106,7 +106,7 @@ SECRET_EVENTS = { "secret_changed", - "secret_removed", + "secret_remove", "secret_rotate", "secret_expired", } @@ -292,18 +292,18 @@ def expired_event(self): """Sugar to generate a secret-expired event.""" if not self.owner: raise ValueError( - "This unit will never receive secret-expire for a secret it does not own.", + "This unit will never receive secret-expired for a secret it does not own.", ) - return Event("secret_expire", secret=self) + return Event("secret_expired", secret=self) @property def remove_event(self): """Sugar to generate a secret-remove event.""" if not self.owner: raise ValueError( - "This unit will never receive secret-removed for a secret it does not own.", + "This unit will never receive secret-remove for a secret it does not own.", ) - return Event("secret_removed", secret=self) + return Event("secret_remove", secret=self) def _set_revision(self, revision: int): """Set a new tracked revision.""" diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index 07c8d30a0..f30fc65ad 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -19,7 +19,7 @@ ("foo_bar_baz_pebble_ready", _EventType.workload), ("foo_pebble_custom_notice", _EventType.workload), ("foo_bar_baz_pebble_custom_notice", _EventType.workload), - ("secret_removed", _EventType.secret), + ("secret_remove", _EventType.secret), ("pre_commit", _EventType.framework), ("commit", _EventType.framework), ("collect_unit_status", _EventType.framework), diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 9ff80d293..269882455 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -2,6 +2,12 @@ import warnings import pytest +from ops import ( + SecretChangedEvent, + SecretExpiredEvent, + SecretRemoveEvent, + SecretRotateEvent, +) from ops.charm import CharmBase from ops.framework import Framework from ops.model import ModelError @@ -554,3 +560,31 @@ def __init__(self, *args): secret.remove_all_revisions() assert not mgr.output.secrets[0].contents # secret wiped + + +@pytest.mark.parametrize( + "evt,owner,cls", + ( + ("changed", None, SecretChangedEvent), + ("rotate", "app", SecretRotateEvent), + ("expired", "app", SecretExpiredEvent), + ("remove", "app", SecretRemoveEvent), + ), +) +def test_emit_event(evt, owner, cls): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + for evt in self.on.events().values(): + self.framework.observe(evt, self._on_event) + self.events = [] + + def _on_event(self, event): + self.events.append(event) + + ctx = Context(MyCharm, meta={"name": "local"}) + secret = Secret(contents={"foo": "bar"}, id="foo", owner=owner) + with ctx.manager(getattr(secret, evt + "_event"), State(secrets=[secret])) as mgr: + mgr.run() + juju_event = mgr.charm.events[0] # Ignore collect-status etc. + assert isinstance(juju_event, cls) From 1d7987fe4880cbf8ebd11a1c5beade57711421d4 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 31 Jul 2024 10:42:20 +0200 Subject: [PATCH 489/546] fixed juju-info network --- scenario/consistency_checker.py | 3 +++ scenario/mocking.py | 3 +++ tests/test_e2e/test_network.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 50eb939d6..c4397efd6 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -427,6 +427,9 @@ def check_network_consistency( errors = [] meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) + # add the implicit juju-info binding so we can override its network without + # having to declare a relation for it in metadata + meta_bindings.add("juju-info") all_relations = charm_spec.get_all_relations() non_sub_relations = { endpoint diff --git a/scenario/mocking.py b/scenario/mocking.py index 02d920438..ac56d719b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -298,6 +298,9 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): "cannot pass relation_id to network_get if the binding name is " "that of an extra-binding. Extra-bindings are not mapped to relation IDs.", ) + elif binding_name == "juju-info": + # implicit relation that always exists + pass # - verify that the binding is a relation endpoint name, but not a subordinate one elif binding_name not in non_sub_relations: logger.error( diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 07808e1de..0a7089e4a 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -118,3 +118,39 @@ def test_no_relation_error(mycharm): ) as mgr: with pytest.raises(RelationNotFoundError): net = mgr.charm.model.get_binding("foo").network + + +def test_juju_info_network_default(mycharm): + ctx = Context( + mycharm, + meta={"name": "foo"}, + ) + + with ctx.manager( + "update_status", + State(), + ) as mgr: + # we have a network for the relation + assert ( + str(mgr.charm.model.get_binding("juju-info").network.bind_address) + == "192.0.2.0" + ) + + +def test_juju_info_network_override(mycharm): + ctx = Context( + mycharm, + meta={"name": "foo"}, + ) + + with ctx.manager( + "update_status", + State( + networks={"juju-info": Network.default(private_address="4.4.4.4")}, + ), + ) as mgr: + # we have a network for the relation + assert ( + str(mgr.charm.model.get_binding("juju-info").network.bind_address) + == "4.4.4.4" + ) From 385dbea680551b65d33179adde5e7b938b19763c Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 31 Jul 2024 10:44:42 +0200 Subject: [PATCH 490/546] vbump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ac35451c5..0d16c44a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.3" +version = "6.1.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 90cd3427a2ed3b02dc5d7e25d3d4d98f540e83d6 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 31 Jul 2024 12:53:45 +0200 Subject: [PATCH 491/546] fmt --- tests/test_e2e/test_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 0a7089e4a..072234757 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -117,7 +117,7 @@ def test_no_relation_error(mycharm): ), ) as mgr: with pytest.raises(RelationNotFoundError): - net = mgr.charm.model.get_binding("foo").network + mgr.charm.model.get_binding("foo").network def test_juju_info_network_default(mycharm): From de641ecaeae225233b4782e6e37916262476804e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 31 Jul 2024 13:51:22 +0200 Subject: [PATCH 492/546] ulterior fix --- pyproject.toml | 2 +- scenario/consistency_checker.py | 6 ++++-- tests/test_e2e/test_network.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0d16c44a0..f83a10114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.4" +version = "6.1.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index c4397efd6..895e6f8fe 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -429,7 +429,7 @@ def check_network_consistency( meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) # add the implicit juju-info binding so we can override its network without # having to declare a relation for it in metadata - meta_bindings.add("juju-info") + implicit_bindings = {"juju-info"} all_relations = charm_spec.get_all_relations() non_sub_relations = { endpoint @@ -438,7 +438,9 @@ def check_network_consistency( } state_bindings = set(state.networks) - if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): + if diff := state_bindings.difference( + meta_bindings.union(non_sub_relations).union(implicit_bindings), + ): errors.append( f"Some network bindings defined in State are not in metadata.yaml: {diff}.", ) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 072234757..28a4133c9 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -154,3 +154,20 @@ def test_juju_info_network_override(mycharm): str(mgr.charm.model.get_binding("juju-info").network.bind_address) == "4.4.4.4" ) + + +def test_explicit_juju_info_network_override(mycharm): + ctx = Context( + mycharm, + meta={ + "name": "foo", + # this charm for whatever reason explicitly defines a juju-info endpoint + "requires": {"juju-info": {"interface": "juju-info"}}, + }, + ) + + with ctx.manager( + "update_status", + State(), + ) as mgr: + assert mgr.charm.model.get_binding("juju-info").network.bind_address From 00ce9b0d8a99f0a24cfafea9e6a89d4eb9f87d46 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 13 Aug 2024 07:51:42 +0200 Subject: [PATCH 493/546] fixed comma and test-pebble-push example --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a56370830..6007079a9 100644 --- a/README.md +++ b/README.md @@ -611,13 +611,13 @@ class MyCharm(ops.CharmBase): def test_pebble_push(): with tempfile.NamedTemporaryFile() as local_file: - container = scenario,Container( + container = scenario.Container( name='foo', can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file.name)} + mounts={'local': scenario.Mount('/local/share/config.yaml', local_file.name)} ) - state_in = State(containers=[container]) - ctx = Context( + state_in = scenario.State(containers=[container]) + ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} ) From 91fef2ab90e0893b411dd439fb88d373948e574e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 27 Aug 2024 08:45:18 +1200 Subject: [PATCH 494/546] chore: add compatibility with the next ops release (#178) The next release of ops has some backwards-incompatible changes to private methods. This PR does the minimum to keep Scenario working with the current version of ops and the next release. I'll open a ticket for doing a nicer version of this where the new `_JujuContext` class is used (which would presumably mean requiring the new version of ops). But this will let people continue upgrading their ops as long as they're using the latest 6.x of Scenario. The relevant ops PR is: https://github.com/canonical/operator/pull/1313 --- pyproject.toml | 2 +- scenario/ops_main_mock.py | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f83a10114..4fbb741c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.5" +version = "6.1.6" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b18c7f02f..8b2845c81 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -3,6 +3,7 @@ # See LICENSE file for licensing details. import inspect import os +import pathlib import sys from typing import TYPE_CHECKING, Any, Optional, Sequence, cast @@ -15,7 +16,7 @@ from ops.log import setup_root_logging # use logger from ops.main so that juju_log will be triggered -from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_charm_dir, _get_event_args +from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args from ops.main import logger as ops_logger if TYPE_CHECKING: # pragma: no cover @@ -33,6 +34,17 @@ class BadOwnerPath(RuntimeError): """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" +# TODO: Use ops.jujucontext's _JujuContext.charm_dir. +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = pathlib.Path(f"{__file__}/../../..").resolve() + else: + charm_dir = pathlib.Path(charm_dir).resolve() + return charm_dir + + def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: """Walk path on root to an ObjectEvents instance.""" obj = root @@ -75,7 +87,17 @@ def _emit_charm_event( f"Use Context.run_custom instead.", ) - args, kwargs = _get_event_args(charm, event_to_emit) + try: + args, kwargs = _get_event_args(charm, event_to_emit) + except TypeError: + # ops 2.16+ + import ops.jujucontext # type: ignore + + args, kwargs = _get_event_args( + charm, + event_to_emit, + ops.jujucontext._JujuContext.from_dict(os.environ), # type: ignore + ) ops_logger.debug("Emitting Juju event %s.", event_name) event_to_emit.emit(*args, **kwargs) @@ -159,7 +181,16 @@ def setup(state: "State", event: "Event", context: "Context", charm_spec: "_Char charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() - dispatcher = _Dispatcher(charm_dir) + try: + dispatcher = _Dispatcher(charm_dir) + except TypeError: + # ops 2.16+ + import ops.jujucontext # type: ignore + + dispatcher = _Dispatcher( + charm_dir, + ops.jujucontext._JujuContext.from_dict(os.environ), # type: ignore + ) dispatcher.run_any_legacy_hook() framework = setup_framework(charm_dir, state, event, context, charm_spec) From 82778621a8994bcbbc1d63d5206a6dcba48edcd2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:55:41 +1300 Subject: [PATCH 495/546] Remove Context.clear() and Context.cleanup(). --- scenario/context.py | 27 --------------------------- tests/test_context.py | 11 ----------- 2 files changed, 38 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 6e07576c6..3acc6bffc 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -395,33 +395,6 @@ def _get_storage_root(self, name: str, index: int) -> Path: storage_root.mkdir(parents=True, exist_ok=True) return storage_root - def clear(self): - """Deprecated. - - Use cleanup instead. - """ - logger.warning( - "Context.clear() is deprecated and will be nuked in v6. " - "Use Context.cleanup() instead.", - ) - self.cleanup() - - def cleanup(self): - """Cleanup side effects histories and reset the simulated filesystem state.""" - self.juju_log = [] - self.app_status_history = [] - self.unit_status_history = [] - self.workload_version_history = [] - self.emitted_events = [] - self.requested_storages = {} - self._action_logs = [] - self._action_results = None - self._action_failure = None - self._output_state = None - - self._tmp.cleanup() - self._tmp = tempfile.TemporaryDirectory() - def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: diff --git a/tests/test_context.py b/tests/test_context.py index ff9ef6902..7d2b795c2 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -51,17 +51,6 @@ def test_run_action(): assert a.id == expected_id -def test_clear(): - ctx = Context(MyCharm, meta={"name": "foo"}) - state = State() - - ctx.run("start", state) - assert ctx.emitted_events - - ctx.clear() - assert not ctx.emitted_events # and others... - - @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @pytest.mark.parametrize("unit_id", (1, 2, 42)) def test_app_name(app_name, unit_id): From 57766b433a0b6e3aa2281d8839bb19293fb637a2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 21:44:13 +1300 Subject: [PATCH 496/546] Remove jsonpatch_delta from the public API. --- scenario/state.py | 21 --------------------- tests/helpers.py | 17 ++++++++++++++++- tests/test_e2e/test_pebble.py | 4 ++-- tests/test_e2e/test_play_assertions.py | 4 ++-- tests/test_e2e/test_state.py | 8 ++++---- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/scenario/state.py b/scenario/state.py index d8be99cbe..10230be3a 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1255,23 +1255,6 @@ def get_storages(self, name: str) -> Tuple["Storage", ...]: """Get all storages with this name.""" return tuple(s for s in self.storage if s.name == name) - # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way. - def jsonpatch_delta(self, other: "State"): - try: - import jsonpatch # type: ignore - except ModuleNotFoundError: - logger.error( - "cannot import jsonpatch: using the .delta() " - "extension requires jsonpatch to be installed." - "Fetch it with pip install jsonpatch.", - ) - return NotImplemented - patch = jsonpatch.make_patch( - dataclasses.asdict(other), - dataclasses.asdict(self), - ).patch - return sort_patch(patch) - def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): # Check whether this dict has the expected mandatory metadata fields according to the @@ -1369,10 +1352,6 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: ) -def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): - return sorted(patch, key=key) - - @dataclasses.dataclass(frozen=True) class DeferredEvent(_DCBase): """An event that has been deferred to run prior to the next Juju event. diff --git a/tests/helpers.py b/tests/helpers.py index a8b2f5510..7558e78d9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,3 +1,4 @@ +import dataclasses import logging from pathlib import Path from typing import ( @@ -5,13 +6,15 @@ Any, Callable, Dict, + List, Optional, - Sequence, Type, TypeVar, Union, ) +import jsonpatch + from scenario.context import DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover @@ -52,3 +55,15 @@ def trigger( pre_event=pre_event, post_event=post_event, ) + + +def jsonpatch_delta(input: "State", output: "State"): + patch = jsonpatch.make_patch( + dataclasses.asdict(output), + dataclasses.asdict(input), + ).patch + return sort_patch(patch) + + +def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]): + return sorted(patch, key=key) diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index e5c16bf73..aa8b25d57 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from scenario import Context from scenario.state import Container, ExecOutput, Mount, Notice, Port, State -from tests.helpers import trigger +from tests.helpers import jsonpatch_delta, trigger @pytest.fixture(scope="function") @@ -159,7 +159,7 @@ def callback(self: CharmBase): else: # nothing has changed out_purged = out.replace(stored_state=state.stored_state) - assert not out_purged.jsonpatch_delta(state) + assert not jsonpatch_delta(out_purged, state) LS = """ diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index b8b92d5a0..4a1829f38 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -4,7 +4,7 @@ from ops.model import ActiveStatus, BlockedStatus from scenario.state import Relation, State -from tests.helpers import trigger +from tests.helpers import jsonpatch_delta, trigger @pytest.fixture(scope="function") @@ -61,7 +61,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") out_purged = out.replace(stored_state=initial_state.stored_state) - assert out_purged.jsonpatch_delta(initial_state) == [ + assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", "path": "/unit_status/message", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 838426aea..06ce07289 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -6,8 +6,8 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State, sort_patch -from tests.helpers import trigger +from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State +from tests.helpers import jsonpatch_delta, sort_patch, trigger CUSTOM_EVT_SUFFIXES = { "relation_created", @@ -58,7 +58,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) out_purged = out.replace(stored_state=state.stored_state) - assert state.jsonpatch_delta(out_purged) == [] + assert jsonpatch_delta(state, out_purged) == [] def test_leader_get(state, mycharm): @@ -97,7 +97,7 @@ def call(charm: CharmBase, e): # ignore stored state in the delta out_purged = out.replace(stored_state=state.stored_state) - assert out_purged.jsonpatch_delta(state) == sort_patch( + assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, {"op": "replace", "path": "/app_status/name", "value": "waiting"}, From c64301fdaec84a26308a3201abfeb2e61fc9c58d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 22:21:12 +1300 Subject: [PATCH 497/546] Remove _DCBase. --- README.md | 15 +++--- scenario/runtime.py | 9 ++-- scenario/sequences.py | 33 ++++++------ scenario/state.py | 70 ++++++++++---------------- tests/test_consistency_checker.py | 6 ++- tests/test_dcbase.py | 41 --------------- tests/test_e2e/test_pebble.py | 3 +- tests/test_e2e/test_play_assertions.py | 4 +- tests/test_e2e/test_state.py | 9 ++-- 9 files changed, 75 insertions(+), 115 deletions(-) delete mode 100644 tests/test_dcbase.py diff --git a/README.md b/README.md index 6007079a9..2cc8f8639 100644 --- a/README.md +++ b/README.md @@ -485,10 +485,12 @@ assert rel.relation_id == next_id This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: ```python -import scenario.state +import dataclasses +from scenario import Relation +from scenario.state import next_relation_id -rel = scenario.Relation('foo') -rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id()) +rel = Relation('foo') +rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, relation_id=next_relation_id()) assert rel2.relation_id == rel.relation_id + 1 ``` @@ -1356,14 +1358,15 @@ state that you obtain in return is a different instance, and all parts of it hav This ensures that you can do delta-based comparison of states without worrying about them being mutated by Scenario. If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use -the `replace` api. +the dataclasses `replace` api. ```python -import scenario +import dataclasses +from scenario import Relation relation = scenario.Relation('foo', remote_app_data={"1": "2"}) # make a copy of relation, but with remote_app_data set to {"3", "4"} -relation2 = relation.replace(remote_app_data={"3", "4"}) +relation2 = dataclasses.replace(relation, remote_app_data={"3", "4"}) ``` # Consistency checks diff --git a/scenario/runtime.py b/scenario/runtime.py index 71b109a55..4395c6ddd 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import copy +import dataclasses import marshal import os import re @@ -379,7 +381,7 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): store = self._get_state_db(temporary_charm_root) deferred = store.get_deferred_events() stored_state = store.get_stored_state() - return state.replace(deferred=deferred, stored_state=stored_state) + return dataclasses.replace(state, deferred=deferred, stored_state=stored_state) @contextmanager def _exec_ctx(self, ctx: "Context"): @@ -418,7 +420,7 @@ def exec( logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}") # we make a copy to avoid mutating the input state - output_state = state.copy() + output_state = copy.deepcopy(state) logger.info(" - generating virtual charm root") with self._exec_ctx(context) as (temporary_charm_root, captured): @@ -441,7 +443,8 @@ def exec( state=output_state, event=event, context=context, - charm_spec=self._charm_spec.replace( + charm_spec=dataclasses.replace( + self._charm_spec, charm_type=self._wrap(charm_type), ), ) diff --git a/scenario/sequences.py b/scenario/sequences.py index 49c01b336..83271e453 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import copy +import dataclasses import typing from itertools import chain from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union @@ -36,21 +38,24 @@ def decompose_meta_event(meta_event: Event, state: State): for relation in state.relations: event = relation.broken_event logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, state.copy() + yield event, copy.deepcopy(state) elif is_rel_created_meta_event: for relation in state.relations: event = relation.created_event logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, state.copy() + yield event, copy.deepcopy(state) else: raise RuntimeError(f"unknown meta-event {meta_event.name}") def generate_startup_sequence(state_template: State): yield from chain( - decompose_meta_event(Event(ATTACH_ALL_STORAGES), state_template.copy()), - ((Event("start"), state_template.copy()),), - decompose_meta_event(Event(CREATE_ALL_RELATIONS), state_template.copy()), + decompose_meta_event(Event(ATTACH_ALL_STORAGES), copy.deepcopy(state_template)), + ((Event("start"), copy.deepcopy(state_template)),), + decompose_meta_event( + Event(CREATE_ALL_RELATIONS), + copy.deepcopy(state_template), + ), ( ( Event( @@ -60,21 +65,21 @@ def generate_startup_sequence(state_template: State): else "leader_settings_changed" ), ), - state_template.copy(), + copy.deepcopy(state_template), ), - (Event("config_changed"), state_template.copy()), - (Event("install"), state_template.copy()), + (Event("config_changed"), copy.deepcopy(state_template)), + (Event("install"), copy.deepcopy(state_template)), ), ) def generate_teardown_sequence(state_template: State): yield from chain( - decompose_meta_event(Event(BREAK_ALL_RELATIONS), state_template.copy()), - decompose_meta_event(Event(DETACH_ALL_STORAGES), state_template.copy()), + decompose_meta_event(Event(BREAK_ALL_RELATIONS), copy.deepcopy(state_template)), + decompose_meta_event(Event(DETACH_ALL_STORAGES), copy.deepcopy(state_template)), ( - (Event("stop"), state_template.copy()), - (Event("remove"), state_template.copy()), + (Event("stop"), copy.deepcopy(state_template)), + (Event("remove"), copy.deepcopy(state_template)), ), ) @@ -112,8 +117,8 @@ def check_builtin_sequences( for event, state in generate_builtin_sequences( ( - template.replace(leader=True), - template.replace(leader=False), + dataclasses.replace(template, leader=True), + dataclasses.replace(template, leader=False), ), ): ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) diff --git a/scenario/state.py b/scenario/state.py index 10230be3a..2f77f48c5 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -4,7 +4,6 @@ """The core Scenario State object, and the components inside it.""" -import copy import dataclasses import datetime import inspect @@ -43,11 +42,6 @@ JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) if TYPE_CHECKING: # pragma: no cover - try: - from typing import Self # type: ignore - except ImportError: - from typing_extensions import Self - from scenario import Context PathLike = Union[str, Path] @@ -134,17 +128,6 @@ class BindFailedError(RuntimeError): """Raised when Event.bind fails.""" -@dataclasses.dataclass(frozen=True) -class _DCBase: - def replace(self, *args, **kwargs): - """Produce a deep copy of this class, with some arguments replaced with new ones.""" - return dataclasses.replace(self.copy(), *args, **kwargs) - - def copy(self) -> "Self": - """Produce a deep copy of this object.""" - return copy.deepcopy(self) - - @dataclasses.dataclass(frozen=True) class CloudCredential: auth_type: str @@ -216,7 +199,7 @@ def _to_ops(self) -> ops.CloudSpec: @dataclasses.dataclass(frozen=True) -class Secret(_DCBase): +class Secret: id: str # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized # secret id (`secret:` prefix) @@ -342,7 +325,7 @@ def normalize_name(s: str): @dataclasses.dataclass(frozen=True) -class Address(_DCBase): +class Address: """An address in a Juju network space.""" hostname: str @@ -363,7 +346,7 @@ def address(self, value): @dataclasses.dataclass(frozen=True) -class BindAddress(_DCBase): +class BindAddress: """An address bound to a network interface in a Juju space.""" interface_name: str @@ -383,7 +366,7 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_DCBase): +class Network: bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] @@ -435,7 +418,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class RelationBase(_DCBase): +class RelationBase: endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -659,7 +642,7 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) -class Model(_DCBase): +class Model: """The Juju model in which the charm is deployed.""" name: str = dataclasses.field(default_factory=_random_model_name) @@ -715,7 +698,7 @@ def _run(self) -> int: @dataclasses.dataclass(frozen=True) -class Mount(_DCBase): +class Mount: """Maps local files to a :class:`Container` filesystem.""" location: Union[str, PurePosixPath] @@ -815,7 +798,7 @@ def event(self): @dataclasses.dataclass(frozen=True) -class Container(_DCBase): +class Container: """A Kubernetes container where a charm's workload runs.""" name: str @@ -981,7 +964,7 @@ def get_notice( @dataclasses.dataclass(frozen=True) -class _EntityStatus(_DCBase): +class _EntityStatus: """This class represents StatusBase and should not be interacted with directly.""" # Why not use StatusBase directly? Because that's not json-serializable. @@ -1023,7 +1006,7 @@ class _MyClass(_EntityStatus, statusbase_subclass): @dataclasses.dataclass(frozen=True) -class StoredState(_DCBase): +class StoredState: # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] @@ -1042,7 +1025,7 @@ def handle_path(self): @dataclasses.dataclass(frozen=True) -class Port(_DCBase): +class Port: """Represents a port on the charm host.""" protocol: _RawPortProtocolLiteral @@ -1085,7 +1068,7 @@ def next_storage_index(update=True): @dataclasses.dataclass(frozen=True) -class Storage(_DCBase): +class Storage: """Represents an (attached!) storage made available to the charm container.""" name: str @@ -1115,7 +1098,7 @@ def detaching_event(self) -> "Event": @dataclasses.dataclass(frozen=True) -class State(_DCBase): +class State: """Represents the juju-owned portion of a unit's state. Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its @@ -1209,17 +1192,18 @@ def _update_status( def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: - return container.replace(can_connect=can_connect) + return dataclasses.replace(container, can_connect=can_connect) return container ctrs = tuple(map(replacer, self.containers)) - return self.replace(containers=ctrs) + return dataclasses.replace(self, containers=ctrs) def with_leadership(self, leader: bool) -> "State": - return self.replace(leader=leader) + return dataclasses.replace(self, leader=leader) def with_unit_status(self, status: StatusBase) -> "State": - return self.replace( + return dataclasses.replace( + self, status=dataclasses.replace( cast(_EntityStatus, self.unit_status), unit=_status_to_entitystatus(status), @@ -1271,7 +1255,7 @@ def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): @dataclasses.dataclass(frozen=True) -class _CharmSpec(_DCBase, Generic[CharmType]): +class _CharmSpec(Generic[CharmType]): """Charm spec.""" charm_type: Type[CharmBase] @@ -1353,7 +1337,7 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: @dataclasses.dataclass(frozen=True) -class DeferredEvent(_DCBase): +class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. In most cases, the :func:`deferred` function should be used to create a @@ -1442,7 +1426,7 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: @dataclasses.dataclass(frozen=True) -class Event(_DCBase): +class Event: """A Juju, ops, or custom event that can be run against a charm. Typically, for simple events, the string name (e.g. ``install``) can be used, @@ -1486,7 +1470,7 @@ def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": "cannot pass param `remote_unit_id` to a " "non-relation event constructor.", ) - return self.replace(relation_remote_unit_id=remote_unit_id) + return dataclasses.replace(self, relation_remote_unit_id=remote_unit_id) def __post_init__(self): path = _EventPath(self.path) @@ -1582,7 +1566,7 @@ def bind(self, state: State): container = state.get_container(entity_name) except ValueError: raise BindFailedError(f"no container found with name {entity_name}") - return self.replace(container=container) + return dataclasses.replace(self, container=container) if self._is_secret_event and not self.secret: if len(state.secrets) < 1: @@ -1591,7 +1575,7 @@ def bind(self, state: State): raise BindFailedError( f"too many secrets found in state: cannot automatically bind {self}", ) - return self.replace(secret=state.secrets[0]) + return dataclasses.replace(self, secret=state.secrets[0]) if self._is_storage_event and not self.storage: storages = state.get_storages(entity_name) @@ -1604,7 +1588,7 @@ def bind(self, state: State): f"too many storages called {entity_name}: binding to first one", ) storage = storages[0] - return self.replace(storage=storage) + return dataclasses.replace(self, storage=storage) if self._is_relation_event and not self.relation: ep_name = entity_name @@ -1613,7 +1597,7 @@ def bind(self, state: State): raise BindFailedError(f"no relations on {ep_name} found in state") if len(relations) > 1: logger.warning(f"too many relations on {ep_name}: binding to first one") - return self.replace(relation=relations[0]) + return dataclasses.replace(self, relation=relations[0]) if self._is_action_event and not self.action: raise BindFailedError( @@ -1702,7 +1686,7 @@ def next_action_id(update=True): @dataclasses.dataclass(frozen=True) -class Action(_DCBase): +class Action: """A ``juju run`` command. Used to simulate ``juju run``, passing in any parameters. For example:: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 707cc7f45..a5ecdb732 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -1,3 +1,5 @@ +import dataclasses + import pytest from ops.charm import CharmBase @@ -490,14 +492,14 @@ def test_storage_states(): _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( - State(storage=[storage1, storage2.replace(index=2)]), + State(storage=[storage1, dataclasses.replace(storage2, index=2)]), Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( - State(storage=[storage1, storage2.replace(name="marx")]), + State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_dcbase.py b/tests/test_dcbase.py deleted file mode 100644 index fd5ff872c..000000000 --- a/tests/test_dcbase.py +++ /dev/null @@ -1,41 +0,0 @@ -import dataclasses -from typing import Dict, List - -from scenario.state import _DCBase - - -@dataclasses.dataclass(frozen=True) -class Foo(_DCBase): - a: int - b: List[int] - c: Dict[int, List[int]] - - -def test_base_case(): - l = [1, 2] - l1 = [1, 2, 3] - d = {1: l1} - f = Foo(1, l, d) - g = f.replace(a=2) - - assert g.a == 2 - assert g.b == l - assert g.c == d - assert g.c[1] == l1 - - -def test_dedup_on_replace(): - l = [1, 2] - l1 = [1, 2, 3] - d = {1: l1} - f = Foo(1, l, d) - g = f.replace(a=2) - - l.append(3) - l1.append(4) - d[2] = "foobar" - - assert g.a == 2 - assert g.b == [1, 2] - assert g.c == {1: [1, 2, 3]} - assert g.c[1] == [1, 2, 3] diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index aa8b25d57..f61432527 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,4 +1,5 @@ import datetime +import dataclasses import tempfile from pathlib import Path @@ -158,7 +159,7 @@ def callback(self: CharmBase): else: # nothing has changed - out_purged = out.replace(stored_state=state.stored_state) + out_purged = dataclasses.replace(out, stored_state=state.stored_state) assert not jsonpatch_delta(out_purged, state) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 4a1829f38..a5166db7b 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -1,3 +1,5 @@ +import dataclasses + import pytest from ops.charm import CharmBase from ops.framework import Framework @@ -60,7 +62,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") - out_purged = out.replace(stored_state=initial_state.stored_state) + out_purged = dataclasses.replace(out, stored_state=initial_state.stored_state) assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 06ce07289..477440d6e 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,4 +1,4 @@ -from dataclasses import asdict +from dataclasses import asdict, replace from typing import Type import pytest @@ -57,7 +57,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) - out_purged = out.replace(stored_state=state.stored_state) + out_purged = replace(out, stored_state=state.stored_state) assert jsonpatch_delta(state, out_purged) == [] @@ -96,7 +96,7 @@ def call(charm: CharmBase, e): assert out.workload_version == "" # ignore stored state in the delta - out_purged = out.replace(stored_state=state.stored_state) + out_purged = replace(out, stored_state=state.stored_state) assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, @@ -224,7 +224,8 @@ def pre_event(charm: CharmBase): assert mycharm.called assert asdict(out.relations[0]) == asdict( - relation.replace( + replace( + relation, local_app_data={"a": "b"}, local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, ) From ee866ddddf002872abcb0b6e099414fc88a16af6 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 4 Apr 2024 13:52:36 +1300 Subject: [PATCH 498/546] Drop tests for unsupported Python, add 3.12. --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 30f40fcad..273c8e295 100644 --- a/tox.ini +++ b/tox.ini @@ -2,10 +2,9 @@ requires = tox>=4.2 env_list = + py312 py311 py38 - py37 - py36 unit lint lint-tests From 2e9adf0dcf54a247a52682be86a7e047b398f466 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 18 Apr 2024 21:07:22 +1200 Subject: [PATCH 499/546] Remove deprecated functionality. --- scenario/context.py | 50 +----------------------- scenario/sequences.py | 14 +++---- scenario/state.py | 31 --------------- scenario/strategies.py | 0 tests/helpers.py | 13 ++++--- tests/test_e2e/test_actions.py | 17 --------- tests/test_e2e/test_manager.py | 13 ------- tests/test_e2e/test_pebble.py | 70 +++++++++++++++++----------------- tests/test_e2e/test_ports.py | 25 +++++++----- tests/test_e2e/test_secrets.py | 46 ++-------------------- tests/test_e2e/test_state.py | 2 - tests/test_e2e/test_status.py | 68 +++++++++++++++++++-------------- tests/test_e2e/test_vroot.py | 2 +- tests/test_hypothesis.py | 0 14 files changed, 110 insertions(+), 241 deletions(-) delete mode 100644 scenario/strategies.py delete mode 100644 tests/test_hypothesis.py diff --git a/scenario/context.py b/scenario/context.py index 3acc6bffc..25eb9f664 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -5,7 +5,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast from ops import CharmBase, EventBase @@ -430,20 +430,6 @@ def _coalesce_event(event: Union[str, Event]) -> Event: ) return event - @staticmethod - def _warn_deprecation_if_pre_or_post_event( - pre_event: Optional[Callable], - post_event: Optional[Callable], - ): - # warn if pre/post event arguments are passed - legacy_mode = pre_event or post_event - if legacy_mode: - logger.warning( - "The [pre/post]_event syntax is deprecated and " - "will be removed in a future release. " - "Please use the ``Context.[action_]manager`` context manager.", - ) - def manager( self, event: Union["Event", str], @@ -497,8 +483,6 @@ def run( self, event: Union["Event", str], state: "State", - pre_event: Optional[Callable[[CharmBase], None]] = None, - post_event: Optional[Callable[[CharmBase], None]] = None, ) -> "State": """Trigger a charm execution with an Event and a State. @@ -508,32 +492,15 @@ def run( :arg event: the Event that the charm will respond to. Can be a string or an Event instance. :arg state: the State instance to use as data source for the hook tool calls that the charm will invoke when handling the Event. - :arg pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.manager`` instead. - :arg post_event: callback to be invoked right after emitting the event on the charm. - Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.manager`` instead. """ - self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - with self._run_event(event=event, state=state) as ops: - if pre_event: - pre_event(cast(CharmBase, ops.charm)) - ops.emit() - - if post_event: - post_event(cast(CharmBase, ops.charm)) - return self.output_state def run_action( self, action: Union["Action", str], state: "State", - pre_event: Optional[Callable[[CharmBase], None]] = None, - post_event: Optional[Callable[[CharmBase], None]] = None, ) -> ActionOutput: """Trigger a charm execution with an Action and a State. @@ -543,25 +510,10 @@ def run_action( :arg action: the Action that the charm will execute. Can be a string or an Action instance. :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). - :arg pre_event: callback to be invoked right before emitting the event on the newly - instantiated charm. Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.action_manager`` instead. - :arg post_event: callback to be invoked right after emitting the event on the charm. - Will receive the charm instance as only positional argument. - This argument is deprecated. Please use ``Context.action_manager`` instead. """ - self._warn_deprecation_if_pre_or_post_event(pre_event, post_event) - _action = self._coalesce_action(action) with self._run_action(action=_action, state=state) as ops: - if pre_event: - pre_event(cast(CharmBase, ops.charm)) - ops.emit() - - if post_event: - post_event(cast(CharmBase, ops.charm)) - return self._finalize_action(self.output_state) def _finalize_action(self, state_out: "State"): diff --git a/scenario/sequences.py b/scenario/sequences.py index 83271e453..2e1cbb22c 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -122,12 +122,10 @@ def check_builtin_sequences( ), ): ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) - out.append( - ctx.run( - event, - state=state, - pre_event=pre_event, - post_event=post_event, - ), - ) + with ctx.manager(event, state=state) as mgr: + if pre_event: + pre_event(mgr.charm) + out.append(mgr.run()) + if post_event: + post_event(mgr.charm) return out diff --git a/scenario/state.py b/scenario/state.py index 2f77f48c5..3458e5107 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -8,7 +8,6 @@ import datetime import inspect import re -import warnings from collections import namedtuple from enum import Enum from itertools import chain @@ -213,10 +212,6 @@ class Secret: # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None - # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether. - # this attribute will be removed in Scenario 7+ - granted: Any = "" # noqa - # what revision is currently tracked by this charm. Only meaningful if owner=False revision: int = 0 @@ -229,27 +224,6 @@ class Secret: expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None - def __post_init__(self): - if self.granted != "": - msg = ( - "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " - "If a Secret is not owned by the app/unit you are testing, nor has been granted to " - "it by the (remote) owner, then omit it from ``State.secrets`` altogether." - ) - logger.warning(msg) - warnings.warn(msg, DeprecationWarning, stacklevel=2) - - if self.owner == "application": - msg = ( - "Secret.owner='application' is deprecated in favour of 'app' " - "and will be removed in Scenario 7+." - ) - logger.warning(msg) - warnings.warn(msg, DeprecationWarning, stacklevel=2) - - # bypass frozen dataclass - object.__setattr__(self, "owner", "app") - # consumer-only events @property def changed_event(self): @@ -973,11 +947,6 @@ class _EntityStatus: message: str = "" def __eq__(self, other): - if isinstance(other, Tuple): - logger.warning( - "Comparing Status with Tuples is deprecated and will be removed soon.", - ) - return (self.name, self.message) == other if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) logger.warning( diff --git a/scenario/strategies.py b/scenario/strategies.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/helpers.py b/tests/helpers.py index 7558e78d9..712b62d15 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -49,12 +49,13 @@ def trigger( charm_root=charm_root, juju_version=juju_version, ) - return ctx.run( - event, - state=state, - pre_event=pre_event, - post_event=post_event, - ) + with ctx.manager(event, state=state) as mgr: + if pre_event: + pre_event(mgr.charm) + state_out = mgr.run() + if post_event: + post_event(mgr.charm) + return state_out def jsonpatch_delta(input: "State", output: "State"): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 5282c1955..30cc9d1e8 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -44,23 +44,6 @@ def test_action_event(mycharm, baz_value): assert evt.params["baz"] is baz_value -def test_action_pre_post(mycharm): - ctx = Context( - mycharm, - meta={"name": "foo"}, - actions={ - "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} - }, - ) - action = Action("foo", params={"baz": True, "bar": 10}) - ctx.run_action( - action, - State(), - pre_event=lambda charm: None, - post_event=lambda charm: None, - ) - - @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index e7fefb7a9..28cbe5168 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -41,19 +41,6 @@ def test_manager(mycharm): assert manager.output # still there! -def test_manager_legacy_pre_post_hooks(mycharm): - ctx = Context(mycharm, meta=mycharm.META) - post_event = MagicMock() - pre_event = MagicMock() - - ctx.run("start", State(), pre_event=pre_event, post_event=post_event) - - assert post_event.called - assert isinstance(post_event.call_args.args[0], mycharm) - assert pre_event.called - assert isinstance(pre_event.call_args.args[0], mycharm) - - def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) with _EventManager(ctx, "start", State()) as manager: diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index f61432527..865ffb302 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -130,11 +130,9 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - out = ctx.run( - event="start", - state=state, - post_event=callback, - ) + with ctx.manager("start", state=state) as mgr: + out = mgr.run() + callback(mgr.charm) if make_dirs: # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt") @@ -233,37 +231,42 @@ def callback(self: CharmBase): @pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus) def test_pebble_plan(charm_cls, starting_service_status): - def callback(self: CharmBase): - foo = self.unit.get_container("foo") + class PlanCharm(charm_cls): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_ready, self._on_ready) - assert foo.get_plan().to_dict() == { - "services": {"fooserv": {"startup": "enabled"}} - } - fooserv = foo.get_services("fooserv")["fooserv"] - assert fooserv.startup == ServiceStartup.ENABLED - assert fooserv.current == ServiceStatus.ACTIVE - - foo.add_layer( - "bar", - { - "summary": "bla", - "description": "deadbeef", - "services": {"barserv": {"startup": "disabled"}}, - }, - ) + def _on_ready(self, event): + foo = event.workload - foo.replan() - assert foo.get_plan().to_dict() == { - "services": { - "barserv": {"startup": "disabled"}, - "fooserv": {"startup": "enabled"}, + assert foo.get_plan().to_dict() == { + "services": {"fooserv": {"startup": "enabled"}} } - } + fooserv = foo.get_services("fooserv")["fooserv"] + assert fooserv.startup == ServiceStartup.ENABLED + assert fooserv.current == ServiceStatus.ACTIVE - assert foo.get_service("barserv").current == starting_service_status - foo.start("barserv") - # whatever the original state, starting a service sets it to active - assert foo.get_service("barserv").current == ServiceStatus.ACTIVE + foo.add_layer( + "bar", + { + "summary": "bla", + "description": "deadbeef", + "services": {"barserv": {"startup": "disabled"}}, + }, + ) + + foo.replan() + assert foo.get_plan().to_dict() == { + "services": { + "barserv": {"startup": "disabled"}, + "fooserv": {"startup": "enabled"}, + } + } + + assert foo.get_service("barserv").current == starting_service_status + foo.start("barserv") + # whatever the original state, starting a service sets it to active + assert foo.get_service("barserv").current == ServiceStatus.ACTIVE container = Container( name="foo", @@ -286,10 +289,9 @@ def callback(self: CharmBase): out = trigger( State(containers=[container]), - charm_type=charm_cls, + charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, event=container.pebble_ready_event, - post_event=callback, ) serv = lambda name, obj: pebble.Service(name, raw=obj) diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index ccff9366d..dc10b3a98 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -1,5 +1,5 @@ import pytest -from ops import CharmBase +from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State from scenario.state import Port @@ -8,6 +8,18 @@ class MyCharm(CharmBase): META = {"name": "edgar"} + def __init__(self, framework: Framework): + super().__init__(framework) + framework.observe(self.on.start, self._open_port) + framework.observe(self.on.stop, self._close_port) + + def _open_port(self, _: StartEvent): + self.unit.open_port("tcp", 12) + + def _close_port(self, _: StopEvent): + assert self.unit.opened_ports() + self.unit.close_port("tcp", 42) + @pytest.fixture def ctx(): @@ -15,10 +27,7 @@ def ctx(): def test_open_port(ctx): - def post_event(charm: CharmBase): - charm.unit.open_port("tcp", 12) - - out = ctx.run("start", State(), post_event=post_event) + out = ctx.run("start", State()) port = out.opened_ports.pop() assert port.protocol == "tcp" @@ -26,9 +35,5 @@ def post_event(charm: CharmBase): def test_close_port(ctx): - def post_event(charm: CharmBase): - assert charm.unit.opened_ports() - charm.unit.close_port("tcp", 42) - - out = ctx.run("start", State(opened_ports={Port("tcp", 42)}), post_event=post_event) + out = ctx.run("stop", State(opened_ports=[Port("tcp", 42)])) assert not out.opened_ports diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 269882455..4fa383634 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -45,23 +45,13 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): with Context(mycharm, meta={"name": "local"}).manager( - state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted=True)]), + state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), event="update_status", ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" -def test_get_secret_not_granted(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( - state=State(secrets=[]), - event="update_status", - ) as mgr: - with pytest.raises(SecretNotFoundError) as e: - assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" - - -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): with Context(mycharm, meta={"name": "local"}).manager( "update_status", @@ -108,8 +98,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): assert charm.model.get_secret(id="foo").get_content()["a"] == "c" -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): with Context(mycharm, meta={"name": "local"}).manager( "update_status", @@ -132,8 +121,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" -@pytest.mark.parametrize("owner", ("app", "unit", "application")) -# "application" is deprecated but still supported +@pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): with pytest.raises(ValueError): _ = Secret( @@ -312,32 +300,6 @@ def test_meta(mycharm, app): assert info.rotation == SecretRotate.HOURLY -def test_secret_deprecation_application(mycharm): - with warnings.catch_warnings(record=True) as captured: - s = Secret("123", {}, owner="application") - assert s.owner == "app" - msg = captured[0].message - assert isinstance(msg, DeprecationWarning) - assert msg.args[0] == ( - "Secret.owner='application' is deprecated in favour of " - "'app' and will be removed in Scenario 7+." - ) - - -@pytest.mark.parametrize("granted", ("app", "unit", False)) -def test_secret_deprecation_granted(mycharm, granted): - with warnings.catch_warnings(record=True) as captured: - s = Secret("123", {}, granted=granted) - assert s.granted == granted - msg = captured[0].message - assert isinstance(msg, DeprecationWarning) - assert msg.args[0] == ( - "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. " - "If a Secret is not owned by the app/unit you are testing, nor has been granted to " - "it by the (remote) owner, then omit it from ``State.secrets`` altogether." - ) - - @pytest.mark.parametrize("leader", (True, False)) @pytest.mark.parametrize("owner", ("app", "unit", None)) def test_secret_permission_model(mycharm, leader, owner): diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 477440d6e..0c79da86a 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -177,7 +177,6 @@ def event_handler(charm: CharmBase, _): # this will NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. - # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? with pytest.raises(Exception): rel.data[rel.app]["a"] = "b" with pytest.raises(Exception): @@ -191,7 +190,6 @@ def pre_event(charm: CharmBase): # this would NOT raise an exception because we're not in an event context! # we're right before the event context is entered in fact. - # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state? # with pytest.raises(Exception): # rel.data[rel.app]["a"] = "b" # with pytest.raises(Exception): diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index 0c28f7e6d..e587b4063 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -45,22 +45,23 @@ def post_event(charm: CharmBase): def test_status_history(mycharm): - def post_event(charm: CharmBase): - for obj in [charm.unit, charm.app]: - obj.status = ActiveStatus("1") - obj.status = BlockedStatus("2") - obj.status = WaitingStatus("3") + class StatusCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, _): + for obj in (self.unit, self.app): + obj.status = ActiveStatus("1") + obj.status = BlockedStatus("2") + obj.status = WaitingStatus("3") ctx = Context( - mycharm, + StatusCharm, meta={"name": "local"}, ) - out = ctx.run( - "update_status", - State(leader=True), - post_event=post_event, - ) + out = ctx.run("update_status", State(leader=True)) assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ @@ -78,12 +79,17 @@ def post_event(charm: CharmBase): def test_status_history_preservation(mycharm): - def post_event(charm: CharmBase): - for obj in [charm.unit, charm.app]: - obj.status = WaitingStatus("3") + class StatusCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, _): + for obj in (self.unit, self.app): + obj.status = WaitingStatus("3") ctx = Context( - mycharm, + StatusCharm, meta={"name": "local"}, ) @@ -94,7 +100,6 @@ def post_event(charm: CharmBase): unit_status=ActiveStatus("foo"), app_status=ActiveStatus("bar"), ), - post_event=post_event, ) assert out.unit_status == WaitingStatus("3") @@ -105,23 +110,30 @@ def post_event(charm: CharmBase): def test_workload_history(mycharm): - def post_event(charm: CharmBase): - charm.unit.set_workload_version("1") - charm.unit.set_workload_version("1.1") - charm.unit.set_workload_version("1.2") + class WorkloadCharm(mycharm): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.install, self._on_install) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.update_status, self._on_update_status) + + def _on_install(self, _): + self.unit.set_workload_version("1") + + def _on_start(self, _): + self.unit.set_workload_version("1.1") + + def _on_update_status(self, _): + self.unit.set_workload_version("1.2") ctx = Context( - mycharm, + WorkloadCharm, meta={"name": "local"}, ) - out = ctx.run( - "update_status", - State( - leader=True, - ), - post_event=post_event, - ) + out = ctx.run("install", State(leader=True)) + out = ctx.run("start", out) + out = ctx.run("update_status", out) assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index c6d59be54..6b6f902e6 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -47,7 +47,7 @@ def test_charm_virtual_root(charm_virtual_root): meta=MyCharm.META, charm_root=charm_virtual_root, ) - assert out.unit_status == ("active", "hello world") + assert out.unit_status == ActiveStatus("hello world") def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py deleted file mode 100644 index e69de29bb..000000000 From 3b8e28950741661a3732851a8ad9be14e97582e5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:49:12 +1300 Subject: [PATCH 500/546] Make RelationBase private (_RelationBase). --- scenario/__init__.py | 2 -- scenario/state.py | 9 ++++----- tests/test_e2e/test_relations.py | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index dfe567f04..fdd42ae77 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -19,7 +19,6 @@ PeerRelation, Port, Relation, - RelationBase, Secret, State, StateValidationError, @@ -38,7 +37,6 @@ "deferred", "StateValidationError", "Secret", - "RelationBase", "Relation", "SubordinateRelation", "PeerRelation", diff --git a/scenario/state.py b/scenario/state.py index 3458e5107..7b1958c92 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -392,7 +392,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class RelationBase: +class _RelationBase: endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -431,9 +431,9 @@ def _get_databag_for_remote( raise NotImplementedError() def __post_init__(self): - if type(self) is RelationBase: + if type(self) is _RelationBase: raise RuntimeError( - "RelationBase cannot be instantiated directly; " + "_RelationBase cannot be instantiated directly; " "please use Relation, PeerRelation, or SubordinateRelation", ) @@ -504,7 +504,6 @@ def broken_event(self) -> "Event": @dataclasses.dataclass(frozen=True) class Relation(RelationBase): """An integration between the charm and another application.""" - remote_app_name: str = "remote" """The name of the remote application, as in the charm's metadata.""" @@ -543,7 +542,7 @@ def _databags(self): @dataclasses.dataclass(frozen=True) -class SubordinateRelation(RelationBase): +class SubordinateRelation(_RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 212e12a68..57ad07692 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -15,10 +15,10 @@ DEFAULT_JUJU_DATABAG, PeerRelation, Relation, - RelationBase, State, StateValidationError, SubordinateRelation, + _RelationBase, ) from tests.helpers import trigger @@ -379,7 +379,7 @@ def post_event(charm: CharmBase): def test_cannot_instantiate_relationbase(): with pytest.raises(RuntimeError): - RelationBase("") + _RelationBase("") def test_relation_ids(): From 065cfd2ba647c66755006b31ac4387e4f949dece Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Apr 2024 11:44:31 +1300 Subject: [PATCH 501/546] Add basic StoredState consistency checks. Update scenario/consistency_checker.py Co-authored-by: PietroPasotti Fix broken files. Update scenario/mocking.py --- scenario/consistency_checker.py | 35 ++++++++++++++++++++++- scenario/runtime.py | 2 +- scenario/state.py | 8 ++++-- tests/test_consistency_checker.py | 46 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 895e6f8fe..41d4ac835 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import marshal import os import re -from collections import Counter +from collections import Counter, defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -71,6 +72,7 @@ def check_consistency( check_relation_consistency, check_network_consistency, check_cloudspec_consistency, + check_storedstate_consistency, ): results = check( state=state, @@ -598,3 +600,34 @@ def check_cloudspec_consistency( ) return Results(errors, warnings) + + +def check_storedstate_consistency( + *, + state: "State", + **_kwargs, # noqa: U101 +) -> Results: + """Check the internal consistency of `state.storedstate`.""" + errors = [] + + # Attribute names must be unique on each object. + names = defaultdict(list) + for ss in state.stored_state: + names[ss.owner_path].append(ss.name) + for owner, owner_names in names.items(): + if len(owner_names) != len(set(owner_names)): + errors.append( + f"{owner} has multiple StoredState objects with the same name.", + ) + + # The content must be marshallable. + for ss in state.stored_state: + # We don't need the marshalled state, just to know that it can be. + # This is the same "only simple types" check that ops does. + try: + marshal.dumps(ss.content) + except ValueError: + errors.append( + f"The StoredState object {ss.owner_path}.{ss.name} should contain only simple types.", + ) + return Results(errors, []) diff --git a/scenario/runtime.py b/scenario/runtime.py index 4395c6ddd..73f6e28df 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -32,7 +32,7 @@ logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( - r"((?P.*)\/)?(?P\D+)\[(?P.*)\]", + r"((?P.*)\/)?(?P<_data_type_name>\D+)\[(?P.*)\]", ) EVENT_REGEX = re.compile(_event_regex) diff --git a/scenario/state.py b/scenario/state.py index 7b1958c92..0a31d1cc0 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -980,13 +980,17 @@ class StoredState: owner_path: Optional[str] name: str = "_stored" + # Ideally, the type here would be only marshallable types, rather than Any. + # However, it's complex to describe those types, since it's a recursive + # definition - even in TypeShed the _Marshallable type includes containers + # like list[Any], which seems to defeat the point. content: Dict[str, Any] = dataclasses.field(default_factory=dict) - data_type_name: str = "StoredStateData" + _data_type_name: str = "StoredStateData" @property def handle_path(self): - return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]" + return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index a5ecdb732..f25b91797 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -20,6 +20,7 @@ Secret, State, Storage, + StoredState, SubordinateRelation, _CharmSpec, ) @@ -624,3 +625,48 @@ def test_cloudspec_consistency(): meta={"name": "MyK8sCharm"}, ), ) + + +def test_storedstate_consistency(): + assert_consistent( + State( + stored_state=[ + StoredState(None, content={"foo": "bar"}), + StoredState(None, "my_stored_state", content={"foo": 1}), + StoredState("MyCharmLib", content={"foo": None}), + StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), + ] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) + assert_inconsistent( + State( + stored_state=[ + StoredState(None, content={"foo": "bar"}), + StoredState(None, "_stored", content={"foo": "bar"}), + ] + ), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) + assert_inconsistent( + State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), + Event("start"), + _CharmSpec( + MyCharm, + meta={ + "name": "foo", + }, + ), + ) From fcd5d9d53133fe3dc80343f30d98f0cca24783bf Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 30 May 2024 19:45:47 +1200 Subject: [PATCH 502/546] Temporarily enable quality checks for the 7.0 branch. --- .github/workflows/quality_checks.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml index 5e1159aaf..2f0e309ac 100644 --- a/.github/workflows/quality_checks.yaml +++ b/.github/workflows/quality_checks.yaml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - 7.0 jobs: linting: @@ -41,4 +42,4 @@ jobs: - name: Install dependencies run: python -m pip install tox - name: Run unit tests - run: tox -vve unit \ No newline at end of file + run: tox -vve unit From 1d0aa9be596d90f3d56eb7963331466f69fd8c2b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:40:48 +1200 Subject: [PATCH 503/546] Test the code in the README. --- README.md | 236 ++++++++++----------------------------- tests/readme-conftest.py | 56 ++++++++++ tox.ini | 17 +++ 3 files changed, 131 insertions(+), 178 deletions(-) create mode 100644 tests/readme-conftest.py diff --git a/README.md b/README.md index 2cc8f8639..3c195e89d 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,6 @@ available. The charm has no config, no relations, no leadership, and its status With that, we can write the simplest possible scenario test: ```python -import ops -import scenario - - -class MyCharm(ops.CharmBase): - pass - - def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(scenario.Event("start"), scenario.State()) @@ -99,9 +91,7 @@ def test_scenario_base(): Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -import ops import pytest -import scenario class MyCharm(ops.CharmBase): @@ -133,9 +123,6 @@ sets the expected unit/application status. We have seen a simple example above i charm transitions through a sequence of statuses? ```python -import ops - - # charm code: def _on_event(self, _event): self.unit.status = ops.MaintenanceStatus('determining who the ruler is...') @@ -174,11 +161,6 @@ context. You can verify that the charm has followed the expected path by checking the unit/app status history like so: ```python -import ops -import scenario -from charm import MyCharm - - def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run('start', scenario.State(leader=False)) @@ -187,7 +169,7 @@ def test_statuses(): ops.MaintenanceStatus('determining who the ruler is...'), ops.WaitingStatus('checking this is right...'), ] - assert out.unit_status == ops.ActiveStatus("I am ruled"), + assert out.unit_status == ops.ActiveStatus("I am ruled") # similarly you can check the app status history: assert ctx.app_status_history == [ @@ -206,10 +188,16 @@ If you want to simulate a situation in which the charm already has seen some eve Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python -import ops -import scenario +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event): + self.model.unit.status = ops.ActiveStatus("foo") # ... +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! @@ -223,10 +211,9 @@ Using a similar api to `*_status_history`, you can assert that the charm has set hook execution: ```python -import scenario - # ... -ctx: scenario.Context +ctx = scenario.Context(HistoryCharm, meta={"name": "foo"}) +ctx.run("start", scenario.State()) assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -241,10 +228,6 @@ given Juju event triggering (say, 'start'), a specific chain of events is emitte resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python -import ops -import scenario - - def test_foo(): ctx = scenario.Context(...) ctx.run('start', ...) @@ -259,8 +242,6 @@ You can configure what events will be captured by passing the following argument For example: ```python -import scenario - def test_emitted_full(): ctx = scenario.Context( MyCharm, @@ -288,14 +269,13 @@ This context manager allows you to intercept any events emitted by the framework Usage: ```python -import ops -import scenario +import scenario.capture_events -with capture_events() as emitted: - ctx = scenario.Context(...) +with scenario.capture_events.capture_events() as emitted: + ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( "update-status", - scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) ) # deferred events get reemitted first @@ -310,9 +290,9 @@ assert isinstance(emitted[1], ops.UpdateStatusEvent) You can filter events by type like so: ```python -import ops +import scenario.capture_events -with capture_events(ops.StartEvent, ops.RelationEvent) as emitted: +with scenario.capture_events.capture_events(ops.StartEvent, ops.RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. pass ``` @@ -330,10 +310,6 @@ Configuration: You can write scenario tests to verify the shape of relation data: ```python -import ops -import scenario - - # This charm copies over remote app data to local unit data class MyCharm(ops.CharmBase): ... @@ -395,8 +371,6 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o - `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python -import scenario - relation = scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, @@ -407,15 +381,22 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -import scenario - state_in = scenario.State(relations=[ scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, )]) -scenario.Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. +meta = { + "name": "invalid", + "peers": { + "peers": { + "interface": "foo", + } + } +} +ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) +ctx.run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. ``` @@ -432,8 +413,6 @@ Because of that, `SubordinateRelation`, compared to `Relation`, always talks in - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) ```python -import scenario - relation = scenario.SubordinateRelation( endpoint="peers", remote_unit_data={"foo": "bar"}, @@ -449,8 +428,6 @@ If you want to trigger relation events, the easiest way to do so is get a hold o event from one of its aptly-named properties: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = relation.changed_event joined_event = relation.joined_event @@ -460,8 +437,6 @@ joined_event = relation.joined_event This is in fact syntactic sugar for: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = scenario.Event('foo-relation-changed', relation=relation) ``` @@ -486,12 +461,11 @@ This can be handy when using `replace` to create new relations, to avoid relatio ```python import dataclasses -from scenario import Relation -from scenario.state import next_relation_id +import scenario.state -rel = Relation('foo') -rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, relation_id=next_relation_id()) -assert rel2.relation_id == rel.relation_id + 1 +rel = scenario.Relation('foo') +rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) +assert rel2.id == rel.id + 1 ``` If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. @@ -511,8 +485,6 @@ The `remote_unit_id` will default to the first ID found in the relation's `remot writing is close to that domain, you should probably override it and pass it manually. ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) @@ -532,8 +504,6 @@ On top of the relation-provided network bindings, a charm can also define some ` If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`. ```python -import scenario - state = scenario.State(networks={ 'foo': scenario.Network.default(private_address='192.0.2.1') }) @@ -552,8 +522,6 @@ To give the charm access to some containers, you need to pass them to the input An example of a state including some containers: ```python -import scenario - state = scenario.State(containers=[ scenario.Container(name="foo", can_connect=True), scenario.Container(name="bar", can_connect=False) @@ -569,14 +537,12 @@ You can configure a container to have some files in it: ```python import pathlib -import scenario - local_file = pathlib.Path('/path/to/local/real/file.txt') container = scenario.Container( name="foo", can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file)} + mounts={'local': scenario.Mount('/local/share/config.yaml', local_file)} ) state = scenario.State(containers=[container]) ``` @@ -597,9 +563,6 @@ data and passing it to the charm via the container. ```python import tempfile -import ops -import scenario - class MyCharm(ops.CharmBase): def __init__(self, framework): @@ -640,10 +603,6 @@ that envvar into the charm's runtime. If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python -import ops -import scenario - - class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) @@ -677,9 +636,6 @@ worse issues to deal with. You need to specify, for each possible command the ch result of that would be: its return code, what will be written to stdout/stderr. ```python -import ops -import scenario - LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml .rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml @@ -757,10 +713,8 @@ If your charm defines `storage` in its metadata, you can use `scenario.Storage` Using the same `get_filesystem` API as `Container`, you can access the temporary directory used by Scenario to mock the filesystem root before and after the scenario runs. ```python -import scenario - # Some charm with a 'foo' filesystem-type storage defined in its metadata: -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) storage = scenario.Storage("foo") # Setup storage with some content: @@ -788,7 +742,7 @@ Note that State only wants to know about **attached** storages. A storage which If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. -```python +```python notest # In MyCharm._on_foo: # The charm requests two new "foo" storage instances to be provisioned: self.model.storages.request("foo", 2) @@ -796,10 +750,8 @@ self.model.storages.request("foo", 2) From test code, you can inspect that: -```python -import scenario - -ctx = scenario.Context(MyCharm) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) # the charm has requested two 'foo' storages to be provisioned: @@ -810,16 +762,14 @@ Requesting storages has no other consequence in Scenario. In real life, this req So a natural follow-up Scenario test suite for this case would be: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(foo_0.attached_event, State(storage=[foo_0])) +ctx.run(foo_0.attached_event, scenario.State(storage=[foo_0])) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +ctx.run(foo_1.attached_event, scenario.State(storage=[foo_0, foo_1])) ``` ## Ports @@ -828,16 +778,12 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(PortCharm, meta=MyCharm.META) state1 = ctx.run("start", scenario.State()) assert state1.opened_ports == [scenario.Port("tcp", 42)] @@ -850,8 +796,6 @@ assert state2.opened_ports == [] Scenario has secrets. Here's how you use them. ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -881,8 +825,6 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -899,8 +841,6 @@ state = scenario.State( To specify a secret owned by some other application and give this unit (or app) access to it: ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -918,12 +858,6 @@ state = scenario.State( Scenario can simulate StoredState. You can define it on the input side as: ```python -import ops -import scenario - -from ops.charm import CharmBase - - class MyCharmType(ops.CharmBase): my_stored_state = ops.StoredState() @@ -955,13 +889,13 @@ However, when testing, this constraint is unnecessarily strict (and it would als So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in the metadata. ```python -import scenario +import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') - assert path == '/path/to/resource.tar' + assert path == pathlib.Path('/path/to/resource.tar') ``` ## Model @@ -971,12 +905,6 @@ but if you need to set the model name or UUID, you can provide a `scenario.Model to the state: ```python -import ops -import scenario - -class MyCharm(ops.CharmBase): - pass - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_in = scenario.State(model=scenario.Model(name="my-model")) out = ctx.run("start", state_in) @@ -1035,11 +963,6 @@ How to test actions with scenario: ## Actions without parameters ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): ctx = scenario.Context(MyCharm) @@ -1064,11 +987,6 @@ def test_backup_action(): If the action takes parameters, you'll need to instantiate an `Action`. ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): # Define an action: action = scenario.Action('do_backup', params={'a': 'b'}) @@ -1089,10 +1007,7 @@ event in its queue (they would be there because they had been deferred in the pr valid. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_update_status(self, event): @@ -1117,14 +1032,7 @@ def test_start_on_deferred_update_status(MyCharm): You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the handler): -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation deferred_start = scenario.Event('start').deferred(MyCharm._on_start) deferred_install = scenario.Event('install').deferred(MyCharm._on_start) ``` @@ -1133,10 +1041,7 @@ On the output side, you can verify that an event that you expect to have been de been deferred. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_start(self, event): @@ -1156,10 +1061,7 @@ Relation instance they are about. So do they in Scenario. You can use the deferr structure: ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_foo_relation_changed(self, event): @@ -1180,14 +1082,7 @@ def test_start_on_deferred_update_status(MyCharm): but you can also use a shortcut from the relation event itself: -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -1200,8 +1095,6 @@ charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. ```python -import scenario - my_deferred_event = scenario.DeferredEvent( handle_path='MyCharm/MyCharmLib/on/database_ready[1]', owner='MyCharmLib', # the object observing the event. Could also be MyCharm. @@ -1223,10 +1116,9 @@ the event is emitted at all. If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: -```python -import scenario - -scenario.Context(...).run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError ``` This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but @@ -1234,10 +1126,9 @@ since the event is defined on another Object, it will fail to find it. You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: -```python -import scenario - -scenario.Context(...).run("my_charm_lib.on.foo", scenario.State()) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("my_charm_lib.on.foo", scenario.State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. @@ -1252,10 +1143,7 @@ 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: -```python -import ops -import scenario - +```python notest from charms.bar.lib_name.v1.charm_lib import CharmLib @@ -1309,16 +1197,12 @@ either inferred from the charm type being passed to `Context` or be passed to it the inferred one. This also allows you to test charms defined on the fly, as in: ```python -import ops -import scenario - - class MyCharmType(ops.CharmBase): pass ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) -ctx.run('start', State()) +ctx.run('start', scenario.State()) ``` A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata @@ -1327,9 +1211,6 @@ you are passing to `.run()` (because `ops` expects it to be a file...). That is, ```python import tempfile -import ops -import scenario - class MyCharmType(ops.CharmBase): pass @@ -1362,11 +1243,10 @@ the dataclasses `replace` api. ```python import dataclasses -from scenario import Relation relation = scenario.Relation('foo', remote_app_data={"1": "2"}) -# make a copy of relation, but with remote_app_data set to {"3", "4"} -relation2 = dataclasses.replace(relation, remote_app_data={"3", "4"}) +# make a copy of relation, but with remote_app_data set to {"3": "4"} +relation2 = dataclasses.replace(relation, remote_app_data={"3": "4"}) ``` # Consistency checks diff --git a/tests/readme-conftest.py b/tests/readme-conftest.py new file mode 100644 index 000000000..d125b930f --- /dev/null +++ b/tests/readme-conftest.py @@ -0,0 +1,56 @@ +"""pytest configuration for testing the README""" + +import ops + +import scenario + + +def pytest_markdown_docs_globals(): + class MyCharm(ops.CharmBase): + META = {"name": "mycharm", "storage": {"foo": {"type": "filesystem"}}} + + class SimpleCharm(ops.CharmBase): + META = {"name": "simplecharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + pass + + class HistoryCharm(ops.CharmBase): + META = {"name": "historycharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + self.unit.set_workload_version("1") + self.unit.set_workload_version("1.2") + self.unit.set_workload_version("1.5") + self.unit.set_workload_version("2.0") + + class PortCharm(ops.CharmBase): + META = {"name": "portcharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.stop, self._on_stop) + + def _on_start(self, _: ops.StartEvent): + self.unit.open_port(protocol="tcp", port=42) + + def _on_stop(self, _: ops.StopEvent): + self.unit.close_port(protocol="tcp", port=42) + + return { + "ops": ops, + "scenario": scenario, + "MyCharm": MyCharm, + "HistoryCharm": HistoryCharm, + "PortCharm": PortCharm, + "SimpleCharm": SimpleCharm, + } diff --git a/tox.ini b/tox.ini index 273c8e295..a404c5f61 100644 --- a/tox.ini +++ b/tox.ini @@ -82,3 +82,20 @@ commands_pre = pip-sync {toxinidir}/docs/requirements.txt commands = sphinx-build -W --keep-going docs/ docs/_build/html + +[testenv:test-readme] +description = Test code snippets in the README. +skip_install = true +allowlist_externals = + mkdir + cp +deps = + . + ops + pytest + pytest-markdown-docs +commands = + mkdir -p {envtmpdir}/test-readme + cp {toxinidir}/README.md {envtmpdir}/test-readme/README.md + cp {toxinidir}/tests/readme-conftest.py {envtmpdir}/test-readme/conftest.py + pytest -v --tb native --log-cli-level=INFO -s {posargs} --markdown-docs {envtmpdir}/test-readme/README.md From 468cf1ab69cf98b0c5f64367bb9eb7a840db6ae7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:49:10 +1200 Subject: [PATCH 504/546] Remove scenario.sequences (and related tests). --- scenario/sequences.py | 131 -------------------------- tests/test_e2e/test_builtin_scenes.py | 56 ----------- 2 files changed, 187 deletions(-) delete mode 100644 scenario/sequences.py delete mode 100644 tests/test_e2e/test_builtin_scenes.py diff --git a/scenario/sequences.py b/scenario/sequences.py deleted file mode 100644 index 2e1cbb22c..000000000 --- a/scenario/sequences.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import copy -import dataclasses -import typing -from itertools import chain -from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union - -from scenario import Context -from scenario.logger import logger as scenario_logger -from scenario.state import ( - ATTACH_ALL_STORAGES, - BREAK_ALL_RELATIONS, - CREATE_ALL_RELATIONS, - DETACH_ALL_STORAGES, - Event, - State, -) - -if typing.TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - -CharmMeta = Optional[Union[str, TextIO, dict]] -logger = scenario_logger.getChild("scenario") - - -def decompose_meta_event(meta_event: Event, state: State): - # decompose the meta event - - if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]: - logger.warning(f"meta-event {meta_event.name} not supported yet") - return - - is_rel_created_meta_event = meta_event.name == CREATE_ALL_RELATIONS - is_rel_broken_meta_event = meta_event.name == BREAK_ALL_RELATIONS - if is_rel_broken_meta_event: - for relation in state.relations: - event = relation.broken_event - logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, copy.deepcopy(state) - elif is_rel_created_meta_event: - for relation in state.relations: - event = relation.created_event - logger.debug(f"decomposed meta {meta_event.name}: {event}") - yield event, copy.deepcopy(state) - else: - raise RuntimeError(f"unknown meta-event {meta_event.name}") - - -def generate_startup_sequence(state_template: State): - yield from chain( - decompose_meta_event(Event(ATTACH_ALL_STORAGES), copy.deepcopy(state_template)), - ((Event("start"), copy.deepcopy(state_template)),), - decompose_meta_event( - Event(CREATE_ALL_RELATIONS), - copy.deepcopy(state_template), - ), - ( - ( - Event( - ( - "leader_elected" - if state_template.leader - else "leader_settings_changed" - ), - ), - copy.deepcopy(state_template), - ), - (Event("config_changed"), copy.deepcopy(state_template)), - (Event("install"), copy.deepcopy(state_template)), - ), - ) - - -def generate_teardown_sequence(state_template: State): - yield from chain( - decompose_meta_event(Event(BREAK_ALL_RELATIONS), copy.deepcopy(state_template)), - decompose_meta_event(Event(DETACH_ALL_STORAGES), copy.deepcopy(state_template)), - ( - (Event("stop"), copy.deepcopy(state_template)), - (Event("remove"), copy.deepcopy(state_template)), - ), - ) - - -def generate_builtin_sequences(template_states: Iterable[State]): - for template_state in template_states: - yield from chain( - generate_startup_sequence(template_state), - generate_teardown_sequence(template_state), - ) - - -def check_builtin_sequences( - charm_type: Type["CharmType"], - meta: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, - config: Optional[Dict[str, Any]] = None, - template_state: State = None, - pre_event: Optional[Callable[["CharmType"], None]] = None, - post_event: Optional[Callable[["CharmType"], None]] = None, -) -> object: - """Test that all the builtin startup and teardown events can fire without errors. - - This will play both scenarios with and without leadership, and raise any exceptions. - - This is a baseline check that in principle all charms (except specific use-cases perhaps), - should pass out of the box. - - If you want to, you can inject more stringent state checks using the - pre_event and post_event hooks. - """ - - template = template_state if template_state else State() - out = [] - - for event, state in generate_builtin_sequences( - ( - dataclasses.replace(template, leader=True), - dataclasses.replace(template, leader=False), - ), - ): - ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config) - with ctx.manager(event, state=state) as mgr: - if pre_event: - pre_event(mgr.charm) - out.append(mgr.run()) - if post_event: - post_event(mgr.charm) - return out diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py deleted file mode 100644 index 587e41491..000000000 --- a/tests/test_e2e/test_builtin_scenes.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from ops.charm import CharmBase, CollectStatusEvent -from ops.framework import Framework - -from scenario.sequences import check_builtin_sequences -from scenario.state import State - -CHARM_CALLED = 0 - - -@pytest.fixture(scope="function") -def mycharm(): - global CHARM_CALLED - CHARM_CALLED = 0 - - class MyCharm(CharmBase): - _call = None - require_config = False - - def __init__(self, framework: Framework): - super().__init__(framework) - self.called = False - if self.require_config: - assert self.config["foo"] == "bar" - - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) - - def _on_event(self, event): - if isinstance(event, CollectStatusEvent): - return - - global CHARM_CALLED - CHARM_CALLED += 1 - - if self._call: - self.called = True - self._call(event) - - return MyCharm - - -def test_builtin_scenes(mycharm): - check_builtin_sequences(mycharm, meta={"name": "foo"}) - assert CHARM_CALLED == 12 - - -def test_builtin_scenes_template(mycharm): - mycharm.require_config = True - check_builtin_sequences( - mycharm, - meta={"name": "foo"}, - config={"options": {"foo": {"type": "string"}}}, - template_state=State(config={"foo": "bar"}), - ) - assert CHARM_CALLED == 12 From aa66be56dadd62cdd8e92a5ceda2af162aebceff Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Apr 2024 14:31:41 +1200 Subject: [PATCH 505/546] Support 'ctx.on.event_name' for specifying events. Fix typo in comment. Remove support for directly running custom events. Update tests and docs to match final (hopefully\!) API decision. Style fixes. Fix tests. The failing test are (a) ones that need to be rewritten for the new system, (b) ones to do with custom events. Remove the old shortcuts on state components. Remove old event properties from Secret. Style fixes. Move the checks that were on binding to the consistency checker. Update rubbish event tests. These are not as valuable without support for emitting custom events directly, but it seems worthwhile to leave them in so that if we add custom event emitting back in the future they're here to build off. Update tests now that emitting custom events is not possible. Minor clean-up. Fix typo found in review. Fix tests for relation.unit. --- README.md | 99 ++---- scenario/__init__.py | 2 - scenario/consistency_checker.py | 60 +++- scenario/context.py | 254 +++++++++----- scenario/mocking.py | 6 +- scenario/ops_main_mock.py | 18 +- scenario/runtime.py | 22 +- scenario/state.py | 197 +---------- tests/helpers.py | 13 +- tests/test_charm_spec_autoload.py | 8 +- tests/test_consistency_checker.py | 204 ++++++----- tests/test_context.py | 16 +- tests/test_context_on.py | 338 +++++++++++++++++++ tests/test_e2e/test_actions.py | 10 +- tests/test_e2e/test_custom_event_triggers.py | 146 -------- tests/test_e2e/test_deferred.py | 27 +- tests/test_e2e/test_event.py | 10 +- tests/test_e2e/test_event_bind.py | 62 ---- tests/test_e2e/test_juju_log.py | 3 +- tests/test_e2e/test_manager.py | 8 +- tests/test_e2e/test_network.py | 6 +- tests/test_e2e/test_pebble.py | 21 +- tests/test_e2e/test_ports.py | 4 +- tests/test_e2e/test_relations.py | 70 ++-- tests/test_e2e/test_rubbish_events.py | 13 +- tests/test_e2e/test_secrets.py | 134 +++++--- tests/test_e2e/test_status.py | 10 +- tests/test_e2e/test_storage.py | 18 +- tests/test_e2e/test_vroot.py | 10 +- tests/test_emitted_events_util.py | 36 +- tests/test_plugin.py | 2 +- tests/test_runtime.py | 10 +- tox.ini | 2 +- 33 files changed, 983 insertions(+), 856 deletions(-) create mode 100644 tests/test_context_on.py delete mode 100644 tests/test_e2e/test_custom_event_triggers.py delete mode 100644 tests/test_e2e/test_event_bind.py diff --git a/README.md b/README.md index 3c195e89d..e69d061c3 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ With that, we can write the simplest possible scenario test: ```python def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run(scenario.Event("start"), scenario.State()) + out = ctx.run(ctx.on.start(), scenario.State()) assert out.unit_status == ops.UnknownStatus() ``` @@ -109,7 +109,7 @@ class MyCharm(ops.CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', scenario.State(leader=leader)) + out = ctx.run(ctx.on.start(), scenario.State(leader=leader)) assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') ``` @@ -163,7 +163,7 @@ You can verify that the charm has followed the expected path by checking the uni ```python def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', scenario.State(leader=False)) + out = ctx.run(ctx.on.start(), scenario.State(leader=False)) assert ctx.unit_status_history == [ ops.UnknownStatus(), ops.MaintenanceStatus('determining who the ruler is...'), @@ -197,8 +197,7 @@ class MyCharm(ops.CharmBase): self.model.unit.status = ops.ActiveStatus("foo") # ... -ctx = scenario.Context(MyCharm, meta={"name": "foo"}) -ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) +ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! # ... @@ -230,7 +229,7 @@ resulting state, black-box as it is, gives little insight into how exactly it wa ```python def test_foo(): ctx = scenario.Context(...) - ctx.run('start', ...) + ctx.run(ctx.on.start(), ...) assert len(ctx.emitted_events) == 1 assert isinstance(ctx.emitted_events[0], ops.StartEvent) @@ -248,7 +247,7 @@ def test_emitted_full(): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run("start", scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) + ctx.run(ctx.on.start(), scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ @@ -274,8 +273,8 @@ import scenario.capture_events with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( - "update-status", - scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) + ctx.on.update_status(), + scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) ) # deferred events get reemitted first @@ -333,7 +332,7 @@ def test_relation_data(): ]) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - state_out = ctx.run('start', state_in) + state_out = ctx.run(ctx.on.start(), state_in) assert state_out.relations[0].local_unit_data == {"abc": "baz!"} # you can do this to check that there are no other differences: @@ -396,9 +395,7 @@ meta = { } } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) -ctx.run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. - - +ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. ``` ### SubordinateRelation @@ -587,7 +584,7 @@ def test_pebble_push(): meta={"name": "foo", "containers": {"foo": {}}} ) ctx.run( - container.pebble_ready_event(), + ctx.on.pebble_ready(container), state_in, ) assert local_file.read().decode() == "TEST" @@ -621,7 +618,7 @@ def test_pebble_push(): meta={"name": "foo", "containers": {"foo": {}}} ) - ctx.run("start", state_in) + ctx.run(ctx.on.start(), state_in) # This is the root of the simulated container filesystem. Any mounts will be symlinks in it. container_root_fs = container.get_filesystem(ctx) @@ -667,7 +664,7 @@ def test_pebble_exec(): meta={"name": "foo", "containers": {"foo": {}}}, ) state_out = ctx.run( - container.pebble_ready_event, + ctx.on.pebble_ready(container), state_in, ) ``` @@ -752,7 +749,7 @@ From test code, you can inspect that: ```python notest ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) +ctx.run(ctx.on.some_event_that_will_cause_on_foo_to_be_called(), scenario.State()) # the charm has requested two 'foo' storages to be provisioned: assert ctx.requested_storages['foo'] == 2 @@ -765,11 +762,11 @@ So a natural follow-up Scenario test suite for this case would be: ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(foo_0.attached_event, scenario.State(storage=[foo_0])) +ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storage=[foo_0])) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, scenario.State(storage=[foo_0, foo_1])) +ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) ``` ## Ports @@ -779,15 +776,15 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ```python ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python ctx = scenario.Context(PortCharm, meta=MyCharm.META) -state1 = ctx.run("start", scenario.State()) +state1 = ctx.run(ctx.on.start(), scenario.State()) assert state1.opened_ports == [scenario.Port("tcp", 42)] -state2 = ctx.run("stop", state1) +state2 = ctx.run(ctx.on.stop(), state1) assert state2.opened_ports == [] ``` @@ -907,7 +904,7 @@ to the state: ```python ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_in = scenario.State(model=scenario.Model(name="my-model")) -out = ctx.run("start", state_in) +out = ctx.run(ctx.on.start(), state_in) assert out.model.name == "my-model" assert out.model.uuid == state_in.model.uuid ``` @@ -1024,7 +1021,7 @@ def test_start_on_deferred_update_status(MyCharm): scenario.deferred('update_status', handler=MyCharm._on_update_status) ] ) - state_out = scenario.Context(MyCharm).run('start', state_in) + state_out = scenario.Context(MyCharm).run(ctx.on.start(), state_in) assert len(state_out.deferred) == 1 assert state_out.deferred[0].name == 'start' ``` @@ -1049,7 +1046,7 @@ class MyCharm(ops.CharmBase): def test_defer(MyCharm): - out = scenario.Context(MyCharm).run('start', scenario.State()) + out = scenario.Context(MyCharm).run(ctx.on.start(), scenario.State()) assert len(out.deferred) == 1 assert out.deferred[0].name == 'start' ``` @@ -1087,54 +1084,6 @@ foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` -## Fine-tuning - -The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by -charm libraries or objects other than the main charm class. - -For general-purpose usage, you will need to instantiate DeferredEvent directly. - -```python -my_deferred_event = scenario.DeferredEvent( - handle_path='MyCharm/MyCharmLib/on/database_ready[1]', - owner='MyCharmLib', # the object observing the event. Could also be MyCharm. - observer='_on_database_ready' -) -``` - -# Emitting custom events - -While the main use case of Scenario is to emit Juju events, i.e. the built-in `start`, `install`, `*-relation-changed`, -etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy. - -Suppose your charm uses a charm library providing an `ingress_provided` event. -The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever -that may be, for example a `foo-relation-changed`. - -However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and -the event is emitted at all. - -If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: - -```python notest -ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError -``` - -This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but -since the event is defined on another Object, it will fail to find it. - -You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: - -```python notest -ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run("my_charm_lib.on.foo", scenario.State()) -``` - -This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. - -(always omit the 'root', i.e. the charm framework key, from the path) - # Live charm introspection Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to @@ -1202,7 +1151,7 @@ class MyCharmType(ops.CharmBase): ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) -ctx.run('start', scenario.State()) +ctx.run(ctx.on.start(), scenario.State()) ``` A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata @@ -1221,7 +1170,7 @@ state = scenario.Context( charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name -).run('start', scenario.State()) +).run(ctx.on.start(), scenario.State()) ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its diff --git a/scenario/__init__.py b/scenario/__init__.py index fdd42ae77..93059ebff 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -10,7 +10,6 @@ CloudSpec, Container, DeferredEvent, - Event, ExecOutput, Model, Mount, @@ -53,5 +52,4 @@ "StoredState", "State", "DeferredEvent", - "Event", ] diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 41d4ac835..004032fa7 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -20,7 +20,7 @@ ) if TYPE_CHECKING: # pragma: no cover - from scenario.state import Event, State + from scenario.state import State, _Event logger = scenario_logger.getChild("consistency_checker") @@ -34,7 +34,7 @@ class Results(NamedTuple): def check_consistency( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version: str, ): @@ -120,8 +120,9 @@ def check_resource_consistency( def check_event_consistency( *, - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", + state: "State", **_kwargs, # noqa: U101 ) -> Results: """Check the internal consistency of the Event data structure. @@ -142,23 +143,24 @@ def check_event_consistency( ) if event._is_relation_event: - _check_relation_event(charm_spec, event, errors, warnings) + _check_relation_event(charm_spec, event, state, errors, warnings) if event._is_workload_event: - _check_workload_event(charm_spec, event, errors, warnings) + _check_workload_event(charm_spec, event, state, errors, warnings) if event._is_action_event: - _check_action_event(charm_spec, event, errors, warnings) + _check_action_event(charm_spec, event, state, errors, warnings) if event._is_storage_event: - _check_storage_event(charm_spec, event, errors, warnings) + _check_storage_event(charm_spec, event, state, errors, warnings) return Results(errors, warnings) def _check_relation_event( charm_spec: _CharmSpec, # noqa: U100 - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -173,11 +175,16 @@ def _check_relation_event( f"relation event should start with relation endpoint name. {event.name} does " f"not start with {event.relation.endpoint}.", ) + if event.relation not in state.relations: + errors.append( + f"cannot emit {event.name} because relation {event.relation.id} is not in the state.", + ) def _check_workload_event( charm_spec: _CharmSpec, # noqa: U100 - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -191,11 +198,22 @@ def _check_workload_event( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", ) + if event.container not in state.containers: + errors.append( + f"cannot emit {event.name} because container {event.container.name} " + f"is not in the state.", + ) + if not event.container.can_connect: + warnings.append( + "you **can** fire fire pebble-ready while the container cannot connect, " + "but that's most likely not what you want.", + ) def _check_action_event( charm_spec: _CharmSpec, - event: "Event", + event: "_Event", + state: "State", # noqa: U100 errors: List[str], warnings: List[str], ): @@ -224,7 +242,8 @@ def _check_action_event( def _check_storage_event( charm_spec: _CharmSpec, - event: "Event", + event: "_Event", + state: "State", errors: List[str], warnings: List[str], # noqa: U100 ): @@ -246,6 +265,11 @@ def _check_storage_event( f"storage event {event.name} refers to storage {storage.name} " f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", ) + elif storage not in state.storage: + errors.append( + f"cannot emit {event.name} because storage {storage.name} " + f"is not in the state.", + ) def _check_action_param_types( @@ -396,7 +420,7 @@ def check_config_consistency( def check_secrets_consistency( *, - event: "Event", + event: "_Event", state: "State", juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 @@ -406,9 +430,11 @@ def check_secrets_consistency( if not event._is_secret_event: return Results(errors, []) - if not state.secrets: + assert event.secret is not None + if event.secret not in state.secrets: + secret_key = event.secret.id if event.secret.id else event.secret.label errors.append( - "the event being processed is a secret event; but the state has no secrets.", + f"cannot emit {event.name} because secret {secret_key} is not in the state.", ) elif juju_version < (3,): errors.append( @@ -422,7 +448,7 @@ def check_secrets_consistency( def check_network_consistency( *, state: "State", - event: "Event", # noqa: U100 + event: "_Event", # noqa: U100 charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: @@ -459,7 +485,7 @@ def check_network_consistency( def check_relation_consistency( *, state: "State", - event: "Event", # noqa: U100 + event: "_Event", # noqa: U100 charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: @@ -528,7 +554,7 @@ def _get_relations(r): def check_containers_consistency( *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: diff --git a/scenario/context.py b/scenario/context.py index 25eb9f664..db990e54e 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -11,13 +11,21 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime -from scenario.state import Action, Event, MetadataNotFoundError, _CharmSpec +from scenario.state import ( + Action, + Container, + MetadataNotFoundError, + Secret, + Storage, + _CharmSpec, + _Event, +) if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType from scenario.ops_main_mock import Ops - from scenario.state import JujuLogLine, State, _EntityStatus + from scenario.state import AnyRelation, JujuLogLine, State, _EntityStatus PathLike = Union[str, Path] @@ -72,7 +80,7 @@ class _Manager: def __init__( self, ctx: "Context", - arg: Union[str, Action, Event], + arg: Union[str, Action, _Event], state_in: "State", ): self._ctx = ctx @@ -157,6 +165,153 @@ def _get_output(self): return self._ctx._finalize_action(self._ctx.output_state) # noqa +class _CharmEvents: + """Events generated by Juju pertaining to application lifecycle. + + By default, the events listed as attributes of this class will be + provided via the :attr:`Context.on` attribute. For example:: + + ctx.run(ctx.on.config_changed(), state) + + This behaves similarly to the :class:`ops.CharmEvents` class but is much + simpler as there are no dynamically named attributes, and no __getattr__ + version to get events. In addition, all of the attributes are methods, + which are used to connect the event to the specific container object that + they relate to (or, for simpler events like "start" or "stop", take no + arguments). + """ + + @staticmethod + def install(): + return _Event("install") + + @staticmethod + def start(): + return _Event("start") + + @staticmethod + def stop(): + return _Event("stop") + + @staticmethod + def remove(): + return _Event("remove") + + @staticmethod + def update_status(): + return _Event("update_status") + + @staticmethod + def config_changed(): + return _Event("config_changed") + + @staticmethod + def upgrade_charm(): + return _Event("upgrade_charm") + + @staticmethod + def pre_series_upgrade(): + return _Event("pre_series_upgrade") + + @staticmethod + def post_series_upgrade(): + return _Event("post_series_upgrade") + + @staticmethod + def leader_elected(): + return _Event("leader_elected") + + @staticmethod + def secret_changed(secret: Secret): + if secret.owner: + raise ValueError( + "This unit will never receive secret-changed for a secret it owns.", + ) + return _Event("secret_changed", secret=secret) + + @staticmethod + def secret_expired(secret: Secret, *, revision: int): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-expire for a secret it does not own.", + ) + return _Event("secret_expired", secret=secret, secret_revision=revision) + + @staticmethod + def secret_rotate(secret: Secret): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-rotate for a secret it does not own.", + ) + return _Event("secret_rotate", secret=secret) + + @staticmethod + def secret_remove(secret: Secret, *, revision: int): + if not secret.owner: + raise ValueError( + "This unit will never receive secret-removed for a secret it does not own.", + ) + return _Event("secret_remove", secret=secret, secret_revision=revision) + + @staticmethod + def collect_app_status(): + return _Event("collect_app_status") + + @staticmethod + def collect_unit_status(): + return _Event("collect_unit_status") + + @staticmethod + def relation_created(relation: "AnyRelation"): + return _Event(f"{relation.endpoint}_relation_created", relation=relation) + + @staticmethod + def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + return _Event( + f"{relation.endpoint}_relation_joined", + relation=relation, + relation_remote_unit_id=remote_unit, + ) + + @staticmethod + def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + return _Event( + f"{relation.endpoint}_relation_changed", + relation=relation, + relation_remote_unit_id=remote_unit, + ) + + @staticmethod + def relation_departed( + relation: "AnyRelation", + *, + remote_unit: Optional[int] = None, + departing_unit: Optional[int] = None, + ): + return _Event( + f"{relation.endpoint}_relation_departed", + relation=relation, + relation_remote_unit_id=remote_unit, + relation_departed_unit_id=departing_unit, + ) + + @staticmethod + def relation_broken(relation: "AnyRelation"): + return _Event(f"{relation.endpoint}_relation_broken", relation=relation) + + @staticmethod + def storage_attached(storage: Storage): + return _Event(f"{storage.name}_storage_attached", storage=storage) + + @staticmethod + def storage_detaching(storage: Storage): + return _Event(f"{storage.name}_storage_detaching", storage=storage) + + @staticmethod + def pebble_ready(container: Container): + return _Event(f"{container.name}_pebble_ready", container=container) + + class Context: """Represents a simulated charm's execution context. @@ -280,7 +435,7 @@ def __init__( >>> # Arrange: set the context up >>> c = Context(MyCharm) >>> # Act: prepare the state and emit an event - >>> state_out = c.run('update-status', State()) + >>> state_out = c.run(c.update_status(), State()) >>> # Assert: verify the output state is what you think it should be >>> assert state_out.unit_status == ActiveStatus('foobar') >>> # Assert: verify the Context contains what you think it should @@ -367,6 +522,8 @@ def __init__( self._action_results: Optional[Dict[str, str]] = None self._action_failure: Optional[str] = None + self.on = _CharmEvents() + def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" self._output_state = output_state @@ -402,39 +559,7 @@ def _record_status(self, state: "State", is_app: bool): else: self.unit_status_history.append(cast("_EntityStatus", state.unit_status)) - @staticmethod - def _coalesce_action(action: Union[str, Action]) -> Action: - """Validate the action argument and cast to Action.""" - if isinstance(action, str): - return Action(action) - - if not isinstance(action, Action): - raise InvalidActionError( - f"Expected Action or action name; got {type(action)}", - ) - return action - - @staticmethod - def _coalesce_event(event: Union[str, Event]) -> Event: - """Validate the event argument and cast to Event.""" - if isinstance(event, str): - event = Event(event) - - if not isinstance(event, Event): - raise InvalidEventError(f"Expected Event | str, got {type(event)}") - - if event._is_action_event: # noqa - raise InvalidEventError( - "Cannot Context.run() action events. " - "Use Context.run_action instead.", - ) - return event - - def manager( - self, - event: Union["Event", str], - state: "State", - ): + def manager(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage:: @@ -450,69 +575,53 @@ def manager( """ return _EventManager(self, event, state) - def action_manager( - self, - action: Union["Action", str], - 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: - >>> with Context().action_manager("foo-action", State()) as manager: + >>> 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. Can be a string or an Action instance. + :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: Union["Event", str], - state: "State", - ): - _event = self._coalesce_event(event) - with self._run(event=_event, state=state) as ops: + def _run_event(self, event: "_Event", state: "State"): + with self._run(event=event, state=state) as ops: yield ops - def run( - self, - event: Union["Event", str], - state: "State", - ) -> "State": + def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an 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 Event that the charm will respond to. Can be a string or an Event instance. + :arg event: the Event that the charm will respond to. :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: + 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: Union["Action", str], - state: "State", - ) -> ActionOutput: + def run_action(self, action: "Action", state: "State") -> ActionOutput: """Trigger a charm execution with an Action 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. Can be a string or an Action instance. + :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). """ - _action = self._coalesce_action(action) - with self._run_action(action=_action, state=state) as ops: + with self._run_action(action=action, state=state) as ops: ops.emit() return self._finalize_action(self.output_state) @@ -532,21 +641,12 @@ def _finalize_action(self, state_out: "State"): return ao @contextmanager - def _run_action( - self, - action: Union["Action", str], - state: "State", - ): - _action = self._coalesce_action(action) - with self._run(event=_action.event, state=state) as ops: + 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", - ): + def _run(self, event: "_Event", state: "State"): runtime = Runtime( charm_spec=self.charm_spec, juju_version=self.juju_version, diff --git a/scenario/mocking.py b/scenario/mocking.py index ac56d719b..5f2c17c6c 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -49,13 +49,13 @@ from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( - Event, ExecOutput, Relation, Secret, State, SubordinateRelation, _CharmSpec, + _Event, ) logger = scenario_logger.getChild("mocking") @@ -102,7 +102,7 @@ class _MockModelBackend(_ModelBackend): def __init__( self, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", context: "Context", ): @@ -655,7 +655,7 @@ def __init__( mounts: Dict[str, Mount], *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", ): self._state = state diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 8b2845c81..d2a0371a2 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: # pragma: no cover from scenario.context import Context - from scenario.state import Event, State, _CharmSpec + from scenario.state import State, _CharmSpec, _Event # pyright: reportPrivateUsage=false @@ -66,7 +66,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: def _emit_charm_event( charm: "CharmBase", event_name: str, - event: Optional["Event"] = None, + event: Optional["_Event"] = None, ): """Emits a charm event based on a Juju event name. @@ -83,8 +83,7 @@ def _emit_charm_event( ops_logger.debug("Event %s not defined for %s.", event_name, charm) raise NoObserverError( f"Cannot fire {event_name!r} on {owner}: " - f"invalid event (not on charm.on). " - f"Use Context.run_custom instead.", + f"invalid event (not on charm.on).", ) try: @@ -105,7 +104,7 @@ def _emit_charm_event( def setup_framework( charm_dir, state: "State", - event: "Event", + event: "_Event", context: "Context", charm_spec: "_CharmSpec", ): @@ -176,7 +175,12 @@ def setup_charm(charm_class, framework, dispatcher): return charm -def setup(state: "State", event: "Event", context: "Context", charm_spec: "_CharmSpec"): +def setup( + state: "State", + event: "_Event", + context: "Context", + charm_spec: "_CharmSpec", +): """Setup dispatcher, framework and charm objects.""" charm_class = charm_spec.charm_type charm_dir = _get_charm_dir() @@ -204,7 +208,7 @@ class Ops: def __init__( self, state: "State", - event: "Event", + event: "_Event", context: "Context", charm_spec: "_CharmSpec", ): diff --git a/scenario/runtime.py b/scenario/runtime.py index 73f6e28df..114e66a91 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -26,7 +26,7 @@ from ops.testing import CharmType from scenario.context import Context - from scenario.state import Event, State, _CharmSpec + from scenario.state import State, _CharmSpec, _Event PathLike = Union[str, Path] @@ -181,7 +181,7 @@ def _cleanup_env(env): # os.unsetenv does not always seem to work !? del os.environ[key] - def _get_event_env(self, state: "State", event: "Event", charm_root: Path): + def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): """Build the simulated environment the operator framework expects.""" env = { "JUJU_VERSION": self._juju_version, @@ -219,7 +219,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): remote_unit_id = event.relation_remote_unit_id # don't check truthiness because remote_unit_id could be 0 - if remote_unit_id is None: + if remote_unit_id is None and not event.name.endswith( + ("_relation_created", "relation_broken"), + ): remote_unit_ids = relation._remote_unit_ids # pyright: ignore if len(remote_unit_ids) == 1: @@ -246,7 +248,12 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): remote_unit = f"{remote_app_name}/{remote_unit_id}" env["JUJU_REMOTE_UNIT"] = remote_unit if event.name.endswith("_relation_departed"): - env["JUJU_DEPARTING_UNIT"] = remote_unit + if event.relation_departed_unit_id: + env[ + "JUJU_DEPARTING_UNIT" + ] = f"{remote_app_name}/{event.relation_departed_unit_id}" + else: + env["JUJU_DEPARTING_UNIT"] = remote_unit if container := event.container: env.update({"JUJU_WORKLOAD_NAME": container.name}) @@ -274,8 +281,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path): "JUJU_SECRET_LABEL": secret.label or "", }, ) - if event.name in ("secret_remove", "secret_expired"): - env["JUJU_SECRET_REVISION"] = str(secret.revision) + # Don't check truthiness because revision could be 0. + if event.secret_revision is not None: + env["JUJU_SECRET_REVISION"] = str(event.secret_revision) return env @@ -398,7 +406,7 @@ def _exec_ctx(self, ctx: "Context"): def exec( self, state: "State", - event: "Event", + event: "_Event", context: "Context", ): """Runs an event with this state as initial state on a charm. diff --git a/scenario/state.py b/scenario/state.py index 0a31d1cc0..de814e3d1 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -123,10 +123,6 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" -class BindFailedError(RuntimeError): - """Raised when Event.bind fails.""" - - @dataclasses.dataclass(frozen=True) class CloudCredential: auth_type: str @@ -224,44 +220,6 @@ class Secret: expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None - # consumer-only events - @property - def changed_event(self): - """Sugar to generate a secret-changed event.""" - if self.owner: - raise ValueError( - "This unit will never receive secret-changed for a secret it owns.", - ) - return Event("secret_changed", secret=self) - - # owner-only events - @property - def rotate_event(self): - """Sugar to generate a secret-rotate event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-rotate for a secret it does not own.", - ) - return Event("secret_rotate", secret=self) - - @property - def expired_event(self): - """Sugar to generate a secret-expired event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-expired for a secret it does not own.", - ) - return Event("secret_expired", secret=self) - - @property - def remove_event(self): - """Sugar to generate a secret-remove event.""" - if not self.owner: - raise ValueError( - "This unit will never receive secret-remove for a secret it does not own.", - ) - return Event("secret_remove", secret=self) - def _set_revision(self, revision: int): """Set a new tracked revision.""" # bypass frozen dataclass @@ -452,46 +410,6 @@ def _validate_databag(self, databag: dict): f"found a value of type {type(v)}", ) - @property - def changed_event(self) -> "Event": - """Sugar to generate a -relation-changed event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-changed"), - relation=cast("AnyRelation", self), - ) - - @property - def joined_event(self) -> "Event": - """Sugar to generate a -relation-joined event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-joined"), - relation=cast("AnyRelation", self), - ) - - @property - def created_event(self) -> "Event": - """Sugar to generate a -relation-created event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-created"), - relation=cast("AnyRelation", self), - ) - - @property - def departed_event(self) -> "Event": - """Sugar to generate a -relation-departed event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-departed"), - relation=cast("AnyRelation", self), - ) - - @property - def broken_event(self) -> "Event": - """Sugar to generate a -relation-broken event.""" - return Event( - path=normalize_name(self.endpoint + "-relation-broken"), - relation=cast("AnyRelation", self), - ) - _DEFAULT_IP = " 192.0.2.0" DEFAULT_JUJU_DATABAG = { @@ -898,16 +816,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """ return ctx._get_container_root(self.name) - @property - def pebble_ready_event(self): - """Sugar to generate a -pebble-ready event.""" - if not self.can_connect: - logger.warning( - "you **can** fire pebble-ready while the container cannot connect, " - "but that's most likely not what you want.", - ) - return Event(path=normalize_name(self.name + "-pebble-ready"), container=self) - def get_notice( self, key: str, @@ -925,7 +833,6 @@ def get_notice( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) - _RawStatusLiteral = Literal[ "waiting", "blocked", @@ -1052,22 +959,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """Simulated filesystem root in this context.""" return ctx._get_storage_root(self.name, self.index) - @property - def attached_event(self) -> "Event": - """Sugar to generate a -storage-attached event.""" - return Event( - path=normalize_name(self.name + "-storage-attached"), - storage=self, - ) - - @property - def detaching_event(self) -> "Event": - """Sugar to generate a -storage-detached event.""" - return Event( - path=normalize_name(self.name + "-storage-detaching"), - storage=self, - ) - @dataclasses.dataclass(frozen=True) class State: @@ -1415,11 +1306,14 @@ class Event: relation: Optional["AnyRelation"] = None """If this is a relation event, the relation it refers to.""" relation_remote_unit_id: Optional[int] = None - """If this is a relation event, the name of the remote unit the event is about.""" + relation_departed_unit_id: Optional[int] = None secret: Optional[Secret] = None """If this is a secret event, the secret it refers to.""" + # if this is a secret-removed or secret-expired event, the secret revision it refers to + secret_revision: Optional[int] = None + container: Optional[Container] = None """If this is a workload (container) event, the container it refers to.""" @@ -1429,21 +1323,8 @@ class Event: action: Optional["Action"] = None """If this is an action event, the :class:`Action` it refers to.""" - # TODO: add other meta for - # - secret events - # - pebble? - # - action? - _owner_path: List[str] = dataclasses.field(default_factory=list) - def __call__(self, remote_unit_id: Optional[int] = None) -> "Event": - if remote_unit_id and not self._is_relation_event: - raise ValueError( - "cannot pass param `remote_unit_id` to a " - "non-relation event constructor.", - ) - return dataclasses.replace(self, relation_remote_unit_id=remote_unit_id) - def __post_init__(self): path = _EventPath(self.path) # bypass frozen dataclass @@ -1521,68 +1402,6 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"): # assuming it is owned by the charm, LOOKS LIKE that of a builtin event or not. return self._path.type is not _EventType.custom - def bind(self, state: State): - """Attach to this event the state component it needs. - - For example, a relation event initialized without a Relation instance will search for - a suitable relation in the provided state and return a copy of itself with that - relation attached. - - In case of ambiguity (e.g. multiple relations found on 'foo' for event - 'foo-relation-changed', we pop a warning and bind the first one. Use with care! - """ - entity_name = self._path.prefix - - if self._is_workload_event and not self.container: - try: - container = state.get_container(entity_name) - except ValueError: - raise BindFailedError(f"no container found with name {entity_name}") - return dataclasses.replace(self, container=container) - - if self._is_secret_event and not self.secret: - if len(state.secrets) < 1: - raise BindFailedError(f"no secrets found in state: cannot bind {self}") - if len(state.secrets) > 1: - raise BindFailedError( - f"too many secrets found in state: cannot automatically bind {self}", - ) - return dataclasses.replace(self, secret=state.secrets[0]) - - if self._is_storage_event and not self.storage: - storages = state.get_storages(entity_name) - if len(storages) < 1: - raise BindFailedError( - f"no storages called {entity_name} found in state", - ) - if len(storages) > 1: - logger.warning( - f"too many storages called {entity_name}: binding to first one", - ) - storage = storages[0] - return dataclasses.replace(self, storage=storage) - - if self._is_relation_event and not self.relation: - ep_name = entity_name - relations = state.get_relations(ep_name) - if len(relations) < 1: - raise BindFailedError(f"no relations on {ep_name} found in state") - if len(relations) > 1: - logger.warning(f"too many relations on {ep_name}: binding to first one") - return dataclasses.replace(self, relation=relations[0]) - - if self._is_action_event and not self.action: - raise BindFailedError( - "cannot automatically bind action events: if the action has mandatory parameters " - "this would probably result in horrible, undebuggable failures downstream.", - ) - - else: - raise BindFailedError( - f"cannot bind {self}: only relation, secret, " - f"or workload events can be bound.", - ) - def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: """Construct a DeferredEvent from this Event.""" handler_repr = repr(handler) @@ -1682,13 +1501,13 @@ def test_backup_action(): the rare cases where a specific ID is required.""" @property - def event(self) -> Event: + def event(self) -> _Event: """Helper to generate an action event from this action.""" - return Event(self.name + ACTION_EVENT_SUFFIX, action=self) + return _Event(self.name + ACTION_EVENT_SUFFIX, action=self) def deferred( - event: Union[str, Event], + event: Union[str, _Event], handler: Callable, event_id: int = 1, relation: Optional["Relation"] = None, @@ -1697,5 +1516,5 @@ def deferred( ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - event = Event(event, relation=relation, container=container, notice=notice) + event = _Event(event, relation=relation, container=container, notice=notice) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/helpers.py b/tests/helpers.py index 712b62d15..7dd1f8351 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType - from scenario.state import Event, State + from scenario.state import State, _Event _CT = TypeVar("_CT", bound=Type[CharmType]) @@ -31,7 +31,7 @@ def trigger( state: "State", - event: Union["Event", str], + event: Union[str, "_Event"], charm_type: Type["CharmType"], pre_event: Optional[Callable[["CharmType"], None]] = None, post_event: Optional[Callable[["CharmType"], None]] = None, @@ -49,6 +49,15 @@ def trigger( charm_root=charm_root, juju_version=juju_version, ) + if isinstance(event, str): + if event.startswith("relation_"): + assert len(state.relations) == 1, "shortcut only works with one relation" + event = getattr(ctx.on, event)(state.relations[0]) + elif event.startswith("pebble_"): + assert len(state.containers) == 1, "shortcut only works with one container" + event = getattr(ctx.on, event)(state.containers[0]) + else: + event = getattr(ctx.on, event)() with ctx.manager(event, state=state) as mgr: if pre_event: pre_event(mgr.charm) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 89a9cfdfb..51ba13919 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -115,7 +115,7 @@ def test_meta_autoload(tmp_path, legacy): meta={"type": "charm", "name": "foo", "summary": "foo", "description": "foo"}, ) as charm: ctx = Context(charm) - ctx.run("start", State()) + ctx.run(ctx.on.start(), State()) @pytest.mark.parametrize("legacy", (True, False)) @@ -143,7 +143,8 @@ def test_relations_ok(tmp_path, legacy): }, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta - Context(charm).run("start", State(relations=[Relation("cuddles")])) + ctx = Context(charm) + ctx.run(ctx.on.start(), State(relations=[Relation("cuddles")])) @pytest.mark.parametrize("legacy", (True, False)) @@ -160,6 +161,7 @@ def test_config_defaults(tmp_path, legacy): config={"options": {"foo": {"type": "bool", "default": True}}}, ) as charm: # this would fail if there were no 'cuddles' relation defined in meta - with Context(charm).manager("start", State()) as mgr: + ctx = Context(charm) + with ctx.manager(ctx.on.start(), State()) as mgr: mgr.run() assert mgr.charm.config["foo"] is True diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index f25b91797..217a68d9f 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,7 +12,6 @@ CloudCredential, CloudSpec, Container, - Event, Network, Notice, PeerRelation, @@ -23,6 +22,7 @@ StoredState, SubordinateRelation, _CharmSpec, + _Event, ) @@ -32,7 +32,7 @@ class MyCharm(CharmBase): def assert_inconsistent( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version="3.0", ): @@ -42,7 +42,7 @@ def assert_inconsistent( def assert_consistent( state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", juju_version="3.0", ): @@ -51,7 +51,7 @@ def assert_consistent( def test_base(): state = State() - event = Event("update_status") + event = _Event("update_status") spec = _CharmSpec(MyCharm, {}) assert_consistent(state, event, spec) @@ -59,12 +59,12 @@ def test_base(): def test_workload_event_without_container(): assert_inconsistent( State(), - Event("foo-pebble-ready", container=Container("foo")), + _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("foo")]), - Event("foo-pebble-ready", container=Container("foo")), + _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( @@ -88,12 +88,12 @@ def test_workload_event_without_container(): def test_container_meta_mismatch(): assert_inconsistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {"containers": {"baz": {}}}), ) assert_consistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -101,12 +101,26 @@ def test_container_meta_mismatch(): def test_container_in_state_but_no_container_in_meta(): assert_inconsistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("bar")]), - Event("foo"), + _Event("foo"), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + + +def test_container_not_in_state(): + container = Container("bar") + assert_inconsistent( + State(), + _Event("bar_pebble_ready", container=container), + _CharmSpec(MyCharm, {"containers": {"bar": {}}}), + ) + assert_consistent( + State(containers=[container]), + _Event("bar_pebble_ready", container=container), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -114,12 +128,12 @@ def test_container_in_state_but_no_container_in_meta(): def test_evt_bad_container_name(): assert_inconsistent( State(), - Event("foo-pebble-ready", container=Container("bar")), + _Event("foo-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {}), ) assert_consistent( State(containers=[Container("bar")]), - Event("bar-pebble-ready", container=Container("bar")), + _Event("bar-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -128,22 +142,24 @@ def test_evt_bad_container_name(): def test_evt_bad_relation_name(suffix): assert_inconsistent( State(), - Event(f"foo{suffix}", relation=Relation("bar")), + _Event(f"foo{suffix}", relation=Relation("bar")), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "xxx"}}}), ) + relation = Relation("bar") assert_consistent( - State(relations=[Relation("bar")]), - Event(f"bar{suffix}", relation=Relation("bar")), + State(relations=[relation]), + _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) def test_evt_no_relation(suffix): - assert_inconsistent(State(), Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) + assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) + relation = Relation("bar") assert_consistent( - State(relations=[Relation("bar")]), - Event(f"bar{suffix}", relation=Relation("bar")), + State(relations=[relation]), + _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -151,12 +167,12 @@ def test_evt_no_relation(suffix): def test_config_key_missing_from_meta(): assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), ) assert_consistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), ) @@ -164,17 +180,17 @@ def test_config_key_missing_from_meta(): def test_bad_config_option_type(): assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}), ) assert_inconsistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {}}}), ) assert_consistent( State(config={"foo": True}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}), ) @@ -192,12 +208,12 @@ def test_config_types(config_type): type_name, valid_value, invalid_value = config_type assert_consistent( State(config={"foo": valid_value}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), ) assert_inconsistent( State(config={"foo": invalid_value}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}), ) @@ -206,28 +222,28 @@ def test_config_types(config_type): def test_config_secret(juju_version): assert_consistent( State(config={"foo": "secret:co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), juju_version=juju_version, ) assert_inconsistent( State(config={"foo": 1}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "secret:secret"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) assert_inconsistent( State(config={"foo": "secret:co28kefmp25c77utl3n!"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), ) @@ -236,7 +252,7 @@ def test_config_secret(juju_version): def test_config_secret_old_juju(juju_version): assert_inconsistent( State(config={"foo": "secret:co28kefmp25c77utl3n0"}), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}), juju_version=juju_version, ) @@ -247,7 +263,7 @@ def test_secrets_jujuv_bad(bad_v): secret = Secret("secret:foo", {0: {"a": "b"}}) assert_inconsistent( State(secrets=[secret]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), bad_v, ) @@ -270,21 +286,35 @@ def test_secrets_jujuv_bad(bad_v): def test_secrets_jujuv_bad(good_v): assert_consistent( State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {}), good_v, ) +def test_secret_not_in_state(): + secret = Secret("secret:foo", {"a": "b"}) + assert_inconsistent( + State(), + _Event("secret_changed", secret=secret), + _CharmSpec(MyCharm, {}), + ) + assert_consistent( + State(secrets=[secret]), + _Event("secret_changed", secret=secret), + _CharmSpec(MyCharm, {}), + ) + + def test_peer_relation_consistency(): assert_inconsistent( State(relations=[Relation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) assert_consistent( State(relations=[PeerRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) @@ -292,7 +322,7 @@ def test_peer_relation_consistency(): def test_duplicate_endpoints_inconsistent(): assert_inconsistent( State(), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, { @@ -306,7 +336,7 @@ def test_duplicate_endpoints_inconsistent(): def test_sub_relation_consistency(): assert_inconsistent( State(relations=[Relation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, @@ -315,7 +345,7 @@ def test_sub_relation_consistency(): assert_consistent( State(relations=[SubordinateRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec( MyCharm, {"requires": {"foo": {"interface": "bar", "scope": "container"}}}, @@ -326,25 +356,30 @@ def test_sub_relation_consistency(): def test_relation_sub_inconsistent(): assert_inconsistent( State(relations=[SubordinateRelation("foo")]), - Event("bar"), + _Event("bar"), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_dupe_containers_inconsistent(): +def test_relation_not_in_state(): + relation = Relation("foo") assert_inconsistent( - State(containers=[Container("foo"), Container("foo")]), - Event("bar"), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + State(), + _Event("foo_relation_changed", relation=relation), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), + ) + assert_consistent( + State(relations=[relation]), + _Event("foo_relation_changed", relation=relation), + _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_container_pebble_evt_consistent(): - container = Container("foo-bar-baz") - assert_consistent( - State(containers=[container]), - container.pebble_ready_event, - _CharmSpec(MyCharm, {"containers": {"foo-bar-baz": {}}}), +def test_dupe_containers_inconsistent(): + assert_inconsistent( + State(containers=[Container("foo"), Container("foo")]), + _Event("bar"), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -386,7 +421,7 @@ def test_action_name(): ) assert_inconsistent( State(), - Event("box_action", action=action), + _Event("box_action", action=action), _CharmSpec(MyCharm, meta={}, actions={"foo": {}}), ) @@ -425,9 +460,9 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + relations=[Relation("foo", id=1), Relation("bar", id=1)] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -440,17 +475,17 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)] + relations=[Relation("foo", id=1), Relation("bar", id=1)] ), - Event("start"), + _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( State( - relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)] + relations=[Relation("foo", id=1), Relation("bar", id=2)] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -464,19 +499,12 @@ def test_storage_event(): storage = Storage("foo") assert_inconsistent( State(storage=[storage]), - Event("foo-storage-attached"), + _Event("foo-storage-attached"), _CharmSpec(MyCharm, meta={"name": "rupert"}), ) assert_inconsistent( State(storage=[storage]), - Event("foo-storage-attached"), - _CharmSpec( - MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} - ), - ) - assert_consistent( - State(storage=[storage]), - storage.attached_event, + _Event("foo-storage-attached"), _CharmSpec( MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} ), @@ -489,19 +517,19 @@ def test_storage_states(): assert_inconsistent( State(storage=[storage1, storage2]), - Event("start"), + _Event("start"), _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( State(storage=[storage1, dataclasses.replace(storage2, index=2)]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -515,11 +543,31 @@ def test_storage_states(): ) +def test_storage_not_in_state(): + storage = Storage("foo") + assert_inconsistent( + State(), + _Event("foo_storage_attached", storage=storage), + _CharmSpec( + MyCharm, + meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}}, + ), + ) + assert_consistent( + State(storage=[storage]), + _Event("foo_storage_attached", storage=storage), + _CharmSpec( + MyCharm, + meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}}, + ), + ) + + def test_resource_states(): # happy path assert_consistent( State(resources={"foo": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -529,7 +577,7 @@ def test_resource_states(): # no resources in state but some in meta: OK. Not realistic wrt juju but fine for testing assert_consistent( State(), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -539,7 +587,7 @@ def test_resource_states(): # resource not defined in meta assert_inconsistent( State(resources={"bar": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}}, @@ -548,7 +596,7 @@ def test_resource_states(): assert_inconsistent( State(resources={"bar": "/foo/bar.yaml"}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "yamlman"}, @@ -559,7 +607,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "wonky"}, @@ -568,7 +616,7 @@ def test_networks_consistency(): assert_inconsistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -581,7 +629,7 @@ def test_networks_consistency(): assert_consistent( State(networks={"foo": Network.default()}), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -637,7 +685,7 @@ def test_storedstate_consistency(): StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), ] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -652,7 +700,7 @@ def test_storedstate_consistency(): StoredState(None, "_stored", content={"foo": "bar"}), ] ), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ @@ -662,7 +710,7 @@ def test_storedstate_consistency(): ) assert_inconsistent( State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={ diff --git a/tests/test_context.py b/tests/test_context.py index 7d2b795c2..d6995efc9 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import Action, Context, Event, State -from scenario.state import next_action_id +from scenario import Action, Context, State +from scenario.state import _Event, next_action_id class MyCharm(CharmBase): @@ -17,14 +17,14 @@ def test_run(): with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope - output = ctx.run("start", state) + output = ctx.run(ctx.on.start(), state) assert output == "foo" assert p.called e = p.call_args.kwargs["event"] s = p.call_args.kwargs["state"] - assert isinstance(e, Event) + assert isinstance(e, _Event) assert e.name == "start" assert s is state @@ -38,7 +38,8 @@ def test_run_action(): ctx._output_state = ( "foo" # would normally be set within the _run_action call scope ) - output = ctx.run_action("do-foo", state) + action = Action("do-foo") + output = ctx.run_action(action, state) assert output.state == "foo" assert p.called @@ -54,8 +55,7 @@ def test_run_action(): @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @pytest.mark.parametrize("unit_id", (1, 2, 42)) def test_app_name(app_name, unit_id): - with Context( - MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id - ).manager("start", State()) as mgr: + 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}" diff --git a/tests/test_context_on.py b/tests/test_context_on.py new file mode 100644 index 000000000..be8c70b59 --- /dev/null +++ b/tests/test_context_on.py @@ -0,0 +1,338 @@ +import copy + +import ops +import pytest + +import scenario + +META = { + "name": "context-charm", + "containers": { + "bar": {}, + }, + "requires": { + "baz": { + "interface": "charmlink", + } + }, + "storage": { + "foo": { + "type": "filesystem", + } + }, +} +ACTIONS = { + "act": { + "params": { + "param": { + "description": "some parameter", + "type": "string", + "default": "", + } + } + }, +} + + +class ContextCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.observed = [] + for event in self.on.events().values(): + framework.observe(event, self._on_event) + + def _on_event(self, event): + self.observed.append(event) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("install", ops.InstallEvent), + ("start", ops.StartEvent), + ("stop", ops.StopEvent), + ("remove", ops.RemoveEvent), + ("update_status", ops.UpdateStatusEvent), + ("config_changed", ops.ConfigChangedEvent), + ("upgrade_charm", ops.UpgradeCharmEvent), + ("pre_series_upgrade", ops.PreSeriesUpgradeEvent), + ("post_series_upgrade", ops.PostSeriesUpgradeEvent), + ("leader_elected", ops.LeaderElectedEvent), + ], +) +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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + assert isinstance(mgr.charm.observed[0], event_kind) + + +@pytest.mark.parametrize("as_kwarg", [True, False]) +@pytest.mark.parametrize( + "event_name,event_kind,owner", + [ + ("secret_changed", ops.SecretChangedEvent, None), + ("secret_rotate", ops.SecretRotateEvent, "app"), + ], +) +def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret("secret:123", {0: {"password": "xxxx"}}, owner=owner) + state_in = scenario.State(secrets=[secret]) + # These look like: + # ctx.run(ctx.on.secret_changed(secret=secret), state) + # The secret must always be passed because the same event name is used for + # all secrets. + if as_kwarg: + args = () + kwargs = {"secret": secret} + else: + args = (secret,) + kwargs = {} + with ctx.manager(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) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.secret.id == secret.id + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("secret_expired", ops.SecretExpiredEvent), + ("secret_remove", ops.SecretRemoveEvent), + ], +) +def test_revision_secret_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret( + "secret:123", + {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + owner="app", + ) + state_in = scenario.State(secrets=[secret]) + # These look like: + # 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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.secret.id == secret.id + assert event.revision == 42 + + +@pytest.mark.parametrize("event_name", ["secret_expired", "secret_remove"]) +def test_revision_secret_events_as_positional_arg(event_name): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + secret = scenario.Secret( + "secret:123", {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner=None + ) + state_in = scenario.State(secrets=[secret]) + with pytest.raises(TypeError): + ctx.run(getattr(ctx.on, event_name)(secret, 42), state_in) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("storage_attached", ops.StorageAttachedEvent), + ("storage_detaching", ops.StorageDetachingEvent), + ], +) +def test_storage_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + storage = scenario.Storage("foo") + state_in = scenario.State(storage=[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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.storage.name == storage.name + assert event.storage.index == storage.index + + +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) + action = scenario.Action("act") + with ctx.action_manager(action, scenario.State()) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.ActionEvent) + + +def test_action_event_with_params(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + action = scenario.Action("act", {"param": "hello"}) + # These look like: + # ctx.run_action(ctx.on.action(action=action), state) + # So that any parameters can be included and the ID can be customised. + with ctx.action_manager(action, scenario.State()) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.ActionEvent) + assert event.id == action.id + assert event.params["param"] == action.params["param"] + + +def test_pebble_ready_event(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + container = scenario.Container("bar", can_connect=True) + 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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.PebbleReadyEvent) + assert event.workload.name == container.name + + +@pytest.mark.parametrize("as_kwarg", [True, False]) +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_created", ops.RelationCreatedEvent), + ("relation_broken", ops.RelationBrokenEvent), + ], +) +def test_relation_app_events(as_kwarg, event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + state_in = scenario.State(relations=[relation]) + # These look like: + # ctx.run(ctx.on.relation_created(relation), state) + if as_kwarg: + args = () + kwargs = {"relation": relation} + else: + args = (relation,) + kwargs = {} + with ctx.manager(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) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit is None + + +def test_relation_complex_name(): + meta = copy.deepcopy(META) + meta["requires"]["foo-bar-baz"] = {"interface": "another-one"} + 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: + mgr.run() + assert len(mgr.charm.observed) == 2 + event = mgr.charm.observed[0] + assert isinstance(event, ops.RelationCreatedEvent) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit is None + + +@pytest.mark.parametrize("event_name", ["relation_created", "relation_broken"]) +def test_relation_events_as_positional_arg(event_name): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + state_in = scenario.State(relations=[relation]) + with pytest.raises(TypeError): + ctx.run(getattr(ctx.on, event_name)(relation, 0), state_in) + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_joined", ops.RelationJoinedEvent), + ("relation_changed", ops.RelationChangedEvent), + ], +) +def test_relation_unit_events_default_unit(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz", remote_units_data={1: {"x": "y"}}) + state_in = scenario.State(relations=[relation]) + # 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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/1" + + +@pytest.mark.parametrize( + "event_name, event_kind", + [ + ("relation_joined", ops.RelationJoinedEvent), + ("relation_changed", ops.RelationChangedEvent), + ], +) +def test_relation_unit_events(event_name, event_kind): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation( + "baz", remote_units_data={1: {"x": "y"}, 2: {"x": "z"}} + ) + 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: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, event_kind) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/2" + + +def test_relation_departed_event(): + ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) + relation = scenario.Relation("baz") + 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( + ctx.on.relation_departed(relation, remote_unit=2, departing_unit=1), state_in + ) as mgr: + mgr.run() + assert len(mgr.charm.observed) == 2 + assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) + event = mgr.charm.observed[0] + assert isinstance(event, ops.RelationDepartedEvent) + assert event.relation.id == relation.id + assert event.app.name == relation.remote_app_name + assert event.unit.name == "remote/2" + assert event.departing_unit.name == "remote/1" diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 30cc9d1e8..6256885c0 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,8 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, Event, State -from tests.helpers import trigger +from scenario.state import Action, State, _Event @pytest.fixture(scope="function") @@ -65,13 +64,6 @@ def test_cannot_run_action(mycharm): ctx.run(action, state=State()) -def test_cannot_run_action_name(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") - with pytest.raises(InvalidEventError): - ctx.run(action.event.name, state=State()) - - def test_cannot_run_action_event(mycharm): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) action = Action("foo") diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py deleted file mode 100644 index 1c6f07cd4..000000000 --- a/tests/test_e2e/test_custom_event_triggers.py +++ /dev/null @@ -1,146 +0,0 @@ -import os -from unittest.mock import MagicMock - -import pytest -from ops.charm import CharmBase, CharmEvents -from ops.framework import EventBase, EventSource, Object - -from scenario import State -from scenario.ops_main_mock import NoObserverError -from scenario.runtime import InconsistentScenarioError -from tests.helpers import trigger - - -def test_custom_event_emitted(): - class FooEvent(EventBase): - pass - - class MyCharmEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - on = MyCharmEvents() - _foo_called = 0 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.framework.observe(self.on.foo, self._on_foo) - self.framework.observe(self.on.start, self._on_start) - - def _on_foo(self, e): - MyCharm._foo_called += 1 - - def _on_start(self, e): - self.on.foo.emit() - - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called == 1 - - trigger(State(), "start", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called == 2 - - -def test_funky_named_event_emitted(): - class FooRelationChangedEvent(EventBase): - pass - - class MyCharmEvents(CharmEvents): - foo_relation_changed = EventSource(FooRelationChangedEvent) - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - on = MyCharmEvents() - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.framework.observe(self.on.foo_relation_changed, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - # we called our custom event like a builtin one. Trouble! - with pytest.raises(InconsistentScenarioError): - trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) - - assert not MyCharm._foo_called - - os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "1" - trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called - os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS") - - -def test_child_object_event_emitted_no_path_raises(): - class FooEvent(EventBase): - pass - - class MyObjEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyObject(Object): - my_on = MyObjEvents() - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.obj = MyObject(self, "child") - self.framework.observe(self.obj.my_on.foo, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - with pytest.raises(NoObserverError): - # this will fail because "foo" isn't registered on MyCharm but on MyCharm.foo - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - assert MyCharm._foo_called - - # workaround: we can use pre_event to have Scenario set up the simulation for us and run our - # test code before it eventually fails. pre_event gets called with the set-up charm instance. - def pre_event(charm: MyCharm): - event_mock = MagicMock() - charm._on_foo(event_mock) - assert charm.unit.name == "mycharm/0" - - # make sure you only filter out NoObserverError, else if pre_event raises, - # they will also be caught while you want them to bubble up. - with pytest.raises(NoObserverError): - trigger( - State(), - "rubbish", # you can literally put anything here - MyCharm, - pre_event=pre_event, - meta=MyCharm.META, - ) - assert MyCharm._foo_called - - -def test_child_object_event(): - class FooEvent(EventBase): - pass - - class MyObjEvents(CharmEvents): - foo = EventSource(FooEvent) - - class MyObject(Object): - my_on = MyObjEvents() - - class MyCharm(CharmBase): - META = {"name": "mycharm"} - _foo_called = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.obj = MyObject(self, "child") - self.framework.observe(self.obj.my_on.foo, self._on_foo) - - def _on_foo(self, e): - MyCharm._foo_called = True - - trigger(State(), "obj.my_on.foo", MyCharm, meta=MyCharm.META) - - assert MyCharm._foo_called diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index a96100bcd..8645c77b1 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,7 +12,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Container, DeferredEvent, Notice, Relation, State, deferred +from scenario.state import Container, Notice, Relation, State, _Event, deferred from tests.helpers import trigger CHARM_CALLED = 0 @@ -79,7 +79,9 @@ def test_deferred_relation_event_without_relation_raises(mycharm): def test_deferred_relation_evt(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") - evt1 = rel.changed_event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_relation_changed", relation=rel).deferred( + handler=mycharm._on_event + ) evt2 = deferred( event="foo_relation_changed", handler=mycharm._on_event, @@ -91,7 +93,7 @@ def test_deferred_relation_evt(mycharm): def test_deferred_workload_evt(mycharm): ctr = Container("foo") - evt1 = ctr.pebble_ready_event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_pebble_ready", container=ctr).deferred(handler=mycharm._on_event) evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr) assert asdict(evt2) == asdict(evt1) @@ -145,13 +147,16 @@ def test_deferred_relation_event(mycharm): def test_deferred_relation_event_from_relation(mycharm): + ctx = Context(mycharm, meta=mycharm.META) mycharm.defer_next = 2 rel = Relation(endpoint="foo", remote_app_name="remote") out = trigger( State( relations=[rel], deferred=[ - rel.changed_event(remote_unit_id=1).deferred(handler=mycharm._on_event) + ctx.on.relation_changed(rel, remote_unit=1).deferred( + handler=mycharm._on_event + ) ], ), "start", @@ -186,7 +191,11 @@ def test_deferred_workload_event(mycharm): out = trigger( State( containers=[ctr], - deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)], + deferred=[ + _Event("foo_pebble_ready", container=ctr).deferred( + handler=mycharm._on_event + ) + ], ), "start", mycharm, @@ -210,10 +219,10 @@ def test_defer_reemit_lifecycle_event(mycharm): ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True) mycharm.defer_next = 1 - state_1 = ctx.run("update-status", State()) + state_1 = ctx.run(ctx.on.update_status(), State()) mycharm.defer_next = 0 - state_2 = ctx.run("start", state_1) + state_2 = ctx.run(ctx.on.start(), state_1) assert [type(e).__name__ for e in ctx.emitted_events] == [ "UpdateStatusEvent", @@ -229,10 +238,10 @@ def test_defer_reemit_relation_event(mycharm): rel = Relation("foo") mycharm.defer_next = 1 - state_1 = ctx.run(rel.created_event, State(relations=[rel])) + state_1 = ctx.run(ctx.on.relation_created(rel), State(relations=[rel])) mycharm.defer_next = 0 - state_2 = ctx.run("start", state_1) + state_2 = ctx.run(ctx.on.start(), state_1) assert [type(e).__name__ for e in ctx.emitted_events] == [ "RelationCreatedEvent", diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py index f30fc65ad..209974213 100644 --- a/tests/test_e2e/test_event.py +++ b/tests/test_e2e/test_event.py @@ -3,7 +3,7 @@ from ops import CharmBase, StartEvent, UpdateStatusEvent from scenario import Context -from scenario.state import Event, State, _CharmSpec, _EventType +from scenario.state import State, _CharmSpec, _Event, _EventType @pytest.mark.parametrize( @@ -29,7 +29,7 @@ ), ) def test_event_type(evt, expected_type): - event = Event(evt) + event = _Event(evt) assert event._path.type is expected_type assert event._is_relation_event is (expected_type is _EventType.relation) @@ -63,7 +63,7 @@ class MyCharm(CharmBase): META = {"name": "joop"} ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) - ctx.run("update-status", State()) + ctx.run(ctx.on.update_status(), State()) assert len(ctx.emitted_events) == 4 assert list(map(type, ctx.emitted_events)) == [ ops.UpdateStatusEvent, @@ -86,7 +86,9 @@ def _foo(self, e): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)])) + ctx.run( + ctx.on.start(), State(deferred=[_Event("update-status").deferred(MyCharm._foo)]) + ) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ diff --git a/tests/test_e2e/test_event_bind.py b/tests/test_e2e/test_event_bind.py deleted file mode 100644 index 4878e6ac3..000000000 --- a/tests/test_e2e/test_event_bind.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest - -from scenario import Container, Event, Relation, Secret, State -from scenario.state import BindFailedError - - -def test_bind_relation(): - event = Event("foo-relation-changed") - foo_relation = Relation("foo") - state = State(relations=[foo_relation]) - assert event.bind(state).relation is foo_relation - - -def test_bind_relation_complex_name(): - event = Event("foo-bar-baz-relation-changed") - foo_relation = Relation("foo_bar_baz") - state = State(relations=[foo_relation]) - assert event.bind(state).relation is foo_relation - - -def test_bind_relation_notfound(): - event = Event("foo-relation-changed") - state = State(relations=[]) - with pytest.raises(BindFailedError): - event.bind(state) - - -def test_bind_relation_toomany(caplog): - event = Event("foo-relation-changed") - foo_relation = Relation("foo") - foo_relation1 = Relation("foo") - state = State(relations=[foo_relation, foo_relation1]) - event.bind(state) - assert "too many relations" in caplog.text - - -def test_bind_secret(): - event = Event("secret-changed") - secret = Secret("foo", {"a": "b"}) - state = State(secrets=[secret]) - assert event.bind(state).secret is secret - - -def test_bind_secret_notfound(): - event = Event("secret-changed") - state = State(secrets=[]) - with pytest.raises(BindFailedError): - event.bind(state) - - -def test_bind_container(): - event = Event("foo-pebble-ready") - container = Container("foo") - state = State(containers=[container]) - assert event.bind(state).container is container - - -def test_bind_container_notfound(): - event = Event("foo-pebble-ready") - state = State(containers=[]) - with pytest.raises(BindFailedError): - event.bind(state) diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py index 5f58a973d..1efd1e709 100644 --- a/tests/test_e2e/test_juju_log.py +++ b/tests/test_e2e/test_juju_log.py @@ -5,7 +5,6 @@ from scenario import Context from scenario.state import JujuLogLine, State -from tests.helpers import trigger logger = logging.getLogger("testing logger") @@ -31,7 +30,7 @@ def _on_event(self, event): def test_juju_log(mycharm): ctx = Context(mycharm, meta=mycharm.META) - ctx.run("start", State()) + ctx.run(ctx.on.start(), State()) assert ctx.juju_log[-2] == JujuLogLine( level="DEBUG", message="Emitting Juju event start." ) diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 28cbe5168..66d39f822 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -31,7 +31,7 @@ def _on_event(self, e): def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) assert not manager.output state_out = manager.run() @@ -43,7 +43,7 @@ def test_manager(mycharm): def test_manager_implicit(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: assert isinstance(manager.charm, mycharm) # do not call .run() @@ -55,7 +55,7 @@ def test_manager_implicit(mycharm): def test_manager_reemit_fails(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, "start", State()) as manager: + with _EventManager(ctx, ctx.on.start(), State()) as manager: manager.run() with pytest.raises(AlreadyEmittedError): manager.run() @@ -65,7 +65,7 @@ def test_manager_reemit_fails(mycharm): def test_context_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with ctx.manager("start", State()) as manager: + with ctx.manager(ctx.on.start(), State()) as manager: state_out = manager.run() assert isinstance(state_out, State) assert ctx.emitted_events[0].handle.kind == "start" diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 28a4133c9..5c08b9491 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -41,7 +41,7 @@ def test_ip_get(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ Relation( @@ -78,7 +78,7 @@ def test_no_sub_binding(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ SubordinateRelation("bar"), @@ -103,7 +103,7 @@ def test_no_relation_error(mycharm): ) with ctx.manager( - "update_status", + ctx.on.update_status(), State( relations=[ Relation( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 865ffb302..bf83c6c62 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -130,7 +130,7 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - with ctx.manager("start", state=state) as mgr: + with ctx.manager(ctx.on.start(), state=state) as mgr: out = mgr.run() callback(mgr.charm) @@ -224,7 +224,7 @@ def callback(self: CharmBase): State(containers=[container]), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, - event=container.pebble_ready_event, + event="pebble_ready", post_event=callback, ) @@ -291,7 +291,7 @@ def _on_ready(self, event): State(containers=[container]), charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, - event=container.pebble_ready_event, + event="pebble_ready", ) serv = lambda name, obj: pebble.Service(name, raw=obj) @@ -318,9 +318,8 @@ def test_exec_wait_error(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + 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") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -341,9 +340,8 @@ def test_exec_wait_output(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + 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") proc = container.exec(["foo"]) out, err = proc.wait_output() @@ -362,9 +360,8 @@ def test_exec_wait_output_error(charm_cls): ] ) - with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager( - "start", state - ) as mgr: + 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") proc = container.exec(["foo"]) with pytest.raises(ExecError): diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index dc10b3a98..135029718 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -27,7 +27,7 @@ def ctx(): def test_open_port(ctx): - out = ctx.run("start", State()) + out = ctx.run(ctx.on.start(), State()) port = out.opened_ports.pop() assert port.protocol == "tcp" @@ -35,5 +35,5 @@ def test_open_port(ctx): def test_close_port(ctx): - out = ctx.run("stop", State(opened_ports=[Port("tcp", 42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports=[Port("tcp", 42)])) assert not out.opened_ports diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 57ad07692..f3447cbe5 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -5,6 +5,8 @@ CharmBase, CharmEvents, CollectStatusEvent, + RelationBrokenEvent, + RelationCreatedEvent, RelationDepartedEvent, RelationEvent, ) @@ -80,6 +82,7 @@ def pre_event(charm: CharmBase): }, }, config={"options": {"foo": {"type": "string"}}}, + pre_event=pre_event, ) @@ -97,7 +100,7 @@ def test_relation_events(mycharm, evt_name): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -141,7 +144,7 @@ def callback(charm: CharmBase, e): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -153,8 +156,14 @@ def callback(charm: CharmBase, e): @pytest.mark.parametrize( - "evt_name", - ("changed", "broken", "departed", "joined", "created"), + "evt_name,has_unit", + [ + ("changed", True), + ("broken", False), + ("departed", True), + ("joined", True), + ("created", False), + ], ) @pytest.mark.parametrize( "remote_app_name", @@ -164,7 +173,9 @@ def callback(charm: CharmBase, e): "remote_unit_id", (0, 1), ) -def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id): +def test_relation_events_attrs( + mycharm, evt_name, has_unit, remote_app_name, remote_unit_id +): relation = Relation( endpoint="foo", interface="foo", remote_app_name=remote_app_name ) @@ -174,20 +185,15 @@ def callback(charm: CharmBase, event): return assert event.app - assert event.unit + if not isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)): + assert event.unit if isinstance(event, RelationDepartedEvent): assert event.departing_unit mycharm._call = callback - trigger( - State( - relations=[ - relation, - ], - ), - getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id), - mycharm, + ctx = Context( + charm_type=mycharm, meta={ "name": "local", "requires": { @@ -195,6 +201,12 @@ def callback(charm: CharmBase, event): }, }, ) + state = State(relations=[relation]) + kwargs = {} + if has_unit: + kwargs["remote_unit"] = remote_unit_id + event = getattr(ctx.on, f"relation_{evt_name}")(relation, **kwargs) + ctx.run(event, state=state) @pytest.mark.parametrize( @@ -218,7 +230,11 @@ def callback(charm: CharmBase, event): return assert event.app # that's always present - assert event.unit + # .unit is always None for created and broken. + if isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)): + assert event.unit is None + else: + assert event.unit assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False)) mycharm._call = callback @@ -229,7 +245,7 @@ def callback(charm: CharmBase, event): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -239,9 +255,11 @@ def callback(charm: CharmBase, event): }, ) - assert ( - "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text - ) + if evt_name not in ("created", "broken"): + assert ( + "remote unit ID unset, and multiple remote unit IDs are present" + in caplog.text + ) def test_relation_default_unit_data_regular(): @@ -287,7 +305,7 @@ def callback(charm: CharmBase, event): relation, ], ), - getattr(relation, f"{evt_name}_event"), + f"relation_{evt_name}", mycharm, meta={ "name": "local", @@ -297,7 +315,8 @@ def callback(charm: CharmBase, event): }, ) - assert "remote unit ID unset; no remote unit data present" in caplog.text + if evt_name not in ("created", "broken"): + assert "remote unit ID unset; no remote unit data present" in caplog.text @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b"")) @@ -337,7 +356,7 @@ def test_relation_event_trigger(relation, evt_name, mycharm): } state = trigger( State(relations=[relation]), - getattr(relation, evt_name + "_event"), + f"relation_{evt_name}", mycharm, meta=meta, ) @@ -370,7 +389,7 @@ def post_event(charm: CharmBase): trigger( State(relations=[sub1, sub2]), - "update-status", + "update_status", mycharm, meta=meta, post_event=post_event, @@ -394,9 +413,10 @@ def test_relation_ids(): def test_broken_relation_not_in_model_relations(mycharm): rel = Relation("foo") - with Context( + ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} - ).manager(rel.broken_event, state=State(relations=[rel])) as mgr: + ) + with ctx.manager(ctx.on.relation_broken(rel), state=State(relations=[rel])) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py index 10582d82d..0656b80cd 100644 --- a/tests/test_e2e/test_rubbish_events.py +++ b/tests/test_e2e/test_rubbish_events.py @@ -5,7 +5,7 @@ from ops.framework import EventBase, EventSource, Framework, Object from scenario.ops_main_mock import NoObserverError -from scenario.state import Container, Event, State, _CharmSpec +from scenario.state import Container, State, _CharmSpec, _Event from tests.helpers import trigger @@ -46,7 +46,7 @@ def _on_event(self, e): @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready")) def test_rubbish_event_raises(mycharm, evt_name): - with pytest.raises(NoObserverError): + with pytest.raises(AttributeError): if evt_name.startswith("kazoo"): os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true" # else it will whine about the container not being in state and meta; @@ -59,14 +59,15 @@ def test_rubbish_event_raises(mycharm, evt_name): @pytest.mark.parametrize("evt_name", ("qux",)) -def test_custom_events_pass(mycharm, evt_name): - trigger(State(), evt_name, mycharm, meta={"name": "foo"}) +def test_custom_events_fail(mycharm, evt_name): + with pytest.raises(AttributeError): + trigger(State(), evt_name, mycharm, meta={"name": "foo"}) # cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961 @pytest.mark.parametrize("evt_name", ("sub",)) def test_custom_events_sub_raise(mycharm, evt_name): - with pytest.raises(RuntimeError): + with pytest.raises(AttributeError): trigger(State(), evt_name, mycharm, meta={"name": "foo"}) @@ -86,4 +87,4 @@ def test_is_custom_event(mycharm, evt_name, expected): spec = _CharmSpec( charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}} ) - assert Event(evt_name)._is_builtin_event(spec) is expected + assert _Event(evt_name)._is_builtin_event(spec) is expected diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 4fa383634..164b250bc 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -34,9 +34,8 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", State() - ) as mgr: + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager(ctx.on.update_status(), State()) as mgr: with pytest.raises(SecretNotFoundError): assert mgr.charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): @@ -44,17 +43,19 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): - with Context(mycharm, meta={"name": "local"}).manager( + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), - event="update_status", + event=ctx.on.update_status(), ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -74,8 +75,9 @@ def test_get_secret_get_refresh(mycharm, owner): @pytest.mark.parametrize("app", (True, False)) def test_get_secret_nonowner_peek_update(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=app, secrets=[ @@ -100,8 +102,9 @@ def test_get_secret_nonowner_peek_update(mycharm, app): @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -123,36 +126,48 @@ def test_get_secret_owner_peek_update(mycharm, owner): @pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): + ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + owner=owner, + ) with pytest.raises(ValueError): - _ = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ).changed_event - - -@pytest.mark.parametrize("evt_prefix", ("rotate", "expired", "remove")) -def test_consumer_events_failures(mycharm, evt_prefix): + _ = ctx.on.secret_changed(secret) + + +@pytest.mark.parametrize( + "evt_suffix,revision", + [ + ("rotate", None), + ("expired", 1), + ("remove", 1), + ], +) +def test_consumer_events_failures(mycharm, evt_suffix, revision): + ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + id="foo", + contents={ + 0: {"a": "b"}, + 1: {"a": "c"}, + }, + ) + kwargs = {"secret": secret} + if revision is not None: + kwargs["revision"] = revision with pytest.raises(ValueError): - _ = getattr( - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - ), - evt_prefix + "_event", - ) + _ = getattr(ctx.on, f"secret_{evt_suffix}")(**kwargs) @pytest.mark.parametrize("app", (True, False)) def test_add(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State(leader=app), ) as mgr: charm = mgr.charm @@ -171,8 +186,9 @@ def test_set_legacy_behaviour(mycharm): # in juju < 3.1.7, secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}, juju_version="3.1.6").manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -212,8 +228,9 @@ def test_set_legacy_behaviour(mycharm): def test_set(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -243,8 +260,9 @@ def test_set(mycharm): def test_set_juju33(mycharm): rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} - with Context(mycharm, meta={"name": "local"}, juju_version="3.3.1").manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") + with ctx.manager( + ctx.on.update_status(), State(), ) as mgr: charm = mgr.charm @@ -271,8 +289,9 @@ def test_set_juju33(mycharm): @pytest.mark.parametrize("app", (True, False)) def test_meta(mycharm, app): - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=True, secrets=[ @@ -310,8 +329,9 @@ def test_secret_permission_model(mycharm, leader, owner): or (owner == "unit") ) - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( leader=leader, secrets=[ @@ -360,10 +380,11 @@ def test_secret_permission_model(mycharm, leader, owner): @pytest.mark.parametrize("app", (True, False)) def test_grant(mycharm, app): - with Context( + ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} - ).manager( - "update_status", + ) + with ctx.manager( + ctx.on.update_status(), State( relations=[Relation("foo", "remote")], secrets=[ @@ -394,8 +415,9 @@ def test_grant(mycharm, app): def test_update_metadata(mycharm): exp = datetime.datetime(2050, 12, 12) - with Context(mycharm, meta={"name": "local"}).manager( - "update_status", + ctx = Context(mycharm, meta={"name": "local"}) + with ctx.manager( + ctx.on.update_status(), State( secrets=[ Secret( @@ -439,10 +461,10 @@ def _on_start(self, _): secret.grant(self.model.relations["bar"][0]) state = State(leader=leader, relations=[Relation("bar")]) - context = Context( + ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) - context.run("start", state) + ctx.run(ctx.on.start(), state) def test_grant_nonowner(mycharm): @@ -482,7 +504,7 @@ class GrantingCharm(CharmBase): def __init__(self, *args): super().__init__(*args) - context = Context( + ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) relation_remote_app = "remote_secret_desirerer" @@ -497,7 +519,7 @@ def __init__(self, *args): ], ) - with context.manager("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: charm = mgr.charm secret = charm.app.add_secret({"foo": "bar"}, label="mylabel") bar_relation = charm.model.relations["bar"][0] @@ -508,7 +530,7 @@ def __init__(self, *args): scenario_secret = mgr.output.secrets[0] assert relation_remote_app in scenario_secret.remote_grants[relation_id] - with context.manager("start", mgr.output) as mgr: + with ctx.manager(ctx.on.start(), mgr.output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) @@ -516,7 +538,7 @@ def __init__(self, *args): scenario_secret = mgr.output.secrets[0] assert scenario_secret.remote_grants == {} - with context.manager("start", mgr.output) as mgr: + with ctx.manager(ctx.on.start(), mgr.output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index e587b4063..6b3bc0c53 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -61,7 +61,7 @@ def _on_update_status(self, _): meta={"name": "local"}, ) - out = ctx.run("update_status", State(leader=True)) + out = ctx.run(ctx.on.update_status(), State(leader=True)) assert out.unit_status == WaitingStatus("3") assert ctx.unit_status_history == [ @@ -94,7 +94,7 @@ def _on_update_status(self, _): ) out = ctx.run( - "update_status", + ctx.on.update_status(), State( leader=True, unit_status=ActiveStatus("foo"), @@ -131,9 +131,9 @@ def _on_update_status(self, _): meta={"name": "local"}, ) - out = ctx.run("install", State(leader=True)) - out = ctx.run("start", out) - out = ctx.run("update_status", out) + out = ctx.run(ctx.on.install(), State(leader=True)) + out = ctx.run(ctx.on.start(), out) + out = ctx.run(ctx.on.update_status(), out) assert ctx.workload_version_history == ["1", "1.1"] assert out.workload_version == "1.2" diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index 87aa93700..b62288bb7 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -23,13 +23,13 @@ def no_storage_ctx(): def test_storage_get_null(no_storage_ctx): - with no_storage_ctx.manager("update-status", State()) as mgr: + with no_storage_ctx.manager(no_storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages assert not len(storages) def test_storage_get_unknown_name(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(KeyError): @@ -37,7 +37,7 @@ def test_storage_get_unknown_name(storage_ctx): def test_storage_request_unknown_name(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(ModelError): @@ -45,7 +45,7 @@ def test_storage_request_unknown_name(storage_ctx): def test_storage_get_some(storage_ctx): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # known but none attached assert storages["foo"] == [] @@ -53,7 +53,7 @@ def test_storage_get_some(storage_ctx): @pytest.mark.parametrize("n", (1, 3, 5)) def test_storage_add(storage_ctx, n): - with storage_ctx.manager("update-status", State()) as mgr: + with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages storages.request("foo", n) @@ -65,7 +65,9 @@ def test_storage_usage(storage_ctx): # setup storage with some content (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") - with storage_ctx.manager("update-status", State(storage=[storage])) as mgr: + with storage_ctx.manager( + storage_ctx.on.update_status(), State(storage=[storage]) + ) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -83,9 +85,9 @@ def test_storage_usage(storage_ctx): def test_storage_attached_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage.attached_event, State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storage=[storage])) def test_storage_detaching_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage.detaching_event, State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_detaching(storage), State(storage=[storage])) diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index 6b6f902e6..c87026111 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -55,8 +55,9 @@ def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): raw_ori_meta = yaml.safe_dump({"name": "karl"}) meta_file.write_text(raw_ori_meta) - with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( - "start", + ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) + with ctx.manager( + ctx.on.start(), State(), ) as mgr: assert meta_file.exists() @@ -77,8 +78,9 @@ def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root): assert not meta_file.exists() - with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager( - "start", + ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) + with ctx.manager( + ctx.on.start(), State(), ) as mgr: assert meta_file.exists() diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index 7fc0eb000..b54c84b45 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -2,8 +2,9 @@ from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent -from scenario import Event, State +from scenario import State from scenario.capture_events import capture_events +from scenario.state import _Event from tests.helpers import trigger @@ -31,31 +32,16 @@ def _on_foo(self, e): pass -def test_capture_custom_evt(): - with capture_events(Foo) as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - - assert len(emitted) == 1 - assert isinstance(emitted[0], Foo) - - -def test_capture_custom_evt_nonspecific_capture(): - with capture_events() as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) - - assert len(emitted) == 1 - assert isinstance(emitted[0], Foo) - - def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): with capture_events(include_framework=True) as emitted: - trigger(State(), "foo", MyCharm, meta=MyCharm.META) + trigger(State(), "start", MyCharm, meta=MyCharm.META) - assert len(emitted) == 4 - assert isinstance(emitted[0], Foo) - assert isinstance(emitted[1], CollectStatusEvent) - assert isinstance(emitted[2], PreCommitEvent) - assert isinstance(emitted[3], CommitEvent) + assert len(emitted) == 5 + assert isinstance(emitted[0], StartEvent) + assert isinstance(emitted[1], Foo) + assert isinstance(emitted[2], CollectStatusEvent) + assert isinstance(emitted[3], PreCommitEvent) + assert isinstance(emitted[4], CommitEvent) def test_capture_juju_evt(): @@ -71,7 +57,7 @@ def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events() as emitted: trigger( - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", MyCharm, meta=MyCharm.META, @@ -87,7 +73,7 @@ def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well with capture_events(include_deferred=False) as emitted: trigger( - State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]), + State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", MyCharm, meta=MyCharm.META, diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 06873f17f..38bca5841 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -21,7 +21,7 @@ def context(): return Context(charm_type=MyCharm, meta={"name": "foo"}) def test_sth(context): - context.run('start', State()) + context.run(context.on.start(), State()) """ ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1fa0e8849..eaaf99efe 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -10,7 +10,7 @@ from scenario import Context from scenario.runtime import Runtime, UncaughtCharmError -from scenario.state import Event, Relation, State, _CharmSpec +from scenario.state import Relation, State, _CharmSpec, _Event def charm_type(): @@ -56,7 +56,9 @@ class MyEvt(EventBase): ) with runtime.exec( - state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta) + state=State(), + event=_Event("bar"), + context=Context(my_charm_type, meta=meta), ) as ops: pass @@ -84,7 +86,7 @@ def test_unit_name(app_name, unit_id): with runtime.exec( state=State(), - event=Event("start"), + event=_Event("start"), context=Context(my_charm_type, meta=meta), ) as ops: assert ops.charm.unit.name == f"{app_name}/{unit_id}" @@ -105,7 +107,7 @@ def test_env_cleanup_on_charm_error(): with pytest.raises(UncaughtCharmError): with runtime.exec( state=State(), - event=Event("box_relation_changed", relation=Relation("box")), + event=_Event("box_relation_changed", relation=Relation("box")), context=Context(my_charm_type, meta=meta), ): assert os.getenv("JUJU_REMOTE_APP") diff --git a/tox.ini b/tox.ini index a404c5f61..e939714b6 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,7 @@ deps = coverage[toml] isort commands = - black --check {[vars]tst_path} {[vars]src_path} + black --check {[vars]tst_path} isort --check-only --profile black {[vars]tst_path} [testenv:fmt] From 1461f12d8efe930596f7a9adecb75e87012aedb5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 17:40:48 +1200 Subject: [PATCH 506/546] Test the code in the README. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e69d061c3..590604e28 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ class MyCharm(ops.CharmBase): self.model.unit.status = ops.ActiveStatus("foo") # ... +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! @@ -274,7 +275,7 @@ with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( ctx.on.update_status(), - scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) ) # deferred events get reemitted first From 7b4461563e219ed5abe29558444676a233cf453d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Apr 2024 14:31:41 +1200 Subject: [PATCH 507/546] Support 'ctx.on.event_name' for specifying events. Update tests and docs to match final (hopefully\!) API decision. Fix tests. The failing test are (a) ones that need to be rewritten for the new system, (b) ones to do with custom events. Various README updates. Typo Style fixes. --- README.md | 50 ++++++++++++++++++++------------------------ tests/test_plugin.py | 6 +++--- tox.ini | 2 +- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 590604e28..c47c5fd00 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ hook execution: ```python # ... ctx = scenario.Context(HistoryCharm, meta={"name": "foo"}) -ctx.run("start", scenario.State()) +ctx.run(ctx.on.start(), scenario.State()) assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -422,21 +422,16 @@ relation.remote_unit_name # "zookeeper/42" ### Triggering Relation Events -If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the -event from one of its aptly-named properties: +If you want to trigger relation events, use `ctx.on.relation_changed` (and so +on for the other relation events) and pass the relation object: ```python -relation = scenario.Relation(endpoint="foo", interface="bar") -changed_event = relation.changed_event -joined_event = relation.joined_event -# ... -``` - -This is in fact syntactic sugar for: +ctx = scenario.Context(MyCharm, meta=MyCharm.META) -```python relation = scenario.Relation(endpoint="foo", interface="bar") -changed_event = scenario.Event('foo-relation-changed', relation=relation) +changed_event = ctx.on.relation_changed(relation=relation) +joined_event = ctx.on.relation_joined(relation=relation) +# ... ``` The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario @@ -474,20 +469,16 @@ All relation events have some additional metadata that does not belong in the Re relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler. -In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the +In order to supply this parameter, as well as the relation object, pass as `remote_unit` the id of the remote unit that the event is about. The reason that this parameter is not supplied to `Relation` but to relation events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr), but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one. -The `remote_unit_id` will default to the first ID found in the relation's `remote_units_data`, but if the test you are -writing is close to that domain, you should probably override it and pass it manually. - ```python -relation = scenario.Relation(endpoint="foo", interface="bar") -remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) -# which is syntactic sugar for: -remote_unit_2_is_joining_event = scenario.Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2) +relation = scenario.Relation(endpoint="foo", interface="bar") +remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2) ``` ## Networks @@ -718,7 +709,7 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr: +with ctx.manager(ctx.on.update_status(), scenario.State(storage=[storage])) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -890,7 +881,7 @@ So, the only consistency-level check we enforce in Scenario when it comes to res import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) -with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: +with ctx.manager(ctx.on.start(), scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') @@ -1031,8 +1022,10 @@ You can also generate the 'deferred' data structure (called a DeferredEvent) fro handler): ```python continuation -deferred_start = scenario.Event('start').deferred(MyCharm._on_start) -deferred_install = scenario.Event('install').deferred(MyCharm._on_start) +ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) + +deferred_start = ctx.on.start().deferred(MyCharm._on_start) +deferred_install = ctx.on.install().deferred(MyCharm._on_start) ``` On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed @@ -1081,8 +1074,10 @@ def test_start_on_deferred_update_status(MyCharm): but you can also use a shortcut from the relation event itself: ```python continuation +ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) + foo_relation = scenario.Relation('foo') -foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +deferred_event = ctx.on.relation_changed(foo_relation).deferred(handler=MyCharm._on_foo_relation_changed) ``` # Live charm introspection @@ -1167,11 +1162,12 @@ class MyCharmType(ops.CharmBase): td = tempfile.TemporaryDirectory() -state = scenario.Context( +ctx = scenario.Context( charm_type=MyCharmType, meta={'name': 'my-charm-name'}, charm_root=td.name -).run(ctx.on.start(), scenario.State()) +) +state = ctx.run(ctx.on.start(), scenario.State()) ``` Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 38bca5841..b802b9eb4 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -12,14 +12,14 @@ def test_plugin_ctx_run(pytester): from scenario import State from scenario import Context import ops - + class MyCharm(ops.CharmBase): pass - + @pytest.fixture def context(): return Context(charm_type=MyCharm, meta={"name": "foo"}) - + def test_sth(context): context.run(context.on.start(), State()) """ diff --git a/tox.ini b/tox.ini index e939714b6..317a3b149 100644 --- a/tox.ini +++ b/tox.ini @@ -90,7 +90,7 @@ allowlist_externals = mkdir cp deps = - . + -e . ops pytest pytest-markdown-docs From 871c0f5c3a43b248686ab8a24e2fd92f342fd729 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 6 Jun 2024 18:49:05 +1200 Subject: [PATCH 508/546] Align with upstream. From 8c62a1ea2d663df001c43e8e5c8b6c9252dabf16 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 2 Jul 2024 23:03:42 +1200 Subject: [PATCH 509/546] Fix merging. Most particularly, the relation_id to id change was merged in main (mistakenly) and then unmerged, so the rebasing undid it, so that is redone. Also, the basic changes for Pebble notices and Cloud Configuration are done in here, although those need to be double-checked to make sure they make sense with the updated API. --- README.md | 4 ++-- scenario/consistency_checker.py | 10 +++++----- scenario/mocking.py | 6 ++---- scenario/ops_main_mock.py | 2 +- scenario/runtime.py | 2 +- scenario/state.py | 11 ++++++----- tests/test_consistency_checker.py | 22 ++++++++-------------- tests/test_e2e/test_cloud_spec.py | 6 +++--- tests/test_e2e/test_deferred.py | 2 +- tests/test_e2e/test_network.py | 4 ++-- tests/test_e2e/test_pebble.py | 2 +- tests/test_e2e/test_play_assertions.py | 2 +- tests/test_e2e/test_relations.py | 2 +- tests/test_e2e/test_secrets.py | 10 +++++----- 14 files changed, 39 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index c47c5fd00..c3e403bcb 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ needs to set up the process that will run `ops.main` with the right environment ### Working with relation IDs -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`. +Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. ```python @@ -447,7 +447,7 @@ import scenario.state next_id = scenario.state.next_relation_id(update=False) rel = scenario.Relation('foo') -assert rel.relation_id == next_id +assert rel.id == next_id ``` This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 004032fa7..8c2837bbc 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -125,7 +125,7 @@ def check_event_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the Event data structure. + """Check the internal consistency of the _Event data structure. For example, it checks that a relation event has a relation instance, and that the relation endpoint name matches the event prefix. @@ -519,13 +519,13 @@ def _get_relations(r): expected_sub = relation_meta.get("scope", "") == "container" relations = _get_relations(endpoint) for relation in relations: - if relation.relation_id in seen_ids: + if relation.id in seen_ids: errors.append( - f"duplicate relation ID: {relation.relation_id} is claimed " + f"duplicate relation ID: {relation.id} is claimed " f"by multiple Relation instances", ) - seen_ids.add(relation.relation_id) + seen_ids.add(relation.id) is_sub = isinstance(relation, SubordinateRelation) if is_sub and not expected_sub: errors.append( @@ -609,7 +609,7 @@ def check_containers_consistency( def check_cloudspec_consistency( *, state: "State", - event: "Event", + event: "_Event", charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: diff --git a/scenario/mocking.py b/scenario/mocking.py index 5f2c17c6c..183c45ba9 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -163,7 +163,7 @@ def _get_relation_by_id( ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: return next( - filter(lambda r: r.relation_id == rel_id, self._state.relations), + filter(lambda r: r.id == rel_id, self._state.relations), ) except StopIteration: raise RelationNotFoundError() @@ -245,9 +245,7 @@ def status_get(self, *, is_app: bool = False): def relation_ids(self, relation_name): return [ - rel.relation_id - for rel in self._state.relations - if rel.endpoint == relation_name + rel.id for rel in self._state.relations if rel.endpoint == relation_name ] def relation_list(self, relation_id: int) -> Tuple[str, ...]: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index d2a0371a2..b9bcbb8f7 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -140,7 +140,7 @@ def setup_framework( # If we are in a RelationBroken event, we want to know which relation is # broken within the model, not only in the event's `.relation` attribute. broken_relation_id = ( - event.relation.relation_id # type: ignore + event.relation.id # type: ignore if event.name.endswith("_relation_broken") else None ) diff --git a/scenario/runtime.py b/scenario/runtime.py index 114e66a91..97a7c773f 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -211,7 +211,7 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): env.update( { "JUJU_RELATION": relation.endpoint, - "JUJU_RELATION_ID": str(relation.relation_id), + "JUJU_RELATION_ID": str(relation.id), "JUJU_REMOTE_APP": remote_app_name, }, ) diff --git a/scenario/state.py b/scenario/state.py index de814e3d1..3b8733680 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -358,7 +358,7 @@ class _RelationBase: """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. If left empty, it will be automatically derived from metadata.yaml.""" - relation_id: int = dataclasses.field(default_factory=next_relation_id) + id: int = dataclasses.field(default_factory=next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -614,7 +614,7 @@ def next_notice_id(update=True): @dataclasses.dataclass(frozen=True) -class Notice(_DCBase): +class Notice: key: str """The notice key, a string that differentiates notices of this type. @@ -673,7 +673,7 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice(_DCBase): +class _BoundNotice: notice: Notice container: "Container" @@ -681,7 +681,7 @@ class _BoundNotice(_DCBase): def event(self): """Sugar to generate a -pebble-custom-notice event for this notice.""" suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX - return Event( + return _Event( path=normalize_name(self.container.name) + suffix, container=self.container, notice=self.notice, @@ -833,6 +833,7 @@ def get_notice( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) + _RawStatusLiteral = Literal[ "waiting", "blocked", @@ -1450,7 +1451,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data = { "relation_name": relation.endpoint, - "relation_id": relation.relation_id, + "relation_id": relation.id, "app_name": remote_app, "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", } diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 217a68d9f..6a955be79 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -69,18 +69,18 @@ def test_workload_event_without_container(): ) assert_inconsistent( State(), - Event("foo-pebble-custom-notice", container=Container("foo")), + _Event("foo-pebble-custom-notice", container=Container("foo")), _CharmSpec(MyCharm, {}), ) notice = Notice("example.com/foo") assert_consistent( State(containers=[Container("foo", notices=[notice])]), - Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), + _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( State(containers=[Container("foo")]), - Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), + _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -459,9 +459,7 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), _Event("start"), _CharmSpec( MyCharm, @@ -474,17 +472,13 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=1)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State( - relations=[Relation("foo", id=1), Relation("bar", id=2)] - ), + State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), _Event("start"), _CharmSpec( MyCharm, @@ -658,7 +652,7 @@ def test_cloudspec_consistency(): assert_consistent( State(model=Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec)), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "MyVMCharm"}, @@ -667,7 +661,7 @@ def test_cloudspec_consistency(): assert_inconsistent( State(model=Model(name="k8s-model", type="kubernetes", cloud_spec=cloud_spec)), - Event("start"), + _Event("start"), _CharmSpec( MyCharm, meta={"name": "MyK8sCharm"}, diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 8ce413f8f..1834b3da6 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -47,14 +47,14 @@ def test_get_cloud_spec(): name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec ), ) - with ctx.manager("start", state=state) as mgr: + with ctx.manager(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("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() @@ -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("start", state) as mgr: + with ctx.manager(ctx.on.start(), state) as mgr: with pytest.raises(ops.ModelError): mgr.charm.model.get_cloud_spec() diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 8645c77b1..fccb326c7 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -169,7 +169,7 @@ def test_deferred_relation_event_from_relation(mycharm): assert out.deferred[0].name == "foo_relation_changed" assert out.deferred[0].snapshot_data == { "relation_name": rel.endpoint, - "relation_id": rel.relation_id, + "relation_id": rel.id, "app_name": "remote", "unit_name": "remote/1", } diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 5c08b9491..683246473 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -48,7 +48,7 @@ def test_ip_get(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"foo": Network.default(private_address="4.4.4.4")}, @@ -110,7 +110,7 @@ def test_no_relation_error(mycharm): interface="foo", remote_app_name="remote", endpoint="metrics-endpoint", - relation_id=1, + id=1, ), ], networks={"bar": Network.default()}, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index bf83c6c62..a92231205 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,5 +1,5 @@ -import datetime import dataclasses +import datetime import tempfile from pathlib import Path diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index a5166db7b..7fe07899d 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -104,7 +104,7 @@ def check_relation_data(charm): Relation( endpoint="relation_test", interface="azdrubales", - relation_id=1, + id=1, remote_app_name="karlos", remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index f3447cbe5..e72f754c1 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -407,7 +407,7 @@ def test_relation_ids(): initial_id = _next_relation_id_counter for i in range(10): rel = Relation("foo") - assert rel.relation_id == initial_id + i + assert rel.id == initial_id + i def test_broken_relation_not_in_model_relations(mycharm): diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 164b250bc..d43414958 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -201,7 +201,9 @@ def test_set_legacy_behaviour(mycharm): ) secret.set_content(rev2) - secret = charm.model.get_secret(label="mylabel") + # We need to get the secret again, because ops caches the content in + # the object. + secret: ops_Secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() @@ -211,7 +213,7 @@ def test_set_legacy_behaviour(mycharm): secret.set_content(rev3) state_out = mgr.run() - secret = charm.model.get_secret(label="mylabel") + secret: ops_Secret = charm.model.get_secret(label="mylabel") assert ( secret.get_content() == secret.peek_content() @@ -513,9 +515,7 @@ def __init__(self, *args): state = State( leader=True, relations=[ - Relation( - "bar", remote_app_name=relation_remote_app, relation_id=relation_id - ) + Relation("bar", remote_app_name=relation_remote_app, id=relation_id) ], ) From c2bd565d4c5aa992fdd524af05a9520a282d0f18 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Thu, 4 Jul 2024 14:07:16 +0900 Subject: [PATCH 510/546] Mark upcoming branch as dev or beta version What do you think? --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4fbb741c1..5ce0b9476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.6" +version = "7.0.0" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 0c55e6473c604c7f041fbc92201d59429fec070d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 5 Jul 2024 13:04:52 +1200 Subject: [PATCH 511/546] Require keyword arguments for the state and its components. --- README.md | 32 ++-- scenario/__init__.py | 8 +- scenario/context.py | 16 +- scenario/mocking.py | 24 +-- scenario/state.py | 228 ++++++++++++++++++++++------ tests/test_consistency_checker.py | 26 ++-- tests/test_context.py | 22 ++- tests/test_context_on.py | 14 +- tests/test_e2e/test_actions.py | 16 +- tests/test_e2e/test_pebble.py | 17 +-- tests/test_e2e/test_ports.py | 17 ++- tests/test_e2e/test_relations.py | 52 +++++++ tests/test_e2e/test_secrets.py | 20 +++ tests/test_e2e/test_state.py | 87 ++++++++++- tests/test_e2e/test_stored_state.py | 17 ++- 15 files changed, 481 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index c3e403bcb..cbbfdc9e1 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,8 @@ meta = { } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. + + ``` ### SubordinateRelation @@ -531,7 +533,7 @@ local_file = pathlib.Path('/path/to/local/real/file.txt') container = scenario.Container( name="foo", can_connect=True, - mounts={'local': scenario.Mount('/local/share/config.yaml', local_file)} + mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)} ) state = scenario.State(containers=[container]) ``` @@ -568,7 +570,7 @@ def test_pebble_push(): container = scenario.Container( name='foo', can_connect=True, - mounts={'local': scenario.Mount('/local/share/config.yaml', local_file.name)} + mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)} ) state_in = scenario.State(containers=[container]) ctx = scenario.Context( @@ -667,32 +669,29 @@ Pebble can generate notices, which Juju will detect, and wake up the charm to let it know that something has happened in the container. The most common use-case is Pebble custom notices, which is a mechanism for the workload application to trigger a charm event. - +- When the charm is notified, there might be a queue of existing notices, or just the one that has triggered the event: ```python -import ops -import scenario - class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) - framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice) + framework.observe(self.on["my-container"].pebble_custom_notice, self._on_notice) def _on_notice(self, event): event.notice.key # == "example.com/c" - for notice in self.unit.get_container("cont").get_notices(): + for notice in self.unit.get_container("my-container").get_notices(): ... ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) notices = [ - scenario.Notice(key="example.com/a", occurences=10), + scenario.Notice(key="example.com/a", occurrences=10), scenario.Notice(key="example.com/b", last_data={"bar": "baz"}), scenario.Notice(key="example.com/c"), ] -cont = scenario.Container(notices=notices) -ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[cont])) +container = scenario.Container("my-container", notices=notices) +ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[container])) ``` ## Storage @@ -766,15 +765,14 @@ ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host VM/container. Using the `State.opened_ports` API, you can: - simulate a charm run with a port opened by some previous execution -```python ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.Port("tcp", 42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.TCPPort(42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python ctx = scenario.Context(PortCharm, meta=MyCharm.META) state1 = ctx.run(ctx.on.start(), scenario.State()) -assert state1.opened_ports == [scenario.Port("tcp", 42)] +assert state1.opened_ports == [scenario.TCPPort(42)] state2 = ctx.run(ctx.on.stop(), state1) assert state2.opened_ports == [] @@ -788,8 +786,8 @@ Scenario has secrets. Here's how you use them. state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'public'}}, id='foo', - contents={0: {'key': 'public'}} ) ] ) @@ -817,8 +815,8 @@ To specify a secret owned by this unit (or app): state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'private'}}, id='foo', - contents={0: {'key': 'private'}}, owner='unit', # or 'app' remote_grants={0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 @@ -833,8 +831,8 @@ To specify a secret owned by some other application and give this unit (or app) state = scenario.State( secrets=[ scenario.Secret( + {0: {'key': 'public'}}, id='foo', - contents={0: {'key': 'public'}}, # owner=None, which is the default revision=0, # the revision that this unit (or app) is currently tracking ) diff --git a/scenario/__init__.py b/scenario/__init__.py index 93059ebff..a73570a67 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -11,12 +11,12 @@ Container, DeferredEvent, ExecOutput, + ICMPPort, Model, Mount, Network, Notice, PeerRelation, - Port, Relation, Secret, State, @@ -24,6 +24,8 @@ Storage, StoredState, SubordinateRelation, + TCPPort, + UDPPort, deferred, ) @@ -47,7 +49,9 @@ "Address", "BindAddress", "Network", - "Port", + "ICMPPort", + "TCPPort", + "UDPPort", "Storage", "StoredState", "State", diff --git a/scenario/context.py b/scenario/context.py index db990e54e..c563814b0 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -19,6 +19,7 @@ Storage, _CharmSpec, _Event, + _max_posargs, ) if TYPE_CHECKING: # pragma: no cover @@ -34,8 +35,8 @@ DEFAULT_JUJU_VERSION = "3.4" -@dataclasses.dataclass -class ActionOutput: +@dataclasses.dataclass(frozen=True) +class ActionOutput(_max_posargs(0)): """Wraps the results of running an action event with ``run_action``.""" state: "State" @@ -388,6 +389,7 @@ def __init__( self, charm_type: Type["CharmType"], meta: Optional[Dict[str, Any]] = None, + *, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, charm_root: Optional["PathLike"] = None, @@ -454,7 +456,7 @@ def __init__( defined in metadata.yaml. :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with - ``juju trust``). Defaults to False + ``juju trust``). Defaults to False. :arg charm_root: virtual charm root the charm will be executed with. If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the execution cwd, you need to use this. E.g.: @@ -627,10 +629,10 @@ def run_action(self, action: "Action", state: "State") -> ActionOutput: def _finalize_action(self, state_out: "State"): ao = ActionOutput( - state_out, - self._action_logs, - self._action_results, - self._action_failure, + state=state_out, + logs=self._action_logs, + results=self._action_results, + failure=self._action_failure, ) # reset all action-related state diff --git a/scenario/mocking.py b/scenario/mocking.py index 183c45ba9..71570de18 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -20,8 +20,11 @@ cast, ) -from ops import CloudSpec, JujuVersion, pebble -from ops.model import ModelError, RelationNotFoundError +from ops import JujuVersion, pebble +from ops.model import CloudSpec as CloudSpec_Ops +from ops.model import ModelError +from ops.model import Port as Port_Ops +from ops.model import RelationNotFoundError from ops.model import Secret as Secret_Ops # lol from ops.model import ( SecretInfo, @@ -39,8 +42,8 @@ Mount, Network, PeerRelation, - Port, Storage, + _port_cls_by_protocol, _RawPortProtocolLiteral, _RawStatusLiteral, ) @@ -112,8 +115,11 @@ def __init__( self._context = context self._charm_spec = charm_spec - def opened_ports(self) -> Set[Port]: - return set(self._state.opened_ports) + def opened_ports(self) -> Set[Port_Ops]: + return { + Port_Ops(protocol=port.protocol, port=port.port) + for port in self._state.opened_ports + } def open_port( self, @@ -122,7 +128,7 @@ def open_port( ): # fixme: the charm will get hit with a StateValidationError # here, not the expected ModelError... - port_ = Port(protocol, port) + port_ = _port_cls_by_protocol[protocol](port=port) ports = self._state.opened_ports if port_ not in ports: ports.append(port_) @@ -132,7 +138,7 @@ def close_port( protocol: "_RawPortProtocolLiteral", port: Optional[int] = None, ): - _port = Port(protocol, port) + _port = _port_cls_by_protocol[protocol](port=port) ports = self._state.opened_ports if _port in ports: ports.remove(_port) @@ -632,7 +638,7 @@ def resource_get(self, resource_name: str) -> str: f"resource {resource_name} not found in State. please pass it.", ) - def credential_get(self) -> CloudSpec: + def credential_get(self) -> CloudSpec_Ops: if not self._context.app_trusted: raise ModelError( "ERROR charm is not trusted, initialise Context with `app_trusted=True`", @@ -672,7 +678,7 @@ def __init__( path = Path(mount.location).parts mounting_dir = container_root.joinpath(*path[1:]) mounting_dir.parent.mkdir(parents=True, exist_ok=True) - mounting_dir.symlink_to(mount.src) + mounting_dir.symlink_to(mount.source) self._root = container_root diff --git a/scenario/state.py b/scenario/state.py index 3b8733680..c788e8552 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -17,6 +17,7 @@ Any, Callable, Dict, + Final, Generic, List, Literal, @@ -30,10 +31,11 @@ ) from uuid import uuid4 -import ops import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents +from ops.model import CloudCredential as CloudCredential_Ops +from ops.model import CloudSpec as CloudSpec_Ops from ops.model import SecretRotate, StatusBase from scenario.logger import logger as scenario_logger @@ -123,8 +125,77 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" +# This can be replaced with the KW_ONLY dataclasses functionality in Python 3.10+. +def _max_posargs(n: int): + class _MaxPositionalArgs: + """Raises TypeError when instantiating objects if arguments are not passed as keywords. + + Looks for a `_max_positional_args` class attribute, which should be an int + indicating the maximum number of positional arguments that can be passed to + `__init__` (excluding `self`). + """ + + _max_positional_args = n + + def __new__(cls, *args, **kwargs): + # inspect.signature guarantees the order of parameters is as + # declared, which aligns with dataclasses. Simpler ways of + # getting the arguments (like __annotations__) do not have that + # guarantee, although in practice it is the case. + parameters = inspect.signature(cls).parameters + required_args = [ + name + for name in tuple(parameters) + if parameters[name].default is inspect.Parameter.empty + and name not in kwargs + ] + n_posargs = len(args) + max_n_posargs = cls._max_positional_args + kw_only = { + name + for name in tuple(parameters)[max_n_posargs:] + if not name.startswith("_") + } + if n_posargs > max_n_posargs: + raise TypeError( + f"{cls.__name__} takes {max_n_posargs} positional " + f"argument{'' if max_n_posargs == 1 else 's'} but " + f"{n_posargs} {'was' if n_posargs == 1 else 'were'} " + f"given. The following arguments are keyword-only: " + f"{', '.join(kw_only)}", + ) from None + # Also check if there are just not enough arguments at all, because + # the default TypeError message will incorrectly describe some of + # the arguments as positional. + elif n_posargs < len(required_args): + required_pos = [ + f"'{arg}'" + for arg in required_args[n_posargs:] + if arg not in kw_only + ] + required_kw = { + f"'{arg}'" for arg in required_args[n_posargs:] if arg in kw_only + } + if required_pos and required_kw: + details = f"positional: {', '.join(required_pos)} and keyword: {', '.join(required_kw)} arguments" + elif required_pos: + details = f"positional argument{'' if len(required_pos) == 1 else 's'}: {', '.join(required_pos)}" + else: + details = f"keyword argument{'' if len(required_kw) == 1 else 's'}: {', '.join(required_kw)}" + raise TypeError(f"{cls.__name__} missing required {details}") from None + return super().__new__(cls) + + def __reduce__(self): + # The default __reduce__ doesn't understand that some arguments have + # to be passed as keywords, so using the copy module fails. + attrs = cast(Dict[str, Any], super().__reduce__()[2]) + return (lambda: self.__class__(**attrs), ()) + + return _MaxPositionalArgs + + @dataclasses.dataclass(frozen=True) -class CloudCredential: +class CloudCredential(_max_posargs(0)): auth_type: str """Authentication type.""" @@ -138,8 +209,8 @@ class CloudCredential: redacted: List[str] = dataclasses.field(default_factory=list) """A list of redacted generic cloud API secrets.""" - def _to_ops(self) -> ops.CloudCredential: - return ops.CloudCredential( + def _to_ops(self) -> CloudCredential_Ops: + return CloudCredential_Ops( auth_type=self.auth_type, attributes=self.attributes, redacted=self.redacted, @@ -147,7 +218,7 @@ def _to_ops(self) -> ops.CloudCredential: @dataclasses.dataclass(frozen=True) -class CloudSpec: +class CloudSpec(_max_posargs(1)): type: str """Type of the cloud.""" @@ -178,8 +249,8 @@ class CloudSpec: is_controller_cloud: bool = False """If this is the cloud used by the controller.""" - def _to_ops(self) -> ops.CloudSpec: - return ops.CloudSpec( + def _to_ops(self) -> CloudSpec_Ops: + return CloudSpec_Ops( type=self.type, name=self.name, region=self.region, @@ -194,16 +265,16 @@ def _to_ops(self) -> ops.CloudSpec: @dataclasses.dataclass(frozen=True) -class Secret: +class Secret(_max_posargs(1)): + # mapping from revision IDs to each revision's contents + contents: Dict[int, "RawSecretRevisionContents"] + id: str # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized # secret id (`secret:` prefix) # but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to # add the prefix manually every time seems painful as well. - # mapping from revision IDs to each revision's contents - contents: Dict[int, "RawSecretRevisionContents"] - # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None @@ -257,7 +328,7 @@ def normalize_name(s: str): @dataclasses.dataclass(frozen=True) -class Address: +class Address(_max_posargs(1)): """An address in a Juju network space.""" hostname: str @@ -278,11 +349,12 @@ def address(self, value): @dataclasses.dataclass(frozen=True) -class BindAddress: +class BindAddress(_max_posargs(1)): """An address bound to a network interface in a Juju space.""" interface_name: str addresses: List[Address] + interface_name: str = "" mac_address: Optional[str] = None def hook_tool_output_fmt(self): @@ -298,7 +370,7 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network: +class Network(_max_posargs(0)): bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] @@ -341,7 +413,7 @@ def default( _next_relation_id_counter = 1 -def next_relation_id(update=True): +def next_relation_id(*, update=True): global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -350,7 +422,7 @@ def next_relation_id(update=True): @dataclasses.dataclass(frozen=True) -class _RelationBase: +class _RelationBase(_max_posargs(2)): endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -533,7 +605,7 @@ def _random_model_name(): @dataclasses.dataclass(frozen=True) -class Model: +class Model(_max_posargs(1)): """The Juju model in which the charm is deployed.""" name: str = dataclasses.field(default_factory=_random_model_name) @@ -568,7 +640,7 @@ def _generate_new_change_id(): @dataclasses.dataclass(frozen=True) -class ExecOutput: +class ExecOutput(_max_posargs(0)): """Mock data for simulated :meth:`ops.Container.exec` calls.""" return_code: int = 0 @@ -589,7 +661,7 @@ def _run(self) -> int: @dataclasses.dataclass(frozen=True) -class Mount: +class Mount(_max_posargs(0)): """Maps local files to a :class:`Container` filesystem.""" location: Union[str, PurePosixPath] @@ -605,7 +677,7 @@ def _now_utc(): _next_notice_id_counter = 1 -def next_notice_id(update=True): +def next_notice_id(*, update=True): global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -614,7 +686,7 @@ def next_notice_id(update=True): @dataclasses.dataclass(frozen=True) -class Notice: +class Notice(_max_posargs(1)): key: str """The notice key, a string that differentiates notices of this type. @@ -673,7 +745,7 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice: +class _BoundNotice(_max_posargs(0)): notice: Notice container: "Container" @@ -689,7 +761,7 @@ def event(self): @dataclasses.dataclass(frozen=True) -class Container: +class Container(_max_posargs(1)): """A Kubernetes container where a charm's workload runs.""" name: str @@ -714,7 +786,18 @@ class Container: ) """The current status of each Pebble service running in the container.""" - # when the charm runs `pebble.pull`, it will return .open() from one of these paths. + # this is how you specify the contents of the filesystem: suppose you want to express that your + # container has: + # - /home/foo/bar.py + # - /bin/bash + # - /bin/baz + # + # this becomes: + # mounts = { + # 'foo': Mount(location='/home/foo/', source=Path('/path/to/local/dir/containing/bar/py/')) + # 'bin': Mount(location='/bin/', source=Path('/path/to/local/dir/containing/bash/and/baz/')) + # } + # when the charm runs `pebble.pull`, it will return .open() from one of those paths. # when the charm pushes, it will either overwrite one of those paths (careful!) or it will # create a tempfile and insert its path in the mock filesystem tree mounts: Dict[str, Mount] = dataclasses.field(default_factory=dict) @@ -828,7 +911,7 @@ def get_notice( """ for notice in self.notices: if notice.key == key and notice.type == notice_type: - return _BoundNotice(notice, self) + return _BoundNotice(notice=notice, container=self) raise KeyError( f"{self.name} does not have a notice with key {key} and type {notice_type}", ) @@ -882,12 +965,13 @@ class _MyClass(_EntityStatus, statusbase_subclass): @dataclasses.dataclass(frozen=True) -class StoredState: +class StoredState(_max_posargs(1)): + name: str = "_stored" + # /-separated Object names. E.g. MyCharm/MyCharmLib. # if None, this StoredState instance is owned by the Framework. - owner_path: Optional[str] + owner_path: Optional[str] = None - name: str = "_stored" # Ideally, the type here would be only marshallable types, rather than Any. # However, it's complex to describe those types, since it's a recursive # definition - even in TypeShed the _Marshallable type includes containers @@ -905,36 +989,80 @@ def handle_path(self): @dataclasses.dataclass(frozen=True) -class Port: +class _Port(_max_posargs(1)): """Represents a port on the charm host.""" - protocol: _RawPortProtocolLiteral - """The protocol that data transferred over the port will use.""" port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" + protocol: _RawPortProtocolLiteral = "tcp" + """The protocol that data transferred over the port will use.""" + def __post_init__(self): - port = self.port - is_icmp = self.protocol == "icmp" - if port: - if is_icmp: - raise StateValidationError( - "`port` arg not supported with `icmp` protocol", - ) - if not (1 <= port <= 65535): - raise StateValidationError( - f"`port` outside bounds [1:65535], got {port}", - ) - elif not is_icmp: + if type(self) is _Port: + raise RuntimeError( + "_Port cannot be instantiated directly; " + "please use TCPPort, UDPPort, or ICMPPort", + ) + + +@dataclasses.dataclass(frozen=True) +class TCPPort(_Port): + """Represents a TCP port on the charm host.""" + + port: int + """The port to open.""" + protocol: _RawPortProtocolLiteral = "tcp" + + def __post_init__(self): + super().__post_init__() + if not (1 <= self.port <= 65535): raise StateValidationError( - f"`port` arg required with `{self.protocol}` protocol", + f"`port` outside bounds [1:65535], got {self.port}", ) +@dataclasses.dataclass(frozen=True) +class UDPPort(_Port): + """Represents a UDP port on the charm host.""" + + port: int + """The port to open.""" + protocol: _RawPortProtocolLiteral = "udp" + + def __post_init__(self): + super().__post_init__() + if not (1 <= self.port <= 65535): + raise StateValidationError( + f"`port` outside bounds [1:65535], got {self.port}", + ) + + +@dataclasses.dataclass(frozen=True) +class ICMPPort(_Port): + """Represents an ICMP port on the charm host.""" + + protocol: _RawPortProtocolLiteral = "icmp" + + _max_positional_args: Final = 0 + + def __post_init__(self): + super().__post_init__() + if self.port is not None: + raise StateValidationError("`port` cannot be set for `ICMPPort`") + + +_port_cls_by_protocol = { + "tcp": TCPPort, + "udp": UDPPort, + "icmp": ICMPPort, +} + + _next_storage_index_counter = 0 # storage indices start at 0 -def next_storage_index(update=True): +def next_storage_index(*, update=True): """Get the index (used to be called ID) the next Storage to be created will get. Pass update=False if you're only inspecting it. @@ -948,7 +1076,7 @@ def next_storage_index(update=True): @dataclasses.dataclass(frozen=True) -class Storage: +class Storage(_max_posargs(1)): """Represents an (attached!) storage made available to the charm container.""" name: str @@ -962,7 +1090,7 @@ def get_filesystem(self, ctx: "Context") -> Path: @dataclasses.dataclass(frozen=True) -class State: +class State(_max_posargs(0)): """Represents the juju-owned portion of a unit's state. Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its @@ -991,7 +1119,7 @@ class State: If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier - opened_ports: List[Port] = dataclasses.field(default_factory=list) + opened_ports: List[_Port] = dataclasses.field(default_factory=list) """Ports opened by juju on this charm.""" leader: bool = False """Whether this charm has leadership.""" @@ -1467,7 +1595,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: _next_action_id_counter = 1 -def next_action_id(update=True): +def next_action_id(*, update=True): global _next_action_id_counter cur = _next_action_id_counter if update: @@ -1478,7 +1606,7 @@ def next_action_id(update=True): @dataclasses.dataclass(frozen=True) -class Action: +class Action(_max_posargs(1)): """A ``juju run`` command. Used to simulate ``juju run``, passing in any parameters. For example:: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 6a955be79..82321558f 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -3,7 +3,6 @@ import pytest from ops.charm import CharmBase -from scenario import Model from scenario.consistency_checker import check_consistency from scenario.runtime import InconsistentScenarioError from scenario.state import ( @@ -12,6 +11,7 @@ CloudCredential, CloudSpec, Container, + Model, Network, Notice, PeerRelation, @@ -285,7 +285,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]), + State(secrets=[Secret(id="secret:foo", contents={0: {"a": "b"}})]), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -293,7 +293,7 @@ def test_secrets_jujuv_bad(good_v): def test_secret_not_in_state(): - secret = Secret("secret:foo", {"a": "b"}) + secret = Secret(id="secret:foo", contents={"a": "b"}) assert_inconsistent( State(), _Event("secret_changed", secret=secret), @@ -673,10 +673,10 @@ def test_storedstate_consistency(): assert_consistent( State( stored_state=[ - StoredState(None, content={"foo": "bar"}), - StoredState(None, "my_stored_state", content={"foo": 1}), - StoredState("MyCharmLib", content={"foo": None}), - StoredState("OtherCharmLib", content={"foo": (1, 2, 3)}), + StoredState(content={"foo": "bar"}), + StoredState(name="my_stored_state", content={"foo": 1}), + StoredState(owner_path="MyCharmLib", content={"foo": None}), + StoredState(owner_path="OtherCharmLib", content={"foo": (1, 2, 3)}), ] ), _Event("start"), @@ -690,8 +690,8 @@ def test_storedstate_consistency(): assert_inconsistent( State( stored_state=[ - StoredState(None, content={"foo": "bar"}), - StoredState(None, "_stored", content={"foo": "bar"}), + StoredState(owner_path=None, content={"foo": "bar"}), + StoredState(owner_path=None, name="_stored", content={"foo": "bar"}), ] ), _Event("start"), @@ -703,7 +703,13 @@ def test_storedstate_consistency(): ), ) assert_inconsistent( - State(stored_state=[StoredState(None, content={"secret": Secret("foo", {})})]), + State( + stored_state=[ + StoredState( + owner_path=None, content={"secret": Secret(id="foo", contents={})} + ) + ] + ), _Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_context.py b/tests/test_context.py index d6995efc9..aed141594 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,7 +3,7 @@ import pytest from ops import CharmBase -from scenario import Action, Context, State +from scenario import Action, ActionOutput, Context, State from scenario.state import _Event, next_action_id @@ -59,3 +59,23 @@ def test_app_name(app_name, 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}" + + +def test_action_output_no_positional_arguments(): + with pytest.raises(TypeError): + ActionOutput(None, None) + + +def test_action_output_no_results(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.act_action, self._on_act_action) + + def _on_act_action(self, _): + pass + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) + out = ctx.run_action(Action("act"), State()) + assert out.results is None + assert out.failure is None diff --git a/tests/test_context_on.py b/tests/test_context_on.py index be8c70b59..d9609d2e3 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -81,7 +81,9 @@ def test_simple_events(event_name, event_kind): ) def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - secret = scenario.Secret("secret:123", {0: {"password": "xxxx"}}, owner=owner) + secret = scenario.Secret( + id="secret:123", contents={0: {"password": "xxxx"}}, owner=owner + ) state_in = scenario.State(secrets=[secret]) # These look like: # ctx.run(ctx.on.secret_changed(secret=secret), state) @@ -112,8 +114,8 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): def test_revision_secret_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - "secret:123", - {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + id="secret:123", + contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner="app", ) state_in = scenario.State(secrets=[secret]) @@ -135,7 +137,9 @@ def test_revision_secret_events(event_name, event_kind): def test_revision_secret_events_as_positional_arg(event_name): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - "secret:123", {42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, owner=None + id="secret:123", + contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + owner=None, ) state_in = scenario.State(secrets=[secret]) with pytest.raises(TypeError): @@ -180,7 +184,7 @@ def test_action_event_no_params(): def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - action = scenario.Action("act", {"param": "hello"}) + action = scenario.Action("act", params={"param": "hello"}) # These look like: # ctx.run_action(ctx.on.action(action=action), state) # So that any parameters can be included and the ID can be customised. diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 6256885c0..39a057e69 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, _Event +from scenario.state import Action, State, _Event, next_action_id @pytest.fixture(scope="function") @@ -154,3 +154,17 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): action = Action("foo", id=uuid) ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) ctx.run_action(action, State()) + + +def test_positional_arguments(): + with pytest.raises(TypeError): + Action("foo", {}) + + +def test_default_arguments(): + expected_id = next_action_id(update=False) + name = "foo" + action = Action(name) + assert action.name == name + assert action.params == {} + assert action.id == expected_id diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index a92231205..7dfbba67e 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, Notice, Port, State +from scenario.state import Container, ExecOutput, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -86,7 +86,7 @@ def callback(self: CharmBase): Container( name="foo", can_connect=True, - mounts={"bar": Mount("/bar/baz.txt", pth)}, + mounts={"bar": Mount(location="/bar/baz.txt", source=pth)}, ) ] ), @@ -97,10 +97,6 @@ def callback(self: CharmBase): ) -def test_port_equality(): - assert Port("tcp", 42) == Port("tcp", 42) - - @pytest.mark.parametrize("make_dirs", (True, False)) def test_fs_pull(charm_cls, make_dirs): text = "lorem ipsum/n alles amat gloriae foo" @@ -122,7 +118,9 @@ def callback(self: CharmBase): td = tempfile.TemporaryDirectory() container = Container( - name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)} + name="foo", + can_connect=True, + mounts={"foo": Mount(location="/foo", source=td.name)}, ) state = State(containers=[container]) @@ -135,14 +133,15 @@ def callback(self: CharmBase): callback(mgr.charm) if make_dirs: - # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt") + # file = (out.get_container("foo").mounts["foo"].source + "bar/baz.txt").open("/foo/bar/baz.txt") # this is one way to retrieve the file file = Path(td.name + "/bar/baz.txt") # another is: assert ( - file == Path(out.get_container("foo").mounts["foo"].src) / "bar" / "baz.txt" + file + == Path(out.get_container("foo").mounts["foo"].source) / "bar" / "baz.txt" ) # but that is actually a symlink to the context's root tmp folder: diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 135029718..3a19148fd 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -2,7 +2,7 @@ from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State -from scenario.state import Port +from scenario.state import StateValidationError, TCPPort, UDPPort, _Port class MyCharm(CharmBase): @@ -35,5 +35,18 @@ def test_open_port(ctx): def test_close_port(ctx): - out = ctx.run(ctx.on.stop(), State(opened_ports=[Port("tcp", 42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports=[TCPPort(42)])) assert not out.opened_ports + + +def test_port_no_arguments(): + with pytest.raises(RuntimeError): + _Port() + + +@pytest.mark.parametrize("klass", (TCPPort, UDPPort)) +def test_port_port(klass): + with pytest.raises(StateValidationError): + klass(port=0) + with pytest.raises(StateValidationError): + klass(port=65536) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index e72f754c1..853c7ba5a 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -21,6 +21,7 @@ StateValidationError, SubordinateRelation, _RelationBase, + next_relation_id, ) from tests.helpers import trigger @@ -421,3 +422,54 @@ def test_broken_relation_not_in_model_relations(mycharm): assert charm.model.get_relation("foo") is None assert charm.model.relations["foo"] == [] + + +@pytest.mark.parametrize("klass", (Relation, PeerRelation, SubordinateRelation)) +def test_relation_positional_arguments(klass): + with pytest.raises(TypeError): + klass("foo", "bar", None) + + +def test_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "database" + interface = "postgresql" + relation = Relation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_app_name == "remote" + assert relation.limit == 1 + assert relation.remote_app_data == {} + assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + + +def test_subordinate_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "database" + interface = "postgresql" + relation = SubordinateRelation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_app_name == "remote" + assert relation.remote_unit_id == 0 + assert relation.remote_app_data == {} + assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + + +def test_peer_relation_default_values(): + expected_id = next_relation_id(update=False) + endpoint = "peers" + interface = "shared" + relation = PeerRelation(endpoint, interface) + assert relation.id == expected_id + assert relation.endpoint == endpoint + assert relation.interface == interface + assert relation.local_app_data == {} + assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.peers_data == {0: DEFAULT_JUJU_DATABAG} diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index d43414958..5958781c1 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -572,3 +572,23 @@ def _on_event(self, event): mgr.run() juju_event = mgr.charm.events[0] # Ignore collect-status etc. assert isinstance(juju_event, cls) + + +def test_no_additional_positional_arguments(): + with pytest.raises(TypeError): + Secret({}, None) + + +def test_default_values(): + contents = {"foo": "bar"} + id = "secret:1" + secret = Secret(contents, id=id) + assert secret.contents == contents + assert secret.id == id + assert secret.label is None + assert secret.revision == 0 + assert secret.description is None + assert secret.owner is None + assert secret.rotate is None + assert secret.expire is None + assert secret.remote_grants == {} diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 0c79da86a..3f1199099 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -1,3 +1,4 @@ +import copy from dataclasses import asdict, replace from typing import Type @@ -6,7 +7,16 @@ from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State +from scenario.state import ( + DEFAULT_JUJU_DATABAG, + Address, + BindAddress, + Container, + Model, + Network, + Relation, + State, +) from tests.helpers import jsonpatch_delta, sort_patch, trigger CUSTOM_EVT_SUFFIXES = { @@ -231,3 +241,78 @@ def pre_event(charm: CharmBase): assert out.relations[0].local_app_data == {"a": "b"} assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG} + + +@pytest.mark.parametrize( + "klass,num_args", + [ + (State, (1,)), + (Address, (0, 2)), + (BindAddress, (0, 2)), + (Network, (0, 2)), + ], +) +def test_positional_arguments(klass, num_args): + for num in num_args: + args = (None,) * num + with pytest.raises(TypeError): + klass(*args) + + +def test_model_positional_arguments(): + with pytest.raises(TypeError): + Model("", "") + + +def test_container_positional_arguments(): + with pytest.raises(TypeError): + Container("", "") + + +def test_container_default_values(): + name = "foo" + container = Container(name) + assert container.name == name + assert container.can_connect is False + assert container.layers == {} + assert container.service_status == {} + assert container.mounts == {} + assert container.exec_mock == {} + assert container.layers == {} + assert container._base_plan == {} + + +def test_state_default_values(): + state = State() + assert state.config == {} + assert state.relations == [] + assert state.networks == {} + assert state.containers == [] + assert state.storage == [] + assert state.opened_ports == [] + assert state.secrets == [] + assert state.resources == {} + assert state.deferred == [] + assert isinstance(state.model, Model) + assert state.leader is False + assert state.planned_units == 1 + assert state.app_status == UnknownStatus() + assert state.unit_status == UnknownStatus() + assert state.workload_version == "" + + +def test_deepcopy_state(): + containers = [Container("foo"), Container("bar")] + state = State(containers=containers) + state_copy = copy.deepcopy(state) + for container in state.containers: + copied_container = state_copy.get_container(container.name) + assert container.name == copied_container.name + + +def test_replace_state(): + containers = [Container("foo"), Container("bar")] + state = State(containers=containers, leader=True) + state2 = replace(state, leader=False) + assert state.leader != state2.leader + assert state.containers == state2.containers diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 22a6235e7..38c38efdb 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -39,7 +39,9 @@ def test_stored_state_initialized(mycharm): out = trigger( State( stored_state=[ - StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}), + StoredState( + owner_path="MyCharm", name="_stored", content={"foo": "FOOX"} + ), ] ), "start", @@ -49,3 +51,16 @@ def test_stored_state_initialized(mycharm): # todo: ordering is messy? assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + + +def test_positional_arguments(): + with pytest.raises(TypeError): + StoredState("_stored", "") + + +def test_default_arguments(): + s = StoredState() + assert s.name == "_stored" + assert s.owner_path == None + assert s.content == {} + assert s._data_type_name == "StoredStateData" From 5285060bea83c198514b01ea5606374cc12cd96c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 9 Jul 2024 19:04:01 +1200 Subject: [PATCH 512/546] feat!: use sets for the state components (#134) * Remove _DCBase. * Adjust the consistency checker and expose the Resource class. * Finish the conversion (all tests pass). * Don't add __eq__ for now. * Update scenario/mocking.py * Allow getting components by passing in the old entity. * Revert back to the simpler get_ methods. * Fix merges. * Remove unused method (was used in the old binding, not generally useful). * Add a basic test for resources. * Add a basic test for resources. * Make networks a set as well. --- README.md | 87 ++++++------- scenario/__init__.py | 2 + scenario/consistency_checker.py | 29 ++--- scenario/mocking.py | 54 ++++---- scenario/runtime.py | 18 +-- scenario/state.py | 166 +++++++++++++++++++++---- tests/helpers.py | 25 ++-- tests/test_charm_spec_autoload.py | 2 +- tests/test_consistency_checker.py | 102 ++++++--------- tests/test_context_on.py | 2 +- tests/test_e2e/test_deferred.py | 8 +- tests/test_e2e/test_network.py | 4 +- tests/test_e2e/test_pebble.py | 32 ++--- tests/test_e2e/test_play_assertions.py | 6 +- tests/test_e2e/test_ports.py | 5 +- tests/test_e2e/test_relations.py | 28 ++--- tests/test_e2e/test_resource.py | 34 +++++ tests/test_e2e/test_secrets.py | 58 ++++----- tests/test_e2e/test_state.py | 40 +++--- tests/test_e2e/test_storage.py | 8 +- tests/test_e2e/test_stored_state.py | 24 +++- 21 files changed, 451 insertions(+), 283 deletions(-) create mode 100644 tests/test_e2e/test_resource.py diff --git a/README.md b/README.md index cbbfdc9e1..d730e7884 100644 --- a/README.md +++ b/README.md @@ -322,22 +322,21 @@ class MyCharm(ops.CharmBase): def test_relation_data(): - state_in = scenario.State(relations=[ - scenario.Relation( - endpoint="foo", - interface="bar", - remote_app_name="remote", - local_unit_data={"abc": "foo"}, - remote_app_data={"cde": "baz!"}, - ), - ]) + rel = scenario.Relation( + endpoint="foo", + interface="bar", + remote_app_name="remote", + local_unit_data={"abc": "foo"}, + remote_app_data={"cde": "baz!"}, + ) + state_in = scenario.State(relations={rel}) ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_out = ctx.run(ctx.on.start(), state_in) - assert state_out.relations[0].local_unit_data == {"abc": "baz!"} - # you can do this to check that there are no other differences: - assert state_out.relations == [ + assert state_out.get_relation(rel.id).local_unit_data == {"abc": "baz!"} + # You can do this to check that there are no other differences: + assert state_out.relations == { scenario.Relation( endpoint="foo", interface="bar", @@ -345,7 +344,7 @@ def test_relation_data(): local_unit_data={"abc": "baz!"}, remote_app_data={"cde": "baz!"}, ), - ] + } # which is very idiomatic and superbly explicit. Noice. ``` @@ -381,11 +380,11 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -state_in = scenario.State(relations=[ +state_in = scenario.State(relations={ scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, - )]) + )}) meta = { "name": "invalid", @@ -496,7 +495,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - 'foo': scenario.Network.default(private_address='192.0.2.1') + scenario.Network.default("foo", private_address='192.0.2.1') }) ``` @@ -508,15 +507,15 @@ When testing a Kubernetes charm, you can mock container interactions. When using be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict. To give the charm access to some containers, you need to pass them to the input state, like so: -`State(containers=[...])` +`State(containers={...})` An example of a state including some containers: ```python -state = scenario.State(containers=[ +state = scenario.State(containers={ scenario.Container(name="foo", can_connect=True), scenario.Container(name="bar", can_connect=False) -]) +}) ``` In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`. @@ -535,7 +534,7 @@ container = scenario.Container( can_connect=True, mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)} ) -state = scenario.State(containers=[container]) +state = scenario.State(containers={container}) ``` In this case, if the charm were to: @@ -572,8 +571,8 @@ def test_pebble_push(): can_connect=True, mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)} ) - state_in = scenario.State(containers=[container]) - ctx = scenario.Context( + state_in = State(containers={container}) + ctx = Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} ) @@ -606,7 +605,7 @@ class MyCharm(ops.CharmBase): def test_pebble_push(): container = scenario.Container(name='foo', can_connect=True) - state_in = scenario.State(containers=[container]) + state_in = scenario.State(containers={container}) ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}} @@ -652,7 +651,7 @@ def test_pebble_exec(): stdout=LS_LL) } ) - state_in = scenario.State(containers=[container]) + state_in = scenario.State(containers={container}) ctx = scenario.Context( MyCharm, meta={"name": "foo", "containers": {"foo": {}}}, @@ -708,7 +707,7 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager(ctx.on.update_status(), scenario.State(storage=[storage])) as mgr: +with ctx.manager(ctx.on.update_status(), scenario.State(storages={storage})) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -753,11 +752,11 @@ So a natural follow-up Scenario test suite for this case would be: ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storage=[foo_0])) +ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storages={foo_0})) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storage=[foo_0, foo_1])) +ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storages={foo_0, foo_1})) ``` ## Ports @@ -766,7 +765,7 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ctx = scenario.Context(MyCharm, meta=MyCharm.META) -ctx.run(ctx.on.start(), scenario.State(opened_ports=[scenario.TCPPort(42)])) +ctx.run(ctx.on.start(), scenario.State(opened_ports={scenario.TCPPort(42)})) ``` - assert that a charm has called `open-port` or `close-port`: ```python @@ -775,7 +774,7 @@ state1 = ctx.run(ctx.on.start(), scenario.State()) assert state1.opened_ports == [scenario.TCPPort(42)] state2 = ctx.run(ctx.on.stop(), state1) -assert state2.opened_ports == [] +assert state2.opened_ports == {} ``` ## Secrets @@ -784,12 +783,12 @@ Scenario has secrets. Here's how you use them. ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'public'}}, id='foo', - ) - ] + ), + }, ) ``` @@ -813,15 +812,15 @@ To specify a secret owned by this unit (or app): ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'private'}}, id='foo', owner='unit', # or 'app' remote_grants={0: {"remote"}} # the secret owner has granted access to the "remote" app over some relation with ID 0 - ) - ] + ), + }, ) ``` @@ -829,14 +828,14 @@ To specify a secret owned by some other application and give this unit (or app) ```python state = scenario.State( - secrets=[ + secrets={ scenario.Secret( {0: {'key': 'public'}}, id='foo', # owner=None, which is the default revision=0, # the revision that this unit (or app) is currently tracking - ) - ] + ), + }, ) ``` @@ -853,15 +852,16 @@ class MyCharmType(ops.CharmBase): assert self.my_stored_state.foo == 'bar' # this will pass! -state = scenario.State(stored_state=[ +state = scenario.State(stored_states={ scenario.StoredState( owner_path="MyCharmType", name="my_stored_state", content={ 'foo': 'bar', 'baz': {42: 42}, - }) -]) + }), + }, +) ``` And the charm's runtime will see `self.my_stored_state.foo` and `.baz` as expected. Also, you can run assertions on it on @@ -879,7 +879,8 @@ So, the only consistency-level check we enforce in Scenario when it comes to res import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) -with ctx.manager(ctx.on.start(), scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: +resource = scenario.Resource(name='foo', path='/path/to/resource.tar') +with ctx.manager(ctx.on.start(), scenario.State(resources={resource})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') @@ -1060,7 +1061,7 @@ class MyCharm(ops.CharmBase): def test_start_on_deferred_update_status(MyCharm): foo_relation = scenario.Relation('foo') scenario.State( - relations=[foo_relation], + relations={foo_relation}, deferred=[ scenario.deferred('foo_relation_changed', handler=MyCharm._on_foo_relation_changed, diff --git a/scenario/__init__.py b/scenario/__init__.py index a73570a67..fafc3631e 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -18,6 +18,7 @@ Notice, PeerRelation, Relation, + Resource, Secret, State, StateValidationError, @@ -52,6 +53,7 @@ "ICMPPort", "TCPPort", "UDPPort", + "Resource", "Storage", "StoredState", "State", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 8c2837bbc..be9b7fc1c 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -4,7 +4,7 @@ import marshal import os import re -from collections import Counter, defaultdict +from collections import defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -108,7 +108,7 @@ def check_resource_consistency( warnings = [] resources_from_meta = set(charm_spec.meta.get("resources", {})) - resources_from_state = set(state.resources) + resources_from_state = {resource.name for resource in state.resources} if not resources_from_meta.issuperset(resources_from_state): errors.append( f"any and all resources passed to State.resources need to have been defined in " @@ -265,7 +265,7 @@ def _check_storage_event( f"storage event {event.name} refers to storage {storage.name} " f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.", ) - elif storage not in state.storage: + elif storage not in state.storages: errors.append( f"cannot emit {event.name} because storage {storage.name} " f"is not in the state.", @@ -330,11 +330,11 @@ def check_storages_consistency( **_kwargs, # noqa: U101 ) -> Results: """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" - state_storage = state.storage + state_storage = state.storages meta_storage = (charm_spec.meta or {}).get("storage", {}) errors = [] - if missing := {s.name for s in state.storage}.difference( + if missing := {s.name for s in state_storage}.difference( set(meta_storage.keys()), ): errors.append( @@ -347,7 +347,7 @@ def check_storages_consistency( if tag in seen: errors.append( f"duplicate storage in State: storage {s.name} with index {s.index} " - f"occurs multiple times in State.storage.", + f"occurs multiple times in State.storages.", ) seen.append(tag) @@ -465,10 +465,8 @@ def check_network_consistency( if metadata.get("scope") != "container" # mark of a sub } - state_bindings = set(state.networks) - if diff := state_bindings.difference( - meta_bindings.union(non_sub_relations).union(implicit_bindings), - ): + state_bindings = {network.binding_name for network in state.networks} + if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): errors.append( f"Some network bindings defined in State are not in metadata.yaml: {diff}.", ) @@ -598,11 +596,6 @@ def check_containers_consistency( f"Missing from metadata: {diff}.", ) - # guard against duplicate container names - names = Counter(state_containers) - if dupes := [n for n in names if names[n] > 1]: - errors.append(f"Duplicate container name(s): {dupes}.") - return Results(errors, []) @@ -633,12 +626,12 @@ def check_storedstate_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of `state.storedstate`.""" + """Check the internal consistency of `state.stored_states`.""" errors = [] # Attribute names must be unique on each object. names = defaultdict(list) - for ss in state.stored_state: + for ss in state.stored_states: names[ss.owner_path].append(ss.name) for owner, owner_names in names.items(): if len(owner_names) != len(set(owner_names)): @@ -647,7 +640,7 @@ def check_storedstate_consistency( ) # The content must be marshallable. - for ss in state.stored_state: + for ss in state.stored_states: # We don't need the marshalled state, just to know that it can be. # This is the same "only simple types" check that ops does. try: diff --git a/scenario/mocking.py b/scenario/mocking.py index 71570de18..a8dae087e 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -129,9 +129,11 @@ def open_port( # fixme: the charm will get hit with a StateValidationError # here, not the expected ModelError... port_ = _port_cls_by_protocol[protocol](port=port) - ports = self._state.opened_ports + ports = set(self._state.opened_ports) if port_ not in ports: - ports.append(port_) + ports.add(port_) + if ports != self._state.opened_ports: + self._state._update_opened_ports(frozenset(ports)) def close_port( self, @@ -139,9 +141,11 @@ def close_port( port: Optional[int] = None, ): _port = _port_cls_by_protocol[protocol](port=port) - ports = self._state.opened_ports + ports = set(self._state.opened_ports) if _port in ports: ports.remove(_port) + if ports != self._state.opened_ports: + self._state._update_opened_ports(frozenset(ports)) def get_pebble(self, socket_path: str) -> "Client": container_name = socket_path.split("/")[ @@ -150,7 +154,7 @@ def get_pebble(self, socket_path: str) -> "Client": container_root = self._context._get_container_root(container_name) try: mounts = self._state.get_container(container_name).mounts - except ValueError: + except KeyError: # container not defined in state. mounts = {} @@ -168,11 +172,9 @@ def _get_relation_by_id( rel_id, ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: try: - return next( - filter(lambda r: r.id == rel_id, self._state.relations), - ) - except StopIteration: - raise RelationNotFoundError() + return self._state.get_relation(rel_id) + except ValueError: + raise RelationNotFoundError() from None def _get_secret(self, id=None, label=None): # FIXME: what error would a charm get IRL? @@ -314,7 +316,10 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): raise RelationNotFoundError() # We look in State.networks for an override. If not given, we return a default network. - network = self._state.networks.get(binding_name, Network.default()) + try: + network = self._state.get_network(binding_name) + except KeyError: + network = Network.default("default") # The name is not used in the output. return network.hook_tool_output_fmt() # setter methods: these can mutate the state. @@ -374,7 +379,9 @@ def secret_add( rotate=rotate, owner=owner, ) - self._state.secrets.append(secret) + secrets = set(self._state.secrets) + secrets.add(secret) + self._state._update_secrets(frozenset(secrets)) return secret_id def _check_can_manage_secret( @@ -560,7 +567,7 @@ def storage_add(self, name: str, count: int = 1): def storage_list(self, name: str) -> List[int]: return [ - storage.index for storage in self._state.storage if storage.name == name + storage.index for storage in self._state.storages if storage.name == name ] def _storage_event_details(self) -> Tuple[int, str]: @@ -587,7 +594,7 @@ def storage_get(self, storage_name_id: str, attribute: str) -> str: name, index = storage_name_id.split("/") index = int(index) storages: List[Storage] = [ - s for s in self._state.storage if s.name == name and s.index == index + s for s in self._state.storages if s.name == name and s.index == index ] # should not really happen: sanity checks. In practice, ops will guard against these paths. @@ -627,16 +634,19 @@ def add_metrics( "it's deprecated API)", ) + # TODO: It seems like this method has no tests. def resource_get(self, resource_name: str) -> str: - try: - return str(self._state.resources[resource_name]) - except KeyError: - # ops will not let us get there if the resource name is unknown from metadata. - # but if the user forgot to add it in State, then we remind you of that. - raise RuntimeError( - f"Inconsistent state: " - f"resource {resource_name} not found in State. please pass it.", - ) + # We assume that there are few enough resources that a linear search + # will perform well enough. + for resource in self._state.resources: + if resource.name == resource_name: + return str(resource.path) + # ops will not let us get there if the resource name is unknown from metadata. + # but if the user forgot to add it in State, then we remind you of that. + raise RuntimeError( + f"Inconsistent state: " + f"resource {resource_name} not found in State. please pass it.", + ) def credential_get(self) -> CloudSpec_Ops: if not self._context.app_trusted: diff --git a/scenario/runtime.py b/scenario/runtime.py index 97a7c773f..97abe9219 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,7 +10,7 @@ import typing from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union +from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, Union import yaml from ops import pebble @@ -62,12 +62,12 @@ def _open_db(self) -> SQLiteStorage: """Open the db.""" return SQLiteStorage(self._state_file) - def get_stored_state(self) -> List["StoredState"]: + def get_stored_states(self) -> FrozenSet["StoredState"]: """Load any StoredState data structures from the db.""" db = self._open_db() - stored_state = [] + stored_states = set() for handle_path in db.list_snapshots(): if not EVENT_REGEX.match(handle_path) and ( match := STORED_STATE_REGEX.match(handle_path) @@ -75,10 +75,10 @@ def get_stored_state(self) -> List["StoredState"]: stored_state_snapshot = db.load_snapshot(handle_path) kwargs = match.groupdict() sst = StoredState(content=stored_state_snapshot, **kwargs) - stored_state.append(sst) + stored_states.add(sst) db.close() - return stored_state + return frozenset(stored_states) def get_deferred_events(self) -> List["DeferredEvent"]: """Load any DeferredEvent data structures from the db.""" @@ -119,7 +119,7 @@ def apply_state(self, state: "State"): ) from e db.save_snapshot(event.handle_path, event.snapshot_data) - for stored_state in state.stored_state: + for stored_state in state.stored_states: db.save_snapshot(stored_state.handle_path, stored_state.content) db.close() @@ -347,7 +347,7 @@ def _virtual_charm_root(self): elif ( not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root ): - logger.warn( + logger.warning( f"Some metadata files found in custom user-provided charm_root " f"{charm_virtual_root} while you have passed meta, config or actions to " f"Context.run(). " @@ -388,8 +388,8 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): """Now that we're done processing this event, read the charm state and expose it.""" store = self._get_state_db(temporary_charm_root) deferred = store.get_deferred_events() - stored_state = store.get_stored_state() - return dataclasses.replace(state, deferred=deferred, stored_state=stored_state) + stored_state = store.get_stored_states() + return dataclasses.replace(state, deferred=deferred, stored_states=stored_state) @contextmanager def _exec_ctx(self, ctx: "Context"): diff --git a/scenario/state.py b/scenario/state.py index c788e8552..12d7d301d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -18,6 +18,7 @@ Callable, Dict, Final, + FrozenSet, Generic, List, Literal, @@ -291,6 +292,9 @@ class Secret(_max_posargs(1)): expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None + def __hash__(self) -> int: + return hash(self.id) + def _set_revision(self, revision: int): """Set a new tracked revision.""" # bypass frozen dataclass @@ -370,11 +374,15 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_max_posargs(0)): +class Network(_max_posargs(1)): + binding_name: str bind_addresses: List[BindAddress] ingress_addresses: List[str] egress_subnets: List[str] + def __hash__(self) -> int: + return hash(self.binding_name) + def hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { @@ -386,6 +394,7 @@ def hook_tool_output_fmt(self): @classmethod def default( cls, + binding_name: str, private_address: str = "192.0.2.0", hostname: str = "", cidr: str = "", @@ -396,6 +405,7 @@ def default( ) -> "Network": """Helper to create a minimal, heavily defaulted Network.""" return cls( + binding_name=binding_name, bind_addresses=[ BindAddress( interface_name=interface_name, @@ -470,6 +480,9 @@ def __post_init__(self): for databag in self._databags: self._validate_databag(databag) + def __hash__(self) -> int: + return hash(self.id) + def _validate_databag(self, databag: dict): if not isinstance(databag, dict): raise StateValidationError( @@ -508,6 +521,9 @@ class Relation(RelationBase): ) """The current content of the databag for each unit in the relation.""" + def __hash__(self) -> int: + return hash(self.id) + @property def _remote_app_name(self) -> str: """Who is on the other end of this relation?""" @@ -542,6 +558,9 @@ class SubordinateRelation(_RelationBase): remote_app_name: str = "remote" remote_unit_id: int = 0 + def __hash__(self) -> int: + return hash(self.id) + @property def _remote_unit_ids(self) -> Tuple[int]: """Ids of the units on the other end of this relation.""" @@ -579,6 +598,9 @@ class PeerRelation(RelationBase): """Current contents of the peer databags.""" # Consistency checks will validate that *this unit*'s ID is not in here. + def __hash__(self) -> int: + return hash(self.id) + @property def _databags(self): """Yield all databags in this relation.""" @@ -837,6 +859,9 @@ class Container(_max_posargs(1)): notices: List[Notice] = dataclasses.field(default_factory=list) + def __hash__(self) -> int: + return hash(self.name) + def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() services = {} # type: Dict[str, pebble.Service] @@ -984,6 +1009,9 @@ class StoredState(_max_posargs(1)): def handle_path(self): return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" + def __hash__(self) -> int: + return hash(self.handle_path) + _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] @@ -1089,6 +1117,14 @@ def get_filesystem(self, ctx: "Context") -> Path: return ctx._get_storage_root(self.name, self.index) +@dataclasses.dataclass(frozen=True) +class Resource(_max_posargs(0)): + """Represents a resource made available to the charm.""" + + name: str + path: "PathLike" + + @dataclasses.dataclass(frozen=True) class State(_max_posargs(0)): """Represents the juju-owned portion of a unit's state. @@ -1102,9 +1138,9 @@ class State(_max_posargs(0)): default_factory=dict, ) """The present configuration of this charm.""" - relations: List["AnyRelation"] = dataclasses.field(default_factory=list) + relations: FrozenSet["AnyRelation"] = dataclasses.field(default_factory=frozenset) """All relations that currently exist for this charm.""" - networks: Dict[str, Network] = dataclasses.field(default_factory=dict) + networks: FrozenSet[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. @@ -1112,36 +1148,38 @@ class State(_max_posargs(0)): support it, but use at your own risk.] If a metadata-defined extra-binding is left empty, it will be defaulted. """ - containers: List[Container] = dataclasses.field(default_factory=list) + containers: FrozenSet[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" - storage: List[Storage] = dataclasses.field(default_factory=list) + storages: FrozenSet[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: List[_Port] = dataclasses.field(default_factory=list) + opened_ports: FrozenSet[_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: List[Secret] = dataclasses.field(default_factory=list) + secrets: FrozenSet[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: Dict[str, "PathLike"] = dataclasses.field(default_factory=dict) - """Mapping from resource name to path at which the resource can be found.""" + resources: FrozenSet[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. Use with caution.""" - # represents the OF's event queue. These events will be emitted before the event being + # Represents the OF's event queue. These events will be emitted before the event being # dispatched, and represent the events that had been deferred during the previous run. # If the charm defers any events during "this execution", they will be appended # to this list. deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list) """Events that have been deferred on this charm by some previous execution.""" - stored_state: List["StoredState"] = dataclasses.field(default_factory=list) + stored_states: FrozenSet["StoredState"] = dataclasses.field( + default_factory=frozenset, + ) """Contents of a charm's stored state.""" # the current statuses. Will be cast to _EntitiyStatus in __post_init__ @@ -1161,6 +1199,24 @@ def __post_init__(self): object.__setattr__(self, name, _status_to_entitystatus(val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") + # It's convenient to pass a set, but we really want the attributes to be + # frozen sets to increase the immutability of State objects. + for name in [ + "relations", + "containers", + "storages", + "networks", + "opened_ports", + "secrets", + "resources", + "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. + if not isinstance(val, frozenset): + object.__setattr__(self, name, frozenset(val)) def _update_workload_version(self, new_workload_version: str): """Update the current app version and record the previous one.""" @@ -1181,6 +1237,16 @@ def _update_status( # bypass frozen dataclass object.__setattr__(self, name, _EntityStatus(new_status, new_message)) + def _update_opened_ports(self, new_ports: FrozenSet[_Port]): + """Update the current opened ports.""" + # bypass frozen dataclass + object.__setattr__(self, "opened_ports", new_ports) + + def _update_secrets(self, new_secrets: FrozenSet[Secret]): + """Update the current secrets.""" + # bypass frozen dataclass + object.__setattr__(self, "secrets", new_secrets) + def with_can_connect(self, container_name: str, can_connect: bool) -> "State": def replacer(container: Container): if container.name == container_name: @@ -1202,15 +1268,73 @@ def with_unit_status(self, status: StatusBase) -> "State": ), ) - def get_container(self, container: Union[str, Container]) -> Container: - """Get container from this State, based on an input container or its name.""" - container_name = ( - container.name if isinstance(container, Container) else container + def get_container(self, container: str, /) -> Container: + """Get container from this State, based on its name.""" + for state_container in self.containers: + if state_container.name == container: + return state_container + raise KeyError(f"container: {container} not found in the State") + + def get_network(self, binding_name: str, /) -> Network: + """Get network from this State, based on its binding name.""" + for network in self.networks: + if network.binding_name == binding_name: + return network + raise KeyError(f"network: {binding_name} not found in the State") + + def get_secret( + self, + *, + id: Optional[str] = None, + label: Optional[str] = None, + ) -> Secret: + """Get secret from this State, based on the secret's id or label.""" + if id is None and label is None: + raise ValueError("An id or label must be provided.") + + for secret in self.secrets: + if ( + (id and label and secret.id == id and secret.label == label) + or (id and label is None and secret.id == id) + or (id is None and label and secret.label == label) + ): + return secret + raise KeyError("secret: not found in the State") + + def get_stored_state( + self, + stored_state: str, + /, + *, + owner_path: Optional[str] = None, + ) -> StoredState: + """Get stored state from this State, based on the stored state's name and owner_path.""" + for ss in self.stored_states: + if ss.name == stored_state and ss.owner_path == owner_path: + return ss + raise ValueError(f"stored state: {stored_state} not found in the State") + + def get_storage( + self, + storage: str, + /, + *, + index: Optional[int] = 0, + ) -> Storage: + """Get storage from this State, based on the storage's name and index.""" + for state_storage in self.storages: + if state_storage.name == storage and storage.index == index: + return state_storage + raise ValueError( + f"storage: name={storage}, index={index} not found in the State", ) - containers = [c for c in self.containers if c.name == container_name] - if not containers: - raise ValueError(f"container: {container_name} not found in the State") - return containers[0] + + def get_relation(self, relation: int, /) -> "AnyRelation": + """Get relation from this State, based on the relation's id.""" + for state_relation in self.relations: + if state_relation.id == relation: + return state_relation + raise KeyError(f"relation: id={relation} not found in the State") def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: """Get all relations on this endpoint from the current state.""" @@ -1227,10 +1351,6 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: if normalize_name(r.endpoint) == normalized_endpoint ) - def get_storages(self, name: str) -> Tuple["Storage", ...]: - """Get all storages with this name.""" - return tuple(s for s in self.storage if s.name == name) - def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]): # Check whether this dict has the expected mandatory metadata fields according to the diff --git a/tests/helpers.py b/tests/helpers.py index 7dd1f8351..c8060d1c9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -52,10 +52,10 @@ def trigger( if isinstance(event, str): if event.startswith("relation_"): assert len(state.relations) == 1, "shortcut only works with one relation" - event = getattr(ctx.on, event)(state.relations[0]) + event = getattr(ctx.on, event)(tuple(state.relations)[0]) elif event.startswith("pebble_"): assert len(state.containers) == 1, "shortcut only works with one container" - event = getattr(ctx.on, event)(state.containers[0]) + event = getattr(ctx.on, event)(tuple(state.containers)[0]) else: event = getattr(ctx.on, event)() with ctx.manager(event, state=state) as mgr: @@ -67,11 +67,22 @@ def trigger( return state_out -def jsonpatch_delta(input: "State", output: "State"): - patch = jsonpatch.make_patch( - dataclasses.asdict(output), - dataclasses.asdict(input), - ).patch +def jsonpatch_delta(self, other: "State"): + dict_other = dataclasses.asdict(other) + dict_self = dataclasses.asdict(self) + for attr in ( + "relations", + "containers", + "storages", + "opened_ports", + "secrets", + "resources", + "stored_states", + "networks", + ): + dict_other[attr] = [dataclasses.asdict(o) for o in dict_other[attr]] + dict_self[attr] = [dataclasses.asdict(o) for o in dict_self[attr]] + patch = jsonpatch.make_patch(dict_other, dict_self).patch return sort_patch(patch) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 51ba13919..fb738f876 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -144,7 +144,7 @@ def test_relations_ok(tmp_path, legacy): ) as charm: # this would fail if there were no 'cuddles' relation defined in meta ctx = Context(charm) - ctx.run(ctx.on.start(), State(relations=[Relation("cuddles")])) + ctx.run(ctx.on.start(), State(relations={Relation("cuddles")})) @pytest.mark.parametrize("legacy", (True, False)) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 82321558f..82d9c76ad 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -16,6 +16,7 @@ Notice, PeerRelation, Relation, + Resource, Secret, State, Storage, @@ -63,7 +64,7 @@ def test_workload_event_without_container(): _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("foo")]), + State(containers={Container("foo")}), _Event("foo-pebble-ready", container=Container("foo")), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -74,12 +75,12 @@ def test_workload_event_without_container(): ) notice = Notice("example.com/foo") assert_consistent( - State(containers=[Container("foo", notices=[notice])]), + State(containers={Container("foo", notices=[notice])}), _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) assert_inconsistent( - State(containers=[Container("foo")]), + State(containers={Container("foo")}), _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) @@ -87,12 +88,12 @@ def test_workload_event_without_container(): def test_container_meta_mismatch(): assert_inconsistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"baz": {}}}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -100,12 +101,12 @@ def test_container_meta_mismatch(): def test_container_in_state_but_no_container_in_meta(): assert_inconsistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("foo"), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -119,7 +120,7 @@ def test_container_not_in_state(): _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) assert_consistent( - State(containers=[container]), + State(containers={container}), _Event("bar_pebble_ready", container=container), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -132,7 +133,7 @@ def test_evt_bad_container_name(): _CharmSpec(MyCharm, {}), ) assert_consistent( - State(containers=[Container("bar")]), + State(containers={Container("bar")}), _Event("bar-pebble-ready", container=Container("bar")), _CharmSpec(MyCharm, {"containers": {"bar": {}}}), ) @@ -147,7 +148,7 @@ def test_evt_bad_relation_name(suffix): ) relation = Relation("bar") assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -158,7 +159,7 @@ def test_evt_no_relation(suffix): assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) relation = Relation("bar") assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event(f"bar{suffix}", relation=relation), _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}), ) @@ -262,13 +263,13 @@ def test_config_secret_old_juju(juju_version): def test_secrets_jujuv_bad(bad_v): secret = Secret("secret:foo", {0: {"a": "b"}}) assert_inconsistent( - State(secrets=[secret]), + State(secrets={secret}), _Event("bar"), _CharmSpec(MyCharm, {}), bad_v, ) assert_inconsistent( - State(secrets=[secret]), + State(secrets={secret}), secret.changed_event, _CharmSpec(MyCharm, {}), bad_v, @@ -285,7 +286,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets=[Secret(id="secret:foo", contents={0: {"a": "b"}})]), + State(secrets={Secret(id="secret:foo", contents={0: {"a": "b"}})}), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -308,12 +309,12 @@ def test_secret_not_in_state(): def test_peer_relation_consistency(): assert_inconsistent( - State(relations=[Relation("foo")]), + State(relations={Relation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) assert_consistent( - State(relations=[PeerRelation("foo")]), + State(relations={PeerRelation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}), ) @@ -335,7 +336,7 @@ def test_duplicate_endpoints_inconsistent(): def test_sub_relation_consistency(): assert_inconsistent( - State(relations=[Relation("foo")]), + State(relations={Relation("foo")}), _Event("bar"), _CharmSpec( MyCharm, @@ -344,7 +345,7 @@ def test_sub_relation_consistency(): ) assert_consistent( - State(relations=[SubordinateRelation("foo")]), + State(relations={SubordinateRelation("foo")}), _Event("bar"), _CharmSpec( MyCharm, @@ -355,7 +356,7 @@ def test_sub_relation_consistency(): def test_relation_sub_inconsistent(): assert_inconsistent( - State(relations=[SubordinateRelation("foo")]), + State(relations={SubordinateRelation("foo")}), _Event("bar"), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) @@ -369,20 +370,12 @@ def test_relation_not_in_state(): _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) assert_consistent( - State(relations=[relation]), + State(relations={relation}), _Event("foo_relation_changed", relation=relation), _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}), ) -def test_dupe_containers_inconsistent(): - assert_inconsistent( - State(containers=[Container("foo"), Container("foo")]), - _Event("bar"), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), - ) - - def test_action_not_in_meta_inconsistent(): action = Action("foo", params={"bar": "baz"}) assert_inconsistent( @@ -459,7 +452,7 @@ def test_action_params_type(ptype, good, bad): def test_duplicate_relation_ids(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State(relations={Relation("foo", id=1), Relation("bar", id=1)}), _Event("start"), _CharmSpec( MyCharm, @@ -472,13 +465,13 @@ def test_duplicate_relation_ids(): def test_relation_without_endpoint(): assert_inconsistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=1)]), + State(relations={Relation("foo", id=1), Relation("bar", id=1)}), _Event("start"), _CharmSpec(MyCharm, meta={"name": "charlemagne"}), ) assert_consistent( - State(relations=[Relation("foo", id=1), Relation("bar", id=2)]), + State(relations={Relation("foo", id=1), Relation("bar", id=2)}), _Event("start"), _CharmSpec( MyCharm, @@ -492,12 +485,12 @@ def test_relation_without_endpoint(): def test_storage_event(): storage = Storage("foo") assert_inconsistent( - State(storage=[storage]), + State(storages={storage}), _Event("foo-storage-attached"), _CharmSpec(MyCharm, meta={"name": "rupert"}), ) assert_inconsistent( - State(storage=[storage]), + State(storages={storage}), _Event("foo-storage-attached"), _CharmSpec( MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}} @@ -510,19 +503,19 @@ def test_storage_states(): storage2 = Storage("foo", index=1) assert_inconsistent( - State(storage=[storage1, storage2]), + State(storages={storage1, storage2}), _Event("start"), _CharmSpec(MyCharm, meta={"name": "everett"}), ) assert_consistent( - State(storage=[storage1, dataclasses.replace(storage2, index=2)]), + State(storages={storage1, dataclasses.replace(storage2, index=2)}), _Event("start"), _CharmSpec( MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}} ), ) assert_consistent( - State(storage=[storage1, dataclasses.replace(storage2, name="marx")]), + State(storages={storage1, dataclasses.replace(storage2, name="marx")}), _Event("start"), _CharmSpec( MyCharm, @@ -548,7 +541,7 @@ def test_storage_not_in_state(): ), ) assert_consistent( - State(storage=[storage]), + State(storages=[storage]), _Event("foo_storage_attached", storage=storage), _CharmSpec( MyCharm, @@ -560,7 +553,7 @@ def test_storage_not_in_state(): def test_resource_states(): # happy path assert_consistent( - State(resources={"foo": "/foo/bar.yaml"}), + State(resources={Resource(name="foo", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -580,7 +573,7 @@ def test_resource_states(): # resource not defined in meta assert_inconsistent( - State(resources={"bar": "/foo/bar.yaml"}), + State(resources={Resource(name="bar", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -589,7 +582,7 @@ def test_resource_states(): ) assert_inconsistent( - State(resources={"bar": "/foo/bar.yaml"}), + State(resources={Resource(name="bar", path="/foo/bar.yaml")}), _Event("start"), _CharmSpec( MyCharm, @@ -600,7 +593,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -609,7 +602,7 @@ def test_networks_consistency(): ) assert_inconsistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -622,7 +615,7 @@ def test_networks_consistency(): ) assert_consistent( - State(networks={"foo": Network.default()}), + State(networks={Network.default("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -672,27 +665,12 @@ def test_cloudspec_consistency(): def test_storedstate_consistency(): assert_consistent( State( - stored_state=[ + stored_states={ StoredState(content={"foo": "bar"}), StoredState(name="my_stored_state", content={"foo": 1}), StoredState(owner_path="MyCharmLib", content={"foo": None}), StoredState(owner_path="OtherCharmLib", content={"foo": (1, 2, 3)}), - ] - ), - _Event("start"), - _CharmSpec( - MyCharm, - meta={ - "name": "foo", - }, - ), - ) - assert_inconsistent( - State( - stored_state=[ - StoredState(owner_path=None, content={"foo": "bar"}), - StoredState(owner_path=None, name="_stored", content={"foo": "bar"}), - ] + } ), _Event("start"), _CharmSpec( @@ -704,11 +682,11 @@ def test_storedstate_consistency(): ) assert_inconsistent( State( - stored_state=[ + stored_states={ StoredState( owner_path=None, content={"secret": Secret(id="foo", contents={})} ) - ] + } ), _Event("start"), _CharmSpec( diff --git a/tests/test_context_on.py b/tests/test_context_on.py index d9609d2e3..1c98b4eae 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -156,7 +156,7 @@ def test_revision_secret_events_as_positional_arg(event_name): def test_storage_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) storage = scenario.Storage("foo") - state_in = scenario.State(storage=[storage]) + 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: diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index fccb326c7..f988dcc5a 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -120,7 +120,7 @@ def test_deferred_relation_event(mycharm): out = trigger( State( - relations=[rel], + relations={rel}, deferred=[ deferred( event="foo_relation_changed", @@ -152,7 +152,7 @@ def test_deferred_relation_event_from_relation(mycharm): rel = Relation(endpoint="foo", remote_app_name="remote") out = trigger( State( - relations=[rel], + relations={rel}, deferred=[ ctx.on.relation_changed(rel, remote_unit=1).deferred( handler=mycharm._on_event @@ -190,7 +190,7 @@ def test_deferred_workload_event(mycharm): out = trigger( State( - containers=[ctr], + containers={ctr}, deferred=[ _Event("foo_pebble_ready", container=ctr).deferred( handler=mycharm._on_event @@ -238,7 +238,7 @@ def test_defer_reemit_relation_event(mycharm): rel = Relation("foo") mycharm.defer_next = 1 - state_1 = ctx.run(ctx.on.relation_created(rel), State(relations=[rel])) + state_1 = ctx.run(ctx.on.relation_created(rel), State(relations={rel})) mycharm.defer_next = 0 state_2 = ctx.run(ctx.on.start(), state_1) diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 683246473..47302698c 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -51,7 +51,7 @@ def test_ip_get(mycharm): id=1, ), ], - networks={"foo": Network.default(private_address="4.4.4.4")}, + networks={Network.default("foo", private_address="4.4.4.4")}, ), ) as mgr: # we have a network for the relation @@ -113,7 +113,7 @@ def test_no_relation_error(mycharm): id=1, ), ], - networks={"bar": Network.default()}, + networks={Network.default("bar")}, ), ) as mgr: with pytest.raises(RelationNotFoundError): diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 7dfbba67e..08acebc3a 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -61,7 +61,7 @@ def callback(self: CharmBase): assert can_connect == self.unit.get_container("foo").can_connect() trigger( - State(containers=[Container(name="foo", can_connect=can_connect)]), + State(containers={Container(name="foo", can_connect=can_connect)}), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="start", @@ -82,13 +82,13 @@ def callback(self: CharmBase): trigger( State( - containers=[ + containers={ Container( name="foo", can_connect=True, mounts={"bar": Mount(location="/bar/baz.txt", source=pth)}, ) - ] + } ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -122,7 +122,7 @@ def callback(self: CharmBase): can_connect=True, mounts={"foo": Mount(location="/foo", source=td.name)}, ) - state = State(containers=[container]) + state = State(containers={container}) ctx = Context( charm_type=charm_cls, @@ -156,7 +156,7 @@ def callback(self: CharmBase): else: # nothing has changed - out_purged = dataclasses.replace(out, stored_state=state.stored_state) + out_purged = dataclasses.replace(out, stored_states=state.stored_states) assert not jsonpatch_delta(out_purged, state) @@ -197,13 +197,13 @@ def callback(self: CharmBase): trigger( State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, ) - ] + } ), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, @@ -220,7 +220,7 @@ def callback(self: CharmBase): container = Container(name="foo", can_connect=True) trigger( - State(containers=[container]), + State(containers={container}), charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, event="pebble_ready", @@ -287,14 +287,14 @@ def _on_ready(self, event): ) out = trigger( - State(containers=[container]), + State(containers={container}), charm_type=PlanCharm, meta={"name": "foo", "containers": {"foo": {}}}, event="pebble_ready", ) serv = lambda name, obj: pebble.Service(name, raw=obj) - container = out.containers[0] + container = out.get_container(container.name) assert container.plan.services == { "barserv": serv("barserv", {"startup": "disabled"}), "fooserv": serv("fooserv", {"startup": "enabled"}), @@ -308,13 +308,13 @@ def _on_ready(self, event): def test_exec_wait_error(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) @@ -328,7 +328,7 @@ def test_exec_wait_error(charm_cls): def test_exec_wait_output(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, @@ -336,7 +336,7 @@ def test_exec_wait_output(charm_cls): ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie") }, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) @@ -350,13 +350,13 @@ def test_exec_wait_output(charm_cls): def test_exec_wait_output_error(charm_cls): state = State( - containers=[ + containers={ Container( name="foo", can_connect=True, exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, ) - ] + } ) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py index 7fe07899d..103940af0 100644 --- a/tests/test_e2e/test_play_assertions.py +++ b/tests/test_e2e/test_play_assertions.py @@ -62,7 +62,7 @@ def post_event(charm): assert out.unit_status == ActiveStatus("yabadoodle") - out_purged = dataclasses.replace(out, stored_state=initial_state.stored_state) + out_purged = dataclasses.replace(out, stored_states=initial_state.stored_states) assert jsonpatch_delta(out_purged, initial_state) == [ { "op": "replace", @@ -100,7 +100,7 @@ def check_relation_data(charm): assert remote_app_data == {"yaba": "doodle"} state_in = State( - relations=[ + relations={ Relation( endpoint="relation_test", interface="azdrubales", @@ -109,7 +109,7 @@ def check_relation_data(charm): remote_app_data={"yaba": "doodle"}, remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}}, ) - ] + } ) trigger( state_in, diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 3a19148fd..80365a01a 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -28,14 +28,15 @@ def ctx(): def test_open_port(ctx): out = ctx.run(ctx.on.start(), State()) - port = out.opened_ports.pop() + assert len(out.opened_ports) == 1 + port = tuple(out.opened_ports)[0] assert port.protocol == "tcp" assert port.port == 12 def test_close_port(ctx): - out = ctx.run(ctx.on.stop(), State(opened_ports=[TCPPort(42)])) + out = ctx.run(ctx.on.stop(), State(opened_ports={TCPPort(42)})) assert not out.opened_ports diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 853c7ba5a..9ba0ed616 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -64,10 +64,10 @@ def pre_event(charm: CharmBase): State( config={"foo": "bar"}, leader=True, - relations=[ + relations={ Relation(endpoint="foo", interface="foo", remote_app_name="remote"), Relation(endpoint="qux", interface="qux", remote_app_name="remote"), - ], + }, ), "start", mycharm, @@ -97,9 +97,9 @@ def test_relation_events(mycharm, evt_name): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -141,9 +141,9 @@ def callback(charm: CharmBase, e): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -202,7 +202,7 @@ def callback(charm: CharmBase, event): }, }, ) - state = State(relations=[relation]) + state = State(relations={relation}) kwargs = {} if has_unit: kwargs["remote_unit"] = remote_unit_id @@ -242,9 +242,9 @@ def callback(charm: CharmBase, event): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -302,9 +302,9 @@ def callback(charm: CharmBase, event): trigger( State( - relations=[ + relations={ relation, - ], + }, ), f"relation_{evt_name}", mycharm, @@ -356,7 +356,7 @@ def test_relation_event_trigger(relation, evt_name, mycharm): "peers": {"b": {"interface": "i2"}}, } state = trigger( - State(relations=[relation]), + State(relations={relation}), f"relation_{evt_name}", mycharm, meta=meta, @@ -389,7 +389,7 @@ def post_event(charm: CharmBase): assert len(relation.units) == 1 trigger( - State(relations=[sub1, sub2]), + State(relations={sub1, sub2}), "update_status", mycharm, meta=meta, @@ -417,7 +417,7 @@ 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: + with ctx.manager(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_resource.py b/tests/test_e2e/test_resource.py new file mode 100644 index 000000000..c4237ea68 --- /dev/null +++ b/tests/test_e2e/test_resource.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import pathlib + +import ops +import pytest + +from scenario import Context, Resource, State + + +class ResourceCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + + +def test_get_resource(): + ctx = Context( + ResourceCharm, + meta={ + "name": "resource-charm", + "resources": {"foo": {"type": "file"}, "bar": {"type": "file"}}, + }, + ) + resource1 = Resource(name="foo", path=pathlib.Path("/tmp/foo")) + resource2 = Resource(name="bar", path=pathlib.Path("~/bar")) + with ctx.manager( + 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 + with pytest.raises(NameError): + mgr.charm.model.resources.fetch("baz") diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 5958781c1..a9a3697e4 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -45,7 +45,7 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm): ctx = Context(mycharm, meta={"name": "local"}) with ctx.manager( - state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}})]), + state=State(secrets={Secret(id="foo", contents={0: {"a": "b"}})}), event=ctx.on.update_status(), ) as mgr: assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" @@ -57,7 +57,7 @@ def test_get_secret_get_refresh(mycharm, owner): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -66,7 +66,7 @@ def test_get_secret_get_refresh(mycharm, owner): }, owner=owner, ) - ] + } ), ) as mgr: charm = mgr.charm @@ -80,7 +80,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): ctx.on.update_status(), State( leader=app, - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -88,7 +88,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): 1: {"a": "c"}, }, ), - ], + }, ), ) as mgr: charm = mgr.charm @@ -106,7 +106,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( id="foo", contents={ @@ -115,7 +115,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): }, owner=owner, ) - ] + } ), ) as mgr: charm = mgr.charm @@ -177,7 +177,7 @@ def test_add(mycharm, app): charm.unit.add_secret({"foo": "bar"}, label="mylabel") assert mgr.output.secrets - secret = mgr.output.secrets[0] + secret = mgr.output.get_secret(label="mylabel") assert secret.contents[0] == {"foo": "bar"} assert secret.label == "mylabel" @@ -221,7 +221,7 @@ def test_set_legacy_behaviour(mycharm): == rev3 ) - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -253,7 +253,7 @@ def test_set(mycharm): assert secret.get_content() == rev2 assert secret.peek_content() == secret.get_content(refresh=True) == rev3 - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -282,7 +282,7 @@ def test_set_juju33(mycharm): assert secret.peek_content() == rev3 assert secret.get_content(refresh=True) == rev3 - assert state_out.secrets[0].contents == { + assert state_out.get_secret(label="mylabel").contents == { 0: rev1, 1: rev2, 2: rev3, @@ -296,7 +296,7 @@ def test_meta(mycharm, app): ctx.on.update_status(), State( leader=True, - secrets=[ + secrets={ Secret( owner="app" if app else "unit", id="foo", @@ -307,7 +307,7 @@ def test_meta(mycharm, app): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: charm = mgr.charm @@ -336,7 +336,7 @@ def test_secret_permission_model(mycharm, leader, owner): ctx.on.update_status(), State( leader=leader, - secrets=[ + secrets={ Secret( id="foo", label="mylabel", @@ -347,7 +347,7 @@ def test_secret_permission_model(mycharm, leader, owner): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: secret = mgr.charm.model.get_secret(id="foo") @@ -389,7 +389,7 @@ def test_grant(mycharm, app): ctx.on.update_status(), State( relations=[Relation("foo", "remote")], - secrets=[ + secrets={ Secret( owner="unit", id="foo", @@ -400,7 +400,7 @@ def test_grant(mycharm, app): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: charm = mgr.charm @@ -410,7 +410,7 @@ def test_grant(mycharm, app): secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) - vals = list(mgr.output.secrets[0].remote_grants.values()) + vals = list(mgr.output.get_secret(label="mylabel").remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] @@ -421,7 +421,7 @@ def test_update_metadata(mycharm): with ctx.manager( ctx.on.update_status(), State( - secrets=[ + secrets={ Secret( owner="unit", id="foo", @@ -430,7 +430,7 @@ def test_update_metadata(mycharm): 0: {"a": "b"}, }, ) - ], + }, ), ) as mgr: secret = mgr.charm.model.get_secret(label="mylabel") @@ -441,7 +441,7 @@ def test_update_metadata(mycharm): rotate=SecretRotate.DAILY, ) - secret_out = mgr.output.secrets[0] + secret_out = mgr.output.get_secret(label="babbuccia") assert secret_out.label == "babbuccia" assert secret_out.rotate == SecretRotate.DAILY assert secret_out.description == "blu" @@ -481,8 +481,8 @@ def post_event(charm: CharmBase): out = trigger( State( - relations=[Relation("foo", "remote")], - secrets=[ + relations={Relation("foo", "remote")}, + secrets={ Secret( id="foo", label="mylabel", @@ -492,7 +492,7 @@ def post_event(charm: CharmBase): 0: {"a": "b"}, }, ) - ], + }, ), "update_status", mycharm, @@ -514,9 +514,9 @@ def __init__(self, *args): state = State( leader=True, - relations=[ + relations={ Relation("bar", remote_app_name=relation_remote_app, id=relation_id) - ], + }, ) with ctx.manager(ctx.on.start(), state) as mgr: @@ -527,7 +527,7 @@ def __init__(self, *args): secret.grant(bar_relation) assert mgr.output.secrets - scenario_secret = mgr.output.secrets[0] + scenario_secret = mgr.output.get_secret(label="mylabel") assert relation_remote_app in scenario_secret.remote_grants[relation_id] with ctx.manager(ctx.on.start(), mgr.output) as mgr: @@ -535,7 +535,7 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) - scenario_secret = mgr.output.secrets[0] + scenario_secret = mgr.output.get_secret(label="mylabel") assert scenario_secret.remote_grants == {} with ctx.manager(ctx.on.start(), mgr.output) as mgr: @@ -543,7 +543,7 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() - assert not mgr.output.secrets[0].contents # secret wiped + assert not mgr.output.get_secret(label="mylabel").contents # secret wiped @pytest.mark.parametrize( diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 3f1199099..aaa3246fa 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -15,6 +15,7 @@ Model, Network, Relation, + Resource, State, ) from tests.helpers import jsonpatch_delta, sort_patch, trigger @@ -67,7 +68,7 @@ def state(): def test_bare_event(state, mycharm): out = trigger(state, "start", mycharm, meta={"name": "foo"}) - out_purged = replace(out, stored_state=state.stored_state) + out_purged = replace(out, stored_states=state.stored_states) assert jsonpatch_delta(state, out_purged) == [] @@ -106,7 +107,7 @@ def call(charm: CharmBase, e): assert out.workload_version == "" # ignore stored state in the delta - out_purged = replace(out, stored_state=state.stored_state) + out_purged = replace(out, stored_states=state.stored_states) assert jsonpatch_delta(out_purged, state) == sort_patch( [ {"op": "replace", "path": "/app_status/message", "value": "foo barz"}, @@ -126,7 +127,7 @@ def pre_event(charm: CharmBase): assert container.can_connect() is connect trigger( - State(containers=[Container(name="foo", can_connect=connect)]), + State(containers={Container(name="foo", can_connect=connect)}), "start", mycharm, meta={ @@ -155,7 +156,7 @@ def pre_event(charm: CharmBase): assert not rel.data[unit] state = State( - relations=[ + relations={ Relation( endpoint="foo", interface="bar", @@ -165,7 +166,7 @@ def pre_event(charm: CharmBase): local_unit_data={"c": "d"}, remote_units_data={0: {}, 1: {"e": "f"}, 2: {}}, ) - ] + } ) trigger( state, @@ -215,7 +216,7 @@ def pre_event(charm: CharmBase): state = State( leader=True, planned_units=4, - relations=[relation], + relations={relation}, ) assert not mycharm.called @@ -231,25 +232,28 @@ def pre_event(charm: CharmBase): ) assert mycharm.called - assert asdict(out.relations[0]) == asdict( + assert asdict(out.get_relation(relation.id)) == asdict( replace( relation, local_app_data={"a": "b"}, local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, ) ) - - assert out.relations[0].local_app_data == {"a": "b"} - assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG} + assert out.get_relation(relation.id).local_app_data == {"a": "b"} + assert out.get_relation(relation.id).local_unit_data == { + "c": "d", + **DEFAULT_JUJU_DATABAG, + } @pytest.mark.parametrize( "klass,num_args", [ (State, (1,)), + (Resource, (1,)), (Address, (0, 2)), (BindAddress, (0, 2)), - (Network, (0, 2)), + (Network, (1, 2)), ], ) def test_positional_arguments(klass, num_args): @@ -285,13 +289,13 @@ def test_container_default_values(): def test_state_default_values(): state = State() assert state.config == {} - assert state.relations == [] - assert state.networks == {} - assert state.containers == [] - assert state.storage == [] - assert state.opened_ports == [] - assert state.secrets == [] - assert state.resources == {} + assert state.relations == frozenset() + assert state.networks == frozenset() + assert state.containers == frozenset() + assert state.storages == frozenset() + assert state.opened_ports == frozenset() + assert state.secrets == frozenset() + assert state.resources == frozenset() assert state.deferred == [] assert isinstance(state.model, Model) assert state.leader is False diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index b62288bb7..3e6912fb3 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -66,7 +66,7 @@ def test_storage_usage(storage_ctx): (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") with storage_ctx.manager( - storage_ctx.on.update_status(), State(storage=[storage]) + storage_ctx.on.update_status(), State(storages={storage}) ) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location @@ -85,9 +85,11 @@ def test_storage_usage(storage_ctx): def test_storage_attached_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storage=[storage])) + storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storages={storage})) def test_storage_detaching_event(storage_ctx): storage = Storage("foo") - storage_ctx.run(storage_ctx.on.storage_detaching(storage), State(storage=[storage])) + storage_ctx.run( + storage_ctx.on.storage_detaching(storage), State(storages={storage}) + ) diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py index 38c38efdb..94b9c3019 100644 --- a/tests/test_e2e/test_stored_state.py +++ b/tests/test_e2e/test_stored_state.py @@ -32,25 +32,37 @@ def _on_event(self, event): def test_stored_state_default(mycharm): out = trigger(State(), "start", mycharm, meta=mycharm.META) - assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + assert out.get_stored_state("_stored", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } + assert out.get_stored_state("_stored2", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } def test_stored_state_initialized(mycharm): out = trigger( State( - stored_state=[ + stored_states={ StoredState( owner_path="MyCharm", name="_stored", content={"foo": "FOOX"} ), - ] + } ), "start", mycharm, meta=mycharm.META, ) - # todo: ordering is messy? - assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}} - assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}} + assert out.get_stored_state("_stored", owner_path="MyCharm").content == { + "foo": "FOOX", + "baz": {12: 142}, + } + assert out.get_stored_state("_stored2", owner_path="MyCharm").content == { + "foo": "bar", + "baz": {12: 142}, + } def test_positional_arguments(): From f11809ad6db3415e1a97aed470bc3b99e5522f82 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 17 Jul 2024 15:19:12 +1200 Subject: [PATCH 513/546] feat!: add Scenario classes that match the ops status classes (#142) Adds classes that match the ops status classes: * UnknownStatus * ActiveStatus * WaitingStatus * MaintenanceStatus * BlockedStatus * ErrorStatus --- README.md | 25 +++--- scenario/__init__.py | 12 +++ scenario/context.py | 4 +- scenario/mocking.py | 4 +- scenario/state.py | 151 +++++++++++++++++++++++++++------ tests/test_e2e/test_actions.py | 2 +- tests/test_e2e/test_status.py | 38 ++++++--- 7 files changed, 183 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index d730e7884..8983a3ec0 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,12 @@ With that, we can write the simplest possible scenario test: def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State()) - assert out.unit_status == ops.UnknownStatus() + assert out.unit_status == scenario.UnknownStatus() ``` +Note that you should always compare the app and unit status using `==`, not `is`. You can compare +them to either the `scenario` objects, or the `ops` ones. + Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python @@ -110,7 +113,7 @@ class MyCharm(ops.CharmBase): def test_status_leader(leader): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State(leader=leader)) - assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') + assert out.unit_status == scenario.ActiveStatus('I rule' if leader else 'I am ruled') ``` By defining the right state we can programmatically define what answers will the charm get to all the questions it can @@ -165,15 +168,15 @@ def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(ctx.on.start(), scenario.State(leader=False)) assert ctx.unit_status_history == [ - ops.UnknownStatus(), - ops.MaintenanceStatus('determining who the ruler is...'), - ops.WaitingStatus('checking this is right...'), + scenario.UnknownStatus(), + scenario.MaintenanceStatus('determining who the ruler is...'), + scenario.WaitingStatus('checking this is right...'), ] - assert out.unit_status == ops.ActiveStatus("I am ruled") + assert out.unit_status == scenario.ActiveStatus("I am ruled") # similarly you can check the app status history: assert ctx.app_status_history == [ - ops.UnknownStatus(), + scenario.UnknownStatus(), ... ] ``` @@ -198,9 +201,9 @@ class MyCharm(ops.CharmBase): # ... ctx = scenario.Context(MyCharm, meta={"name": "foo"}) -ctx.run(ctx.on.start(), scenario.State(unit_status=ops.ActiveStatus('foo'))) +ctx.run(ctx.on.start(), scenario.State(unit_status=scenario.ActiveStatus('foo'))) assert ctx.unit_status_history == [ - ops.ActiveStatus('foo'), # now the first status is active: 'foo'! + scenario.ActiveStatus('foo'), # now the first status is active: 'foo'! # ... ] ``` @@ -248,7 +251,7 @@ def test_emitted_full(): capture_deferred_events=True, capture_framework_events=True, ) - ctx.run(ctx.on.start(), scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)])) + ctx.run(ctx.on.start(), scenario.State(deferred=[ctx.on.update_status().deferred(MyCharm._foo)])) assert len(ctx.emitted_events) == 5 assert [e.handle.kind for e in ctx.emitted_events] == [ @@ -396,8 +399,6 @@ meta = { } ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) ctx.run(ctx.on.start(), state_in) # invalid: this unit's id cannot be the ID of a peer. - - ``` ### SubordinateRelation diff --git a/scenario/__init__.py b/scenario/__init__.py index fafc3631e..aa70017c6 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -4,14 +4,18 @@ from scenario.context import ActionOutput, Context from scenario.state import ( Action, + ActiveStatus, Address, BindAddress, + BlockedStatus, CloudCredential, CloudSpec, Container, DeferredEvent, + ErrorStatus, ExecOutput, ICMPPort, + MaintenanceStatus, Model, Mount, Network, @@ -27,6 +31,8 @@ SubordinateRelation, TCPPort, UDPPort, + UnknownStatus, + WaitingStatus, deferred, ) @@ -58,4 +64,10 @@ "StoredState", "State", "DeferredEvent", + "ErrorStatus", + "BlockedStatus", + "WaitingStatus", + "MaintenanceStatus", + "ActiveStatus", + "UnknownStatus", ] diff --git a/scenario/context.py b/scenario/context.py index c563814b0..1930945a1 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -557,9 +557,9 @@ def _get_storage_root(self, name: str, index: int) -> Path: def _record_status(self, state: "State", is_app: bool): """Record the previous status before a status change.""" if is_app: - self.app_status_history.append(cast("_EntityStatus", state.app_status)) + self.app_status_history.append(state.app_status) else: - self.unit_status_history.append(cast("_EntityStatus", state.unit_status)) + self.unit_status_history.append(state.unit_status) def manager(self, event: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. diff --git a/scenario/mocking.py b/scenario/mocking.py index a8dae087e..94c567219 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -43,6 +43,7 @@ Network, PeerRelation, Storage, + _EntityStatus, _port_cls_by_protocol, _RawPortProtocolLiteral, _RawStatusLiteral, @@ -338,7 +339,8 @@ def status_set( is_app: bool = False, ): self._context._record_status(self._state, is_app) - self._state._update_status(status, message, is_app) + status_obj = _EntityStatus.from_status_name(status, message) + self._state._update_status(status_obj, is_app) def juju_log(self, level: str, message: str): self._context.juju_log.append(JujuLogLine(level, message)) diff --git a/scenario/state.py b/scenario/state.py index 12d7d301d..d077c1f34 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -16,6 +16,7 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Dict, Final, FrozenSet, @@ -32,6 +33,7 @@ ) from uuid import uuid4 +import ops import yaml from ops import pebble from ops.charm import CharmBase, CharmEvents @@ -956,18 +958,17 @@ def get_notice( class _EntityStatus: """This class represents StatusBase and should not be interacted with directly.""" - # Why not use StatusBase directly? Because that's not json-serializable. + # Why not use StatusBase directly? Because that can't be used with + # dataclasses.asdict to then be JSON-serializable. name: _RawStatusLiteral message: str = "" + _entity_statuses: ClassVar[Dict[str, Type["_EntityStatus"]]] = {} + def __eq__(self, other): if isinstance(other, (StatusBase, _EntityStatus)): return (self.name, self.message) == (other.name, other.message) - logger.warning( - f"Comparing Status with {other} is not stable and will be forbidden soon." - f"Please compare with StatusBase directly.", - ) return super().__eq__(other) def __repr__(self): @@ -976,17 +977,89 @@ def __repr__(self): return f"{status_type_name}()" return f"{status_type_name}('{self.message}')" + @classmethod + def from_status_name( + cls, + name: _RawStatusLiteral, + message: str = "", + ) -> "_EntityStatus": + # Note that this won't work for UnknownStatus. + # All subclasses have a default 'name' attribute, but the type checker can't tell that. + return cls._entity_statuses[name](message=message) # type:ignore + + @classmethod + def from_ops(cls, obj: StatusBase) -> "_EntityStatus": + return cls.from_status_name(cast(_RawStatusLiteral, obj.name), obj.message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class UnknownStatus(_EntityStatus, ops.UnknownStatus): + __doc__ = ops.UnknownStatus.__doc__ + + name: Literal["unknown"] = "unknown" + + def __init__(self): + super().__init__(name=self.name) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class ErrorStatus(_EntityStatus, ops.ErrorStatus): + __doc__ = ops.ErrorStatus.__doc__ + + name: Literal["error"] = "error" + + def __init__(self, message: str = ""): + super().__init__(name="error", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class ActiveStatus(_EntityStatus, ops.ActiveStatus): + __doc__ = ops.ActiveStatus.__doc__ + + name: Literal["active"] = "active" + + def __init__(self, message: str = ""): + super().__init__(name="active", message=message) -def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus: - """Convert StatusBase to _EntityStatus.""" - statusbase_subclass = type(StatusBase.from_name(obj.name, obj.message)) - class _MyClass(_EntityStatus, statusbase_subclass): - # Custom type inheriting from a specific StatusBase subclass to support instance checks: - # isinstance(state.unit_status, ops.ActiveStatus) - pass +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class BlockedStatus(_EntityStatus, ops.BlockedStatus): + __doc__ = ops.BlockedStatus.__doc__ - return _MyClass(cast(_RawStatusLiteral, obj.name), obj.message) + name: Literal["blocked"] = "blocked" + + def __init__(self, message: str = ""): + super().__init__(name="blocked", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class MaintenanceStatus(_EntityStatus, ops.MaintenanceStatus): + __doc__ = ops.MaintenanceStatus.__doc__ + + name: Literal["maintenance"] = "maintenance" + + def __init__(self, message: str = ""): + super().__init__(name="maintenance", message=message) + + +@dataclasses.dataclass(frozen=True, eq=False, repr=False) +class WaitingStatus(_EntityStatus, ops.WaitingStatus): + __doc__ = ops.WaitingStatus.__doc__ + + name: Literal["waiting"] = "waiting" + + def __init__(self, message: str = ""): + super().__init__(name="waiting", message=message) + + +_EntityStatus._entity_statuses.update( + unknown=UnknownStatus, + error=ErrorStatus, + active=ActiveStatus, + blocked=BlockedStatus, + maintenance=MaintenanceStatus, + waiting=WaitingStatus, +) @dataclasses.dataclass(frozen=True) @@ -1033,6 +1106,11 @@ def __post_init__(self): "please use TCPPort, UDPPort, or ICMPPort", ) + def __eq__(self, other: object) -> bool: + if isinstance(other, (_Port, ops.Port)): + return (self.protocol, self.port) == (other.protocol, other.port) + return False + @dataclasses.dataclass(frozen=True) class TCPPort(_Port): @@ -1112,6 +1190,11 @@ class Storage(_max_posargs(1)): index: int = dataclasses.field(default_factory=next_storage_index) # Every new Storage instance gets a new one, if there's trouble, override. + def __eq__(self, other: object) -> bool: + if isinstance(other, (Storage, ops.Storage)): + return (self.name, self.index) == (other.name, other.index) + return False + def get_filesystem(self, ctx: "Context") -> Path: """Simulated filesystem root in this context.""" return ctx._get_storage_root(self.name, self.index) @@ -1182,23 +1265,45 @@ class State(_max_posargs(0)): ) """Contents of a charm's stored state.""" - # the current statuses. Will be cast to _EntitiyStatus in __post_init__ - app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + # the current statuses. + app_status: _EntityStatus = UnknownStatus() """Status of the application.""" - unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown") + unit_status: _EntityStatus = UnknownStatus() """Status of the unit.""" workload_version: str = "" """Workload version.""" def __post_init__(self): + # Let people pass in the ops classes, and convert them to the appropriate Scenario classes. for name in ["app_status", "unit_status"]: val = getattr(self, name) if isinstance(val, _EntityStatus): pass elif isinstance(val, StatusBase): - object.__setattr__(self, name, _status_to_entitystatus(val)) + object.__setattr__(self, name, _EntityStatus.from_ops(val)) else: raise TypeError(f"Invalid status.{name}: {val!r}") + normalised_ports = [ + _Port(protocol=port.protocol, port=port.port) + if isinstance(port, ops.Port) + else port + for port in self.opened_ports + ] + if self.opened_ports != normalised_ports: + object.__setattr__(self, "opened_ports", normalised_ports) + normalised_storage = [ + Storage(name=storage.name, index=storage.index) + if isinstance(storage, ops.Storage) + else storage + for storage in self.storages + ] + if self.storages != normalised_storage: + object.__setattr__(self, "storages", normalised_storage) + # ops.Container, ops.Model, ops.Relation, ops.Secret should not be instantiated by charmers. + # ops.Network does not have the relation name, so cannot be converted. + # ops.Resources does not contain the source of the resource, so cannot be converted. + # ops.StoredState is not convenient to initialise with data, so not useful here. + # It's convenient to pass a set, but we really want the attributes to be # frozen sets to increase the immutability of State objects. for name in [ @@ -1228,14 +1333,13 @@ def _update_workload_version(self, new_workload_version: str): def _update_status( self, - new_status: _RawStatusLiteral, - new_message: str = "", + new_status: _EntityStatus, is_app: bool = False, ): - """Update the current app/unit status and add the previous one to the history.""" + """Update the current app/unit status.""" name = "app_status" if is_app else "unit_status" # bypass frozen dataclass - object.__setattr__(self, name, _EntityStatus(new_status, new_message)) + object.__setattr__(self, name, new_status) def _update_opened_ports(self, new_ports: FrozenSet[_Port]): """Update the current opened ports.""" @@ -1262,10 +1366,7 @@ def with_leadership(self, leader: bool) -> "State": def with_unit_status(self, status: StatusBase) -> "State": return dataclasses.replace( self, - status=dataclasses.replace( - cast(_EntityStatus, self.unit_status), - unit=_status_to_entitystatus(status), - ), + unit_status=_EntityStatus.from_ops(status), ) def get_container(self, container: str, /) -> Container: diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 39a057e69..34c9cd946 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, _Event, next_action_id +from scenario.state import Action, State, next_action_id @pytest.fixture(scope="function") diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py index 6b3bc0c53..f88f4b04b 100644 --- a/tests/test_e2e/test_status.py +++ b/tests/test_e2e/test_status.py @@ -1,17 +1,18 @@ +import ops import pytest from ops.charm import CharmBase from ops.framework import Framework -from ops.model import ( + +from scenario import Context +from scenario.state import ( ActiveStatus, BlockedStatus, ErrorStatus, MaintenanceStatus, + State, UnknownStatus, WaitingStatus, ) - -from scenario import Context -from scenario.state import State, _status_to_entitystatus from tests.helpers import trigger @@ -52,9 +53,9 @@ def __init__(self, framework): def _on_update_status(self, _): for obj in (self.unit, self.app): - obj.status = ActiveStatus("1") - obj.status = BlockedStatus("2") - obj.status = WaitingStatus("3") + obj.status = ops.ActiveStatus("1") + obj.status = ops.BlockedStatus("2") + obj.status = ops.WaitingStatus("3") ctx = Context( StatusCharm, @@ -70,7 +71,7 @@ def _on_update_status(self, _): BlockedStatus("2"), ] - assert out.app_status == WaitingStatus("3") + assert out.app_status == ops.WaitingStatus("3") assert ctx.app_status_history == [ UnknownStatus(), ActiveStatus("1"), @@ -151,7 +152,20 @@ def _on_update_status(self, _): ), ) def test_status_comparison(status): - entitystatus = _status_to_entitystatus(status) - assert entitystatus == entitystatus == status - assert isinstance(entitystatus, type(status)) - assert repr(entitystatus) == repr(status) + if isinstance(status, UnknownStatus): + ops_status = ops.UnknownStatus() + else: + ops_status = getattr(ops, status.__class__.__name__)(status.message) + # A status can be compared to itself. + assert status == status + # A status can be compared to another instance of the scenario class. + if isinstance(status, UnknownStatus): + assert status == status.__class__() + else: + assert status == status.__class__(status.message) + # A status can be compared to an instance of the ops class. + assert status == ops_status + # isinstance also works for comparing to the ops classes. + assert isinstance(status, type(ops_status)) + # The repr of the scenario and ops classes should be identical. + assert repr(status) == repr(ops_status) From 962026022d771ccb2de73ecede40424e5cd9e9f2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:20:51 +1200 Subject: [PATCH 514/546] Make action events more like other events. (#161) --- .gitignore | 2 +- README.md | 11 +++++---- docs/custom_conf.py | 1 + scenario/__init__.py | 2 -- scenario/consistency_checker.py | 4 ++-- scenario/context.py | 40 ++++++++++++++++++------------- scenario/state.py | 15 +++++------- tests/test_consistency_checker.py | 26 ++++++++++---------- tests/test_context.py | 23 ++++++++---------- tests/test_context_on.py | 11 ++++----- tests/test_e2e/test_actions.py | 34 ++++++++------------------ tests/test_e2e/test_manager.py | 4 ++-- 12 files changed, 80 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index c20b5a450..a2f1492ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ venv/ build/ -docs/build/ +docs/_build/ *.charm .tox/ .coverage diff --git a/README.md b/README.md index 8983a3ec0..875243394 100644 --- a/README.md +++ b/README.md @@ -957,7 +957,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'] @@ -973,17 +973,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() + ) # ... ``` diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 37ad32efa..c7f2a9432 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -308,6 +308,7 @@ 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'), ] diff --git a/scenario/__init__.py b/scenario/__init__.py index aa70017c6..2f6471e75 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -3,7 +3,6 @@ # See LICENSE file for licensing details. from scenario.context import ActionOutput, Context from scenario.state import ( - Action, ActiveStatus, Address, BindAddress, @@ -37,7 +36,6 @@ ) __all__ = [ - "Action", "ActionOutput", "CloudCredential", "CloudSpec", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index be9b7fc1c..0b54ee43e 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -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, ) @@ -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], ): diff --git a/scenario/context.py b/scenario/context.py index 1930945a1..af61eedfb 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -12,11 +12,11 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( - Action, Container, MetadataNotFoundError, Secret, Storage, + _Action, _CharmSpec, _Event, _max_posargs, @@ -26,7 +26,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] @@ -81,7 +81,7 @@ class _Manager: def __init__( self, ctx: "Context", - arg: Union[str, Action, _Event], + arg: Union[str, _Action, _Event], state_in: "State", ): self._ctx = ctx @@ -160,7 +160,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 @@ -312,6 +312,19 @@ def storage_detaching(storage: Storage): def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) + @staticmethod + 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. @@ -577,7 +590,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: @@ -607,23 +620,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) @@ -642,11 +655,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( diff --git a/scenario/state.py b/scenario/state.py index d077c1f34..6e9e9c827 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1670,7 +1670,7 @@ class Event: notice: Optional[Notice] = None """If this is a Pebble notice event, the notice it refers to.""" - 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) @@ -1827,15 +1827,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 @@ -1850,11 +1852,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], diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 82d9c76ad..2e2efb9e7 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -4,10 +4,10 @@ from ops.charm import CharmBase from scenario.consistency_checker import check_consistency +from scenario.context import Context from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, - Action, CloudCredential, CloudSpec, Container, @@ -22,6 +22,7 @@ Storage, StoredState, SubordinateRelation, + _Action, _CharmSpec, _Event, ) @@ -377,19 +378,19 @@ def test_relation_not_in_state(): def test_action_not_in_meta_inconsistent(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec(MyCharm, meta={}, actions={}), ) def test_action_meta_type_inconsistent(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "zabazaba"}}}} ), @@ -397,24 +398,24 @@ def test_action_meta_type_inconsistent(): assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec(MyCharm, meta={}, actions={"foo": {"params": {"bar": {}}}}), ) def test_action_name(): - action = Action("foo", params={"bar": "baz"}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_consistent( State(), - action.event, + ctx.on.action("foo", params={"bar": "baz"}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}} ), ) assert_inconsistent( State(), - _Event("box_action", action=action), + _Event("box_action", action=ctx.on.action("foo", params={"bar": "baz"})), _CharmSpec(MyCharm, meta={}, actions={"foo": {}}), ) @@ -431,19 +432,18 @@ def test_action_name(): @pytest.mark.parametrize("ptype,good,bad", _ACTION_TYPE_CHECKS) def test_action_params_type(ptype, good, bad): - action = Action("foo", params={"bar": good}) + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) assert_consistent( State(), - action.event, + ctx.on.action("foo", params={"bar": good}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} ), ) if bad is not None: - action = Action("foo", params={"bar": bad}) assert_inconsistent( State(), - action.event, + ctx.on.action("foo", params={"bar": bad}), _CharmSpec( MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}} ), diff --git a/tests/test_context.py b/tests/test_context.py index aed141594..2ca8b93aa 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import Action, ActionOutput, Context, State -from scenario.state import _Event, next_action_id +from scenario import ActionOutput, Context, State +from scenario.state import _Action, _Event, next_action_id class MyCharm(CharmBase): @@ -34,22 +34,19 @@ def test_run_action(): state = State() expected_id = next_action_id(update=False) - with patch.object(ctx, "_run_action") as p: - ctx._output_state = ( - "foo" # would normally be set within the _run_action call scope - ) - action = Action("do-foo") - output = ctx.run_action(action, state) + with patch.object(ctx, "_run") as p: + ctx._output_state = "foo" # would normally be set within the _run call scope + output = ctx.run_action(ctx.on.action("do-foo"), state) assert output.state == "foo" assert p.called - a = p.call_args.kwargs["action"] + e = p.call_args.kwargs["event"] s = p.call_args.kwargs["state"] - assert isinstance(a, Action) - assert a.event.name == "do_foo_action" + assert isinstance(e, _Event) + assert e.name == "do_foo_action" assert s is state - assert a.id == expected_id + assert e.action.id == expected_id @pytest.mark.parametrize("app_name", ("foo", "bar", "george")) @@ -76,6 +73,6 @@ def _on_act_action(self, _): pass ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) - out = ctx.run_action(Action("act"), State()) + out = ctx.run_action(ctx.on.action("act"), State()) assert out.results is None assert out.failure is None diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 1c98b4eae..151bc3033 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -173,8 +173,7 @@ 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) - action = scenario.Action("act") - with ctx.action_manager(action, scenario.State()) as mgr: + with ctx.action_manager(ctx.on.action("act"), scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) @@ -184,18 +183,18 @@ def test_action_event_no_params(): def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - action = scenario.Action("act", params={"param": "hello"}) # These look like: # ctx.run_action(ctx.on.action(action=action), state) # So that any parameters can be included and the ID can be customised. - with ctx.action_manager(action, scenario.State()) as mgr: + call_event = ctx.on.action("act", params={"param": "hello"}) + with ctx.action_manager(call_event, scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) event = mgr.charm.observed[0] assert isinstance(event, ops.ActionEvent) - assert event.id == action.id - assert event.params["param"] == action.params["param"] + assert event.id == call_event.action.id + assert event.params["param"] == call_event.action.params["param"] def test_pebble_ready_event(): diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 34c9cd946..b0668355f 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -5,7 +5,7 @@ from scenario import Context from scenario.context import InvalidEventError -from scenario.state import Action, State, next_action_id +from scenario.state import State, _Action, next_action_id @pytest.fixture(scope="function") @@ -34,8 +34,7 @@ def test_action_event(mycharm, baz_value): "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} }, ) - action = Action("foo", params={"baz": baz_value, "bar": 10}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) evt = ctx.emitted_events[0] @@ -51,24 +50,15 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo"), State()) def test_cannot_run_action(mycharm): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") with pytest.raises(InvalidEventError): - ctx.run(action, state=State()) - - -def test_cannot_run_action_event(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - action = Action("foo") - with pytest.raises(InvalidEventError): - ctx.run(action.event, state=State()) + ctx.run(ctx.on.action("foo"), state=State()) @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) @@ -82,10 +72,9 @@ def handle_evt(charm: CharmBase, evt): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(action, State()) + out = ctx.run_action(ctx.on.action("foo"), State()) assert out.results == res_value assert out.success is True @@ -104,9 +93,8 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(action, State()) + out = ctx.run_action(ctx.on.action("foo"), State()) assert out.failure == "failed becozz" assert out.logs == ["log1", "log2"] @@ -133,9 +121,8 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo") ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo"), State()) @pytest.mark.skipif( @@ -151,20 +138,19 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt - action = Action("foo", id=uuid) ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(action, State()) + ctx.run_action(ctx.on.action("foo", id=uuid), State()) def test_positional_arguments(): with pytest.raises(TypeError): - Action("foo", {}) + _Action("foo", {}) def test_default_arguments(): expected_id = next_action_id(update=False) name = "foo" - action = Action(name) + action = _Action(name) assert action.name == name assert action.params == {} assert action.id == expected_id diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 66d39f822..3f99ffd0b 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -4,7 +4,7 @@ from ops import ActiveStatus from ops.charm import CharmBase, CollectStatusEvent -from scenario import Action, Context, State +from scenario import Context, State from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager @@ -73,7 +73,7 @@ def test_context_manager(mycharm): def test_context_action_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS) - with ctx.action_manager(Action("do-x"), State()) as manager: + with ctx.action_manager(ctx.on.action("do-x"), State()) as manager: ao = manager.run() assert isinstance(ao, ActionOutput) assert ctx.emitted_events[0].handle.kind == "do_x_action" From df27d5790eb1cf0862f8e2c5ad4b22a87ff4d9aa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:21:30 +1200 Subject: [PATCH 515/546] Use Iterable as the container component type, so that any iterable of hashable objects can be passed, but still convert to frozenset underneath. (#160) --- docs/custom_conf.py | 1 + scenario/state.py | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/custom_conf.py b/docs/custom_conf.py index c7f2a9432..3e035474f 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -311,4 +311,5 @@ def _compute_navigation_tree(context): ('py:class', '_Event'), ('py:class', 'scenario.state._DCBase'), ('py:class', 'scenario.state._EntityStatus'), + ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] diff --git a/scenario/state.py b/scenario/state.py index 6e9e9c827..535479fbe 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -21,6 +21,7 @@ Final, FrozenSet, Generic, + Iterable, List, Literal, Optional, @@ -1221,9 +1222,9 @@ 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. @@ -1231,24 +1232,24 @@ class State(_max_posargs(0)): 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. @@ -1260,7 +1261,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.""" @@ -1317,9 +1318,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)) From d8c743b4bf124d1a0eb5024482d24ff85b8b37c7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 24 Jul 2024 17:29:52 +1200 Subject: [PATCH 516/546] feat: add support for Pebble check events (#151) * Add support for Pebble checks. Also update the support for Pebble notices to be aligned with the 7.x approach. --- README.md | 20 ++++++- pyproject.toml | 2 +- scenario/__init__.py | 2 + scenario/consistency_checker.py | 11 ++++ scenario/context.py | 26 +++++++++ scenario/mocking.py | 5 +- scenario/runtime.py | 3 ++ scenario/state.py | 80 +++++++++++++++++----------- tests/test_consistency_checker.py | 41 +++++++++++++++ tests/test_e2e/test_deferred.py | 4 +- tests/test_e2e/test_pebble.py | 87 +++++++++++++++++++++++++++++-- tox.ini | 1 - 12 files changed, 245 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 875243394..ca0b0dc62 100644 --- a/README.md +++ b/README.md @@ -691,7 +691,25 @@ notices = [ scenario.Notice(key="example.com/c"), ] container = scenario.Container("my-container", notices=notices) -ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[container])) +state = scenario.State(containers={container}) +ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state) +``` + +### Pebble Checks + +A Pebble plan can contain checks, and when those checks exceed the configured +failure threshold, or start succeeding again after, Juju will emit a +pebble-check-failed or pebble-check-recovered event. In order to simulate these +events, you need to add a `CheckInfo` to the container. Note that the status of the +check doesn't have to match the event being generated: by the time that Juju +sends a pebble-check-failed event the check might have started passing again. + +```python +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) +check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN) +container = scenario.Container("my-container", check_infos={check_info}) +state = scenario.State(containers={container}) +ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state) ``` ## Storage diff --git a/pyproject.toml b/pyproject.toml index 5ce0b9476..99f1be058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.12", + "ops>=2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/__init__.py b/scenario/__init__.py index 2f6471e75..bbd2c694b 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -7,6 +7,7 @@ Address, BindAddress, BlockedStatus, + CheckInfo, CloudCredential, CloudSpec, Container, @@ -37,6 +38,7 @@ __all__ = [ "ActionOutput", + "CheckInfo", "CloudCredential", "CloudSpec", "Context", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index 0b54ee43e..e319a1078 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -563,6 +563,9 @@ def check_containers_consistency( meta_containers = list(map(normalize_name, meta.get("containers", {}))) state_containers = [normalize_name(c.name) for c in state.containers] all_notices = {notice.id for c in state.containers for notice in c.notices} + all_checks = { + (c.name, check.name) for c in state.containers for check in c.check_infos + } errors = [] # it's fine if you have containers in meta that are not in state.containers (yet), but it's @@ -587,6 +590,14 @@ def check_containers_consistency( f"the event being processed concerns notice {event.notice!r}, but that " "notice is not in any of the containers present in the state.", ) + if ( + event.check_info + and (evt_container_name, event.check_info.name) not in all_checks + ): + errors.append( + f"the event being processed concerns check {event.check_info.name}, but that " + "check is not the {evt_container_name} container.", + ) # - a container in state.containers is not in meta.containers if diff := (set(state_containers).difference(set(meta_containers))): diff --git a/scenario/context.py b/scenario/context.py index af61eedfb..5c02c674c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -12,8 +12,10 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( + CheckInfo, Container, MetadataNotFoundError, + Notice, Secret, Storage, _Action, @@ -312,6 +314,30 @@ def storage_detaching(storage: Storage): def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) + @staticmethod + def pebble_custom_notice(container: Container, notice: Notice): + return _Event( + f"{container.name}_pebble_custom_notice", + container=container, + notice=notice, + ) + + @staticmethod + def pebble_check_failed(container: Container, info: CheckInfo): + return _Event( + f"{container.name}_pebble_check_failed", + container=container, + check_info=info, + ) + + @staticmethod + def pebble_check_recovered(container: Container, info: CheckInfo): + return _Event( + f"{container.name}_pebble_check_recovered", + container=container, + check_info=info, + ) + @staticmethod def action( name: str, diff --git a/scenario/mocking.py b/scenario/mocking.py index 94c567219..3f53deb2a 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -694,8 +694,9 @@ def __init__( self._root = container_root - # load any existing notices from the state + # load any existing notices and check information from the state self._notices: Dict[Tuple[str, str], pebble.Notice] = {} + self._check_infos: Dict[str, pebble.CheckInfo] = {} for container in state.containers: for notice in container.notices: if hasattr(notice.type, "value"): @@ -703,6 +704,8 @@ def __init__( else: notice_type = str(notice.type) self._notices[notice_type, notice.key] = notice._to_ops() + for check in container.check_infos: + self._check_infos[check.name] = check._to_ops() def get_plan(self) -> pebble.Plan: return self._container.plan diff --git a/scenario/runtime.py b/scenario/runtime.py index 97abe9219..2f739f8fc 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -271,6 +271,9 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): }, ) + if check_info := event.check_info: + env["JUJU_PEBBLE_CHECK_NAME"] = check_info.name + if storage := event.storage: env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"}) diff --git a/scenario/state.py b/scenario/state.py index 535479fbe..e97a29cca 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -91,6 +91,8 @@ } PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" +PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" +PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", @@ -770,18 +772,37 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) -class _BoundNotice(_max_posargs(0)): - notice: Notice - container: "Container" +class CheckInfo(_max_posargs(1)): + name: str + """Name of the check.""" - @property - def event(self): - """Sugar to generate a -pebble-custom-notice event for this notice.""" - suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX - return _Event( - path=normalize_name(self.container.name) + suffix, - container=self.container, - notice=self.notice, + level: Optional[pebble.CheckLevel] = None + """Level of the check.""" + + status: pebble.CheckStatus = pebble.CheckStatus.UP + """Status of the check. + + CheckStatus.UP means the check is healthy (the number of failures is less + than the threshold), CheckStatus.DOWN means the check is unhealthy + (the number of failures has reached the threshold). + """ + + failures: int = 0 + """Number of failures since the check last succeeded.""" + + threshold: int = 3 + """Failure threshold. + + This is how many consecutive failures for the check to be considered “down”. + """ + + def _to_ops(self) -> pebble.CheckInfo: + return pebble.CheckInfo( + name=self.name, + level=self.level, + status=self.status, + failures=self.failures, + threshold=self.threshold, ) @@ -862,6 +883,8 @@ class Container(_max_posargs(1)): notices: List[Notice] = dataclasses.field(default_factory=list) + check_infos: FrozenSet[CheckInfo] = frozenset() + def __hash__(self) -> int: return hash(self.name) @@ -927,23 +950,6 @@ def get_filesystem(self, ctx: "Context") -> Path: """ return ctx._get_container_root(self.name) - def get_notice( - self, - key: str, - notice_type: pebble.NoticeType = pebble.NoticeType.CUSTOM, - ) -> _BoundNotice: - """Get a Pebble notice by key and type. - - Raises: - KeyError: if the notice is not found. - """ - for notice in self.notices: - if notice.key == key and notice.type == notice_type: - return _BoundNotice(notice=notice, container=self) - raise KeyError( - f"{self.name} does not have a notice with key {key} and type {notice_type}", - ) - _RawStatusLiteral = Literal[ "waiting", @@ -1631,6 +1637,10 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload + if s.endswith(PEBBLE_CHECK_FAILED_EVENT_SUFFIX): + return PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload + if s.endswith(PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): + return PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload if s in BUILTIN_EVENTS: return "", _EventType.builtin @@ -1670,6 +1680,9 @@ class Event: notice: Optional[Notice] = None """If this is a Pebble notice event, the notice it refers to.""" + check_info: Optional[CheckInfo] = None + """If this is a Pebble check event, the check info it provides.""" + action: Optional["_Action"] = None """If this is an action event, the :class:`Action` it refers to.""" @@ -1787,6 +1800,8 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: "notice_type": notice_type, }, ) + elif self.check_info: + snapshot_data["check_name"] = self.check_info.name elif self._is_relation_event: # this is a RelationEvent. @@ -1860,8 +1875,15 @@ def deferred( relation: Optional["Relation"] = None, container: Optional["Container"] = None, notice: Optional["Notice"] = None, + check_info: Optional["CheckInfo"] = None, ): """Construct a DeferredEvent from an Event or an event name.""" if isinstance(event, str): - event = _Event(event, relation=relation, container=container, notice=notice) + event = _Event( + event, + relation=relation, + container=container, + notice=notice, + check_info=check_info, + ) return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 2e2efb9e7..1b97b94bb 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -8,6 +8,7 @@ from scenario.runtime import InconsistentScenarioError from scenario.state import ( RELATION_EVENTS_SUFFIX, + CheckInfo, CloudCredential, CloudSpec, Container, @@ -85,6 +86,46 @@ def test_workload_event_without_container(): _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice), _CharmSpec(MyCharm, {"containers": {"foo": {}}}), ) + check = CheckInfo("http-check") + assert_consistent( + State(containers={Container("foo", check_infos={check})}), + _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo")}), + _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_consistent( + State(containers={Container("foo", check_infos={check})}), + _Event( + "foo-pebble-check-recovered", container=Container("foo"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo")}), + _Event( + "foo-pebble-check-recovered", container=Container("foo"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + # Ensure the check is in the correct container. + assert_inconsistent( + State(containers={Container("foo", check_infos={check}), Container("bar")}), + _Event( + "foo-pebble-check-recovered", container=Container("bar"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}), + ) + assert_inconsistent( + State(containers={Container("foo", check_infos={check}), Container("bar")}), + _Event( + "bar-pebble-check-recovered", container=Container("bar"), check_info=check + ), + _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}), + ) def test_container_meta_mismatch(): diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index f988dcc5a..2b21dd90a 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -102,7 +102,9 @@ def test_deferred_workload_evt(mycharm): def test_deferred_notice_evt(mycharm): notice = Notice(key="example.com/bar") ctr = Container("foo", notices=[notice]) - evt1 = ctr.get_notice("example.com/bar").event.deferred(handler=mycharm._on_event) + evt1 = _Event("foo_pebble_custom_notice", notice=notice, container=ctr).deferred( + handler=mycharm._on_event + ) evt2 = deferred( event="foo_pebble_custom_notice", handler=mycharm._on_event, diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index 08acebc3a..da40cf5d5 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -10,7 +10,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import Container, ExecOutput, Mount, Notice, State +from scenario.state import CheckInfo, Container, ExecOutput, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -381,7 +381,9 @@ def test_pebble_custom_notice(charm_cls): state = State(containers=[container]) ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) - with ctx.manager(container.get_notice("example.com/baz").event, state) as mgr: + with ctx.manager( + ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state + ) as mgr: container = mgr.charm.unit.get_container("foo") assert container.get_notices() == [n._to_ops() for n in notices] @@ -437,4 +439,83 @@ def _on_custom_notice(self, event: PebbleCustomNoticeEvent): ) state = State(containers=[container]) ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) - ctx.run(container.get_notice(key).event, state) + ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state) + + +def test_pebble_check_failed(): + infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_pebble_check_failed, self._on_check_failed) + + def _on_check_failed(self, event): + infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN) + container = Container("foo", check_infos={check}) + state = State(containers={container}) + ctx.run(ctx.on.pebble_check_failed(container, check), state=state) + assert len(infos) == 1 + assert infos[0].name == "http-check" + assert infos[0].status == pebble.CheckStatus.DOWN + assert infos[0].failures == 7 + + +def test_pebble_check_recovered(): + infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe( + self.on.foo_pebble_check_recovered, self._on_check_recovered + ) + + def _on_check_recovered(self, event): + infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + check = CheckInfo("http-check") + container = Container("foo", check_infos={check}) + state = State(containers={container}) + ctx.run(ctx.on.pebble_check_recovered(container, check), state=state) + assert len(infos) == 1 + assert infos[0].name == "http-check" + assert infos[0].status == pebble.CheckStatus.UP + assert infos[0].failures == 0 + + +def test_pebble_check_failed_two_containers(): + foo_infos = [] + bar_infos = [] + + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe( + self.on.foo_pebble_check_failed, self._on_foo_check_failed + ) + framework.observe( + self.on.bar_pebble_check_failed, self._on_bar_check_failed + ) + + def _on_foo_check_failed(self, event): + foo_infos.append(event.info) + + def _on_bar_check_failed(self, event): + bar_infos.append(event.info) + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}, "bar": {}}}) + check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN) + foo_container = Container("foo", check_infos={check}) + bar_container = Container("bar", check_infos={check}) + state = State(containers={foo_container, bar_container}) + ctx.run(ctx.on.pebble_check_failed(foo_container, check), state=state) + assert len(foo_infos) == 1 + assert foo_infos[0].name == "http-check" + assert foo_infos[0].status == pebble.CheckStatus.DOWN + assert foo_infos[0].failures == 7 + assert len(bar_infos) == 0 diff --git a/tox.ini b/tox.ini index 317a3b149..9ecb3a329 100644 --- a/tox.ini +++ b/tox.ini @@ -91,7 +91,6 @@ allowlist_externals = cp deps = -e . - ops pytest pytest-markdown-docs commands = From d5b0de632708c855a8a9ffa45c59accd1aa4490b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 25 Jul 2024 21:06:07 +1200 Subject: [PATCH 517/546] feat!: move default network into Network() (#163) This moves the default Network into the `Network` initialisation, rather than as a separate classmethod that provides a `Network` object. This is more consistent with the rest of the Scenario objects, which try to have useful defaults where possible. For the most simple case: ```python # Previously network = Network.default() # Now network = Network("default") # The name is needed because of the `State` change elsewhere ``` To override elements of the default is a little bit more work, particularly if it's in the nested `Address` object, but it doesn't seem too bad: ```python # Previously network = Network.default(private_address="129.0.2.1") # Now network = Network("foo", [BindAddress([Address("129.0.2.1")])]) ``` --- README.md | 4 +-- scenario/mocking.py | 2 +- scenario/state.py | 48 +++++++++---------------------- tests/test_consistency_checker.py | 6 ++-- tests/test_e2e/test_network.py | 13 +++++++-- tests/test_e2e/test_state.py | 2 +- 6 files changed, 30 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index ca0b0dc62..3052ecf6a 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2) Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is. If your charm has a relation `"foo"` (defined in its metadata), then the charm will be able at runtime to do `self.model.get_binding("foo").network`. -The network you'll get by doing so is heavily defaulted (see `state.Network.default`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. +The network you'll get by doing so is heavily defaulted (see `state.Network`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario. @@ -496,7 +496,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - scenario.Network.default("foo", private_address='192.0.2.1') + scenario.Network("foo", [BindAddress([Address('192.0.2.1')])]) }) ``` diff --git a/scenario/mocking.py b/scenario/mocking.py index 3f53deb2a..5879676ff 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -320,7 +320,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): try: network = self._state.get_network(binding_name) except KeyError: - network = Network.default("default") # The name is not used in the output. + network = Network("default") # The name is not used in the output. return network.hook_tool_output_fmt() # setter methods: these can mutate the state. diff --git a/scenario/state.py b/scenario/state.py index e97a29cca..9bf219397 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -340,11 +340,11 @@ def normalize_name(s: str): class Address(_max_posargs(1)): """An address in a Juju network space.""" - hostname: str - """A host name that maps to the address in :attr:`value`.""" value: str """The IP address in the space.""" - cidr: str + hostname: str = "" + """A host name that maps to the address in :attr:`value`.""" + cidr: str = "" """The CIDR of the address in :attr:`value`.""" @property @@ -379,11 +379,17 @@ def hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) -class Network(_max_posargs(1)): +class Network(_max_posargs(2)): binding_name: str - bind_addresses: List[BindAddress] - ingress_addresses: List[str] - egress_subnets: List[str] + bind_addresses: List[BindAddress] = dataclasses.field( + default_factory=lambda: [BindAddress([Address("192.0.2.0")])], + ) + ingress_addresses: List[str] = dataclasses.field( + default_factory=lambda: ["192.0.2.0"], + ) + egress_subnets: List[str] = dataclasses.field( + default_factory=lambda: ["192.0.2.0/24"], + ) def __hash__(self) -> int: return hash(self.binding_name) @@ -396,34 +402,6 @@ def hook_tool_output_fmt(self): "ingress-addresses": self.ingress_addresses, } - @classmethod - def default( - cls, - binding_name: str, - private_address: str = "192.0.2.0", - hostname: str = "", - cidr: str = "", - interface_name: str = "", - mac_address: Optional[str] = None, - egress_subnets=("192.0.2.0/24",), - ingress_addresses=("192.0.2.0",), - ) -> "Network": - """Helper to create a minimal, heavily defaulted Network.""" - return cls( - binding_name=binding_name, - bind_addresses=[ - BindAddress( - interface_name=interface_name, - mac_address=mac_address, - addresses=[ - Address(hostname=hostname, value=private_address, cidr=cidr), - ], - ), - ], - egress_subnets=list(egress_subnets), - ingress_addresses=list(ingress_addresses), - ) - _next_relation_id_counter = 1 diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 1b97b94bb..5695e5b95 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -634,7 +634,7 @@ def test_resource_states(): def test_networks_consistency(): assert_inconsistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -643,7 +643,7 @@ def test_networks_consistency(): ) assert_inconsistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, @@ -656,7 +656,7 @@ def test_networks_consistency(): ) assert_consistent( - State(networks={Network.default("foo")}), + State(networks={Network("foo")}), _Event("start"), _CharmSpec( MyCharm, diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 47302698c..761e9c710 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -4,7 +4,14 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Network, Relation, State, SubordinateRelation +from scenario.state import ( + Address, + BindAddress, + Network, + Relation, + State, + SubordinateRelation, +) @pytest.fixture(scope="function") @@ -51,7 +58,7 @@ def test_ip_get(mycharm): id=1, ), ], - networks={Network.default("foo", private_address="4.4.4.4")}, + networks={Network("foo", [BindAddress([Address("4.4.4.4")])])}, ), ) as mgr: # we have a network for the relation @@ -113,7 +120,7 @@ def test_no_relation_error(mycharm): id=1, ), ], - networks={Network.default("bar")}, + networks={Network("bar")}, ), ) as mgr: with pytest.raises(RelationNotFoundError): diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index aaa3246fa..325cda667 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -253,7 +253,7 @@ def pre_event(charm: CharmBase): (Resource, (1,)), (Address, (0, 2)), (BindAddress, (0, 2)), - (Network, (1, 2)), + (Network, (0, 3)), ], ) def test_positional_arguments(klass, num_args): From 8023e85c19c1d2f50c55c665a9db9bd93c2572d0 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 7 Aug 2024 15:37:04 +1200 Subject: [PATCH 518/546] feat!: simplify testing secret management (#168) Adjust testing secret management to be simpler - in particular, to avoid needing to manually manage revisions, which should be transparent to charms. Rather than `Secret`s having a dictionary of revision:content-dict, they have two content dictionaries, `tracked_content` (required) and `latest_content` (set to the same value as `tracked_content` if not provided). This matches what charms can see: only either the tracked revision or the latest revision. A new attribute, `removed_secret_revisions` is added to `Context` to track removal of secret revisions in the `secret-remove` and `secret-expired` hooks. Calling `secret-remove --revision` in those hooks must be done, so should be tested, but don't actually change the state that's visible to the charm (for `secret-remove` the whole point is that the secret revision is no longer visible to anyone, so it should be removed). Tests could mock the `secret_remove` method, but it seems cleaner to provide a mechanism, given that this should be part of any charm that uses secrets. Charms should only remove specific revisions in the `secret-remove` and `secret-expired` hooks, and only remove the revision that's provided, but it is possible to remove arbitrary revisions. Modelling this is complicated (the state of the Juju secret is a mess afterwards) and it is always a mistake, so rather than trying to make the model fit the bad code, an exception is raised instead. A warning is logged if a secret revision is created that is the same as the existing revision - in the latest Juju this is a no-op, but in earlier version it's a problem, and either way it's something that the charm should avoid if possible. --------- Co-authored-by: PietroPasotti --- README.md | 69 ++++-- scenario/context.py | 54 +---- scenario/mocking.py | 71 +++--- scenario/state.py | 51 +++-- tests/test_consistency_checker.py | 18 +- tests/test_context_on.py | 18 +- tests/test_e2e/test_secrets.py | 345 ++++++++++++++++-------------- 7 files changed, 327 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index 3052ecf6a..982a21ffb 100644 --- a/README.md +++ b/README.md @@ -705,9 +705,9 @@ check doesn't have to match the event being generated: by the time that Juju sends a pebble-check-failed event the check might have started passing again. ```python -ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}}) +ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my_container": {}}}) check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN) -container = scenario.Container("my-container", check_infos={check_info}) +container = scenario.Container("my_container", check_infos={check_info}) state = scenario.State(containers={container}) ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state) ``` @@ -804,22 +804,27 @@ Scenario has secrets. Here's how you use them. state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'public'}}, - id='foo', - ), - }, + tracked_content={'key': 'public'}, + latest_content={'key': 'public', 'cert': 'private'}, + ) + } ) ``` -The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping -from revision numbers (integers) to a `str:str` dict representing the payload of the revision. +The only mandatory arguments to Secret is the `tracked_content` dict: a `str:str` +mapping representing the content of the revision. If there is a newer revision +of the content than the one the unit that's handling the event is tracking, then +`latest_content` should also be provided - if it's not, then Scenario assumes +that `latest_content` is the `tracked_content`. If there are other revisions of +the content, simply don't include them: the unit has no way of knowing about +these. There are three cases: - the secret is owned by this app but not this unit, in which case this charm can only manage it if we are the leader - the secret is owned by this unit, in which case this charm can always manage it (leader or not) -- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it +- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it (this includes user secrets) -Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights. +Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm' (or a user), and that other has granted us view rights. The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists. @@ -830,32 +835,52 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python +rel = scenario.Relation("web") state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'private'}}, - id='foo', + {'key': 'private'}, owner='unit', # or 'app' - remote_grants={0: {"remote"}} - # the secret owner has granted access to the "remote" app over some relation with ID 0 - ), - }, + # The secret owner has granted access to the "remote" app over some relation: + remote_grants={rel.id: {"remote"}} + ) + } ) ``` -To specify a secret owned by some other application and give this unit (or app) access to it: +To specify a secret owned by some other application, or a user secret, and give this unit (or app) access to it: ```python state = scenario.State( secrets={ scenario.Secret( - {0: {'key': 'public'}}, - id='foo', + {'key': 'public'}, # owner=None, which is the default - revision=0, # the revision that this unit (or app) is currently tracking - ), - }, + ) + } +) +``` + +When handling the `secret-expired` and `secret-remove` events, the charm must remove the specified revision of the secret. For `secret-remove`, the revision will no longer be in the `State`, because it's no longer in use (which is why the `secret-remove` event was triggered). To ensure that the charm is removing the secret, check the context for the history of secret removal: + +```python +class SecretCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + event.secret.remove_revision(event.revision) + + +ctx = scenario.Context(SecretCharm, meta={"name": "foo"}) +secret = scenario.Secret({"password": "xxxxxxxx"}, owner="app") +old_revision = 42 +state = ctx.run( + ctx.on.secret_remove(secret, revision=old_revision), + scenario.State(leader=True, secrets={secret}) ) +assert ctx.removed_secret_revisions == [old_revision] ``` ## StoredState diff --git a/scenario/context.py b/scenario/context.py index 5c02c674c..359205f7a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -376,6 +376,7 @@ class Context: - :attr:`app_status_history`: record of the app statuses the charm has set - :attr:`unit_status_history`: record of the unit statuses the charm has set - :attr:`workload_version_history`: record of the workload versions the charm has set + - :attr:`removed_secret_revisions`: record of the secret revisions the charm has removed - :attr:`emitted_events`: record of the events (including custom) that the charm has processed This allows you to write assertions not only on the output state, but also, to some @@ -441,48 +442,6 @@ def __init__( ): """Represents a simulated charm's execution context. - It is the main entry point to running a scenario test. - - 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. - 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: - - ``juju_log``: record of what the charm has sent to juju-log - - ``app_status_history``: record of the app statuses the charm has set - - ``unit_status_history``: record of the unit statuses the charm has set - - ``workload_version_history``: record of the workload versions the charm has set - - ``emitted_events``: record of the events (including custom ones) that the charm has - processed - - This allows you to write assertions not only on the output state, but also, to some - extent, on the path the charm took to get there. - - A typical scenario test will look like: - - >>> from scenario import Context, State - >>> from ops import ActiveStatus - >>> from charm import MyCharm, MyCustomEvent # noqa - >>> - >>> def test_foo(): - >>> # Arrange: set the context up - >>> c = Context(MyCharm) - >>> # Act: prepare the state and emit an event - >>> state_out = c.run(c.update_status(), State()) - >>> # Assert: verify the output state is what you think it should be - >>> assert state_out.unit_status == ActiveStatus('foobar') - >>> # Assert: verify the Context contains what you think it should - >>> assert len(c.emitted_events) == 4 - >>> assert isinstance(c.emitted_events[3], MyCustomEvent) - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). If none is provided, we will search for a ``metadata.yaml`` file in the charm root. @@ -497,16 +456,6 @@ def __init__( :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with ``juju trust``). Defaults to False. :arg charm_root: virtual charm root the charm will be executed with. - If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the - execution cwd, you need to use this. E.g.: - - >>> import scenario - >>> import tempfile - >>> virtual_root = tempfile.TemporaryDirectory() - >>> local_path = Path(local_path.name) - >>> (local_path / 'foo').mkdir() - >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - >>> scenario.Context(... charm_root=virtual_root).run(...) """ if not any((meta, actions, config)): @@ -551,6 +500,7 @@ def __init__( self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] self.workload_version_history: List[str] = [] + self.removed_secret_revisions: List[int] = [] self.emitted_events: List[EventBase] = [] self.requested_storages: Dict[str, int] = {} diff --git a/scenario/mocking.py b/scenario/mocking.py index 5879676ff..94e706e90 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -2,7 +2,6 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import datetime -import random import shutil from io import StringIO from pathlib import Path @@ -202,21 +201,16 @@ def _get_secret(self, id=None, label=None): return secrets[0] elif label: - secrets = [s for s in self._state.secrets if s.label == label] - if not secrets: - raise SecretNotFoundError(label) - return secrets[0] + try: + return self._state.get_secret(label=label) + except KeyError: + raise SecretNotFoundError(label) from None else: # if all goes well, this should never be reached. ops.model.Secret will check upon # instantiation that either an id or a label are set, and raise a TypeError if not. raise RuntimeError("need id or label.") - @staticmethod - def _generate_secret_id(): - id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)])) - return f"secret:{id}" - def _check_app_data_access(self, is_app: bool): if not isinstance(is_app, bool): raise TypeError("is_app parameter to relation_get must be a boolean") @@ -371,10 +365,8 @@ def secret_add( ) -> str: from scenario.state import Secret - secret_id = self._generate_secret_id() secret = Secret( - id=secret_id, - contents={0: content}, + content, label=label, description=description, expire=expire, @@ -384,7 +376,7 @@ def secret_add( secrets = set(self._state.secrets) secrets.add(secret) self._state._update_secrets(frozenset(secrets)) - return secret_id + return secret.id def _check_can_manage_secret( self, @@ -414,19 +406,19 @@ def secret_get( secret = self._get_secret(id, label) juju_version = self._context.juju_version if not (juju_version == "3.1.7" or juju_version >= "3.3.1"): - # in this medieval juju chapter, + # In this medieval Juju chapter, # secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 if secret.owner is not None: refresh = True - revision = secret.revision if peek or refresh: - revision = max(secret.contents.keys()) if refresh: - secret._set_revision(revision) + secret._track_latest_revision() + assert secret.latest_content is not None + return secret.latest_content - return secret.contents[revision] + return secret.tracked_content def secret_info_get( self, @@ -442,7 +434,7 @@ def secret_info_get( return SecretInfo( id=secret.id, label=secret.label, - revision=max(secret.contents), + revision=secret._latest_revision, expires=secret.expire, rotation=secret.rotate, rotates=None, # not implemented yet. @@ -461,6 +453,15 @@ def secret_set( secret = self._get_secret(id, label) self._check_can_manage_secret(secret) + if content == secret.latest_content: + # In Juju 3.6 and higher, this is a no-op, but it's good to warn + # charmers if they are doing this, because it's not generally good + # practice. + # https://bugs.launchpad.net/juju/+bug/2069238 + logger.warning( + f"secret {id} contents set to the existing value: new revision not needed", + ) + secret._update_metadata( content=content, label=label, @@ -499,10 +500,32 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None): secret = self._get_secret(id) self._check_can_manage_secret(secret) - if revision: - del secret.contents[revision] - else: - secret.contents.clear() + # Removing all revisions means that the secret is removed, so is no + # longer in the state. + if revision is None: + secrets = set(self._state.secrets) + secrets.remove(secret) + self._state._update_secrets(frozenset(secrets)) + return + + # Juju does not prevent removing the tracked or latest revision, but it + # is always a mistake to do this. Rather than having the state model a + # secret where the tracked/latest revision cannot be retrieved but the + # secret still exists, we raise instead, so that charms know that there + # is a problem with their code. + if revision in (secret._tracked_revision, secret._latest_revision): + raise ValueError( + "Charms should not remove the latest revision of a secret. " + "Add a new revision with `set_content()` instead, and the previous " + "revision will be cleaned up by the secret owner when no longer in use.", + ) + + # For all other revisions, the content is not visible to the charm + # (this is as designed: the secret is being removed, so it should no + # longer be in use). That means that the state does not need to be + # modified - however, unit tests should be able to verify that the remove call was + # executed, so we track that in a history list in the context. + self._context.removed_secret_revisions.append(revision) def relation_remote_app_name( self, diff --git a/scenario/state.py b/scenario/state.py index 9bf219397..5de1545c0 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -7,7 +7,9 @@ import dataclasses import datetime import inspect +import random import re +import string from collections import namedtuple from enum import Enum from itertools import chain @@ -270,24 +272,26 @@ def _to_ops(self) -> CloudSpec_Ops: ) +def _generate_secret_id(): + # This doesn't account for collisions, but the odds are so low that it + # should not be possible in any realistic test run. + secret_id = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(20) + ) + return f"secret:{secret_id}" + + @dataclasses.dataclass(frozen=True) class Secret(_max_posargs(1)): - # mapping from revision IDs to each revision's contents - contents: Dict[int, "RawSecretRevisionContents"] + tracked_content: "RawSecretRevisionContents" + latest_content: Optional["RawSecretRevisionContents"] = None - id: str - # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized - # secret id (`secret:` prefix) - # but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to - # add the prefix manually every time seems painful as well. + id: str = dataclasses.field(default_factory=_generate_secret_id) # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None - # what revision is currently tracked by this charm. Only meaningful if owner=False - revision: int = 0 - # mapping from relation IDs to remote unit/apps to which this secret has been granted. # Only applicable if owner remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict) @@ -297,13 +301,25 @@ class Secret(_max_posargs(1)): expire: Optional[datetime.datetime] = None rotate: Optional[SecretRotate] = None + # what revision is currently tracked by this charm. Only meaningful if owner=False + _tracked_revision: int = 1 + + # what revision is the latest for this secret. + _latest_revision: int = 1 + def __hash__(self) -> int: return hash(self.id) - def _set_revision(self, revision: int): - """Set a new tracked revision.""" + def __post_init__(self): + if self.latest_content is None: + # bypass frozen dataclass + object.__setattr__(self, "latest_content", self.tracked_content) + + def _track_latest_revision(self): + """Set the current revision to the tracked revision.""" # bypass frozen dataclass - object.__setattr__(self, "revision", revision) + object.__setattr__(self, "_tracked_revision", self._latest_revision) + object.__setattr__(self, "tracked_content", self.latest_content) def _update_metadata( self, @@ -314,11 +330,12 @@ def _update_metadata( rotate: Optional[SecretRotate] = None, ): """Update the metadata.""" - revision = max(self.contents.keys()) - if content: - self.contents[revision + 1] = content - # bypass frozen dataclass + object.__setattr__(self, "_latest_revision", self._latest_revision + 1) + # TODO: if this is done twice in the same hook, then Juju ignores the + # first call, it doesn't continue to update like this does. + if content: + object.__setattr__(self, "latest_content", content) if label: object.__setattr__(self, "label", label) if description: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 5695e5b95..3db6f8e84 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -303,7 +303,7 @@ def test_config_secret_old_juju(juju_version): @pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99")) def test_secrets_jujuv_bad(bad_v): - secret = Secret("secret:foo", {0: {"a": "b"}}) + secret = Secret({"a": "b"}) assert_inconsistent( State(secrets={secret}), _Event("bar"), @@ -312,14 +312,14 @@ def test_secrets_jujuv_bad(bad_v): ) assert_inconsistent( State(secrets={secret}), - secret.changed_event, + _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), bad_v, ) assert_inconsistent( State(), - secret.changed_event, + _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), bad_v, ) @@ -328,7 +328,7 @@ def test_secrets_jujuv_bad(bad_v): @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100")) def test_secrets_jujuv_bad(good_v): assert_consistent( - State(secrets={Secret(id="secret:foo", contents={0: {"a": "b"}})}), + State(secrets={Secret({"a": "b"})}), _Event("bar"), _CharmSpec(MyCharm, {}), good_v, @@ -336,14 +336,14 @@ def test_secrets_jujuv_bad(good_v): def test_secret_not_in_state(): - secret = Secret(id="secret:foo", contents={"a": "b"}) + secret = Secret({"a": "b"}) assert_inconsistent( State(), _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), ) assert_consistent( - State(secrets=[secret]), + State(secrets={secret}), _Event("secret_changed", secret=secret), _CharmSpec(MyCharm, {}), ) @@ -723,11 +723,7 @@ def test_storedstate_consistency(): ) assert_inconsistent( State( - stored_states={ - StoredState( - owner_path=None, content={"secret": Secret(id="foo", contents={})} - ) - } + stored_states={StoredState(owner_path=None, content={"secret": Secret({})})} ), _Event("start"), _CharmSpec( diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 151bc3033..8ddbf4d49 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -81,10 +81,8 @@ def test_simple_events(event_name, event_kind): ) def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) - secret = scenario.Secret( - id="secret:123", contents={0: {"password": "xxxx"}}, owner=owner - ) - state_in = scenario.State(secrets=[secret]) + secret = scenario.Secret({"password": "xxxx"}, owner=owner) + state_in = scenario.State(secrets={secret}) # These look like: # ctx.run(ctx.on.secret_changed(secret=secret), state) # The secret must always be passed because the same event name is used for @@ -114,11 +112,11 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): def test_revision_secret_events(event_name, event_kind): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - id="secret:123", - contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + tracked_content={"password": "yyyy"}, + latest_content={"password": "xxxx"}, owner="app", ) - state_in = scenario.State(secrets=[secret]) + state_in = scenario.State(secrets={secret}) # These look like: # ctx.run(ctx.on.secret_expired(secret=secret, revision=revision), state) # The secret and revision must always be passed because the same event name @@ -137,11 +135,11 @@ def test_revision_secret_events(event_name, event_kind): def test_revision_secret_events_as_positional_arg(event_name): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( - id="secret:123", - contents={42: {"password": "yyyy"}, 43: {"password": "xxxx"}}, + tracked_content={"password": "yyyy"}, + latest_content={"password": "xxxx"}, owner=None, ) - state_in = scenario.State(secrets=[secret]) + state_in = scenario.State(secrets={secret}) with pytest.raises(TypeError): ctx.run(getattr(ctx.on, event_name)(secret, 42), state_in) diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index a9a3697e4..710efd613 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -1,5 +1,4 @@ import datetime -import warnings import pytest from ops import ( @@ -42,97 +41,91 @@ def test_get_secret_no_secret(mycharm): assert mgr.charm.model.get_secret(label="foo") -def test_get_secret(mycharm): +@pytest.mark.parametrize("owner", ("app", "unit")) +def test_get_secret(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret({"a": "b"}, owner=owner) with ctx.manager( - state=State(secrets={Secret(id="foo", contents={0: {"a": "b"}})}), + state=State(secrets={secret}), event=ctx.on.update_status(), ) as mgr: - assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b" + assert mgr.charm.model.get_secret(id=secret.id).get_content()["a"] == "b" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_get_refresh(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + owner=owner, + ) with ctx.manager( ctx.on.update_status(), - State( - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ) - } - ), + State(secrets={secret}), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) @pytest.mark.parametrize("app", (True, False)) def test_get_secret_nonowner_peek_update(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + ) with ctx.manager( ctx.on.update_status(), State( leader=app, - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - ), - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c" + # Verify that the peek has not refreshed: + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" - assert charm.model.get_secret(id="foo").get_content()["a"] == "c" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "c" @pytest.mark.parametrize("owner", ("app", "unit")) def test_get_secret_owner_peek_update(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + tracked_content={"a": "b"}, + latest_content={"a": "c"}, + owner=owner, + ) with ctx.manager( ctx.on.update_status(), State( - secrets={ - Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, - owner=owner, - ) - } + secrets={secret}, ), ) as mgr: charm = mgr.charm - assert charm.model.get_secret(id="foo").get_content()["a"] == "b" - assert charm.model.get_secret(id="foo").peek_content()["a"] == "c" - assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c" + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c" + # Verify that the peek has not refreshed: + assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b" + assert ( + charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c" + ) @pytest.mark.parametrize("owner", ("app", "unit")) def test_secret_changed_owner_evt_fails(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, + tracked_content={"a": "b"}, + latest_content={"a": "c"}, owner=owner, ) with pytest.raises(ValueError): @@ -150,11 +143,8 @@ def test_secret_changed_owner_evt_fails(mycharm, owner): def test_consumer_events_failures(mycharm, evt_suffix, revision): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret( - id="foo", - contents={ - 0: {"a": "b"}, - 1: {"a": "c"}, - }, + tracked_content={"a": "b"}, + latest_content={"a": "c"}, ) kwargs = {"secret": secret} if revision is not None: @@ -178,15 +168,15 @@ def test_add(mycharm, app): assert mgr.output.secrets secret = mgr.output.get_secret(label="mylabel") - assert secret.contents[0] == {"foo": "bar"} + assert secret.latest_content == secret.tracked_content == {"foo": "bar"} assert secret.label == "mylabel" def test_set_legacy_behaviour(mycharm): # in juju < 3.1.7, secret owners always used to track the latest revision. # ref: https://bugs.launchpad.net/juju/+bug/2037120 - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -211,26 +201,18 @@ def test_set_legacy_behaviour(mycharm): == rev2 ) - secret.set_content(rev3) state_out = mgr.run() - secret: ops_Secret = charm.model.get_secret(label="mylabel") - assert ( - secret.get_content() - == secret.peek_content() - == secret.get_content(refresh=True) - == rev3 - ) - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) def test_set(mycharm): - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}) + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -244,25 +226,25 @@ def test_set(mycharm): == rev1 ) + # TODO: if this is done in the same event hook, it's more complicated + # than this. Figure out what we should do here. + # Also the next test, for Juju 3.3 secret.set_content(rev2) assert secret.get_content() == rev1 assert secret.peek_content() == secret.get_content(refresh=True) == rev2 - secret.set_content(rev3) state_out = mgr.run() - assert secret.get_content() == rev2 - assert secret.peek_content() == secret.get_content(refresh=True) == rev3 - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) def test_set_juju33(mycharm): - rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"} ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") + rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} with ctx.manager( ctx.on.update_status(), State(), @@ -276,44 +258,36 @@ def test_set_juju33(mycharm): assert secret.peek_content() == rev2 assert secret.get_content(refresh=True) == rev2 - secret.set_content(rev3) state_out = mgr.run() - assert secret.get_content() == rev2 - assert secret.peek_content() == rev3 - assert secret.get_content(refresh=True) == rev3 - assert state_out.get_secret(label="mylabel").contents == { - 0: rev1, - 1: rev2, - 2: rev3, - } + assert ( + state_out.get_secret(label="mylabel").tracked_content + == state_out.get_secret(label="mylabel").latest_content + == rev2 + ) @pytest.mark.parametrize("app", (True, False)) def test_meta(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + owner="app" if app else "unit", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) with ctx.manager( ctx.on.update_status(), State( leader=True, - secrets={ - Secret( - owner="app" if app else "unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm assert charm.model.get_secret(label="mylabel") - secret = charm.model.get_secret(id="foo") + secret = charm.model.get_secret(id=secret.id) info = secret.get_info() assert secret.label is None @@ -332,32 +306,27 @@ def test_secret_permission_model(mycharm, leader, owner): ) ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + label="mylabel", + owner=owner, + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) + secret_id = secret.id with ctx.manager( ctx.on.update_status(), State( leader=leader, - secrets={ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - owner=owner, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: - secret = mgr.charm.model.get_secret(id="foo") + # can always view + secret: ops_Secret = mgr.charm.model.get_secret(id=secret_id) assert secret.get_content()["a"] == "b" assert secret.peek_content() assert secret.get_content(refresh=True) - # can always view - secret: ops_Secret = mgr.charm.model.get_secret(id="foo") - if expect_manage: assert secret.get_content() assert secret.peek_content() @@ -385,22 +354,18 @@ def test_grant(mycharm, app): ctx = Context( mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}} ) + secret = Secret( + {"a": "b"}, + owner="unit", + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) with ctx.manager( ctx.on.update_status(), State( relations=[Relation("foo", "remote")], - secrets={ - Secret( - owner="unit", - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: charm = mgr.charm @@ -418,19 +383,15 @@ def test_update_metadata(mycharm): exp = datetime.datetime(2050, 12, 12) ctx = Context(mycharm, meta={"name": "local"}) + secret = Secret( + {"a": "b"}, + owner="unit", + label="mylabel", + ) with ctx.manager( ctx.on.update_status(), State( - secrets={ - Secret( - owner="unit", - id="foo", - label="mylabel", - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), ) as mgr: secret = mgr.charm.model.get_secret(label="mylabel") @@ -462,7 +423,7 @@ def _on_start(self, _): secret = self.unit.add_secret({"foo": "bar"}) secret.grant(self.model.relations["bar"][0]) - state = State(leader=leader, relations=[Relation("bar")]) + state = State(leader=leader, relations={Relation("bar")}) ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} ) @@ -470,29 +431,26 @@ def _on_start(self, _): def test_grant_nonowner(mycharm): - def post_event(charm: CharmBase): - secret = charm.model.get_secret(id="foo") + secret = Secret( + {"a": "b"}, + label="mylabel", + description="foobarbaz", + rotate=SecretRotate.HOURLY, + ) + secret_id = secret.id + def post_event(charm: CharmBase): + secret = charm.model.get_secret(id=secret_id) secret = charm.model.get_secret(label="mylabel") foo = charm.model.get_relation("foo") with pytest.raises(ModelError): secret.grant(relation=foo) - out = trigger( + trigger( State( relations={Relation("foo", "remote")}, - secrets={ - Secret( - id="foo", - label="mylabel", - description="foobarbaz", - rotate=SecretRotate.HOURLY, - contents={ - 0: {"a": "b"}, - }, - ) - }, + secrets={secret}, ), "update_status", mycharm, @@ -503,8 +461,7 @@ def post_event(charm: CharmBase): def test_add_grant_revoke_remove(): class GrantingCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) + pass ctx = Context( GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}} @@ -543,7 +500,71 @@ def __init__(self, *args): secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() - assert not mgr.output.get_secret(label="mylabel").contents # secret wiped + with pytest.raises(KeyError): + mgr.output.get_secret(label="mylabel") + + +def test_secret_removed_event(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"a": "b"}, owner="app") + old_revision = 42 + state = ctx.run( + ctx.on.secret_remove(secret, revision=old_revision), + State(leader=True, secrets={secret}), + ) + assert secret in state.secrets + assert ctx.removed_secret_revisions == [old_revision] + + +def test_secret_expired_event(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_expired, self._on_secret_expired) + + def _on_secret_expired(self, event): + event.secret.set_content({"password": "newpass"}) + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"password": "oldpass"}, owner="app") + old_revision = 42 + state = ctx.run( + ctx.on.secret_expired(secret, revision=old_revision), + State(leader=True, secrets={secret}), + ) + assert state.get_secret(id=secret.id).latest_content == {"password": "newpass"} + assert ctx.removed_secret_revisions == [old_revision] + + +def test_remove_bad_revision(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.secret_remove, self._on_secret_remove) + + def _on_secret_remove(self, event): + with pytest.raises(ValueError): + event.secret.remove_revision(event.revision) + + ctx = Context(SecretCharm, meta={"name": "foo"}) + secret = Secret({"a": "b"}, owner="app") + ctx.run( + ctx.on.secret_remove(secret, revision=secret._latest_revision), + State(leader=True, secrets={secret}), + ) + ctx.run( + ctx.on.secret_remove(secret, revision=secret._tracked_revision), + State(leader=True, secrets={secret}), + ) @pytest.mark.parametrize( @@ -576,17 +597,15 @@ def _on_event(self, event): def test_no_additional_positional_arguments(): with pytest.raises(TypeError): - Secret({}, None) + Secret({}, {}, None) def test_default_values(): contents = {"foo": "bar"} - id = "secret:1" - secret = Secret(contents, id=id) - assert secret.contents == contents - assert secret.id == id + secret = Secret(contents) + assert secret.latest_content == secret.tracked_content == contents + assert secret.id.startswith("secret:") assert secret.label is None - assert secret.revision == 0 assert secret.description is None assert secret.owner is None assert secret.rotate is None From f18efd378e34d78ed49f4e449a49f3172f7eaf8b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 8 Aug 2024 15:12:21 +1200 Subject: [PATCH 519/546] feat!: unify run() and run_action() and simplify context managers (#162) The `run_action()` method (both standalone and in the context manager) are removed. This means that all events, including action events, are emitted with the same `run()` call, and both return the output state. To get access to the results of actions, a new `action_output` attribute is added to the `Context`. This is a simplified representation of the Juju `operation` object (and the `task` objects in them), which are displayed with `juju run`, but also available via `juju show-operation`. The class has the same name as the Harness `ActionOutput` and will be consolidated into a single class when Scenario is added to ops. For example: ```python out = ctx.run_action(Action("backup", params={"output": "data.tar.gz"}), State()) assert out.results == {"size": 1234} assert out.logs = [..., ...] assert out.state... state = ctx.run(ctx.on.action("backup", params={"output": "data.tar.gz"}), State()) assert ctx.action_output.results == {"size": 1234} assert ctx.action_output.logs = [..., ...] assert state... ``` When the charm calls `event.fail()`, this raises an exception, in the same way that Harness does. For example: ```python out = ctx.run_action("bad-action", State()) assert out.failure == "couldn't do the thing" with pytest.raises(ActionFailed) as exc_info: ctx.run(ctx.on.action("bad-action"), State()) assert exc_info.value.message == "couldn't do the thing" ``` The advantage of this is that tests that the action is successful do not need to have `assert ctx.action_output.status != "failed"`, which is easy to miss. In addition, the `Context.manager` and `Context.action_manager` methods are replaced by the ability to use the `Context` object itself as a context manager. For example: ```python ctx = Context(MyCharm) with ctx(ctx.on.start(), State()) as event: event.charm.prepare() state = event.run() assert event.charm... ``` The same code is also used (with `ctx.on.action()`) for action events. Advantages: * Slightly shorter code (no ".manager" or ".action_manager") * Avoids naming complications with "manager", "action_manager" and the various alternatives proposed in #115. The `.output` property of the context manager is also removed. The context manager will still handle running the event if it's not done explicitly, but if that's the case then the output is not available. We want to encourage people to explicitly run the event, not rely on the automated behaviour - although I think it does make sense that it does run, rather than raise or end in a weird situation where the event never ran. This replaces #115 and #118, being a combination of ideas/discussion from both, plus incorporating the unification of run/run_action discussed here, and the "action fail raises" discussed elsewhere. Also, as drive-by changes while names are being made consistent: * `_Port` becomes `Port` * `_RelationBase` becomes (again) `RelationBase` --------- Co-authored-by: PietroPasotti --- README.md | 56 +++++--- docs/custom_conf.py | 1 + docs/index.rst | 1 - scenario/__init__.py | 8 +- scenario/context.py | 214 +++++++++--------------------- scenario/mocking.py | 9 +- scenario/runtime.py | 4 +- scenario/state.py | 45 ++++--- tests/helpers.py | 2 +- tests/test_charm_spec_autoload.py | 2 +- tests/test_context.py | 36 ++--- tests/test_context_on.py | 122 +++++++++-------- tests/test_e2e/test_actions.py | 99 ++++++++++---- tests/test_e2e/test_cloud_spec.py | 6 +- tests/test_e2e/test_manager.py | 24 ++-- tests/test_e2e/test_network.py | 6 +- tests/test_e2e/test_pebble.py | 10 +- tests/test_e2e/test_ports.py | 4 +- tests/test_e2e/test_relations.py | 6 +- tests/test_e2e/test_resource.py | 2 +- tests/test_e2e/test_secrets.py | 54 ++++---- tests/test_e2e/test_storage.py | 14 +- tests/test_e2e/test_vroot.py | 4 +- 23 files changed, 354 insertions(+), 375 deletions(-) diff --git a/README.md b/README.md index 982a21ffb..0e2332ff6 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,7 @@ If you want to, you can override any of these relation or extra-binding associat ```python state = scenario.State(networks={ - scenario.Network("foo", [BindAddress([Address('192.0.2.1')])]) + scenario.Network("foo", [scenario.BindAddress([scenario.Address('192.0.2.1')])]) }) ``` @@ -726,8 +726,8 @@ storage = scenario.Storage("foo") # Setup storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager(ctx.on.update_status(), scenario.State(storages={storage})) as mgr: - foo = mgr.charm.model.storages["foo"][0] +with ctx(ctx.on.update_status(), scenario.State(storages={storage})) as manager: + foo = manager.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" assert path.exists() @@ -924,9 +924,9 @@ import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) resource = scenario.Resource(name='foo', path='/path/to/resource.tar') -with ctx.manager(ctx.on.start(), scenario.State(resources={resource})) as mgr: +with ctx(ctx.on.start(), scenario.State(resources={resource})) as manager: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. - path = mgr.charm.model.resources.fetch('foo') + path = manager.charm.model.resources.fetch('foo') assert path == pathlib.Path('/path/to/resource.tar') ``` @@ -988,7 +988,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: @@ -1000,18 +999,32 @@ 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()) + state = 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'} + # You can assert on action results and logs using the context: + assert ctx.action_logs == ['baz', 'qux'] + assert ctx.action_results == {'foo': 'bar'} +``` + +## Failing Actions + +If the charm code calls `event.fail()` to indicate that the action has failed, +an `ActionFailed` exception will be raised. This avoids having to include +success checks in every test where the action is successful. + +```python +def test_backup_action_failed(): + ctx = scenario.Context(MyCharm) + + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("do_backup"), scenario.State()) + assert exc_info.value.message == "sorry, couldn't do the backup" + # The state is also available if that's required: + assert exc_info.value.state.get_container(...) - else: - # If the action fails, we can read a failure message: - assert out.failure == 'boo-hoo' + # You can still assert action results and logs that occured as well as the failure: + assert ctx.action_logs == ['baz', 'qux'] + assert ctx.action_results == {'foo': 'bar'} ``` ## Parametrized Actions @@ -1024,7 +1037,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( + state = ctx.run( ctx.on.action("do_backup", params={'a': 'b'}), scenario.State() ) @@ -1130,7 +1143,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 @@ -1152,8 +1165,7 @@ 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 manager: # This is your charm instance, after ops has set it up: charm: MyCharm = manager.charm @@ -1174,8 +1186,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 `manager.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 diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 3e035474f..10deb0096 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -311,5 +311,6 @@ def _compute_navigation_tree(context): ('py:class', '_Event'), ('py:class', 'scenario.state._DCBase'), ('py:class', 'scenario.state._EntityStatus'), + ('py:class', 'scenario.state._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] diff --git a/docs/index.rst b/docs/index.rst index 1a261b3d6..4d1af4d95 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,6 @@ scenario.Context .. automodule:: scenario.context - scenario.consistency_checker ============================ diff --git a/scenario/__init__.py b/scenario/__init__.py index bbd2c694b..24c1cac0a 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,8 +1,9 @@ #!/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, Manager from scenario.state import ( + ActionFailed, ActiveStatus, Address, BindAddress, @@ -21,6 +22,7 @@ Network, Notice, PeerRelation, + Port, Relation, Resource, Secret, @@ -37,7 +39,7 @@ ) __all__ = [ - "ActionOutput", + "ActionFailed", "CheckInfo", "CloudCredential", "CloudSpec", @@ -56,6 +58,7 @@ "Address", "BindAddress", "Network", + "Port", "ICMPPort", "TCPPort", "UDPPort", @@ -70,4 +73,5 @@ "MaintenanceStatus", "ActiveStatus", "UnknownStatus", + "Manager", ] diff --git a/scenario/context.py b/scenario/context.py index 359205f7a..7998ceb41 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -import dataclasses import tempfile from contextlib import contextmanager from pathlib import Path @@ -12,6 +11,7 @@ from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( + ActionFailed, CheckInfo, Container, MetadataNotFoundError, @@ -21,7 +21,6 @@ _Action, _CharmSpec, _Event, - _max_posargs, ) if TYPE_CHECKING: # pragma: no cover @@ -37,36 +36,12 @@ DEFAULT_JUJU_VERSION = "3.4" -@dataclasses.dataclass(frozen=True) -class ActionOutput(_max_posargs(0)): - """Wraps the results of running an action event with ``run_action``.""" - - 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] - """Any logs associated with the action output, set by the charm with - :meth:`ops.ActionEvent.log`.""" - results: Optional[Dict[str, Any]] - """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 - - 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): @@ -77,13 +52,22 @@ class AlreadyEmittedError(RuntimeError): """Raised when ``run()`` is called more than once.""" -class _Manager: - """Context manager to offer test code some runtime charm object introspection.""" +class Manager: + """Context manager to offer test code some runtime charm object introspection. + + This class should not be instantiated directly: use a :class:`Context` + in a ``with`` statement instead, for example:: + + ctx = Context(MyCharm) + with ctx(ctx.on.start(), State()) as manager: + manager.charm.setup() + manager.run() + """ def __init__( self, ctx: "Context", - arg: Union[str, _Action, _Event], + arg: _Event, state_in: "State", ): self._ctx = ctx @@ -91,25 +75,21 @@ def __init__( self._state_in = state_in self._emitted: bool = False - self._run = None 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): - raise NotImplementedError("override in subclass") - - def _get_output(self): - raise NotImplementedError("override in subclass") + return self._ctx._run # noqa def __enter__(self): self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in) @@ -117,57 +97,29 @@ def __enter__(self): 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. """ 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 + 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("manager not invoked. Doing so implicitly...") + logger.debug( + "user didn't emit the event within the context manager scope. Doing so implicitly upon exit...", + ) self.run() -class _EventManager(_Manager): - if TYPE_CHECKING: # pragma: no cover - output: State # pyright: ignore[reportIncompatibleVariableOverride] - - def run(self) -> "State": - return cast("State", super().run()) - - @property - def _runner(self): - return self._ctx._run_event # noqa - - def _get_output(self): - return self._ctx._output_state # noqa - - -class _ActionManager(_Manager): - if TYPE_CHECKING: # pragma: no cover - output: ActionOutput # pyright: ignore[reportIncompatibleVariableOverride] - - def run(self) -> "ActionOutput": - return cast("ActionOutput", super().run()) - - @property - def _runner(self): - return self._ctx._run # noqa - - def _get_output(self): - return self._ctx._finalize_action(self._ctx.output_state) # noqa - - class _CharmEvents: """Events generated by Juju pertaining to application lifecycle. @@ -360,14 +312,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``: @@ -378,6 +328,10 @@ class Context: - :attr:`workload_version_history`: record of the workload versions the charm has set - :attr:`removed_secret_revisions`: record of the secret revisions the charm has removed - :attr:`emitted_events`: record of the events (including custom) that the charm has processed + - :attr:`action_logs`: logs associated with the action output, set by the charm with + :meth:`ops.ActionEvent.log` + - :attr:`action_results`: 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` This allows you to write assertions not only on the output state, but also, to some extent, on the path the charm took to get there. @@ -410,19 +364,16 @@ def test_foo(): (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') scenario.Context(... charm_root=virtual_root).run(...) - Args: - charm_type: the CharmBase subclass to call :meth:`ops.main` on. - meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). - If none is provided, we will search for a ``metadata.yaml`` file in the charm root. - actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). - If none is provided, we will search for a ``actions.yaml`` file in the charm root. - config: charm config to use. Needs to be a valid config.yaml format (as a dict). - If none is provided, we will search for a ``config.yaml`` file in the charm root. - juju_version: Juju agent version to simulate. - app_name: App name that this charm is deployed as. Defaults to the charm name as - defined in its metadata - unit_id: Unit ID that this charm is deployed as. Defaults to 0. - charm_root: virtual charm root the charm will be executed with. + If you need access to the charm object that will handle the event, use the + class in a ``with`` statement, like:: + + import scenario + + def test_foo(): + ctx = scenario.Context(MyCharm) + with ctx(ctx.on.start(), State()) as manager: + manager.charm._some_private_setup() + manager.run() """ def __init__( @@ -507,11 +458,10 @@ 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_logs: List[str] = [] + self.action_results: Optional[Dict[str, Any]] = None + self._action_failure_message: Optional[str] = None self.on = _CharmEvents() @@ -550,13 +500,14 @@ 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: "_Event", state: "State"): """Context manager to introspect live charm object before and after the event is emitted. Usage:: - with Context().manager("start", State()) as manager: - assert manager.charm._some_private_attribute == "foo" # noqa + ctx = Context(MyCharm) + with ctx(ctx.on.start(), State()) as manager: + manager.charm._some_private_setup() manager.run() # this will fire the event assert manager.charm._some_private_attribute == "bar" # noqa @@ -564,27 +515,7 @@ def manager(self, event: "_Event", state: "State"): event: the :class:`Event` that the charm will respond to. state: the :class:`State` instance to use when handling the Event. """ - 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: - yield ops + return Manager(self, event, state) def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an Event and a State. @@ -596,40 +527,19 @@ 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: - 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, 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 event.action: + # Reset the logs, failure status, and results, in case the context + # is reused. + self.action_logs.clear() + if self.action_results is not None: + self.action_results.clear() + self._action_failure_message = None 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 + if event.action: + if self._action_failure_message is not None: + raise ActionFailed(self._action_failure_message, self.output_state) + return self.output_state @contextmanager def _run(self, event: "_Event", state: "State"): diff --git a/scenario/mocking.py b/scenario/mocking.py index 94e706e90..04f5a873a 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -554,21 +554,24 @@ 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 + if self._context.action_results: + self._context.action_results.update(results) + else: + self._context.action_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_failure_message = 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_logs.append(message) def action_get(self): action = self._event.action diff --git a/scenario/runtime.py b/scenario/runtime.py index 2f739f8fc..e853c682e 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -20,7 +20,7 @@ from scenario.capture_events import capture_events from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import DeferredEvent, PeerRelation, StoredState +from scenario.state import ActionFailed, DeferredEvent, PeerRelation, StoredState if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -466,7 +466,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( diff --git a/scenario/state.py b/scenario/state.py index 5de1545c0..d1eb108a8 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -133,6 +133,14 @@ class MetadataNotFoundError(RuntimeError): """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" +class ActionFailed(Exception): + """Raised at the end of the hook if the charm has called `event.fail()`.""" + + def __init__(self, message: str, state: "State"): + self.message = message + self.state = state + + # This can be replaced with the KW_ONLY dataclasses functionality in Python 3.10+. def _max_posargs(n: int): class _MaxPositionalArgs: @@ -432,7 +440,7 @@ def next_relation_id(*, update=True): @dataclasses.dataclass(frozen=True) -class _RelationBase(_max_posargs(2)): +class RelationBase(_max_posargs(2)): endpoint: str """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" @@ -471,9 +479,9 @@ def _get_databag_for_remote( raise NotImplementedError() def __post_init__(self): - if type(self) is _RelationBase: + if type(self) is RelationBase: raise RuntimeError( - "_RelationBase cannot be instantiated directly; " + "RelationBase cannot be instantiated directly; " "please use Relation, PeerRelation, or SubordinateRelation", ) @@ -548,7 +556,7 @@ def _databags(self): @dataclasses.dataclass(frozen=True) -class SubordinateRelation(_RelationBase): +class SubordinateRelation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), @@ -1092,8 +1100,12 @@ def __hash__(self) -> int: @dataclasses.dataclass(frozen=True) -class _Port(_max_posargs(1)): - """Represents a port on the charm host.""" +class Port(_max_posargs(1)): + """Represents a port on the charm host. + + Port objects should not be instantiated directly: use TCPPort, UDPPort, or + ICMPPort instead. + """ port: Optional[int] = None """The port to open. Required for TCP and UDP; not allowed for ICMP.""" @@ -1102,20 +1114,20 @@ class _Port(_max_posargs(1)): """The protocol that data transferred over the port will use.""" def __post_init__(self): - if type(self) is _Port: + if type(self) is Port: raise RuntimeError( - "_Port cannot be instantiated directly; " + "Port cannot be instantiated directly; " "please use TCPPort, UDPPort, or ICMPPort", ) def __eq__(self, other: object) -> bool: - if isinstance(other, (_Port, ops.Port)): + if isinstance(other, (Port, ops.Port)): return (self.protocol, self.port) == (other.protocol, other.port) return False @dataclasses.dataclass(frozen=True) -class TCPPort(_Port): +class TCPPort(Port): """Represents a TCP port on the charm host.""" port: int @@ -1131,7 +1143,7 @@ def __post_init__(self): @dataclasses.dataclass(frozen=True) -class UDPPort(_Port): +class UDPPort(Port): """Represents a UDP port on the charm host.""" port: int @@ -1147,7 +1159,7 @@ def __post_init__(self): @dataclasses.dataclass(frozen=True) -class ICMPPort(_Port): +class ICMPPort(Port): """Represents an ICMP port on the charm host.""" protocol: _RawPortProtocolLiteral = "icmp" @@ -1240,7 +1252,7 @@ class State(_max_posargs(0)): If a storage is not attached, omit it from this listing.""" # we don't use sets to make json serialization easier - opened_ports: Iterable[_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.""" @@ -1286,7 +1298,7 @@ def __post_init__(self): else: raise TypeError(f"Invalid status.{name}: {val!r}") normalised_ports = [ - _Port(protocol=port.protocol, port=port.port) + Port(protocol=port.protocol, port=port.port) if isinstance(port, ops.Port) else port for port in self.opened_ports @@ -1342,7 +1354,7 @@ def _update_status( # bypass frozen dataclass object.__setattr__(self, name, new_status) - def _update_opened_ports(self, new_ports: FrozenSet[_Port]): + def _update_opened_ports(self, new_ports: FrozenSet[Port]): """Update the current opened ports.""" # bypass frozen dataclass object.__setattr__(self, "opened_ports", new_ports) @@ -1844,10 +1856,11 @@ class _Action(_max_posargs(1)): def test_backup_action(): ctx = scenario.Context(MyCharm) - out: scenario.ActionOutput = ctx.run_action( + state = ctx.run( ctx.on.action('do_backup', params={'filename': 'foo'}), scenario.State() ) + assert ctx.action_results == ... """ name: str diff --git a/tests/helpers.py b/tests/helpers.py index c8060d1c9..82161c79b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -58,7 +58,7 @@ 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 mgr: if pre_event: pre_event(mgr.charm) state_out = mgr.run() diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index fb738f876..57b93a313 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -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: + with ctx(ctx.on.start(), State()) as mgr: mgr.run() assert mgr.charm.config["foo"] is True diff --git a/tests/test_context.py b/tests/test_context.py index 2ca8b93aa..361b4543b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,8 +3,8 @@ import pytest from ops import CharmBase -from scenario import ActionOutput, Context, State -from scenario.state import _Action, _Event, next_action_id +from scenario import Context, State +from scenario.state import _Event, next_action_id class MyCharm(CharmBase): @@ -36,8 +36,8 @@ def test_run_action(): with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope - output = ctx.run_action(ctx.on.action("do-foo"), state) - assert output.state == "foo" + output = ctx.run(ctx.on.action("do-foo"), state) + assert output == "foo" assert p.called e = p.call_args.kwargs["event"] @@ -53,26 +53,18 @@ 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: + with ctx(ctx.on.start(), State()) as mgr: assert mgr.charm.app.name == app_name assert mgr.charm.unit.name == f"{app_name}/{unit_id}" -def test_action_output_no_positional_arguments(): - with pytest.raises(TypeError): - ActionOutput(None, None) - - -def test_action_output_no_results(): - class MyCharm(CharmBase): - def __init__(self, framework): - super().__init__(framework) - framework.observe(self.on.act_action, self._on_act_action) - - def _on_act_action(self, _): - pass - +def test_context_manager(): ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) - out = ctx.run_action(ctx.on.action("act"), State()) - assert out.results is None - assert out.failure is None + state = State() + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert mgr.charm.meta.name == "foo" + + with ctx(ctx.on.action("act"), state) as mgr: + mgr.run() + assert mgr.charm.meta.name == "foo" diff --git a/tests/test_context_on.py b/tests/test_context_on.py index 8ddbf4d49..32759fd49 100644 --- a/tests/test_context_on.py +++ b/tests/test_context_on.py @@ -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) @@ -93,13 +93,13 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.secret.id == secret.id + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.secret.id == secret.id @pytest.mark.parametrize( @@ -121,14 +121,14 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.secret.id == secret.id - assert event.revision == 42 + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.secret.id == secret.id + assert mgr.revision == 42 @pytest.mark.parametrize("event_name", ["secret_expired", "secret_remove"]) @@ -157,42 +157,42 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.storage.name == storage.name - assert event.storage.index == storage.index + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.storage.name == storage.name + assert mgr.storage.index == storage.index 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: + # ctx.run(ctx.on.action(action_name), state) + with ctx(ctx.on.action("act"), scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.ActionEvent) + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.ActionEvent) def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: - # ctx.run_action(ctx.on.action(action=action), state) + # ctx.run(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: + with ctx(call_event, scenario.State()) as mgr: mgr.run() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.ActionEvent) - assert event.id == call_event.action.id - assert event.params["param"] == call_event.action.params["param"] + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.ActionEvent) + assert mgr.id == call_event.action.id + assert mgr.params["param"] == call_event.action.params["param"] def test_pebble_ready_event(): @@ -201,13 +201,13 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, ops.PebbleReadyEvent) - assert event.workload.name == container.name + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.PebbleReadyEvent) + assert mgr.workload.name == container.name @pytest.mark.parametrize("as_kwarg", [True, False]) @@ -230,15 +230,15 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit is None + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit is None def test_relation_complex_name(): @@ -247,14 +247,14 @@ 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] - assert isinstance(event, ops.RelationCreatedEvent) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit is None + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.RelationCreatedEvent) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit is None @pytest.mark.parametrize("event_name", ["relation_created", "relation_broken"]) @@ -280,15 +280,15 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/1" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/1" @pytest.mark.parametrize( @@ -306,17 +306,15 @@ 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) - event = mgr.charm.observed[0] - assert isinstance(event, event_kind) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/2" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, event_kind) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/2" def test_relation_departed_event(): @@ -325,15 +323,15 @@ 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() assert len(mgr.charm.observed) == 2 assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent) - event = mgr.charm.observed[0] - assert isinstance(event, ops.RelationDepartedEvent) - assert event.relation.id == relation.id - assert event.app.name == relation.remote_app_name - assert event.unit.name == "remote/2" - assert event.departing_unit.name == "remote/1" + mgr = mgr.charm.observed[0] + assert isinstance(mgr, ops.RelationDepartedEvent) + assert mgr.relation.id == relation.id + assert mgr.app.name == relation.remote_app_name + assert mgr.unit.name == "remote/2" + assert mgr.departing_unit.name == "remote/1" diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index b0668355f..7b6d17277 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -3,8 +3,7 @@ from ops.charm import ActionEvent, CharmBase from ops.framework import Framework -from scenario import Context -from scenario.context import InvalidEventError +from scenario import ActionFailed, Context from scenario.state import State, _Action, next_action_id @@ -34,14 +33,29 @@ def test_action_event(mycharm, baz_value): "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}} }, ) - ctx.run_action(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) + state = ctx.run(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State()) + assert isinstance(state, State) evt = ctx.emitted_events[0] - assert evt.params["bar"] == 10 assert evt.params["baz"] is baz_value +def test_action_no_results(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.act_action, self._on_act_action) + + def _on_act_action(self, _): + pass + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}}) + ctx.run(ctx.on.action("act"), State()) + assert ctx.action_results is None + assert ctx.action_logs == [] + + @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2})) def test_action_event_results_invalid(mycharm, res_value): def handle_evt(charm: CharmBase, evt: ActionEvent): @@ -51,19 +65,12 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo"), State()) - - -def test_cannot_run_action(mycharm): - ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - - with pytest.raises(InvalidEventError): - ctx.run(ctx.on.action("foo"), state=State()) + ctx.run(ctx.on.action("foo"), State()) @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_results_valid(mycharm, res_value): - def handle_evt(charm: CharmBase, evt): + def handle_evt(_: CharmBase, evt): if not isinstance(evt, ActionEvent): return evt.set_results(res_value) @@ -74,15 +81,14 @@ def handle_evt(charm: CharmBase, evt): ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(ctx.on.action("foo"), State()) + ctx.run(ctx.on.action("foo"), State()) - assert out.results == res_value - assert out.success is True + assert ctx.action_results == res_value @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"})) def test_action_event_outputs(mycharm, res_value): - def handle_evt(charm: CharmBase, evt: ActionEvent): + def handle_evt(_: CharmBase, evt: ActionEvent): if not isinstance(evt, ActionEvent): return @@ -94,11 +100,31 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - out = ctx.run_action(ctx.on.action("foo"), State()) + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("foo"), State()) + assert exc_info.value.message == "failed becozz" + assert ctx.action_results == {"my-res": res_value} + assert ctx.action_logs == ["log1", "log2"] + + +def test_action_continues_after_fail(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_action, self._on_foo_action) + + def _on_foo_action(self, event): + event.log("starting") + event.set_results({"initial": "result"}) + event.fail("oh no!") + event.set_results({"final": "result"}) - assert out.failure == "failed becozz" - assert out.logs == ["log1", "log2"] - assert out.success is False + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}}) + with pytest.raises(ActionFailed) as exc_info: + ctx.run(ctx.on.action("foo"), State()) + assert exc_info.value.message == "oh no!" + assert ctx.action_logs == ["starting"] + assert ctx.action_results == {"initial": "result", "final": "result"} def _ops_less_than(wanted_major, wanted_minor): @@ -114,7 +140,7 @@ def _ops_less_than(wanted_major, wanted_minor): _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id" ) def test_action_event_has_id(mycharm): - def handle_evt(charm: CharmBase, evt: ActionEvent): + def handle_evt(_: CharmBase, evt: ActionEvent): if not isinstance(evt, ActionEvent): return assert isinstance(evt.id, str) and evt.id != "" @@ -122,7 +148,7 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo"), State()) + ctx.run(ctx.on.action("foo"), State()) @pytest.mark.skipif( @@ -139,7 +165,32 @@ def handle_evt(charm: CharmBase, evt: ActionEvent): mycharm._evt_handler = handle_evt ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}}) - ctx.run_action(ctx.on.action("foo", id=uuid), State()) + ctx.run(ctx.on.action("foo", id=uuid), State()) + + +def test_two_actions_same_context(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.foo_action, self._on_foo_action) + framework.observe(self.on.bar_action, self._on_bar_action) + + def _on_foo_action(self, event): + event.log("foo") + event.set_results({"foo": "result"}) + + def _on_bar_action(self, event): + event.log("bar") + event.set_results({"bar": "result"}) + + ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}, "bar": {}}) + ctx.run(ctx.on.action("foo"), State()) + assert ctx.action_results == {"foo": "result"} + assert ctx.action_logs == ["foo"] + # Not recommended, but run another action in the same context. + ctx.run(ctx.on.action("bar"), State()) + assert ctx.action_results == {"bar": "result"} + assert ctx.action_logs == ["bar"] def test_positional_arguments(): diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py index 1834b3da6..c3a092482 100644 --- a/tests/test_e2e/test_cloud_spec.py +++ b/tests/test_e2e/test_cloud_spec.py @@ -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() @@ -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() diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py index 3f99ffd0b..1856c7ae6 100644 --- a/tests/test_e2e/test_manager.py +++ b/tests/test_e2e/test_manager.py @@ -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 AlreadyEmittedError, Manager @pytest.fixture(scope="function") @@ -23,7 +23,6 @@ def _on_event(self, e): if isinstance(e, CollectStatusEvent): return - print("event!") self.unit.status = ActiveStatus(e.handle.kind) return MyCharm @@ -31,41 +30,34 @@ def _on_event(self, e): def test_manager(mycharm): ctx = Context(mycharm, meta=mycharm.META) - with _EventManager(ctx, ctx.on.start(), State()) as manager: + with Manager(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 Manager(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 Manager(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) - 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" @@ -73,7 +65,7 @@ def test_context_manager(mycharm): 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() - assert isinstance(ao, ActionOutput) + with ctx(ctx.on.action("do-x"), State()) as manager: + state_out = manager.run() + assert isinstance(state_out, State) assert ctx.emitted_events[0].handle.kind == "do_x_action" diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index 761e9c710..a09d09f68 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -47,7 +47,7 @@ def test_ip_get(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ @@ -84,7 +84,7 @@ def test_no_sub_binding(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ @@ -109,7 +109,7 @@ def test_no_relation_error(mycharm): }, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[ diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index da40cf5d5..dec93c4d8 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -128,7 +128,7 @@ def callback(self: CharmBase): charm_type=charm_cls, meta={"name": "foo", "containers": {"foo": {}}}, ) - with ctx.manager(ctx.on.start(), state=state) as mgr: + with ctx(ctx.on.start(), state=state) as mgr: out = mgr.run() callback(mgr.charm) @@ -318,7 +318,7 @@ 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: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -340,7 +340,7 @@ 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: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) out, err = proc.wait_output() @@ -360,7 +360,7 @@ 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: + with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) with pytest.raises(ExecError): @@ -381,7 +381,7 @@ 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") diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py index 80365a01a..9e46665a8 100644 --- a/tests/test_e2e/test_ports.py +++ b/tests/test_e2e/test_ports.py @@ -2,7 +2,7 @@ from ops import CharmBase, Framework, StartEvent, StopEvent from scenario import Context, State -from scenario.state import StateValidationError, TCPPort, UDPPort, _Port +from scenario.state import Port, StateValidationError, TCPPort, UDPPort class MyCharm(CharmBase): @@ -42,7 +42,7 @@ def test_close_port(ctx): def test_port_no_arguments(): with pytest.raises(RuntimeError): - _Port() + Port() @pytest.mark.parametrize("klass", (TCPPort, UDPPort)) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 9ba0ed616..44433e216 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -17,10 +17,10 @@ DEFAULT_JUJU_DATABAG, PeerRelation, Relation, + RelationBase, State, StateValidationError, SubordinateRelation, - _RelationBase, next_relation_id, ) from tests.helpers import trigger @@ -399,7 +399,7 @@ def post_event(charm: CharmBase): def test_cannot_instantiate_relationbase(): with pytest.raises(RuntimeError): - _RelationBase("") + RelationBase("") def test_relation_ids(): @@ -417,7 +417,7 @@ 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: + with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: charm = mgr.charm assert charm.model.get_relation("foo") is None diff --git a/tests/test_e2e/test_resource.py b/tests/test_e2e/test_resource.py index c4237ea68..aebe8a0d2 100644 --- a/tests/test_e2e/test_resource.py +++ b/tests/test_e2e/test_resource.py @@ -25,7 +25,7 @@ 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 diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index 710efd613..d369a7112 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -34,7 +34,7 @@ def _on_event(self, event): def test_get_secret_no_secret(mycharm): ctx = Context(mycharm, meta={"name": "local"}) - with ctx.manager(ctx.on.update_status(), State()) as mgr: + with ctx(ctx.on.update_status(), State()) as mgr: with pytest.raises(SecretNotFoundError): assert mgr.charm.model.get_secret(id="foo") with pytest.raises(SecretNotFoundError): @@ -45,7 +45,7 @@ def test_get_secret_no_secret(mycharm): def test_get_secret(mycharm, owner): ctx = Context(mycharm, meta={"name": "local"}) secret = Secret({"a": "b"}, owner=owner) - with ctx.manager( + with ctx( state=State(secrets={secret}), event=ctx.on.update_status(), ) as mgr: @@ -60,7 +60,7 @@ def test_get_secret_get_refresh(mycharm, owner): latest_content={"a": "c"}, owner=owner, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State(secrets={secret}), ) as mgr: @@ -77,7 +77,7 @@ def test_get_secret_nonowner_peek_update(mycharm, app): tracked_content={"a": "b"}, latest_content={"a": "c"}, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=app, @@ -104,7 +104,7 @@ def test_get_secret_owner_peek_update(mycharm, owner): latest_content={"a": "c"}, owner=owner, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( secrets={secret}, @@ -156,7 +156,7 @@ def test_consumer_events_failures(mycharm, evt_suffix, revision): @pytest.mark.parametrize("app", (True, False)) def test_add(mycharm, app): ctx = Context(mycharm, meta={"name": "local"}) - with ctx.manager( + with ctx( ctx.on.update_status(), State(leader=app), ) as mgr: @@ -165,9 +165,10 @@ def test_add(mycharm, app): charm.app.add_secret({"foo": "bar"}, label="mylabel") else: charm.unit.add_secret({"foo": "bar"}, label="mylabel") + output = mgr.run() - assert mgr.output.secrets - secret = mgr.output.get_secret(label="mylabel") + assert output.secrets + secret = output.get_secret(label="mylabel") assert secret.latest_content == secret.tracked_content == {"foo": "bar"} assert secret.label == "mylabel" @@ -177,7 +178,7 @@ def test_set_legacy_behaviour(mycharm): # ref: https://bugs.launchpad.net/juju/+bug/2037120 ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6") rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -213,7 +214,7 @@ def test_set_legacy_behaviour(mycharm): def test_set(mycharm): ctx = Context(mycharm, meta={"name": "local"}) rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -245,7 +246,7 @@ def test_set(mycharm): def test_set_juju33(mycharm): ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1") rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"} - with ctx.manager( + with ctx( ctx.on.update_status(), State(), ) as mgr: @@ -277,7 +278,7 @@ def test_meta(mycharm, app): description="foobarbaz", rotate=SecretRotate.HOURLY, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=True, @@ -314,7 +315,7 @@ def test_secret_permission_model(mycharm, leader, owner): rotate=SecretRotate.HOURLY, ) secret_id = secret.id - with ctx.manager( + with ctx( ctx.on.update_status(), State( leader=leader, @@ -361,7 +362,7 @@ def test_grant(mycharm, app): description="foobarbaz", rotate=SecretRotate.HOURLY, ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( relations=[Relation("foo", "remote")], @@ -375,7 +376,8 @@ def test_grant(mycharm, app): secret.grant(relation=foo) else: secret.grant(relation=foo, unit=foo.units.pop()) - vals = list(mgr.output.get_secret(label="mylabel").remote_grants.values()) + output = mgr.run() + vals = list(output.get_secret(label="mylabel").remote_grants.values()) assert vals == [{"remote"}] if app else [{"remote/0"}] @@ -388,7 +390,7 @@ def test_update_metadata(mycharm): owner="unit", label="mylabel", ) - with ctx.manager( + with ctx( ctx.on.update_status(), State( secrets={secret}, @@ -401,8 +403,9 @@ def test_update_metadata(mycharm): expire=exp, rotate=SecretRotate.DAILY, ) + output = mgr.run() - secret_out = mgr.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" @@ -476,32 +479,35 @@ class GrantingCharm(CharmBase): }, ) - with ctx.manager(ctx.on.start(), state) as mgr: + with ctx(ctx.on.start(), state) as mgr: charm = mgr.charm secret = charm.app.add_secret({"foo": "bar"}, label="mylabel") bar_relation = charm.model.relations["bar"][0] secret.grant(bar_relation) + output = mgr.run() - assert mgr.output.secrets - scenario_secret = mgr.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.manager(ctx.on.start(), mgr.output) as mgr: + with ctx(ctx.on.start(), output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.revoke(bar_relation) + output = mgr.run() - scenario_secret = mgr.output.get_secret(label="mylabel") + scenario_secret = output.get_secret(label="mylabel") assert scenario_secret.remote_grants == {} - with ctx.manager(ctx.on.start(), mgr.output) as mgr: + with ctx(ctx.on.start(), output) as mgr: charm: GrantingCharm = mgr.charm secret = charm.model.get_secret(label="mylabel") secret.remove_all_revisions() + output = mgr.run() with pytest.raises(KeyError): - mgr.output.get_secret(label="mylabel") + output.get_secret(label="mylabel") def test_secret_removed_event(): diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py index 3e6912fb3..34785aa1c 100644 --- a/tests/test_e2e/test_storage.py +++ b/tests/test_e2e/test_storage.py @@ -23,13 +23,13 @@ def no_storage_ctx(): def test_storage_get_null(no_storage_ctx): - with no_storage_ctx.manager(no_storage_ctx.on.update_status(), State()) as mgr: + with no_storage_ctx(no_storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages assert not len(storages) def test_storage_get_unknown_name(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(KeyError): @@ -37,7 +37,7 @@ def test_storage_get_unknown_name(storage_ctx): def test_storage_request_unknown_name(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # not in metadata with pytest.raises(ModelError): @@ -45,7 +45,7 @@ def test_storage_request_unknown_name(storage_ctx): def test_storage_get_some(storage_ctx): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages # known but none attached assert storages["foo"] == [] @@ -53,7 +53,7 @@ def test_storage_get_some(storage_ctx): @pytest.mark.parametrize("n", (1, 3, 5)) def test_storage_add(storage_ctx, n): - with storage_ctx.manager(storage_ctx.on.update_status(), State()) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State()) as mgr: storages = mgr.charm.model.storages storages.request("foo", n) @@ -65,9 +65,7 @@ def test_storage_usage(storage_ctx): # setup storage with some content (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld") - with storage_ctx.manager( - storage_ctx.on.update_status(), State(storages={storage}) - ) as mgr: + with storage_ctx(storage_ctx.on.update_status(), State(storages={storage})) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py index c87026111..fa8a3d412 100644 --- a/tests/test_e2e/test_vroot.py +++ b/tests/test_e2e/test_vroot.py @@ -56,7 +56,7 @@ def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root): meta_file.write_text(raw_ori_meta) ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) - with ctx.manager( + with ctx( ctx.on.start(), State(), ) as mgr: @@ -79,7 +79,7 @@ def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root): assert not meta_file.exists() ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root) - with ctx.manager( + with ctx( ctx.on.start(), State(), ) as mgr: From 62fdfc03471c2569c704607868bc62c3771389fc Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 13 Aug 2024 10:20:29 +1200 Subject: [PATCH 520/546] fix: update secret label when getting with both id and label (#172) When we get a secret and provide both ID and label, the label should update to the provided one. This was previously missing in Scenario. Fixes #95 --- scenario/mocking.py | 6 ++++++ scenario/state.py | 5 +++++ tests/test_e2e/test_secrets.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/scenario/mocking.py b/scenario/mocking.py index 04f5a873a..8452b314b 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -404,6 +404,9 @@ def secret_get( peek: bool = False, ) -> Dict[str, str]: secret = self._get_secret(id, label) + # If both the id and label are provided, then update the label. + if id is not None and label is not None: + secret._set_label(label) juju_version = self._context.juju_version if not (juju_version == "3.1.7" or juju_version >= "3.3.1"): # In this medieval Juju chapter, @@ -427,6 +430,9 @@ def secret_info_get( label: Optional[str] = None, ) -> SecretInfo: secret = self._get_secret(id, label) + # If both the id and label are provided, then update the label. + if id is not None and label is not None: + secret._set_label(label) # only "manage"=write access level can read secret info self._check_can_manage_secret(secret) diff --git a/scenario/state.py b/scenario/state.py index d1eb108a8..ded75798e 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -323,6 +323,10 @@ def __post_init__(self): # bypass frozen dataclass object.__setattr__(self, "latest_content", self.tracked_content) + def _set_label(self, label): + # bypass frozen dataclass + object.__setattr__(self, "label", label) + def _track_latest_revision(self): """Set the current revision to the tracked revision.""" # bypass frozen dataclass @@ -342,6 +346,7 @@ def _update_metadata( object.__setattr__(self, "_latest_revision", self._latest_revision + 1) # TODO: if this is done twice in the same hook, then Juju ignores the # first call, it doesn't continue to update like this does. + # Fix when https://github.com/canonical/operator/issues/1288 is resolved. if content: object.__setattr__(self, "latest_content", content) if label: diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index d369a7112..fb1590b79 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -601,6 +601,24 @@ def _on_event(self, event): assert isinstance(juju_event, cls) +def test_set_label_on_get(): + class SecretCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + id = self.unit.add_secret({"foo": "bar"}).id + secret = self.model.get_secret(id=id, label="label1") + assert secret.label == "label1" + secret = self.model.get_secret(id=id, label="label2") + assert secret.label == "label2" + + ctx = Context(SecretCharm, meta={"name": "foo"}) + state = ctx.run(ctx.on.start(), State()) + assert state.get_secret(label="label2").tracked_content == {"foo": "bar"} + + def test_no_additional_positional_arguments(): with pytest.raises(TypeError): Secret({}, {}, None) From 190d3c03d3413e6bd2e9fb8087da100dd24e6c31 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 20 Aug 2024 16:11:44 +1200 Subject: [PATCH 521/546] feat!: simplify creating deferred events (#174) A couple of changes to simplify setting up the queue of deferred events: * Adjusted the docs for `DeferredEvent` to strongly suggest that it's for internal use only and people should use the `.deferred()` method of an `_Event` instead. It's still required internally (to go to/from the ops state) and might be needed in obscure cases, but this reduces the number of ways people need to be aware of by one. * The `scenario.deferred()` helper is removed. When using this method for events that link to components (relations, containers, etc) it was possible to not have the event set up correctly, and it didn't really seem to offer much over `.deferred()`. Removing it leaves one normal way to create deferred events. Finally, setting up the `DeferredEvent` snapshot data only handled workload events and relation events (and wasn't always correct for all relation events). It should now cover all current events with the right snapshot. --- README.md | 56 ++----------------- scenario/__init__.py | 2 - scenario/state.py | 98 +++++++++++++++++++++------------ tests/test_e2e/test_deferred.py | 51 +---------------- 4 files changed, 71 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 0e2332ff6..18c828425 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ with scenario.capture_events.capture_events() as emitted: ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( ctx.on.update_status(), - scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) + scenario.State(deferred=[ctx.on.start().deferred(SimpleCharm._on_start)]) ) # deferred events get reemitted first @@ -1050,7 +1050,7 @@ def test_backup_action(): Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that event in its queue (they would be there because they had been deferred in the previous run), then the output state is -valid. +valid. You generate the deferred data structure using the event's `deferred()` method: ```python class MyCharm(ops.CharmBase): @@ -1063,28 +1063,19 @@ class MyCharm(ops.CharmBase): event.defer() -def test_start_on_deferred_update_status(MyCharm): +def test_start_on_deferred_update_status(): """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred.""" + ctx = scenario.Context(MyCharm) state_in = scenario.State( deferred=[ - scenario.deferred('update_status', handler=MyCharm._on_update_status) + ctx.on.update_status().deferred(handler=MyCharm._on_update_status) ] ) - state_out = scenario.Context(MyCharm).run(ctx.on.start(), state_in) + state_out = ctx.run(ctx.on.start(), state_in) assert len(state_out.deferred) == 1 assert state_out.deferred[0].name == 'start' ``` -You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the -handler): - -```python continuation -ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) - -deferred_start = ctx.on.start().deferred(MyCharm._on_start) -deferred_install = ctx.on.install().deferred(MyCharm._on_start) -``` - On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed been deferred. @@ -1102,41 +1093,6 @@ def test_defer(MyCharm): assert out.deferred[0].name == 'start' ``` -## Deferring relation events - -If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the -Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data -structure: - -```python -class MyCharm(ops.CharmBase): - ... - - def _on_foo_relation_changed(self, event): - event.defer() - - -def test_start_on_deferred_update_status(MyCharm): - foo_relation = scenario.Relation('foo') - scenario.State( - relations={foo_relation}, - deferred=[ - scenario.deferred('foo_relation_changed', - handler=MyCharm._on_foo_relation_changed, - relation=foo_relation) - ] - ) -``` - -but you can also use a shortcut from the relation event itself: - -```python continuation -ctx = scenario.Context(MyCharm, meta={"name": "deferring"}) - -foo_relation = scenario.Relation('foo') -deferred_event = ctx.on.relation_changed(foo_relation).deferred(handler=MyCharm._on_foo_relation_changed) -``` - # Live charm introspection Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to diff --git a/scenario/__init__.py b/scenario/__init__.py index 24c1cac0a..8981d3b3a 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -35,7 +35,6 @@ UDPPort, UnknownStatus, WaitingStatus, - deferred, ) __all__ = [ @@ -44,7 +43,6 @@ "CloudCredential", "CloudSpec", "Context", - "deferred", "StateValidationError", "Secret", "Relation", diff --git a/scenario/state.py b/scenario/state.py index ded75798e..45cb6ea88 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -1571,8 +1571,13 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. - In most cases, the :func:`deferred` function should be used to create a - ``DeferredEvent`` instance.""" + Tests should not instantiate this class directly: use :meth:`_Event.deferred` + instead. For example: + + ctx = Context(MyCharm) + deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) + state = State(deferred=[deferred_start]) + """ handle_path: str owner: str @@ -1581,6 +1586,12 @@ class DeferredEvent: # needs to be marshal.dumps-able. snapshot_data: Dict = dataclasses.field(default_factory=dict) + # It would be nicer if people could do something like: + # `isinstance(state.deferred[0], ops.StartEvent)` + # than comparing with the string names, but there's only one `_Event` + # class in Scenario, and it also needs to be created from the context, + # which is not available here. For the ops classes, it's complex to create + # them because they need a Handle. @property def name(self): return self.handle_path.split("/")[-1].split("[")[0] @@ -1789,17 +1800,18 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: owner_name, handler_name = match.groups()[0].split(".")[-2:] handle_path = f"{owner_name}/on/{self.name}[{event_id}]" + # Many events have no snapshot data: install, start, stop, remove, config-changed, + # upgrade-charm, pre-series-upgrade, post-series-upgrade, leader-elected, + # leader-settings-changed, collect-metrics snapshot_data = {} # fixme: at this stage we can't determine if the event is a builtin one or not; if it is # not, then the coming checks are meaningless: the custom event could be named like a # relation event but not *be* one. if self._is_workload_event: - # this is a WorkloadEvent. The snapshot: - container = cast(Container, self.container) - snapshot_data = { - "container_name": container.name, - } + # Enforced by the consistency checker, but for type checkers: + assert self.container is not None + snapshot_data["container_name"] = self.container.name if self.notice: if hasattr(self.notice.type, "value"): notice_type = cast(pebble.NoticeType, self.notice.type).value @@ -1816,8 +1828,9 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: snapshot_data["check_name"] = self.check_info.name elif self._is_relation_event: - # this is a RelationEvent. - relation = cast("AnyRelation", self.relation) + # Enforced by the consistency checker, but for type checkers: + assert self.relation is not None + relation = self.relation if isinstance(relation, PeerRelation): # FIXME: relation.unit for peers should point to , but we # don't have access to the local app name in this context. @@ -1825,12 +1838,46 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: else: remote_app = relation.remote_app_name - snapshot_data = { - "relation_name": relation.endpoint, - "relation_id": relation.id, - "app_name": remote_app, - "unit_name": f"{remote_app}/{self.relation_remote_unit_id}", - } + snapshot_data.update( + { + "relation_name": relation.endpoint, + "relation_id": relation.id, + "app_name": remote_app, + }, + ) + if not self.name.endswith(("_created", "_broken")): + snapshot_data[ + "unit_name" + ] = f"{remote_app}/{self.relation_remote_unit_id}" + if self.name.endswith("_departed"): + snapshot_data[ + "departing_unit" + ] = f"{remote_app}/{self.relation_departed_unit_id}" + + elif self._is_storage_event: + # Enforced by the consistency checker, but for type checkers: + assert self.storage is not None + snapshot_data.update( + { + "storage_name": self.storage.name, + "storage_index": self.storage.index, + # "storage_location": str(self.storage.get_filesystem(self._context)), + }, + ) + + elif self._is_secret_event: + # Enforced by the consistency checker, but for type checkers: + assert self.secret is not None + snapshot_data.update( + {"secret_id": self.secret.id, "secret_label": self.secret.label}, + ) + if self.name.endswith(("_remove", "_expired")): + snapshot_data["secret_revision"] = self.secret_revision + + elif self._is_action_event: + # Enforced by the consistency checker, but for type checkers: + assert self.action is not None + snapshot_data["id"] = self.action.id return DeferredEvent( handle_path, @@ -1879,24 +1926,3 @@ def test_backup_action(): Every action invocation is automatically assigned a new one. Override in the rare cases where a specific ID is required.""" - - -def deferred( - event: Union[str, _Event], - handler: Callable, - event_id: int = 1, - relation: Optional["Relation"] = None, - container: Optional["Container"] = None, - notice: Optional["Notice"] = None, - check_info: Optional["CheckInfo"] = None, -): - """Construct a DeferredEvent from an Event or an event name.""" - if isinstance(event, str): - event = _Event( - event, - relation=relation, - container=container, - notice=notice, - check_info=check_info, - ) - return event.deferred(handler=handler, event_id=event_id) diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py index 2b21dd90a..44e21fcec 100644 --- a/tests/test_e2e/test_deferred.py +++ b/tests/test_e2e/test_deferred.py @@ -12,7 +12,7 @@ from ops.framework import Framework from scenario import Context -from scenario.state import Container, Notice, Relation, State, _Event, deferred +from scenario.state import Container, Notice, Relation, State, _Event from tests.helpers import trigger CHARM_CALLED = 0 @@ -54,7 +54,7 @@ def test_deferred_evt_emitted(mycharm): mycharm.defer_next = 2 out = trigger( - State(deferred=[deferred(event="update_status", handler=mycharm._on_event)]), + State(deferred=[_Event("update_status").deferred(handler=mycharm._on_event)]), "start", mycharm, meta=mycharm.META, @@ -72,49 +72,6 @@ def test_deferred_evt_emitted(mycharm): assert isinstance(start, StartEvent) -def test_deferred_relation_event_without_relation_raises(mycharm): - with pytest.raises(AttributeError): - deferred(event="foo_relation_changed", handler=mycharm._on_event) - - -def test_deferred_relation_evt(mycharm): - rel = Relation(endpoint="foo", remote_app_name="remote") - evt1 = _Event("foo_relation_changed", relation=rel).deferred( - handler=mycharm._on_event - ) - evt2 = deferred( - event="foo_relation_changed", - handler=mycharm._on_event, - relation=rel, - ) - - assert asdict(evt2) == asdict(evt1) - - -def test_deferred_workload_evt(mycharm): - ctr = Container("foo") - evt1 = _Event("foo_pebble_ready", container=ctr).deferred(handler=mycharm._on_event) - evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr) - - assert asdict(evt2) == asdict(evt1) - - -def test_deferred_notice_evt(mycharm): - notice = Notice(key="example.com/bar") - ctr = Container("foo", notices=[notice]) - evt1 = _Event("foo_pebble_custom_notice", notice=notice, container=ctr).deferred( - handler=mycharm._on_event - ) - evt2 = deferred( - event="foo_pebble_custom_notice", - handler=mycharm._on_event, - container=ctr, - notice=notice, - ) - - assert asdict(evt2) == asdict(evt1) - - def test_deferred_relation_event(mycharm): mycharm.defer_next = 2 @@ -124,10 +81,8 @@ def test_deferred_relation_event(mycharm): State( relations={rel}, deferred=[ - deferred( - event="foo_relation_changed", + _Event("foo_relation_changed", relation=rel).deferred( handler=mycharm._on_event, - relation=rel, ) ], ), From abe24c219afc2c299a0edd78b4e947e4d20496aa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 20 Aug 2024 18:19:31 +1200 Subject: [PATCH 522/546] refactor!: rename Container.exec_mocks to Container.execs and extend mocking (#124) Firstly, some simple renaming: * `Container.exec_mocks` becomes `Container.execs` * `Container.service_status` becomes `Container.service_statuses` * `ExecOutput` becomes `Exec` A behaviour change that is a bugfix: * When a process exits non-zero, the `ExecError` should have the stdout/stderr if `wait_output` was used (it's not readable afterwards by the charm, although the Scenario code doesn't enforce that). Some more substantial changes: * Provide the ability to get the exact command, the stdin, and all the other args that the charm used with the process (everything from os.testing's `ExecArgs`), via a new context attribute `exec_history`, which is a (default) dict where the key is the container name and the value is a list of Pebble exec's that have been run. * Support the same "find the closest match to this prefix" system for commands as Harness does We could add more of the functionality that Harness has, but I think this is a solid subset (I've wanted to be able to get to the stdin in writing tests myself, and the simple mock matching seems handy). It should be easy enough to extend in the future without needing API changes, I think, since this now has both input and output. The key parts that are missing are properly supporting binary IO and the execution context. --------- Co-authored-by: PietroPasotti --- README.md | 23 ++++- scenario/__init__.py | 4 +- scenario/consistency_checker.py | 38 ++++--- scenario/context.py | 2 + scenario/mocking.py | 161 ++++++++++++++++++++++++------ scenario/state.py | 55 +++++++--- tests/test_consistency_checker.py | 14 ++- tests/test_e2e/test_pebble.py | 55 +++++++--- tests/test_e2e/test_state.py | 4 +- 9 files changed, 275 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 18c828425..bab3ab898 100644 --- a/README.md +++ b/README.md @@ -639,6 +639,7 @@ class MyCharm(ops.CharmBase): def _on_start(self, _): foo = self.unit.get_container('foo') proc = foo.exec(['ls', '-ll']) + proc.stdin.write("...") stdout, _ = proc.wait_output() assert stdout == LS_LL @@ -646,10 +647,12 @@ class MyCharm(ops.CharmBase): def test_pebble_exec(): container = scenario.Container( name='foo', - exec_mock={ - ('ls', '-ll'): # this is the command we're mocking - scenario.ExecOutput(return_code=0, # this data structure contains all we need to mock the call. - stdout=LS_LL) + execs={ + scenario.Exec( + command_prefix=['ls'], + return_code=0, + stdout=LS_LL, + ), } ) state_in = scenario.State(containers={container}) @@ -661,8 +664,20 @@ def test_pebble_exec(): ctx.on.pebble_ready(container), state_in, ) + assert ctx.exec_history[container.name][0].command == ['ls', '-ll'] + assert ctx.exec_history[container.name][0].stdin == "..." ``` +Scenario will attempt to find the right `Exec` object by matching the provided +command prefix against the command used in the ops `container.exec()` call. For +example if the command is `['ls', '-ll']` then the searching will be: + + 1. an `Exec` with exactly the same as command prefix, `('ls', '-ll')` + 2. an `Exec` with the command prefix `('ls', )` + 3. an `Exec` with the command prefix `()` + +If none of these are found Scenario will raise an `ExecError`. + ### Pebble Notices Pebble can generate notices, which Juju will detect, and wake up the charm to diff --git a/scenario/__init__.py b/scenario/__init__.py index 8981d3b3a..1b9416f8b 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -14,7 +14,7 @@ Container, DeferredEvent, ErrorStatus, - ExecOutput, + Exec, ICMPPort, MaintenanceStatus, Model, @@ -49,7 +49,7 @@ "SubordinateRelation", "PeerRelation", "Model", - "ExecOutput", + "Exec", "Mount", "Container", "Notice", diff --git a/scenario/consistency_checker.py b/scenario/consistency_checker.py index e319a1078..c2205540b 100644 --- a/scenario/consistency_checker.py +++ b/scenario/consistency_checker.py @@ -4,7 +4,7 @@ import marshal import os import re -from collections import defaultdict +from collections import Counter, defaultdict from collections.abc import Sequence from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union @@ -186,27 +186,33 @@ def _check_workload_event( event: "_Event", state: "State", errors: List[str], - warnings: List[str], # noqa: U100 + warnings: List[str], ): if not event.container: errors.append( "cannot construct a workload event without the container instance. " "Please pass one.", ) - elif not event.name.startswith(normalize_name(event.container.name)): - errors.append( - f"workload event should start with container name. {event.name} does " - f"not start with {event.container.name}.", - ) - if event.container not in state.containers: + else: + if not event.name.startswith(normalize_name(event.container.name)): errors.append( - f"cannot emit {event.name} because container {event.container.name} " - f"is not in the state.", + f"workload event should start with container name. {event.name} does " + f"not start with {event.container.name}.", ) - if not event.container.can_connect: - warnings.append( - "you **can** fire fire pebble-ready while the container cannot connect, " - "but that's most likely not what you want.", + if event.container not in state.containers: + errors.append( + f"cannot emit {event.name} because container {event.container.name} " + f"is not in the state.", + ) + if not event.container.can_connect: + warnings.append( + "you **can** fire fire pebble-ready while the container cannot connect, " + "but that's most likely not what you want.", + ) + names = Counter(exec.command_prefix for exec in event.container.execs) + if dupes := [n for n in names if names[n] > 1]: + errors.append( + f"container {event.container.name} has duplicate command prefixes: {dupes}", ) @@ -585,18 +591,20 @@ def check_containers_consistency( f"container with that name is not present in the state. It's odd, but " f"consistent, if it cannot connect; but it should at least be there.", ) + # - you're processing a Notice event and that notice is not in any of the containers if event.notice and event.notice.id not in all_notices: errors.append( f"the event being processed concerns notice {event.notice!r}, but that " "notice is not in any of the containers present in the state.", ) + # - you're processing a Check event and that check is not in the check's container if ( event.check_info and (evt_container_name, event.check_info.name) not in all_checks ): errors.append( f"the event being processed concerns check {event.check_info.name}, but that " - "check is not the {evt_container_name} container.", + f"check is not in the {evt_container_name} container.", ) # - a container in state.containers is not in meta.containers diff --git a/scenario/context.py b/scenario/context.py index 7998ceb41..0f7ca1e13 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast from ops import CharmBase, EventBase +from ops.testing import ExecArgs from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime @@ -450,6 +451,7 @@ def __init__( self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] + self.exec_history: Dict[str, List[ExecArgs]] = {} self.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] self.emitted_events: List[EventBase] = [] diff --git a/scenario/mocking.py b/scenario/mocking.py index 8452b314b..b1a60a7d7 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -3,7 +3,6 @@ # See LICENSE file for licensing details. import datetime import shutil -from io import StringIO from pathlib import Path from typing import ( TYPE_CHECKING, @@ -14,6 +13,7 @@ Mapping, Optional, Set, + TextIO, Tuple, Union, cast, @@ -33,7 +33,7 @@ _ModelBackend, ) from ops.pebble import Client, ExecError -from ops.testing import _TestingPebbleClient +from ops.testing import ExecArgs, _TestingPebbleClient from scenario.logger import logger as scenario_logger from scenario.state import ( @@ -52,7 +52,7 @@ from scenario.context import Context from scenario.state import Container as ContainerSpec from scenario.state import ( - ExecOutput, + Exec, Relation, Secret, State, @@ -72,26 +72,46 @@ class ActionMissingFromContextError(Exception): class _MockExecProcess: - def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"): - self._command = command + def __init__( + self, + change_id: int, + args: ExecArgs, + return_code: int, + stdin: Optional[TextIO], + stdout: Optional[TextIO], + stderr: Optional[TextIO], + ): self._change_id = change_id - self._out = out + self._args = args + self._return_code = return_code self._waited = False - self.stdout = StringIO(self._out.stdout) - self.stderr = StringIO(self._out.stderr) + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + + def __del__(self): + if not self._waited: + self._close_stdin() + + def _close_stdin(self): + if self._args.stdin is None and self.stdin is not None: + self.stdin.seek(0) + self._args.stdin = self.stdin.read() def wait(self): + self._close_stdin() self._waited = True - exit_code = self._out.return_code - if exit_code != 0: - raise ExecError(list(self._command), exit_code, None, None) + if self._return_code != 0: + raise ExecError(list(self._args.command), self._return_code, None, None) def wait_output(self): - out = self._out - exit_code = out.return_code - if exit_code != 0: - raise ExecError(list(self._command), exit_code, None, None) - return out.stdout, out.stderr + self._close_stdin() + self._waited = True + stdout = self.stdout.read() if self.stdout is not None else None + stderr = self.stderr.read() if self.stderr is not None else None + if self._return_code != 0: + raise ExecError(list(self._args.command), self._return_code, stdout, stderr) + return stdout, stderr def send_signal(self, sig: Union[int, str]): # noqa: U100 raise NotImplementedError() @@ -165,6 +185,8 @@ def get_pebble(self, socket_path: str) -> "Client": state=self._state, event=self._event, charm_spec=self._charm_spec, + context=self._context, + container_name=container_name, ) def _get_relation_by_id( @@ -705,11 +727,15 @@ def __init__( state: "State", event: "_Event", charm_spec: "_CharmSpec", + context: "Context", + container_name: str, ): self._state = state self.socket_path = socket_path self._event = event self._charm_spec = charm_spec + self._context = context + self._container_name = container_name # wipe just in case if container_root.exists(): @@ -762,21 +788,100 @@ def _layers(self) -> Dict[str, pebble.Layer]: @property def _service_status(self) -> Dict[str, pebble.ServiceStatus]: - return self._container.service_status + return self._container.service_statuses + + # Based on a method of the same name from ops.testing. + def _find_exec_handler(self, command) -> Optional["Exec"]: + handlers = {exec.command_prefix: exec for exec in self._container.execs} + # Start with the full command and, each loop iteration, drop the last + # element, until it matches one of the command prefixes in the execs. + # This includes matching against the empty list, which will match any + # command, if there is not a more specific match. + for prefix_len in reversed(range(len(command) + 1)): + command_prefix = tuple(command[:prefix_len]) + if command_prefix in handlers: + return handlers[command_prefix] + # None of the command prefixes in the execs matched the command, no + # matter how much of it was used, so we have failed to find a handler. + return None - def exec(self, *args, **kwargs): # noqa: U100 type: ignore - cmd = tuple(args[0]) - out = self._container.exec_mock.get(cmd) - if not out: - raise RuntimeError( - f"mock for cmd {cmd} not found. Please pass to the Container " - f"{self._container.name} a scenario.ExecOutput mock for the " - f"command your charm is attempting to run, or patch " - f"out whatever leads to the call.", + def exec( + self, + command: List[str], + *, + environment: Optional[Dict[str, str]] = None, + working_dir: Optional[str] = None, + timeout: Optional[float] = None, + user_id: Optional[int] = None, + user: Optional[str] = None, + group_id: Optional[int] = None, + group: Optional[str] = None, + stdin: Optional[Union[str, bytes, TextIO]] = None, + stdout: Optional[TextIO] = None, + stderr: Optional[TextIO] = None, + encoding: Optional[str] = "utf-8", + combine_stderr: bool = False, + **kwargs, + ): + handler = self._find_exec_handler(command) + if not handler: + raise ExecError( + command, + 127, + "", + f"mock for cmd {command} not found. Please patch out whatever " + f"leads to the call, or pass to the Container {self._container.name} " + f"a scenario.Exec mock for the command your charm is attempting " + f"to run, such as " + f"'Container(..., execs={{scenario.Exec({list(command)}, ...)}})'", ) - change_id = out._run() - return _MockExecProcess(change_id=change_id, command=cmd, out=out) + if stdin is None: + proc_stdin = self._transform_exec_handler_output("", encoding) + else: + proc_stdin = None + stdin = stdin.read() if hasattr(stdin, "read") else stdin # type: ignore + if stdout is None: + proc_stdout = self._transform_exec_handler_output(handler.stdout, encoding) + else: + proc_stdout = None + stdout.write(handler.stdout) + if stderr is None: + proc_stderr = self._transform_exec_handler_output(handler.stderr, encoding) + else: + proc_stderr = None + stderr.write(handler.stderr) + + args = ExecArgs( + command=command, + environment=environment or {}, + working_dir=working_dir, + timeout=timeout, + user_id=user_id, + user=user, + group_id=group_id, + group=group, + stdin=stdin, # type:ignore # If None, will be replaced by proc_stdin.read() later. + encoding=encoding, + combine_stderr=combine_stderr, + ) + try: + self._context.exec_history[self._container_name].append(args) + except KeyError: + self._context.exec_history[self._container_name] = [args] + + change_id = handler._run() + return cast( + pebble.ExecProcess[Any], + _MockExecProcess( + change_id=change_id, + args=args, + return_code=handler.return_code, + stdin=proc_stdin, + stdout=proc_stdout, + stderr=proc_stderr, + ), + ) def _check_connection(self): if not self._container.can_connect: diff --git a/scenario/state.py b/scenario/state.py index 45cb6ea88..7f1e39e96 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -27,6 +27,7 @@ List, Literal, Optional, + Sequence, Set, Tuple, Type, @@ -675,26 +676,42 @@ def _generate_new_change_id(): @dataclasses.dataclass(frozen=True) -class ExecOutput(_max_posargs(0)): +class Exec(_max_posargs(1)): """Mock data for simulated :meth:`ops.Container.exec` calls.""" + command_prefix: Sequence[str] return_code: int = 0 - """The return code of the process (0 is success).""" + """The return code of the process. + + Use 0 to mock the process ending successfully, and other values for failure. + """ stdout: str = "" - """Any content written to stdout by the process.""" + """Any content written to stdout by the process. + + Provide content that the real process would write to stdout, which can be + read by the charm. + """ stderr: str = "" - """Any content written to stderr by the process.""" + """Any content written to stderr by the process. + + Provide content that the real process would write to stderr, which can be + read by the charm. + """ # change ID: used internally to keep track of mocked processes _change_id: int = dataclasses.field(default_factory=_generate_new_change_id) + def __post_init__(self): + # The command prefix can be any sequence type, and a list is tidier to + # write when there's only one string. However, this object needs to be + # hashable, so can't contain a list. We 'freeze' the sequence to a tuple + # to support that. + object.__setattr__(self, "command_prefix", tuple(self.command_prefix)) + def _run(self) -> int: return self._change_id -_ExecMock = Dict[Tuple[str, ...], ExecOutput] - - @dataclasses.dataclass(frozen=True) class Mount(_max_posargs(0)): """Maps local files to a :class:`Container` filesystem.""" @@ -835,7 +852,7 @@ class Container(_max_posargs(1)): layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" - service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field( + service_statuses: Dict[str, pebble.ServiceStatus] = dataclasses.field( default_factory=dict, ) """The current status of each Pebble service running in the container.""" @@ -871,20 +888,23 @@ class Container(_max_posargs(1)): } """ - exec_mock: _ExecMock = dataclasses.field(default_factory=dict) + execs: Iterable[Exec] = frozenset() """Simulate executing commands in the container. - Specify each command the charm might run in the container and a :class:`ExecOutput` + Specify each command the charm might run in the container and an :class:`Exec` containing its return code and any stdout/stderr. For example:: container = scenario.Container( name='foo', - exec_mock={ - ('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu') - ('dig', '+short', 'canonical.com'): - scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21') + execs={ + scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'), + scenario.Exec( + ['dig', '+short', 'canonical.com'], + return_code=0, + stdout='185.125.190.20\\n185.125.190.21', + ), } ) """ @@ -896,6 +916,11 @@ class Container(_max_posargs(1)): def __hash__(self) -> int: return hash(self.name) + def __post_init__(self): + if not isinstance(self.execs, frozenset): + # Allow passing a regular set (or other iterable) of Execs. + object.__setattr__(self, "execs", frozenset(self.execs)) + def _render_services(self): # copied over from ops.testing._TestingPebbleClient._render_services() services = {} # type: Dict[str, pebble.Service] @@ -936,7 +961,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]: # in pebble, it just returns "nothing matched" if there are 0 matches, # but it ignores services it doesn't recognize continue - status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE) + status = self.service_statuses.get(name, pebble.ServiceStatus.INACTIVE) if service.startup == "": startup = pebble.ServiceStartup.DISABLED else: diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 3db6f8e84..7e717c96f 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -12,6 +12,7 @@ CloudCredential, CloudSpec, Container, + Exec, Model, Network, Notice, @@ -23,7 +24,6 @@ Storage, StoredState, SubordinateRelation, - _Action, _CharmSpec, _Event, ) @@ -181,6 +181,18 @@ def test_evt_bad_container_name(): ) +def test_duplicate_execs_in_container(): + container = Container( + "foo", + execs={Exec(["ls", "-l"], return_code=0), Exec(["ls", "-l"], return_code=1)}, + ) + assert_inconsistent( + State(containers=[container]), + _Event("foo-pebble-ready", container=container), + _CharmSpec(MyCharm, {"containers": {"foo": {}}}), + ) + + @pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) def test_evt_bad_relation_name(suffix): assert_inconsistent( diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py index dec93c4d8..621e40547 100644 --- a/tests/test_e2e/test_pebble.py +++ b/tests/test_e2e/test_pebble.py @@ -1,5 +1,6 @@ import dataclasses import datetime +import io import tempfile from pathlib import Path @@ -10,7 +11,7 @@ from ops.pebble import ExecError, ServiceStartup, ServiceStatus from scenario import Context -from scenario.state import CheckInfo, Container, ExecOutput, Mount, Notice, State +from scenario.state import CheckInfo, Container, Exec, Mount, Notice, State from tests.helpers import jsonpatch_delta, trigger @@ -193,7 +194,7 @@ def callback(self: CharmBase): container = self.unit.get_container("foo") proc = container.exec([cmd]) proc.wait() - assert proc.stdout.read() == "hello pebble" + assert proc.stdout.read() == out trigger( State( @@ -201,7 +202,7 @@ def callback(self: CharmBase): Container( name="foo", can_connect=True, - exec_mock={(cmd,): ExecOutput(stdout="hello pebble")}, + execs={Exec([cmd], stdout=out)}, ) } ), @@ -212,6 +213,32 @@ def callback(self: CharmBase): ) +@pytest.mark.parametrize( + "stdin,write", + ( + [None, "hello world!"], + ["hello world!", None], + [io.StringIO("hello world!"), None], + ), +) +def test_exec_history_stdin(stdin, write): + class MyCharm(CharmBase): + def __init__(self, framework: Framework): + super().__init__(framework) + self.framework.observe(self.on.foo_pebble_ready, self._on_ready) + + def _on_ready(self, _): + proc = self.unit.get_container("foo").exec(["ls"], stdin=stdin) + if write: + proc.stdin.write(write) + proc.wait() + + ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}}) + container = Container(name="foo", can_connect=True, execs={Exec([])}) + ctx.run(ctx.on.pebble_ready(container=container), State(containers={container})) + assert ctx.exec_history[container.name][0].stdin == "hello world!" + + def test_pebble_ready(charm_cls): def callback(self: CharmBase): foo = self.unit.get_container("foo") @@ -279,7 +306,7 @@ def _on_ready(self, event): } ) }, - service_status={ + service_statuses={ "fooserv": pebble.ServiceStatus.ACTIVE, # todo: should we disallow setting status for services that aren't known YET? "barserv": starting_service_status, @@ -312,7 +339,7 @@ def test_exec_wait_error(charm_cls): Container( name="foo", can_connect=True, - exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + execs={Exec(["foo"], stdout="hello pebble", return_code=1)}, ) } ) @@ -321,20 +348,19 @@ def test_exec_wait_error(charm_cls): with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") proc = container.exec(["foo"]) - with pytest.raises(ExecError): - proc.wait() - assert proc.stdout.read() == "hello pebble" + with pytest.raises(ExecError) as exc_info: + proc.wait_output() + assert exc_info.value.stdout == "hello pebble" -def test_exec_wait_output(charm_cls): +@pytest.mark.parametrize("command", (["foo"], ["foo", "bar"], ["foo", "bar", "baz"])) +def test_exec_wait_output(charm_cls, command): state = State( containers={ Container( name="foo", can_connect=True, - exec_mock={ - ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie") - }, + execs={Exec(["foo"], stdout="hello pebble", stderr="oepsie")}, ) } ) @@ -342,10 +368,11 @@ def test_exec_wait_output(charm_cls): ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}) with ctx(ctx.on.start(), state) as mgr: container = mgr.charm.unit.get_container("foo") - proc = container.exec(["foo"]) + proc = container.exec(command) out, err = proc.wait_output() assert out == "hello pebble" assert err == "oepsie" + assert ctx.exec_history[container.name][0].command == command def test_exec_wait_output_error(charm_cls): @@ -354,7 +381,7 @@ def test_exec_wait_output_error(charm_cls): Container( name="foo", can_connect=True, - exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)}, + execs={Exec(["foo"], stdout="hello pebble", return_code=1)}, ) } ) diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index 325cda667..d6e3aa5c5 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -279,9 +279,9 @@ def test_container_default_values(): assert container.name == name assert container.can_connect is False assert container.layers == {} - assert container.service_status == {} + assert container.service_statuses == {} assert container.mounts == {} - assert container.exec_mock == {} + assert container.execs == frozenset() assert container.layers == {} assert container._base_plan == {} From 33cb2e3931fa15f5da0f52ee2123b9960d9d5b3a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 29 Aug 2024 19:09:28 +1200 Subject: [PATCH 523/546] chore!: adjust privacy (#176) A collection of small changes that generally fall into "remove x from the public API": * Remove the `with_can_connect`, `with_leadership`, and `with_unit_status` convenience methods from `State`. * Makes `next_relation_id`, `next_action_id`, `next_storage_index`, and `next_notice_id` private. * Removes `Context.output_state`. * Makes all the *_SUFFIX constants private. * Makes all the *_EVENTS constants private, except `META_EVENTS`, which is removed. * Makes `capture_events` private (and consolidates capture_events.py into runtime.py). * Makes both `hook_tool_output_fmt` methods private. * Makes `normalize_name` private. * Moves all of the Scenario error exception classes (the ones that no-one should be catching) to a scenario.errors namespace/module. * Renames the consistency checker module to be private. * Makes `DEFAULT_JUJU_VERSION` and `DEFAULT_JUJU_DATABAG` private. * Adds various classes/types to the top-level scenario namespace for use in type annotations: * Removes `AnyRelation` in favour of using `RelationBase` * Removes `PathLike` in favour of `str|Path`. Fixes #175. --- README.md | 73 +-------- docs/custom_conf.py | 2 - docs/index.rst | 11 -- pyproject.toml | 5 - scenario/__init__.py | 58 ++++--- ...ncy_checker.py => _consistency_checker.py} | 16 +- scenario/capture_events.py | 101 ------------ scenario/context.py | 61 +++---- scenario/errors.py | 56 +++++++ scenario/mocking.py | 37 ++--- scenario/ops_main_mock.py | 21 +-- scenario/runtime.py | 136 +++++++++++++--- scenario/state.py | 149 +++++++----------- tests/helpers.py | 8 +- tests/test_consistency_checker.py | 22 +-- tests/test_context.py | 4 +- tests/test_e2e/test_actions.py | 4 +- tests/test_e2e/test_relations.py | 32 ++-- tests/test_e2e/test_state.py | 6 +- tests/test_emitted_events_util.py | 11 +- 20 files changed, 343 insertions(+), 470 deletions(-) rename scenario/{consistency_checker.py => _consistency_checker.py} (97%) delete mode 100644 scenario/capture_events.py create mode 100644 scenario/errors.py diff --git a/README.md b/README.md index bab3ab898..7204fb082 100644 --- a/README.md +++ b/README.md @@ -263,51 +263,6 @@ def test_emitted_full(): ] ``` -### Low-level access: using directly `capture_events` - -If you need more control over what events are captured (or you're not into pytest), you can use directly the context -manager that powers the `emitted_events` fixture: `scenario.capture_events`. -This context manager allows you to intercept any events emitted by the framework. - -Usage: - -```python -import scenario.capture_events - -with scenario.capture_events.capture_events() as emitted: - ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) - state_out = ctx.run( - ctx.on.update_status(), - scenario.State(deferred=[ctx.on.start().deferred(SimpleCharm._on_start)]) - ) - -# deferred events get reemitted first -assert isinstance(emitted[0], ops.StartEvent) -# the main Juju event gets emitted next -assert isinstance(emitted[1], ops.UpdateStatusEvent) -# possibly followed by a tail of all custom events that the main Juju event triggered in turn -# assert isinstance(emitted[2], MyFooEvent) -# ... -``` - -You can filter events by type like so: - -```python -import scenario.capture_events - -with scenario.capture_events.capture_events(ops.StartEvent, ops.RelationEvent) as emitted: - # capture all `start` and `*-relation-*` events. - pass -``` - -Configuration: - -- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(ops.EventBase)`. -- By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if - they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`. -- By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by - passing: `capture_events(include_deferred=False)`. - ## Relations You can write scenario tests to verify the shape of relation data: @@ -439,32 +394,6 @@ joined_event = ctx.on.relation_joined(relation=relation) The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario needs to set up the process that will run `ops.main` with the right environment variables. -### Working with relation IDs - -Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `id`. -To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`. - -```python -import scenario.state - -next_id = scenario.state.next_relation_id(update=False) -rel = scenario.Relation('foo') -assert rel.id == next_id -``` - -This can be handy when using `replace` to create new relations, to avoid relation ID conflicts: - -```python -import dataclasses -import scenario.state - -rel = scenario.Relation('foo') -rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) -assert rel2.id == rel.id + 1 -``` - -If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error. - ### Additional event parameters All relation events have some additional metadata that does not belong in the Relation object, such as, for a @@ -1231,7 +1160,7 @@ therefore, so far as we're concerned, that can't happen, and therefore we help y are consistent and raise an exception if that isn't so. That happens automatically behind the scenes whenever you trigger an event; -`scenario.consistency_checker.check_consistency` is called and verifies that the scenario makes sense. +`scenario._consistency_checker.check_consistency` is called and verifies that the scenario makes sense. ## Caveats: diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 10deb0096..70bf3e10f 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -306,10 +306,8 @@ def _compute_navigation_tree(context): # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ # 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._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), diff --git a/docs/index.rst b/docs/index.rst index 4d1af4d95..272af9596 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,17 +17,6 @@ scenario.Context .. automodule:: scenario.context -scenario.consistency_checker -============================ - -.. automodule:: scenario.consistency_checker - - -scenario.capture_events -======================= - -.. automodule:: scenario.capture_events - Indices ======= diff --git a/pyproject.toml b/pyproject.toml index 99f1be058..b1f030d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,11 +96,6 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" -[tool.pyright] -ignore = [ - "scenario/sequences.py", - "scenario/capture_events.py" -] [tool.isort] profile = "black" diff --git a/scenario/__init__.py b/scenario/__init__.py index 1b9416f8b..3439daa16 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + from scenario.context import Context, Manager from scenario.state import ( ActionFailed, ActiveStatus, Address, + AnyJson, BindAddress, BlockedStatus, + CharmType, CheckInfo, CloudCredential, CloudSpec, @@ -16,6 +19,7 @@ ErrorStatus, Exec, ICMPPort, + JujuLogLine, MaintenanceStatus, Model, Mount, @@ -23,7 +27,10 @@ Notice, PeerRelation, Port, + RawDataBagContents, + RawSecretRevisionContents, Relation, + RelationBase, Resource, Secret, State, @@ -33,43 +40,52 @@ SubordinateRelation, TCPPort, UDPPort, + UnitID, UnknownStatus, WaitingStatus, ) __all__ = [ "ActionFailed", + "ActiveStatus", + "Address", + "AnyJson", + "BindAddress", + "BlockedStatus", + "CharmType", "CheckInfo", "CloudCredential", "CloudSpec", + "Container", "Context", - "StateValidationError", - "Secret", - "Relation", - "SubordinateRelation", - "PeerRelation", - "Model", + "DeferredEvent", + "ErrorStatus", "Exec", + "ICMPPort", + "JujuLogLine", + "MaintenanceStatus", + "Manager", + "Model", "Mount", - "Container", - "Notice", - "Address", - "BindAddress", "Network", + "Notice", + "PeerRelation", "Port", - "ICMPPort", - "TCPPort", - "UDPPort", + "RawDataBagContents", + "RawSecretRevisionContents", + "Relation", + "RelationBase", "Resource", + "Secret", + "State", + "StateValidationError", "Storage", "StoredState", - "State", - "DeferredEvent", - "ErrorStatus", - "BlockedStatus", - "WaitingStatus", - "MaintenanceStatus", - "ActiveStatus", + "SubordinateRelation", + "TCPPort", + "UDPPort", + "UnitID", "UnknownStatus", - "Manager", + "WaitingStatus", + "deferred", ] diff --git a/scenario/consistency_checker.py b/scenario/_consistency_checker.py similarity index 97% rename from scenario/consistency_checker.py rename to scenario/_consistency_checker.py index c2205540b..68fd3c24f 100644 --- a/scenario/consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -9,14 +9,14 @@ from numbers import Number from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union -from scenario.runtime import InconsistentScenarioError +from scenario.errors import InconsistentScenarioError from scenario.runtime import logger as scenario_logger from scenario.state import ( PeerRelation, SubordinateRelation, _Action, _CharmSpec, - normalize_name, + _normalise_name, ) if TYPE_CHECKING: # pragma: no cover @@ -170,7 +170,7 @@ def _check_relation_event( "Please pass one.", ) else: - if not event.name.startswith(normalize_name(event.relation.endpoint)): + if not event.name.startswith(_normalise_name(event.relation.endpoint)): errors.append( f"relation event should start with relation endpoint name. {event.name} does " f"not start with {event.relation.endpoint}.", @@ -194,7 +194,7 @@ def _check_workload_event( "Please pass one.", ) else: - if not event.name.startswith(normalize_name(event.container.name)): + if not event.name.startswith(_normalise_name(event.container.name)): errors.append( f"workload event should start with container name. {event.name} does " f"not start with {event.container.name}.", @@ -231,7 +231,7 @@ def _check_action_event( ) return - elif not event.name.startswith(normalize_name(action.name)): + elif not event.name.startswith(_normalise_name(action.name)): errors.append( f"action event should start with action name. {event.name} does " f"not start with {action.name}.", @@ -261,7 +261,7 @@ def _check_storage_event( "cannot construct a storage event without the Storage instance. " "Please pass one.", ) - elif not event.name.startswith(normalize_name(storage.name)): + elif not event.name.startswith(_normalise_name(storage.name)): errors.append( f"storage event should start with storage name. {event.name} does " f"not start with {storage.name}.", @@ -566,8 +566,8 @@ def check_containers_consistency( # event names will be normalized; need to compare against normalized container names. meta = charm_spec.meta - meta_containers = list(map(normalize_name, meta.get("containers", {}))) - state_containers = [normalize_name(c.name) for c in state.containers] + meta_containers = list(map(_normalise_name, meta.get("containers", {}))) + state_containers = [_normalise_name(c.name) for c in state.containers] all_notices = {notice.id for c in state.containers for notice in c.notices} all_checks = { (c.name, check.name) for c in state.containers for check in c.check_infos diff --git a/scenario/capture_events.py b/scenario/capture_events.py deleted file mode 100644 index 3b0947978..000000000 --- a/scenario/capture_events.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import typing -from contextlib import contextmanager -from typing import Type, TypeVar - -from ops import CollectStatusEvent -from ops.framework import ( - CommitEvent, - EventBase, - Framework, - Handle, - NoTypeError, - PreCommitEvent, -) - -_T = TypeVar("_T", bound=EventBase) - - -@contextmanager -def capture_events( - *types: Type[EventBase], - include_framework=False, - include_deferred=True, -): - """Capture all events of type `*types` (using instance checks). - - Arguments exposed so that you can define your own fixtures if you want to. - - Example:: - >>> from ops.charm import StartEvent - >>> from scenario import Event, State - >>> from charm import MyCustomEvent, MyCharm # noqa - >>> - >>> def test_my_event(): - >>> with capture_events(StartEvent, MyCustomEvent) as captured: - >>> trigger(State(), ("start", MyCharm, meta=MyCharm.META) - >>> - >>> assert len(captured) == 2 - >>> e1, e2 = captured - >>> assert isinstance(e2, MyCustomEvent) - >>> assert e2.custom_attr == 'foo' - """ - allowed_types = types or (EventBase,) - - captured = [] - _real_emit = Framework._emit - _real_reemit = Framework.reemit - - def _wrapped_emit(self, evt): - if not include_framework and isinstance( - evt, - (PreCommitEvent, CommitEvent, CollectStatusEvent), - ): - return _real_emit(self, evt) - - if isinstance(evt, allowed_types): - # dump/undump the event to ensure any custom attributes are (re)set by restore() - evt.restore(evt.snapshot()) - captured.append(evt) - - return _real_emit(self, evt) - - def _wrapped_reemit(self): - # Framework calls reemit() before emitting the main juju event. We intercept that call - # and capture all events in storage. - - if not include_deferred: - return _real_reemit(self) - - # load all notices from storage as events. - for event_path, _, _ in self._storage.notices(): - event_handle = Handle.from_path(event_path) - try: - event = self.load_snapshot(event_handle) - except NoTypeError: - continue - event = typing.cast(EventBase, event) - event.deferred = False - self._forget(event) # prevent tracking conflicts - - if not include_framework and isinstance( - event, - (PreCommitEvent, CommitEvent), - ): - continue - - if isinstance(event, allowed_types): - captured.append(event) - - return _real_reemit(self) - - Framework._emit = _wrapped_emit # type: ignore - Framework.reemit = _wrapped_reemit # type: ignore - - yield captured - - Framework._emit = _real_emit # type: ignore - Framework.reemit = _real_reemit # type: ignore diff --git a/scenario/context.py b/scenario/context.py index 0f7ca1e13..677597892 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -9,6 +9,7 @@ from ops import CharmBase, EventBase from ops.testing import ExecArgs +from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger from scenario.runtime import Runtime from scenario.state import ( @@ -28,29 +29,11 @@ from ops.testing import CharmType from scenario.ops_main_mock import Ops - from scenario.state import AnyJson, AnyRelation, JujuLogLine, State, _EntityStatus - - PathLike = Union[str, Path] + from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus logger = scenario_logger.getChild("runtime") -DEFAULT_JUJU_VERSION = "3.4" - - -class InvalidEventError(RuntimeError): - """raised when something is wrong with the event passed to Context.run""" - - -class InvalidActionError(InvalidEventError): - """raised when something is wrong with an action passed to Context.run""" - - -class ContextSetupError(RuntimeError): - """Raised by Context when setup fails.""" - - -class AlreadyEmittedError(RuntimeError): - """Raised when ``run()`` is called more than once.""" +_DEFAULT_JUJU_VERSION = "3.5" class Manager: @@ -218,11 +201,11 @@ def collect_unit_status(): return _Event("collect_unit_status") @staticmethod - def relation_created(relation: "AnyRelation"): + def relation_created(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod - def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", relation=relation, @@ -230,7 +213,11 @@ def relation_joined(relation: "AnyRelation", *, remote_unit: Optional[int] = Non ) @staticmethod - def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = None): + def relation_changed( + relation: "RelationBase", + *, + remote_unit: Optional[int] = None, + ): return _Event( f"{relation.endpoint}_relation_changed", relation=relation, @@ -239,7 +226,7 @@ def relation_changed(relation: "AnyRelation", *, remote_unit: Optional[int] = No @staticmethod def relation_departed( - relation: "AnyRelation", + relation: "RelationBase", *, remote_unit: Optional[int] = None, departing_unit: Optional[int] = None, @@ -252,7 +239,7 @@ def relation_departed( ) @staticmethod - def relation_broken(relation: "AnyRelation"): + def relation_broken(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_broken", relation=relation) @staticmethod @@ -384,8 +371,8 @@ def __init__( *, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = DEFAULT_JUJU_VERSION, + charm_root: Optional[Union[str, Path]] = None, + juju_version: str = _DEFAULT_JUJU_VERSION, capture_deferred_events: bool = False, capture_framework_events: bool = False, app_name: Optional[str] = None, @@ -471,19 +458,6 @@ def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" self._output_state = output_state - @property - def output_state(self) -> "State": - """The output state obtained by running an event on this context. - - Raises: - RuntimeError: if this ``Context`` hasn't been :meth:`run` yet. - """ - if not self._output_state: - raise RuntimeError( - "No output state available. ``.run()`` this Context first.", - ) - return self._output_state - def _get_container_root(self, container_name: str): """Get the path to a tempdir where this container's simulated root will live.""" return Path(self._tmp.name) / "containers" / container_name @@ -538,10 +512,13 @@ def run(self, event: "_Event", state: "State") -> "State": self._action_failure_message = None with self._run(event=event, state=state) as ops: ops.emit() + # We know that the output state will have been set by this point, + # so let the type checkers know that too. + assert self._output_state is not None if event.action: if self._action_failure_message is not None: - raise ActionFailed(self._action_failure_message, self.output_state) - return self.output_state + raise ActionFailed(self._action_failure_message, self._output_state) + return self._output_state @contextmanager def _run(self, event: "_Event", state: "State"): diff --git a/scenario/errors.py b/scenario/errors.py new file mode 100644 index 000000000..56a01d126 --- /dev/null +++ b/scenario/errors.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Exceptions raised by the framework. + +Note that these exceptions are not meant to be caught by charm authors. They are +used by the framework to signal errors or inconsistencies in the charm tests +themselves. +""" + + +class ContextSetupError(RuntimeError): + """Raised by Context when setup fails.""" + + +class AlreadyEmittedError(RuntimeError): + """Raised when ``run()`` is called more than once.""" + + +class ScenarioRuntimeError(RuntimeError): + """Base class for exceptions raised by the runtime module.""" + + +class UncaughtCharmError(ScenarioRuntimeError): + """Error raised if the charm raises while handling the event being dispatched.""" + + +class InconsistentScenarioError(ScenarioRuntimeError): + """Error raised when the combination of state and event is inconsistent.""" + + +class StateValidationError(RuntimeError): + """Raised when individual parts of the State are inconsistent.""" + + # as opposed to InconsistentScenario error where the **combination** of + # several parts of the State are. + + +class MetadataNotFoundError(RuntimeError): + """Raised when Scenario can't find a metadata file in the provided charm root.""" + + +class ActionMissingFromContextError(Exception): + """Raised when the user attempts to invoke action hook tools outside an action context.""" + + # This is not an ops error: in ops, you'd have to go exceptionally out of + # your way to trigger this flow. + + +class NoObserverError(RuntimeError): + """Error raised when the event being dispatched has no registered observers.""" + + +class BadOwnerPath(RuntimeError): + """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" diff --git a/scenario/mocking.py b/scenario/mocking.py index b1a60a7d7..f5207a378 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -35,13 +35,17 @@ from ops.pebble import Client, ExecError from ops.testing import ExecArgs, _TestingPebbleClient +from scenario.errors import ActionMissingFromContextError from scenario.logger import logger as scenario_logger from scenario.state import ( JujuLogLine, Mount, Network, PeerRelation, + Relation, + RelationBase, Storage, + SubordinateRelation, _EntityStatus, _port_cls_by_protocol, _RawPortProtocolLiteral, @@ -51,26 +55,11 @@ if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import Container as ContainerSpec - from scenario.state import ( - Exec, - Relation, - Secret, - State, - SubordinateRelation, - _CharmSpec, - _Event, - ) + from scenario.state import Exec, Secret, State, _CharmSpec, _Event logger = scenario_logger.getChild("mocking") -class ActionMissingFromContextError(Exception): - """Raised when the user attempts to invoke action hook tools outside an action context.""" - - # This is not an ops error: in ops, you'd have to go exceptionally out of your way to trigger - # this flow. - - class _MockExecProcess: def __init__( self, @@ -189,10 +178,7 @@ def get_pebble(self, socket_path: str) -> "Client": container_name=container_name, ) - def _get_relation_by_id( - self, - rel_id, - ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]: + def _get_relation_by_id(self, rel_id) -> "RelationBase": try: return self._state.get_relation(rel_id) except ValueError: @@ -254,7 +240,10 @@ def relation_get(self, relation_id: int, member_name: str, is_app: bool): elif is_app: if isinstance(relation, PeerRelation): return relation.local_app_data - return relation.remote_app_data + elif isinstance(relation, (Relation, SubordinateRelation)): + return relation.remote_app_data + else: + raise TypeError("relation_get: unknown relation type") elif member_name == self.unit_name: return relation.local_unit_data @@ -337,7 +326,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None): network = self._state.get_network(binding_name) except KeyError: network = Network("default") # The name is not used in the output. - return network.hook_tool_output_fmt() + return network._hook_tool_output_fmt() # setter methods: these can mutate the state. def application_version_set(self, version: str): @@ -570,8 +559,10 @@ def relation_remote_app_name( if isinstance(relation, PeerRelation): return self.app_name - else: + elif isinstance(relation, (Relation, SubordinateRelation)): return relation.remote_app_name + else: + raise TypeError("relation_remote_app_name: unknown relation type") def action_set(self, results: Dict[str, Any]): if not self._event.action: diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index b9bcbb8f7..cc7391ccb 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -19,6 +19,8 @@ from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args from ops.main import logger as ops_logger +from scenario.errors import BadOwnerPath, NoObserverError + if TYPE_CHECKING: # pragma: no cover from scenario.context import Context from scenario.state import State, _CharmSpec, _Event @@ -26,25 +28,6 @@ # pyright: reportPrivateUsage=false -class NoObserverError(RuntimeError): - """Error raised when the event being dispatched has no registered observers.""" - - -class BadOwnerPath(RuntimeError): - """Error raised when the owner path does not lead to a valid ObjectEvents instance.""" - - -# TODO: Use ops.jujucontext's _JujuContext.charm_dir. -def _get_charm_dir(): - charm_dir = os.environ.get("JUJU_CHARM_DIR") - if charm_dir is None: - # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. - charm_dir = pathlib.Path(f"{__file__}/../../..").resolve() - else: - charm_dir = pathlib.Path(charm_dir).resolve() - return charm_dir - - def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: """Walk path on root to an ObjectEvents instance.""" obj = root diff --git a/scenario/runtime.py b/scenario/runtime.py index e853c682e..754829c0b 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -10,17 +10,32 @@ import typing from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, Union +from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, TypeVar, Union import yaml -from ops import pebble -from ops.framework import _event_regex +from ops import CollectStatusEvent, pebble +from ops.framework import ( + CommitEvent, + EventBase, + Framework, + Handle, + NoTypeError, + PreCommitEvent, + _event_regex, +) from ops.storage import NoSnapshotError, SQLiteStorage -from scenario.capture_events import capture_events +from scenario.errors import UncaughtCharmError from scenario.logger import logger as scenario_logger from scenario.ops_main_mock import NoObserverError -from scenario.state import ActionFailed, DeferredEvent, PeerRelation, StoredState +from scenario.state import ( + ActionFailed, + DeferredEvent, + PeerRelation, + Relation, + StoredState, + SubordinateRelation, +) if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -28,8 +43,6 @@ from scenario.context import Context from scenario.state import State, _CharmSpec, _Event - PathLike = Union[str, Path] - logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( r"((?P.*)\/)?(?P<_data_type_name>\D+)\[(?P.*)\]", @@ -39,18 +52,6 @@ RUNTIME_MODULE = Path(__file__).parent -class ScenarioRuntimeError(RuntimeError): - """Base class for exceptions raised by scenario.runtime.""" - - -class UncaughtCharmError(ScenarioRuntimeError): - """Error raised if the charm raises while handling the event being dispatched.""" - - -class InconsistentScenarioError(ScenarioRuntimeError): - """Error raised when the combination of state and event is inconsistent.""" - - class UnitStateDB: """Represents the unit-state.db.""" @@ -156,7 +157,7 @@ class Runtime: def __init__( self, charm_spec: "_CharmSpec", - charm_root: Optional["PathLike"] = None, + charm_root: Optional[Union[str, Path]] = None, juju_version: str = "3.0.0", app_name: Optional[str] = None, unit_id: Optional[int] = 0, @@ -206,8 +207,10 @@ def _get_event_env(self, state: "State", event: "_Event", charm_root: Path): if event._is_relation_event and (relation := event.relation): if isinstance(relation, PeerRelation): remote_app_name = self._app_name - else: + elif isinstance(relation, (Relation, SubordinateRelation)): remote_app_name = relation.remote_app_name + else: + raise ValueError(f"Unknown relation type: {relation}") env.update( { "JUJU_RELATION": relation.endpoint, @@ -398,8 +401,8 @@ def _close_storage(self, state: "State", temporary_charm_root: Path): def _exec_ctx(self, ctx: "Context"): """python 3.8 compatibility shim""" with self._virtual_charm_root() as temporary_charm_root: - # todo allow customizing capture_events - with capture_events( + # TODO: allow customising capture_events + with _capture_events( include_deferred=ctx.capture_deferred_events, include_framework=ctx.capture_framework_events, ) as captured: @@ -423,7 +426,7 @@ def exec( # todo consider forking out a real subprocess and do the mocking by # mocking hook tool executables - from scenario.consistency_checker import check_consistency # avoid cycles + from scenario._consistency_checker import check_consistency # avoid cycles check_consistency(state, event, self._charm_spec, self._juju_version) @@ -485,3 +488,88 @@ def exec( context.emitted_events.extend(captured) logger.info("event dispatched. done.") context._set_output_state(output_state) + + +_T = TypeVar("_T", bound=EventBase) + + +@contextmanager +def _capture_events( + *types: Type[EventBase], + include_framework=False, + include_deferred=True, +): + """Capture all events of type `*types` (using instance checks). + + Arguments exposed so that you can define your own fixtures if you want to. + + Example:: + >>> from ops.charm import StartEvent + >>> from scenario import Event, State + >>> from charm import MyCustomEvent, MyCharm # noqa + >>> + >>> def test_my_event(): + >>> with capture_events(StartEvent, MyCustomEvent) as captured: + >>> trigger(State(), ("start", MyCharm, meta=MyCharm.META) + >>> + >>> assert len(captured) == 2 + >>> e1, e2 = captured + >>> assert isinstance(e2, MyCustomEvent) + >>> assert e2.custom_attr == 'foo' + """ + allowed_types = types or (EventBase,) + + captured = [] + _real_emit = Framework._emit + _real_reemit = Framework.reemit + + def _wrapped_emit(self, evt): + if not include_framework and isinstance( + evt, + (PreCommitEvent, CommitEvent, CollectStatusEvent), + ): + return _real_emit(self, evt) + + if isinstance(evt, allowed_types): + # dump/undump the event to ensure any custom attributes are (re)set by restore() + evt.restore(evt.snapshot()) + captured.append(evt) + + return _real_emit(self, evt) + + def _wrapped_reemit(self): + # Framework calls reemit() before emitting the main juju event. We intercept that call + # and capture all events in storage. + + if not include_deferred: + return _real_reemit(self) + + # load all notices from storage as events. + for event_path, _, _ in self._storage.notices(): + event_handle = Handle.from_path(event_path) + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + continue + event = typing.cast(EventBase, event) + event.deferred = False + self._forget(event) # prevent tracking conflicts + + if not include_framework and isinstance( + event, + (PreCommitEvent, CommitEvent), + ): + continue + + if isinstance(event, allowed_types): + captured.append(event) + + return _real_reemit(self) + + Framework._emit = _wrapped_emit # type: ignore + Framework.reemit = _wrapped_reemit # type: ignore + + yield captured + + Framework._emit = _real_emit # type: ignore + Framework.reemit = _real_reemit # type: ignore diff --git a/scenario/state.py b/scenario/state.py index 7f1e39e96..33d5f2809 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -45,6 +45,7 @@ from ops.model import CloudSpec as CloudSpec_Ops from ops.model import SecretRotate, StatusBase +from scenario.errors import MetadataNotFoundError, StateValidationError from scenario.logger import logger as scenario_logger JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) @@ -52,8 +53,6 @@ if TYPE_CHECKING: # pragma: no cover from scenario import Context -PathLike = Union[str, Path] -AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"] AnyJson = Union[str, bool, dict, int, float, list] RawSecretRevisionContents = RawDataBagContents = Dict[str, str] UnitID = int @@ -67,9 +66,9 @@ BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS" DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES" -ACTION_EVENT_SUFFIX = "_action" +_ACTION_EVENT_SUFFIX = "_action" # all builtin events except secret events. They're special because they carry secret metadata. -BUILTIN_EVENTS = { +_BUILTIN_EVENTS = { "start", "stop", "install", @@ -86,53 +85,35 @@ "leader_settings_changed", "collect_metrics", } -FRAMEWORK_EVENTS = { +_FRAMEWORK_EVENTS = { "pre_commit", "commit", "collect_app_status", "collect_unit_status", } -PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" -PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" -PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" -PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" -RELATION_EVENTS_SUFFIX = { +_PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready" +_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice" +_PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed" +_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered" +_RELATION_EVENTS_SUFFIX = { "_relation_changed", "_relation_broken", "_relation_joined", "_relation_departed", "_relation_created", } -STORAGE_EVENTS_SUFFIX = { +_STORAGE_EVENTS_SUFFIX = { "_storage_detaching", "_storage_attached", } -SECRET_EVENTS = { +_SECRET_EVENTS = { "secret_changed", "secret_remove", "secret_rotate", "secret_expired", } -META_EVENTS = { - "CREATE_ALL_RELATIONS": "_relation_created", - "BREAK_ALL_RELATIONS": "_relation_broken", - "DETACH_ALL_STORAGES": "_storage_detaching", - "ATTACH_ALL_STORAGES": "_storage_attached", -} - - -class StateValidationError(RuntimeError): - """Raised when individual parts of the State are inconsistent.""" - - # as opposed to InconsistentScenario error where the - # **combination** of several parts of the State are. - - -class MetadataNotFoundError(RuntimeError): - """Raised when Scenario can't find a metadata.yaml file in the provided charm root.""" - class ActionFailed(Exception): """Raised at the end of the hook if the charm has called `event.fail()`.""" @@ -362,7 +343,7 @@ def _update_metadata( object.__setattr__(self, "rotate", rotate) -def normalize_name(s: str): +def _normalise_name(s: str): """Event names, in Scenario, uniformly use underscores instead of dashes.""" return s.replace("-", "_") @@ -397,7 +378,7 @@ class BindAddress(_max_posargs(1)): interface_name: str = "" mac_address: Optional[str] = None - def hook_tool_output_fmt(self): + def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would # todo support for legacy (deprecated) `interfacename` and `macaddress` fields? dct = { @@ -425,10 +406,12 @@ class Network(_max_posargs(2)): def __hash__(self) -> int: return hash(self.binding_name) - def hook_tool_output_fmt(self): + def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would return { - "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses], + "bind-addresses": [ + ba._hook_tool_output_fmt() for ba in self.bind_addresses + ], "egress-subnets": self.egress_subnets, "ingress-addresses": self.ingress_addresses, } @@ -437,7 +420,7 @@ def hook_tool_output_fmt(self): _next_relation_id_counter = 1 -def next_relation_id(*, update=True): +def _next_relation_id(*, update=True): global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -454,7 +437,7 @@ class RelationBase(_max_posargs(2)): """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. If left empty, it will be automatically derived from metadata.yaml.""" - id: int = dataclasses.field(default_factory=next_relation_id) + id: int = dataclasses.field(default_factory=_next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, if there's trouble, override.""" @@ -462,7 +445,7 @@ class RelationBase(_max_posargs(2)): """This application's databag for this relation.""" local_unit_data: "RawDataBagContents" = dataclasses.field( - default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) """This unit's databag for this relation.""" @@ -510,8 +493,8 @@ def _validate_databag(self, databag: dict): ) -_DEFAULT_IP = " 192.0.2.0" -DEFAULT_JUJU_DATABAG = { +_DEFAULT_IP = "192.0.2.0" +_DEFAULT_JUJU_DATABAG = { "egress-subnets": _DEFAULT_IP, "ingress-address": _DEFAULT_IP, "private-address": _DEFAULT_IP, @@ -531,7 +514,7 @@ class Relation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) """The current content of the application databag.""" remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, # dedup + default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()}, # dedup ) """The current content of the databag for each unit in the relation.""" @@ -565,7 +548,7 @@ def _databags(self): class SubordinateRelation(RelationBase): remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) remote_unit_data: "RawDataBagContents" = dataclasses.field( - default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(), + default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) # app name and ID of the remote unit that *this unit* is attached to. @@ -607,7 +590,7 @@ class PeerRelation(RelationBase): """A relation to share data between units of the charm.""" peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field( - default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()}, + default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()}, ) """Current contents of the peer databags.""" # Consistency checks will validate that *this unit*'s ID is not in here. @@ -729,7 +712,7 @@ def _now_utc(): _next_notice_id_counter = 1 -def next_notice_id(*, update=True): +def _next_notice_id(*, update=True): global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -746,7 +729,7 @@ class Notice(_max_posargs(1)): ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``. """ - id: str = dataclasses.field(default_factory=next_notice_id) + id: str = dataclasses.field(default_factory=_next_notice_id) """Unique ID for this notice.""" user_id: Optional[int] = None @@ -1212,7 +1195,7 @@ def __post_init__(self): _next_storage_index_counter = 0 # storage indices start at 0 -def next_storage_index(*, update=True): +def _next_storage_index(*, update=True): """Get the index (used to be called ID) the next Storage to be created will get. Pass update=False if you're only inspecting it. @@ -1231,7 +1214,7 @@ class Storage(_max_posargs(1)): name: str - index: int = dataclasses.field(default_factory=next_storage_index) + index: int = dataclasses.field(default_factory=_next_storage_index) # Every new Storage instance gets a new one, if there's trouble, override. def __eq__(self, other: object) -> bool: @@ -1249,7 +1232,7 @@ class Resource(_max_posargs(0)): """Represents a resource made available to the charm.""" name: str - path: "PathLike" + path: Union[str, Path] @dataclasses.dataclass(frozen=True) @@ -1265,7 +1248,7 @@ class State(_max_posargs(0)): default_factory=dict, ) """The present configuration of this charm.""" - relations: Iterable["AnyRelation"] = dataclasses.field(default_factory=frozenset) + relations: Iterable["RelationBase"] = dataclasses.field(default_factory=frozenset) """All relations that currently exist for this charm.""" networks: Iterable[Network] = dataclasses.field(default_factory=frozenset) """Manual overrides for any relation and extra bindings currently provisioned for this charm. @@ -1394,24 +1377,6 @@ def _update_secrets(self, new_secrets: FrozenSet[Secret]): # bypass frozen dataclass object.__setattr__(self, "secrets", new_secrets) - def with_can_connect(self, container_name: str, can_connect: bool) -> "State": - def replacer(container: Container): - if container.name == container_name: - return dataclasses.replace(container, can_connect=can_connect) - return container - - ctrs = tuple(map(replacer, self.containers)) - return dataclasses.replace(self, containers=ctrs) - - def with_leadership(self, leader: bool) -> "State": - return dataclasses.replace(self, leader=leader) - - def with_unit_status(self, status: StatusBase) -> "State": - return dataclasses.replace( - self, - unit_status=_EntityStatus.from_ops(status), - ) - def get_container(self, container: str, /) -> Container: """Get container from this State, based on its name.""" for state_container in self.containers: @@ -1473,14 +1438,14 @@ def get_storage( f"storage: name={storage}, index={index} not found in the State", ) - def get_relation(self, relation: int, /) -> "AnyRelation": + def get_relation(self, relation: int, /) -> "RelationBase": """Get relation from this State, based on the relation's id.""" for state_relation in self.relations: if state_relation.id == relation: return state_relation raise KeyError(f"relation: id={relation} not found in the State") - def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: + def get_relations(self, endpoint: str) -> Tuple["RelationBase", ...]: """Get all relations on this endpoint from the current state.""" # we rather normalize the endpoint than worry about cursed metadata situations such as: @@ -1488,11 +1453,11 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]: # foo-bar: ... # foo_bar: ... - normalized_endpoint = normalize_name(endpoint) + normalized_endpoint = _normalise_name(endpoint) return tuple( r for r in self.relations - if normalize_name(r.endpoint) == normalized_endpoint + if _normalise_name(r.endpoint) == normalized_endpoint ) @@ -1643,7 +1608,7 @@ class _EventPath(str): type: _EventType def __new__(cls, string): - string = normalize_name(string) + string = _normalise_name(string) instance = super().__new__(cls, string) instance.name = name = string.split(".")[-1] @@ -1662,35 +1627,35 @@ def __new__(cls, string): @staticmethod def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: - for suffix in RELATION_EVENTS_SUFFIX: + for suffix in _RELATION_EVENTS_SUFFIX: if s.endswith(suffix): return suffix, _EventType.relation - if s.endswith(ACTION_EVENT_SUFFIX): - return ACTION_EVENT_SUFFIX, _EventType.action + if s.endswith(_ACTION_EVENT_SUFFIX): + return _ACTION_EVENT_SUFFIX, _EventType.action - if s in SECRET_EVENTS: + if s in _SECRET_EVENTS: return s, _EventType.secret - if s in FRAMEWORK_EVENTS: + if s in _FRAMEWORK_EVENTS: return s, _EventType.framework # Whether the event name indicates that this is a storage event. - for suffix in STORAGE_EVENTS_SUFFIX: + for suffix in _STORAGE_EVENTS_SUFFIX: if s.endswith(suffix): return suffix, _EventType.storage # Whether the event name indicates that this is a workload event. - if s.endswith(PEBBLE_READY_EVENT_SUFFIX): - return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): - return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CHECK_FAILED_EVENT_SUFFIX): - return PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload - if s.endswith(PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): - return PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload - - if s in BUILTIN_EVENTS: + if s.endswith(_PEBBLE_READY_EVENT_SUFFIX): + return _PEBBLE_READY_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX): + return _PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CHECK_FAILED_EVENT_SUFFIX): + return _PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload + if s.endswith(_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX): + return _PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload + + if s in _BUILTIN_EVENTS: return "", _EventType.builtin return "", _EventType.custom @@ -1711,7 +1676,7 @@ class Event: storage: Optional["Storage"] = None """If this is a storage event, the storage it refers to.""" - relation: Optional["AnyRelation"] = None + relation: Optional["RelationBase"] = None """If this is a relation event, the relation it refers to.""" relation_remote_unit_id: Optional[int] = None relation_departed_unit_id: Optional[int] = None @@ -1860,8 +1825,10 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: # FIXME: relation.unit for peers should point to , but we # don't have access to the local app name in this context. remote_app = "local" - else: + elif isinstance(relation, (Relation, SubordinateRelation)): remote_app = relation.remote_app_name + else: + raise RuntimeError(f"unexpected relation type: {relation!r}") snapshot_data.update( { @@ -1915,7 +1882,7 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: _next_action_id_counter = 1 -def next_action_id(*, update=True): +def _next_action_id(*, update=True): global _next_action_id_counter cur = _next_action_id_counter if update: @@ -1946,7 +1913,7 @@ def test_backup_action(): params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) """Parameter values passed to the action.""" - id: str = dataclasses.field(default_factory=next_action_id) + id: str = dataclasses.field(default_factory=_next_action_id) """Juju action ID. Every action invocation is automatically assigned a new one. Override in diff --git a/tests/helpers.py b/tests/helpers.py index 82161c79b..5ceffa9d2 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -15,7 +15,7 @@ import jsonpatch -from scenario.context import DEFAULT_JUJU_VERSION, Context +from scenario.context import _DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover from ops.testing import CharmType @@ -24,8 +24,6 @@ _CT = TypeVar("_CT", bound=Type[CharmType]) - PathLike = Union[str, Path] - logger = logging.getLogger() @@ -38,8 +36,8 @@ def trigger( meta: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None, - charm_root: Optional["PathLike"] = None, - juju_version: str = DEFAULT_JUJU_VERSION, + charm_root: Optional[Union[str, Path]] = None, + juju_version: str = _DEFAULT_JUJU_VERSION, ) -> "State": ctx = Context( charm_type=charm_type, diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py index 7e717c96f..e585d10e9 100644 --- a/tests/test_consistency_checker.py +++ b/tests/test_consistency_checker.py @@ -3,11 +3,11 @@ import pytest from ops.charm import CharmBase -from scenario.consistency_checker import check_consistency +from scenario._consistency_checker import check_consistency from scenario.context import Context -from scenario.runtime import InconsistentScenarioError +from scenario.errors import InconsistentScenarioError from scenario.state import ( - RELATION_EVENTS_SUFFIX, + _RELATION_EVENTS_SUFFIX, CheckInfo, CloudCredential, CloudSpec, @@ -181,19 +181,7 @@ def test_evt_bad_container_name(): ) -def test_duplicate_execs_in_container(): - container = Container( - "foo", - execs={Exec(["ls", "-l"], return_code=0), Exec(["ls", "-l"], return_code=1)}, - ) - assert_inconsistent( - State(containers=[container]), - _Event("foo-pebble-ready", container=container), - _CharmSpec(MyCharm, {"containers": {"foo": {}}}), - ) - - -@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX) def test_evt_bad_relation_name(suffix): assert_inconsistent( State(), @@ -208,7 +196,7 @@ def test_evt_bad_relation_name(suffix): ) -@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX) +@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX) def test_evt_no_relation(suffix): assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {})) relation = Relation("bar") diff --git a/tests/test_context.py b/tests/test_context.py index 361b4543b..0d55ca9e0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,7 +4,7 @@ from ops import CharmBase from scenario import Context, State -from scenario.state import _Event, next_action_id +from scenario.state import _Event, _next_action_id class MyCharm(CharmBase): @@ -32,7 +32,7 @@ def test_run(): def test_run_action(): ctx = Context(MyCharm, meta={"name": "foo"}) state = State() - expected_id = next_action_id(update=False) + expected_id = _next_action_id(update=False) with patch.object(ctx, "_run") as p: ctx._output_state = "foo" # would normally be set within the _run call scope diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py index 7b6d17277..0ab845b99 100644 --- a/tests/test_e2e/test_actions.py +++ b/tests/test_e2e/test_actions.py @@ -4,7 +4,7 @@ from ops.framework import Framework from scenario import ActionFailed, Context -from scenario.state import State, _Action, next_action_id +from scenario.state import State, _Action, _next_action_id @pytest.fixture(scope="function") @@ -199,7 +199,7 @@ def test_positional_arguments(): def test_default_arguments(): - expected_id = next_action_id(update=False) + expected_id = _next_action_id(update=False) name = "foo" action = _Action(name) assert action.name == name diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index 44433e216..b78804254 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -14,14 +14,14 @@ from scenario import Context from scenario.state import ( - DEFAULT_JUJU_DATABAG, + _DEFAULT_JUJU_DATABAG, PeerRelation, Relation, RelationBase, State, StateValidationError, SubordinateRelation, - next_relation_id, + _next_relation_id, ) from tests.helpers import trigger @@ -265,19 +265,19 @@ def callback(charm: CharmBase, event): def test_relation_default_unit_data_regular(): relation = Relation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} def test_relation_default_unit_data_sub(): relation = SubordinateRelation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG def test_relation_default_unit_data_peer(): relation = PeerRelation("baz") - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG @pytest.mark.parametrize( @@ -431,7 +431,7 @@ def test_relation_positional_arguments(klass): def test_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "database" interface = "postgresql" relation = Relation(endpoint, interface) @@ -439,15 +439,15 @@ def test_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG assert relation.remote_app_name == "remote" assert relation.limit == 1 assert relation.remote_app_data == {} - assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} def test_subordinate_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "database" interface = "postgresql" relation = SubordinateRelation(endpoint, interface) @@ -455,15 +455,15 @@ def test_subordinate_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG assert relation.remote_app_name == "remote" assert relation.remote_unit_id == 0 assert relation.remote_app_data == {} - assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG def test_peer_relation_default_values(): - expected_id = next_relation_id(update=False) + expected_id = _next_relation_id(update=False) endpoint = "peers" interface = "shared" relation = PeerRelation(endpoint, interface) @@ -471,5 +471,5 @@ def test_peer_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == DEFAULT_JUJU_DATABAG - assert relation.peers_data == {0: DEFAULT_JUJU_DATABAG} + assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.peers_data == {0: _DEFAULT_JUJU_DATABAG} diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py index d6e3aa5c5..9cd1e9c00 100644 --- a/tests/test_e2e/test_state.py +++ b/tests/test_e2e/test_state.py @@ -8,7 +8,7 @@ from ops.model import ActiveStatus, UnknownStatus, WaitingStatus from scenario.state import ( - DEFAULT_JUJU_DATABAG, + _DEFAULT_JUJU_DATABAG, Address, BindAddress, Container, @@ -236,13 +236,13 @@ def pre_event(charm: CharmBase): replace( relation, local_app_data={"a": "b"}, - local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG}, + local_unit_data={"c": "d", **_DEFAULT_JUJU_DATABAG}, ) ) assert out.get_relation(relation.id).local_app_data == {"a": "b"} assert out.get_relation(relation.id).local_unit_data == { "c": "d", - **DEFAULT_JUJU_DATABAG, + **_DEFAULT_JUJU_DATABAG, } diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py index b54c84b45..8a324dbc3 100644 --- a/tests/test_emitted_events_util.py +++ b/tests/test_emitted_events_util.py @@ -1,9 +1,8 @@ -import pytest from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent from scenario import State -from scenario.capture_events import capture_events +from scenario.runtime import _capture_events from scenario.state import _Event from tests.helpers import trigger @@ -33,7 +32,7 @@ def _on_foo(self, e): def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): - with capture_events(include_framework=True) as emitted: + with _capture_events(include_framework=True) as emitted: trigger(State(), "start", MyCharm, meta=MyCharm.META) assert len(emitted) == 5 @@ -45,7 +44,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): def test_capture_juju_evt(): - with capture_events() as emitted: + with _capture_events() as emitted: trigger(State(), "start", MyCharm, meta=MyCharm.META) assert len(emitted) == 2 @@ -55,7 +54,7 @@ def test_capture_juju_evt(): def test_capture_deferred_evt(): # todo: this test should pass with ops < 2.1 as well - with capture_events() as emitted: + with _capture_events() as emitted: trigger( State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", @@ -71,7 +70,7 @@ def test_capture_deferred_evt(): def test_capture_no_deferred_evt(): # todo: this test should pass with ops < 2.1 as well - with capture_events(include_deferred=False) as emitted: + with _capture_events(include_deferred=False) as emitted: trigger( State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]), "start", From 1da9684e56193c6f170ea48aa51c6faf62f774f4 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 29 Aug 2024 21:57:46 +1200 Subject: [PATCH 524/546] docs: add instructions for moving from 6.x to 7.x (#143) Since 7.x has so many breaking changes, add an `UPGRADING.md` doc that lists them all, with before/after examples. Note that this is *not* the full release notes for 7.x - it doesn't cover any of the non-breaking changes, other than when they are incidentally used as part of the examples. We can write release notes for the release fairly shortly, which can be more comprehensive (but probably not have as many examples). --- .gitignore | 1 + UPGRADING.md | 408 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tox.ini | 2 +- 4 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 UPGRADING.md diff --git a/.gitignore b/.gitignore index a2f1492ce..3d4532269 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ __pycache__/ dist/ *.pytest_cache htmlcov/ +.vscode diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 000000000..16df72fa5 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,408 @@ +# Upgrading + +## Scenario 6.x to Scenario 7.x + +Scenario 7.0 has substantial API incompatibility with earlier versions, but +comes with an intention to reduce the frequency of breaking changes in the +future, aligning with the `ops` library. + +The changes listed below are not the only features introduced in Scenario 7.0 +(for that, see the release notes), but cover the breaking changes where you will +need to update your existing Scenario tests. + +### Specify events via context.on + +In previous versions of Scenario, an event would be passed to `Context.run` +as a string name, via a convenient shorthand property of a state component +(e.g. `Relation`, `Container`), or by explicitly constructing an `Event` object. +These have been unified into a single `Context.on.{event name}()` approach, +which is more consistent, resembles the structure you're familiar with from +charm `observe` calls, and should provide more context to IDE and linting tools. + +```python +# Older Scenario code. +ctx.run('start', state) +ctx.run(container.pebble_ready_event, state) +ctx.run(Event('relation-joined', relation=relation), state) + +# Scenario 7.x +ctx.run(ctx.on.start(), state) +ctx.run(ctx.on.pebble_ready(container=container), state) +ctx.run(ctx.on.relation_joined(relation=relation), state) +``` + +The same applies to action events: + +```python +# Older Scenario code. +action = Action("backup", params={...}) +ctx.run_action(action, state) + +# Scenario 7.x +ctx.run(ctx.on.action("backup", params={...}), state) +``` + +### Provide State components as (frozen) sets + +The state components were previously lists, but containers, relations, networks, +and other state components do not have any inherent ordering. This led to +'magic' numbers creeping into test code. These are now all sets, and have 'get' +methods to retrieve the object you want to assert on. In addition, they are +actually `frozenset`s (Scenario will automatically freeze them if you pass a +`set`), which increases the immutability of the state and prevents accidentally +modifying the input state. + +```python +# Older Scenario code. +state_in = State(containers=[c1, c2], relations=[r1, r2]) +... +assert state_out.containers[1]... +assert state_out.relations[0]... +state_out.relations.append(r3) # Not recommended! + +# Scenario 7.x +state_in = State(containers={c1, c2}, relations={r1, r2}) +... +assert state_out.get_container(c2.name)... +assert state_out.get_relation(id=r1.id)... +new_state = dataclasses.replace(state_out, relations=state_out.relations + {r3}) +``` + +### Run action events in the same way as other events + +Previously, to run an action event Scenario offered a `run_action` method that +returned an object containing the result of the action. The `run_action()` +method (top-level and on the context manager) has been unified with the `run()` +method. All events, including action events, are run with `run()` and return a +`State` object. The action logs and history are available via the `Context` +object, and if the charm calls `event.fail()`, an exception will be raised. + +```python +# Older Scenario Code +action = Action("backup", params={...}) +out = ctx.run_action(action, state) +assert out.logs == ["baz", "qux"] +assert not out.success +assert out.results == {"foo": "bar"} +assert out.failure == "boo-hoo" + +# Scenario 7.x +with pytest.raises(ActionFailure) as exc_info: + ctx.run(ctx.on.action("backup", params={...}), State()) +assert ctx.action_logs == ['baz', 'qux'] +assert ctx.action_results == {"foo": "bar"} +assert exc_info.value.message == "boo-hoo" +``` + +### Use the Context object as a context manager + +The deprecated `pre_event` and `post_event` arguments to `run` +(and `run_action`) have been removed: use the context handler instead. In +addition, the `Context` object itself is now used for a context manager, rather +than having `.manager()` and `action_manager()` methods. + +In addition, the `.output` attribute of the context manager has been removed. +The state should be accessed explicitly by using the return value of the +`run()` method. + +```python +# Older Scenario code. +ctx = Context(MyCharm) +state = ctx.run("start", pre_event=lambda charm: charm.prepare(), state=State()) + +ctx = Context(MyCharm) +with ctx.manager("start", State()) as mgr: + mgr.charm.prepare() +assert mgr.output.... + +# Scenario 7.x +ctx = Context(MyCharm) +with ctx(ctx.on.start(), State()) as manager: + manager.charm.prepare() + out = manager.run() + assert out... +``` + +### Pass State components are by keyword + +Previously, it was possible (but inadvisable) to use positional arguments for +the `State` and its components. Most state components, and the `State` object +itself, now request at least some arguments to be passed by keyword. In most +cases, it's likely that you were already doing this, but the API is now +enforced. + +```python +# Older Scenario code. +container1 = Container('foo', True) +state = State({'key': 'value'}, [relation1, relation2], [network], [container1, container2]) + +# Scenario 7.x +container1 = Container('foo', can_connect=True) +state = State( + config={'key': 'value'}, + relations={relation1, relation2}, + networks={network}, + containers={container1, container2}, +) +``` + +### Pass only the tracked and latest content to Secrets + +In the past, any number of revision contents were provided when creating a +`Secret. Now, rather than having a dictionary of many revisions as part of `Secret` +objects, only the tracked and latest revision content needs to be included. +These are the only revisions that the charm has access to, so any other +revisions are not required. In addition, there's no longer a requirement to +pass in an ID. + +```python +# Older Scenario code. +state = State( + secrets=[ + scenario.Secret( + id='foo', + contents={0: {'certificate': 'xxxx'}} + ), + scenario.Secret( + id='foo', + contents={ + 0: {'password': '1234'}, + 1: {'password': 'abcd'}, + 2: {'password': 'admin'}, + } + ), + ] +) + +# Scenario 7.x +state = State( + secrets={ + scenario.Secret({'certificate': 'xxxx'}), + scenario.Secret( + tracked_content={'password': '1234'}, + latest_content={'password': 'admin'}, + ), + } +) +``` + +### Trigger custom events by triggering the underlying Juju event + +Scenario no longer supports explicitly running custom events. Instead, you +should run the Juju event(s) that will trigger the custom event. For example, +if you have a charm lib that will emit a `database-created` event on +`relation-created`: + +```python +# Older Scenario code. +ctx.run("my_charm_lib.on.database_created", state) + +# Scenario 7.x +ctx.run(ctx.on.relation_created(relation=relation), state) +``` + +Scenario will still capture custom events in `Context.emitted_events`. + +### Copy objects with dataclasses.replace and copy.deepcopy + +The `copy()` and `replace()` methods of `State` and the various state components +have been removed. You should use the `dataclasses.replace` and `copy.deepcopy` +methods instead. + +```python +# Older Scenario code. +new_container = container.replace(can_connect=True) +duplicate_relation = relation.copy() + +# Scenario 7.x +new_container = dataclasses.replace(container, can_connect=True) +duplicate_relation = copy.deepcopy(relation) +``` + +### Define resources with the Resource class + +The resources in State objects were previously plain dictionaries, and are now +`scenario.Resource` objects, aligning with all of the other State components. + +```python +# Older Scenario code +state = State(resources={"/path/to/foo", pathlib.Path("/mock/foo")}) + +# Scenario 7.x +resource = Resource(location="/path/to/foo", source=pathlib.Path("/mock/foo")) +state = State(resources={resource}) +``` + +### Give Network objects a binding name attribute + +Previously, `Network` objects were added to the state as a dictionary of +`{binding_name: network}`. Now, `Network` objects are added to the state as a +set, like the other components. This means that the `Network` object now +requires a binding name to be passed in when it is created. + +```python +# Older Scenario code +state = State(networks={"foo": Network.default()}) + +# Scenario 7.x +state = State(networks={Network.default("foo")}) +``` + +### Use the .deferred() method to populate State.deferred + +Previously, there were multiple methods to populate the `State.deferred` list: +events with a `.deferred()` method, the `scenario.deferred()` method, and +creating a `DeferredEvent` object manually. Now, for Juju events, you should +always use the `.deferred()` method of the event -- this also ensures that the +deferred event has all of the required links (to relations, containers, secrets, +and so on). + +```python +# Older Scenario code +deferred_start = scenario.deferred('start', handler=MyCharm._on_start) +deferred_relation_created = Relation('foo').changed_event.deferred(handler=MyCharm._on_foo_relation_changed) +deferred_config_changed = DeferredEvent( + handle_path='MyCharm/on/config_changed[1]', + owner='MyCharm', + observer='_on_config_changed' +) + +# Scenario 7.x +deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) +deferred_relation_changed = ctx.on.relation_changed(Relation('foo')).deferred(handler=MyCharm._on_foo_relation_changed) +deferred_config_changed = ctx.on.config_changed().deferred(handler=MyCharm._on_config_changed) +``` + +### Update names: State.storages, State.stored_states, Container.execs, Container.service_statuses + +The `State.storage` and `State.stored_state` attributes are now plurals. This +reflects that you may have more than one in the state, and also aligns with the +other State components. + +```python +# Older Scenario code +state = State(stored_state=[ss1, ss2], storage=[s1, s2]) + +# Scenario 7.x +state = State(stored_states={s1, s2}, storages={s1, s2}) +``` + +Similarly, `Container.exec_mocks` is now named `Container.execs`, +`Container.service_status` is now named `Container.service_statuses`, and +`ExecOutput` is now named `Exec`. + +```python +# Older Scenario code +container = Container( + name="foo", + exec_mock={("ls", "-ll"): ExecOutput(return_code=0, stdout=....)}, + service_status={"srv1": ops.pebble.ServiceStatus.ACTIVE} +) + +# Scenario 7.x +container = Container( + name="foo", + execs={Exec(["ls", "-ll"], return_code=0, stdout=....)}, + service_statuses={"srv1": ops.pebble.ServiceStatus.ACTIVE}, +) +``` + +### Don't use `Event`, or `StoredState.data_type_name` + +Several attributes and classes that were never intended for end users have been +made private: + +* The `data_type_name` attribute of `StoredState` is now private. +* The `Event` class is now private. + +### Use Catan rather than `scenario.sequences` + +The `scenario.sequences` module has been removed. We encourage you to look at +the new [Catan](https://github.com/PietroPasotti/catan) package. + +### Use the jsonpatch library directly + +The `State.jsonpatch_delta()` and `state.sort_patch()` methods have been +removed. We are considering adding delta-comparisons of state again in the +future, but have not yet decided how this will look. In the meantime, you can +use the jsonpatch package directly if necessary. See the tests/helpers.py file +for an example. + +### Remove calls to `cleanup`/`clear` + +The `Context.cleanup()` and `Context.clear()` methods have been removed. You +do not need to manually call any cleanup methods after running an event. If you +want a fresh `Context` (e.g. with no history), you should create a new object. + +### Include secrets in the state only if the charm has permission to view them + +`Secret.granted` has been removed. Only include in the state the secrets that +the charm has permission to (at least) view. + +### Use 'app' for application-owned secrets + +`Secret.owner` should be `'app'` (or `'unit'` or `None`) rather than +`'application'`. + +### Compare statuses with status objects + +It is no longer possible to compare statuses with tuples. Create the appropriate +status object and compare to that. Note that you should always compare statuses +with `==` not `is`. + +### Pass the name of the container to `State.get_container` + +The `State.get_container` method previously allowed passing in a `Container` +object or a container name, but now only accepts a name. This is more consistent +with the other new `get_*` methods, some of which would be quite complex if they +accepted an object or key. + +### Use `State.storages` to get all the storages in the state + +The `State.get_storages` method has been removed. This was primarily intended +for internal use. You can use `State.get_storage` or iterate through +`State.storages` instead. + +### Use .replace() to change can_connect, leader, and unit_status + +The `State` class previously had convenience methods `with_can_connect`, +`with_leadership`, and `with_unit_status`. You should now use the regular +`.replace()` mechanism instead. + +```python +# Older Scenario code +new_state = state.with_can_connect(container_name, can_connect=True) +new_state = state.with_leadership(leader=True) +new_state = state.with_unit_status(status=ActiveStatus()) + +# Scenario 7.x +new_container = dataclasses.replace(container, can_connect=True) +new_state = dataclasses.replace(containers={container}) +new_state = dataclasses.replace(state, leader=True) +new_state = dataclasses.replace(state, status=ActiveStatus()) +``` + +### Let Scenario handle the relation, action, and notice IDs, and storage index + +Scenario previously had `next_relation_id`, `next_action_id`, +`next_storage_index`, and `next_notice_id` methods. You should now let Scenario +manage the IDs and indexes of these objects. + +### Get the output state from the run() call + +The `Context` class previously had an `output_state` attribute that held the +most recent output state. You should now get the output state from the `run()` +return value. + +### Don't use internal details + +The `*_SUFFIX`, and `_EVENTS` names, the `hook_tool_output_fmt()` methods, the +`normalize_name` method, the `DEFAULT_JUJU_VERSION` and `DEFAULT_JUJU_DATABAG` +names have all been removed, and shouldn't need replacing. + +The `capture_events` and `consistency_checker` modules are also no longer +available for public use - the consistency checker will still run automatically, +and the `Context` class has attributes for capturing events. + +The `AnyRelation` and `PathLike` names have been removed: use `RelationBase` and +`str | Path` instead. diff --git a/pyproject.toml b/pyproject.toml index b1f030d82..801e263f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.15", + "ops==2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/tox.ini b/tox.ini index 9ecb3a329..149dfbaf9 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops + ops==2.15 pyright==1.1.347 commands = pyright scenario From 7f7c07d4d7bf7acd04dbc3f30873ee50abb1e05c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 30 Aug 2024 16:46:11 +1200 Subject: [PATCH 525/546] docs: expand the reference documentation (#169) Expands the docstrings so that when used with Sphinx's autodoc they will fit in with the other ops docs on ops.rtd but also still work standalone. The Sphinx autodoc system uses the `__new__` signature in preference to the `__init__` one, which means that by default all classes that are using the `_MaxPositionalArgs` class have a `*args, **kwargs` signature, which is not informative. custom_conf.py monkeypatches Sphinx to work around this, including tweaking the signature so that the `*` appears in the correct place for the maximum number of positional arguments. Also bumps the Sphinx version to align with ops. --------- Co-authored-by: PietroPasotti --- docs/custom_conf.py | 26 +++- docs/index.rst | 18 +-- docs/requirements.txt | 100 +++++++++------- pyproject.toml | 2 +- scenario/__init__.py | 59 ++++++++- scenario/_consistency_checker.py | 79 +++++++----- scenario/context.py | 200 +++++++++++++++++++++---------- scenario/runtime.py | 2 +- scenario/state.py | 193 ++++++++++++++++++++++------- 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/docs/custom_conf.py b/docs/custom_conf.py index 70bf3e10f..a4993d4b5 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -31,6 +31,7 @@ def _compute_navigation_tree(context): r'''^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) ([^.()]+) \s* # thing name + (?: \[\s*(.*)\s*])? # optional: type parameters list, Sphinx 7&8 (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -306,9 +307,32 @@ def _compute_navigation_tree(context): # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ # Please keep this list sorted alphabetically. - ('py:class', '_CharmSpec'), ('py:class', '_Event'), + ('py:class', '_EntityStatus'), + ('py:class', 'ModelError'), # This is in a copied docstring so we can't fix it. ('py:class', 'scenario.state._EntityStatus'), ('py:class', 'scenario.state._Event'), ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] + +# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses +# of _MaxPositionalArgs. +import inspect +import sphinx.ext.autodoc + +_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature + +def _custom_get_signature(self): + if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__): + signature = inspect.signature(self.object) + parameters = [] + for position, param in enumerate(signature.parameters.values()): + if position >= self.object._max_positional_args: + parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY)) + else: + parameters.append(param) + signature = signature.replace(parameters=parameters) + return None, None, signature + return _real_get_signature(self) + +sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature diff --git a/docs/index.rst b/docs/index.rst index 272af9596..a64e16f76 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,13 @@ - Scenario API reference ====================== -.. toctree:: - :maxdepth: 2 - :caption: Contents: - -scenario.State -============== - -.. automodule:: scenario.state - +scenario +======== -scenario.Context -================ +.. automodule:: scenario + :special-members: __call__ -.. automodule:: scenario.context +.. automodule:: scenario.errors Indices diff --git a/docs/requirements.txt b/docs/requirements.txt index 7b02bdf09..f517d4853 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,53 +1,65 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml # -alabaster==0.7.13 +alabaster==1.0.0 # via sphinx -babel==2.14.0 +anyio==4.4.0 + # via + # starlette + # watchfiles +babel==2.16.0 # via sphinx beautifulsoup4==4.12.3 # via # canonical-sphinx-extensions # furo # pyspelling -bracex==2.4 +bracex==2.5 # via wcmatch -canonical-sphinx-extensions==0.0.19 +canonical-sphinx-extensions==0.0.23 # via ops-scenario (pyproject.toml) -certifi==2024.2.2 +certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests +click==8.1.7 + # via uvicorn colorama==0.4.6 # via sphinx-autobuild -docutils==0.19 +docutils==0.21.2 # via # canonical-sphinx-extensions # myst-parser # sphinx # sphinx-tabs -furo==2024.1.29 +furo==2024.8.6 # via ops-scenario (pyproject.toml) +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via canonical-sphinx-extensions +h11==0.14.0 + # via uvicorn html5lib==1.1 # via pyspelling -idna==3.6 - # via requests +idna==3.8 + # via + # anyio + # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # myst-parser # sphinx linkify-it-py==2.0.3 # via ops-scenario (pyproject.toml) -livereload==2.6.3 - # via sphinx-autobuild -lxml==5.2.1 +lxml==5.3.0 # via pyspelling -markdown==3.6 +markdown==3.7 # via pyspelling markdown-it-py==3.0.0 # via @@ -55,44 +67,46 @@ markdown-it-py==3.0.0 # myst-parser markupsafe==2.1.5 # via jinja2 -mdit-py-plugins==0.4.0 +mdit-py-plugins==0.4.1 # via myst-parser mdurl==0.1.2 # via markdown-it-py -myst-parser==2.0.0 +myst-parser==4.0.0 # via ops-scenario (pyproject.toml) -ops==2.12.0 +ops==2.15.0 # via ops-scenario (pyproject.toml) -packaging==24.0 +packaging==24.1 # via sphinx -pygments==2.17.2 +pygments==2.18.0 # via # furo # sphinx # sphinx-tabs pyspelling==2.10 # via ops-scenario (pyproject.toml) -pyyaml==6.0.1 +pyyaml==6.0.2 # via # myst-parser # ops # ops-scenario (pyproject.toml) # pyspelling -requests==2.31.0 +requests==2.32.3 # via # canonical-sphinx-extensions # sphinx six==1.16.0 - # via - # html5lib - # livereload + # via html5lib +smmap==5.0.1 + # via gitdb +sniffio==1.3.1 + # via anyio snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via # beautifulsoup4 # pyspelling -sphinx==6.2.1 +sphinx==8.0.2 # via # canonical-sphinx-extensions # furo @@ -106,43 +120,49 @@ sphinx==6.2.1 # sphinx-tabs # sphinxcontrib-jquery # sphinxext-opengraph -sphinx-autobuild==2024.2.4 +sphinx-autobuild==2024.4.16 # via ops-scenario (pyproject.toml) sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 # via ops-scenario (pyproject.toml) -sphinx-design==0.5.0 +sphinx-design==0.6.1 # via ops-scenario (pyproject.toml) -sphinx-notfound-page==1.0.0 +sphinx-notfound-page==1.0.4 # via ops-scenario (pyproject.toml) sphinx-tabs==3.4.5 # via ops-scenario (pyproject.toml) -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jquery==4.1 # via ops-scenario (pyproject.toml) sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxext-opengraph==0.9.1 # via ops-scenario (pyproject.toml) -tornado==6.4 - # via livereload +starlette==0.38.2 + # via sphinx-autobuild uc-micro-py==1.0.3 # via linkify-it-py -urllib3==2.2.1 +urllib3==2.2.2 # via requests -wcmatch==8.5.1 +uvicorn==0.30.6 + # via sphinx-autobuild +watchfiles==0.24.0 + # via sphinx-autobuild +wcmatch==9.0 # via pyspelling webencodings==0.5.1 # via html5lib -websocket-client==1.7.0 +websocket-client==1.8.0 # via ops +websockets==13.0.1 + # via sphinx-autobuild diff --git a/pyproject.toml b/pyproject.toml index 801e263f0..03ffdfc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ docs = [ "linkify-it-py", "myst-parser", "pyspelling", - "sphinx==6.2.1", + "sphinx ~= 8.0.0", "sphinx-autobuild", "sphinx-copybutton", "sphinx-design", diff --git a/scenario/__init__.py b/scenario/__init__.py index 3439daa16..2ba5a24c7 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -2,6 +2,64 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +"""Charm state-transition testing SDK for Ops charms. + +Write tests that declaratively define the Juju state all at once, define the +Juju context against which to test the charm, and fire a single event on the +charm to execute its logic. The tests can then assert that the Juju state has +changed as expected. + +These tests are 'state-transition' tests, a way to test isolated units of charm +functionality (how the state changes in reaction to events). They are not +necessarily tests of individual methods or functions (but might be, depending on +the charm's event observers); they are testing the 'contract' of the charm: given +a certain state, when a certain event happens, the charm should transition to a +certain (likely different) state. They do not test against a real Juju +controller and model, and focus on a single Juju unit, unlike integration tests. +For simplicity, we refer to them as 'unit' tests in the charm context. + +Writing these tests should nudge you into thinking of a charm as a black-box +input->output function. The input is the union of an `Event` (why am I, charm, +being executed), a `State` (am I leader? what is my integration data? what is my +config?...) and the charm's execution `Context` (what integrations can I have? +what containers can I have?...). The output is another `State`: the state after +the charm has had a chance to interact with the mocked Juju model and affect the +state. + +.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png + :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right + +Writing unit tests for a charm, then, means verifying that: + +- the charm does not raise uncaught exceptions while handling the event +- the output state (as compared with the input state) is as expected. + +A test consists of three broad steps: + +- **Arrange**: + - declare the context + - declare the input state +- **Act**: + - select an event to fire + - run the context (i.e. obtain the output state, given the input state and the event) +- **Assert**: + - verify that the output state (as compared with the input state) is how you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + - optionally, you can use a context manager to get a hold of the charm instance and run + assertions on APIs and state internal to it. + +The most basic scenario is one in which all is defaulted and barely any data is +available. The charm has no config, no integrations, no leadership, and its +status is `unknown`. With that, we can write the simplest possible test: + +.. code-block:: python + + def test_base(): + ctx = Context(MyCharm) + out = ctx.run(ctx.on.start(), State()) + assert out.unit_status == UnknownStatus() +""" + from scenario.context import Context, Manager from scenario.state import ( ActionFailed, @@ -87,5 +145,4 @@ "UnitID", "UnknownStatus", "WaitingStatus", - "deferred", ] diff --git a/scenario/_consistency_checker.py b/scenario/_consistency_checker.py index 68fd3c24f..748806ff7 100644 --- a/scenario/_consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -1,6 +1,23 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +""" +The :meth:`check_consistency` function is the primary entry point for the +consistency checks. Calling it ensures that the :class:`State` and the event, +in combination with the ``Context``, is viable in Juju. For example, Juju can't +emit a ``foo-relation-changed`` event on your charm unless your charm has +declared a ``foo`` relation endpoint in its metadata. + +Normally, there is no need to explicitly call this function; that happens +automatically behind the scenes whenever you trigger an event. + +If you have a clear false negative, are explicitly testing 'edge', +inconsistent situations, or for whatever reason the checker is in your way, you +can set the ``SCENARIO_SKIP_CONSISTENCY_CHECKS`` environment variable and skip +it altogether. +""" + import marshal import os import re @@ -26,7 +43,11 @@ class Results(NamedTuple): - """Consistency checkers return type.""" + """Consistency checker return type. + + Each consistency check function returns a ``Results`` instance with the + warnings and errors found during the check. + """ errors: Iterable[str] warnings: Iterable[str] @@ -38,20 +59,22 @@ def check_consistency( charm_spec: "_CharmSpec", juju_version: str, ): - """Validate the combination of a state, an event, a charm spec, and a juju version. - - When invoked, it performs a series of checks that validate that the state is consistent with - itself, with the event being emitted, the charm metadata, etc... - - This function performs some basic validation of the combination of inputs that goes into a - scenario test and determines if the scenario is a realistic/plausible/consistent one. - - A scenario is inconsistent if it can practically never occur because it contradicts - the juju model. - For example: juju guarantees that upon calling config-get, a charm will only ever get the keys - it declared in its config.yaml. - So a State declaring some config keys that are not in the charm's config.yaml is nonsense, - and the combination of the two is inconsistent. + """Validate the combination of a state, an event, a charm spec, and a Juju version. + + When invoked, it performs a series of checks that validate that the state is + consistent with itself, with the event being emitted, the charm metadata, + and so on. + + This function performs some basic validation of the combination of inputs + that goes into a test and determines if the scenario is a + realistic/plausible/consistent one. + + A scenario is inconsistent if it can practically never occur because it + contradicts the Juju model. For example: Juju guarantees that upon calling + ``config-get``, a charm will only ever get the keys it declared in its + config metadata, so a :class:`scenario.State` declaring some config keys + that are not in the charm's ``charmcraft.yaml`` is nonsense, and the + combination of the two is inconsistent. """ juju_version_: Tuple[int, ...] = tuple(map(int, juju_version.split("."))) @@ -103,7 +126,7 @@ def check_resource_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the resources from metadata and in State.""" + """Check the internal consistency of the resources from metadata and in :class:`scenario.State`.""" errors = [] warnings = [] @@ -125,7 +148,7 @@ def check_event_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of the _Event data structure. + """Check the internal consistency of the ``_Event`` data structure. For example, it checks that a relation event has a relation instance, and that the relation endpoint name matches the event prefix. @@ -335,7 +358,7 @@ def check_storages_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml).""" + """Check the consistency of the :class:`scenario.State` storages with the charm_spec metadata.""" state_storage = state.storages meta_storage = (charm_spec.meta or {}).get("storage", {}) errors = [] @@ -373,7 +396,7 @@ def check_config_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of the state.config with the charm_spec.config (config.yaml).""" + """Check the consistency of the :class:`scenario.State` config with the charm_spec config.""" state_config = state.config meta_config = (charm_spec.config or {}).get("options", {}) errors = [] @@ -381,7 +404,8 @@ def check_config_consistency( for key, value in state_config.items(): if key not in meta_config: errors.append( - f"config option {key!r} in state.config but not specified in config.yaml.", + f"config option {key!r} in state.config but not specified in config.yaml or " + f"charmcraft.yaml.", ) continue @@ -431,7 +455,7 @@ def check_secrets_consistency( juju_version: Tuple[int, ...], **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of Secret-related stuff.""" + """Check the consistency of any :class:`scenario.Secret` in the :class:`scenario.State`.""" errors = [] if not event._is_secret_event: return Results(errors, []) @@ -458,6 +482,7 @@ def check_network_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any :class:`scenario.Network` in the :class:`scenario.State`.""" errors = [] meta_bindings = set(charm_spec.meta.get("extra-bindings", ())) @@ -474,7 +499,7 @@ def check_network_consistency( state_bindings = {network.binding_name for network in state.networks} if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): errors.append( - f"Some network bindings defined in State are not in metadata.yaml: {diff}.", + f"Some network bindings defined in State are not in the metadata: {diff}.", ) endpoints = {endpoint for endpoint, metadata in all_relations} @@ -493,6 +518,7 @@ def check_relation_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: + """Check the consistency of any relations in the :class:`scenario.State`.""" errors = [] peer_relations_meta = charm_spec.meta.get("peers", {}).items() @@ -562,7 +588,7 @@ def check_containers_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check the consistency of `state.containers` vs. `charm_spec.meta`.""" + """Check the consistency of :class:`scenario.State` containers with the charm_spec metadata.""" # event names will be normalized; need to compare against normalized container names. meta = charm_spec.meta @@ -625,7 +651,7 @@ def check_cloudspec_consistency( charm_spec: "_CharmSpec", **_kwargs, # noqa: U101 ) -> Results: - """Check that Kubernetes charms/models don't have `state.cloud_spec`.""" + """Check that Kubernetes models don't have :attr:`scenario.State.cloud_spec` set.""" errors = [] warnings = [] @@ -633,8 +659,7 @@ def check_cloudspec_consistency( if state.model.type == "kubernetes" and state.model.cloud_spec: errors.append( "CloudSpec is only available for machine charms, not Kubernetes charms. " - "Tell Scenario to simulate a machine substrate with: " - "`scenario.State(..., model=scenario.Model(type='lxd'))`.", + "Simulate a machine substrate with: `State(..., model=Model(type='lxd'))`.", ) return Results(errors, warnings) @@ -645,7 +670,7 @@ def check_storedstate_consistency( state: "State", **_kwargs, # noqa: U101 ) -> Results: - """Check the internal consistency of `state.stored_states`.""" + """Check the internal consistency of any :class:`scenario.StoredState` in the :class:`scenario.State`.""" errors = [] # Attribute names must be unique on each object. diff --git a/scenario/context.py b/scenario/context.py index 677597892..08bfdd50c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + +import functools import tempfile from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast -from ops import CharmBase, EventBase -from ops.testing import ExecArgs +import ops +import ops.testing from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger @@ -26,8 +28,6 @@ ) if TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - from scenario.ops_main_mock import Ops from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus @@ -64,12 +64,16 @@ def __init__( self.output: Optional["State"] = None @property - def charm(self) -> CharmBase: + def charm(self) -> ops.CharmBase: + """The charm object instantiated by ops to handle the event. + + The charm is only available during the context manager scope. + """ if not self.ops: raise RuntimeError( "you should __enter__ this context manager before accessing this", ) - return cast(CharmBase, self.ops.charm) + return cast(ops.CharmBase, self.ops.charm) @property def _runner(self): @@ -104,63 +108,87 @@ def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100 self.run() -class _CharmEvents: - """Events generated by Juju pertaining to application lifecycle. +def _copy_doc(original_func): + """Copy the docstring from `original_func` to the wrapped function.""" + + def decorator(wrapper_func): + @functools.wraps(wrapper_func) + def wrapped(*args, **kwargs): + return wrapper_func(*args, **kwargs) + + wrapped.__doc__ = original_func.__doc__ + return wrapped - By default, the events listed as attributes of this class will be - provided via the :attr:`Context.on` attribute. For example:: + return decorator + + +class CharmEvents: + """Events generated by Juju or ops pertaining to the application lifecycle. + + The events listed as attributes of this class should be accessed via the + :attr:`Context.on` attribute. For example:: ctx.run(ctx.on.config_changed(), state) This behaves similarly to the :class:`ops.CharmEvents` class but is much - simpler as there are no dynamically named attributes, and no __getattr__ + simpler as there are no dynamically named attributes, and no ``__getattr__`` version to get events. In addition, all of the attributes are methods, - which are used to connect the event to the specific container object that - they relate to (or, for simpler events like "start" or "stop", take no - arguments). + which are used to connect the event to the specific object that they relate + to (or, for simpler events like "start" or "stop", take no arguments). """ @staticmethod + @_copy_doc(ops.InstallEvent) def install(): return _Event("install") @staticmethod + @_copy_doc(ops.StartEvent) def start(): return _Event("start") @staticmethod + @_copy_doc(ops.StopEvent) def stop(): return _Event("stop") @staticmethod + @_copy_doc(ops.RemoveEvent) def remove(): return _Event("remove") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def update_status(): return _Event("update_status") @staticmethod + @_copy_doc(ops.ConfigChangedEvent) def config_changed(): return _Event("config_changed") @staticmethod + @_copy_doc(ops.UpdateStatusEvent) def upgrade_charm(): return _Event("upgrade_charm") @staticmethod + @_copy_doc(ops.PreSeriesUpgradeEvent) def pre_series_upgrade(): return _Event("pre_series_upgrade") @staticmethod + @_copy_doc(ops.PostSeriesUpgradeEvent) def post_series_upgrade(): return _Event("post_series_upgrade") @staticmethod + @_copy_doc(ops.LeaderElectedEvent) def leader_elected(): return _Event("leader_elected") @staticmethod + @_copy_doc(ops.SecretChangedEvent) def secret_changed(secret: Secret): if secret.owner: raise ValueError( @@ -169,6 +197,7 @@ def secret_changed(secret: Secret): return _Event("secret_changed", secret=secret) @staticmethod + @_copy_doc(ops.SecretExpiredEvent) def secret_expired(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -177,6 +206,7 @@ def secret_expired(secret: Secret, *, revision: int): return _Event("secret_expired", secret=secret, secret_revision=revision) @staticmethod + @_copy_doc(ops.SecretRotateEvent) def secret_rotate(secret: Secret): if not secret.owner: raise ValueError( @@ -185,6 +215,7 @@ def secret_rotate(secret: Secret): return _Event("secret_rotate", secret=secret) @staticmethod + @_copy_doc(ops.SecretRemoveEvent) def secret_remove(secret: Secret, *, revision: int): if not secret.owner: raise ValueError( @@ -194,17 +225,21 @@ def secret_remove(secret: Secret, *, revision: int): @staticmethod def collect_app_status(): + """Event triggered at the end of every hook to collect app statuses for evaluation""" return _Event("collect_app_status") @staticmethod def collect_unit_status(): + """Event triggered at the end of every hook to collect unit statuses for evaluation""" return _Event("collect_unit_status") @staticmethod + @_copy_doc(ops.RelationCreatedEvent) def relation_created(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_created", relation=relation) @staticmethod + @_copy_doc(ops.RelationJoinedEvent) def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None): return _Event( f"{relation.endpoint}_relation_joined", @@ -213,6 +248,7 @@ def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = No ) @staticmethod + @_copy_doc(ops.RelationChangedEvent) def relation_changed( relation: "RelationBase", *, @@ -225,6 +261,7 @@ def relation_changed( ) @staticmethod + @_copy_doc(ops.RelationDepartedEvent) def relation_departed( relation: "RelationBase", *, @@ -239,22 +276,27 @@ def relation_departed( ) @staticmethod + @_copy_doc(ops.RelationBrokenEvent) def relation_broken(relation: "RelationBase"): return _Event(f"{relation.endpoint}_relation_broken", relation=relation) @staticmethod + @_copy_doc(ops.StorageAttachedEvent) def storage_attached(storage: Storage): return _Event(f"{storage.name}_storage_attached", storage=storage) @staticmethod + @_copy_doc(ops.StorageDetachingEvent) def storage_detaching(storage: Storage): return _Event(f"{storage.name}_storage_detaching", storage=storage) @staticmethod + @_copy_doc(ops.PebbleReadyEvent) def pebble_ready(container: Container): return _Event(f"{container.name}_pebble_ready", container=container) @staticmethod + @_copy_doc(ops.PebbleCustomNoticeEvent) def pebble_custom_notice(container: Container, notice: Notice): return _Event( f"{container.name}_pebble_custom_notice", @@ -263,6 +305,7 @@ def pebble_custom_notice(container: Container, notice: Notice): ) @staticmethod + @_copy_doc(ops.PebbleCheckFailedEvent) def pebble_check_failed(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_failed", @@ -271,6 +314,7 @@ def pebble_check_failed(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.PebbleCheckRecoveredEvent) def pebble_check_recovered(container: Container, info: CheckInfo): return _Event( f"{container.name}_pebble_check_recovered", @@ -279,6 +323,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): ) @staticmethod + @_copy_doc(ops.ActionEvent) def action( name: str, params: Optional[Dict[str, "AnyJson"]] = None, @@ -295,13 +340,15 @@ def action( class Context: """Represents a simulated charm's execution context. - It is the main entry point to running a scenario test. + The main entry point to running a test. It contains: - 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. + - the charm source code being executed + - the metadata files associated with it + - a charm project repository root + - the Juju version to be simulated - 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 + After you have instantiated ``Context``, typically you will call :meth:`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: @@ -310,63 +357,77 @@ class Context: Any side effects generated by executing the charm, that are not rightful part of the ``State``, are in fact stored in the ``Context``: - - :attr:`juju_log`: record of what the charm has sent to juju-log - - :attr:`app_status_history`: record of the app statuses the charm has set - - :attr:`unit_status_history`: record of the unit statuses the charm has set - - :attr:`workload_version_history`: record of the workload versions the charm has set - - :attr:`removed_secret_revisions`: record of the secret revisions the charm has removed - - :attr:`emitted_events`: record of the events (including custom) that the charm has processed - - :attr:`action_logs`: logs associated with the action output, set by the charm with - :meth:`ops.ActionEvent.log` - - :attr:`action_results`: 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` + - :attr:`juju_log` + - :attr:`app_status_history` + - :attr:`unit_status_history` + - :attr:`workload_version_history` + - :attr:`removed_secret_revisions` + - :attr:`requested_storages` + - :attr:`emitted_events` + - :attr:`action_logs` + - :attr:`action_results` This allows you to write assertions not only on the output state, but also, to some extent, on the path the charm took to get there. - A typical scenario test will look like:: + A typical test will look like:: - from scenario import Context, State - from ops import ActiveStatus from charm import MyCharm, MyCustomEvent # noqa def test_foo(): # Arrange: set the context up - c = Context(MyCharm) + ctx = Context(MyCharm) # Act: prepare the state and emit an event - state_out = c.run('update-status', State()) + state_out = ctx.run(ctx.on.update_status(), State()) # Assert: verify the output state is what you think it should be assert state_out.unit_status == ActiveStatus('foobar') # Assert: verify the Context contains what you think it should assert len(c.emitted_events) == 4 assert isinstance(c.emitted_events[3], MyCustomEvent) - If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the - execution cwd, you need to use the ``charm_root`` argument. For example:: - - import scenario - import tempfile - virtual_root = tempfile.TemporaryDirectory() - local_path = Path(local_path.name) - (local_path / 'foo').mkdir() - (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') - scenario.Context(... charm_root=virtual_root).run(...) - If you need access to the charm object that will handle the event, use the class in a ``with`` statement, like:: - import scenario - def test_foo(): - ctx = scenario.Context(MyCharm) + ctx = Context(MyCharm) with ctx(ctx.on.start(), State()) as manager: manager.charm._some_private_setup() manager.run() """ + juju_log: List["JujuLogLine"] + """A record of what the charm has sent to juju-log""" + app_status_history: List["_EntityStatus"] + """A record of the app statuses the charm has set""" + unit_status_history: List["_EntityStatus"] + """A record of the unit statuses the charm has set""" + workload_version_history: List[str] + """A record of the workload versions the charm has set""" + removed_secret_revisions: List[int] + """A record of the secret revisions the charm has removed""" + emitted_events: List[ops.EventBase] + """A record of the events (including custom) that the charm has processed""" + requested_storages: Dict[str, int] + """A record of the storages the charm has requested""" + action_logs: List[str] + """The logs associated with the action output, set by the charm with :meth:`ops.ActionEvent.log` + + This will be empty when handling a non-action event. + """ + action_results: Optional[Dict[str, Any]] + """A key-value mapping assigned by the charm as a result of the action. + + This will be ``None`` if the charm never calls :meth:`ops.ActionEvent.set_results` + """ + on: CharmEvents + """The events that this charm can respond to. + + Use this when calling :meth:`run` to specify the event to emit. + """ + def __init__( self, - charm_type: Type["CharmType"], + charm_type: Type[ops.testing.CharmType], meta: Optional[Dict[str, Any]] = None, *, actions: Optional[Dict[str, Any]] = None, @@ -381,7 +442,17 @@ def __init__( ): """Represents a simulated charm's execution context. - :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. + If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the + execution cwd, you need to use the ``charm_root`` argument. For example:: + + import tempfile + virtual_root = tempfile.TemporaryDirectory() + local_path = Path(local_path.name) + (local_path / 'foo').mkdir() + (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') + Context(... charm_root=virtual_root).run(...) + + :arg charm_type: the :class:`ops.CharmBase` subclass to handle the event. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). If none is provided, we will search for a ``metadata.yaml`` file in the charm root. :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict). @@ -390,11 +461,11 @@ def __init__( If none is provided, we will search for a ``config.yaml`` file in the charm root. :arg juju_version: Juju agent version to simulate. :arg app_name: App name that this charm is deployed as. Defaults to the charm name as - defined in metadata.yaml. - :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0. + defined in the metadata. + :arg unit_id: Unit ID that this charm is deployed as. :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with - ``juju trust``). Defaults to False. - :arg charm_root: virtual charm root the charm will be executed with. + ``juju trust``). + :arg charm_root: virtual charm filesystem root the charm will be executed with. """ if not any((meta, actions, config)): @@ -438,10 +509,10 @@ def __init__( self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] - self.exec_history: Dict[str, List[ExecArgs]] = {} + self.exec_history: Dict[str, List[ops.testing.ExecArgs]] = {} self.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] - self.emitted_events: List[EventBase] = [] + self.emitted_events: List[ops.EventBase] = [] self.requested_storages: Dict[str, int] = {} # set by Runtime.exec() in self._run() @@ -452,7 +523,7 @@ def __init__( self.action_results: Optional[Dict[str, Any]] = None self._action_failure_message: Optional[str] = None - self.on = _CharmEvents() + self.on = CharmEvents() def _set_output_state(self, output_state: "State"): """Hook for Runtime to set the output state.""" @@ -488,20 +559,21 @@ def __call__(self, event: "_Event", state: "State"): assert manager.charm._some_private_attribute == "bar" # noqa Args: - event: the :class:`Event` that the charm will respond to. - state: the :class:`State` instance to use when handling the Event. + event: the event that the charm will respond to. + state: the :class:`State` instance to use when handling the event. """ return Manager(self, event, state) def run(self, event: "_Event", state: "State") -> "State": - """Trigger a charm execution with an Event and a State. + """Trigger a charm execution with an 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. + specified :class:`State`, then emit the event on the charm. - :arg event: the Event that the charm will respond to. - :arg state: the State instance to use as data source for the hook tool calls that the - charm will invoke when handling the Event. + :arg event: the event that the charm will respond to. Use the :attr:`on` attribute to + specify the event; for example: ``ctx.on.start()``. + :arg state: the :class:`State` instance to use as data source for the hook tool calls that + the charm will invoke when handling the event. """ if event.action: # Reset the logs, failure status, and results, in case the context diff --git a/scenario/runtime.py b/scenario/runtime.py index 754829c0b..f4df73db8 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -121,7 +121,7 @@ def apply_state(self, state: "State"): 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) + db.save_snapshot(stored_state._handle_path, stored_state.content) db.close() diff --git a/scenario/state.py b/scenario/state.py index 33d5f2809..3a72f20b1 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -10,7 +10,6 @@ import random import re import string -from collections import namedtuple from enum import Enum from itertools import chain from pathlib import Path, PurePosixPath @@ -48,8 +47,6 @@ from scenario.errors import MetadataNotFoundError, StateValidationError from scenario.logger import logger as scenario_logger -JujuLogLine = namedtuple("JujuLogLine", ("level", "message")) - if TYPE_CHECKING: # pragma: no cover from scenario import Context @@ -116,7 +113,7 @@ class ActionFailed(Exception): - """Raised at the end of the hook if the charm has called `event.fail()`.""" + """Raised at the end of the hook if the charm has called ``event.fail()``.""" def __init__(self, message: str, state: "State"): self.message = message @@ -192,8 +189,20 @@ def __reduce__(self): return _MaxPositionalArgs +@dataclasses.dataclass(frozen=True) +class JujuLogLine(_max_posargs(2)): + """An entry in the Juju debug-log.""" + + level: str + """The level of the message, for example ``INFO`` or ``ERROR``.""" + message: str + """The log message.""" + + @dataclasses.dataclass(frozen=True) class CloudCredential(_max_posargs(0)): + __doc__ = ops.CloudCredential.__doc__ + auth_type: str """Authentication type.""" @@ -217,6 +226,8 @@ def _to_ops(self) -> CloudCredential_Ops: @dataclasses.dataclass(frozen=True) class CloudSpec(_max_posargs(1)): + __doc__ = ops.CloudSpec.__doc__ + type: str """Type of the cloud.""" @@ -273,23 +284,51 @@ def _generate_secret_id(): @dataclasses.dataclass(frozen=True) class Secret(_max_posargs(1)): + """A Juju secret. + + This class is used for both user and charm secrets. + """ + tracked_content: "RawSecretRevisionContents" + """The content of the secret that the charm is currently tracking. + + This is the content the charm will receive with a + :meth:`ops.Secret.get_content` call.""" latest_content: Optional["RawSecretRevisionContents"] = None + """The content of the latest revision of the secret. + + This is the content the charm will receive with a + :meth:`ops.Secret.peek_content` call.""" id: str = dataclasses.field(default_factory=_generate_secret_id) + """The Juju ID of the secret. + + This is automatically assigned and should not usually need to be explicitly set. + """ - # indicates if the secret is owned by THIS unit, THIS app or some other app/unit. - # if None, the implication is that the secret has been granted to this unit. owner: Literal["unit", "app", None] = None + """Indicates if the secret is owned by *this* unit, *this* application, or + another application/unit. + + If None, the implication is that read access to the secret has been granted + to this unit. + """ - # mapping from relation IDs to remote unit/apps to which this secret has been granted. - # Only applicable if owner remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict) + """Mapping from relation IDs to remote units and applications to which this + secret has been granted.""" label: Optional[str] = None + """A human-readable label the charm can use to retrieve the secret. + + If this is set, it implies that the charm has previously set the label. + """ description: Optional[str] = None + """A human-readable description of the secret.""" expire: Optional[datetime.datetime] = None + """The time at which the secret will expire.""" rotate: Optional[SecretRotate] = None + """The rotation policy for the secret.""" # what revision is currently tracked by this charm. Only meaningful if owner=False _tracked_revision: int = 1 @@ -375,8 +414,11 @@ class BindAddress(_max_posargs(1)): interface_name: str addresses: List[Address] + """The addresses in the space.""" interface_name: str = "" + """The name of the network interface.""" mac_address: Optional[str] = None + """The MAC address of the interface.""" def _hook_tool_output_fmt(self): # dumps itself to dict in the same format the hook tool would @@ -392,16 +434,22 @@ def _hook_tool_output_fmt(self): @dataclasses.dataclass(frozen=True) class Network(_max_posargs(2)): + """A Juju network space.""" + binding_name: str + """The name of the network space.""" bind_addresses: List[BindAddress] = dataclasses.field( default_factory=lambda: [BindAddress([Address("192.0.2.0")])], ) + """Addresses that the charm's application should bind to.""" ingress_addresses: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0"], ) + """Addresses other applications should use to connect to the unit.""" egress_subnets: List[str] = dataclasses.field( default_factory=lambda: ["192.0.2.0/24"], ) + """Subnets that other units will see the charm connecting from.""" def __hash__(self) -> int: return hash(self.binding_name) @@ -421,6 +469,11 @@ def _hook_tool_output_fmt(self): def _next_relation_id(*, update=True): + """Get the ID the next relation to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_relation_id_counter cur = _next_relation_id_counter if update: @@ -431,11 +484,11 @@ def _next_relation_id(*, update=True): @dataclasses.dataclass(frozen=True) class RelationBase(_max_posargs(2)): endpoint: str - """Relation endpoint name. Must match some endpoint name defined in metadata.yaml.""" + """Relation endpoint name. Must match some endpoint name defined in the metadata.""" interface: Optional[str] = None - """Interface name. Must match the interface name attached to this endpoint in metadata.yaml. - If left empty, it will be automatically derived from metadata.yaml.""" + """Interface name. Must match the interface name attached to this endpoint in the metadata. + If left empty, it will be automatically derived from the metadata.""" id: int = dataclasses.field(default_factory=_next_relation_id) """Juju relation ID. Every new Relation instance gets a unique one, @@ -546,14 +599,19 @@ def _databags(self): @dataclasses.dataclass(frozen=True) class SubordinateRelation(RelationBase): + """A relation to share data between a subordinate and a principal charm.""" + remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict) + """The current content of the remote application databag.""" remote_unit_data: "RawDataBagContents" = dataclasses.field( default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), ) + """The current content of the remote unit databag.""" - # app name and ID of the remote unit that *this unit* is attached to. remote_app_name: str = "remote" + """The name of the remote application that *this unit* is attached to.""" remote_unit_id: int = 0 + """The ID of the remote unit that *this unit* is attached to.""" def __hash__(self) -> int: return hash(self.id) @@ -582,6 +640,7 @@ def _databags(self): @property def remote_unit_name(self) -> str: + """The full name of the remote unit, in the form ``remote/0``.""" return f"{self.remote_app_name}/{self.remote_unit_id}" @@ -713,6 +772,11 @@ def _now_utc(): def _next_notice_id(*, update=True): + """Get the ID the next Pebble notice to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_notice_id_counter cur = _next_notice_id_counter if update: @@ -722,6 +786,8 @@ def _next_notice_id(*, update=True): @dataclasses.dataclass(frozen=True) class Notice(_max_posargs(1)): + """A Pebble notice.""" + key: str """The notice key, a string that differentiates notices of this type. @@ -781,6 +847,8 @@ def _to_ops(self) -> pebble.Notice: @dataclasses.dataclass(frozen=True) class CheckInfo(_max_posargs(1)): + """A health check for a Pebble workload container.""" + name: str """Name of the check.""" @@ -790,9 +858,10 @@ class CheckInfo(_max_posargs(1)): status: pebble.CheckStatus = pebble.CheckStatus.UP """Status of the check. - CheckStatus.UP means the check is healthy (the number of failures is less - than the threshold), CheckStatus.DOWN means the check is unhealthy - (the number of failures has reached the threshold). + :attr:`ops.pebble.CheckStatus.UP` means the check is healthy (the number of + failures is fewer than the threshold), :attr:`ops.pebble.CheckStatus.DOWN` + means the check is unhealthy (the number of failures has reached the + threshold). """ failures: int = 0 @@ -801,7 +870,7 @@ class CheckInfo(_max_posargs(1)): threshold: int = 3 """Failure threshold. - This is how many consecutive failures for the check to be considered “down”. + This is how many consecutive failures for the check to be considered 'down'. """ def _to_ops(self) -> pebble.CheckInfo: @@ -831,7 +900,7 @@ class Container(_max_posargs(1)): # will be unknown. all that we can know is the resulting plan (the 'computed plan'). _base_plan: dict = dataclasses.field(default_factory=dict) # We expect most of the user-facing testing to be covered by this 'layers' attribute, - # as all will be known when unit-testing. + # as it is all that will be known when unit-testing. layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict) """All :class:`ops.pebble.Layer` definitions that have already been added to the container.""" @@ -866,8 +935,8 @@ class Container(_max_posargs(1)): this becomes:: mounts = { - 'foo': scenario.Mount('/home/foo', Path('/path/to/local/dir/containing/bar/py/')), - 'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')), + 'foo': Mount('/home/foo', pathlib.Path('/path/to/local/dir/containing/bar/py/')), + 'bin': Mount('/bin/', pathlib.Path('/path/to/local/dir/containing/bash/and/baz/')), } """ @@ -879,7 +948,7 @@ class Container(_max_posargs(1)): For example:: - container = scenario.Container( + container = Container( name='foo', execs={ scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'), @@ -893,8 +962,10 @@ class Container(_max_posargs(1)): """ notices: List[Notice] = dataclasses.field(default_factory=list) + """Any Pebble notices that already exist in the container.""" check_infos: FrozenSet[CheckInfo] = frozenset() + """All Pebble health checks that have been added to the container.""" def __hash__(self) -> int: return hash(self.name) @@ -917,9 +988,9 @@ def _render_services(self): def plan(self) -> pebble.Plan: """The 'computed' Pebble plan. - i.e. the base plan plus the layers that have been added on top. - You should run your assertions on this plan, not so much on the layers, as those are - input data. + This is the base plan plus the layers that have been added on top. + You should run your assertions on this plan, not so much on the layers, + as those are input data. """ # copied over from ops.testing._TestingPebbleClient.get_plan(). @@ -1087,26 +1158,40 @@ def __init__(self, message: str = ""): @dataclasses.dataclass(frozen=True) class StoredState(_max_posargs(1)): + """Represents unit-local state that persists across events.""" + name: str = "_stored" + """The attribute in the parent Object where the state is stored. + + For example, ``_stored`` in this class:: + + class MyCharm(ops.CharmBase): + _stored = ops.StoredState() + + """ - # /-separated Object names. E.g. MyCharm/MyCharmLib. - # if None, this StoredState instance is owned by the Framework. owner_path: Optional[str] = None + """The path to the owner of this StoredState instance. + + If None, the owner is the Framework. Otherwise, /-separated object names, + for example MyCharm/MyCharmLib. + """ # Ideally, the type here would be only marshallable types, rather than Any. # However, it's complex to describe those types, since it's a recursive # definition - even in TypeShed the _Marshallable type includes containers # like list[Any], which seems to defeat the point. content: Dict[str, Any] = dataclasses.field(default_factory=dict) + """The content of the :class:`ops.StoredState` instance.""" _data_type_name: str = "StoredStateData" @property - def handle_path(self): + def _handle_path(self): return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]" def __hash__(self) -> int: - return hash(self.handle_path) + return hash(self._handle_path) _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"] @@ -1116,8 +1201,8 @@ def __hash__(self) -> int: class Port(_max_posargs(1)): """Represents a port on the charm host. - Port objects should not be instantiated directly: use TCPPort, UDPPort, or - ICMPPort instead. + Port objects should not be instantiated directly: use :class:`TCPPort`, + :class:`UDPPort`, or :class:`ICMPPort` instead. """ port: Optional[int] = None @@ -1146,6 +1231,10 @@ class TCPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "tcp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1162,6 +1251,10 @@ class UDPPort(Port): port: int """The port to open.""" protocol: _RawPortProtocolLiteral = "udp" + """The protocol that data transferred over the port will use. + + :meta private: + """ def __post_init__(self): super().__post_init__() @@ -1176,6 +1269,10 @@ class ICMPPort(Port): """Represents an ICMP port on the charm host.""" protocol: _RawPortProtocolLiteral = "icmp" + """The protocol that data transferred over the port will use. + + :meta private: + """ _max_positional_args: Final = 0 @@ -1210,12 +1307,16 @@ def _next_storage_index(*, update=True): @dataclasses.dataclass(frozen=True) class Storage(_max_posargs(1)): - """Represents an (attached!) storage made available to the charm container.""" + """Represents an (attached) storage made available to the charm container.""" name: str + """The name of the storage, as found in the charm metadata.""" index: int = dataclasses.field(default_factory=_next_storage_index) - # Every new Storage instance gets a new one, if there's trouble, override. + """The index of this storage instance. + + For Kubernetes charms, this will always be 1. For machine charms, each new + Storage instance gets a new index.""" def __eq__(self, other: object) -> bool: if isinstance(other, (Storage, ops.Storage)): @@ -1232,15 +1333,17 @@ class Resource(_max_posargs(0)): """Represents a resource made available to the charm.""" name: str + """The name of the resource, as found in the charm metadata.""" path: Union[str, Path] + """A local path that will be provided to the charm as the content of the resource.""" @dataclasses.dataclass(frozen=True) class State(_max_posargs(0)): - """Represents the juju-owned portion of a unit's state. + """Represents the Juju-owned portion of a unit's state. Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its - lifecycle. For example, status-get will return data from `State.status`, is-leader will + lifecycle. For example, status-get will return data from `State.unit_status`, is-leader will return data from `State.leader`, and so on. """ @@ -1254,31 +1357,36 @@ class State(_max_posargs(0)): """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. + + .. warning:: + `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: Iterable[Container] = dataclasses.field(default_factory=frozenset) """All containers (whether they can connect or not) that this charm is aware of.""" storages: Iterable[Storage] = dataclasses.field(default_factory=frozenset) - """All ATTACHED storage instances for this charm. + """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: Iterable[Port] = dataclasses.field(default_factory=frozenset) - """Ports opened by juju on this charm.""" + """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: 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: 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. + Use with caution.""" # Represents the OF's event queue. These events will be emitted before the event being @@ -1561,8 +1669,8 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]: class DeferredEvent: """An event that has been deferred to run prior to the next Juju event. - Tests should not instantiate this class directly: use :meth:`_Event.deferred` - instead. For example: + Tests should not instantiate this class directly: use the `deferred` method + of the event instead. For example: ctx = Context(MyCharm) deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start) @@ -1883,6 +1991,11 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent: def _next_action_id(*, update=True): + """Get the ID the next action to be created will get. + + Pass update=False if you're only inspecting it. + Pass update=True if you also want to bump it. + """ global _next_action_id_counter cur = _next_action_id_counter if update: From 537cc0d02ceadc85a1b682289b53283520ad0f3a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 2 Sep 2024 21:53:24 +1200 Subject: [PATCH 526/546] Fix merge. --- pyproject.toml | 2 +- scenario/_consistency_checker.py | 4 +++- scenario/ops_main_mock.py | 15 +++++++++++++-- scenario/state.py | 6 +++--- tests/test_e2e/test_network.py | 27 ++++----------------------- tests/test_e2e/test_secrets.py | 28 ---------------------------- tox.ini | 2 +- 7 files changed, 25 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03ffdfc2c..eb6695b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops==2.15", + "ops~=2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/_consistency_checker.py b/scenario/_consistency_checker.py index 748806ff7..e8807aeb6 100644 --- a/scenario/_consistency_checker.py +++ b/scenario/_consistency_checker.py @@ -497,7 +497,9 @@ def check_network_consistency( } state_bindings = {network.binding_name for network in state.networks} - if diff := state_bindings.difference(meta_bindings.union(non_sub_relations)): + if diff := state_bindings.difference( + meta_bindings.union(non_sub_relations).union(implicit_bindings), + ): errors.append( f"Some network bindings defined in State are not in the metadata: {diff}.", ) diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index cc7391ccb..27f00a8b1 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -28,6 +28,17 @@ # pyright: reportPrivateUsage=false +# TODO: Use ops.jujucontext's _JujuContext.charm_dir. +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = pathlib.Path(f"{__file__}/../../..").resolve() + else: + charm_dir = pathlib.Path(charm_dir).resolve() + return charm_dir + + def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents: """Walk path on root to an ObjectEvents instance.""" obj = root @@ -70,7 +81,7 @@ def _emit_charm_event( ) try: - args, kwargs = _get_event_args(charm, event_to_emit) + args, kwargs = _get_event_args(charm, event_to_emit) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore @@ -169,7 +180,7 @@ def setup( charm_dir = _get_charm_dir() try: - dispatcher = _Dispatcher(charm_dir) + dispatcher = _Dispatcher(charm_dir) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore diff --git a/scenario/state.py b/scenario/state.py index 3a72f20b1..b93d9f769 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -412,7 +412,6 @@ def address(self, value): class BindAddress(_max_posargs(1)): """An address bound to a network interface in a Juju space.""" - interface_name: str addresses: List[Address] """The addresses in the space.""" interface_name: str = "" @@ -557,6 +556,7 @@ def _validate_databag(self, databag: dict): @dataclasses.dataclass(frozen=True) class Relation(RelationBase): """An integration between the charm and another application.""" + remote_app_name: str = "remote" """The name of the remote application, as in the charm's metadata.""" @@ -760,7 +760,7 @@ class Mount(_max_posargs(0)): location: Union[str, PurePosixPath] """The location inside of the container.""" - src: Union[str, Path] + source: Union[str, Path] """The content to provide when the charm does :meth:`ops.Container.pull`.""" @@ -1770,7 +1770,7 @@ def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]: @dataclasses.dataclass(frozen=True) -class Event: +class _Event: """A Juju, ops, or custom event that can be run against a charm. Typically, for simple events, the string name (e.g. ``install``) can be used, diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py index a09d09f68..7fe786677 100644 --- a/tests/test_e2e/test_network.py +++ b/tests/test_e2e/test_network.py @@ -133,8 +133,8 @@ def test_juju_info_network_default(mycharm): meta={"name": "foo"}, ) - with ctx.manager( - "update_status", + with ctx( + ctx.on.update_status(), State(), ) as mgr: # we have a network for the relation @@ -144,25 +144,6 @@ def test_juju_info_network_default(mycharm): ) -def test_juju_info_network_override(mycharm): - ctx = Context( - mycharm, - meta={"name": "foo"}, - ) - - with ctx.manager( - "update_status", - State( - networks={"juju-info": Network.default(private_address="4.4.4.4")}, - ), - ) as mgr: - # we have a network for the relation - assert ( - str(mgr.charm.model.get_binding("juju-info").network.bind_address) - == "4.4.4.4" - ) - - def test_explicit_juju_info_network_override(mycharm): ctx = Context( mycharm, @@ -173,8 +154,8 @@ def test_explicit_juju_info_network_override(mycharm): }, ) - with ctx.manager( - "update_status", + with ctx( + ctx.on.update_status(), State(), ) as mgr: assert mgr.charm.model.get_binding("juju-info").network.bind_address diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py index fb1590b79..0d5730df5 100644 --- a/tests/test_e2e/test_secrets.py +++ b/tests/test_e2e/test_secrets.py @@ -573,34 +573,6 @@ def _on_secret_remove(self, event): ) -@pytest.mark.parametrize( - "evt,owner,cls", - ( - ("changed", None, SecretChangedEvent), - ("rotate", "app", SecretRotateEvent), - ("expired", "app", SecretExpiredEvent), - ("remove", "app", SecretRemoveEvent), - ), -) -def test_emit_event(evt, owner, cls): - class MyCharm(CharmBase): - def __init__(self, framework): - super().__init__(framework) - for evt in self.on.events().values(): - self.framework.observe(evt, self._on_event) - self.events = [] - - def _on_event(self, event): - self.events.append(event) - - ctx = Context(MyCharm, meta={"name": "local"}) - secret = Secret(contents={"foo": "bar"}, id="foo", owner=owner) - with ctx.manager(getattr(secret, evt + "_event"), State(secrets=[secret])) as mgr: - mgr.run() - juju_event = mgr.charm.events[0] # Ignore collect-status etc. - assert isinstance(juju_event, cls) - - def test_set_label_on_get(): class SecretCharm(CharmBase): def __init__(self, framework): diff --git a/tox.ini b/tox.ini index 149dfbaf9..d31557318 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops==2.15 + ops~=2.15 pyright==1.1.347 commands = pyright scenario From 150a73b099169efb859d1963daaa2329936d9264 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 3 Sep 2024 17:36:23 +1200 Subject: [PATCH 527/546] Let action params be a Mapping rather than strictly a dict. --- scenario/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index b93d9f769..b35081f6f 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -25,6 +25,7 @@ Iterable, List, Literal, + Mapping, Optional, Sequence, Set, @@ -2023,7 +2024,7 @@ def test_backup_action(): name: str """Juju action name, as found in the charm metadata.""" - params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict) + params: Mapping[str, "AnyJson"] = dataclasses.field(default_factory=dict) """Parameter values passed to the action.""" id: str = dataclasses.field(default_factory=_next_action_id) From 987b82416787756a431fbd100d5a8ff1623470f8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 3 Sep 2024 17:39:57 +1200 Subject: [PATCH 528/546] Allow passing a Mapping for the action params, not just a dict. --- scenario/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index 08bfdd50c..7b41ec7ce 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -6,7 +6,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type, Union, cast import ops import ops.testing @@ -326,7 +326,7 @@ def pebble_check_recovered(container: Container, info: CheckInfo): @_copy_doc(ops.ActionEvent) def action( name: str, - params: Optional[Dict[str, "AnyJson"]] = None, + params: Optional[Mapping[str, "AnyJson"]] = None, id: Optional[str] = None, ): kwargs = {} From c848415912c01f9a9cbf07e7ccc235d6d47c1f06 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 5 Sep 2024 11:41:35 +1200 Subject: [PATCH 529/546] chore: add some code to ease the transition from 6.x to 7.0 (#185) * Remove `Manager.output` as noticed by @PietroPasotti * If `Context.run_action` is called, raise an error but point them towards the solution. * If `Context.run` is called with a string or callable event, raise an error but point them towards the solution * If `Relation.relation_id` is used, raise an error but point towards `.id` --- .gitignore | 1 + scenario/context.py | 57 ++++++++++++++++++++++++++++++++++++++++++++- scenario/state.py | 10 ++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d4532269..dd6ae82f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ venv/ +.venv/ build/ docs/_build/ *.charm diff --git a/scenario/context.py b/scenario/context.py index 7b41ec7ce..cb5331d5c 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -61,7 +61,6 @@ def __init__( self._emitted: bool = False self.ops: Optional["Ops"] = None - self.output: Optional["State"] = None @property def charm(self) -> ops.CharmBase: @@ -564,6 +563,16 @@ def __call__(self, event: "_Event", state: "State"): """ return Manager(self, event, state) + def run_action(self, action: str, state: "State"): + """Use `run()` instead. + + :private: + """ + raise AttributeError( + f"call with `ctx.run`, like `ctx.run(ctx.on.action({action!r})` " + "and find the results in `ctx.action_results`", + ) + def run(self, event: "_Event", state: "State") -> "State": """Trigger a charm execution with an event and a State. @@ -575,6 +584,52 @@ def run(self, event: "_Event", state: "State") -> "State": :arg state: the :class:`State` instance to use as data source for the hook tool calls that the charm will invoke when handling the event. """ + # Help people transition from Scenario 6: + if isinstance(event, str): + event = event.replace("-", "_") # type: ignore + if event in ( + "install", + "start", + "stop", + "remove", + "update_status", + "config_changed", + "upgrade_charm", + "pre_series_upgrade", + "post_series_upgrade", + "leader_elected", + "collect_app_status", + "collect_unit_status", + ): + suggested = f"{event}()" + elif event in ("secret_changed", "secret_rotate"): + suggested = f"{event}(my_secret)" + elif event in ("secret_expired", "secret_remove"): + suggested = f"{event}(my_secret, revision=1)" + elif event in ( + "relation_created", + "relation_joined", + "relation_changed", + "relation_departed", + "relation_broken", + ): + suggested = f"{event}(my_relation)" + elif event in ("storage_attached", "storage_detaching"): + suggested = f"{event}(my_storage)" + elif event == "pebble_ready": + suggested = f"{event}(my_container)" + elif event == "pebble_custom_notice": + suggested = f"{event}(my_container, my_notice)" + else: + suggested = "event()" + raise TypeError( + f"call with an event from `ctx.on`, like `ctx.on.{suggested}`", + ) + if callable(event): + raise TypeError( + "You should call the event method. Did you forget to add parentheses?", + ) + if event.action: # Reset the logs, failure status, and results, in case the context # is reused. diff --git a/scenario/state.py b/scenario/state.py index b35081f6f..e7190ea53 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -26,6 +26,7 @@ List, Literal, Mapping, + NoReturn, Optional, Sequence, Set, @@ -502,6 +503,14 @@ class RelationBase(_max_posargs(2)): ) """This unit's databag for this relation.""" + @property + def relation_id(self) -> NoReturn: + """Use `.id` instead of `.relation_id`. + + :private: + """ + raise AttributeError("use .id instead of .relation_id") + @property def _databags(self): """Yield all databags in this relation.""" @@ -1435,6 +1444,7 @@ def __post_init__(self): ] if self.storages != normalised_storage: object.__setattr__(self, "storages", normalised_storage) + # ops.Container, ops.Model, ops.Relation, ops.Secret should not be instantiated by charmers. # ops.Network does not have the relation name, so cannot be converted. # ops.Resources does not contain the source of the resource, so cannot be converted. From f774cb1f57f8340347234319dfbbdeef90bcd21e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 16:57:21 +1200 Subject: [PATCH 530/546] Cap ops at 2.16. --- pyproject.toml | 2 +- scenario/ops_main_mock.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4fbb741c1..da160e447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops>=2.12", + "ops>=2.12,<=2.16", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 8b2845c81..c090aef9d 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -88,7 +88,7 @@ def _emit_charm_event( ) try: - args, kwargs = _get_event_args(charm, event_to_emit) + args, kwargs = _get_event_args(charm, event_to_emit) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore @@ -182,7 +182,7 @@ def setup(state: "State", event: "Event", context: "Context", charm_spec: "_Char charm_dir = _get_charm_dir() try: - dispatcher = _Dispatcher(charm_dir) + dispatcher = _Dispatcher(charm_dir) # type: ignore except TypeError: # ops 2.16+ import ops.jujucontext # type: ignore From de9ff88f6351e43c6adb28afb40ba85abebb5ce8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 16:59:52 +1200 Subject: [PATCH 531/546] Bump version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index da160e447..388a3a504 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "6.1.6" +version = "6.1.7" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 21836cd60bc9bcb2a7043bbeb1c308b6759210d6 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 20:55:05 +1200 Subject: [PATCH 532/546] Inspect __init__ explicitly, for 3.8 compatibility. --- scenario/state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/state.py b/scenario/state.py index e7190ea53..9179735d7 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -139,12 +139,13 @@ def __new__(cls, *args, **kwargs): # declared, which aligns with dataclasses. Simpler ways of # getting the arguments (like __annotations__) do not have that # guarantee, although in practice it is the case. - parameters = inspect.signature(cls).parameters + parameters = inspect.signature(cls.__init__).parameters required_args = [ name for name in tuple(parameters) if parameters[name].default is inspect.Parameter.empty and name not in kwargs + and name != "self" ] n_posargs = len(args) max_n_posargs = cls._max_positional_args From 199581b47aa3448378530831cf1ae64f17c628f2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 20:55:21 +1200 Subject: [PATCH 533/546] Bump version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eb6695b42..f8c89a48c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.0" +version = "7.0.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From ab086fcb08fe9601cf46ae418cda19b5b54dc9a9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 12 Sep 2024 14:35:35 +1200 Subject: [PATCH 534/546] Handle the changes coming in ops 2.17 --- pyproject.toml | 2 +- scenario/mocking.py | 19 ++++++++++++++----- scenario/ops_main_mock.py | 13 +++++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8c89a48c..7151bf22d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.1" +version = "7.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index f5207a378..5b91c15b4 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -199,11 +199,20 @@ def _get_secret(self, id=None, label=None): # in scenario, you can create Secret(id="foo"), # but ops.Secret will prepend a "secret:" prefix to that ID. # we allow getting secret by either version. - secrets = [ - s - for s in self._state.secrets - if canonicalize_id(s.id) == canonicalize_id(id) - ] + try: + secrets = [ + s + for s in self._state.secrets + if canonicalize_id(s.id, model_uuid=self._state.model.uuid) # type: ignore + == canonicalize_id(id, model_uuid=self._state.model.uuid) # type: ignore + ] + except TypeError: + # ops 2.16 + secrets = [ + s + for s in self._state.secrets + if canonicalize_id(s.id) == canonicalize_id(id) # type: ignore + ] if not secrets: raise SecretNotFoundError(id) return secrets[0] diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 27f00a8b1..786b113f8 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -12,13 +12,18 @@ import ops.model import ops.storage from ops import CharmBase + +# use logger from ops._main so that juju_log will be triggered +try: + from ops._main import CHARM_STATE_FILE, _Dispatcher, _get_event_args # type: ignore + from ops._main import logger as ops_logger # type: ignore +except ImportError: + # Ops 2.16 + from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args # type: ignore + from ops.main import logger as ops_logger # type: ignore from ops.charm import CharmMeta from ops.log import setup_root_logging -# use logger from ops.main so that juju_log will be triggered -from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args -from ops.main import logger as ops_logger - from scenario.errors import BadOwnerPath, NoObserverError if TYPE_CHECKING: # pragma: no cover From 505f65f22109a06a622d0fbb0429e06c3b7352a7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 10 Sep 2024 22:16:33 +1200 Subject: [PATCH 535/546] Remove dependency on ops.testing. --- pyproject.toml | 4 +-- scenario/context.py | 21 ++++++++++---- scenario/mocking.py | 46 ++++++++++++++++++++----------- scenario/runtime.py | 4 +-- scenario/state.py | 2 +- tests/helpers.py | 4 +-- tests/test_charm_spec_autoload.py | 5 +--- tox.ini | 2 +- 8 files changed, 53 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8c89a48c..9de5d6d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.1" +version = "7.0.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops~=2.15", + "ops~=2.17", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/context.py b/scenario/context.py index cb5331d5c..4f4e409bc 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type, Union, cast import ops -import ops.testing from scenario.errors import AlreadyEmittedError, ContextSetupError from scenario.logger import logger as scenario_logger @@ -28,8 +27,17 @@ ) if TYPE_CHECKING: # pragma: no cover + from ops._private.harness import ExecArgs + from scenario.ops_main_mock import Ops - from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus + from scenario.state import ( + AnyJson, + CharmType, + JujuLogLine, + RelationBase, + State, + _EntityStatus, + ) logger = scenario_logger.getChild("runtime") @@ -426,7 +434,7 @@ def test_foo(): def __init__( self, - charm_type: Type[ops.testing.CharmType], + charm_type: Type["CharmType"], meta: Optional[Dict[str, Any]] = None, *, actions: Optional[Dict[str, Any]] = None, @@ -508,7 +516,7 @@ def __init__( self.juju_log: List["JujuLogLine"] = [] self.app_status_history: List["_EntityStatus"] = [] self.unit_status_history: List["_EntityStatus"] = [] - self.exec_history: Dict[str, List[ops.testing.ExecArgs]] = {} + self.exec_history: Dict[str, List["ExecArgs"]] = {} self.workload_version_history: List[str] = [] self.removed_secret_revisions: List[int] = [] self.emitted_events: List[ops.EventBase] = [] @@ -644,7 +652,10 @@ def run(self, event: "_Event", state: "State") -> "State": assert self._output_state is not None if event.action: if self._action_failure_message is not None: - raise ActionFailed(self._action_failure_message, self._output_state) + raise ActionFailed( + self._action_failure_message, + state=self._output_state, + ) return self._output_state @contextmanager diff --git a/scenario/mocking.py b/scenario/mocking.py index f5207a378..b2542e8c4 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. + import datetime +import io import shutil from pathlib import Path from typing import ( @@ -20,6 +22,7 @@ ) from ops import JujuVersion, pebble +from ops._private.harness import ExecArgs, _TestingPebbleClient from ops.model import CloudSpec as CloudSpec_Ops from ops.model import ModelError from ops.model import Port as Port_Ops @@ -33,7 +36,6 @@ _ModelBackend, ) from ops.pebble import Client, ExecError -from ops.testing import ExecArgs, _TestingPebbleClient from scenario.errors import ActionMissingFromContextError from scenario.logger import logger as scenario_logger @@ -66,9 +68,9 @@ def __init__( change_id: int, args: ExecArgs, return_code: int, - stdin: Optional[TextIO], - stdout: Optional[TextIO], - stderr: Optional[TextIO], + stdin: Optional[Union[TextIO, io.BytesIO]], + stdout: Optional[Union[TextIO, io.BytesIO]], + stderr: Optional[Union[TextIO, io.BytesIO]], ): self._change_id = change_id self._args = args @@ -99,7 +101,12 @@ def wait_output(self): stdout = self.stdout.read() if self.stdout is not None else None stderr = self.stderr.read() if self.stderr is not None else None if self._return_code != 0: - raise ExecError(list(self._args.command), self._return_code, stdout, stderr) + raise ExecError( + list(self._args.command), + self._return_code, + stdout, # type: ignore + stderr, # type: ignore + ) return stdout, stderr def send_signal(self, sig: Union[int, str]): # noqa: U100 @@ -167,15 +174,18 @@ def get_pebble(self, socket_path: str) -> "Client": # container not defined in state. mounts = {} - return _MockPebbleClient( - socket_path=socket_path, - container_root=container_root, - mounts=mounts, - state=self._state, - event=self._event, - charm_spec=self._charm_spec, - context=self._context, - container_name=container_name, + return cast( + Client, + _MockPebbleClient( + socket_path=socket_path, + container_root=container_root, + mounts=mounts, + state=self._state, + event=self._event, + charm_spec=self._charm_spec, + context=self._context, + container_name=container_name, + ), ) def _get_relation_by_id(self, rel_id) -> "RelationBase": @@ -607,7 +617,7 @@ def storage_add(self, name: str, count: int = 1): ) if "/" in name: - # this error is raised by ops.testing but not by ops at runtime + # this error is raised by Harness but not by ops at runtime raise ModelError('storage name cannot contain "/"') self._context.requested_storages[name] = count @@ -743,6 +753,10 @@ def __init__( self._root = container_root + self._notices: Dict[Tuple[str, str], pebble.Notice] = {} + self._last_notice_id = 0 + self._changes: Dict[str, pebble.Change] = {} + # load any existing notices and check information from the state self._notices: Dict[Tuple[str, str], pebble.Notice] = {} self._check_infos: Dict[str, pebble.CheckInfo] = {} @@ -781,7 +795,7 @@ def _layers(self) -> Dict[str, pebble.Layer]: def _service_status(self) -> Dict[str, pebble.ServiceStatus]: return self._container.service_statuses - # Based on a method of the same name from ops.testing. + # Based on a method of the same name from Harness. def _find_exec_handler(self, command) -> Optional["Exec"]: handlers = {exec.command_prefix: exec for exec in self._container.execs} # Start with the full command and, each loop iteration, drop the last diff --git a/scenario/runtime.py b/scenario/runtime.py index f4df73db8..92cfcae7d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -38,10 +38,8 @@ ) if TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - from scenario.context import Context - from scenario.state import State, _CharmSpec, _Event + from scenario.state import CharmType, State, _CharmSpec, _Event logger = scenario_logger.getChild("runtime") STORED_STATE_REGEX = re.compile( diff --git a/scenario/state.py b/scenario/state.py index 9179735d7..b089c582c 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -117,7 +117,7 @@ class ActionFailed(Exception): """Raised at the end of the hook if the charm has called ``event.fail()``.""" - def __init__(self, message: str, state: "State"): + def __init__(self, message: str, *, state: "State"): self.message = message self.state = state diff --git a/tests/helpers.py b/tests/helpers.py index 5ceffa9d2..49ffaeff9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -18,9 +18,7 @@ from scenario.context import _DEFAULT_JUJU_VERSION, Context if TYPE_CHECKING: # pragma: no cover - from ops.testing import CharmType - - from scenario.state import State, _Event + from scenario.state import CharmType, State, _Event _CT = TypeVar("_CT", bound=Type[CharmType]) diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py index 57b93a313..b9da4d244 100644 --- a/tests/test_charm_spec_autoload.py +++ b/tests/test_charm_spec_autoload.py @@ -1,18 +1,15 @@ import importlib import sys -import tempfile from contextlib import contextmanager from pathlib import Path from typing import Type import pytest import yaml -from ops import CharmBase -from ops.testing import CharmType from scenario import Context, Relation, State from scenario.context import ContextSetupError -from scenario.state import MetadataNotFoundError, _CharmSpec +from scenario.state import CharmType, MetadataNotFoundError, _CharmSpec CHARM = """ from ops import CharmBase diff --git a/tox.ini b/tox.ini index d31557318..bfe802f11 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops~=2.15 + ops~=2.17 pyright==1.1.347 commands = pyright scenario From 842e4696bf6f10a4420f645f4f2e7d0dc0e0ee6b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 12 Sep 2024 14:54:56 +1200 Subject: [PATCH 536/546] Assume that the adjustment for Secret._canonicalize_id will come before this. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9de5d6d3f..253924d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.2" +version = "7.0.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 16137ca19a174b6e0fc4830b251e4d87ca034bef Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 12 Sep 2024 14:35:35 +1200 Subject: [PATCH 537/546] Handle the changes coming in ops 2.17 --- scenario/mocking.py | 19 ++++++++++++++----- scenario/ops_main_mock.py | 13 +++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/scenario/mocking.py b/scenario/mocking.py index b2542e8c4..99b5d768d 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -209,11 +209,20 @@ def _get_secret(self, id=None, label=None): # in scenario, you can create Secret(id="foo"), # but ops.Secret will prepend a "secret:" prefix to that ID. # we allow getting secret by either version. - secrets = [ - s - for s in self._state.secrets - if canonicalize_id(s.id) == canonicalize_id(id) - ] + try: + secrets = [ + s + for s in self._state.secrets + if canonicalize_id(s.id, model_uuid=self._state.model.uuid) # type: ignore + == canonicalize_id(id, model_uuid=self._state.model.uuid) # type: ignore + ] + except TypeError: + # ops 2.16 + secrets = [ + s + for s in self._state.secrets + if canonicalize_id(s.id) == canonicalize_id(id) # type: ignore + ] if not secrets: raise SecretNotFoundError(id) return secrets[0] diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py index 27f00a8b1..786b113f8 100644 --- a/scenario/ops_main_mock.py +++ b/scenario/ops_main_mock.py @@ -12,13 +12,18 @@ import ops.model import ops.storage from ops import CharmBase + +# use logger from ops._main so that juju_log will be triggered +try: + from ops._main import CHARM_STATE_FILE, _Dispatcher, _get_event_args # type: ignore + from ops._main import logger as ops_logger # type: ignore +except ImportError: + # Ops 2.16 + from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args # type: ignore + from ops.main import logger as ops_logger # type: ignore from ops.charm import CharmMeta from ops.log import setup_root_logging -# use logger from ops.main so that juju_log will be triggered -from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args -from ops.main import logger as ops_logger - from scenario.errors import BadOwnerPath, NoObserverError if TYPE_CHECKING: # pragma: no cover From 6686bb588cbf29d8bac26818672e5f175460f71b Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 13 Sep 2024 12:08:43 +1200 Subject: [PATCH 538/546] Handle both ops 2.15+ and ops 2.17 --- pyproject.toml | 2 +- scenario/context.py | 7 +++++-- scenario/mocking.py | 7 ++++++- tox.ini | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 253924d03..59986c60e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ license.text = "Apache-2.0" keywords = ["juju", "test"] dependencies = [ - "ops~=2.17", + "ops~=2.15", "PyYAML>=6.0.1", ] readme = "README.md" diff --git a/scenario/context.py b/scenario/context.py index 4f4e409bc..26665c06a 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -27,7 +27,10 @@ ) if TYPE_CHECKING: # pragma: no cover - from ops._private.harness import ExecArgs + try: + from ops._private.harness import ExecArgs # type: ignore + except ImportError: + from ops.testing import ExecArgs # type: ignore from scenario.ops_main_mock import Ops from scenario.state import ( @@ -499,7 +502,7 @@ def __init__( self.charm_root = charm_root self.juju_version = juju_version if juju_version.split(".")[0] == "2": - logger.warn( + logger.warning( "Juju 2.x is closed and unsupported. You may encounter inconsistencies.", ) diff --git a/scenario/mocking.py b/scenario/mocking.py index 99b5d768d..d3ec6e201 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -22,7 +22,12 @@ ) from ops import JujuVersion, pebble -from ops._private.harness import ExecArgs, _TestingPebbleClient + +try: + from ops._private.harness import ExecArgs, _TestingPebbleClient # type: ignore +except ImportError: + from ops.testing import ExecArgs, _TestingPebbleClient # type: ignore + from ops.model import CloudSpec as CloudSpec_Ops from ops.model import ModelError from ops.model import Port as Port_Ops diff --git a/tox.ini b/tox.ini index bfe802f11..d31557318 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ commands = description = Static typing checks. skip_install = true deps = - ops~=2.17 + ops~=2.15 pyright==1.1.347 commands = pyright scenario From 97501fd7dcef41a009a5ed2f3f3af63a541f40b1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 18 Sep 2024 14:53:38 +1200 Subject: [PATCH 539/546] Catch the correct error when a relation doesn't exist. --- pyproject.toml | 2 +- scenario/mocking.py | 2 +- tests/test_e2e/test_relations.py | 48 +++++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7151bf22d..59986c60e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.2" +version = "7.0.3" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/mocking.py b/scenario/mocking.py index 5b91c15b4..9e004af47 100644 --- a/scenario/mocking.py +++ b/scenario/mocking.py @@ -181,7 +181,7 @@ def get_pebble(self, socket_path: str) -> "Client": def _get_relation_by_id(self, rel_id) -> "RelationBase": try: return self._state.get_relation(rel_id) - except ValueError: + except KeyError: raise RelationNotFoundError() from None def _get_secret(self, id=None, label=None): diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index b78804254..cc77734d3 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -13,6 +13,7 @@ from ops.framework import EventBase, Framework from scenario import Context +from scenario.errors import UncaughtCharmError from scenario.state import ( _DEFAULT_JUJU_DATABAG, PeerRelation, @@ -411,17 +412,50 @@ def test_relation_ids(): assert rel.id == initial_id + i -def test_broken_relation_not_in_model_relations(mycharm): - rel = Relation("foo") +def test_get_relation_when_missing(): + class MyCharm(CharmBase): + def __init__(self, framework): + super().__init__(framework) + self.framework.observe(self.on.update_status, self._on_update_status) + self.framework.observe(self.on.config_changed, self._on_config_changed) + self.relation = None + + def _on_update_status(self, _): + self.relation = self.model.get_relation("foo") + + def _on_config_changed(self, _): + self.relation = self.model.get_relation("foo", self.config["relation-id"]) ctx = Context( - mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} + MyCharm, + meta={"name": "foo", "requires": {"foo": {"interface": "foo"}}}, + config={"options": {"relation-id": {"type": "int", "description": "foo"}}}, ) - with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: - charm = mgr.charm + # There should be no error if the relation is missing - get_relation returns + # None in that case. + with ctx(ctx.on.update_status(), State()) as mgr: + mgr.run() + assert mgr.charm.relation is None - assert charm.model.get_relation("foo") is None - assert charm.model.relations["foo"] == [] + # There should also be no error if the relation is present, of course. + rel = Relation("foo") + with ctx(ctx.on.update_status(), State(relations={rel})) as mgr: + mgr.run() + assert mgr.charm.relation.id == rel.id + + # If a relation that doesn't exist is requested, that should also not raise + # an error. + with ctx(ctx.on.config_changed(), State(config={"relation-id": 42})) as mgr: + mgr.run() + rel = mgr.charm.relation + assert rel.id == 42 + assert not rel.active + + # If there's no defined relation with the name, then get_relation raises KeyError. + ctx = Context(MyCharm, meta={"name": "foo"}) + with pytest.raises(UncaughtCharmError) as exc: + ctx.run(ctx.on.update_status(), State()) + assert isinstance(exc.value.__cause__, KeyError) @pytest.mark.parametrize("klass", (Relation, PeerRelation, SubordinateRelation)) From 7be495f9dd6e146f7426a0153cd856dd939a385e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 18 Sep 2024 15:00:19 +1200 Subject: [PATCH 540/546] Restore test removed by mistake. --- tests/test_e2e/test_relations.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py index cc77734d3..ca39cc98b 100644 --- a/tests/test_e2e/test_relations.py +++ b/tests/test_e2e/test_relations.py @@ -412,6 +412,19 @@ def test_relation_ids(): assert rel.id == initial_id + i +def test_broken_relation_not_in_model_relations(mycharm): + rel = Relation("foo") + + ctx = Context( + mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}} + ) + with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr: + charm = mgr.charm + + assert charm.model.get_relation("foo") is None + assert charm.model.relations["foo"] == [] + + def test_get_relation_when_missing(): class MyCharm(CharmBase): def __init__(self, framework): From 614545a742d784c43ec17eef90448daa2db4aa1a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 18 Sep 2024 21:43:55 +1200 Subject: [PATCH 541/546] Add py.typed stub file. --- scenario/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scenario/py.typed diff --git a/scenario/py.typed b/scenario/py.typed new file mode 100644 index 000000000..e69de29bb From 0a1b4a87b69bda4c8648388e7a7cf1e4fb718cb9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Wed, 18 Sep 2024 21:46:02 +1200 Subject: [PATCH 542/546] Bump version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 59986c60e..67dca9a94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.3" +version = "7.0.4" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } From 2433c1cbbd52e8f46ee181a2c8067309bcd98e17 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 19 Sep 2024 23:45:14 +1200 Subject: [PATCH 543/546] Minor doc fixes. --- scenario/context.py | 2 +- scenario/state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scenario/context.py b/scenario/context.py index cb5331d5c..8c7d271c2 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -167,7 +167,7 @@ def config_changed(): return _Event("config_changed") @staticmethod - @_copy_doc(ops.UpdateStatusEvent) + @_copy_doc(ops.UpgradeCharmEvent) def upgrade_charm(): return _Event("upgrade_charm") diff --git a/scenario/state.py b/scenario/state.py index 9179735d7..09d1895a0 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -824,7 +824,7 @@ class Notice(_max_posargs(1)): last_repeated: datetime.datetime = dataclasses.field(default_factory=_now_utc) """The time this notice was last repeated. - See Pebble's `Notices documentation `_ + See Pebble's `Notices documentation `_ for an explanation of what "repeated" means. """ From 7ec526eb2824ab44915d0fb44c5e54d833d70723 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 20 Sep 2024 10:22:45 +1200 Subject: [PATCH 544/546] Use a slightly more strict type for AnyJson. --- pyproject.toml | 2 +- scenario/state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 67dca9a94..462fd4d93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "7.0.4" +version = "7.0.5" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/state.py b/scenario/state.py index 3520f3441..b8546d150 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -52,7 +52,7 @@ if TYPE_CHECKING: # pragma: no cover from scenario import Context -AnyJson = Union[str, bool, dict, int, float, list] +AnyJson = Union[str, bool, Dict[str, "AnyJson"], int, float, List["AnyJson"]] RawSecretRevisionContents = RawDataBagContents = Dict[str, str] UnitID = int From e88789b640b860556752fbab67463a4079c32fc8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 23 Sep 2024 17:02:16 +1200 Subject: [PATCH 545/546] Expose CharmEvents at the top level. --- scenario/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenario/__init__.py b/scenario/__init__.py index 2ba5a24c7..5b13ae6b6 100644 --- a/scenario/__init__.py +++ b/scenario/__init__.py @@ -60,7 +60,7 @@ def test_base(): assert out.unit_status == UnknownStatus() """ -from scenario.context import Context, Manager +from scenario.context import CharmEvents, Context, Manager from scenario.state import ( ActionFailed, ActiveStatus, @@ -110,6 +110,7 @@ def test_base(): "AnyJson", "BindAddress", "BlockedStatus", + "CharmEvents", "CharmType", "CheckInfo", "CloudCredential", From 8d26b14e75169833988b1aba6ba80a66db944aa8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 27 Sep 2024 12:59:24 +1200 Subject: [PATCH 546/546] Move everything to a new top-level folder to simplify merging. --- .../.github}/workflows/build_wheels.yaml | 0 .../.github}/workflows/quality_checks.yaml | 0 .gitignore => ops-scenario/.gitignore | 0 .../.pre-commit-config.yaml | 0 CODEOWNERS => ops-scenario/CODEOWNERS | 0 CONTRIBUTING.md => ops-scenario/CONTRIBUTING.md | 0 LICENSE.txt => ops-scenario/LICENSE.txt | 0 README.md => ops-scenario/README.md | 0 UPGRADING.md => ops-scenario/UPGRADING.md | 0 {docs => ops-scenario/docs}/.sphinx/_static/404.svg | 0 .../docs}/.sphinx/_static/custom.css | 0 .../docs}/.sphinx/_static/favicon.png | Bin .../docs}/.sphinx/_static/furo_colors.css | 0 .../docs}/.sphinx/_static/github_issue_links.css | 0 .../docs}/.sphinx/_static/github_issue_links.js | 0 .../docs}/.sphinx/_static/header-nav.js | 0 .../docs}/.sphinx/_static/header.css | 0 {docs => ops-scenario/docs}/.sphinx/_static/tag.png | Bin .../docs}/.sphinx/_templates/404.html | 0 .../docs}/.sphinx/_templates/base.html | 0 .../docs}/.sphinx/_templates/footer.html | 0 .../docs}/.sphinx/_templates/header.html | 0 .../docs}/.sphinx/_templates/page.html | 0 .../docs}/.sphinx/_templates/sidebar/search.html | 0 .../docs}/.sphinx/build_requirements.py | 0 {docs => ops-scenario/docs}/.sphinx/pa11y.json | 0 .../docs}/.sphinx/spellingcheck.yaml | 0 {docs => ops-scenario/docs}/_static/custom.css | 0 {docs => ops-scenario/docs}/_static/favicon.png | Bin .../docs}/_static/github_issue_links.css | 0 .../docs}/_static/github_issue_links.js | 0 {docs => ops-scenario/docs}/_templates/base.html | 0 {docs => ops-scenario/docs}/_templates/footer.html | 0 {docs => ops-scenario/docs}/_templates/page.html | 0 {docs => ops-scenario/docs}/conf.py | 0 {docs => ops-scenario/docs}/custom_conf.py | 0 {docs => ops-scenario/docs}/index.rst | 0 {docs => ops-scenario/docs}/requirements.txt | 0 pyproject.toml => ops-scenario/pyproject.toml | 0 .../resources}/state-transition-model.png | Bin {scenario => ops-scenario/scenario}/__init__.py | 0 .../scenario}/_consistency_checker.py | 0 {scenario => ops-scenario/scenario}/context.py | 0 {scenario => ops-scenario/scenario}/errors.py | 0 {scenario => ops-scenario/scenario}/logger.py | 0 {scenario => ops-scenario/scenario}/mocking.py | 0 .../scenario}/ops_main_mock.py | 0 {scenario => ops-scenario/scenario}/py.typed | 0 {scenario => ops-scenario/scenario}/runtime.py | 0 {scenario => ops-scenario/scenario}/state.py | 0 {tests => ops-scenario/tests}/helpers.py | 0 {tests => ops-scenario/tests}/readme-conftest.py | 0 .../tests}/test_charm_spec_autoload.py | 0 .../tests}/test_consistency_checker.py | 0 {tests => ops-scenario/tests}/test_context.py | 0 {tests => ops-scenario/tests}/test_context_on.py | 0 .../tests}/test_e2e/test_actions.py | 0 .../tests}/test_e2e/test_cloud_spec.py | 0 .../tests}/test_e2e/test_config.py | 0 .../tests}/test_e2e/test_deferred.py | 0 .../tests}/test_e2e/test_event.py | 0 .../tests}/test_e2e/test_juju_log.py | 0 .../tests}/test_e2e/test_manager.py | 0 .../tests}/test_e2e/test_network.py | 0 .../tests}/test_e2e/test_pebble.py | 0 .../tests}/test_e2e/test_play_assertions.py | 0 .../tests}/test_e2e/test_ports.py | 0 .../tests}/test_e2e/test_relations.py | 0 .../tests}/test_e2e/test_resource.py | 0 .../tests}/test_e2e/test_rubbish_events.py | 0 .../tests}/test_e2e/test_secrets.py | 0 .../tests}/test_e2e/test_state.py | 0 .../tests}/test_e2e/test_status.py | 0 .../tests}/test_e2e/test_storage.py | 0 .../tests}/test_e2e/test_stored_state.py | 0 .../tests}/test_e2e/test_vroot.py | 0 .../tests}/test_emitted_events_util.py | 0 {tests => ops-scenario/tests}/test_plugin.py | 0 {tests => ops-scenario/tests}/test_runtime.py | 0 tox.ini => ops-scenario/tox.ini | 0 80 files changed, 0 insertions(+), 0 deletions(-) rename {.github => ops-scenario/.github}/workflows/build_wheels.yaml (100%) rename {.github => ops-scenario/.github}/workflows/quality_checks.yaml (100%) rename .gitignore => ops-scenario/.gitignore (100%) rename .pre-commit-config.yaml => ops-scenario/.pre-commit-config.yaml (100%) rename CODEOWNERS => ops-scenario/CODEOWNERS (100%) rename CONTRIBUTING.md => ops-scenario/CONTRIBUTING.md (100%) rename LICENSE.txt => ops-scenario/LICENSE.txt (100%) rename README.md => ops-scenario/README.md (100%) rename UPGRADING.md => ops-scenario/UPGRADING.md (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/404.svg (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/custom.css (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/favicon.png (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/furo_colors.css (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/github_issue_links.css (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/github_issue_links.js (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/header-nav.js (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/header.css (100%) rename {docs => ops-scenario/docs}/.sphinx/_static/tag.png (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/404.html (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/base.html (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/footer.html (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/header.html (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/page.html (100%) rename {docs => ops-scenario/docs}/.sphinx/_templates/sidebar/search.html (100%) rename {docs => ops-scenario/docs}/.sphinx/build_requirements.py (100%) rename {docs => ops-scenario/docs}/.sphinx/pa11y.json (100%) rename {docs => ops-scenario/docs}/.sphinx/spellingcheck.yaml (100%) rename {docs => ops-scenario/docs}/_static/custom.css (100%) rename {docs => ops-scenario/docs}/_static/favicon.png (100%) rename {docs => ops-scenario/docs}/_static/github_issue_links.css (100%) rename {docs => ops-scenario/docs}/_static/github_issue_links.js (100%) rename {docs => ops-scenario/docs}/_templates/base.html (100%) rename {docs => ops-scenario/docs}/_templates/footer.html (100%) rename {docs => ops-scenario/docs}/_templates/page.html (100%) rename {docs => ops-scenario/docs}/conf.py (100%) rename {docs => ops-scenario/docs}/custom_conf.py (100%) rename {docs => ops-scenario/docs}/index.rst (100%) rename {docs => ops-scenario/docs}/requirements.txt (100%) rename pyproject.toml => ops-scenario/pyproject.toml (100%) rename {resources => ops-scenario/resources}/state-transition-model.png (100%) rename {scenario => ops-scenario/scenario}/__init__.py (100%) rename {scenario => ops-scenario/scenario}/_consistency_checker.py (100%) rename {scenario => ops-scenario/scenario}/context.py (100%) rename {scenario => ops-scenario/scenario}/errors.py (100%) rename {scenario => ops-scenario/scenario}/logger.py (100%) rename {scenario => ops-scenario/scenario}/mocking.py (100%) rename {scenario => ops-scenario/scenario}/ops_main_mock.py (100%) rename {scenario => ops-scenario/scenario}/py.typed (100%) rename {scenario => ops-scenario/scenario}/runtime.py (100%) rename {scenario => ops-scenario/scenario}/state.py (100%) rename {tests => ops-scenario/tests}/helpers.py (100%) rename {tests => ops-scenario/tests}/readme-conftest.py (100%) rename {tests => ops-scenario/tests}/test_charm_spec_autoload.py (100%) rename {tests => ops-scenario/tests}/test_consistency_checker.py (100%) rename {tests => ops-scenario/tests}/test_context.py (100%) rename {tests => ops-scenario/tests}/test_context_on.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_actions.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_cloud_spec.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_config.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_deferred.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_event.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_juju_log.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_manager.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_network.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_pebble.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_play_assertions.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_ports.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_relations.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_resource.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_rubbish_events.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_secrets.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_state.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_status.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_storage.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_stored_state.py (100%) rename {tests => ops-scenario/tests}/test_e2e/test_vroot.py (100%) rename {tests => ops-scenario/tests}/test_emitted_events_util.py (100%) rename {tests => ops-scenario/tests}/test_plugin.py (100%) rename {tests => ops-scenario/tests}/test_runtime.py (100%) rename tox.ini => ops-scenario/tox.ini (100%) diff --git a/.github/workflows/build_wheels.yaml b/ops-scenario/.github/workflows/build_wheels.yaml similarity index 100% rename from .github/workflows/build_wheels.yaml rename to ops-scenario/.github/workflows/build_wheels.yaml diff --git a/.github/workflows/quality_checks.yaml b/ops-scenario/.github/workflows/quality_checks.yaml similarity index 100% rename from .github/workflows/quality_checks.yaml rename to ops-scenario/.github/workflows/quality_checks.yaml diff --git a/.gitignore b/ops-scenario/.gitignore similarity index 100% rename from .gitignore rename to ops-scenario/.gitignore diff --git a/.pre-commit-config.yaml b/ops-scenario/.pre-commit-config.yaml similarity index 100% rename from .pre-commit-config.yaml rename to ops-scenario/.pre-commit-config.yaml diff --git a/CODEOWNERS b/ops-scenario/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to ops-scenario/CODEOWNERS diff --git a/CONTRIBUTING.md b/ops-scenario/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to ops-scenario/CONTRIBUTING.md diff --git a/LICENSE.txt b/ops-scenario/LICENSE.txt similarity index 100% rename from LICENSE.txt rename to ops-scenario/LICENSE.txt diff --git a/README.md b/ops-scenario/README.md similarity index 100% rename from README.md rename to ops-scenario/README.md diff --git a/UPGRADING.md b/ops-scenario/UPGRADING.md similarity index 100% rename from UPGRADING.md rename to ops-scenario/UPGRADING.md diff --git a/docs/.sphinx/_static/404.svg b/ops-scenario/docs/.sphinx/_static/404.svg similarity index 100% rename from docs/.sphinx/_static/404.svg rename to ops-scenario/docs/.sphinx/_static/404.svg diff --git a/docs/.sphinx/_static/custom.css b/ops-scenario/docs/.sphinx/_static/custom.css similarity index 100% rename from docs/.sphinx/_static/custom.css rename to ops-scenario/docs/.sphinx/_static/custom.css diff --git a/docs/.sphinx/_static/favicon.png b/ops-scenario/docs/.sphinx/_static/favicon.png similarity index 100% rename from docs/.sphinx/_static/favicon.png rename to ops-scenario/docs/.sphinx/_static/favicon.png diff --git a/docs/.sphinx/_static/furo_colors.css b/ops-scenario/docs/.sphinx/_static/furo_colors.css similarity index 100% rename from docs/.sphinx/_static/furo_colors.css rename to ops-scenario/docs/.sphinx/_static/furo_colors.css diff --git a/docs/.sphinx/_static/github_issue_links.css b/ops-scenario/docs/.sphinx/_static/github_issue_links.css similarity index 100% rename from docs/.sphinx/_static/github_issue_links.css rename to ops-scenario/docs/.sphinx/_static/github_issue_links.css diff --git a/docs/.sphinx/_static/github_issue_links.js b/ops-scenario/docs/.sphinx/_static/github_issue_links.js similarity index 100% rename from docs/.sphinx/_static/github_issue_links.js rename to ops-scenario/docs/.sphinx/_static/github_issue_links.js diff --git a/docs/.sphinx/_static/header-nav.js b/ops-scenario/docs/.sphinx/_static/header-nav.js similarity index 100% rename from docs/.sphinx/_static/header-nav.js rename to ops-scenario/docs/.sphinx/_static/header-nav.js diff --git a/docs/.sphinx/_static/header.css b/ops-scenario/docs/.sphinx/_static/header.css similarity index 100% rename from docs/.sphinx/_static/header.css rename to ops-scenario/docs/.sphinx/_static/header.css diff --git a/docs/.sphinx/_static/tag.png b/ops-scenario/docs/.sphinx/_static/tag.png similarity index 100% rename from docs/.sphinx/_static/tag.png rename to ops-scenario/docs/.sphinx/_static/tag.png diff --git a/docs/.sphinx/_templates/404.html b/ops-scenario/docs/.sphinx/_templates/404.html similarity index 100% rename from docs/.sphinx/_templates/404.html rename to ops-scenario/docs/.sphinx/_templates/404.html diff --git a/docs/.sphinx/_templates/base.html b/ops-scenario/docs/.sphinx/_templates/base.html similarity index 100% rename from docs/.sphinx/_templates/base.html rename to ops-scenario/docs/.sphinx/_templates/base.html diff --git a/docs/.sphinx/_templates/footer.html b/ops-scenario/docs/.sphinx/_templates/footer.html similarity index 100% rename from docs/.sphinx/_templates/footer.html rename to ops-scenario/docs/.sphinx/_templates/footer.html diff --git a/docs/.sphinx/_templates/header.html b/ops-scenario/docs/.sphinx/_templates/header.html similarity index 100% rename from docs/.sphinx/_templates/header.html rename to ops-scenario/docs/.sphinx/_templates/header.html diff --git a/docs/.sphinx/_templates/page.html b/ops-scenario/docs/.sphinx/_templates/page.html similarity index 100% rename from docs/.sphinx/_templates/page.html rename to ops-scenario/docs/.sphinx/_templates/page.html diff --git a/docs/.sphinx/_templates/sidebar/search.html b/ops-scenario/docs/.sphinx/_templates/sidebar/search.html similarity index 100% rename from docs/.sphinx/_templates/sidebar/search.html rename to ops-scenario/docs/.sphinx/_templates/sidebar/search.html diff --git a/docs/.sphinx/build_requirements.py b/ops-scenario/docs/.sphinx/build_requirements.py similarity index 100% rename from docs/.sphinx/build_requirements.py rename to ops-scenario/docs/.sphinx/build_requirements.py diff --git a/docs/.sphinx/pa11y.json b/ops-scenario/docs/.sphinx/pa11y.json similarity index 100% rename from docs/.sphinx/pa11y.json rename to ops-scenario/docs/.sphinx/pa11y.json diff --git a/docs/.sphinx/spellingcheck.yaml b/ops-scenario/docs/.sphinx/spellingcheck.yaml similarity index 100% rename from docs/.sphinx/spellingcheck.yaml rename to ops-scenario/docs/.sphinx/spellingcheck.yaml diff --git a/docs/_static/custom.css b/ops-scenario/docs/_static/custom.css similarity index 100% rename from docs/_static/custom.css rename to ops-scenario/docs/_static/custom.css diff --git a/docs/_static/favicon.png b/ops-scenario/docs/_static/favicon.png similarity index 100% rename from docs/_static/favicon.png rename to ops-scenario/docs/_static/favicon.png diff --git a/docs/_static/github_issue_links.css b/ops-scenario/docs/_static/github_issue_links.css similarity index 100% rename from docs/_static/github_issue_links.css rename to ops-scenario/docs/_static/github_issue_links.css diff --git a/docs/_static/github_issue_links.js b/ops-scenario/docs/_static/github_issue_links.js similarity index 100% rename from docs/_static/github_issue_links.js rename to ops-scenario/docs/_static/github_issue_links.js diff --git a/docs/_templates/base.html b/ops-scenario/docs/_templates/base.html similarity index 100% rename from docs/_templates/base.html rename to ops-scenario/docs/_templates/base.html diff --git a/docs/_templates/footer.html b/ops-scenario/docs/_templates/footer.html similarity index 100% rename from docs/_templates/footer.html rename to ops-scenario/docs/_templates/footer.html diff --git a/docs/_templates/page.html b/ops-scenario/docs/_templates/page.html similarity index 100% rename from docs/_templates/page.html rename to ops-scenario/docs/_templates/page.html diff --git a/docs/conf.py b/ops-scenario/docs/conf.py similarity index 100% rename from docs/conf.py rename to ops-scenario/docs/conf.py diff --git a/docs/custom_conf.py b/ops-scenario/docs/custom_conf.py similarity index 100% rename from docs/custom_conf.py rename to ops-scenario/docs/custom_conf.py diff --git a/docs/index.rst b/ops-scenario/docs/index.rst similarity index 100% rename from docs/index.rst rename to ops-scenario/docs/index.rst diff --git a/docs/requirements.txt b/ops-scenario/docs/requirements.txt similarity index 100% rename from docs/requirements.txt rename to ops-scenario/docs/requirements.txt diff --git a/pyproject.toml b/ops-scenario/pyproject.toml similarity index 100% rename from pyproject.toml rename to ops-scenario/pyproject.toml diff --git a/resources/state-transition-model.png b/ops-scenario/resources/state-transition-model.png similarity index 100% rename from resources/state-transition-model.png rename to ops-scenario/resources/state-transition-model.png diff --git a/scenario/__init__.py b/ops-scenario/scenario/__init__.py similarity index 100% rename from scenario/__init__.py rename to ops-scenario/scenario/__init__.py diff --git a/scenario/_consistency_checker.py b/ops-scenario/scenario/_consistency_checker.py similarity index 100% rename from scenario/_consistency_checker.py rename to ops-scenario/scenario/_consistency_checker.py diff --git a/scenario/context.py b/ops-scenario/scenario/context.py similarity index 100% rename from scenario/context.py rename to ops-scenario/scenario/context.py diff --git a/scenario/errors.py b/ops-scenario/scenario/errors.py similarity index 100% rename from scenario/errors.py rename to ops-scenario/scenario/errors.py diff --git a/scenario/logger.py b/ops-scenario/scenario/logger.py similarity index 100% rename from scenario/logger.py rename to ops-scenario/scenario/logger.py diff --git a/scenario/mocking.py b/ops-scenario/scenario/mocking.py similarity index 100% rename from scenario/mocking.py rename to ops-scenario/scenario/mocking.py diff --git a/scenario/ops_main_mock.py b/ops-scenario/scenario/ops_main_mock.py similarity index 100% rename from scenario/ops_main_mock.py rename to ops-scenario/scenario/ops_main_mock.py diff --git a/scenario/py.typed b/ops-scenario/scenario/py.typed similarity index 100% rename from scenario/py.typed rename to ops-scenario/scenario/py.typed diff --git a/scenario/runtime.py b/ops-scenario/scenario/runtime.py similarity index 100% rename from scenario/runtime.py rename to ops-scenario/scenario/runtime.py diff --git a/scenario/state.py b/ops-scenario/scenario/state.py similarity index 100% rename from scenario/state.py rename to ops-scenario/scenario/state.py diff --git a/tests/helpers.py b/ops-scenario/tests/helpers.py similarity index 100% rename from tests/helpers.py rename to ops-scenario/tests/helpers.py diff --git a/tests/readme-conftest.py b/ops-scenario/tests/readme-conftest.py similarity index 100% rename from tests/readme-conftest.py rename to ops-scenario/tests/readme-conftest.py diff --git a/tests/test_charm_spec_autoload.py b/ops-scenario/tests/test_charm_spec_autoload.py similarity index 100% rename from tests/test_charm_spec_autoload.py rename to ops-scenario/tests/test_charm_spec_autoload.py diff --git a/tests/test_consistency_checker.py b/ops-scenario/tests/test_consistency_checker.py similarity index 100% rename from tests/test_consistency_checker.py rename to ops-scenario/tests/test_consistency_checker.py diff --git a/tests/test_context.py b/ops-scenario/tests/test_context.py similarity index 100% rename from tests/test_context.py rename to ops-scenario/tests/test_context.py diff --git a/tests/test_context_on.py b/ops-scenario/tests/test_context_on.py similarity index 100% rename from tests/test_context_on.py rename to ops-scenario/tests/test_context_on.py diff --git a/tests/test_e2e/test_actions.py b/ops-scenario/tests/test_e2e/test_actions.py similarity index 100% rename from tests/test_e2e/test_actions.py rename to ops-scenario/tests/test_e2e/test_actions.py diff --git a/tests/test_e2e/test_cloud_spec.py b/ops-scenario/tests/test_e2e/test_cloud_spec.py similarity index 100% rename from tests/test_e2e/test_cloud_spec.py rename to ops-scenario/tests/test_e2e/test_cloud_spec.py diff --git a/tests/test_e2e/test_config.py b/ops-scenario/tests/test_e2e/test_config.py similarity index 100% rename from tests/test_e2e/test_config.py rename to ops-scenario/tests/test_e2e/test_config.py diff --git a/tests/test_e2e/test_deferred.py b/ops-scenario/tests/test_e2e/test_deferred.py similarity index 100% rename from tests/test_e2e/test_deferred.py rename to ops-scenario/tests/test_e2e/test_deferred.py diff --git a/tests/test_e2e/test_event.py b/ops-scenario/tests/test_e2e/test_event.py similarity index 100% rename from tests/test_e2e/test_event.py rename to ops-scenario/tests/test_e2e/test_event.py diff --git a/tests/test_e2e/test_juju_log.py b/ops-scenario/tests/test_e2e/test_juju_log.py similarity index 100% rename from tests/test_e2e/test_juju_log.py rename to ops-scenario/tests/test_e2e/test_juju_log.py diff --git a/tests/test_e2e/test_manager.py b/ops-scenario/tests/test_e2e/test_manager.py similarity index 100% rename from tests/test_e2e/test_manager.py rename to ops-scenario/tests/test_e2e/test_manager.py diff --git a/tests/test_e2e/test_network.py b/ops-scenario/tests/test_e2e/test_network.py similarity index 100% rename from tests/test_e2e/test_network.py rename to ops-scenario/tests/test_e2e/test_network.py diff --git a/tests/test_e2e/test_pebble.py b/ops-scenario/tests/test_e2e/test_pebble.py similarity index 100% rename from tests/test_e2e/test_pebble.py rename to ops-scenario/tests/test_e2e/test_pebble.py diff --git a/tests/test_e2e/test_play_assertions.py b/ops-scenario/tests/test_e2e/test_play_assertions.py similarity index 100% rename from tests/test_e2e/test_play_assertions.py rename to ops-scenario/tests/test_e2e/test_play_assertions.py diff --git a/tests/test_e2e/test_ports.py b/ops-scenario/tests/test_e2e/test_ports.py similarity index 100% rename from tests/test_e2e/test_ports.py rename to ops-scenario/tests/test_e2e/test_ports.py diff --git a/tests/test_e2e/test_relations.py b/ops-scenario/tests/test_e2e/test_relations.py similarity index 100% rename from tests/test_e2e/test_relations.py rename to ops-scenario/tests/test_e2e/test_relations.py diff --git a/tests/test_e2e/test_resource.py b/ops-scenario/tests/test_e2e/test_resource.py similarity index 100% rename from tests/test_e2e/test_resource.py rename to ops-scenario/tests/test_e2e/test_resource.py diff --git a/tests/test_e2e/test_rubbish_events.py b/ops-scenario/tests/test_e2e/test_rubbish_events.py similarity index 100% rename from tests/test_e2e/test_rubbish_events.py rename to ops-scenario/tests/test_e2e/test_rubbish_events.py diff --git a/tests/test_e2e/test_secrets.py b/ops-scenario/tests/test_e2e/test_secrets.py similarity index 100% rename from tests/test_e2e/test_secrets.py rename to ops-scenario/tests/test_e2e/test_secrets.py diff --git a/tests/test_e2e/test_state.py b/ops-scenario/tests/test_e2e/test_state.py similarity index 100% rename from tests/test_e2e/test_state.py rename to ops-scenario/tests/test_e2e/test_state.py diff --git a/tests/test_e2e/test_status.py b/ops-scenario/tests/test_e2e/test_status.py similarity index 100% rename from tests/test_e2e/test_status.py rename to ops-scenario/tests/test_e2e/test_status.py diff --git a/tests/test_e2e/test_storage.py b/ops-scenario/tests/test_e2e/test_storage.py similarity index 100% rename from tests/test_e2e/test_storage.py rename to ops-scenario/tests/test_e2e/test_storage.py diff --git a/tests/test_e2e/test_stored_state.py b/ops-scenario/tests/test_e2e/test_stored_state.py similarity index 100% rename from tests/test_e2e/test_stored_state.py rename to ops-scenario/tests/test_e2e/test_stored_state.py diff --git a/tests/test_e2e/test_vroot.py b/ops-scenario/tests/test_e2e/test_vroot.py similarity index 100% rename from tests/test_e2e/test_vroot.py rename to ops-scenario/tests/test_e2e/test_vroot.py diff --git a/tests/test_emitted_events_util.py b/ops-scenario/tests/test_emitted_events_util.py similarity index 100% rename from tests/test_emitted_events_util.py rename to ops-scenario/tests/test_emitted_events_util.py diff --git a/tests/test_plugin.py b/ops-scenario/tests/test_plugin.py similarity index 100% rename from tests/test_plugin.py rename to ops-scenario/tests/test_plugin.py diff --git a/tests/test_runtime.py b/ops-scenario/tests/test_runtime.py similarity index 100% rename from tests/test_runtime.py rename to ops-scenario/tests/test_runtime.py diff --git a/tox.ini b/ops-scenario/tox.ini similarity index 100% rename from tox.ini rename to ops-scenario/tox.ini