python-cq 0.1.0__py3-none-any.whl

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.
cq/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from ._core.command import Command, CommandBus, command_handler, find_command_bus
2
+ from ._core.dto import DTO
3
+ from ._core.event import Event, EventBus, event_handler, find_event_bus
4
+ from ._core.middleware import Middleware, MiddlewareResult
5
+ from ._core.query import Query, QueryBus, find_query_bus, query_handler
6
+
7
+ __all__ = (
8
+ "Command",
9
+ "CommandBus",
10
+ "DTO",
11
+ "Event",
12
+ "EventBus",
13
+ "Middleware",
14
+ "MiddlewareResult",
15
+ "Query",
16
+ "QueryBus",
17
+ "command_handler",
18
+ "event_handler",
19
+ "find_command_bus",
20
+ "find_event_bus",
21
+ "find_query_bus",
22
+ "query_handler",
23
+ )
cq/_core/__init__.py ADDED
File without changes
cq/_core/bus.py ADDED
@@ -0,0 +1,156 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+ from collections import defaultdict
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from inspect import isclass
7
+ from types import GenericAlias
8
+ from typing import Protocol, Self, TypeAliasType, runtime_checkable
9
+
10
+ import injection
11
+
12
+ from cq._core.middleware import Middleware, MiddlewareGroup
13
+
14
+ type HandlerType[**P, T] = type[Handler[P, T]]
15
+ type HandlerFactory[**P, T] = Callable[..., Handler[P, T]]
16
+
17
+ type BusType[I, O] = type[Bus[I, O]]
18
+
19
+
20
+ @runtime_checkable
21
+ class Handler[**P, T](Protocol):
22
+ __slots__ = ()
23
+
24
+ @abstractmethod
25
+ async def handle(self, *args: P.args, **kwargs: P.kwargs) -> T:
26
+ raise NotImplementedError
27
+
28
+
29
+ @runtime_checkable
30
+ class Bus[I, O](Protocol):
31
+ __slots__ = ()
32
+
33
+ @abstractmethod
34
+ async def dispatch(self, input_value: I, /) -> O:
35
+ raise NotImplementedError
36
+
37
+ def dispatch_no_wait(self, first_input_value: I, /, *input_values: I) -> None:
38
+ asyncio.gather(
39
+ *(
40
+ self.dispatch(input_value)
41
+ for input_value in (first_input_value, *input_values)
42
+ )
43
+ )
44
+
45
+ @abstractmethod
46
+ def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Self:
47
+ raise NotImplementedError
48
+
49
+ @abstractmethod
50
+ def add_middlewares(self, *middlewares: Middleware[[I], O]) -> Self:
51
+ raise NotImplementedError
52
+
53
+
54
+ @dataclass(eq=False, frozen=True, slots=True)
55
+ class SubscriberDecorator[I, O]:
56
+ bus_type: BusType[I, O] | TypeAliasType | GenericAlias
57
+ injection_module: injection.Module = field(default_factory=injection.mod)
58
+
59
+ def __call__[T](
60
+ self,
61
+ first_input_type: type[I],
62
+ /,
63
+ *input_types: type[I],
64
+ ) -> Callable[[T], T]:
65
+ def decorator(wrapped: T) -> T:
66
+ if not isclass(wrapped) or not issubclass(wrapped, Handler):
67
+ raise TypeError(f"`{wrapped}` isn't a valid handler.")
68
+
69
+ bus = self.__find_bus()
70
+ factory = self.injection_module.make_injected_function(wrapped)
71
+
72
+ for input_type in (first_input_type, *input_types):
73
+ bus.subscribe(input_type, factory)
74
+
75
+ return wrapped # type: ignore[return-value]
76
+
77
+ return decorator
78
+
79
+ def __find_bus(self) -> Bus[I, O]:
80
+ return self.injection_module.find_instance(self.bus_type)
81
+
82
+
83
+ class _BaseBus[I, O](Bus[I, O], ABC):
84
+ __slots__ = ("__middleware_group",)
85
+
86
+ __middleware_group: MiddlewareGroup[[I], O]
87
+
88
+ def __init__(self) -> None:
89
+ self.__middleware_group = MiddlewareGroup()
90
+
91
+ def add_middlewares(self, *middlewares: Middleware[[I], O]) -> Self:
92
+ self.__middleware_group.add(*middlewares)
93
+ return self
94
+
95
+ async def _invoke(self, handler: Handler[[I], O], input_value: I, /) -> O:
96
+ return await self.__middleware_group.invoke(handler.handle, input_value)
97
+
98
+
99
+ class SimpleBus[I, O](_BaseBus[I, O]):
100
+ __slots__ = ("__handlers",)
101
+
102
+ __handlers: dict[type[I], HandlerFactory[[I], O]]
103
+
104
+ def __init__(self) -> None:
105
+ super().__init__()
106
+ self.__handlers = {}
107
+
108
+ async def dispatch(self, input_value: I, /) -> O:
109
+ input_type = type(input_value)
110
+
111
+ try:
112
+ handler_factory = self.__handlers[input_type]
113
+ except KeyError:
114
+ return NotImplemented
115
+
116
+ return await self._invoke(handler_factory(), input_value)
117
+
118
+ def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Self:
119
+ if input_type in self.__handlers:
120
+ raise RuntimeError(
121
+ f"A handler is already registered for the input type: `{input_type}`."
122
+ )
123
+
124
+ self.__handlers[input_type] = factory
125
+ return self
126
+
127
+
128
+ class TaskBus[I](_BaseBus[I, None]):
129
+ __slots__ = ("__handlers",)
130
+
131
+ __handlers: dict[type[I], list[HandlerFactory[[I], None]]]
132
+
133
+ def __init__(self) -> None:
134
+ super().__init__()
135
+ self.__handlers = defaultdict(list)
136
+
137
+ async def dispatch(self, input_value: I, /) -> None:
138
+ handler_factories = self.__handlers.get(type(input_value))
139
+
140
+ if not handler_factories:
141
+ return
142
+
143
+ await asyncio.gather(
144
+ *(
145
+ self._invoke(handler_factory(), input_value)
146
+ for handler_factory in handler_factories
147
+ )
148
+ )
149
+
150
+ def subscribe(
151
+ self,
152
+ input_type: type[I],
153
+ factory: HandlerFactory[[I], None],
154
+ ) -> Self:
155
+ self.__handlers[input_type].append(factory)
156
+ return self
cq/_core/command.py ADDED
@@ -0,0 +1,21 @@
1
+ from abc import ABC
2
+ from typing import Any
3
+
4
+ import injection
5
+
6
+ from cq._core.bus import Bus, SimpleBus, SubscriberDecorator
7
+ from cq._core.dto import DTO
8
+
9
+
10
+ class Command(DTO, ABC):
11
+ __slots__ = ()
12
+
13
+
14
+ type CommandBus[T] = Bus[Command, T]
15
+ command_handler: SubscriberDecorator[Command, Any] = SubscriberDecorator(CommandBus)
16
+
17
+ injection.set_constant(SimpleBus(), CommandBus, alias=True)
18
+
19
+
20
+ def find_command_bus[T]() -> CommandBus[T]:
21
+ return injection.find_instance(CommandBus)
cq/_core/dto.py ADDED
@@ -0,0 +1,10 @@
1
+ from abc import ABC
2
+ from typing import ClassVar
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+
7
+ class DTO(BaseModel, ABC):
8
+ __slots__ = ()
9
+
10
+ model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True)
cq/_core/event.py ADDED
@@ -0,0 +1,20 @@
1
+ from abc import ABC
2
+
3
+ import injection
4
+
5
+ from cq._core.bus import Bus, SubscriberDecorator, TaskBus
6
+ from cq._core.dto import DTO
7
+
8
+
9
+ class Event(DTO, ABC):
10
+ __slots__ = ()
11
+
12
+
13
+ type EventBus = Bus[Event, None]
14
+ event_handler: SubscriberDecorator[Event, None] = SubscriberDecorator(EventBus)
15
+
16
+ injection.set_constant(TaskBus(), EventBus, alias=True)
17
+
18
+
19
+ def find_event_bus() -> EventBus:
20
+ return injection.find_instance(EventBus)
cq/_core/middleware.py ADDED
@@ -0,0 +1,74 @@
1
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
2
+ from dataclasses import dataclass, field
3
+ from typing import Self
4
+
5
+ type MiddlewareResult[T] = AsyncGenerator[None, T]
6
+ type Middleware[**P, T] = Callable[P, MiddlewareResult[T]]
7
+
8
+
9
+ @dataclass(eq=False, frozen=True, slots=True)
10
+ class MiddlewareGroup[**P, T]:
11
+ __middlewares: list[Middleware[P, T]] = field(
12
+ default_factory=list,
13
+ init=False,
14
+ repr=False,
15
+ )
16
+
17
+ @property
18
+ def __stack(self) -> Iterator[Middleware[P, T]]:
19
+ return iter(self.__middlewares)
20
+
21
+ def add(self, *middlewares: Middleware[P, T]) -> Self:
22
+ self.__middlewares.extend(middlewares)
23
+ return self
24
+
25
+ async def invoke(
26
+ self,
27
+ handler: Callable[P, Awaitable[T]],
28
+ /,
29
+ *args: P.args,
30
+ **kwargs: P.kwargs,
31
+ ) -> T:
32
+ return await self.__apply_stack(handler, self.__stack)(*args, **kwargs)
33
+
34
+ @classmethod
35
+ def __apply_middleware(
36
+ cls,
37
+ handler: Callable[P, Awaitable[T]],
38
+ middleware: Middleware[P, T],
39
+ ) -> Callable[P, Awaitable[T]]:
40
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
41
+ generator: MiddlewareResult[T] = middleware(*args, **kwargs)
42
+ value: T = NotImplemented
43
+
44
+ try:
45
+ await anext(generator)
46
+
47
+ try:
48
+ value = await handler(*args, **kwargs)
49
+ except BaseException as exc:
50
+ await generator.athrow(exc)
51
+ else:
52
+ await generator.asend(value)
53
+
54
+ except StopAsyncIteration:
55
+ ...
56
+
57
+ finally:
58
+ await generator.aclose()
59
+
60
+ return value
61
+
62
+ return wrapper
63
+
64
+ @classmethod
65
+ def __apply_stack(
66
+ cls,
67
+ handler: Callable[P, Awaitable[T]],
68
+ stack: Iterator[Middleware[P, T]],
69
+ ) -> Callable[P, Awaitable[T]]:
70
+ for middleware in stack:
71
+ new_handler = cls.__apply_middleware(handler, middleware)
72
+ return cls.__apply_stack(new_handler, stack)
73
+
74
+ return handler
cq/_core/query.py ADDED
@@ -0,0 +1,21 @@
1
+ from abc import ABC
2
+ from typing import Any
3
+
4
+ import injection
5
+
6
+ from cq._core.bus import Bus, SimpleBus, SubscriberDecorator
7
+ from cq._core.dto import DTO
8
+
9
+
10
+ class Query(DTO, ABC):
11
+ __slots__ = ()
12
+
13
+
14
+ type QueryBus[T] = Bus[Query, T]
15
+ query_handler: SubscriberDecorator[Query, Any] = SubscriberDecorator(QueryBus)
16
+
17
+ injection.set_constant(SimpleBus(), QueryBus, alias=True)
18
+
19
+
20
+ def find_query_bus[T]() -> QueryBus[T]:
21
+ return injection.find_instance(QueryBus)
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.1
2
+ Name: python-cq
3
+ Version: 0.1.0
4
+ Summary: Lightweight CQRS library.
5
+ Home-page: https://github.com/100nm/python-cq
6
+ License: MIT
7
+ Keywords: cqrs
8
+ Author: remimd
9
+ Requires-Python: >=3.12,<4
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
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.12
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Dist: pydantic (>=2,<3)
24
+ Requires-Dist: python-injection
25
+ Project-URL: Repository, https://github.com/100nm/python-cq
26
+ Description-Content-Type: text/markdown
27
+
28
+ # python-cq
29
+
30
+ [![CI](https://github.com/100nm/python-cq/actions/workflows/ci.yml/badge.svg)](https://github.com/100nm/python-cq)
31
+ [![PyPI](https://img.shields.io/pypi/v/python-cq.svg?color=blue)](https://pypi.org/project/python-cq/)
32
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
33
+
34
+ Lightweight library for separating Python code according to **Command and Query Responsibility Segregation** principles.
35
+
36
+ Dependency injection is handled by [python-injection](https://github.com/100nm/python-injection).
37
+
38
+ Easy to use with [FastAPI](https://github.com/fastapi/fastapi).
39
+
40
+ ## Installation
41
+
42
+ ⚠️ _Requires Python 3.12 or higher_
43
+
44
+ ```bash
45
+ pip install python-cq
46
+ ```
47
+
48
+ ## Resources
49
+
50
+ * [**Writing Application Layer**](https://github.com/100nm/python-cq/tree/prod/documentation/writing-application-layer.md)
51
+ * [**FastAPI Example**](https://github.com/100nm/python-cq/tree/prod/documentation/fastapi-example.md)
52
+
@@ -0,0 +1,11 @@
1
+ cq/__init__.py,sha256=FFTaq-Kq_ywMqWwaucK4MSYPSVbyHizEEYrRwFj3iIU,604
2
+ cq/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ cq/_core/bus.py,sha256=1P0jHjK1q33GvvB6lo2-IEhK2RP0EZIIy4GyI9YcLVA,4537
4
+ cq/_core/command.py,sha256=dqN99iG4eAD5cXhi0wzWnuWVE9kPhoNE05S-QDGcqHc,476
5
+ cq/_core/dto.py,sha256=jrlTRvG9fXFyv0nb8PQY2WTVPldgGKxg-r2xL6FOT0c,206
6
+ cq/_core/event.py,sha256=9LocJ3QM1MZ4mqx8V1YJT-2lsNulUKix6Epab_u2VmM,424
7
+ cq/_core/middleware.py,sha256=aCTmTcDkTxhmCS2NLenVpMo5QLJmKrEyn6SI6yAuFk8,2117
8
+ cq/_core/query.py,sha256=rpYLviIFoy5uWVPCVsvGdfzHeyc21HSmt1e1KBFEz_A,456
9
+ python_cq-0.1.0.dist-info/METADATA,sha256=Lr4fXKlyW_ULiTq03uYxbDA0rtt59N0oh1pUWkYmu38,2039
10
+ python_cq-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
11
+ python_cq-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any