"""Singleton decorator for async functions.""" from __future__ import annotations import asyncio import functools from typing import TYPE_CHECKING, Any, TypeVar, cast if TYPE_CHECKING: from collections.abc import Callable, Coroutine T = TypeVar("T") # Global cache for singleton results _SINGLETON_CACHE: dict[str, Any] = {} def singleton( key: str, ) -> Callable[ [Callable[[], Coroutine[Any, Any, T]]], Callable[[], Coroutine[Any, Any, T]] ]: """Decorate an async function that should be called once. Result will be cached and simultaneous calls will be handled. """ def wrapper( func: Callable[[], Coroutine[Any, Any, T]], ) -> Callable[[], Coroutine[Any, Any, T]]: """Wrap a function with caching logic.""" @functools.wraps(func) async def async_wrapped() -> T: if key not in _SINGLETON_CACHE: # Use a future to handle simultaneous calls loop = asyncio.get_running_loop() future: asyncio.Future[T] = loop.create_future() _SINGLETON_CACHE[key] = future try: result = await func() except Exception as e: # On exception, remove the future so next call can retry # Set exception first so waiters get it, then remove from cache future.set_exception(e) # Suppress "Future exception was never retrieved" when there # are no concurrent waiters; awaiters still receive it. future.exception() del _SINGLETON_CACHE[key] raise else: # Replace future with the actual result _SINGLETON_CACHE[key] = result future.set_result(result) return result obj_or_future = _SINGLETON_CACHE[key] if isinstance(obj_or_future, asyncio.Future): # Another call is already in progress, wait for it # This will either return the result or raise the exception return cast("T", await obj_or_future) return cast("T", obj_or_future) return async_wrapped return wrapper