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
6 changes: 3 additions & 3 deletions cq/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,17 @@
from ._core.related_events import AnyIORelatedEvents, RelatedEvents

__all__ = (
"__cq__",
"CQ",
"AnyCommandBus",
"AnyIORelatedEvents",
"Bus",
"CQ",
"Command",
"CommandBus",
"Consumer",
"ContextCommandPipeline",
"ContextPipeline",
"Delivery",
"DIAdapter",
"Delivery",
"Dispatcher",
"Event",
"EventBus",
Expand All @@ -46,6 +45,7 @@
"QueryBus",
"Queue",
"RelatedEvents",
"__cq__",
"command_handler",
"event_handler",
"new_command_bus",
Expand Down
2 changes: 1 addition & 1 deletion cq/_core/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def __call__[T](
and issubclass(message_or_handler_type, Handler)
):
return self.__decorator(
message_or_handler_type,
message_or_handler_type, # type: ignore[arg-type]
fail_silently=fail_silently,
)

Expand Down
2 changes: 1 addition & 1 deletion cq/_core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async def __call__(
while True:
try:
value = await call_next(*args, **kwargs)
except BaseException as exc:
except BaseException as exc: # noqa: BLE001
await generator.athrow(exc)
else:
await generator.asend(value)
Expand Down
5 changes: 3 additions & 2 deletions cq/_core/queues/abc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import abstractmethod
from collections.abc import AsyncIterable
from typing import AsyncContextManager, Protocol, runtime_checkable
from contextlib import AbstractAsyncContextManager
from typing import Protocol, runtime_checkable


@runtime_checkable
Expand All @@ -16,7 +17,7 @@ async def send(self, message: T, /) -> None:


@runtime_checkable
class Delivery[T](AsyncContextManager[T], Protocol):
class Delivery[T](AbstractAsyncContextManager[T], Protocol):
__slots__ = ()


Expand Down
6 changes: 3 additions & 3 deletions cq/_core/queues/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ async def draining(
async with (
Pump(self, dispatcher, fail_silently)
.add_middlewares(*middlewares)
.draining(concurrency=concurrency, graceful=True)
.draining(concurrency=concurrency, graceful=True),
self,
):
async with self:
yield self
yield self

async def send(self, message: T, /) -> None:
await self.__producer.send(message)
4 changes: 2 additions & 2 deletions cq/ext/injection.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, Awaitable, Callable
from typing import Any

from injection import Module, adefine_scope, mod
from injection.exceptions import ScopeAlreadyDefinedError
Expand Down
7 changes: 4 additions & 3 deletions cq/middlewares/contextlib.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

from contextlib import AbstractAsyncContextManager, AbstractContextManager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, AsyncContextManager, ContextManager
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: # pragma: no cover
from cq import MiddlewareResult
Expand All @@ -11,7 +12,7 @@

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

async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
async with self.context:
Expand All @@ -20,7 +21,7 @@ async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:

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

async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
with self.context:
Expand Down
2 changes: 2 additions & 0 deletions docs/di.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ from collections.abc import Awaitable, Callable
from cq import CQ, Command, DIAdapter, CommandBus, EventBus, Middleware, QueryBus
from typing import Any


class MyDIAdapter(DIAdapter):
def command_scope(self) -> Middleware[[Command], Any]:
"""
Expand Down Expand Up @@ -88,6 +89,7 @@ class MyDIAdapter(DIAdapter):
"""
...


cq = CQ(MyDIAdapter()).register_defaults()
```

Expand Down
13 changes: 11 additions & 2 deletions docs/guides/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ Each bus can be customized by attaching listeners and middlewares. The recommend
from cq import CommandBus, new_command_bus
from injection import injectable

async def listener(message):
...

async def listener(message): ...


async def middleware(message):
# runs before the handler
result = yield
# runs after the handler


@injectable
def command_bus_factory() -> CommandBus:
bus = new_command_bus()
Expand Down Expand Up @@ -48,6 +50,7 @@ A middleware wraps handler execution. Use it to run logic before and after the h
```python
import time


async def timing_middleware(message):
start = time.time()
yield
Expand All @@ -65,6 +68,7 @@ If you need to read or substitute the return value, write a "classic" middleware
```python
import time


async def timing_middleware(call_next, message):
start = time.time()
result = await call_next(message)
Expand All @@ -81,13 +85,15 @@ Listeners and middlewares can also be classes with a `__call__` method, which is
```python
from dataclasses import dataclass


@dataclass
class LogListener:
logger: Logger

async def __call__(self, message):
self.logger.info(f"Received: {message}")


@dataclass
class TimingMiddleware:
metrics: MetricsService
Expand All @@ -97,6 +103,7 @@ class TimingMiddleware:
yield
await self.metrics.record(time.time() - start)


@dataclass
class ClassicTimingMiddleware:
metrics: MetricsService
Expand Down Expand Up @@ -140,9 +147,11 @@ If every attempt fails, the last exception is re-raised.
from cq import new_command_bus
from cq.middlewares.exc import CaptureExceptionMiddleware


async def report(exception, message):
sentry_sdk.capture_exception(exception)


bus = new_command_bus()
bus.add_middlewares(CaptureExceptionMiddleware(report, reraise=True))
```
Expand Down
19 changes: 15 additions & 4 deletions docs/guides/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A message is any Python object. Since messages are pure data containers, `datacl
```python
from dataclasses import dataclass


@dataclass
class CreateUserCommand:
name: str
Expand All @@ -24,10 +25,10 @@ A handler is a class with an async `handle` method that takes the message as its
```python
from cq import command_handler


@command_handler
class CreateUserHandler:
async def handle(self, command: CreateUserCommand):
...
async def handle(self, command: CreateUserCommand): ...
```

The decorator inspects the annotation on the first parameter of `handle` to determine which message type the handler subscribes to. All constructor dependencies are resolved at runtime by the configured DI adapter.
Expand All @@ -40,12 +41,12 @@ Defining a handler as a `NamedTuple` gives you a concise, immutable declaration
from cq import command_handler
from typing import NamedTuple


@command_handler
class CreateUserHandler(NamedTuple):
repository: UserRepository

async def handle(self, command: CreateUserCommand):
...
async def handle(self, command: CreateUserCommand): ...
```

`UserRepository` will be resolved by the DI container when the handler is instantiated.
Expand All @@ -57,9 +58,11 @@ A handler registered for a base class (or a `Protocol`, or a generic alias) will
```python
class DomainEvent: ...


class UserCreatedEvent(DomainEvent):
user_id: int


@event_handler
class AuditLogger:
async def handle(self, event: DomainEvent):
Expand All @@ -78,11 +81,13 @@ from cq import command_handler
from dataclasses import dataclass
from typing import NamedTuple


@dataclass
class CreateUserCommand:
name: str
email: str


@command_handler
class CreateUserHandler(NamedTuple):
repository: UserRepository
Expand All @@ -99,6 +104,7 @@ A command handler can inject a `RelatedEvents` object to publish events as part
```python
from cq import RelatedEvents, command_handler


@command_handler
class CreateUserHandler(NamedTuple):
repository: UserRepository
Expand Down Expand Up @@ -126,10 +132,12 @@ from cq import query_handler
from dataclasses import dataclass
from typing import NamedTuple


@dataclass
class GetUserByIdQuery:
user_id: int


@query_handler
class GetUserByIdHandler(NamedTuple):
repository: UserRepository
Expand All @@ -147,17 +155,20 @@ from cq import event_handler
from dataclasses import dataclass
from typing import NamedTuple


@dataclass
class UserCreatedEvent:
user_id: int


@event_handler
class SendWelcomeEmailHandler(NamedTuple):
email_service: EmailService

async def handle(self, event: UserCreatedEvent):
await self.email_service.send_welcome(event.user_id)


@event_handler
class TrackUserCreatedHandler(NamedTuple):
analytics: AnalyticsService
Expand Down
13 changes: 9 additions & 4 deletions docs/guides/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A pipeline runs a sequence of commands. Each step receives the result of the pre
```python
from cq import ContextCommandPipeline


class PaymentContext:
transaction_id: int

Expand All @@ -24,8 +25,7 @@ class PaymentContext:
return NotifyMerchantCommand(transaction_id=result.transaction_id)

@pipeline.step
def _(self, result: MerchantNotifiedResult):
...
def _(self, result: MerchantNotifiedResult): ...
```

`ContextCommandPipeline()` uses the default `CQ` instance. If you manage your own `CQ` (see [Custom DI adapter](../di.md)), pass its DI adapter explicitly: `ContextCommandPipeline(cq.di)`.
Expand Down Expand Up @@ -97,8 +97,11 @@ from cq import ContextCommandPipeline
from typing import ClassVar, Self
from uuid import UUID


class LinkOAuthAccountContext:
pipeline: ClassVar[ContextCommandPipeline[VerifyIDTokenCommand]] = ContextCommandPipeline()
pipeline: ClassVar[ContextCommandPipeline[VerifyIDTokenCommand]] = (
ContextCommandPipeline()
)

def __init__(self, user_id: UUID, provider: OAuthProvider) -> None:
self.user_id = user_id
Expand Down Expand Up @@ -126,7 +129,9 @@ A pipeline is itself a dispatcher. You can wrap it with middlewares the same way

```python
class PaymentContext:
pipeline = ContextCommandPipeline().add_middlewares(timing_middleware, retry_middleware)
pipeline = ContextCommandPipeline().add_middlewares(
timing_middleware, retry_middleware
)
# ...
```

Expand Down
2 changes: 2 additions & 0 deletions docs/guides/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ async def sentry_middleware(message):
except Exception as exc:
sentry_sdk.capture_exception(exc)


pump = Pump(queue, command_bus).add_middlewares(sentry_middleware)
```

Expand All @@ -113,6 +114,7 @@ from cq import CommandBus, MemoryQueue
from injection import inject
from typing import Any


@inject
async def main(command_bus: CommandBus[Any]) -> None:
async with MemoryQueue().draining(command_bus) as queue:
Expand Down
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,27 @@ from cq import CommandBus, command_handler
from dataclasses import dataclass
from injection import inject


@dataclass
class CreateUserCommand:
name: str
email: str


@command_handler
class CreateUserHandler:
async def handle(self, command: CreateUserCommand) -> int:
# ... persist the user, return its id
return 42


@inject
async def main(bus: CommandBus[int]) -> None:
command = CreateUserCommand(name="Ada", email="ada@example.com")
user_id = await bus.dispatch(command)
print(f"Created user {user_id}")


asyncio.run(main())
```

Expand Down
Loading