From f54de9e30f7273f34039c72669c7c40ffb73a3ee Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 10:00:34 +1300 Subject: [PATCH 01/26] ci: switch to file-by-file ignores for type checking testing/tests --- pyproject.toml | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ed1ee4ede..a25bd510e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -269,8 +269,41 @@ 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/helpers.py", + "testing/tests/test_charm_spec_autoload.py", + "testing/tests/test_consistency_checker.py", + "testing/tests/test_context_on.py", + "testing/tests/test_context.py", + "testing/tests/test_emitted_events_util.py", + "testing/tests/test_plugin.py", + "testing/tests/test_runtime.py", + "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/__init__.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" From 13c4c5e191604ea0debcacd1fc94dfcdbce880ad Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 11:38:38 +1300 Subject: [PATCH 02/26] test: add type annotations to test_charm_spec_autoload. --- pyproject.toml | 1 - testing/tests/test_charm_spec_autoload.py | 39 ++++++++++++----------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a25bd510e..c3a9c5d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_charm_spec_autoload.py", "testing/tests/test_consistency_checker.py", "testing/tests/test_context_on.py", "testing/tests/test_context.py", diff --git a/testing/tests/test_charm_spec_autoload.py b/testing/tests/test_charm_spec_autoload.py index d35f2e6e2..7aa4cde0d 100644 --- a/testing/tests/test_charm_spec_autoload.py +++ b/testing/tests/test_charm_spec_autoload.py @@ -2,15 +2,18 @@ import importlib import sys +from collections.abc import Generator from contextlib import contextmanager from pathlib import Path +from typing import Any import pytest import yaml +from ops import CharmBase 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 CHARM = """ from ops import CharmBase @@ -20,7 +23,7 @@ class MyCharm(CharmBase): pass @contextmanager -def import_name(name: str, source: Path) -> type[CharmType]: +def import_name(name: str, source: Path) -> Generator[type[CharmBase]]: pkg_path = str(source.parent) sys.path.append(pkg_path) charm = importlib.import_module('mycharm') @@ -34,12 +37,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, -): +) -> Generator[type[CharmBase]]: src = root / 'src' src.mkdir(parents=True) charmpy = src / 'mycharm.py' @@ -70,35 +73,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) -> None: 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) -> None: 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) -> None: 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) -> None: 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) -> None: with create_tempcharm( tmp_path, legacy=True, meta={'type': config_type, 'name': 'foo'} ) as charm: @@ -106,7 +109,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) -> None: with create_tempcharm( tmp_path, legacy=legacy, @@ -117,7 +120,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) -> None: with create_tempcharm( tmp_path, legacy=legacy, @@ -128,7 +131,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) -> None: with create_tempcharm( tmp_path, legacy=legacy, @@ -146,7 +149,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) -> None: with create_tempcharm( tmp_path, legacy=legacy, From e26c0a089685d7b021daa77af438ff2528272c60 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 11:46:51 +1300 Subject: [PATCH 03/26] test: add type annotations to test_consistency_checker. --- pyproject.toml | 1 - testing/tests/test_consistency_checker.py | 46 ++++++++++++----------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3a9c5d61..88ccc03f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_consistency_checker.py", "testing/tests/test_context_on.py", "testing/tests/test_context.py", "testing/tests/test_emitted_events_util.py", diff --git a/testing/tests/test_consistency_checker.py b/testing/tests/test_consistency_checker.py index 99ebd969b..9984c12d4 100644 --- a/testing/tests/test_consistency_checker.py +++ b/testing/tests/test_consistency_checker.py @@ -1,6 +1,8 @@ from __future__ import annotations import dataclasses +from collections.abc import Callable +from typing import Any import pytest import ops @@ -35,30 +37,30 @@ class MyCharm(ops.CharmBase): def assert_inconsistent( - state: 'State', - event: '_Event', - charm_spec: '_CharmSpec', - juju_version='3.0', - unit_id=0, -): + state: State, + event: _Event, + charm_spec: _CharmSpec[ops.CharmBase], + juju_version: str = '3.0', + unit_id: int = 0, +) -> None: with pytest.raises(InconsistentScenarioError): check_consistency(state, event, charm_spec, juju_version, unit_id) def assert_consistent( - state: 'State', - event: '_Event', - charm_spec: '_CharmSpec', - juju_version='3.0', - unit_id=0, -): + state: State, + event: _Event, + charm_spec: _CharmSpec[ops.CharmBase], + juju_version: str = '3.0', + unit_id: int = 0, +) -> None: check_consistency(state, event, charm_spec, juju_version, unit_id) -def test_base(): +def test_base() -> None: state = State() event = _Event('update_status') - spec = _CharmSpec(MyCharm, {}) + spec: _CharmSpec[ops.CharmBase] = _CharmSpec(MyCharm, {}) assert_consistent(state, event, spec) @@ -214,7 +216,7 @@ def test_evt_bad_container_name(): (CheckInfo('chk2'), False), ], ) -def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool): +def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool) -> None: layer = ops.pebble.Layer({ 'checks': { 'chk1': { @@ -227,7 +229,7 @@ def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool): } }) state = State(containers={Container('foo', check_infos={check}, layers={'base': layer})}) - asserter = assert_consistent if consistent else assert_inconsistent + asserter: Callable[..., None] = assert_consistent if consistent else assert_inconsistent asserter( state, _Event('foo-pebble-ready', container=Container('foo')), @@ -301,7 +303,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]) -> None: type_name, valid_value, invalid_value = config_type assert_consistent( State(config={'foo': valid_value}), @@ -316,7 +318,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) -> None: assert_consistent( State(config={'foo': 'secret:co28kefmp25c77utl3n0'}), _Event('bar'), @@ -346,7 +348,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) -> None: assert_inconsistent( State(config={'foo': 'secret:co28kefmp25c77utl3n0'}), _Event('bar'), @@ -359,7 +361,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) -> None: secret = Secret({'a': 'b'}) assert_inconsistent( State(secrets={secret}), @@ -383,7 +385,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) -> None: assert_consistent( State(secrets={Secret({'a': 'b'})}), _Event('bar'), @@ -538,7 +540,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) -> None: ctx = Context(MyCharm, meta={'name': 'foo'}, actions={'foo': {}}) assert_consistent( State(), From 8f8ae8c93ae3ce3d2c208f233ead731f5d3ce4b8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 15:26:13 +1300 Subject: [PATCH 04/26] test: add type annotations for test_context_on. --- pyproject.toml | 1 - testing/tests/test_context_on.py | 77 ++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 88ccc03f9..74cb5ce48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_context_on.py", "testing/tests/test_context.py", "testing/tests/test_emitted_events_util.py", "testing/tests/test_plugin.py", diff --git a/testing/tests/test_context_on.py b/testing/tests/test_context_on.py index 15687f10b..3a652ee89 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -3,6 +3,7 @@ import copy import datetime import typing +from typing import Any, Literal import ops import pytest @@ -10,7 +11,7 @@ import scenario -META = { +META: dict[str, Any] = { 'name': 'context-charm', 'containers': { 'bar': {}, @@ -63,7 +64,7 @@ def _on_event(self, event: ops.EventBase): ('leader_elected', ops.LeaderElectedEvent), ], ) -def test_simple_events(event_name: str, event_kind: type[ops.EventBase]): +def test_simple_events(event_name: str, event_kind: type[ops.EventBase]) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.install(), state) @@ -81,7 +82,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]) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.pre_series_upgrade(), state) @@ -101,7 +102,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.EventBase], + owner: Literal['unit', 'app'] | None, +) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret({'password': 'xxxx'}, owner=owner) state_in = scenario.State(secrets={secret}) @@ -119,6 +125,7 @@ def test_simple_secret_events(as_kwarg, event_name, event_kind, owner): mgr.run() secret_event, collect_status = mgr.charm.observed assert isinstance(secret_event, event_kind) + secret_event = typing.cast('ops.SecretEvent', secret_event) assert secret_event.secret.id == secret.id assert isinstance(collect_status, ops.CollectStatusEvent) @@ -130,7 +137,7 @@ 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.EventBase]) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( tracked_content={'password': 'yyyy'}, @@ -146,13 +153,14 @@ def test_revision_secret_events(event_name, event_kind): mgr.run() secret_event, collect_status = mgr.charm.observed assert isinstance(secret_event, event_kind) + secret_event = typing.cast('ops.SecretExpiredEvent | ops.SecretRemoveEvent', secret_event) assert secret_event.secret.id == secret.id assert secret_event.revision == 42 assert isinstance(collect_status, ops.CollectStatusEvent) @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) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( tracked_content={'password': 'yyyy'}, @@ -171,7 +179,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.EventBase]) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) storage = scenario.Storage('foo') state_in = scenario.State(storages=[storage]) @@ -181,12 +189,13 @@ def test_storage_events(event_name, event_kind): mgr.run() storage_event, collect_status = mgr.charm.observed assert isinstance(storage_event, event_kind) + storage_event = typing.cast('ops.StorageEvent', storage_event) assert storage_event.storage.name == storage.name assert storage_event.storage.index == storage.index assert isinstance(collect_status, ops.CollectStatusEvent) -def test_action_event_no_params(): +def test_action_event_no_params() -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.action(action_name), state) @@ -197,7 +206,7 @@ def test_action_event_no_params(): assert isinstance(collect_status, ops.CollectStatusEvent) -def test_action_event_with_params(): +def test_action_event_with_params() -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.action(action=action), state) @@ -212,7 +221,7 @@ def test_action_event_with_params(): assert isinstance(collect_status, ops.CollectStatusEvent) -def test_pebble_ready_event(): +def test_pebble_ready_event() -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) container = scenario.Container('bar', can_connect=True) state_in = scenario.State(containers=[container]) @@ -234,7 +243,9 @@ 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.EventBase] +) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -250,14 +261,15 @@ def test_relation_app_events(as_kwarg, event_name, event_kind): mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) + relation_event = typing.cast('ops.RelationEvent', relation_event) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name assert relation_event.unit is None assert isinstance(collect_status, ops.CollectStatusEvent) -def test_relation_complex_name(): - meta = copy.deepcopy(META) +def test_relation_complex_name() -> None: + meta: dict[str, Any] = 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') @@ -273,7 +285,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) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -288,7 +300,9 @@ 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.EventBase] +) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz', remote_units_data={1: {'x': 'y'}}) state_in = scenario.State(relations=[relation]) @@ -299,8 +313,10 @@ def test_relation_unit_events_default_unit(event_name, event_kind): mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) + relation_event = typing.cast('ops.RelationEvent', relation_event) 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) @@ -312,7 +328,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.EventBase]) -> None: 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]) @@ -322,13 +338,15 @@ def test_relation_unit_events(event_name, event_kind): mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) + relation_event = typing.cast('ops.RelationEvent', relation_event) 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) -def test_relation_departed_event(): +def test_relation_departed_event() -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -341,6 +359,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) @@ -498,19 +517,19 @@ def __init__(self, framework: ops.Framework): framework.observe(self.consumer.on.state_event, self._on_event) -def test_custom_event_no_args(): +def test_custom_event_no_args() -> None: 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) assert isinstance(custom_event, CustomEvent) -def test_custom_event_with_args(): +def test_custom_event_with_args() -> None: 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() @@ -521,14 +540,14 @@ def test_custom_event_with_args(): assert custom_event.arg1 == 42 -def test_custom_event_is_hookevent(): +def test_custom_event_is_hookevent() -> None: 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(): - meta = META.copy() +def test_custom_event_with_scenario_args() -> None: + meta: dict[str, Any] = META.copy() meta['requires']['endpoint'] = {'interface': 'int1'} meta['requires']['sub-endpoint'] = {'interface': 'int2', 'scope': 'container'} meta['peers'] = {'peer-endpoint': {'interface': 'int3'}} @@ -569,7 +588,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, @@ -659,16 +678,16 @@ def __init__(self, framework: ops.Framework): framework.observe(self.consumer2.on.foo_changed, self._on_event) -def test_custom_event_two_libraries(): +def test_custom_event_two_libraries() -> None: 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) From 43b0a8580c0e45725a5849c623bde4c983e365e1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 16:32:35 +1300 Subject: [PATCH 05/26] test: add type annotations to test_context. --- pyproject.toml | 1 - testing/tests/test_context.py | 57 ++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74cb5ce48..89188de7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_context.py", "testing/tests/test_emitted_events_util.py", "testing/tests/test_plugin.py", "testing/tests/test_runtime.py", diff --git a/testing/tests/test_context.py b/testing/tests/test_context.py index a871b8d52..075adb266 100644 --- a/testing/tests/test_context.py +++ b/testing/tests/test_context.py @@ -3,19 +3,19 @@ import os from unittest.mock import patch +import ops import pytest -from ops import CharmBase from scenario import Context, State from scenario.errors import UncaughtCharmError from scenario.state import _Event, _next_action_id -class MyCharm(CharmBase): +class MyCharm(ops.CharmBase): pass -def test_run(): +def test_run() -> None: ctx = Context(MyCharm, meta={'name': 'foo'}) state = State() @@ -34,7 +34,7 @@ def test_run(): assert s is state -def test_run_action(): +def test_run_action() -> None: ctx = Context(MyCharm, meta={'name': 'foo'}) state = State() expected_id = _next_action_id(update=False) @@ -52,12 +52,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) -> None: 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 @@ -65,7 +66,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) -> 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()): @@ -73,7 +74,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) -> 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()): @@ -81,14 +82,14 @@ 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) -> 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()): assert os.getenv('JUJU_PRINCIPAL_UNIT') == principal_unit -def test_context_manager(): +def test_context_manager() -> None: ctx = Context(MyCharm, meta={'name': 'foo'}, actions={'act': {}}) state = State() with ctx(ctx.on.start(), state) as mgr: @@ -100,27 +101,29 @@ def test_context_manager(): assert mgr.charm.meta.name == 'foo' -def test_app_name_and_unit_id_default(): +def test_app_name_and_unit_id_default() -> None: ctx = Context(MyCharm, meta={'name': 'foo'}) assert ctx.app_name == 'foo' assert ctx.unit_id == 0 -def test_app_name_and_unit_id(): +def test_app_name_and_unit_id() -> None: ctx = Context(MyCharm, meta={'name': 'foo'}, app_name='notfoo', unit_id=42) assert ctx.app_name == 'notfoo' assert ctx.unit_id == 42 @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 +) -> None: + class CrashyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework) -> None: 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) -> None: raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -133,14 +136,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) -> None: + class CrashyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework) -> None: 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) -> None: raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -150,14 +153,14 @@ def _on_start(self, event): assert 'TEST_ENV_VAR' not in os.environ -def test_context_manager_env_cleared(): - class GoodCharm(CharmBase): - def __init__(self, framework): +def test_context_manager_env_cleared() -> None: + class GoodCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework) -> None: 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) -> None: os.environ['TEST_ENV_VAR'] = '2' ctx = Context(GoodCharm, meta={'name': 'crashy'}) @@ -167,13 +170,13 @@ def _on_start(self, event): assert 'TEST_ENV_VAR' not in os.environ -def test_run_env_cleared(): - class GoodCharm(CharmBase): - def __init__(self, framework): +def test_run_env_cleared() -> None: + class GoodCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) self.framework.observe(self.on.start, self._on_start) - def _on_start(self, event): + def _on_start(self, event: ops.EventBase) -> None: os.environ['TEST_ENV_VAR'] = '1' ctx = Context(GoodCharm, meta={'name': 'crashy'}) From 34e9703c2202ae8637e830a001e34c6d70e8b6a2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 16:38:10 +1300 Subject: [PATCH 06/26] test: add type annotations to test_emitted_events_util --- pyproject.toml | 1 - testing/tests/test_emitted_events_util.py | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89188de7f..e70a1649c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_emitted_events_util.py", "testing/tests/test_plugin.py", "testing/tests/test_runtime.py", "testing/tests/test_e2e/test_network.py", diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 6792823e8..6bb3ba692 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent @@ -17,21 +19,21 @@ class MyCharmEvents(CharmEvents): class MyCharm(CharmBase): META = {'name': 'mycharm'} - on = MyCharmEvents() + on = MyCharmEvents() # type: ignore[assignment] - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: 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): + def _on_start(self, e: EventBase) -> None: self.on.foo.emit() - def _on_foo(self, e): + def _on_foo(self, e: EventBase) -> None: pass -def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): +def test_capture_custom_evt_nonspecific_capture_include_fw_evts() -> None: ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) ctx.run(ctx.on.start(), State()) @@ -44,7 +46,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): assert isinstance(emitted[4], CommitEvent) -def test_capture_juju_evt(): +def test_capture_juju_evt() -> None: ctx = Context(MyCharm, meta=MyCharm.META) ctx.run(ctx.on.start(), State()) @@ -54,7 +56,7 @@ def test_capture_juju_evt(): assert isinstance(emitted[1], Foo) -def test_capture_deferred_evt(): +def test_capture_deferred_evt() -> None: ctx = Context(MyCharm, meta=MyCharm.META, capture_deferred_events=True) deferred = [_Event('foo').deferred(handler=MyCharm._on_foo)] ctx.run(ctx.on.start(), State(deferred=deferred)) @@ -66,7 +68,7 @@ def test_capture_deferred_evt(): assert isinstance(emitted[2], Foo) -def test_capture_no_deferred_evt(): +def test_capture_no_deferred_evt() -> None: ctx = Context(MyCharm, meta=MyCharm.META) deferred = [_Event('foo').deferred(handler=MyCharm._on_foo)] ctx.run(ctx.on.start(), State(deferred=deferred)) From 2bf445b656ecb209aafca7e27575c74dd4af8f70 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 16:42:42 +1300 Subject: [PATCH 07/26] test: add type annotations to test_plugin --- pyproject.toml | 1 - testing/tests/test_plugin.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e70a1649c..3331f8b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_plugin.py", "testing/tests/test_runtime.py", "testing/tests/test_e2e/test_network.py", "testing/tests/test_e2e/test_cloud_spec.py", diff --git a/testing/tests/test_plugin.py b/testing/tests/test_plugin.py index 47d4419ea..29c33c621 100644 --- a/testing/tests/test_plugin.py +++ b/testing/tests/test_plugin.py @@ -2,13 +2,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) -> None: # create a temporary pytest test module - pytester.makepyfile( + pytester.makepyfile( # type: ignore[misc] """ import pytest from scenario import State From 5a80ce432dcb299e67043fa7358544c28bcb7b71 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 17:07:21 +1300 Subject: [PATCH 08/26] test: add type annotations to test_runtime --- pyproject.toml | 1 - testing/tests/test_runtime.py | 86 +++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3331f8b51..9ad310873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,6 @@ include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py exclude = [ "tracing/*", "testing/tests/helpers.py", - "testing/tests/test_runtime.py", "testing/tests/test_e2e/test_network.py", "testing/tests/test_e2e/test_cloud_spec.py", "testing/tests/test_e2e/test_play_assertions.py", diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py index 3c90c5c12..c8e8f2ec5 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -2,6 +2,7 @@ import os from tempfile import TemporaryDirectory +from typing import Any import pytest @@ -10,10 +11,11 @@ from scenario import Context, ActiveStatus from scenario.state import Relation, State, _CharmSpec, _Event -from scenario._runtime import Runtime, UncaughtCharmError +from scenario._runtime import Runtime +from scenario.errors import UncaughtCharmError -def charm_type(): +def charm_type() -> type[ops.CharmBase]: class _CharmEvents(ops.CharmEvents): pass @@ -21,12 +23,12 @@ class MyCharm(ops.CharmBase): on = _CharmEvents() # type: ignore _event = None - def __init__(self, framework: ops.Framework): + def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._catchall) - def _catchall(self, e: ops.EventBase): + def _catchall(self, e: ops.EventBase) -> None: if self._event: return MyCharm._event = e @@ -34,9 +36,9 @@ def _catchall(self, e: ops.EventBase): return MyCharm -def test_event_emission(): +def test_event_emission() -> None: with TemporaryDirectory(): - meta = { + meta: dict[str, Any] = { 'name': 'foo', 'requires': {'ingress-per-unit': {'interface': 'ingress_per_unit'}}, } @@ -46,74 +48,80 @@ def test_event_emission(): class MyEvt(ops.EventBase): pass - my_charm_type.on.define_event('bar', MyEvt) + my_charm_type.on.define_event('bar', MyEvt) # type: ignore[attr-defined] + charm_spec: _CharmSpec[ops.CharmBase] = _CharmSpec( + my_charm_type, + meta=meta, + ) runtime = Runtime( 'foo', - _CharmSpec( - my_charm_type, - meta=meta, - ), + charm_spec, ) + ctx: Context[ops.CharmBase] = 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[attr-defined] + assert isinstance(my_charm_type._event, MyEvt) # type: ignore[attr-defined] @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) -> None: + 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): +def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch) -> None: 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 @@ -125,15 +133,15 @@ def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch): assert os.getenv('JUJU_REMOTE_APP', None) is None -def test_juju_version_is_set_in_environ(): +def test_juju_version_is_set_in_environ() -> None: version = '2.9' class MyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework): + def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) framework.observe(self.on.start, self._on_start) - def _on_start(self, _: ops.StartEvent): + def _on_start(self, _: ops.StartEvent) -> None: with pytest.warns(DeprecationWarning): assert ops.JujuVersion.from_environ() == version @@ -142,13 +150,13 @@ def _on_start(self, _: ops.StartEvent): @pytest.mark.parametrize('exit_code', (-1, 0, 1, 42)) -def test_ops_raises_abort(exit_code: int, monkeypatch: pytest.MonkeyPatch): +def test_ops_raises_abort(exit_code: int, monkeypatch: pytest.MonkeyPatch) -> None: class MyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework): + def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) framework.observe(self.on.start, self._on_start) - def _on_start(self, _: ops.StartEvent): + def _on_start(self, _: ops.StartEvent) -> None: self.unit.status = ops.ActiveStatus() # Charms can't actually do this (_Abort is private), but this is # simpler than causing the framework to raise it. @@ -168,11 +176,11 @@ def _on_start(self, _: ops.StartEvent): class ValueErrorCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework): + def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) framework.observe(self.on.update_status, self._on_update_status) - def _on_update_status(self, event: ops.EventBase): + def _on_update_status(self, event: ops.EventBase) -> None: raise ValueError() @@ -200,15 +208,15 @@ 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 +) -> None: monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) ctx = Context(ValueErrorCharm, meta={'name': 'value-error'}) with pytest.raises(expected_error): 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) -> None: monkeypatch.delenv('SCENARIO_BARE_CHARM_ERRORS', raising=False) ctx = Context(ValueErrorCharm, meta={'name': 'value-error'}) with pytest.raises(UncaughtCharmError): From c157aea66d096554bd63f570095a210b58e9033f Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 17:09:46 +1300 Subject: [PATCH 09/26] fix: add type annotation for Runtime.exec --- testing/src/scenario/_runtime.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index fad5ffe90..2b3c2e684 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 Generator 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], + ) -> Generator[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 From 086b3f7e2e0b8a748d74e15c94c6e555e413ee53 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 17:10:31 +1300 Subject: [PATCH 10/26] chore: there's nothing in __init__ to type check --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ad310873..a7c1052f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -277,7 +277,6 @@ exclude = [ "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/__init__.py", "testing/tests/test_e2e/test_resource.py", "testing/tests/test_e2e/test_state.py", "testing/tests/test_e2e/test_actions.py", From 724e0796a40977b74cc95976df574992b99c1954 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 17:30:20 +1300 Subject: [PATCH 11/26] refactor: don't use sort_patch, just sort things inline. --- testing/tests/test_e2e/test_state.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py index b8ffa1523..ec86a3c33 100644 --- a/testing/tests/test_e2e/test_state.py +++ b/testing/tests/test_e2e/test_state.py @@ -40,7 +40,7 @@ layer_from_rockcraft, ) from scenario.context import Context -from tests.helpers import jsonpatch_delta, sort_patch, trigger +from tests.helpers import jsonpatch_delta, trigger CUSTOM_EVT_SUFFIXES = { 'relation_created', @@ -137,12 +137,16 @@ 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([ - {'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'}, - ]) + expected = sorted( + [ + {'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'}, + ], + key=lambda obj: obj['path'] + obj['op'], + ) + assert jsonpatch_delta(out_purged, state) == expected @pytest.mark.parametrize('connect', (True, False)) From 0a38fe9ca5c98784151c7c63a03bf9e3f7959c45 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 12 Dec 2025 17:31:18 +1300 Subject: [PATCH 12/26] test: add type annotations to tests/helpers. --- pyproject.toml | 1 - testing/tests/helpers.py | 19 +++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7c1052f8..3b74597c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,7 +272,6 @@ builtins-ignorelist = ["id", "min", "map", "range", "type", "TimeoutError", "Con 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/helpers.py", "testing/tests/test_e2e/test_network.py", "testing/tests/test_e2e/test_cloud_spec.py", "testing/tests/test_e2e/test_play_assertions.py", diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index 913dcbf98..db3ad9782 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -3,10 +3,10 @@ import dataclasses import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, cast from collections.abc import Callable -import jsonpatch +import jsonpatch # type: ignore[import-untyped] from scenario.context import _DEFAULT_JUJU_VERSION, Context from scenario.state import _Event @@ -14,14 +14,12 @@ if TYPE_CHECKING: # pragma: no cover from scenario.state import CharmType, State - _CT = TypeVar('_CT', bound=type[CharmType]) - logger = logging.getLogger() def trigger( state: 'State', - event: str | '_Event', + event: 'str | _Event', charm_type: type['CharmType'], pre_event: Callable[['CharmType'], None] | None = None, post_event: Callable[['CharmType'], None] | None = None, @@ -58,7 +56,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 ( @@ -73,9 +71,6 @@ 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) + patch = jsonpatch.make_patch(dict_other, dict_self).patch # type: ignore + patch = cast('list[dict[str, Any]]', patch) + return sorted(patch, key=lambda obj: obj['path'] + obj['op']) From a4cf6973ec315c44f8792a6a0d48d7b73b8c2743 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 12:52:01 +1300 Subject: [PATCH 13/26] chore: remove -> None from __init__. --- testing/tests/test_context.py | 8 ++++---- testing/tests/test_emitted_events_util.py | 2 +- testing/tests/test_runtime.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/testing/tests/test_context.py b/testing/tests/test_context.py index 075adb266..2c1322b1c 100644 --- a/testing/tests/test_context.py +++ b/testing/tests/test_context.py @@ -118,7 +118,7 @@ def test_context_manager_uncaught_error( bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch ) -> None: class CrashyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' @@ -138,7 +138,7 @@ def _on_start(self, event: ops.EventBase) -> None: @pytest.mark.parametrize('bare_charm_errors', ('1', '0')) def test_run_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch) -> None: class CrashyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' @@ -155,7 +155,7 @@ def _on_start(self, event: ops.EventBase) -> None: def test_context_manager_env_cleared() -> None: class GoodCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) os.environ['TEST_ENV_VAR'] = '1' @@ -172,7 +172,7 @@ def _on_start(self, event: ops.EventBase) -> None: def test_run_env_cleared() -> None: class GoodCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) self.framework.observe(self.on.start, self._on_start) diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 6bb3ba692..053d5e5c0 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -21,7 +21,7 @@ class MyCharm(CharmBase): META = {'name': 'mycharm'} on = MyCharmEvents() # type: ignore[assignment] - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.framework.observe(self.on.start, self._on_start) self.framework.observe(self.on.foo, self._on_foo) diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py index c8e8f2ec5..770b82563 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -23,7 +23,7 @@ class MyCharm(ops.CharmBase): on = _CharmEvents() # type: ignore _event = None - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) for evt in self.on.events().values(): self.framework.observe(evt, self._catchall) @@ -137,7 +137,7 @@ def test_juju_version_is_set_in_environ() -> None: version = '2.9' class MyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.start, self._on_start) @@ -152,7 +152,7 @@ def _on_start(self, _: ops.StartEvent) -> None: @pytest.mark.parametrize('exit_code', (-1, 0, 1, 42)) def test_ops_raises_abort(exit_code: int, monkeypatch: pytest.MonkeyPatch) -> None: class MyCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.start, self._on_start) @@ -176,7 +176,7 @@ def _on_start(self, _: ops.StartEvent) -> None: class ValueErrorCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: + def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.update_status, self._on_update_status) From 0ba66d43f883d4045056ae5944291706eb7813e5 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 12:57:07 +1300 Subject: [PATCH 14/26] refactor: Use the typical pattern for charm init, as suggested in review. --- testing/tests/test_emitted_events_util.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 053d5e5c0..5d101abec 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -1,9 +1,7 @@ from __future__ import annotations -from typing import Any - 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 from scenario import Context, State from scenario.state import _Event @@ -21,10 +19,10 @@ class MyCharm(CharmBase): META = {'name': 'mycharm'} on = MyCharmEvents() # type: ignore[assignment] - def __init__(self, *args: Any, **kwargs: Any): - 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: EventBase) -> None: self.on.foo.emit() From 90742219aaa471771467eb3b136763dabb19090e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 13:02:51 +1300 Subject: [PATCH 15/26] refactor: avoid cast, per review suggestion. --- testing/tests/test_context_on.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/tests/test_context_on.py b/testing/tests/test_context_on.py index 3a652ee89..3bca3b8af 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -244,7 +244,7 @@ def test_pebble_ready_event() -> None: ], ) def test_relation_app_events( - as_kwarg: bool, event_name: str, event_kind: type[ops.EventBase] + as_kwarg: bool, event_name: str, event_kind: type[ops.RelationEvent] ) -> None: ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') @@ -261,7 +261,6 @@ def test_relation_app_events( mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) - relation_event = typing.cast('ops.RelationEvent', relation_event) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name assert relation_event.unit is None From cd14ec0e18b14d763e65ecd628e6b2da0701acc4 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:22:17 +1300 Subject: [PATCH 16/26] chore: remove scopes from type: ignore, used by mypy but not pyright. --- testing/tests/helpers.py | 2 +- testing/tests/test_emitted_events_util.py | 2 +- testing/tests/test_plugin.py | 2 +- testing/tests/test_runtime.py | 11 ++++++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index db3ad9782..becbeeeea 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, cast from collections.abc import Callable -import jsonpatch # type: ignore[import-untyped] +import jsonpatch # type: ignore from scenario.context import _DEFAULT_JUJU_VERSION, Context from scenario.state import _Event diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 5d101abec..793b68490 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -17,7 +17,7 @@ class MyCharmEvents(CharmEvents): class MyCharm(CharmBase): META = {'name': 'mycharm'} - on = MyCharmEvents() # type: ignore[assignment] + on = MyCharmEvents() # type: ignore def __init__(self, framework: Framework): super().__init__(framework) diff --git a/testing/tests/test_plugin.py b/testing/tests/test_plugin.py index 29c33c621..6d7dcd8b3 100644 --- a/testing/tests/test_plugin.py +++ b/testing/tests/test_plugin.py @@ -10,7 +10,7 @@ def test_plugin_ctx_run(pytester: pytest.Pytester) -> None: # create a temporary pytest test module - pytester.makepyfile( # type: ignore[misc] + 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 770b82563..4f065891b 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -48,7 +48,9 @@ def test_event_emission() -> None: class MyEvt(ops.EventBase): pass - my_charm_type.on.define_event('bar', MyEvt) # type: ignore[attr-defined] + my_charm_type.on.define_event( # type: ignore + 'bar', MyEvt + ) charm_spec: _CharmSpec[ops.CharmBase] = _CharmSpec( my_charm_type, @@ -67,8 +69,11 @@ class MyEvt(ops.EventBase): ) as manager: manager.run() - assert my_charm_type._event # type: ignore[attr-defined] - assert isinstance(my_charm_type._event, MyEvt) # type: ignore[attr-defined] + 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')) From 59cc28dda96dbd40c0d4d987187f65f102305291 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:25:07 +1300 Subject: [PATCH 17/26] chore: remove -> None --- testing/tests/test_charm_spec_autoload.py | 18 +++++----- testing/tests/test_consistency_checker.py | 20 +++++------ testing/tests/test_context.py | 36 +++++++++---------- testing/tests/test_context_on.py | 44 +++++++++++------------ testing/tests/test_emitted_events_util.py | 12 +++---- testing/tests/test_plugin.py | 2 +- testing/tests/test_runtime.py | 22 ++++++------ 7 files changed, 74 insertions(+), 80 deletions(-) diff --git a/testing/tests/test_charm_spec_autoload.py b/testing/tests/test_charm_spec_autoload.py index 7aa4cde0d..e002dab66 100644 --- a/testing/tests/test_charm_spec_autoload.py +++ b/testing/tests/test_charm_spec_autoload.py @@ -77,31 +77,31 @@ def create_tempcharm( yield charm_class -def test_autoload_no_meta_fails(tmp_path: Path) -> None: +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: Path) -> None: +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: Path) -> None: +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: Path) -> None: +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: Path, config_type: str) -> None: +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: @@ -109,7 +109,7 @@ def test_autoload_legacy_type_passes(tmp_path: Path, config_type: str) -> None: @pytest.mark.parametrize('legacy', (True, False)) -def test_meta_autoload(tmp_path: Path, legacy: bool) -> None: +def test_meta_autoload(tmp_path: Path, legacy: bool): with create_tempcharm( tmp_path, legacy=legacy, @@ -120,7 +120,7 @@ def test_meta_autoload(tmp_path: Path, legacy: bool) -> None: @pytest.mark.parametrize('legacy', (True, False)) -def test_no_meta_raises(tmp_path: Path, legacy: bool) -> None: +def test_no_meta_raises(tmp_path: Path, legacy: bool): with create_tempcharm( tmp_path, legacy=legacy, @@ -131,7 +131,7 @@ def test_no_meta_raises(tmp_path: Path, legacy: bool) -> None: @pytest.mark.parametrize('legacy', (True, False)) -def test_relations_ok(tmp_path: Path, legacy: bool) -> None: +def test_relations_ok(tmp_path: Path, legacy: bool): with create_tempcharm( tmp_path, legacy=legacy, @@ -149,7 +149,7 @@ def test_relations_ok(tmp_path: Path, legacy: bool) -> None: @pytest.mark.parametrize('legacy', (True, False)) -def test_config_defaults(tmp_path: Path, legacy: bool) -> None: +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 9984c12d4..46540b66f 100644 --- a/testing/tests/test_consistency_checker.py +++ b/testing/tests/test_consistency_checker.py @@ -42,7 +42,7 @@ def assert_inconsistent( charm_spec: _CharmSpec[ops.CharmBase], juju_version: str = '3.0', unit_id: int = 0, -) -> None: +): with pytest.raises(InconsistentScenarioError): check_consistency(state, event, charm_spec, juju_version, unit_id) @@ -53,11 +53,11 @@ def assert_consistent( charm_spec: _CharmSpec[ops.CharmBase], juju_version: str = '3.0', unit_id: int = 0, -) -> None: +): check_consistency(state, event, charm_spec, juju_version, unit_id) -def test_base() -> None: +def test_base(): state = State() event = _Event('update_status') spec: _CharmSpec[ops.CharmBase] = _CharmSpec(MyCharm, {}) @@ -216,7 +216,7 @@ def test_evt_bad_container_name(): (CheckInfo('chk2'), False), ], ) -def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool) -> None: +def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool): layer = ops.pebble.Layer({ 'checks': { 'chk1': { @@ -303,7 +303,7 @@ def test_bad_config_option_type(): ('boolean', False, 'foo'), ), ) -def test_config_types(config_type: tuple[str, Any, Any]) -> None: +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}), @@ -318,7 +318,7 @@ def test_config_types(config_type: tuple[str, Any, Any]) -> None: @pytest.mark.parametrize('juju_version', ('3.4', '3.5', '4.0')) -def test_config_secret(juju_version: str) -> None: +def test_config_secret(juju_version: str): assert_consistent( State(config={'foo': 'secret:co28kefmp25c77utl3n0'}), _Event('bar'), @@ -348,7 +348,7 @@ def test_config_secret(juju_version: str) -> None: @pytest.mark.parametrize('juju_version', ('2.9', '3.3')) -def test_config_secret_old_juju(juju_version: str) -> None: +def test_config_secret_old_juju(juju_version: str): assert_inconsistent( State(config={'foo': 'secret:co28kefmp25c77utl3n0'}), _Event('bar'), @@ -361,7 +361,7 @@ def test_config_secret_old_juju(juju_version: str) -> None: "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: str) -> None: +def test_secrets_jujuv_bad(bad_v: str): secret = Secret({'a': 'b'}) assert_inconsistent( State(secrets={secret}), @@ -385,7 +385,7 @@ def test_secrets_jujuv_bad(bad_v: str) -> None: @pytest.mark.parametrize('good_v', ('3.0', '3.1', '3', '3.33', '4', '100')) -def test_secrets_jujuv_good(good_v: str) -> None: +def test_secrets_jujuv_good(good_v: str): assert_consistent( State(secrets={Secret({'a': 'b'})}), _Event('bar'), @@ -540,7 +540,7 @@ def test_action_name(): @pytest.mark.parametrize('ptype,good,bad', _ACTION_TYPE_CHECKS) -def test_action_params_type(ptype: str, good: Any, bad: Any) -> None: +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 2c1322b1c..4846c5671 100644 --- a/testing/tests/test_context.py +++ b/testing/tests/test_context.py @@ -15,7 +15,7 @@ class MyCharm(ops.CharmBase): pass -def test_run() -> None: +def test_run(): ctx = Context(MyCharm, meta={'name': 'foo'}) state = State() @@ -34,7 +34,7 @@ def test_run() -> None: assert s is state -def test_run_action() -> None: +def test_run_action(): ctx = Context(MyCharm, meta={'name': 'foo'}) state = State() expected_id = _next_action_id(update=False) @@ -58,7 +58,7 @@ def test_run_action() -> None: @pytest.mark.parametrize('app_name', ('foo', 'bar', 'george')) @pytest.mark.parametrize('unit_id', (1, 2, 42)) -def test_app_name(app_name: str, unit_id: int) -> None: +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 @@ -66,7 +66,7 @@ def test_app_name(app_name: str, unit_id: int) -> None: @pytest.mark.parametrize('machine_id', ('0', None, '42', '0/lxd/4')) -def test_machine_id_envvar(machine_id: str | None) -> None: +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()): @@ -74,7 +74,7 @@ def test_machine_id_envvar(machine_id: str | None) -> None: @pytest.mark.parametrize('availability_zone', ('zone1', None, 'us-east-1a')) -def test_availability_zone_envvar(availability_zone: str | None) -> None: +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()): @@ -82,14 +82,14 @@ def test_availability_zone_envvar(availability_zone: str | None) -> None: @pytest.mark.parametrize('principal_unit', ('main/0', None, 'app/42')) -def test_principal_unit_envvar(principal_unit: str | None) -> None: +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()): assert os.getenv('JUJU_PRINCIPAL_UNIT') == principal_unit -def test_context_manager() -> None: +def test_context_manager(): ctx = Context(MyCharm, meta={'name': 'foo'}, actions={'act': {}}) state = State() with ctx(ctx.on.start(), state) as mgr: @@ -101,29 +101,27 @@ def test_context_manager() -> None: assert mgr.charm.meta.name == 'foo' -def test_app_name_and_unit_id_default() -> None: +def test_app_name_and_unit_id_default(): ctx = Context(MyCharm, meta={'name': 'foo'}) assert ctx.app_name == 'foo' assert ctx.unit_id == 0 -def test_app_name_and_unit_id() -> None: +def test_app_name_and_unit_id(): ctx = Context(MyCharm, meta={'name': 'foo'}, app_name='notfoo', unit_id=42) assert ctx.app_name == 'notfoo' assert ctx.unit_id == 42 @pytest.mark.parametrize('bare_charm_errors', ('1', '0')) -def test_context_manager_uncaught_error( - bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch -) -> None: +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: ops.EventBase) -> None: + def _on_start(self, event: ops.EventBase): raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -136,14 +134,14 @@ def _on_start(self, event: ops.EventBase) -> None: @pytest.mark.parametrize('bare_charm_errors', ('1', '0')) -def test_run_uncaught_error(bare_charm_errors: str, monkeypatch: pytest.MonkeyPatch) -> None: +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: ops.EventBase) -> None: + def _on_start(self, event: ops.EventBase): raise RuntimeError('Crash!') monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) @@ -153,14 +151,14 @@ def _on_start(self, event: ops.EventBase) -> None: assert 'TEST_ENV_VAR' not in os.environ -def test_context_manager_env_cleared() -> None: +def test_context_manager_env_cleared(): 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: ops.EventBase) -> None: + def _on_start(self, event: ops.EventBase): os.environ['TEST_ENV_VAR'] = '2' ctx = Context(GoodCharm, meta={'name': 'crashy'}) @@ -170,13 +168,13 @@ def _on_start(self, event: ops.EventBase) -> None: assert 'TEST_ENV_VAR' not in os.environ -def test_run_env_cleared() -> None: +def test_run_env_cleared(): 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: ops.EventBase) -> None: + 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 3bca3b8af..58cefc756 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -64,7 +64,7 @@ def _on_event(self, event: ops.EventBase): ('leader_elected', ops.LeaderElectedEvent), ], ) -def test_simple_events(event_name: str, event_kind: type[ops.EventBase]) -> None: +def test_simple_events(event_name: str, event_kind: type[ops.EventBase]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.install(), state) @@ -82,7 +82,7 @@ def test_simple_events(event_name: str, event_kind: type[ops.EventBase]) -> None ('post_series_upgrade', ops.PostSeriesUpgradeEvent), ], ) -def test_simple_deprecated_events(event_name: str, event_kind: type[ops.EventBase]) -> None: +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) @@ -107,7 +107,7 @@ def test_simple_secret_events( event_name: str, event_kind: type[ops.EventBase], owner: Literal['unit', 'app'] | None, -) -> None: +): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret({'password': 'xxxx'}, owner=owner) state_in = scenario.State(secrets={secret}) @@ -137,7 +137,7 @@ def test_simple_secret_events( ('secret_remove', ops.SecretRemoveEvent), ], ) -def test_revision_secret_events(event_name: str, event_kind: type[ops.EventBase]) -> None: +def test_revision_secret_events(event_name: str, event_kind: type[ops.EventBase]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) secret = scenario.Secret( tracked_content={'password': 'yyyy'}, @@ -160,7 +160,7 @@ def test_revision_secret_events(event_name: str, event_kind: type[ops.EventBase] @pytest.mark.parametrize('event_name', ['secret_expired', 'secret_remove']) -def test_revision_secret_events_as_positional_arg(event_name: str) -> None: +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'}, @@ -179,7 +179,7 @@ def test_revision_secret_events_as_positional_arg(event_name: str) -> None: ('storage_detaching', ops.StorageDetachingEvent), ], ) -def test_storage_events(event_name: str, event_kind: type[ops.EventBase]) -> None: +def test_storage_events(event_name: str, event_kind: type[ops.EventBase]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) storage = scenario.Storage('foo') state_in = scenario.State(storages=[storage]) @@ -195,7 +195,7 @@ def test_storage_events(event_name: str, event_kind: type[ops.EventBase]) -> Non assert isinstance(collect_status, ops.CollectStatusEvent) -def test_action_event_no_params() -> None: +def test_action_event_no_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.action(action_name), state) @@ -206,7 +206,7 @@ def test_action_event_no_params() -> None: assert isinstance(collect_status, ops.CollectStatusEvent) -def test_action_event_with_params() -> None: +def test_action_event_with_params(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) # These look like: # ctx.run(ctx.on.action(action=action), state) @@ -221,7 +221,7 @@ def test_action_event_with_params() -> None: assert isinstance(collect_status, ops.CollectStatusEvent) -def test_pebble_ready_event() -> None: +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]) @@ -243,9 +243,7 @@ def test_pebble_ready_event() -> None: ('relation_broken', ops.RelationBrokenEvent), ], ) -def test_relation_app_events( - as_kwarg: bool, event_name: str, event_kind: type[ops.RelationEvent] -) -> None: +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]) @@ -267,7 +265,7 @@ def test_relation_app_events( assert isinstance(collect_status, ops.CollectStatusEvent) -def test_relation_complex_name() -> None: +def test_relation_complex_name(): meta: dict[str, Any] = copy.deepcopy(META) meta['requires']['foo-bar-baz'] = {'interface': 'another-one'} ctx = scenario.Context(ContextCharm, meta=meta, actions=ACTIONS) @@ -284,7 +282,7 @@ def test_relation_complex_name() -> None: @pytest.mark.parametrize('event_name', ['relation_created', 'relation_broken']) -def test_relation_events_as_positional_arg(event_name: str) -> None: +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]) @@ -299,9 +297,7 @@ def test_relation_events_as_positional_arg(event_name: str) -> None: ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events_default_unit( - event_name: str, event_kind: type[ops.EventBase] -) -> None: +def test_relation_unit_events_default_unit(event_name: str, event_kind: type[ops.EventBase]): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz', remote_units_data={1: {'x': 'y'}}) state_in = scenario.State(relations=[relation]) @@ -327,7 +323,7 @@ def test_relation_unit_events_default_unit( ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events(event_name: str, event_kind: type[ops.EventBase]) -> None: +def test_relation_unit_events(event_name: str, event_kind: type[ops.EventBase]): 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]) @@ -345,7 +341,7 @@ def test_relation_unit_events(event_name: str, event_kind: type[ops.EventBase]) assert isinstance(collect_status, ops.CollectStatusEvent) -def test_relation_departed_event() -> None: +def test_relation_departed_event(): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) relation = scenario.Relation('baz') state_in = scenario.State(relations=[relation]) @@ -516,7 +512,7 @@ def __init__(self, framework: ops.Framework): framework.observe(self.consumer.on.state_event, self._on_event) -def test_custom_event_no_args() -> None: +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: # type: ignore mgr.run() @@ -525,7 +521,7 @@ def test_custom_event_no_args() -> None: assert isinstance(custom_event, CustomEvent) -def test_custom_event_with_args() -> None: +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), # type: ignore @@ -539,13 +535,13 @@ def test_custom_event_with_args() -> None: assert custom_event.arg1 == 42 -def test_custom_event_is_hookevent() -> None: +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) # type: ignore -def test_custom_event_with_scenario_args() -> None: +def test_custom_event_with_scenario_args(): meta: dict[str, Any] = META.copy() meta['requires']['endpoint'] = {'interface': 'int1'} meta['requires']['sub-endpoint'] = {'interface': 'int2', 'scope': 'container'} @@ -677,7 +673,7 @@ def __init__(self, framework: ops.Framework): framework.observe(self.consumer2.on.foo_changed, self._on_event) -def test_custom_event_two_libraries() -> None: +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: # type: ignore diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 793b68490..999f85c1b 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -24,14 +24,14 @@ def __init__(self, framework: Framework): framework.observe(self.on.start, self._on_start) framework.observe(self.on.foo, self._on_foo) - def _on_start(self, e: EventBase) -> None: + def _on_start(self, e: EventBase): self.on.foo.emit() - def _on_foo(self, e: EventBase) -> None: + def _on_foo(self, e: EventBase): pass -def test_capture_custom_evt_nonspecific_capture_include_fw_evts() -> None: +def test_capture_custom_evt_nonspecific_capture_include_fw_evts(): ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True) ctx.run(ctx.on.start(), State()) @@ -44,7 +44,7 @@ def test_capture_custom_evt_nonspecific_capture_include_fw_evts() -> None: assert isinstance(emitted[4], CommitEvent) -def test_capture_juju_evt() -> None: +def test_capture_juju_evt(): ctx = Context(MyCharm, meta=MyCharm.META) ctx.run(ctx.on.start(), State()) @@ -54,7 +54,7 @@ def test_capture_juju_evt() -> None: assert isinstance(emitted[1], Foo) -def test_capture_deferred_evt() -> None: +def test_capture_deferred_evt(): ctx = Context(MyCharm, meta=MyCharm.META, capture_deferred_events=True) deferred = [_Event('foo').deferred(handler=MyCharm._on_foo)] ctx.run(ctx.on.start(), State(deferred=deferred)) @@ -66,7 +66,7 @@ def test_capture_deferred_evt() -> None: assert isinstance(emitted[2], Foo) -def test_capture_no_deferred_evt() -> None: +def test_capture_no_deferred_evt(): ctx = Context(MyCharm, meta=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 6d7dcd8b3..c8c3cca93 100644 --- a/testing/tests/test_plugin.py +++ b/testing/tests/test_plugin.py @@ -8,7 +8,7 @@ sys.path.append('.') -def test_plugin_ctx_run(pytester: pytest.Pytester) -> None: +def test_plugin_ctx_run(pytester: pytest.Pytester): # create a temporary pytest test module pytester.makepyfile( # type: ignore """ diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py index 4f065891b..f98a8d2c3 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -28,7 +28,7 @@ def __init__(self, framework: ops.Framework): for evt in self.on.events().values(): self.framework.observe(evt, self._catchall) - def _catchall(self, e: ops.EventBase) -> None: + def _catchall(self, e: ops.EventBase): if self._event: return MyCharm._event = e @@ -36,7 +36,7 @@ def _catchall(self, e: ops.EventBase) -> None: return MyCharm -def test_event_emission() -> None: +def test_event_emission(): with TemporaryDirectory(): meta: dict[str, Any] = { 'name': 'foo', @@ -78,7 +78,7 @@ class MyEvt(ops.EventBase): @pytest.mark.parametrize('app_name', ('foo', 'bar-baz', 'QuX2')) @pytest.mark.parametrize('unit_id', (1, 2, 42)) -def test_unit_name(app_name: str, unit_id: int) -> None: +def test_unit_name(app_name: str, unit_id: int): meta: dict[str, Any] = { 'name': app_name, } @@ -104,7 +104,7 @@ def test_unit_name(app_name: str, unit_id: int) -> None: assert manager.charm.unit.name == f'{app_name}/{unit_id}' -def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch) -> None: +def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', 'false') meta: dict[str, Any] = {'name': 'frank', 'requires': {'box': {'interface': 'triangle'}}} @@ -138,7 +138,7 @@ def test_env_clean_on_charm_error(monkeypatch: pytest.MonkeyPatch) -> None: assert os.getenv('JUJU_REMOTE_APP', None) is None -def test_juju_version_is_set_in_environ() -> None: +def test_juju_version_is_set_in_environ(): version = '2.9' class MyCharm(ops.CharmBase): @@ -146,7 +146,7 @@ def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.start, self._on_start) - def _on_start(self, _: ops.StartEvent) -> None: + def _on_start(self, _: ops.StartEvent): with pytest.warns(DeprecationWarning): assert ops.JujuVersion.from_environ() == version @@ -155,13 +155,13 @@ def _on_start(self, _: ops.StartEvent) -> None: @pytest.mark.parametrize('exit_code', (-1, 0, 1, 42)) -def test_ops_raises_abort(exit_code: int, monkeypatch: pytest.MonkeyPatch) -> None: +def test_ops_raises_abort(exit_code: int, monkeypatch: pytest.MonkeyPatch): class MyCharm(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.start, self._on_start) - def _on_start(self, _: ops.StartEvent) -> None: + def _on_start(self, _: ops.StartEvent): self.unit.status = ops.ActiveStatus() # Charms can't actually do this (_Abort is private), but this is # simpler than causing the framework to raise it. @@ -185,7 +185,7 @@ def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on.update_status, self._on_update_status) - def _on_update_status(self, event: ops.EventBase) -> None: + def _on_update_status(self, event: ops.EventBase): raise ValueError() @@ -214,14 +214,14 @@ def _on_update_status(self, event: ops.EventBase) -> None: ) def test_bare_charm_errors_set( monkeypatch: pytest.MonkeyPatch, expected_error: type[Exception], bare_charm_errors: str -) -> None: +): monkeypatch.setenv('SCENARIO_BARE_CHARM_ERRORS', bare_charm_errors) ctx = Context(ValueErrorCharm, meta={'name': 'value-error'}) with pytest.raises(expected_error): ctx.run(ctx.on.update_status(), State()) -def test_bare_charm_errors_not_set(monkeypatch: pytest.MonkeyPatch) -> None: +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): From 901e1881c5793930a51ca5b97026b140d3e07d10 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:26:45 +1300 Subject: [PATCH 18/26] chore: remove unnecessary type annotation, per review. --- testing/tests/test_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py index f98a8d2c3..58cf5e336 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -61,7 +61,7 @@ class MyEvt(ops.EventBase): charm_spec, ) - ctx: Context[ops.CharmBase] = Context(my_charm_type, meta=meta) + ctx = Context(my_charm_type, meta=meta) with runtime.exec( state=State(), event=_Event('bar'), From 33c60ebbc7865eac0103a121ca128baca58c59b8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:36:09 +1300 Subject: [PATCH 19/26] chore: remove unnecessary quotes. --- testing/tests/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index becbeeeea..267233312 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -18,7 +18,7 @@ def trigger( - state: 'State', + state: State, event: 'str | _Event', charm_type: type['CharmType'], pre_event: Callable[['CharmType'], None] | None = None, @@ -28,7 +28,7 @@ def trigger( config: dict[str, Any] | None = None, charm_root: str | Path | None = None, juju_version: str = _DEFAULT_JUJU_VERSION, -) -> 'State': +) -> State: ctx = Context( charm_type=charm_type, meta=meta, @@ -56,7 +56,7 @@ def trigger( return state_out -def jsonpatch_delta(self: 'State', other: 'State') -> list[dict[str, Any]]: +def jsonpatch_delta(self: State, other: State) -> list[dict[str, Any]]: dict_other = dataclasses.asdict(other) dict_self = dataclasses.asdict(self) for attr in ( From 8c1e771c05e8aae51bab8e332e40a0927566b67e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:36:27 +1300 Subject: [PATCH 20/26] chore: remove unnecessary sorted(). --- testing/tests/test_e2e/test_state.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py index ec86a3c33..8205212c9 100644 --- a/testing/tests/test_e2e/test_state.py +++ b/testing/tests/test_e2e/test_state.py @@ -137,15 +137,13 @@ def call(charm: CharmBase, e): # ignore stored state in the delta out_purged = replace(out, stored_states=state.stored_states) - expected = sorted( - [ - {'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'}, - ], - key=lambda obj: obj['path'] + obj['op'], - ) + # 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 From 61b9db6f7e5a6dab61318530f7e24d494e67031e Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 14:57:49 +1300 Subject: [PATCH 21/26] refactor: avoid casts by using a tighter type. --- testing/tests/test_context_on.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/testing/tests/test_context_on.py b/testing/tests/test_context_on.py index 58cefc756..88d9e70ab 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -105,7 +105,7 @@ def test_simple_deprecated_events(event_name: str, event_kind: type[ops.EventBas def test_simple_secret_events( as_kwarg: bool, event_name: str, - event_kind: type[ops.EventBase], + event_kind: type[ops.SecretEvent], owner: Literal['unit', 'app'] | None, ): ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS) @@ -125,7 +125,6 @@ def test_simple_secret_events( mgr.run() secret_event, collect_status = mgr.charm.observed assert isinstance(secret_event, event_kind) - secret_event = typing.cast('ops.SecretEvent', secret_event) assert secret_event.secret.id == secret.id assert isinstance(collect_status, ops.CollectStatusEvent) @@ -137,7 +136,9 @@ def test_simple_secret_events( ('secret_remove', ops.SecretRemoveEvent), ], ) -def test_revision_secret_events(event_name: str, event_kind: type[ops.EventBase]): +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'}, @@ -153,7 +154,6 @@ def test_revision_secret_events(event_name: str, event_kind: type[ops.EventBase] mgr.run() secret_event, collect_status = mgr.charm.observed assert isinstance(secret_event, event_kind) - secret_event = typing.cast('ops.SecretExpiredEvent | ops.SecretRemoveEvent', secret_event) assert secret_event.secret.id == secret.id assert secret_event.revision == 42 assert isinstance(collect_status, ops.CollectStatusEvent) @@ -179,7 +179,7 @@ def test_revision_secret_events_as_positional_arg(event_name: str): ('storage_detaching', ops.StorageDetachingEvent), ], ) -def test_storage_events(event_name: str, event_kind: type[ops.EventBase]): +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]) @@ -189,7 +189,6 @@ def test_storage_events(event_name: str, event_kind: type[ops.EventBase]): mgr.run() storage_event, collect_status = mgr.charm.observed assert isinstance(storage_event, event_kind) - storage_event = typing.cast('ops.StorageEvent', storage_event) assert storage_event.storage.name == storage.name assert storage_event.storage.index == storage.index assert isinstance(collect_status, ops.CollectStatusEvent) From 6c075f0038a48e8801864eda595905f0cc0b4faa Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 15 Dec 2025 15:00:43 +1300 Subject: [PATCH 22/26] chore: remove unnecessary cast and annotations, based on review suggestions. --- testing/tests/test_context_on.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/testing/tests/test_context_on.py b/testing/tests/test_context_on.py index 88d9e70ab..c8156f210 100644 --- a/testing/tests/test_context_on.py +++ b/testing/tests/test_context_on.py @@ -265,7 +265,7 @@ def test_relation_app_events(as_kwarg: bool, event_name: str, event_kind: type[o def test_relation_complex_name(): - meta: dict[str, Any] = copy.deepcopy(META) + 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') @@ -296,7 +296,7 @@ def test_relation_events_as_positional_arg(event_name: str): ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events_default_unit(event_name: str, event_kind: type[ops.EventBase]): +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]) @@ -307,7 +307,6 @@ def test_relation_unit_events_default_unit(event_name: str, event_kind: type[ops mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) - relation_event = typing.cast('ops.RelationEvent', relation_event) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name assert relation_event.unit is not None @@ -322,7 +321,7 @@ def test_relation_unit_events_default_unit(event_name: str, event_kind: type[ops ('relation_changed', ops.RelationChangedEvent), ], ) -def test_relation_unit_events(event_name: str, event_kind: type[ops.EventBase]): +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]) @@ -332,7 +331,6 @@ def test_relation_unit_events(event_name: str, event_kind: type[ops.EventBase]): mgr.run() relation_event, collect_status = mgr.charm.observed assert isinstance(relation_event, event_kind) - relation_event = typing.cast('ops.RelationEvent', relation_event) assert relation_event.relation.id == relation.id assert relation_event.app.name == relation.remote_app_name assert relation_event.unit is not None @@ -541,7 +539,7 @@ def test_custom_event_is_hookevent(): def test_custom_event_with_scenario_args(): - meta: dict[str, Any] = META.copy() + meta = META.copy() meta['requires']['endpoint'] = {'interface': 'int1'} meta['requires']['sub-endpoint'] = {'interface': 'int2', 'scope': 'container'} meta['peers'] = {'peer-endpoint': {'interface': 'int3'}} From 6aa58fcac7fd40a974d83e1d0c1c2aa2fb0ce760 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 16 Dec 2025 15:22:24 +1300 Subject: [PATCH 23/26] Post merge fixes. --- testing/tests/helpers.py | 6 +++--- testing/tests/test_consistency_checker.py | 15 +++++++-------- testing/tests/test_emitted_events_util.py | 10 +++++----- testing/tests/test_runtime.py | 3 ++- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index b5fe8e2b6..b813161a2 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -9,7 +9,7 @@ from pathlib import Path 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 @@ -58,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 ( @@ -74,4 +74,4 @@ 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 # type: ignore - return sorted(patch, key=lambda obj: obj['path'] + obj['op']) + return sorted(patch, key=lambda obj: obj['path'] + obj['op']) # type: ignore diff --git a/testing/tests/test_consistency_checker.py b/testing/tests/test_consistency_checker.py index 7b0e57675..7c8802d31 100644 --- a/testing/tests/test_consistency_checker.py +++ b/testing/tests/test_consistency_checker.py @@ -4,7 +4,6 @@ from __future__ import annotations import dataclasses -from collections.abc import Callable from typing import Any import pytest @@ -42,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) @@ -53,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) @@ -232,7 +231,7 @@ def test_checkinfo_matches_layer(check: CheckInfo, consistent: bool): } }) state = State(containers={Container('foo', check_infos={check}, layers={'base': layer})}) - asserter: Callable[..., None] = assert_consistent if consistent else assert_inconsistent + asserter = assert_consistent if consistent else assert_inconsistent asserter( state, _Event('foo-pebble-ready', container=Container('foo')), diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py index 1205af610..5812afb1d 100644 --- a/testing/tests/test_emitted_events_util.py +++ b/testing/tests/test_emitted_events_util.py @@ -23,7 +23,7 @@ class MyCharmEvents(CharmEvents): class MyCharm(CharmBase): META: Mapping[str, Any] = {'name': 'mycharm'} - on = MyCharmEvents() + on = MyCharmEvents() # type: ignore def __init__(self, framework: Framework): super().__init__(framework) @@ -38,7 +38,7 @@ def _on_foo(self, e: EventBase): 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_runtime.py b/testing/tests/test_runtime.py index fca864b1b..20b9f5650 100644 --- a/testing/tests/test_runtime.py +++ b/testing/tests/test_runtime.py @@ -9,7 +9,8 @@ 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 9416ef0af0a399131e62050efea0398740fc8b80 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 19 Dec 2025 08:45:31 +1300 Subject: [PATCH 24/26] Use Iterator[] instead of Generator[] for simpler compatibility with older Python. --- testing/src/scenario/_runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index 2b3c2e684..07de01ec9 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -10,7 +10,7 @@ import os import tempfile import typing -from collections.abc import Generator +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Generic @@ -282,7 +282,7 @@ def exec( state: State, event: _Event, context: Context[CharmType], - ) -> Generator[Ops[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 From d049dffdb39b463ed76583330ed56009ca969179 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 19 Dec 2025 08:46:32 +1300 Subject: [PATCH 25/26] Use Iterator[] instead of Generator[] for simpler compatibility with older Python. --- testing/tests/test_charm_spec_autoload.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing/tests/test_charm_spec_autoload.py b/testing/tests/test_charm_spec_autoload.py index 3db2e38af..01b7df1fc 100644 --- a/testing/tests/test_charm_spec_autoload.py +++ b/testing/tests/test_charm_spec_autoload.py @@ -5,7 +5,7 @@ import importlib import sys -from collections.abc import Generator +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import Any @@ -26,7 +26,7 @@ class MyCharm(CharmBase): pass @contextmanager -def import_name(name: str, source: Path) -> Generator[type[CharmBase]]: +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') @@ -45,7 +45,7 @@ def create_tempcharm( config: dict[str, Any] | None = None, name: str = 'MyCharm', legacy: bool = False, -) -> Generator[type[CharmBase]]: +) -> Iterator[type[CharmBase]]: src = root / 'src' src.mkdir(parents=True) charmpy = src / 'mycharm.py' From 4465156c398d202b16a14244935d2a0f0ca721ba Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 19 Dec 2025 08:49:52 +1300 Subject: [PATCH 26/26] Update testing/tests/helpers.py Co-authored-by: James Garner --- testing/tests/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py index b813161a2..20a704b20 100644 --- a/testing/tests/helpers.py +++ b/testing/tests/helpers.py @@ -73,5 +73,7 @@ def jsonpatch_delta(self: State, other: State) -> list[dict[str, Any]]: ): dict_other[attr] = [dataclasses.asdict(o) for o in dict_other[attr]] dict_self[attr] = [dataclasses.asdict(o) for o in dict_self[attr]] + # 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