Repository: python-trio/trio

Issue:
Drop `async_generator` dependency
The `async_generator` backport has served us well, but now that we've dropped Python 3.5 async generators exist, and having dropped Python 3.6 all the introspection functions we need are now in the standard library too.

I expect this to be a fairly small cleanup, but it's always nice to delete code and dependencies you don't need anymore 😄 


Discussion:
async_generator includes `aclosing()`.  While we've since added that to Python std library, it's only as of Python 3.10.  So unless we're going to drop Python < 3.10 from trio, it's necessary to keep using async_generator, or make a local copy of `aclosing()`.


https://async-generator.readthedocs.io/en/latest/reference.html#context-managers

oh, but I'm surprised to find that trio only uses `aclosing()` in some tests

perhaps it's enough to do something hacky, like disable relevant tests when running on Python < 3.10

https://github.com/python-trio/trio/blob/6754c74eacfad9cc5c92d5c24727a2f3b620624e/trio/_core/tests/test_asyncgen.py#L6
We can condition the dependency and import on Python version, which will let people on 3.10 (👋 ) or later avoid the dependency... it's a little fiddly, but I do think that avoiding unneeded dependencies is pretty important at the bottom of the stack.
since it's only used in tests, just skip the relevant tests on python < 3.10
I think this will break ki protection (at least) on all old code that uses `async_generator` regardless of python version, since `inspect.isasyncgenfunction` does not recognise async generators created with that library.
```python
@async_generator.async_generator
async def d():
    await async_generator.yield_()

async_generator.isasyncgenfunction(f) # true
inspect.isasyncgenfunction(f) # false
```
which is used in `trio/_core/_ki.py` line 144

I see a couple ways to handle this:
* keep the dependency for now, and print a warning (or raise an exception) if the output of the above functions differ
* drop the dependency, and inspect `sys.modules` for `async_generator`, in which case it gives warning/exception
* drop the dependency, but if `sys.modules` detects `async_generator` it imports it and checks if output of the above functions differ. If it does it gives warning (and continue working like previously) or raises exception.

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -81,7 +81,6 @@
     install_requires=[
         "attrs >= 19.2.0",  # for eq
         "sortedcontainers",
-        "async_generator >= 1.9",
         "idna",
         "outcome",
         "sniffio",
diff --git a/trio/_core/_ki.py b/trio/_core/_ki.py
--- a/trio/_core/_ki.py
+++ b/trio/_core/_ki.py
@@ -2,9 +2,8 @@
 import signal
 import sys
 from functools import wraps
-import attr
 
-import async_generator
+import attr
 
 from .._util import is_main_thread
 
@@ -109,6 +108,14 @@ def currently_ki_protected():
     return ki_protection_enabled(sys._getframe())
 
 
+# This is to support the async_generator package necessary for aclosing on <3.10
+# functions decorated @async_generator are given this magic property that's a
+# reference to the object itself
+# see python-trio/async_generator/async_generator/_impl.py
+def legacy_isasyncgenfunction(obj):
+    return getattr(obj, "_async_gen_function", None) == id(obj)
+
+
 def _ki_protection_decorator(enabled):
     def decorator(fn):
         # In some version of Python, isgeneratorfunction returns true for
@@ -141,7 +148,7 @@ def wrapper(*args, **kwargs):
                 return gen
 
             return wrapper
-        elif async_generator.isasyncgenfunction(fn):
+        elif inspect.isasyncgenfunction(fn) or legacy_isasyncgenfunction(fn):
 
             @wraps(fn)
             def wrapper(*args, **kwargs):
diff --git a/trio/_util.py b/trio/_util.py
--- a/trio/_util.py
+++ b/trio/_util.py
@@ -11,8 +11,7 @@
 import typing as t
 import threading
 import collections
-
-from async_generator import isasyncgen
+import inspect
 
 import trio
 
@@ -143,6 +142,7 @@ def _return_value_looks_like_wrong_library(value):
     # for things like functools.partial objects wrapping an async
     # function. So we have to just call it and then check whether the
     # return value is a coroutine object.
+    # Note: will not be necessary on python>=3.8, see https://bugs.python.org/issue34890
     if not isinstance(coro, collections.abc.Coroutine):
         # Give good error for: nursery.start_soon(func_returning_future)
         if _return_value_looks_like_wrong_library(coro):
@@ -152,7 +152,7 @@ def _return_value_looks_like_wrong_library(value):
                 "That won't work without some sort of compatibility shim.".format(coro)
             )
 
