Skip to content

Commit 866b4a9

Browse files
committed
feat(service): add service composition primitives
1 parent 9f08f2f commit 866b4a9

6 files changed

Lines changed: 318 additions & 0 deletions

File tree

advanced_alchemy/service/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
SQLAlchemySyncRepositoryReadService,
2020
SQLAlchemySyncRepositoryService,
2121
)
22+
from advanced_alchemy.service._typing import ServiceProvider, ServiceWithSession
2223
from advanced_alchemy.service._util import ResultConverter, find_filter
24+
from advanced_alchemy.service.composition import ServiceComposition
2325
from advanced_alchemy.service.pagination import OffsetPagination
2426
from advanced_alchemy.typing import ATTRS_INSTALLED
2527
from advanced_alchemy.utils.serialization import (
@@ -77,6 +79,9 @@
7779
"SQLAlchemySyncQueryService",
7880
"SQLAlchemySyncRepositoryReadService",
7981
"SQLAlchemySyncRepositoryService",
82+
"ServiceComposition",
83+
"ServiceProvider",
84+
"ServiceWithSession",
8085
"SupportedSchemaModel",
8186
"fields",
8287
"find_filter",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Service-side typing surface."""
2+
3+
from collections.abc import AsyncGenerator, Callable
4+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
5+
6+
from typing_extensions import TypeAlias, TypeVar
7+
8+
if TYPE_CHECKING:
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
__all__ = ("ServiceProvider", "ServiceWithSession")
12+
13+
T = TypeVar("T")
14+
15+
ServiceProvider: TypeAlias = Callable[["AsyncSession"], AsyncGenerator[T, None]]
16+
"""Callable that yields a service instance for a session."""
17+
18+
19+
@runtime_checkable
20+
class ServiceWithSession(Protocol):
21+
"""Structural protocol for services constructible with ``session=``."""
22+
23+
def __init__(self, *, session: "AsyncSession") -> None: ...
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Framework-agnostic service composition primitives.
2+
3+
Example:
4+
Compose providers that yield services for a caller-owned async session:
5+
6+
.. code-block:: python
7+
8+
async with (
9+
ServiceComposition.starting_with(provide_users_service)
10+
.add(provide_roles_service)
11+
.open(session=db_session)
12+
) as (users, roles):
13+
await users.create(data)
14+
await roles.assign_default_role(data)
15+
"""
16+
17+
from contextlib import AsyncExitStack, asynccontextmanager
18+
from typing import TYPE_CHECKING, Any, Generic, cast
19+
20+
from typing_extensions import TypeVar, TypeVarTuple, Unpack
21+
22+
from advanced_alchemy.service._typing import ServiceProvider
23+
24+
if TYPE_CHECKING:
25+
from collections.abc import AsyncGenerator
26+
27+
from sqlalchemy.ext.asyncio import AsyncSession
28+
29+
__all__ = ("ServiceComposition", "ServiceProvider")
30+
31+
T = TypeVar("T")
32+
Ts = TypeVarTuple("Ts")
33+
34+
35+
@asynccontextmanager
36+
async def _aclose_generator(
37+
generator: "AsyncGenerator[Any, None]",
38+
) -> "AsyncGenerator[AsyncGenerator[Any, None], None]":
39+
# Python 3.9 lacks contextlib.aclosing; replace this helper when 3.9 support is dropped.
40+
try:
41+
yield generator
42+
finally:
43+
await generator.aclose()
44+
45+
46+
class ServiceComposition(Generic[Unpack[Ts]]):
47+
"""Builder for composing services with a shared async session.
48+
49+
Use :meth:`starting_with` to seed the composition, then chain
50+
:meth:`add` calls to preserve each service's positional type.
51+
"""
52+
53+
__slots__ = ("_providers",)
54+
55+
def __init__(self, providers: "tuple[ServiceProvider[Any], ...]" = ()) -> None:
56+
self._providers = providers
57+
58+
@classmethod
59+
def starting_with(cls, provider: "ServiceProvider[T]") -> "ServiceComposition[T]":
60+
"""Begin a composition with the first provider."""
61+
return cast("ServiceComposition[T]", cls((provider,)))
62+
63+
def add(self, provider: "ServiceProvider[T]") -> "ServiceComposition[Unpack[Ts], T]":
64+
"""Append a provider, extending the service tuple by one position."""
65+
return cast("ServiceComposition[Unpack[Ts], T]", ServiceComposition((*self._providers, provider)))
66+
67+
@asynccontextmanager
68+
async def open(self, *, session: "AsyncSession") -> "AsyncGenerator[tuple[Unpack[Ts]], None]":
69+
"""Enter all providers on ``session`` and yield their services.
70+
71+
The session lifecycle remains owned by the caller. Entered provider
72+
generators are closed in reverse order on normal exit, user exception,
73+
or partial-entry failure.
74+
"""
75+
services: list[Any] = []
76+
async with AsyncExitStack() as stack:
77+
for provider in self._providers:
78+
generator = await stack.enter_async_context(_aclose_generator(provider(session)))
79+
services.append(await generator.__anext__())
80+
yield cast("tuple[Unpack[Ts]]", tuple(services))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit tests for service helpers."""
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Tests for service composition primitives."""
2+
3+
from collections.abc import AsyncGenerator
4+
from typing import Any
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from advanced_alchemy.service import ServiceComposition, ServiceProvider, ServiceWithSession
11+
12+
pytestmark = pytest.mark.unit
13+
14+
15+
class FakeServiceA:
16+
def __init__(self, *, session: AsyncSession) -> None:
17+
self.session = session
18+
19+
20+
class FakeServiceB:
21+
def __init__(self, *, session: AsyncSession) -> None:
22+
self.session = session
23+
24+
25+
class FakeServiceC:
26+
def __init__(self, *, session: AsyncSession) -> None:
27+
self.session = session
28+
29+
30+
async def provider_a(session: AsyncSession) -> AsyncGenerator[FakeServiceA, None]:
31+
yield FakeServiceA(session=session)
32+
33+
34+
async def provider_b(session: AsyncSession) -> AsyncGenerator[FakeServiceB, None]:
35+
yield FakeServiceB(session=session)
36+
37+
38+
async def provider_c(session: AsyncSession) -> AsyncGenerator[FakeServiceC, None]:
39+
yield FakeServiceC(session=session)
40+
41+
42+
def test_service_provider_alias_accepts_async_generator_provider() -> None:
43+
provider: ServiceProvider[FakeServiceA] = provider_a
44+
45+
assert provider is provider_a
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_starting_with_returns_typed_composition() -> None:
50+
session = MagicMock(spec=AsyncSession)
51+
composer = ServiceComposition.starting_with(provider_a)
52+
53+
async with composer.open(session=session) as services:
54+
(a,) = services
55+
56+
assert isinstance(a, FakeServiceA)
57+
assert a.session is session
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_chain_three_providers_share_session() -> None:
62+
session = MagicMock(spec=AsyncSession)
63+
composer = ServiceComposition.starting_with(provider_a).add(provider_b).add(provider_c)
64+
65+
async with composer.open(session=session) as services:
66+
a, b, c = services
67+
68+
assert isinstance(a, FakeServiceA)
69+
assert isinstance(b, FakeServiceB)
70+
assert isinstance(c, FakeServiceC)
71+
assert a.session is b.session is c.session is session
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_partial_failure_closes_entered_providers() -> None:
76+
cleanup_ran = False
77+
78+
async def cleanup_provider(session: AsyncSession) -> AsyncGenerator[FakeServiceA, None]:
79+
try:
80+
yield FakeServiceA(session=session)
81+
finally:
82+
nonlocal cleanup_ran
83+
cleanup_ran = True
84+
85+
async def failing_provider(session: AsyncSession) -> AsyncGenerator[Any, None]:
86+
if repr(session) == "unused":
87+
yield None
88+
raise RuntimeError("fail at entry")
89+
90+
session = MagicMock(spec=AsyncSession)
91+
composer = ServiceComposition.starting_with(cleanup_provider).add(failing_provider)
92+
93+
with pytest.raises(RuntimeError, match="fail at entry"):
94+
async with composer.open(session=session):
95+
pass
96+
97+
assert cleanup_ran
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_user_exception_inside_block_closes_entered_providers() -> None:
102+
cleanup_ran = False
103+
104+
async def cleanup_provider(session: AsyncSession) -> AsyncGenerator[FakeServiceA, None]:
105+
try:
106+
yield FakeServiceA(session=session)
107+
finally:
108+
nonlocal cleanup_ran
109+
cleanup_ran = True
110+
111+
session = MagicMock(spec=AsyncSession)
112+
113+
async def raise_user_error() -> None:
114+
async with ServiceComposition.starting_with(cleanup_provider).open(session=session):
115+
raise ValueError("user error")
116+
117+
with pytest.raises(ValueError, match="user error"):
118+
await raise_user_error()
119+
120+
assert cleanup_ran
121+
122+
123+
def test_service_with_session_protocol_recognizes_session_keyword_services() -> None:
124+
class GoodService:
125+
def __init__(self, *, session: AsyncSession) -> None:
126+
self.session = session
127+
128+
service = GoodService(session=MagicMock(spec=AsyncSession))
129+
130+
assert isinstance(service, ServiceWithSession)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Static typing assertions for service composition primitives."""
2+
3+
from collections.abc import AsyncGenerator
4+
from typing import TYPE_CHECKING
5+
6+
from typing_extensions import assert_type
7+
8+
from advanced_alchemy.service import ServiceComposition
9+
10+
if TYPE_CHECKING:
11+
from sqlalchemy.ext.asyncio import AsyncSession
12+
13+
14+
class S1:
15+
pass
16+
17+
18+
class S2:
19+
pass
20+
21+
22+
class S3:
23+
pass
24+
25+
26+
class S4:
27+
pass
28+
29+
30+
class S5:
31+
pass
32+
33+
34+
class S6:
35+
pass
36+
37+
38+
class S7:
39+
pass
40+
41+
42+
class S8:
43+
pass
44+
45+
46+
def p1(session: "AsyncSession") -> AsyncGenerator[S1, None]: ... # type: ignore[empty-body]
47+
48+
49+
def p2(session: "AsyncSession") -> AsyncGenerator[S2, None]: ... # type: ignore[empty-body]
50+
51+
52+
def p3(session: "AsyncSession") -> AsyncGenerator[S3, None]: ... # type: ignore[empty-body]
53+
54+
55+
def p4(session: "AsyncSession") -> AsyncGenerator[S4, None]: ... # type: ignore[empty-body]
56+
57+
58+
def p5(session: "AsyncSession") -> AsyncGenerator[S5, None]: ... # type: ignore[empty-body]
59+
60+
61+
def p6(session: "AsyncSession") -> AsyncGenerator[S6, None]: ... # type: ignore[empty-body]
62+
63+
64+
def p7(session: "AsyncSession") -> AsyncGenerator[S7, None]: ... # type: ignore[empty-body]
65+
66+
67+
def p8(session: "AsyncSession") -> AsyncGenerator[S8, None]: ... # type: ignore[empty-body]
68+
69+
70+
def test_arity_three_typing() -> None:
71+
composer = ServiceComposition.starting_with(p1).add(p2).add(p3)
72+
73+
assert_type(composer, ServiceComposition[S1, S2, S3])
74+
75+
76+
def test_arity_eight_typing() -> None:
77+
composer = ServiceComposition.starting_with(p1).add(p2).add(p3).add(p4).add(p5).add(p6).add(p7).add(p8)
78+
79+
assert_type(composer, ServiceComposition[S1, S2, S3, S4, S5, S6, S7, S8])

0 commit comments

Comments
 (0)