Repository: canonical/operator
Pull request #552 (2021-06-16 08:01:02)

Expose 'limit' in RelationMeta and improve ‘scope’
This PR adds support to `limit` in the `RelationMeta` class, which is currently not accessible at runtime by charms, and adds type validation and correctly sets the default value for `RelationMeta.scope`.

The [metadata.yaml spec](https://discourse.charmhub.io/t/charm-metadata-v2/3674) allows to limit the cardinality of relations using the `limit` keyword, e.g., when we want _up to_ one relation in the model using the `mongodb-datasource` interface. Additionally, the enumeration of the possible values for `RelationMeta.scope` (namely: `global`, which is default, or `container`) are not validated, and unless the scope is explicitly set, `RelationMeta.scope` is set to `None` rather than the default `global`.


Patch:
diff --git a/ops/charm.py b/ops/charm.py
index 41ddd25..41168a4 100755
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -795,16 +795,28 @@ class RelationMeta:
         role: This is :class:`RelationRole`; one of peer/requires/provides
         relation_name: Name of this relation from metadata.yaml
         interface_name: Optional definition of the interface protocol.
-        scope: "global" or "container" scope based on how the relation should be used.
+        limit: Optional definition of maximum number of connections to this relation endpoint.
+        scope: "global" (default) or "container" scope based on how the relation should be used.
     """
 
+    VALID_SCOPES = ['global', 'container']
+
     def __init__(self, role: RelationRole, relation_name: str, raw: dict):
         if not isinstance(role, RelationRole):
             raise TypeError("role should be a Role, not {!r}".format(role))
+        self._default_scope = self.VALID_SCOPES[0]
         self.role = role
         self.relation_name = relation_name
         self.interface_name = raw['interface']
-        self.scope = raw.get('scope')
+
+        self.limit = raw.get('limit')
+        if self.limit and not isinstance(self.limit, int):
+            raise TypeError("limit should be an int, not {}".format(type(self.limit)))
+
+        self.scope = raw.get('scope') or self._default_scope
+        if self.scope not in self.VALID_SCOPES:
+            raise TypeError("scope should be one of {}; not '{}'".format(
+                ', '.join("'{}'".format(s) for s in self.VALID_SCOPES), self.scope))
 
 
 class StorageMeta:


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index 7f99786..78dd4aa 100755
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -277,6 +277,51 @@ containers:
         ])
         self.assertEqual(charm.count, 2)
 
+    def test_relations_meta(self):
+
+        # language=YAML
+        self.meta = CharmMeta.from_yaml('''
+name: my-charm
+requires:
+  database:
+    interface: mongodb
+    limit: 1
+    scope: container
+  metrics:
+    interface: prometheus-scraping
+''')
+
+        self.assertEqual(self.meta.requires['database'].interface_name, 'mongodb')
+        self.assertEqual(self.meta.requires['database'].limit, 1)
+        self.assertEqual(self.meta.requires['database'].scope, 'container')
+
+        self.assertEqual(self.meta.requires['metrics'].interface_name, 'prometheus-scraping')
+        self.assertIsNone(self.meta.requires['metrics'].limit)
+        self.assertEqual(self.meta.requires['metrics'].scope, 'global')  # Default value
+
+    def test_relations_meta_limit_type_validation(self):
+        with self.assertRaisesRegex(TypeError, "limit should be an int, not <class 'str'>"):
+            # language=YAML
+            self.meta = CharmMeta.from_yaml('''
+name: my-charm
+requires:
+  database:
+    interface: mongodb
+    limit: foobar
+''')
+
+    def test_relations_meta_scope_type_validation(self):
+        with self.assertRaisesRegex(TypeError,
+                                    "scope should be one of 'global', 'container'; not 'foobar'"):
+            # language=YAML
+            self.meta = CharmMeta.from_yaml('''
+name: my-charm
+requires:
+  database:
+    interface: mongodb
+    scope: foobar
+''')
+
     @classmethod
     def _get_action_test_meta(cls):
         # language=YAML