Repository: aramis-lab/clinica
Pull request #1490: [REF] Search for SPM standalone execution file with pattern instead of hardcoding for spm12
Author: login: AliceJoubert
Created: 2025-04-02T09:50:31Z | Merged: 2025-04-10T06:40:56Z

Adresses part of #1481 regarding `_get_platform_dependant_matlab_command_for_spm_standalone`.


Rq : not sure it will do well with CI set-up


Diff:
diff --git a/clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py b/clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
index bab9b997a..157018d14 100644
--- a/clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
+++ b/clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
@@ -135,8 +135,6 @@ def _build_core_nodes(self):
             zip_list_files,
         )
 
-        use_spm_standalone_if_available()
-
         # Get <subject_id> (e.g. sub-CLNC01_ses-M000) from input_node
         # and print begin message
         # =======================
diff --git a/clinica/utils/spm.py b/clinica/utils/spm.py
index 8255b86fc..85479dd16 100644
--- a/clinica/utils/spm.py
+++ b/clinica/utils/spm.py
@@ -114,16 +114,32 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    spm_files = list(spm_standalone_home.rglob("run_spm*.sh"))
+    if len(spm_files) != 1:
+        raise FileNotFoundError(
+            f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME {spm_standalone_home} : {" ; ".join([str(path) for path in sorted(spm_files)])}."
+        )
+    return spm_files[0].name
+
+
 def _get_platform_dependant_matlab_command_for_spm_standalone(
     spm_standalone_home: Path, mcr_home: Path
 ) -> str:
     import platform
 
+    from clinica.utils.stream import cprint
+
+    spm_file = _get_real_spm_standalone_file(spm_standalone_home)
+    cprint(
+        f"Using the following spm file to build the matlab dependent command : {spm_standalone_home/spm_file}",
+        lvl="debug",
+    )
     user_system = platform.system().lower()
     if user_system.startswith("darwin"):
-        return f"cd {spm_standalone_home} && ./run_spm12.sh {mcr_home} script"
+        return f"cd {spm_standalone_home} && ./{spm_file} {mcr_home} script"
     if user_system.startswith("linux"):
-        return f"{spm_standalone_home / 'run_spm12.sh'} {mcr_home} script"
+        return f"{spm_standalone_home / spm_file} {mcr_home} script"
     raise SystemError(
         f"Clinica only support macOS and Linux. Your system is {user_system}."
     )
diff --git a/test/unittests/utils/test_check_dependency.py b/test/unittests/utils/test_check_dependency.py
index 66a23b89b..bf5548218 100644
--- a/test/unittests/utils/test_check_dependency.py
+++ b/test/unittests/utils/test_check_dependency.py
@@ -291,6 +291,8 @@ class SPMStandaloneVersionMock:
         def set_mlab_paths(self, matlab_cmd: str, use_mcr: bool):
             pass
 
+    spm_filename = f"run_spm{SPMStandaloneVersionMock.version}.sh"
+
     spm_standalone_home_mock = tmp_path / "spm_standalone_home"
     spm_standalone_home_mock.mkdir()
     mcr_home_mock = tmp_path / "mcr_home"
@@ -310,16 +312,20 @@ def set_mlab_paths(self, matlab_cmd: str, use_mcr: bool):
             with mock.patch.object(
                 SPMStandaloneVersionMock, "set_mlab_paths", return_value=None
             ) as mock_method:
-                assert get_software_version("spm standalone") == Version("13.234")
-                spm_mock.set_mlab_paths.assert_called()
-                mock_method.assert_called_once_with(
-                    matlab_cmd=(
-                        f"cd {tmp_path / 'spm_standalone_home'} && ./run_spm12.sh {tmp_path / 'mcr_home'} script"
-                        if platform == "darwin"
-                        else f"{tmp_path / 'spm_standalone_home' / 'run_spm12.sh'} {tmp_path / 'mcr_home'} script"
-                    ),
-                    use_mcr=True,
-                )
+                with mock.patch(
+                    "clinica.utils.spm._get_real_spm_standalone_file",
+                    return_value=spm_filename,
+                ):
+                    assert get_software_version("spm standalone") == Version("13.234")
+                    spm_mock.set_mlab_paths.assert_called()
+                    mock_method.assert_called_once_with(
+                        matlab_cmd=(
+                            f"cd {tmp_path / 'spm_standalone_home'} && ./{spm_filename} {tmp_path / 'mcr_home'} script"
+                            if platform == "darwin"
+                            else f"{tmp_path / 'spm_standalone_home' / spm_filename} {tmp_path / 'mcr_home'} script"
+                        ),
+                        use_mcr=True,
+                    )
 
 
 def test_get_fsl_version(mocker):
