Source code for advanced_alchemy.service.composition

"""Framework-agnostic service composition primitives.

Example:
    Compose providers that yield services for a caller-owned async session:

    .. code-block:: python

        async with (
            ServiceComposition.starting_with(provide_users_service)
            .add(provide_roles_service)
            .open(session=db_session)
        ) as (users, roles):
            await users.create(data)
            await roles.assign_default_role(data)
"""

from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any, Generic, cast

from typing_extensions import TypeVar, TypeVarTuple, Unpack

from advanced_alchemy.service._typing import ServiceProvider

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator

    from sqlalchemy.ext.asyncio import AsyncSession

__all__ = ("ServiceComposition", "ServiceProvider")

T = TypeVar("T")
Ts = TypeVarTuple("Ts")


@asynccontextmanager
async def _aclose_generator(
    generator: "AsyncGenerator[Any, None]",
) -> "AsyncGenerator[AsyncGenerator[Any, None], None]":
    # Python 3.9 lacks contextlib.aclosing; replace this helper when 3.9 support is dropped.
    try:
        yield generator
    finally:
        await generator.aclose()


[docs] class ServiceComposition(Generic[Unpack[Ts]]): """Builder for composing services with a shared async session. Use :meth:`starting_with` to seed the composition, then chain :meth:`add` calls to preserve each service's positional type. """ __slots__ = ("_providers",)
[docs] def __init__(self, providers: "tuple[ServiceProvider[Any], ...]" = ()) -> None: self._providers = providers
[docs] @classmethod def starting_with(cls, provider: "ServiceProvider[T]") -> "ServiceComposition[T]": """Begin a composition with the first provider.""" return cast("ServiceComposition[T]", cls((provider,)))
[docs] def add(self, provider: "ServiceProvider[T]") -> "ServiceComposition[Unpack[Ts], T]": """Append a provider, extending the service tuple by one position.""" return cast("ServiceComposition[Unpack[Ts], T]", ServiceComposition((*self._providers, provider)))
[docs] @asynccontextmanager async def open(self, *, session: "AsyncSession") -> "AsyncGenerator[tuple[Unpack[Ts]], None]": """Enter all providers on ``session`` and yield their services. The session lifecycle remains owned by the caller. Entered provider generators are closed in reverse order on normal exit, user exception, or partial-entry failure. """ services: list[Any] = [] async with AsyncExitStack() as stack: for provider in self._providers: generator = await stack.enter_async_context(_aclose_generator(provider(session))) services.append(await generator.__anext__()) yield cast("tuple[Unpack[Ts]]", tuple(services))