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
27 changes: 17 additions & 10 deletions docs/di.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
You create your own `Router` instance to wire the library against a custom `DIAdapter`:

```python
from cq import Router, ContextCommandPipeline
from cq import Router

router = Router(my_di_adapter).register_defaults()

Expand All @@ -25,37 +25,44 @@ new_event_bus = router.new_event_bus
new_query_bus = router.new_query_bus
```

When you build a `ContextCommandPipeline` against a non-default `Router`, pass its DI adapter explicitly so the pipeline dispatches through the right buses:
Pipelines follow the same rule. Build them from your own `Router` so they dispatch through the right buses:

```python
ContextCommandPipeline(router.di)
from cq import ContextCommandPipeline


class PaymentContext:
pipeline: ContextCommandPipeline[ValidateCartCommand] = router.command_pipeline()
```

If you use the default `Router`, `ContextCommandPipeline()` (with no argument) is enough.

## Implementing a `DIAdapter`

`DIAdapter` is a `Protocol` with four methods, three of them required:
`DIAdapter` is a `Protocol` with four methods, of which only `lazy` and `wire` are required:

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


class MyDIAdapter(DIAdapter):
def command_scope(self) -> Middleware[[Command], Any]:
def command_scope(self) -> Middleware[Concatenate[Command, ...], Any] | None:
"""
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.
It must manage the lifecycle of a `RelatedEvents` instance and make
it resolvable for as long as the dispatch lasts, so that command
handlers can inject it.

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.

Optional: the default implementation returns `None`, meaning no
middleware wraps the dispatch. Command handlers then have no
`RelatedEvents` to inject.
"""
...

Expand Down
22 changes: 21 additions & 1 deletion docs/guides/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The same pattern applies to `QueryBus` and `EventBus`, with `new_query_bus()` an

## Listeners

Listeners are fire-and-forget callables that receive the message. They are useful for logging, metrics, or any side effect that does not need to influence the handler.
Listeners are callables that receive the message and give nothing back to the bus. They are useful for logging, metrics, or any side effect that does not need to influence the handler.

```python
async def log_listener(message):
Expand All @@ -43,6 +43,8 @@ Listeners are scheduled in an `anyio` task group, so several listeners run concu
* **`CommandBus` and `QueryBus`**: every listener must finish before the handler runs. The handler cannot start until listeners have settled, and `dispatch` returns the handler's value as soon as it completes.
* **`EventBus`**: listeners and handlers share the same task group, so they all run concurrently. `dispatch` returns once everything has finished.

A listener is fire-and-forget in the sense that the bus ignores its return value, not in the sense that it is isolated. An exception raised by a listener escapes `dispatch` inside an `ExceptionGroup`, and on a `CommandBus` or a `QueryBus` this happens before the handler is called, so the message is never handled. Guard fragile listener code with its own try/except if it must not affect the dispatch.

## Middlewares

A middleware wraps handler execution. Use it to run logic before and after the handler processes the message, or to handle exceptions.
Expand Down Expand Up @@ -78,6 +80,24 @@ async def timing_middleware(call_next, message):

Both styles can be mixed freely in the same bus.

### Execution order

Middlewares nest around the handler like the layers of an onion. Within one call, they run in the order you list them: the first argument is the outermost layer, so it starts first and finishes last.

```python
bus.add_middlewares(outer, inner)
# outer -> inner -> handler -> inner -> outer
```

Each subsequent call wraps whatever is already registered, so the middlewares added last end up outermost:

```python
bus.add_middlewares(a, b) # a -> b -> handler
bus.add_middlewares(c) # c -> a -> b -> handler
```

The rule also covers the middleware that the DI adapter installs on the command bus. `new_command_bus()` registers the command scope before you add anything, so your own middlewares sit outside of it: they wrap the dispatch of every related event, but they cannot inject a dependency that only exists inside the scope, such as `RelatedEvents`.

## Class-based listeners and middlewares

Listeners and middlewares can also be classes with a `__call__` method, which is convenient when they need their own dependencies:
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/dispatching.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dispatching messages

**python-cq** exposes three bus types to dispatch messages to their handlers: `CommandBus`, `QueryBus`, and `EventBus`. Each takes a generic parameter that types the return value of `dispatch`.
**python-cq** exposes three bus types to dispatch messages to their handlers: `CommandBus`, `QueryBus`, and `EventBus`. `CommandBus` and `QueryBus` take a generic parameter that types the value returned by `dispatch`. `EventBus` takes none, since it returns nothing.

A bus instance is obtained from your DI container. The examples below assume the bus has already been resolved; see [Configuring a bus](configuring.md) for how to build and register one.

Expand Down
6 changes: 5 additions & 1 deletion docs/guides/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class CreateUserHandler:

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.

A command or a query accepts a single handler: registering a second one for the same message type raises a `RuntimeError` as soon as the module is imported. An event accepts any number of handlers.

### Using `NamedTuple` for handlers

Defining a handler as a `NamedTuple` gives you a concise, immutable declaration of its dependencies:
Expand Down Expand Up @@ -115,7 +117,9 @@ class CreateUserHandler(NamedTuple):
self.events.add(UserCreatedEvent(user_id=user.id))
```

Calling `events.add(...)` schedules each event on a task group that lives for the duration of the command dispatch scope. Events are dispatched concurrently, and the command dispatch only returns once every scheduled event has been fully handled. If any event handler raises, the exception propagates back to the caller of the command.
Calling `events.add(...)` schedules each event on a task group that lives for the duration of the command dispatch scope. Events are dispatched concurrently, and the command dispatch only returns once every scheduled event has been fully handled.

If an event handler raises, the exception propagates back to the caller of the command, wrapped in two nested `ExceptionGroup`s: one for the events scheduled by the command, one for the handlers of that event. Catch it with `except*`, which matches through any level of nesting.

You can add multiple events in one call:

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class PaymentContext:
def _(self, result: MerchantNotifiedResult): ...
```

`ContextCommandPipeline()` uses the default `Router` instance. If you manage your own `Router` (see [Custom DI adapter](../di.md)), pass its DI adapter explicitly: `ContextCommandPipeline(router.di)`.
`ContextCommandPipeline()` uses the default `Router` instance. If you manage your own `Router` (see [Custom DI adapter](../di.md)), build the pipeline from it with `router.command_pipeline()` so that it dispatches through the right buses.

## Steps

Expand Down
4 changes: 3 additions & 1 deletion docs/guides/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Any object that satisfies these protocols can act as a queue. The library provid

## `MemoryQueue`

`MemoryQueue` is a thin wrapper around `anyio.create_memory_object_stream`. It is bounded by an optional `maxsize`, in which case `send` waits until a slot is available.
`MemoryQueue` is a thin wrapper around `anyio.create_memory_object_stream`. `maxsize` is how many messages the queue buffers before `send` starts waiting for a free slot:

```python
from cq import Command, MemoryQueue
Expand All @@ -27,6 +27,8 @@ queue: MemoryQueue[Command] = MemoryQueue(maxsize=100)
await queue.send(command)
```

The default, `maxsize=0`, means no buffer at all: `send` waits until a consumer takes the message. It keeps the producer from running ahead of the consumer, but it also means that `await queue.send(...)` blocks forever if nothing is draining the queue. Send from inside a draining context (see below), or pass `maxsize=math.inf` for an unbounded buffer.

`MemoryQueue` is the right tool when producer and consumer live in the same process. It is not thread-safe: both `send` and consumption must run on the event loop that created the queue. For cross-process or persistent queues, implement `Queue` against your transport of choice.

## Draining a queue with `Pump`
Expand Down