Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions cq/_core/cq.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
SingleHandlerRegistry,
)
from cq._core.message import Command, Event, Query
from cq._core.middlewares.scope import CommandDispatchScopeMiddleware


class CQ:
Expand Down Expand Up @@ -57,8 +56,7 @@ def query_types(self) -> KeysView[type[Query]]:

def new_command_bus(self) -> Bus[Command, Any]:
bus = SimpleBus(self.__command_registry)
command_middleware = CommandDispatchScopeMiddleware(self.__di)
bus.add_middlewares(command_middleware)
bus.add_middlewares(self.__di.command_scope())
return bus

def new_event_bus(self) -> Bus[Event, None]:
Expand Down
37 changes: 14 additions & 23 deletions cq/_core/di.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from abc import abstractmethod
from collections.abc import Awaitable, Callable
from contextlib import nullcontext
from typing import TYPE_CHECKING, Any, AsyncContextManager, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from cq.middlewares.contextlib import AsyncContextManagerMiddleware

if TYPE_CHECKING: # pragma: no cover
from cq import CommandBus, EventBus, QueryBus
from cq import Command, CommandBus, EventBus, Middleware, QueryBus


@runtime_checkable
Expand All @@ -22,30 +24,19 @@ class DIAdapter(Protocol):
__slots__ = ()

@abstractmethod
def command_scope(self) -> AsyncContextManager[None]:
def command_scope(self) -> Middleware[[Command], Any]:
"""
Return an async context manager that delimits the lifetime of a
command dispatch.
Return a middleware that wraps each command dispatch.

**Responsibilities**

The scope must at minimum manage the lifecycle of a ``RelatedEvents``
instance and register it so that it is resolvable via injection for
the duration of the scope.

**Nested calls**

``command_scope`` is entered in two distinct situations:

1. Around a standard command dispatch (via
``CommandDispatchScopeMiddleware``).
2. Around each step of a ``ContextCommandPipeline``, which itself
wraps a command dispatch.
The middleware must at minimum manage the lifecycle of a
``RelatedEvents`` instance and register it so that it is resolvable
via injection for the duration of the dispatch.

This means two nested calls can occur for a single logical command.
Implementations must detect re-entrant activation (e.g. a scope
already active on the current task) and silently ignore the inner
call instead of opening a second, conflicting scope.
If you already have an async context manager for the scope, wrap it
with ``cq.middlewares.contextlib.AsyncContextManagerMiddleware``
instead of writing the middleware by hand.
"""

raise NotImplementedError
Expand Down Expand Up @@ -97,8 +88,8 @@ def wire[T](self, tp: type[T]) -> Callable[..., Awaitable[T]]:
class NoDI(DIAdapter):
__slots__ = ()

def command_scope(self) -> AsyncContextManager[None]:
return nullcontext()
def command_scope(self) -> Middleware[[Command], Any]:
return AsyncContextManagerMiddleware(nullcontext())

def lazy[T](self, tp: type[T], /) -> Callable[[], Awaitable[T]]:
tp_str = getattr(tp, "__name__", str(tp))
Expand Down
Empty file removed cq/_core/middlewares/__init__.py
Empty file.
14 changes: 0 additions & 14 deletions cq/_core/middlewares/scope.py

This file was deleted.

3 changes: 0 additions & 3 deletions cq/_core/pipetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
ConvertMethodSync,
)
from cq._core.message import Command, CommandBus, Query, QueryBus
from cq._core.middlewares.scope import CommandDispatchScopeMiddleware


class ContextCommandPipeline[C: Command](ContextPipeline[C]):
Expand All @@ -22,8 +21,6 @@ class ContextCommandPipeline[C: Command](ContextPipeline[C]):
def __init__(self, di: DIAdapter) -> None:
super().__init__(LazyDispatcher(CommandBus, di))
self.__query_dispatcher = LazyDispatcher(QueryBus, di)
command_middleware = CommandDispatchScopeMiddleware(di)
self.add_middlewares(command_middleware)

def add_static_query_step[Q: Query](self, query: Q, /) -> Self:
return self.add_static_step(query, dispatcher=self.__query_dispatcher)
Expand Down
21 changes: 7 additions & 14 deletions cq/ext/injection.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, AsyncContextManager, Awaitable, Callable
from typing import Any, Awaitable, Callable

from injection import Module, adefine_scope, mod
from injection.exceptions import ScopeAlreadyDefinedError

from cq._core.di import DIAdapter
from cq._core.message import CommandBus, EventBus, QueryBus
from cq._core.middleware import MiddlewareResult
from cq._core.message import Command, CommandBus, EventBus, QueryBus
from cq._core.middleware import Middleware, MiddlewareResult
from cq._core.related_events import AnyIORelatedEvents, RelatedEvents

__all__ = ("CQScope", "InjectionAdapter", "InjectionScopeMiddleware")
Expand All @@ -24,12 +24,11 @@ class InjectionAdapter(DIAdapter):
module: Module = field(default_factory=mod)
threadsafe: bool | None = field(default=None)

def command_scope(self) -> AsyncContextManager[None]:
return InjectionScopeMiddleware( # type: ignore[return-value]
def command_scope(self) -> Middleware[[Command], Any]:
return InjectionScopeMiddleware(
CQScope.COMMAND_DISPATCH,
exist_ok=True,
threadsafe=self.threadsafe,
)._cm
)

