diff --git a/nipype/conftest.py b/nipype/conftest.py index 151906678f..e87a9affb8 100644 --- a/nipype/conftest.py +++ b/nipype/conftest.py @@ -1,6 +1,7 @@ +import importlib import os import shutil -from tempfile import mkdtemp +import tempfile import pytest import numpy as np import py.path as pp @@ -8,9 +9,29 @@ NIPYPE_DATADIR = os.path.realpath( os.path.join(os.path.dirname(__file__), "testing/data") ) -temp_folder = mkdtemp() -data_dir = os.path.join(temp_folder, "data") -shutil.copytree(NIPYPE_DATADIR, data_dir) +NIPYPE_TMPDIR = tempfile.mkdtemp() +TMP_DATADIR = os.path.join(NIPYPE_TMPDIR, "data") + + +def pytest_configure(config): + shutil.copytree(NIPYPE_DATADIR, TMP_DATADIR) + + # Pytest uses gettempdir() to construct its tmp_paths, but the logic to get + # `pytest-of-/pytest-` directories is contingent on not directly + # configuring the `config.option.base_temp` value. + # Instead of replicating that logic, inject a new directory into gettempdir() + # + # Use the discovered temp dir as a base, to respect user/system settings. + if ' ' not in (base_temp := tempfile.gettempdir()): + new_base = os.path.join(base_temp, "nipype tmp") + os.makedirs(new_base, exist_ok=True) + os.environ['TMPDIR'] = os.path.join(base_temp, "nipype tmp") + importlib.reload(tempfile) + assert tempfile.gettempdir() == os.path.join(base_temp, "nipype tmp") + + +def pytest_unconfigure(config): + shutil.rmtree(NIPYPE_TMPDIR) @pytest.fixture(autouse=True) @@ -18,7 +39,7 @@ def add_np(doctest_namespace): doctest_namespace["np"] = np doctest_namespace["os"] = os doctest_namespace["pytest"] = pytest - doctest_namespace["datadir"] = data_dir + doctest_namespace["datadir"] = TMP_DATADIR @pytest.fixture(scope='session', autouse=True) @@ -33,7 +54,7 @@ def _docdir(request): doctest_plugin = request.config.pluginmanager.getplugin("doctest") if isinstance(request.node, doctest_plugin.DoctestItem): # Get the fixture dynamically by its name. - tmpdir = pp.local(data_dir) + tmpdir = pp.local(TMP_DATADIR) # Chdir only for the duration of the test. with tmpdir.as_cwd(): @@ -42,8 +63,3 @@ def _docdir(request): else: # For normal tests, we have to yield, since this is a yield-fixture. yield - - -def pytest_unconfigure(config): - # Delete temp folder after session is finished - shutil.rmtree(temp_folder) diff --git a/nipype/info.py b/nipype/info.py index 7ad5aba5bb..5b1abaeb21 100644 --- a/nipype/info.py +++ b/nipype/info.py @@ -108,6 +108,7 @@ def get_nipype_gitversion(): DATEUTIL_MIN_VERSION = "2.2" SIMPLEJSON_MIN_VERSION = "3.8.0" PROV_MIN_VERSION = "1.5.2" +LXML_MIN_VERSION = "5.0" RDFLIB_MIN_VERSION = "5.0.0" CLICK_MIN_VERSION = "6.6.0" PYDOT_MIN_VERSION = "1.2.3" @@ -139,6 +140,7 @@ def get_nipype_gitversion(): "numpy>=%s" % NUMPY_MIN_VERSION, "packaging", "prov>=%s" % PROV_MIN_VERSION, + f"lxml>={LXML_MIN_VERSION}", # prov < 2.0.2 depended on lxml, now it's an [xml] extra "pydot>=%s" % PYDOT_MIN_VERSION, "python-dateutil>=%s" % DATEUTIL_MIN_VERSION, "rdflib>=%s" % RDFLIB_MIN_VERSION, diff --git a/nipype/interfaces/ants/utils.py b/nipype/interfaces/ants/utils.py index 57202f5a34..ef6d2b61c8 100644 --- a/nipype/interfaces/ants/utils.py +++ b/nipype/interfaces/ants/utils.py @@ -928,7 +928,7 @@ class LabelGeometry(ANTSCommand): >>> label_extract.inputs.dimension = 3 >>> label_extract.inputs.label_image = 'atlas.nii.gz' >>> label_extract.cmdline - 'LabelGeometryMeasures 3 atlas.nii.gz [] atlas.csv' + "LabelGeometryMeasures 3 atlas.nii.gz '[]' atlas.csv" >>> label_extract.inputs.intensity_image = 'ants_Warp.nii.gz' >>> label_extract.cmdline diff --git a/nipype/interfaces/base/core.py b/nipype/interfaces/base/core.py index 8fadd9cc2d..7f196622e2 100644 --- a/nipype/interfaces/base/core.py +++ b/nipype/interfaces/base/core.py @@ -32,7 +32,7 @@ from ...external.due import due -from .traits_extension import traits, isdefined, Undefined +from .traits_extension import traits, isdefined, BasePath, Undefined from .specs import ( BaseInterfaceInputSpec, CommandLineInputSpec, @@ -798,6 +798,13 @@ def _format_arg(self, name, trait_spec, value): # type-checking code here as well sep = trait_spec.sep if trait_spec.sep is not None else " " + if argstr == '%s': + inner_traits = getattr(trait_spec, "inner_traits", None) + if inner_traits and any( + t.is_trait_type(BasePath) for t in inner_traits + ): + values = [shlex.quote(elt) for elt in value] + if argstr.endswith("..."): # repeatable option # --id %d... will expand to @@ -807,6 +814,8 @@ def _format_arg(self, name, trait_spec, value): else: return argstr % sep.join(str(elt) for elt in value) else: + if argstr == '%s' and trait_spec.is_trait_type(BasePath): + return shlex.quote(value) # Append options using format string. return argstr % value diff --git a/nipype/interfaces/base/tests/test_core.py b/nipype/interfaces/base/tests/test_core.py index d86142ff3b..400bafea25 100644 --- a/nipype/interfaces/base/tests/test_core.py +++ b/nipype/interfaces/base/tests/test_core.py @@ -608,3 +608,19 @@ def _run_interface(self, runtime): with pytest.raises(RuntimeError): BrokenRuntime().run() + + +def test_CommandLine_escape(tmp_path): + test_file = tmp_path / "test file.txt" + test_file.write_text("content") + + class InputSpec(nib.TraitedSpec): + in_file = nib.File(desc="a file", exists=True, argstr="%s") + + class CatCommand(nib.CommandLine): + input_spec = InputSpec + _cmd = "cat" + + command = CatCommand(in_file=str(test_file)) + result = command.run() + assert result.runtime.stdout == "content" diff --git a/nipype/interfaces/freesurfer/preprocess.py b/nipype/interfaces/freesurfer/preprocess.py index 89c218f969..8ddd35fa91 100644 --- a/nipype/interfaces/freesurfer/preprocess.py +++ b/nipype/interfaces/freesurfer/preprocess.py @@ -1975,7 +1975,7 @@ def _list_outputs(self): outputs["out_fsl_file"] = op.abspath(_in.out_fsl_file) if isdefined(_in.init_cost_file): - if isinstance(_in.out_fsl_file, bool): + if isinstance(_in.init_cost_file, bool): outputs["init_cost_file"] = outputs["out_reg_file"] + ".initcost" else: outputs["init_cost_file"] = op.abspath(_in.init_cost_file) diff --git a/nipype/pipeline/plugins/base.py b/nipype/pipeline/plugins/base.py index 1571ab71a9..300ae2c55e 100644 --- a/nipype/pipeline/plugins/base.py +++ b/nipype/pipeline/plugins/base.py @@ -5,6 +5,7 @@ from copy import deepcopy from glob import glob import os +import shlex import shutil from time import sleep, time from traceback import format_exception @@ -564,7 +565,7 @@ def _submit_job(self, node, updatehash=False): batch_dir, name = os.path.split(pyscript) name = ".".join(name.split(".")[:-1]) batchscript = "\n".join( - (self._template.rstrip("\n"), f"{sys.executable} {pyscript}") + (self._template.rstrip("\n"), shlex.join([sys.executable, pyscript])) ) batchscriptfile = os.path.join(batch_dir, "batchscript_%s.sh" % name) with open(batchscriptfile, "w") as fp: diff --git a/tox.ini b/tox.ini index 571b93628b..02a4bbc3bb 100644 --- a/tox.ini +++ b/tox.ini @@ -76,6 +76,7 @@ extras = full: ssh full: nipy setenv = + NO_ET=1 FSLOUTPUTTYPE=NIFTI_GZ pre: PIP_EXTRA_INDEX_URL=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple pre: UV_EXTRA_INDEX_URL=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple