Repository: PrefectHQ/prefect

Issue:
RFC: merging __init__() and run() arguments
#291 raises the specter of an old discussion we had tabled -- how to handle arguments that are appropriate for both `__init__` and `run`. 

In short: flows built with the functional API will prefer run() arguments, since the user often isn't instantiating the task explicitly at all. Conversely, flows built with the imperative API are instantiated and then attached to other tasks explicitly, so __init__() seems more appropriate.

If we don't provide some way of doing this, then users will end up writing repetitive code which is unpleasant:

```python
class MyTask(Task):
    def __init__(self, a, b):
        self.a = a
        self.b = b
  
    def run(self, a=None, b=None):
        a = self.a if a is None else a
        b = self.b if b is None else b
```

I'll throw out two possible alternatives to get the ball rolling:


- This decorator provides default values for any specified run arguments, retrieving those values from instance attributes of the same name.
```python
class MyTask(Task):
    def __init__(self, a, b):
        self.a = a
        self.b = b

	@defaults_from_attrs('a', 'b')  
    def run(self, a, b):
		...
```

This indicates the same thing, but uses Python 3 annotations instead.
```python
class MyTask(Task):
    def __init__(self, a, b):
        self.a = a
        self.b = b
  
    def run(self, a:DefaultFromAttr, b:DefaultFromAttr):
		...
```

The annotations approach has the disadvantage of not allowing proper typing of the argument itself.


Discussion:
I currently vote for:
```python
class MyTask(Task):
    def __init__(self, a, b):
        self.a = a
        self.b = b

    @defaults_from_attrs('a', 'b')  
    def run(self, a, b):
		...
```
Let's get the SQL tasks in and then revisit this to see how a refactor might look and feel!

Resolving patch:
diff --git a/src/prefect/core/task.py b/src/prefect/core/task.py
--- a/src/prefect/core/task.py
+++ b/src/prefect/core/task.py
@@ -24,7 +24,8 @@
 
 
 def _validate_run_signature(run: Callable) -> None:
-    run_sig = inspect.getfullargspec(run)
+    func = getattr(run, "__wrapped__", run)
+    run_sig = inspect.getfullargspec(func)
     if run_sig.varargs:
         raise ValueError(
             "Tasks with variable positional arguments (*args) are not "
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
@@ -2,13 +2,14 @@
 
 import inspect
 from contextlib import contextmanager
-from typing import Any, Callable, Iterator
+from functools import wraps
+from typing import Any, Callable, Iterator, Iterable
 
 from toolz import curry
 
 import prefect
 
-__all__ = ["tags", "as_task", "pause_task", "task", "unmapped"]
+__all__ = ["tags", "as_task", "pause_task", "task", "unmapped", "defaults_from_attrs"]
 
 
 @contextmanager
@@ -195,3 +196,51 @@ def run(self):
 
     def __init__(self, task: "prefect.Task"):
         self.task = as_task(task)
+
+
+@curry
+def defaults_from_attrs(attr_args: Iterable[str], run_method: Callable) -> Callable:
+    """
+    Helper decorator for dealing with Task classes with attributes which serve
+    as defaults for `Task.run`.  Specifically, this decorator allows the author of a Task
+    to identify certain keyword arguments to the run method which will fall back to `self.ATTR_NAME`
+    if not explicitly provided to `self.run`.  This pattern allows users to create a Task "template",
+    whose default settings can be created at initialization but overrided in individual instances when the
+    Task is called.
+
+    Args:
+        - attr_args (Iterable[str]): an iterable of strings specifying which
+            kwargs should fallback to attributes, if not provided at runtime. Note that
+            the strings provided here must match keyword arguments in the `run` call signature,
+            as well as the names of attributes of this Task.
+        - run_method (Callable): the `Task.run` method to implement the behavior for
+
+    Returns:
+        - Callable: the decorated / altered `Task.run` method
+
+    Example:
+    ```python
+    class MyTask(Task):
+        def __init__(self, a=None, b=None):
+            self.a = a
+            self.b = b
+
+        @defaults_from_attrs(['a', 'b'])
+        def run(self, a=None, b=None):
+            return a, b
+
+    task = MyTask(a=1, b=2)
+
+    task.run() # (1, 2)
+    task.run(a=99) # (99, 2)
+    task.run(a=None, b=None) # (None, None)
+    ```
+    """
+
+    @wraps(run_method)
+    def method(self, *args, **kwargs):
+        for attr in attr_args:
+            kwargs.setdefault(attr, getattr(self, attr))
+        return run_method(self, *args, **kwargs)
+
+    return method


Test patch:
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
@@ -193,3 +193,77 @@ def run(self, x, y):
         res = f.run(return_tasks=f.tasks, task_states={t1: Resume()})
         assert res.result[t1].is_successful()
         assert isinstance(res.result[t2], Paused)
+
+
+class TestDefaultFromAttrs:
+    @pytest.fixture
+    def xtask(self):
+        class A(Task):
+            def __init__(self, x=None):
+                self.x = x
+                super().__init__()
+
+            @tasks.defaults_from_attrs(["x"])
+            def run(self, x=None):
+                "Lil doc"
+                return x
+
+        return A
+
+    @pytest.fixture
+    def multitask(self):
+        class B(Task):
+            def __init__(self, x=None, y=None):
+                self.x = x
+                self.y = y
+                super().__init__()
+
+            @tasks.defaults_from_attrs(["x"])
+            def run(self, x=None, y=None):
+                return x, y
+
+        return B
+
+    def test_pulls_from_attr_if_not_provided_at_runtime(self, xtask):
+        a = xtask(5)
+        assert a.run() == 5
+
+    def test_runtime_takes_precedence(self, xtask):
+        a = xtask(5)
+        assert a.run(x=6) == 6
+
+    def test_even_none_at_runtime_takes_precedence(self, xtask):
+        """
+        This test ensures that `None` isn't some ambiguous special case: keywords
+        provided at runtime _always_ take precedence.
+        """
+        a = xtask(5)
+        assert a.run(x=None) is None
+
+    def test_doc_is_unaffected(self, xtask):
+        assert xtask.run.__doc__ == "Lil doc"
+
+    def test_args_not_listed_are_unaffected(self, multitask):
+        b = multitask(x=1, y=2)
+        assert b.run() == (1, None)
+
+    def test_works_with_multiple_args(self, multitask):
+        b = multitask(x=1, y=2)
+        assert b.run(y=3, x=55) == (55, 3)
+
+    def test_raises_if_attr_wasnt_set_at_init(self):
+        """
+        It would be nice to raise this at creation time, but unfortunately
+        the information just isn't available.
+        """
+
+        class Forgot(Task):
+            @tasks.defaults_from_attrs(["x"])
+            def run(self, x=None):
+                return x
+
+        t = Forgot()
+        with pytest.raises(AttributeError) as exc:
+            t.run()
+
+        assert "no attribute 'x'" in str(exc.value)