From 2219e5347553a44ee8f6c70fde662e06c6131fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:53:58 +0000 Subject: [PATCH] input_TOPP: exclude/include a whole parameter section with a trailing ':' A pattern such as "fd:" now selects every parameter under that top-level section, so a workflow can hide a subsection that does not apply to it without listing each parameter. Plain names keep their suffix matching. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P4MTpJCZMdSHNRFixkxcnz --- src/workflow/StreamlitUI.py | 49 +++++++++++--------- tests/test_topp_parameter_matching.py | 66 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 tests/test_topp_parameter_matching.py diff --git a/src/workflow/StreamlitUI.py b/src/workflow/StreamlitUI.py index 787150af..898ef7ff 100644 --- a/src/workflow/StreamlitUI.py +++ b/src/workflow/StreamlitUI.py @@ -28,6 +28,31 @@ ) +def _matches_topp_parameter(pattern: str, key: bytes) -> bool: + """ + Match an include/exclude pattern against a TOPP parameter key. + + Key format: b"ToolName:1:section:subsection:param_name" + + A pattern matches the end of the parameter path, bounded by ':' or the + start of the path ("tol" matches "FD:tol"). A pattern ending in ':' names + a whole section from the top of the path instead ("fd:" matches every + parameter under "fd:", but not "FD:" or "algorithm:fd:"). + """ + pattern = pattern.lstrip(":") # Strip legacy leading colon + key_str = key.decode() + + # Extract param path after "ToolName:1:" + parts = key_str.split(":") + param_path = ":".join(parts[2:]) if len(parts) > 2 else key_str + + if pattern.endswith(":"): + return param_path.startswith(pattern) + + # Check if pattern matches as a suffix, bounded by ':' or start + return param_path == pattern or param_path.endswith(":" + pattern) + + def _mounted_data_root() -> Union[Path, None]: """Return the validated mount root from the ``local_data_dir`` setting. @@ -866,7 +891,7 @@ def input_TOPP( Args: topp_tool_name (str): The name of the TOPP tool for which to generate inputs. num_cols (int, optional): Number of columns to use for the layout. Defaults to 3. - exclude_parameters (List[str], optional): List of parameter names to exclude from the widget. Defaults to an empty list. + exclude_parameters (List[str], optional): List of parameter names to exclude from the widget. A name ending in ":" excludes that whole top-level section (e.g. "fd:"). Defaults to an empty list. include_parameters (List[str], optional): List of parameter names to include in the widget. Defaults to an empty list. flag_parameters (List[str], optional): List of parameter names that should be treated as no-value CLI flags during command construction. @@ -1054,24 +1079,6 @@ def _input_TOPP_impl( param = poms.Param() poms.ParamXMLFile().load(str(ini_file_path), param) - def _matches_parameter(pattern: str, key: bytes) -> bool: - """ - Match pattern against TOPP parameter key using suffix matching. - - Key format: b"ToolName:1:section:subsection:param_name" - - Returns True if pattern matches the end of the param path, - bounded by ':' or start of path. - """ - pattern = pattern.lstrip(":") # Strip legacy leading colon - key_str = key.decode() - - # Extract param path after "ToolName:1:" - parts = key_str.split(":") - param_path = ":".join(parts[2:]) if len(parts) > 2 else key_str - - # Check if pattern matches as a suffix, bounded by ':' or start - return param_path == pattern or param_path.endswith(":" + pattern) # Always apply base exclusions (input/output files, standard excludes) excluded_keys = [ @@ -1090,7 +1097,7 @@ def _matches_parameter(pattern: str, key: bytes) -> bool: if not ( b"input file" in param.getTags(key) or b"output file" in param.getTags(key) - or any([_matches_parameter(k, key) for k in excluded_keys]) + or any([_matches_topp_parameter(k, key) for k in excluded_keys]) ) ] @@ -1098,7 +1105,7 @@ def _matches_parameter(pattern: str, key: bytes) -> bool: if include_parameters: included_keys = { key for key in valid_keys - if any([_matches_parameter(k, key) for k in include_parameters]) + if any([_matches_topp_parameter(k, key) for k in include_parameters]) } else: included_keys = set(valid_keys) # All are included when no filter specified diff --git a/tests/test_topp_parameter_matching.py b/tests/test_topp_parameter_matching.py new file mode 100644 index 00000000..e0bc1b89 --- /dev/null +++ b/tests/test_topp_parameter_matching.py @@ -0,0 +1,66 @@ +""" +``_matches_topp_parameter()`` — how ``input_TOPP(exclude_parameters=..., +include_parameters=...)`` patterns select TOPP parameters. + +A plain name matches the end of the parameter path, bounded by ':'. A name +ending in ':' selects a whole top-level section, so a tool can hide a +subsection (e.g. FLASHTnT's ``fd:`` parameters, which only apply to raw input) +without listing every parameter in it. +""" +import os +import sys +from unittest.mock import MagicMock + +import pytest + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(PROJECT_ROOT) + +# Same sys.modules mocking as tests/test_streamlit_ui_param_read.py. +mock_streamlit = MagicMock() +mock_streamlit.session_state = {} + +_originals = { + name: sys.modules.get(name) + for name in ("streamlit", "pyopenms", "src.common.common") +} +sys.modules["streamlit"] = mock_streamlit +if _originals["pyopenms"] is None: + sys.modules["pyopenms"] = MagicMock() +if _originals["src.common.common"] is None: + sys.modules["src.common.common"] = MagicMock() + +from src.workflow.StreamlitUI import _matches_topp_parameter + +for name, module in _originals.items(): + if module is not None: + sys.modules[name] = module + else: + sys.modules.pop(name, None) + +for _key in list(sys.modules.keys()): + if _key.startswith("src.workflow"): + sys.modules.pop(_key, None) + + +@pytest.mark.parametrize( + "pattern, key, expected", + [ + # suffix matching, bounded by ':' + ("tol", b"FLASHTnT:1:fd:FD:tol", True), + ("FD:tol", b"FLASHTnT:1:fd:FD:tol", True), + ("ol", b"FLASHTnT:1:fd:FD:tol", False), + ("prsm_fdr", b"FLASHTnT:1:prsm_fdr", True), + # legacy leading colon is ignored + (":prsm_fdr", b"FLASHTnT:1:prsm_fdr", True), + # a trailing ':' names a top-level section + ("fd:", b"FLASHTnT:1:fd:FD:tol", True), + ("fd:FD:", b"FLASHTnT:1:fd:FD:tol", True), + ("fd:", b"FLASHTnT:1:fdr", False), + ("fd:", b"FLASHTnT:1:prsm_fdr", False), + ("FD:", b"FLASHTnT:1:fd:FD:tol", False), + ("fd:", b"FLASHTnT:1:tag:fd:x", False), + ], +) +def test_matches_topp_parameter(pattern, key, expected): + assert _matches_topp_parameter(pattern, key) is expected