diff --git a/test/unittests/utils/test_spm.py b/test/unittests/utils/test_spm.py
index 68c55d866..e6bf6c04b 100644
--- a/test/unittests/utils/test_spm.py
+++ b/test/unittests/utils/test_spm.py
@@ -121,6 +121,9 @@ def test_get_platform_dependant_matlab_command_for_spm_standalone(
     )
 
     mocker.patch("platform.system", return_value=platform)
+    mocker.patch(
+        "clinica.utils.spm._get_real_spm_standalone_file", return_value="run_spm12.sh"
+    )
 
     assert (
         _get_platform_dependant_matlab_command_for_spm_standalone(
@@ -136,6 +139,9 @@ def test_get_platform_dependant_matlab_command_for_spm_standalone_error(mocker):
     )
 
     mocker.patch("platform.system", return_value="foo")
+    mocker.patch(
+        "clinica.utils.spm._get_real_spm_standalone_file", return_value="run_spm12.sh"
+    )
 
     with pytest.raises(
         SystemError,
@@ -144,3 +150,44 @@ def test_get_platform_dependant_matlab_command_for_spm_standalone_error(mocker):
         _get_platform_dependant_matlab_command_for_spm_standalone(
             Path("/foo/bar"), Path("/foo/bar/baz")
         )
+
+
+def test_get_real_spm_standalone_file_no_file_error(tmp_path):
+    from clinica.utils.spm import _get_real_spm_standalone_file
+
+    with pytest.raises(
+        FileNotFoundError,
+        match=f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME {tmp_path} : ",
+    ):
+        _get_real_spm_standalone_file(tmp_path)
+
+
+def test_get_real_spm_standalone_file_several_files_error(tmp_path):
+    from clinica.utils.spm import _get_real_spm_standalone_file
+
+    (tmp_path / "run_spm1.sh").touch()
+    (tmp_path / "run_spm2.sh").touch()
+
+    with pytest.raises(
+        FileNotFoundError,
+        match=f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME {tmp_path} : {tmp_path/"run_spm1.sh"} ; {tmp_path/"run_spm2.sh"}",
+    ):
+        _get_real_spm_standalone_file(tmp_path)
+
+
+@pytest.mark.parametrize(
+    "spm_file",
+    [
+        "run_spm.sh",
+        "run_spm12.sh",
+        "run_spm24.sh",
+        "run_spm25.01.sh",
+        "run_spmrc24.sh",
+        "run_spm_25.sh",
+    ],
+)
+def test_get_real_spm_standalone_file_error(tmp_path, spm_file):
+    from clinica.utils.spm import _get_real_spm_standalone_file
+
+    (tmp_path / spm_file).touch()
+    assert spm_file == _get_real_spm_standalone_file(tmp_path)


Reviews:
user: login: NicolasGensollen
body: Thanks @AliceJoubert !

The PR looks good to me. I just have a small comment on the function `_get_real_spm_standalone_file`.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:49:03Z
user: login: AliceJoubert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:54:19Z
user: login: AliceJoubert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:55:14Z
user: login: NicolasGensollen
body: Thanks @AliceJoubert !

This LGTM. I'll let you merge once you have made the test we discussed.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T11:06:02Z
user: login: AliceJoubert
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T15:02:02Z

Review comments:
diff_hunk: @@ -114,16 +114,27 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    try:
+        return next(spm_standalone_home.rglob("run_spm*.sh")).name
path: clinica/utils/spm.py
user: login: NicolasGensollen
body: I'm wondering whether it could happen that multiple "run_spmXX.sh" files are present ? I doubt this could be the case but it might be more robust to handle the case, WDYT ? We could handle it by raising an error with the list of files found.
Also, I think it could be interesting to log the file used (INFO or DEBUG) (probably done in the caller rather than here).
created_at: 2025-04-04T06:42:25Z
author_association: COLLABORATOR
diff_hunk: @@ -135,7 +135,7 @@ def _build_core_nodes(self):
             zip_list_files,
         )
 
-        use_spm_standalone_if_available()
+        use_spm_standalone_if_available()  # todo : why needed at this point ?
path: clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
user: login: NicolasGensollen
body: Good question, I think this is a leftover from legacy code. With the new improved way of checking third party softwares, this is indeed not needed since the Nipype configuration is done when trying to get the version. I think we can remove it and test whether the pipeline runs with both SPM + Matlab and SPM + MCR. WDYT ?
created_at: 2025-04-04T06:47:46Z
author_association: COLLABORATOR
diff_hunk: @@ -135,7 +135,7 @@ def _build_core_nodes(self):
             zip_list_files,
         )
 
