Repository: aio-libs/aiojobs

Issue:
Add name param to spawn
## Feature request:

I would like to be able to use the scheduler API to set and get task/job names. Something like so:

### Proposed Usage

```python
await scheduler.spawn(my_task(), name="my-task")
await scheduler.spawn(another_task(), name="another-task")

jobs = [job for job in scheduler._jobs]
task_names = [job._task.get_name() for job in jobs]
```
### Output:
```
task_names
> ['my-task', 'another-task']
```

### Bonus Usage

Even better would be delegating `job.get_name()` to `job._task.get_name()` so the final usage could be like:

```python
job_names = [job.get_name() for job in scheduler._jobs]
```
### Output:
```
job_names
> ['my-task', 'another-task']
```

At the moment if I needed to get the task name I would have to use the following and I'd rather not access so many private variables if possible:

```python
task_names = [job._coro.cr_code.co_name for job in scheduler._jobs]
```

Discussion:
Dreamsorcerer: Also, tests failed on 3.7. So, we'll need an `if sys.version_info > (3, 7):` check.
zoicsoftware: Thank you for the quick feedback!

With the desire for it to be get_name() to match the asycio syntax, do you think we should add a set_name() function to the Job so that the name API fully matches that of Task? It would also allow users to change the name after creation. 
Dreamsorcerer: > With the desire for it to be get_name() to match the asycio syntax, do you think we should add a set_name() function to the Job so that the name API fully matches that of Task? It would also allow users to change the name after creation.

Yeah, could do.

Patch:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..f6caf33
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,13 @@
+version: 2
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3"
+
+sphinx:
+   configuration: docs/conf.py
+
+python:
+   install:
+   - requirements: requirements/doc.txt
diff --git a/aiojobs/_job.py b/aiojobs/_job.py
index 5c3928d..85ccbc6 100644
--- a/aiojobs/_job.py
+++ b/aiojobs/_job.py
@@ -14,9 +14,15 @@ _T = TypeVar("_T", covariant=True)
 
 
 class Job(Generic[_T]):
-    def __init__(self, coro: Coroutine[object, object, _T], scheduler: Scheduler):
+    def __init__(
+        self,
+        coro: Coroutine[object, object, _T],
+        scheduler: Scheduler,
+        name: Optional[str] = None,
+    ):
         self._coro = coro
         self._scheduler: Optional[Scheduler] = scheduler
+        self._name = name
         loop = asyncio.get_running_loop()
         self._started = loop.create_future()
 
@@ -50,6 +56,21 @@ class Job(Generic[_T]):
     def closed(self) -> bool:
         return self._closed
 
+    def get_name(self) -> Optional[str]:
+        """Get the task name.
+
+        See https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.get_name.
+        Returns None if no name was set on the Job object and job has not yet started.
+        """
+        if sys.version_info >= (3, 8) and self._task:
+            return self._task.get_name()
+        return self._name
+
+    def set_name(self, name: str) -> None:
+        self._name = name
+        if sys.version_info >= (3, 8) and self._task is not None:
+            self._task.set_name(name)
+
     async def _do_wait(self, timeout: Optional[float]) -> _T:
         async with async_timeout.timeout(timeout):
             # TODO: add a test for waiting for a pending coro
@@ -118,7 +139,10 @@ class Job(Generic[_T]):
 
     def _start(self) -> None:
         assert self._task is None
-        self._task = asyncio.create_task(self._coro)
+        if sys.version_info >= (3, 8):
+            self._task = asyncio.create_task(self._coro, name=self._name)
+        else:
+            self._task = asyncio.create_task(self._coro)
         self._task.add_done_callback(self._done_callback)
         self._started.set_result(None)
 
diff --git a/aiojobs/_scheduler.py b/aiojobs/_scheduler.py
index 43b7269..d7d08ce 100644
--- a/aiojobs/_scheduler.py
+++ b/aiojobs/_scheduler.py
@@ -85,10 +85,12 @@ class Scheduler(Collection[Job[object]]):
     def closed(self) -> bool:
         return self._closed
 
-    async def spawn(self, coro: Coroutine[object, object, _T]) -> Job[_T]:
+    async def spawn(
+        self, coro: Coroutine[object, object, _T], name: Optional[str] = None
+    ) -> Job[_T]:
         if self._closed:
             raise RuntimeError("Scheduling a new job after closing")
-        job = Job(coro, self)
+        job = Job(coro, self, name=name)
         should_start = self._limit is None or self.active_count < self._limit
         if should_start:
             job._start()
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 612b347..19b2abc 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -1,5 +1,5 @@
 -r ci.txt
 -r doc.txt
 mypy==0.991
-pre-commit==2.21.0
+pre-commit==3.0.1
 pytest-sugar==0.9.6


Test patch:
diff --git a/tests/test_job.py b/tests/test_job.py
index 73d4a10..7013c7a 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -1,4 +1,5 @@
 import asyncio
+import sys
 from contextlib import suppress
 from typing import Awaitable, Callable, NoReturn
 from unittest import mock
@@ -287,3 +288,39 @@ async def test_exception_handler_called_once(make_scheduler: _MakeScheduler) ->
     await scheduler.spawn(coro())
     await scheduler.close()
     handler.assert_called_once()
+
+
+async def test_get_job_name(scheduler: Scheduler) -> None:
+    async def coro() -> None:
+        """Dummy function."""
+
+    job = await scheduler.spawn(coro(), name="test_job_name")
+    assert job.get_name() == "test_job_name"
+    if sys.version_info >= (3, 8):
+        assert job._task is not None
+        assert job._task.get_name() == "test_job_name"
+
+
+async def test_get_default_job_name(scheduler: Scheduler) -> None:
+    async def coro() -> None:
+        """Dummy function."""
+
+    job = await scheduler.spawn(coro())
+    if sys.version_info >= (3, 8):
+        job_name = job.get_name()
+        assert job_name is not None
+        assert job_name.startswith("Task-")
+    else:
+        assert job.get_name() is None
+
+
+async def test_set_job_name(scheduler: Scheduler) -> None:
+    async def coro() -> None:
+        """Dummy function."""
+
+    job = await scheduler.spawn(coro(), name="original_name")
+    job.set_name("changed_name")
+    assert job.get_name() == "changed_name"
+    if sys.version_info >= (3, 8):
+        assert job._task is not None
+        assert job._task.get_name() == "changed_name"