Skip to content

feat: Request[Result] typing for send and stream

Choose a tag to compare

@vadikko2 vadikko2 released this 21 Oct 14:29
· 36 commits to master since this release
1a99b49

Summary

Request[Result] carries a result. Request[None] is an empty result. RequestHandler[ConcreteRequest] takes that single argument. handle has no return annotation: the overloads are what checkers use, and the implementation omits the annotation so PyCharm infers the concrete result. RequestMediator.send(request: IRequest[_SendResT]) -> _SendResT. A one-argument subscription still stores (request, response) at runtime.

class ReadMeetingQueryResult(cqrs.Response):
    users: list[str]


class ReadMeetingQuery(cqrs.Request[ReadMeetingQueryResult]):
    meeting_id: str


class JoinMeetingCommand(cqrs.Request[None]):
    user_id: str
    meeting_id: str


class ReadMeetingQueryHandler(cqrs.RequestHandler[ReadMeetingQuery]):
    async def handle(self, request: ReadMeetingQuery):
        return ReadMeetingQueryResult(users=list(STORAGE[request.meeting_id]))


class JoinMeetingCommandHandler(cqrs.RequestHandler[JoinMeetingCommand]):
    async def handle(self, request: JoinMeetingCommand):
        STORAGE[request.meeting_id].append(request.user_id)


assert await mediator.send(JoinMeetingCommand(user_id="1", meeting_id="1")) is None
assert await mediator.send(JoinMeetingCommand(user_id="2", meeting_id="1")) is None
users = await mediator.send(ReadMeetingQuery(meeting_id="1"))
assert users.users == ["1", "2"]

That sample is examples/events/typed_request_handler.py. users is ReadMeetingQueryResult. Each JoinMeetingCommand send is None.

An unparameterized Request stays Request[Any], so send on it stays Any. RequestHandler[Request, Result] stays valid, and that form may still annotate handle. Existing tests and examples keep working.

class BareQuery(cqrs.Request):
    meeting_id: str


bare = await mediator.send(BareQuery(meeting_id="1"))


class OldReadMeetingQueryHandler(cqrs.RequestHandler[ReadMeetingQuery, ReadMeetingQueryResult]):
    async def handle(self, request: ReadMeetingQuery) -> ReadMeetingQueryResult:
        return ReadMeetingQueryResult(users=["old"])

bare is Any. The two-argument handler is the form already in the tree.

  • src/cqrs/py.typed marks the package typed (PEP 561) so checkers use the annotations shipped in the wheel. typing-extensions is >=4.7, which is what TypeVar defaults need. Version 5.1.0 to 5.2.0.
  • AGENTS.md is for contributors changing this library: public exports, the Python 3.10 floor, and this request shape. CLAUDE.md points at it.
  • templates/AGENTS.md is the file an application author copies. It describes calling the package.
  • examples/events/typed_request_handler.py runs the query and command above.
  • examples/streaming/typed_streaming_handler.py is the streaming form: ProcessItemsCommand is Request[ItemResult], and StreamingRequestHandler[ProcessItemsCommand].handle yields ItemResult with no element annotation.
  • examples/cor/typed_cor_request_handler.py is the chain form: CreditCardHandler is CORRequestHandler[ProcessPaymentCommand] and handle has no return annotation. Older examples are unchanged.

README samples use the one-argument handler. The three Transactional Outbox tips render as separate GitHub alerts. The README no longer calls the project a Diator fork. CONTRIBUTING still says "Fork and Clone".