Repository: canonical/operator
Pull request #1784 (2025-06-04 01:13:10)

fix: fix type annotation of container check_infos in ops.testing
Testing code such as `container = testing.Container("db", check_infos={check_info})` was failing type checks, with this error:

```
error: Argument of type "set[CheckInfo]" cannot be assigned to parameter "check_infos" of type "frozenset[CheckInfo]" in function "__init__"
    "set[CheckInfo]" is not assignable to "frozenset[CheckInfo]" (reportArgumentType)
```

This PR fixes the type annotation of `check_infos` so that non-frozen sets are acceptable.

tonyandrewmeyer: > It's annoying that our own checks didn't detect this. We do run pyright over the code in `testing/src` but not `testing/tests`. We should really do that - I'll open a ticket for it.

#1785.

Patch:
diff --git a/ops/charm.py b/ops/charm.py
index 836186c..47ad8be 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -1541,7 +1541,9 @@ class CharmMeta:
     ):
         raw_: dict[str, Any] = raw or {}
         actions_raw_: dict[str, Any] = actions_raw or {}
-        config_raw_: dict[str, Any] = config_raw or {}
+        # config_raw might be {'options': None} - this is accepted by
+        # charmcraft, even though {'options: {}} would be more correct.
+        options_raw: dict[str, Any] = (config_raw or {}).get('options') or {}
 
         # When running in production, this data is generally loaded from
         # metadata.yaml. However, when running tests, this data is
@@ -1606,7 +1608,7 @@ class CharmMeta:
         # This is predominately for backwards compatibility with Harness. In a
         # real Juju environment this shouldn't be possible, because charmcraft
         # validates the config when packing.
-        for name, config in config_raw_.get('options', {}).items():
+        for name, config in options_raw.items():
             if 'type' not in config:
                 raise RuntimeError(
                     f'Incorrectly formatted config in YAML, option {name} is '
@@ -1619,7 +1621,7 @@ class CharmMeta:
                 default=config.get('default'),
                 description=config.get('description'),
             )
-            for name, config in config_raw_.get('options', {}).items()
+            for name, config in options_raw.items()
         }
         self.containers = {
             name: ContainerMeta(name, container)


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index 8b6c645..214935f 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -125,8 +125,44 @@ def test_observer_not_referenced_warning(
     assert 'Reference to ops.Object' in caplog.text
 
 
-def test_empty_action_and_config():
-    meta = ops.CharmMeta.from_yaml('name: my-charm', '', '')
+@pytest.mark.parametrize('empty_yaml', ['', '{}', None])
+def test_empty_action_and_config_from_yaml(empty_yaml: str | None):
+    meta = ops.CharmMeta.from_yaml('name: my-charm', empty_yaml, empty_yaml)
+    assert meta.actions == {}
+    assert meta.config == {}
+    if empty_yaml is not None:
+        meta = ops.CharmMeta.from_yaml('name: my-charm', config=f'options: {empty_yaml}')
+        assert meta.actions == {}
+        assert meta.config == {}
+
+
+@pytest.mark.parametrize('empty_yaml', ['', '{}', None])
+def test_empty_action_and_config_from_charm_root(empty_yaml: str | None):
+    with tempfile.TemporaryDirectory() as d:
+        td = pathlib.Path(d)
+        (td / 'metadata.yaml').write_text('name: my-charm')
+        if empty_yaml is not None:
+            (td / 'actions.yaml').write_text(empty_yaml)
+            (td / 'config.yaml').write_text(empty_yaml)
+        meta = ops.CharmMeta.from_charm_root(td)
+    assert meta.actions == {}
+    assert meta.config == {}
+    with tempfile.TemporaryDirectory() as d:
+        td = pathlib.Path(d)
+        (td / 'metadata.yaml').write_text('name: my-charm')
+        if empty_yaml is not None:
+            (td / 'config.yaml').write_text(f'options:\n  {empty_yaml}')
+        meta = ops.CharmMeta.from_charm_root(td)
+    assert meta.actions == {}
+    assert meta.config == {}
+
+
+@pytest.mark.parametrize('empty_yaml', [{}, None])
+def test_empty_action_and_config(empty_yaml: dict[str, typing.Any] | None):
+    meta = ops.CharmMeta({'name': 'my-charm'}, empty_yaml, empty_yaml)
+    assert meta.actions == {}
+    assert meta.config == {}
+    meta = ops.CharmMeta({'name': 'my-charm'}, config_raw={'options': empty_yaml})
     assert meta.actions == {}
     assert meta.config == {}
 
diff --git a/testing/src/scenario/state.py b/testing/src/scenario/state.py
index 49b1034..7571f60 100644
--- a/testing/src/scenario/state.py
+++ b/testing/src/scenario/state.py
@@ -1016,7 +1016,7 @@ class Container(_max_posargs(1)):
     notices: Sequence[Notice] = dataclasses.field(default_factory=list)
     """Any Pebble notices that already exist in the container."""
 
-    check_infos: frozenset[CheckInfo] = frozenset()
+    check_infos: Iterable[CheckInfo] = frozenset()
     """All Pebble health checks that have been added to the container."""
 
     def __hash__(self) -> int: