diff --git a/.circleci/config.yml b/.circleci/config.yml index 3fdcd94ac..57d0f2389 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,7 +75,7 @@ jobs: - data/ds000001 - data/ds000001-fmriprep - test: + default_model: machine: image: ubuntu-2204:2022.10.2 @@ -87,13 +87,53 @@ jobs: - run: mkdir -p ${HOME}/outputs/ds000001/derivatives - run: - name: print version + name: default model subject level command: | user_name=cpplab repo_name=$(echo "${CIRCLE_PROJECT_REPONAME}" | tr '[:upper:]' '[:lower:]') docker run -ti --rm \ -v /tmp/workspace/data/ds000001:/bids_dataset \ - ${user_name}/${repo_name} --version + -v ${HOME}/outputs:/outputs \ + ${user_name}/${repo_name} \ + /bids_dataset \ + /outputs/ds000001 \ + subject \ + --action default_model \ + --task balloonanalogrisktask \ + --space MNI152NLin2009cAsym \ + --verbosity 3 + + cat ${HOME}/outputs/ds000001/derivatives/models/*.json + + - run: + name: default model dataset level + command: | + user_name=cpplab + repo_name=$(echo "${CIRCLE_PROJECT_REPONAME}" | tr '[:upper:]' '[:lower:]') + docker run -ti --rm \ + -v /tmp/workspace/data/ds000001:/bids_dataset \ + -v ${HOME}/outputs:/outputs \ + ${user_name}/${repo_name} \ + /bids_dataset \ + /outputs/ds000001 \ + dataset \ + --action default_model \ + --task balloonanalogrisktask \ + --space MNI152NLin2009cAsym \ + --verbosity 3 + + cat ${HOME}/outputs/ds000001/derivatives/models/*.json + + stats: + machine: + image: ubuntu-2204:2022.10.2 + + steps: + - attach_workspace: + at: /tmp/workspace + - run: docker load -i /tmp/workspace/docker/image.tar + + - run: mkdir -p ${HOME}/outputs/ds000001/derivatives - run: name: smooth @@ -112,14 +152,13 @@ jobs: --participant_label 01 02 \ --space MNI152NLin2009cAsym \ --fwhm 8 \ - --verbosity 2 - no_output_timeout: 6h + --verbosity 3 - # needed to access the model + # needed to access the model - checkout - run: - name: stats + name: stats subject level command: | user_name=cpplab repo_name=$(echo "${CIRCLE_PROJECT_REPONAME}" | tr '[:upper:]' '[:lower:]') @@ -127,6 +166,7 @@ jobs: -v /tmp/workspace/data/ds000001:/bids_dataset \ -v ${HOME}/outputs:/outputs \ -v ~/project/demos/openneuro/models:/models \ + -v ~/project/demos/openneuro/options:/options \ ${user_name}/${repo_name} \ /bids_dataset \ /outputs/ds000001 \ @@ -137,11 +177,33 @@ jobs: --ignore slicetiming \ --space MNI152NLin2009cAsym \ --skip_validation \ - --fwhm 8 \ + --fwhm 0 \ --participant_label 01 02 \ - --verbosity 2 - no_output_timeout: 6h + --verbosity 3 \ + --options /options/ds000001.json + - run: + name: stats group level + command: | + user_name=cpplab + repo_name=$(echo "${CIRCLE_PROJECT_REPONAME}" | tr '[:upper:]' '[:lower:]') + docker run -ti --rm \ + -v /tmp/workspace/data/ds000001:/bids_dataset \ + -v ${HOME}/outputs:/outputs \ + -v ~/project/demos/openneuro/models:/models \ + -v ~/project/demos/openneuro/options:/options \ + ${user_name}/${repo_name} \ + /bids_dataset \ + /outputs/ds000001 \ + dataset \ + --action stats \ + --preproc_dir /outputs/ds000001/derivatives/bidspm-preproc \ + --model_file /models/model-balloonanalogrisktaskDefault_smdl.json \ + --space MNI152NLin2009cAsym \ + --skip_validation \ + --fwhm 0 \ + --verbosity 3 \ + --options /options/ds000001.json deploy: @@ -187,7 +249,11 @@ workflows: jobs: - build - get_data - - test: + - default_model: + requires: + - build + - get_data + - stats: requires: - build - get_data @@ -195,6 +261,6 @@ workflows: context: - DOCKER_HUB requires: - - test + - build # VS Code Extension Version: 1.5.1 diff --git a/.github/workflows/apptainer_build.yml b/.github/workflows/apptainer_build.yml index d743977f0..9931775c6 100644 --- a/.github/workflows/apptainer_build.yml +++ b/.github/workflows/apptainer_build.yml @@ -17,6 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 - uses: eWaterCycle/setup-apptainer@v2 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9872addd4..3b5f20dd0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,7 +65,7 @@ jobs: matlab: R2023b mode: slow - test_type: unit - os: macos-latest + os: macos-13 matlab: R2023b mode: fast fail-fast: false diff --git a/.gitignore b/.gitignore index fd033faf2..50cf15a29 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ skipped_roi_*.tsv CHANGES README tests/data/tsv_files/moae_results_table.tsv +htmlcov # Project specific onsets*_events.mat @@ -48,6 +49,8 @@ src/bidspm/_version.py **/__pycache__ **/build .coverage +.pytest_cache +.tox .mypy_cache diff --git a/Dockerfile b/Dockerfile index 882248993..38adbb223 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM bids/base_validator +FROM bids/base_validator:1.13.1 ARG DEBIAN_FRONTEND="noninteractive" @@ -48,6 +48,7 @@ WORKDIR /home/neuro COPY . /home/neuro/bidspm WORKDIR /home/neuro/bidspm RUN pip install --no-cache-dir --upgrade pip && \ + pip3 --no-cache-dir install -r requirements.txt && \ pip3 --no-cache-dir install . && \ octave --no-gui --eval "addpath('/opt/spm12/'); savepath ();" && \ octave --no-gui --eval "addpath(pwd); savepath(); bidspm(); path" diff --git a/Makefile b/Makefile index 678fcab4e..119b14901 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,7 @@ coverage: ## use coverage coverage erase coverage run --source src -m pytest coverage report -m + coverage html ################################################################################ # DOCKER diff --git a/README.md b/README.md index f352a0c3c..fe1bdf122 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ To start using bidspm, you just need to initialize it for the current MATLAB / O bidspm() ``` -Please see our [documentation](https://bidspm.readthedocs.io/en/latest/installation.html) for more info. +Please see our [documentation](https://bidspm.readthedocs.io/en/latest/installation/index.html) for more info. ## Usage diff --git a/bidspm.def b/bidspm.def index e23d0b0f6..f5f118de8 100644 --- a/bidspm.def +++ b/bidspm.def @@ -1,5 +1,14 @@ BootStrap: docker -From: bids/base_validator +From: bids/base_validator:1.13.1 + +%files + pyproject.toml /opt/bidspm/pyproject.toml + requirements.txt /opt/bidspm/requirements.txt + README.md /opt/bidspm/README.md + bidspm.m /opt/bidspm/bidspm.m + src /opt/bidspm/src + lib /opt/bidspm/lib + .git /opt/bidspm/.git %post apt-get -qq update @@ -38,10 +47,9 @@ From: bids/base_validator make -C /opt/spm12/src PLATFORM=octave install ln -s /opt/spm12/bin/spm12-octave /usr/local/bin/spm12 - mkdir /opt/bidspm - git clone --recurse-submodules https://github.com/cpp-lln-lab/bidspm.git /opt/bidspm - pip install --no-cache-dir --upgrade pip && \ - pip3 --no-cache-dir install /opt/bidspm && \ + pip install --upgrade pip + pip install -r /opt/bidspm/requirements.txt + pip install /opt/bidspm octave --no-gui --eval "addpath('/opt/spm12/'); savepath ('/usr/share/octave/site/m/startup/octaverc');" && \ octave --no-gui --eval "addpath('/opt/bidspm/'); savepath('/usr/share/octave/site/m/startup/octaverc'); bidspm(); path" diff --git a/bidspm.m b/bidspm.m index 4b56e8dd7..3906e1d56 100644 --- a/bidspm.m +++ b/bidspm.m @@ -179,15 +179,6 @@ function initBidspm(dev) pathSep, ... genpath(fullfile(rootDir(), 'src', 'workflows', 'stats'))); - % add library that do not have an set up script - libList = {'spmup'}; - - for i = 1:numel(libList) - BIDSPM_PATHS = cat(2, BIDSPM_PATHS, ... - pathSep, ... - genpath(fullfile(rootDir(), 'lib', libList{i}))); - end - libList = {'mancoreg', ... 'bids-matlab', ... 'slice_display', ... @@ -270,7 +261,7 @@ function updateMacstoolbox() end if exist(target_dir, 'dir') == 7 - msg = sprintf('updating MACS toolbox: '); + msg = sprintf('updating MACS toolbox\n'); fprintf(1, msg); [status, cmdout] = system(sprintf('git -C %s pull', target_dir)); if status ~= 0 diff --git a/demos/openneuro/Makefile b/demos/openneuro/Makefile index 7d8070c87..f60d53e44 100644 --- a/demos/openneuro/Makefile +++ b/demos/openneuro/Makefile @@ -18,11 +18,12 @@ data_ds000001: mkdir -p inputs cd inputs && datalad install ///openneuro/ds000001 cd inputs && datalad install ///openneuro-derivatives/ds000001-fmriprep - cd inputs/ds000001 && datalad get sub-0[1-5] -J 3 - cd inputs/ds000001-fmriprep && datalad get sub-0[1-5]/func/*tsv -J 12 - cd inputs/ds000001-fmriprep && datalad get sub-0[1-5]/func/*json -J 12 - cd inputs/ds000001-fmriprep && datalad get sub-0[1-5]/func/*MNI*desc-*bold.nii.gz -J 12 - cd inputs/ds000001-fmriprep && datalad get sub-0[1-5]/anat/*MNI*desc-preproc*.nii.gz -J 12 + cd inputs/ds000001 && datalad get sub-0[1-2] -J 3 + cd inputs/ds000001-fmriprep && datalad get sub-0[1-2]/func/*tsv -J 12 + cd inputs/ds000001-fmriprep && datalad get sub-0[1-2]/func/*json -J 12 + cd inputs/ds000001-fmriprep && datalad get sub-0[1-2]/anat/*MNI*desc-preproc*.nii.gz -J 12 + cd inputs/ds000001-fmriprep && datalad get sub-0[1-2]/func/*MNI*desc-preproc*.nii.gz -J 12 + cd inputs/ds000001-fmriprep && datalad get sub-0[1-2]/func/*MNI*desc-*bold.nii.gz -J 12 data_ds000114: mkdir -p inputs diff --git a/demos/openneuro/models/model-balloonanalogrisktaskDefault_smdl.json b/demos/openneuro/models/model-balloonanalogrisktaskDefault_smdl.json index 85d424e03..59271af4e 100644 --- a/demos/openneuro/models/model-balloonanalogrisktaskDefault_smdl.json +++ b/demos/openneuro/models/model-balloonanalogrisktaskDefault_smdl.json @@ -47,7 +47,8 @@ { "name": [ "cash_demean" - ] + ], + "nidm": false } ] } diff --git a/demos/openneuro/options/ds000001.json b/demos/openneuro/options/ds000001.json new file mode 100644 index 000000000..81772723c --- /dev/null +++ b/demos/openneuro/options/ds000001.json @@ -0,0 +1,5 @@ +{ + "results": { + "nidm": false + } +} diff --git a/demos/validate_models.m b/demos/validate_models.m index 8578fedcc..87ef1ee99 100644 --- a/demos/validate_models.m +++ b/demos/validate_models.m @@ -9,8 +9,6 @@ % AND it will run some extra checks implemented in bids-matlab % and bidspm. % -% See also: https://bidspm.readthedocs.io/en/latest/bids_stats_model.html#using-the-bids-stats-model-python-package -% this_dir = fileparts(mfilename('fullpath')); diff --git a/docs/faq/stats/naming-conditions.question.md b/docs/faq/stats/naming-conditions.question.md index f4f5b4540..0bd73ad49 100644 --- a/docs/faq/stats/naming-conditions.question.md +++ b/docs/faq/stats/naming-conditions.question.md @@ -27,7 +27,7 @@ So for example: If your BIDS dataset has conditions that do not follow this rule, then you can use the [`Replace` variable transform](https://github.com/bids-standard/variable-transform/blob/main/spec/munge.md#replace) -in your [BIDS statistical model](https://bidspm.readthedocs.io/en/latest/bids_stats_model.html#transformation') +in your [BIDS statistical model](https://bidspm.readthedocs.io/en/latest/stats/bids_stats_model.html#transformation') to rename them on the fly without having to manually edit potentially dozens of files. See also example below. diff --git a/docs/source/installation/index.md b/docs/source/installation/index.md index 602c6df44..fe24ac176 100644 --- a/docs/source/installation/index.md +++ b/docs/source/installation/index.md @@ -88,7 +88,7 @@ After installing bidspm python package, you can get access to extra validation o #### BIDS stats model validation -Please see [the documentation](https://bidspm.readthedocs.io/en/latest/bids_stats_model.html#using-the-bids-stats-model-python-package) +Please see [the documentation](https://bidspm.readthedocs.io/en/latest/stats/bids_stats_model.html#using-the-bids-stats-model-python-package) #### BIDS dataset validation diff --git a/docs/source/usage_notes.rst b/docs/source/usage_notes.rst index ec85b0263..0c00b296e 100644 --- a/docs/source/usage_notes.rst +++ b/docs/source/usage_notes.rst @@ -11,5 +11,5 @@ Command line API ================ .. argparse:: - :ref: src.parsers.common_parser + :ref: src.bidspm.parsers.common_parser :prog: bidspm diff --git a/lib/bids-matlab b/lib/bids-matlab index 6e4c2f79e..f57e1fc75 160000 --- a/lib/bids-matlab +++ b/lib/bids-matlab @@ -1 +1 @@ -Subproject commit 6e4c2f79e8a81808b9b5cef28bb6ab22fc95e70b +Subproject commit f57e1fc759bc4656aa96782db81ca7dcf80457f6 diff --git a/lib/utils/unfold.m b/lib/utils/unfold.m index 5b6b901f8..14000211c 100644 --- a/lib/utils/unfold.m +++ b/lib/utils/unfold.m @@ -97,7 +97,7 @@ function unfold(input, varargin) for i = 1:NF if NS > 1 - size_ = size_e(input); + size_ = size(input); if show name_i = [name '(' indToStr(size_, h) ').' F{i}]; else diff --git a/pyproject.toml b/pyproject.toml index 212740487..ca1b41f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,8 +66,8 @@ style = [ ] [project.scripts] -bidspm = "bidspm:cli" -validate_model = "validate:main" +bidspm = "bidspm.bidspm:cli" +validate_model = "bidspm.validate:cli" [project.urls] Homepage = "https://bidspm.readthedocs.io" @@ -133,4 +133,10 @@ module = [ ] [tool.pytest.ini_options] -addopts = "-ra -vv" +addopts = "-ra -q -vv --showlocals --strict-markers --strict-config" +# filterwarnings = ["error"] +# log_cli_level = "warning" +minversion = "6.0.0" +norecursedirs = "data" +testpaths = ["tests/"] +xfail_strict = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..83bd631ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --strip-extras pyproject.toml +# +annotated-types==0.7.0 + # via pydantic +bsmschema==0.1.0 + # via bidspm (pyproject.toml) +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +pydantic==2.8.0 + # via bsmschema +pydantic-core==2.20.0 + # via pydantic +pygments==2.18.0 + # via rich +rich==13.7.1 + # via + # bidspm (pyproject.toml) + # rich-argparse +rich-argparse==1.5.2 + # via bidspm (pyproject.toml) +typing-extensions==4.12.2 + # via + # pydantic + # pydantic-core diff --git a/src/IO/saveOptions.m b/src/IO/saveOptions.m index 1fd9e5721..d2262fef9 100644 --- a/src/IO/saveOptions.m +++ b/src/IO/saveOptions.m @@ -17,7 +17,9 @@ function saveOptions(opt) spm_mkdir(optionDir); taskString = ''; - if isfield(opt, 'taskName') + if isfield(opt, 'taskName') && ... + iscellstr(opt.taskName) && ... + ~all(cellfun(@(x) strcmp(x, ''), opt.taskName)) taskString = ['_task-', strjoin(opt.taskName, '')]; end diff --git a/src/bidspm/bidspm.py b/src/bidspm/bidspm.py index 66b5e215b..8c0cb99f9 100755 --- a/src/bidspm/bidspm.py +++ b/src/bidspm/bidspm.py @@ -13,12 +13,12 @@ log = bidspm_log(name="bidspm") -new_line = ", ...\n\t\t\t " +new_line = ", ...\n\t " def base_cmd(bids_dir: Path, output_dir: Path) -> str: - cmd = " bidspm();" - cmd += f" bidspm('{bids_dir}'{new_line}'{output_dir}'" + cmd = " bidspm('init');" + cmd += f" bidspm( '{bids_dir}'{new_line}'{output_dir}'" return cmd @@ -36,11 +36,14 @@ def append_base_arguments( cmd: str, verbosity: int | None = None, space: list[str] | None = None, - task: list[str] | None = None, + task: list[str] | str | None = None, ignore: list[str] | None = None, + options: Path | None = None, ) -> str: """Append arguments common to all actions to the command string.""" - task = "{ '" + "', '".join(task) + "' }" if task is not None else None # type: ignore + if task != "{''}": + task = "{ '" + "', '".join(task) + "' }" if task is not None else None + space = "{ '" + "', '".join(space) + "' }" if space is not None else None # type: ignore ignore = "{ '" + "', '".join(ignore) + "' }" if ignore is not None else None # type: ignore @@ -52,6 +55,8 @@ def append_base_arguments( cmd += f"{new_line}'task', {task}" if ignore: cmd += f"{new_line}'ignore', {ignore}" + if options: + cmd += f"{new_line}'options', '{str(options)}'" return cmd @@ -90,23 +95,33 @@ def default_model( analysis_level: str = "dataset", verbosity: int = 2, space: list[str] | None = None, - task: list[str] | None = None, + task: list[str] | str | None = None, ignore: list[str] | None = None, -) -> int: + options: Path | None = None, +) -> int | str: if space and len(space) > 1: log.error(f"Only one space allowed for statistical analysis. Got\n:{space}") return 1 - cmd = base_cmd(bids_dir=bids_dir, output_dir=output_dir) - cmd = append_main_cmd(cmd=cmd, analysis_level=analysis_level, action="default_model") - cmd = append_base_arguments( - cmd=cmd, verbosity=verbosity, space=space, task=task, ignore=ignore + if task is None: + task = "{''}" + + cmd = generate_cmd( + bids_dir=bids_dir, + output_dir=output_dir, + analysis_level=analysis_level, + action="default_model", + verbosity=verbosity, + space=space, + task=task, + ignore=ignore, + options=options, ) cmd = end_cmd(cmd) log.info("Creating default model.") - return run_command(cmd) + return cmd def preprocess( @@ -124,15 +139,22 @@ def preprocess( skip_validation: bool = False, bids_filter_file: Path | None = None, dry_run: bool = False, -) -> int: + options: Path | None = None, +) -> int | str: if action == "preprocess" and task and len(task) > 1: log.error(f"Only one task allowed for preprocessing. Got\n:{task}") return 1 - cmd = base_cmd(bids_dir=bids_dir, output_dir=output_dir) - cmd = append_main_cmd(cmd=cmd, analysis_level="subject", action=action) - cmd = append_base_arguments( - cmd=cmd, verbosity=verbosity, space=space, task=task, ignore=ignore + cmd = generate_cmd( + bids_dir=bids_dir, + output_dir=output_dir, + analysis_level="subject", + action=action, + verbosity=verbosity, + space=space, + task=task, + ignore=ignore, + options=options, ) cmd = append_common_arguments( cmd=cmd, @@ -155,13 +177,12 @@ def preprocess( elif action == "smooth": log.info("Running smoothing.") - return run_command(cmd) + return cmd def create_roi( bids_dir: Path, output_dir: Path, - action: str, preproc_dir: Path | None = None, verbosity: int = 2, participant_label: list[str] | None = None, @@ -170,17 +191,20 @@ def create_roi( roi_name: list[str] | None = None, space: list[str] | None = None, bids_filter_file: Path | None = None, -) -> int: + options: Path | None = None, +) -> str: roi_name = "{ '" + "', '".join(roi_name) + "' }" if roi_name is not None else None # type: ignore if roi_dir is None: roi_dir = Path() - cmd = base_cmd(bids_dir=bids_dir, output_dir=output_dir) - cmd = append_main_cmd(cmd=cmd, analysis_level="subject", action=action) - cmd = append_base_arguments( - cmd=cmd, + cmd = generate_cmd( + bids_dir=bids_dir, + output_dir=output_dir, + analysis_level="subject", + action="create_roi", verbosity=verbosity, space=space, + options=options, ) cmd = append_common_arguments( cmd=cmd, @@ -197,12 +221,13 @@ def create_roi( log.info("Creating ROI.") - return run_command(cmd) + return cmd def stats( bids_dir: Path, output_dir: Path, + analysis_level: str, action: str, preproc_dir: Path, model_file: Path, @@ -219,15 +244,22 @@ def stats( concatenate: bool = False, design_only: bool = False, keep_residuals: bool = False, -) -> int: + options: Path | None = None, +) -> int | str: if space and len(space) > 1: log.error(f"Only one space allowed for statistical analysis. Got\n:{space}") return 1 - cmd = base_cmd(bids_dir=bids_dir, output_dir=output_dir) - cmd = append_main_cmd(cmd=cmd, analysis_level="subject", action=action) - cmd = append_base_arguments( - cmd=cmd, verbosity=verbosity, space=space, task=task, ignore=ignore + cmd = generate_cmd( + bids_dir=bids_dir, + output_dir=output_dir, + analysis_level=analysis_level, + action=action, + verbosity=verbosity, + space=space, + task=task, + ignore=ignore, + options=options, ) cmd = append_common_arguments( cmd=cmd, @@ -251,30 +283,55 @@ def stats( log.info("Running statistics.") - return run_command(cmd) + return cmd + + +def generate_cmd( + bids_dir: Path, + output_dir: Path, + analysis_level: str, + action: str, + verbosity: int = 2, + space: list[str] | None = None, + task: list[str] | str | None = None, + ignore: list[str] | None = None, + options: Path | None = None, +) -> str: + cmd = base_cmd(bids_dir=bids_dir, output_dir=output_dir) + cmd = append_main_cmd(cmd=cmd, analysis_level=analysis_level, action=action) + cmd = append_base_arguments( + cmd=cmd, + verbosity=verbosity, + space=space, + task=task, + ignore=ignore, + options=options, + ) + return cmd def cli(argv: Any = sys.argv) -> None: parser = common_parser() - args, unknowns = parser.parse_known_args(argv[1:]) + args, _ = parser.parse_known_args(argv[1:]) - bids_dir = Path(args.bids_dir[0]).resolve() - output_dir = Path(args.output_dir[0]).resolve() + bids_dir = Path(args.bids_dir[0]).absolute() + output_dir = Path(args.output_dir[0]).absolute() analysis_level = args.analysis_level[0] action = args.action[0] roi_atlas = args.roi_atlas[0] bids_filter_file = ( - Path(args.bids_filter_file[0]).resolve() + Path(args.bids_filter_file[0]).absolute() if args.bids_filter_file is not None else None ) preproc_dir = ( - Path(args.preproc_dir[0]).resolve() if args.preproc_dir is not None else None + Path(args.preproc_dir[0]).absolute() if args.preproc_dir is not None else None ) model_file = ( - Path(args.model_file[0]).resolve() if args.model_file is not None else None + Path(args.model_file[0]).absolute() if args.model_file is not None else None ) + options = Path(args.options).absolute() if args.options is not None else None return_code = bidspm( bids_dir, @@ -301,6 +358,7 @@ def cli(argv: Any = sys.argv) -> None: concatenate=args.concatenate, design_only=args.design_only, keep_residuals=args.keep_residuals, + options=options, ) if return_code == 1: @@ -334,6 +392,7 @@ def bidspm( concatenate: bool = False, design_only: bool = False, keep_residuals: bool = False, + options: Path | None = None, ) -> int: if not bids_dir.is_dir(): log.error(f"The 'bids_dir' does not exist:\n\t{bids_dir}") @@ -344,7 +403,7 @@ def bidspm( return 1 if action == "default_model": - return_code = default_model( + cmd = default_model( bids_dir=bids_dir, output_dir=output_dir, analysis_level=analysis_level, @@ -352,10 +411,11 @@ def bidspm( task=task, space=space, ignore=ignore, + options=options, ) elif action in {"preprocess", "smooth"}: - return_code = preprocess( + cmd = preprocess( bids_dir=bids_dir, output_dir=output_dir, action=action, @@ -370,12 +430,12 @@ def bidspm( anat_only=anat_only, bids_filter_file=bids_filter_file, dry_run=dry_run, + options=options, ) elif action in {"create_roi"}: - return_code = create_roi( + cmd = create_roi( bids_dir=bids_dir, output_dir=output_dir, - action=action, preproc_dir=preproc_dir, verbosity=verbosity, participant_label=participant_label, @@ -384,6 +444,7 @@ def bidspm( roi_name=roi_name, space=space, bids_filter_file=bids_filter_file, + options=options, ) elif action in {"stats", "contrasts", "results"}: @@ -395,9 +456,10 @@ def bidspm( log.error(f"'model_file' must be specified for stats. Got:\n{model_file}") return 1 - return_code = stats( + cmd = stats( bids_dir=bids_dir, output_dir=output_dir, + analysis_level=analysis_level, action=action, preproc_dir=preproc_dir, model_file=model_file, @@ -413,12 +475,13 @@ def bidspm( concatenate=concatenate, design_only=design_only, keep_residuals=keep_residuals, + options=options, ) else: log.error(f"\nunknown action: {action}") return 1 - return return_code + return cmd if isinstance(cmd, int) else run_command(cmd) def run_command(cmd: str, platform: str | None = None) -> int: diff --git a/src/bidspm/matlab.py b/src/bidspm/matlab.py index 07ed64aaa..f61073517 100644 --- a/src/bidspm/matlab.py +++ b/src/bidspm/matlab.py @@ -2,12 +2,16 @@ def matlab() -> str: - """Return the path to the Matlab executable. + r"""Return the path to the Matlab executable. Modify this value to match your Matlab installation. The MATLAB 'matlabroot' should tell you where MATLAB is installed. The 'matlab' executable is usually in the 'bin' subdirectory. + + - Windows: ``'C:\\Program Files\\MATLAB\\R20XXx\bin\\matlab.exe'`` + - Mac: ``'/Applications/Matlab_R20XXx.app/bin/matlab'`` + - Linux: ``'/usr/local/MATLAB/R20XXx/bin/matlab'`` """ - return "/usr/local/MATLAB/R2018a/ bin/matlab" + return "/usr/local/MATLAB/R2018a/bin/matlab" diff --git a/src/bidspm/parsers.py b/src/bidspm/parsers.py index 68a0ab5f9..a1c04759b 100644 --- a/src/bidspm/parsers.py +++ b/src/bidspm/parsers.py @@ -94,7 +94,7 @@ def common_parser() -> argparse.ArgumentParser: help=""" Verbosity level. """, - choices=[0, 1, 2], + choices=[0, 1, 2, 3], default=2, type=int, nargs=1, diff --git a/src/bidspm/validate.py b/src/bidspm/validate.py index 6af9daee6..6157bd9da 100644 --- a/src/bidspm/validate.py +++ b/src/bidspm/validate.py @@ -27,28 +27,31 @@ def validate(file: Path) -> int: return 1 -def main(argv: Any = sys.argv) -> None: - parser = validate_parser() - args = parser.parse_args(argv[1:]) - - input_ = Path(args.model[0]) +def main(input: Path) -> int: + if not input.exists(): + raise FileNotFoundError(f"{input} does not exist.") - if not input_.exists(): - raise FileNotFoundError(f"{input_} does not exist.") + if input.is_file(): + log.info(f"Validating {input}") + global_status = validate(input) - elif input_.is_file(): - log.info(f"Validating {input_}") - return_code = validate(input_) - sys.exit(return_code) - - if input_.is_dir(): + elif input.is_dir(): global_status = 0 - for file in input_.glob("*_smdl.json"): + for file in input.glob("*_smdl.json"): return_code = validate(file) if return_code == 1: global_status = 1 - sys.exit(global_status) + + return global_status + + +def cli(argv: Any = sys.argv) -> None: + parser = validate_parser() + args = parser.parse_args(argv[1:]) + input_ = Path(args.model[0]).resolve() + return_code = main(input=input_) + sys.exit(return_code) if __name__ == "__main__": - main() + cli(sys.argv) diff --git a/src/cli/getOptionsFromCliArgument.m b/src/cli/getOptionsFromCliArgument.m index 5c35edcd8..eaab7b5db 100644 --- a/src/cli/getOptionsFromCliArgument.m +++ b/src/cli/getOptionsFromCliArgument.m @@ -74,6 +74,11 @@ opt.toolbox.MACS.model.dir = args.Results.models_dir; end + if opt.verbosity > 3 + unfold(opt); + end + unfold(opt); + end function opt = optionsPreprocessing(opt, args, action) @@ -147,10 +152,14 @@ end function opt = getOptions(args) + if isstruct(args.Results.options) opt = args.Results.options; elseif exist(args.Results.options, 'file') == 2 opt = bids.util.jsondecode(args.Results.options); + opt = checkOptions(opt); + logger('INFO', ['options loaded from file:\n', args.Results.options], ... + 'options', opt); end if isempty(opt) % set defaults diff --git a/src/infra/checkToolbox.m b/src/infra/checkToolbox.m index 5fbdb94f9..ac4d076ca 100644 --- a/src/infra/checkToolbox.m +++ b/src/infra/checkToolbox.m @@ -100,7 +100,7 @@ function updateMacstoolbox() end if exist(target_dir, 'dir') == 7 - msg = sprintf('updating MACS toolbox: '); + msg = sprintf('updating MACS toolbox\n'); fprintf(1, msg); system(sprintf('git -C %s pull', ... target_dir)); diff --git a/src/stats/subject_level/constructContrastNameFromBidsEntity.m b/src/stats/subject_level/constructContrastNameFromBidsEntity.m index 67d085545..4b7c7aae5 100644 --- a/src/stats/subject_level/constructContrastNameFromBidsEntity.m +++ b/src/stats/subject_level/constructContrastNameFromBidsEntity.m @@ -6,7 +6,7 @@ % % contrastName = constructContrastNameFromBidsEntity(cdtName, SPM, iSess) % - % If no information is foun + % If no information is found % it falls back on using the the SPM session number % % :param SPM: content of SPM.mat diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..c61660fb6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture +def root_dir(): + return Path(__file__).parents[1] + + +@pytest.fixture +def data_dir(): + return Path(__file__).parent / "data" diff --git a/tests/test_bidspm.py b/tests/test_bidspm.py index 2ae21a828..f7602bd74 100644 --- a/tests/test_bidspm.py +++ b/tests/test_bidspm.py @@ -3,14 +3,20 @@ from pathlib import Path -from bidspm.parsers import common_parser -from src.bidspm import ( +import pytest + +from bidspm.bidspm import ( append_base_arguments, append_common_arguments, base_cmd, bidspm, + create_roi, + default_model, + preprocess, run_command, + stats, ) +from bidspm.parsers import common_parser def test_base_cmd(): @@ -18,7 +24,7 @@ def test_base_cmd(): bids_dir = Path("/path/to/bids") output_dir = Path("/path/to/output") cmd = base_cmd(bids_dir, output_dir) - assert cmd == " bidspm(); bidspm('/path/to/bids', ...\n\t\t\t '/path/to/output'" + assert cmd == " bidspm('init'); bidspm( '/path/to/bids', ...\n\t '/path/to/output'" def test_parser(): @@ -52,7 +58,7 @@ def test_append_common_arguments(): ) assert ( cmd - == ", ...\n\t\t\t 'fwhm', 6, ...\n\t\t\t 'participant_label', { '01', '02' }, ...\n\t\t\t 'skip_validation', true, ...\n\t\t\t 'dry_run', true" + == ", ...\n\t 'fwhm', 6, ...\n\t 'participant_label', { '01', '02' }, ...\n\t 'skip_validation', true, ...\n\t 'dry_run', true" ) @@ -62,7 +68,7 @@ def test_append_base_arguments(): ) assert ( cmd - == ", ...\n\t\t\t 'verbosity', 0, ...\n\t\t\t 'space', { 'foo', 'bar' }, ...\n\t\t\t 'task', { 'spam', 'eggs' }, ...\n\t\t\t 'ignore', { 'nii' }" + == ", ...\n\t 'verbosity', 0, ...\n\t 'space', { 'foo', 'bar' }, ...\n\t 'task', { 'spam', 'eggs' }, ...\n\t 'ignore', { 'nii' }" ) @@ -95,3 +101,133 @@ def test_bidspm_error_action(caplog): ) assert return_code == 1 assert ["\nunknown action: spam"] == [rec.message for rec in caplog.records] + + +@pytest.mark.parametrize( + "action", + [ + "preprocess", + "smooth", + ], +) +@pytest.mark.parametrize("dry_run", [True, False]) +@pytest.mark.parametrize("skip_validation", [True, False]) +@pytest.mark.parametrize("anat_only", [True, False]) +def test_preprocess(action, dry_run, skip_validation, anat_only): + + preprocess( + bids_dir=Path(), + output_dir=Path(), + action=action, + verbosity=2, + participant_label=["01"], + fwhm=6, + dummy_scans=1, + space=["MNI"], + task=["rest"], + ignore=["slice-timing"], + anat_only=anat_only, + skip_validation=skip_validation, + bids_filter_file=None, + dry_run=dry_run, + ) + + +def test_options(): + cmd = preprocess( + bids_dir=Path(), + output_dir=Path(), + action="preprocess", + participant_label=["01"], + space=["MNI"], + task=["rest"], + options=Path() / "foo.json", + ) + assert "'options', 'foo.json'" in cmd + + cmd = stats( + bids_dir=Path(), + output_dir=Path(), + preproc_dir=Path(), + analysis_level="subject", + model_file=Path(), + action="stats", + participant_label=["01"], + options=Path() / "foo.json", + ) + assert "'options', 'foo.json'" in cmd + + +@pytest.mark.parametrize("analysis_level", ["subject", "dataset"]) +@pytest.mark.parametrize( + "action", + [ + "stats", + "contrasts", + "results", + ], +) +@pytest.mark.parametrize("roi_based", [True, False]) +@pytest.mark.parametrize("concatenate", [True, False]) +@pytest.mark.parametrize("design_only", [True, False]) +@pytest.mark.parametrize("keep_residuals", [True, False]) +@pytest.mark.parametrize("dry_run", [True, False]) +@pytest.mark.parametrize("skip_validation", [True, False]) +def test_stats( + analysis_level, + action, + roi_based, + dry_run, + keep_residuals, + skip_validation, + design_only, + concatenate, +): + + cmd = stats( + bids_dir=Path(), + output_dir=Path(), + preproc_dir=Path(), + analysis_level=analysis_level, + model_file=Path(), + action=action, + verbosity=2, + participant_label=["01"], + fwhm=6, + skip_validation=skip_validation, + bids_filter_file=None, + dry_run=dry_run, + roi_based=roi_based, + keep_residuals=keep_residuals, + design_only=design_only, + concatenate=concatenate, + ) + assert analysis_level in cmd + + +@pytest.mark.parametrize("analysis_level", ["subject", "dataset"]) +def test_defautl_model(analysis_level): + default_model( + bids_dir=Path(), + output_dir=Path(), + analysis_level=analysis_level, + verbosity=2, + space=["MNI"], + task=["rest"], + ignore=["foo"], + ) + + +def test_create_roi(): + create_roi( + bids_dir=Path(), + output_dir=Path(), + preproc_dir=Path(), + verbosity=2, + participant_label=["01"], + roi_dir=Path(), + roi_atlas="neuromorphometrics", + roi_name=["foo", "bar"], + space=["MNI"], + bids_filter_file=None, + ) diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 000000000..4dcab02cf --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,16 @@ +import pytest + +from bidspm.validate import main + + +def test_main_missing(data_dir): + with pytest.raises(FileNotFoundError): + main(data_dir / "models" / "foo.json") + + +def test_main_file(data_dir): + main(data_dir / "models" / "model-bug385_smdl.json") + + +def test_main_folder(root_dir): + main(root_dir / "demos" / "MoAE" / "models") diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..3467b8676 --- /dev/null +++ b/tox.ini @@ -0,0 +1,33 @@ +; See https://tox.wiki/en +[tox] +requires = + tox>=4 +; run lint by default when just calling "tox" +env_list = lint + +; ENVIRONMENTS +; ------------ +[style] +description = common environment for style checkers (rely on pre-commit hooks) +skip_install = true +deps = + pre-commit + +; COMMANDS +; -------- +[testenv:lint] +description = install pre-commit hooks and run all linters and formatters +skip_install = true +deps = + {[style]deps} +commands = + pre-commit install + pre-commit run --all-files --show-diff-on-failure {posargs:} + +[testenv:update_dependencies] +description = update requirements.txt +skip_install = true +deps = + pip-tools +commands = + pip-compile --strip-extras pyproject.toml {posargs:}