python-hexagonal 0.2.0__py3-none-any.whl → 0.3.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.
- hexagonal/adapters/drivens/buses/base/command_bus.py +8 -11
- hexagonal/adapters/drivens/buses/base/event_bus.py +49 -22
- hexagonal/adapters/drivens/buses/base/infrastructure.py +4 -4
- hexagonal/adapters/drivens/buses/base/message_bus.py +34 -3
- hexagonal/adapters/drivens/buses/base/query.py +9 -2
- hexagonal/adapters/drivens/buses/inmemory/command_bus.py +2 -2
- hexagonal/adapters/drivens/buses/inmemory/event_bus.py +5 -2
- hexagonal/adapters/drivens/mappers.py +2 -2
- hexagonal/adapters/drivens/repository/base/repository.py +5 -8
- hexagonal/adapters/drivens/repository/base/unit_of_work.py +25 -9
- hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +2 -1
- hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +9 -6
- hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +68 -7
- hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +2 -2
- hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +1 -1
- hexagonal/adapters/drivens/repository/sqlite/datastore.py +14 -8
- hexagonal/adapters/drivens/repository/sqlite/outbox.py +2 -2
- hexagonal/adapters/drivens/repository/sqlite/repository.py +1 -1
- hexagonal/adapters/drivers/app.py +19 -10
- hexagonal/application/__init__.py +9 -0
- hexagonal/application/api.py +16 -78
- hexagonal/application/app.py +18 -15
- hexagonal/application/bus_app.py +4 -0
- hexagonal/application/handlers.py +74 -7
- hexagonal/application/infrastructure.py +3 -3
- hexagonal/application/query.py +22 -6
- hexagonal/application/topics.py +4 -4
- hexagonal/domain/__init__.py +3 -0
- hexagonal/domain/aggregate.py +8 -8
- hexagonal/domain/base.py +7 -5
- hexagonal/domain/queries.py +8 -3
- hexagonal/domain/responses.py +58 -0
- hexagonal/entrypoints/app.py +13 -6
- hexagonal/entrypoints/base.py +2 -2
- hexagonal/entrypoints/bus.py +15 -3
- hexagonal/entrypoints/sqlalchemy.py +7 -2
- hexagonal/entrypoints/sqlite.py +9 -2
- hexagonal/integrations/sqlalchemy.py +2 -0
- hexagonal/ports/drivens/__init__.py +18 -0
- hexagonal/ports/drivens/buses.py +33 -9
- hexagonal/ports/drivens/infrastructure.py +1 -1
- hexagonal/ports/drivens/repository.py +32 -9
- hexagonal/ports/drivens/scoped.py +36 -0
- hexagonal/ports/drivers/app.py +3 -3
- {python_hexagonal-0.2.0.dist-info → python_hexagonal-0.3.0.dist-info}/METADATA +5 -1
- python_hexagonal-0.3.0.dist-info/RECORD +72 -0
- {python_hexagonal-0.2.0.dist-info → python_hexagonal-0.3.0.dist-info}/WHEEL +1 -1
- python_hexagonal-0.2.0.dist-info/RECORD +0 -70
|
@@ -4,7 +4,6 @@ from eventsourcing.utils import get_topic
|
|
|
4
4
|
|
|
5
5
|
from hexagonal.domain import (
|
|
6
6
|
CloudMessage,
|
|
7
|
-
Command,
|
|
8
7
|
HandlerAlreadyRegistered,
|
|
9
8
|
HandlerNotRegistered,
|
|
10
9
|
TCommand,
|
|
@@ -36,14 +35,14 @@ class BaseCommandBus(ICommandBus[TManager], MessageBus[TManager]):
|
|
|
36
35
|
|
|
37
36
|
def register_handler(
|
|
38
37
|
self, command_type: Type[TCommand], handler: IMessageHandler[TCommand]
|
|
39
|
-
):
|
|
38
|
+
) -> None:
|
|
40
39
|
self.verify()
|
|
41
40
|
name = self._get_name(command_type)
|
|
42
41
|
if name in self._handlers:
|
|
43
42
|
raise HandlerAlreadyRegistered(f"Command: {name}")
|
|
44
43
|
self._handlers[name] = handler
|
|
45
44
|
|
|
46
|
-
def unregister_handler(self, command_type: Type[TCommand]):
|
|
45
|
+
def unregister_handler(self, command_type: Type[TCommand]) -> None:
|
|
47
46
|
self.verify()
|
|
48
47
|
name = self._get_name(command_type)
|
|
49
48
|
if name in self._handlers:
|
|
@@ -52,18 +51,16 @@ class BaseCommandBus(ICommandBus[TManager], MessageBus[TManager]):
|
|
|
52
51
|
raise HandlerNotRegistered(f"Command: {name}")
|
|
53
52
|
|
|
54
53
|
def dispatch(
|
|
55
|
-
self,
|
|
54
|
+
self,
|
|
55
|
+
command: CloudMessage[TCommand],
|
|
56
|
+
*,
|
|
57
|
+
to_outbox: bool = False,
|
|
56
58
|
) -> None:
|
|
57
59
|
self.verify()
|
|
58
|
-
cmd = (
|
|
59
|
-
command
|
|
60
|
-
if not isinstance(command, Command)
|
|
61
|
-
else CloudMessage[command.__class__].new(command) # type: ignore
|
|
62
|
-
)
|
|
63
60
|
if to_outbox:
|
|
64
|
-
self.
|
|
61
|
+
self.save_to_outbox(command)
|
|
65
62
|
else:
|
|
66
|
-
self.process_command(
|
|
63
|
+
self.process_command(command)
|
|
67
64
|
|
|
68
65
|
def process_command(self, command: CloudMessage[TCommand]) -> None:
|
|
69
66
|
self._process_messages(command)
|
|
@@ -21,22 +21,36 @@ logger = logging.getLogger(__name__)
|
|
|
21
21
|
class HandlerError(Exception):
|
|
22
22
|
def __init__(
|
|
23
23
|
self,
|
|
24
|
-
evento: CloudMessage[TEvento],
|
|
24
|
+
evento: CloudMessage[TEvento] | TEvento,
|
|
25
25
|
handler: IMessageHandler[TEvent] | Callable[..., None],
|
|
26
26
|
error: Exception,
|
|
27
|
-
):
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
handler: {
|
|
31
|
-
handler.__class__.__name__ # pyright: ignore[reportUnknownMemberType]
|
|
27
|
+
) -> None:
|
|
28
|
+
handler_name = (
|
|
29
|
+
handler.__class__.__name__
|
|
32
30
|
if isinstance(handler, IMessageHandler)
|
|
33
31
|
else handler.__name__
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
)
|
|
33
|
+
event_name = (
|
|
34
|
+
evento.__class__.__name__
|
|
35
|
+
if not isinstance(evento, CloudMessage)
|
|
36
|
+
else evento.payload.__class__.__name__
|
|
37
|
+
)
|
|
38
|
+
event_topic = (
|
|
39
|
+
evento.TOPIC if not isinstance(evento, CloudMessage) else evento.type
|
|
40
|
+
)
|
|
41
|
+
event_dump = (
|
|
42
|
+
evento.model_dump_json(indent=2)
|
|
43
|
+
if not isinstance(evento, CloudMessage)
|
|
44
|
+
else evento.model_dump_json(indent=2)
|
|
45
|
+
)
|
|
46
|
+
super().__init__(f"""
|
|
47
|
+
Error al Manejar Evento {event_name}
|
|
48
|
+
handler: {handler_name}
|
|
49
|
+
evento: {event_topic}
|
|
50
|
+
datos: {event_dump}
|
|
37
51
|
error: {error}
|
|
38
52
|
stacktrace: {error.__traceback__}
|
|
39
|
-
""")
|
|
53
|
+
""")
|
|
40
54
|
|
|
41
55
|
|
|
42
56
|
class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
@@ -52,6 +66,9 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
52
66
|
def _get_key(self, obj: Type[TEvent] | IMessageHandler[TEvent]) -> str:
|
|
53
67
|
if not isinstance(obj, IMessageHandler):
|
|
54
68
|
return get_topic(obj)
|
|
69
|
+
handler_key = getattr(obj, "handler_key", None)
|
|
70
|
+
if isinstance(handler_key, str):
|
|
71
|
+
return handler_key
|
|
55
72
|
try:
|
|
56
73
|
return get_topic(obj.__class__)
|
|
57
74
|
except TopicError as e:
|
|
@@ -59,7 +76,9 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
59
76
|
f"Handler: {obj.__class__}, error: {e}"
|
|
60
77
|
) from e
|
|
61
78
|
|
|
62
|
-
def subscribe(
|
|
79
|
+
def subscribe(
|
|
80
|
+
self, event_type: Type[TEvent], handler: IMessageHandler[TEvent]
|
|
81
|
+
) -> None:
|
|
63
82
|
self.verify()
|
|
64
83
|
key_event = self._get_key(event_type)
|
|
65
84
|
handlers = self.handlers.get(key_event, {})
|
|
@@ -69,7 +88,9 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
69
88
|
handlers[key_handler] = handler
|
|
70
89
|
self.handlers[key_event] = handlers
|
|
71
90
|
|
|
72
|
-
def unsubscribe(
|
|
91
|
+
def unsubscribe(
|
|
92
|
+
self, event_type: Type[TEvent], *handlers: IMessageHandler[TEvent]
|
|
93
|
+
) -> None:
|
|
73
94
|
self.verify()
|
|
74
95
|
key_event = self._get_key(event_type)
|
|
75
96
|
if not handlers:
|
|
@@ -88,7 +109,9 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
88
109
|
if not list(self.handlers[key_event].values()):
|
|
89
110
|
del self.handlers[key_event]
|
|
90
111
|
|
|
91
|
-
def _wait_for(
|
|
112
|
+
def _wait_for(
|
|
113
|
+
self, event_type: Type[TEvent], handler: Callable[[TEvent], None]
|
|
114
|
+
) -> None:
|
|
92
115
|
name = self._get_key(event_type)
|
|
93
116
|
if name not in self.wait_list:
|
|
94
117
|
self.wait_list[name] = []
|
|
@@ -99,7 +122,7 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
99
122
|
len(self.wait_list[name]),
|
|
100
123
|
)
|
|
101
124
|
|
|
102
|
-
def _handle_wait_list(self, event: TEvento):
|
|
125
|
+
def _handle_wait_list(self, event: TEvento) -> None:
|
|
103
126
|
event_type = type(event)
|
|
104
127
|
key = self._get_key(event_type)
|
|
105
128
|
logger.debug(" [DEBUG _handle_wait_list] Publishing event type=%s", key)
|
|
@@ -112,14 +135,17 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
112
135
|
logger.debug(" [DEBUG _handle_wait_list] No handlers registered!")
|
|
113
136
|
while wait_list:
|
|
114
137
|
if self.raise_error:
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
immediate_handler = wait_list.pop()
|
|
139
|
+
immediate_handler(event)
|
|
117
140
|
else:
|
|
141
|
+
queued_handler: Callable[[TEvento], None] | None = None
|
|
118
142
|
try:
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
queued_handler = wait_list.pop()
|
|
144
|
+
queued_handler(event)
|
|
121
145
|
except Exception as e:
|
|
122
|
-
|
|
146
|
+
if queued_handler is None:
|
|
147
|
+
raise
|
|
148
|
+
raise HandlerError(event, queued_handler, e) from e
|
|
123
149
|
|
|
124
150
|
@overload
|
|
125
151
|
def wait_for_publish(
|
|
@@ -136,9 +162,10 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
136
162
|
) -> Callable[[Callable[[TEvent], None]], None] | None:
|
|
137
163
|
self.verify()
|
|
138
164
|
if handler:
|
|
139
|
-
|
|
165
|
+
self._wait_for(event_type, handler)
|
|
166
|
+
return None
|
|
140
167
|
|
|
141
|
-
def decorator(func: Callable[[TEvent], None]):
|
|
168
|
+
def decorator(func: Callable[[TEvent], None]) -> None:
|
|
142
169
|
self._wait_for(event_type, func)
|
|
143
170
|
|
|
144
171
|
return decorator
|
|
@@ -169,4 +196,4 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
169
196
|
logger.error(e)
|
|
170
197
|
|
|
171
198
|
def process_events(self, *events: CloudMessage[TEvent]) -> None:
|
|
172
|
-
|
|
199
|
+
self._process_messages(*events)
|
|
@@ -16,23 +16,23 @@ class BaseBusInfrastructure(IBusInfrastructure[TManager], InfrastructureGroup):
|
|
|
16
16
|
event_bus: IEventBus[TManager],
|
|
17
17
|
query_bus: IQueryBus[TManager],
|
|
18
18
|
*args: IBaseInfrastructure,
|
|
19
|
-
):
|
|
19
|
+
) -> None:
|
|
20
20
|
self._command_bus = command_bus
|
|
21
21
|
self._event_bus = event_bus
|
|
22
22
|
self._query_bus = query_bus
|
|
23
23
|
super().__init__(self._command_bus, self._event_bus, self._query_bus, *args)
|
|
24
24
|
|
|
25
25
|
@property
|
|
26
|
-
def command_bus(self):
|
|
26
|
+
def command_bus(self) -> ICommandBus[TManager]:
|
|
27
27
|
self.verify()
|
|
28
28
|
return self._command_bus
|
|
29
29
|
|
|
30
30
|
@property
|
|
31
|
-
def event_bus(self):
|
|
31
|
+
def event_bus(self) -> IEventBus[TManager]:
|
|
32
32
|
self.verify()
|
|
33
33
|
return self._event_bus
|
|
34
34
|
|
|
35
35
|
@property
|
|
36
|
-
def query_bus(self):
|
|
36
|
+
def query_bus(self) -> IQueryBus[TManager]:
|
|
37
37
|
self.verify()
|
|
38
38
|
return self._query_bus
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from abc import abstractmethod
|
|
2
|
-
from typing import Any
|
|
2
|
+
from typing import Any, Callable
|
|
3
3
|
|
|
4
4
|
from hexagonal.application import Infrastructure
|
|
5
5
|
from hexagonal.domain import CloudMessage
|
|
@@ -9,6 +9,7 @@ from hexagonal.ports.drivens import (
|
|
|
9
9
|
IOutboxRepository,
|
|
10
10
|
TManager,
|
|
11
11
|
)
|
|
12
|
+
from hexagonal.ports.drivens.scoped import IWriteScopeRunner, TWriteScope
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class MessageBus(IBaseMessageBus[TManager], Infrastructure):
|
|
@@ -16,9 +17,13 @@ class MessageBus(IBaseMessageBus[TManager], Infrastructure):
|
|
|
16
17
|
self,
|
|
17
18
|
inbox_repository: IInboxRepository[TManager],
|
|
18
19
|
outbox_repository: IOutboxRepository[TManager],
|
|
19
|
-
):
|
|
20
|
+
) -> None:
|
|
20
21
|
self._inbox_repository = inbox_repository
|
|
21
22
|
self._outbox_repository = outbox_repository
|
|
23
|
+
self._write_scope_runner: IWriteScopeRunner[Any] | None = None
|
|
24
|
+
self._outbox_repository_getter: (
|
|
25
|
+
Callable[[Any], IOutboxRepository[TManager]] | None
|
|
26
|
+
) = None
|
|
22
27
|
super().__init__()
|
|
23
28
|
|
|
24
29
|
@property
|
|
@@ -29,8 +34,34 @@ class MessageBus(IBaseMessageBus[TManager], Infrastructure):
|
|
|
29
34
|
def outbox_repository(self) -> IOutboxRepository[TManager]:
|
|
30
35
|
return self._outbox_repository
|
|
31
36
|
|
|
37
|
+
def configure_scope_runtime(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
write_scope_runner: IWriteScopeRunner[TWriteScope] | None = None,
|
|
41
|
+
outbox_repository_getter: Callable[[TWriteScope], IOutboxRepository[TManager]]
|
|
42
|
+
| None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._write_scope_runner = write_scope_runner
|
|
45
|
+
self._outbox_repository_getter = outbox_repository_getter
|
|
46
|
+
|
|
47
|
+
def save_to_outbox(self, *messages: CloudMessage[Any]) -> None:
|
|
48
|
+
self.verify()
|
|
49
|
+
if self._write_scope_runner is None or self._outbox_repository_getter is None:
|
|
50
|
+
self.outbox_repository.save(*messages)
|
|
51
|
+
return
|
|
52
|
+
outbox_repository_getter = self._outbox_repository_getter
|
|
53
|
+
|
|
54
|
+
scope = self._write_scope_runner.current_write_scope
|
|
55
|
+
if scope is not None:
|
|
56
|
+
outbox_repository_getter(scope).save(*messages)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
self._write_scope_runner.run_in_write_scope(
|
|
60
|
+
lambda write_scope: outbox_repository_getter(write_scope).save(*messages)
|
|
61
|
+
)
|
|
62
|
+
|
|
32
63
|
# publish
|
|
33
|
-
def publish_from_outbox(self, limit: int | None = None):
|
|
64
|
+
def publish_from_outbox(self, limit: int | None = None) -> None:
|
|
34
65
|
self.verify()
|
|
35
66
|
messages = self.outbox_repository.fetch_pending(limit=limit)
|
|
36
67
|
self._publish_messages(*messages)
|
|
@@ -15,6 +15,7 @@ from hexagonal.domain import (
|
|
|
15
15
|
TView,
|
|
16
16
|
)
|
|
17
17
|
from hexagonal.ports.drivens import IQueryBus, IQueryHandler, TManager
|
|
18
|
+
from hexagonal.ports.drivens.scoped import IReadScopeRunner
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
@@ -22,8 +23,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
22
23
|
|
|
23
24
|
def initialize(self, env: Mapping[str, str]) -> None:
|
|
24
25
|
self.handlers = {}
|
|
26
|
+
self._read_scope_runner: IReadScopeRunner[Any] | None = None
|
|
25
27
|
super().initialize(env)
|
|
26
28
|
|
|
29
|
+
def configure_read_scope_runtime(
|
|
30
|
+
self, read_scope_runner: IReadScopeRunner[Any] | None = None
|
|
31
|
+
) -> None:
|
|
32
|
+
self._read_scope_runner = read_scope_runner
|
|
33
|
+
|
|
27
34
|
def _get_name(self, query_type: Type[QueryBase[TView]]) -> str:
|
|
28
35
|
return get_topic(query_type)
|
|
29
36
|
|
|
@@ -37,14 +44,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
37
44
|
self,
|
|
38
45
|
query_type: Type[TQuery],
|
|
39
46
|
handler: IQueryHandler[TManager, TQuery, TView],
|
|
40
|
-
):
|
|
47
|
+
) -> None:
|
|
41
48
|
self.verify()
|
|
42
49
|
name = self._get_name(query_type)
|
|
43
50
|
if name in self.handlers:
|
|
44
51
|
raise HandlerAlreadyRegistered(f"Query: {name}")
|
|
45
52
|
self.handlers[name] = handler
|
|
46
53
|
|
|
47
|
-
def unregister_handler(self, query_type: Type[TQuery]):
|
|
54
|
+
def unregister_handler(self, query_type: Type[TQuery]) -> None:
|
|
48
55
|
self.verify()
|
|
49
56
|
name = self._get_name(query_type)
|
|
50
57
|
if name in self.handlers:
|
|
@@ -16,7 +16,7 @@ class InMemoryCommandBus(BaseCommandBus[TManager]):
|
|
|
16
16
|
def _publish_message(self, message: CloudMessage[TCommand]) -> None:
|
|
17
17
|
return self._process_messages(message)
|
|
18
18
|
|
|
19
|
-
def consume(self, limit: int | None = None):
|
|
19
|
+
def consume(self, limit: int | None = None) -> None:
|
|
20
20
|
return # No-op for non-queued bus
|
|
21
21
|
|
|
22
22
|
|
|
@@ -66,5 +66,5 @@ class InMemoryQueueCommandBus(BaseCommandBus[TManager]):
|
|
|
66
66
|
finally:
|
|
67
67
|
self.queue.task_done()
|
|
68
68
|
|
|
69
|
-
def consume(self, limit: int | None = None):
|
|
69
|
+
def consume(self, limit: int | None = None) -> None:
|
|
70
70
|
self._worker.start()
|
|
@@ -15,6 +15,9 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class InMemoryEventBus(BaseEventBus[TManager]):
|
|
18
|
+
def shutdown(self) -> None:
|
|
19
|
+
return
|
|
20
|
+
|
|
18
21
|
def _publish_message(self, message: CloudMessage[TEvento]) -> None:
|
|
19
22
|
super()._publish_message(message)
|
|
20
23
|
self._process_messages(message)
|
|
@@ -22,7 +25,7 @@ class InMemoryEventBus(BaseEventBus[TManager]):
|
|
|
22
25
|
def publish(self, *events: CloudMessage[TEvent]) -> None:
|
|
23
26
|
return self._publish_messages(*events)
|
|
24
27
|
|
|
25
|
-
def consume(self, limit: int | None = None):
|
|
28
|
+
def consume(self, limit: int | None = None) -> None:
|
|
26
29
|
pass # No-op for non-queued bus
|
|
27
30
|
|
|
28
31
|
|
|
@@ -68,5 +71,5 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
|
|
|
68
71
|
finally:
|
|
69
72
|
self.queue.task_done()
|
|
70
73
|
|
|
71
|
-
def consume(self, limit: int | None = None):
|
|
74
|
+
def consume(self, limit: int | None = None) -> None:
|
|
72
75
|
self._worker.start()
|
|
@@ -91,8 +91,8 @@ class MessageMapper(Mapper[UUID]):
|
|
|
91
91
|
getattr(cls, f"upcast_v{from_version}_v{from_version + 1}")(event_state)
|
|
92
92
|
from_version += 1
|
|
93
93
|
if issubclass(cls, AggregateSnapshot):
|
|
94
|
-
return cls.model_validate(event_state)
|
|
95
|
-
domain_event = object.__new__(cls)
|
|
94
|
+
return cast(DomainEventProtocol[UUID], cls.model_validate(event_state))
|
|
95
|
+
domain_event = cast(DomainEventProtocol[UUID], object.__new__(cls))
|
|
96
96
|
domain_event.__dict__.update(event_state)
|
|
97
97
|
return domain_event
|
|
98
98
|
|
|
@@ -38,22 +38,19 @@ class BaseRepositoryAdapter(IBaseRepository[TManager], Infrastructure):
|
|
|
38
38
|
name = self.NAME or self.__class__.__name__.upper()
|
|
39
39
|
env2.update(env)
|
|
40
40
|
self.env = Environment(name, env2)
|
|
41
|
-
self.
|
|
42
|
-
self._manager_at_uow: TManager | None = None
|
|
41
|
+
self._scope_connection_manager: TManager | None = None
|
|
43
42
|
super().initialize(self.env)
|
|
44
43
|
|
|
45
44
|
def attach_to_unit_of_work(self, uow: IUnitOfWork[TManager]) -> None:
|
|
46
|
-
self.
|
|
47
|
-
self._manager_at_uow = uow.connection_manager
|
|
45
|
+
self._scope_connection_manager = uow.connection_manager
|
|
48
46
|
|
|
49
47
|
def detach_from_unit_of_work(self) -> None:
|
|
50
|
-
self.
|
|
51
|
-
self._manager_at_uow = None
|
|
48
|
+
self._scope_connection_manager = None
|
|
52
49
|
|
|
53
50
|
@property
|
|
54
51
|
def connection_manager(self) -> TManager:
|
|
55
|
-
if self.
|
|
56
|
-
return self.
|
|
52
|
+
if self._scope_connection_manager is not None:
|
|
53
|
+
return self._scope_connection_manager
|
|
57
54
|
return self._connection_manager
|
|
58
55
|
|
|
59
56
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from
|
|
1
|
+
from types import TracebackType
|
|
2
|
+
from typing import Any, Mapping, cast
|
|
2
3
|
|
|
3
4
|
from eventsourcing.utils import get_topic
|
|
4
5
|
|
|
@@ -11,7 +12,7 @@ class BaseUnitOfWork(IUnitOfWork[TManager], InfrastructureGroup):
|
|
|
11
12
|
self,
|
|
12
13
|
*repositories: IBaseRepository[TManager],
|
|
13
14
|
connection_manager: TManager,
|
|
14
|
-
):
|
|
15
|
+
) -> None:
|
|
15
16
|
self._repositories = {get_topic(repo.__class__): repo for repo in repositories}
|
|
16
17
|
self._initialized = False
|
|
17
18
|
self._manager = connection_manager
|
|
@@ -20,20 +21,26 @@ class BaseUnitOfWork(IUnitOfWork[TManager], InfrastructureGroup):
|
|
|
20
21
|
def initialize(self, env: Mapping[str, str]) -> None:
|
|
21
22
|
self._initialized = True
|
|
22
23
|
self._active = False
|
|
24
|
+
self._attached_repositories: list[IBaseRepository[TManager]] = []
|
|
23
25
|
return super().initialize(env)
|
|
24
26
|
|
|
25
27
|
@property
|
|
26
28
|
def initialized(self) -> bool:
|
|
27
29
|
return self._initialized and super().initialized
|
|
28
30
|
|
|
29
|
-
def attach_repo(self, repo: IBaseRepository[TManager]):
|
|
31
|
+
def attach_repo(self, repo: IBaseRepository[TManager]) -> None:
|
|
30
32
|
topic = get_topic(repo.__class__)
|
|
31
33
|
if topic in self._repositories:
|
|
32
34
|
return
|
|
33
35
|
|
|
34
36
|
self._repositories[topic] = repo
|
|
35
|
-
if
|
|
37
|
+
if (
|
|
38
|
+
self.initialized
|
|
39
|
+
and self._active
|
|
40
|
+
and repo.connection_manager is not self._manager
|
|
41
|
+
):
|
|
36
42
|
repo.attach_to_unit_of_work(self)
|
|
43
|
+
self._attached_repositories.append(repo)
|
|
37
44
|
|
|
38
45
|
def detach_repo(self, repo: IBaseRepository[TManager]) -> None:
|
|
39
46
|
topic = get_topic(repo.__class__)
|
|
@@ -45,21 +52,30 @@ class BaseUnitOfWork(IUnitOfWork[TManager], InfrastructureGroup):
|
|
|
45
52
|
del self._repositories[topic]
|
|
46
53
|
|
|
47
54
|
@property
|
|
48
|
-
def connection_manager(self):
|
|
55
|
+
def connection_manager(self) -> TManager:
|
|
49
56
|
return self._manager
|
|
50
57
|
|
|
51
|
-
def __enter__(self):
|
|
58
|
+
def __enter__(self) -> "BaseUnitOfWork[TManager]":
|
|
52
59
|
self.verify()
|
|
53
60
|
if self._active:
|
|
54
61
|
return self
|
|
55
62
|
# get connection from manager, the connection is entered yet
|
|
56
63
|
self._ctx = self._manager.start_connection()
|
|
64
|
+
self._attached_repositories = []
|
|
57
65
|
for repo in self._repositories.values():
|
|
66
|
+
if repo.connection_manager is self._manager:
|
|
67
|
+
continue
|
|
58
68
|
repo.attach_to_unit_of_work(self)
|
|
69
|
+
self._attached_repositories.append(repo)
|
|
59
70
|
self._active = True
|
|
60
71
|
return self
|
|
61
72
|
|
|
62
|
-
def __exit__(
|
|
73
|
+
def __exit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc_val: BaseException | None,
|
|
77
|
+
exc_tb: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
63
79
|
self._active = False
|
|
64
80
|
try:
|
|
65
81
|
if exc_type is None:
|
|
@@ -70,6 +86,6 @@ class BaseUnitOfWork(IUnitOfWork[TManager], InfrastructureGroup):
|
|
|
70
86
|
# Log or handle commit/rollback errors
|
|
71
87
|
raise RuntimeError(f"Failed to finalize transaction: {e}") from e
|
|
72
88
|
finally:
|
|
73
|
-
for repo in self.
|
|
89
|
+
for repo in self._attached_repositories:
|
|
74
90
|
repo.detach_from_unit_of_work()
|
|
75
|
-
self._ctx.__exit__(exc_type, exc_val, exc_tb)
|
|
91
|
+
cast(Any, self._ctx).__exit__(exc_type, exc_val, exc_tb)
|
|
@@ -6,7 +6,7 @@ backends (PostgreSQL, MySQL, SQLite) through SQLAlchemy's abstraction layer.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from .datastore import SQLAlchemyConnectionContextManager, SQLAlchemyDatastore
|
|
9
|
-
from .infrastructure import SQLAlchemyInfrastructure
|
|
9
|
+
from .infrastructure import SQLAlchemyInfrastructure, SQLAlchemyScopeRunner
|
|
10
10
|
from .outbox import (
|
|
11
11
|
SQLAlchemyInboxRepository,
|
|
12
12
|
SQLAlchemyOutboxRepository,
|
|
@@ -27,6 +27,7 @@ __all__ = [
|
|
|
27
27
|
"SQLAlchemyOutboxRepository",
|
|
28
28
|
"SQLAlchemyInboxRepository",
|
|
29
29
|
"SQLAlchemyInfrastructure",
|
|
30
|
+
"SQLAlchemyScopeRunner",
|
|
30
31
|
"SQLAlchemyPairInboxOutbox",
|
|
31
32
|
"SQLAlchemyEntityRepositoryAdapter",
|
|
32
33
|
"SQLAlchemySearchRepositoryAdapter",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""SQLAlchemy datastore and connection context manager."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Iterator, Mapping
|
|
4
|
-
from contextlib import contextmanager
|
|
4
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
7
|
from eventsourcing.utils import strtobool
|
|
@@ -38,7 +38,7 @@ class SQLAlchemyDatastore:
|
|
|
38
38
|
pool_recycle: int = 3600,
|
|
39
39
|
pool_pre_ping: bool = True,
|
|
40
40
|
max_overflow: int = 10,
|
|
41
|
-
):
|
|
41
|
+
) -> None:
|
|
42
42
|
"""Initialize SQLAlchemy datastore.
|
|
43
43
|
|
|
44
44
|
Args:
|
|
@@ -142,7 +142,7 @@ class SQLAlchemyConnectionContextManager(IConnectionManager):
|
|
|
142
142
|
Maintains a current connection reference for transaction coordination.
|
|
143
143
|
"""
|
|
144
144
|
|
|
145
|
-
def __init__(self, datastore: Optional[SQLAlchemyDatastore] = None):
|
|
145
|
+
def __init__(self, datastore: Optional[SQLAlchemyDatastore] = None) -> None:
|
|
146
146
|
"""Initialize connection context manager.
|
|
147
147
|
|
|
148
148
|
Args:
|
|
@@ -151,7 +151,7 @@ class SQLAlchemyConnectionContextManager(IConnectionManager):
|
|
|
151
151
|
"""
|
|
152
152
|
self._datastore = datastore
|
|
153
153
|
self._current_connection: Optional[Connection] = None
|
|
154
|
-
self._connection_ctx: Optional[
|
|
154
|
+
self._connection_ctx: Optional[AbstractContextManager[Connection]] = None
|
|
155
155
|
self._initialized = datastore is not None
|
|
156
156
|
|
|
157
157
|
@property
|
|
@@ -213,7 +213,10 @@ class SQLAlchemyConnectionContextManager(IConnectionManager):
|
|
|
213
213
|
)
|
|
214
214
|
self._initialized = True
|
|
215
215
|
|
|
216
|
-
def
|
|
216
|
+
def create_scoped_manager(self) -> "SQLAlchemyConnectionContextManager":
|
|
217
|
+
return SQLAlchemyConnectionContextManager(self.datastore)
|
|
218
|
+
|
|
219
|
+
def start_connection(self) -> Any:
|
|
217
220
|
"""Start a new connection context.
|
|
218
221
|
|
|
219
222
|
Returns:
|
|
@@ -280,7 +283,7 @@ class SQLAlchemyConnectionContextManager(IConnectionManager):
|
|
|
280
283
|
self._current_connection = None
|
|
281
284
|
conn = self.current_connection
|
|
282
285
|
yield conn
|
|
283
|
-
if commit:
|
|
286
|
+
if commit and conn.in_transaction():
|
|
284
287
|
conn.commit()
|
|
285
288
|
except Exception:
|
|
286
289
|
# Reset connection on error
|
|
@@ -1,12 +1,60 @@
|
|
|
1
1
|
"""SQLAlchemy infrastructure grouping."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Callable, Generic, Mapping, TypeVar, cast
|
|
5
|
+
|
|
3
6
|
from hexagonal.adapters.drivens.mappers import MessageMapper
|
|
4
|
-
from hexagonal.application import
|
|
7
|
+
from hexagonal.application import Infrastructure
|
|
8
|
+
from hexagonal.ports.drivens import TResult
|
|
5
9
|
|
|
6
10
|
from .datastore import SQLAlchemyConnectionContextManager, SQLAlchemyDatastore
|
|
7
11
|
|
|
12
|
+
TWriteScope = TypeVar("TWriteScope")
|
|
13
|
+
TReadScope = TypeVar("TReadScope")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SQLAlchemyScopeRunner(Generic[TWriteScope, TReadScope]):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
create_write_scope: Callable[[], TWriteScope],
|
|
20
|
+
create_read_scope: Callable[[], TReadScope],
|
|
21
|
+
) -> None:
|
|
22
|
+
self._create_write_scope = create_write_scope
|
|
23
|
+
self._create_read_scope = create_read_scope
|
|
24
|
+
self._local = threading.local()
|
|
25
|
+
|
|
26
|
+
def run_in_write_scope(self, work: Callable[[TWriteScope], TResult]) -> TResult:
|
|
27
|
+
stack: list[TWriteScope] | None = getattr(
|
|
28
|
+
self._local, "write_scope_stack", None
|
|
29
|
+
)
|
|
30
|
+
if stack is None:
|
|
31
|
+
stack = []
|
|
32
|
+
self._local.write_scope_stack = stack
|
|
33
|
+
|
|
34
|
+
if stack:
|
|
35
|
+
return work(stack[-1])
|
|
36
|
+
|
|
37
|
+
scope = self._create_write_scope()
|
|
38
|
+
stack.append(scope)
|
|
39
|
+
try:
|
|
40
|
+
return work(scope)
|
|
41
|
+
finally:
|
|
42
|
+
stack.pop()
|
|
8
43
|
|
|
9
|
-
|
|
44
|
+
def run_in_read_scope(self, work: Callable[[TReadScope], TResult]) -> TResult:
|
|
45
|
+
return work(self._create_read_scope())
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def current_write_scope(self) -> TWriteScope | None:
|
|
49
|
+
stack = cast(
|
|
50
|
+
list[TWriteScope] | None, getattr(self._local, "write_scope_stack", None)
|
|
51
|
+
)
|
|
52
|
+
if not stack:
|
|
53
|
+
return None
|
|
54
|
+
return stack[-1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SQLAlchemyInfrastructure(Infrastructure):
|
|
10
58
|
"""Groups SQLAlchemy connection manager and mapper for dependency injection.
|
|
11
59
|
|
|
12
60
|
Provides a convenient way to initialize and access the core
|
|
@@ -17,7 +65,7 @@ class SQLAlchemyInfrastructure(ComposableInfrastructure):
|
|
|
17
65
|
self,
|
|
18
66
|
mapper: MessageMapper,
|
|
19
67
|
datastore: SQLAlchemyDatastore | None = None,
|
|
20
|
-
):
|
|
68
|
+
) -> None:
|
|
21
69
|
"""Initialize SQLAlchemy infrastructure.
|
|
22
70
|
|
|
23
71
|
Args:
|
|
@@ -25,15 +73,28 @@ class SQLAlchemyInfrastructure(ComposableInfrastructure):
|
|
|
25
73
|
datastore: Optional SQLAlchemyDatastore instance.
|
|
26
74
|
If not provided, connection_manager.initialize() must be called.
|
|
27
75
|
"""
|
|
76
|
+
super().__init__()
|
|
28
77
|
self._datastore = datastore
|
|
29
78
|
self._mapper = mapper
|
|
30
|
-
self.
|
|
31
|
-
|
|
79
|
+
self._env: Mapping[str, str] = {}
|
|
80
|
+
self._initialized = datastore is not None
|
|
81
|
+
|
|
82
|
+
def initialize(self, env: Mapping[str, str]) -> None:
|
|
83
|
+
self._env = dict(env)
|
|
84
|
+
self._initialized = True
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def datastore(self) -> SQLAlchemyDatastore:
|
|
88
|
+
if self._datastore is None:
|
|
89
|
+
raise RuntimeError("Datastore not initialized. Call initialize() first.")
|
|
90
|
+
return self._datastore
|
|
32
91
|
|
|
33
92
|
@property
|
|
34
93
|
def connection_manager(self) -> SQLAlchemyConnectionContextManager:
|
|
35
|
-
|
|
36
|
-
|
|
94
|
+
return self.create_connection_manager()
|
|
95
|
+
|
|
96
|
+
def create_connection_manager(self) -> SQLAlchemyConnectionContextManager:
|
|
97
|
+
return SQLAlchemyConnectionContextManager(self.datastore)
|
|
37
98
|
|
|
38
99
|
@property
|
|
39
100
|
def mapper(self) -> MessageMapper:
|