diff --git a/docs/source/tutorial/2-advanced-execution.ipynb b/docs/source/tutorial/2-advanced-execution.ipynb index 714ffae49..164ab87fd 100644 --- a/docs/source/tutorial/2-advanced-execution.ipynb +++ b/docs/source/tutorial/2-advanced-execution.ipynb @@ -351,8 +351,8 @@ "how to utilise containers and add support for other software environments.\n", "\n", "It is also possible to specify functions to run at hooks that are immediately before and after\n", - "the task is executed by passing a `pydra.engine.spec.TaskHooks` object to the `hooks`\n", - "keyword arg. The callable should take the `pydra.engine.core.Job` object as its only\n", + "the task is executed by passing a `pydra.engine.hooks.TaskHooks` object to the `hooks`\n", + "keyword arg. The callable should take the `pydra.engine.job.Job` object as its only\n", "argument and return None. The available hooks to attach functions are:\n", "\n", "* pre_run: before the task cache directory is created\n", @@ -415,7 +415,7 @@ ], "metadata": { "kernelspec": { - "display_name": "wf12", + "display_name": "wf13", "language": "python", "name": "python3" }, @@ -429,7 +429,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.5" + "version": "3.13.1" } }, "nbformat": 4, diff --git a/docs/source/tutorial/7-canonical-form.ipynb b/docs/source/tutorial/7-canonical-form.ipynb index ee6e043b1..756d30ce2 100644 --- a/docs/source/tutorial/7-canonical-form.ipynb +++ b/docs/source/tutorial/7-canonical-form.ipynb @@ -34,8 +34,8 @@ "Default values can also be set directly, as with Attrs classes.\n", "\n", "In order to allow static type-checkers to check the type of outputs of tasks added\n", - "to workflows, it is also necessary to explicitly extend from the `pydra.engine.python.Task`\n", - "and `pydra.engine.python.Outputs` classes (they are otherwise set as bases by the\n", + "to workflows, it is also necessary to explicitly extend from the `pydra.compose.python.Task`\n", + "and `pydra.compose.python.Outputs` classes (they are otherwise set as bases by the\n", "`define` method implicitly). Thus the \"canonical form\" of Python task is as\n", "follows" ] diff --git a/pydra/compose/base/helpers.py b/pydra/compose/base/helpers.py index ea9f3f084..9846d2056 100644 --- a/pydra/compose/base/helpers.py +++ b/pydra/compose/base/helpers.py @@ -183,7 +183,7 @@ def extract_function_inputs_and_outputs( input_types[p.name] = type_hints.get(p.name, ty.Any) if p.default is not inspect.Parameter.empty: input_defaults[p.name] = p.default - if inputs: + if inputs is not None: if not isinstance(inputs, dict): raise ValueError( f"Input names ({inputs}) should not be provided when " @@ -218,41 +218,43 @@ def extract_function_inputs_and_outputs( f"value {default}" ) return_type = type_hints.get("return", ty.Any) - if outputs and len(outputs) > 1: - if return_type is not ty.Any: - if ty.get_origin(return_type) is not tuple: - raise ValueError( - f"Multiple outputs specified ({outputs}) but non-tuple " - f"return value {return_type}" - ) - return_types = ty.get_args(return_type) - if len(return_types) != len(outputs): - raise ValueError( - f"Length of the outputs ({outputs}) does not match that " - f"of the return types ({return_types})" - ) - output_types = dict(zip(outputs, return_types)) - else: - output_types = {o: ty.Any for o in outputs} - if isinstance(outputs, dict): - for output_name, output in outputs.items(): - if isinstance(output, Out) and output.type is ty.Any: - output.type = output_types[output_name] + if outputs: + if len(outputs) > 1: + if return_type is not ty.Any: + if ty.get_origin(return_type) is not tuple: + raise ValueError( + f"Multiple outputs specified ({outputs}) but non-tuple " + f"return value {return_type}" + ) + return_types = ty.get_args(return_type) + if len(return_types) != len(outputs): + raise ValueError( + f"Length of the outputs ({outputs}) does not match that " + f"of the return types ({return_types})" + ) + output_types = dict(zip(outputs, return_types)) + else: + output_types = {o: ty.Any for o in outputs} + if isinstance(outputs, dict): + for output_name, output in outputs.items(): + if isinstance(output, Out) and output.type is ty.Any: + output.type = output_types[output_name] + else: + outputs = output_types else: - outputs = output_types - - elif outputs: - if isinstance(outputs, dict): - output_name, output = next(iter(outputs.items())) - elif isinstance(outputs, list): - output_name = outputs[0] - output = ty.Any - if isinstance(output, Out): - if output.type is ty.Any: - output.type = return_type - elif output is ty.Any: - output = return_type - outputs = {output_name: output} + if isinstance(outputs, dict): + output_name, output = next(iter(outputs.items())) + elif isinstance(outputs, list): + output_name = outputs[0] + output = ty.Any + if isinstance(output, Out): + if output.type is ty.Any: + output.type = return_type + elif output is ty.Any: + output = return_type + outputs = {output_name: output} + elif outputs == [] or return_type in (None, type(None)): + outputs = {} else: outputs = {"out": return_type} return inputs, outputs diff --git a/pydra/compose/python.py b/pydra/compose/python.py index 7eefc0291..d4eafe7ea 100644 --- a/pydra/compose/python.py +++ b/pydra/compose/python.py @@ -239,6 +239,10 @@ def _run(self, job: "Job[PythonTask]", rerun: bool = True) -> None: return_names = [f.name for f in task_fields(self.Outputs)] if returned is None: job.return_values = {nm: None for nm in return_names} + elif not return_names: + raise ValueError( + f"No output fields were specified, but the function returned {returned}" + ) elif len(return_names) == 1: # if only one element in the fields, everything should be returned together job.return_values[return_names[0]] = returned diff --git a/pydra/compose/tests/test_python_fields.py b/pydra/compose/tests/test_python_fields.py index 459f21f25..f6444f3c5 100644 --- a/pydra/compose/tests/test_python_fields.py +++ b/pydra/compose/tests/test_python_fields.py @@ -424,3 +424,54 @@ def TestFunc(a: A): outputs = TestFunc(a=A(x=7))() assert outputs.out == 7 + + +def test_no_outputs1(): + """Test function tasks with no outputs specified by None return type""" + + @python.define + def TestFunc1(a: A) -> None: + print(a) + + outputs = TestFunc1(a=A(x=7))() + assert not task_fields(outputs) + + +def test_no_outputs2(): + """Test function tasks with no outputs set explicitly""" + + @python.define(outputs=[]) + def TestFunc2(a: A): + print(a) + + outputs = TestFunc2(a=A(x=7))() + assert not task_fields(outputs) + + +def test_no_outputs_fail(): + """Test function tasks with object inputs""" + + @python.define(outputs=[]) + def TestFunc3(a: A): + return a + + with pytest.raises(ValueError, match="No output fields were specified"): + TestFunc3(a=A(x=7))() + + +def test_only_one_output_fail(): + + with pytest.raises(ValueError, match="Multiple outputs specified"): + + @python.define(outputs=["out1", "out2"]) + def TestFunc4(a: A) -> A: + return a + + +def test_incorrect_num_outputs_fail(): + + with pytest.raises(ValueError, match="Length of the outputs"): + + @python.define(outputs=["out1", "out2"]) + def TestFunc5(a: A) -> tuple[A, A, A]: + return a, a, a diff --git a/pyproject.toml b/pyproject.toml index b9c1925d2..e93f75dc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,7 @@ testpaths = ["pydra"] log_cli_level = "INFO" xfail_strict = true addopts = [ - "-svx", + "-svv", "-ra", "--strict-config", "--strict-markers",