-        if isasyncgen(coro):
+        if inspect.isasyncgen(coro):
             raise TypeError(
                 "start_soon expected an async function but got an async "
                 "generator {!r}".format(coro)


Test patch:
diff --git a/trio/_core/tests/test_asyncgen.py b/trio/_core/tests/test_asyncgen.py
--- a/trio/_core/tests/test_asyncgen.py
+++ b/trio/_core/tests/test_asyncgen.py
@@ -1,13 +1,15 @@
 import sys
 import weakref
 import pytest
+import contextlib
 from math import inf
 from functools import partial
-from async_generator import aclosing
+
 from ... import _core
 from .tutil import gc_collect_harder, buggy_pypy_asyncgens, restore_unraisablehook
 
 
+@pytest.mark.skipif(sys.version_info < (3, 10), reason="no aclosing() in stdlib<3.10")
 def test_asyncgen_basics():
     collected = []
 
@@ -46,7 +48,7 @@ async def async_main():
         assert collected.pop() == "abandoned"
 
         # aclosing() ensures it's cleaned up at point of use
-        async with aclosing(example("exhausted 1")) as aiter:
+        async with contextlib.aclosing(example("exhausted 1")) as aiter:
             assert 42 == await aiter.asend(None)
         assert collected.pop() == "exhausted 1"
 
@@ -58,7 +60,7 @@ async def async_main():
         gc_collect_harder()
 
         # No problems saving the geniter when using either of these patterns
-        async with aclosing(example("exhausted 3")) as aiter:
+        async with contextlib.aclosing(example("exhausted 3")) as aiter:
             saved.append(aiter)
             assert 42 == await aiter.asend(None)
         assert collected.pop() == "exhausted 3"
diff --git a/trio/_core/tests/test_ki.py b/trio/_core/tests/test_ki.py
--- a/trio/_core/tests/test_ki.py
+++ b/trio/_core/tests/test_ki.py
@@ -6,13 +6,12 @@
 import threading
 import contextlib
 import time
+import inspect
 
-from async_generator import (
-    async_generator,
-    yield_,
-    isasyncgenfunction,
-    asynccontextmanager,
-)
+try:
+    from async_generator import yield_, async_generator
+except ImportError:  # pragma: no cover
+    async_generator = yield_ = None
 
 from ... import _core
 from ...testing import wait_all_tasks_blocked
@@ -142,7 +141,8 @@ def protected_manager():
             raise KeyError
 
 
-async def test_agen_protection():
+@pytest.mark.skipif(async_generator is None, reason="async_generator not installed")
+async def test_async_generator_agen_protection():
     @_core.enable_ki_protection
     @async_generator
     async def agen_protected1():
@@ -180,9 +180,16 @@ async def agen_unprotected2():
         finally:
             assert not _core.currently_ki_protected()
 
+    await _check_agen(agen_protected1)
+    await _check_agen(agen_protected2)
+    await _check_agen(agen_unprotected1)
+    await _check_agen(agen_unprotected2)
+
+
+async def test_native_agen_protection():
     # Native async generators
     @_core.enable_ki_protection
-    async def agen_protected3():
+    async def agen_protected():
         assert _core.currently_ki_protected()
         try:
             yield
@@ -190,35 +197,32 @@ async def agen_protected3():
             assert _core.currently_ki_protected()
 
     @_core.disable_ki_protection
-    async def agen_unprotected3():
+    async def agen_unprotected():
         assert not _core.currently_ki_protected()
         try:
             yield
         finally:
             assert not _core.currently_ki_protected()
 
-    for agen_fn in [
-        agen_protected1,
-        agen_protected2,
-        agen_protected3,
-        agen_unprotected1,
-        agen_unprotected2,
-        agen_unprotected3,
-    ]:
-        async for _ in agen_fn():  # noqa
+    await _check_agen(agen_protected)
+    await _check_agen(agen_unprotected)
+
+
+async def _check_agen(agen_fn):
+    async for _ in agen_fn():  # noqa
+        assert not _core.currently_ki_protected()
+
+    # asynccontextmanager insists that the function passed must itself be an
+    # async gen function, not a wrapper around one
+    if inspect.isasyncgenfunction(agen_fn):
+        async with contextlib.asynccontextmanager(agen_fn)():
             assert not _core.currently_ki_protected()
 
-        # asynccontextmanager insists that the function passed must itself be an
-        # async gen function, not a wrapper around one
-        if isasyncgenfunction(agen_fn):
-            async with asynccontextmanager(agen_fn)():
-                assert not _core.currently_ki_protected()
-
-            # Another case that's tricky due to:
-            #   https://bugs.python.org/issue29590
-            with pytest.raises(KeyError):
-                async with asynccontextmanager(agen_fn)():
-                    raise KeyError
+        # Another case that's tricky due to:
+        #   https://bugs.python.org/issue29590
+        with pytest.raises(KeyError):
+            async with contextlib.asynccontextmanager(agen_fn)():
+                raise KeyError
 
 
 # Test the case where there's no magic local anywhere in the call stack
diff --git a/trio/testing/_sequencer.py b/trio/testing/_sequencer.py
--- a/trio/testing/_sequencer.py
+++ b/trio/testing/_sequencer.py
@@ -1,7 +1,7 @@
 from collections import defaultdict
+from contextlib import asynccontextmanager
 
 import attr
-from async_generator import asynccontextmanager
 
 from .. import _core
 from .. import _util
diff --git a/trio/tests/test_dtls.py b/trio/tests/test_dtls.py
--- a/trio/tests/test_dtls.py
+++ b/trio/tests/test_dtls.py
@@ -4,7 +4,7 @@
 from trio import DTLSEndpoint
 import random
 import attr
-from async_generator import asynccontextmanager
+from contextlib import asynccontextmanager
 from itertools import count
 
 import trustme
diff --git a/trio/tests/test_ssl.py b/trio/tests/test_ssl.py
--- a/trio/tests/test_ssl.py
+++ b/trio/tests/test_ssl.py
@@ -7,12 +7,11 @@
 import threading
 import socket as stdlib_socket
 import ssl
-from contextlib import contextmanager
+from contextlib import asynccontextmanager, contextmanager
 from functools import partial
 
 from OpenSSL import SSL
 import trustme
-from async_generator import asynccontextmanager
 
 import trio
 from .. import _core
diff --git a/trio/tests/test_subprocess.py b/trio/tests/test_subprocess.py
--- a/trio/tests/test_subprocess.py
+++ b/trio/tests/test_subprocess.py
@@ -3,11 +3,11 @@
 import signal
 import subprocess
 import sys
+from contextlib import asynccontextmanager
 from functools import partial
 from pathlib import Path as SyncPath
 
 import pytest
-from async_generator import asynccontextmanager
 
 from .. import (
     ClosedResourceError,