Skip to content

Commit 6e1a332

Browse files
authored
Merge pull request #790 from nipype/handle-none-outputs
Handle-none-outputs
2 parents 3aad031 + ca82a5c commit 6e1a332

File tree

7 files changed

+129
-47
lines changed

7 files changed

+129
-47
lines changed

docs/source/tutorial/2-advanced-execution.ipynb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,8 @@
351351
"how to utilise containers and add support for other software environments.\n",
352352
"\n",
353353
"It is also possible to specify functions to run at hooks that are immediately before and after\n",
354-
"the task is executed by passing a `pydra.engine.spec.TaskHooks` object to the `hooks`\n",
355-
"keyword arg. The callable should take the `pydra.engine.core.Job` object as its only\n",
354+
"the task is executed by passing a `pydra.engine.hooks.TaskHooks` object to the `hooks`\n",
355+
"keyword arg. The callable should take the `pydra.engine.job.Job` object as its only\n",
356356
"argument and return None. The available hooks to attach functions are:\n",
357357
"\n",
358358
"* pre_run: before the task cache directory is created\n",
@@ -415,7 +415,7 @@
415415
],
416416
"metadata": {
417417
"kernelspec": {
418-
"display_name": "wf12",
418+
"display_name": "wf13",
419419
"language": "python",
420420
"name": "python3"
421421
},
@@ -429,7 +429,7 @@
429429
"name": "python",
430430
"nbconvert_exporter": "python",
431431
"pygments_lexer": "ipython3",
432-
"version": "3.12.5"
432+
"version": "3.13.1"
433433
}
434434
},
435435
"nbformat": 4,

docs/source/tutorial/7-canonical-form.ipynb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
"Default values can also be set directly, as with Attrs classes.\n",
3535
"\n",
3636
"In order to allow static type-checkers to check the type of outputs of tasks added\n",
37-
"to workflows, it is also necessary to explicitly extend from the `pydra.engine.python.Task`\n",
38-
"and `pydra.engine.python.Outputs` classes (they are otherwise set as bases by the\n",
37+
"to workflows, it is also necessary to explicitly extend from the `pydra.compose.python.Task`\n",
38+
"and `pydra.compose.python.Outputs` classes (they are otherwise set as bases by the\n",
3939
"`define` method implicitly). Thus the \"canonical form\" of Python task is as\n",
4040
"follows"
4141
]

