From 1904b8b6f14a68cd90c18e5abbbce8cadcdd2e55 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Tue, 11 Jul 2023 04:59:01 -0600 Subject: [PATCH 1/3] Clarify what counts as 'running' --- .github/workflows/ci.yml | 6 +-- docs/source/index.rst | 78 +++++++++++++++++++++--------------- newsfragments/39.feature.rst | 13 ++++++ sniffio/_impl.py | 25 ++++++++++-- 4 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 newsfragments/39.feature.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 518b8c3..e6e6498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Checkout @@ -34,7 +34,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12-dev'] check_formatting: ['0'] extra_name: [''] include: @@ -70,7 +70,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/docs/source/index.rst b/docs/source/index.rst index 93227db..e06a044 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -58,48 +58,60 @@ avoid collisions, this should match your library's PEP 503 normalized name on Py .. data:: thread_local.name -Make sure that whenever your library is calling a coroutine ``throw()``, ``send()``, or ``close()`` -that this is set to your identifier string. In most cases, this will be as simple as: +Make sure that whenever your library is potentially executing user-provided code, +this is set to your identifier string. In many cases, you can set it once when +your library starts up and restore it on shutdown: .. code-block:: python3 - from sniffio import thread_local - - # Your library's step function - def step(...): - old_name, thread_local.name = thread_local.name, "my-library's-PyPI-name" - try: - result = coro.send(None) - finally: - thread_local.name = old_name + from sniffio import thread_local as sniffio_loop + + # Your library's run function (like trio.run() or asyncio.run()) + def run(...): + old_name, sniffio_loop.name = sniffio_loop.name, "my-library's-PyPI-name" + try: + # actual event loop implementation left as an exercise to the reader + finally: + sniffio_loop.name = old_name + +In unusual situations you may need to be more fine-grained about it: + +* If you're using something akin to Trio `guest mode + `__ + to permit running your library on top of another event loop, then + you'll want to make sure that :func:`current_async_library` can + correctly identify which library (host or guest) is running at any + given moment. To achieve this, you should set and restore + :data:`thread_local.name` around each "tick" of your library's logic + (the part that is invoked as a callback from the host loop), rather + than around an entire ``run()`` function. + +* If you're using something akin to `trio-asyncio + `__ to implement one async + library on top of another, then you can set and restore :data:`thread_local.name` + around each task step (call to a coroutine object ``send()``, ``throw()``, or + ``close()`` method) into the 'inner' library. For example, trio-asyncio does + something like: + + .. code-block:: python3 + + from sniffio import thread_local as sniffio_loop + + # Your library's compatibility loop + async def main_loop(self, ...) -> None: + ... + handle: asyncio.Handle = await self.get_next_handle() + old_name, sniffio_loop.name = sniffio_loop.name, "asyncio" + try: + result = handle._callback(obj._args) + finally: + sniffio_loop.name = old_name **Step 3:** Send us a PR to add your library to the list of supported libraries above. That's it! -There are libraries that directly drive a sniffio-naive coroutine from another, -outer sniffio-aware coroutine such as `trio_asyncio`. -These libraries should make sure to set the correct value -while calling a synchronous function that will go on to drive the -sniffio-naive coroutine. - - -.. code-block:: python3 - - from sniffio import thread_local - - # Your library's compatibility loop - async def main_loop(self, ...) -> None: - ... - handle: asyncio.Handle = await self.get_next_handle() - old_name, thread_local.name = thread_local.name, "asyncio" - try: - result = handle._callback(obj._args) - finally: - thread_local.name = old_name - - .. toctree:: :maxdepth: 1 diff --git a/newsfragments/39.feature.rst b/newsfragments/39.feature.rst new file mode 100644 index 0000000..b541b90 --- /dev/null +++ b/newsfragments/39.feature.rst @@ -0,0 +1,13 @@ +sniffio now attempts to return the expected library name when +:func:`sniffio.current_async_library` is called from code that is +associated with an async library but is not part of an async task. +This includes asyncio ``call_soon()`` and ``call_later()`` callbacks, and +Trio instrumentation and ``abort_fn`` handlers. (Previously, sniffio's +behavior in these situations was inconsistent.) The sniffio +documentation now explains more precisely which async library counts +as "currently running" in ambiguous cases. Libraries other than +asyncio may need updates to their sniffio integration in order to +fully conform to the new semantics; the documentation includes an +updated recipe. The new semantics also reduce the number of situations +where updates to sniffio's internals are required, which should modestly +improve the performance of libraries that interoperate with sniffio. diff --git a/sniffio/_impl.py b/sniffio/_impl.py index c1a7bbf..60c29c5 100644 --- a/sniffio/_impl.py +++ b/sniffio/_impl.py @@ -37,6 +37,23 @@ def current_async_library() -> str: depending on current mode ================ =========== ============================ + If :func:`current_async_library` returns ``"someio"``, then that + generally means you can ``await someio.sleep(0)`` if you're in an + async function, and you can access ``someio``\\'s global state (to + start background tasks, determine the current time, etc) even if you're + not in an async function. + + .. note:: Situations such as `guest mode + `__ + and `trio-asyncio `__ + can result in more than one async library being "running" in the same + thread at the same time. In such ambiguous cases, `sniffio` + returns the name of the library that has most directly invoked its + caller. Within an async task, if :func:`current_async_library` + returns ``"someio"`` then that means you can ``await someio.sleep(0)``. + Outside of a task, you will get ``"asyncio"`` in asyncio callbacks, + ``"trio"`` in trio instruments and abort handlers, etc. + Returns: A string like ``"trio"``. @@ -75,11 +92,13 @@ async def generic_sleep(seconds): if "asyncio" in sys.modules: import asyncio try: - current_task = asyncio.current_task # type: ignore[attr-defined] + test = asyncio._get_running_loop # type: ignore[attr-defined] except AttributeError: - current_task = asyncio.Task.current_task # type: ignore[attr-defined] + # 3.6 doesn't have _get_running_loop, so we can only detect + # asyncio if we're inside a task (as opposed to a callback) + test = asyncio.Task.current_task # type: ignore[attr-defined] try: - if current_task() is not None: + if test() is not None: return "asyncio" except RuntimeError: pass From 4e42363cd04f2a4a9bf9d9c850795e748f2ed401 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Tue, 11 Jul 2023 05:20:49 -0600 Subject: [PATCH 2/3] Update tests, fix CI --- sniffio/_impl.py | 6 ++++-- sniffio/_tests/test_sniffio.py | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/sniffio/_impl.py b/sniffio/_impl.py index 60c29c5..5b41b12 100644 --- a/sniffio/_impl.py +++ b/sniffio/_impl.py @@ -1,5 +1,5 @@ from contextvars import ContextVar -from typing import Optional +from typing import Callable, Optional import sys import threading @@ -92,7 +92,9 @@ async def generic_sleep(seconds): if "asyncio" in sys.modules: import asyncio try: - test = asyncio._get_running_loop # type: ignore[attr-defined] + test: Callable[ + [], object + ] = asyncio._get_running_loop # type: ignore[attr-defined] except AttributeError: # 3.6 doesn't have _get_running_loop, so we can only detect # asyncio if we're inside a task (as opposed to a callback) diff --git a/sniffio/_tests/test_sniffio.py b/sniffio/_tests/test_sniffio.py index 984c8c0..7a361e7 100644 --- a/sniffio/_tests/test_sniffio.py +++ b/sniffio/_tests/test_sniffio.py @@ -45,23 +45,28 @@ def test_asyncio(): ran = [] - async def this_is_asyncio(): + def test_from_callback(): assert current_async_library() == "asyncio" - # Call it a second time to exercise the caching logic + ran.append(2) + + async def this_is_asyncio(): + asyncio.get_running_loop().call_soon(test_from_callback) assert current_async_library() == "asyncio" - ran.append(True) + ran.append(1) asyncio.run(this_is_asyncio()) - assert ran == [True] + assert ran == [1, 2] with pytest.raises(AsyncLibraryNotFoundError): current_async_library() -# https://github.com/dabeaz/curio/pull/354 +# https://github.com/dabeaz/curio/pull/354 has the Windows/3.9 fix. +# 3.12 error is from importing a private name that no longer exists in the +# multiprocessing module; unclear if it's going to be fixed or not. @pytest.mark.skipif( - os.name == "nt" and sys.version_info >= (3, 9), - reason="Curio breaks on Python 3.9+ on Windows. Fix was not released yet", + (os.name == "nt" and sys.version_info >= (3, 9)) or sys.version_info >= (3, 12), + reason="Curio breaks on Python 3.9+ on Windows and 3.12+ everywhere", ) def test_curio(): import curio @@ -72,8 +77,6 @@ def test_curio(): ran = [] async def this_is_curio(): - assert current_async_library() == "curio" - # Call it a second time to exercise the caching logic assert current_async_library() == "curio" ran.append(True) From 852561d15c088403bd187d821837772c67d5d5a8 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Tue, 11 Jul 2023 05:28:29 -0600 Subject: [PATCH 3/3] tweak docs --- docs/source/index.rst | 27 ++++++++++++++------------- sniffio/_impl.py | 15 ++------------- sniffio/_tests/test_sniffio.py | 3 ++- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index e06a044..9351307 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -54,9 +54,10 @@ easy. **Step 1:** Pick the magic string that will identify your library. To avoid collisions, this should match your library's PEP 503 normalized name on PyPI. -**Step 2:** There's a special :class:`threading.local` object: +**Step 2:** There's a special :class:`threading.local` object attribute that +sniffio consults to determine the currently running library: -.. data:: thread_local.name +.. data:: sniffio.thread_local.name Make sure that whenever your library is potentially executing user-provided code, this is set to your identifier string. In many cases, you can set it once when @@ -64,15 +65,15 @@ your library starts up and restore it on shutdown: .. code-block:: python3 - from sniffio import thread_local as sniffio_loop + from sniffio import thread_local as sniffio_library # Your library's run function (like trio.run() or asyncio.run()) def run(...): - old_name, sniffio_loop.name = sniffio_loop.name, "my-library's-PyPI-name" + old_name, sniffio_library.name = sniffio_library.name, "my-library's-PyPI-name" try: # actual event loop implementation left as an exercise to the reader finally: - sniffio_loop.name = old_name + sniffio_library.name = old_name In unusual situations you may need to be more fine-grained about it: @@ -87,25 +88,25 @@ In unusual situations you may need to be more fine-grained about it: than around an entire ``run()`` function. * If you're using something akin to `trio-asyncio - `__ to implement one async - library on top of another, then you can set and restore :data:`thread_local.name` - around each task step (call to a coroutine object ``send()``, ``throw()``, or - ``close()`` method) into the 'inner' library. For example, trio-asyncio does - something like: + `__ to implement one + async library on top of another, then you can set and restore + :data:`thread_local.name` around each synchronous call that might + execute user code on behalf of the 'inner' library. + For example, trio-asyncio does something like: .. code-block:: python3 - from sniffio import thread_local as sniffio_loop + from sniffio import thread_local as sniffio_library # Your library's compatibility loop async def main_loop(self, ...) -> None: ... handle: asyncio.Handle = await self.get_next_handle() - old_name, sniffio_loop.name = sniffio_loop.name, "asyncio" + old_name, sniffio_library.name = sniffio_library.name, "asyncio" try: result = handle._callback(obj._args) finally: - sniffio_loop.name = old_name + sniffio_library.name = old_name **Step 3:** Send us a PR to add your library to the list of supported libraries above. diff --git a/sniffio/_impl.py b/sniffio/_impl.py index 5b41b12..729d63f 100644 --- a/sniffio/_impl.py +++ b/sniffio/_impl.py @@ -91,19 +91,8 @@ async def generic_sleep(seconds): # Need to sniff for asyncio if "asyncio" in sys.modules: import asyncio - try: - test: Callable[ - [], object - ] = asyncio._get_running_loop # type: ignore[attr-defined] - except AttributeError: - # 3.6 doesn't have _get_running_loop, so we can only detect - # asyncio if we're inside a task (as opposed to a callback) - test = asyncio.Task.current_task # type: ignore[attr-defined] - try: - if test() is not None: - return "asyncio" - except RuntimeError: - pass + if asyncio._get_running_loop() is not None: + return "asyncio" # Sniff for curio (for now) if 'curio' in sys.modules: diff --git a/sniffio/_tests/test_sniffio.py b/sniffio/_tests/test_sniffio.py index 7a361e7..f8669e4 100644 --- a/sniffio/_tests/test_sniffio.py +++ b/sniffio/_tests/test_sniffio.py @@ -65,7 +65,8 @@ async def this_is_asyncio(): # 3.12 error is from importing a private name that no longer exists in the # multiprocessing module; unclear if it's going to be fixed or not. @pytest.mark.skipif( - (os.name == "nt" and sys.version_info >= (3, 9)) or sys.version_info >= (3, 12), + (os.name == "nt" and sys.version_info >= (3, 9)) + or sys.version_info >= (3, 12), reason="Curio breaks on Python 3.9+ on Windows and 3.12+ everywhere", ) def test_curio():