python-hexagonal 0.1.2__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 (79) hide show
  1. python_hexagonal-0.3.0/PKG-INFO +94 -0
  2. python_hexagonal-0.3.0/README.md +80 -0
  3. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/pyproject.toml +19 -1
  4. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/command_bus.py +8 -11
  5. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/event_bus.py +49 -22
  6. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/infrastructure.py +4 -4
  7. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/message_bus.py +34 -3
  8. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/query.py +24 -7
  9. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/command_bus.py +2 -2
  10. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/event_bus.py +10 -4
  11. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/mappers.py +4 -2
  12. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/__init__.py +4 -2
  13. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/repository.py +40 -13
  14. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/base/unit_of_work.py +25 -9
  15. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +9 -2
  16. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +9 -6
  17. python_hexagonal-0.3.0/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +102 -0
  18. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/models.py +9 -5
  19. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +3 -3
  20. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +123 -5
  21. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/datastore.py +14 -8
  22. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/outbox.py +2 -2
  23. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/repository.py +1 -1
  24. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivers/app.py +19 -10
  25. python_hexagonal-0.3.0/src/hexagonal/application/__init__.py +59 -0
  26. python_hexagonal-0.3.0/src/hexagonal/application/api.py +148 -0
  27. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/application/app.py +18 -15
  28. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/application/bus_app.py +4 -0
  29. python_hexagonal-0.3.0/src/hexagonal/application/handlers.py +203 -0
  30. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/application/infrastructure.py +3 -3
  31. python_hexagonal-0.3.0/src/hexagonal/application/query.py +101 -0
  32. python_hexagonal-0.3.0/src/hexagonal/application/topics.py +45 -0
  33. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/domain/__init__.py +20 -0
  34. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/domain/aggregate.py +34 -18
  35. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/domain/base.py +17 -7
  36. python_hexagonal-0.3.0/src/hexagonal/domain/queries.py +22 -0
  37. python_hexagonal-0.3.0/src/hexagonal/domain/responses.py +58 -0
  38. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/app.py +13 -6
  39. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/base.py +2 -2
  40. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/bus.py +15 -3
  41. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/sqlalchemy.py +13 -10
  42. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/sqlite.py +9 -2
  43. python_hexagonal-0.3.0/src/hexagonal/integrations/__init__.py +1 -0
  44. python_hexagonal-0.3.0/src/hexagonal/integrations/sqlalchemy.py +51 -0
  45. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/__init__.py +22 -0
  46. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/buses.py +46 -9
  47. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/infrastructure.py +1 -1
  48. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/repository.py +56 -11
  49. python_hexagonal-0.3.0/src/hexagonal/ports/drivens/scoped.py +36 -0
  50. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivers/app.py +3 -3
  51. python_hexagonal-0.1.2/PKG-INFO +0 -16
  52. python_hexagonal-0.1.2/README.md +0 -2
  53. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +0 -41
  54. python_hexagonal-0.1.2/src/hexagonal/application/__init__.py +0 -29
  55. python_hexagonal-0.1.2/src/hexagonal/application/api.py +0 -61
  56. python_hexagonal-0.1.2/src/hexagonal/application/handlers.py +0 -107
  57. python_hexagonal-0.1.2/src/hexagonal/application/query.py +0 -71
  58. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/__init__.py +0 -0
  59. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/__init__.py +0 -0
  60. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/__init__.py +0 -0
  61. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/__init__.py +0 -0
  62. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/__init__.py +0 -0
  63. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/base/utils.py +0 -0
  64. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/__init__.py +0 -0
  65. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/buses/inmemory/infra.py +0 -0
  66. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/__init__.py +0 -0
  67. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +0 -0
  68. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +0 -0
  69. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/__init__.py +0 -0
  70. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/env_vars.py +0 -0
  71. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +0 -0
  72. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +0 -0
  73. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/adapters/drivers/__init__.py +0 -0
  74. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/domain/exceptions.py +0 -0
  75. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/entrypoints/__init__.py +0 -0
  76. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/__init__.py +0 -0
  77. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivens/application.py +0 -0
  78. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/ports/drivers/__init__.py +0 -0
  79. {python_hexagonal-0.1.2 → python_hexagonal-0.3.0}/src/hexagonal/py.typed +0 -0
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.3
2
+ Name: python-hexagonal
3
+ Version: 0.3.0
4
+ Summary: Framework to build hexagonal architecture applications in Python.
5
+ Author: jose-matos-9281
6
+ Author-email: jose-matos-9281 <58991817+jose-matos-9281@users.noreply.github.com>
7
+ Requires-Dist: eventsourcing>=9.4.6
8
+ Requires-Dist: orjson>=3.11.5
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: sqlalchemy>=2.0.45
11
+ Requires-Dist: uuid6>=2025.0.1
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # python-hexagonal
16
+
17
+ `python-hexagonal` is a library for building hexagonal applications in Python.
18
+ The real usage story lives in `src/example` and the proof lives in
19
+ `src/tests/use_cases`, so this documentation starts there instead of pretending a
20
+ two-line snippet is enough.
21
+
22
+ ## Start here
23
+
24
+ - Install the package: `pip install python-hexagonal`
25
+ - Follow the example-backed guide: [`docs/getting-started/first-app.md`](docs/getting-started/first-app.md)
26
+ - Understand the architecture roles:
27
+ [`docs/explanation/architecture-from-example.md`](docs/explanation/architecture-from-example.md)
28
+ - Bootstrap the adapter-specific SQLAlchemy path:
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)
32
+ - Learn the canonical testing workflow:
33
+ [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
34
+ - Check the guardrails before copying imports:
35
+ [`docs/reference/supported-surface.md`](docs/reference/supported-surface.md)
36
+ - Review the evidence map if you want to verify a claim against code or tests:
37
+ [`docs/reference/evidence-map.yaml`](docs/reference/evidence-map.yaml)
38
+
39
+ ## What you learn from the first path
40
+
41
+ After the first-app guide you should understand:
42
+
43
+ - where the domain model lives and why it stays isolated
44
+ - how application APIs wrap command, query, and event buses
45
+ - how ports describe the infrastructure your app needs
46
+ - how entrypoints assemble an app from environment and infrastructure
47
+ - how the use-case tests prove the workflow end to end
48
+
49
+ ## The blueprint we actually trust
50
+
51
+ These files are the baseline for the supported adoption story:
52
+
53
+ - `src/example/contacto/domain/contacto.py` - aggregate behavior, value-object
54
+ strategy, and query entry points
55
+ - `src/example/contacto/application/app.py` - application composition through
56
+ `BusAppGroup`
57
+ - `src/example/contacto/ports/drivens.py` - infrastructure contracts and
58
+ repository boundaries
59
+ - `src/example/app/application/api.py` - top-level API wrapper consumers call
60
+ - `src/example/app/entrypoints/main.py` - environment-driven bootstrap via
61
+ `EntrypointGroup`
62
+ - `src/example/app/entrypoints/db/sqlalchemy.py` - SQLAlchemy-specific
63
+ infrastructure assembly
64
+ - `src/tests/use_cases/base.py` - canonical test bootstrap with migrations,
65
+ entrypoint creation, and topic registration
66
+
67
+ ## Supported path vs internals
68
+
69
+ The documented path is intentionally narrow.
70
+
71
+ - Start from `hexagonal.domain`, `hexagonal.application`,
72
+ `hexagonal.ports.drivens`, `hexagonal.ports.drivers`, and
73
+ `hexagonal.entrypoints`
74
+ - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
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
78
+ - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
79
+ the whole framework story
80
+ - Do not treat `hexagonal.__init__` as the public integration surface; right now
81
+ it only exposes `hello()`
82
+ - Do not cargo-cult example names like `exampleAPI`, `exampleEntrypoint`, or
83
+ `Exampletate`; copy the roles, not the labels
84
+ - Do not build new code against `hexagonal.adapters.*`; that path is kept for
85
+ compatibility, while the supported SQLAlchemy extension surface now lives under
86
+ `hexagonal.integrations.sqlalchemy`
87
+
88
+ ## Companion skill
89
+
90
+ This repo also ships an installable companion skill at
91
+ `skills/python-hexagonal-usage/SKILL.md`.
92
+ It is there to inspect a user repository, map it to the same architecture, and
93
+ point people back to the written docs. It does NOT replace the docs and it does
94
+ NOT get to invent new supported APIs.
@@ -0,0 +1,80 @@
1
+ # python-hexagonal
2
+
3
+ `python-hexagonal` is a library for building hexagonal applications in Python.
4
+ The real usage story lives in `src/example` and the proof lives in
5
+ `src/tests/use_cases`, so this documentation starts there instead of pretending a
6
+ two-line snippet is enough.
7
+
8
+ ## Start here
9
+
10
+ - Install the package: `pip install python-hexagonal`
11
+ - Follow the example-backed guide: [`docs/getting-started/first-app.md`](docs/getting-started/first-app.md)
12
+ - Understand the architecture roles:
13
+ [`docs/explanation/architecture-from-example.md`](docs/explanation/architecture-from-example.md)
14
+ - Bootstrap the adapter-specific SQLAlchemy path:
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)
18
+ - Learn the canonical testing workflow:
19
+ [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
20
+ - Check the guardrails before copying imports:
21
+ [`docs/reference/supported-surface.md`](docs/reference/supported-surface.md)
22
+ - Review the evidence map if you want to verify a claim against code or tests:
23
+ [`docs/reference/evidence-map.yaml`](docs/reference/evidence-map.yaml)
24
+
25
+ ## What you learn from the first path
26
+
27
+ After the first-app guide you should understand:
28
+
29
+ - where the domain model lives and why it stays isolated
30
+ - how application APIs wrap command, query, and event buses
31
+ - how ports describe the infrastructure your app needs
32
+ - how entrypoints assemble an app from environment and infrastructure
33
+ - how the use-case tests prove the workflow end to end
34
+
35
+ ## The blueprint we actually trust
36
+
37
+ These files are the baseline for the supported adoption story:
38
+
39
+ - `src/example/contacto/domain/contacto.py` - aggregate behavior, value-object
40
+ strategy, and query entry points
41
+ - `src/example/contacto/application/app.py` - application composition through
42
+ `BusAppGroup`
43
+ - `src/example/contacto/ports/drivens.py` - infrastructure contracts and
44
+ repository boundaries
45
+ - `src/example/app/application/api.py` - top-level API wrapper consumers call
46
+ - `src/example/app/entrypoints/main.py` - environment-driven bootstrap via
47
+ `EntrypointGroup`
48
+ - `src/example/app/entrypoints/db/sqlalchemy.py` - SQLAlchemy-specific
49
+ infrastructure assembly
50
+ - `src/tests/use_cases/base.py` - canonical test bootstrap with migrations,
51
+ entrypoint creation, and topic registration
52
+
53
+ ## Supported path vs internals
54
+
55
+ The documented path is intentionally narrow.
56
+
57
+ - Start from `hexagonal.domain`, `hexagonal.application`,
58
+ `hexagonal.ports.drivens`, `hexagonal.ports.drivers`, and
59
+ `hexagonal.entrypoints`
60
+ - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
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
64
+ - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
65
+ the whole framework story
66
+ - Do not treat `hexagonal.__init__` as the public integration surface; right now
67
+ it only exposes `hello()`
68
+ - Do not cargo-cult example names like `exampleAPI`, `exampleEntrypoint`, or
69
+ `Exampletate`; copy the roles, not the labels
70
+ - Do not build new code against `hexagonal.adapters.*`; that path is kept for
71
+ compatibility, while the supported SQLAlchemy extension surface now lives under
72
+ `hexagonal.integrations.sqlalchemy`
73
+
74
+ ## Companion skill
75
+
76
+ This repo also ships an installable companion skill at
77
+ `skills/python-hexagonal-usage/SKILL.md`.
78
+ It is there to inspect a user repository, map it to the same architecture, and
79
+ point people back to the written docs. It does NOT replace the docs and it does
80
+ NOT get to invent new supported APIs.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-hexagonal"
3
- version = "0.1.2"
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,9 +26,27 @@ 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 = [
47
+ "alembic>=1.18.4",
31
48
  "pytest>=9.0.2",
49
+ "python-dotenv>=1.2.2",
32
50
  ]
33
51
  sqlalchemy = [
34
52
  "sqlalchemy>=2.0.45",
@@ -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)
@@ -7,12 +7,15 @@ from hexagonal.domain import (
7
7
  HandlerAlreadyRegistered,
8
8
  HandlerNotRegistered,
9
9
  Query,
10
+ QueryBase,
11
+ QueryOne,
10
12
  QueryResult,
11
13
  QueryResults,
12
14
  TQuery,
13
15
  TView,
14
16
  )
15
17
  from hexagonal.ports.drivens import IQueryBus, IQueryHandler, TManager
18
+ from hexagonal.ports.drivens.scoped import IReadScopeRunner
16
19
 
17
20
 
18
21
  class QueryBus(IQueryBus[TManager], Infrastructure):
@@ -20,14 +23,20 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
20
23
 
21
24
  def initialize(self, env: Mapping[str, str]) -> None:
22
25
  self.handlers = {}
26
+ self._read_scope_runner: IReadScopeRunner[Any] | None = None
23
27
  super().initialize(env)
24
28
 
25
- def _get_name(self, query_type: Type[Query[TView]]) -> str:
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
+
34
+ def _get_name(self, query_type: Type[QueryBase[TView]]) -> str:
26
35
  return get_topic(query_type)
27
36
 
28
37
  def _get_handler(
29
- self, query: Query[TView]
30
- ) -> IQueryHandler[TManager, Query[TView], TView] | None:
38
+ self, query: QueryBase[TView]
39
+ ) -> IQueryHandler[TManager, QueryBase[TView], TView] | None:
31
40
  name = self._get_name(query.__class__)
