Skip to content

Commit f7d643c

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

File tree

2 files changed

+535
-8
lines changed

2 files changed

+535
-8
lines changed

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

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

0 commit comments

Comments
 (0)