From 488e20122e4105b3226eb15cd210e677154f4b31 Mon Sep 17 00:00:00 2001 From: Adrian Nembach Date: Thu, 19 Mar 2026 15:48:46 +0100 Subject: [PATCH] AP-25704: add inactive output support for Python nodes --- .../ports/InactivePortConversionTest.java | 91 +++++++++ .../unittest/test_knime_node_backend.py | 184 ++++++++++++++++++ .../nodes/ports/PythonPortObjects.java | 51 +++++ .../nodes/ports/PythonPortTypeRegistry.java | 25 ++- .../src/main/python/_node_backend_launcher.py | 18 ++ .../main/python/knime/extension/__init__.py | 1 + .../src/main/python/knime/extension/nodes.py | 21 +- 7 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 org.knime.python3.nodes.tests/src/test/java/org/knime/python3/nodes/ports/InactivePortConversionTest.java diff --git a/org.knime.python3.nodes.tests/src/test/java/org/knime/python3/nodes/ports/InactivePortConversionTest.java b/org.knime.python3.nodes.tests/src/test/java/org/knime/python3/nodes/ports/InactivePortConversionTest.java new file mode 100644 index 000000000..23081c0b7 --- /dev/null +++ b/org.knime.python3.nodes.tests/src/test/java/org/knime/python3/nodes/ports/InactivePortConversionTest.java @@ -0,0 +1,91 @@ +/* + * ------------------------------------------------------------------------ + * + * Copyright by KNIME AG, Zurich, Switzerland + * Website: http://www.knime.com; Email: contact@knime.com + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, Version 3, as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + * + * Additional permission under GNU GPL version 3 section 7: + * + * KNIME interoperates with ECLIPSE solely via ECLIPSE's plug-in APIs. + * Hence, KNIME and ECLIPSE are both independent programs and are not + * derived from each other. Should, however, the interpretation of the + * GNU GPL Version 3 ("License") under any applicable laws result in + * KNIME and ECLIPSE being a combined program, KNIME AG herewith grants + * you the additional permission to use and propagate KNIME together with + * ECLIPSE with only the license terms in place for ECLIPSE applying to + * ECLIPSE and the GNU GPL Version 3 applying for KNIME, provided the + * license terms of ECLIPSE themselves allow for the respective use and + * propagation of ECLIPSE together with KNIME. + * + * Additional permission relating to nodes for KNIME that extend the Node + * Extension (and in particular that are based on subclasses of NodeModel, + * NodeDialog, and NodeView) and that only interoperate with KNIME through + * standard APIs ("Nodes"): + * Nodes are deemed to be separate and independent programs and to not be + * covered works. Notwithstanding anything to the contrary in the + * License, the License does not apply to Nodes, you are not required to + * license Nodes under the License, and you are granted a license to + * prepare and propagate Nodes, in each case even if such Nodes are + * propagated with or for interoperation with KNIME. The owner of a Node + * may freely choose the license terms applicable to such Node, including + * when such Node is propagated with or for interoperation with KNIME. + * --------------------------------------------------------------------- + */ +package org.knime.python3.nodes.ports; + +import static org.junit.Assert.assertSame; + +import org.junit.Test; +import org.knime.core.node.port.inactive.InactiveBranchPortObject; +import org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec; +import org.knime.python3.nodes.ports.PythonPortObjects.PythonInactivePortObject; +import org.knime.python3.nodes.ports.PythonPortObjects.PythonInactivePortObjectSpec; +import org.knime.python3.nodes.ports.converters.PortObjectConversionContext; + +/** + * Tests conversion of inactive branch port objects and specs between KNIME and Python. + */ +public class InactivePortConversionTest { + + @Test + public void testConvertInactiveSpecToPython() { + var converted = PythonPortTypeRegistry.convertPortObjectSpecToPython(InactiveBranchPortObjectSpec.INSTANCE); + + assertSame(PythonInactivePortObjectSpec.INSTANCE, converted); + } + + @Test + public void testConvertInactiveSpecFromPython() { + var converted = PythonPortTypeRegistry.convertPortObjectSpecFromPython(PythonInactivePortObjectSpec.INSTANCE); + + assertSame(InactiveBranchPortObjectSpec.INSTANCE, converted); + } + + @Test + public void testConvertInactiveObjectToPython() { + var converted = PythonPortTypeRegistry.convertPortObjectToPython(InactiveBranchPortObject.INSTANCE, + new PortObjectConversionContext(null, null, null)); + + assertSame(PythonInactivePortObject.INSTANCE, converted); + } + + @Test + public void testConvertInactiveObjectFromPython() { + var converted = PythonPortTypeRegistry.convertPortObjectFromPython(PythonInactivePortObject.INSTANCE, + new PortObjectConversionContext(null, null, null)); + + assertSame(InactiveBranchPortObject.INSTANCE, converted); + } +} diff --git a/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_node_backend.py b/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_node_backend.py index ce4178fe9..af5906b7c 100644 --- a/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_node_backend.py +++ b/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_node_backend.py @@ -1,4 +1,5 @@ import unittest +from contextlib import contextmanager import _node_backend_launcher as knb from _ports import JavaPortTypeRegistry @@ -437,6 +438,189 @@ def test_connection_port_object_to_python(self): self.assertEqual("badabummm", obj.spec.data) self.assertEqual("this data is not serialized", obj._transient_data) + def test_inactive_spec_from_python(self): + port = knext.Port(knext.PortType.TABLE, "Test port", "Test port") + pypos = self.registry.spec_from_python(knext.InactivePort, port, "NodeId", 0) + self.assertEqual( + "org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec", + pypos.getJavaClassName(), + ) + + def test_inactive_spec_to_python(self): + port = knext.Port(knext.PortType.TABLE, "Test port", "Test port") + java_spec = knb._PythonPortObjectSpec( + "org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec", {} + ) + self.assertIs( + knext.InactivePort, + self.registry.spec_to_python(java_spec, port, callback_mock), + ) + + def test_inactive_object_from_python(self): + port = knext.Port(knext.PortType.TABLE, "Test port", "Test port") + out = self.registry.port_object_from_python( + knext.InactivePort, None, port, "nodeID", 0 + ) + self.assertEqual( + "org.knime.core.node.port.inactive.InactiveBranchPortObject", + out.getJavaClassName(), + ) + + def test_inactive_object_to_python(self): + port = knext.Port(knext.PortType.TABLE, "Test port", "Test port") + java_obj = self.MockFromJavaObject( + spec=None, + file_path="unused", + class_name="org.knime.core.node.port.inactive.InactiveBranchPortObject", + ) + self.assertIs( + knext.InactivePort, + self.registry.port_object_to_python(java_obj, port, callback_mock), + ) + + +class InactivePortProxyTest(unittest.TestCase): + @kn.node("Inactive output node", kn.NodeType.OTHER, "", "") + @kn.output_binary("Primary output", "Primary binary output.", id="primary") + @kn.output_binary_group( + "Secondary outputs", "Secondary binary outputs.", id="secondary" + ) + class NodeWithInactiveOutputs: + def configure(self, ctx): + return ( + knext.BinaryPortObjectSpec("primary"), + [kn.InactivePort, knext.BinaryPortObjectSpec("secondary")], + ) + + def execute(self, ctx): + return ( + b"primary-output", + [kn.InactivePort, b"secondary-output"], + ) + + class MockJavaConfigContext: + def get_input_port_map(self): + return {} + + def get_node_id(self): + return "0:1" + + class MockJavaExecContext(MockJavaConfigContext): + def is_canceled(self): + return False + + class _MockFileStore: + def __init__(self, file_path: str) -> None: + self._file_path = file_path + + def get_key(self): + return self._file_path + + def get_file_path(self): + return self._file_path + + class MockJavaCallback: + def __init__(self) -> None: + self._created_files = [] + + def get_flow_variables(self): + return {} + + def set_flow_variables(self, flow_variables): + pass + + def log(self, msg, sev): + pass + + def set_failure(self, message, details, invalid_settings): + raise AssertionError(message) + + def create_filestore_file(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + self._created_files.append(tmp.name) + return InactivePortProxyTest._MockFileStore(tmp.name) + + def cleanup(self): + for file_path in self._created_files: + if os.path.exists(file_path): + os.remove(file_path) + + @contextmanager + def _monkey_patch_java_helpers(self): + original_to_java_list = knb._to_java_list + original_create_linked_hashmap = knb._create_linked_hashmap + knb._to_java_list = lambda list_: list_ + knb._create_linked_hashmap = lambda: {} + try: + yield + finally: + knb._to_java_list = original_to_java_list + knb._create_linked_hashmap = original_create_linked_hashmap + + def _create_proxy(self): + java_port_type_registry: JavaPortTypeRegistry = unittest.mock.create_autospec( + JavaPortTypeRegistry + ) + java_port_type_registry.can_decode_port_object.return_value = False + java_port_type_registry.can_encode_port_object.return_value = False + java_port_type_registry.can_decode_spec.return_value = False + java_port_type_registry.can_encode_spec.return_value = False + + registry = knb._PortTypeRegistry("test.extension", java_port_type_registry) + proxy = knb._PythonNodeProxy( + node=type(self).NodeWithInactiveOutputs(), + port_type_registry=registry, + knime_parser=None, + extension_version="0.0.1", + ) + callback = self.MockJavaCallback() + proxy.initializeJavaCallback(callback) + return proxy, callback + + def test_configure_with_inactive_outputs_keeps_output_arity(self): + proxy, callback = self._create_proxy() + try: + with self._monkey_patch_java_helpers(): + outputs = proxy.configure([], self.MockJavaConfigContext()) + finally: + callback.cleanup() + + self.assertEqual(3, len(outputs)) + self.assertEqual( + "org.knime.python3.nodes.ports.PythonBinaryBlobPortObjectSpec", + outputs[0].getJavaClassName(), + ) + self.assertEqual( + "org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec", + outputs[1].getJavaClassName(), + ) + self.assertEqual( + "org.knime.python3.nodes.ports.PythonBinaryBlobPortObjectSpec", + outputs[2].getJavaClassName(), + ) + + def test_execute_with_inactive_outputs_keeps_output_arity(self): + proxy, callback = self._create_proxy() + try: + with self._monkey_patch_java_helpers(): + outputs = proxy.execute([], self.MockJavaExecContext()) + finally: + callback.cleanup() + + self.assertEqual(3, len(outputs)) + self.assertEqual( + "org.knime.python3.nodes.ports.PythonBinaryBlobFileStorePortObject", + outputs[0].getJavaClassName(), + ) + self.assertEqual( + "org.knime.core.node.port.inactive.InactiveBranchPortObject", + outputs[1].getJavaClassName(), + ) + self.assertEqual( + "org.knime.python3.nodes.ports.PythonBinaryBlobFileStorePortObject", + outputs[2].getJavaClassName(), + ) + class DescriptionParsingTest(unittest.TestCase): def setUp(self): diff --git a/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortObjects.java b/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortObjects.java index 23381ff0b..5d42ba3b5 100644 --- a/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortObjects.java +++ b/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortObjects.java @@ -68,6 +68,8 @@ import org.knime.core.node.port.PortObject; import org.knime.core.node.port.PortObjectSpec; import org.knime.core.node.port.PortType; +import org.knime.core.node.port.inactive.InactiveBranchPortObject; +import org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec; import org.knime.core.node.port.image.ImagePortObject; import org.knime.core.node.port.image.ImagePortObjectSpec; import org.knime.credentials.base.Credential; @@ -189,6 +191,28 @@ public interface PortObjectProvider { PortObject getPortObject(); } + /** + * {@link PythonPortObject} specialization representing an inactive output. + */ + public static final class PythonInactivePortObject implements PythonPortObject { + + public static final PythonInactivePortObject INSTANCE = new PythonInactivePortObject(); + + private PythonInactivePortObject() { + // singleton + } + + @Override + public String getJavaClassName() { + return InactiveBranchPortObject.class.getName(); + } + + @Override + public String toString() { + return "PythonInactivePortObject"; + } + } + /** * {@link PythonPortObject} implementation for {@link BufferedDataTable}s used and populated on the Java side. */ @@ -507,6 +531,33 @@ public interface PortObjectSpecProvider { PortObjectSpec getPortObjectSpec(); } + /** + * {@link PythonPortObjectSpec} specialization representing an inactive output spec. + */ + public static final class PythonInactivePortObjectSpec implements PythonPortObjectSpec { + + public static final PythonInactivePortObjectSpec INSTANCE = new PythonInactivePortObjectSpec(); + + private PythonInactivePortObjectSpec() { + // singleton + } + + @Override + public String getJavaClassName() { + return InactiveBranchPortObjectSpec.class.getName(); + } + + @Override + public String toJsonString() { + return "{}"; + } + + @Override + public String toString() { + return "PythonInactivePortObjectSpec"; + } + } + /** * {@link PythonPortObjectSpec} specialization wrapping a {@link DataTableSpec} */ diff --git a/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortTypeRegistry.java b/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortTypeRegistry.java index 511fc8315..5d4709e1b 100644 --- a/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortTypeRegistry.java +++ b/org.knime.python3.nodes/src/main/java/org/knime/python3/nodes/ports/PythonPortTypeRegistry.java @@ -60,6 +60,8 @@ import org.knime.core.node.port.PortObject; import org.knime.core.node.port.PortObjectSpec; import org.knime.core.node.port.PortType; +import org.knime.core.node.port.inactive.InactiveBranchPortObject; +import org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec; import org.knime.core.node.port.image.ImagePortObject; import org.knime.core.node.port.image.ImagePortObjectSpec; import org.knime.core.node.workflow.capture.WorkflowPortObject; @@ -67,6 +69,8 @@ import org.knime.credentials.base.CredentialPortObject; import org.knime.python3.nodes.ports.PythonPortObjects.PythonPortObject; import org.knime.python3.nodes.ports.PythonPortObjects.PythonPortObjectSpec; +import org.knime.python3.nodes.ports.PythonPortObjects.PythonInactivePortObject; +import org.knime.python3.nodes.ports.PythonPortObjects.PythonInactivePortObjectSpec; import org.knime.python3.nodes.ports.converters.PortObjectConversionContext; import org.knime.python3.nodes.ports.converters.PortObjectConverterInterfaces.KnimeToPythonPortObjectConverter; import org.knime.python3.nodes.ports.converters.PortObjectConverterInterfaces.PythonPortObjectConverter; @@ -197,6 +201,10 @@ public static PythonPortObjectSpec convertPortObjectSpecToPython(final PortObjec return null; } + if (spec instanceof InactiveBranchPortObjectSpec) { + return PythonInactivePortObjectSpec.INSTANCE; + } + var instance = InstanceHolder.INSTANCE; try { @@ -238,8 +246,12 @@ public static PortObjectSpec convertPortObjectSpecFromPython(final PythonPortObj return null; } - var instance = InstanceHolder.INSTANCE; String specClassName = pythonSpec.getJavaClassName(); + if (InactiveBranchPortObjectSpec.class.getName().equals(specClassName)) { + return InactiveBranchPortObjectSpec.INSTANCE; + } + + var instance = InstanceHolder.INSTANCE; if (pythonSpec instanceof PythonExtensionPortObjectSpec extensionSpec) { try { return instance.m_extensionConverters.convertSpecFromPython(extensionSpec, @@ -284,6 +296,10 @@ public static PythonPortObject convertPortObjectToPython(final PortObject portOb throw new IllegalStateException("Cannot convert `null` portObject from KNIME to Python"); } + if (portObject instanceof InactiveBranchPortObject) { + return PythonInactivePortObject.INSTANCE; + } + var instance = InstanceHolder.INSTANCE; try { @@ -341,9 +357,12 @@ public static PortObject convertPortObjectFromPython(final PythonPortObject pure throw new IllegalStateException("Cannot convert 'null' portObject from Python to KNIME"); } - var instance = InstanceHolder.INSTANCE; - String javaClassName = purePythonPortObject.getJavaClassName(); + if (InactiveBranchPortObject.class.getName().equals(javaClassName)) { + return InactiveBranchPortObject.INSTANCE; + } + + var instance = InstanceHolder.INSTANCE; if (purePythonPortObject instanceof PythonExtensionPortObject extensionPortObject) { try { diff --git a/org.knime.python3.nodes/src/main/python/_node_backend_launcher.py b/org.knime.python3.nodes/src/main/python/_node_backend_launcher.py index 68e5a5c03..21ab23dce 100644 --- a/org.knime.python3.nodes/src/main/python/_node_backend_launcher.py +++ b/org.knime.python3.nodes/src/main/python/_node_backend_launcher.py @@ -239,6 +239,12 @@ class Java: _bdt_java_type = "org.knime.core.node.BufferedDataTable" +_inactive_branch_port_object_java_type = ( + "org.knime.core.node.port.inactive.InactiveBranchPortObject" +) +_inactive_branch_port_object_spec_java_type = ( + "org.knime.core.node.port.inactive.InactiveBranchPortObjectSpec" +) class _PythonWorkflowPortObject: @@ -619,6 +625,8 @@ def spec_to_python(self, spec: _PythonPortObjectSpec, port: kn.Port, java_callba if spec is None: return None class_name = spec.getJavaClassName() + if class_name == _inactive_branch_port_object_spec_java_type: + return kn.InactivePort if self._extension_port_type_registry.can_decode_spec(class_name): return self._extension_port_type_registry.decode_spec( spec, @@ -729,6 +737,11 @@ def _is_compatible_port_type( def spec_from_python( self, spec, port: kn.Port, node_id: str, port_idx: int ) -> _PythonPortObjectSpec: + if spec is kn.InactivePort: + return _PythonPortObjectSpec( + _inactive_branch_port_object_spec_java_type, {} + ) + if self._extension_port_type_registry.can_encode_spec(spec): return self._extension_port_type_registry.encode_spec(spec) @@ -781,6 +794,8 @@ def port_object_to_python( self, port_object: _PythonPortObject, port: kn.Port, java_callback ): class_name = port_object.getJavaClassName() + if class_name == _inactive_branch_port_object_java_type: + return kn.InactivePort if self._extension_port_type_registry.can_decode_port_object(class_name): return self._extension_port_type_registry.decode_port_object(port_object) @@ -866,6 +881,9 @@ def port_object_from_python( _PythonImagePortObject, _PythonCredentialPortObject, ]: + if obj is kn.InactivePort: + return _PythonPortObject(_inactive_branch_port_object_java_type) + if self._extension_port_type_registry.can_encode_port_object(obj): return self._extension_port_type_registry.encode_port_object(obj) diff --git a/org.knime.python3.nodes/src/main/python/knime/extension/__init__.py b/org.knime.python3.nodes/src/main/python/knime/extension/__init__.py index c973e551a..02b0ec719 100644 --- a/org.knime.python3.nodes/src/main/python/knime/extension/__init__.py +++ b/org.knime.python3.nodes/src/main/python/knime/extension/__init__.py @@ -116,6 +116,7 @@ output_image_group = _kn.output_image_group output_port_group = _kn.output_port_group WorkflowExecutionError = _kn.WorkflowExecutionError +InactivePort = _kn.InactivePort ## knime.api.table Table = _kt.Table BatchOutputTable = _kt.BatchOutputTable diff --git a/org.knime.python3.nodes/src/main/python/knime/extension/nodes.py b/org.knime.python3.nodes/src/main/python/knime/extension/nodes.py index 623371429..b2e1f1764 100644 --- a/org.knime.python3.nodes/src/main/python/knime/extension/nodes.py +++ b/org.knime.python3.nodes/src/main/python/knime/extension/nodes.py @@ -951,7 +951,8 @@ def configure(self, config_context: ConfigurationContext, *inputs): Either a single spec, or a tuple or list of specs. The number of specs must match the number of defined output ports, and they must be returned in this order. Alternatively, instead of a spec, a knext.Column can be returned (if the spec shall - only consist of one column). + only consist of one column). Return `knext.InactivePort` for an output slot to + mark that port inactive. Raises ------ @@ -980,7 +981,8 @@ def execute(self, exec_context: ExecutionContext, *inputs): The number of output objects must match the number of defined output ports, and they must be returned in this order. Tables must be provided as a `kn.Table` or `kn.BatchOutputTable`, while binary data - should be returned as plain Python `bytes` object. + should be returned as plain Python `bytes` object. Return `knext.InactivePort` + for an output slot to mark that port inactive. """ pass @@ -1325,6 +1327,21 @@ def __init__(self, message) -> None: super().__init__(message) +class _InactivePort: + """ + Singleton marker for inactive output ports. + + Return `knext.InactivePort` from `configure()` or `execute()` for an output slot to + mark the corresponding port inactive instead of producing an empty port object. + """ + + def __repr__(self) -> str: + return "InactivePort" + + +InactivePort = _InactivePort() + + def _unwrap_results(func: Callable) -> Callable: """ Configure and view can both return _ColumnarView or _TabularView respectively, which have a virtual