Repository: PrefectHQ/prefect

Issue:
Unsupported types for unmapped
While attempting to use Prefect to build a Flow for some feature engineering work, I ran into some difficulty with passing `unmapped` args to a `map` operation:

```
clean_data = impute.map(data_fields, replacement_dict=unmapped({np.nan: 0.}))
```

I had made an `impute` Task that takes in a dictionary of "old: new" value pairs for data imputation. This raised the following error:

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-23-8cc2655f441d> in <module>
----> 1 clean = impute.map(data_fields, replacement_dict=unmapped({np.nan: 0.}))

/usr/local/lib/python3.6/dist-packages/prefect/utilities/tasks.py in __init__(self, task)
    197 
    198     def __init__(self, task: "prefect.Task"):
--> 199         self.task = as_task(task)
    200 
    201 

/usr/local/lib/python3.6/dist-packages/prefect/utilities/tasks.py in as_task(x)
     66         return prefect.tasks.core.collections.Set().bind(*x)
     67     elif isinstance(x, dict):
---> 68         return prefect.tasks.core.collections.Dict().bind(**x)
     69 
     70     # functions

TypeError: bind() keywords must be strings
```

It seems to not like `np.nan` as a key.

Next, I attempted to pass a data type as an argument, in a `cast` Task:

```
clean_data = cast.map(clean_data, new_type=unmapped(np.int))
```

`unmapped(np.int)` raised the following:

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/usr/lib/python3.6/inspect.py in getfullargspec(func)
   1123                                        skip_bound_arg=False,
-> 1124                                        sigcls=Signature)
   1125     except Exception as ex:

/usr/lib/python3.6/inspect.py in _signature_from_callable(obj, follow_wrapper_chains, skip_bound_arg, sigcls)
   2346                     raise ValueError(
-> 2347                         'no signature found for builtin type {!r}'.format(obj))
   2348 

ValueError: no signature found for builtin type <class 'int'>

The above exception was the direct cause of the following exception:

TypeError                                 Traceback (most recent call last)
<ipython-input-21-fbdd941b881e> in <module>
     21     # clean data
     22     clean = impute.map(data_fields, replacement_pairs=unmapped([(np.nan, 0.)]))
---> 23     clean = cast.map(clean, new_type=unmapped(int))
     24 
     25     # stats

/usr/local/lib/python3.6/dist-packages/prefect/utilities/tasks.py in __init__(self, task)
    197 
    198     def __init__(self, task: "prefect.Task"):
--> 199         self.task = as_task(task)
    200 
    201 

/usr/local/lib/python3.6/dist-packages/prefect/utilities/tasks.py in as_task(x)
     70     # functions
     71     elif callable(x):
---> 72         return prefect.tasks.core.function.FunctionTask(fn=x)
     73 
     74     # constants

/usr/local/lib/python3.6/dist-packages/prefect/tasks/core/function.py in __init__(self, fn, name, **kwargs)
     44             name = getattr(fn, "__name__", type(self).__name__)
     45 
---> 46         prefect.core.task._validate_run_signature(fn)  # type: ignore
     47         self.run = fn
     48 

/usr/local/lib/python3.6/dist-packages/prefect/core/task.py in _validate_run_signature(run)
     24 def _validate_run_signature(run: Callable) -> None:
     25     func = getattr(run, "__wrapped__", run)
---> 26     run_sig = inspect.getfullargspec(func)
     27     if run_sig.varargs:
     28         raise ValueError(

/usr/lib/python3.6/inspect.py in getfullargspec(func)
   1128         # else. So to be fully backwards compatible, we catch all
   1129         # possible exceptions here, and reraise a TypeError.
-> 1130         raise TypeError('unsupported callable') from ex
   1131 
   1132     args = []

TypeError: unsupported callable
```

While I was able to get around the first error by replacing the dictionary with a list of tuples, the second error seems harder to get around, since providing a data type to be cast to is key to making the Task flexible.


Discussion:
@ntaylorwss thank you yet again for an excellent bug report!  I believe I know what's going on; coincidentally, this is related to the [most recent PIN](https://docs.prefect.io/guide/PINs/PIN-6-Remove-Constant-Tasks.html) (which hasn't been implemented yet but would solve this problem).

I believe I know of a quick fix though; I will get back to you soon!
FYI @ntaylorwss so your work isn't held up: you should be able to get around these errors by wrapping these objects in `Constant` tasks:
```python
from prefect.tasks.core.constants import Constant


clean_data = impute.map(data_fields, replacement_dict=unmapped(Constant({np.nan: 0.})))
...
clean_data = cast.map(clean_data, new_type=unmapped(Constant(np.int)))
```
it's a little clunkier, but does the same thing that `unmapped` was _attempting_ to do under the hood.

Resolving patch:
diff --git a/src/prefect/tasks/core/collections.py b/src/prefect/tasks/core/collections.py
--- a/src/prefect/tasks/core/collections.py
+++ b/src/prefect/tasks/core/collections.py
@@ -131,12 +131,19 @@ class Dict(Task):
     def __init__(self, *args: Any, **kwargs: Any):
         super().__init__(*args, **kwargs)
 
