Repository: RealOrangeOne/django-tasks (language: python)

Issue:
Support calling `@task` without any attributes.
This is currently valid:

```python
@task()
def my_task():
    pass
```

This isn't:

```python
@task
def my_task():
    pass
```

And raises an unhelpful error: "'function' object has no attribute 'enqueue'".

We should support being able to not call `task`, and infer the defaults from there. This pattern is used for a few different decorators, and avoids a number of potential foot-guns.


Discussion / hints:

Fix patch:
diff --git a/django_tasks/task.py b/django_tasks/task.py
index c8497a7..484d62e 100644
--- a/django_tasks/task.py
+++ b/django_tasks/task.py
@@ -11,6 +11,7 @@
     Optional,
     TypeVar,
     Union,
+    overload,
 )
 
 from asgiref.sync import async_to_sync, sync_to_async
@@ -149,11 +150,31 @@ def module_path(self) -> str:
         return f"{self.func.__module__}.{self.func.__qualname__}"
 
 
+# Bare decorator usage
+# e.g. @task
+@overload
+def task(function: Callable[P, T], /) -> Task[P, T]: ...
+
+
+# Decorator with arguments
+# e.g. @task() or @task(priority=1, ...)
+@overload
 def task(
+    *,
     priority: int = 0,
     queue_name: str = DEFAULT_QUEUE_NAME,
     backend: str = DEFAULT_TASK_BACKEND_ALIAS,
-) -> Callable[[Callable[P, T]], Task[P, T]]:
+) -> Callable[[Callable[P, T]], Task[P, T]]: ...
+
+
+# Implementation
+def task(
+    function: Optional[Callable[P, T]] = None,
+    *,
+    priority: int = 0,
+    queue_name: str = DEFAULT_QUEUE_NAME,
+    backend: str = DEFAULT_TASK_BACKEND_ALIAS,
+) -> Union[Task[P, T], Callable[[Callable[P, T]], Task[P, T]]]:
     """
     A decorator used to create a task.
     """
@@ -164,6 +185,9 @@ def wrapper(f: Callable[P, T]) -> Task[P, T]:
             priority=priority, func=f, queue_name=queue_name, backend=backend
         )
 
+    if function:
+        return wrapper(function)
+
     return wrapper
 
 


Test patch:
diff --git a/tests/tasks.py b/tests/tasks.py
index e3237c4..a816554 100644
--- a/tests/tasks.py
+++ b/tests/tasks.py
@@ -6,6 +6,11 @@ def noop_task(*args: tuple, **kwargs: dict) -> None:
     return None
 
 
+@task
+def noop_task_from_bare_decorator(*args: tuple, **kwargs: dict) -> None:
+    return None
+
+
 @task()
 async def noop_task_async(*args: tuple, **kwargs: dict) -> None:
     return None
diff --git a/tests/tests/test_tasks.py b/tests/tests/test_tasks.py
index d8faa04..993ab25 100644
--- a/tests/tests/test_tasks.py
+++ b/tests/tests/test_tasks.py
@@ -8,6 +8,7 @@
 from django_tasks import (
     DEFAULT_QUEUE_NAME,
     ResultStatus,
+    Task,
     default_task_backend,
     task,
     tasks,
@@ -37,6 +38,11 @@ def test_using_correct_backend(self) -> None:
         self.assertEqual(default_task_backend, tasks["default"])
         self.assertIsInstance(tasks["default"], DummyBackend)
 
+    def test_task_decorator(self) -> None:
+        self.assertIsInstance(test_tasks.noop_task, Task)
+        self.assertIsInstance(test_tasks.noop_task_async, Task)
+        self.assertIsInstance(test_tasks.noop_task_from_bare_decorator, Task)
+
     def test_enqueue_task(self) -> None:
         result = test_tasks.noop_task.enqueue()