diff --git a/docs/example_multiple_instances.py b/docs/example_multiple_instances.py new file mode 100644 index 00000000..9b511b56 --- /dev/null +++ b/docs/example_multiple_instances.py @@ -0,0 +1,115 @@ +""" +Example workflow demonstrating the use of the same TOPP tool multiple times +with different configurations using tool_instance_name parameter. + +This example shows how to use IDFilter twice in a DDA-TMT workflow: +1. First with strict filtering (PSM FDR 0.01) +2. Then with lenient filtering (PSM FDR 0.05) +""" + +import streamlit as st +from src.workflow.WorkflowManager import WorkflowManager +from pathlib import Path + + +class MultipleToolInstancesExample(WorkflowManager): + """Example workflow showing how to use the same tool multiple times.""" + + def __init__(self) -> None: + super().__init__("Multiple Tool Instances Example", st.session_state["workspace"]) + + def upload(self) -> None: + """Upload idXML files for filtering.""" + self.ui.upload_widget( + key="idXML-files", + name="Identification files", + file_types="idXML", + ) + + @st.fragment + def configure(self) -> None: + """Configure parameters for two IDFilter instances.""" + # Select input files + self.ui.select_input_file("idXML-files", multiple=True) + + # Create tabs for two different filtering stages + t = st.tabs(["**Strict Filtering**", "**Lenient Filtering**"]) + + with t[0]: + st.info("First filtering stage with strict FDR threshold") + # First instance of IDFilter with custom defaults for strict filtering + self.ui.input_TOPP( + "IDFilter", + tool_instance_name="IDFilter-strict", + custom_defaults={"score:pep": 0.01}, # Strict FDR threshold + ) + + with t[1]: + st.info("Second filtering stage with lenient FDR threshold") + # Second instance of IDFilter with custom defaults for lenient filtering + self.ui.input_TOPP( + "IDFilter", + tool_instance_name="IDFilter-lenient", + custom_defaults={"score:pep": 0.05}, # Lenient FDR threshold + ) + + def execution(self) -> None: + """Execute workflow with two different IDFilter instances.""" + # Check if files are selected + if not self.params["idXML-files"]: + self.logger.log("ERROR: No idXML files selected.") + return + + # Get input files + in_files = self.file_manager.get_files(self.params["idXML-files"]) + self.logger.log(f"Processing {len(in_files)} identification files") + + # First filtering stage: Strict filtering + self.logger.log("Running strict filtering (FDR 0.01)...") + out_strict = self.file_manager.get_files( + in_files, "idXML", "strict-filtering" + ) + self.executor.run_topp( + "IDFilter", + input_output={"in": in_files, "out": out_strict}, + tool_instance_name="IDFilter-strict" # Use strict instance parameters + ) + + # Second filtering stage: Lenient filtering + self.logger.log("Running lenient filtering (FDR 0.05)...") + out_lenient = self.file_manager.get_files( + in_files, "idXML", "lenient-filtering" + ) + self.executor.run_topp( + "IDFilter", + input_output={"in": in_files, "out": out_lenient}, + tool_instance_name="IDFilter-lenient" # Use lenient instance parameters + ) + + self.logger.log("Filtering completed!") + + @st.fragment + def results(self) -> None: + """Display results.""" + strict_dir = Path(self.workflow_dir, "results", "strict-filtering") + lenient_dir = Path(self.workflow_dir, "results", "lenient-filtering") + + if strict_dir.exists() and lenient_dir.exists(): + st.success("Both filtering stages completed successfully!") + + col1, col2 = st.columns(2) + with col1: + st.subheader("Strict Filtering Results") + strict_files = list(strict_dir.glob("*.idXML")) + st.info(f"Files created: {len(strict_files)}") + for f in strict_files: + st.write(f"- {f.name}") + + with col2: + st.subheader("Lenient Filtering Results") + lenient_files = list(lenient_dir.glob("*.idXML")) + st.info(f"Files created: {len(lenient_files)}") + for f in lenient_files: + st.write(f"- {f.name}") + else: + st.warning("No results yet. Please run the workflow first.") diff --git a/docs/toppframework.py b/docs/toppframework.py index 87b666d7..0dc1248d 100644 --- a/docs/toppframework.py +++ b/docs/toppframework.py @@ -98,6 +98,32 @@ def content(): It takes the obligatory **topp_tool_name** parameter and generates input widgets for each parameter present in the **ini** file (automatically created) except for input and output file parameters. For all input file parameters a widget needs to be created with `self.ui.select_input_file` with an appropriate **key**. For TOPP tool parameters only non-default values are stored. +**Using the same TOPP tool multiple times:** + +When using the same TOPP tool multiple times in a workflow with different parameter configurations, provide a unique **tool_instance_name** to distinguish between the instances: + +```python +# First instance of IDFilter with strict filtering +self.ui.input_TOPP("IDFilter", tool_instance_name="IDFilter-strict") + +# Second instance of IDFilter with lenient filtering +self.ui.input_TOPP("IDFilter", tool_instance_name="IDFilter-lenient") +``` + +When executing the tool, pass the same **tool_instance_name** to `self.executor.run_topp()`: + +```python +# Run first instance +self.executor.run_topp("IDFilter", + input_output={"in": in_files_1, "out": out_files_1}, + tool_instance_name="IDFilter-strict") + +# Run second instance with different parameters +self.executor.run_topp("IDFilter", + input_output={"in": in_files_2, "out": out_files_2}, + tool_instance_name="IDFilter-lenient") +``` + **3. Choose `self.ui.input_python` to automatically generate complete input sections for a custom Python tool:** Takes the obligatory **script_file** argument. The default location for the Python script files is in `src/python-tools` (in this case the `.py` file extension is optional in the **script_file** argument), however, any other path can be specified as well. Parameters need to be specified in the Python script in the **DEFAULTS** variable with the mandatory **key** and **value** parameters. diff --git a/src/workflow/CommandExecutor.py b/src/workflow/CommandExecutor.py index c93eccca..62a77d6d 100644 --- a/src/workflow/CommandExecutor.py +++ b/src/workflow/CommandExecutor.py @@ -157,7 +157,7 @@ def read_stderr(): stdout_thread.join() stderr_thread.join() - def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> None: + def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}, tool_instance_name: str = None) -> None: """ Constructs and executes commands for the specified tool OpenMS TOPP tool based on the given input and output configurations. Ensures that all input/output file lists @@ -175,6 +175,7 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> N tool (str): The executable name or path of the tool. input_output (dict): A dictionary specifying the input/output parameter names (as key) and their corresponding file paths (as value). custom_params (dict): A dictionary of custom parameters to pass to the tool. + tool_instance_name (str, optional): Unique identifier for this tool instance. Use when the same tool is configured multiple times with different parameters. Raises: ValueError: If the lengths of input/output file lists are inconsistent, @@ -196,6 +197,8 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> N # Load parameters for non-defaults params = self.parameter_manager.get_parameters_from_json() + # Use tool_instance_name if provided, otherwise use tool name + param_key = tool_instance_name if tool_instance_name else tool # Construct commands for each process for i in range(n_processes): command = [tool] @@ -215,8 +218,11 @@ def run_topp(self, tool: str, input_output: dict, custom_params: dict = {}) -> N else: command += [value[i]] # Add non-default TOPP tool parameters - if tool in params.keys(): - for k, v in params[tool].items(): + if param_key in params.keys(): + for k, v in params[param_key].items(): + # Skip metadata keys (starting with underscore) + if k.startswith("_"): + continue command += [f"-{k}"] if isinstance(v, str) and "\n" in v: command += v.split("\n") diff --git a/src/workflow/ParameterManager.py b/src/workflow/ParameterManager.py index e517bfb0..cffa467c 100644 --- a/src/workflow/ParameterManager.py +++ b/src/workflow/ParameterManager.py @@ -42,8 +42,8 @@ def save_parameters(self) -> None: # Advanced parameters are only in session state if the view is active json_params = self.get_parameters_from_json() | json_params - # get a list of TOPP tools which are in session state - current_topp_tools = list( + # get a list of TOPP tool instances (or tools) which are in session state + current_topp_instances = list( set( [ k.replace(self.topp_param_prefix, "").split(":1:")[0] @@ -52,31 +52,86 @@ def save_parameters(self) -> None: ] ) ) - # for each TOPP tool, open the ini file - for tool in current_topp_tools: - if tool not in json_params: - json_params[tool] = {} + # for each TOPP tool instance, open the ini file + for instance in current_topp_instances: + if instance not in json_params: + json_params[instance] = {} + # Extract actual tool name from instance name (instance might be the tool name itself) + # We need to check which ini file exists to determine the actual tool name + tool_name = self._get_tool_name_from_instance(instance) + if not tool_name: + continue # load the param object param = poms.Param() - poms.ParamXMLFile().load(str(Path(self.ini_dir, f"{tool}.ini")), param) - # get all session state param keys and values for this tool + poms.ParamXMLFile().load(str(Path(self.ini_dir, f"{tool_name}.ini")), param) + # get all session state param keys and values for this tool instance for key, value in st.session_state.items(): - if key.startswith(f"{self.topp_param_prefix}{tool}:1:"): - # get ini_key - ini_key = key.replace(self.topp_param_prefix, "").encode() + if key.startswith(f"{self.topp_param_prefix}{instance}:1:"): + # get ini_key by replacing instance with actual tool name + ini_key_str = key.replace(self.topp_param_prefix, "").replace(f"{instance}:1:", f"{tool_name}:1:") + ini_key = ini_key_str.encode() # get ini (default) value by ini_key ini_value = param.getValue(ini_key) # check if value is different from default if ( (ini_value != value) - or (key.split(":1:")[1] in json_params[tool]) + or (key.split(":1:")[1] in json_params[instance]) ): # store non-default value - json_params[tool][key.split(":1:")[1]] = value + json_params[instance][key.split(":1:")[1]] = value # Save to json file with open(self.params_file, "w", encoding="utf-8") as f: json.dump(json_params, f, indent=4) + def _get_tool_name_from_instance(self, instance: str) -> str: + """ + Get the actual TOPP tool name from an instance identifier. + If the instance name corresponds to an existing ini file, it's the tool name itself. + Otherwise, extract from stored metadata in params. + + Args: + instance (str): The tool instance identifier (could be tool name or custom name) + + Returns: + str: The actual tool name, or None if not found + """ + # Check if instance name corresponds to an existing ini file + if Path(self.ini_dir, f"{instance}.ini").exists(): + return instance + + # Check if we have metadata about this instance in the params + params = self.get_parameters_from_json() + if instance in params and isinstance(params[instance], dict): + # Check if there's a special metadata key for tool name + if "_tool_name" in params[instance]: + return params[instance]["_tool_name"] + + # Otherwise, find which ini file was used for this instance by checking session state + for ini_file in Path(self.ini_dir).glob("*.ini"): + tool_name = ini_file.stem + # Check if any session state key matches the pattern for this instance and tool + for key in st.session_state.keys(): + if key.startswith(f"{self.topp_param_prefix}{instance}:1:"): + # Try to see if this parameter exists in this tool's ini file + try: + param = poms.Param() + poms.ParamXMLFile().load(str(ini_file), param) + param_name = key.split(":1:")[1] + # Check if this parameter exists in the tool + test_key = f"{tool_name}:1:{param_name}".encode() + if test_key in param.keys(): + # Store this mapping for future use + if instance in params: + params[instance]["_tool_name"] = tool_name + else: + params[instance] = {"_tool_name": tool_name} + with open(self.params_file, "w", encoding="utf-8") as f: + json.dump(params, f, indent=4) + return tool_name + except Exception: + continue + return None + def get_parameters_from_json(self) -> dict: """ Loads parameters from the JSON file if it exists and returns them as a dictionary. @@ -98,18 +153,23 @@ def get_parameters_from_json(self) -> dict: st.error("**ERROR**: Attempting to load an invalid JSON parameter file. Reset to defaults.") return {} - def get_topp_parameters(self, tool: str) -> dict: + def get_topp_parameters(self, tool_or_instance: str) -> dict: """ - Get all parameters for a TOPP tool, merging defaults with user values. + Get all parameters for a TOPP tool or tool instance, merging defaults with user values. Args: - tool: Name of the TOPP tool (e.g., "CometAdapter") + tool_or_instance: Name of the TOPP tool (e.g., "CometAdapter") or tool instance name (e.g., "IDFilter-first") Returns: Dict with parameter names as keys (without tool prefix) and their values. Returns empty dict if ini file doesn't exist. """ - ini_path = Path(self.ini_dir, f"{tool}.ini") + # Determine if this is an instance name or actual tool name + tool_name = self._get_tool_name_from_instance(tool_or_instance) + if not tool_name: + return {} + + ini_path = Path(self.ini_dir, f"{tool_name}.ini") if not ini_path.exists(): return {} @@ -118,7 +178,7 @@ def get_topp_parameters(self, tool: str) -> dict: poms.ParamXMLFile().load(str(ini_path), param) # Build dict from ini (extract short key names) - prefix = f"{tool}:1:" + prefix = f"{tool_name}:1:" full_params = {} for key in param.keys(): key_str = key.decode() if isinstance(key, bytes) else str(key) @@ -127,7 +187,8 @@ def get_topp_parameters(self, tool: str) -> dict: full_params[short_key] = param.getValue(key) # Override with user-modified values from JSON - user_params = self.get_parameters_from_json().get(tool, {}) + # Use tool_or_instance as key since that's what's stored in params + user_params = self.get_parameters_from_json().get(tool_or_instance, {}) full_params.update(user_params) return full_params diff --git a/src/workflow/StreamlitUI.py b/src/workflow/StreamlitUI.py index 24d2f239..3b1f4285 100644 --- a/src/workflow/StreamlitUI.py +++ b/src/workflow/StreamlitUI.py @@ -542,6 +542,7 @@ def input_TOPP( display_subsections: bool = True, display_subsection_tabs: bool = False, custom_defaults: dict = {}, + tool_instance_name: str = None, ) -> None: """ Generates input widgets for TOPP tool parameters dynamically based on the tool's @@ -557,6 +558,7 @@ def input_TOPP( display_subsections (bool, optional): Whether to split parameters into subsections based on the prefix. Defaults to True. display_subsection_tabs (bool, optional): Whether to display main subsections in separate tabs (if more than one main section). Defaults to False. custom_defaults (dict, optional): Dictionary of custom defaults to use. Defaults to an empty dict. + tool_instance_name (str, optional): Unique identifier for this tool instance. Required when using the same TOPP tool multiple times with different configurations. Defaults to None. """ if not display_subsections: @@ -564,6 +566,18 @@ def input_TOPP( if display_subsection_tabs: display_subsections = True + # Use tool_instance_name if provided, otherwise use tool name + param_key = tool_instance_name if tool_instance_name else topp_tool_name + + # Store tool name mapping for instances + if tool_instance_name and tool_instance_name != topp_tool_name: + # Store the actual tool name in params for later retrieval + if param_key not in self.params: + self.params[param_key] = {} + if "_tool_name" not in self.params[param_key]: + self.params[param_key]["_tool_name"] = topp_tool_name + self.parameter_manager.save_parameters() + # write defaults ini files ini_file_path = Path(self.parameter_manager.ini_dir, f"{topp_tool_name}.ini") if not ini_file_path.exists(): @@ -636,9 +650,9 @@ def input_TOPP( # else check if the parameter is already in self.params, if yes take the value from self.params for p in params: name = p["key"].decode().split(":1:")[1] - if topp_tool_name in self.params: - if name in self.params[topp_tool_name]: - p["value"] = self.params[topp_tool_name][name] + if param_key in self.params: + if name in self.params[param_key]: + p["value"] = self.params[param_key][name] elif name in custom_defaults: p["value"] = custom_defaults[name] elif name in custom_defaults: @@ -668,7 +682,8 @@ def input_TOPP( # Display tool name if required if display_tool_name: - st.markdown(f"**{topp_tool_name}**") + display_name = f"{topp_tool_name}" if not tool_instance_name else f"{topp_tool_name} ({tool_instance_name})" + st.markdown(f"**{display_name}**") tab_names = [k for k in param_sections.keys() if ":" not in k] tabs = None @@ -696,8 +711,10 @@ def display_TOPP_params(params: dict, num_cols): cols = st.columns(num_cols) i = 0 for p in params: - # get key and name - key = f"{self.parameter_manager.topp_param_prefix}{p['key'].decode()}" + # get key and name - replace tool name with param_key for unique widget keys + original_key = p['key'].decode() + widget_key = original_key.replace(f"{topp_tool_name}:1:", f"{param_key}:1:") + key = f"{self.parameter_manager.topp_param_prefix}{widget_key}" name = p["name"] try: # sometimes strings with newline, handle as list diff --git a/tests/manual_verification.py b/tests/manual_verification.py new file mode 100644 index 00000000..eae37f61 --- /dev/null +++ b/tests/manual_verification.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Manual verification script for multiple TOPP tool instances feature. +This script simulates the workflow to verify that: +1. Multiple instances can be configured separately +2. Parameters are stored correctly +3. Parameters are retrieved correctly for execution +""" + +import json +import tempfile +import shutil +from pathlib import Path +import sys + +# Mock streamlit session_state +class MockSessionState(dict): + pass + +sys.modules['streamlit'] = type(sys)('streamlit') +sys.modules['streamlit'].session_state = MockSessionState() +sys.modules['streamlit'].fragment = lambda func: func # Mock decorator + +# Import after mocking +from src.workflow.ParameterManager import ParameterManager + + +def test_multiple_instances(): + """Test multiple tool instances functionality.""" + # Create temporary directory + temp_dir = tempfile.mkdtemp() + try: + workflow_dir = Path(temp_dir) + pm = ParameterManager(workflow_dir) + + # Simulate what would happen with two IDFilter instances + print("\n=== Testing Multiple Tool Instances ===\n") + + # Step 1: Simulate initial metadata storage (done by StreamlitUI.input_TOPP) + print("1. Creating initial parameter structure with tool metadata...") + # Create ini directory and file + ini_dir = workflow_dir / "ini" + ini_dir.mkdir(exist_ok=True) + + # Create a minimal ini file (would be created by OpenMS in real usage) + ini_file = ini_dir / "IDFilter.ini" + # Create minimal XML ini file structure + ini_content = """ + + + + + +""" + with open(ini_file, 'w') as f: + f.write(ini_content) + + initial_params = { + "IDFilter-first": { + "_tool_name": "IDFilter" + }, + "IDFilter-second": { + "_tool_name": "IDFilter" + } + } + with open(pm.params_file, 'w') as f: + json.dump(initial_params, f, indent=2) + print(f" Initial params: {json.dumps(initial_params, indent=2)}") + + # Step 2: Verify _get_tool_name_from_instance works + print("\n2. Verifying tool name resolution...") + tool_name_1 = pm._get_tool_name_from_instance("IDFilter-first") + tool_name_2 = pm._get_tool_name_from_instance("IDFilter-second") + print(f" IDFilter-first resolves to: {tool_name_1}") + print(f" IDFilter-second resolves to: {tool_name_2}") + assert tool_name_1 == "IDFilter", f"Expected 'IDFilter', got '{tool_name_1}'" + assert tool_name_2 == "IDFilter", f"Expected 'IDFilter', got '{tool_name_2}'" + print(" ✓ Tool name resolution working correctly") + + # Step 3: Simulate parameter updates (done by user in UI) + print("\n3. Simulating parameter changes in UI...") + params = pm.get_parameters_from_json() + params["IDFilter-first"]["score:pep"] = 0.01 # Strict filtering + params["IDFilter-second"]["score:pep"] = 0.05 # Lenient filtering + with open(pm.params_file, 'w') as f: + json.dump(params, f, indent=2) + print(f" Updated params: {json.dumps(params, indent=2)}") + + # Step 4: Verify parameter retrieval for execution + print("\n4. Verifying parameter retrieval for execution...") + params_first = pm.get_topp_parameters("IDFilter-first") + params_second = pm.get_topp_parameters("IDFilter-second") + print(f" IDFilter-first params: {params_first}") + print(f" IDFilter-second params: {params_second}") + + # Step 5: Verify parameters are different + print("\n5. Verifying instances have different parameters...") + assert "score:pep" in params_first, "First instance should have score:pep" + assert "score:pep" in params_second, "Second instance should have score:pep" + assert params_first["score:pep"] == 0.01, f"Expected 0.01, got {params_first['score:pep']}" + assert params_second["score:pep"] == 0.05, f"Expected 0.05, got {params_second['score:pep']}" + print(" ✓ First instance: score:pep = 0.01 (strict)") + print(" ✓ Second instance: score:pep = 0.05 (lenient)") + + # Step 6: Verify backward compatibility + print("\n6. Testing backward compatibility (tool without instance name)...") + params = pm.get_parameters_from_json() + params["IDFilter"] = {"score:pep": 0.001} # Regular tool without instance + with open(pm.params_file, 'w') as f: + json.dump(params, f, indent=2) + + tool_name = pm._get_tool_name_from_instance("IDFilter") + print(f" IDFilter (no instance) resolves to: {tool_name}") + assert tool_name == "IDFilter", f"Expected 'IDFilter', got '{tool_name}'" + print(" ✓ Backward compatibility maintained") + + print("\n=== All Tests Passed! ===\n") + print("Summary:") + print(" ✓ Tool instances can be configured separately") + print(" ✓ Each instance stores its own parameters") + print(" ✓ Parameters are correctly retrieved for execution") + print(" ✓ Metadata keys are handled properly") + print(" ✓ Backward compatibility is maintained") + + finally: + # Cleanup + shutil.rmtree(temp_dir) + + +if __name__ == "__main__": + test_multiple_instances() diff --git a/tests/test_multiple_tool_instances.py b/tests/test_multiple_tool_instances.py new file mode 100644 index 00000000..9ec0d522 --- /dev/null +++ b/tests/test_multiple_tool_instances.py @@ -0,0 +1,272 @@ +""" +Tests for multiple TOPP tool instances functionality. + +This module verifies that the same TOPP tool can be used multiple times +with different configurations using the tool_instance_name parameter. +""" +import os +import sys +import pytest +from unittest.mock import patch, MagicMock, mock_open +from pathlib import Path +import json +import tempfile +import shutil + +# Add project root to path for imports +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(PROJECT_ROOT) + +# Create mock for pyopenms to avoid dependency on actual OpenMS installation +mock_pyopenms = MagicMock() +mock_pyopenms.__version__ = "2.9.1" +sys.modules['pyopenms'] = mock_pyopenms + +from src.workflow.ParameterManager import ParameterManager + + +@pytest.fixture +def temp_workflow_dir(): + """Create a temporary workflow directory for testing.""" + temp_dir = tempfile.mkdtemp() + yield Path(temp_dir) + shutil.rmtree(temp_dir) + + +@pytest.fixture +def mock_streamlit_state(): + """Mock Streamlit session state.""" + return {} + + +def test_tool_instance_name_storage(temp_workflow_dir, mock_streamlit_state): + """Test that tool instance names are correctly stored and retrieved.""" + with patch('streamlit.session_state', mock_streamlit_state): + pm = ParameterManager(temp_workflow_dir) + + # Simulate storing parameters for two instances of the same tool + workflow_stem = temp_workflow_dir.stem + + # First instance: IDFilter-first + mock_streamlit_state[f"{workflow_stem}-TOPP-IDFilter-first:1:score:psm"] = 0.05 + + # Second instance: IDFilter-second + mock_streamlit_state[f"{workflow_stem}-TOPP-IDFilter-second:1:score:psm"] = 0.01 + + # Create mock ini file + ini_dir = temp_workflow_dir / "ini" + ini_dir.mkdir(parents=True, exist_ok=True) + (ini_dir / "IDFilter.ini").touch() + + # Pre-populate params file with _tool_name metadata + # (this would be set by StreamlitUI.input_TOPP in real usage) + initial_params = { + "IDFilter-first": {"_tool_name": "IDFilter"}, + "IDFilter-second": {"_tool_name": "IDFilter"} + } + with open(pm.params_file, 'w') as f: + json.dump(initial_params, f) + + # Mock pyopenms Param and ParamXMLFile + mock_param = MagicMock() + mock_param.keys.return_value = [ + b"IDFilter:1:score:psm" + ] + mock_param.getValue.return_value = 0.0 # Default value + + with patch('pyopenms.Param', return_value=mock_param): + with patch('pyopenms.ParamXMLFile') as mock_xml: + mock_xml_instance = MagicMock() + mock_xml.return_value = mock_xml_instance + + # Save parameters + pm.save_parameters() + + # Verify that parameters were saved correctly + assert pm.params_file.exists() + with open(pm.params_file, 'r') as f: + saved_params = json.load(f) + + # Check that both instances are stored separately + assert "IDFilter-first" in saved_params + assert "IDFilter-second" in saved_params + assert saved_params["IDFilter-first"]["score:psm"] == 0.05 + assert saved_params["IDFilter-second"]["score:psm"] == 0.01 + # Verify metadata is preserved + assert saved_params["IDFilter-first"]["_tool_name"] == "IDFilter" + assert saved_params["IDFilter-second"]["_tool_name"] == "IDFilter" + + +def test_get_tool_name_from_instance(temp_workflow_dir): + """Test that _get_tool_name_from_instance correctly resolves tool names.""" + pm = ParameterManager(temp_workflow_dir) + + # Create mock ini file + ini_dir = temp_workflow_dir / "ini" + ini_dir.mkdir(parents=True, exist_ok=True) + (ini_dir / "IDFilter.ini").touch() + + # Test 1: Instance name is the tool name itself + assert pm._get_tool_name_from_instance("IDFilter") == "IDFilter" + + # Test 2: Instance name with metadata in params file + params_data = { + "IDFilter-first": { + "_tool_name": "IDFilter", + "score:psm": 0.05 + } + } + with open(pm.params_file, 'w') as f: + json.dump(params_data, f) + + assert pm._get_tool_name_from_instance("IDFilter-first") == "IDFilter" + + +def test_get_topp_parameters_with_instance(temp_workflow_dir): + """Test that get_topp_parameters works with tool instances.""" + pm = ParameterManager(temp_workflow_dir) + + # Create mock ini file and params file + ini_dir = temp_workflow_dir / "ini" + ini_dir.mkdir(parents=True, exist_ok=True) + (ini_dir / "IDFilter.ini").touch() + + # Create params file with two instances + params_data = { + "IDFilter-first": { + "_tool_name": "IDFilter", + "score:psm": 0.05 + }, + "IDFilter-second": { + "_tool_name": "IDFilter", + "score:psm": 0.01 + } + } + with open(pm.params_file, 'w') as f: + json.dump(params_data, f) + + # Mock pyopenms + mock_param = MagicMock() + mock_param.keys.return_value = [ + b"IDFilter:1:score:psm" + ] + mock_param.getValue.return_value = 0.0 # Default value + + with patch('pyopenms.Param', return_value=mock_param): + with patch('pyopenms.ParamXMLFile') as mock_xml: + mock_xml_instance = MagicMock() + mock_xml.return_value = mock_xml_instance + + # Get parameters for first instance + params_first = pm.get_topp_parameters("IDFilter-first") + assert params_first["score:psm"] == 0.05 + + # Get parameters for second instance + params_second = pm.get_topp_parameters("IDFilter-second") + assert params_second["score:psm"] == 0.01 + + +def test_backward_compatibility(temp_workflow_dir, mock_streamlit_state): + """Test that existing code without tool_instance_name still works.""" + with patch('streamlit.session_state', mock_streamlit_state): + pm = ParameterManager(temp_workflow_dir) + + # Simulate storing parameters for a tool without instance name + workflow_stem = temp_workflow_dir.stem + mock_streamlit_state[f"{workflow_stem}-TOPP-IDFilter:1:score:psm"] = 0.05 + + # Create mock ini file + ini_dir = temp_workflow_dir / "ini" + ini_dir.mkdir(parents=True, exist_ok=True) + (ini_dir / "IDFilter.ini").touch() + + # Mock pyopenms + mock_param = MagicMock() + mock_param.keys.return_value = [ + b"IDFilter:1:score:psm" + ] + mock_param.getValue.return_value = 0.0 # Default value + + with patch('pyopenms.Param', return_value=mock_param): + with patch('pyopenms.ParamXMLFile') as mock_xml: + mock_xml_instance = MagicMock() + mock_xml.return_value = mock_xml_instance + + # Save parameters + pm.save_parameters() + + # Verify parameters were saved under the tool name + with open(pm.params_file, 'r') as f: + saved_params = json.load(f) + + assert "IDFilter" in saved_params + assert saved_params["IDFilter"]["score:psm"] == 0.05 + + +def test_run_topp_with_instance_name(): + """Test that run_topp correctly uses tool_instance_name.""" + from src.workflow.CommandExecutor import CommandExecutor + from src.workflow.Logger import Logger + + with tempfile.TemporaryDirectory() as temp_dir: + workflow_dir = Path(temp_dir) + (workflow_dir / "pids").mkdir(parents=True, exist_ok=True) + (workflow_dir / "ini").mkdir(parents=True, exist_ok=True) + (workflow_dir / "IDFilter.ini").touch() + + # Create mock parameter manager + mock_pm = MagicMock() + mock_pm.get_parameters_from_json.return_value = { + "IDFilter-first": { + "_tool_name": "IDFilter", + "score:psm": 0.05 + }, + "IDFilter-second": { + "_tool_name": "IDFilter", + "score:psm": 0.01 + } + } + mock_pm.ini_dir = workflow_dir / "ini" + + # Create mock logger + mock_logger = MagicMock() + + # Create executor + executor = CommandExecutor(workflow_dir, mock_logger, mock_pm) + + # Mock run_command to capture the command + captured_commands = [] + + def mock_run_command(cmd): + captured_commands.append(cmd) + + executor.run_command = mock_run_command + + # Test running with first instance + executor.run_topp( + "IDFilter", + {"in": ["input.idXML"], "out": ["output.idXML"]}, + tool_instance_name="IDFilter-first" + ) + + # Verify command includes parameters from first instance + assert len(captured_commands) == 1 + cmd = captured_commands[0] + assert "IDFilter" in cmd + assert "-score:psm" in cmd + assert "0.05" in cmd + + # Clear and test with second instance + captured_commands.clear() + executor.run_topp( + "IDFilter", + {"in": ["input.idXML"], "out": ["output.idXML"]}, + tool_instance_name="IDFilter-second" + ) + + # Verify command includes parameters from second instance + assert len(captured_commands) == 1 + cmd = captured_commands[0] + assert "IDFilter" in cmd + assert "-score:psm" in cmd + assert "0.01" in cmd