32
41
  return self.handlers.get(name)
33
42
 
@@ -35,14 +44,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
35
44
  self,
36
45
  query_type: Type[TQuery],
37
46
  handler: IQueryHandler[TManager, TQuery, TView],
38
- ):
47
+ ) -> None:
39
48
  self.verify()
40
49
  name = self._get_name(query_type)
41
50
  if name in self.handlers:
42
51
  raise HandlerAlreadyRegistered(f"Query: {name}")
43
52
  self.handlers[name] = handler
44
53
 
45
- def unregister_handler(self, query_type: Type[TQuery]):
54
+ def unregister_handler(self, query_type: Type[TQuery]) -> None:
46
55
  self.verify()
47
56
  name = self._get_name(query_type)
48
57
  if name in self.handlers:
@@ -50,6 +59,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
50
59
  else:
51
60
  raise HandlerNotRegistered(f"Query: {name}")
52
61
 
62
+ @overload
63
+ def get(
64
+ self,
65
+ query: QueryOne[TView],
66
+ *,
67
+ one: bool = False,
68
+ ) -> QueryResult[TView]: ...
69
+
53
70
  @overload
54
71
  def get(self, query: Query[TView], *, one: Literal[True]) -> QueryResult[TView]: ...
55
72
 
@@ -63,7 +80,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
63
80
 