pydra/compose/base/helpers.py

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,17 @@ def extract_function_inputs_and_outputs(
183183
input_types[p.name] = type_hints.get(p.name, ty.Any)
184184
if p.default is not inspect.Parameter.empty:
185185
input_defaults[p.name] = p.default
186-
if inputs:
186+
if inputs is not None:
187187
if not isinstance(inputs, dict):
188-
raise ValueError(
189-
f"Input names ({inputs}) should not be provided when "
190-
"wrapping/decorating a function as "
191-
)
188+
if non_named_args := [
189+
i for i in inputs if not isinstance(i, Arg) or i.name is None
190+
]:
191+
raise ValueError(
192+
"Only named Arg objects should be provided as inputs (i.e. not names or "
193+
"other objects should not be provided when wrapping/decorating a "
194+
f"function: found {non_named_args} when wrapping/decorating {function!r}"
195+
)
196+
inputs = {i.name: i for i in inputs}
192197
if not has_varargs:
193198
if unrecognised := set(inputs) - set(input_types):
194199
raise ValueError(
@@ -218,41 +223,43 @@ def extract_function_inputs_and_outputs(
218223
f"value {default}"
219224
)
220225
return_type = type_hints.get("return", ty.Any)
221-
if outputs and len(outputs) > 1:
222-
if return_type is not ty.Any:
223-
if ty.get_origin(return_type) is not tuple:
224-
raise ValueError(
225-
f"Multiple outputs specified ({outputs}) but non-tuple "
226-
f"return value {return_type}"
227-
)
228-
return_types = ty.get_args(return_type)
229-
if len(return_types) != len(outputs):
230-
raise ValueError(
231-
f"Length of the outputs ({outputs}) does not match that "
232-
f"of the return types ({return_types})"
233-
)
234-
output_types = dict(zip(outputs, return_types))
235-
else:
236-
output_types = {o: ty.Any for o in outputs}
237-
if isinstance(outputs, dict):
238-
for output_name, output in outputs.items():
239-
if isinstance(output, Out) and output.type is ty.Any:
240-
output.type = output_types[output_name]
226+
if outputs:
227+
if len(outputs) > 1:
228+
if return_type is not ty.Any:
229+
if ty.get_origin(return_type) is not tuple:
230+
raise ValueError(
231+
f"Multiple outputs specified ({outputs}) but non-tuple "
232+
f"return value {return_type}"
233+
)
234+
return_types = ty.get_args(return_type)
235+
if len(return_types) != len(outputs):
236+
raise ValueError(
237+
f"Length of the outputs ({outputs}) does not match that "
238+
f"of the return types ({return_types})"
239+
)
240+
output_types = dict(zip(outputs, return_types))
241+
else:
242+
output_types = {o: ty.Any for o in outputs}
243+
if isinstance(outputs, dict):
244+
for output_name, output in outputs.items():
245+
if isinstance(output, Out) and output.type is ty.Any:
246+
output.type = output_types[output_name]
247+
else:
248+
outputs = output_types
241249
else:
242-
outputs = output_types
243-
244-
elif outputs:
245-
if isinstance(outputs, dict):
246-
output_name, output = next(iter(outputs.items()))
247-
elif isinstance(outputs, list):
248-
output_name = outputs[0]
249-
output = ty.Any
250-
if isinstance(output, Out):
251-
if output.type is ty.Any:
252-
output.type = return_type
253-
elif output is ty.Any:
254-
output = return_type
255-
outputs = {output_name: output}
250+
if isinstance(outputs, dict):
251+
output_name, output = next(iter(outputs.items()))
252+
elif isinstance(outputs, list):
253+
output_name = outputs[0]
254+
output = ty.Any
255+
if isinstance(output, Out):
256+
if output.type is ty.Any:
257+
output.type = return_type
258+
elif output is ty.Any:
259+
output = return_type
260+
outputs = {output_name: output}
261+
elif outputs == [] or return_type in (None, type(None)):
262+
outputs = {}
256263
else:
257264
outputs = {"out": return_type}
258265
return inputs, outputs

pydra/compose/python.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ def _run(self, job: "Job[PythonTask]", rerun: bool = True) -> None:
239239
return_names = [f.name for f in task_fields(self.Outputs)]
240240
if returned is None:
241241
job.return_values = {nm: None for nm in return_names}
242+
elif not return_names:
243+
raise ValueError(
244+
f"No output fields were specified, but the function returned {returned}"
245+
)
242246
elif len(return_names) == 1:
243247
# if only one element in the fields, everything should be returned together
244248
job.return_values[return_names[0]] = returned

pydra/compose/tests/test_python_fields.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ def func(function: ty.Callable) -> ty.Callable:
4141
return function
4242

4343

44+
def test_function_arg_fail2():
45+
46+
with pytest.raises(
47+
ValueError, match="Only named Arg objects should be provided as inputs"
48+
):
49+
50+
@python.define(inputs=[python.arg(help="an int")])
51+
def func(a: int) -> int:
52+
return a * 2
53+
54+
55+
def test_function_arg_add_help():
56+
57+
@python.define(inputs=[python.arg(name="a", help="an int")])
58+
def func(a: int) -> int:
59+
return a * 2
60+
61+
assert task_fields(func).a.help == "an int"
62+
63+
4464
def test_interface_wrap_function_with_default():
4565
def func(a: int, k: float = 2.0) -> float:
4666
"""Sample function with inputs and outputs"""
@@ -424,3 +444,54 @@ def TestFunc(a: A):
424444

425445
outputs = TestFunc(a=A(x=7))()
426446
assert outputs.out == 7
447+
448+
449+
def test_no_outputs1():
450+
"""Test function tasks with no outputs specified by None return type"""
451+
452+
@python.define
453+
def TestFunc1(a: A) -> None:
454+
print(a)
455+
456+
outputs = TestFunc1(a=A(x=7))()
457+
assert not task_fields(outputs)
458+
459+
460+
def test_no_outputs2():
461+
"""Test function tasks with no outputs set explicitly"""
462+
463+
@python.define(outputs=[])
464+
def TestFunc2(a: A):
465+
print(a)
466+
467+
outputs = TestFunc2(a=A(x=7))()
468+
assert not task_fields(outputs)
469+
470+
471+
def test_no_outputs_fail():
472+
"""Test function tasks with object inputs"""
473+
474+
@python.define(outputs=[])
475+
def TestFunc3(a: A):
476+
return a
477+
478+
with pytest.raises(ValueError, match="No output fields were specified"):
479+
TestFunc3(a=A(x=7))()
480+
481+
482+
def test_only_one_output_fail():
483+
484+
with pytest.raises(ValueError, match="Multiple outputs specified"):
485+
486+
@python.define(outputs=["out1", "out2"])
487+
def TestFunc4(a: A) -> A:
488+
return a
489+
490+
491+
def test_incorrect_num_outputs_fail():
492+
493+
with pytest.raises(ValueError, match="Length of the outputs"):
494+
495+
@python.define(outputs=["out1", "out2"])
496+
def TestFunc5(a: A) -> tuple[A, A, A]:
497+
return a, a, a

pydra/utils/general.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
logger = logging.getLogger("pydra")
2323
if ty.TYPE_CHECKING:
24-
from pydra.compose.base import Task
24+
from pydra.compose.base import Task, Field # noqa
2525
from pydra.compose import workflow
2626

2727

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ testpaths = ["pydra"]
140140
log_cli_level = "INFO"
141141
xfail_strict = true
142142
addopts = [
143-
"-svx",
143+
"-svv",
144144
"-ra",
145145
"--strict-config",
146146
"--strict-markers",

0 commit comments

Comments
 (0)