python-cq 0.15.2__tar.gz → 0.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {python_cq-0.15.2 → python_cq-0.16.0}/PKG-INFO +11 -3
  2. python_cq-0.16.0/cq/__init__.py +75 -0
  3. python_cq-0.16.0/cq/_core/cq.py +63 -0
  4. python_cq-0.16.0/cq/_core/di.py +113 -0
  5. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/dispatcher/base.py +0 -9
  6. python_cq-0.16.0/cq/_core/dispatcher/lazy.py +24 -0
  7. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/handler.py +3 -10
  8. python_cq-0.16.0/cq/_core/message.py +13 -0
  9. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/middleware.py +2 -1
  10. python_cq-0.16.0/cq/_core/middlewares/scope.py +14 -0
  11. python_cq-0.16.0/cq/_core/pipetools.py +52 -0
  12. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/related_events.py +16 -12
  13. python_cq-0.16.0/cq/ext/injection.py +102 -0
  14. python_cq-0.16.0/cq/py.typed +0 -0
  15. {python_cq-0.15.2 → python_cq-0.16.0}/docs/index.md +8 -1
  16. {python_cq-0.15.2 → python_cq-0.16.0}/pyproject.toml +6 -4
  17. python_cq-0.15.2/cq/__init__.py +0 -51
  18. python_cq-0.15.2/cq/_core/dispatcher/lazy.py +0 -28
  19. python_cq-0.15.2/cq/_core/message.py +0 -71
  20. python_cq-0.15.2/cq/_core/pipetools.py +0 -75
  21. python_cq-0.15.2/cq/_core/scope.py +0 -5
  22. python_cq-0.15.2/cq/ext/fastapi.py +0 -73
  23. python_cq-0.15.2/cq/middlewares/scope.py +0 -33
  24. {python_cq-0.15.2 → python_cq-0.16.0}/.gitignore +0 -0
  25. {python_cq-0.15.2 → python_cq-0.16.0}/LICENSE +0 -0
  26. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/__init__.py +0 -0
  27. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/common/__init__.py +0 -0
  28. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/common/typing.py +0 -0
  29. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/dispatcher/__init__.py +0 -0
  30. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/dispatcher/bus.py +0 -0
  31. {python_cq-0.15.2 → python_cq-0.16.0}/cq/_core/dispatcher/pipe.py +0 -0
  32. {python_cq-0.15.2/cq/ext → python_cq-0.16.0/cq/_core/middlewares}/__init__.py +0 -0
  33. {python_cq-0.15.2 → python_cq-0.16.0}/cq/exceptions.py +0 -0
  34. {python_cq-0.15.2/cq/middlewares → python_cq-0.16.0/cq/ext}/__init__.py +0 -0
  35. /python_cq-0.15.2/cq/py.typed → /python_cq-0.16.0/cq/middlewares/__init__.py +0 -0
  36. {python_cq-0.15.2 → python_cq-0.16.0}/cq/middlewares/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-cq
3
- Version: 0.15.2
3
+ Version: 0.16.0
4
4
  Summary: CQRS library for async Python projects.
5
5
  Project-URL: Documentation, https://python-cq.remimd.dev
6
6
  Project-URL: Repository, https://github.com/100nm/python-cq
@@ -24,8 +24,9 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Classifier: Typing :: Typed
25
25
  Requires-Python: <3.15,>=3.12
26
26
  Requires-Dist: anyio
27
- Requires-Dist: python-injection
28
27
  Requires-Dist: type-analyzer
28
+ Provides-Extra: injection
29
+ Requires-Dist: python-injection[async]; extra == 'injection'
29
30
  Description-Content-Type: text/markdown
30
31
 
31
32
  # python-cq
