Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f54de9e
ci: switch to file-by-file ignores for type checking testing/tests
tonyandrewmeyer Dec 11, 2025
13c4c5e
test: add type annotations to test_charm_spec_autoload.
tonyandrewmeyer Dec 11, 2025
e26c0a0
test: add type annotations to test_consistency_checker.
tonyandrewmeyer Dec 11, 2025
8f8ae8c
test: add type annotations for test_context_on.
tonyandrewmeyer Dec 12, 2025
43b0a85
test: add type annotations to test_context.
tonyandrewmeyer Dec 12, 2025
34e9703
test: add type annotations to test_emitted_events_util
tonyandrewmeyer Dec 12, 2025
2bf445b
test: add type annotations to test_plugin
tonyandrewmeyer Dec 12, 2025
5a80ce4
test: add type annotations to test_runtime
tonyandrewmeyer Dec 12, 2025
c157aea
fix: add type annotation for Runtime.exec
tonyandrewmeyer Dec 12, 2025
086b3f7
chore: there's nothing in __init__ to type check
tonyandrewmeyer Dec 12, 2025
724e079
refactor: don't use sort_patch, just sort things inline.
tonyandrewmeyer Dec 12, 2025
0a38fe9
test: add type annotations to tests/helpers.
tonyandrewmeyer Dec 12, 2025
a4cf697
chore: remove -> None from __init__.
tonyandrewmeyer Dec 14, 2025
0ba66d4
refactor: Use the typical pattern for charm init, as suggested in rev…
tonyandrewmeyer Dec 14, 2025
9074221
refactor: avoid cast, per review suggestion.
tonyandrewmeyer Dec 15, 2025
cd14ec0
chore: remove scopes from type: ignore, used by mypy but not pyright.
tonyandrewmeyer Dec 15, 2025
59cc28d
chore: remove -> None
tonyandrewmeyer Dec 15, 2025
901e188
chore: remove unnecessary type annotation, per review.
tonyandrewmeyer Dec 15, 2025
33c60eb
chore: remove unnecessary quotes.
tonyandrewmeyer Dec 15, 2025
8c1e771
chore: remove unnecessary sorted().
tonyandrewmeyer Dec 15, 2025
61b9db6
refactor: avoid casts by using a tighter type.
tonyandrewmeyer Dec 15, 2025
6c075f0
chore: remove unnecessary cast and annotations, based on review sugge…
tonyandrewmeyer Dec 15, 2025
7127074
Merge remote-tracking branch 'origin/main' into type-check-scenario-t…
tonyandrewmeyer Dec 16, 2025
6aa58fc
Post merge fixes.
tonyandrewmeyer Dec 16, 2025
9416ef0
Use Iterator[] instead of Generator[] for simpler compatibility with …
tonyandrewmeyer Dec 18, 2025
d049dff
Use Iterator[] instead of Generator[] for simpler compatibility with …
tonyandrewmeyer Dec 18, 2025
4465156
Update testing/tests/helpers.py
tonyandrewmeyer Dec 18, 2025
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
28 changes: 26 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,32 @@ convention = "google"
builtins-ignorelist = ["id", "min", "map", "range", "type", "TimeoutError", "ConnectionError", "Warning", "input", "format"]

