diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a7a9980 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,34 @@ +on: + push: + branches: + - main + - develop + pull_request: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-latest + + env: + VIP_API_KEY: ${{ secrets.VIP_API_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest_mock requests girder_client + + - name: Run Pytest + run: | + export PYTHONPATH=src:$PYTHONPATH + pytest diff --git a/.gitignore b/.gitignore index 4e9cb7e..6574c27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ __pycache__/ +.pytest_cache/ .vscode/ vip_outputs/ -perso/ examples/old publish.sh dist/ -tests/old -tests/__pycache__ +.env +test_pipedef.py diff --git a/src/vip_client/classes/VipCI.py b/src/vip_client/classes/VipCI.py index bf38ce1..7b8cb7a 100644 --- a/src/vip_client/classes/VipCI.py +++ b/src/vip_client/classes/VipCI.py @@ -226,7 +226,7 @@ def run_session(self, nb_runs=1, refresh_time=30) -> VipCI: 2. Monitors pipeline executions until they are all over; and Adds metadata on Girder output folder. - /!\ This function assumes that all session properties are already set. + |!| This function assumes that all session properties are already set. Optional arguments can be provided: - Increase `nb_runs` to run more than 1 execution at once; - Set `refresh_time` to modify the default refresh time. diff --git a/src/vip_client/classes/VipLauncher.py b/src/vip_client/classes/VipLauncher.py index 06ac75b..345e763 100644 --- a/src/vip_client/classes/VipLauncher.py +++ b/src/vip_client/classes/VipLauncher.py @@ -146,14 +146,10 @@ def input_settings(self, input_settings: dict): # Check type if not isinstance(input_settings, dict): raise TypeError("`input_settings` should be a dictionary") - # Check if each input can be converted to a string with valid characters for VIP - for param, value in input_settings.items(): - invalid = self._invalid_chars_for_vip(value) - # if not (set(invalid) <= {'\\'}): # '\\' is OK for Windows paths #EDIT: Corrected in _invalid_chars_for_vip - if invalid: - raise ValueError( - f"Parameter '{param}' contains some invalid character(s): {', '.join(invalid)}" - ) + + # Check if each input can be converted to a string with valid characters and no empty strings + self._check_invalid_input(input_settings) + # Parse the input settings new_settings = self._parse_input_settings(input_settings) self._print("parsed") @@ -489,10 +485,7 @@ def launch_pipeline( self._print("OK") # Check the input parameters self._print("Input settings: ", end="", flush=True) - # Check existence - if not self._is_defined("_input_settings"): - raise TypeError("Please provide input parameters for Session: %s" %self._session_name) - # Check content + # Check content self._check_input_settings(location=self._SERVER_NAME) self._print("OK") # End parameters checks @@ -602,7 +595,7 @@ def run_session( self, nb_runs=1, refresh_time=30) -> VipLauncher: 1. Launches pipeline executions on VIP; 2. Monitors pipeline executions until they are all over. - /!\ This function assumes that all session properties are already set. + |!| This function assumes that all session properties are already set. Optional arguments can be provided: - Increase `nb_runs` to run more than 1 execution at once; - Set `refresh_time` to modify the default refresh time; @@ -1562,7 +1555,7 @@ def _check_input_settings(self, input_settings: dict=None, location: str=None) - if self._is_defined("_input_settings"): input_settings = self._get_input_settings(location) else: - raise AttributeError("Input settings are missing") + raise AttributeError("Input settings have not been defined.") # Check the pipeline identifier if not self._is_defined("_pipeline_id"): raise AttributeError("Input settings could not be checked without a pipeline identifier.") @@ -1584,14 +1577,14 @@ def _check_input_keys(self, input_settings: dict) -> None: # required parameters without a default value { param["name"] for param in self._pipeline_def['parameters'] - if not param["isOptional"] and (param["defaultValue"] == '$input.getDefaultValue()') + if not param["isOptional"] and param["defaultValue"] == None } # current parameters - set(input_settings.keys()) ) # Raise an error if a field is missing if missing_fields: - raise TypeError("Missing input parameter(s) :\n" + ", ".join(missing_fields)) + raise AttributeError("Missing input parameter(s): " + ", ".join(sorted(missing_fields))) # Check every input parameter is a valid field unknown_fields = ( set(input_settings.keys()) # current parameters @@ -1603,16 +1596,13 @@ def _check_input_keys(self, input_settings: dict) -> None: + f"'] are useless for pipeline '{self._pipeline_id}'. This may throw RuntimeError later.") # ------------------------------------------------ - # Check the parameter values according to pipeline descriptor - def _check_input_values(self, input_settings: dict, location: str) -> None: - """ - Checks if each parameter value in `input_settings` matches its pipeline description in `parameters`. - `location` refers to the storage infrastructure (e.g., VIP) to scan for missing files. - - Prerequisite: input_settings is defined and contains only strings, or lists of strings. - """ - # Browse the input parameters - for param in self._pipeline_def['parameters'] : + + def _check_invalid_input(self, input_settings: dict, parameters_ref=None): + missing_inputs = [] + invalid_chars_inputs = [] + if not parameters_ref: # Check from itself if no parameters_ref provided + parameters_ref = [{"name": param} for param in input_settings.keys()] + for param in parameters_ref: # Get parameter name name = param['name'] # Skip irrelevant inputs (this should not happen after self._check_input_keys()) @@ -1620,33 +1610,60 @@ def _check_input_values(self, input_settings: dict, location: str) -> None: continue # Get input value value = input_settings[name] - # `request` will send only strings - if not self._isinstance(value, str): # This should not happen - raise ValueError( # Parameter could not be parsed correctly - f"Parameter: '{name}' \n\twith value: '{value}' \n\twith type: '{type(value)}')\ncould not be parsed."\ - +"Please double check the value; if correct, try converting it to `str` in the `input_settings`." - ) + # Check the input has no empty values if not self._is_input_full(value): - raise ValueError( - f"Parameter '{name}' contains an empty value" - ) + missing_inputs.append(name) + continue # Check invalid characters for VIP invalid = self._invalid_chars_for_vip(value) if invalid: - raise ValueError( - f"Parameter '{name}' contains some invalid character(s): {', '.join(invalid)}" - ) - # If input is a File, check file(s) existence + invalid_chars_inputs.append((name, invalid)) + continue + + if missing_inputs: + raise ValueError( + f"Missing input value(s) for parameter(s): {', '.join(sorted(missing_inputs))}" + ) + if invalid_chars_inputs: + raise ValueError( + f"Invalid character(s) in input value(s) for parameter(s): {', '.join(sorted(invalid_chars_inputs))}" + ) + + # Check the parameter values according to pipeline descriptor + def _check_input_values(self, input_settings: dict, location: str) -> None: + """ + Checks if each parameter value in `input_settings` matches its pipeline description in `parameters`. + `location` refers to the storage infrastructure (e.g., VIP) to scan for missing files. + + Prerequisite: input_settings is defined and contains only strings, or lists of strings. + """ + self._check_invalid_input(input_settings, self._pipeline_def['parameters']) + + wrong_type_inputs = [] + for param in self._pipeline_def['parameters']: + name = param['name'] + value = input_settings.get(name) + # If input is a File, check file(s) existence if param["type"] == "File": # Ensure every file exists at `location` missing_file = self._first_missing_file(value, location) if missing_file: raise FileNotFoundError( - f"Parameter '{name}': The following file is missing in the {location.upper()} file system:\n\t{missing_file}" + f"Parameter '{name}': The following file is missing in the {location.upper()} file system: {missing_file}" ) + if param["type"] == "Boolean": + if value not in ["true", "false"]: + wrong_type_inputs.append(name) + continue # Check other input formats ? else: pass # TODO + + if wrong_type_inputs: + raise ValueError( + f"Wrong type(s) for parameter(s): {', '.join(sorted(wrong_type_inputs))}" + ) + # ------------------------------------------------ # Function to look for empty values @@ -1655,12 +1672,10 @@ def _is_input_full(cls, value): """ Returns False if `value` contains an empty string or list. """ - if isinstance(value, list) and cls._isinstance(value, str): # Case: list of strings - return all([(len(v) > 0) for v in value]) - elif isinstance(value, (str, list)): # Case: list or string - return (len(value) > 0) - else: # Case: other - return True + if isinstance(value, list): # Case: list + return len(value) > 0 and all([cls._is_input_full(v) for v in value]) + else: + return (len(str(value)) > 0) # ------------------------------------------------ # Function to assert the input contains only a certain Python type diff --git a/src/vip_client/classes/VipSession.py b/src/vip_client/classes/VipSession.py index e8c9e36..9047345 100644 --- a/src/vip_client/classes/VipSession.py +++ b/src/vip_client/classes/VipSession.py @@ -542,7 +542,7 @@ def run_session( 3. Monitors pipeline executions until they are all over; 4. Downloads execution results from VIP. - /!\ This method assumes that all session properties are already set. + |!| This method assumes that all session properties are already set. Optional arguments can still be provided: - Set `update_files` to False to avoid checking the input data on VIP; - Increase `nb_runs` to run more than 1 execution at once; @@ -583,7 +583,7 @@ def finish(self, timeout=300) -> VipSession: self._print( " Please run finish() from the original session or remove them manually on the VIP portal:") self._print(f"\t{self._VIP_PORTAL}") # Save the session - self._save() + self._save() # Return return self # ------------------------------------------------ @@ -1076,7 +1076,7 @@ def parse_value(input): elif isinstance(input, (str, os.PathLike)): # Case: VIP path if str(input).startswith(self._SERVER_PATH_PREFIX): # PurePath.is_relative_to() is unavailable for Python <3.9 - if self._is_defined('_vip_input_dir'): + if self._is_defined('_vip_input_dir'): input_dir = self._vip_input_dir input_path = PurePosixPath(input) else: # Return input if `_vip_input_dir` is unset @@ -1091,8 +1091,10 @@ def parse_value(input): return input # Return the part of `input_path` that is relative to `input_dir` (if relevant) try: # No condition since PurePath.is_relative_to() is unavailable for Python <3.9 - return PurePosixPath( # Force Posix flavor to avoid conflicts with Windows paths when checking equality - input_path.relative_to(input_dir)) # Relative part of `input_path` + if len(str(input)) > 0: + return PurePosixPath( # Force Posix flavor to avoid conflicts with Windows paths when checking equality + input_path.relative_to(input_dir)) # Relative part of `input_path` + return input except ValueError: # This is the case when no relative part could be found return input diff --git a/src/vip_client/utils/vip.py b/src/vip_client/utils/vip.py index d590ca7..f8f144e 100644 --- a/src/vip_client/utils/vip.py +++ b/src/vip_client/utils/vip.py @@ -141,6 +141,7 @@ def create_dir(path)->bool: """ Return True if done, False otherwise """ + path = path + '/' if path[-1] != '/' else path url = __PREFIX + 'path' + path rq = SESSION.put(url, headers=__headers) try: @@ -396,10 +397,10 @@ def get_exec_results(exec_id, timeout: int=None) -> str: # ----------------------------------------------------------------------------- def kill_execution(exec_id, deleteFiles=False) -> bool: - url = __PREFIX + 'executions/' + exec_id + url = __PREFIX + 'executions/' + exec_id + '/kill' if deleteFiles: url += '?deleteFiles=true' - rq = SESSION.delete(url, headers=__headers) + rq = SESSION.put(url, headers=__headers) try: manage_errors(rq) except RuntimeError: diff --git a/tests/FakeGirderClient.py b/tests/FakeGirderClient.py new file mode 100644 index 0000000..a1a40cb --- /dev/null +++ b/tests/FakeGirderClient.py @@ -0,0 +1,44 @@ + +class FakeGirderClient(): + + pipeline_id = "LCModel/0.1" + def __init__(self, apiUrl): + pass + def authenticate(self, apiKey): + return True + + def resourceLookup(self, path): + return {'_id': 'fake_id', '_modelType': 'folder'} + + def createFolder(self, parentId, name, reuseExisting=True, **kwargs): + return {'_id': 'fake_id'} + + def addMetadataToFolder(self, folderId, metadata): + return True + + def getFolder(cls, folderId): + metadata = { + 'input_settings': { + 'zipped_folder': 'fake_value', + 'basis_file': 'fake_value', + 'signal_file': ['fake_value', 'fake_value'], + 'control_file': ['fake_value']}, + "pipeline_id": cls.pipeline_id, + 'session_name': 'test-VipLauncher', + 'workflows': {}, + "vip_output_dir": "/vip/Home/test-VipLauncher/OUTPUTS" + } + return {'_id': 'fake_id', 'meta': metadata} + + def get(self, path): + return {'_id': 'fake_id'} + + def listFiles(self, folderId): + return [{'_id': 'fake_id'}] + + def listItem(self, folderId): + return {'_id': 'fake_id'} + + @classmethod + def set_pipeline_id(cls, pipeline_id): + cls.pipeline_id = pipeline_id \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 3588236..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Tests for the VIP Python Client -""" - -# Import classes and packages to secure the namespace -import sys -from pathlib import Path -SOURCE_ROOT = str(Path(__file__).parents[1] / "src") # <=> /src/ -sys.path.append(SOURCE_ROOT) -import vip_client \ No newline at end of file diff --git a/tests/data/file.txt b/tests/data/file.txt new file mode 100644 index 0000000..bad8398 --- /dev/null +++ b/tests/data/file.txt @@ -0,0 +1 @@ +This is juste a test file for upload \ No newline at end of file diff --git a/tests/data/file_copy.txt b/tests/data/file_copy.txt new file mode 100644 index 0000000..bad8398 --- /dev/null +++ b/tests/data/file_copy.txt @@ -0,0 +1 @@ +This is juste a test file for upload \ No newline at end of file diff --git a/tests/mocked_services.py b/tests/mocked_services.py new file mode 100644 index 0000000..bd41bbd --- /dev/null +++ b/tests/mocked_services.py @@ -0,0 +1,152 @@ +import io +from pathlib import Path + + +def mock_vip_api(mocker, pipeline_id): + + def fake_list_pipeline(): + return [ + {'identifier': 'LCModel/0.1', 'name': 'LCModel', 'description': None, + 'version': '0.1', 'parameters': [], 'canExecute': True}, + {'identifier': 'CQUEST/0.3', 'name': 'LCModel', 'description': None, + 'version': '0.1', 'parameters': [], 'canExecute': True} + ] + + def fake_set_api_key(api_key): + return True if api_key == "FAKE_KEY" else False + + def fake_pipeline_def(pipeline): + return { + 'identifier': pipeline_id, + 'name': 'LCModel', + 'description': 'MR spectrosocpy signal quantification software', + 'version': '0.1', + 'parameters': [ + { + 'name': 'zipped_folder', + 'type': 'File', + 'defaultValue': None, + 'description': 'Archive containing all metabolite & macromolecules in .RAW format', + 'isOptional': False, + 'isReturnedValue': False + }, + { + 'name': 'basis_file', + 'type': 'File', + 'defaultValue': None, + 'description': "Text file with extension '.basis' containing information & prior ...", + 'isOptional': False, + 'isReturnedValue': False + }, + { + 'name': 'signal_file', + 'type': 'File', + 'defaultValue': None, + 'description': "Text file with extension '.RAW' containing the signal to quantify", + 'isOptional': False, + 'isReturnedValue': False + }, + { + 'name': 'control_file', + 'type': 'File', + 'defaultValue': None, + 'description': "Text file with extension '.control' setting up constraints, options and prior knowledge used in LCModel algorithm", + 'isOptional': True, + 'isReturnedValue': False + }, + { + 'name': 'script_file', + 'type': 'File', + 'defaultValue': '/vip/ReproVIP (group)/LCModel/run-lcmodel.sh', + 'description': 'Script lauching lcmodel', + 'isOptional': False, 'isReturnedValue': False + } + ], + 'canExecute': True + } + + def fake_init_exec(pipeline, name, inputValues, resultsLocation): + return 'workflow-XXXXXX' + + def fake_execution_info(workflow_id): + return {'status': 'Finished', 'returnedFiles': [], 'startDate': 0} + + def fake_list_elements(self): + return [{'name': 'element1', 'path': 'path1'}, {'name': 'element2', 'path': 'path2'}] + + def fake_exists(path): + return False + + #mocker.patch("vip_client.utils.vip.exists", side_effect = fake_exists) + mocker.patch("vip_client.utils.vip.upload").return_value = True + mocker.patch("vip_client.utils.vip.download").return_value = True + mocker.patch("vip_client.utils.vip.pipeline_def").side_effect = fake_pipeline_def + mocker.patch("vip_client.utils.vip.list_pipeline").side_effect = fake_list_pipeline + mocker.patch("vip_client.utils.vip.setApiKey").side_effect = fake_set_api_key + mocker.patch("vip_client.utils.vip.init_exec").side_effect = fake_init_exec + mocker.patch("vip_client.utils.vip.execution_info").side_effect = fake_execution_info + mocker.patch("vip_client.utils.vip.list_elements").side_effect = fake_list_elements + +def mock_pathlib(mocker): + + def fake_pathlib_open(mode='r', buffering=-1, encoding=None, errors=None, newline=None): + return io.open('tmp_data.json', mode, buffering, encoding, errors, newline) + + def fake_pathlib_iterdir(): + return [Path('tmp_data.json')] + + mocker.patch("pathlib.Path.exists").return_value = True + mocker.patch("pathlib.Path.unlink").return_value = True + mocker.patch("pathlib.Path.open").side_effect = fake_pathlib_open + mocker.patch("pathlib.Path.iterdir").side_effect = fake_pathlib_iterdir + +def mock_os(mocker): + mocker.patch("os.unlink") + + class FakeGirderClient(): + + pipeline_id = "LCModel/0.1" + def __init__(self, apiUrl): + pass + def authenticate(self, apiKey): + return True + + def resourceLookup(self, path): + return {'_id': 'fake_id', '_modelType': 'folder'} + + def createFolder(self, parentId, name, reuseExisting=True, **kwargs): + return {'_id': 'fake_id'} + + def addMetadataToFolder(self, folderId, metadata): + return True + + def getFolder(cls, folderId): + metadata = { + 'input_settings': { + 'zipped_folder': 'fake_value', + 'basis_file': 'fake_value', + 'signal_file': ['fake_value', 'fake_value'], + 'control_file': ['fake_value']}, + "pipeline_id": cls.pipeline_id, + 'session_name': 'test-VipLauncher', + 'workflows': {}, + "vip_output_dir": "/vip/Home/test-VipLauncher/OUTPUTS" + } + return {'_id': 'fake_id', 'meta': metadata} + + def get(self, path): + return {'_id': 'fake_id'} + + def listFiles(self, folderId): + return [{'_id': 'fake_id'}] + + def listItem(self, folderId): + return {'_id': 'fake_id'} + + @classmethod + def set_pipeline_id(cls, pipeline_id): + cls.pipeline_id = pipeline_id + +def mock_girder_client(mocker): + from FakeGirderClient import FakeGirderClient + mocker.patch("girder_client.GirderClient", FakeGirderClient) \ No newline at end of file diff --git a/tests/test_VipCI.py b/tests/test_VipCI.py new file mode 100644 index 0000000..0c07205 --- /dev/null +++ b/tests/test_VipCI.py @@ -0,0 +1,177 @@ +import io +from urllib.error import HTTPError +import pytest +from pathlib import * + +import pytest_mock +from src.vip_client.utils import vip +from src.vip_client.classes import VipCI +from mocked_services import mock_vip_api, mock_girder_client, mock_pathlib, mock_os +from FakeGirderClient import FakeGirderClient + + +def get_properties(obj) -> dict: + """ + Get session properties as they should be returned by the getter functions + """ + # Function to parse a single element + def get_element(element): + if isinstance(element, dict): + return {key: get_element(value) for key, value in element.items()} + elif isinstance(element, list): + return [get_element(value) for value in element] + elif element is None: + return None + else: + return str(element) + # Return + return {prop: get_element(value) for prop, value in obj.input_properties.items()} + +@pytest.fixture(scope="function", autouse=True) +def setup_teardown_vip_launcher(request, mocker): + # Mock services + mock_vip_api(mocker, "LCModel/0.1") + mock_girder_client(mocker) + mock_pathlib(mocker) + mock_os(mocker) + + # Create a buffer file for the backup + with open('tmp_data.json', 'w') as f: + f.write('{}') + + # Setup code before running the tests in the class + print("Handshake with VIP") + VipCI.init(vip_key="FAKE_KEY", girder_key="FAKE_KEY") + print("Setup done") + +@pytest.fixture(scope="function", autouse=True) +def cleanup(): + # Teardown code after running each test function + yield + # Remove the buffer file + try: + Path('tmp_data.json').unlink() + except FileNotFoundError: + pass + + +@pytest.mark.parametrize( + "nb_runs, pipeline_id", + [ + (1, "LCModel/0.1"), + (2, "CQUEST/0.3"), + (3, "LCModel/0.1") + ] +) +def test_run_and_finish(mocker, nb_runs, pipeline_id): + + FakeGirderClient.set_pipeline_id(pipeline_id) + wf_counter = 0 + processing = True + + def fake_init_exec(pipeline, name, inputValues, resultsLocation): + nonlocal wf_counter + wf_counter += 1 + return f'workflow-{wf_counter}' + + def fake_execution_info(workflow_id): + nonlocal processing + if not processing: + return {'status': 'Finished', 'returnedFiles': [], 'startDate': 0} + processing -= 1 + return {'status': 'Running', 'returnedFiles': [], 'startDate': 0} + + # Re patch the init_exec function to update the workflow counter + mocker.patch("vip_client.utils.vip.init_exec").side_effect = fake_init_exec + mocker.patch("vip_client.utils.vip.execution_info").side_effect = fake_execution_info + + # Launch a Full Session Run + s = VipCI() + s.pipeline_id = pipeline_id + s.output_dir = PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS") + s.input_settings = { + "zipped_folder": 'fake_value', + "basis_file": 'fake_value', + "signal_file": ['fake_value', 'fake_value'], + "control_file": ['fake_value'] + } + s.run_session(nb_runs=nb_runs) + # Check the Results + assert s.workflows + assert len(s.workflows) == nb_runs + for wid in s.workflows: + assert s.workflows[wid]["status"] == "Finished" + assert s.pipeline_id == pipeline_id + +@pytest.mark.parametrize( + "backup_location, input_settings, pipeline_id, output_dir", + [ + ('girder', { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), + ), + (None, { + "zipped_folder": None, + "basis_file": None, + "signal_file": None, + "control_file": None + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), + ), + ('girder', { + "zipped_folder": 'different_value1', + "basis_file": 'different_value2', + "signal_file": ['different_value3', 'different_value4'], + "control_file": ['different_value5'] + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), + ) + ] +) +def test_backup(mocker, backup_location, input_settings, pipeline_id, output_dir): + + VipCI._BACKUP_LOCATION = backup_location + + # Create session + s1 = VipCI(pipeline_id=pipeline_id, input_settings=input_settings) + s1.output_dir = output_dir + + assert s1._save() is not (VipCI._BACKUP_LOCATION is None) # Return False if no backup location + + # Load backup + s2 = VipCI(output_dir=s1.output_dir) + # Check parameters + assert s2.output_dir == s1.output_dir + if VipCI._BACKUP_LOCATION is None: + assert not s2._load() + assert s2.input_settings != s1.input_settings + assert s2.pipeline_id != s1.pipeline_id + else: + assert s2.input_settings == s1.input_settings + assert s2.pipeline_id == s1.pipeline_id + + +def test_properties_interface(mocker): + + VipCI._BACKUP_LOCATION = "girder" + + # Copy the first session + s = VipCI() + s.input_settings = { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + } + # Backup the inputs + backup = s.input_settings + # Run a subtest for each property + for prop in s.input_settings: + setattr(s, prop, None) # Calls deleter + assert getattr(s, prop) is None # Public attribute must be None + assert not s._is_defined("_" + prop) # Private attribute must be unset + setattr(s, prop, backup[prop]) # Reset + # Test correct reset + for key, value in s.input_settings.items(): + assert getattr(s, key) == value diff --git a/tests/test_VipLauncher.py b/tests/test_VipLauncher.py index fee1dfb..d5fe2a7 100644 --- a/tests/test_VipLauncher.py +++ b/tests/test_VipLauncher.py @@ -1,300 +1,214 @@ -import unittest +import io +import pytest from pathlib import * -try: # Use through unittest - from vip_client.classes import VipLauncher -except ModuleNotFoundError: # Use as a script - import sys - SOURCE_ROOT = str(Path(__file__).parents[1] / "src") # <=> /src/ - sys.path.append(SOURCE_ROOT) - from vip_client.classes import VipLauncher - -# class SessionInputs(): -# """Class to record parameters for a given Session""" - -# def __init__(self, **kwargs) -> None: -# """Generic constructor""" -# for key, value in kwargs.items(): -# setattr(self, key, value) -# # ------------------------------------------------ - -# def to_dict(self, *args) -> dict: -# """Get several parameters at once as a dictionary""" -# if not args: return self.__dict__ -# else: return {prop: getattr(self, prop) for prop in args} -# # ------------------------------------------------ -# # ------------------------------------------------------------------ - -class VipLauncher_(VipLauncher): - - # Data paths - VIP_INPUT_DIR = PurePosixPath("/vip/Home/test-VipLauncher/INPUTS/lcmodel_sample") - VIP_OUTPUT_DIR = PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS") - - # Variables to keep track of sessions - SESSION_NBR = 0 - SESSIONS = [] +from src.vip_client.utils import vip +from src.vip_client.classes import VipLauncher +from mocked_services import mock_vip_api, mock_pathlib, mock_os + + +def get_properties(obj) -> dict: + """ + Get session properties as they should be returned by the getter functions + """ + # Function to parse a single element + def get_element(element): + if isinstance(element, dict): + return {key: get_element(value) for key, value in element.items()} + elif isinstance(element, list): + return [get_element(value) for value in element] + elif element is None: + return None + else: + return str(element) + # Return + return {prop: get_element(value) for prop, value in obj.input_properties.items()} + +@pytest.fixture(scope="function", autouse=True) +def setup_teardown_vip_launcher(request, mocker): + # Create a buffer file for the backup + with open('tmp_data.json', 'w') as f: + f.write('{}') + # Mock the VIP API + mock_vip_api(mocker, "LCModel/0.1") + mock_pathlib(mocker) + mock_os(mocker) + # Setup code before running the tests in the class + print("Handshake with VIP") + VipLauncher.init(api_key="FAKE_KEY") + print("Setup done") + +@pytest.fixture(scope="function", autouse=True) +def cleanup(): + # Teardown code after running each test function + yield + # Remove the buffer file + try: + Path('tmp_data.json').unlink() + except FileNotFoundError: + pass + + +@pytest.mark.parametrize( + "nb_runs, pipeline_id", + [ + (1, "LCModel/0.1"), + (2, "CQUEST/0.3"), + (3, "LCModel/0.1") + ] +) +def test_run_and_finish(mocker, nb_runs, pipeline_id): + + removed = False + + def fake_exists(path): + if path == '/vip/Home/test-VipLauncher/OUTPUTS' and not removed: + return True + if path == 'fake_value' and not removed: + return True + return False - # Constructor - def __init__(self, *args, **kwargs) -> None: - self.SESSION_NBR += 1 - if args or kwargs: # For a regular VipLauncher initialization - super().__init__(*args, **kwargs) - else: # Initiate with testing inputs - inputs = self.testing_inputs(self.SESSION_NBR) - super().__init__(**inputs) - self.input_properties = inputs - self.output_properties = self.get_properties() - # Append to session register - self.SESSIONS.append(self) - # ------------------------------------------ - - # Basic inputs for VipLauncher - def testing_inputs(self, session_number) -> dict: - session_name = "Test_VipLauncher_" + str(session_number) - return { - 'session_name': session_name, - 'output_dir' : self.VIP_OUTPUT_DIR / session_name, - 'pipeline_id' : "LCModel/0.1", - 'input_settings': { - # Regular path - "zipped_folder": self.VIP_INPUT_DIR / "basis.zip", - # String path - "basis_file": str(self.VIP_INPUT_DIR / "Basis_117T.basis"), - # List of paths - "signal_file": [ (self.VIP_INPUT_DIR / s) for s in ( - "signals/Rec001.RAW", - "signals/Rec002.RAW" - ) - ], - # List of a single string - "control_file": [str(self.VIP_INPUT_DIR / "parameters/fit_117T_A.control")] - } - } - # ------------------------------------------ - - # Oracle for properties values - def get_properties(self) -> dict: - """ - Get session properties as they should be returned by the getter functions - """ - # Function to parse a single element - def get_element(element): - if isinstance(element, dict): - return {key: get_element(value) for key, value in element.items()} - elif isinstance(element, list): - return [get_element(value) for value in element] - elif element is None: - return None - else: - return str(element) - # Return - return {prop: get_element(value) for prop, value in self.input_properties.items()} - # ------------------------------------------ - - # Destructor -# ------------------------------------------------------------------ - - -class Test_VipLauncher(unittest.TestCase): - - __name__ = "Test_VipLauncher" - - @classmethod - def setUpClass(cls) -> None: - # Handshake with VIP - VipLauncher.init(api_key="VIP_API_KEY") - - @classmethod - def tearDownClass(cls) -> None: - for session in VipLauncher_.SESSIONS: - # Clean temporary data on VIP - session.finish() - # ------------------------------------------------ - - # Function Testing - - def test_run_and_finish(self): - # Launch a Full Session Run - s = VipLauncher_().run_session() - # Check the Results - self.assertFullRun(s) - # Finish the Session - s.finish(timeout=50) - # Check Deletion - self.assertFinish(s) - # ------------------------------------------------ - - def test_backup(self): - # Return if backup is disabled - if VipLauncher_._BACKUP_LOCATION is None: - return - # Create session - s1 = VipLauncher_() - # Backup - s1._save() - # Load backup - s2 = VipLauncher_(output_dir=s1.output_dir) - # Check parameters - self.checkProperties(s2, s1.output_properties) - # ------------------------------------------------ - - # Public interface for properties - - def test_properties_interface(self): - """ - Tests tha an attribute can be accessed, set and deleted - according to the interface rules set in @property - """ - # Copy the first session - s = VipLauncher_() - # Backup the inputs - backup = s.input_properties - # Run a subtest for each property - for prop in s.input_properties: - with self.subTest(property=prop): - setattr(s, prop, None) # Calls deleter - self.assert_(getattr(s, prop) is None) # Public attribute must be None - self.failIf(s._is_defined("_" + prop)) # Private attribute must be unset - setattr(s, prop, backup[prop]) # Reset - # Test correct reset - self.assertEqual(s.get_properties(), s.output_properties) - # ------------------------------------------------ - - # Display - - def test_log_wrapper(self): - """Tests that VipLauncher._printc correctly uses `textwrap.TextWrapper.fill()`""" - import textwrap - # Test wrapper & text - wrapper = textwrap.TextWrapper( - width = 70, - initial_indent = ' ', - subsequent_indent = '', - max_lines = 5, - drop_whitespace = False, - replace_whitespace = False, - break_on_hyphens = False, - break_long_words = False + def fake_delete_path(path): + nonlocal removed + removed = True + return True + + mocker.patch("vip_client.utils.vip.exists").side_effect = fake_exists + mocker.patch("vip_client.utils.vip.delete_path").side_effect = fake_delete_path + + removed = False + + # Launch a Full Session Run + s = VipLauncher() + s.pipeline_id = pipeline_id + s.output_dir = PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS") + s.input_settings = { + "zipped_folder": 'fake_value', + "basis_file": 'fake_value', + "signal_file": ['fake_value', 'fake_value'], + "control_file": ['fake_value'] + } + s.run_session(nb_runs=nb_runs) + # Check the Results + assert s.workflows + assert len(s.workflows) == 1 + for wid in s.workflows: + assert s.workflows[wid]["status"] == "Finished" + assert s.pipeline_id == pipeline_id + # Finish the Session + s.finish(timeout=1) + # Check Deletion + assert removed + for wid in s.workflows: + assert s.workflows[wid]["status"] == "Removed" + +@pytest.mark.parametrize( + "backup_location, input_settings, pipeline_id, output_dir", + [ + ('vip', { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), + ), + (None, { + "zipped_folder": None, + "basis_file": None, + "signal_file": None, + "control_file": None + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), + ), + ('vip', { + "zipped_folder": 'different_value1', + "basis_file": 'different_value2', + "signal_file": ['different_value3', 'different_value4'], + "control_file": ['different_value5'] + }, "LCModel/0.1", PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS"), ) - text = """Parses the input settings, i.e.: - - Converts all input paths (local or VIP) to PathLib objects - and write them relatively to their input directory. For example: - '/vip/Home/API/INPUTS/my_signals/signal001' becomes: 'my_signals/signal001' - - Leaves the other parameters untouched.\n""" - # Setup temporary files - f1, f2 = Path("tmp/test_1.txt"), Path("tmp/test_2.txt") - if not f1.parent.is_dir(): - f1.parent.mkdir() - # Write - with f1.open("w") as t1, f2.open("w") as t2: - print(wrapper.fill(text), end="", file=t1) # oracle - VipLauncher._printc(text, end="", wrapper=wrapper, file=t2) # test - # Read - with f1.open("r") as t1, f2.open("r") as t2: - self.assertEqual(t1.read(), t2.read()) - # End : Delete temporary files - f1.unlink() - f2.unlink() - try: f1.parent.rmdir() - except: pass - # ------------------------------------------------ - - # Robustness testing - - # Conflicts - def test_backup_with_conflicts(self): - # Return if backup is disabled - if VipLauncher_._BACKUP_LOCATION is None: - return - # Create session - s1 = VipLauncher_() - # Backup - s1._save() - # Load backup with conflicting session name - s2 = VipLauncher_(output_dir=s1.output_dir, session_name="other-name") - # Check parameters - self.checkProperties(s2, s1.output_properties) - # ------------------------------------------------ - - # Public interface for properties - def test_properties_interface_with_conflicts(self): - return # TODO - s = VipLauncher_.SESSIONS[0] - # Backup the inputs - backup = s.input_properties - # Run a subtest for each property - for prop in s.input_properties: - with self.subTest(property=prop): - setattr(s, prop, None) # Calls deleter - self.assert_(getattr(s, prop) is None) # Public attribute must be None - self.failIf(s._is_defined("_" + prop)) # Private attribute must be unset - setattr(s, prop, backup[prop]) # Reset - # Test correct reset - self.assertEqual(s.get_properties(), s.output_properties) - # ------------------------------------------------ - - # UTILS - - # Function to assert if a file exists - def assertExists(self, path, location): - return self.assert_(VipLauncher._exists(path, location)) - # ------------------------------------------------ + ] +) +def test_backup(mocker, backup_location, input_settings, pipeline_id, output_dir): + + removed = False + removed2 = False + access_counter = 3 - # Function to assert if a file does not exists - def assertNotExists(self, path, location): - return self.failIf(VipLauncher._exists(path, location)) - # ------------------------------------------------ - - # Function to assert full run success - def assertFullRun(self, s: VipLauncher): - """ - Asserts that a full run worked by checking output files - """ - # Output directory & Metadata - self.assertExists(s._vip_output_dir, "vip") - if s._BACKUP_LOCATION is not None: - self.assertExists(s._vip_output_dir / s._SAVE_FILE, s._BACKUP_LOCATION) - # Browse output files - self.assert_(s.workflows) - for wid in s.workflows: - # Workflow finished - self.assertEqual(s.workflows[wid]["status"], "Finished") - # Output exists - self.assert_(s.workflows[wid]["outputs"]) - for output in s.workflows[wid]["outputs"]: - # File exists - self.assertExists(PurePosixPath(output["path"]), "vip") - # ------------------------------------------------ - - def checkProperties(self, session: VipLauncher_, properties: dict): - """Assert that all parameters match""" - p = properties - s = session._get(*(p.keys())) - return self.assertDictEqual(s, p) - # ------------------------------------------------ - - # Function to assert full run success - def assertFinish(self, s: VipLauncher): - """ - Asserts that a full run worked by checking output files - """ - # Local output directory does not exist - self.assertNotExists(s.vip_output_dir, "vip") - # Browse output files - self.assert_(s.workflows) - for wid in s.workflows: - # Workflow removed - self.assert_(s.workflows[wid]["status"] == "Removed") - # Output exists - self.assert_(s.workflows[wid]["outputs"]) - for output in s.workflows[wid]["outputs"]: - # File exists - self.assertNotExists(PurePosixPath(output["path"]), "vip") - # ------------------------------------------------ + def fake_exists(path): + nonlocal access_counter + if path == '/vip/Home/test-VipLauncher/OUTPUTS/session_data.json': + if access_counter > 0: + access_counter -= 1 + return False + else: + return True + nonlocal removed2 + if path == '/vip/Home/test-VipLauncher/OUTPUTS' and not removed: + return True + if path == "/vip/Home/test-VipLauncher/OUTPUTS/session_data.json" and not removed2: + removed2 = True + return False + if path == 'fake_value' and not removed: + return True + return True - -if __name__=="__main__": - unittest.main() - - + def fake_delete_path(path): + nonlocal removed + removed = True + return True + + mocker.patch("vip_client.utils.vip.exists").side_effect = fake_exists + mocker.patch("vip_client.utils.vip.delete_path").side_effect = fake_delete_path + + mock_vip_api(mocker, pipeline_id) + mock_pathlib(mocker) + mock_os(mocker) + + VipLauncher._BACKUP_LOCATION = backup_location + # Create session + s1 = VipLauncher() + s1.input_settings = input_settings + s1.pipeline_id = pipeline_id + s1.output_dir = output_dir + + # Return if backup is disabled + assert s1._save() is not (VipLauncher._BACKUP_LOCATION is None) # Return False if no backup location + + # Load backup + s2 = VipLauncher(output_dir=s1.output_dir) + # Check parameters + assert s2.output_dir == s1.output_dir + if VipLauncher._BACKUP_LOCATION is None: + assert not s2._load() + assert s2.input_settings != s1.input_settings + assert s2.pipeline_id != s1.pipeline_id + else: + assert s2.input_settings == s1.input_settings + assert s2.pipeline_id == s1.pipeline_id + + +def test_properties_interface(mocker): + + mocker.patch("vip_client.utils.vip.exists").return_value = True + + VipLauncher._BACKUP_LOCATION = "vip" + + # Copy the first session + s = VipLauncher(output_dir=PurePosixPath("/vip/Home/test-VipLauncher/OUTPUTS")) + s.input_settings = { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + } + # Backup the inputs + backup = s.input_settings + # Run a subtest for each property + for prop in s.input_settings: + setattr(s, prop, None) # Calls deleter + assert getattr(s, prop) is None # Public attribute must be None + assert not s._is_defined("_" + prop) # Private attribute must be unset + setattr(s, prop, backup[prop]) # Reset + # Test correct reset + for key, value in s.input_settings.items(): + assert getattr(s, key) == value diff --git a/tests/test_VipSession.py b/tests/test_VipSession.py index 2da6ebe..cc4fdaf 100644 --- a/tests/test_VipSession.py +++ b/tests/test_VipSession.py @@ -1,15 +1,202 @@ -from test_VipLauncher import Test_VipLauncher, VipLauncher_ +import io +from unittest.mock import patch +import pytest +from pathlib import * -class Test_VipSession(Test_VipLauncher): - # Test upload / download - # Test portability between sessions +import pytest_mock +from src.vip_client.utils import vip +from src.vip_client.classes import VipSession +from mocked_services import mock_vip_api, mock_pathlib, mock_os - pass +def get_properties(obj) -> dict: + """ + Get session properties as they should be returned by the getter functions + """ + # Function to parse a single element + def get_element(element): + if isinstance(element, dict): + return {key: get_element(value) for key, value in element.items()} + elif isinstance(element, list): + return [get_element(value) for value in element] + elif element is None: + return None + else: + return str(element) + # Return + return {prop: get_element(value) for prop, value in obj.input_properties.items()} + +@pytest.fixture(scope="function", autouse=True) +def setup_teardown_vip_launcher(request, mocker): + + # Create a buffer file for the backup + with open('tmp_data.json', 'w') as f: + f.write('{}') + + # Mock the VIP API + mock_vip_api(mocker, "LCModel/0.1") + mock_pathlib(mocker) + mock_os(mocker) + + # Setup code before running the tests in the class + print("Handshake with VIP") + VipSession.init(api_key="FAKE_KEY") + print("Setup done") + +@pytest.fixture(scope="function", autouse=True) +def cleanup(): + # Teardown code after running each test function + yield + # Remove the buffer file + try: + Path('tmp_data.json').unlink() + except FileNotFoundError: + pass + + +@pytest.mark.parametrize( + "nb_runs, pipeline_id", + [ + (1, "LCModel/0.1"), + (2, "CQUEST/0.3"), + (3, "LCModel/0.1") + ] +) +def test_run_and_finish(mocker, nb_runs, pipeline_id): + + wf_counter = 0 + processing = 100 + + + def fake_exists(cls=None, path=None, location="local"): + return True + + with patch.object(VipSession, '_exists', fake_exists): + + def fake_init_exec(pipeline, name, inputValues, resultsLocation): + nonlocal wf_counter + wf_counter += 1 + return 'workflow-X' + str(wf_counter) + + def fake_execution_info(workflow_id): + nonlocal processing + if not processing: + return {'status': 'Finished', 'returnedFiles': [], 'startDate': 0} + processing -= 1 + return {'status': 'Running', 'returnedFiles': [], 'startDate': 0} + + + mocker.patch("vip_client.utils.vip.execution_info").side_effect = fake_execution_info + + + mocker.patch("vip_client.utils.vip.execution_info").side_effect = fake_execution_info + mocker.patch("vip_client.utils.vip.init_exec").side_effect = fake_init_exec + + # Launch a Full Session Run + s = VipSession(output_dir="test-VipSession/out", input_dir="test-VipSession/in") + s.pipeline_id = pipeline_id + s.input_settings = { + "zipped_folder": 'path/on/host/input.zip', + "basis_file": 'path/on/host/fake_value', + "signal_file": ['path/on/host/fake_value', 'path/on/host/fake_value'], + "control_file": ['path/on/host/fake_value'] + } + s.run_session(nb_runs=nb_runs, refresh_time=0) + # Check the Results + assert s.workflows + assert len(s.workflows) == nb_runs + for wid in s.workflows: + assert s.workflows[wid]["status"] == "Finished" + assert s.pipeline_id == pipeline_id + +@pytest.mark.parametrize( + "backup_location, input_settings, pipeline_id, output_dir", + [ + ('local', { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + }, "LCModel/0.1", "test-VipSession/out", + ), + (None, { + "zipped_folder": None, + "basis_file": None, + "signal_file": None, + "control_file": None + }, "LCModel/0.1", "test-VipSession/out", + ), + ('local', { + "zipped_folder": 'different_value1', + "basis_file": 'different_value2', + "signal_file": ['different_value3', 'different_value4'], + "control_file": ['different_value5'] + }, "LCModel/0.1", "test-VipSession/out", + ) + ] +) +def test_backup(mocker, backup_location, input_settings, pipeline_id, output_dir): + + def fake_pipeline_def(pipeline): + return {'identifier': pipeline_id, 'name': 'LCModel', 'description': 'MR spectrosocpy signal quantification software', 'version': '0.1', 'parameters': [{'name': 'zipped_folder', 'type': 'File', 'defaultValue': '$input.getDefaultValue()', 'description': 'Archive containing all metabolite & macromolecules in .RAW format', 'isOptional': False, 'isReturnedValue': False}, {'name': 'basis_file', 'type': 'File', 'defaultValue': '$input.getDefaultValue()', 'description': "Text file with extension '.basis' containing information & prior knowledge about the metabolites used for signal fit", 'isOptional': False, 'isReturnedValue': False}, {'name': 'signal_file', 'type': 'File', 'defaultValue': '$input.getDefaultValue()', 'description': "Text file with extension '.RAW' containing the signal to quantify", 'isOptional': False, 'isReturnedValue': False}, {'name': 'control_file', 'type': 'File', 'defaultValue': '$input.getDefaultValue()', 'description': "Text file with extension '.control' setting up constraints, options and prior knowledge used in LCModel algorithm", 'isOptional': False, 'isReturnedValue': False}, {'name': 'script_file', 'type': 'File', 'defaultValue': '/vip/ReproVIP (group)/LCModel/run-lcmodel.sh', 'description': 'Script lauching lcmodel', 'isOptional': False, 'isReturnedValue': False}], 'canExecute': True} + + s1_init = True + + def fake_is_file(): + nonlocal s1_init + return not s1_init # If s1 is initialized, return False for not using the backup + + # Mock the VipSession method "_exists" + mocker.patch.object(VipSession, '_exists', return_value=True) + + mocker.patch("vip_client.utils.vip.pipeline_def").side_effect = fake_pipeline_def + mocker.patch("pathlib.Path.is_file").side_effect = fake_is_file - # @classmethod - # def tearDownClass(cls) -> None: - # for session in VipLauncher_.SESSIONS: - # # Clean temporary data on VIP - # session.finish() - # # Clean local data - # session._output_dir.unlink() \ No newline at end of file + VipSession._BACKUP_LOCATION = backup_location + # Return if backup is disabled + + # Create session + s1 = VipSession(output_dir=output_dir) + s1.input_settings = input_settings + s1.pipeline_id = pipeline_id + + assert s1._save() is not (VipSession._BACKUP_LOCATION is None) # Return False if no backup location + + # Backup + s1._save() + # Set the s1 initialization flag to False + s1_init = False + # Load backup + s2 = VipSession(output_dir=s1.output_dir) + # Check parameters + assert s2.output_dir == s1.output_dir + if VipSession._BACKUP_LOCATION is None: + assert not s2._load() + assert s2.input_settings != s1.input_settings + assert s2.pipeline_id != s1.pipeline_id + else: + assert s2.input_settings == s1.input_settings + assert s2.pipeline_id == s1.pipeline_id + +def test_properties_interface(mocker): + + VipSession._BACKUP_LOCATION = "local" + + # Copy the first session + s = VipSession() + s.input_settings = { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": ['fake_value5'] + } + # Backup the inputs + backup = s.input_settings + # Run a subtest for each property + for prop in s.input_settings: + setattr(s, prop, None) # Calls deleter + assert getattr(s, prop) is None # Public attribute must be None + assert not s._is_defined("_" + prop) # Private attribute must be unset + setattr(s, prop, backup[prop]) # Reset + # Test correct reset + for key, value in s.input_settings.items(): + assert getattr(s, key) == value diff --git a/tests/test_global.py b/tests/test_global.py new file mode 100644 index 0000000..1784bfd --- /dev/null +++ b/tests/test_global.py @@ -0,0 +1,134 @@ +from src.vip_client.classes import VipSession, VipLauncher, VipCI +from mocked_services import mock_vip_api, mock_pathlib, mock_os, mock_girder_client +import pytest + + +test_cases_missing_input_fields = [ + { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'] + }, + { + "zipped_folder": 'fake_value1', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": 'fake_value5' + }, + { + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + }, + { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + }, + { + } +] + +# VipSession trouve pas que l'input est vide quand on a '' et non [] +test_cases_missing_input_values = [ + { + "zipped_folder": 'fake_value1', + "basis_file": '', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": 'fake_value5' + }, + { + "zipped_folder": 'fake_value1', + "basis_file": 'fake_value2', + "signal_file": [], + "control_file": 'fake_value5' + }, + { + "zipped_folder": '', + "basis_file": 'fake_value2', + "signal_file": ['fake_value3', 'fake_value4'], + "control_file": 'fake_value5' + } +] + +test_cases_missing_input_fields = [(input_settings, tested_class) for input_settings in test_cases_missing_input_fields for tested_class in [VipSession, VipLauncher, VipCI]] +test_cases_missing_input_values = [(input_settings, tested_class) for input_settings in test_cases_missing_input_values for tested_class in [VipSession, VipLauncher, VipCI]] + +@pytest.fixture(scope="function", autouse=True) +def setup_teardown_vip_launcher(request, mocker): + # Mock the VIP API + mock_vip_api(mocker, "LCModel/0.1") + mock_pathlib(mocker) + mock_os(mocker) + mock_girder_client(mocker) + + # Setup code before running the tests in the class + print("Handshake with VIP") + VipSession.init(api_key="FAKE_KEY") + VipLauncher.init(api_key="FAKE_KEY") + VipCI.init(vip_key="FAKE_KEY", girder_key="FAKE_KEY") + print("Setup done") + + +@pytest.mark.parametrize( + "input_settings, tested_class", test_cases_missing_input_fields +) +def test_missing_input_settings(input_settings, tested_class): + + VipCI._BACKUP_LOCATION = None + + # Copy the first session + s = VipCI() + s.pipeline_id = "LCModel/0.1" + s.output_dir = "/path/to/output" + s.input_settings = input_settings + + needed_fields = ["zipped_folder", "basis_file", "signal_file"] + missing_fields = [field for field in needed_fields if field not in input_settings] + + if not missing_fields: + s.run_session() + return + + # catch the exception message + with pytest.raises(AttributeError) as e: + s.run_session() + assert str(e.value) == "Missing input parameter(s): " + ", ".join(sorted(missing_fields)) + + + +@pytest.mark.parametrize( + "input_settings, tested_class", test_cases_missing_input_values +) +def test_missing_input_values(mocker, input_settings, tested_class): + + def is_input_full(value): + """ + Returns False if `value` contains an empty string or list. + """ + if isinstance(value, list): # Case: list + return len(value) > 0 and all([is_input_full(v) for v in value]) + else: + return (len(str(value)) > 0) + + tested_class._BACKUP_LOCATION = None + + mocker.patch("pathlib.Path.is_file").return_value = True + + # Copy the first session + s = tested_class() + s.pipeline_id = "LCModel/0.1" + if tested_class == VipSession: + mocker.patch.object(VipSession, '_exists', return_value=True) + s.input_dir = "." + else: + s.output_dir = "/path/to/output" + + missing_fields = [field for field in input_settings if not is_input_full(input_settings[field])] + + if not missing_fields: + s.input_settings = input_settings + s.run_session() + return + # Catch the exception message + with pytest.raises(ValueError) as e: + s.input_settings = input_settings + s.run_session() + assert str(e.value) == "Missing input value(s) for parameter(s): " + ", ".join(sorted(missing_fields)) diff --git a/tests/test_vip_utils.py b/tests/test_vip_utils.py new file mode 100644 index 0000000..f559c0e --- /dev/null +++ b/tests/test_vip_utils.py @@ -0,0 +1,134 @@ +import pytest +import time +import os + +from src.vip_client.utils.vip import * + +BASE_PATH_VIP = '/vip/Home/API/client_tests/' +BASE_PATH_LOCAL = 'tests/data/' + + +def compare_files(file1, file2): + with open(file1, 'r') as f1, open(file2, 'r') as f2: + return f1.read() == f2.read() + +@pytest.fixture(scope="session", autouse=True) +def setup_teardown_vip_launcher(): + assert setApiKey(os.environ['VIP_API_KEY']) + assert new_session() + if not create_dir(BASE_PATH_VIP): + raise Exception("Error creating directory") + counter = 0 + while not exists(BASE_PATH_VIP): + time.sleep(1) + if counter > 100: + raise Exception("Directory not created after delay") + counter += 1 + yield + assert delete_path(BASE_PATH_VIP) + +def test_upload_download(): + assert upload(BASE_PATH_LOCAL + 'file.txt', BASE_PATH_VIP + 'file.txt') + assert exists(BASE_PATH_VIP + 'file.txt') + assert download(BASE_PATH_VIP + 'file.txt', BASE_PATH_LOCAL + 'file_copy.txt') + assert compare_files(BASE_PATH_LOCAL + 'file.txt', BASE_PATH_LOCAL + 'file_copy.txt') + assert delete_path(BASE_PATH_VIP + 'file.txt') + + +def test_init_exec(): + input_values = { + 'mand_text': 'value1', + 'mand_file': '/vip/Home/file.txt', + } + exec_id = init_exec('Fake_app_test/0.1', resultsLocation=BASE_PATH_VIP, inputValues=input_values, name='test_init_exec') + exec_info = execution_info(exec_id) + assert exec_info['status'] == 'Running' + + +def test_kill_exec(): + input_values = { + 'mand_text': 'value1', + 'mand_file': '/vip/Home/file.txt', + 'mand_time': 100, + } + exec_id = init_exec('Fake_app_test_delay/0.1', resultsLocation=BASE_PATH_VIP, inputValues=input_values, name='test_kill_exec') + counter = 0 + while execution_info(exec_id)['status'] != 'Running': + time.sleep(1) + if counter > 100: + raise Exception("Execution not ended after delay") + counter += 1 + assert kill_execution(exec_id, deleteFiles=True) + counter = 0 + while execution_info(exec_id)['status'] != 'Killed': + time.sleep(1) + if counter > 100: + raise Exception("Execution not ended after delay") + +def test_get_exec_stdout(): + input_values = { + 'mand_text': 'value1', + 'mand_file': '/vip/Home/file.txt', + } + exec_id = init_exec('Fake_app_test/0.1', resultsLocation=BASE_PATH_VIP, inputValues=input_values, name='test_get_exec_stdout') + counter = 0 + while execution_info(exec_id)['status'] != 'Finished': + time.sleep(1) + if counter > 100: + raise Exception("Execution not ended after delay") + counter += 1 + stdout = get_exec_stdout(exec_id) + assert isinstance(stdout, str) + + +def test_get_exec_results(): + input_values = { + 'mand_text': 'value1', + 'mand_file': '/vip/Home/file.txt', + } + exec_id = init_exec('Fake_app_test/0.1', resultsLocation=BASE_PATH_VIP, inputValues=input_values, name='test_get_exec_results') + counter = 0 + while execution_info(exec_id)['status'] != 'Finished': + time.sleep(1) + if counter > 100: + raise Exception("Execution not ended after delay") + counter += 1 + results = get_exec_results(exec_id) + # assert that this is a list of dictionaries + assert isinstance(results, list) + for r in results: + assert isinstance(r, dict) + assert 'executionId' in r + assert 'path' in r + + +def test_list_pipeline(): + pipelines = list_pipeline() + # assert that this is a list of dictionaries + assert isinstance(pipelines, list) + for p in pipelines: + assert isinstance(p, dict) + assert 'identifier' in p + assert 'name' in p + assert 'version' in p + assert 'parameters' in p + assert 'canExecute' in p + assert 'description' in p + + +def test_pipeline_def(): + pipeline = pipeline_def('CQUEST/0.3') + assert isinstance(pipeline, dict) + assert 'identifier' in pipeline + assert 'name' in pipeline + assert 'version' in pipeline + assert 'parameters' in pipeline + assert 'canExecute' in pipeline + assert 'description' in pipeline + + +def test_platform_info(): + info = platform_info() + assert isinstance(info, dict) + assert info['platformName'] == 'VIP' + \ No newline at end of file