python-hexagonal 0.2.0__tar.gz → 0.3.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 (74) hide show
  1. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/PKG-INFO +5 -1
  2. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/README.md +4 -0
  3. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/pyproject.toml +17 -1
  4. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/command_bus.py +8 -11
  5. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/event_bus.py +49 -22
  6. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/infrastructure.py +4 -4
  7. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/message_bus.py +34 -3
  8. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/query.py +9 -2
  9. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/command_bus.py +2 -2
  10. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/event_bus.py +5 -2
  11. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/mappers.py +2 -2
  12. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/repository.py +5 -8
  13. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/unit_of_work.py +25 -9
  14. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +2 -1
  15. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +9 -6
  16. python_hexagonal-0.3.0/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +102 -0
  17. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +2 -2
  18. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +1 -1
  19. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/datastore.py +14 -8
  20. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/outbox.py +2 -2
  21. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/repository.py +1 -1
  22. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivers/app.py +19 -10
  23. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/__init__.py +9 -0
  24. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/api.py +16 -78
  25. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/app.py +18 -15
  26. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/bus_app.py +4 -0
  27. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/handlers.py +74 -7
  28. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/infrastructure.py +3 -3
  29. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/query.py +22 -6
  30. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/application/topics.py +4 -4
  31. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/domain/__init__.py +3 -0
  32. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/domain/aggregate.py +8 -8
  33. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/domain/base.py +7 -5
  34. python_hexagonal-0.3.0/src/hexagonal/domain/queries.py +22 -0
  35. python_hexagonal-0.3.0/src/hexagonal/domain/responses.py +58 -0
  36. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/app.py +13 -6
  37. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/base.py +2 -2
  38. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/bus.py +15 -3
  39. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/sqlalchemy.py +7 -2
  40. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/sqlite.py +9 -2
  41. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/integrations/sqlalchemy.py +2 -0
  42. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/__init__.py +18 -0
  43. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/buses.py +33 -9
  44. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/infrastructure.py +1 -1
  45. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/repository.py +32 -9
  46. python_hexagonal-0.3.0/src/hexagonal/ports/drivens/scoped.py +36 -0
  47. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivers/app.py +3 -3
  48. python_hexagonal-0.2.0/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +0 -41
  49. python_hexagonal-0.2.0/src/hexagonal/domain/queries.py +0 -17
  50. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/__init__.py +0 -0
  51. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/__init__.py +0 -0
  52. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/__init__.py +0 -0
  53. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/__init__.py +0 -0
  54. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/__init__.py +0 -0
  55. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/utils.py +0 -0
  56. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/__init__.py +0 -0
  57. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/infra.py +0 -0
  58. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/__init__.py +0 -0
  59. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/__init__.py +0 -0
  60. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +0 -0
  61. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/models.py +0 -0
  62. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +0 -0
  63. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/__init__.py +0 -0
  64. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/env_vars.py +0 -0
  65. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +0 -0
  66. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +0 -0
  67. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivers/__init__.py +0 -0
  68. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/domain/exceptions.py +0 -0
  69. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/__init__.py +0 -0
  70. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/integrations/__init__.py +0 -0
  71. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/__init__.py +0 -0
  72. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/application.py +0 -0
  73. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivers/__init__.py +0 -0
  74. {python_hexagonal-0.2.0 → python_hexagonal-0.3.0}/src/hexagonal/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-hexagonal
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Framework to build hexagonal architecture applications in Python.
5
5
  Author: jose-matos-9281
6
6
  Author-email: jose-matos-9281 <58991817+jose-matos-9281@users.noreply.github.com>
@@ -27,6 +27,8 @@ two-line snippet is enough.
27
27
  [`docs/explanation/architecture-from-example.md`](docs/explanation/architecture-from-example.md)
28
28
  - Bootstrap the adapter-specific SQLAlchemy path:
29
29
  [`docs/how-to/bootstrap-sqlalchemy-app.md`](docs/how-to/bootstrap-sqlalchemy-app.md)
30
+ - Migrate existing apps to the scoped execution model:
31
+ [`docs/how-to/migrate-to-0.3.0-scoped-execution.md`](docs/how-to/migrate-to-0.3.0-scoped-execution.md)
30
32
  - Learn the canonical testing workflow:
