Repository: nipype/pydra

Issue:
functions with multiple values in return statement
If default `out` for output is used (without defining `output_names` - will be in pr) and a function returns two values, only one value is saved. See:

```
@pydra.to_task
def add_vector(x1, y1, x2, y2):
    return (x1 + x2, y1 + y2)

task5 = add_vector(name="add_vect", 
                   x1=[10, 20], y1=[1, 2], x2=[10, 20, 30], y2=[10, 20, 30])
task5.split(splitter=[("x1", "y1"), ("x2", "y2")])
task5(plugin="cf")
task5.result()
```

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 715b9cb8..1c6f6392 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -14,8 +14,6 @@ env:
     - CHECK_TYPE="test"
     - INSTALL_TYPE="pip"
     - INSTALL_DEPENDS="pip setuptools"
-    - PIP_ARGS=""
-
   matrix:
     - INSTALL_TYPE="install"
     - INSTALL_TYPE="develop"
@@ -28,14 +26,9 @@ env:
 # This should only fail with pip 10.0.1 with URL requirements (e.g., "package @ URL")
 # Useful for testing un-released upstream fixes
 matrix:
-  include:
-  - python: "3.8-dev"
-    env: INSTALL_TYPE="pip" PIP_ARGS="--pre"
   allow_failures:
   - python: 3.7
     env: INSTALL_DEPENDS="pip==10.0.1 setuptools==30.3.0"
-  - python: "3.8-dev"
-    env: INSTALL_TYPE="pip" PIP_ARGS="--pre"
 
 
 before_install:
@@ -46,7 +39,7 @@ install:
   - |
     if [ "$CHECK_TYPE" = "test" ]; then
         if [ "$INSTALL_TYPE" = "pip" ]; then
-            pip install "$PIP_ARGS" .
+            pip install .
         elif [ "$INSTALL_TYPE" = "install" ]; then
             python setup.py install
         elif [ "$INSTALL_TYPE" = "develop" ]; then
@@ -85,7 +78,7 @@ before_script:
 script:
   - |
     if [ "$CHECK_TYPE" = "test" ]; then
-       pytest -vs -n auto --cov pydra --cov-config .coveragerc --cov-report xml:cov.xml --doctest-modules pydra
+        py.test -vs -n auto --cov pydra --cov-config .coveragerc --cov-report xml:cov.xml --doctest-modules pydra
     elif [ "$CHECK_TYPE" = "style" ]; then
         black pydra setup.py
     fi
diff --git a/pydra/engine/task.py b/pydra/engine/task.py
index 315c72e7..d6bcf030 100644
--- a/pydra/engine/task.py
+++ b/pydra/engine/task.py
@@ -97,29 +97,32 @@ class FunctionTask(TaskBase):
                 )
             else:
                 return_info = func.__annotations__["return"]
-                if hasattr(return_info, "__name__"):
+                if hasattr(return_info, "__name__") and hasattr(
+                    return_info, "__annotations__"
+                ):
                     output_spec = SpecInfo(
                         name=return_info.__name__,
                         fields=list(return_info.__annotations__.items()),
                         bases=(BaseSpec,),
                     )
                 # Objects like int, float, list, tuple, and dict do not have __name__ attribute.
+                elif hasattr(return_info, "__annotations__"):
+                    output_spec = SpecInfo(
+                        name="Output",
+                        fields=list(return_info.__annotations__.items()),
+                        bases=(BaseSpec,),
+                    )
                 else:
-                    if hasattr(return_info, "__annotations__"):
-                        output_spec = SpecInfo(
-                            name="Output",
-                            fields=list(return_info.__annotations__.items()),
-                            bases=(BaseSpec,),
-                        )
-                    else:
-                        output_spec = SpecInfo(
-                            name="Output",
-                            fields=[
-                                ("out{}".format(n + 1), t)
-                                for n, t in enumerate(return_info)
-                            ],
-                            bases=(BaseSpec,),
-                        )
+                    if not isinstance(return_info, tuple):
+                        return_info = (return_info,)
+                    output_spec = SpecInfo(
+                        name="Output",
+                        fields=[
+                            ("out{}".format(n + 1), t)
+                            for n, t in enumerate(return_info)
+                        ],
+                        bases=(BaseSpec,),
+                    )
         elif "return" in func.__annotations__:
             raise NotImplementedError("Branch not implemented")
         self.output_spec = output_spec
@@ -129,9 +132,16 @@ class FunctionTask(TaskBase):
         del inputs["_func"]
         self.output_ = None
         output = cp.loads(self.inputs._func)(**inputs)
-        if not isinstance(output, tuple):
-            output = (output,)
-        self.output_ = list(output)
+        if len(self.output_spec.fields) > 1:
+            if len(self.output_spec.fields) == len(output):
+                self.output_ = list(output)
+            else:
+                raise Exception(
+                    f"expected {len(self.output_spec.fields)} elements, "
+                    f"but {len(output)} were returned"
+                )
+        else:  # if only one element in the fields, everything should be returned together
+            self.output_ = output
 
     def _list_outputs(self):
         return self.output_


Test patch:
diff --git a/pydra/engine/tests/test_task.py b/pydra/engine/tests/test_task.py
index 04edf590..e73fa165 100644
--- a/pydra/engine/tests/test_task.py
+++ b/pydra/engine/tests/test_task.py
@@ -73,7 +73,108 @@ def test_annotated_func():
     ]
 
 
