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..8c4d47063 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,370 @@ def test_invalid_assignment_static(self): self.assertEqual(obj.static_plain, "INVALID") +class TestModelOptions(kp.EnumParameterOptions): + """Test enum for hidden_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 ParameterizedWithHiddenChoices: + """Test class for EnumParameter with hidden_choices callable""" + + @staticmethod + def _hide_two(ctx): + """Hide NEURAL_NET and SVM""" + return [TestModelOptions.NEURAL_NET, TestModelOptions.SVM] + + @staticmethod + def _hide_by_specs(ctx): + """Hide based on context specs""" + if ctx is None: + # No context - hide advanced option (hiding use-case) + return [TestModelOptions.NEURAL_NET, TestModelOptions.RANDOM_FOREST] + + specs = ctx.get_input_specs() + if not specs or len(specs) == 0: + return [] # Hide nothing + + # 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.NEURAL_NET, TestModelOptions.SVM] + else: + return [TestModelOptions.LINEAR, TestModelOptions.RANDOM_FOREST] + + @staticmethod + def _hide_invalid(): + """Returns invalid members for testing warnings""" + return ["INVALID_OPTION", TestModelOptions.LINEAR] + + @staticmethod + def _hide_empty(ctx): + """Returns empty list - hide nothing""" + return [] + + param_filtered = kp.EnumParameter( + label="Filtered Model", + description="Model with filtered choices", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + hidden_choices=_hide_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, + hidden_choices=_hide_by_specs.__func__, + ) + + param_no_filter = kp.EnumParameter( + label="No Filter", + description="All options visible", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + ) + + +class TestEnumParameterHiddenChoices(unittest.TestCase): + """Test EnumParameter hidden_choices functionality""" + + def test_filtered_schema_contains_subset(self): + """Test that filtered options appear in schema oneOf""" + obj = ParameterizedWithHiddenChoices() + 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 (NEURAL_NET and SVM are hidden) + 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 hidden_choices shows all options""" + obj = ParameterizedWithHiddenChoices() + 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 = ParameterizedWithHiddenChoices() + + # With one spec - hide NEURAL_NET and SVM + 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 - hide LINEAR and RANDOM_FOREST + 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_hiding(self): + """Test that None context enables hiding use-case""" + obj = ParameterizedWithHiddenChoices() + + # 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 hide NEURAL_NET and RANDOM_FOREST (defined for None case) + self.assertEqual(values, {"LINEAR", "SVM"}) + + def test_description_respects_hidden_choices(self): + """Test that description respects hidden_choices based on None context""" + + # param_filtered uses _hide_two which doesn't check context + # So description should show only LINEAR and RANDOM_FOREST + desc_dict = ParameterizedWithHiddenChoices.param_filtered._extract_description( + "param_filtered", None + ) + description = desc_dict["description"] + + # Description should contain only non-hidden options + self.assertIn("Linear Regression", description) + self.assertIn("Random Forest", description) + # Should NOT contain hidden 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 hides NEURAL_NET and RANDOM_FOREST for None context + desc_dict = ( + ParameterizedWithHiddenChoices.param_context_dependent._extract_description( + "param_context_dependent", None + ) + ) + description = desc_dict["description"] + + # Description should show non-hidden options + self.assertIn("Linear Regression", description) + self.assertIn("Support Vector Machine", description) + # Should NOT contain hidden 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 hidden_choices + desc_dict = ParameterizedWithHiddenChoices.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 = ParameterizedWithHiddenChoices() + + # Extract schema with filtering active + kp.extract_schema(obj, dialog_creation_context=DummyDialogCreationContext()) + + # NEURAL_NET is hidden 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 = ParameterizedWithHiddenChoices() + + # param_context_dependent uses enum member as default + self.assertEqual(obj.param_context_dependent, "LINEAR") + + def test_empty_filter_result_shows_all(self): + """Test that empty filter result shows all options (hide nothing)""" + + def empty_filter(ctx): + return [] + + param = kp.EnumParameter( + label="Empty Filter", + description="Should show all", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + hidden_choices=empty_filter, + ) + + class TestObj: + empty_param = param + + obj = TestObj() + + # Should show all options (no warning expected for empty hide list) + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + s = schema["properties"]["model"]["properties"]["empty_param"] + values = {entry["const"] for entry in s["oneOf"]} + # Should show all options + self.assertEqual(values, {"LINEAR", "RANDOM_FOREST", "NEURAL_NET", "SVM"}) + + 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, + hidden_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 hide only LINEAR (which is valid, but the only valid member in the hidden list) + # The invalid members in the list are filtered out + s = schema["properties"]["model"]["properties"]["invalid_param"] + values = {entry["const"] for entry in s["oneOf"]} + # LINEAR is hidden, others remain visible + self.assertEqual(values, {"RANDOM_FOREST", "NEURAL_NET", "SVM"}) + + def test_default_not_in_visible_options_warns(self): + """Test that warning is logged when default is not in visible options""" + + def hide_including_default(ctx): + # Hide LINEAR (which is the default) and NEURAL_NET + return [TestModelOptions.LINEAR, TestModelOptions.NEURAL_NET] + + param = kp.EnumParameter( + label="Default Not Visible", + description="Default hidden", + default_value=TestModelOptions.LINEAR.name, # Will be hidden + enum=TestModelOptions, + hidden_choices=hide_including_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 hidden_choices callable is cached per context""" + call_count = [0] + + def counting_filter(ctx): + call_count[0] += 1 + return [TestModelOptions.NEURAL_NET, TestModelOptions.SVM] + + param = kp.EnumParameter( + label="Cached", + description="Should cache", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + hidden_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) + + # For a given dialog_creation_context, the callable should be invoked only once; + # subsequent uses with the same context reuse the cached result. + self.assertEqual(call_count[0], 1) + + def test_hiding_all_options_shows_empty(self): + """Test that hiding all options results in empty list with warning""" + + def hide_all(ctx): + return list(TestModelOptions) + + param = kp.EnumParameter( + label="Hide All", + description="All hidden", + default_value=TestModelOptions.LINEAR.name, + enum=TestModelOptions, + hidden_choices=hide_all, + ) + + class TestObj: + hide_all_param = param + + obj = TestObj() + + # Should log warning about hiding all options + with self.assertLogs("Python backend", level="WARNING") as log: + schema = kp.extract_schema( + obj, dialog_creation_context=DummyDialogCreationContext() + ) + + # Check warning was logged + warning_msg = " ".join(log.output) + self.assertIn("would hide all options", warning_msg.lower()) + + s = schema["properties"]["model"]["properties"]["hide_all_param"] + self.assertEqual(s["oneOf"], []) + + 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..31e641b96 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,21 @@ 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. + + Returns + ------- + str + The formatted options description including the available options. + """ # ensure that the options description is indented correctly if docstring: lines = docstring.expandtabs().splitlines() @@ -1524,7 +1538,10 @@ 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 +1597,60 @@ class attributes of the form `OPTION_NAME = (OPTION_LABEL, OPTION_DESCRIPTION)`. ... default_value=CoffeeOptions.CLASSIC.name, ... enum=CoffeeOptions, ... ) + + Dynamic Filtering + ----------------- + The optional ``hidden_choices`` callable allows filtering which enum members are hidden 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 hide. + If ``None`` or an empty list is returned, all options are shown. If the callable returns only + invalid members or all 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 items to hide when ``ctx is None`` effectively provides static filtering 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 hide_by_model_support(context): + ... # Handle None context (no connection or description generation) + ... if context is None: + ... # Hide advanced option in description + ... return [ModelOptions.NEURAL_NET] + ... + ... # Get input specifications for dialog filtering + ... specs = context.get_input_specs() + ... if not specs: + ... return [] # Hide nothing, show all + ... + ... # 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 not 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, + ... hidden_choices=hide_by_model_support, + ... ) """ class Style(Enum): @@ -1597,13 +1668,28 @@ 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, + hidden_choices: Optional[ + Callable[[Optional[Any]], List[EnumParameterOptions]] + ] = None, ): + """ + Parameters + ---------- + hidden_choices : Optional[Callable[[Optional[DialogCreationContext]], List[EnumParameterOptions]]] + Optional callable that filters which enum members are hidden in the dialog. + The callable receives a DialogCreationContext (or None) and must return a list + of enum members to hide. If None, empty list, or not provided, all enum members are shown. + """ if validator is None: validator = self._default_validator else: @@ -1616,9 +1702,19 @@ 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 hidden_choices callable wrapped with cache + # Cache size of 2 to handle both None (description) and actual context (schema) + if hidden_choices is not None: + self._hidden_choices = lru_cache(maxsize=2)(hidden_choices) + else: + self._hidden_choices = None + super().__init__( label, description, @@ -1628,24 +1724,139 @@ 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 hidden_choices callable is set, otherwise + calls the callable and validates the returned members to hide, then returns + the remaining members. + """ + if self._hidden_choices is None: + return list(self._enum) + + # Call the cached callable with context (can be None) + try: + hidden_members = self._hidden_choices(dialog_creation_context) + except Exception as e: + LOGGER.warning( + f"Error calling hidden_choices for parameter '{self._label}': {e}. " + f"Showing all options." + ) + return list(self._enum) + + if not isinstance(hidden_members, (list, tuple)): + LOGGER.warning( + f"hidden_choices for parameter '{self._label}' must return a list or tuple, " + f"got {type(hidden_members).__name__}. Showing all options." + ) + return list(self._enum) + + # Handle empty list - show all options (nothing hidden) + if not hidden_members: + 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 hidden_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"hidden_choices for parameter '{self._label}' returned invalid members: " + f"{invalid_members}. Valid options are: {valid_options}" + ) + + # If all members would be hidden, show none + all_members = list(self._enum) + if len(validated_members) >= len(all_members): + LOGGER.warning( + f"hidden_choices for parameter '{self._label}' would hide all options." + ) + return [] + + # Return members that are not in the hidden list + hidden_names = {member.name for member in validated_members} + visible_members = [ + member for member in all_members if member.name not in hidden_names + ] + + return visible_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. + + Returns + ------- + str + A formatted description string containing the available options, + optionally restricted to the provided ``visible_options``. + + Returns + ------- + str + A formatted description string containing the available options, + optionally restricted to the provided ``visible_options``. + """ + 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._hidden_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