Skip to content

Commit 50d6697

Browse files
committed
AP-25288: Add visible_choices for EnumParameter
Allows to dynamically filter which enum values are displayed.
1 parent 6eb429e commit 50d6697

File tree

2 files changed

+522
-8
lines changed

2 files changed

+522
-8
lines changed

org.knime.python3.nodes.tests/src/test/python/unittest/test_knime_parameter.py

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,6 +1601,340 @@ def test_invalid_assignment_static(self):
16011601
self.assertEqual(obj.static_plain, "INVALID")
16021602

16031603

1604+
class TestModelOptions(kp.EnumParameterOptions):
1605+
"""Test enum for visible_choices testing"""
1606+
LINEAR = ("Linear Regression", "Fits a linear model")
1607+
RANDOM_FOREST = ("Random Forest", "Ensemble tree model")
1608+
NEURAL_NET = ("Neural Network", "Deep learning model")
1609+
SVM = ("Support Vector Machine", "SVM model")
1610+
1611+
1612+
class ParameterizedWithVisibleChoices:
1613+
"""Test class for EnumParameter with visible_choices callable"""
1614+
1615+
@staticmethod
1616+
def _filter_to_two(ctx):
1617+
"""Filter to only LINEAR and RANDOM_FOREST"""
1618+
return [TestModelOptions.LINEAR, TestModelOptions.RANDOM_FOREST]
1619+
1620+
@staticmethod
1621+
def _filter_by_specs(ctx):
1622+
"""Filter based on context specs"""
1623+
if ctx is None:
1624+
# No context - return subset (subsetting use-case)
1625+
return [TestModelOptions.LINEAR, TestModelOptions.SVM]
1626+
1627+
specs = ctx.get_input_specs()
1628+
if not specs or len(specs) == 0:
1629+
return list(TestModelOptions)
1630+
1631+
# Simulate filtering based on spec (e.g., spec has 'supported_models' attribute)
1632+
# For testing, we'll use spec count as a proxy
1633+
if len(specs) == 1:
1634+
return [TestModelOptions.LINEAR, TestModelOptions.RANDOM_FOREST]
1635+
else:
1636+
return [TestModelOptions.NEURAL_NET, TestModelOptions.SVM]
1637+
1638+
@staticmethod
1639+
def _filter_invalid():
1640+
"""Returns invalid members for testing warnings"""
1641+
return ["INVALID_OPTION", TestModelOptions.LINEAR]
1642+
1643+
@staticmethod
1644+
def _filter_empty(ctx):
1645+
"""Returns empty list"""
1646+
return []
1647+
1648+
param_filtered = kp.EnumParameter(
1649+
label="Filtered Model",
1650+
description="Model with filtered choices",
1651+
default_value=TestModelOptions.LINEAR.name,
1652+
enum=TestModelOptions,
1653+
visible_choices=_filter_to_two.__func__,
1654+
)
1655+
1656+
param_context_dependent = kp.EnumParameter(
1657+
label="Context Dependent",
1658+
description="Choices depend on context",
1659+
default_value=TestModelOptions.LINEAR, # Using enum member as default
1660+
enum=TestModelOptions,
1661+
visible_choices=_filter_by_specs.__func__,
1662+
)
1663+
1664+
param_no_filter = kp.EnumParameter(
1665+
label="No Filter",
1666+
description="All options visible",
1667+
default_value=TestModelOptions.LINEAR.name,
1668+
enum=TestModelOptions,
1669+
)
1670+
1671+
1672+
class TestEnumParameterVisibleChoices(unittest.TestCase):
1673+
"""Test EnumParameter visible_choices functionality"""
1674+
1675+
def test_filtered_schema_contains_subset(self):
1676+
"""Test that filtered options appear in schema oneOf"""
1677+
obj = ParameterizedWithVisibleChoices()
1678+
schema = kp.extract_schema(
1679+
obj, dialog_creation_context=DummyDialogCreationContext()
1680+
)
1681+
s = schema["properties"]["model"]["properties"]["param_filtered"]
1682+
1683+
self.assertIn("oneOf", s)
1684+
values = {entry["const"] for entry in s["oneOf"]}
1685+
# Should only contain LINEAR and RANDOM_FOREST
1686+
self.assertEqual(values, {"LINEAR", "RANDOM_FOREST"})
1687+
self.assertNotIn("NEURAL_NET", values)
1688+
self.assertNotIn("SVM", values)
1689+
1690+
def test_no_filter_shows_all_options(self):
1691+
"""Test that parameter without visible_choices shows all options"""
1692+
obj = ParameterizedWithVisibleChoices()
1693+
schema = kp.extract_schema(
1694+
obj, dialog_creation_context=DummyDialogCreationContext()
1695+
)
1696+
s = schema["properties"]["model"]["properties"]["param_no_filter"]
1697+
1698+
self.assertIn("oneOf", s)
1699+
values = {entry["const"] for entry in s["oneOf"]}
1700+
self.assertEqual(values, {"LINEAR", "RANDOM_FOREST", "NEURAL_NET", "SVM"})
1701+
1702+
def test_context_dependent_filtering(self):
1703+
"""Test that filtering works based on context"""
1704+
obj = ParameterizedWithVisibleChoices()
1705+
1706+
# With one spec
1707+
ctx_one = DummyDialogCreationContext(specs=[test_schema])
1708+
schema = kp.extract_schema(obj, dialog_creation_context=ctx_one)
1709+
s = schema["properties"]["model"]["properties"]["param_context_dependent"]
1710+
values = {entry["const"] for entry in s["oneOf"]}
1711+
self.assertEqual(values, {"LINEAR", "RANDOM_FOREST"})
1712+
1713+
# With two specs
1714+
ctx_two = DummyDialogCreationContext(specs=[test_schema, test_schema])
1715+
schema = kp.extract_schema(obj, dialog_creation_context=ctx_two)
1716+
s = schema["properties"]["model"]["properties"]["param_context_dependent"]
1717+
values = {entry["const"] for entry in s["oneOf"]}
1718+
self.assertEqual(values, {"NEURAL_NET", "SVM"})
1719+
1720+
def test_none_context_subsetting(self):
1721+
"""Test that None context enables subsetting use-case"""
1722+
obj = ParameterizedWithVisibleChoices()
1723+
1724+
# Extract schema without context
1725+
schema = kp.extract_schema(obj, dialog_creation_context=None)
1726+
s = schema["properties"]["model"]["properties"]["param_context_dependent"]
1727+
1728+
self.assertIn("oneOf", s)
1729+
values = {entry["const"] for entry in s["oneOf"]}
1730+
# Should return subset defined for None case
1731+
self.assertEqual(values, {"LINEAR", "SVM"})
1732+
1733+
def test_description_respects_visible_choices(self):
1734+
"""Test that description respects visible_choices based on None context"""
1735+
1736+
# param_filtered uses _filter_to_two which doesn't check context
1737+
# So description should show only LINEAR and RANDOM_FOREST
1738+
desc_dict = ParameterizedWithVisibleChoices.param_filtered._extract_description(
1739+
"param_filtered", None
1740+
)
1741+
description = desc_dict["description"]
1742+
1743+
# Description should contain only filtered options
1744+
self.assertIn("Linear Regression", description)
1745+
self.assertIn("Random Forest", description)
1746+
# Should NOT contain filtered-out options
1747+
self.assertNotIn("Neural Network", description)
1748+
self.assertNotIn("Support Vector Machine", description)
1749+
1750+
def test_description_with_context_dependent_filter(self):
1751+
"""Test description with context-dependent filter (None case)"""
1752+
1753+
# param_context_dependent returns subset for None context
1754+
desc_dict = ParameterizedWithVisibleChoices.param_context_dependent._extract_description(
1755+
"param_context_dependent", None
1756+
)
1757+
description = desc_dict["description"]
1758+
1759+
# Description should show subset returned for None
1760+
self.assertIn("Linear Regression", description)
1761+
self.assertIn("Support Vector Machine", description)
1762+
# Should NOT contain other options
1763+
self.assertNotIn("Random Forest", description)
1764+
self.assertNotIn("Neural Network", description)
1765+
1766+
def test_description_without_filter_shows_all(self):
1767+
"""Test that description without filter shows all options"""
1768+
1769+
# param_no_filter has no visible_choices
1770+
desc_dict = ParameterizedWithVisibleChoices.param_no_filter._extract_description(
1771+
"param_no_filter", None
1772+
)
1773+
description = desc_dict["description"]
1774+
1775+
# Description should contain all enum options
1776+
self.assertIn("Linear Regression", description)
1777+
self.assertIn("Random Forest", description)
1778+
self.assertIn("Neural Network", description)
1779+
self.assertIn("Support Vector Machine", description)
1780+
1781+
def test_validation_accepts_filtered_out_values(self):
1782+
"""Test that validation accepts any enum member, even if filtered out"""
1783+
obj = ParameterizedWithVisibleChoices()
1784+
1785+
# Extract schema with filtering active
1786+
kp.extract_schema(
1787+
obj, dialog_creation_context=DummyDialogCreationContext()
1788+
)
1789+
1790+
# NEURAL_NET is filtered out but should still be valid
1791+
obj.param_filtered = "NEURAL_NET"
1792+
self.assertEqual(obj.param_filtered, "NEURAL_NET")
1793+
1794+
# Invalid value should still fail validation
1795+
with self.assertRaises(ValueError):
1796+
obj.param_filtered = "INVALID_OPTION"
1797+
1798+
def test_default_as_enum_member(self):
1799+
"""Test that default_value accepts enum member directly"""
1800+
obj = ParameterizedWithVisibleChoices()
1801+
1802+
# param_context_dependent uses enum member as default
1803+
self.assertEqual(obj.param_context_dependent, "LINEAR")
1804+
1805+
def test_empty_filter_result_shows_empty(self):
1806+
"""Test that empty filter result shows no options with warning"""
1807+
1808+
def empty_filter(ctx):
1809+
return []
1810+
1811+
param = kp.EnumParameter(
1812+
label="Empty Filter",
1813+
description="Should be empty",
1814+
default_value=TestModelOptions.LINEAR.name,
1815+
enum=TestModelOptions,
1816+
visible_choices=empty_filter,
1817+
)
1818+
1819+
class TestObj:
1820+
empty_param = param
1821+
1822+
obj = TestObj()
1823+
1824+
# Should log warning and return empty
1825+
with self.assertLogs("Python backend", level="WARNING") as log:
1826+
schema = kp.extract_schema(
1827+
obj, dialog_creation_context=DummyDialogCreationContext()
1828+
)
1829+
1830+
# Check warning was logged
1831+
self.assertTrue(
1832+
any("returned an empty list" in msg or "empty options" in msg for msg in log.output)
1833+
)
1834+
1835+
s = schema["properties"]["model"]["properties"]["empty_param"]
1836+
self.assertEqual(s["oneOf"], [])
1837+
1838+
def test_invalid_members_filtered_with_warning(self):
1839+
"""Test that invalid members are filtered out with warning"""
1840+
1841+
def invalid_filter(ctx):
1842+
# Return mix of valid and invalid
1843+
class FakeMember:
1844+
name = "INVALID"
1845+
1846+
return [TestModelOptions.LINEAR, FakeMember(), "not_a_member"]
1847+
1848+
param = kp.EnumParameter(
1849+
label="Invalid Filter",
1850+
description="Has invalid members",
1851+
default_value=TestModelOptions.LINEAR.name,
1852+
enum=TestModelOptions,
1853+
visible_choices=invalid_filter,
1854+
)
1855+
1856+
class TestObj:
1857+
invalid_param = param
1858+
1859+
obj = TestObj()
1860+
1861+
# Should log warning about invalid members
1862+
with self.assertLogs("Python backend", level="WARNING") as log:
1863+
schema = kp.extract_schema(
1864+
obj, dialog_creation_context=DummyDialogCreationContext()
1865+
)
1866+
1867+
# Check warning was logged with valid options listed
1868+
warning_msg = " ".join(log.output)
1869+
self.assertIn("invalid members", warning_msg.lower())
1870+
self.assertIn("Valid options", warning_msg)
1871+
1872+
# Schema should only contain valid member
1873+
s = schema["properties"]["model"]["properties"]["invalid_param"]
1874+
values = {entry["const"] for entry in s["oneOf"]}
1875+
self.assertEqual(values, {"LINEAR"})
1876+
1877+
def test_default_not_in_visible_options_warns(self):
1878+
"""Test that warning is logged when default is not in visible options"""
1879+
1880+
def filter_without_default(ctx):
1881+
return [TestModelOptions.RANDOM_FOREST, TestModelOptions.SVM]
1882+
1883+
param = kp.EnumParameter(
1884+
label="Default Not Visible",
1885+
description="Default filtered out",
1886+
default_value=TestModelOptions.LINEAR.name, # Not in visible choices
1887+
enum=TestModelOptions,
1888+
visible_choices=filter_without_default,
1889+
)
1890+
1891+
class TestObj:
1892+
param_with_hidden_default = param
1893+
1894+
obj = TestObj()
1895+
1896+
# Should log warning about default not visible
1897+
with self.assertLogs("Python backend", level="WARNING") as log:
1898+
kp.extract_schema(
1899+
obj, dialog_creation_context=DummyDialogCreationContext()
1900+
)
1901+
1902+
warning_msg = " ".join(log.output)
1903+
self.assertIn("Default value", warning_msg)
1904+
self.assertIn("not in the currently visible options", warning_msg)
1905+
1906+
def test_caching_works(self):
1907+
"""Test that visible_choices callable is cached per context"""
1908+
call_count = [0]
1909+
1910+
def counting_filter(ctx):
1911+
call_count[0] += 1
1912+
return [TestModelOptions.LINEAR, TestModelOptions.SVM]
1913+
1914+
param = kp.EnumParameter(
1915+
label="Cached",
1916+
description="Should cache",
1917+
default_value=TestModelOptions.LINEAR.name,
1918+
enum=TestModelOptions,
1919+
visible_choices=counting_filter,
1920+
)
1921+
1922+
class TestObj:
1923+
cached_param = param
1924+
1925+
obj = TestObj()
1926+
ctx = DummyDialogCreationContext()
1927+
1928+
# Extract schema multiple times with same context
1929+
kp.extract_schema(obj, dialog_creation_context=ctx)
1930+
kp.extract_schema(obj, dialog_creation_context=ctx)
1931+
kp.extract_schema(obj, dialog_creation_context=ctx)
1932+
1933+
# Should be called once: the same context is used for both description and schema
1934+
# generation, and the result is cached after the first call
1935+
self.assertEqual(call_count[0], 1)
1936+
1937+
16041938
class DummyDialogCreationContext:
16051939
def __init__(self, specs: List = None) -> None:
16061940
class DummyJavaContext:

0 commit comments

Comments
 (0)