From b780358c6ec0e5eabb37a1e4b5dfa1376e7e0d46 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 3 Sep 2024 19:50:11 +1200 Subject: [PATCH 1/8] Very roughly adjust for Scenario 7.0 API. --- interface_tester/interface_test.py | 61 ++++++++++++++++++------------ interface_tester/plugin.py | 4 +- pyproject.toml | 4 +- tests/unit/test_e2e.py | 48 +++++++++++------------ 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index daa008c..e40f3da 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -1,5 +1,6 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. +import copy import dataclasses import inspect import logging @@ -13,8 +14,8 @@ import pydantic from ops.testing import CharmType from pydantic import ValidationError -from scenario import Context, Event, Relation, State, state -from scenario.state import _EventPath +from scenario import Context, Relation, State +from scenario.state import _DEFAULT_JUJU_DATABAG, _Event, _EventPath from interface_tester.errors import InvalidTestCaseError, SchemaValidationError @@ -251,7 +252,7 @@ def ctx(self) -> Optional[_InterfaceTestContext]: """ return _TESTER_CTX - def run(self, event: Union[str, Event]) -> State: + def run(self, event: Union[str, _Event]) -> State: """Simulate the emission on an event in the initial state you passed to the initializer. Calling this method will run scenario and verify that the charm being tested can handle @@ -326,7 +327,7 @@ def assert_relation_data_empty(self): # remove the default unit databag keys or we'll get false positives. local_unit_data_keys = set(relation.local_unit_data).difference( - set(state.DEFAULT_JUJU_DATABAG.keys()) + set(_DEFAULT_JUJU_DATABAG.keys()) ) if local_unit_data_keys: @@ -365,7 +366,7 @@ def _detach(self): # release singleton Tester.__instance__ = None - def _run(self, event: Union[str, Event]): + def _run(self, event: Union[str, _Event]): logger.debug("running %s" % event) self._has_run = True @@ -379,22 +380,21 @@ def _run(self, event: Union[str, Event]): # some required config, a "happy" status, network information, OTHER relations. # Typically, should NOT touch the relation that this interface test is about # -> so we overwrite and warn on conflict: state_template is the baseline, - state = (self.ctx.state_template or State()).copy() + state = copy.deepcopy(self.ctx.state_template or State()) relations = self._generate_relations_state( state, input_state, self.ctx.supported_endpoints, self.ctx.role ) # State is frozen; replace - modified_state = state.replace(relations=relations) + modified_state = dataclasses.replace(state, relations=relations) # the Relation instance this test is about: relation = next(filter(lambda r: r.interface == self.ctx.interface_name, relations)) - evt: Event = self._cast_event(event, relation) - logger.info("collected test for %s with %s" % (self.ctx.interface_name, evt.name)) - return self._run_scenario(evt, modified_state) + logger.info("collected test for %s with %s" % (self.ctx.interface_name, event)) + return self._run_scenario(event, relation, modified_state) - def _run_scenario(self, event: Event, state: State): + def _run_scenario(self, event: Union[str, _Event], relation: Relation, state: State): logger.debug("running scenario with state=%s, event=%s" % (state, event)) kwargs = {} @@ -408,21 +408,30 @@ def _run_scenario(self, event: Event, state: State): config=self.ctx.config, **kwargs, ) + event: _Event = self._cast_event(ctx, event, relation) return ctx.run(event, state) - def _cast_event(self, raw_event: Union[str, Event], relation: Relation): - # test.EVENT might be a string or an Event. Cast to Event. - event = Event(raw_event) if isinstance(raw_event, str) else raw_event - - if not isinstance(event, Event): - raise InvalidTestCaseError( - f"Expected Event or str, not {type(raw_event)}. " - f"Invalid test case: {self} cannot cast {raw_event} to Event." - ) + def _cast_event(self, ctx: Context, raw_event: Union[str, _Event], relation: Relation): + if isinstance(raw_event, str): + if raw_event.endswith("-relation-changed"): + event = ctx.on.relation_changed(relation) + elif raw_event.endswith("-relation-departed"): + event = ctx.on.relation_departed(relation) + elif raw_event.endswith("-relation-broken"): + event = ctx.on.relation_broken(relation) + elif raw_event.endswith("-relation-joined"): + event = ctx.on.relation_joined(relation) + elif raw_event.endswith("-relation-created"): + event = ctx.on.relation_created(relation) + else: + raise InvalidTestCaseError( + f"Bad interface test specification: event {raw_event} is not a relation event." + ) - if not event._is_relation_event: + if not isinstance(event, _Event): raise InvalidTestCaseError( - f"Bad interface test specification: event {raw_event} " "is not a relation event." + f"Expected _Event or str, not {type(raw_event)}. " + f"Invalid test case: {self} cannot cast {raw_event} to _Event." ) # todo: if the user passes a relation event that is NOT about the relation @@ -434,8 +443,10 @@ def _cast_event(self, raw_event: Union[str, Event], relation: Relation): # next we need to ensure that the event's .relation is our relation, and that the endpoint # in the relation and the event path match that of the charm we're testing. - charm_event = event.replace( - relation=relation, path=relation.endpoint + typing.cast(_EventPath, event.path).suffix + charm_event = dataclasses.replace( + event, + relation=relation, + path=relation.endpoint + typing.cast(_EventPath, event.path).suffix, ) return charm_event @@ -495,7 +506,7 @@ def filter_relations(rels: List[Relation], op: Callable): # relations that come from the state_template presumably have the right endpoint, # but those that we get from interface tests cannot. relations_with_endpoint = [ - r.replace(endpoint=endpoint) for r in relations_from_input_state + dataclasses.replace(r, endpoint=endpoint) for r in relations_from_input_state ] relations.extend(relations_with_endpoint) diff --git a/interface_tester/plugin.py b/interface_tester/plugin.py index 26331cd..5dc1e85 100644 --- a/interface_tester/plugin.py +++ b/interface_tester/plugin.py @@ -7,7 +7,9 @@ from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Type from ops.testing import CharmType -from scenario.state import MetadataNotFoundError, State, _CharmSpec +from scenario import State +from scenario.errors import MetadataNotFoundError +from scenario.state import _CharmSpec from interface_tester.collector import InterfaceTestSpec, gather_test_spec_for_version from interface_tester.errors import ( diff --git a/pyproject.toml b/pyproject.toml index 4b43cd6..9a6cb70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "pytest-interface-tester" -version = "3.1.0" +version = "3.1.1" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" }, ] @@ -22,7 +22,7 @@ keywords = ["juju", "relation interfaces"] dependencies = [ "pydantic>= 1.10.7", "typer==0.7.0", - "ops-scenario>=5.2", + "ops-scenario>=7.0", "pytest" ] diff --git a/tests/unit/test_e2e.py b/tests/unit/test_e2e.py index e6f9f12..3653b5c 100644 --- a/tests/unit/test_e2e.py +++ b/tests/unit/test_e2e.py @@ -108,12 +108,12 @@ def test_error_if_skip_schema_before_run(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) t.skip_schema_validation() """ @@ -134,12 +134,12 @@ def test_error_if_not_relation_event(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) t.run("foobadooble-changed") t.skip_schema_validation() @@ -165,12 +165,12 @@ def test_error_if_assert_relation_data_empty_before_run(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) t.assert_relation_data_empty() """ @@ -192,12 +192,12 @@ def test_error_if_assert_schema_valid_before_run(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) t.assert_schema_valid() """ @@ -218,12 +218,12 @@ def test_error_if_assert_schema_without_schema(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) state_out = t.run("axolotl-relation-changed") t.assert_schema_valid() @@ -245,12 +245,12 @@ def test_error_if_return_before_schema_call(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) state_out = t.run("axolotl-relation-changed") """ @@ -271,12 +271,12 @@ def test_error_if_return_without_run(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={} - )] + )} )) """ @@ -321,12 +321,12 @@ def test_valid_run(endpoint, evt_type): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={{Relation( endpoint='{endpoint}', # should not matter interface='tracing', remote_app_name='remote', local_app_data={{}} - )] + )}} )) state_out = t.run("{endpoint}-relation-{evt_type}") t.assert_schema_valid(schema=DataBagSchema()) @@ -348,13 +348,13 @@ def test_valid_run_default_schema(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={"foo":"1"}, local_unit_data={"bar": "smackbeef"} - )] + )} )) state_out = t.run("axolotl-relation-changed") t.assert_schema_valid() @@ -391,13 +391,13 @@ def test_default_schema_validation_failure(): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={"foo":"abc"}, local_unit_data={"bar": "smackbeef"} - )] + )} )) state_out = t.run("axolotl-relation-changed") t.assert_schema_valid() @@ -444,13 +444,13 @@ class FooBarSchema(DataBagSchema): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={"foo":"1"}, local_unit_data={"bar": "smackbeef"} - )] + )} )) state_out = t.run("axolotl-relation-changed") t.assert_schema_valid(schema=FooBarSchema) @@ -481,13 +481,13 @@ class FooBarSchema(DataBagSchema): def test_data_on_changed(): t = Tester(State( - relations=[Relation( + relations={Relation( endpoint='foobadooble', # should not matter interface='tracing', remote_app_name='remote', local_app_data={"foo":"abc"}, local_unit_data={"bar": "smackbeef"} - )] + )} )) state_out = t.run("axolotl-relation-changed") t.assert_schema_valid(schema=FooBarSchema) From 978471d77c49559ab271813757ab16b945e513e3 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 6 Sep 2024 13:52:19 +1200 Subject: [PATCH 2/8] Update interface_tester/interface_test.py --- interface_tester/interface_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index e40f3da..6959549 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -430,8 +430,7 @@ def _cast_event(self, ctx: Context, raw_event: Union[str, _Event], relation: Rel if not isinstance(event, _Event): raise InvalidTestCaseError( - f"Expected _Event or str, not {type(raw_event)}. " - f"Invalid test case: {self} cannot cast {raw_event} to _Event." + f"Bad interface test specification: event {raw_event} should be a relation event." ) # todo: if the user passes a relation event that is NOT about the relation From 286b960582d7921a900fe317ffbf3b245a96cfde Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 6 Sep 2024 13:54:23 +1200 Subject: [PATCH 3/8] Update interface_tester/interface_test.py --- interface_tester/interface_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index 6959549..c597fac 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -430,7 +430,7 @@ def _cast_event(self, ctx: Context, raw_event: Union[str, _Event], relation: Rel if not isinstance(event, _Event): raise InvalidTestCaseError( - f"Bad interface test specification: event {raw_event} should be a relation event." + f"Bad interface test specification: event {raw_event} should be a relation event string or _Event." ) # todo: if the user passes a relation event that is NOT about the relation From 75c6d5bd90cdb2ecf295b73a908de49218356775 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 20:25:55 +1200 Subject: [PATCH 4/8] Style fixes. --- interface_tester/interface_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index c597fac..3310da1 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -430,7 +430,8 @@ def _cast_event(self, ctx: Context, raw_event: Union[str, _Event], relation: Rel if not isinstance(event, _Event): raise InvalidTestCaseError( - f"Bad interface test specification: event {raw_event} should be a relation event string or _Event." + f"Bad interface test specification: event {raw_event} should be a relation event " + f"string or _Event." ) # todo: if the user passes a relation event that is NOT about the relation From e8e80f5a6724030c31d0421b9e2f162c3af65472 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 9 Sep 2024 21:03:23 +1200 Subject: [PATCH 5/8] Avoid the 7.0.0 release. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a6cb70..fb40f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ keywords = ["juju", "relation interfaces"] dependencies = [ "pydantic>= 1.10.7", "typer==0.7.0", - "ops-scenario>=7.0", + "ops-scenario>=7.0.1", "pytest" ] From aa9982ed473633c9d8c6bb8ee5f215f0a8e44373 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 13 Sep 2024 10:27:07 +1200 Subject: [PATCH 6/8] Update interface_tester/interface_test.py Co-authored-by: PietroPasotti --- interface_tester/interface_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index 3310da1..2a3a258 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -380,7 +380,7 @@ def _run(self, event: Union[str, _Event]): # some required config, a "happy" status, network information, OTHER relations. # Typically, should NOT touch the relation that this interface test is about # -> so we overwrite and warn on conflict: state_template is the baseline, - state = copy.deepcopy(self.ctx.state_template or State()) + state = dataclasses.replace(self.ctx.state_template) if self.ctx.state_template else State() relations = self._generate_relations_state( state, input_state, self.ctx.supported_endpoints, self.ctx.role From 36b0aba194b84a8257449eeba4785093b1df4dcf Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 13 Sep 2024 18:10:47 +1200 Subject: [PATCH 7/8] Use CharmEvents to avoid needing ctx.on. --- interface_tester/interface_test.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index 2a3a258..2e03197 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -1,6 +1,5 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. -import copy import dataclasses import inspect import logging @@ -15,6 +14,7 @@ from ops.testing import CharmType from pydantic import ValidationError from scenario import Context, Relation, State +from scenario.context import CharmEvents from scenario.state import _DEFAULT_JUJU_DATABAG, _Event, _EventPath from interface_tester.errors import InvalidTestCaseError, SchemaValidationError @@ -380,7 +380,9 @@ def _run(self, event: Union[str, _Event]): # some required config, a "happy" status, network information, OTHER relations. # Typically, should NOT touch the relation that this interface test is about # -> so we overwrite and warn on conflict: state_template is the baseline, - state = dataclasses.replace(self.ctx.state_template) if self.ctx.state_template else State() + state = ( + dataclasses.replace(self.ctx.state_template) if self.ctx.state_template else State() + ) relations = self._generate_relations_state( state, input_state, self.ctx.supported_endpoints, self.ctx.role @@ -390,11 +392,12 @@ def _run(self, event: Union[str, _Event]): # the Relation instance this test is about: relation = next(filter(lambda r: r.interface == self.ctx.interface_name, relations)) + evt: _Event = self._cast_event(event, relation) - logger.info("collected test for %s with %s" % (self.ctx.interface_name, event)) - return self._run_scenario(event, relation, modified_state) + logger.info("collected test for %s with %s" % (self.ctx.interface_name, evt.name)) + return self._run_scenario(evt, modified_state) - def _run_scenario(self, event: Union[str, _Event], relation: Relation, state: State): + def _run_scenario(self, event: Union[str, _Event], state: State): logger.debug("running scenario with state=%s, event=%s" % (state, event)) kwargs = {} @@ -408,21 +411,20 @@ def _run_scenario(self, event: Union[str, _Event], relation: Relation, state: St config=self.ctx.config, **kwargs, ) - event: _Event = self._cast_event(ctx, event, relation) return ctx.run(event, state) - def _cast_event(self, ctx: Context, raw_event: Union[str, _Event], relation: Relation): + def _cast_event(self, raw_event: Union[str, _Event], relation: Relation): if isinstance(raw_event, str): if raw_event.endswith("-relation-changed"): - event = ctx.on.relation_changed(relation) + event = CharmEvents.relation_changed(relation) elif raw_event.endswith("-relation-departed"): - event = ctx.on.relation_departed(relation) + event = CharmEvents.relation_departed(relation) elif raw_event.endswith("-relation-broken"): - event = ctx.on.relation_broken(relation) + event = CharmEvents.relation_broken(relation) elif raw_event.endswith("-relation-joined"): - event = ctx.on.relation_joined(relation) + event = CharmEvents.relation_joined(relation) elif raw_event.endswith("-relation-created"): - event = ctx.on.relation_created(relation) + event = CharmEvents.relation_created(relation) else: raise InvalidTestCaseError( f"Bad interface test specification: event {raw_event} is not a relation event." From 6a223253c0d4f5b0695695ea61a6199c014b02ae Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 16 Sep 2024 11:20:26 +1200 Subject: [PATCH 8/8] Adjust per review. --- interface_tester/interface_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/interface_tester/interface_test.py b/interface_tester/interface_test.py index 2e03197..f0e2258 100644 --- a/interface_tester/interface_test.py +++ b/interface_tester/interface_test.py @@ -414,6 +414,12 @@ def _run_scenario(self, event: Union[str, _Event], state: State): return ctx.run(event, state) def _cast_event(self, raw_event: Union[str, _Event], relation: Relation): + if not isinstance(raw_event, (_Event, str)): + raise InvalidTestCaseError( + f"Bad interface test specification: event {raw_event} should be a relation event " + f"string or _Event." + ) + if isinstance(raw_event, str): if raw_event.endswith("-relation-changed"): event = CharmEvents.relation_changed(relation) @@ -430,12 +436,6 @@ def _cast_event(self, raw_event: Union[str, _Event], relation: Relation): f"Bad interface test specification: event {raw_event} is not a relation event." ) - if not isinstance(event, _Event): - raise InvalidTestCaseError( - f"Bad interface test specification: event {raw_event} should be a relation event " - f"string or _Event." - ) - # todo: if the user passes a relation event that is NOT about the relation # interface that this test is about, at this point we are injecting the wrong # Relation instance.