Repository: PrefectHQ/prefect

Issue:
Tasks do not support callable objects
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

As the title states, callable objects (i.e. those that implement `__call__`) are not supported by tasks.

This happens because callable objects don't have two attributes: `__name__` and `__qualname__`.
The `__name__` restriction can be bypassed by defining the `name` attribute for the task - in which case, the error below is different.

Without the name, the task fails when calling `prefect.utilities.importtools.to_qualified_name`.
https://github.com/PrefectHQ/prefect/blob/8294cb1e9b64660deffb912c9c3c0c9246ba5af6/src/prefect/utilities/importtools.py#L18-L29

Lambdas work because both `__name__` and `__qualname__` are set to `<lambda>`.

### Reproduction

```python3
from typing import Any

import prefect


class CallableObj:
    def __init__(self) -> None:
        self.x = 10

    def __call__(self, *args: Any, **kwds: Any) -> Any:
        print(self.x)


@prefect.flow
def test_flow():
    t = prefect.Task(fn=CallableObj())
    t()
```


### Error

#### Without declaring the `name`

```
File "/Users/duarte/Documents/data/pdbt/dystematic/pdbt/flows/raw/epfr/materializations/test_flow.py", line 18, in test_flow
    t = prefect.Task(fn=CallableObj())
  File "/Users/duarte/Documents/data/pdbt/.venv/lib/python3.8/site-packages/prefect/context.py", line 163, in __register_init__
    __init__(__self__, *args, **kwargs)
  File "/Users/duarte/Documents/data/pdbt/.venv/lib/python3.8/site-packages/prefect/tasks.py", line 136, in __init__
    self.name = name or self.fn.__name__
AttributeError: 'CallableObj' object has no attribute '__name__'
```

#### Declaring the `name`
```
  File "/Users/duarte/Documents/data/pdbt/.venv/lib/python3.8/site-packages/prefect/context.py", line 163, in __register_init__
    __init__(__self__, *args, **kwargs)
  File "/Users/duarte/Documents/data/pdbt/.venv/lib/python3.8/site-packages/prefect/tasks.py", line 142, in __init__
    self.task_key = to_qualified_name(self.fn)
  File "/Users/duarte/Documents/data/pdbt/.venv/lib/python3.8/site-packages/prefect/utilities/importtools.py", line 29, in to_qualified_name
    return obj.__module__ + "." + obj.__qualname__
AttributeError: 'CallableObj' object has no attribute '__qualname__'
```

### Versions

```Text
[11:58:43] Φ prefect version
Version:             2.5.0
API version:         0.8.2
Python version:      3.8.9
Git commit:          eac37918
Built:               Thu, Oct 6, 2022 12:41 PM
OS/Arch:             darwin/x86_64
Profile:             default
Server type:         ephemeral
Server:
  Database:          sqlite
  SQLite version:    3.32.3
```


### Additional context

_No response_


Discussion:
Happy to review a fix for this! I imagine we'll need to find another way to generate a key for tasks that are not functions. Perhaps the qualified name is present on `type(instance)`?
Is the task key supposed to be unique?

The code below tells me "yes" but at the same time, it will depend on the dynamic key.
https://github.com/PrefectHQ/prefect/blob/20097b0b07330f84290be5f24c253246102d2242/src/prefect/engine.py#L858-L860

If the task key is supposed to be unique, the code is broken for lambdas.

> Perhaps the qualified name is present on `type(instance)`?

It is, I'm assuming you meant to use this like:
```python
if not hasattr(instance, '__qualname__'):
    self.task_key = type(instance).__qualname__ 
```
The task key is used to match task runs to a "task". Basically, to group task runs by the code that they use. The dynamic key is appended for each call of the task. Together, the task key and the dynamic key form a unique task run identifier.

Here, I think something like `self.task_key = type(instance).__qualname__` may be reasonable if you want to group all task runs for all instances of a class. If you want to group each instance separately, we'll need to do something else. However, the task key is intended to be stable on interpreter reruns so if you were to use the `id` of an instance or something we'd have a problem. I think pulling the qualified name from the type is the most reasonable option for now. We can expose ways for users to define their own task key in the future, e.g. with a `__prefect_task_key__(self) -> str` method.
Alright, sounds good to me. When adding tests for this feature, where should I put them? I was thinking of https://github.com/PrefectHQ/prefect/blob/main/tests/test_tasks.py
That'd be great! Thanks!

Resolving patch:
diff --git a/src/prefect/tasks.py b/src/prefect/tasks.py
--- a/src/prefect/tasks.py
+++ b/src/prefect/tasks.py
@@ -153,7 +153,11 @@ def __init__(
         raise_for_reserved_arguments(self.fn, ["return_state", "wait_for"])
 
         self.tags = set(tags if tags else [])
-        self.task_key = to_qualified_name(self.fn)
+
+        if not hasattr(self.fn, "__qualname__"):
+            self.task_key = to_qualified_name(type(self.fn))
+        else:
+            self.task_key = to_qualified_name(self.fn)
 
         self.cache_key_fn = cache_key_fn
         self.cache_expiration = cache_expiration


Test patch:
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -1,7 +1,7 @@
 import datetime
 import inspect
 import warnings
-from typing import Dict, List
+from typing import Any, Dict, List
 from unittest.mock import MagicMock
 from uuid import UUID
 
@@ -155,6 +155,15 @@ def bar():
         with pytest.raises(ValueError, match="Test"):
             state.result()
 
+    def test_task_supports_callable_objects(self):
+        class A:
+            def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
+                return "hello"
+
+        a = A()
+        task = Task(fn=a, name="Task")
+        assert task.fn is a
+
 
 class TestTaskRun:
     def test_task_run_outside_flow_raises(self):