From bca9939f29fba2fe3a55190d2cda66fd7b808ade Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Wed, 16 Jan 2019 11:20:57 +0000 Subject: [PATCH 1/6] inspect: add introspection API for asynchronous generators The functions inspect.getasyncgenstate and inspect.getasyncgenlocals allow to determine the current state of asynchronous generators and mirror the introspection API for generators and coroutines. --- Doc/library/inspect.rst | 28 +++- Lib/inspect.py | 50 +++++++ Lib/test/test_inspect.py | 132 ++++++++++++++++++ ...3-02-26-17-29-57.gh-issue-79940.SAfmAy.rst | 2 + Objects/genobject.c | 10 ++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-02-26-17-29-57.gh-issue-79940.SAfmAy.rst diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst index 9c3be5a250a67e..53b9b8bef961be 100644 --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -1439,8 +1439,8 @@ code execution:: pass -Current State of Generators and Coroutines ------------------------------------------- +Current State of Generators, Coroutines, and Asynchronous Generators +-------------------------------------------------------------------- When implementing coroutine schedulers and for other advanced uses of generators, it is useful to determine whether a generator is currently @@ -1475,6 +1475,22 @@ generator to be determined easily. .. versionadded:: 3.5 +.. function:: getasyncgenstate(agen) + + Get current state of an asynchronous generator object. The function is + intended to be used with asynchronous iterator objects created by + :keyword:`async def` functions which use the :keyword:`yield` statement, + but will accept any asynchronous generator-like object that has + ``ag_running`` and ``ag_frame`` attributes. + + Possible states are: + * AGEN_CREATED: Waiting to start execution. + * AGEN_RUNNING: Currently being executed by the interpreter. + * AGEN_SUSPENDED: Currently suspended at a yield expression. + * AGEN_CLOSED: Execution has completed. + + .. versionadded:: 3.12 + The current internal state of the generator can also be queried. This is mostly useful for testing purposes, to ensure that internal state is being updated as expected: @@ -1506,6 +1522,14 @@ updated as expected: .. versionadded:: 3.5 +.. function:: getasyncgenlocals(agen) + + This function is analogous to :func:`~inspect.getgeneratorlocals`, but + works for asynchronous generator objects created by :keyword:`async def` + functions which use the :keyword:`yield` statement. + + .. versionadded:: 3.12 + .. _inspect-module-co-flags: diff --git a/Lib/inspect.py b/Lib/inspect.py index 8bb3a375735af6..e0db0d1f8f81a3 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -34,6 +34,10 @@ 'Yury Selivanov ') __all__ = [ + "AGEN_CLOSED", + "AGEN_CREATED", + "AGEN_RUNNING", + "AGEN_SUSPENDED", "ArgInfo", "Arguments", "Attribute", @@ -77,6 +81,8 @@ "getabsfile", "getargs", "getargvalues", + "getasyncgenlocals", + "getasyncgenstate", "getattr_static", "getblock", "getcallargs", @@ -1935,6 +1941,50 @@ def getcoroutinelocals(coroutine): return {} +# ----------------------------------- asynchronous generator introspection + +AGEN_CREATED = 'AGEN_CREATED' +AGEN_RUNNING = 'AGEN_RUNNING' +AGEN_SUSPENDED = 'AGEN_SUSPENDED' +AGEN_CLOSED = 'AGEN_CLOSED' + + +def getasyncgenstate(agen): + """Get current state of an asynchronous generator object. + + Possible states are: + AGEN_CREATED: Waiting to start execution. + AGEN_RUNNING: Currently being executed by the interpreter. + AGEN_SUSPENDED: Currently suspended at a yield expression. + AGEN_CLOSED: Execution has completed. + """ + if agen.ag_running: + return AGEN_RUNNING + if agen.ag_suspended: + return AGEN_SUSPENDED + if agen.ag_frame is None: + return AGEN_CLOSED + return AGEN_CREATED + + +def getasyncgenlocals(agen): + """ + Get the mapping of asynchronous generator local variables to their current + values. + + A dict is returned, with the keys the local variable names and values the + bound values.""" + + if not isasyncgen(agen): + raise TypeError("{!r} is not a Python async generator".format(agen)) + + frame = getattr(agen, "ag_frame", None) + if frame is not None: + return agen.ag_frame.f_locals + else: + return {} + + ############################################################################### ### Function Signature Object (PEP 362) ############################################################################### diff --git a/Lib/test/test_inspect.py b/Lib/test/test_inspect.py index 92aba519d28a08..701056c8779870 100644 --- a/Lib/test/test_inspect.py +++ b/Lib/test/test_inspect.py @@ -2321,6 +2321,138 @@ async def func(a=None): {'a': None, 'gencoro': gencoro, 'b': 'spam'}) +class TestGetAsyncGenState(unittest.TestCase): + + def setUp(self): + async def number_asyncgen(): + for number in range(5): + yield number + self.asyncgen = number_asyncgen() + + def tearDown(self): + try: + self.asyncgen.aclose().send(None) + except StopIteration: + pass + + def _asyncgenstate(self): + return inspect.getasyncgenstate(self.asyncgen) + + def test_created(self): + self.assertEqual(self._asyncgenstate(), inspect.AGEN_CREATED) + + def test_suspended(self): + try: + next(self.asyncgen.__anext__()) + except StopIteration as exc: + self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) + self.assertEqual(exc.args, (0,)) + + def test_closed_after_exhaustion(self): + while True: + try: + next(self.asyncgen.__anext__()) + except StopAsyncIteration: + self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) + break + except StopIteration as exc: + if exc.args is None: + self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) + break + self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) + + def test_closed_after_immediate_exception(self): + with self.assertRaises(RuntimeError): + self.asyncgen.athrow(RuntimeError).send(None) + self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) + + def test_running(self): + async def running_check_asyncgen(): + for number in range(5): + self.assertEqual(self._asyncgenstate(), inspect.AGEN_RUNNING) + yield number + self.assertEqual(self._asyncgenstate(), inspect.AGEN_RUNNING) + self.asyncgen = running_check_asyncgen() + # Running up to the first yield + try: + next(self.asyncgen.__anext__()) + except StopIteration: + pass + # Running after the first yield + try: + next(self.asyncgen.__anext__()) + except StopIteration: + pass + + def test_easy_debugging(self): + # repr() and str() of a asyncgen state should contain the state name + names = 'AGEN_CREATED AGEN_RUNNING AGEN_SUSPENDED AGEN_CLOSED'.split() + for name in names: + state = getattr(inspect, name) + self.assertIn(name, repr(state)) + self.assertIn(name, str(state)) + + def test_getasyncgenlocals(self): + async def each(lst, a=None): + b=(1, 2, 3) + for v in lst: + if v == 3: + c = 12 + yield v + + numbers = each([1, 2, 3]) + self.assertEqual(inspect.getasyncgenlocals(numbers), + {'a': None, 'lst': [1, 2, 3]}) + try: + next(numbers.__anext__()) + except StopIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(numbers), + {'a': None, 'lst': [1, 2, 3], 'v': 1, + 'b': (1, 2, 3)}) + try: + next(numbers.__anext__()) + except StopIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(numbers), + {'a': None, 'lst': [1, 2, 3], 'v': 2, + 'b': (1, 2, 3)}) + try: + next(numbers.__anext__()) + except StopIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(numbers), + {'a': None, 'lst': [1, 2, 3], 'v': 3, + 'b': (1, 2, 3), 'c': 12}) + try: + next(numbers.__anext__()) + except StopAsyncIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(numbers), {}) + + def test_getasyncgenlocals_empty(self): + async def yield_one(): + yield 1 + one = yield_one() + self.assertEqual(inspect.getasyncgenlocals(one), {}) + try: + next(one.__anext__()) + except StopIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(one), {}) + try: + next(one.__anext__()) + except StopAsyncIteration: + pass + self.assertEqual(inspect.getasyncgenlocals(one), {}) + + def test_getasyncgenlocals_error(self): + self.assertRaises(TypeError, inspect.getasyncgenlocals, 1) + self.assertRaises(TypeError, inspect.getasyncgenlocals, lambda x: True) + self.assertRaises(TypeError, inspect.getasyncgenlocals, set) + self.assertRaises(TypeError, inspect.getasyncgenlocals, (2,3)) + + class MySignature(inspect.Signature): # Top-level to make it picklable; # used in test_signature_object_pickle diff --git a/Misc/NEWS.d/next/Library/2023-02-26-17-29-57.gh-issue-79940.SAfmAy.rst b/Misc/NEWS.d/next/Library/2023-02-26-17-29-57.gh-issue-79940.SAfmAy.rst new file mode 100644 index 00000000000000..31b8ead8433279 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-02-26-17-29-57.gh-issue-79940.SAfmAy.rst @@ -0,0 +1,2 @@ +Add :func:`inspect.getasyncgenstate` and :func:`inspect.getasyncgenlocals`. +Patch by Thomas Krennwallner. diff --git a/Objects/genobject.c b/Objects/genobject.c index 4ab6581e12ab3a..b9d8ed0569b683 100644 --- a/Objects/genobject.c +++ b/Objects/genobject.c @@ -1552,6 +1552,15 @@ ag_getcode(PyGenObject *gen, void *Py_UNUSED(ignored)) return _gen_getcode(gen, "ag__code"); } +static PyObject * +ag_getsuspended(PyAsyncGenObject *ag, void *Py_UNUSED(ignored)) +{ + if (ag->ag_frame_state == FRAME_SUSPENDED) { + Py_RETURN_TRUE; + } + Py_RETURN_FALSE; +} + static PyGetSetDef async_gen_getsetlist[] = { {"__name__", (getter)gen_get_name, (setter)gen_set_name, PyDoc_STR("name of the async generator")}, @@ -1561,6 +1570,7 @@ static PyGetSetDef async_gen_getsetlist[] = { PyDoc_STR("object being awaited on, or None")}, {"ag_frame", (getter)ag_getframe, NULL, NULL}, {"ag_code", (getter)ag_getcode, NULL, NULL}, + {"ag_suspended", (getter)ag_getsuspended, NULL, NULL}, {NULL} /* Sentinel */ }; From 093595abf2505d0ea57cc99a38bec7be80b44579 Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Fri, 10 Mar 2023 10:54:33 -0500 Subject: [PATCH 2/6] inspect: use f-string in getasyncgenlocals() --- Lib/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/inspect.py b/Lib/inspect.py index e0db0d1f8f81a3..c89778178c8935 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -1976,7 +1976,7 @@ def getasyncgenlocals(agen): bound values.""" if not isasyncgen(agen): - raise TypeError("{!r} is not a Python async generator".format(agen)) + raise TypeError(f"{agen!r} is not a Python async generator") frame = getattr(agen, "ag_frame", None) if frame is not None: From 1931c0b24d9809cbc91bcd8b6b173cee5d29b93c Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Fri, 10 Mar 2023 10:55:14 -0500 Subject: [PATCH 3/6] inspect: use anext() built-in in TestGetAsyncGenState --- Lib/test/test_inspect.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_inspect.py b/Lib/test/test_inspect.py index 701056c8779870..e3c8e53ff1d9cd 100644 --- a/Lib/test/test_inspect.py +++ b/Lib/test/test_inspect.py @@ -2343,7 +2343,7 @@ def test_created(self): def test_suspended(self): try: - next(self.asyncgen.__anext__()) + next(anext(self.asyncgen)) except StopIteration as exc: self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) self.assertEqual(exc.args, (0,)) @@ -2351,7 +2351,7 @@ def test_suspended(self): def test_closed_after_exhaustion(self): while True: try: - next(self.asyncgen.__anext__()) + next(anext(self.asyncgen)) except StopAsyncIteration: self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) break @@ -2375,12 +2375,12 @@ async def running_check_asyncgen(): self.asyncgen = running_check_asyncgen() # Running up to the first yield try: - next(self.asyncgen.__anext__()) + next(anext(self.asyncgen)) except StopIteration: pass # Running after the first yield try: - next(self.asyncgen.__anext__()) + next(anext(self.asyncgen)) except StopIteration: pass @@ -2404,28 +2404,28 @@ async def each(lst, a=None): self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3]}) try: - next(numbers.__anext__()) + next(anext(numbers)) except StopIteration: pass self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 1, 'b': (1, 2, 3)}) try: - next(numbers.__anext__()) + next(anext(numbers)) except StopIteration: pass self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 2, 'b': (1, 2, 3)}) try: - next(numbers.__anext__()) + next(anext(numbers)) except StopIteration: pass self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 3, 'b': (1, 2, 3), 'c': 12}) try: - next(numbers.__anext__()) + next(anext(numbers)) except StopAsyncIteration: pass self.assertEqual(inspect.getasyncgenlocals(numbers), {}) @@ -2436,12 +2436,12 @@ async def yield_one(): one = yield_one() self.assertEqual(inspect.getasyncgenlocals(one), {}) try: - next(one.__anext__()) + next(anext(one)) except StopIteration: pass self.assertEqual(inspect.getasyncgenlocals(one), {}) try: - next(one.__anext__()) + next(anext(one)) except StopAsyncIteration: pass self.assertEqual(inspect.getasyncgenlocals(one), {}) From 771f95e4e3fb97af1ebd714b0cc4f12b6af85c78 Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Sat, 11 Mar 2023 06:08:11 -0500 Subject: [PATCH 4/6] whatsnew: add an entry for inspect --- Doc/whatsnew/3.12.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Doc/whatsnew/3.12.rst b/Doc/whatsnew/3.12.rst index e551c5b4fd06a9..4b6dd309c5095a 100644 --- a/Doc/whatsnew/3.12.rst +++ b/Doc/whatsnew/3.12.rst @@ -242,6 +242,10 @@ inspect a :term:`coroutine` for use with :func:`iscoroutinefunction`. (Contributed Carlton Gibson in :gh:`99247`.) +* Add :func:`inspect.getasyncgenstate` and :func:`inspect.getasyncgenlocals` + for determining the current state of asynchronous generators. + (Contributed by Thomas Krennwallner in :gh:`11590`.) + pathlib ------- From ac46ef8d11e8b9bded29dfa23a40d8701492907c Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Sat, 11 Mar 2023 07:02:50 -0500 Subject: [PATCH 5/6] inspect: use IsolatedAsyncioTestCase for TestGetAsyncGenState --- Lib/test/test_inspect.py | 97 +++++++++++++++------------------------- 1 file changed, 36 insertions(+), 61 deletions(-) diff --git a/Lib/test/test_inspect.py b/Lib/test/test_inspect.py index e3c8e53ff1d9cd..cc2286edd8c4d6 100644 --- a/Lib/test/test_inspect.py +++ b/Lib/test/test_inspect.py @@ -1,3 +1,4 @@ +import asyncio import builtins import collections import datetime @@ -65,6 +66,10 @@ def revise(filename, *args): git = mod.StupidGit() +def tearDownModule(): + asyncio.set_event_loop_policy(None) + + def signatures_with_lexicographic_keyword_only_parameters(): """ Yields a whole bunch of functions with only keyword-only parameters, @@ -2321,7 +2326,7 @@ async def func(a=None): {'a': None, 'gencoro': gencoro, 'b': 'spam'}) -class TestGetAsyncGenState(unittest.TestCase): +class TestGetAsyncGenState(unittest.IsolatedAsyncioTestCase): def setUp(self): async def number_asyncgen(): @@ -2329,11 +2334,8 @@ async def number_asyncgen(): yield number self.asyncgen = number_asyncgen() - def tearDown(self): - try: - self.asyncgen.aclose().send(None) - except StopIteration: - pass + async def asyncTearDown(self): + await self.asyncgen.aclose() def _asyncgenstate(self): return inspect.getasyncgenstate(self.asyncgen) @@ -2341,32 +2343,25 @@ def _asyncgenstate(self): def test_created(self): self.assertEqual(self._asyncgenstate(), inspect.AGEN_CREATED) - def test_suspended(self): - try: - next(anext(self.asyncgen)) - except StopIteration as exc: - self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) - self.assertEqual(exc.args, (0,)) - - def test_closed_after_exhaustion(self): - while True: - try: - next(anext(self.asyncgen)) - except StopAsyncIteration: - self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) - break - except StopIteration as exc: - if exc.args is None: - self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) - break + async def test_suspended(self): + value = await anext(self.asyncgen) + self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) + self.assertEqual(value, 0) + + async def test_closed_after_exhaustion(self): + countdown = 7 + with self.assertRaises(StopAsyncIteration): + while countdown := countdown - 1: + await anext(self.asyncgen) + self.assertEqual(countdown, 1) self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) - def test_closed_after_immediate_exception(self): + async def test_closed_after_immediate_exception(self): with self.assertRaises(RuntimeError): - self.asyncgen.athrow(RuntimeError).send(None) + await self.asyncgen.athrow(RuntimeError) self.assertEqual(self._asyncgenstate(), inspect.AGEN_CLOSED) - def test_running(self): + async def test_running(self): async def running_check_asyncgen(): for number in range(5): self.assertEqual(self._asyncgenstate(), inspect.AGEN_RUNNING) @@ -2374,15 +2369,11 @@ async def running_check_asyncgen(): self.assertEqual(self._asyncgenstate(), inspect.AGEN_RUNNING) self.asyncgen = running_check_asyncgen() # Running up to the first yield - try: - next(anext(self.asyncgen)) - except StopIteration: - pass + await anext(self.asyncgen) + self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) # Running after the first yield - try: - next(anext(self.asyncgen)) - except StopIteration: - pass + await anext(self.asyncgen) + self.assertEqual(self._asyncgenstate(), inspect.AGEN_SUSPENDED) def test_easy_debugging(self): # repr() and str() of a asyncgen state should contain the state name @@ -2392,7 +2383,7 @@ def test_easy_debugging(self): self.assertIn(name, repr(state)) self.assertIn(name, str(state)) - def test_getasyncgenlocals(self): + async def test_getasyncgenlocals(self): async def each(lst, a=None): b=(1, 2, 3) for v in lst: @@ -2403,47 +2394,31 @@ async def each(lst, a=None): numbers = each([1, 2, 3]) self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3]}) - try: - next(anext(numbers)) - except StopIteration: - pass + await anext(numbers) self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 1, 'b': (1, 2, 3)}) - try: - next(anext(numbers)) - except StopIteration: - pass + await anext(numbers) self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 2, 'b': (1, 2, 3)}) - try: - next(anext(numbers)) - except StopIteration: - pass + await anext(numbers) self.assertEqual(inspect.getasyncgenlocals(numbers), {'a': None, 'lst': [1, 2, 3], 'v': 3, 'b': (1, 2, 3), 'c': 12}) - try: - next(anext(numbers)) - except StopAsyncIteration: - pass + with self.assertRaises(StopAsyncIteration): + await anext(numbers) self.assertEqual(inspect.getasyncgenlocals(numbers), {}) - def test_getasyncgenlocals_empty(self): + async def test_getasyncgenlocals_empty(self): async def yield_one(): yield 1 one = yield_one() self.assertEqual(inspect.getasyncgenlocals(one), {}) - try: - next(anext(one)) - except StopIteration: - pass + await anext(one) self.assertEqual(inspect.getasyncgenlocals(one), {}) - try: - next(anext(one)) - except StopAsyncIteration: - pass + with self.assertRaises(StopAsyncIteration): + await anext(one) self.assertEqual(inspect.getasyncgenlocals(one), {}) def test_getasyncgenlocals_error(self): From 5302f2374b11254ae84a1892cdd50bf666b02c91 Mon Sep 17 00:00:00 2001 From: Thomas Krennwallner Date: Sat, 11 Mar 2023 07:44:16 -0500 Subject: [PATCH 6/6] whatsnew: use bpo issue instead of github id --- Doc/whatsnew/3.12.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.12.rst b/Doc/whatsnew/3.12.rst index 4b6dd309c5095a..01bd73a82a6b25 100644 --- a/Doc/whatsnew/3.12.rst +++ b/Doc/whatsnew/3.12.rst @@ -244,7 +244,7 @@ inspect * Add :func:`inspect.getasyncgenstate` and :func:`inspect.getasyncgenlocals` for determining the current state of asynchronous generators. - (Contributed by Thomas Krennwallner in :gh:`11590`.) + (Contributed by Thomas Krennwallner in :issue:`35759`.) pathlib -------