diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 47984d1..cd21abe 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -31,10 +31,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.12"] # Check oldest and newest versions + python-version: ["3.11", "3.12"] # Check oldest and newest versions pip-flags: ["", "--editable"] pydra: - - "pydra" + - "'pydra>=1.0a0'" - "--editable git+https://github.com/nipype/pydra.git#egg=pydra" steps: @@ -49,18 +49,18 @@ jobs: - name: Install Pydra run: | pip install ${{ matrix.pydra }} - python -c "import pydra as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" + python -c "import pydra.utils as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" - name: Install task package run: | pip install ${{ matrix.pip-flags }} ".[dev]" - python -c "import pydra.tasks.$SUBPACKAGE as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" - python -c "import pydra as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" + python -c "import pydra.compose.$SUBPACKAGE as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" + python -c "import pydra.utils as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" test: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -69,26 +69,27 @@ jobs: with: python-version: ${{ matrix.python-version }} mamba-version: "*" - channels: ${{ env.FSLCONDA }},conda-forge,defaults + channels: ${{ env.FSLCONDA }},conda-forge channel-priority: true - name: Install FSL run: | mamba install fsl-avwutils - mamba env config vars set FSLDIR="$CONDA_PREFIX" FSLOUTPUTTYPE="NIFTI_GZ" # Hack because we're not doing a full FSL install echo "6.0.7.9" > $CONDA_PREFIX/etc/fslversion + - name: Set FSLDIR + run: echo FSLDIR=$CONDA_PREFIX >> $GITHUB_ENV - name: Upgrade pip run: | python -m pip install --upgrade pip - name: Install task package run: | pip install ".[test]" - python -c "import pydra.tasks.$SUBPACKAGE as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" - python -c "import pydra as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" + python -c "import pydra.compose.$SUBPACKAGE as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" + python -c "import pydra.utils as m; print(f'{m.__name__} {m.__version__} @ {m.__file__}')" - name: Test with pytest run: | - pytest -sv --doctest-modules --pyargs pydra.tasks.$SUBPACKAGE \ - --cov pydra.tasks.$SUBPACKAGE --cov-report xml --cov-report term-missing + pytest -sv --doctest-modules --pyargs pydra.compose.$SUBPACKAGE \ + --cov pydra.compose.$SUBPACKAGE --cov-report xml --cov-report term-missing - uses: codecov/codecov-action@v4 if: ${{ always() }} with: diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..48bc99c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Test Config", + "type": "python", + "request": "launch", + "purpose": [ + "debug-test" + ], + "justMyCode": false, + "console": "internalConsole", + "env": { + "_PYTEST_RAISE": "1" + }, + "args": [ + "--capture=no", + ] + }, + ] +} diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..391704e --- /dev/null +++ b/conftest.py @@ -0,0 +1,16 @@ +import os +import typing as ty +import pytest + +# For debugging in IDE's don't catch raised exceptions and let the IDE +# break at it +if os.getenv("_PYTEST_RAISE", "0") != "0": + + @pytest.hookimpl(tryfirst=True) + def pytest_exception_interact(call: pytest.CallInfo[ty.Any]) -> None: + if call.excinfo is not None: + raise call.excinfo.value + + @pytest.hookimpl(tryfirst=True) + def pytest_internalerror(excinfo: pytest.ExceptionInfo[BaseException]) -> None: + raise excinfo.value diff --git a/pydra/compose/nipype1/__init__.py b/pydra/compose/nipype1/__init__.py new file mode 100644 index 0000000..26d0f33 --- /dev/null +++ b/pydra/compose/nipype1/__init__.py @@ -0,0 +1,11 @@ +from .builder import ( + Task, + Outputs, + define, + arg, + out, +) +from ._version import __version__ + + +__all__ = ["Task", "Outputs", "define", "arg", "out", "__version__"] diff --git a/pydra/compose/nipype1/builder.py b/pydra/compose/nipype1/builder.py new file mode 100644 index 0000000..dbf992e --- /dev/null +++ b/pydra/compose/nipype1/builder.py @@ -0,0 +1,213 @@ +import nipype +import attrs +import typing as ty +from pydra.compose import base +from pydra.compose.base.builder import build_task_class +from pydra.utils.general import task_fields, task_dict +from fileformats.generic import File, Directory, FileSet +import nipype.interfaces.base.traits_extension +from pydra.engine.job import Job +from pydra.utils.typing import is_fileset_or_union + + +__all__ = ["define", "arg", "out", "Task", "Outputs"] + + +class arg(base.Arg): + """Argument of a Python task + + Parameters + ---------- + help: str + A short description of the input field. + default : Any, optional + the default value for the argument + allowed_values: list, optional + List of allowed values for the field. + requires: list, optional + Names of the inputs that are required together with the field. + copy_mode: File.CopyMode, optional + The mode of copying the file, by default it is File.CopyMode.any + copy_collation: File.CopyCollation, optional + The collation of the file, by default it is File.CopyCollation.any + copy_ext_decomp: File.ExtensionDecomposition, optional + The extension decomposition of the file, by default it is + File.ExtensionDecomposition.single + readonly: bool, optional + If True the input field can’t be provided by the user but it aggregates other + input fields (for example the fields with argstr: -o {fldA} {fldB}), by default + it is False + type: type, optional + The type of the field, by default it is Any + name: str, optional + The name of the field, used when specifying a list of fields instead of a mapping + from name to field, by default it is None + """ + + +class out(base.Out): + """Output of a Python task + + Parameters + ---------- + name: str, optional + The name of the field, used when specifying a list of fields instead of a mapping + from name to field, by default it is None + type: type, optional + The type of the field, by default it is Any + help: str, optional + A short description of the input field. + requires: list, optional + Names of the inputs that are required together with the field. + converter: callable, optional + The converter for the field passed through to the attrs.field, by default it is None + validator: callable | iterable[callable], optional + The validator(s) for the field passed through to the attrs.field, by default it is None + position : int + The position of the output in the output list, allows for tuple unpacking of + outputs + """ + + +def define(interface: nipype.interfaces.base.BaseInterface) -> "Task": + """ + Create an interface for a function or a class. + + Parameters + ---------- + wrapped : type | callable | None + The function or class to create an interface for. + inputs : list[str | Arg] | dict[str, Arg | type] | None + The inputs to the function or class. + outputs : list[str | base.Out] | dict[str, base.Out | type] | type | None + The outputs of the function or class. + auto_attribs : bool + Whether to use auto_attribs mode when creating the class. + xor: Sequence[str | None] | Sequence[Sequence[str | None]], optional + Names of args that are exclusive mutually exclusive, which must include + the name of the current field. If this list includes None, then none of the + fields need to be set. + + Returns + ------- + Task + The task class for the Python function + """ + inputs = traitedspec_to_fields( + interface.inputs, arg, skip_fields={"interface", "function_str"} + ) + outputs = traitedspec_to_fields(interface._outputs(), out) + + task_class = build_task_class( + Nipype1Task, + Nipype1Outputs, + inputs, + outputs, + name=type(interface).__name__, + klass=None, + bases=(), + outputs_bases=(), + ) + + task_class._interface = interface + + return task_class + + +class Nipype1Outputs(base.Outputs): + + @classmethod + def _from_job(cls, job: "Job[Nipype1Outputs]") -> ty.Self: + """Collect the outputs of a job from a combination of the provided inputs, + the objects in the output directory, and the stdout and stderr of the process. + + Parameters + ---------- + job : Job[Task] + The job whose outputs are being collected. + outputs_dict : dict[str, ty.Any] + The outputs of the job, as a dictionary + + Returns + ------- + outputs : Outputs + The outputs of the job in dataclass + """ + outputs = super()._from_task(job) + for name, val in job.return_values.items(): + setattr(outputs, name, val) + return outputs + + @classmethod + def _from_task(cls, job: "Job[Nipype1Outputs]") -> ty.Self: + # Added for backwards compatibility + return cls._from_job(job) + + +class Nipype1Task(base.Task): + """Wrap a Nipype 1.x Interface as a Pydra Task + + This utility translates the Nipype 1 input and output specs to + Pydra-style specs, wraps the run command, and exposes the output + in Pydra Task outputs. + + >>> import pytest + >>> from pydra.compose.nipype1.tests import load_resource + >>> from nipype.interfaces import fsl + >>> if fsl.Info.version() is None: + ... pytest.skip() + >>> img = load_resource('nipype', 'testing/data/tpms_msk.nii.gz') + + >>> from pydra.compose.nipype1.builder import define + >>> Threshold = define(fsl.Threshold()) + >>> thresh = Threshold(in_file=img, thresh=0.5) + >>> res = thresh() + >>> res.out_file # DOCTEST: +ELLIPSIS + File('.../tpms_msk_thresh.nii.gz') + """ + + _task_type = "nipype1" + + def _run(self, job: "Job[Nipype1Task]", rerun: bool = False) -> None: + fields = task_fields(self) + inputs = { + n: v if not isinstance(v, FileSet) else str(v) + for n, v in task_dict(self).items() + if v is not None or fields[n].mandatory + } + node = nipype.Node(self._interface, base_dir=job.cache_dir, name=type(self).__name__) + node.inputs.trait_set(**inputs) + res = node.run() + job.return_values = res.outputs.get() + + +FieldType = ty.TypeVar("FieldType", bound=arg | out) + + +def traitedspec_to_fields( + traitedspec, field_type: type[FieldType], skip_fields: set[str] = set() +) -> dict[str, FieldType]: + trait_names = set(traitedspec.copyable_trait_names()) + fields = {} + for name, trait in traitedspec.traits().items(): + if name in skip_fields: + continue + type_ = TYPE_CONVERSIONS.get(type(trait.trait_type), ty.Any) + if not trait.mandatory: + type_ = type_ | None + default = None + else: + default = base.NO_DEFAULT + if name in trait_names: + fields[name] = field_type(name=name, help=trait.desc, type=type_, default=default) + return fields + + +Task = Nipype1Task +Outputs = Nipype1Outputs + + +TYPE_CONVERSIONS = { + nipype.interfaces.base.traits_extension.File: File, + nipype.interfaces.base.traits_extension.Directory: Directory, +} diff --git a/pydra/tasks/nipype1/tests/__init__.py b/pydra/compose/nipype1/tests/__init__.py similarity index 100% rename from pydra/tasks/nipype1/tests/__init__.py rename to pydra/compose/nipype1/tests/__init__.py diff --git a/pydra/tasks/nipype1/tests/test_nipype1task.py b/pydra/compose/nipype1/tests/test_nipype1task.py similarity index 59% rename from pydra/tasks/nipype1/tests/test_nipype1task.py rename to pydra/compose/nipype1/tests/test_nipype1task.py index 5ceeb16..f54ffde 100644 --- a/pydra/tasks/nipype1/tests/test_nipype1task.py +++ b/pydra/compose/nipype1/tests/test_nipype1task.py @@ -1,11 +1,10 @@ import pytest import shutil -from . import load_resource - +from fileformats.generic import File +from pydra.compose import nipype1 from nipype.interfaces import fsl import nipype.interfaces.utility as nutil - -from pydra.tasks.nipype1 import Nipype1Task +from . import load_resource @pytest.mark.skipif(fsl.Info.version() is None, reason="Test requires FSL") @@ -17,12 +16,12 @@ def test_isolation(tmp_path): out_dir = tmp_path / "output" out_dir.mkdir() - slicer = Nipype1Task(fsl.Slice(), cache_dir=str(out_dir)) - slicer.inputs.in_file = in_file + Slicer = nipype1.define(fsl.Slice()) + slicer = Slicer(in_file=File(in_file)) - res = slicer() - assert res.output.out_files - assert all(fname.startswith(str(out_dir)) for fname in res.output.out_files) + outputs = slicer(cache_root=out_dir) + assert outputs.out_files + assert all(fname.startswith(str(out_dir)) for fname in outputs.out_files) def test_preserve_input_types(): @@ -34,8 +33,9 @@ def with_tuple(in_param: tuple): input_names=["in_param"], output_names=["out_param"], function=with_tuple ) - nipype1_task_tuple = Nipype1Task(interface=tuple_interface, in_param=tuple(["test"])) + TaskTuple = nipype1.define(tuple_interface) + nipype1_task_tuple = TaskTuple(in_param=tuple(["test"])) - nipype1_task_tuple() + outputs = nipype1_task_tuple() - assert isinstance(nipype1_task_tuple._interface._list_outputs()["out_param"], tuple) + assert isinstance(outputs.out_param, tuple) diff --git a/pydra/tasks/nipype1/__init__.py b/pydra/tasks/nipype1/__init__.py deleted file mode 100644 index 94ee228..0000000 --- a/pydra/tasks/nipype1/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" ->>> from pydra import ShellCommandTask ->>> import pydra.tasks.nipype1 -""" - -try: - from ._version import __version__ -except ImportError: # pragma: no cover - __version__ = "0+unknown" - -from .utils import Nipype1Task - -__all__ = ["Nipype1Task"] diff --git a/pydra/tasks/nipype1/utils.py b/pydra/tasks/nipype1/utils.py deleted file mode 100644 index 913403b..0000000 --- a/pydra/tasks/nipype1/utils.py +++ /dev/null @@ -1,76 +0,0 @@ -import pydra -import nipype -import attrs -import typing as ty - -__all__ = ["Nipype1Task"] - - -def traitedspec_to_specinfo(traitedspec): - trait_names = set(traitedspec.copyable_trait_names()) - return pydra.specs.SpecInfo( - name="Inputs", - fields=[ - (name, attrs.field(metadata={"help_string": trait.desc}, type=ty.Any)) - for name, trait in traitedspec.traits().items() - if name in trait_names - ], - bases=(pydra.engine.specs.BaseSpec,), - ) - - -class Nipype1Task(pydra.engine.task.TaskBase): - """Wrap a Nipype 1.x Interface as a Pydra Task - - This utility translates the Nipype 1 input and output specs to - Pydra-style specs, wraps the run command, and exposes the output - in Pydra Task outputs. - - >>> import pytest - >>> from pydra.tasks.nipype1.tests import load_resource - >>> from nipype.interfaces import fsl - >>> if fsl.Info.version() is None: - ... pytest.skip() - >>> img = load_resource('nipype', 'testing/data/tpms_msk.nii.gz') - - >>> from pydra.tasks.nipype1.utils import Nipype1Task - >>> thresh = Nipype1Task(fsl.Threshold()) - >>> thresh.inputs.in_file = img - >>> thresh.inputs.thresh = 0.5 - >>> res = thresh() - >>> res.output.out_file # DOCTEST: +ELLIPSIS - '.../tpms_msk_thresh.nii.gz' - """ - - def __init__( - self, - interface: nipype.interfaces.base.BaseInterface, - audit_flags: pydra.AuditFlag = pydra.AuditFlag.NONE, - cache_dir=None, - cache_locations=None, - messenger_args=None, - messengers=None, - name=None, - **kwargs, - ): - self.input_spec = traitedspec_to_specinfo(interface.inputs) - self._interface = interface - if name is None: - name = interface.__class__.__name__ - super(Nipype1Task, self).__init__( - name, - inputs=kwargs, - audit_flags=audit_flags, - messengers=messengers, - messenger_args=messenger_args, - cache_dir=cache_dir, - cache_locations=cache_locations, - ) - self.output_spec = traitedspec_to_specinfo(interface._outputs()) - - def _run_task(self, environment=None): - inputs = attrs.asdict(self.inputs, filter=lambda a, v: v is not attrs.NOTHING) - node = nipype.Node(self._interface, base_dir=self.output_dir, name=self.name) - node.inputs.trait_set(**inputs) - res = node.run() - self.output_ = res.outputs.get() diff --git a/pyproject.toml b/pyproject.toml index 7fe2461..feed842 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [build-system] -requires = ["flit_scm"] -build-backend = "flit_scm:buildapi" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" [project] name = "pydra-nipype1" description = "Tools for importing nipype 1.x interfaces into Pydra" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.11" dependencies = [ - "pydra >=0.6.2", + "pydra >=1.0a0", "nipype", "attrs >=21.3.0", ] @@ -49,16 +49,20 @@ test = [ homepage = "https://github.com/nipype/pydra-nipype1" repository = "https://github.com/nipype/pydra-nipype1" -[tool.flit.module] -name = "pydra.tasks.nipype1" +[tool.hatch.version] +source = "vcs" -[tool.flit.sdist] -exclude = [".gitignore"] +[tool.hatch.build.hooks.vcs] +version-file = "pydra/compose/nipype1/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["pydra"] +include-only = ["pydra/compose/nipype1"] [tool.setuptools_scm] -write_to = "pydra/tasks/nipype1/_version.py" +write_to = "pydra/compose/nipype1/_version.py" [tool.black] line-length = 99 -target-version = ["py37"] +target-version = ["py311"] exclude = "_version.py"