diff --git a/pyproject.toml b/pyproject.toml index 131ad6492..94d717a74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index fad5ffe90..07de01ec9 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -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 @@ -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. @@ -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 diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index ed285ccf3..20a704b20 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -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() @@ -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 ( @@ -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 diff --git a/testing/tests/test_charm_spec_autoload.py b/testing/tests/test_charm_spec_autoload.py index 1d77caf6e..01b7df1fc 100644 --- a/testing/tests/test_charm_spec_autoload.py +++ b/testing/tests/test_charm_spec_autoload.py @@ -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 @@ -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') @@ -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' @@ -72,35 +76,35 @@ 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: @@ -108,7 +112,7 @@ def test_autoload_legacy_type_passes(tmp_path, config_type): @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, @@ -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, @@ -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, @@ -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, diff --git a/testing/tests/test_consistency_checker.py b/testing/tests/test_consistency_checker.py index 81ce7eb4e..7c8802d31 100644 --- a/testing/tests/test_consistency_checker.py +++ b/testing/tests/test_consistency_checker.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses +from typing import Any import pytest from scenario._consistency_checker import check_consistency @@ -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) @@ -51,9 +52,9 @@ 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) @@ -61,7 +62,7 @@ def assert_consistent( def test_base(): state = State() event = _Event('update_status') - spec = _CharmSpec(MyCharm, {}) + spec: _CharmSpec[ops.CharmBase] = _CharmSpec(MyCharm, {}) assert_consistent(state, event, spec) @@ -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}), @@ -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'), @@ -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'), @@ -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}), @@ -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'), @@ -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(), diff --git a/testing/tests/test_context.py b/testing/tests/test_context.py index 054698193..c033b544c 100644 --- a/testing/tests/test_context.py +++ b/testing/tests/test_context.py @@ -11,10 +11,10 @@ from scenario.errors import UncaughtCharmError from scenario.state import _Event, _next_action_id -from ops import CharmBase +import ops -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): pass @@ -55,12 +55,13 @@ def test_run_action(): assert isinstance(e, _Event) assert e.name == 'do_foo_action' assert s is state + assert e.action is not None assert e.action.id == expected_id @pytest.mark.parametrize('app_name', ('foo', 'bar', 'george')) @pytest.mark.parametrize('unit_id', (1, 2, 42)) -def test_app_name(app_name, unit_id): +def test_app_name(app_name: str, unit_id: int): ctx = Context(MyCharm, meta={'name': 'foo'}, app_name=app_name, unit_id=unit_id) with ctx(ctx.on.start(), State()) as mgr: assert mgr.charm.app.name == app_name @@ -68,7 +69,7 @@ def test_app_name(app_name, unit_id): @pytest.mark.parametrize('machine_id', ('0', None, '42', '0/lxd/4')) -def test_machine_id_envvar(machine_id): +def test_machine_id_envvar(machine_id: str | None): ctx = Context(MyCharm, meta={'name': 'foo'}, machine_id=machine_id) os.environ.pop('JUJU_MACHINE_ID', None) # cleanup env to be sure with ctx(ctx.on.start(), State()): @@ -76,7 +77,7 @@ def test_machine_id_envvar(machine_id): @pytest.mark.parametrize('availability_zone', ('zone1', None, 'us-east-1a')) -def test_availability_zone_envvar(availability_zone): +def test_availability_zone_envvar(availability_zone: str | None): ctx = Context(MyCharm, meta={'name': 'foo'}, availability_zone=availability_zone) os.environ.pop('JUJU_AVAILABILITY_ZONE', None) # cleanup env to be sure with ctx(ctx.on.start(), State()): @@ -84,7 +85,7 @@ def test_availability_zone_envvar(availability_zone): @pytest.mark.parametrize('principal_unit', ('main/0', None, 'app/42')) -def test_principal_unit_envvar(principal_unit): +def test_principal_unit_envvar(principal_unit: str | None): ctx = Context(MyCharm, meta={'name': 'foo'}, principal_unit=principal_unit) os.environ.pop('JUJU_PRINCIPAL_UNIT', None) # cleanup env to be sure with ctx(ctx.on.start(), State()): @@ -116,14 +117,14 @@ def test_app_name_and_unit_id(): @pytest.mark.parametrize('bare_charm_errors', ('1', '0')) -def test_context_manager_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.Monkeypatch): - class CrashyCharm(CharmBase): - def __init__(self, framework): +def test_context_manager_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch): + class CrashyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' - def _on_start(self, event): + def _on_start(self, event: ops.EventBase): raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -136,14 +137,14 @@ def _on_start(self, event): @pytest.mark.parametrize('bare_charm_errors', ('1', '0')) -def test_run_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.Monkeypatch): - class CrashyCharm(CharmBase): - def __init__(self, framework): +def test_run_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch): + class CrashyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' - def _on_start(self, event): + def _on_start(self, event: ops.EventBase): raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -154,13 +155,13 @@ def _on_start(self, event): def test_context_manager_env_cleared(): - class GoodCharm(CharmBase): - def __init__(self, framework): + class GoodCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' - def _on_start(self, event): + def _on_start(self, event: ops.EventBase): os.environ['TEST_ENV_VAR'] = '2' ctx = Context(GoodCharm, meta={'name': 'crashy'}) @@ -171,12 +172,12 @@ def _on_start(self, event): def test_run_env_cleared(): - class GoodCharm(CharmBase): - def __init__(self, framework): + class GoodCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) - def _on_start(self, event): + def _on_start(self, event: ops.EventBase): os.environ['TEST_ENV_VAR'] = '1' ctx = Context(GoodCharm, meta={'name': 'crashy'}) diff --git a/testing/tests/test_context_on.py b/testing/tests/test_context_on.py index 88d11f358..0c1f6edf2 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -6,13 +6,14 @@ import copy import datetime import typing +from typing import Any, Literal import pytest import scenario import ops -META = { +META: dict[str, Any] = { 'name': 'context-charm', 'containers': { 'bar': {}, @@ -83,7 +84,7 @@ def test_simple_events(event_name: str, event_kind: type[ops.EventBase]): ('post_series_upgrade', ops.PostSeriesUpgradeEvent), ], ) -def test_simple_deprecated_events(event_name, event_kind): +def test_simple_deprecated_events(event_name: str, event_kind: type[ops.EventBase]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.pre_series_upgrade(), state) @@ -103,7 +104,12 @@ def test_simple_deprecated_events(event_name, event_kind): ('secret_rotate', ops.SecretRotateEvent, 'app'), ], ) -def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): +def test_simple_secret_events( + as_kwarg: bool, + event_name: str, + event_kind: type[ops.SecretEvent], + owner: Literal['unit', 'app'] | None, +): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret({'password': 'xxxx'}, owner=owner) state_in = scenario.State(secrets={secret}) @@ -132,7 +138,9 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): ('secret_remove', ops.SecretRemoveEvent), ], ) -def test_revision_secret_events(event_name, event_kind): +def test_revision_secret_events( + event_name: str, event_kind: type[ops.SecretExpiredEvent | ops.SecretRemoveEvent] +): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( tracked_content={'password': 'yyyy'}, @@ -154,7 +162,7 @@ def test_revision_secret_events(event_name, event_kind): @pytest.mark.parametrize('event_name', ['secret_expired', 'secret_remove']) -def test_revision_secret_events_as_positional_arg(event_name): +def test_revision_secret_events_as_positional_arg(event_name: str): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( tracked_content={'password': 'yyyy'}, @@ -173,7 +181,7 @@ def test_revision_secret_events_as_positional_arg(event_name): ('storage_detaching', ops.StorageDetachingEvent), ], ) -def test_storage_events(event_name, event_kind): +def test_storage_events(event_name: str, event_kind: type[ops.StorageEvent]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) storage = scenario.Storage('foo') state_in = scenario.State(storages=[storage]) @@ -236,7 +244,7 @@ def test_pebble_ready_event(): ('relation_broken', ops.RelationBrokenEvent), ], ) -def test_relation_app_events(as_kwarg, event_name, event_kind): +def test_relation_app_events(as_kwarg: bool, event_name: str, event_kind: type[ops.RelationEvent]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -275,7 +283,7 @@ def test_relation_complex_name(): @pytest.mark.parametrize('event_name', ['relation_created', 'relation_broken']) -def test_relation_events_as_positional_arg(event_name): +def test_relation_events_as_positional_arg(event_name: str): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -290,7 +298,7 @@ def test_relation_events_as_positional_arg(event_name): ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events_default_unit(event_name, event_kind): +def test_relation_unit_events_default_unit(event_name: str, event_kind: type[ops.RelationEvent]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz', remote_units_data={1: {'x': 'y'}}) state_in = scenario.State(relations=[relation]) @@ -303,6 +311,7 @@ def test_relation_unit_events_default_unit(event_name, event_kind): assert isinstance(relation_event, event_kind) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name + assert relation_event.unit is not None assert relation_event.unit.name == 'remote/1' assert isinstance(collect_status, ops.CollectStatusEvent) @@ -314,7 +323,7 @@ def test_relation_unit_events_default_unit(event_name, event_kind): ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events(event_name, event_kind): +def test_relation_unit_events(event_name: str, event_kind: type[ops.RelationEvent]): 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]) @@ -326,6 +335,7 @@ def test_relation_unit_events(event_name, event_kind): assert isinstance(relation_event, event_kind) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name + assert relation_event.unit is not None assert relation_event.unit.name == 'remote/2' assert isinstance(collect_status, ops.CollectStatusEvent) @@ -343,6 +353,7 @@ def test_relation_departed_event(): assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name assert relation_event.unit.name == 'remote/2' + assert relation_event.departing_unit is not None assert relation_event.departing_unit.name == 'remote/1' assert isinstance(collect_status, ops.CollectStatusEvent) @@ -502,7 +513,7 @@ def __init__(self, framework: ops.Framework): def test_custom_event_no_args(): ctx = scenario.Context(CustomCharm, meta=META, actions=ACTIONS) - with ctx(ctx.on.custom(MyConsumer.on.foo_started), scenario.State()) as mgr: + with ctx(ctx.on.custom(MyConsumer.on.foo_started), scenario.State()) as mgr: # type: ignore mgr.run() custom_event, collect_status = mgr.charm.observed assert isinstance(collect_status, ops.CollectStatusEvent) @@ -512,7 +523,7 @@ def test_custom_event_no_args(): def test_custom_event_with_args(): ctx = scenario.Context(CustomCharm, meta=META, actions=ACTIONS) with ctx( - ctx.on.custom(MyConsumer.on.foo_changed, 'foo', arg1=42), + ctx.on.custom(MyConsumer.on.foo_changed, 'foo', arg1=42), # type: ignore scenario.State(), ) as mgr: mgr.run() @@ -526,7 +537,7 @@ def test_custom_event_with_args(): def test_custom_event_is_hookevent(): ctx = scenario.Context(CustomCharm, meta=META, actions=ACTIONS) with pytest.raises(ValueError): - ctx.on.custom(MyConsumer.on.foo_relation_changed) + ctx.on.custom(MyConsumer.on.foo_relation_changed) # type: ignore def test_custom_event_with_scenario_args(): @@ -571,7 +582,7 @@ def test_custom_event_with_scenario_args(): with ctx( ctx.on.custom( - MyConsumer.on.state_event, + MyConsumer.on.state_event, # type: ignore cloudcredential=cloudcredential, cloudspec=cloudspec, secret=secret, @@ -664,13 +675,13 @@ def __init__(self, framework: ops.Framework): def test_custom_event_two_libraries(): ctx = scenario.Context(TwoLibraryCharm, meta=META, actions=ACTIONS) - with ctx(ctx.on.custom(MyConsumer.on.foo_changed), scenario.State()) as mgr: + with ctx(ctx.on.custom(MyConsumer.on.foo_changed), scenario.State()) as mgr: # type: ignore mgr.run() evt, cs = mgr.charm.observed assert isinstance(cs, ops.CollectStatusEvent) assert isinstance(evt, CustomEvent) - with ctx(ctx.on.custom(OtherConsumer.on.foo_changed), scenario.State()) as mgr: + with ctx(ctx.on.custom(OtherConsumer.on.foo_changed), scenario.State()) as mgr: # type: ignore mgr.run() evt, collect_status = mgr.charm.observed assert isinstance(collect_status, ops.CollectStatusEvent) diff --git a/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py index 500098d02..7979674e4 100644 --- a/testing/tests/test_e2e/test_state.py +++ b/testing/tests/test_e2e/test_state.py @@ -42,7 +42,7 @@ from ops.charm import CharmBase, CharmEvents, CollectStatusEvent from ops.framework import EventBase, Framework from ops.model import ActiveStatus, UnknownStatus, WaitingStatus -from tests.helpers import jsonpatch_delta, sort_patch, trigger +from tests.helpers import jsonpatch_delta, trigger CUSTOM_EVT_SUFFIXES = { 'relation_created', @@ -139,12 +139,14 @@ def call(charm: CharmBase, e): # ignore stored state in the delta out_purged = replace(out, stored_states=state.stored_states) - assert jsonpatch_delta(out_purged, state) == sort_patch([ + # This should be sorted by path then op. + expected = [ {'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'}, - ]) + ] + assert jsonpatch_delta(out_purged, state) == expected @pytest.mark.parametrize('connect', (True, False)) diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 86c2bfdbb..5812afb1d 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -10,7 +10,7 @@ from scenario.state import _Event from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent -from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent +from ops.framework import CommitEvent, EventBase, EventSource, Framework, PreCommitEvent class Foo(EventBase): @@ -23,22 +23,22 @@ class MyCharmEvents(CharmEvents): class MyCharm(CharmBase): META: Mapping[str, Any] = {'name': 'mycharm'} - on = MyCharmEvents() + on = MyCharmEvents() # type: ignore - 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 __init__(self, framework: Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.foo, self._on_foo) - def _on_start(self, e): + def _on_start(self, e: EventBase): self.on.foo.emit() - def _on_foo(self, e): + def _on_foo(self, e: EventBase): pass def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): - ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) + ctx = Context(MyCharm, meta=dict(MyCharm.META), capture_framework_events=True) ctx.run(ctx.on.start(), State()) emitted = ctx.emitted_events @@ -51,7 +51,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): def test_capture_juju_evt(): - ctx = Context(MyCharm, meta=MyCharm.META) + ctx = Context(MyCharm, meta=dict(MyCharm.META)) ctx.run(ctx.on.start(), State()) emitted = ctx.emitted_events @@ -61,7 +61,7 @@ def test_capture_juju_evt(): def test_capture_deferred_evt(): - ctx = Context(MyCharm, meta=MyCharm.META, capture_deferred_events=True) + ctx = Context(MyCharm, meta=dict(MyCharm.META), capture_deferred_events=True) deferred = [_Event('foo').deferred(handler=MyCharm._on_foo)] ctx.run(ctx.on.start(), State(deferred=deferred)) @@ -73,7 +73,7 @@ def test_capture_deferred_evt(): def test_capture_no_deferred_evt(): - ctx = Context(MyCharm, meta=MyCharm.META) + ctx = Context(MyCharm, meta=dict(MyCharm.META)) deferred = [_Event('foo').deferred(handler=MyCharm._on_foo)] ctx.run(ctx.on.start(), State(deferred=deferred)) diff --git a/testing/tests/test_plugin.py b/testing/tests/test_plugin.py index 3c7109d6d..7bcf3e4f7 100644 --- a/testing/tests/test_plugin.py +++ b/testing/tests/test_plugin.py @@ -5,13 +5,15 @@ import sys +import pytest + pytest_plugins = 'pytester' sys.path.append('.') -def test_plugin_ctx_run(pytester): +def test_plugin_ctx_run(pytester: pytest.Pytester): # create a temporary pytest test module - pytester.makepyfile( + pytester.makepyfile( # type: ignore """ import pytest from scenario import State diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py index 835f56405..20b9f5650 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -5,17 +5,19 @@ import os from tempfile import TemporaryDirectory +from typing import Any import pytest from scenario import ActiveStatus, Context -from scenario._runtime import Runtime, UncaughtCharmError +from scenario._runtime import Runtime +from scenario.errors import UncaughtCharmError from scenario.state import Relation, State, _CharmSpec, _Event import ops from ops._main import _Abort -def charm_type(): +def charm_type() -> type[ops.CharmBase]: class _CharmEvents(ops.CharmEvents): pass @@ -38,7 +40,7 @@ def _catchall(self, e: ops.EventBase): def test_event_emission(): with TemporaryDirectory(): - meta = { + meta: dict[str, Any] = { 'name': 'foo', 'requires': {'ingress-per-unit': {'interface': 'ingress_per_unit'}}, } @@ -48,74 +50,85 @@ def test_event_emission(): class MyEvt(ops.EventBase): pass - my_charm_type.on.define_event('bar', MyEvt) + my_charm_type.on.define_event( # type: ignore + 'bar', MyEvt + ) + charm_spec: _CharmSpec[ops.CharmBase] = _CharmSpec( + my_charm_type, + meta=meta, + ) runtime = Runtime( 'foo', - _CharmSpec( - my_charm_type, - meta=meta, - ), + charm_spec, ) + ctx = Context(my_charm_type, meta=meta) with runtime.exec( state=State(), event=_Event('bar'), - context=Context(my_charm_type, meta=meta), + context=ctx, ) as manager: manager.run() - assert my_charm_type._event - assert isinstance(my_charm_type._event, MyEvt) + assert my_charm_type._event # type: ignore + assert isinstance( + my_charm_type._event, # type: ignore + MyEvt, + ) @pytest.mark.parametrize('app_name', ('foo', 'bar-baz', 'QuX2')) @pytest.mark.parametrize('unit_id', (1, 2, 42)) -def test_unit_name(app_name, unit_id): - meta = { +def test_unit_name(app_name: str, unit_id: int): + meta: dict[str, Any] = { 'name': app_name, } my_charm_type = charm_type() + charm_spec: _CharmSpec[ops.CharmBase] = _CharmSpec( + my_charm_type, + meta=meta, + ) runtime = Runtime( app_name, - _CharmSpec( - my_charm_type, - meta=meta, - ), + charm_spec, unit_id=unit_id, ) + ctx: Context[ops.CharmBase] = Context(my_charm_type, meta=meta) with runtime.exec( state=State(), event=_Event('start'), - context=Context(my_charm_type, meta=meta), + context=ctx, ) as manager: assert manager.charm.unit.name == f'{app_name}/{unit_id}' def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', 'false') - meta = {'name': 'frank', 'requires': {'box': {'interface': 'triangle'}}} + meta: dict[str, Any] = {'name': 'frank', 'requires': {'box': {'interface': 'triangle'}}} my_charm_type = charm_type() + charm_spec: _CharmSpec[ops.CharmBase] = _CharmSpec( + my_charm_type, + meta=meta, + ) runtime = Runtime( 'frank', - _CharmSpec( - my_charm_type, - meta=meta, - ), + charm_spec, ) remote_name = 'ava' rel = Relation('box', remote_app_name=remote_name) + ctx: Context[ops.CharmBase] = Context(my_charm_type, meta=meta) with pytest.raises(UncaughtCharmError) as exc: with runtime.exec( state=State(relations={rel}), event=_Event('box_relation_changed', relation=rel), - context=Context(my_charm_type, meta=meta), + context=ctx, ) as manager: assert manager._juju_context.remote_app_name == remote_name assert 'JUJU_REMOTE_APP' in os.environ @@ -202,7 +215,7 @@ def _on_update_status(self, event: ops.EventBase): ), ) def test_bare_charm_errors_set( - monkeypatch: pytest.Monkeypatch, expected_error: type[Exception], bare_charm_errors: str | None + monkeypatch: pytest.MonkeyPatch, expected_error: type[Exception], bare_charm_errors: str ): monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) ctx = Context(ValueErrorCharm, meta={'name': 'value-error'}) @@ -210,7 +223,7 @@ def test_bare_charm_errors_set( ctx.run(ctx.on.update_status(), State()) -def test_bare_charm_errors_not_set(monkeypatch: pytest.Monkeypatch): +def test_bare_charm_errors_not_set(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv('SCENARIO_BARE_CHARM_ERRORS', raising=False) ctx = Context(ValueErrorCharm, meta={'name': 'value-error'}) with pytest.raises(UncaughtCharmError):