Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

charm state initial prototype #28

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,70 @@ 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.

# Charm State

Suppose that your charm code makes an http call to a server somewhere to get some data, say, the current temperature reading from a sensor on top of the Nieuwe Kerk in Delft, The Netherlands.

If you follow the best practices of how to structure your charm code, then you are aware that this piece of data, at runtime, is categorised as 'charm state'.
Scenario offers a way to plug into this system natively, and integrate this charm state data structure into its own `State` tree.

If your charm code looks like this:
```python
from dataclasses import dataclass
from ops import CharmBase, Framework

from scenario.charm_state import CharmStateBackend
from scenario.state import CharmState

# in state.py
@dataclass(frozen=True)
class MyState(CharmState):
temperature: float = 4.5 # brr

# in state.py
class MyCharmStateBackend(CharmStateBackend):
@property
def temperature(self) -> int:
import requests
return requests.get('http://nieuwekerk.delft.nl/temp...').json()['celsius']

# no setter: you can't change the weather.
# ... Can you?

# in charm.py
class MyCharm(CharmBase):
state = MyCharmStateBackend()

def __init__(self, framework: Framework):
super().__init__(framework)
self.temperature = self.state.temperature
```

Then you can write scenario tests like that:

```python
import pytest
from scenario import Context, State
from charm import MyCharm
from state import MyState

@pytest.fixture
def ctx():
return Context(MyCharm, meta={"name": "foo"})


@pytest.mark.parametrize("temp", (1.1, 10.2, 20.3))
def test_get(ctx, temp):
state = State(charm_state=MyState("state", temperature=temp))

# the charm code will get the value from State.charm_state.temperature instead of making http calls at test-time.
def post_event(charm: MyCharm):
assert charm.temperature == temp

ctx.run("start", state=state, post_event=post_event)
```


# Snapshot

Scenario comes with a cli tool called `snapshot`. Assuming you've pip-installed `ops-scenario`, you should be able to
Expand All @@ -806,4 +870,5 @@ all you need from `scenario`, and you have a working `State` that you can `Conte
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.
that you wish to trigger.

27 changes: 27 additions & 0 deletions scenario/charm_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.
import inspect
from typing import Generic, TypeVar

_M = TypeVar("_M")


class CharmStateBackend(Generic[_M]):
# todo consider alternative names:
# - interface?
# - facade?

def _generate_model(self):
"""use inspect to find all props and generate a model data structure."""
model = {}
prop: property
# todo exclude members from base class
for name, prop in inspect.getmembers(
type(self),
predicate=lambda o: isinstance(o, property),
):
settable = bool(getattr(prop, "fset", False))
model[name] = settable

return model
63 changes: 41 additions & 22 deletions scenario/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,10 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):

return env

@staticmethod
def _wrap(charm_type: "_CT") -> "_CT":
@contextmanager
def _wrap_charm(self, charm_type: "_CT", state: "State") -> "_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.
# todo this should only be needed if we call run multiple times on the same runtime.
# can we avoid it?
class WrappedEvents(charm_type.on.__class__):
pass
Expand All @@ -249,7 +249,24 @@ class WrappedCharm(charm_type): # type: ignore
on = WrappedEvents()

WrappedCharm.__name__ = charm_type.__name__
return WrappedCharm

# charm state patchery
state_model = state.charm_state
if not state_model:
# this charm has no declared state; leave it.
yield WrappedCharm
return

old_model = getattr(charm_type, state_model.name, None)
if not old_model:
raise RuntimeError(
f"charm state model name {state_model.name!r} not "
f"found on {charm_type.__name__}",
)

setattr(charm_type, state_model.name, state_model)
yield WrappedCharm
setattr(charm_type, state_model.name, old_model)

@contextmanager
def virtual_charm_root(self):
Expand Down Expand Up @@ -369,24 +386,26 @@ def exec(
# 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.replace(
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}",
) from e
finally:
logger.info(" - Exited ops.main.")
# patch charm.state as well as other hackery needed for working around ops design
with self._wrap_charm(charm_type, state) as wrapped_charm:
try:
mocked_main(
pre_event=pre_event,
post_event=post_event,
state=output_state,
event=event,
charm_spec=self._charm_spec.replace(
charm_type=wrapped_charm,
),
)
except NoObserverError:
raise # propagate along
except Exception as e:
raise UncaughtCharmError(
f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}",
) from e
finally:
logger.info(" - Exited ops.main.")

logger.info(" - Clearing env")
self._cleanup_env(env)
Expand Down
7 changes: 7 additions & 0 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,11 @@ def handle_path(self):
return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]"


@dataclasses.dataclass(frozen=True)
class CharmState(_DCBase):
name: str = "state"


@dataclasses.dataclass(frozen=True)
class State(_DCBase):
"""Represents the juju-owned portion of a unit's state.
Expand Down Expand Up @@ -852,6 +857,8 @@ class State(_DCBase):
deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list)
stored_state: List["StoredState"] = dataclasses.field(default_factory=dict)

charm_state: Optional[CharmState] = None

# todo:
# actions?

Expand Down
54 changes: 54 additions & 0 deletions tests/test_charm_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from dataclasses import dataclass

import pytest
from ops import CharmBase, Framework

from scenario import Context
from scenario.charm_state import CharmStateBackend
from scenario.state import CharmState, State


@dataclass(frozen=True)
class MyState(CharmState):
foo: int = 10
bar: str = "10"


class MyCharmStateBackend(CharmStateBackend):
@property
def foo(self) -> int:
return 10

@foo.setter
def foo(self, val: int):
pass

@property
def bar(self) -> str:
return "10"


class MyCharm(CharmBase):
state = MyCharmStateBackend()

def __init__(self, framework: Framework):
super().__init__(framework)
self.foo = self.state.foo
self.bar = self.state.bar


@pytest.fixture
def ctx():
return Context(MyCharm, meta={"name": "foo"})


@pytest.mark.parametrize("attr", ("foo", "bar"))
@pytest.mark.parametrize("val", (1, 10, 20))
def test_get(ctx, attr, val):
state = State(charm_state=MyState("state", val, str(val)))

def post_event(charm: MyCharm):
assert charm.foo == val
assert charm.bar == str(val)

ctx.run("start", state=state, post_event=post_event)