diff --git a/clinica/utils/check_dependency.py b/clinica/utils/check_dependency.py index a04bb263b..d1d5d2563 100644 --- a/clinica/utils/check_dependency.py +++ b/clinica/utils/check_dependency.py @@ -5,10 +5,21 @@ import functools import os from enum import Enum +from functools import partial from pathlib import Path from typing import Optional, Tuple, Union +from packaging.specifiers import SpecifierSet +from packaging.version import Version + from clinica.utils.exceptions import ClinicaMissingDependencyError +from clinica.utils.stream import ( + LoggingLevel, + cprint, + get_logging_level, + log_and_raise, + log_and_warn, +) __all__ = [ "ThirdPartySoftware", @@ -22,6 +33,8 @@ "check_binary", "check_environment_variable", "check_software", + "get_software_min_version_supported", + "get_software_version", ] @@ -347,59 +360,357 @@ def _check_spm(): ) -def _check_fsl_above_version_five() -> None: - """Check FSL software.""" - import nipype.interfaces.fsl as fsl +def get_software_min_version_supported( + software: Union[str, ThirdPartySoftware], +) -> Version: + """Return the minimum version of the provided third-party software required by Clinica. + + Parameters + ---------- + software : str or ThirdPartySoftware + One of the third-party software of Clinica. + + Returns + ------- + Version : + The minimum version number of the software required by Clinica. + + Examples + -------- + >>> from clinica.utils.check_dependency import get_software_min_version_supported + >>> get_software_min_version_supported("ants") + + """ + software = ThirdPartySoftware(software) + if software == ThirdPartySoftware.FREESURFER: + return Version("6.0.0") + if software == ThirdPartySoftware.FSL: + return Version("5.0.5") + if software == ThirdPartySoftware.ANTS: + return Version("2.5.0") + if software == ThirdPartySoftware.DCM2NIIX: + return Version("1.0.20240202") + if software == ThirdPartySoftware.MRTRIX: + return Version("3.0.3") + if software == ThirdPartySoftware.CONVERT3D: + return Version("1.0.0") + if software == ThirdPartySoftware.MATLAB: + return Version("9.2.0.556344") + if software == ThirdPartySoftware.SPM: + return Version("12.7219") + if software == ThirdPartySoftware.MCR: + return Version("9.0.1") + if software == ThirdPartySoftware.SPMSTANDALONE: + return Version("12.7219") + if software == ThirdPartySoftware.PETPVC: + return Version("0.0.0") + + +def get_software_version(software: Union[str, ThirdPartySoftware]) -> Version: + """Return the version of the provided third-party software. + + Parameters + ---------- + software : str or ThirdPartySoftware + One of the third-party software of Clinica. + + Returns + ------- + Version : + The version number of the installed software. + + Notes + ----- + This function assumes the software are correctly installed. + It doesn't run any check and directly try to infer the version number by calling an + underlying executable. + + Examples + -------- + >>> from clinica.utils.check_dependency import get_software_version + >>> get_software_version("freesurfer") + + """ + software = ThirdPartySoftware(software) + if software == ThirdPartySoftware.FREESURFER: + return _get_freesurfer_version() + if software == ThirdPartySoftware.FSL: + return _get_fsl_version() + if software == ThirdPartySoftware.ANTS: + return _get_ants_version() + if software == ThirdPartySoftware.DCM2NIIX: + return _get_dcm2niix_version() + if software == ThirdPartySoftware.MRTRIX: + return _get_mrtrix_version() + if software == ThirdPartySoftware.CONVERT3D: + return _get_convert3d_version() + if software == ThirdPartySoftware.MATLAB: + return _get_matlab_version() + if software == ThirdPartySoftware.SPM: + return _get_spm_version() + if software == ThirdPartySoftware.MCR: + return _get_mcr_version() + if software == ThirdPartySoftware.SPMSTANDALONE: + return _get_spm_standalone_version() + if software == ThirdPartySoftware.PETPVC: + return Version("0.0.0") + + +def _get_freesurfer_version() -> Version: + from nipype.interfaces import freesurfer + + return Version(str(freesurfer.Info.looseversion())) + + +def _get_spm_version() -> Version: + from nipype.interfaces import spm + + return Version(spm.SPMCommand().version) + + +def _get_spm_standalone_version() -> Version: + import os + from pathlib import Path + + from nipype.interfaces import spm + + spm_path = Path(os.environ["SPM_HOME"]) + matlab_cmd = f"{spm_path / 'run_spm12.sh'} {os.environ['MCR_HOME']} script" + spm.SPMCommand.set_mlab_paths(matlab_cmd=matlab_cmd, use_mcr=True) + return Version(spm.SPMCommand().version) + + +def _get_fsl_version() -> Version: + import re + + from nipype.interfaces import fsl from clinica.utils.stream import cprint - _check_fsl() + raw_output = str(fsl.Info.version()) try: - if fsl.Info.version().split(".") < ["5", "0", "5"]: - raise ClinicaMissingDependencyError( - "FSL version must be greater than 5.0.5" - ) + return Version(re.search(r"\s*([\d.]+)", raw_output).group(1)) except Exception as e: cprint(msg=str(e), lvl="error") -def _check_freesurfer_above_version_six() -> None: - """Check FreeSurfer software >= 6.0.0.""" - import nipype.interfaces.freesurfer as freesurfer +def _get_software_version_from_command_line( + executable: str, prepend_with_v: bool = True, two_dashes: bool = True +) -> Version: + import re - from clinica.utils.stream import cprint + from clinica.utils.stream import log_and_raise - _check_freesurfer() try: - if freesurfer.Info().version().split("-")[3].split(".") < ["6", "0", "0"]: - raise ClinicaMissingDependencyError( - "FreeSurfer version must be greater than 6.0.0" + return Version( + re.search( + rf"{'v' if prepend_with_v else ''}\s*([\d.]+)", + _run_command(executable, two_dashes=two_dashes), ) + .group(1) + .strip(".") + ) except Exception as e: - cprint(msg=str(e), lvl="error") + log_and_raise(str(e), RuntimeError) + + +def _run_command(executable: str, two_dashes: bool = True) -> str: + import subprocess + + return subprocess.run( + [executable, f"-{'-' if two_dashes else ''}version"], stdout=subprocess.PIPE + ).stdout.decode("utf-8") + + +_get_ants_version = partial( + _get_software_version_from_command_line, + executable="antsRegistration", + prepend_with_v=False, +) +_get_dcm2niix_version = partial( + _get_software_version_from_command_line, executable="dcm2niix" +) +_get_mrtrix_version = partial( + _get_software_version_from_command_line, + executable="mrtransform", + prepend_with_v=False, +) +_get_convert3d_version = partial( + _get_software_version_from_command_line, + executable="c3d", + prepend_with_v=False, + two_dashes=False, +) + + +def _get_matlab_version() -> Version: + import re + + from clinica.utils.stream import log_and_raise + + try: + return Version( + re.search(r"\(\s*([\d.]+)\)", _get_matlab_start_session_message()) + .group(1) + .strip(".") + ) + except Exception as e: + log_and_raise(str(e), RuntimeError) + +def _get_matlab_start_session_message() -> str: + """Start Matlab, get the message displayed at the beginning of the session, and quit.""" + import subprocess + + return subprocess.run( + ["matlab", "-r", "quit", "-nojvm"], stdout=subprocess.PIPE + ).stdout.decode("utf-8") + + +def _get_mcr_version() -> Version: + import os + + mcr_home_path = Path(os.environ.get("MCR_HOME")) + raw_version = mcr_home_path.parent.name + return _map_mcr_release_to_version_number(raw_version) + + +def _map_mcr_release_to_version_number(mcr_release: str) -> Version: + """Map the MCR release to a proper version number. + + If the found version is older than the minimum supported version, we don't bother mapping it + to its version number, 0.0.0 is returned. + If the release is >= 2024a, we use the fact that Matlab is now using proper calendar versioning. + If the release is in-between, we use a hardcoded mapping. + """ + mcr_versions_mapping = { + "2023b": Version("23.2"), + "2023a": Version("9.14"), + "2022b": Version("9.13"), + "2022a": Version("9.12"), + "2021b": Version("9.11"), + "2021a": Version("9.10"), + "2020b": Version("9.9"), + "2020a": Version("9.8"), + "2019b": Version("9.7"), + "2019a": Version("9.6"), + "2018b": Version("9.5"), + "2018a": Version("9.4"), + "2017b": Version("9.3"), + "2017a": Version("9.2"), + "2016b": Version("9.1"), + "2016a": Version("9.0.1"), + } + if int(mcr_release[:4]) >= 2024: + return Version(f"{mcr_release[2:4]}.{'1' if mcr_release[-1] == 'a' else '2'}") + return mcr_versions_mapping.get(mcr_release, Version("0.0.0")) + + +def _check_software_version( + software: ThirdPartySoftware, + *, + log_level: Optional[LoggingLevel] = None, + specifier: Optional[SpecifierSet] = None, +): + """Check that the installed version of the software is satisfying the constraints imposed by Clinica.""" + log_level = log_level or LoggingLevel.WARNING + if specifier is None: + specifier = SpecifierSet(f">={get_software_min_version_supported(software)}") + if (installed_version := get_software_version(software)) not in specifier: + (log_and_raise if log_level >= LoggingLevel.ERROR else log_and_warn)( + f"{software.value} version is {installed_version}. We strongly recommend to have {software.value} {specifier}.", + ( + ClinicaMissingDependencyError + if log_level == LoggingLevel.ERROR + else UserWarning + ), + ) + cprint( + f"Found installation of {software.value} with version {installed_version}, satisfying {specifier}.", + lvl="info", + ) + + +def check_software( + software: Union[str, ThirdPartySoftware], + *, + log_level: Optional[Union[str, LoggingLevel]] = None, + specifier: Optional[Union[str, SpecifierSet]] = None, +): + """Run some checks on the given software. -def check_software(software: Union[str, ThirdPartySoftware]): + These checks are of two types: + - checks to verify that the executable is present in the PATH. + Also check configurations made through environment variables. + - checks on the installed version. The installed version has to + satisfy the constraints imposed by Clinica. + + Parameters + ---------- + software : str or ThirdPartySoftware + One of the third-party software of Clinica. + + log_level : str or LoggingLevel, optional + Whether to raise or warn if the version of the installed software does not + match the requirement. For specific cases like if FreeSurfer is < 6.0, an + error will be raised independently of this parameter because it is impossible + that Clinica pipelines could work in these situations. + Default is set to warnings. + + specifier : str or SpecifierSet, optional + A version constraint for the software (i.e. '>=2.0.1', '<1.0.0', or '==0.10.9'). + If not provided, the specifier will be '>= minimum_version' where 'minimum_version' + is the minimum version number of the software required by Clinica. + + Raises + ------ + ClinicaMissingDependencyError : + If an issue is found with the installation of the provided software. + In some cases where the software is correctly installed, but the version is considered + a bit old compared to the one recommended by Clinica, a warning could be given instead of + an error. In this situation, it is strongly recommended to upgrade the dependency even + though Clinica does not raise. + + Examples + -------- + >>> from clinica.utils.check_dependency import check_software + >>> check_software("dcm2niix") + >>> check_software("matlab", log_level="error") + >>> check_software("ants", specifier=">=2.5") + """ software = ThirdPartySoftware(software) + if specifier: + if specifier == "": + specifier = None + else: + specifier = SpecifierSet(specifier) + if log_level: + log_level = get_logging_level(log_level) + else: + log_level = LoggingLevel.WARNING if software == ThirdPartySoftware.ANTS: - return _check_ants() + _check_ants() if software == ThirdPartySoftware.FSL: - return _check_fsl_above_version_five() + _check_fsl() + log_level = LoggingLevel.ERROR if software == ThirdPartySoftware.FREESURFER: - return _check_freesurfer_above_version_six() + _check_freesurfer() + log_level = LoggingLevel.ERROR if ( software == ThirdPartySoftware.SPM or software == ThirdPartySoftware.SPMSTANDALONE or software == ThirdPartySoftware.MCR ): - return _check_spm() + _check_spm() + log_level = LoggingLevel.ERROR if software == ThirdPartySoftware.MATLAB: - return _check_matlab() + _check_matlab() if software == ThirdPartySoftware.DCM2NIIX: - return _check_dcm2niix() + _check_dcm2niix() if software == ThirdPartySoftware.PETPVC: - return _check_petpvc() + _check_petpvc() if software == ThirdPartySoftware.MRTRIX: - return _check_mrtrix() + _check_mrtrix() if software == ThirdPartySoftware.CONVERT3D: - return _check_convert3d() + _check_convert3d() + _check_software_version(software, log_level=log_level, specifier=specifier) diff --git a/test/unittests/utils/test_check_dependency.py b/test/unittests/utils/test_check_dependency.py index b687d7dea..bcd2f66e9 100644 --- a/test/unittests/utils/test_check_dependency.py +++ b/test/unittests/utils/test_check_dependency.py @@ -1,8 +1,13 @@ import os import re +from test.unittests.iotools.converters.adni_to_bids.modality_converters.test_adni_fmap import ( + expected, +) from unittest import mock import pytest +from packaging.specifiers import SpecifierSet +from packaging.version import Version from clinica.utils.check_dependency import ( SoftwareEnvironmentVariable, @@ -220,3 +225,292 @@ def test_check_spm_alone(tmp_path, mocker): mocker.patch("clinica.utils.check_dependency.is_binary_present", return_value=True) with mock.patch.dict(os.environ, {"SPM_HOME": str(tmp_path)}): _check_spm() + + +@pytest.mark.parametrize( + "software,expected", + [ + (ThirdPartySoftware.FREESURFER, Version("6.0.0")), + (ThirdPartySoftware.FSL, Version("5.0.5")), + (ThirdPartySoftware.ANTS, Version("2.5.0")), + (ThirdPartySoftware.DCM2NIIX, Version("1.0.20240202")), + (ThirdPartySoftware.MRTRIX, Version("3.0.3")), + (ThirdPartySoftware.CONVERT3D, Version("1.0.0")), + (ThirdPartySoftware.MATLAB, Version("9.2.0.556344")), + (ThirdPartySoftware.SPM, Version("12.7219")), + (ThirdPartySoftware.MCR, Version("9.0.1")), + (ThirdPartySoftware.SPMSTANDALONE, Version("12.7219")), + (ThirdPartySoftware.PETPVC, Version("0.0.0")), + ], +) +def test_get_software_min_version_supported(software: str, expected: Version): + from clinica.utils.check_dependency import get_software_min_version_supported + + assert get_software_min_version_supported(software) == expected + + +def test_get_freesurfer_version(mocker): + from clinica.utils.check_dependency import get_software_version + + mocker.patch("nipype.interfaces.freesurfer.Info.looseversion", return_value="1.2.3") + + assert get_software_version("freesurfer") == Version("1.2.3") + + +def test_get_spm_version(): + from clinica.utils.check_dependency import get_software_version + + class SPMVersionMock: + version: str = "12.6789" + + with mock.patch( + "nipype.interfaces.spm.SPMCommand", wraps=SPMVersionMock + ) as spm_mock: + assert get_software_version("spm") == Version("12.6789") + spm_mock.assert_called_once() + + +def test_get_spm_standalone_version(tmp_path): + from clinica.utils.check_dependency import get_software_version + + class SPMStandaloneVersionMock: + version: str = "13.234" + + def set_mlab_paths(self, matlab_cmd: str, use_mcr: bool): + pass + + with mock.patch.dict( + os.environ, + { + "SPM_HOME": str(tmp_path / "spm_home_mock"), + "MCR_HOME": str(tmp_path / "mcr_home_mock"), + }, + ): + with mock.patch( + "nipype.interfaces.spm.SPMCommand", wraps=SPMStandaloneVersionMock + ) as spm_mock: + 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"{tmp_path / 'spm_home_mock' / 'run_spm12.sh'} {tmp_path / 'mcr_home_mock'} script", + use_mcr=True, + ) + + +def test_get_fsl_version(mocker): + from clinica.utils.check_dependency import get_software_version + + mocker.patch("nipype.interfaces.fsl.Info.version", return_value="3.2.1:9e026117") + + assert get_software_version("fsl") == Version("3.2.1") + + +def ants_version_mock(executable: str, two_dashes: bool = True) -> str: + return "ANTs Version: 2.5.0.post7-g46ab4e7\nCompiled: Sep 8 2023 14:35:39" + + +def test_get_ants_version(): + from clinica.utils.check_dependency import get_software_version + + with mock.patch( + "clinica.utils.check_dependency._run_command", wraps=ants_version_mock + ) as ants_mock: + assert get_software_version("ants") == Version("2.5.0") + ants_mock.assert_called_once_with("antsRegistration", two_dashes=True) + + +def dcm2niix_version_mock(executable: str, two_dashes: bool = True) -> str: + return ( + "Compression will be faster with 'pigz' installed http://macappstore.org/pigz/\n" + "Chris Rorden's dcm2niiX version v1.0.20240202 Clang15.0.0 ARM (64-bit MacOS)\n" + "v1.0.20240202" + ) + + +def test_get_dcm2niix_version(): + from clinica.utils.check_dependency import get_software_version + + with mock.patch( + "clinica.utils.check_dependency._run_command", wraps=dcm2niix_version_mock + ) as dcm2niix_mock: + assert get_software_version("dcm2niix") == Version("1.0.20240202") + dcm2niix_mock.assert_called_once_with("dcm2niix", two_dashes=True) + + +def mrtrix_version_mock(executable: str, two_dashes: bool = True) -> str: + return ( + "== mrtransform 3.0.3 ==\n" + "64 bit release version with nogui, built Oct 22 2021, using Eigen 3.3.7\n" + "Author(s): J-Donald Tournier (jdtournier@gmail.com) and David Raffelt " + "(david.raffelt@florey.edu.au) and Max Pietsch (maximilian.pietsch@kcl.ac.uk)\n" + "Copyright (c) 2008-2021 the MRtrix3 contributors.\n\n" + "This Source Code Form is subject to the terms of the Mozilla Public\n" + "License, v. 2.0. If a copy of the MPL was not distributed with this\n" + "file, You can obtain one at http://mozilla.org/MPL/2.0/.\n\n" + "Covered Software is provided under this License on an 'as is'\n" + "basis, without warranty of any kind, either expressed, implied, or\n" + "statutory, including, without limitation, warranties that the\n" + "Covered Software is free of defects, merchantable, fit for a\n" + "particular purpose or non-infringing.\n" + "See the Mozilla Public License v. 2.0 for more details.\n\n" + "For more details, see http://www.mrtrix.org/." + ) + + +def test_get_mrtrix_version(): + from clinica.utils.check_dependency import get_software_version + + with mock.patch( + "clinica.utils.check_dependency._run_command", wraps=mrtrix_version_mock + ) as mrtrix_mock: + assert get_software_version("mrtrix") == Version("3.0.3") + mrtrix_mock.assert_called_once_with("mrtransform", two_dashes=True) + + +def convert3d_version_mock(executable: str, two_dashes: bool = True) -> str: + return "Version 1.0.0" + + +def test_get_convert3d_version(): + from clinica.utils.check_dependency import get_software_version + + with mock.patch( + "clinica.utils.check_dependency._run_command", wraps=convert3d_version_mock + ) as c3d_mock: + assert get_software_version("convert3d") == Version("1.0.0") + c3d_mock.assert_called_once_with("c3d", two_dashes=False) + + +def matlab_version_mock() -> str: + return ( + "\x1b[?1h\x1b=\n " + "< M A T L A B (R) >\n " + "Copyright 1984-2017 The MathWorks, Inc.\n" + " R2017b (9.3.0.713579) 64-bit (glnxa64)\n" + "September 14, 2017\n\n \nFor online documentation, see http://www.mathworks.com/support\n" + "For product information, visit www.mathworks.com.\n \n\x1b[?1l\x1b>" + ) + + +def test_get_matlab_version(): + from clinica.utils.check_dependency import get_software_version + + with mock.patch( + "clinica.utils.check_dependency._get_matlab_start_session_message", + wraps=matlab_version_mock, + ) as matlab_mock: + assert get_software_version("matlab") == Version("9.3.0.713579") + matlab_mock.assert_called_once() + + +mcr_version_test_suite = [ + ("2024a", Version("24.1")), + ("2023b", Version("23.2")), + ("2023a", Version("9.14")), + ("2022b", Version("9.13")), + ("2022a", Version("9.12")), + ("2021b", Version("9.11")), + ("2021a", Version("9.10")), + ("2020b", Version("9.9")), + ("2020a", Version("9.8")), + ("2019b", Version("9.7")), + ("2019a", Version("9.6")), + ("2018b", Version("9.5")), + ("2018a", Version("9.4")), + ("2017b", Version("9.3")), + ("2017a", Version("9.2")), + ("2016b", Version("9.1")), + ("2016a", Version("9.0.1")), + ("2015b", Version("0.0.0")), + ("1789a", Version("0.0.0")), +] + + +@pytest.mark.parametrize("version,expected", mcr_version_test_suite) +def test_map_mcr_release_to_version_number(version, expected): + from clinica.utils.check_dependency import _map_mcr_release_to_version_number # noqa + + assert _map_mcr_release_to_version_number(version) == expected + + +@pytest.mark.parametrize("version,expected", mcr_version_test_suite) +def test_get_mcr_version(tmp_path, version, expected): + from clinica.utils.check_dependency import get_software_version + + with mock.patch.dict( + os.environ, + { + "MCR_HOME": str(tmp_path / "mcr_home_mock" / version / "v95"), + }, + ): + assert get_software_version("MCR") == expected + + +def test_get_petpvc_version(): + from clinica.utils.check_dependency import get_software_version + + assert get_software_version("petpvc") == Version("0.0.0") + + +def test_check_software_version(mocker): + from clinica.utils.check_dependency import _check_software_version # noqa + from clinica.utils.stream import LoggingLevel + + mocker.patch( + "clinica.utils.check_dependency.get_software_version", + return_value=Version("1.0.1"), + ) + mocker.patch( + "clinica.utils.check_dependency.get_software_min_version_supported", + return_value=Version("1.1.0"), + ) + + with pytest.raises( + ClinicaMissingDependencyError, + match="ants version is 1.0.1. We strongly recommend to have ants >=1.1.0.", + ): + _check_software_version(ThirdPartySoftware.ANTS, log_level=LoggingLevel.ERROR) + _check_software_version( + ThirdPartySoftware.ANTS, + log_level=LoggingLevel.ERROR, + specifier=SpecifierSet("==1.0.1"), + ) + _check_software_version( + ThirdPartySoftware.ANTS, + log_level=LoggingLevel.ERROR, + specifier=SpecifierSet("<2"), + ) + _check_software_version( + ThirdPartySoftware.ANTS, + log_level=LoggingLevel.ERROR, + specifier=SpecifierSet(">1.0"), + ) + + with pytest.raises( + ClinicaMissingDependencyError, + match="ants version is 1.0.1. We strongly recommend to have ants ==1.2.3.", + ): + _check_software_version( + ThirdPartySoftware.ANTS, + log_level=LoggingLevel.ERROR, + specifier=SpecifierSet("==1.2.3"), + ) + + with pytest.warns( + UserWarning, + match="ants version is 1.0.1. We strongly recommend to have ants >=1.1.0.", + ): + _check_software_version(ThirdPartySoftware.ANTS, log_level=LoggingLevel.WARNING) + + with pytest.warns( + UserWarning, + match="ants version is 1.0.1. We strongly recommend to have ants <=0.23.", + ): + _check_software_version( + ThirdPartySoftware.ANTS, + log_level=LoggingLevel.WARNING, + specifier=SpecifierSet("<=0.23"), + )