From 50d6697b24176ffab2d98a9604e5a8a257811cfa Mon Sep 17 00:00:00 2001 From: Adrian Nembach Date: Tue, 30 Dec 2025 22:19:43 +0100 Subject: [PATCH] AP-25288: Add visible_choices for EnumParameter Allows to dynamically filter which enum values are displayed. --- .../python/unittest/test_knime_parameter.py | 334 ++++++++++++++++++ .../main/python/knime/extension/parameter.py | 196 +++++++++- 2 files changed, 522 insertions(+), 8 deletions(-) diff --git a/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_parameter.py b/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_parameter.py index 691c5e6b9..fd625be78 100644 --- a/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_parameter.py +++ b/org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_parameter.py @@ -1601,6 +1601,340 @@ def test_invalid_assignment_static(self): self.assertEqual(obj.static_plain, "INVALID") +class TestModelOptions(kp.EnumParameterOptions): + """Test enum for visible_choices testing""" + LINEAR = ("Linear Regression", "Fits a linear model") + RANDOM_FOREST = ("Random Forest", "Ensemble tree model") + NEURAL_NET = ("Neural Network", "Deep learning model") + SVM = ("Support Vector Machine", "SVM model") + + +class ParameterizedWithVisibleChoices: + """Test class for EnumParameter with visible_choices callable""" + + @staticmethod + def _filter_to_two(ctx): + """Filter to only LINEAR and RANDOM_FOREST""" + return [TestModelOptions.LINEAR, TestModelOptions.RANDOM_FOREST] + + @staticmethod + def _filter_by_specs(ctx): + """Filter based on context specs""" + if ctx is None: + # No context - return subset (subsetting use-case) + return [TestModelOptions.LINEAR, TestModelOptions.SVM] + + specs = ctx.get_input_specs() + if not specs or len(specs) == 0: + return list(TestModelOptions) + + # Simulate filtering based on spec (e.g., spec has 'supported_models' attribute) + # For testing, we'll use spec count as a proxy + if len(specs) == 1: + return [TestModelOptions.LINEAR, TestModelOptions.RANDOM_FOREST] + else: + return [TestModelOptions.NEURAL_NET, TestModelOptions.SVM] + + @staticmethod + def _filter_invalid(): + """Returns invalid members for testing warnings""" + return ["INVALID_OPTION", TestModelOptions.LINEAR] + + @staticmethod + def _filter_empty(ctx): + """Returns empty list""" + return [] + + param_filtered = kp.EnumParameter( + label="Filtered Model", + description="Model with filtered choices", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + visible_choices=_filter_to_two.__func__, + ) + + param_context_dependent = kp.EnumParameter( + label="Context Dependent", + description="Choices depend on context", + default_value=TestModelOptions.LINEAR, # Using enum member as default + enum=TestModelOptions, + visible_choices=_filter_by_specs.__func__, + ) + + param_no_filter = kp.EnumParameter( + label="No Filter", + description="All options visible", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + ) + + +class TestEnumParameterVisibleChoices(unittest.TestCase): + """Test EnumParameter visible_choices functionality""" + + def test_filtered_schema_contains_subset(self): + """Test that filtered options appear in schema oneOf""" + obj = ParameterizedWithVisibleChoices() + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + s = schema["properties"]["model"]["properties"]["param_filtered"] + + self.assertIn("oneOf", s) + values = {entry["const"] for entry in s["oneOf"]} + # Should only contain LINEAR and RANDOM_FOREST + self.assertEqual(values, {"LINEAR", "RANDOM_FOREST"}) + self.assertNotIn("NEURAL_NET", values) + self.assertNotIn("SVM", values) + + def test_no_filter_shows_all_options(self): + """Test that parameter without visible_choices shows all options""" + obj = ParameterizedWithVisibleChoices() + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + s = schema["properties"]["model"]["properties"]["param_no_filter"] + + self.assertIn("oneOf", s) + values = {entry["const"] for entry in s["oneOf"]} + self.assertEqual(values, {"LINEAR", "RANDOM_FOREST", "NEURAL_NET", "SVM"}) + + def test_context_dependent_filtering(self): + """Test that filtering works based on context""" + obj = ParameterizedWithVisibleChoices() + + # With one spec + ctx_one = DummyDialogCreationContext(specs=[test_schema]) + schema = kp.extract_schema(obj, dialog_creation_context=ctx_one) + s = schema["properties"]["model"]["properties"]["param_context_dependent"] + values = {entry["const"] for entry in s["oneOf"]} + self.assertEqual(values, {"LINEAR", "RANDOM_FOREST"}) + + # With two specs + ctx_two = DummyDialogCreationContext(specs=[test_schema, test_schema]) + schema = kp.extract_schema(obj, dialog_creation_context=ctx_two) + s = schema["properties"]["model"]["properties"]["param_context_dependent"] + values = {entry["const"] for entry in s["oneOf"]} + self.assertEqual(values, {"NEURAL_NET", "SVM"}) + + def test_none_context_subsetting(self): + """Test that None context enables subsetting use-case""" + obj = ParameterizedWithVisibleChoices() + + # Extract schema without context + schema = kp.extract_schema(obj, dialog_creation_context=None) + s = schema["properties"]["model"]["properties"]["param_context_dependent"] + + self.assertIn("oneOf", s) + values = {entry["const"] for entry in s["oneOf"]} + # Should return subset defined for None case + self.assertEqual(values, {"LINEAR", "SVM"}) + + def test_description_respects_visible_choices(self): + """Test that description respects visible_choices based on None context""" + + # param_filtered uses _filter_to_two which doesn't check context + # So description should show only LINEAR and RANDOM_FOREST + desc_dict = ParameterizedWithVisibleChoices.param_filtered._extract_description( + "param_filtered", None + ) + description = desc_dict["description"] + + # Description should contain only filtered options + self.assertIn("Linear Regression", description) + self.assertIn("Random Forest", description) + # Should NOT contain filtered-out options + self.assertNotIn("Neural Network", description) + self.assertNotIn("Support Vector Machine", description) + + def test_description_with_context_dependent_filter(self): + """Test description with context-dependent filter (None case)""" + + # param_context_dependent returns subset for None context + desc_dict = ParameterizedWithVisibleChoices.param_context_dependent._extract_description( + "param_context_dependent", None + ) + description = desc_dict["description"] + + # Description should show subset returned for None + self.assertIn("Linear Regression", description) + self.assertIn("Support Vector Machine", description) + # Should NOT contain other options + self.assertNotIn("Random Forest", description) + self.assertNotIn("Neural Network", description) + + def test_description_without_filter_shows_all(self): + """Test that description without filter shows all options""" + + # param_no_filter has no visible_choices + desc_dict = ParameterizedWithVisibleChoices.param_no_filter._extract_description( + "param_no_filter", None + ) + description = desc_dict["description"] + + # Description should contain all enum options + self.assertIn("Linear Regression", description) + self.assertIn("Random Forest", description) + self.assertIn("Neural Network", description) + self.assertIn("Support Vector Machine", description) + + def test_validation_accepts_filtered_out_values(self): + """Test that validation accepts any enum member, even if filtered out""" + obj = ParameterizedWithVisibleChoices() + + # Extract schema with filtering active + kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + # NEURAL_NET is filtered out but should still be valid + obj.param_filtered = "NEURAL_NET" + self.assertEqual(obj.param_filtered, "NEURAL_NET") + + # Invalid value should still fail validation + with self.assertRaises(ValueError): + obj.param_filtered = "INVALID_OPTION" + + def test_default_as_enum_member(self): + """Test that default_value accepts enum member directly""" + obj = ParameterizedWithVisibleChoices() + + # param_context_dependent uses enum member as default + self.assertEqual(obj.param_context_dependent, "LINEAR") + + def test_empty_filter_result_shows_empty(self): + """Test that empty filter result shows no options with warning""" + + def empty_filter(ctx): + return [] + + param = kp.EnumParameter( + label="Empty Filter", + description="Should be empty", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + visible_choices=empty_filter, + ) + + class TestObj: + empty_param = param + + obj = TestObj() + + # Should log warning and return empty + with self.assertLogs("Python backend", level="WARNING") as log: + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + # Check warning was logged + self.assertTrue( + any("returned an empty list" in msg or "empty options" in msg for msg in log.output) + ) + + s = schema["properties"]["model"]["properties"]["empty_param"] + self.assertEqual(s["oneOf"], []) + + def test_invalid_members_filtered_with_warning(self): + """Test that invalid members are filtered out with warning""" + + def invalid_filter(ctx): + # Return mix of valid and invalid + class FakeMember: + name = "INVALID" + + return [TestModelOptions.LINEAR, FakeMember(), "not_a_member"] + + param = kp.EnumParameter( + label="Invalid Filter", + description="Has invalid members", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + visible_choices=invalid_filter, + ) + + class TestObj: + invalid_param = param + + obj = TestObj() + + # Should log warning about invalid members + with self.assertLogs("Python backend", level="WARNING") as log: + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + # Check warning was logged with valid options listed + warning_msg = " ".join(log.output) + self.assertIn("invalid members", warning_msg.lower()) + self.assertIn("Valid options", warning_msg) + + # Schema should only contain valid member + s = schema["properties"]["model"]["properties"]["invalid_param"] + values = {entry["const"] for entry in s["oneOf"]} + self.assertEqual(values, {"LINEAR"}) + + def test_default_not_in_visible_options_warns(self): + """Test that warning is logged when default is not in visible options""" + + def filter_without_default(ctx): + return [TestModelOptions.RANDOM_FOREST, TestModelOptions.SVM] + + param = kp.EnumParameter( + label="Default Not Visible", + description="Default filtered out", + default_value=TestModelOptions.LINEAR.name, # Not in visible choices + enum=TestModelOptions, + visible_choices=filter_without_default, + ) + + class TestObj: + param_with_hidden_default = param + + obj = TestObj() + + # Should log warning about default not visible + with self.assertLogs("Python backend", level="WARNING") as log: + kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + warning_msg = " ".join(log.output) + self.assertIn("Default value", warning_msg) + self.assertIn("not in the currently visible options", warning_msg) + + def test_caching_works(self): + """Test that visible_choices callable is cached per context""" + call_count = [0] + + def counting_filter(ctx): + call_count[0] += 1 + return [TestModelOptions.LINEAR, TestModelOptions.SVM] + + param = kp.EnumParameter( + label="Cached", + description="Should cache", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + visible_choices=counting_filter, + ) + + class TestObj: + cached_param = param + + obj = TestObj() + ctx = DummyDialogCreationContext() + + # Extract schema multiple times with same context + kp.extract_schema(obj, dialog_creation_context=ctx) + kp.extract_schema(obj, dialog_creation_context=ctx) + kp.extract_schema(obj, dialog_creation_context=ctx) + + # Should be called once: the same context is used for both description and schema + # generation, and the result is cached after the first call + self.assertEqual(call_count[0], 1) + + class DummyDialogCreationContext: def __init__(self, specs: List = None) -> None: class DummyJavaContext: diff --git a/org.knime.python3.nodes/src/main/python/knime/extension/parameter.py b/org.knime.python3.nodes/src/main/python/knime/extension/parameter.py index 603f4c4c5..97c9ba20e 100644 --- a/org.knime.python3.nodes/src/main/python/knime/extension/parameter.py +++ b/org.knime.python3.nodes/src/main/python/knime/extension/parameter.py @@ -1514,7 +1514,16 @@ def __init__(self, label, description): self.description = description @classmethod - def _generate_options_description(cls, docstring: str): + def _generate_options_description(cls, docstring: str, visible_members=None): + """Generate options description. + + Parameters + ---------- + docstring : str + The parameter docstring + visible_members : list, optional + List of member names to include. If None, all members are included. + """ # ensure that the options description is indented correctly if docstring: lines = docstring.expandtabs().splitlines() @@ -1524,7 +1533,8 @@ def _generate_options_description(cls, docstring: str): indent = "" options_description = f"\n\n{indent}**Available options:**\n\n" - for member in cls._member_names_: + members_to_show = visible_members if visible_members is not None else cls._member_names_ + for member in members_to_show: options_description += ( f"{indent}- {cls[member].label}: {cls[member].description}\n" ) @@ -1580,6 +1590,59 @@ class attributes of the form `OPTION_NAME = (OPTION_LABEL, OPTION_DESCRIPTION)`. ... default_value=CoffeeOptions.CLASSIC.name, ... enum=CoffeeOptions, ... ) + + Dynamic Filtering + ----------------- + The optional ``visible_choices`` callable allows filtering which enum members are displayed in the + dialog and node description based on the runtime context. This is useful for hiding options that + are not applicable given the current input data. + + The callable receives a ``DialogCreationContext`` (or ``None`` if the node is not connected or + during node description generation at startup) and must return a list of enum members to display. + If the callable returns an empty list or only invalid members, a warning is logged. + + **Important:** Validation accepts any enum member regardless of filtering. This ensures that saved + workflows remain valid even when the context changes and different options are filtered. + + The ``DialogCreationContext`` parameter can be ``None`` in two scenarios: + + - During node description generation at KNIME startup + - When the node has no input connections + + Your callable should handle ``None`` gracefully. By returning different options based on whether + the context is ``None``, you can control what appears in the node description versus the dialog. + For example, returning a subset when ``ctx is None`` effectively provides static subsetting in + the description while still allowing dynamic filtering in the dialog based on actual input data. + + >>> class ModelOptions(EnumParameterOptions): + ... LINEAR = ("Linear Regression", "Fits a linear model") + ... RANDOM_FOREST = ("Random Forest", "Ensemble tree model") + ... NEURAL_NET = ("Neural Network", "Deep learning model") + ... + ... def filter_by_model_support(context): + ... # Handle None context (no connection or description generation) + ... if context is None: + ... # Return subset for description - these are the "primary" options + ... return [ModelOptions.LINEAR, ModelOptions.RANDOM_FOREST] + ... + ... # Get input specifications for dialog filtering + ... specs = context.get_input_specs() + ... if not specs: + ... return list(ModelOptions) + ... + ... # Filter based on model capabilities from input + ... model_spec = specs[0] # Assuming model is first input + ... supported = model_spec.get_supported_options() # Hypothetical method + ... + ... return [opt for opt in ModelOptions if opt.name in supported] + ... + ... model_param = knext.EnumParameter( + ... label="Model Type", + ... description="Select the model to use.", + ... default_value=ModelOptions.LINEAR, # Can use enum member directly + ... enum=ModelOptions, + ... visible_choices=filter_by_model_support, + ... ) """ class Style(Enum): @@ -1597,13 +1660,26 @@ def __init__( self, label: Optional[str] = None, description: Optional[str] = None, - default_value: Union[str, DefaultValueProvider[str]] = None, + default_value: Union[ + str, EnumParameterOptions, DefaultValueProvider[Union[str, EnumParameterOptions]] + ] = None, enum: Optional[EnumParameterOptions] = None, validator: Optional[Callable[[str], None]] = None, since_version: Optional[Union[Version, str]] = None, is_advanced: bool = False, style: Optional[Style] = None, + visible_choices: Optional[ + Callable[[Optional[Any]], List[EnumParameterOptions]] + ] = None, ): + """ + Parameters + ---------- + visible_choices : Optional[Callable[[Optional[DialogCreationContext]], List[EnumParameterOptions]]] + Optional callable that filters which enum members are displayed in the dialog. + The callable receives a DialogCreationContext (or None) and must return a list + of enum members to show. If None or not provided, all enum members are shown. + """ if validator is None: validator = self._default_validator else: @@ -1616,8 +1692,18 @@ def __init__( self._enum = enum if default_value is None: default_value = self._enum.get_all_options()[0].name + elif hasattr(default_value, "name"): + # Support enum member as default_value + default_value = default_value.name self._style = style + + # Store visible_choices callable wrapped with cache + # Cache size of 2 to handle both None (description) and actual context (schema) + if visible_choices is not None: + self._visible_choices = lru_cache(maxsize=2)(visible_choices) + else: + self._visible_choices = None super().__init__( label, @@ -1628,24 +1714,118 @@ def __init__( is_advanced, ) + def _get_visible_options(self, dialog_creation_context): + """Get the list of enum members to display in the dialog. + + Returns the full enum if no visible_choices callable is set, otherwise + calls the callable and validates the returned members. + """ + if self._visible_choices is None: + return list(self._enum) + + # Call the cached callable with context (can be None) + try: + filtered_members = self._visible_choices(dialog_creation_context) + except Exception as e: + LOGGER.warning( + f"Error calling visible_choices for parameter '{self._label}': {e}. " + f"Showing all options." + ) + return list(self._enum) + + if not isinstance(filtered_members, (list, tuple)): + LOGGER.warning( + f"visible_choices for parameter '{self._label}' must return a list or tuple, " + f"got {type(filtered_members).__name__}. Showing all options." + ) + return list(self._enum) + + # Validate that all returned members exist in the enum + valid_names = set(self._enum._member_names_) + validated_members = [] + invalid_members = [] + + for member in filtered_members: + if hasattr(member, "name") and member.name in valid_names: + validated_members.append(member) + else: + invalid_members.append(member) + + # Warn about invalid members + if invalid_members: + valid_options = ", ".join(self._enum._member_names_) + LOGGER.warning( + f"visible_choices for parameter '{self._label}' returned invalid members: " + f"{invalid_members}. Valid options are: {valid_options}" + ) + + # Handle empty result - developer responsibility to implement correctly + if not validated_members: + if not filtered_members: + # Empty list returned - log warning and show empty + LOGGER.warning( + f"visible_choices for parameter '{self._label}' returned an empty list. " + f"Showing empty options." + ) + return [] + else: + # All members were invalid - already warned above + LOGGER.warning( + f"visible_choices for parameter '{self._label}' returned no valid members. " + f"Showing empty options." + ) + return [] + + return validated_members + def _get_options(self, dialog_creation_context) -> dict: if self._style: return {"format": self._style.value} return super()._get_options(dialog_creation_context) - def _generate_description(self): - return self._enum._generate_options_description(self.__doc__) + def _generate_description(self, visible_options=None): + """Generate description with optional visible options filtering. + + Parameters + ---------- + visible_options : list of EnumParameterOptions, optional + List of enum members to include in description. If None, all members + from self._enum are included. If provided, only these members appear + in the description. + """ + if visible_options is None: + # No filtering - generate description for all options + return self._enum._generate_options_description(self.__doc__) + + # Generate description for filtered options + visible_member_names = [opt.name for opt in visible_options] + return self._enum._generate_options_description(self.__doc__, visible_member_names) def _extract_schema(self, extension_version=None, dialog_creation_context=None): schema = super()._extract_schema( dialog_creation_context=dialog_creation_context ) - schema["description"] = self._generate_description() - schema["oneOf"] = [{"const": e.name, "title": e.label} for e in self._enum] + + # Use filtered options for dialog UI + visible_options = self._get_visible_options(dialog_creation_context) + schema["description"] = self._generate_description(visible_options) + schema["oneOf"] = [{"const": e.name, "title": e.label} for e in visible_options] + + # Warn if default value is not in visible options + if visible_options and self._default_value: + visible_names = {e.name for e in visible_options} + if self._default_value not in visible_names: + LOGGER.warning( + f"Default value '{self._default_value}' for parameter '{self._label}' " + f"is not in the currently visible options: {', '.join(visible_names)}" + ) + return schema def _extract_description(self, name, parent_scope: _Scope): - return {"name": self._label, "description": self._generate_description()} + # Get visible options with None context for node description + visible_options = self._get_visible_options(None) if self._visible_choices else None + return {"name": self._label, "description": self._generate_description(visible_options)} def validator(self, func): # we retain the default validator to ensure that value is always one of the available options