python-cq 0.17.0__tar.gz → 0.18.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 (35) hide show
  1. python_cq-0.18.0/PKG-INFO +110 -0
  2. {python_cq-0.17.0 → python_cq-0.18.0}/cq/__init__.py +11 -3
  3. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/cq.py +1 -1
  4. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/di.py +1 -1
  5. python_cq-0.17.0/cq/_core/dispatcher/base.py → python_cq-0.18.0/cq/_core/dispatchers/abc.py +5 -5
  6. {python_cq-0.17.0/cq/_core/dispatcher → python_cq-0.18.0/cq/_core/dispatchers}/bus.py +16 -16
  7. {python_cq-0.17.0/cq/_core/dispatcher → python_cq-0.18.0/cq/_core/dispatchers}/lazy.py +3 -3
  8. {python_cq-0.17.0/cq/_core/dispatcher → python_cq-0.18.0/cq/_core/dispatchers}/pipe.py +32 -36
  9. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/handler.py +32 -32
  10. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/message.py +1 -1
  11. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/pipetools.py +2 -2
  12. python_cq-0.18.0/cq/_core/pump.py +34 -0
  13. python_cq-0.18.0/cq/_core/queues/abc.py +29 -0
  14. python_cq-0.18.0/cq/_core/queues/memory.py +42 -0
  15. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/related_events.py +5 -6
  16. python_cq-0.18.0/cq/py.typed +0 -0
  17. python_cq-0.18.0/docs/index.md +79 -0
  18. {python_cq-0.17.0 → python_cq-0.18.0}/pyproject.toml +1 -1
  19. python_cq-0.17.0/PKG-INFO +0 -78
  20. python_cq-0.17.0/docs/index.md +0 -47
  21. {python_cq-0.17.0 → python_cq-0.18.0}/.gitignore +0 -0
  22. {python_cq-0.17.0 → python_cq-0.18.0}/LICENSE +0 -0
  23. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/__init__.py +0 -0
  24. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/common/__init__.py +0 -0
  25. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/common/typing.py +0 -0
  26. {python_cq-0.17.0/cq/_core/dispatcher → python_cq-0.18.0/cq/_core/dispatchers}/__init__.py +0 -0
  27. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/middleware.py +0 -0
  28. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/middlewares/__init__.py +0 -0
  29. {python_cq-0.17.0 → python_cq-0.18.0}/cq/_core/middlewares/scope.py +0 -0
  30. {python_cq-0.17.0/cq/ext → python_cq-0.18.0/cq/_core/queues}/__init__.py +0 -0
  31. {python_cq-0.17.0 → python_cq-0.18.0}/cq/exceptions.py +0 -0
  32. {python_cq-0.17.0/cq/middlewares → python_cq-0.18.0/cq/ext}/__init__.py +0 -0
  33. {python_cq-0.17.0 → python_cq-0.18.0}/cq/ext/injection.py +0 -0
  34. /python_cq-0.17.0/cq/py.typed → /python_cq-0.18.0/cq/middlewares/__init__.py +0 -0
  35. {python_cq-0.17.0 → python_cq-0.18.0}/cq/middlewares/retry.py +0 -0
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-cq
3
+ Version: 0.18.0
4
+ Summary: CQRS library for async Python projects.
5
+ Project-URL: Documentation, https://python-cq.remimd.dev
6
+ Project-URL: Repository, https://github.com/100nm/python-cq
7
+ Author: remimd
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cqrs
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: <3.15,>=3.12
26
+ Requires-Dist: anyio
27
+ Requires-Dist: type-analyzer
28
+ Provides-Extra: injection
29
+ Requires-Dist: python-injection[async]; extra == 'injection'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # python-cq
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)
35
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
36
+
37
+ **python-cq** is an async-first Python library for organizing code around CQRS. It separates reads (queries), writes (commands), and notifications (events) into dedicated message buses, and lets you plug in any dependency injection framework behind a small protocol.
38
+
39
+ ## What is CQRS?
40
+
41
+ CQRS (Command Query Responsibility Segregation) splits read operations from write operations. Each operation has a single, well-defined responsibility, which:
42
+
43
+ * **clarifies intent**: a `CreateUserCommand` does one thing, and its name says so;
44
+ * **keeps handlers small**: one message, one handler, easy to test in isolation;
45
+ * **makes side effects explicit**: events fan out to subscribers without coupling the producer to them.
46
+
47
+ CQRS is often discussed alongside distributed systems and Event Sourcing, but the pattern is just as useful in a local or monolithic application. The boundaries it draws are valuable on their own.
48
+
49
+ ## Three message types
50
+
51
+ | Type | Intent | Handlers | Returns |
52
+ |-----------|---------------------------------------|-----------------------|--------------------------|
53
+ | `Command` | Change the state of the system | Exactly one | The handler's return value |
54
+ | `Query` | Read state without side effects | Exactly one | The handler's return value |
55
+ | `Event` | Notify that something has happened | Zero, one, or many | Nothing |
56
+
57
+ A `Command` is allowed to return a value (for convenience, typically an id or a result object), but that does not mean it should be used as a query. Keep intent clear.
58
+
59
+ ## Installation
60
+
61
+ Requires Python 3.12 or higher.
62
+
63
+ With the default DI backend ([python-injection](https://github.com/100nm/python-injection), recommended):
64
+
65
+ ```bash
66
+ pip install "python-cq[injection]"
67
+ ```
68
+
69
+ Without dependency injection (you will need to implement a `DIAdapter`):
70
+
71
+ ```bash
72
+ pip install python-cq
73
+ ```
74
+
75
+ ## Quickstart
76
+
77
+ ```python
78
+ import asyncio
79
+ from cq import CommandBus, command_handler
80
+ from dataclasses import dataclass
81
+ from injection import inject
82
+
83
+ @dataclass
84
+ class CreateUserCommand:
85
+ name: str
86
+ email: str
87
+
88
+ @command_handler
89
+ class CreateUserHandler:
90
+ async def handle(self, command: CreateUserCommand) -> int:
91
+ # ... persist the user, return its id
92
+ return 42
93
+
94
+ @inject
95
+ async def main(bus: CommandBus[int]) -> None:
96
+ command = CreateUserCommand(name="Ada", email="ada@example.com")
97
+ user_id = await bus.dispatch(command)
98
+ print(f"Created user {user_id}")
99
+
100
+ asyncio.run(main())
101
+ ```
102
+
103
+ The decorator registers the handler against the type of its first `handle` parameter. The bus is resolved by the DI container and dispatched to that handler.
104
+
105
+ ## Prerequisites
106
+
107
+ Familiarity with the following helps you get the most out of python-cq:
108
+
109
+ * **CQRS**, in particular the distinction between Commands, Queries, and Events.
110
+ * **Domain Driven Design (DDD)**, particularly aggregates and bounded contexts, which complement CQRS well.
@@ -1,9 +1,9 @@
1
1
  from ._core.cq import CQ
2
2
  from ._core.di import DIAdapter
3
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
4
+ from ._core.dispatchers.abc import Dispatcher
5
+ from ._core.dispatchers.bus import Bus
6
+ from ._core.dispatchers.pipe import ContextPipeline, Pipe
7
7
  from ._core.message import (
8
8
  AnyCommandBus,
9
9
  Command,
@@ -15,6 +15,9 @@ from ._core.message import (
15
15
  )
16
16
  from ._core.middleware import Middleware, MiddlewareResult, resolve_handler_source
17
17
  from ._core.pipetools import ContextCommandPipeline as _ContextCommandPipeline
18
+ from ._core.pump import Pump
19
+ from ._core.queues.abc import Consumer, Producer, Queue
20
+ from ._core.queues.memory import MemoryQueue
18
21
  from ._core.related_events import AnyIORelatedEvents, RelatedEvents
19
22
 
20
23
  __all__ = (
@@ -24,17 +27,22 @@ __all__ = (
24
27
  "CQ",
25
28
  "Command",
26
29
  "CommandBus",
30
+ "Consumer",
27
31
  "ContextCommandPipeline",
28
32
  "ContextPipeline",
29
33
  "DIAdapter",
30
34
  "Dispatcher",
31
35
  "Event",
32
36
  "EventBus",
37
+ "MemoryQueue",
33
38
  "Middleware",
34
39
  "MiddlewareResult",
35
40
  "Pipe",
41
+ "Producer",
42
+ "Pump",
36
43
  "Query",
37
44
  "QueryBus",
45
+ "Queue",
38
46
  "RelatedEvents",
39
47
  "command_handler",
40
48
  "event_handler",
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Self
2
2
 
3
3
  from cq._core.di import DIAdapter
4
- from cq._core.dispatcher.bus import Bus, SimpleBus, TaskBus
4
+ from cq._core.dispatchers.bus import Bus, SimpleBus, TaskBus
5
5
  from cq._core.handler import (
6
6
  HandlerDecorator,
7
7
  HandlerRegistry,
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
5
5
  from contextlib import nullcontext
6
6
  from typing import TYPE_CHECKING, Any, AsyncContextManager, Protocol, runtime_checkable
7
7
 
8
- if TYPE_CHECKING:
8
+ if TYPE_CHECKING: # pragma: no cover
9
9
  from cq import CommandBus, EventBus, QueryBus
10
10
 
11
11
 
@@ -9,11 +9,11 @@ from cq._core.middleware import Middleware, MiddlewareGroup
9
9
  class Dispatcher[I, O](Protocol):
10
10
  __slots__ = ()
11
11
 
12
- async def __call__(self, input_value: I, /) -> O:
13
- return await self.dispatch(input_value)
12
+ async def __call__(self, message: I, /) -> O:
13
+ return await self.dispatch(message)
14
14
 
15
15
  @abstractmethod
16
- async def dispatch(self, input_value: I, /) -> O:
16
+ async def dispatch(self, message: I, /) -> O:
17
17
  raise NotImplementedError
18
18
 
19
19
 
@@ -32,12 +32,12 @@ class BaseDispatcher[I, O](Dispatcher[I, O], ABC):
32
32
  async def _invoke_with_middlewares(
33
33
  self,
34
34
  handler: Callable[[I], Awaitable[O]],
35
- input_value: I,
35
+ message: I,
36
36
  /,
37
37
  fail_silently: bool = False,
38
38
  ) -> O:
39
39
  try:
40
- return await self.__middleware_group.invoke(handler, input_value)
40
+ return await self.__middleware_group.invoke(handler, message)
41
41
  except Exception:
42
42
  if fail_silently:
43
43
  return NotImplemented
@@ -5,7 +5,7 @@ from typing import Any, Protocol, Self, runtime_checkable
5
5
  import anyio
6
6
  from anyio.abc import TaskGroup
7
7
 
8
- from cq._core.dispatcher.base import BaseDispatcher, Dispatcher
8
+ from cq._core.dispatchers.abc import BaseDispatcher, Dispatcher
9
9
  from cq._core.handler import (
10
10
  HandleFunction,
11
11
  HandlerFactory,
@@ -33,7 +33,7 @@ class Bus[I, O](Dispatcher[I, O], Protocol):
33
33
  @abstractmethod
34
34
  def subscribe(
35
35
  self,
36
- input_type: type[I],
36
+ message_type: type[I],
37
37
  factory: HandlerFactory[[I], O],
38
38
  fail_silently: bool = ...,
39
39
  ) -> Self:
@@ -57,19 +57,19 @@ class BaseBus[I, O](BaseDispatcher[I, O], Bus[I, O], ABC):
57
57
 
58
58
  def subscribe(
59
59
  self,
60
- input_type: type[I],
60
+ message_type: type[I],
61
61
  factory: HandlerFactory[[I], O],
62
62
  fail_silently: bool = False,
63
63
  ) -> Self:
64
- self.__registry.subscribe(input_type, factory, fail_silently=fail_silently)
64
+ self.__registry.subscribe(message_type, factory, fail_silently=fail_silently)
65
65
  return self
66
66
 
67
- def _handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
68
- return self.__registry.handlers_from(input_type)
67
+ def _handlers_from(self, message_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
68
+ return self.__registry.handlers_from(message_type)
69
69
 
70
- def _trigger_listeners(self, input_value: I, /, task_group: TaskGroup) -> None:
70
+ def _trigger_listeners(self, message: I, /, task_group: TaskGroup) -> None:
71
71
  for listener in self.__listeners:
72
- task_group.start_soon(listener, input_value)
72
+ task_group.start_soon(listener, message)
73
73
 
74
74
 
75
75
  class SimpleBus[I, O](BaseBus[I, O]):
@@ -78,14 +78,14 @@ class SimpleBus[I, O](BaseBus[I, O]):
78
78
  def __init__(self, registry: HandlerRegistry[I, O] | None = None, /) -> None:
79
79
  super().__init__(registry or SingleHandlerRegistry())
80
80
 
81
- async def dispatch(self, input_value: I, /) -> O:
81
+ async def dispatch(self, message: I, /) -> O:
82
82
  async with anyio.create_task_group() as task_group:
83
- self._trigger_listeners(input_value, task_group)
83
+ self._trigger_listeners(message, task_group)
84
84
 
85
- for handler in self._handlers_from(type(input_value)):
85
+ for handler in self._handlers_from(type(message)):
86
86
  return await self._invoke_with_middlewares(
87
87
  handler,
88
- input_value,
88
+ message,
89
89
  handler.fail_silently,
90
90
  )
91
91
 
@@ -98,14 +98,14 @@ class TaskBus[I](BaseBus[I, None]):
98
98
  def __init__(self, registry: HandlerRegistry[I, None] | None = None, /) -> None:
99
99
  super().__init__(registry or MultipleHandlerRegistry())
100
100
 
101
- async def dispatch(self, input_value: I, /) -> None:
101
+ async def dispatch(self, message: I, /) -> None:
102
102
  async with anyio.create_task_group() as task_group:
103
- self._trigger_listeners(input_value, task_group)
103
+ self._trigger_listeners(message, task_group)
104
104
 
105
- for handler in self._handlers_from(type(input_value)):
105
+ for handler in self._handlers_from(type(message)):
106
106
  task_group.start_soon(
107
107
  self._invoke_with_middlewares,
108
108
  handler,
109
- input_value,
109
+ message,
110
110
  handler.fail_silently,
111
111
  )
@@ -3,7 +3,7 @@ from types import GenericAlias
3
3
  from typing import TypeAliasType
4
4
 
5
5
  from cq._core.di import DIAdapter
6
- from cq._core.dispatcher.base import Dispatcher
6
+ from cq._core.dispatchers.abc import Dispatcher
7
7
 
8
8
 
9
9
  class LazyDispatcher[I, O](Dispatcher[I, O]):
@@ -19,6 +19,6 @@ class LazyDispatcher[I, O](Dispatcher[I, O]):
19
19
  ) -> None:
20
20
  self.__resolve = di.lazy(dispatcher_type) # type: ignore[arg-type]
21
21
 
22
- async def dispatch(self, input_value: I, /) -> O:
22
+ async def dispatch(self, message: I, /) -> O:
23
23
  dispatcher = await self.__resolve()
24
- return await dispatcher.dispatch(input_value)
24
+ return await dispatcher.dispatch(message)
@@ -14,7 +14,7 @@ from typing import (
14
14
  )
15
15
 
16
16
  from cq._core.common.typing import Decorator, Method
17
- from cq._core.dispatcher.base import BaseDispatcher, Dispatcher
17
+ from cq._core.dispatchers.abc import BaseDispatcher, Dispatcher
18
18
  from cq._core.middleware import Middleware, MiddlewareGroup
19
19
 
20
20
  type ConvertAsync[**P, I, O] = Callable[Concatenate[O, P], Awaitable[I]]
@@ -31,7 +31,7 @@ class PipelineConverter[**P, I, O](Protocol):
31
31
  __slots__ = ()
32
32
 
33
33
  @abstractmethod
34
- async def convert(self, output_value: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
34
+ async def convert(self, result: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
35
35
  raise NotImplementedError
36
36
 
37
37
 
@@ -54,28 +54,24 @@ class PipelineSteps[**P, I, O]:
54
54
  self.__steps.append(PipelineStep(converter, dispatcher))
55
55
  return self
56
56
 
57
- def add_static[T](
58
- self,
59
- input_value: T,
60
- dispatcher: Dispatcher[T, Any] | None,
61
- ) -> Self:
62
- converter = _StaticPipelineConverter(input_value)
57
+ def add_static[T](self, message: T, dispatcher: Dispatcher[T, Any] | None) -> Self:
58
+ converter = _StaticPipelineConverter(message)
63
59
  self.add(converter, dispatcher) # type: ignore[arg-type]
64
60
  return self
65
61
 
66
- async def execute(self, input_value: I, /, *args: P.args, **kwargs: P.kwargs) -> O:
62
+ async def execute(self, message: I, /, *args: P.args, **kwargs: P.kwargs) -> O:
67
63
  dispatcher = self.default_dispatcher
68
64
 
69
65
  for step in self.__steps:
70
- output_value = await dispatcher.dispatch(input_value)
71
- input_value = await step.converter.convert(output_value, *args, **kwargs)
66
+ result = await dispatcher.dispatch(message)
67
+ message = await step.converter.convert(result, *args, **kwargs)
72
68
 
73
- if input_value is None:
69
+ if message is None:
74
70
  return NotImplemented
75
71
 
76
72
  dispatcher = step.dispatcher or self.default_dispatcher
77
73
 
78
- return await dispatcher.dispatch(input_value)
74
+ return await dispatcher.dispatch(message)
79
75
 
80
76
 
81
77
  class Pipe[I, O](BaseDispatcher[I, O]):
@@ -136,15 +132,15 @@ class Pipe[I, O](BaseDispatcher[I, O]):
136
132
 
137
133
  def add_static_step[T](
138
134
  self,
139
- input_value: T,
135
+ message: T,
140
136
  /,
141
137
  dispatcher: Dispatcher[T, Any] | None = None,
142
138
  ) -> Self:
143
- self.__steps.add_static(input_value, dispatcher)
139
+ self.__steps.add_static(message, dispatcher)
144
140
  return self
145
141
 
146
- async def dispatch(self, input_value: I, /) -> O:
147
- return await self._invoke_with_middlewares(self.__steps.execute, input_value)
142
+ async def dispatch(self, message: I, /) -> O:
143
+ return await self._invoke_with_middlewares(self.__steps.execute, message)
148
144
 
149
145
 
150
146
  class ContextPipeline[I]:
@@ -199,11 +195,11 @@ class ContextPipeline[I]:
199
195
 
200
196
  def add_static_step[T](
201
197
  self,
202
- input_value: T,
198
+ message: T,
203
199
  /,
204
200
  dispatcher: Dispatcher[T, Any] | None = None,
205
201
  ) -> Self:
206
- self.__steps.add_static(input_value, dispatcher)
202
+ self.__steps.add_static(message, dispatcher)
207
203
  return self
208
204
 
209
205
  if TYPE_CHECKING: # pragma: no cover
@@ -255,49 +251,49 @@ class ContextPipeline[I]:
255
251
 
256
252
  async def __execute[Context](
257
253
  self,
258
- input_value: I,
254
+ message: I,
259
255
  /,
260
256
  *,
261
257
  context: Context,
262
258
  context_type: type[Context] | None,
263
259
  ) -> Context:
264
- async def handler(i: I, /) -> Context:
265
- await self.__steps.execute(i, context, context_type)
260
+ async def handler(first_message: I, /) -> Context:
261
+ await self.__steps.execute(first_message, context, context_type)
266
262
  return context
267
263
 
268
- return await self.__middleware_group.invoke(handler, input_value)
264
+ return await self.__middleware_group.invoke(handler, message)
269
265
 
270
266
 
271
267
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
272
268
  class BoundContextPipeline[I, O](Dispatcher[I, O]):
273
269
  dispatch_method: Callable[[I], Awaitable[O]]
274
270
 
275
- async def dispatch(self, input_value: I, /) -> O:
276
- return await self.dispatch_method(input_value)
271
+ async def dispatch(self, message: I, /) -> O:
272
+ return await self.dispatch_method(message)
277
273
 
278
274
 
279
275
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
280
276
  class _AsyncPipelineConverter[**P, I, O](PipelineConverter[P, I, O]):
281
277
  converter: ConvertAsync[P, I, O]
282
278
 
283
- async def convert(self, output_value: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
284
- return await self.converter(output_value, *args, **kwargs)
279
+ async def convert(self, result: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
280
+ return await self.converter(result, *args, **kwargs)
285
281
 
286
282
 
287
283
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
288
284
  class _SyncPipelineConverter[**P, I, O](PipelineConverter[P, I, O]):
289
285
  converter: ConvertSync[P, I, O]
290
286
 
291
- async def convert(self, output_value: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
292
- return self.converter(output_value, *args, **kwargs)
287
+ async def convert(self, result: O, /, *args: P.args, **kwargs: P.kwargs) -> I:
288
+ return self.converter(result, *args, **kwargs)
293
289
 
294
290
 
295
291
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
296
292
  class _StaticPipelineConverter[I](PipelineConverter[..., I, Any]):
297
- input_value: I
293
+ message: I
298
294
 
299
- async def convert(self, output_value: Any, /, *args: Any, **kwargs: Any) -> I:
300
- return self.input_value
295
+ async def convert(self, result: Any, /, *args: Any, **kwargs: Any) -> I:
296
+ return self.message
301
297
 
302
298
 
303
299
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
@@ -308,13 +304,13 @@ class _AsyncContextPipelineConverter[I, O](
308
304
 
309
305
  async def convert(
310
306
  self,
311
- output_value: O,
307
+ result: O,
312
308
  /,
313
309
  context: object,
314
310
  context_type: type | None,
315
311
  ) -> I:
316
312
  method = self.converter.__get__(context, context_type)
317
- return await method(output_value)
313
+ return await method(result)
318
314
 
319
315
 
320
316
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
@@ -325,10 +321,10 @@ class _SyncContextPipelineConverter[I, O](
325
321
 
326
322
  async def convert(
327
323
  self,
328
- output_value: O,
324
+ result: O,
329
325
  /,
330
326
  context: object,
331
327
  context_type: type | None,
332
328
  ) -> I:
333
329
  method = self.converter.__get__(context, context_type)
334
- return method(output_value)
330
+ return method(result)
@@ -50,13 +50,13 @@ class HandlerRegistry[I, O](Protocol):
50
50
  __slots__ = ()
51
51
 
52
52
  @abstractmethod
53
- def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
53
+ def handlers_from(self, message_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
54
54
  raise NotImplementedError
55
55
 
56
56
  @abstractmethod
57
57
  def subscribe(
58
58
  self,
59
- input_type: type[I],
59
+ message_type: type[I],
60
60
  handler_factory: HandlerFactory[[I], O],
61
61
  handler_type: HandlerType[[I], O] | None = ...,
62
62
  fail_silently: bool = ...,
@@ -71,20 +71,20 @@ class MultipleHandlerRegistry[I, O](HandlerRegistry[I, O]):
71
71
  init=False,
72
72
  )
73
73
 
74
- def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
75
- for key_type in _iter_key_types(input_type):
74
+ def handlers_from(self, message_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
75
+ for key_type in _iter_key_types(message_type):
76
76
  yield from self.__values.get(key_type, ())
77
77
 
78
78
  def subscribe(
79
79
  self,
80
- input_type: type[I],
80
+ message_type: type[I],
81
81
  handler_factory: HandlerFactory[[I], O],
82
82
  handler_type: HandlerType[[I], O] | None = None,
83
83
  fail_silently: bool = False,
84
84
  ) -> Self:
85
85
  function = HandleFunction.create(handler_factory, handler_type, fail_silently)
86
86
 
87
- for key_type in _build_key_types(input_type):
87
+ for key_type in _build_key_types(message_type):
88
88
  self.__values[key_type].append(function)
89
89
 
90
90
  return self
@@ -97,26 +97,26 @@ class SingleHandlerRegistry[I, O](HandlerRegistry[I, O]):
97
97
  init=False,
98
98
  )
99
99
 
100
- def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
101
- for key_type in _iter_key_types(input_type):
100
+ def handlers_from(self, message_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
101
+ for key_type in _iter_key_types(message_type):
102
102
  function = self.__values.get(key_type, None)
103
103
  if function is not None:
104
104
  yield function
105
105
 
106
106
  def subscribe(
107
107
  self,
108
- input_type: type[I],
108
+ message_type: type[I],
109
109
  handler_factory: HandlerFactory[[I], O],
110
110
  handler_type: HandlerType[[I], O] | None = None,
111
111
  fail_silently: bool = False,
112
112
  ) -> Self:
113
113
  function = HandleFunction.create(handler_factory, handler_type, fail_silently)
114
- entries = {key_type: function for key_type in _build_key_types(input_type)}
114
+ entries = {key_type: function for key_type in _build_key_types(message_type)}
115
115
 
116
116
  for key_type in entries:
117
117
  if key_type in self.__values:
118
118
  raise RuntimeError(
119
- f"A handler is already registered for the input type: `{key_type}`."
119
+ f"A handler is already registered for the message type: `{key_type}`."
120
120
  )
121
121
 
122
122
  self.__values.update(entries)
@@ -133,7 +133,7 @@ class HandlerDecorator[I, O]:
133
133
  @overload
134
134
  def __call__(
135
135
  self,
136
- input_or_handler_type: type[I],
136
+ message_or_handler_type: type[I],
137
137
  /,
138
138
  *,
139
139
  fail_silently: bool = ...,
@@ -142,7 +142,7 @@ class HandlerDecorator[I, O]:
142
142
  @overload
143
143
  def __call__[T](
144
144
  self,
145
- input_or_handler_type: T,
145
+ message_or_handler_type: T,
146
146
  /,
147
147
  *,
148
148
  fail_silently: bool = ...,
@@ -151,7 +151,7 @@ class HandlerDecorator[I, O]:
151
151
  @overload
152
152
  def __call__(
153
153
  self,
154
- input_or_handler_type: None = ...,
154
+ message_or_handler_type: None = ...,
155
155
  /,
156
156
  *,
157
157
  fail_silently: bool = ...,
@@ -159,24 +159,24 @@ class HandlerDecorator[I, O]:
159
159
 
160
160
  def __call__[T](
161
161
  self,
162
- input_or_handler_type: type[I] | T | None = None,
162
+ message_or_handler_type: type[I] | T | None = None,
163
163
  /,
164
164
  *,
165
165
  fail_silently: bool = False,
166
166
  ) -> Any:
167
167
  if (
168
- input_or_handler_type is not None
169
- and isclass(input_or_handler_type)
170
- and issubclass(input_or_handler_type, Handler)
168
+ message_or_handler_type is not None
169
+ and isclass(message_or_handler_type)
170
+ and issubclass(message_or_handler_type, Handler)
171
171
  ):
172
172
  return self.__decorator(
173
- input_or_handler_type,
173
+ message_or_handler_type,
174
174
  fail_silently=fail_silently,
175
175
  )
176
176
 
177
177
  return partial(
178
178
  self.__decorator,
179
- input_type=input_or_handler_type, # type: ignore[arg-type]
179
+ message_type=message_or_handler_type, # type: ignore[arg-type]
180
180
  fail_silently=fail_silently,
181
181
  )
182
182
 
@@ -185,42 +185,42 @@ class HandlerDecorator[I, O]:
185
185
  wrapped: HandlerType[[I], O],
186
186
  /,
187
187
  *,
188
- input_type: type[I] | None = None,
188
+ message_type: type[I] | None = None,
189
189
  fail_silently: bool = False,
190
190
  ) -> HandlerType[[I], O]:
191
191
  factory = self.di.wire(wrapped)
192
- input_type = input_type or _resolve_input_type(wrapped)
193
- self.registry.subscribe(input_type, factory, wrapped, fail_silently)
192
+ message_type = message_type or _resolve_message_type(wrapped)
193
+ self.registry.subscribe(message_type, factory, wrapped, fail_silently)
194
194
  return wrapped
195
195
 
196
196
 
197
- def _build_key_types(input_type: Any) -> tuple[Any, ...]:
197
+ def _build_key_types(message_type: Any) -> tuple[Any, ...]:
198
198
  config = MatchingTypesConfig(ignore_none=True)
199
- return matching_types(input_type, config)
199
+ return matching_types(message_type, config)
200
200
 
201
201
 
202
- def _iter_key_types(input_type: Any) -> Iterator[Any]:
202
+ def _iter_key_types(message_type: Any) -> Iterator[Any]:
203
203
  config = MatchingTypesConfig(
204
204
  with_bases=True,
205
205
  with_origin=True,
206
206
  with_type_alias_value=True,
207
207
  )
208
- return iter_matching_types(input_type, config)
208
+ return iter_matching_types(message_type, config)
209
209
 
210
210
 
211
- def _resolve_input_type[I, O](handler_type: HandlerType[[I], O]) -> type[I]:
211
+ def _resolve_message_type[I, O](handler_type: HandlerType[[I], O]) -> type[I]:
212
212
  fake_method = handler_type.handle.__get__(NotImplemented, handler_type)
213
213
  signature = inspect_signature(fake_method, eval_str=True)
214
214
 
215
215
  for parameter in signature.parameters.values():
216
- input_type = parameter.annotation
216
+ message_type = parameter.annotation
217
217
 
218
- if input_type is Parameter.empty:
218
+ if message_type is Parameter.empty:
219
219
  break
220
220
 
221
- return input_type
221
+ return message_type
222
222
 
223
223
  raise TypeError(
224
- f"Unable to resolve input type for handler `{handler_type}`, "
224
+ f"Unable to resolve message type for handler `{handler_type}`, "
225
225
  "`handle` method must have a type annotation for its first parameter."
226
226
  )
@@ -1,6 +1,6 @@
1
1
  from typing import Any
2
2
 
3
- from cq._core.dispatcher.base import Dispatcher
3
+ from cq._core.dispatchers.abc import Dispatcher
4
4
 
5
5
  Command = object
6
6
  Event = object
@@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, Any, Self, overload
3
3
  from cq import Dispatcher
4
4
  from cq._core.common.typing import Decorator
5
5
  from cq._core.di import DIAdapter
6
- from cq._core.dispatcher.lazy import LazyDispatcher
7
- from cq._core.dispatcher.pipe import (
6
+ from cq._core.dispatchers.lazy import LazyDispatcher
7
+ from cq._core.dispatchers.pipe import (
8
8
  ContextPipeline,
9
9
  ConvertMethod,
10
10
  ConvertMethodAsync,
@@ -0,0 +1,34 @@
1
+ from collections.abc import AsyncIterator, Awaitable, Callable
2
+ from contextlib import asynccontextmanager
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ import anyio
7
+
8
+ from cq._core.queues.abc import Consumer
9
+
10
+
11
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
12
+ class Pump[T]:
13
+ consumer: Consumer[T]
14
+ dispatcher: Callable[[T], Awaitable[Any]]
15
+ fail_silently: bool = field(default=False)
16
+
17
+ async def drain(self) -> None:
18
+ async for message in self.consumer:
19
+ try:
20
+ await self.dispatcher(message)
21
+ except Exception:
22
+ if not self.fail_silently:
23
+ raise
24
+
25
+ @asynccontextmanager
26
+ async def draining(self, /, *, graceful: bool = False) -> AsyncIterator[None]:
27
+ async with anyio.create_task_group() as task_group:
28
+ task_group.start_soon(self.drain)
29
+
30
+ try:
31
+ yield
32
+ finally:
33
+ if not graceful:
34
+ task_group.cancel_scope.cancel()
@@ -0,0 +1,29 @@
1
+ from abc import abstractmethod
2
+ from collections.abc import AsyncIterator
3
+ from typing import Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class Producer[T](Protocol):
8
+ __slots__ = ()
9
+
10
+ async def __call__(self, message: T, /) -> None:
11
+ return await self.send(message)
12
+
13
+ @abstractmethod
14
+ async def send(self, message: T, /) -> None:
15
+ raise NotImplementedError
16
+
17
+
18
+ @runtime_checkable
19
+ class Consumer[T](Protocol):
20
+ __slots__ = ()
21
+
22
+ @abstractmethod
23
+ def __aiter__(self) -> AsyncIterator[T]:
24
+ raise NotImplementedError
25
+
26
+
27
+ @runtime_checkable
28
+ class Queue[T](Producer[T], Consumer[T], Protocol):
29
+ __slots__ = ()
@@ -0,0 +1,42 @@
1
+ from collections.abc import AsyncIterator, Awaitable, Callable
2
+ from contextlib import asynccontextmanager
3
+ from typing import Any, Self
4
+
5
+ import anyio
6
+ from anyio.abc import ObjectReceiveStream, ObjectSendStream
7
+
8
+ from cq._core.pump import Pump
9
+ from cq._core.queues.abc import Queue
10
+
11
+
12
+ class MemoryQueue[T](Queue[T]):
13
+ __slots__ = ("__consumer", "__producer")
14
+
15
+ __consumer: ObjectReceiveStream[T]
16
+ __producer: ObjectSendStream[T]
17
+
18
+ def __init__(self, maxsize: int = 0) -> None:
19
+ self.__producer, self.__consumer = anyio.create_memory_object_stream(maxsize)
20
+
21
+ def __aiter__(self) -> AsyncIterator[T]:
22
+ return aiter(self.__consumer)
23
+
24
+ async def close(self) -> None:
25
+ await self.__producer.aclose()
26
+
27
+ @asynccontextmanager
28
+ async def draining(
29
+ self,
30
+ dispatcher: Callable[[T], Awaitable[Any]],
31
+ /,
32
+ *,
33
+ fail_silently: bool = False,
34
+ ) -> AsyncIterator[Self]:
35
+ async with Pump(self, dispatcher, fail_silently).draining(graceful=True):
36
+ try:
37
+ yield self
38
+ finally:
39
+ await self.close()
40
+
41
+ async def send(self, message: T, /) -> None:
42
+ await self.__producer.send(message)
@@ -1,12 +1,13 @@
1
1
  from abc import abstractmethod
2
+ from collections.abc import Awaitable, Callable
2
3
  from dataclasses import dataclass, field
3
4
  from types import TracebackType
4
- from typing import Protocol, Self, runtime_checkable
5
+ from typing import Any, Protocol, Self, runtime_checkable
5
6
 
6
7
  from anyio import create_task_group
7
8
  from anyio.abc import TaskGroup
8
9
 
9
- from cq._core.message import Event, EventBus
10
+ from cq._core.message import Event
10
11
 
11
12
 
12
13
  @runtime_checkable
@@ -20,7 +21,7 @@ class RelatedEvents(Protocol):
20
21
 
21
22
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
22
23
  class AnyIORelatedEvents(RelatedEvents):
23
- event_bus: EventBus
24
+ emit: Callable[[Event], Awaitable[Any]]
24
25
  task_group: TaskGroup = field(default_factory=create_task_group)
25
26
  history: list[Event] = field(default_factory=list, init=False)
26
27
 
@@ -41,7 +42,5 @@ class AnyIORelatedEvents(RelatedEvents):
41
42
 
42
43
  def add(self, *events: Event) -> None:
43
44
  self.history.extend(events)
44
- dispatch_method = self.event_bus.dispatch
45
-
46
45
  for event in events:
47
- self.task_group.start_soon(dispatch_method, event)
46
+ self.task_group.start_soon(self.emit, event)
File without changes
@@ -0,0 +1,79 @@
1
+ # python-cq
2
+
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
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
5
+
6
+ **python-cq** is an async-first Python library for organizing code around CQRS. It separates reads (queries), writes (commands), and notifications (events) into dedicated message buses, and lets you plug in any dependency injection framework behind a small protocol.
7
+
8
+ ## What is CQRS?
9
+
10
+ CQRS (Command Query Responsibility Segregation) splits read operations from write operations. Each operation has a single, well-defined responsibility, which:
11
+
12
+ * **clarifies intent**: a `CreateUserCommand` does one thing, and its name says so;
13
+ * **keeps handlers small**: one message, one handler, easy to test in isolation;
14
+ * **makes side effects explicit**: events fan out to subscribers without coupling the producer to them.
15
+
16
+ CQRS is often discussed alongside distributed systems and Event Sourcing, but the pattern is just as useful in a local or monolithic application. The boundaries it draws are valuable on their own.
17
+
18
+ ## Three message types
19
+
20
+ | Type | Intent | Handlers | Returns |
21
+ |-----------|---------------------------------------|-----------------------|--------------------------|
22
+ | `Command` | Change the state of the system | Exactly one | The handler's return value |
23
+ | `Query` | Read state without side effects | Exactly one | The handler's return value |
24
+ | `Event` | Notify that something has happened | Zero, one, or many | Nothing |
25
+
26
+ A `Command` is allowed to return a value (for convenience, typically an id or a result object), but that does not mean it should be used as a query. Keep intent clear.
27
+
28
+ ## Installation
29
+
30
+ Requires Python 3.12 or higher.
31
+
32
+ With the default DI backend ([python-injection](https://github.com/100nm/python-injection), recommended):
33
+
34
+ ```bash
35
+ pip install "python-cq[injection]"
36
+ ```
37
+
38
+ Without dependency injection (you will need to implement a `DIAdapter`):
39
+
40
+ ```bash
41
+ pip install python-cq
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ import asyncio
48
+ from cq import CommandBus, command_handler
49
+ from dataclasses import dataclass
50
+ from injection import inject
51
+
52
+ @dataclass
53
+ class CreateUserCommand:
54
+ name: str
55
+ email: str
56
+
57
+ @command_handler
58
+ class CreateUserHandler:
59
+ async def handle(self, command: CreateUserCommand) -> int:
60
+ # ... persist the user, return its id
61
+ return 42
62
+
63
+ @inject
64
+ async def main(bus: CommandBus[int]) -> None:
65
+ command = CreateUserCommand(name="Ada", email="ada@example.com")
66
+ user_id = await bus.dispatch(command)
67
+ print(f"Created user {user_id}")
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ The decorator registers the handler against the type of its first `handle` parameter. The bus is resolved by the DI container and dispatched to that handler.
73
+
74
+ ## Prerequisites
75
+
76
+ Familiarity with the following helps you get the most out of python-cq:
77
+
78
+ * **CQRS**, in particular the distinction between Commands, Queries, and Events.
79
+ * **Domain Driven Design (DDD)**, particularly aggregates and bounded contexts, which complement CQRS well.
@@ -20,7 +20,7 @@ test = [
20
20
 
21
21
  [project]
22
22
  name = "python-cq"
23
- version = "0.17.0"
23
+ version = "0.18.0"
24
24
  description = "CQRS library for async Python projects."
25
25
  license = "MIT"
26
26
  license-files = ["LICENSE"]
python_cq-0.17.0/PKG-INFO DELETED
@@ -1,78 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-cq
3
- Version: 0.17.0
4
- Summary: CQRS library for async Python projects.
5
- Project-URL: Documentation, https://python-cq.remimd.dev
6
- Project-URL: Repository, https://github.com/100nm/python-cq
7
- Author: remimd
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Keywords: cqrs
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Natural Language :: English
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3 :: Only
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Programming Language :: Python :: 3.13
20
- Classifier: Programming Language :: Python :: 3.14
21
- Classifier: Topic :: Software Development :: Libraries
22
- Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
23
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
- Classifier: Typing :: Typed
25
- Requires-Python: <3.15,>=3.12
26
- Requires-Dist: anyio
27
- Requires-Dist: type-analyzer
28
- Provides-Extra: injection
29
- Requires-Dist: python-injection[async]; extra == 'injection'
30
- Description-Content-Type: text/markdown
31
-
32
- # python-cq
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)
35
- [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
36
-
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.
38
-
39
- ## What is CQRS?
40
-
41
- CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read operations from write operations. This separation helps to:
42
-
43
- - **Clarify intent**: each operation has a single, well-defined responsibility
44
- - **Improve maintainability**: smaller, focused handlers are easier to understand and modify
45
- - **Simplify testing**: isolated handlers are straightforward to unit test
46
-
47
- CQRS is often associated with distributed systems and Event Sourcing, but its benefits extend beyond that. Even in a local or monolithic application, adopting this pattern helps structure your code and makes the boundaries between reading and writing explicit.
48
-
49
- ## Prerequisites
50
-
51
- To get the most out of **python-cq**, familiarity with the following concepts is recommended:
52
-
53
- - **CQRS** and the distinction between Commands, Queries and Events
54
- - **Domain Driven Design (DDD)**, particularly aggregates and bounded contexts
55
-
56
- This knowledge will help you design coherent handlers and organize your code effectively.
57
-
58
- ## Message types
59
-
60
- **python-cq** provides three types of messages to model your application's operations:
61
-
62
- - **Command**: represents an intent to change the system's state. A command is handled by exactly one handler and may return a value for convenience.
63
- - **Query**: represents a request for information. A query is handled by exactly one handler and returns data without side effects.
64
- - **Event**: represents something that has happened in the system. An event can be handled by zero, one, or many handlers, enabling loose coupling between components.
65
-
66
- ## Installation
67
-
68
- Requires Python 3.12 or higher.
69
-
70
- Without dependency injection:
71
- ```bash
72
- pip install python-cq
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
- ```
@@ -1,47 +0,0 @@
1
- # python-cq
2
-
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
- [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-cq.svg?color=4051b5&style=for-the-badge)](https://pypistats.org/packages/python-cq)
5
-
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
-
8
- ## What is CQRS?
9
-
10
- CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read operations from write operations. This separation helps to:
11
-
12
- - **Clarify intent**: each operation has a single, well-defined responsibility
13
- - **Improve maintainability**: smaller, focused handlers are easier to understand and modify
14
- - **Simplify testing**: isolated handlers are straightforward to unit test
15
-
16
- CQRS is often associated with distributed systems and Event Sourcing, but its benefits extend beyond that. Even in a local or monolithic application, adopting this pattern helps structure your code and makes the boundaries between reading and writing explicit.
17
-
18
- ## Prerequisites
19
-
20
- To get the most out of **python-cq**, familiarity with the following concepts is recommended:
21
-
22
- - **CQRS** and the distinction between Commands, Queries and Events
23
- - **Domain Driven Design (DDD)**, particularly aggregates and bounded contexts
24
-
25
- This knowledge will help you design coherent handlers and organize your code effectively.
26
-
27
- ## Message types
28
-
29
- **python-cq** provides three types of messages to model your application's operations:
30
-
31
- - **Command**: represents an intent to change the system's state. A command is handled by exactly one handler and may return a value for convenience.
32
- - **Query**: represents a request for information. A query is handled by exactly one handler and returns data without side effects.
33
- - **Event**: represents something that has happened in the system. An event can be handled by zero, one, or many handlers, enabling loose coupling between components.
34
-
35
- ## Installation
36
-
37
- Requires Python 3.12 or higher.
38
-
39
- Without dependency injection:
40
- ```bash
41
- pip install python-cq
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
- ```
File without changes
File without changes
File without changes