-        use_spm_standalone_if_available()
+        use_spm_standalone_if_available()  # todo : why needed at this point ?
path: clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
user: login: AliceJoubert
body: Okay I will test that separately
created_at: 2025-04-04T14:54:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,16 +114,27 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    try:
+        return next(spm_standalone_home.rglob("run_spm*.sh")).name
path: clinica/utils/spm.py
user: login: AliceJoubert
body: I don't think that would happen but I can handle the case anyway !
created_at: 2025-04-04T14:55:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,16 +114,32 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    spm_files = list(spm_standalone_home.rglob("run_spm*.sh"))
+    if len(spm_files) != 1:
+        raise FileNotFoundError(
+            f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME : {spm_standalone_home}"
+        )
+    return spm_files[0].name
+
+
 def _get_platform_dependant_matlab_command_for_spm_standalone(
     spm_standalone_home: Path, mcr_home: Path
 ) -> str:
     import platform
 
+    from clinica.utils.stream import cprint
+
+    spm_file = _get_real_spm_standalone_file(spm_standalone_home)
+    cprint(
+        f"Using the following spm file to build the matlab dependent command : {spm_file}",
path: clinica/utils/spm.py
user: login: NicolasGensollen
body: ```suggestion
        f"Using the following spm file to build the matlab dependent command : {spm_standalone_home / spm_file}",
```
created_at: 2025-04-07T11:01:06Z
author_association: COLLABORATOR
diff_hunk: @@ -114,16 +114,32 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    spm_files = list(spm_standalone_home.rglob("run_spm*.sh"))
+    if len(spm_files) != 1:
+        raise FileNotFoundError(
+            f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME : {spm_standalone_home}"
path: clinica/utils/spm.py
user: login: NicolasGensollen
body: ```suggestion
            f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME ({spm_standalone_home}) : {spm_files}."
```
created_at: 2025-04-07T11:02:34Z
author_association: COLLABORATOR
diff_hunk: @@ -135,7 +135,7 @@ def _build_core_nodes(self):
             zip_list_files,
         )
 
-        use_spm_standalone_if_available()
+        use_spm_standalone_if_available()  # todo : why needed at this point ?
path: clinica/pipelines/t1_volume_tissue_segmentation/t1_volume_tissue_segmentation_pipeline.py
user: login: NicolasGensollen
body: If you have tested it and it works as expected, I believe this is ready to be merged.
created_at: 2025-04-07T11:04:39Z
author_association: COLLABORATOR
diff_hunk: @@ -114,16 +114,32 @@ def configure_nipype_interface_to_work_with_spm() -> None:
     mlab.MatlabCommand.set_default_paths(f"{get_spm_home()}")
 
 
+def _get_real_spm_standalone_file(spm_standalone_home: Path) -> str:
+    spm_files = list(spm_standalone_home.rglob("run_spm*.sh"))
+    if len(spm_files) != 1:
+        raise FileNotFoundError(
+            f"There is no or several 'run_spmXX.sh' in your SPMSTANDALONE_HOME : {spm_standalone_home}"
path: clinica/utils/spm.py
user: login: AliceJoubert
body: I had to do a little work-around since regex does not like `()` or `[]`
created_at: 2025-04-08T15:02:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: AliceJoubert
created_at: 2025-04-08T15:06:32Z
body: I could test with both Matlab + SPM12 and SPM12 standalone set-up and it works so I removed the duplicated line looking for the environment variables.
author_association: CONTRIBUTOR
user: login: NicolasGensollen
created_at: 2025-04-10T06:40:49Z
body: Thanks @AliceJoubert !

Merging then !
author_association: COLLABORATOR