Skip to content

Commit c2bba37

Browse files
authored
feat: ✨ Input type is now optional for HandlerDecorator
1 parent e77a2ac commit c2bba37

11 files changed

Lines changed: 387 additions & 264 deletions

File tree

cq/_core/handler.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from collections.abc import Awaitable, Callable, Iterator
44
from dataclasses import dataclass, field
55
from functools import partial
6-
from inspect import getmro, isclass
6+
from inspect import Parameter, getmro, isclass
7+
from inspect import signature as inspect_signature
78
from typing import Any, Protocol, Self, runtime_checkable
89

910
import injection
@@ -88,16 +89,50 @@ class HandlerDecorator[I, O]:
8889
manager: HandlerManager[I, O]
8990
injection_module: injection.Module = field(default_factory=injection.mod)
9091

91-
def __call__(self, input_type: type[I], /) -> Any:
92-
def decorator(wrapped: type[Handler[[I], O]]) -> type[Handler[[I], O]]:
93-
if not isclass(wrapped) or not issubclass(wrapped, Handler):
94-
raise TypeError(f"`{wrapped}` isn't a valid handler.")
92+
def __call__(
93+
self,
94+
input_or_handler_type: type[I] | HandlerType[[I], O] | None = None,
95+
/,
96+
) -> Any:
97+
if input_or_handler_type is None:
98+
return self.__decorator
99+
100+
elif isclass(input_or_handler_type) and issubclass(
101+
input_or_handler_type,
102+
Handler,
103+
):
104+
return self.__decorator(input_or_handler_type)
105+
106+
return partial(self.__decorator, input_type=input_or_handler_type) # type: ignore[arg-type]
107+
108+
def __decorator(
109+
self,
110+
wrapped: HandlerType[[I], O],
111+
*,
112+
input_type: type[I] | None = None,
113+
) -> HandlerType[[I], O]:
114+
factory = self.injection_module.make_async_factory(wrapped)
115+
input_type = input_type or _resolve_input_type(wrapped)
116+
self.manager.subscribe(input_type, factory)
117+
return wrapped
95118

96-
factory = self.injection_module.make_async_factory(wrapped)
97-
self.manager.subscribe(input_type, factory)
98-
return wrapped
99119

100-
return decorator
120+
def _resolve_input_type[I, O](handler_type: HandlerType[[I], O]) -> type[I]:
121+
fake_handle_method = handler_type.handle.__get__(NotImplemented)
122+
signature = inspect_signature(fake_handle_method, eval_str=True)
123+
124+
for parameter in signature.parameters.values():
125+
input_type = parameter.annotation
126+
127+
if input_type is Parameter.empty:
128+
break
129+
130+
return input_type
131+
132+
raise TypeError(
133+
f"Unable to resolve input type for handler `{handler_type}`, "
134+
"`handle` method must have a type annotation for its first parameter."
135+
)
101136

102137

103138
def _make_handle_function[I, O](
@@ -106,6 +141,6 @@ def _make_handle_function[I, O](
106141
return partial(__handle, factory=factory)
107142

108143

109-
async def __handle[I, O](input_value: I, factory: HandlerFactory[[I], O]) -> O:
144+
async def __handle[I, O](input_value: I, *, factory: HandlerFactory[[I], O]) -> O:
110145
handler = await factory()
111146
return await handler.handle(input_value)

cq/_core/message.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
@injection.singleton(inject=False, mode="fallback")
3737
def new_command_bus() -> CommandBus: # type: ignore[type-arg]
3838
bus = SimpleBus(command_handler.manager)
39-
bus.add_middlewares(InjectionScopeMiddleware(CQScope.ON_COMMAND))
39+
transaction_scope_middleware = InjectionScopeMiddleware(
40+
CQScope.TRANSACTION,
41+
exist_ok=True,
42+
)
43+
bus.add_middlewares(transaction_scope_middleware)
4044
return bus
4145

4246

cq/_core/related_events.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,20 @@ def add(self, *events: Event) -> None:
2323
class SimpleRelatedEvents(RelatedEvents):
2424
items: list[Event] = field(default_factory=list)
2525

26+
def __bool__(self) -> bool:
27+
return bool(self.items)
28+
2629
def add(self, *events: Event) -> None:
2730
self.items.extend(events)
2831

2932

30-
@injection.scoped(CQScope.ON_COMMAND, mode="fallback")
33+
@injection.scoped(CQScope.TRANSACTION, mode="fallback")
3134
async def related_events_recipe(event_bus: EventBus) -> AsyncIterator[RelatedEvents]:
3235
yield (instance := SimpleRelatedEvents())
33-
events = instance.items
3436

35-
if not events:
37+
if not instance:
3638
return
3739

3840
async with anyio.create_task_group() as task_group:
39-
for event in events:
41+
for event in instance.items:
4042
task_group.start_soon(event_bus.dispatch, event)

cq/_core/scope.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33

44
class CQScope(StrEnum):
5-
ON_COMMAND = auto()
5+
TRANSACTION = auto()

cq/middlewares/retry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from collections.abc import Iterable
1+
from collections.abc import Sequence
22
from typing import Any
33

44
import anyio
@@ -19,7 +19,7 @@ def __init__(
1919
self,
2020
retry: int,
2121
delay: float = 0,
22-
exceptions: Iterable[type[BaseException]] = (Exception,),
22+
exceptions: Sequence[type[BaseException]] = (Exception,),
2323
) -> None:
2424
self.__delay = delay
2525
self.__exceptions = tuple(exceptions)

cq/middlewares/scope.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
from __future__ import annotations
22

3+
from contextlib import AsyncExitStack
4+
from dataclasses import dataclass, field
35
from typing import TYPE_CHECKING, Any
46

57
from injection import adefine_scope
8+
from injection.exceptions import ScopeAlreadyDefinedError
69

710
if TYPE_CHECKING: # pragma: no cover
811
from cq import MiddlewareResult
912

1013
__all__ = ("InjectionScopeMiddleware",)
1114

1215

16+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
1317
class InjectionScopeMiddleware:
14-
__slots__ = ("__scope_name",)
18+
scope_name: str
19+
exist_ok: bool = field(default=False, kw_only=True)
1520

16-
__scope_name: str
21+
async def __call__(self, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
22+
async with AsyncExitStack() as stack:
23+
try:
24+
await stack.enter_async_context(
25+
adefine_scope(self.scope_name),
26+
)
1727

18-
def __init__(self, scope_name: str) -> None:
19-
self.__scope_name = scope_name
28+
except ScopeAlreadyDefinedError:
29+
if not self.exist_ok:
30+
raise
2031

21-
async def __call__(self, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
22-
async with adefine_scope(self.__scope_name):
2332
yield

documentation/fastapi-example.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ExampleCommand(BaseModel): ...
2828

2929
class ExampleReturnType: ...
3030

31-
@command_handler(ExampleCommand)
31+
@command_handler
3232
class ExampleHandler:
3333
def __init__(self, service: ExampleService) -> None:
3434
self.service = service

documentation/writing-application-layer.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class UserProfileView:
2727
class ReadUserProfileQuery(msgspec.Struct, frozen=True):
2828
user_id: int
2929

30-
@query_handler(ReadUserProfileQuery)
30+
@query_handler
3131
class ReadUserProfileHandler:
3232
async def handle(self, query: ReadUserProfileQuery) -> UserProfileView:
3333
""" User profile reading logic """
@@ -67,7 +67,7 @@ from cq import command_handler
6767
class UpdateUserProfileCommand:
6868
""" Data required to update user profile """
6969

70-
@command_handler(UpdateUserProfileCommand)
70+
@command_handler
7171
class UpdateUserProfileHandler:
7272
async def handle(self, command: UpdateUserProfileCommand) -> None:
7373
""" User profile updating logic """
@@ -107,7 +107,7 @@ from cq import event_handler
107107
class UserRegistered:
108108
""" Data to process the event """
109109

110-
@event_handler(UserRegistered)
110+
@event_handler
111111
class SendConfirmationEmailHandler:
112112
async def handle(self, event: UserRegistered) -> None:
113113
""" Confirmation email sending logic """
@@ -123,7 +123,7 @@ from cq import RelatedEvents, command_handler
123123
class UserRegistrationCommand:
124124
""" Data required to register a user """
125125

126-
@command_handler(UserRegistrationCommand)
126+
@command_handler
127127
class UserRegistrationHandler:
128128
def __init__(self, related_events: RelatedEvents) -> None:
129129
self.related_events = related_events

tests/core/test_handler.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from typing import Any, NoReturn
2+
3+
import pytest
4+
5+
from cq._core.handler import HandlerDecorator, SingleHandlerManager
6+
7+
8+
class _Handler:
9+
async def handle(self, input_value: str) -> NoReturn:
10+
raise NotImplementedError
11+
12+
13+
class TestHandlerDecorator:
14+
@pytest.fixture(scope="function")
15+
def handler_decorator(self) -> HandlerDecorator[Any, Any]:
16+
return HandlerDecorator(SingleHandlerManager())
17+
18+
def test_call_with_success_return_wrapped_type(
19+
self,
20+
handler_decorator: HandlerDecorator[Any, Any],
21+
) -> None:
22+
assert handler_decorator(_Handler) is _Handler
23+
24+
def test_call_with_input_type_return_wrapped_type(
25+
self,
26+
handler_decorator: HandlerDecorator[Any, Any],
27+
) -> None:
28+
assert handler_decorator(str)(_Handler) is _Handler
29+
30+
def test_call_with_no_args_return_wrapped_type(
31+
self,
32+
handler_decorator: HandlerDecorator[Any, Any],
33+
) -> None:
34+
assert handler_decorator()(_Handler) is _Handler
35+
36+
def test_call_with_missing_input_type_annotation_raise_type_error(
37+
self,
38+
handler_decorator: HandlerDecorator[Any, Any],
39+
) -> None:
40+
with pytest.raises(TypeError):
41+
42+
@handler_decorator
43+
class Handler:
44+
async def handle(self, input_value): ... # type: ignore[no-untyped-def]
45+
46+
def test_call_with_missing_input_in_handle_raise_type_error(
47+
self,
48+
handler_decorator: HandlerDecorator[Any, Any],
49+
) -> None:
50+
with pytest.raises(TypeError):
51+
52+
@handler_decorator
53+
class Handler:
54+
async def handle(self): ... # type: ignore[no-untyped-def]

tests/test_command_bus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ async def test_dispatch_with_related_events(
1111
) -> None:
1212
class _Event: ...
1313

14-
@event_handler(_Event)
14+
@event_handler
1515
class _EventHandler:
1616
async def handle(self, event: _Event) -> None: ...
1717

1818
class _Command: ...
1919

20-
@command_handler(_Command)
20+
@command_handler
2121
class _CommandHandler:
2222
def __init__(self, related_events: RelatedEvents) -> None:
2323
self.related_events = related_events

0 commit comments

Comments
 (0)