[tool.pyright]
include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py", "testing/src/*.py"]
exclude = ["tracing/*"]
include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py", "testing/src/*.py", "testing/tests/*.py", "testing/tests/test_e2e/*.py"]
exclude = [
"tracing/*",
"testing/tests/test_e2e/test_network.py",
"testing/tests/test_e2e/test_cloud_spec.py",
"testing/tests/test_e2e/test_play_assertions.py",
"testing/tests/test_e2e/test_vroot.py",
"testing/tests/test_e2e/test_resource.py",
"testing/tests/test_e2e/test_state.py",
"testing/tests/test_e2e/test_actions.py",
"testing/tests/test_e2e/test_config.py",
"testing/tests/test_e2e/test_event.py",
"testing/tests/test_e2e/test_deferred.py",
"testing/tests/test_e2e/test_stored_state.py",
"testing/tests/test_e2e/conftest.py",
"testing/tests/test_e2e/test_secrets.py",
"testing/tests/test_e2e/test_relations.py",
"testing/tests/test_e2e/test_trace_data.py",
"testing/tests/test_e2e/test_juju_log.py",
"testing/tests/test_e2e/test_status.py",
"testing/tests/test_e2e/test_storage.py",
"testing/tests/test_e2e/test_manager.py",
"testing/tests/test_e2e/test_ports.py",
"testing/tests/test_e2e/test_rubbish_events.py",
"testing/tests/test_e2e/test_pebble.py",
]
extraPaths = ["testing", "tracing"]
pythonVersion = "3.10" # check no python > 3.10 features are used
pythonPlatform = "All"
Expand Down
13 changes: 8 additions & 5 deletions testing/src/scenario/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
import os
import tempfile
import typing
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Generic

import yaml

Expand All @@ -23,21 +24,23 @@
from .errors import NoObserverError, UncaughtCharmError
from .logger import logger as scenario_logger
from .state import (
CharmType,
PeerRelation,
Relation,
SubordinateRelation,
)

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

logger = scenario_logger.getChild('runtime')

RUNTIME_MODULE = Path(__file__).parent


class Runtime:
class Runtime(Generic[CharmType]):
"""Charm runtime wrapper.

This object bridges a local environment and a charm artifact.
Expand Down Expand Up @@ -278,8 +281,8 @@ def exec(
self,
state: State,
event: _Event,
context: Context,
):
context: Context[CharmType],
) -> Iterator[Ops[CharmType]]:
"""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
Expand Down
18 changes: 7 additions & 11 deletions testing/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,15 @@
import logging
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar
from typing import TYPE_CHECKING, Any

import jsonpatch
import jsonpatch # type: ignore
from scenario.context import _DEFAULT_JUJU_VERSION, Context
from scenario.state import _Event

if TYPE_CHECKING: # pragma: no cover
from scenario.state import CharmType, State

_CT = TypeVar('_CT', bound=type[CharmType])

logger = logging.getLogger()


Expand Down Expand Up @@ -60,7 +58,7 @@ def trigger(
return state_out


def jsonpatch_delta(self, other: State):
def jsonpatch_delta(self: State, other: State) -> list[dict[str, Any]]:
dict_other = dataclasses.asdict(other)
dict_self = dataclasses.asdict(self)
for attr in (
Expand All @@ -75,9 +73,7 @@ def jsonpatch_delta(self, other: State):
):
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)


def sort_patch(patch: list[dict], key=lambda obj: obj['path'] + obj['op']):
return sorted(patch, key=key)
# The jsonpatch library is untyped.
# See: https://github.com/stefankoegl/python-json-patch/issues/158
patch = jsonpatch.make_patch(dict_other, dict_self).patch # type: ignore
return sorted(patch, key=lambda obj: obj['path'] + obj['op']) # type: ignore
40 changes: 22 additions & 18 deletions testing/tests/test_charm_spec_autoload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@

import importlib
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

import pytest
import yaml
from scenario import Context, Relation, State
from scenario.context import ContextSetupError
from scenario.state import CharmType, MetadataNotFoundError, _CharmSpec
from scenario.errors import ContextSetupError, MetadataNotFoundError
from scenario.state import _CharmSpec

from ops import CharmBase

CHARM = """
from ops import CharmBase
Expand All @@ -22,7 +26,7 @@ class MyCharm(CharmBase): pass


@contextmanager
def import_name(name: str, source: Path) -> type[CharmType]:
def import_name(name: str, source: Path) -> Iterator[type[CharmBase]]:
pkg_path = str(source.parent)
sys.path.append(pkg_path)
charm = importlib.import_module('mycharm')
Expand All @@ -36,12 +40,12 @@ def import_name(name: str, source: Path) -> type[CharmType]:
def create_tempcharm(
root: Path,
charm: str = CHARM,
meta=None,
actions=None,
config=None,
meta: dict[str, Any] | None = None,
actions: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
name: str = 'MyCharm',
legacy: bool = False,
):
) -> Iterator[type[CharmBase]]:
src = root / 'src'
src.mkdir(parents=True)
charmpy = src / 'mycharm.py'
Expand Down Expand Up @@ -72,43 +76,43 @@ def create_tempcharm(
if unified_meta:
(root / 'charmcraft.yaml').write_text(yaml.safe_dump(unified_meta))

with import_name(name, charmpy) as charm:
yield charm
with import_name(name, charmpy) as charm_class:
yield charm_class


def test_autoload_no_meta_fails(tmp_path):
def test_autoload_no_meta_fails(tmp_path: Path):
with create_tempcharm(tmp_path) as charm:
with pytest.raises(MetadataNotFoundError):
_CharmSpec.autoload(charm)


def test_autoload_no_type_fails(tmp_path):
def test_autoload_no_type_fails(tmp_path: 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):
def test_autoload_legacy_no_meta_fails(tmp_path: 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):
def test_autoload_legacy_no_type_passes(tmp_path: 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):
def test_autoload_legacy_type_passes(tmp_path: Path, config_type: str):
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):
def test_meta_autoload(tmp_path: Path, legacy: bool):
with create_tempcharm(
tmp_path,
legacy=legacy,
Expand All @@ -119,7 +123,7 @@ def test_meta_autoload(tmp_path, legacy):


@pytest.mark.parametrize('legacy', (True, False))
def test_no_meta_raises(tmp_path, legacy):
def test_no_meta_raises(tmp_path: Path, legacy: bool):
with create_tempcharm(
tmp_path,
legacy=legacy,
Expand All @@ -130,7 +134,7 @@ def test_no_meta_raises(tmp_path, legacy):


@pytest.mark.parametrize('legacy', (True, False))
def test_relations_ok(tmp_path, legacy):
def test_relations_ok(tmp_path: Path, legacy: bool):
with create_tempcharm(
tmp_path,
legacy=legacy,
Expand All @@ -148,7 +152,7 @@ def test_relations_ok(tmp_path, legacy):


@pytest.mark.parametrize('legacy', (True, False))
def test_config_defaults(tmp_path, legacy):
def test_config_defaults(tmp_path: Path, legacy: bool):
with create_tempcharm(
tmp_path,
legacy=legacy,
Expand Down
27 changes: 14 additions & 13 deletions testing/tests/test_consistency_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import dataclasses
from typing import Any

import pytest
from scenario._consistency_checker import check_consistency
Expand Down Expand Up @@ -40,9 +41,9 @@ class MyCharm(ops.CharmBase):
def assert_inconsistent(
state: State,
event: _Event,
charm_spec: _CharmSpec,
juju_version='3.0',
unit_id=0,
charm_spec: _CharmSpec[ops.CharmBase],
juju_version: str = '3.0',
unit_id: int = 0,
):
with pytest.raises(InconsistentScenarioError):
check_consistency(state, event, charm_spec, juju_version, unit_id)
Expand All @@ -51,17 +52,17 @@ def assert_inconsistent(
def assert_consistent(
state: State,
event: _Event,
charm_spec: _CharmSpec,
juju_version='3.0',
unit_id=0,
charm_spec: _CharmSpec[ops.CharmBase],
juju_version: str = '3.0',
unit_id: int = 0,
):
check_consistency(state, event, charm_spec, juju_version, unit_id)


def test_base():
state = State()
event = _Event('update_status')
spec = _CharmSpec(MyCharm, {})
spec: _CharmSpec[ops.CharmBase] = _CharmSpec(MyCharm, {})
assert_consistent(state, event, spec)


Expand Down Expand Up @@ -304,7 +305,7 @@ def test_bad_config_option_type():
('boolean', False, 'foo'),
),
)
def test_config_types(config_type):
def test_config_types(config_type: tuple[str, Any, Any]):
type_name, valid_value, invalid_value = config_type
assert_consistent(
State(config={'foo': valid_value}),
Expand All @@ -319,7 +320,7 @@ def test_config_types(config_type):


@pytest.mark.parametrize('juju_version', ('3.4', '3.5', '4.0'))
def test_config_secret(juju_version):
def test_config_secret(juju_version: str):
assert_consistent(
State(config={'foo': 'secret:co28kefmp25c77utl3n0'}),
_Event('bar'),
Expand Down Expand Up @@ -349,7 +350,7 @@ def test_config_secret(juju_version):


@pytest.mark.parametrize('juju_version', ('2.9', '3.3'))
def test_config_secret_old_juju(juju_version):
def test_config_secret_old_juju(juju_version: str):
assert_inconsistent(
State(config={'foo': 'secret:co28kefmp25c77utl3n0'}),
_Event('bar'),
Expand All @@ -362,7 +363,7 @@ def test_config_secret_old_juju(juju_version):
"The right exception is raised but pytest.raises doesn't catch it - figure this out!"
)
@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):
def test_secrets_jujuv_bad(bad_v: str):
secret = Secret({'a': 'b'})
assert_inconsistent(
State(secrets={secret}),
Expand All @@ -386,7 +387,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_good(good_v):
def test_secrets_jujuv_good(good_v: str):
assert_consistent(
State(secrets={Secret({'a': 'b'})}),
_Event('bar'),
Expand Down Expand Up @@ -541,7 +542,7 @@ def test_action_name():


@pytest.mark.parametrize('ptype,good,bad', _ACTION_TYPE_CHECKS)
def test_action_params_type(ptype, good, bad):
def test_action_params_type(ptype: str, good: Any, bad: Any):
ctx = Context(MyCharm, meta={'name': 'foo'}, actions={'foo': {}})
assert_consistent(
State(),
Expand Down
Loading