Source code for sqlspec.utils.sync_tools

"""Utilities for async/sync interoperability in SQLSpec.

This module provides utilities for converting between async and sync functions,
managing concurrency limits, and handling context managers. Used primarily
for adapter implementations that need to support both sync and async patterns.
"""

import asyncio
import atexit
import concurrent.futures
import contextvars
import functools
import inspect
import os
import sys
import threading
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast

from typing_extensions import ParamSpec

from sqlspec.utils.env import get_env
from sqlspec.utils.module_loader import module_available
from sqlspec.utils.portal import get_global_portal

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Coroutine
    from types import TracebackType

if module_available("uvloop"):
    import uvloop  # pyright: ignore[reportMissingImports]
else:
    uvloop = None  # type: ignore[assignment,unused-ignore]


ReturnT = TypeVar("ReturnT")
ParamSpecT = ParamSpec("ParamSpecT")
T = TypeVar("T")

DEFAULT_ASYNC_THREAD_LIMIT = 8
ASYNC_THREAD_LIMIT_ENV = "SQLSPEC_ASYNC_THREAD_LIMIT"
_ASYNC_THREAD_NAME_PREFIX = "sqlspec-async"


[docs] class NoValue: """Sentinel class for missing values."""
NO_VALUE = NoValue()
[docs] class CapacityLimiter: """Limits the number of concurrent operations using a semaphore."""
[docs] def __init__(self, total_tokens: int) -> None: """Initialize the capacity limiter. Args: total_tokens: Maximum number of concurrent operations allowed """ self._total_tokens = total_tokens self._semaphore_instance: asyncio.Semaphore | None = None self._pid: int | None = None
@property def _semaphore(self) -> asyncio.Semaphore: """Lazy initialization of asyncio.Semaphore with per-process tracking. Reinitializes the semaphore if running in a new process (detected via PID). This ensures pytest-xdist workers each get their own semaphore bound to their event loop, preventing cross-process deadlocks. """ current_pid = os.getpid() if self._semaphore_instance is None or self._pid != current_pid: self._semaphore_instance = asyncio.Semaphore(self._total_tokens) self._pid = current_pid return self._semaphore_instance
[docs] async def acquire(self) -> None: """Acquire a token from the semaphore.""" await self._semaphore.acquire()
[docs] def release(self) -> None: """Release a token back to the semaphore.""" self._semaphore.release()
@property def total_tokens(self) -> int: """Get the total number of tokens available.""" return self._total_tokens @total_tokens.setter def total_tokens(self, value: int) -> None: self._total_tokens = value self._semaphore_instance = None self._pid = None
[docs] async def __aenter__(self) -> None: """Async context manager entry.""" await self.acquire()
[docs] async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> None: """Async context manager exit.""" self.release()
_default_async_executor: concurrent.futures.ThreadPoolExecutor | None = None _default_async_executor_pid: int | None = None _managed_async_executor: concurrent.futures.ThreadPoolExecutor | None = None _managed_async_executor_lock = threading.Lock() _managed_async_executor_max_workers: int | None = None _managed_async_executor_pid: int | None = None _managed_async_executor_resolved_max_workers: int | None = None
[docs] def set_default_async_executor(executor: "concurrent.futures.ThreadPoolExecutor | None") -> None: """Set a caller-owned process-local default thread executor for ``async_``. SQLSpec never shuts down caller-provided executors. If a process fork is detected later, the stored executor is cleared instead of reused, and ``async_`` falls back to SQLSpec's managed default pool. Args: executor: Caller-owned thread executor to use by default, or ``None`` to clear it and use SQLSpec's managed pool. """ global _default_async_executor, _default_async_executor_pid validated_executor = _validate_async_thread_executor(executor) with _managed_async_executor_lock: _default_async_executor = validated_executor _default_async_executor_pid = os.getpid() if validated_executor is not None else None
[docs] def enable_default_async_thread_pool(max_workers: int | None = None) -> None: """Configure and eagerly create SQLSpec's managed default executor for ``async_``. Args: max_workers: Optional worker limit. When omitted, ``SQLSPEC_ASYNC_THREAD_LIMIT`` is read, falling back to ``DEFAULT_ASYNC_THREAD_LIMIT``. """ global _managed_async_executor_max_workers with _managed_async_executor_lock: _managed_async_executor_max_workers = max_workers _ensure_managed_async_executor_locked(os.getpid())
[docs] def get_default_async_executor() -> concurrent.futures.ThreadPoolExecutor: """Return the configured or managed default executor for ``async_``. Returns: Caller-owned default thread executor or SQLSpec-managed executor. """ global _default_async_executor, _default_async_executor_pid current_pid = os.getpid() with _managed_async_executor_lock: if _default_async_executor is not None: if _default_async_executor_pid == current_pid: return _default_async_executor _default_async_executor = None _default_async_executor_pid = None return _ensure_managed_async_executor_locked(current_pid)
[docs] def shutdown_default_async_executor(wait: bool = False) -> None: """Clear default async executors and shut down SQLSpec-owned pools. The next ``async_`` call recreates SQLSpec's managed default pool. Args: wait: Whether to wait for SQLSpec-managed executor tasks to finish. """ global _default_async_executor, _default_async_executor_pid global _managed_async_executor, _managed_async_executor_max_workers, _managed_async_executor_pid global _managed_async_executor_resolved_max_workers with _managed_async_executor_lock: if _managed_async_executor is not None: _managed_async_executor.shutdown(wait=wait) _managed_async_executor = None _managed_async_executor_max_workers = None _managed_async_executor_pid = None _managed_async_executor_resolved_max_workers = None _default_async_executor = None _default_async_executor_pid = None
def _ensure_managed_async_executor_locked(current_pid: int) -> concurrent.futures.ThreadPoolExecutor: global _managed_async_executor, _managed_async_executor_pid, _managed_async_executor_resolved_max_workers max_workers = _resolve_managed_async_thread_limit() if ( _managed_async_executor is None or _managed_async_executor_pid != current_pid or _managed_async_executor_resolved_max_workers != max_workers ): if _managed_async_executor is not None: _managed_async_executor.shutdown(wait=False) _managed_async_executor = concurrent.futures.ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix=_ASYNC_THREAD_NAME_PREFIX ) _managed_async_executor_pid = current_pid _managed_async_executor_resolved_max_workers = max_workers return _managed_async_executor def _validate_async_thread_executor(executor: object) -> "concurrent.futures.ThreadPoolExecutor | None": if executor is None or isinstance(executor, concurrent.futures.ThreadPoolExecutor): return executor msg = "async_ executors must be concurrent.futures.ThreadPoolExecutor instances to preserve contextvars" raise TypeError(msg) def _resolve_managed_async_thread_limit() -> int: if _managed_async_executor_max_workers is None: max_workers = get_env(ASYNC_THREAD_LIMIT_ENV, DEFAULT_ASYNC_THREAD_LIMIT)() else: max_workers = _managed_async_executor_max_workers return max(1, max_workers) atexit.register(shutdown_default_async_executor) class _RunWrapper(Generic[ParamSpecT, ReturnT]): __slots__ = ("__dict__", "_function") def __init__(self, async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]") -> None: self._function = async_function functools.update_wrapper(self, async_function) def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": partial_f = functools.partial(self._function, *args, **kwargs) try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is not None: if loop.is_running(): with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, partial_f()) return future.result() return asyncio.run(partial_f()) if uvloop and sys.platform != "win32": return uvloop.run(partial_f()) # pyright: ignore[reportUnknownMemberType] return asyncio.run(partial_f())
[docs] def run_(async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]") -> "Callable[ParamSpecT, ReturnT]": """Convert an async function to a blocking function using asyncio.run(). Args: async_function: The async function to convert. Returns: A blocking function that runs the async function. """ return _RunWrapper(async_function)
[docs] def await_( async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]", raise_sync_error: bool = False ) -> "Callable[ParamSpecT, ReturnT]": """Convert an async function to a blocking one, running in the main async loop. When no event loop exists, automatically creates and uses a global portal for async-to-sync bridging via background thread. Set raise_sync_error=True to disable this behavior and raise errors instead. Args: async_function: The async function to convert. raise_sync_error: If True, raises RuntimeError when no loop exists. If False (default), uses portal pattern for automatic bridging. Returns: A blocking function that runs the async function. """ return _AwaitWrapper(async_function, raise_sync_error)
[docs] def async_( function: "Callable[ParamSpecT, ReturnT]", *, limiter: "CapacityLimiter | None" = None, executor: "concurrent.futures.ThreadPoolExecutor | None" = None, ) -> "Callable[ParamSpecT, Awaitable[ReturnT]]": """Convert a blocking function to an async one using a thread executor. Args: function: The blocking function to convert. limiter: Limit the total number of threads. executor: Optional thread executor to use instead of SQLSpec's managed default thread executor. Returns: An async function that runs the original function in a thread. """ return _AsyncWrapper(function, limiter, executor)
[docs] def ensure_async_( function: "Callable[ParamSpecT, Awaitable[ReturnT] | ReturnT]", ) -> "Callable[ParamSpecT, Awaitable[ReturnT]]": """Convert a function to an async one if it is not already. Args: function: The function to convert. Returns: An async function that runs the original function. """ if inspect.iscoroutinefunction(function): return function return _EnsureAsyncWrapper(function)
class _AwaitWrapper(Generic[ParamSpecT, ReturnT]): __slots__ = ("__dict__", "_function", "_raise_sync_error") def __init__( self, async_function: "Callable[ParamSpecT, Coroutine[Any, Any, ReturnT]]", raise_sync_error: bool ) -> None: self._function = async_function self._raise_sync_error = raise_sync_error functools.update_wrapper(self, async_function) def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": partial_f = functools.partial(self._function, *args, **kwargs) try: loop = asyncio.get_running_loop() except RuntimeError: if self._raise_sync_error: msg = "Cannot run async function" raise RuntimeError(msg) from None portal = get_global_portal() typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) return portal.call(typed_partial) if loop.is_running(): try: current_task = asyncio.current_task(loop=loop) except RuntimeError: current_task = None if current_task is not None: if self._raise_sync_error: msg = "await_ cannot be called from within an async task running on the same event loop. Use 'await' instead." raise RuntimeError(msg) portal = get_global_portal() typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) return portal.call(typed_partial) future = asyncio.run_coroutine_threadsafe(partial_f(), loop) return future.result() if self._raise_sync_error: msg = "Cannot run async function" raise RuntimeError(msg) portal = get_global_portal() typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) return portal.call(typed_partial) class _AsyncWrapper(Generic[ParamSpecT, ReturnT]): __slots__ = ("__dict__", "_executor", "_function", "_limiter") def __init__( self, function: "Callable[ParamSpecT, ReturnT]", limiter: "CapacityLimiter | None", executor: "concurrent.futures.ThreadPoolExecutor | None", ) -> None: self._executor = _validate_async_thread_executor(executor) self._function = function self._limiter = limiter functools.update_wrapper(self, function) async def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": executor = self._executor if self._executor is not None else get_default_async_executor() if self._limiter is not None: async with self._limiter: return await self._run_sync(executor, *args, **kwargs) return await self._run_sync(executor, *args, **kwargs) async def _run_sync( self, executor: "concurrent.futures.ThreadPoolExecutor", *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs" ) -> "ReturnT": loop = asyncio.get_running_loop() ctx = contextvars.copy_context() call = functools.partial(ctx.run, self._function, *args, **kwargs) # Equivalent to run_in_executor(), without Python 3.14's deprecated debug-loop coroutine probe. return await asyncio.wrap_future(executor.submit(call), loop=loop) class _EnsureAsyncWrapper(Generic[ParamSpecT, ReturnT]): __slots__ = ("__dict__", "_function") def __init__(self, function: "Callable[ParamSpecT, Awaitable[ReturnT] | ReturnT]") -> None: self._function = function functools.update_wrapper(self, function) async def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> "ReturnT": result = self._function(*args, **kwargs) if inspect.isawaitable(result): return await cast("Awaitable[ReturnT]", result) return result class _ContextManagerWrapper(Generic[T]): def __init__(self, cm: AbstractContextManager[T]) -> None: self._cm = cm async def __aenter__(self) -> T: return self._cm.__enter__() async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": return self._cm.__exit__(exc_type, exc_val, exc_tb)
[docs] def with_ensure_async_( obj: "AbstractContextManager[T] | AbstractAsyncContextManager[T]", ) -> "AbstractAsyncContextManager[T]": """Convert a context manager to an async one if it is not already. Args: obj: The context manager to convert. Returns: An async context manager that runs the original context manager. """ if isinstance(obj, AbstractContextManager): return cast("AbstractAsyncContextManager[T]", _ContextManagerWrapper(obj)) return obj
[docs] async def get_next(iterable: Any, default: Any = NO_VALUE, *args: Any) -> Any: # pragma: no cover """Return the next item from an async iterator. Args: iterable: An async iterable. default: An optional default value to return if the iterable is empty. *args: The remaining args Returns: The next value of the iterable. """ if isinstance(default, NoValue): return await anext(iterable) return await anext(iterable, default)