+def test_annotated_func_multreturn():
+    """function has two elements in the return statement"""
+
+    @to_task
+    def testfunc(
+        a: float
+    ) -> ty.NamedTuple("Output", [("fractional", float), ("integer", int)]):
+        import math
+
+        return math.modf(a)
+
+    funky = testfunc(a=3.5)
+    assert hasattr(funky.inputs, "a")
+    assert hasattr(funky.inputs, "_func")
+    assert getattr(funky.inputs, "a") == 3.5
+    assert getattr(funky.inputs, "_func") is not None
+    assert set(funky.output_names) == set(["fractional", "integer"])
+    assert funky.__class__.__name__ + "_" + funky.inputs.hash == funky.checksum
+
+    result = funky()
+    assert os.path.exists(funky.cache_dir / funky.checksum / "_result.pklz")
+    assert hasattr(result, "output")
+    assert hasattr(result.output, "fractional")
+    assert result.output.fractional == 0.5
+    assert hasattr(result.output, "integer")
+    assert result.output.integer == 3
+
+    help = funky.help(returnhelp=True)
+    assert help == [
+        "Help for FunctionTask",
+        "Input Parameters:",
+        "- a: float",
+        "- _func: str",
+        "Output Parameters:",
+        "- fractional: float",
+        "- integer: int",
+    ]
+
+
+def test_annotated_func_multreturn_exception():
+    """function has two elements in the return statement,
+        but three element provided in the spec - should raise an error
+    """
+
+    @to_task
+    def testfunc(
+        a: float
+    ) -> ty.NamedTuple(
+        "Output", [("fractional", float), ("integer", int), ("whoknows", int)]
+    ):
+        import math
+
+        return math.modf(a)
+
+    funky = testfunc(a=3.5)
+    with pytest.raises(Exception) as excinfo:
+        funky()
+    assert "expected 3 elements" in str(excinfo.value)
+
+
 def test_halfannotated_func():
+    @to_task
+    def testfunc(a, b) -> int:
+        return a + b
+
+    funky = testfunc(a=10, b=20)
+    assert hasattr(funky.inputs, "a")
+    assert hasattr(funky.inputs, "b")
+    assert hasattr(funky.inputs, "_func")
+    assert getattr(funky.inputs, "a") == 10
+    assert getattr(funky.inputs, "b") == 20
+    assert getattr(funky.inputs, "_func") is not None
+    assert set(funky.output_names) == set(["out1"])
+    assert funky.__class__.__name__ + "_" + funky.inputs.hash == funky.checksum
+
+    result = funky()
+    assert hasattr(result, "output")
+    assert hasattr(result.output, "out1")
+    assert result.output.out1 == 30
+
+    assert os.path.exists(funky.cache_dir / funky.checksum / "_result.pklz")
+
+    funky.result()  # should not recompute
+    funky.inputs.a = 11
+    assert funky.result() is None
+    funky()
+    result = funky.result()
+    assert result.output.out1 == 31
+    help = funky.help(returnhelp=True)
+
+    assert help == [
+        "Help for FunctionTask",
+        "Input Parameters:",
+        "- a: _empty",
+        "- b: _empty",
+        "- _func: str",
+        "Output Parameters:",
+        "- out1: int",
+    ]
+
+
+def test_halfannotated_func_multreturn():
     @to_task
     def testfunc(a, b) -> (int, int):
         return a + 1, b + 1
@@ -131,6 +232,26 @@ def test_notannotated_func():
     assert result.output.out == 20.2
 
 
+def test_notannotated_func_multreturn():
+    """ no annotation and multiple values are returned
+        all elements should be returned as a tuple ans set to "out"
+    """
+
+    @to_task
+    def no_annots(c, d):
+        return c + d, c - d
+
+    natask = no_annots(c=17, d=3.2)
+    assert hasattr(natask.inputs, "c")
+    assert hasattr(natask.inputs, "d")
+    assert hasattr(natask.inputs, "_func")
+
+    result = natask._run()
+    assert hasattr(result, "output")
+    assert hasattr(result.output, "out")
+    assert result.output.out == (20.2, 13.8)
+
+
 def test_exception_func():
     @to_task
     def raise_exception(c, d):
@@ -140,16 +261,31 @@ def test_exception_func():
     assert pytest.raises(Exception, bad_funk)
 
 
-@pytest.mark.xfail(reason="errors from cloudpickle")
-def test_audit(tmpdir):
+def test_audit_prov(tmpdir):
     @to_task
     def testfunc(a: int, b: float = 0.1) -> ty.NamedTuple("Output", [("out", float)]):
         return a + b
 
-    funky = testfunc(a=1, audit_flags=AuditFlag.PROV, messengers=PrintMessenger())
+    funky = testfunc(a=1, audit_flags=AuditFlag.PROV, messengers=FileMessenger())
     funky.cache_dir = tmpdir
     funky()
 
+    funky = testfunc(a=2, audit_flags=AuditFlag.PROV, messengers=FileMessenger())
+    message_path = tmpdir / funky.checksum / "messages"
+    funky.cache_dir = tmpdir
+    funky.messenger_args = dict(message_dir=message_path)
+    funky()
+
+    collect_messages(tmpdir / funky.checksum, message_path, ld_op="compact")
+    assert (tmpdir / funky.checksum / "messages.jsonld").exists()
+
+
+@pytest.mark.xfail(reason="errors from cloudpickle")
+def test_audit_all(tmpdir):
+    @to_task
+    def testfunc(a: int, b: float = 0.1) -> ty.NamedTuple("Output", [("out", float)]):
+        return a + b
+
     funky = testfunc(a=2, audit_flags=AuditFlag.ALL, messengers=FileMessenger())
     message_path = tmpdir / funky.checksum / "messages"
     funky.cache_dir = tmpdir