Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-asgiref for openSUSE:Factory checked in at 2025-07-20 15:28:36 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-asgiref (Old) and /work/SRC/openSUSE:Factory/.python-asgiref.new.8875 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-asgiref" Sun Jul 20 15:28:36 2025 rev:12 rq:1294058 version:3.9.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-asgiref/python-asgiref.changes 2024-04-09 16:46:35.802677633 +0200 +++ /work/SRC/openSUSE:Factory/.python-asgiref.new.8875/python-asgiref.changes 2025-07-20 15:29:12.892424113 +0200 @@ -1,0 +2,19 @@ +Thu Jul 17 08:40:37 UTC 2025 - Dirk Müller <dmuel...@suse.com> + +- update to 3.9.1: + * Adds support for Python 3.13. + * Drops support for (end-of-life) Python 3.8. + * Fixes an error with conflicting kwargs between AsyncToSync + and the wrapped function. (#471) + * Fixes Local isolation between asyncio Tasks. (#478) + * Fixes a reference cycle in Local (#508) + * Fixes a deadlock in CurrentThreadExecutor with nested + async_to_sync → + sync_to_async → async_to_sync → create_task calls. (#494) + * The ApplicationCommunicator testing utility will now return the + task result if it's already completed on send_input and + receive_nothing. You may need to catch (e.g.) the + asyncio.exceptions.CancelledError if sending messages to + already finished consumers in your tests. (#505) + +------------------------------------------------------------------- Old: ---- asgiref-3.8.1.tar.gz New: ---- asgiref-3.9.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-asgiref.spec ++++++ --- /var/tmp/diff_new_pack.oRuEm8/_old 2025-07-20 15:29:14.336483870 +0200 +++ /var/tmp/diff_new_pack.oRuEm8/_new 2025-07-20 15:29:14.336483870 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-asgiref # -# Copyright (c) 2024 SUSE LLC +# Copyright (c) 2025 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-asgiref -Version: 3.8.1 +Version: 3.9.1 Release: 0 Summary: ASGI specs, helper code, and adapters License: BSD-3-Clause ++++++ asgiref-3.8.1.tar.gz -> asgiref-3.9.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/PKG-INFO new/asgiref-3.9.1/PKG-INFO --- old/asgiref-3.8.1/PKG-INFO 2024-03-22 15:39:12.874578500 +0100 +++ new/asgiref-3.9.1/PKG-INFO 2025-07-08 11:07:34.391446800 +0200 @@ -1,6 +1,6 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.4 Name: asgiref -Version: 3.8.1 +Version: 3.9.1 Summary: ASGI specs, helper code, and adapters Home-page: https://github.com/django/asgiref/ Author: Django Software Foundation @@ -17,19 +17,20 @@ Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Internet :: WWW/HTTP -Requires-Python: >=3.8 +Requires-Python: >=3.9 License-File: LICENSE Requires-Dist: typing_extensions>=4; python_version < "3.11" Provides-Extra: tests Requires-Dist: pytest; extra == "tests" Requires-Dist: pytest-asyncio; extra == "tests" -Requires-Dist: mypy>=0.800; extra == "tests" +Requires-Dist: mypy>=1.14.0; extra == "tests" +Dynamic: license-file asgiref ======= @@ -129,7 +130,7 @@ Dependencies ------------ -``asgiref`` requires Python 3.8 or higher. +``asgiref`` requires Python 3.9 or higher. Contributing @@ -179,7 +180,7 @@ python -m build twine upload dist/* - rm -r build/ dist/ + rm -r asgiref.egg-info dist Implementation Details diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/README.rst new/asgiref-3.9.1/README.rst --- old/asgiref-3.8.1/README.rst 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/README.rst 2024-12-07 09:15:18.000000000 +0100 @@ -96,7 +96,7 @@ Dependencies ------------ -``asgiref`` requires Python 3.8 or higher. +``asgiref`` requires Python 3.9 or higher. Contributing @@ -146,7 +146,7 @@ python -m build twine upload dist/* - rm -r build/ dist/ + rm -r asgiref.egg-info dist Implementation Details diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/__init__.py new/asgiref-3.9.1/asgiref/__init__.py --- old/asgiref-3.8.1/asgiref/__init__.py 2024-03-22 15:26:51.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/__init__.py 2025-07-08 11:03:55.000000000 +0200 @@ -1 +1 @@ -__version__ = "3.8.1" +__version__ = "3.9.1" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/current_thread_executor.py new/asgiref-3.9.1/asgiref/current_thread_executor.py --- old/asgiref-3.8.1/asgiref/current_thread_executor.py 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/current_thread_executor.py 2025-07-03 11:26:09.000000000 +0200 @@ -1,8 +1,8 @@ -import queue import sys import threading +from collections import deque from concurrent.futures import Executor, Future -from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union +from typing import Any, Callable, TypeVar if sys.version_info >= (3, 10): from typing import ParamSpec @@ -53,10 +53,12 @@ the thread they came from. """ - def __init__(self) -> None: + def __init__(self, old_executor: "CurrentThreadExecutor | None") -> None: self._work_thread = threading.current_thread() - self._work_queue: queue.Queue[Union[_WorkItem, "Future[Any]"]] = queue.Queue() - self._broken = False + self._work_ready = threading.Condition(threading.Lock()) + self._work_items = deque[_WorkItem]() # synchronized by _work_ready + self._broken = False # synchronized by _work_ready + self._old_executor = old_executor def run_until_future(self, future: "Future[Any]") -> None: """ @@ -68,24 +70,30 @@ raise RuntimeError( "You cannot run CurrentThreadExecutor from a different thread" ) - future.add_done_callback(self._work_queue.put) - # Keep getting and running work items until we get the future we're waiting for - # back via the future's done callback. - try: - while True: + + def done(future: "Future[Any]") -> None: + with self._work_ready: + self._broken = True + self._work_ready.notify() + + future.add_done_callback(done) + # Keep getting and running work items until the future we're waiting for + # is done and the queue is empty. + while True: + with self._work_ready: + while not self._work_items and not self._broken: + self._work_ready.wait() + if not self._work_items: + break # Get a work item and run it - work_item = self._work_queue.get() - if work_item is future: - return - assert isinstance(work_item, _WorkItem) - work_item.run() - del work_item - finally: - self._broken = True + work_item = self._work_items.popleft() + work_item.run() + del work_item - def _submit( + def submit( self, fn: Callable[_P, _R], + /, *args: _P.args, **kwargs: _P.kwargs, ) -> "Future[_R]": @@ -94,22 +102,22 @@ raise RuntimeError( "You cannot submit onto CurrentThreadExecutor from its own thread" ) - # Check they're not too late or the executor errored - if self._broken: - raise RuntimeError("CurrentThreadExecutor already quit or is broken") - # Add to work queue f: "Future[_R]" = Future() work_item = _WorkItem(f, fn, *args, **kwargs) - self._work_queue.put(work_item) - # Return the future - return f - # Python 3.9+ has a new signature for submit with a "/" after `fn`, to enforce - # it to be a positional argument. If we ignore[override] mypy on 3.9+ will be - # happy but 3.8 will say that the ignore comment is unused, even when - # defining them differently based on sys.version_info. - # We should be able to remove this when we drop support for 3.8. - if not TYPE_CHECKING: + # Walk up the CurrentThreadExecutor stack to find the closest one still + # running + executor = self + while True: + with executor._work_ready: + if not executor._broken: + # Add to work queue + executor._work_items.append(work_item) + executor._work_ready.notify() + break + if executor._old_executor is None: + raise RuntimeError("CurrentThreadExecutor already quit or is broken") + executor = executor._old_executor - def submit(self, fn, *args, **kwargs): - return self._submit(fn, *args, **kwargs) + # Return the future + return f diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/local.py new/asgiref-3.9.1/asgiref/local.py --- old/asgiref-3.8.1/asgiref/local.py 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/local.py 2025-07-08 11:03:38.000000000 +0200 @@ -24,12 +24,12 @@ if key == "_data": return super().__setattr__(key, value) - storage_object = self._data.get({}) + storage_object = self._data.get({}).copy() storage_object[key] = value self._data.set(storage_object) def __delattr__(self, key: str) -> None: - storage_object = self._data.get({}) + storage_object = self._data.get({}).copy() if key in storage_object: del storage_object[key] self._data.set(storage_object) @@ -82,12 +82,15 @@ def _lock_storage(self): # Thread safe access to storage if self._thread_critical: + is_async = True try: # this is a test for are we in a async or sync # thread - will raise RuntimeError if there is # no current loop asyncio.get_running_loop() except RuntimeError: + is_async = False + if not is_async: # We are in a sync thread, the storage is # just the plain thread local (i.e, "global within # this thread" - it doesn't matter where you are diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/server.py new/asgiref-3.9.1/asgiref/server.py --- old/asgiref-3.8.1/asgiref/server.py 2024-03-14 15:03:34.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/server.py 2025-07-03 11:24:24.000000000 +0200 @@ -57,12 +57,28 @@ Runs the asyncio event loop with our handler loop. """ event_loop = asyncio.get_event_loop() - asyncio.ensure_future(self.application_checker()) try: - event_loop.run_until_complete(self.handle()) + event_loop.run_until_complete(self.arun()) except KeyboardInterrupt: logger.info("Exiting due to Ctrl-C/interrupt") + async def arun(self): + """ + Runs the asyncio event loop with our handler loop. + """ + + class Done(Exception): + pass + + async def handle(): + await self.handle() + raise Done + + try: + await asyncio.gather(self.application_checker(), handle()) + except Done: + pass + async def handle(self): raise NotImplementedError("You must implement handle()") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/sync.py new/asgiref-3.9.1/asgiref/sync.py --- old/asgiref-3.8.1/asgiref/sync.py 2024-03-22 15:15:14.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/sync.py 2025-07-03 11:25:24.000000000 +0200 @@ -179,15 +179,14 @@ # You can't call AsyncToSync from a thread with a running event loop try: - event_loop = asyncio.get_running_loop() + asyncio.get_running_loop() except RuntimeError: pass else: - if event_loop.is_running(): - raise RuntimeError( - "You cannot use AsyncToSync in the same thread as an async event loop - " - "just await the async function directly." - ) + raise RuntimeError( + "You cannot use AsyncToSync in the same thread as an async event loop - " + "just await the async function directly." + ) # Make a future for the return information call_result: "Future[_R]" = Future() @@ -196,7 +195,7 @@ # need one for every sync frame, even if there's one above us in the # same thread. old_executor = getattr(self.executors, "current", None) - current_executor = CurrentThreadExecutor() + current_executor = CurrentThreadExecutor(old_executor) self.executors.current = current_executor # Wrapping context in list so it can be reassigned from within @@ -207,7 +206,6 @@ # an asyncio.CancelledError to. task_context = getattr(SyncToAsync.threadlocal, "task_context", None) - loop = None # Use call_soon_threadsafe to schedule a synchronous callback on the # main event loop's thread if it's there, otherwise make a new loop # in this thread. @@ -217,35 +215,45 @@ sys.exc_info(), task_context, context, - *args, - **kwargs, + # prepare an awaitable which can be passed as is to self.main_wrap, + # so that `args` and `kwargs` don't need to be + # destructured when passed to self.main_wrap + # (which is required by `ParamSpec`) + # as that may cause overlapping arguments + self.awaitable(*args, **kwargs), ) - if not (self.main_event_loop and self.main_event_loop.is_running()): - # Make our own event loop - in a new thread - and run inside that. - loop = asyncio.new_event_loop() + async def new_loop_wrap() -> None: + loop = asyncio.get_running_loop() self.loop_thread_executors[loop] = current_executor + try: + await awaitable + finally: + del self.loop_thread_executors[loop] + + if self.main_event_loop is not None: + try: + self.main_event_loop.call_soon_threadsafe( + self.main_event_loop.create_task, awaitable + ) + except RuntimeError: + running_in_main_event_loop = False + else: + running_in_main_event_loop = True + # Run the CurrentThreadExecutor until the future is done. + current_executor.run_until_future(call_result) + else: + running_in_main_event_loop = False + + if not running_in_main_event_loop: + # Make our own event loop - in a new thread - and run inside that. loop_executor = ThreadPoolExecutor(max_workers=1) - loop_future = loop_executor.submit( - self._run_event_loop, loop, awaitable - ) - if current_executor: - # Run the CurrentThreadExecutor until the future is done - current_executor.run_until_future(loop_future) + loop_future = loop_executor.submit(asyncio.run, new_loop_wrap()) + # Run the CurrentThreadExecutor until the future is done. + current_executor.run_until_future(loop_future) # Wait for future and/or allow for exception propagation loop_future.result() - else: - # Call it inside the existing loop - self.main_event_loop.call_soon_threadsafe( - self.main_event_loop.create_task, awaitable - ) - if current_executor: - # Run the CurrentThreadExecutor until the future is done - current_executor.run_until_future(call_result) finally: - # Clean up any executor we were running - if loop is not None: - del self.loop_thread_executors[loop] _restore_context(context[0]) # Restore old current thread executor state self.executors.current = old_executor @@ -253,42 +261,6 @@ # Wait for results from the future. return call_result.result() - def _run_event_loop(self, loop, coro): - """ - Runs the given event loop (designed to be called in a thread). - """ - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(coro) - finally: - try: - # mimic asyncio.run() behavior - # cancel unexhausted async generators - tasks = asyncio.all_tasks(loop) - for task in tasks: - task.cancel() - - async def gather(): - await asyncio.gather(*tasks, return_exceptions=True) - - loop.run_until_complete(gather()) - for task in tasks: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "unhandled exception during loop shutdown", - "exception": task.exception(), - "task": task, - } - ) - if hasattr(loop, "shutdown_asyncgens"): - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - loop.close() - asyncio.set_event_loop(self.main_event_loop) - def __get__(self, parent: Any, objtype: Any) -> Callable[_P, _R]: """ Include self for methods @@ -302,8 +274,7 @@ exc_info: "OptExcInfo", task_context: "Optional[List[asyncio.Task[Any]]]", context: List[contextvars.Context], - *args: _P.args, - **kwargs: _P.kwargs, + awaitable: Union[Coroutine[Any, Any, _R], Awaitable[_R]], ) -> None: """ Wraps the awaitable with something that puts the result into the @@ -326,9 +297,9 @@ try: raise exc_info[1] except BaseException: - result = await self.awaitable(*args, **kwargs) + result = await awaitable else: - result = await self.awaitable(*args, **kwargs) + result = await awaitable except BaseException as e: call_result.set_exception(e) else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/testing.py new/asgiref-3.9.1/asgiref/testing.py --- old/asgiref-3.8.1/asgiref/testing.py 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/testing.py 2025-07-03 11:24:24.000000000 +0200 @@ -13,18 +13,40 @@ """ def __init__(self, application, scope): + self._future = None self.application = guarantee_single_callable(application) self.scope = scope - self.input_queue = asyncio.Queue() - self.output_queue = asyncio.Queue() - # Clear context - this ensures that context vars set in the testing scope - # are not "leaked" into the application which would normally begin with - # an empty context. In Python >= 3.11 this could also be written as: - # asyncio.create_task(..., context=contextvars.Context()) - self.future = contextvars.Context().run( - asyncio.create_task, - self.application(scope, self.input_queue.get, self.output_queue.put), - ) + self._input_queue = None + self._output_queue = None + + # For Python 3.9 we need to lazily bind the queues, on 3.10+ they bind the + # event loop lazily. + @property + def input_queue(self): + if self._input_queue is None: + self._input_queue = asyncio.Queue() + return self._input_queue + + @property + def output_queue(self): + if self._output_queue is None: + self._output_queue = asyncio.Queue() + return self._output_queue + + @property + def future(self): + if self._future is None: + # Clear context - this ensures that context vars set in the testing scope + # are not "leaked" into the application which would normally begin with + # an empty context. In Python >= 3.11 this could also be written as: + # asyncio.create_task(..., context=contextvars.Context()) + self._future = contextvars.Context().run( + asyncio.create_task, + self.application( + self.scope, self.input_queue.get, self.output_queue.put + ), + ) + return self._future async def wait(self, timeout=1): """ @@ -46,11 +68,15 @@ pass def stop(self, exceptions=True): - if not self.future.done(): - self.future.cancel() + future = self._future + if future is None: + return + + if not future.done(): + future.cancel() elif exceptions: # Give a chance to raise any exceptions - self.future.result() + future.result() def __del__(self): # Clean up on deletion @@ -64,6 +90,10 @@ """ Sends a single message to the application """ + # Make sure there's not an exception to raise from the task + if self.future.done(): + self.future.result() + # Give it the message await self.input_queue.put(message) @@ -94,6 +124,10 @@ """ Checks that there is no message to receive in the given time. """ + # Make sure there's not an exception to raise from the task + if self.future.done(): + self.future.result() + # `interval` has precedence over `timeout` start = time.monotonic() while time.monotonic() - start < timeout: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref/typing.py new/asgiref-3.9.1/asgiref/typing.py --- old/asgiref-3.8.1/asgiref/typing.py 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/asgiref/typing.py 2024-12-07 09:15:18.000000000 +0100 @@ -189,6 +189,7 @@ class WebSocketDisconnectEvent(TypedDict): type: Literal["websocket.disconnect"] code: int + reason: Optional[str] class WebSocketCloseEvent(TypedDict): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref.egg-info/PKG-INFO new/asgiref-3.9.1/asgiref.egg-info/PKG-INFO --- old/asgiref-3.8.1/asgiref.egg-info/PKG-INFO 2024-03-22 15:39:12.000000000 +0100 +++ new/asgiref-3.9.1/asgiref.egg-info/PKG-INFO 2025-07-08 11:07:34.000000000 +0200 @@ -1,6 +1,6 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.4 Name: asgiref -Version: 3.8.1 +Version: 3.9.1 Summary: ASGI specs, helper code, and adapters Home-page: https://github.com/django/asgiref/ Author: Django Software Foundation @@ -17,19 +17,20 @@ Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Internet :: WWW/HTTP -Requires-Python: >=3.8 +Requires-Python: >=3.9 License-File: LICENSE Requires-Dist: typing_extensions>=4; python_version < "3.11" Provides-Extra: tests Requires-Dist: pytest; extra == "tests" Requires-Dist: pytest-asyncio; extra == "tests" -Requires-Dist: mypy>=0.800; extra == "tests" +Requires-Dist: mypy>=1.14.0; extra == "tests" +Dynamic: license-file asgiref ======= @@ -129,7 +130,7 @@ Dependencies ------------ -``asgiref`` requires Python 3.8 or higher. +``asgiref`` requires Python 3.9 or higher. Contributing @@ -179,7 +180,7 @@ python -m build twine upload dist/* - rm -r build/ dist/ + rm -r asgiref.egg-info dist Implementation Details diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref.egg-info/SOURCES.txt new/asgiref-3.9.1/asgiref.egg-info/SOURCES.txt --- old/asgiref-3.8.1/asgiref.egg-info/SOURCES.txt 2024-03-22 15:39:12.000000000 +0100 +++ new/asgiref-3.9.1/asgiref.egg-info/SOURCES.txt 2025-07-08 11:07:34.000000000 +0200 @@ -22,6 +22,7 @@ asgiref.egg-info/requires.txt asgiref.egg-info/top_level.txt tests/test_compatibility.py +tests/test_garbage_collection.py tests/test_local.py tests/test_server.py tests/test_sync.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/asgiref.egg-info/requires.txt new/asgiref-3.9.1/asgiref.egg-info/requires.txt --- old/asgiref-3.8.1/asgiref.egg-info/requires.txt 2024-03-22 15:39:12.000000000 +0100 +++ new/asgiref-3.9.1/asgiref.egg-info/requires.txt 2025-07-08 11:07:34.000000000 +0200 @@ -5,4 +5,4 @@ [tests] pytest pytest-asyncio -mypy>=0.800 +mypy>=1.14.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/setup.cfg new/asgiref-3.9.1/setup.cfg --- old/asgiref-3.8.1/setup.cfg 2024-03-22 15:39:12.875497800 +0100 +++ new/asgiref-3.9.1/setup.cfg 2025-07-08 11:07:34.392425000 +0200 @@ -16,11 +16,11 @@ Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Topic :: Internet :: WWW/HTTP project_urls = Documentation = https://asgi.readthedocs.io/ @@ -28,7 +28,7 @@ Changelog = https://github.com/django/asgiref/blob/master/CHANGELOG.txt [options] -python_requires = >=3.8 +python_requires = >=3.9 packages = find: include_package_data = true install_requires = @@ -39,11 +39,12 @@ tests = pytest pytest-asyncio - mypy>=0.800 + mypy>=1.14.0 [tool:pytest] testpaths = tests asyncio_mode = strict +asyncio_default_fixture_loop_scope = function [flake8] exclude = venv/*,tox/*,specs/* diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tests/test_garbage_collection.py new/asgiref-3.9.1/tests/test_garbage_collection.py --- old/asgiref-3.8.1/tests/test_garbage_collection.py 1970-01-01 01:00:00.000000000 +0100 +++ new/asgiref-3.9.1/tests/test_garbage_collection.py 2025-07-08 09:43:59.000000000 +0200 @@ -0,0 +1,61 @@ +import gc +import sys + +import pytest + +from asgiref.local import Local + + +def disable_gc_for_garbage_collection_test() -> None: + # Disable automatic garbage collection. To have control over when + # garbage collection is performed. This is necessary to ensure that another + # that thread doesn't accidentally trigger it by simply executing code. + gc.disable() + + # Delete the garbage list(`gc.garbage`) to ensure that other tests don't + # interfere with this test. + gc.collect() + + # Set the garbage collection debugging flag to store all unreachable + # objects in `gc.garbage`. This is necessary to ensure that the + # garbage list is empty after execute test code. Otherwise, the test + # will always pass. The garbage list isn't automatically populated + # because it costs extra CPU cycles + gc.set_debug(gc.DEBUG_SAVEALL) + + +def clean_up_after_garbage_collection_test() -> None: + # Clean up the garbage collection settings. Re-enable automatic garbage + # collection. This step is mandatory to avoid running other tests without + # automatic garbage collection. + gc.set_debug(0) + gc.enable() + + +@pytest.mark.skipif( + sys.implementation.name == "pypy", reason="Test relies on CPython GC internals" +) +def test_thread_critical_Local_remove_all_reference_cycles() -> None: + try: + # given + # Disable automatic garbage collection and set debugging flag. + disable_gc_for_garbage_collection_test() + + # when + # Create thread critical Local object in sync context. + try: + getattr(Local(thread_critical=True), "missing") + except AttributeError: + pass + # Enforce garbage collection to populate the garbage list for inspection. + gc.collect() + + # then + # Ensure that the garbage list is empty. The garbage list is only valid + # until the next collection cycle so we can only make assertions about it + # before re-enabling automatic collection. + assert gc.garbage == [] + # Restore garbage collection settings to their original state. This should always be run to avoid interfering + # with other tests to ensure that code should be executed in the `finally' block. + finally: + clean_up_after_garbage_collection_test() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tests/test_local.py new/asgiref-3.9.1/tests/test_local.py --- old/asgiref-3.8.1/tests/test_local.py 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/tests/test_local.py 2025-07-08 11:03:38.000000000 +0200 @@ -1,6 +1,7 @@ import asyncio import gc import threading +from threading import Thread import pytest @@ -338,3 +339,55 @@ # inner value was set inside a new async context, meaning that # we do not see it, as context vars don't propagate up the stack assert not hasattr(test_local_not_tc, "test_value") + + +def test_visibility_thread_asgiref() -> None: + """Check visibility with subthreads.""" + test_local = Local() + test_local.value = 0 + + def _test() -> None: + # Local() is cleared when changing thread + assert not hasattr(test_local, "value") + setattr(test_local, "value", 1) + assert test_local.value == 1 + + thread = Thread(target=_test) + thread.start() + thread.join() + + assert test_local.value == 0 + + +@pytest.mark.asyncio +async def test_visibility_task() -> None: + """Check visibility with asyncio tasks.""" + test_local = Local() + test_local.value = 0 + + async def _test() -> None: + # Local is inherited when changing task + assert test_local.value == 0 + test_local.value = 1 + assert test_local.value == 1 + + await asyncio.create_task(_test()) + + # Changes should not leak to the caller + assert test_local.value == 0 + + +@pytest.mark.asyncio +async def test_deletion() -> None: + """Check visibility with asyncio tasks.""" + test_local = Local() + test_local.value = 123 + + async def _test() -> None: + # Local is inherited when changing task + assert test_local.value == 123 + del test_local.value + assert not hasattr(test_local, "value") + + await asyncio.create_task(_test()) + assert test_local.value == 123 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tests/test_server.py new/asgiref-3.9.1/tests/test_server.py --- old/asgiref-3.8.1/tests/test_server.py 2024-03-14 15:03:34.000000000 +0100 +++ new/asgiref-3.9.1/tests/test_server.py 2025-07-03 11:24:24.000000000 +0200 @@ -1,8 +1,8 @@ import asyncio import socket as sock -from functools import partial import pytest +import pytest_asyncio from asgiref.server import StatelessServer @@ -74,8 +74,8 @@ self._sock.close() -@pytest.fixture(scope="function") -def server(): +@pytest_asyncio.fixture(scope="function") +async def server(): async def app(scope, receive, send): while True: msg = await receive() @@ -92,25 +92,12 @@ assert server_addr == expected_address -async def server_auto_close(fut, timeout): - """Server run based on run_until_complete. It will block forever with handle - function because it is a while True loop without break. Use this method to close - server automatically.""" - loop = asyncio.get_running_loop() - task = asyncio.ensure_future(fut, loop=loop) - await asyncio.sleep(timeout) - task.cancel() - - -def test_stateless_server(server): +@pytest.mark.asyncio +async def test_stateless_server(server): """StatelessServer can be instantiated with an ASGI 3 application.""" """Create a UDP Server can register instance based on name from message of client. Clients can communicate to other client by name through server""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - server.handle = partial(server_auto_close, fut=server.handle(), timeout=1.0) - client1 = Client(name="client1") client2 = Client(name="client2") @@ -124,30 +111,35 @@ await check_client_msg(client2, server.address, b"Welcome") await check_client_msg(client2, server.address, b"Hello") - task1 = loop.create_task(check_client1_behavior()) - task2 = loop.create_task(check_client2_behavior()) + class Done(Exception): + pass - server.run() + async def do_test(): + await asyncio.gather(check_client1_behavior(), check_client2_behavior()) + raise Done - assert task1.done() - assert task2.done() + try: + await asyncio.gather(server.arun(), do_test()) + except Done: + pass -def test_server_delete_instance(server): +@pytest.mark.asyncio +async def test_server_delete_instance(server): """The max_applications of Server is 10. After 20 times register, application number should be 10.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - server.handle = partial(server_auto_close, fut=server.handle(), timeout=1.0) - client1 = Client(name="client1") + class Done(Exception): + pass + async def client1_multiple_register(): for i in range(20): await client1.register(server.address, name=f"client{i}") print(f"client{i}") await check_client_msg(client1, server.address, b"Welcome") + raise Done - task = loop.create_task(client1_multiple_register()) - server.run() - - assert task.done() + try: + await asyncio.gather(client1_multiple_register(), server.arun()) + except Done: + pass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tests/test_sync.py new/asgiref-3.9.1/tests/test_sync.py --- old/asgiref-3.8.1/tests/test_sync.py 2024-03-21 15:29:47.000000000 +0100 +++ new/asgiref-3.9.1/tests/test_sync.py 2025-07-03 11:25:24.000000000 +0200 @@ -7,6 +7,7 @@ import warnings from concurrent.futures import ThreadPoolExecutor from functools import wraps +from typing import Any from unittest import TestCase import pytest @@ -1174,3 +1175,109 @@ assert task_complete assert task_executed + + +def test_async_to_sync_overlapping_kwargs() -> None: + """ + Tests that AsyncToSync correctly passes through kwargs to the wrapped function, + particularly in the case where the wrapped function uses same names for the parameters + as the wrapper. + """ + + @async_to_sync + async def test_function(**kwargs: Any) -> None: + assert kwargs + + # AsyncToSync.main_wrap has a param named `context`. + # So we pass the same argument here to test for the error + # "AsyncToSync.main_wrap() got multiple values for argument '<kwarg>'" + test_function(context=1) + + +@pytest.mark.asyncio +async def test_sync_to_async_overlapping_kwargs() -> None: + """ + Tests that SyncToAsync correctly passes through kwargs to the wrapped function, + particularly in the case where the wrapped function uses same names for the parameters + as the wrapper. + """ + + @sync_to_async + def test_function(**kwargs: Any) -> None: + assert kwargs + + # SyncToAsync.__call__.loop.run_in_executor has a param named `task_context`. + await test_function(task_context=1) + + +def test_nested_task() -> None: + async def inner() -> asyncio.Task[None]: + return asyncio.create_task(sync_to_async(print)("inner")) + + async def main() -> None: + task = await sync_to_async(async_to_sync(inner))() + await task + + async_to_sync(main)() + + +def test_nested_task_later() -> None: + def later(fut: asyncio.Future[asyncio.Task[None]]) -> None: + task = asyncio.create_task(sync_to_async(print)("later")) + fut.set_result(task) + + async def inner() -> asyncio.Future[asyncio.Task[None]]: + loop = asyncio.get_running_loop() + fut = loop.create_future() + loop.call_later(0.1, later, fut) + return fut + + async def main() -> None: + fut = await sync_to_async(async_to_sync(inner))() + task = await fut + await task + + async_to_sync(main)() + + +def test_double_nested_task() -> None: + async def inner() -> asyncio.Task[None]: + return asyncio.create_task(sync_to_async(print)("inner")) + + async def outer() -> asyncio.Task[asyncio.Task[None]]: + return asyncio.create_task(sync_to_async(async_to_sync(inner))()) + + async def main() -> None: + outer_task = await sync_to_async(async_to_sync(outer))() + inner_task = await outer_task + await inner_task + + async_to_sync(main)() + + +# asyncio.Barrier is new in Python 3.11. Nest definition (rather than using +# skipIf) to avoid mypy error. +if sys.version_info >= (3, 11): + + def test_two_nested_tasks_with_asyncio_run() -> None: + barrier = asyncio.Barrier(3) + event = threading.Event() + + async def inner() -> None: + task = asyncio.create_task(sync_to_async(event.wait)()) + await barrier.wait() + await task + + async def outer() -> tuple[asyncio.Task[None], asyncio.Task[None]]: + task0 = asyncio.create_task(inner()) + task1 = asyncio.create_task(inner()) + await barrier.wait() + event.set() + return task0, task1 + + async def main() -> None: + task0, task1 = await sync_to_async(async_to_sync(outer))() + await task0 + await task1 + + asyncio.run(main()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tests/test_testing.py new/asgiref-3.9.1/tests/test_testing.py --- old/asgiref-3.8.1/tests/test_testing.py 2024-03-14 15:06:51.000000000 +0100 +++ new/asgiref-3.9.1/tests/test_testing.py 2025-07-03 11:24:24.000000000 +0200 @@ -1,3 +1,5 @@ +import asyncio + import pytest from asgiref.testing import ApplicationCommunicator @@ -43,3 +45,46 @@ await instance.receive_output() # Response received completely assert await instance.receive_nothing(0.01) is True + + +def test_receive_nothing_lazy_loop(): + """ + Tests ApplicationCommunicator.receive_nothing to return the correct value. + """ + # Get an ApplicationCommunicator instance + def wsgi_application(environ, start_response): + start_response("200 OK", []) + yield b"content" + + application = WsgiToAsgi(wsgi_application) + instance = ApplicationCommunicator( + application, + { + "type": "http", + "http_version": "1.0", + "method": "GET", + "path": "/foo/", + "query_string": b"bar=baz", + "headers": [], + }, + ) + + async def test(): + # No event + assert await instance.receive_nothing() is True + + # Produce 3 events to receive + await instance.send_input({"type": "http.request"}) + # Start event of the response + assert await instance.receive_nothing() is False + await instance.receive_output() + # First body event of the response announcing further body event + assert await instance.receive_nothing() is False + await instance.receive_output() + # Last body event of the response + assert await instance.receive_nothing() is False + await instance.receive_output() + # Response received completely + assert await instance.receive_nothing(0.01) is True + + asyncio.run(test()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asgiref-3.8.1/tox.ini new/asgiref-3.9.1/tox.ini --- old/asgiref-3.8.1/tox.ini 2024-03-21 14:08:55.000000000 +0100 +++ new/asgiref-3.9.1/tox.ini 2024-12-07 09:15:18.000000000 +0100 @@ -1,6 +1,6 @@ [tox] envlist = - py{38,39,310,311,312}-{test,mypy} + py{38,39,310,311,312,313}-{test,mypy} qa [testenv]