def lazy[T](self, tp: type[T], /) -> Callable[[], Awaitable[T]]:
awaitable = self.module.aget_lazy_instance(tp, threadsafe=self.threadsafe)
Expand Down Expand Up @@ -83,12 +82,6 @@ class InjectionScopeMiddleware:
threadsafe: bool | None = field(default=None, kw_only=True)

async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
async with self._cm: # type: ignore[attr-defined]
yield

@property
@asynccontextmanager
async def _cm(self) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
try:
await stack.enter_async_context(
Expand Down
27 changes: 27 additions & 0 deletions cq/middlewares/contextlib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, AsyncContextManager, ContextManager

if TYPE_CHECKING: # pragma: no cover
from cq import MiddlewareResult

__all__ = ("AsyncContextManagerMiddleware", "ContextManagerMiddleware")


@dataclass(repr=False, eq=False, frozen=True, slots=True)
class AsyncContextManagerMiddleware:
context: AsyncContextManager[Any]

async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
async with self.context:
yield


@dataclass(repr=False, eq=False, frozen=True, slots=True)
class ContextManagerMiddleware:
context: ContextManager[Any]

async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
with self.context:
yield
16 changes: 7 additions & 9 deletions docs/di.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,22 @@ If you use the default `CQ`, `ContextCommandPipeline()` (with no argument) is en

```python
from collections.abc import Awaitable, Callable
from cq import CQ, DIAdapter, CommandBus, EventBus, QueryBus
from typing import Any, AsyncContextManager
from cq import CQ, Command, DIAdapter, CommandBus, EventBus, Middleware, QueryBus
from typing import Any

class MyDIAdapter(DIAdapter):
def command_scope(self) -> AsyncContextManager[None]:
def command_scope(self) -> Middleware[[Command], Any]:
"""
Return an async context manager that delimits a command dispatch.
Return a middleware that wraps each command dispatch.

Responsibilities:
1. Open a DI scope for the duration of the command.
2. Build a `RelatedEvents` instance inside that scope and make it
resolvable, so command handlers can inject it.
3. Silently ignore nested re-entrant activations (see below).

Re-entrancy: `command_scope` is opened twice for a single logical
command when a `ContextCommandPipeline` wraps a command dispatch.
Implementations must detect that case (for example, by checking a
contextvar) and skip opening a second scope.
If you already have an async context manager for the scope, wrap it
with `cq.middlewares.contextlib.AsyncContextManagerMiddleware` instead
of writing the middleware by hand.
"""
...

Expand Down
125 changes: 125 additions & 0 deletions tests/middlewares/test_contextlib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Self

import pytest

from cq import Bus
from cq.middlewares.contextlib import (
AsyncContextManagerMiddleware,
ContextManagerMiddleware,
)


class TestAsyncContextManagerMiddleware:
async def test_call_with_success(self, bus: Bus[Any, Any]) -> None:
events: list[str] = []

@asynccontextmanager
async def context() -> AsyncIterator[None]:
events.append("enter")
try:
yield
finally:
events.append("exit")

class Handler:
async def handle(self, message: str) -> None:
events.append("handle")

@classmethod
async def async_factory(cls) -> Self:
return cls()

bus.add_middlewares(AsyncContextManagerMiddleware(context()))
bus.subscribe(str, Handler.async_factory)

await bus.dispatch("Hello world!")
assert events == ["enter", "handle", "exit"]

async def test_call_with_handler_error_raise_value_error(
self,
bus: Bus[Any, Any],
) -> None:
events: list[str] = []

@asynccontextmanager
async def context() -> AsyncIterator[None]:
events.append("enter")
try:
yield
finally:
events.append("exit")

class Handler:
async def handle(self, message: str) -> None:
raise ValueError(message)

@classmethod
async def async_factory(cls) -> Self:
return cls()

bus.add_middlewares(AsyncContextManagerMiddleware(context()))
bus.subscribe(str, Handler.async_factory)

with pytest.raises(ValueError):
await bus.dispatch("Hello world!")

assert events == ["enter", "exit"]


class TestContextManagerMiddleware:
async def test_call_with_success(self, bus: Bus[Any, Any]) -> None:
events: list[str] = []

@contextmanager
def context() -> Iterator[None]:
events.append("enter")
try:
yield
finally:
events.append("exit")

class Handler:
async def handle(self, message: str) -> None:
events.append("handle")

@classmethod
async def async_factory(cls) -> Self:
return cls()

bus.add_middlewares(ContextManagerMiddleware(context()))
bus.subscribe(str, Handler.async_factory)

await bus.dispatch("Hello world!")
assert events == ["enter", "handle", "exit"]

async def test_call_with_handler_error_raise_value_error(
self,
bus: Bus[Any, Any],
) -> None:
events: list[str] = []

@contextmanager
def context() -> Iterator[None]:
events.append("enter")
try:
yield
finally:
events.append("exit")

class Handler:
async def handle(self, message: str) -> None:
raise ValueError(message)

@classmethod
async def async_factory(cls) -> Self:
return cls()

bus.add_middlewares(ContextManagerMiddleware(context()))
bus.subscribe(str, Handler.async_factory)

with pytest.raises(ValueError):
await bus.dispatch("Hello world!")

assert events == ["enter", "exit"]
Loading