Repository: PrefectHQ/prefect

Issue:
Add Ability to overwrite task settings when calling a task in Functional API
Currently, a common pattern is:
```python
t = MyTask(name="test task", tags=["A"])

with Flow() as f:
   res1 = t(1)
   res2 = t(2)
   ...
```
and each individual copied instance of the task will appear with the same name `"test task"` in the UI and any other place that information is surfaced.

To allow for more nuanced control of the copying of tasks, we should add a reserved kwarg `_task_args` to task call signatures which allow for overriding things (such as `name`) when calling / copying the task.


Discussion:
Just throwing this out there, but another option would be some sugar like:
```python
t = MyTask(name="test task", tags=["A"])

with Flow() as f:
   res1 = t["one"](1)
   res2 = t["two"](2)
```

which could be used to rename the individual tasks `"test task[one]"` and `"test task[two]"`.

This would mean the current `__getitem__` implementation would be gone, but I personally don't see people using tasks which are so small that they are going to be using / expecting that behavior.

The reason I'm suggesting this different idea is that I think the documentation for the special kwargs to `task.__call__` are somewhat hidden (they don't naturally appear in a docstring for example), so I'm hesitant to pile on more things there.
This `__getitem__` sugar feels very unnatural to me; it doesn't have any Python analogue. (The existing getitem sugar is tenuous, but at least it mimics Python behavior of indexing an object.)

Also, this would only solve the issue for names, not for other attributes like triggers.

If the issue is documentation, then we need to add more.
> Also, this would only solve the issue for names, not for other attributes like triggers.

Right, but I suspect names will be the only thing people really use this for, which is why I was trying to brainstorm a leaner version than
```python
new_task = old_task(a, b, c, y, _task_args={"name": "new_task"})
```
which feels clunky to me, and is attempting to solve a more general problem than the one we know about (names in the UI).  For things like triggers, retries, etc. I think people will simply create new task instances because at that point the underlying task is probably fundamentally different anyway.

But, if we think a reserved kwarg is the way to go, then I suppose adding one layer of depth to the argument for the sake of supporting a more general pattern isn't so bad.
I think this feature is going to add a lot of utility to `FunctionTasks` (and the `@task` decorator more broadly) because it will allow those tasks to be reused without having to rewrite them. Currently, to use the same `@task`-decorated function as a task with two different retry behaviors, you have to rewrite the decorated function just to change the retry value. With this kwarg, you could affect the same behavior much more cleanly. I'd vote that this is a very explicit form of controlling behavior and should have a very explicit implementation.
Good point 👍🏻👍🏻

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
@@ -35,7 +35,7 @@ def _validate_run_signature(run: Callable) -> None:
             "to *args."
         )
 
