diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 371404bc22438c8526386453de3d99183daaf155..482b9abfc76ebdbb9d69c384ad2b276ecbf4f92a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,8 @@ Added ^^^^^ +* New wrapper functions for the FSL :func:`.prelude` and :func:`applyxfm4D` + commands. * New ``firstDot`` option to the :func:`.path.getExt`, :func:`.path.removeExt`, and :func:`.path.splitExt`, functions, offering rudimentary support for double-barrelled filenames. @@ -21,9 +23,38 @@ Changed * The :func:`.gifti.relatedFiles` function now supports files with BIDS-style naming conventions. +* The :func:`.run.run` and :func:`.run.runfsl` functions now pass through + any additional keyword arguments to ``subprocess.Popen``. +* The :func:`.run.runfsl` function now raises an error on attempts to + run a command which is not present in ``$FSLDIR/bin/`` (e.g. ``ls``). * The :mod:`.bids` module has been updated to support files with any extension, not just those in the core BIDS specification (``.nii``, ``.nii.gz``, ``.json``, ``.tsv``). +* The return value of a function decorated with :func:`.fileOrImage`, + :func:`.fileOrArray`, or :func:`.fileOrText` is now accessed via an attribute + called ``stdout``, instead of ``output``. +* Output files of functions decorated with :func:`.fileOrImage`, + :func:`.fileOrArray`, or :func:`.fileOrText`, which have been loaded via the + :attr:`.LOAD` symbol, can now be accessed as attributes of the returned + results object, in addition to being accessed as dict items. +* Wrapper functions decorated with the :func:`.fileOrImage`, + :func:`.fileOrArray`, or :func:`.fileOrText` decorators will now pass all + arguments and return values through unchanged if an argument called ``submit`` + is passed in, and is set to ``True`` (or any non-``False`` + value). Furthermore, in such a scenario a :exc:`ValueError` will be raised if + any in-memory objects or ``LOAD`` symbols are passed. +* The :func:`.fileOrText` decorator has been updated to work with input + values - file paths must be passed in as ``pathlib.Path`` objects, so they + can be differentiated from input values. + + +Fixed +^^^^^ + + +* Updated the :func:`.prepareArgs` function to use ``shlex.split`` when + preparing shell command arguments, instead of performing a naive whitespace + split. 2.8.4 (Monday 2nd March 2020) @@ -213,8 +244,8 @@ Changed * The :class:`.Cache` class has a new ``lru`` option, allowing it to be used as a least-recently-used cache. -* The :mod:`.filetree` module has been refactored to make it easier for the - :mod:`.query` module to work with file tree hierarchies. +* The :mod:`fsl.utils.filetree` module has been refactored to make it easier + for the :mod:`.query` module to work with file tree hierarchies. * The :meth:`.LabelAtlas.get` method has a new ``binary`` flag, allowing either a binary mask, or a mask with the original label value, to be returned. diff --git a/doc/conf.py b/doc/conf.py index 792b358e7bfa05bf7b5dcd050d4360dd61accec2..804db7ee21fadb60cb323c44f4cf71faee4253f5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -56,7 +56,7 @@ master_doc = 'index' # General information about the project. project = u'fslpy' -copyright = u'{}, Paul McCarthy, University of Oxford, Oxford, UK'.format( +copyright = u'{}, FMRIB Centre, University of Oxford, Oxford, UK'.format( date.year) # Links to other things diff --git a/fsl/utils/image/__init__.py b/fsl/utils/image/__init__.py index 54dde4b2a7c40e1c97f8b7fcfaa4f86d1b5781b5..a0d9ace49d800c95f6a8e62921c1a97e9632d096 100644 --- a/fsl/utils/image/__init__.py +++ b/fsl/utils/image/__init__.py @@ -10,8 +10,7 @@ manipulating and working with :class:`.Image` objects. The following modules are available: .. autosummary:: - :nosignature - .image.resample - .image.roi + fsl.utils.image.resample + fsl.utils.image.roi """ diff --git a/fsl/utils/run.py b/fsl/utils/run.py index 12cc036067d7c502afbca6cd90153c38ced00859..7211965d773f03d6c3abd583e3a4776bebaf7c2c 100644 --- a/fsl/utils/run.py +++ b/fsl/utils/run.py @@ -3,6 +3,7 @@ # run.py - Functions for running shell commands # # Author: Paul McCarthy <pauldmccarthy@gmail.com> +# Author: Michiel Cottaar <michiel.cottaar@ndcn.ox.ac.uk> # """This module provides some functions for running shell commands. @@ -20,6 +21,7 @@ import sys +import shlex import logging import threading import contextlib @@ -81,7 +83,7 @@ def prepareArgs(args): # Argument was a command string if isinstance(args[0], six.string_types): - args = args[0].split() + args = shlex.split(args[0]) # Argument was an unpacked sequence else: @@ -164,17 +166,21 @@ def run(*args, **kwargs): - cmd: Optional file-like object to which the command itself is logged. + All other keyword arguments are passed through to the ``subprocess.Popen`` + object (via :func:`_realrun`), unless ``submit=True``, in which case they + are ignored. + :returns: If ``submit`` is provided, the return value of :func:`.fslsub` is returned. Otherwise returns a single value or a tuple, based on the based on the ``stdout``, ``stderr``, and ``exitcode`` arguments. """ - returnStdout = kwargs.get('stdout', True) - returnStderr = kwargs.get('stderr', False) - returnExitcode = kwargs.get('exitcode', False) - submit = kwargs.get('submit', {}) - log = kwargs.get('log', {}) + returnStdout = kwargs.pop('stdout', True) + returnStderr = kwargs.pop('stderr', False) + returnExitcode = kwargs.pop('exitcode', False) + submit = kwargs.pop('submit', {}) + log = kwargs.pop('log', {}) tee = log .get('tee', False) logStdout = log .get('stdout', None) logStderr = log .get('stderr', None) @@ -206,7 +212,7 @@ def run(*args, **kwargs): # Run directly - delegate to _realrun stdout, stderr, exitcode = _realrun( - tee, logStdout, logStderr, logCmd, *args) + tee, logStdout, logStderr, logCmd, *args, **kwargs) if not returnExitcode and (exitcode != 0): raise RuntimeError('{} returned non-zero exit code: {}'.format( @@ -221,7 +227,6 @@ def run(*args, **kwargs): else: return tuple(results) - def _dryrun(submit, returnStdout, returnStderr, returnExitcode, *args): """Used by the :func:`run` function when the :attr:`DRY_RUN` flag is active. @@ -242,7 +247,7 @@ def _dryrun(submit, returnStdout, returnStderr, returnExitcode, *args): else: return tuple(results) -def _realrun(tee, logStdout, logStderr, logCmd, *args): +def _realrun(tee, logStdout, logStderr, logCmd, *args, **kwargs): """Used by :func:`run`. Runs the given command and manages its standard output and error streams. @@ -261,12 +266,14 @@ def _realrun(tee, logStdout, logStderr, logCmd, *args): :arg args: Command to run + :arg kwargs: Passed through to the ``subprocess.Popen`` object. + :returns: A tuple containing: - the command's standard output as a string. - the command's standard error as a string. - the command's exit code. """ - proc = sp.Popen(args, stdout=sp.PIPE, stderr=sp.PIPE) + proc = sp.Popen(args, stdout=sp.PIPE, stderr=sp.PIPE, **kwargs) with tempdir.tempdir(changeto=False) as td: # We always direct the command's stdout/ @@ -352,6 +359,12 @@ def runfsl(*args, **kwargs): args[0] = cmdpath break + # error if the command cannot + # be found in a FSL directory + else: + raise FileNotFoundError('FSL tool {} not found (checked {})'.format( + args[0], ', '.join(prefixes))) + return run(*args, **kwargs) diff --git a/fsl/wrappers/__init__.py b/fsl/wrappers/__init__.py index 453313f2ca14e592732cf8866cabd78065413d29..5e1f61e89dda468017033e20b526712d70dda4c4 100644 --- a/fsl/wrappers/__init__.py +++ b/fsl/wrappers/__init__.py @@ -87,6 +87,7 @@ from .fsl_anat import (fsl_anat,) # noqa from .flirt import (flirt, # noqa invxfm, applyxfm, + applyxfm4D, concatxfm, mcflirt) from .fnirt import (fnirt, # noqa @@ -95,6 +96,7 @@ from .fnirt import (fnirt, # noqa convertwarp) from .fslmaths import (fslmaths,) # noqa from .fugue import (fugue, # noqa + prelude, sigloss) from .melodic import (melodic, # noqa fsl_regfilt) diff --git a/fsl/wrappers/flirt.py b/fsl/wrappers/flirt.py index 8b5d1d526dc6a893441204d788d58b7302d59260..df7eb2722b19954303ef36132f7d7f36365ac673 100644 --- a/fsl/wrappers/flirt.py +++ b/fsl/wrappers/flirt.py @@ -14,6 +14,7 @@ tools. flirt applyxfm + applyxfm4D invxfm concatxfm mcflirt @@ -70,6 +71,25 @@ def applyxfm(src, ref, mat, out, interp='spline', **kwargs): **kwargs) +@wutils.fileOrArray('mat') +@wutils.fileOrImage('src', 'ref', 'out') +@wutils.fslwrapper +def applyxfm4D(src, ref, out, mat, **kwargs): + """Wrapper for the ``applyxfm4D`` command. """ + + asrt.assertIsNifti(src, ref) + + valmap = { + 'singlematrix' : wutils.SHOW_IF_TRUE, + 'fourdigit' : wutils.SHOW_IF_TRUE, + } + + cmd = ['applyxfm4D', src, ref, out, mat] + cmd += wutils.applyArgStyle('-', valmap=valmap, **kwargs) + + return cmd + + @wutils.fileOrArray() @wutils.fslwrapper def invxfm(inmat, omat): diff --git a/fsl/wrappers/fugue.py b/fsl/wrappers/fugue.py index ced52f2267bc3140794d2baddd7de778b6bd2daa..8b61f030c5e048888692569f1be9bfc2ec1dcc9e 100644 --- a/fsl/wrappers/fugue.py +++ b/fsl/wrappers/fugue.py @@ -62,3 +62,24 @@ def sigloss(input, sigloss, **kwargs): cmd += wutils.applyArgStyle('--', valmap=valmap, **kwargs) return cmd + + +@wutils.fileOrImage('complex', 'abs', 'phase', 'mask', + 'out', 'unwrap', 'savemask', 'rawphase', 'labels') +@wutils.fslwrapper +def prelude(**kwargs): + """Wrapper for the ``sigloss`` command.""" + + valmap = { + 'labelslices' : wutils.SHOW_IF_TRUE, + 'slices' : wutils.SHOW_IF_TRUE, + 'force3D' : wutils.SHOW_IF_TRUE, + 'removeramps' : wutils.SHOW_IF_TRUE, + 'verbose' : wutils.SHOW_IF_TRUE, + } + + cmd = ['prelude'] + wutils.applyArgStyle('--=', + valmap=valmap, + **kwargs) + + return cmd diff --git a/fsl/wrappers/wrapperutils.py b/fsl/wrappers/wrapperutils.py index 81fb86826849b6a4eb55c9012f71c0c61cf40cc6..f325bca18d5f5c38990b0db57c8066746acf392a 100644 --- a/fsl/wrappers/wrapperutils.py +++ b/fsl/wrappers/wrapperutils.py @@ -95,6 +95,7 @@ import sys import glob import random import string +import pathlib import fnmatch import inspect import logging @@ -154,12 +155,12 @@ def cmdwrapper(func): :func:`fsl.utils.run.run` function in a standardised manner. """ def wrapper(*args, **kwargs): - stdout = kwargs.pop('stdout', True) - stderr = kwargs.pop('stderr', True) + stdout = kwargs.pop('stdout', True) + stderr = kwargs.pop('stderr', True) exitcode = kwargs.pop('exitcode', False) - submit = kwargs.pop('submit', None) - log = kwargs.pop('log', {'tee' : True}) - cmd = func(*args, **kwargs) + submit = kwargs.pop('submit', None) + log = kwargs.pop('log', {'tee' : True}) + cmd = func(*args, **kwargs) return run.run(cmd, stderr=stderr, log=log, @@ -175,12 +176,12 @@ def fslwrapper(func): :func:`fsl.utils.run.runfsl` function in a standardised manner. """ def wrapper(*args, **kwargs): - stdout = kwargs.pop('stdout', True) - stderr = kwargs.pop('stderr', True) + stdout = kwargs.pop('stdout', True) + stderr = kwargs.pop('stderr', True) exitcode = kwargs.pop('exitcode', False) - submit = kwargs.pop('submit', None) - log = kwargs.pop('log', {'tee' : True}) - cmd = func(*args, **kwargs) + submit = kwargs.pop('submit', None) + log = kwargs.pop('log', {'tee' : True}) + cmd = func(*args, **kwargs) return run.runfsl(cmd, stderr=stderr, log=log, @@ -446,10 +447,33 @@ class _FileOrThing(object): Functions decorated with a ``_FileOrThing`` decorator will always return a ``dict``-like object, where the function's actual return value is - accessible via an attribute called ``output``. All output arguments with a + accessible via an attribute called ``stdout``. All output arguments with a value of ``LOAD`` will be present as dictionary entries, with the keyword - argument names used as keys. Any ``LOAD`` output arguments which were not - generated by the function will not be present in the dictionary. + argument names used as keys; these values will also be accessible as + attributes of the results dict, when possible. Any ``LOAD`` output + arguments which were not generated by the function will not be present in + the dictionary. + + + **Cluster submission** + + + The above description holds in all situations, except when an argument + called ``submit`` is passed, and is set to a value which evaluates to + ``True``. In this case, the ``_FileOrThing`` decorator will pass all + arguments straight through to the decorated function, and will return its + return value unchanged. + + + This is because most functions that are decorated with the + :func:`fileOrImage` or :func:`fileOrArray` decorators will invoke a call + to :func:`.run.run` or :func:`.runfsl`, where a value of ``submit=True`` + will cause the command to be executed asynchronously on a cluster + platform. + + + A :exc:`ValueError` will be raised if the decorated function is called + with ``submit=True``, and with any in-memory objects or ``LOAD`` symbols. **Example** @@ -488,20 +512,23 @@ class _FileOrThing(object): # The function's return value # is accessed via an attribute called - # "output" on the dict - assert concat('atob.txt', 'btoc.txt', 'atoc.mat').output == 'Done' + # "stdout" on the dict + assert concat('atob.txt', 'btoc.txt', 'atoc.mat').stdout == 'Done' # Outputs to be loaded into memory # are returned in a dictionary, - # with argument names as keys. + # with argument names as keys. Values + # can be accessed as dict items, or + # as attributes. atoc = concat('atob.txt', 'btoc.txt', LOAD)['atoc'] + atoc = concat('atob.txt', 'btoc.txt', LOAD).atoc # In-memory inputs are saved to # temporary files, and those file # names are passed to the concat # function. atoc = concat(np.diag([2, 2, 2, 0]), - np.diag([3, 3, 3, 3]), LOAD)['atoc'] + np.diag([3, 3, 3, 3]), LOAD).atoc **Using with other decorators** @@ -525,16 +552,37 @@ class _FileOrThing(object): items, with the argument name as key, and the output object (the "thing") as value. + Where possible (i.e. for outputs named with a valid Python + identifier), the outputs are also made accessible as attributes of + the ``_Results`` object. + The decorated function's actual return value is accessible via the - :meth:`output` property. + :meth:`stdout` property. """ - def __init__(self, output): - self.__output = output + + + def __init__(self, stdout): + """Create a ``_Results`` dict. + + :arg stdout: Return value of the decorated function (typically a + tuple containing the standard output and error of the + underlying command). + """ + super().__init__() + self.__stdout = stdout + + + def __setitem__(self, key, val): + """Add an item to the dict. The item is also added as an attribute. + """ + super().__setitem__(key, val) + setattr(self, key, val) + @property - def output(self): + def stdout(self): """Access the return value of the decorated function. """ - return self.__output + return self.__stdout def __init__(self, @@ -604,6 +652,27 @@ class _FileOrThing(object): func = self.__func argnames = namedPositionals(func, args) + # Special case - if fsl.utils.run[fsl] is + # being decorated (e.g. via cmdwrapper/ + # fslwrapper), and submit=True, this call + # will ultimately submit the job to the + # cluster, and will return immediately. + # + # We error if we are given any in-memory + # things, or LOAD symbols. + # + # n.b. testing values to be strings could + # interfere with the fileOrText decorator. + # Possible solution is to use pathlib? + if kwargs.get('submit', False): + allargs = {**dict(zip(argnames, args)), **kwargs} + for name, val in allargs.items(): + if (name in self.__things) and \ + (not isinstance(val, six.string_types)): + raise ValueError('Cannot use in-memory objects ' + 'or LOAD with submit=True!') + return func(*args, **kwargs) + # If this _FileOrThing is being called # by another _FileOrThing don't create # another working directory. We do this @@ -1032,14 +1101,39 @@ def fileOrText(*args, **kwargs): """Decorator which can be used to ensure that any text output (e.g. log file) are saved to text files, and output files can be loaded and returned as strings. + + To be able to distinguish between input values and input file paths, the + ``fileOrText`` decorator requires that input and output file paths are + passed in as ``pathlib.Path`` objects. For example, given a function + like this:: + + @fileOrText() + def myfunc(infile, outfile): + ... + + if we want to pass file paths for both ``infile`` and ``outfile``, we would + do this:: + + from pathlib import Path + myfunc(Path('input.txt'), Path('output.txt')) + + Input values may be passed in as normal strings, e.g.:: + + myfunc('input data', Path('output.txt')) + + Output values can be loaded as normal via the :attr:`LOAD` symbol, e.g.:: + + myfunc(Path('input.txt'), LOAD) """ def prepIn(workdir, name, val): infile = None - if isinstance(val, six.string_types): - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt') as f: + if not isinstance(val, pathlib.Path): + with tempfile.NamedTemporaryFile(mode='w', + suffix='.txt', + delete=False) as f: f.write(val) infile = f.name return infile diff --git a/setup.py b/setup.py index ea4b43f21f2978da1b4216d3bc4bffe3c8faa790..e1b4c967a72360f80883760c60f7dd25e9836097 100644 --- a/setup.py +++ b/setup.py @@ -105,6 +105,7 @@ setup( 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Software Development :: Libraries :: Python Modules'], packages=packages, diff --git a/tests/test_fslsub.py b/tests/test_fslsub.py index 878e9b32c14d5dbb838290133b3b5ebdf87fcaa6..e2fb9ebcc934e93a2b03a160cfbe36f156754013 100644 --- a/tests/test_fslsub.py +++ b/tests/test_fslsub.py @@ -3,9 +3,70 @@ # test_fslsub.py - Tests functions in the fsl.utils.fslsub module. # # Author: Michiel Cottaar <Michiel.Cottaar@ndcn.ox.ac.uk> +# Author: Paul McCarthy <pauldmccarthy@gmail.com> # -from fsl.utils import fslsub + +import os +import os.path as op +import sys +import textwrap as tw +import contextlib + +from fsl.utils import fslsub +from fsl.utils.tempdir import tempdir + +from . import mockFSLDIR + + +mock_fsl_sub = """ +#!{} + +import random +import os +import os.path as op +import sys +import subprocess as sp +import fsl + +args = sys.argv[1:] + +for i in range(len(args)): + a = args[i] + if a[0] == '-': + if a[1] == 's': + i += 2 + elif a[1] not in ('F', 'v'): + i += 1 + continue + else: + break + +args = args[i:] + +env = dict(os.environ) +env['PYTHONPATH'] = op.join(op.dirname(fsl.__file__), '..') + +cmd = op.basename(args[0]) +jobid = random.randint(1, 9999) + +with open('{{}}.o{{}}'.format(cmd, jobid), 'w') as stdout, \ + open('{{}}.e{{}}'.format(cmd, jobid), 'w') as stderr: + result = sp.run(args, stdout=stdout, stderr=stderr, env=env) + +print(str(jobid)) +sys.exit(0) +""".format(sys.executable).strip() + + +@contextlib.contextmanager +def fslsub_mockFSLDIR(): + with mockFSLDIR() as fsldir: + fslsubbin = op.join(fsldir, 'bin', 'fsl_sub') + with open(fslsubbin, 'wt') as f: + f.write(mock_fsl_sub) + os.chmod(fslsubbin, 0o755) + yield fsldir def test_flatten_jobids(): @@ -18,3 +79,42 @@ def test_flatten_jobids(): assert fslsub._flatten_job_ids([job_ids[:2], job_ids[2:]]) == res assert fslsub._flatten_job_ids([set(job_ids[:2]), job_ids[2:]]) == res assert fslsub._flatten_job_ids(((job_ids, ), job_ids + job_ids)) == res + + +def test_submit(): + script = tw.dedent("""#!/usr/bin/env bash + echo "standard output" + echo "standard error" >&2 + exit 0 + """).strip() + + with fslsub_mockFSLDIR(), tempdir(): + cmd = op.join('.', 'myscript') + with open(cmd, 'wt') as f: + f.write(script) + os.chmod(cmd, 0o755) + + jid = fslsub.submit(cmd) + fslsub.wait(jid) + stdout, stderr = fslsub.output(jid) + + assert stdout.strip() == 'standard output' + assert stderr.strip() == 'standard error' + + +def myfunc(): + print('standard output') + print('standard error', file=sys.stderr) + + +def test_func_to_cmd(): + with fslsub_mockFSLDIR(), tempdir(): + cmd = fslsub.func_to_cmd(myfunc, (), {}) + jid = fslsub.submit(cmd) + + fslsub.wait(jid) + + stdout, stderr = fslsub.output(jid) + + assert stdout.strip() == 'standard output' + assert stderr.strip() == 'standard error' diff --git a/tests/test_run.py b/tests/test_run.py index 8e7a7a8b1327508cb9b39ba6f2a84f8867be38fc..be9e443ca5d6b6da98b3bbf6187eff339d5c11b8 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -36,6 +36,17 @@ def mkexec(path, contents): os.chmod(path, 0o755) +def test_prepareArgs(): + tests = [ + ('a b c', ['a', 'b', 'c']), + (['a', 'b', 'c'], ['a', 'b', 'c']), + ('abc "woop woop"', ['abc', 'woop woop']), + (['abc', 'woop woop'], ['abc', 'woop woop']), + ] + + for args, expected in tests: + assert run.prepareArgs((args, )) == expected + def test_run(): test_script = textwrap.dedent(""" @@ -149,6 +160,25 @@ def test_run_tee(): assert capture.stdout == expstdout +def test_run_passthrough(): + + test_script = textwrap.dedent(""" + #!/bin/bash + + echo "env: $RUN_TEST_ENV_VAR" + """).strip() + + with tempdir.tempdir(): + + # return code == 0 + mkexec('script.sh', test_script.format(0)) + + env = {'RUN_TEST_ENV_VAR' : 'howdy ho'} + expstdout = "env: howdy ho\n" + + assert run.run('./script.sh', env=env) == expstdout + + def test_dryrun(): test_script = textwrap.dedent(""" @@ -203,6 +233,10 @@ def test_runfsl(): fslplatform.fsldir = fsldir assert run.runfsl('fslhd').strip() == 'fsldir' + # non-FSL command - should error + with pytest.raises(FileNotFoundError): + run.runfsl('ls') + # FSLDEVDIR should take precedence fsldevdir = './fsldev' fslhd = op.join(fsldevdir, 'bin', 'fslhd') diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 9f4819af0580ac61061e6e12dfa7306be9bf779e..1759672a94c96c77cbe7946d86431d63b4cd0e83 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -48,7 +48,7 @@ def test_bet(): bet = op.join(fsldir, 'bin', 'bet') result = fw.bet('input', 'output', mask=True, c=(10, 20, 30)) expected = (bet + ' input output', ('-m', '-c 10 20 30')) - assert checkResult(result.output[0], *expected, stripdir=[2]) + assert checkResult(result.stdout[0], *expected, stripdir=[2]) def test_robustfov(): @@ -56,7 +56,7 @@ def test_robustfov(): rfov = op.join(fsldir, 'bin', 'robustfov') result = fw.robustfov('input', 'output', b=180) expected = (rfov + ' -i input', ('-r output', '-b 180')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_eddy_cuda(): @@ -73,7 +73,7 @@ def test_eddy_cuda(): '--out=out', '--dont_mask_output')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_topup(): @@ -81,7 +81,7 @@ def test_topup(): topup = op.join(fsldir, 'bin', 'topup') result = fw.topup('imain', 'datain', minmet=1) expected = topup + ' --imain=imain --datain=datain --minmet=1' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_flirt(): @@ -90,7 +90,7 @@ def test_flirt(): result = fw.flirt('src', 'ref', usesqform=True, anglerep='euler') expected = (flirt + ' -in src -ref ref', ('-usesqform', '-anglerep euler')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_applyxfm(): @@ -102,7 +102,18 @@ def test_applyxfm(): '-out out', '-init mat', '-interp trilinear')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) + + +def test_applyxfm4D(): + with asrt.disabled(), run.dryrun(), mockFSLDIR(bin=('applyxfm4D',)) as fsldir: + applyxfm = op.join(fsldir, 'bin', 'applyxfm4D') + result = fw.applyxfm4D( + 'src', 'ref', 'out', 'mat', fourdigit=True, userprefix='boo') + expected = (applyxfm + ' src ref out mat', + ('-fourdigit', + '-userprefix boo')) + assert checkResult(result.stdout[0], *expected) def test_invxfm(): @@ -110,7 +121,7 @@ def test_invxfm(): cnvxfm = op.join(fsldir, 'bin', 'convert_xfm') result = fw.invxfm('mat', 'output') expected = cnvxfm + ' -omat output -inverse mat' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_concatxfm(): @@ -118,7 +129,7 @@ def test_concatxfm(): cnvxfm = op.join(fsldir, 'bin', 'convert_xfm') result = fw.concatxfm('mat1', 'mat2', 'output') expected = cnvxfm + ' -omat output -concat mat2 mat1' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_mcflirt(): @@ -129,7 +140,7 @@ def test_mcflirt(): ('-out output', '-cost normcorr', '-dof 12')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_fnirt(): @@ -142,7 +153,7 @@ def test_fnirt(): '--iout=iout', '--fout=fout', '--subsamp=8,6,4,2')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_applywarp(): @@ -151,7 +162,7 @@ def test_applywarp(): result = fw.applywarp('src', 'ref', 'out', warp='warp', abs=True, super=True) expected = (applywarp + ' --in=src --ref=ref --out=out', ('--warp=warp', '--abs', '--super')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_invwarp(): @@ -161,7 +172,7 @@ def test_invwarp(): rel=True, noconstraint=True) expected = (invwarp + ' --warp=warp --ref=ref --out=out', ('--rel', '--noconstraint')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_convertwarp(): @@ -170,7 +181,7 @@ def test_convertwarp(): result = fw.convertwarp('out', 'ref', absout=True, jacobian='jacobian') expected = (cnvwarp + ' --ref=ref --out=out', ('--absout', '--jacobian=jacobian')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_fugue(): @@ -182,7 +193,7 @@ def test_fugue(): '--warp=warp', '--median', '--dwell=10')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) @@ -192,7 +203,21 @@ def test_sigloss(): result = fw.sigloss('input', 'sigloss', mask='mask', te=0.5) expected = (sigloss + ' --in input --sigloss sigloss', ('--mask mask', '--te 0.5')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) + + +def test_prelude(): + with asrt.disabled(), run.dryrun(), mockFSLDIR(bin=('prelude',)) as fsldir: + prelude = op.join(fsldir, 'bin', 'prelude') + result = fw.prelude(complex='complex', + out='out', + labelslices=True, + start=5) + expected = (prelude, ('--complex=complex', + '--out=out', + '--labelslices', + '--start=5')) + assert checkResult(result.stdout[0], *expected) def test_melodic(): @@ -201,7 +226,7 @@ def test_melodic(): result = fw.melodic('input', dim=50, mask='mask', Oall=True) expected = (melodic + ' --in=input', ('--dim=50', '--mask=mask', '--Oall')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_fsl_regfilt(): @@ -211,7 +236,7 @@ def test_fsl_regfilt(): filter=(1, 2, 3, 4), vn=True) expected = (regfilt + ' --in=input --out=output --design=design', ('--filter=1,2,3,4', '--vn')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) @@ -220,7 +245,7 @@ def test_fslreorient2std(): r2std = op.join(fsldir, 'bin', 'fslreorient2std') result = fw.fslreorient2std('input', 'output') expected = r2std + ' input output' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_fslroi(): @@ -229,15 +254,15 @@ def test_fslroi(): result = fw.fslroi('input', 'output', 1, 10) expected = fslroi + ' input output 1 10' - assert result.output[0] == expected + assert result.stdout[0] == expected result = fw.fslroi('input', 'output', 1, 10, 2, 20, 3, 30) expected = fslroi + ' input output 1 10 2 20 3 30' - assert result.output[0] == expected + assert result.stdout[0] == expected result = fw.fslroi('input', 'output', 1, 10, 2, 20, 3, 30, 4, 40) expected = fslroi + ' input output 1 10 2 20 3 30 4 40' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_slicer(): @@ -245,7 +270,7 @@ def test_slicer(): slicer = op.join(fsldir, 'bin', 'slicer') result = fw.slicer('input1', 'input2', i=(20, 100), x=(20, 'x.png')) expected = slicer + ' input1 input2 -i 20 100 -x 20 x.png' - assert result.output[0] == expected + assert result.stdout[0] == expected def test_cluster(): @@ -255,7 +280,7 @@ def test_cluster(): fractional=True, osize='osize') expected = (cluster + ' --in=input --thresh=thresh', ('--fractional', '--osize=osize')) - assert checkResult(result.output[0], *expected) + assert checkResult(result.stdout[0], *expected) def test_fslmaths(): @@ -275,7 +300,7 @@ def test_fslmaths(): '-inm inmim', '-bptf 1 10', 'output'] expected = ' '.join(expected) - assert result.output[0] == expected + assert result.stdout[0] == expected # test LOAD output with tempdir() as td, mockFSLDIR(bin=('fslmaths',)) as fsldir: @@ -303,12 +328,12 @@ def test_fast(): result = fw.fast('input', 'myseg', n_classes=3) expected = [cmd, '-v', '--out=myseg', '--class=3', 'input'] - assert result.output[0] == ' '.join(expected) + assert result.stdout[0] == ' '.join(expected) result = fw.fast(('in1', 'in2', 'in3'), 'myseg', n_classes=3) expected = [cmd, '-v', '--out=myseg', '--class=3', 'in1', 'in2', 'in3'] - assert result.output[0] == ' '.join(expected) + assert result.stdout[0] == ' '.join(expected) @@ -323,4 +348,4 @@ def test_fsl_anat(): expected = [cmd, '-i', 't1', '-o', 'fsl_anat', '-t', 'T1', '-s', '25'] - assert result.output[0] == ' '.join(expected) + assert result.stdout[0] == ' '.join(expected) diff --git a/tests/test_wrapperutils.py b/tests/test_wrapperutils.py index 9e1d5d6a82c56801a60918801c28bfeb81f4e825..c943ae5cf4d02f174793d2e25d8ddce779569d0c 100644 --- a/tests/test_wrapperutils.py +++ b/tests/test_wrapperutils.py @@ -8,6 +8,7 @@ import os.path as op import os import shlex +import pathlib import textwrap try: from unittest import mock @@ -353,6 +354,30 @@ def test_fileOrThing_sequence(): assert np.all(func(infiles[0], wutils.LOAD)['out'] == inputs[0]) +def test_fileOrText(): + + @wutils.fileOrText('input', 'output') + def func(input, output): + data = open(input).read() + data = ''.join(['{}{}'.format(c, c) for c in data]) + open(output, 'wt').write(data) + + with tempdir.tempdir(): + + data = 'abcdefg' + exp = 'aabbccddeeffgg' + + open('input.txt', 'wt').write(data) + + func(pathlib.Path('input.txt'), pathlib.Path('output.txt')) + assert open('output.txt').read() == exp + + func('abcdefg', pathlib.Path('output.txt')) + assert open('output.txt').read() == exp + + assert func('12345', wutils.LOAD).output == '1122334455' + + def test_fileOrThing_outprefix(): @wutils.fileOrImage('img', outprefix='output_base') @@ -496,6 +521,111 @@ def test_fileOrThing_outprefix_directory(): assert np.all(np.asanyarray(res[op.join('foo', 'myout_imgs', 'img4')].dataobj) == exp4) + +def test_fileOrThing_results(): + @wutils.fileOrArray('input', 'regular_output', outprefix='outpref') + def func(input, regular_output, outpref): + + input = np.loadtxt(input) + + regout = input * 2 + prefouts = [] + for i in range(3, 6): + prefouts.append(input * i) + + np.savetxt(regular_output, regout) + for i, o in enumerate(prefouts): + np.savetxt('{}_{}.txt'.format(outpref, i), o) + + return ('return', 'value') + + input = np.random.randint(1, 10, (3, 3)) + infile = 'input.txt' + exp = [input * i for i in range(2, 6)] + + with tempdir.tempdir(): + + np.savetxt(infile, input) + + result = func('input.txt', 'regout.txt', 'outpref') + assert len(result) == 0 + assert result.stdout == ('return', 'value') + assert (np.loadtxt('regout.txt') == exp[0]).all() + for i in range(3): + assert (np.loadtxt('outpref_{}.txt'.format(i)) == exp[i+1]).all() + + result = func(input, 'regout.txt', 'outpref') + assert len(result) == 0 + assert result.stdout == ('return', 'value') + assert (np.loadtxt('regout.txt') == exp[0]).all() + for i in range(3): + assert (np.loadtxt('outpref_{}.txt'.format(i)) == exp[i+1]).all() + + result = func(input, wutils.LOAD, 'outpref') + assert len(result) == 1 + assert result.stdout == ('return', 'value') + assert (result .regular_output == exp[0]).all() + assert (result['regular_output'] == exp[0]).all() + for i in range(3): + assert (np.loadtxt('outpref_{}.txt'.format(i)) == exp[i+1]).all() + + # todo outpref + result = func(input, wutils.LOAD, wutils.LOAD) + assert len(result) == 4 + assert result.stdout == ('return', 'value') + assert (result .regular_output == exp[0]).all() + assert (result['regular_output'] == exp[0]).all() + + assert (result .outpref_0 == exp[1]).all() + assert (result['outpref_0'] == exp[1]).all() + assert (result .outpref_1 == exp[2]).all() + assert (result['outpref_1'] == exp[2]).all() + assert (result .outpref_2 == exp[3]).all() + assert (result['outpref_2'] == exp[3]).all() + + for i in range(3): + assert (np.loadtxt('outpref_{}.txt'.format(i)) == exp[i+1]).all() + + result = func(input, wutils.LOAD, wutils.LOAD) + assert len(result) == 4 + + +def test_FileOrThing_invalid_identifiers(): + # unlikely to ever happen, but let's test arguments with + # names that are not valid python identifiers + @wutils.fileOrArray('in val', '2out') + def func(**kwargs): + + infile = kwargs['in val'] + outfile = kwargs['2out'] + + input = np.loadtxt(infile) + np.savetxt(outfile, input * 2) + + return ('return', 'value') + + input = np.random.randint(1, 10, (3, 3)) + infile = 'input.txt' + exp = input * 2 + + with tempdir.tempdir(): + + np.savetxt(infile, input) + + res = func(**{'in val' : infile, '2out' : 'output.txt'}) + assert res.stdout == ('return', 'value') + assert (np.loadtxt('output.txt') == exp).all() + + res = func(**{'in val' : input, '2out' : 'output.txt'}) + assert res.stdout == ('return', 'value') + assert (np.loadtxt('output.txt') == exp).all() + + res = func(**{'in val' : input, '2out' : wutils.LOAD}) + assert res.stdout == ('return', 'value') + assert (res['2out'] == exp).all() + + + def test_chained_fileOrImageAndArray(): @wutils.fileOrImage('image', 'outimage') @wutils.fileOrArray('array', 'outarray') @@ -584,6 +714,37 @@ def test_fileOrThing_chained_outprefix(): assert np.all(res['out_array'] == exparr) +def test_fileOrThing_submit(): + + @wutils.fileOrImage('input', 'output') + def func(input, output, submit=False): + + if submit: + return 'submitted!' + + img = nib.load(input) + img = nib.nifti1.Nifti1Image(np.asanyarray(img.dataobj) * 2, np.eye(4)) + + nib.save(img, output) + + with tempdir.tempdir() as td: + img = nib.nifti1.Nifti1Image(np.array([[1, 2], [3, 4]]), np.eye(4)) + exp = np.asanyarray(img.dataobj) * 2 + nib.save(img, 'input.nii.gz') + + result = func(img, wutils.LOAD) + assert np.all(np.asanyarray(result['output'].dataobj) == exp) + + assert func('input.nii.gz', 'output.nii.gz', submit=True) == 'submitted!' + + with pytest.raises(ValueError): + func(img, wutils.LOAD, submit=True) + with pytest.raises(ValueError): + func(img, 'output.nii.gz', submit=True) + with pytest.raises(ValueError): + func('input.nii.gz', wutils.LOAD, submit=True) + + def test_cmdwrapper(): @wutils.cmdwrapper def func(a, b):