Skip to content

Commit

Permalink
feat: Make Context and Manager variadic types (#1445)
Browse files Browse the repository at this point in the history
Closes #1444.

This change enables passing the Charm type all the way from the Context
to the charm attribute in the manager for better autocompletion and type
checks.

You can give this a try with the following code snippet:

```python
from ops import CharmBase
from testing.src.scenario import Context, State


class MyCharm(CharmBase):
    some_attribute: str


def test_function():
    ctx = Context(MyCharm)
    state = State()

    with ctx(ctx.on.config_changed(), state) as manager:
        charm = manager.charm
        charm.some_attribute  # behold, autocompletion!
```

A word of caution, though, the testing module is not using the bundled
scenario code base. It is still using `ops_scenario`. For users to
benefit from this new feature, we would need to change the import
machinery.
  • Loading branch information
Batalex authored Nov 27, 2024
1 parent 7cc5ec9 commit 04edc17
Show file tree
Hide file tree
Showing 2 changed files with 17 additions and 16 deletions.
18 changes: 9 additions & 9 deletions testing/src/scenario/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
from contextlib import contextmanager
from pathlib import Path
from typing import (
Generic,
TYPE_CHECKING,
Any,
Callable,
Mapping,
cast,
)

import ops
Expand Down Expand Up @@ -60,7 +60,7 @@
_DEFAULT_JUJU_VERSION = "3.5"


class Manager:
class Manager(Generic[CharmType]):
"""Context manager to offer test code some runtime charm object introspection.
This class should not be instantiated directly: use a :class:`Context`
Expand All @@ -74,7 +74,7 @@ class Manager:

def __init__(
self,
ctx: Context,
ctx: Context[CharmType],
arg: _Event,
state_in: State,
):
Expand All @@ -85,19 +85,19 @@ def __init__(
self._emitted: bool = False
self._wrapped_ctx = None

self.ops: Ops | None = None
self.ops: Ops[CharmType] | None = None

@property
def charm(self) -> ops.CharmBase:
def charm(self) -> CharmType:
"""The charm object instantiated by ops to handle the event.
The charm is only available during the context manager scope.
"""
if not self.ops:
if self.ops is None or self.ops.charm is None:
raise RuntimeError(
"you should __enter__ this context manager before accessing this",
)
return cast(ops.CharmBase, self.ops.charm)
return self.ops.charm

@property
def _runner(self):
Expand Down Expand Up @@ -361,7 +361,7 @@ def action(
return _Event(f"{name}_action", action=_Action(name, **kwargs))


class Context:
class Context(Generic[CharmType]):
"""Represents a simulated charm's execution context.
The main entry point to running a test. It contains:
Expand Down Expand Up @@ -571,7 +571,7 @@ def _record_status(self, state: State, is_app: bool):
else:
self.unit_status_history.append(state.unit_status)

def __call__(self, event: _Event, state: State):
def __call__(self, event: _Event, state: State) -> Manager[CharmType]:
"""Context manager to introspect live charm object before and after the event is emitted.
Usage::
Expand Down
15 changes: 8 additions & 7 deletions testing/src/scenario/ops_main_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os
import pathlib
import sys
from typing import TYPE_CHECKING, Any, Optional, Sequence, Type, cast
from typing import TYPE_CHECKING, Any, Generic, Optional, Sequence, Type, cast

import ops.charm
import ops.framework
Expand All @@ -22,10 +22,11 @@
from ops.log import setup_root_logging

from .errors import BadOwnerPath, NoObserverError
from .state import CharmType

if TYPE_CHECKING: # pragma: no cover
from .context import Context
from .state import CharmType, State, _CharmSpec, _Event
from .state import State, _CharmSpec, _Event

# pyright: reportPrivateUsage=false

Expand Down Expand Up @@ -82,7 +83,7 @@ def setup_framework(
charm_dir: pathlib.Path,
state: "State",
event: "_Event",
context: "Context",
context: "Context[CharmType]",
charm_spec: "_CharmSpec[CharmType]",
juju_context: Optional[ops.jujucontext._JujuContext] = None,
):
Expand Down Expand Up @@ -160,7 +161,7 @@ def setup_charm(
def setup(
state: "State",
event: "_Event",
context: "Context",
context: "Context[CharmType]",
charm_spec: "_CharmSpec[CharmType]",
juju_context: Optional[ops.jujucontext._JujuContext] = None,
):
Expand All @@ -180,14 +181,14 @@ def setup(
return dispatcher, framework, charm


class Ops:
class Ops(Generic[CharmType]):
"""Class to manage stepping through ops setup, event emission and framework commit."""

def __init__(
self,
state: "State",
event: "_Event",
context: "Context",
context: "Context[CharmType]",
charm_spec: "_CharmSpec[CharmType]",
juju_context: Optional[ops.jujucontext._JujuContext] = None,
):
Expand All @@ -202,7 +203,7 @@ def __init__(
# set by setup()
self.dispatcher: Optional[_Dispatcher] = None
self.framework: Optional[ops.Framework] = None
self.charm: Optional[ops.CharmBase] = None
self.charm: Optional["CharmType"] = None

self._has_setup = False
self._has_emitted = False
Expand Down

0 comments on commit 04edc17

Please sign in to comment.