Initial commit

This commit is contained in:
Denys Zaitsev
2026-04-03 23:25:54 +03:00
parent 531a1301d5
commit d7e1066c60
3843 changed files with 1554468 additions and 0 deletions
@@ -0,0 +1,37 @@
"""Entrypoint for asyncio.Runner backport.
Below is a quick summary of the changes.
asyncio.tasks._PyTask was overridden to be able to patch some behavior in Python < 3.11 and allow
this backport to function. _CTask implementation of the API is not in scope of this backport at this time.
The list of minimal changes backported are the following:
1. contextvars.copy_context behavior change on __init__.
2. _num_cancels_requested attribute
3. cancelling() function
4. uncancel() function
All of these are implemented in [CTask](https://github.com/python/cpython/blob/3.11/Modules/_asynciomodule.c)
in Python 3.11, but the _CTask implementations were not backported. Trying to provide 1-1 Task implementation
in this backport to Python 3.11 is not in scope and not needed.
For #1, since we can't mimic how `contextvars.copy_context` is used in Python 3.11 in the new __init__:
```python
if context is None:
self._context = contextvars.copy_context()
else:
self._context = context
```
This is achieved in `Runner.run` by patching `contextvars.copy_context` temporarily to return
our copied context instead. This assures the contextvars are preserved when the runner runs.
This approach was necessary since versions below 3.11 always run `self._context = contextvars.copy_context()`
right before being scheduled. This helps us avoid having to backport the Task context kwarg signature and
the new task factory implementation.
"""
from .runner import Runner
__all__ = ["Runner"]
@@ -0,0 +1,20 @@
"""Backported implementation of _int_to_enum from 3.11 compatible down to Python 3.8."""
import signal
from enum import IntEnum
from typing import TypeVar, Union, Type
_T = TypeVar("_T")
_E = TypeVar("_E", bound=IntEnum)
_orig_int_to_enum = signal._int_to_enum # type: ignore[attr-defined]
# See https://github.com/python/cpython/blob/3.11/Lib/signal.py
def _patched_int_to_enum(value: Union[int, _T], enum_klass: Type[_E]) -> Union[_E, _T]:
"""Convert a possible numeric value to an IntEnum member.
If it's not a known member, return the value itself.
"""
if not isinstance(value, int):
return value
return _orig_int_to_enum(value, enum_klass) # type: ignore[no-any-return]
@@ -0,0 +1,18 @@
from contextlib import contextmanager
from typing import Any, TypeVar, Iterator
_T = TypeVar("_T")
@contextmanager
def _patch_object(target: Any, attribute: str, new: _T) -> Iterator[None]:
"""A basic version of unittest.mock patch.object."""
# https://github.com/python/cpython/blob/3.8/Lib/unittest/mock.py#L1358
temp_original = getattr(target, attribute)
# https://github.com/python/cpython/blob/3.8/Lib/unittest/mock.py#L1490
setattr(target, attribute, new)
try:
yield
finally:
# https://github.com/python/cpython/blob/3.8/Lib/unittest/mock.py#L1509
setattr(target, attribute, temp_original)
@@ -0,0 +1,275 @@
"""Backported implementation of asyncio.Runner from 3.11 compatible down to Python 3.8."""
import asyncio
import asyncio.tasks
import contextvars
import enum
import functools
import signal
import sys
import threading
from asyncio import coroutines, events, tasks, exceptions, AbstractEventLoop
from contextvars import Context
from types import TracebackType, FrameType
from typing import (
Callable,
Coroutine,
TypeVar,
Any,
Optional,
Type,
final,
TYPE_CHECKING,
)
from ._int_to_enum import _orig_int_to_enum, _patched_int_to_enum
from ._patch import _patch_object
from .tasks import Task
# See https://github.com/python/cpython/blob/3.11/Lib/asyncio/runners.py
if TYPE_CHECKING: # pragma: no cover
from signal import _HANDLER
__all__ = ("Runner",)
_T = TypeVar("_T")
class _State(enum.Enum):
CREATED = "created"
INITIALIZED = "initialized"
CLOSED = "closed"
@final
class Runner:
"""A context manager that controls event loop life cycle.
The context manager always creates a new event loop,
allows to run async functions inside it,
and properly finalizes the loop at the context manager exit.
If debug is True, the event loop will be run in debug mode.
If loop_factory is passed, it is used for new event loop creation.
asyncio.run(main(), debug=True)
is a shortcut for
with Runner(debug=True) as runner:
runner.run(main())
The run() method can be called multiple times within the runner's context.
This can be useful for interactive console (e.g. IPython),
unittest runners, console tools, -- everywhere when async code
is called from existing sync framework and where the preferred single
asyncio.run() call doesn't work.
"""
# Note: the class is final, it is not intended for inheritance.
def __init__(
self,
*,
debug: Optional[bool] = None,
loop_factory: Optional[Callable[[], AbstractEventLoop]] = None,
):
self._state = _State.CREATED
self._debug = debug
self._loop_factory = loop_factory
self._loop: Optional[AbstractEventLoop] = None
self._context = None
self._interrupt_count = 0
self._set_event_loop = False
def __enter__(self) -> "Runner":
self._lazy_init()
return self
def __exit__(
self,
exc_type: Type[BaseException],
exc_val: BaseException,
exc_tb: TracebackType,
) -> None:
self.close()
def close(self) -> None:
"""Shutdown and close event loop."""
if self._state is not _State.INITIALIZED:
return
try:
# TYPING: self._loop should not be None
loop: AbstractEventLoop = self._loop # type: ignore[assignment]
_cancel_all_tasks(loop)
loop.run_until_complete(loop.shutdown_asyncgens())
# Backport Patch
if sys.version_info >= (3, 9):
loop.run_until_complete(loop.shutdown_default_executor())
else:
loop.run_until_complete(_shutdown_default_executor(loop))
finally:
if self._set_event_loop:
events.set_event_loop(None)
loop.close()
self._loop = None
self._state = _State.CLOSED
# See https://github.com/python/cpython/pull/113444
# Reverts signal._int_to_enum patch
signal._int_to_enum = _orig_int_to_enum # type: ignore[attr-defined]
def get_loop(self) -> AbstractEventLoop:
"""Return embedded event loop."""
self._lazy_init()
# TYPING: self._loop should not be None
return self._loop # type: ignore[return-value]
def run(
self, coro: Coroutine[Any, Any, _T], *, context: Optional[Context] = None
) -> _T:
"""Run a coroutine inside the embedded event loop."""
if not coroutines.iscoroutine(coro):
raise ValueError("a coroutine was expected, got {!r}".format(coro))
if events._get_running_loop() is not None:
# fail fast with short traceback
raise RuntimeError(
"Runner.run() cannot be called from a running event loop"
)
self._lazy_init()
if context is None:
context = self._context
# Backport Patch: <= 3.11 does not have create_task with context parameter
# Reader note: context.run will not work here as it does not match how asyncio.Runner retains context
with _patch_object(asyncio.tasks, asyncio.tasks.Task.__name__, Task):
with _patch_object(
contextvars, contextvars.copy_context.__name__, lambda: context
):
task = self._loop.create_task(coro) # type: ignore[union-attr]
if (
threading.current_thread() is threading.main_thread()
and signal.getsignal(signal.SIGINT) is signal.default_int_handler
):
sigint_handler: "_HANDLER" = functools.partial(
self._on_sigint, main_task=task
)
try:
signal.signal(signal.SIGINT, sigint_handler)
except ValueError:
# `signal.signal` may throw if `threading.main_thread` does
# not support signals (e.g. embedded interpreter with signals
# not registered - see gh-91880)
sigint_handler = None
else:
sigint_handler = None
self._interrupt_count = 0
try:
return self._loop.run_until_complete(task) # type: ignore[union-attr, no-any-return]
except exceptions.CancelledError:
if self._interrupt_count > 0:
uncancel = getattr(task, "uncancel", None)
if uncancel is not None and uncancel() == 0:
raise KeyboardInterrupt()
raise # CancelledError
finally:
if (
sigint_handler is not None
and signal.getsignal(signal.SIGINT) is sigint_handler
):
signal.signal(signal.SIGINT, signal.default_int_handler)
def _lazy_init(self) -> None:
if self._state is _State.CLOSED:
raise RuntimeError("Runner is closed")
if self._state is _State.INITIALIZED:
return
# See https://github.com/python/cpython/pull/113444
# Patches signal._int_to_enum temporarily
signal._int_to_enum = _patched_int_to_enum # type: ignore[attr-defined]
# Continue original implementation
if self._loop_factory is None:
self._loop = events.new_event_loop()
if not self._set_event_loop:
# Call set_event_loop only once to avoid calling
# attach_loop multiple times on child watchers
events.set_event_loop(self._loop)
self._set_event_loop = True
else:
self._loop = self._loop_factory()
if self._debug is not None:
self._loop.set_debug(self._debug)
self._context = contextvars.copy_context() # type: ignore[assignment]
self._state = _State.INITIALIZED
def _on_sigint(
self, signum: int, frame: Optional[FrameType], main_task: asyncio.Task
) -> None:
# TYPING: self._loop should not be None
self._interrupt_count += 1
if self._interrupt_count == 1 and not main_task.done():
main_task.cancel()
# wakeup loop if it is blocked by select() with long timeout
self._loop.call_soon_threadsafe(lambda: None) # type: ignore[union-attr]
return
raise KeyboardInterrupt()
# See https://github.com/python/cpython/blob/3.11/Lib/asyncio/runners.py
def _cancel_all_tasks(loop: AbstractEventLoop) -> None:
to_cancel = tasks.all_tasks(loop)
if not to_cancel:
return
for task in to_cancel:
task.cancel()
loop.run_until_complete(tasks.gather(*to_cancel, return_exceptions=True))
for task in to_cancel:
if task.cancelled():
continue
if task.exception() is not None:
loop.call_exception_handler(
{
"message": "unhandled exception during asyncio.run() shutdown",
"exception": task.exception(),
"task": task,
}
)
# See https://github.com/python/cpython/blob/3.11/Lib/asyncio/base_events.py
async def _shutdown_default_executor(loop: AbstractEventLoop) -> None:
"""Schedule the shutdown of the default executor."""
# TYPING: Both _executor_shutdown_called and _default_executor are expected private properties of BaseEventLoop
loop._executor_shutdown_called = True # type: ignore[attr-defined]
if loop._default_executor is None: # type: ignore[attr-defined]
return
future = loop.create_future()
# Backport modified to include send loop to _do_shutdown
thread = threading.Thread(target=_do_shutdown, args=(loop, future))
thread.start()
try:
await future
finally:
thread.join()
def _do_shutdown(loop: AbstractEventLoop, future: asyncio.futures.Future) -> None:
try:
loop._default_executor.shutdown(wait=True) # type: ignore[attr-defined]
if not loop.is_closed():
loop.call_soon_threadsafe(future.set_result, None)
except Exception as ex:
if not loop.is_closed():
loop.call_soon_threadsafe(future.set_exception, ex)
@@ -0,0 +1,26 @@
from _typeshed import Unused
from asyncio import AbstractEventLoop
from contextvars import Context
from typing import Any, TypeVar, Optional, Callable, Coroutine
from typing_extensions import Self, final
__all__ = ("Runner",)
_T = TypeVar("_T")
@final
class Runner:
def __init__(
self,
*,
debug: Optional[bool] = None,
loop_factory: Optional[Callable[[], AbstractEventLoop]] = None,
) -> None: ...
def __enter__(self) -> Self: ...
def __exit__(self, exc_type: Unused, exc_val: Unused, exc_tb: Unused) -> None: ...
def close(self) -> None: ...
def get_loop(self) -> AbstractEventLoop: ...
def run(
self, coro: Coroutine[Any, Any, _T], *, context: Optional[Context] = None
) -> _T: ...
@@ -0,0 +1,94 @@
"""Minimal backported implementation of asyncio._PyTask from 3.11 compatible down to Python 3.8."""
import asyncio.tasks
import sys
from asyncio import AbstractEventLoop
from typing import Coroutine, TypeVar, Any, Optional
_T = TypeVar("_T")
# See https://github.com/python/cpython/blob/3.11/Lib/asyncio/tasks.py
class Task(asyncio.tasks._PyTask): # type: ignore[name-defined, misc]
"""A coroutine wrapped in a Future."""
def __init__(
self,
coro: Coroutine[Any, Any, _T],
*,
loop: Optional[AbstractEventLoop] = None,
name: Optional[str] = None,
) -> None:
self._num_cancels_requested = 0
# https://github.com/python/cpython/blob/3.11/Modules/_asynciomodule.c#L2026
# Backport Note: self._context is temporarily patched in Runner.run() instead.
super().__init__(coro, loop=loop, name=name)
def cancel(self, msg: Optional[str] = None) -> bool:
"""Request that this task cancel itself.
This arranges for a CancelledError to be thrown into the
wrapped coroutine on the next cycle through the event loop.
The coroutine then has a chance to clean up or even deny
the request using try/except/finally.
Unlike Future.cancel, this does not guarantee that the
task will be cancelled: the exception might be caught and
acted upon, delaying cancellation of the task or preventing
cancellation completely. The task may also return a value or
raise a different exception.
Immediately after this method is called, Task.cancelled() will
not return True (unless the task was already cancelled). A
task will be marked as cancelled when the wrapped coroutine
terminates with a CancelledError exception (even if cancel()
was not called).
This also increases the task's count of cancellation requests.
"""
self._log_traceback = False
if self.done():
return False
self._num_cancels_requested += 1
# These two lines are controversial. See discussion starting at
# https://github.com/python/cpython/pull/31394#issuecomment-1053545331
# Also remember that this is duplicated in _asynciomodule.c.
# if self._num_cancels_requested > 1:
# return False
if self._fut_waiter is not None:
if sys.version_info >= (3, 9):
if self._fut_waiter.cancel(msg=msg):
# Leave self._fut_waiter; it may be a Task that
# catches and ignores the cancellation so we may have
# to cancel it again later.
return True
else:
if self._fut_waiter.cancel():
return True
# It must be the case that self.__step is already scheduled.
self._must_cancel = True
if sys.version_info >= (3, 9):
self._cancel_message = msg
return True
def cancelling(self) -> int:
"""Return the count of the task's cancellation requests.
This count is incremented when .cancel() is called
and may be decremented using .uncancel().
"""
return self._num_cancels_requested
def uncancel(self) -> int:
"""Decrement the task's count of cancellation requests.
This should be called by the party that called `cancel()` on the task
beforehand.
Returns the remaining number of cancellation requests.
"""
if self._num_cancels_requested > 0:
self._num_cancels_requested -= 1
return self._num_cancels_requested