-    reserved_kwargs = ["upstream_tasks", "mapped"]
+    reserved_kwargs = ["upstream_tasks", "mapped", "task_args"]
     violations = [kw for kw in reserved_kwargs if kw in run_sig.args]
     if violations:
         msg = "Tasks cannot have the following argument names: {}.".format(
@@ -70,7 +70,8 @@ def run(self, x, y):
             return x + y
     ```
 
-    *Note:* The implemented `run` method cannot have `*args` in its signature.
+    *Note:* The implemented `run` method cannot have `*args` in its signature. In addition,
+    the following keywords are reserved: `upstream_tasks`, `task_args` and `mapped`.
 
     An instance of a `Task` can be used functionally to generate other task instances
     with the same attributes but with different values bound to their `run` methods.
@@ -254,6 +255,12 @@ def run(self) -> None:
         """
         The `run()` method is called (with arguments, if appropriate) to run a task.
 
+        *Note:* The implemented `run` method cannot have `*args` in its signature. In addition,
+        the following keywords are reserved: `upstream_tasks`, `task_args` and `mapped`.
+
+        If a task has arguments in its `run()` method, these can be bound either by using the functional
+        API and _calling_ the task instance, or by using `self.bind` directly.
+
         In addition to running arbitrary functions, tasks can interact with Prefect in a few ways:
         <ul><li> Return an optional result. When this function runs successfully,
             the task is considered successful and the result (if any) can be
@@ -274,9 +281,19 @@ def run(self) -> None:
 
     # Dependencies -------------------------------------------------------------
 
-    def copy(self) -> "Task":
+    def copy(self, **task_args: Any) -> "Task":
         """
         Creates and returns a copy of the current Task.
+
+        Args:
+            - **task_args (dict, optional): a dictionary of task attribute keyword arguments, these attributes
+                will be set on the new copy
+
+        Raises:
+            - AttributeError: if any passed `task_args` are not attributes of the original
+
+        Returns:
+            - Task: a copy of the current Task, with any attributes updated from `task_args`
         """
 
         flow = prefect.context.get("flow", None)
@@ -291,10 +308,20 @@ def copy(self) -> "Task":
             )
 
         new = copy.copy(self)
+
+        # check task_args
+        for attr, val in task_args.items():
+            if not hasattr(new, attr):
+                raise AttributeError(
+                    "{0} does not have {1} as an attribute".format(self, attr)
+                )
+            else:
+                setattr(new, attr, val)
+
         # assign new id
         new.id = str(uuid.uuid4())
 
-        new.tags = copy.deepcopy(self.tags)
+        new.tags = copy.deepcopy(self.tags).union(set(new.tags))
         tags = set(prefect.context.get("tags", set()))
         new.tags.update(tags)
 
@@ -304,6 +331,7 @@ def __call__(
         self,
         *args: Any,
         mapped: bool = False,
+        task_args: dict = None,
         upstream_tasks: Iterable[Any] = None,
         **kwargs: Any
     ) -> "Task":
@@ -319,6 +347,8 @@ def __call__(
                 with the specified keyword arguments; defaults to `False`.
                 If `True`, any arguments contained within a `prefect.utilities.tasks.unmapped`
                 container will _not_ be mapped over.
+            - task_args (dict, optional): a dictionary of task attribute keyword arguments, these attributes
+                will be set on the new copy
             - upstream_tasks ([Task], optional): a list of upstream dependencies
                 for the new task.  This kwarg can be used to functionally specify
                 dependencies without binding their result to `run()`
@@ -326,7 +356,7 @@ def __call__(
         Returns:
             - Task: a new Task instance
         """
-        new = self.copy()
+        new = self.copy(**(task_args or {}))
         new.bind(*args, mapped=mapped, upstream_tasks=upstream_tasks, **kwargs)
         return new
 


Test patch:
diff --git a/tests/core/test_task.py b/tests/core/test_task.py
--- a/tests/core/test_task.py
+++ b/tests/core/test_task.py
@@ -124,6 +124,19 @@ class UpstreamTasks(Task):
                 def run(self, x, upstream_tasks=None):
                     pass
 
+    def test_class_instantiation_rejects_task_args_kwarg(self):
+        with pytest.raises(ValueError):
+
+            class TaskArgs(Task):
+                def run(self, x, task_args):
+                    pass
+
+        with pytest.raises(ValueError):
+
+            class TaskArgs(Task):
+                def run(self, x, task_args=None):
+                    pass
+
     def test_create_task_with_and_without_cache_for(self):
         t1 = Task()
         assert t1.cache_validator is never_use
@@ -243,44 +256,51 @@ def test_outputs_task_decorator(self):
             assert self.mult(x=1).outputs() == int
 
 
-def test_copy_copies():
-    class CopyTask(Task):
-        class_attr = 42
+class TestTaskCopy:
+    def test_copy_copies(self):
+        class CopyTask(Task):
+            class_attr = 42
 
-        def __init__(self, instance_val, **kwargs):
-            self.instance_val = instance_val
-            super().__init__(**kwargs)
+            def __init__(self, instance_val, **kwargs):
+                self.instance_val = instance_val
+                super().__init__(**kwargs)
 
-        def run(self, run_val):
-            return (run_val, self.class_attr, self.instance_val)
+            def run(self, run_val):
+                return (run_val, self.class_attr, self.instance_val)
 
-    ct = CopyTask("username")
-    other = ct.copy()
-    assert isinstance(other, CopyTask)
-    assert ct is not other
-    assert hash(ct) != hash(other)
-    assert ct != other
-    assert other.run("pass") == ("pass", 42, "username")
+        ct = CopyTask("username")
+        other = ct.copy()
+        assert isinstance(other, CopyTask)
+        assert ct is not other
+        assert hash(ct) != hash(other)
+        assert ct != other
+        assert other.run("pass") == ("pass", 42, "username")
 
+    def test_copy_warns_if_dependencies_in_active_flow(self):
+        t1 = Task()
+        t2 = Task()
 
-def test_copy_warns_if_dependencies_in_active_flow():
-    t1 = Task()
-    t2 = Task()
+        with Flow(name="test"):
+            t1.set_dependencies(downstream_tasks=[t2])
+            with pytest.warns(UserWarning):
+                t1.copy()
 
-    with Flow(name="test"):
-        t1.set_dependencies(downstream_tasks=[t2])
-        with pytest.warns(UserWarning):
-            t1.copy()
+            with Flow(name="test"):
+                # no dependencies in this flow
+                t1.copy()
 
-        with Flow(name="test"):
-            # no dependencies in this flow
-            t1.copy()
+    def test_copy_changes_id(self):
+        t1 = Task()
+        t2 = t1.copy()
+        assert t1.id != t2.id
 
+    def test_copy_accepts_task_args(self):
+        t = Task()
+        t2 = t.copy(name="new-task")
+        t3 = t.copy(**{"max_retries": 4200})
 
-def test_copy_changes_id():
-    t1 = Task()
-    t2 = t1.copy()
-    assert t1.id != t2.id
+        assert t2.name == "new-task"
+        assert t3.max_retries == 4200
 
 
 def test_task_has_id():
@@ -373,3 +393,41 @@ def test_parameter_deserialization(self):
         assert p2.name == p.name
         assert p2.required == p.required
         assert p2.default == p.default
+
+
+class TestTaskArgs:
+    def test_task_args_raises_for_non_attrs(self):
+        t = Task()
+        with Flow(name="test") as f:
+            with pytest.raises(AttributeError) as exc:
+                res = t(task_args={"foo": "bar"})
+
+        assert "foo" in str(exc.value)
+
+    @pytest.mark.parametrize(
+        "attr,val",
+        [
+            ("name", "foo-bar"),
+            ("slug", "foo-bar"),
+            ("max_retries", 4200),
+            ("retry_delay", timedelta(seconds=1)),
+            ("timeout", 12),
+            ("skip_on_upstream_skip", False),
+            ("cache_for", timedelta(seconds=1)),
+        ],
+    )
+    def test_task_args_sets_new_attrs(self, attr, val):
+        t = Task()
+        with Flow(name="test") as f:
+            res = t(task_args={attr: val})
+
+        assert getattr(f.tasks.pop(), attr) == val
+
+    def test_tags_are_appended_to_when_updating_with_task_args(self):
+        t = AddTask(tags=["math"])
+
+        with prefect.context(tags=["test"]):
+            with Flow(name="test"):
+                t2 = t(1, 2, task_args={"name": "test-tags", "tags": ["new-tag"]})
+
+        assert t2.tags == {"math", "test", "new-tag"}