-    def run(self, **task_results: Any) -> dict:  # type: ignore
+    def run(self, keys: Iterable[Any], values: Iterable[Any]) -> dict:  # type: ignore
         """
         Args:
-            - **task_results (Any): task result key / value pairs to collect into a dict
+            - keys (Iterable[Any]): a list of keys that will form the dictionary
+            - values (Iterable[Any]): a list of values for the dictionary
 
         Returns:
             - dict: a dict of task results
+
+        Raises:
+            - ValueError: if the number of keys and the number of values are different
         """
-        return task_results
+        if len(keys) != len(values):
+            raise ValueError("A different number of keys and values were provided!")
+
+        return {k: v for k, v in zip(keys, values)}
diff --git a/src/prefect/utilities/tasks.py b/src/prefect/utilities/tasks.py
--- a/src/prefect/utilities/tasks.py
+++ b/src/prefect/utilities/tasks.py
@@ -65,11 +65,11 @@ def as_task(x: Any) -> "prefect.Task":
     elif isinstance(x, set):
         return prefect.tasks.core.collections.Set().bind(*x)
     elif isinstance(x, dict):
-        return prefect.tasks.core.collections.Dict().bind(**x)
-
-    # functions
-    elif callable(x):
-        return prefect.tasks.core.function.FunctionTask(fn=x)
+        keys, values = [], []
+        for k, v in x.items():
+            keys.append(k)
+            values.append(v)
+        return prefect.tasks.core.collections.Dict().bind(keys=keys, values=values)
 
     # constants
     else:


Test patch:
diff --git a/tests/tasks/test_core.py b/tests/tasks/test_core.py
--- a/tests/tasks/test_core.py
+++ b/tests/tasks/test_core.py
@@ -82,9 +82,23 @@ def test_set_binds_varargs(self):
     def test_dict_returns_a_dict(self):
         l = collections.Dict()
         with Flow(name="test") as f:
-            l.bind(a=1, b=2)
+            l.bind(keys=["a", "b"], values=[1, 2])
         assert f.run().result[l].result == dict(a=1, b=2)
 
+    def test_dict_handles_non_string_keys(self):
+        l = collections.Dict()
+        with Flow(name="test") as f:
+            l.bind(keys=[None, 55], values=[1, 2])
+        assert f.run().result[l].result == {None: 1, 55: 2}
+
+    def test_dict_raises_for_differing_length_key_value_pairs(self):
+        l = collections.Dict()
+        with Flow(name="test") as f:
+            l.bind(keys=["a"], values=[1, 2])
+        state = f.run()
+        assert state.result[l].is_failed()
+        assert isinstance(state.result[l].result, ValueError)
+
     def test_list_automatically_applied_to_callargs(self):
         x = Parameter("x")
         y = Parameter("y")
@@ -129,7 +143,9 @@ def test_dict_automatically_applied_to_callargs(self):
             identity.bind(x=dict(a=x, b=y))
         state = f.run(parameters=dict(x=1, y=2))
 
-        assert len(f.tasks) == 4
+        assert (
+            len(f.tasks) == 8
+        )  # 2 params, identity, Dict, 2 Lists for Dict, "a" and "b"
         assert sum(isinstance(t, collections.Dict) for t in f.tasks) == 1
         assert state.result[identity].result == dict(a=1, b=2)
 
@@ -141,5 +157,5 @@ def test_nested_collection_automatically_applied_to_callargs(self):
             identity.bind(x=dict(a=[x, dict(y=y)], b=(y, set([x]))))
         state = f.run(parameters=dict(x=1, y=2))
 
-        assert len(f.tasks) == 8
+        assert len(f.tasks) == 15
         assert state.result[identity].result == dict(a=[1, dict(y=2)], b=(2, set([1])))
diff --git a/tests/utilities/test_tasks.py b/tests/utilities/test_tasks.py
--- a/tests/utilities/test_tasks.py
+++ b/tests/utilities/test_tasks.py
@@ -89,6 +89,37 @@ def fn(upstream_tasks):
                 pass
 
 
+class TestAsTask:
+    @pytest.mark.parametrize(
+        "obj",
+        [
+            1,
+            (3, 4),
+            ["a", "b"],
+            "string",
+            dict(x=42),
+            type(None),
+            lambda *args: None,
+            {None: 88},
+        ],
+    )
+    def test_as_task_with_basic_python_objs(self, obj):
+        @tasks.task
+        def return_val(x):
+            "Necessary because constant tasks aren't tracked inside the flow"
+            return x
+
+        with Flow("test") as f:
+            t = tasks.as_task(obj)
+            val = return_val(t)
+
+        assert isinstance(t, Task)
+        res = FlowRunner(f).run(return_tasks=[val])
+
+        assert res.is_successful()
+        assert res.result[val].result == obj
+
+
 def test_tag_contextmanager_works_with_task_decorator():
     @tasks.task
     def mytask():