@@ -33,7 +34,7 @@ Description-Content-Type: text/markdown
33
34
  [![PyPI - Version](https://img.shields.io/pypi/v/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypi.org/project/python-cq)
34
35
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
35
36
 
36
- **python-cq** is a Python package designed to organize your code following CQRS principles. It builds on top of [python-injection](https://github.com/100nm/python-injection) for dependency injection.
37
+ **python-cq** is a Python package designed to organize your code following CQRS principles. It provides a `DIAdapter` protocol for dependency injection, with [python-injection](https://github.com/100nm/python-injection) as the default implementation available via the `[injection]` extra.
37
38
 
38
39
  ## What is CQRS?
39
40
 
@@ -65,6 +66,13 @@ This knowledge will help you design coherent handlers and organize your code eff
65
66
  ## Installation
66
67
 
67
68
  Requires Python 3.12 or higher.
69
+
70
+ Without dependency injection:
68
71
  ```bash
69
72
  pip install python-cq
70
73
  ```
74
+
75
+ With [python-injection](https://github.com/100nm/python-injection) as the DI backend (recommended):
76
+ ```bash
77
+ pip install "python-cq[injection]"
78
+ ```
@@ -0,0 +1,75 @@
1
+ from ._core.cq import CQ
2
+ from ._core.di import DIAdapter
3
+ from ._core.di import NoDI as _NoDI
4
+ from ._core.dispatcher.base import Dispatcher
5
+ from ._core.dispatcher.bus import Bus
6
+ from ._core.dispatcher.pipe import ContextPipeline, Pipe
7
+ from ._core.message import (
8
+ AnyCommandBus,
9
+ Command,
10
+ CommandBus,
11
+ Event,
12
+ EventBus,
13
+ Query,
14
+ QueryBus,
15
+ )
16
+ from ._core.middleware import Middleware, MiddlewareResult, resolve_handler_source
17
+ from ._core.pipetools import ContextCommandPipeline as _ContextCommandPipeline
18
+ from ._core.related_events import AnyIORelatedEvents, RelatedEvents
19
+
20
+ __all__ = (
21
+ "AnyCommandBus",
22
+ "AnyIORelatedEvents",
23
+ "Bus",
24
+ "CQ",
25
+ "Command",
26
+ "CommandBus",
27
+ "ContextCommandPipeline",
28
+ "ContextPipeline",
29
+ "DIAdapter",
30
+ "Dispatcher",
31
+ "Event",
32
+ "EventBus",
33
+ "Middleware",
34
+ "MiddlewareResult",
35
+ "Pipe",
36
+ "Query",
37
+ "QueryBus",
38
+ "RelatedEvents",
39
+ "command_handler",
40
+ "event_handler",
41
+ "new_command_bus",
42
+ "new_event_bus",
43
+ "new_query_bus",
44
+ "query_handler",
45
+ "resolve_handler_source",
46
+ )
47
+
48
+ try:
49
+ from cq.ext.injection import InjectionAdapter as _InjectionAdapter
50
+
51
+ except ImportError: # pragma: no cover
52
+ _default = CQ(_NoDI())
53
+
54
+ else:
55
+ _default = CQ(_InjectionAdapter())
56
+
57
+ _default.register_defaults()
58
+
59
+ command_handler = _default.command_handler
60
+ event_handler = _default.event_handler
61
+ query_handler = _default.query_handler
62
+
63
+ new_command_bus = _default.new_command_bus
64
+ new_event_bus = _default.new_event_bus
65
+ new_query_bus = _default.new_query_bus
66
+
67
+
68
+ class ContextCommandPipeline[C: Command](_ContextCommandPipeline[C]):
69
+ __slots__ = ()
70
+
71
+ def __init__(self, di: DIAdapter = _default.di) -> None:
72
+ super().__init__(di)
73
+
74
+
75
+ del _default
@@ -0,0 +1,63 @@
1
+ from typing import Any, Self
2
+
3
+ from cq._core.di import DIAdapter
4
+ from cq._core.dispatcher.bus import Bus, SimpleBus, TaskBus
5
+ from cq._core.handler import (
6
+ HandlerDecorator,
7
+ HandlerRegistry,
8
+ MultipleHandlerRegistry,
9
+ SingleHandlerRegistry,
10
+ )
11
+ from cq._core.message import Command, Event, Query
12
+ from cq._core.middlewares.scope import CommandDispatchScopeMiddleware
13
+
14
+
15
+ class CQ:
16
+ __slots__ = ("__command_registry", "__di", "__event_registry", "__query_registry")
17
+
18
+ __command_registry: HandlerRegistry[Command, Any]
19
+ __di: DIAdapter
20
+ __event_registry: HandlerRegistry[Event, Any]
21
+ __query_registry: HandlerRegistry[Query, Any]
22
+
23
+ def __init__(self, di: DIAdapter, /) -> None:
24
+ self.__di = di
25
+ self.__command_registry = SingleHandlerRegistry()
26
+ self.__event_registry = MultipleHandlerRegistry()
27
+ self.__query_registry = SingleHandlerRegistry()
28
+
29
+ @property
30
+ def di(self) -> DIAdapter:
31
+ return self.__di
32
+
33
+ @property
34
+ def command_handler(self) -> HandlerDecorator[Command, Any]:
35
+ return HandlerDecorator(self.__command_registry, self.__di)
36
+
37
+ @property
38
+ def event_handler(self) -> HandlerDecorator[Event, Any]:
39
+ return HandlerDecorator(self.__event_registry, self.__di)
40
+
41
+ @property
42
+ def query_handler(self) -> HandlerDecorator[Query, Any]:
43
+ return HandlerDecorator(self.__query_registry, self.__di)
44
+
45
+ def new_command_bus(self) -> Bus[Command, Any]:
46
+ bus = SimpleBus(self.__command_registry)
47
+ command_middleware = CommandDispatchScopeMiddleware(self.__di)
48
+ bus.add_middlewares(command_middleware)
49
+ return bus
50
+
51
+ def new_event_bus(self) -> Bus[Event, None]:
52
+ return TaskBus(self.__event_registry)
53
+
54
+ def new_query_bus(self) -> Bus[Query, Any]:
55
+ return SimpleBus(self.__query_registry)
56
+
57
+ def register_defaults(self) -> Self:
58
+ self.__di.register_defaults(
59
+ self.new_command_bus,
60
+ self.new_event_bus,
61
+ self.new_query_bus,
62
+ )
63
+ return self
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from collections.abc import Awaitable, Callable
5
+ from contextlib import nullcontext
6
+ from typing import TYPE_CHECKING, Any, AsyncContextManager, Protocol, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ from cq import CommandBus, EventBus, QueryBus
10
+
11
+
12
+ @runtime_checkable
13
+ class DIAdapter(Protocol):
14
+ """
15
+ Protocol for integrating a dependency injection container with python-cq.
16
+
17
+ Implement this protocol to connect your DI framework to the CQ buses.
18
+ A concrete implementation (``InjectionAdapter``) is provided via the
19
+ ``python-cq[injection]`` extra for projects that use *python-injection*.
20
+ """
21
+
22
+ __slots__ = ()
23
+
24
+ @abstractmethod
25
+ def command_scope(self) -> AsyncContextManager[None]:
26
+ """
27
+ Return an async context manager that delimits the lifetime of a
28
+ command dispatch.
29
+
30
+ **Responsibilities**
31
+
32
+ The scope must at minimum manage the lifecycle of a ``RelatedEvents``
33
+ instance and register it so that it is resolvable via injection for
34
+ the duration of the scope.
35
+
36
+ **Nested calls**
37
+
38
+ ``command_scope`` is entered in two distinct situations:
39
+
40
+ 1. Around a standard command dispatch (via
41
+ ``CommandDispatchScopeMiddleware``).
42
+ 2. Around each step of a ``ContextCommandPipeline``, which itself
43
+ wraps a command dispatch.
44
+
45
+ This means two nested calls can occur for a single logical command.
46
+ Implementations must detect re-entrant activation (e.g. a scope
47
+ already active on the current task) and silently ignore the inner
48
+ call instead of opening a second, conflicting scope.
49
+ """
50
+
51
+ raise NotImplementedError
52
+
53
+ @abstractmethod
54
+ def lazy[T](self, tp: type[T]) -> Callable[[], Awaitable[T]]:
55
+ """
56
+ Return a callable that resolves an instance of ``tp`` in two steps.
57
+
58
+ 1. ``lazy(tp)`` obtains a resolver from the DI framework for ``tp``.
59
+ 2. Calling and awaiting the returned callable performs the actual
60
+ resolution and returns the instance.
61
+ """
62
+
63
+ raise NotImplementedError
64
+
65
+ def register_defaults(
66
+ self,
67
+ command_bus: Callable[..., CommandBus[Any]],
68
+ event_bus: Callable[..., EventBus],
69
+ query_bus: Callable[..., QueryBus[Any]],
70
+ ) -> None:
71
+ """
72
+ Register the CQ buses as default providers in the DI container.
73
+
74
+ Called once during setup so that handlers and middlewares can
75
+ declare ``CommandBus``, ``EventBus``, or ``QueryBus`` as
76
+ constructor dependencies and receive the configured instances.
77
+
78
+ The default implementation is a no-op for adapters that do not
79
+ need automatic bus registration.
80
+ """
81
+
82
+ return
83
+
84
+ @abstractmethod
85
+ def wire[T](self, tp: type[T]) -> Callable[..., Awaitable[T]]:
86
+ """
87
+ Return an async factory that instantiates ``tp`` with injected
88
+ dependencies.
89
+
90
+ Used internally to build handler instances whose dependencies are
91
+ resolved by the container.
92
+ """
93
+
94
+ raise NotImplementedError
95
+
96
+
97
+ class NoDI(DIAdapter):
98
+ __slots__ = ()
99
+
100
+ def command_scope(self) -> AsyncContextManager[None]:
101
+ return nullcontext()
102
+
103
+ def lazy[T](self, tp: type[T], /) -> Callable[[], Awaitable[T]]:
104
+ tp_str = getattr(tp, "__name__", str(tp))
105
+ raise RuntimeError(
106
+ f"Can't lazily resolve {tp_str}: no DI container configured."
107
+ )
108
+
109
+ def wire[T](self, tp: type[T], /) -> Callable[..., Awaitable[T]]:
110
+ async def factory() -> T:
111
+ return tp()
112
+
113
+ return factory
@@ -15,15 +15,6 @@ class Dispatcher[I, O](Protocol):
15
15
  raise NotImplementedError
16
16
 
17
17
 
18
- @runtime_checkable
19
- class DeferredDispatcher[I](Protocol):
20
- __slots__ = ()
21
-
22
- @abstractmethod
23
- async def defer(self, input_value: I, /) -> None:
24
- raise NotImplementedError
25
-
26
-
27
18
  class BaseDispatcher[I, O](Dispatcher[I, O], ABC):
28
19
  __slots__ = ("__middleware_group",)
29
20
 
@@ -0,0 +1,24 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from types import GenericAlias
3
+ from typing import TypeAliasType
4
+
5
+ from cq._core.di import DIAdapter
6
+ from cq._core.dispatcher.base import Dispatcher
7
+
8
+
9
+ class LazyDispatcher[I, O](Dispatcher[I, O]):
10
+ __slots__ = ("__resolve",)
11
+
12
+ __resolve: Callable[[], Awaitable[Dispatcher[I, O]]]
13
+
14
+ def __init__(
15
+ self,
16
+ dispatcher_type: type[Dispatcher[I, O]] | TypeAliasType | GenericAlias,
17
+ /,
18
+ di: DIAdapter,
19
+ ) -> None:
20
+ self.__resolve = di.lazy(dispatcher_type) # type: ignore[arg-type]
21
+
22
+ async def dispatch(self, input_value: I, /) -> O:
23
+ dispatcher = await self.__resolve()
24
+ return await dispatcher.dispatch(input_value)
@@ -7,10 +7,10 @@ from inspect import Parameter, isclass, unwrap
7
7
  from inspect import signature as inspect_signature
8
8
  from typing import TYPE_CHECKING, Any, Protocol, Self, overload, runtime_checkable
9
9
 
10
- import injection
11
10
  from type_analyzer import MatchingTypesConfig, iter_matching_types, matching_types
12
11
 
13
12
  from cq._core.common.typing import Decorator
13
+ from cq._core.di import DIAdapter, NoDI
14
14
 
15
15
  type HandlerType[**P, T] = type[Handler[P, T]]
16
16
  type HandlerFactory[**P, T] = Callable[..., Awaitable[Handler[P, T]]]
@@ -126,7 +126,7 @@ class SingleHandlerRegistry[I, O](HandlerRegistry[I, O]):
126
126
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
127
127
  class HandlerDecorator[I, O]:
128
128
  registry: HandlerRegistry[I, O]
129
- injection_module: injection.Module = field(default_factory=injection.mod)
129
+ di: DIAdapter = field(default_factory=NoDI)
130
130
 
131
131
  if TYPE_CHECKING: # pragma: no cover
132
132
 
@@ -137,7 +137,6 @@ class HandlerDecorator[I, O]:
137
137
  /,
138
138
  *,
139
139
  fail_silently: bool = ...,
140
- threadsafe: bool | None = ...,
141
140
  ) -> Decorator: ...
142
141
 
143
142
  @overload
@@ -147,7 +146,6 @@ class HandlerDecorator[I, O]:
147
146
  /,
148
147
  *,
149
148
  fail_silently: bool = ...,
150
- threadsafe: bool | None = ...,
151
149
  ) -> T: ...
152
150
 
153
151
  @overload
@@ -157,7 +155,6 @@ class HandlerDecorator[I, O]:
157
155
  /,
158
156
  *,
159
157
  fail_silently: bool = ...,
160
- threadsafe: bool | None = ...,
161
158
  ) -> Decorator: ...
162
159
 
163
160
  def __call__[T](
@@ -166,7 +163,6 @@ class HandlerDecorator[I, O]:
166
163
  /,
167
164
  *,
168
165
  fail_silently: bool = False,
169
- threadsafe: bool | None = None,
170
166
  ) -> Any:
171
167
  if (
172
168
  input_or_handler_type is not None
@@ -176,14 +172,12 @@ class HandlerDecorator[I, O]:
176
172
  return self.__decorator(
177
173
  input_or_handler_type,
178
174
  fail_silently=fail_silently,
179
- threadsafe=threadsafe,
180
175
  )
181
176
 
182
177
  return partial(
183
178
  self.__decorator,
184
179
  input_type=input_or_handler_type, # type: ignore[arg-type]
185
180
  fail_silently=fail_silently,
186
- threadsafe=threadsafe,
187
181
  )
188
182
 
189
183
  def __decorator(
@@ -193,9 +187,8 @@ class HandlerDecorator[I, O]:
193
187
  *,
194
188
  input_type: type[I] | None = None,
195
189
  fail_silently: bool = False,
196
- threadsafe: bool | None = None,
197
190
  ) -> HandlerType[[I], O]:
198
- factory = self.injection_module.make_async_factory(wrapped, threadsafe)
191
+ factory = self.di.wire(wrapped)
199
192
  input_type = input_type or _resolve_input_type(wrapped)
200
193
  self.registry.subscribe(input_type, factory, wrapped, fail_silently)
201
194
  return wrapped
@@ -0,0 +1,13 @@
1
+ from typing import Any
2
+
3
+ from cq._core.dispatcher.base import Dispatcher
4
+
5
+ Command = object
6
+ Event = object
7
+ Query = object
8
+
9
+ type CommandBus[T] = Dispatcher[Command, T]
10
+ type EventBus = Dispatcher[Event, None]
11
+ type QueryBus[T] = Dispatcher[Query, T]
12
+
13
+ AnyCommandBus = CommandBus[Any]
@@ -9,7 +9,8 @@ from cq.exceptions import MiddlewareError
9
9
  type MiddlewareResult[T] = AsyncGenerator[None, T]
10
10
  type GeneratorMiddleware[**P, T] = Callable[P, MiddlewareResult[T]]
11
11
  type ClassicMiddleware[**P, T] = Callable[
12
- Concatenate[Callable[P, Awaitable[T]], P], Awaitable[T]
12
+ Concatenate[Callable[P, Awaitable[T]], P],
13
+ Awaitable[T],
13
14
  ]
14
15
 
15
16
  type Middleware[**P, T] = ClassicMiddleware[P, T] | GeneratorMiddleware[P, T]
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from cq._core.di import DIAdapter
5
+ from cq._core.middleware import MiddlewareResult
6
+
7
+
8
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
9
+ class CommandDispatchScopeMiddleware:
10
+ di: DIAdapter
11
+
12
+ async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
13
+ async with self.di.command_scope():
14
+ yield
@@ -0,0 +1,52 @@
1
+ from typing import TYPE_CHECKING, Any, overload
2
+
3
+ from cq import Dispatcher
4
+ from cq._core.common.typing import Decorator
5
+ from cq._core.di import DIAdapter
6
+ from cq._core.dispatcher.lazy import LazyDispatcher
7
+ from cq._core.dispatcher.pipe import (
8
+ ContextPipeline,
9
+ ConvertMethod,
10
+ ConvertMethodAsync,
11
+ ConvertMethodSync,
12
+ )
13
+ from cq._core.message import Command, CommandBus, Query, QueryBus
14
+ from cq._core.middlewares.scope import CommandDispatchScopeMiddleware
15
+
16
+
17
+ class ContextCommandPipeline[C: Command](ContextPipeline[C]):
18
+ __slots__ = ("__query_dispatcher",)
19
+
20
+ __query_dispatcher: Dispatcher[Query, Any]
21
+
22
+ def __init__(self, di: DIAdapter) -> None:
23
+ super().__init__(LazyDispatcher(CommandBus, di))
24
+ self.__query_dispatcher = LazyDispatcher(QueryBus, di)
25
+ command_middleware = CommandDispatchScopeMiddleware(di)
26
+ self.add_middlewares(command_middleware)
27
+
28
+ if TYPE_CHECKING: # pragma: no cover
29
+
30
+ @overload
31
+ def query_step[Q: Query](
32
+ self,
33
+ wrapped: ConvertMethodAsync[Q, Any],
34
+ /,
35
+ ) -> ConvertMethodAsync[Q, Any]: ...
36
+
37
+ @overload
38
+ def query_step[Q: Query](
39
+ self,
40
+ wrapped: ConvertMethodSync[Q, Any],
41
+ /,
42
+ ) -> ConvertMethodSync[Q, Any]: ...
43
+
44
+ @overload
45
+ def query_step(self, wrapped: None = ..., /) -> Decorator: ...
46
+
47
+ def query_step[Q: Query]( # type: ignore[misc]
48
+ self,
49
+ wrapped: ConvertMethod[Q, Any] | None = None,
50
+ /,
51
+ ) -> Any:
52
+ return self.step(wrapped, dispatcher=self.__query_dispatcher)
@@ -1,14 +1,12 @@
1
1
  from abc import abstractmethod
2
- from collections.abc import AsyncIterator
3
2
  from dataclasses import dataclass, field
4
- from typing import Protocol, runtime_checkable
3
+ from types import TracebackType
4
+ from typing import Any, Protocol, Self, runtime_checkable
5
5
 
6
- import anyio
7
- import injection
6
+ from anyio import create_task_group
8
7
  from anyio.abc import TaskGroup
9
8
 
10
9
  from cq._core.message import Event, EventBus
11
- from cq._core.scope import CQScope
12
10
 
13
11
 
14
12
  @runtime_checkable
@@ -23,21 +21,27 @@ class RelatedEvents(Protocol):
23
21
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
24
22
  class AnyIORelatedEvents(RelatedEvents):
25
23
  event_bus: EventBus
26
- task_group: TaskGroup
24
+ task_group: TaskGroup = field(default_factory=create_task_group)
27
25
  history: list[Event] = field(default_factory=list, init=False)
28
26
 
29
27
  def __bool__(self) -> bool: # pragma: no cover
30
28
  return bool(self.history)
31
29
 
30
+ async def __aenter__(self) -> Self:
31
+ await self.task_group.__aenter__()
32
+ return self
33
+
34
+ async def __aexit__(
35
+ self,
36
+ exc_type: type[BaseException] | None,
37
+ exc_value: BaseException | None,
38
+ traceback: TracebackType | None,
39
+ ) -> Any:
40
+ return await self.task_group.__aexit__(exc_type, exc_value, traceback)
41
+
32
42
  def add(self, *events: Event) -> None:
33
43
  self.history.extend(events)
34
44
  dispatch_method = self.event_bus.dispatch
35
45
 
36
46
  for event in events:
37
47
  self.task_group.start_soon(dispatch_method, event)
38
-
39
-
40
- @injection.scoped(CQScope.TRANSACTION, mode="fallback")
41
- async def related_events_recipe(event_bus: EventBus) -> AsyncIterator[RelatedEvents]:
42
- async with anyio.create_task_group() as task_group:
43
- yield AnyIORelatedEvents(event_bus, task_group)
@@ -0,0 +1,102 @@
1
+ from collections.abc import AsyncIterator
2
+ from contextlib import AsyncExitStack, asynccontextmanager
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any, AsyncContextManager, Awaitable, Callable
6
+
7
+ from injection import Module, adefine_scope, mod
8
+ from injection.exceptions import ScopeAlreadyDefinedError
9
+
10
+ from cq._core.di import DIAdapter
11
+ from cq._core.message import CommandBus, EventBus, QueryBus
12
+ from cq._core.middleware import MiddlewareResult
13
+ from cq._core.related_events import AnyIORelatedEvents, RelatedEvents
14
+
15
+ __all__ = ("CQScope", "InjectionAdapter", "InjectionScopeMiddleware")
16
+
17
+
18
+ class CQScope(StrEnum):
19
+ COMMAND_DISPATCH = "__cq_command_dispatch__"
20
+
21
+
22
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
23
+ class InjectionAdapter(DIAdapter):
24
+ module: Module = field(default_factory=mod)
25
+ threadsafe: bool | None = field(default=None)
26
+
27
+ def command_scope(self) -> AsyncContextManager[None]:
28
+ return InjectionScopeMiddleware( # type: ignore[return-value]
29
+ CQScope.COMMAND_DISPATCH,
30
+ exist_ok=True,
31
+ threadsafe=self.threadsafe,
32
+ )._cm
33
+
34
+ def lazy[T](self, tp: type[T], /) -> Callable[[], Awaitable[T]]:
35
+ awaitable = self.module.aget_lazy_instance(tp, threadsafe=self.threadsafe)
36
+ return lambda: awaitable
37
+
38
+ def register_defaults(
39
+ self,
40
+ command_bus: Callable[..., CommandBus[Any]],
41
+ event_bus: Callable[..., EventBus],
42
+ query_bus: Callable[..., QueryBus[Any]],
43
+ ) -> None:
44
+ self.module.injectable(
45
+ command_bus,
46
+ ignore_type_hint=True,
47
+ inject=False,
48
+ on=CommandBus,
49
+ mode="fallback",
50
+ )
51
+ self.module.injectable(
52
+ event_bus,
53
+ ignore_type_hint=True,
54
+ inject=False,
55
+ on=EventBus,
56
+ mode="fallback",
57
+ )
58
+ self.module.injectable(
59
+ query_bus,
60
+ ignore_type_hint=True,
61
+ inject=False,
62
+ on=QueryBus,
63
+ mode="fallback",
64
+ )
65
+ self.module.scoped(
66
+ CQScope.COMMAND_DISPATCH,
67
+ mode="fallback",
68
+ )(self.__new_related_events)
69
+
70
+ def wire[T](self, tp: type[T], /) -> Callable[..., Awaitable[T]]:
71
+ return self.module.make_async_factory(tp, self.threadsafe)
72
+
73
+ @staticmethod
74
+ async def __new_related_events(event_bus: EventBus) -> AsyncIterator[RelatedEvents]:
75
+ async with AnyIORelatedEvents(event_bus) as events:
76
+ yield events
77
+
78
+
79
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
80
+ class InjectionScopeMiddleware:
81
+ scope_name: str
82
+ exist_ok: bool = field(default=False, kw_only=True)
83
+ threadsafe: bool | None = field(default=None, kw_only=True)
84
+
85
+ async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
86
+ async with self._cm: # type: ignore[attr-defined]
87
+ yield
88
+
89
+ @property
90
+ @asynccontextmanager
91
+ async def _cm(self) -> AsyncIterator[None]:
92
+ async with AsyncExitStack() as stack:
93
+ try:
94
+ await stack.enter_async_context(
95
+ adefine_scope(self.scope_name, threadsafe=self.threadsafe)
96
+ )
97
+
98
+ except ScopeAlreadyDefinedError:
99
+ if not self.exist_ok:
100
+ raise
101
+
102
+ yield
File without changes
@@ -3,7 +3,7 @@
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypi.org/project/python-cq)
4
4
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
5
5
 
6
- **python-cq** is a Python package designed to organize your code following CQRS principles. It builds on top of [python-injection](https://github.com/100nm/python-injection) for dependency injection.
6
+ **python-cq** is a Python package designed to organize your code following CQRS principles. It provides a `DIAdapter` protocol for dependency injection, with [python-injection](https://github.com/100nm/python-injection) as the default implementation available via the `[injection]` extra.
7
7
 
8
8
  ## What is CQRS?
9
9
 
@@ -35,6 +35,13 @@ This knowledge will help you design coherent handlers and organize your code eff
35
35
  ## Installation
36
36
 
37
37
  Requires Python 3.12 or higher.
38
+
39
+ Without dependency injection:
38
40
  ```bash
39
41
  pip install python-cq
40
42
  ```
43
+
44
+ With [python-injection](https://github.com/100nm/python-injection) as the DI backend (recommended):
45
+ ```bash
46
+ pip install "python-cq[injection]"
47
+ ```
@@ -13,8 +13,6 @@ docs = [
13
13
  "mkdocs-material",
14
14
  ]
15
15
  test = [
16
- "fastapi",
17
- "httpx",
18
16
  "pytest",
19
17
  "pytest-asyncio",
20
18
  "pytest-cov",
@@ -22,7 +20,7 @@ test = [
22
20
 
23
21
  [project]
24
22
  name = "python-cq"
25
- version = "0.15.2"
23
+ version = "0.16.0"
26
24
  description = "CQRS library for async Python projects."
27
25
  license = "MIT"
28
26
  license-files = ["LICENSE"]
@@ -48,10 +46,14 @@ classifiers = [
48
46
  ]
49
47
  dependencies = [
50
48
  "anyio",
51
- "python-injection",
52
49
  "type-analyzer",
53
50
  ]
54
51
 
52
+ [project.optional-dependencies]
53
+ injection = [
54
+ "python-injection[async]",
55
+ ]
56
+
55
57
  [project.urls]
56
58
  Documentation = "https://python-cq.remimd.dev"
57
59
  Repository = "https://github.com/100nm/python-cq"
@@ -1,51 +0,0 @@
1
- from ._core.dispatcher.base import DeferredDispatcher, Dispatcher
2
- from ._core.dispatcher.bus import Bus
3
- from ._core.dispatcher.lazy import LazyDispatcher
4
- from ._core.dispatcher.pipe import ContextPipeline, Pipe
5
- from ._core.message import (
6
- AnyCommandBus,
7
- Command,
8
- CommandBus,
9
- Event,
10
- EventBus,
11
- Query,
12
- QueryBus,
13
- command_handler,
14
- event_handler,
15
- new_command_bus,
16
- new_event_bus,
17
- new_query_bus,
18
- query_handler,
19
- )
20
- from ._core.middleware import Middleware, MiddlewareResult, resolve_handler_source
21
- from ._core.pipetools import ContextCommandPipeline
22
- from ._core.related_events import RelatedEvents
23
- from ._core.scope import CQScope
24
-
25
- __all__ = (
26
- "AnyCommandBus",
27
- "Bus",
28
- "CQScope",
29
- "Command",
30
- "CommandBus",
31
- "ContextCommandPipeline",
32
- "ContextPipeline",
33
- "DeferredDispatcher",
34
- "Dispatcher",
35
- "Event",
36
- "EventBus",
37
- "LazyDispatcher",
38
- "Middleware",
39
- "MiddlewareResult",
40
- "Pipe",
41
- "Query",
42
- "QueryBus",
43
- "RelatedEvents",
44
- "command_handler",
45
- "event_handler",
46
- "new_command_bus",
47
- "new_event_bus",
48
- "new_query_bus",
49
- "query_handler",
50
- "resolve_handler_source",
51
- )
@@ -1,28 +0,0 @@
1
- from collections.abc import Awaitable
2
- from types import GenericAlias
3
- from typing import TypeAliasType
4
-
5
- import injection
6
-
7
- from cq._core.dispatcher.base import Dispatcher
8
-
9
-
10
- class LazyDispatcher[I, O](Dispatcher[I, O]):
11
- __slots__ = ("__value",)
12
-
13
- __value: Awaitable[Dispatcher[I, O]]
14
-
15
- def __init__(
16
- self,
17
- dispatcher_type: type[Dispatcher[I, O]] | TypeAliasType | GenericAlias,
18
- /,
19
- *,
20
- injection_module: injection.Module | None = None,
21
- threadsafe: bool | None = None,
22
- ) -> None:
23
- module = injection_module or injection.mod()
24
- self.__value = module.aget_lazy_instance(dispatcher_type, threadsafe=threadsafe)
25
-
26
- async def dispatch(self, input_value: I, /) -> O:
27
- dispatcher = await self.__value
28
- return await dispatcher.dispatch(input_value)
@@ -1,71 +0,0 @@
1
- from typing import Any, Final
2
-
3
- import injection
4
-
5
- from cq._core.dispatcher.base import Dispatcher
6
- from cq._core.dispatcher.bus import Bus, SimpleBus, TaskBus
7
- from cq._core.handler import (
8
- HandlerDecorator,
9
- MultipleHandlerRegistry,
10
- SingleHandlerRegistry,
11
- )
12
- from cq._core.scope import CQScope
13
- from cq.middlewares.scope import InjectionScopeMiddleware
14
-
15
- Command = object
16
- Event = object
17
- Query = object
18
-
19
- type CommandBus[T] = Dispatcher[Command, T]
20
- type EventBus = Dispatcher[Event, None]
21
- type QueryBus[T] = Dispatcher[Query, T]
22
-
23
- AnyCommandBus = CommandBus[Any]
24
-
25
-
26
- command_handler: Final[HandlerDecorator[Command, Any]] = HandlerDecorator(
27
- SingleHandlerRegistry(),
28
- )
29
- event_handler: Final[HandlerDecorator[Event, Any]] = HandlerDecorator(
30
- MultipleHandlerRegistry(),
31
- )
32
- query_handler: Final[HandlerDecorator[Query, Any]] = HandlerDecorator(
33
- SingleHandlerRegistry(),
34
- )
35
-
36
-
37
- @injection.injectable(
38
- ignore_type_hint=True,
39
- inject=False,
40
- on=CommandBus,
41
- mode="fallback",
42
- )
43
- def new_command_bus(*, threadsafe: bool | None = None) -> Bus[Command, Any]:
44
- bus = SimpleBus(command_handler.registry)
45
- transaction_scope_middleware = InjectionScopeMiddleware(
46
- CQScope.TRANSACTION,
47
- exist_ok=True,
48
- threadsafe=threadsafe,
49
- )
50
- bus.add_middlewares(transaction_scope_middleware)
51
- return bus
52
-
53
-
54
- @injection.injectable(
55
- ignore_type_hint=True,
56
- inject=False,
57
- on=EventBus,
58
- mode="fallback",
59
- )
60
- def new_event_bus() -> Bus[Event, None]:
61
- return TaskBus(event_handler.registry)
62
-
63
-
64
- @injection.injectable(
65
- ignore_type_hint=True,
66
- inject=False,
67
- on=QueryBus,
68
- mode="fallback",
69
- )
70
- def new_query_bus() -> Bus[Query, Any]:
71
- return SimpleBus(query_handler.registry)
@@ -1,75 +0,0 @@
1
- from typing import TYPE_CHECKING, Any, overload
2
-
3
- import injection
4
-
5
- from cq import Dispatcher
6
- from cq._core.common.typing import Decorator
7
- from cq._core.dispatcher.lazy import LazyDispatcher
8
- from cq._core.dispatcher.pipe import (
9
- ContextPipeline,
10
- ConvertMethod,
11
- ConvertMethodAsync,
12
- ConvertMethodSync,
13
- )
14
- from cq._core.message import AnyCommandBus, Command, Query, QueryBus
15
- from cq._core.scope import CQScope
16
- from cq.middlewares.scope import InjectionScopeMiddleware
17
-
18
-
19
- class ContextCommandPipeline[I: Command](ContextPipeline[I]):
20
- __slots__ = ("__query_dispatcher",)
21
-
22
- __query_dispatcher: Dispatcher[Query, Any]
23
-
24
- def __init__(
25
- self,
26
- /,
27
- *,
28
- injection_module: injection.Module | None = None,
29
- threadsafe: bool | None = None,
30
- ) -> None:
31
- command_dispatcher = LazyDispatcher(
32
- AnyCommandBus,
33
- injection_module=injection_module,
34
- threadsafe=threadsafe,
35
- )
36
- super().__init__(command_dispatcher)
37
-
38
- self.__query_dispatcher = LazyDispatcher(
39
- QueryBus,
40
- injection_module=injection_module,
41
- threadsafe=threadsafe,
42
- )
43
-
44
- transaction_scope_middleware = InjectionScopeMiddleware(
45
- CQScope.TRANSACTION,
46
- exist_ok=True,
47
- threadsafe=threadsafe,
48
- )
49
- self.add_middlewares(transaction_scope_middleware)
50
-
51
- if TYPE_CHECKING: # pragma: no cover
52
-
53
- @overload
54
- def query_step[T: Query](
55
- self,
56
- wrapped: ConvertMethodAsync[T, Any],
57
- /,
58
- ) -> ConvertMethodAsync[T, Any]: ...
59
-
60
- @overload
61
- def query_step[T: Query](
62
- self,
63
- wrapped: ConvertMethodSync[T, Any],
64
- /,
65
- ) -> ConvertMethodSync[T, Any]: ...
66
-
67
- @overload
68
- def query_step(self, wrapped: None = ..., /) -> Decorator: ...
69
-
70
- def query_step[T: Query]( # type: ignore[misc]
71
- self,
72
- wrapped: ConvertMethod[T, Any] | None = None,
73
- /,
74
- ) -> Any:
75
- return self.step(wrapped, dispatcher=self.__query_dispatcher)
@@ -1,5 +0,0 @@
1
- from enum import StrEnum
2
-
3
-
4
- class CQScope(StrEnum):
5
- TRANSACTION = "__cq_transaction__"
@@ -1,73 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import TYPE_CHECKING, Annotated, Any
3
-
4
- from fastapi import BackgroundTasks, Depends
5
- from injection.ext.fastapi import Inject
6
-
7
- from cq import (
8
- Command,
9
- CommandBus,
10
- DeferredDispatcher,
11
- Dispatcher,
12
- Event,
13
- EventBus,
14
- Query,
15
- QueryBus,
16
- )
17
-
18
- __all__ = (
19
- "DeferredCommandBus",
20
- "DeferredEventBus",
21
- "DeferredQueryBus",
22
- "FastAPIDeferredDispatcher",
23
- )
24
-
25
-
26
- @dataclass(repr=False, eq=False, frozen=True, slots=True)
27
- class FastAPIDeferredDispatcher[I](DeferredDispatcher[I]):
28
- background_tasks: BackgroundTasks
29
- dispatcher: Dispatcher[I, Any]
30
-
31
- async def defer(self, input_value: I, /) -> None:
32
- self.background_tasks.add_task(self.dispatcher.dispatch, input_value)
33
-
34
-
35
- async def new_deferred_command_bus[T](
36
- background_tasks: BackgroundTasks,
37
- command_bus: Inject[CommandBus[T]],
38
- ) -> DeferredDispatcher[Command]:
39
- return FastAPIDeferredDispatcher(background_tasks, command_bus)
40
-
41
-
42
- async def new_deferred_event_bus(
43
- background_tasks: BackgroundTasks,
44
- event_bus: Inject[EventBus],
45
- ) -> DeferredDispatcher[Event]:
46
- return FastAPIDeferredDispatcher(background_tasks, event_bus)
47
-
48
-
49
- async def new_deferred_query_bus[T](
50
- background_tasks: BackgroundTasks,
51
- query_bus: Inject[QueryBus[T]],
52
- ) -> DeferredDispatcher[Query]:
53
- return FastAPIDeferredDispatcher(background_tasks, query_bus)
54
-
55
-
56
- if TYPE_CHECKING: # pragma: no cover
57
- type DeferredCommandBus = DeferredDispatcher[Command]
58
- type DeferredEventBus = DeferredDispatcher[Event]
59
- type DeferredQueryBus = DeferredDispatcher[Query]
60
-
61
- else:
62
- DeferredCommandBus = Annotated[
63
- DeferredDispatcher[Command],
64
- Depends(new_deferred_command_bus, use_cache=False),
65
- ]
66
- DeferredEventBus = Annotated[
67
- DeferredDispatcher[Event],
68
- Depends(new_deferred_event_bus, use_cache=False),
69
- ]
70
- DeferredQueryBus = Annotated[
71
- DeferredDispatcher[Query],
72
- Depends(new_deferred_query_bus, use_cache=False),
73
- ]
@@ -1,33 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from contextlib import AsyncExitStack
4
- from dataclasses import dataclass, field
5
- from typing import TYPE_CHECKING, Any
6
-
7
- from injection import adefine_scope
8
- from injection.exceptions import ScopeAlreadyDefinedError
9
-
10
- if TYPE_CHECKING: # pragma: no cover
11
- from cq import MiddlewareResult
12
-
13
- __all__ = ("InjectionScopeMiddleware",)
14
-
15
-
16
- @dataclass(repr=False, eq=False, frozen=True, slots=True)
17
- class InjectionScopeMiddleware:
18
- scope_name: str
19
- exist_ok: bool = field(default=False, kw_only=True)
20
- threadsafe: bool | None = field(default=None, kw_only=True)
21
-
22
- async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
23
- async with AsyncExitStack() as stack:
24
- try:
25
- await stack.enter_async_context(
26
- adefine_scope(self.scope_name, threadsafe=self.threadsafe)
27
- )
28
-
29
- except ScopeAlreadyDefinedError:
30
- if not self.exist_ok:
31
- raise
32
-
33
- yield
File without changes
File without changes
File without changes