apsw.aio — APSW 3.53.3.1 documentation

# Source code for apsw.aio

    "Implements async framework controllers, configuration, and helpers"

    from __future__ import annotations

    import asyncio
    import contextlib
    import contextvars
    import logging
    import math
    import queue
    import sys
    import threading
    import time
    from collections.abc import Callable, Coroutine
    from typing import Any, TypeVar

    import apsw

    logger = logging.getLogger(__name__)

    T = TypeVar("T")

    deadline: contextvars.ContextVar[int | float | None] = contextvars.ContextVar("apsw.aio.deadline", default=None)
    """Absolute time deadline for a request in seconds

    This makes a best effort to timeout a database operation including any
    sync and async callbacks if the deadline is passed.  The default
    (``None``) is no deadline.

    The deadline is set at the point an APSW call is made, and changes
    after that are not observed.  It is based on the clock used by the
    event loop.  Typical usage is:

    .. code-block:: python

        # 10 seconds from now.  You'll need to get the time from your
        # framework as documented below.

        with apsw.aio.contextvar_set(apsw.aio.deadline,
            anyio.current_time() + 10):

            async for row in await db.execute("time consuming query ..."):
                print(f"{row=}")

    :class:`AsyncIO`

        :code:`apsw.aio.deadline` is the only way to set a deadline.
        :exc:`TimeoutError` will
        be raised if the deadline is exceeded.  The current time is
        available from  :meth:`asyncio.get_running_loop().time()
        <asyncio.loop.time>`

    :class:`Trio`

        If :code:`apsw.aio.deadline` is set then it is used for the
        deadline.  :exc:`trio.TooSlowError` is raised.  The current time
        is available from :func:`trio.current_time`.

        Otherwise the :func:`trio.current_effective_deadline` where the
        call is made is used.

    AnyIO

        If :code:`apsw.aio.deadline` is set then it is used for the
        deadline.  :exc:`TimeoutError` is raised.  The current time is
        available from :func:`anyio.current_time`.

        Otherwise the :func:`anyio.current_effective_deadline` where the
        call is made is used.

    """

    check_progress_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
        "apsw.aio.check_progress_steps", default=50_000
    )
    """How many internal SQLite steps between checks for cancellation and deadlines

    While SQLite queries are executing, periodic checks are made to see if
    the request has been cancelled, or the deadline exceeded.  This is
    done in the :meth:`progress handler
    <apsw.Connection.set_progress_handler>`.

    The default should correspond to around 10 checks per second, but will
    vary a lot based on the queries.  The smaller the number, the more
    frequent the checks, but also more time consumed making the checks.

    This is only used during connection creation.  Typical usage is:

    .. code-block:: python

        with apsw.aio.contextvar_set(apsw.aio.check_progress_steps, 500):
            db = await apsw.Connection.as_async(...)
    """

    if sys.version_info >= (3, 14):

        def contextvar_set(var: contextvars.ContextVar[T], value: T) -> contextvars.Token[T]:
            """Wrapper for setting a :class:`~contextvars.ContextVar` during a :code:`with` block

            Python 3.14+ lets you do::

                with var.set(value):
                    # code here
                    ...

            This wrapper provides the same functionality for all
            Python versions::

                with apsw.aio.contextvar_set(var, value):
                    # code here
                    ...

            """
            return var.set(value)

    else:

        def contextvar_set(var: contextvars.ContextVar[T], value: T) -> contextvars.Token[T]:
            @contextlib.contextmanager
            def _contextvar_set_wrapper():
                token = var.set(value)
                try:
                    yield token
                finally:
                    var.reset(token)

            return _contextvar_set_wrapper()

    async def make_session(db: apsw.AsyncConnection, schema: str) -> apsw.AsyncSession:
        "Helper to create a :class:`~apsw.Session` in async mode for an async database"
        # This mainly exists to give IDEs and type checkers the clues they need
        if not hasattr(apsw, "Session"):
            # misuse is what SQLite uses
            raise apsw.MisuseError("The session extension is not enabled and available")
        return await db.async_run(apsw.Session, db, schema)

    class _Cancelled(BaseException):
        """
        Raised in the worker thread on seeing call cancellation.

        The original caller in async will get their framework's
        cancellation exception - this is just to terminate call processing
        back through the call stacks
        """

        pass

    # this is used to track the currently processing call for all controllers
    # as _tls.current_call
    _tls = threading.local()

    class _CallTracker:
        """
        All the details for the lifecycle of a call.
        """

        __slots__ = (
            # Used for result ready.  asyncio uses Future which also
            # includes the result/exception, while trio/anyio use the
            # result/is_exception fields
            "completion",
            # result value or exception
            "result",
            # is it an exception?
            "is_exception",
            # BoxedCall to make
            "call",
            # deadline in event loop clock
            "deadline_loop",
            # deadline in worker thread relative to monotonic clock
            "deadline_monotonic",
            # cancel indication
            "is_cancelled",
            # if a callback is async and run back in the event loop then
            # this can be called to cancel it
            "cancel_async_cb",
        )

        completion: asyncio.Future | anyio.Event | trio.Event
        result: Any | BaseException
        is_exception: bool
        call: Callable[[], Any]
        deadline_loop: None | float | int
        deadline_monotonic: None | float | int
        is_cancelled: bool
        cancel_async_cb: Callable[[], Any] | None

        def __init__(self, completion: asyncio.Event | anyio.Event | trio.Event, call: Callable[[], Any]) -> None:
            self.is_exception = False
            self.is_cancelled = False
            self.completion = completion
            self.call = call
            self.deadline_loop = None
            self.deadline_monotonic = None
            self.cancel_async_cb = None

        def set_deadline(self, value: int | float, loop_time: int | float):
            self.deadline_loop = value
            if value is not math.inf:
                self.deadline_monotonic = value - loop_time + time.monotonic()

        def monotonic_exceeded(self) -> bool:
            return self.deadline_monotonic is not None and time.monotonic() > self.deadline_monotonic

        def cancel(self):
            "Cancel the call"

            self.is_cancelled = True
            if self.cancel_async_cb is not None:
                self.cancel_async_cb()

    # These are used to directly return values and exceptions without
    # sending to the worker thread such as prefetched query rows.
    async def _coro_for_value(value):
        return value

    if sys.version_info < (3, 12):
        # Python 3.12 unified the exc type, value, and traceback into the single
        # exception object.

        async def _coro_for_exception(exc):
            raise exc[0](exc[1]).with_traceback(exc[2])

    else:

        async def _coro_for_exception(exc):
            raise exc

    # this is separate to avoid the version issues above
    async def _coro_for_stopasynciteration():
        raise StopAsyncIteration

    class AsyncIO:
        """:class:`Controller <apsw.AsyncConnectionController>` for :mod:`asyncio`"""

        def configure(self, db: apsw.Connection):
            "Setup database, just after it is created"
            apsw.async_run_coro = self.async_run_coro

            for hook in apsw.connection_hooks:
                hook(db)
            db.set_progress_handler(self.progress_checker, check_progress_steps.get(), id=self)

        async def send(self, call: Callable[[], Any]):
            "Send call to worker"
            tracker = _CallTracker(self.loop.create_future(), call)
            if (this_deadline := deadline.get()) is not None:
                tracker.set_deadline(this_deadline, self.loop.time())
            self.queue.put(tracker)
            try:
                await tracker.completion
                return tracker.completion.result()
            except:
                tracker.cancel()
                raise

        def close(self):
            "Called on connection close, so the worker thread can be stopped"
            # How we tell the worker thread to exit
            self.queue.put(None)

        def progress_checker(self):
            "Periodic check for cancellation and deadlines"
            if _tls.current_call.is_cancelled:
                raise _Cancelled("cancelled in progress checked")
            if _tls.current_call.monotonic_exceeded():
                raise TimeoutError()
            return False

        def worker_thread_run(self):
            "Does the enqueued call processing in the worker thread"

            q = self.queue

            while (tracker := q.get()) is not None:
                if not tracker.is_cancelled:
                    # we don't restore this because the queue is not
                    # re-entrant, so there is no point
                    _tls.current_call = tracker

                    try:
                        # should we even start?
                        if tracker.monotonic_exceeded():
                            raise TimeoutError()
                        self.loop.call_soon_threadsafe(self.set_future_result, tracker.completion, tracker.call())

                    except BaseException as exc:
                        # BaseException is deliberately used because CancelledError
                        # is a subclass of it
                        self.loop.call_soon_threadsafe(self.set_future_exception, tracker.completion, exc)

        def set_future_result(self, future: asyncio.Future, value: Any):
            if not future.done():
                future.set_result(value)

        def set_future_exception(self, future: asyncio.Future, exc: BaseException):
            if not future.done():
                future.set_exception(exc)

        def async_run_coro(self, coro: Coroutine):
            "Called in worker thread to run a coroutine in the event loop"

            tracker = _tls.current_call

            try:
                if tracker.is_cancelled:
                    raise _Cancelled("cancelled in async_run_coro")

                return asyncio.run_coroutine_threadsafe(
                    self.run_coro_in_loop(coro, tracker, contextvars.copy_context()), self.loop
                ).result()

            finally:
                coro.close()

        if sys.version_info < (3, 11):

            async def run_coro_in_loop(self, coro: Coroutine, tracker: _CallTracker, context: contextvars.Context) -> Any:
                "Executes the coro in the event loop"

                task = context.run(asyncio.create_task, coro)
                tracker.cancel_async_cb = task.cancel
                if tracker.is_cancelled:
                    return

                if tracker.deadline_loop is not None:
                    return await asyncio.wait_for(task, tracker.deadline_loop - self.loop.time())
                return await task

        elif sys.version_info < (3, 12):

            async def run_coro_in_loop(self, coro: Coroutine, tracker: _CallTracker, context: contextvars.Context) -> Any:
                "Executes the coro in the event loop"

                task = context.run(asyncio.create_task, coro)
                tracker.cancel_async_cb = task.cancel
                if tracker.is_cancelled:
                    return

                async with asyncio.timeout_at(tracker.deadline_loop):
                    return await task

        else:

            async def run_coro_in_loop(self, coro: Coroutine, tracker: _CallTracker, context: contextvars.Context) -> Any:
                "Executes the coro in the event loop"

                # Note: we don't set cancel_async_cb back to None on exit
                # because cancelling an already completed task is doesn't
                # error or cause problems.

                task = asyncio.create_task(coro, context=context)
                tracker.cancel_async_cb = task.cancel
                if tracker.is_cancelled:
                    return

                async with asyncio.timeout_at(tracker.deadline_loop):
                    return await task

        def __init__(self, *, thread_name: str = "asyncio apsw background worker"):
            self.queue: queue.SimpleQueue[_CallTracker | None] = queue.SimpleQueue()
            self.loop = asyncio.get_running_loop()
            threading.Thread(name=thread_name, target=self.worker_thread_run).start()

    class Trio:
        """:class:`Controller <apsw.AsyncConnectionController>` for |trio|"""

        def configure(self, db: apsw.Connection):
            "Setup database, just after it is created"
            apsw.async_run_coro = self.async_run_coro

            for hook in apsw.connection_hooks:
                hook(db)
            db.set_progress_handler(self.progress_checker, check_progress_steps.get(), id=self)

        async def send(self, call: Callable[[], Any]):
            "Enqueues call to worker thread"
            tracker = _CallTracker(trio.Event(), call)
            if (this_deadline := deadline.get()) is None:
                this_deadline = trio.current_effective_deadline()
            tracker.set_deadline(this_deadline, trio.current_time())

            self.queue.put(tracker)
            try:
                await tracker.completion.wait()
                if tracker.is_exception:
                    raise tracker.result
                return tracker.result
            except:
                tracker.cancel()
                raise

        def close(self):
            "Called on connection close, so the worker thread can be stopped"
            self.queue.put(None)

        def progress_checker(self):
            "Periodic check for cancellation and deadlines"
            if _tls.current_call.is_cancelled:
                raise _Cancelled("cancelled in progress handler")
            if _tls.current_call.monotonic_exceeded():
                raise trio.TooSlowError("deadline exceeded in progress handler")
            return False

        def worker_thread_run(self):
            "Does the enqueued call processing in the worker thread"
            q = self.queue

            while (tracker := q.get()) is not None:
                if not tracker.is_cancelled:
                    # we don't restore this because the queue is not
                    # re-entrant, so there is no point
                    _tls.current_call = tracker

                    try:
                        # should we even start?
                        if tracker.monotonic_exceeded():
                            raise trio.TooSlowError()
                        tracker.result = tracker.call()

                    except BaseException as exc:
                        # BaseException is deliberately used because Cancelled
                        # is a subclass of it
                        tracker.result = exc
                        tracker.is_exception = True

                    finally:
                        self.token.run_sync_soon(tracker.completion.set)

        def async_run_coro(self, coro: Coroutine):
            "Called in worker thread to run a coroutine in the event loop"
            try:
                tracker = _tls.current_call
                if tracker.is_cancelled:
                    raise _Cancelled("Cancelled in async_run_coro")

                return trio.from_thread.run(self.run_coro_in_loop, coro, tracker, trio_token=self.token)
            finally:
                coro.close()

        async def run_coro_in_loop(self, coro: Coroutine, tracker: _CallTracker):
            "Executes the coro in the event loop"
            with trio.fail_at(deadline=math.inf if tracker.deadline_loop is None else tracker.deadline_loop) as scope:
                tracker.cancel_async_cb = scope.cancel
                if tracker.is_cancelled:
                    return
                return await coro

        def __init__(self, *, thread_name: str = "trio apsw background worker"):
            global trio
            import trio

            self.queue: queue.SimpleQueue[_CallTracker | None] = queue.SimpleQueue()
            self.token = trio.lowlevel.current_trio_token()
            threading.Thread(name=thread_name, target=self.worker_thread_run).start()

    class AnyIO:
        """:class:`Controller <apsw.AsyncConnectionController>` for |anyio|"""

        def configure(self, db: apsw.Connection):
            "Setup database, just after it is created"
            apsw.async_run_coro = self.async_run_coro

            for hook in apsw.connection_hooks:
                hook(db)
            db.set_progress_handler(self.progress_checker, check_progress_steps.get(), id=self)

        async def send(self, call: Callable[[], Any]):
            "Enqueues call to worker thread"

            tracker = _CallTracker(anyio.Event(), call)
            if (this_deadline := deadline.get()) is None:
                this_deadline = anyio.current_effective_deadline()
            tracker.set_deadline(this_deadline, anyio.current_time())

            self.queue.put(tracker)
            try:
                await tracker.completion.wait()
                if tracker.is_exception:
                    raise tracker.result
                return tracker.result
            except:
                tracker.cancel()
                raise

        def close(self):
            "Called on connection close, so the worker thread can be stopped"
            self.queue.put(None)

        def progress_checker(self):
            "Periodic check for cancellation and deadlines"
            tracker = _tls.current_call
            if tracker.is_cancelled:
                raise _Cancelled("cancelled in progress handler")
            if tracker.monotonic_exceeded():
                raise TimeoutError("deadline exceeded in progress handler")
            return False

        def worker_thread_run(self):
            "Does the enqueued call processing in the worker thread"
            q = self.queue

            while (tracker := q.get()) is not None:
                if not tracker.is_cancelled:
                    # we don't restore this because the queue is not
                    # re-entrant, so there is no point
                    _tls.current_call = tracker

                    try:
                        # should we even start?
                        if tracker.monotonic_exceeded():
                            raise TimeoutError("Deadline exceeded in queue")
                        tracker.result = tracker.call()

                    except BaseException as exc:
                        # BaseException is deliberately used because CancelledError
                        # is a subclass of it
                        tracker.result = exc
                        tracker.is_exception = True

                    finally:
                        anyio.from_thread.run_sync(tracker.completion.set, token=self.token)

        def async_run_coro(self, coro: Coroutine):
            "Called in worker thread to run a coroutine in the event loop"

            try:
                tracker = _tls.current_call
                if tracker.is_cancelled:
                    raise _Cancelled("Cancelled in async_run_coro")
                if tracker.monotonic_exceeded():
                    raise TimeoutError("deadline exceeded in async_run_coro")
                return anyio.from_thread.run(self.run_coro_in_loop, coro, tracker, token=self.token)
            finally:
                coro.close()

        async def run_coro_in_loop(self, coro: Coroutine, tracker: _CallTracker):
            "Executes coro in the event loop"

            with anyio.fail_after(
                math.inf if tracker.deadline_loop is None else tracker.deadline_loop - anyio.current_time()
            ) as scope:
                tracker.cancel_async_cb = scope.cancel
                if tracker.is_cancelled:
                    return
                return await coro

        def __init__(self, *, thread_name: str = "anyio apsw background worker"):
            global anyio
            import anyio

            self.queue: queue.SimpleQueue[_CallTracker | None] = queue.SimpleQueue()
            self.token = anyio.lowlevel.current_token()
            threading.Thread(name=thread_name, target=self.worker_thread_run).start()

    # True means they can be tried, False means too old etc
    _anyio_usable = True
    _trio_usable = True

    def Auto() -> Trio | AsyncIO | AnyIO:
        """
        Automatically detects the current async framework running event
        loop and returns the appropriate controller.  This is the default
        for :attr:`apsw.async_controller`.

        **AnyIO note**

            The :class:`AnyIO` controller is only returned if
            :func:`anyio.run` is in the call stack.

            If you are simultaneously using anyio and another framework
            then you should manually configure
            :attr:`apsw.async_controller` to get the one you want.

            This matters especially for timeouts and cancellations where
            each framework is different.

        :exc:`RuntimeError` is raised if the framework can't be detected.

        """
        global _anyio_usable, _trio_usable
        # This variable tracks which class to use.  It is instantiated
        # outside of the try/except blocks so exceptions in its
        # initialization will be raised.
        found = None

        if found is None and "anyio" in sys.modules and _anyio_usable:
            try:
                import anyio

                # this checks if an anyio supported event loop is running
                # but anyio works with asyncio/trio as the loop ...
                anyio.get_current_task()

                # ... so we need to check if anyio.run is in the call stack
                anyio_run_code = anyio.run.__code__

                frame = sys._getframe()
                while frame:
                    if frame.f_code is anyio_run_code:
                        found = AnyIO
                        break
                    frame = frame.f_back

                if found:
                    found = None
                    # check its version is ok
                    import importlib.metadata
                    ver = tuple(map(int, importlib.metadata.version("anyio").split(".")))
                    if  ver >= (4, 11, 0):
                        found = AnyIO
                    else:
                        logger.error(f"anyio {ver} was found but is too old to be used with the AnyIO controller")
                        _anyio_usable = False

            except:
                pass

        if found is None and "trio" in sys.modules and _trio_usable:
            try:
                import trio

                trio.lowlevel.current_trio_token()

                # check its version is ok
                import importlib.metadata
                ver = tuple(map(int, importlib.metadata.version("trio").split(".")))
                if  ver >= (0, 20, 0):
                    found = Trio
                else:
                    logger.error(f"trio {ver=} was found but is too old to be used with the Trio controller")
                    _trio_usable = False

            except:
                pass

        if found is None and "asyncio" in sys.modules:
            try:
                asyncio.get_running_loop()
                found = AsyncIO
            except:
                pass

        if not found:
            raise RuntimeError("Unable to determine current Async environment")

        return found()