31
33
  [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
32
34
  - Check the guardrails before copying imports:
@@ -71,6 +73,8 @@ The documented path is intentionally narrow.
71
73
  `hexagonal.entrypoints`
72
74
  - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
73
75
  SQLAlchemy repository and unit-of-work utilities
76
+ - Treat scoped execution as the default mental model: shared datastore and
77
+ mapper, fresh write/read scope per operation
74
78
  - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
75
79
  the whole framework story
76
80
  - Do not treat `hexagonal.__init__` as the public integration surface; right now
@@ -13,6 +13,8 @@ two-line snippet is enough.
13
13
  [`docs/explanation/architecture-from-example.md`](docs/explanation/architecture-from-example.md)
14
14
  - Bootstrap the adapter-specific SQLAlchemy path:
15
15
  [`docs/how-to/bootstrap-sqlalchemy-app.md`](docs/how-to/bootstrap-sqlalchemy-app.md)
16
+ - Migrate existing apps to the scoped execution model:
17
+ [`docs/how-to/migrate-to-0.3.0-scoped-execution.md`](docs/how-to/migrate-to-0.3.0-scoped-execution.md)
16
18
  - Learn the canonical testing workflow:
17
19
  [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
18
20
  - Check the guardrails before copying imports:
@@ -57,6 +59,8 @@ The documented path is intentionally narrow.
57
59
  `hexagonal.entrypoints`
58
60
  - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
59
61
  SQLAlchemy repository and unit-of-work utilities
62
+ - Treat scoped execution as the default mental model: shared datastore and
63
+ mapper, fresh write/read scope per operation
60
64
  - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
61
65
  the whole framework story
62
66
  - Do not treat `hexagonal.__init__` as the public integration surface; right now
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-hexagonal"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Framework to build hexagonal architecture applications in Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -26,6 +26,22 @@ module-name = "hexagonal"
26
26
  select = ["I", "E", "F", "B"]
27
27
  fixable = ["I", "E", "F", "B"]
28
28
 
29
+ [tool.mypy]
30
+ mypy_path = ["src"]
31
+ python_version = "3.12"
32
+ strict = true
33
+ namespace_packages = true
34
+ explicit_package_bases = true
35
+ show_error_codes = true
36
+
37
+ [[tool.mypy.overrides]]
38
+ module = ["alembic", "alembic.*"]
39
+ ignore_missing_imports = true
40
+
41
+ [[tool.mypy.overrides]]
42
+ module = ["tests.*"]
43
+ disable_error_code = ["attr-defined", "no-untyped-call", "no-untyped-def", "var-annotated"]
44
+
29
45
  [dependency-groups]
30
46
  dev = [
31
47
  "alembic>=1.18.4",
@@ -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, command: TCommand | CloudMessage[TCommand], *, to_outbox: bool = False
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.outbox_repository.save(cmd)
61
+ self.save_to_outbox(command)
65
62
  else:
66
- self.process_command(cmd)
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
- super().__init__(f"""
29
- Error al Manejar Evento {evento.__class__.__name__}
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
- evento: {evento.type}
36
- datos: {evento.model_dump_json(indent=2)}
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
- """) # type: ignore # noqa: E501
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(self, event_type: Type[TEvent], handler: IMessageHandler[TEvent]):
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(self, event_type: Type[TEvent], *handlers: IMessageHandler[TEvent]):
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(self, event_type: Type[TEvent], handler: Callable[[TEvent], None]):
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
- handler = wait_list.pop()
116
- handler(event)
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
- handler = wait_list.pop()
120
- handler(event)
143
+ queued_handler = wait_list.pop()
144
+ queued_handler(event)
121
145
  except Exception as e:
122
- raise HandlerError(event, handler, e) from e # type: ignore
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
- return self._wait_for(event_type, handler)
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
- return self._process_messages(*events)
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) # type: ignore[return-value]
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._attached_to_uow = False
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._attached_to_uow = True
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._attached_to_uow = False
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._attached_to_uow and self._manager_at_uow is not None:
56
- return self._manager_at_uow
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 typing import Mapping
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 self.initialized and self._active:
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__(self, exc_type, exc_val, exc_tb): # type: ignore
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._repositories.values():
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) # type: ignore
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[Any] = None
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 start_connection(self):
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