From 7de48a9d8a7c7815f513dc76f04fa237b366b7f6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Mon, 3 Apr 2023 22:42:56 +0200 Subject: [PATCH] `PortNamespace`: Make `dynamic` apply recursively (#263) The `dynamic` attribute of a port namespace indicates whether it should accept ports that are not explicitly defined. This was mostly used during validation, when a dictionary of port values was matched against a given `PortNamespace`. This was, however, not being applied recursively _and_ only during validation. For example, given a dynamic portnamespace, validating a dictionary: { 'nested': { 'output': 'some_value' } } would pass validation without problems. However, the `Process.out` call that would actually attempt to attach the output to the process instance would call: self.spec().outputs.get_port(namespace_separator.join(namespace)) which would raise, since `get_port` would raise a `ValueError`: ValueError: port 'output' does not exist in port namespace 'nested' The problem is that the `nested` namespace is expected, because the top level namespace was marked as dynamic, however, it itself would not also be treated as dynamic and so attempting to retrieve `some_value` from the `nested` namespace, would trigger a `KeyError`. Here the logic in `PortNamespace.get_port` is updated to check in advance whether the port exists in the namespace, and if not the case _and_ the namespace is dynamic, the nested port namespace is created. The attributes of the new namespace are inherited from its parent namespace, making the `dynamic` attribute act recursively. Cherry-pick: 4c29f4459c8eb8a8263049ac338189c604702e4e --- src/plumpy/ports.py | 13 ++++++++++++- test/test_port.py | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/plumpy/ports.py b/src/plumpy/ports.py index 7e69bef8..399c598a 100644 --- a/src/plumpy/ports.py +++ b/src/plumpy/ports.py @@ -446,9 +446,20 @@ def get_port(self, name: str) -> Union[Port, 'PortNamespace']: namespace = name.split(self.NAMESPACE_SEPARATOR) port_name = namespace.pop(0) - if port_name not in self: + if port_name not in self and not self.dynamic: raise ValueError(f"port '{port_name}' does not exist in port namespace '{self.name}'") + if port_name not in self and self.dynamic: + self[port_name] = self.__class__( + name=port_name, + required=self.required, + validator=self.validator, + valid_type=self.valid_type, + default=self.default, + dynamic=self.dynamic, + populate_defaults=self.populate_defaults + ) + if namespace: portnamespace = cast(PortNamespace, self[port_name]) return portnamespace.get_port(self.NAMESPACE_SEPARATOR.join(namespace)) diff --git a/test/test_port.py b/test/test_port.py index 55ddbe66..eca1d09f 100644 --- a/test/test_port.py +++ b/test/test_port.py @@ -208,6 +208,27 @@ def test_port_namespace_get_port(self): port = self.port_namespace.get_port('sub.name.space.' + self.BASE_PORT_NAME) self.assertEqual(port, self.port) + def test_port_namespace_get_port_dynamic(self): + """Test that ``get_port`` does not raise if a port does not exist as long as the namespace is dynamic. + + In this case, the method should create the subnamespace on-the-fly with the same stats as the host namespace. + """ + port_namespace = PortNamespace(self.BASE_PORT_NAMESPACE_NAME, dynamic=True) + + name = 'undefined' + sub_namespace = port_namespace.get_port(name) + + assert isinstance(sub_namespace, PortNamespace) + assert sub_namespace.dynamic + assert sub_namespace.name == name + + name = 'nested.undefined' + sub_namespace = port_namespace.get_port(name) + + assert isinstance(sub_namespace, PortNamespace) + assert sub_namespace.dynamic + assert sub_namespace.name == 'undefined' + def test_port_namespace_create_port_namespace(self): """ Test the create_port_namespace function of the PortNamespace class