From 02fecb6046bb5ec0dbbad00ffcd2043e893fcea5 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 27 Dec 2021 12:46:22 -0500 Subject: [PATCH] Drop Python 3.6 (#307) Python 3.6 is now EOL, and we had to do a lot of work to support it, so this cleans a lot up. --- .github/workflows/tests.yml | 1 - .pre-commit-config.yaml | 4 +- README.rst | 2 +- asgiref/_pep562.py | 61 ------------------ asgiref/compatibility.py | 16 ----- asgiref/sync.py | 112 +++++++++++---------------------- asgiref/timeout.py | 15 +---- asgiref/typing.py | 5 -- setup.cfg | 3 +- tests/test_sync.py | 18 +++--- tests/test_sync_contextvars.py | 5 +- tox.ini | 2 +- 12 files changed, 55 insertions(+), 189 deletions(-) delete mode 100644 asgiref/_pep562.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 925a4889..1a264b2b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,6 @@ jobs: fail-fast: false matrix: python-version: - - 3.6 - 3.7 - 3.8 - 3.9 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a29712fa..8a050774 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,13 +3,13 @@ repos: rev: v2.9.0 hooks: - id: pyupgrade - args: ["--py36-plus"] + args: ["--py37-plus"] - repo: https://github.com/psf/black rev: 20.8b1 hooks: - id: black - args: ["--target-version=py36"] + args: ["--target-version=py37"] - repo: https://github.com/pycqa/isort rev: 5.7.0 diff --git a/README.rst b/README.rst index fd139138..6ec5f5a7 100644 --- a/README.rst +++ b/README.rst @@ -96,7 +96,7 @@ file handles for incoming POST bodies). Dependencies ------------ -``asgiref`` requires Python 3.6 or higher. +``asgiref`` requires Python 3.7 or higher. Contributing diff --git a/asgiref/_pep562.py b/asgiref/_pep562.py deleted file mode 100644 index 3059257a..00000000 --- a/asgiref/_pep562.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Backport of PEP 562. -https://pypi.org/search/?q=pep562 -Licensed under MIT -Copyright (c) 2018 Isaac Muse -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, -and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions -of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF -CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. -""" -import sys -from typing import Any, Callable, List, Optional - - -class Pep562: - """ - Backport of PEP 562 . - Wraps the module in a class that exposes the mechanics to override `__dir__` and `__getattr__`. - The given module will be searched for overrides of `__dir__` and `__getattr__` and use them when needed. - """ - - def __init__(self, name: str) -> None: - """Acquire `__getattr__` and `__dir__`, but only replace module for versions less than Python 3.7.""" - - self._module = sys.modules[name] - self._get_attr = getattr(self._module, "__getattr__", None) - self._get_dir: Optional[Callable[..., List[str]]] = getattr( - self._module, "__dir__", None - ) - sys.modules[name] = self # type: ignore[assignment] - - def __dir__(self) -> List[str]: - """Return the overridden `dir` if one was provided, else apply `dir` to the module.""" - - return self._get_dir() if self._get_dir else dir(self._module) - - def __getattr__(self, name: str) -> Any: - """ - Attempt to retrieve the attribute from the module, and if missing, use the overridden function if present. - """ - - try: - return getattr(self._module, name) - except AttributeError: - if self._get_attr: - return self._get_attr(name) - raise - - -def pep562(module_name: str) -> None: - """Helper function to apply PEP 562.""" - - if sys.version_info < (3, 7): - Pep562(module_name) diff --git a/asgiref/compatibility.py b/asgiref/compatibility.py index 54862e4a..eccaee0d 100644 --- a/asgiref/compatibility.py +++ b/asgiref/compatibility.py @@ -1,6 +1,5 @@ import asyncio import inspect -import sys def is_double_callable(application): @@ -46,18 +45,3 @@ def guarantee_single_callable(application): if is_double_callable(application): application = double_to_single_callable(application) return application - - -if sys.version_info >= (3, 7): - # these were introduced in 3.7 - get_running_loop = asyncio.get_running_loop - run_future = asyncio.run - create_task = asyncio.create_task - current_task = asyncio.current_task -else: - # marked as deprecated in 3.10, did not exist before 3.7 - get_running_loop = asyncio.get_event_loop - run_future = asyncio.ensure_future - # does nothing, this is fine for <3.7 - create_task = lambda task: task - current_task = asyncio.Task.current_task diff --git a/asgiref/sync.py b/asgiref/sync.py index 3710a7f1..b71b3799 100644 --- a/asgiref/sync.py +++ b/asgiref/sync.py @@ -1,4 +1,5 @@ import asyncio.coroutines +import contextvars import functools import inspect import os @@ -9,15 +10,9 @@ from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Callable, Dict, Optional, overload -from .compatibility import current_task, get_running_loop from .current_thread_executor import CurrentThreadExecutor from .local import Local -if sys.version_info >= (3, 7): - import contextvars -else: - contextvars = None - def _restore_context(context): # Check for changes in contextvars, and set them to the current @@ -55,8 +50,6 @@ class ThreadSensitiveContext: In Python 3.7+, the ThreadSensitiveContext() context manager may be used to specify a thread pool per context. - In Python 3.6, usage of this context manager has no effect. - This context manager is re-entrant, so only the outer-most call to ThreadSensitiveContext will set the context. @@ -70,32 +63,22 @@ class ThreadSensitiveContext: def __init__(self): self.token = None - if contextvars: - - async def __aenter__(self): - try: - SyncToAsync.thread_sensitive_context.get() - except LookupError: - self.token = SyncToAsync.thread_sensitive_context.set(self) - - return self - - async def __aexit__(self, exc, value, tb): - if not self.token: - return + async def __aenter__(self): + try: + SyncToAsync.thread_sensitive_context.get() + except LookupError: + self.token = SyncToAsync.thread_sensitive_context.set(self) - executor = SyncToAsync.context_to_thread_executor.pop(self, None) - if executor: - executor.shutdown() - SyncToAsync.thread_sensitive_context.reset(self.token) + return self - else: + async def __aexit__(self, exc, value, tb): + if not self.token: + return - async def __aenter__(self): - return self - - async def __aexit__(self, exc, value, tb): - pass + executor = SyncToAsync.context_to_thread_executor.pop(self, None) + if executor: + executor.shutdown() + SyncToAsync.thread_sensitive_context.reset(self.token) class AsyncToSync: @@ -135,7 +118,7 @@ def __init__(self, awaitable, force_new_loop=False): self.main_event_loop = None else: try: - self.main_event_loop = get_running_loop() + self.main_event_loop = asyncio.get_running_loop() except RuntimeError: # There's no event loop in this thread. Look for the threadlocal if # we're inside SyncToAsync @@ -154,7 +137,7 @@ def __init__(self, awaitable, force_new_loop=False): def __call__(self, *args, **kwargs): # You can't call AsyncToSync from a thread with a running event loop try: - event_loop = get_running_loop() + event_loop = asyncio.get_running_loop() except RuntimeError: pass else: @@ -164,12 +147,9 @@ def __call__(self, *args, **kwargs): "just await the async function directly." ) - if contextvars is not None: - # Wrapping context in list so it can be reassigned from within - # `main_wrap`. - context = [contextvars.copy_context()] - else: - context = None + # Wrapping context in list so it can be reassigned from within + # `main_wrap`. + context = [contextvars.copy_context()] # Make a future for the return information call_result = Future() @@ -218,8 +198,7 @@ def __call__(self, *args, **kwargs): del self.executors.current if old_current_executor: self.executors.current = old_current_executor - if contextvars is not None: - _restore_context(context[0]) + _restore_context(context[0]) # Wait for results from the future. return call_result.result() @@ -235,10 +214,7 @@ def _run_event_loop(self, loop, coro): try: # mimic asyncio.run() behavior # cancel unexhausted async generators - if sys.version_info >= (3, 7, 0): - tasks = asyncio.all_tasks(loop) - else: - tasks = asyncio.Task.all_tasks(loop) + tasks = asyncio.all_tasks(loop) for task in tasks: task.cancel() @@ -299,8 +275,7 @@ async def main_wrap( finally: del self.launch_map[current_task] - if context is not None: - context[0] = contextvars.copy_context() + context[0] = contextvars.copy_context() class SyncToAsync: @@ -345,21 +320,15 @@ class SyncToAsync: # Maintain a contextvar for the current execution context. Optionally used # for thread sensitive mode. - if sys.version_info >= (3, 7): - thread_sensitive_context: "contextvars.ContextVar[str]" = ( - contextvars.ContextVar("thread_sensitive_context") - ) - else: - thread_sensitive_context: None = None + thread_sensitive_context: "contextvars.ContextVar[str]" = contextvars.ContextVar( + "thread_sensitive_context" + ) # Contextvar that is used to detect if the single thread executor # would be awaited on while already being used in the same context - if sys.version_info >= (3, 7): - deadlock_context: "contextvars.ContextVar[bool]" = contextvars.ContextVar( - "deadlock_context" - ) - else: - deadlock_context: None = None + deadlock_context: "contextvars.ContextVar[bool]" = contextvars.ContextVar( + "deadlock_context" + ) # Maintaining a weak reference to the context ensures that thread pools are # erased once the context goes out of scope. This terminates the thread pool. @@ -388,7 +357,7 @@ def __init__( pass async def __call__(self, *args, **kwargs): - loop = get_running_loop() + loop = asyncio.get_running_loop() # Work out what thread to run the code in if self._thread_sensitive: @@ -422,14 +391,11 @@ async def __call__(self, *args, **kwargs): # Use the passed in executor, or the loop's default if it is None executor = self._executor - if contextvars is not None: - context = contextvars.copy_context() - child = functools.partial(self.func, *args, **kwargs) - func = context.run - args = (child,) - kwargs = {} - else: - func = self.func + context = contextvars.copy_context() + child = functools.partial(self.func, *args, **kwargs) + func = context.run + args = (child,) + kwargs = {} try: # Run the code in the right thread @@ -448,8 +414,7 @@ async def __call__(self, *args, **kwargs): ret = await asyncio.wait_for(future, timeout=None) finally: - if contextvars is not None: - _restore_context(context) + _restore_context(context) if self.deadlock_context: self.deadlock_context.set(False) @@ -497,12 +462,11 @@ def thread_handler(self, loop, source_task, exc_info, func, *args, **kwargs): @staticmethod def get_current_task(): """ - Cross-version implementation of asyncio.current_task() - - Returns None if there is no task. + Implementation of asyncio.current_task() + that returns None if there is no task. """ try: - return current_task() + return asyncio.current_task() except RuntimeError: return None diff --git a/asgiref/timeout.py b/asgiref/timeout.py index 052e3181..65932d17 100644 --- a/asgiref/timeout.py +++ b/asgiref/timeout.py @@ -10,8 +10,6 @@ from types import TracebackType from typing import Any, Optional, Type -from .compatibility import current_task as asyncio_current_task - class timeout: """timeout context manager. @@ -83,7 +81,7 @@ def _do_enter(self) -> "timeout": if self._timeout is None: return self - self._task = current_task(self._loop) + self._task = asyncio.current_task(self._loop) if self._task is None: raise RuntimeError( "Timeout context manager should be used " "inside a task" @@ -112,14 +110,3 @@ def _cancel_task(self) -> None: if self._task is not None: self._task.cancel() self._cancelled = True - - -def current_task(loop: asyncio.AbstractEventLoop) -> "Optional[asyncio.Task[Any]]": - task = asyncio_current_task(loop=loop) - if task is None: - # this should be removed, tokio must use register_task and family API - fn = getattr(loop, "current_task", None) - if fn is not None: - task = fn() - - return task diff --git a/asgiref/typing.py b/asgiref/typing.py index c8c2e787..7d0d5953 100644 --- a/asgiref/typing.py +++ b/asgiref/typing.py @@ -13,8 +13,6 @@ Union, ) -from asgiref._pep562 import pep562 - if sys.version_info >= (3, 8): from typing import Literal, Protocol, TypedDict else: @@ -282,6 +280,3 @@ def __getattr__(name: str) -> Any: def __dir__() -> List[str]: return sorted(list(__all__) + list(__deprecated__.keys())) - - -pep562(__name__) diff --git a/setup.cfg b/setup.cfg index 01d0b50b..2e41756d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,6 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -27,7 +26,7 @@ project_urls = Changelog = https://github.com/django/asgiref/blob/master/CHANGELOG.txt [options] -python_requires = >=3.6 +python_requires = >=3.7 packages = find: include_package_data = true install_requires = diff --git a/tests/test_sync.py b/tests/test_sync.py index ad4ec9bb..5fdff422 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,7 +1,6 @@ import asyncio import functools import multiprocessing -import sys import threading import time from concurrent.futures import ThreadPoolExecutor @@ -10,7 +9,6 @@ import pytest -from asgiref.compatibility import create_task, get_running_loop from asgiref.sync import ThreadSensitiveContext, async_to_sync, sync_to_async from asgiref.timeout import timeout @@ -36,15 +34,15 @@ def sync_function(): assert result == 42 assert end - start >= 1 # Set workers to 1, call it twice and make sure that works right - loop = get_running_loop() + loop = asyncio.get_running_loop() old_executor = loop._default_executor or ThreadPoolExecutor() loop.set_default_executor(ThreadPoolExecutor(max_workers=1)) try: start = time.monotonic() await asyncio.wait( [ - create_task(async_function()), - create_task(async_function()), + asyncio.create_task(async_function()), + asyncio.create_task(async_function()), ] ) end = time.monotonic() @@ -436,7 +434,9 @@ def inner(result): result["thread"] = threading.current_thread() # Run it (in supposed parallel!) - await asyncio.wait([create_task(outer(result_1)), create_task(inner(result_2))]) + await asyncio.wait( + [asyncio.create_task(outer(result_1)), asyncio.create_task(inner(result_2))] + ) # They should not have run in the main thread, but in the same thread assert result_1["thread"] != threading.current_thread() @@ -458,8 +458,8 @@ async def fn(): # Run it (in supposed parallel!) await asyncio.wait( [ - create_task(store_thread_async(result_1)), - create_task(store_thread_async(result_2)), + asyncio.create_task(store_thread_async(result_1)), + asyncio.create_task(store_thread_async(result_2)), ] ) @@ -673,7 +673,6 @@ def sync_func(): ) -@pytest.mark.skipif(sys.version_info < (3, 7), reason="Issue persists with 3.6") def test_sync_to_async_deadlock_raises(): def db_write(): pass @@ -697,7 +696,6 @@ async def server_entry(): asyncio.run(server_entry()) -@pytest.mark.skipif(sys.version_info < (3, 7), reason="Issue persists with 3.6") def test_sync_to_async_deadlock_ignored_with_exception(): """ Ensures that throwing an exception from inside a deadlock-protected block diff --git a/tests/test_sync_contextvars.py b/tests/test_sync_contextvars.py index 9665bf99..4fc569ef 100644 --- a/tests/test_sync_contextvars.py +++ b/tests/test_sync_contextvars.py @@ -4,7 +4,6 @@ import pytest -from asgiref.compatibility import create_task from asgiref.sync import ThreadSensitiveContext, async_to_sync, sync_to_async contextvars = pytest.importorskip("contextvars") @@ -26,7 +25,9 @@ async def fn(result): await store_thread(result) # Run it (in true parallel!) - await asyncio.wait([create_task(fn(result_1)), create_task(fn(result_2))]) + await asyncio.wait( + [asyncio.create_task(fn(result_1)), asyncio.create_task(fn(result_2))] + ) # They should not have run in the main thread, and on different threads assert result_1["thread"] != threading.current_thread() diff --git a/tox.ini b/tox.ini index b3be3e45..a8b03260 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39,310}-{test,mypy} + py{37,38,39,310}-{test,mypy} qa [testenv]