64
81
  def get(
65
82
  self,
66
- query: Query[TView],
83
+ query: Query[TView] | QueryOne[TView],
67
84
  *,
68
85
  one: bool = False,
69
86
  ) -> QueryResult[TView] | QueryResults[TView]:
@@ -73,7 +90,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
73
90
  if not handler:
74
91
  raise HandlerNotRegistered(f"Query: {name}")
75
92
  results = handler.get(query)
76
- if not one:
93
+ if not (one or isinstance(query, QueryOne)):
77
94
  return results
78
95
  if len(results) == 0:
79
96
  raise ValueError("No results found")
@@ -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()
@@ -5,6 +5,8 @@ import threading
5
5
  from queue import Empty, Queue
6
6
  from typing import Mapping
7
7
 
8
+ from eventsourcing.utils import strtobool
9
+
8
10
  from hexagonal.adapters.drivens.buses.base import BaseEventBus
9
11
  from hexagonal.domain import CloudMessage, TEvent, TEvento
10
12
  from hexagonal.ports.drivens import TManager
@@ -13,6 +15,9 @@ logger = logging.getLogger(__name__)
13
15
 
14
16
 
15
17
  class InMemoryEventBus(BaseEventBus[TManager]):
18
+ def shutdown(self) -> None:
19
+ return
20
+
16
21
  def _publish_message(self, message: CloudMessage[TEvento]) -> None:
17
22
  super()._publish_message(message)
18
23
  self._process_messages(message)
@@ -20,7 +25,7 @@ class InMemoryEventBus(BaseEventBus[TManager]):
20
25
  def publish(self, *events: CloudMessage[TEvent]) -> None:
21
26
  return self._publish_messages(*events)
22
27
 
23
- def consume(self, limit: int | None = None):
28
+ def consume(self, limit: int | None = None) -> None:
24
29
  pass # No-op for non-queued bus
25
30
 
26
31
 
@@ -28,7 +33,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
28
33
  def initialize(self, env: Mapping[str, str]) -> None:
29
34
  self.queue: Queue[CloudMessage[TEvento]] = Queue() # o Queue(maxsize=...)
30
35
  self._stop = threading.Event()
31
- self._worker = threading.Thread(target=self._worker_loop, daemon=True)
36
+ daemon = strtobool(env.get("EVENT_BUS_WORKER_DAEMON", "true"))
37
+ self._worker = threading.Thread(target=self._worker_loop, daemon=daemon)
32
38
 
33
39
  super().initialize(env)
34
40
 
@@ -41,8 +47,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
41
47
  return self._publish_messages(*events)
42
48
 
43
49
  def _publish_message(self, message: CloudMessage[TEvento]) -> None:
44
- super()._publish_message(message)
45
50
  self.verify()
51
+ super()._publish_message(message)
46
52
  self.queue.put(message)
47
53
  # No llamamos consume() aquí: el worker se encarga.
48
54
 
@@ -65,5 +71,5 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
65
71
  finally:
66
72
  self.queue.task_done()
67
73
 
68
- def consume(self, limit: int | None = None):
74
+ def consume(self, limit: int | None = None) -> None:
69
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
 
@@ -123,6 +123,8 @@ class MessageMapper(Mapper[UUID]):
123
123
  def default_orjson_value_serializer(obj: Any) -> Any:
124
124
  if isinstance(obj, (UUID, Decimal)):
125
125
  return str(obj)
126
+ if isinstance(obj, Inmutable):
127
+ return obj.model_dump(mode="json")
126
128
  raise TypeError
127
129
 
128
130