python-hexagonal 0.1.2__tar.gz → 0.2.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/PKG-INFO +90 -0
  2. python_hexagonal-0.2.0/README.md +76 -0
  3. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/pyproject.toml +3 -1
  4. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/query.py +15 -5
  5. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/inmemory/event_bus.py +5 -2
  6. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/mappers.py +2 -0
  7. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/base/__init__.py +4 -2
  8. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/base/repository.py +35 -5
  9. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +7 -1
  10. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/models.py +9 -5
  11. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +1 -1
  12. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +123 -5
  13. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/application/__init__.py +24 -3
  14. python_hexagonal-0.2.0/src/hexagonal/application/api.py +210 -0
  15. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/application/handlers.py +40 -11
  16. python_hexagonal-0.2.0/src/hexagonal/application/query.py +85 -0
  17. python_hexagonal-0.2.0/src/hexagonal/application/topics.py +45 -0
  18. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/domain/__init__.py +17 -0
  19. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/domain/aggregate.py +26 -10
  20. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/domain/base.py +10 -2
  21. python_hexagonal-0.2.0/src/hexagonal/domain/queries.py +17 -0
  22. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/sqlalchemy.py +6 -8
  23. python_hexagonal-0.2.0/src/hexagonal/integrations/__init__.py +1 -0
  24. python_hexagonal-0.2.0/src/hexagonal/integrations/sqlalchemy.py +49 -0
  25. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivens/__init__.py +4 -0
  26. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivens/buses.py +14 -1
  27. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivens/repository.py +27 -5
  28. python_hexagonal-0.1.2/PKG-INFO +0 -16
  29. python_hexagonal-0.1.2/README.md +0 -2
  30. python_hexagonal-0.1.2/src/hexagonal/application/api.py +0 -61
  31. python_hexagonal-0.1.2/src/hexagonal/application/query.py +0 -71
  32. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/__init__.py +0 -0
  33. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/__init__.py +0 -0
  34. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/__init__.py +0 -0
  35. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/__init__.py +0 -0
  36. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/__init__.py +0 -0
  37. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/command_bus.py +0 -0
  38. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/event_bus.py +0 -0
  39. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/infrastructure.py +0 -0
  40. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/message_bus.py +0 -0
  41. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/base/utils.py +0 -0
  42. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/inmemory/__init__.py +0 -0
  43. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/inmemory/command_bus.py +0 -0
  44. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/buses/inmemory/infra.py +0 -0
  45. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/__init__.py +0 -0
  46. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/base/unit_of_work.py +0 -0
  47. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +0 -0
  48. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +0 -0
  49. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +0 -0
  50. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +0 -0
  51. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/__init__.py +0 -0
  52. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/datastore.py +0 -0
  53. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/env_vars.py +0 -0
  54. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +0 -0
  55. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/outbox.py +0 -0
  56. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/repository.py +0 -0
  57. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +0 -0
  58. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivers/__init__.py +0 -0
  59. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/adapters/drivers/app.py +0 -0
  60. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/application/app.py +0 -0
  61. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/application/bus_app.py +0 -0
  62. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/application/infrastructure.py +0 -0
  63. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/domain/exceptions.py +0 -0
  64. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/__init__.py +0 -0
  65. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/app.py +0 -0
  66. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/base.py +0 -0
  67. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/bus.py +0 -0
  68. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/entrypoints/sqlite.py +0 -0
  69. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/__init__.py +0 -0
  70. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivens/application.py +0 -0
  71. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivens/infrastructure.py +0 -0
  72. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivers/__init__.py +0 -0
  73. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/ports/drivers/app.py +0 -0
  74. {python_hexagonal-0.1.2 → python_hexagonal-0.2.0}/src/hexagonal/py.typed +0 -0
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.3
2
+ Name: python-hexagonal
3
+ Version: 0.2.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
+ - Learn the canonical testing workflow:
31
+ [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
32
+ - Check the guardrails before copying imports:
33
+ [`docs/reference/supported-surface.md`](docs/reference/supported-surface.md)
34
+ - Review the evidence map if you want to verify a claim against code or tests:
35
+ [`docs/reference/evidence-map.yaml`](docs/reference/evidence-map.yaml)
36
+
37
+ ## What you learn from the first path
38
+
39
+ After the first-app guide you should understand:
40
+
41
+ - where the domain model lives and why it stays isolated
42
+ - how application APIs wrap command, query, and event buses
43
+ - how ports describe the infrastructure your app needs
44
+ - how entrypoints assemble an app from environment and infrastructure
45
+ - how the use-case tests prove the workflow end to end
46
+
47
+ ## The blueprint we actually trust
48
+
49
+ These files are the baseline for the supported adoption story:
50
+
51
+ - `src/example/contacto/domain/contacto.py` - aggregate behavior, value-object
52
+ strategy, and query entry points
53
+ - `src/example/contacto/application/app.py` - application composition through
54
+ `BusAppGroup`
55
+ - `src/example/contacto/ports/drivens.py` - infrastructure contracts and
56
+ repository boundaries
57
+ - `src/example/app/application/api.py` - top-level API wrapper consumers call
58
+ - `src/example/app/entrypoints/main.py` - environment-driven bootstrap via
59
+ `EntrypointGroup`
60
+ - `src/example/app/entrypoints/db/sqlalchemy.py` - SQLAlchemy-specific
61
+ infrastructure assembly
62
+ - `src/tests/use_cases/base.py` - canonical test bootstrap with migrations,
63
+ entrypoint creation, and topic registration
64
+
65
+ ## Supported path vs internals
66
+
67
+ The documented path is intentionally narrow.
68
+
69
+ - Start from `hexagonal.domain`, `hexagonal.application`,
70
+ `hexagonal.ports.drivens`, `hexagonal.ports.drivers`, and
71
+ `hexagonal.entrypoints`
72
+ - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
73
+ SQLAlchemy repository and unit-of-work utilities
74
+ - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
75
+ the whole framework story
76
+ - Do not treat `hexagonal.__init__` as the public integration surface; right now
77
+ it only exposes `hello()`
78
+ - Do not cargo-cult example names like `exampleAPI`, `exampleEntrypoint`, or
79
+ `Exampletate`; copy the roles, not the labels
80
+ - Do not build new code against `hexagonal.adapters.*`; that path is kept for
81
+ compatibility, while the supported SQLAlchemy extension surface now lives under
82
+ `hexagonal.integrations.sqlalchemy`
83
+
84
+ ## Companion skill
85
+
86
+ This repo also ships an installable companion skill at
87
+ `skills/python-hexagonal-usage/SKILL.md`.
88
+ It is there to inspect a user repository, map it to the same architecture, and
89
+ point people back to the written docs. It does NOT replace the docs and it does
90
+ NOT get to invent new supported APIs.
@@ -0,0 +1,76 @@
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
+ - Learn the canonical testing workflow:
17
+ [`docs/how-to/test-use-cases.md`](docs/how-to/test-use-cases.md)
18
+ - Check the guardrails before copying imports:
19
+ [`docs/reference/supported-surface.md`](docs/reference/supported-surface.md)
20
+ - Review the evidence map if you want to verify a claim against code or tests:
21
+ [`docs/reference/evidence-map.yaml`](docs/reference/evidence-map.yaml)
22
+
23
+ ## What you learn from the first path
24
+
25
+ After the first-app guide you should understand:
26
+
27
+ - where the domain model lives and why it stays isolated
28
+ - how application APIs wrap command, query, and event buses
29
+ - how ports describe the infrastructure your app needs
30
+ - how entrypoints assemble an app from environment and infrastructure
31
+ - how the use-case tests prove the workflow end to end
32
+
33
+ ## The blueprint we actually trust
34
+
35
+ These files are the baseline for the supported adoption story:
36
+
37
+ - `src/example/contacto/domain/contacto.py` - aggregate behavior, value-object
38
+ strategy, and query entry points
39
+ - `src/example/contacto/application/app.py` - application composition through
40
+ `BusAppGroup`
41
+ - `src/example/contacto/ports/drivens.py` - infrastructure contracts and
42
+ repository boundaries
43
+ - `src/example/app/application/api.py` - top-level API wrapper consumers call
44
+ - `src/example/app/entrypoints/main.py` - environment-driven bootstrap via
45
+ `EntrypointGroup`
46
+ - `src/example/app/entrypoints/db/sqlalchemy.py` - SQLAlchemy-specific
47
+ infrastructure assembly
48
+ - `src/tests/use_cases/base.py` - canonical test bootstrap with migrations,
49
+ entrypoint creation, and topic registration
50
+
51
+ ## Supported path vs internals
52
+
53
+ The documented path is intentionally narrow.
54
+
55
+ - Start from `hexagonal.domain`, `hexagonal.application`,
56
+ `hexagonal.ports.drivens`, `hexagonal.ports.drivers`, and
57
+ `hexagonal.entrypoints`
58
+ - Reach for `hexagonal.integrations.sqlalchemy` when you need the reusable
59
+ SQLAlchemy repository and unit-of-work utilities
60
+ - Treat `hexagonal.entrypoints.sqlalchemy` as adapter-specific convenience, not
61
+ the whole framework story
62
+ - Do not treat `hexagonal.__init__` as the public integration surface; right now
63
+ it only exposes `hello()`
64
+ - Do not cargo-cult example names like `exampleAPI`, `exampleEntrypoint`, or
65
+ `Exampletate`; copy the roles, not the labels
66
+ - Do not build new code against `hexagonal.adapters.*`; that path is kept for
67
+ compatibility, while the supported SQLAlchemy extension surface now lives under
68
+ `hexagonal.integrations.sqlalchemy`
69
+
70
+ ## Companion skill
71
+
72
+ This repo also ships an installable companion skill at
73
+ `skills/python-hexagonal-usage/SKILL.md`.
74
+ It is there to inspect a user repository, map it to the same architecture, and
75
+ point people back to the written docs. It does NOT replace the docs and it does
76
+ 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.2.0"
4
4
  description = "Framework to build hexagonal architecture applications in Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -28,7 +28,9 @@ fixable = ["I", "E", "F", "B"]
28
28
 
29
29
  [dependency-groups]
30
30
  dev = [
31
+ "alembic>=1.18.4",
31
32
  "pytest>=9.0.2",
33
+ "python-dotenv>=1.2.2",
32
34
  ]
33
35
  sqlalchemy = [
34
36
  "sqlalchemy>=2.0.45",
@@ -7,6 +7,8 @@ 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,
@@ -22,12 +24,12 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
22
24
  self.handlers = {}
23
25
  super().initialize(env)
24
26
 
25
- def _get_name(self, query_type: Type[Query[TView]]) -> str:
27
+ def _get_name(self, query_type: Type[QueryBase[TView]]) -> str:
26
28
  return get_topic(query_type)
27
29
 
28
30
  def _get_handler(
29
- self, query: Query[TView]
30
- ) -> IQueryHandler[TManager, Query[TView], TView] | None:
31
+ self, query: QueryBase[TView]
32
+ ) -> IQueryHandler[TManager, QueryBase[TView], TView] | None:
31
33
  name = self._get_name(query.__class__)
32
34
  return self.handlers.get(name)
33
35
 
@@ -50,6 +52,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
50
52
  else:
51
53
  raise HandlerNotRegistered(f"Query: {name}")
52
54
 
55
+ @overload
56
+ def get(
57
+ self,
58
+ query: QueryOne[TView],
59
+ *,
60
+ one: bool = False,
61
+ ) -> QueryResult[TView]: ...
62
+
53
63
  @overload
54
64
  def get(self, query: Query[TView], *, one: Literal[True]) -> QueryResult[TView]: ...
55
65
 
@@ -63,7 +73,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
63
73
 
64
74
  def get(
65
75
  self,
66
- query: Query[TView],
76
+ query: Query[TView] | QueryOne[TView],
67
77
  *,
68
78
  one: bool = False,
69
79
  ) -> QueryResult[TView] | QueryResults[TView]:
@@ -73,7 +83,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
73
83
  if not handler:
74
84
  raise HandlerNotRegistered(f"Query: {name}")
75
85
  results = handler.get(query)
76
- if not one:
86
+ if not (one or isinstance(query, QueryOne)):
77
87
  return results
78
88
  if len(results) == 0:
79
89
  raise ValueError("No results found")
@@ -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
@@ -28,7 +30,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
28
30
  def initialize(self, env: Mapping[str, str]) -> None:
29
31
  self.queue: Queue[CloudMessage[TEvento]] = Queue() # o Queue(maxsize=...)
30
32
  self._stop = threading.Event()
31
- self._worker = threading.Thread(target=self._worker_loop, daemon=True)
33
+ daemon = strtobool(env.get("EVENT_BUS_WORKER_DAEMON", "true"))
34
+ self._worker = threading.Thread(target=self._worker_loop, daemon=daemon)
32
35
 
33
36
  super().initialize(env)
34
37
 
@@ -41,8 +44,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
41
44
  return self._publish_messages(*events)
42
45
 
43
46
  def _publish_message(self, message: CloudMessage[TEvento]) -> None:
44
- super()._publish_message(message)
45
47
  self.verify()
48
+ super()._publish_message(message)
46
49
  self.queue.put(message)
47
50
  # No llamamos consume() aquí: el worker se encarga.
48
51
 
@@ -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
 
@@ -1,7 +1,8 @@
1
1
  from .repository import (
2
2
  BaseAggregateRepositoryAdapter,
3
+ BaseEntityRepositoryAdapter,
3
4
  BaseRepositoryAdapter,
4
- TAggregate,
5
+ BaseSearchRepositoryAdapter,
5
6
  )
6
7
  from .unit_of_work import BaseUnitOfWork
7
8
 
@@ -9,5 +10,6 @@ __all__ = [
9
10
  "BaseRepositoryAdapter",
10
11
  "BaseAggregateRepositoryAdapter",
11
12
  "BaseUnitOfWork",
12
- "TAggregate",
13
+ "BaseEntityRepositoryAdapter",
14
+ "BaseSearchRepositoryAdapter",
13
15
  ]
@@ -1,12 +1,10 @@
1
1
  # pyright: reportMissingTypeStubs=false, reportUnknownArgumentType=false, reportMissingParameterType=none, reportGeneralTypeIssues=none
2
2
 
3
3
  from typing import (
4
- Any,
5
4
  ClassVar,
6
5
  Dict,
7
6
  Mapping,
8
7
  Type,
9
- TypeVar,
10
8
  get_args,
11
9
  get_origin,
12
10
  )
@@ -16,16 +14,16 @@ from eventsourcing.persistence import Mapper
16
14
  from eventsourcing.utils import Environment
17
15
 
18
16
  from hexagonal.application import Infrastructure
19
- from hexagonal.domain import AggregateRoot, TIdEntity
17
+ from hexagonal.domain import TAggregate, TEntity, TIdEntity, TQuery, TView
20
18
  from hexagonal.ports.drivens import (
21
19
  IAggregateRepository,
22
20
  IBaseRepository,
21
+ IEntityRepository,
22
+ ISearchRepository,
23
23
  IUnitOfWork,
24
24
  TManager,
25
25
  )
26
26
 
27
- TAggregate = TypeVar("TAggregate", bound=AggregateRoot[Any, Any])
28
-
29
27
 
30
28
  class BaseRepositoryAdapter(IBaseRepository[TManager], Infrastructure):
31
29
  ENV: ClassVar[Dict[str, str]] = {}
@@ -83,3 +81,35 @@ class BaseAggregateRepositoryAdapter(
83
81
  @property
84
82
  def aggregate_name(self) -> str:
85
83
  return self._type_of_aggregate.__name__
84
+
85
+
86
+ class BaseEntityRepositoryAdapter(
87
+ BaseRepositoryAdapter[TManager],
88
+ IEntityRepository[TManager, TEntity, TIdEntity],
89
+ ):
90
+ _type_of_entity: Type[TEntity]
91
+
92
+ def __init_subclass__(cls) -> None:
93
+ super().__init_subclass__()
94
+ # Inspect generic base to find the concrete type argument
95
+ for base in getattr(cls, "__orig_bases__", []):
96
+ origin = get_origin(base)
97
+ if origin and issubclass(origin, BaseEntityRepositoryAdapter):
98
+ args = get_args(base)
99
+ if args:
100
+ cls._type_of_entity = args[0]
101
+ cls.NAME = cls._type_of_entity.__name__.upper()
102
+
103
+ def __init__(self, mapper: Mapper[UUID], connection_manager: TManager):
104
+ super().__init__(connection_manager)
105
+ self._mapper = mapper
106
+
107
+ @property
108
+ def entity_name(self) -> str:
109
+ return self._type_of_entity.__name__
110
+
111
+
112
+ class BaseSearchRepositoryAdapter(
113
+ BaseRepositoryAdapter[TManager],
114
+ ISearchRepository[TManager, TQuery, TView],
115
+ ): ...
@@ -12,7 +12,11 @@ from .outbox import (
12
12
  SQLAlchemyOutboxRepository,
13
13
  SQLAlchemyPairInboxOutbox,
14
14
  )
15
- from .repository import SQLAlchemyRepositoryAdapter
15
+ from .repository import (
16
+ SQLAlchemyEntityRepositoryAdapter,
17
+ SQLAlchemyRepositoryAdapter,
18
+ SQLAlchemySearchRepositoryAdapter,
19
+ )
16
20
  from .unit_of_work import SQLAlchemyUnitOfWork
17
21
 
18
22
  __all__ = [
@@ -24,4 +28,6 @@ __all__ = [
24
28
  "SQLAlchemyInboxRepository",
25
29
  "SQLAlchemyInfrastructure",
26
30
  "SQLAlchemyPairInboxOutbox",
31
+ "SQLAlchemyEntityRepositoryAdapter",
32
+ "SQLAlchemySearchRepositoryAdapter",
27
33
  ]
@@ -19,7 +19,7 @@ metadata = MetaData()
19
19
 
20
20
  def _get_table_key(table_name: str, schema: str | None = None) -> str:
21
21
  """Get the key used by SQLAlchemy metadata to store the table."""
22
- if schema:
22
+ if schema and schema != "":
23
23
  return f"{schema}.{table_name}"
24
24
  return table_name
25
25
 
@@ -34,6 +34,7 @@ def create_aggregates_table(table_name: str, schema: str | None = None) -> Table
34
34
  Returns:
35
35
  SQLAlchemy Table object for aggregate snapshots
36
36
  """
37
+ schema = schema if schema and schema != "" else None
37
38
  full_table_name = f"aggregates_{table_name}"
38
39
  table_key = _get_table_key(full_table_name, schema)
39
40
 
@@ -45,7 +46,7 @@ def create_aggregates_table(table_name: str, schema: str | None = None) -> Table
45
46
  full_table_name,
46
47
  metadata,
47
48
  Column("originator_id", String(36), nullable=False),
48
- Column("aggregate_name", Text, nullable=False),
49
+ Column("aggregate_name", String(255), nullable=False),
49
50
  Column("originator_version", Integer, nullable=False),
50
51
  Column("topic", Text, nullable=False),
51
52
  Column("state", LargeBinary, nullable=False),
@@ -66,6 +67,7 @@ def create_events_table(table_name: str, schema: str | None = None) -> Table:
66
67
  Returns:
67
68
  SQLAlchemy Table object for aggregate events history
68
69
  """
70
+ schema = schema if schema and schema != "" else None
69
71
  full_table_name = f"aggregates_{table_name}_events"
70
72
  table_key = _get_table_key(full_table_name, schema)
71
73
 
@@ -77,7 +79,7 @@ def create_events_table(table_name: str, schema: str | None = None) -> Table:
77
79
  full_table_name,
78
80
  metadata,
79
81
  Column("originator_id", String(36), nullable=False),
80
- Column("aggregate_name", Text, nullable=False),
82
+ Column("aggregate_name", String(255), nullable=False),
81
83
  Column("originator_version", Integer, nullable=False),
82
84
  Column("topic", Text, nullable=False),
83
85
  Column("state", LargeBinary, nullable=False),
@@ -97,6 +99,7 @@ def create_outbox_table(table_name: str = "outbox", schema: str | None = None) -
97
99
  Returns:
98
100
  SQLAlchemy Table object for outbox messages
99
101
  """
102
+ schema = schema if schema and schema != "" else None
100
103
  table_key = _get_table_key(table_name, schema)
101
104
 
102
105
  # Return existing table if already defined
@@ -107,7 +110,7 @@ def create_outbox_table(table_name: str = "outbox", schema: str | None = None) -
107
110
  table_name,
108
111
  metadata,
109
112
  Column("message_id", String(36), primary_key=True),
110
- Column("topic", Text, nullable=False),
113
+ Column("topic", String(255), nullable=False),
111
114
  Column("message", LargeBinary, nullable=False),
112
115
  Column("published_at", DateTime(timezone=True), nullable=True),
113
116
  Column("failed_at", DateTime(timezone=True), nullable=True),
@@ -134,6 +137,7 @@ def create_inbox_table(table_name: str = "inbox", schema: str | None = None) ->
134
137
  Returns:
135
138
  SQLAlchemy Table object for inbox messages
136
139
  """
140
+ schema = schema if schema and schema != "" else None
137
141
  table_key = _get_table_key(table_name, schema)
138
142
 
139
143
  # Return existing table if already defined
@@ -144,7 +148,7 @@ def create_inbox_table(table_name: str = "inbox", schema: str | None = None) ->
144
148
  table_name,
145
149
  metadata,
146
150
  Column("message_id", String(36), nullable=False),
147
- Column("handler", Text, nullable=False),
151
+ Column("handler", String(255), nullable=False),
148
152
  Column("received_at", DateTime(timezone=True), nullable=False),
149
153
  Column("processed_at", DateTime(timezone=True), nullable=True),
150
154
  Column("error", Text, nullable=True),
@@ -313,7 +313,7 @@ class SQLAlchemyInboxRepository(
313
313
 
314
314
  def _cursor(self):
315
315
  """Get the current connection for executing statements."""
316
- return self._connection_manager.cursor()
316
+ return self.connection_manager.cursor()
317
317
 
318
318
  def create_tables(self) -> None:
319
319
  """Create the inbox table if it doesn't exist."""
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from typing import Any, ClassVar, Dict, Mapping, Sequence, Tuple, TypeVar, cast
6
+ from abc import abstractmethod
7
+ from typing import Any, ClassVar, Dict, Mapping, Sequence, Tuple, cast
7
8
  from uuid import UUID
8
9
 
9
10
  from eventsourcing.domain import CanMutateAggregate
@@ -11,24 +12,104 @@ from eventsourcing.persistence import StoredEvent
11
12
  from eventsourcing.utils import strtobool
12
13
  from sqlalchemy import Connection, delete, insert, select, update
13
14
 
14
- from hexagonal.adapters.drivens.repository.base import BaseAggregateRepositoryAdapter
15
+ from hexagonal.adapters.drivens.repository.base import (
16
+ BaseAggregateRepositoryAdapter,
17
+ BaseEntityRepositoryAdapter,
18
+ BaseSearchRepositoryAdapter,
19
+ )
15
20
  from hexagonal.domain import (
16
21
  AggregateNotFound,
17
- AggregateRoot,
18
22
  AggregateSnapshot,
19
23
  AggregateVersionMismatch,
20
24
  SnapshotState,
25
+ TAggregate,
26
+ TEntity,
21
27
  TIdEntity,
28
+ TQuery,
29
+ TView,
22
30
  )
23
31
 
24
32
  from .datastore import SQLAlchemyConnectionContextManager
25
33
  from .models import create_aggregates_table, create_events_table
26
34
 
27
- TAggregate = TypeVar("TAggregate", bound=AggregateRoot[Any, Any])
28
-
29
35
  logger = logging.getLogger(__name__)
30
36
 
31
37
 
38
+ class SQLAlchemyEntityRepositoryAdapter(
39
+ BaseEntityRepositoryAdapter[SQLAlchemyConnectionContextManager, TEntity, TIdEntity]
40
+ ):
41
+ """SQLAlchemy repository adapter for entities.
42
+
43
+ This adapter implements the IEntityRepository interface using SQLAlchemy
44
+ as the backing store. It handles the persistence and retrieval of entities.
45
+
46
+ Supports multiple database backends (PostgreSQL, MySQL, SQLite) through
47
+ SQLAlchemy's abstraction layer.
48
+
49
+ Args:
50
+ mapper: Mapper for converting between domain and persistence models
51
+ connection_manager: SQLAlchemy connection context manager
52
+ """
53
+
54
+ def initialize(self, env: Mapping[str, str]) -> None:
55
+ """Initialize the repository from environment variables."""
56
+ super().initialize(env)
57
+ self._schema_name: str | None = self.env.get("SCHEMA_NAME")
58
+
59
+ def _get(self, conn: Connection, id: TIdEntity) -> TEntity | None:
60
+ """Fetch entity from database."""
61
+ # Implement entity retrieval logic using SQLAlchemy
62
+ # This is a placeholder implementation and should be replaced with actual logic
63
+ return None
64
+
65
+ def _insert(self, conn: Connection, entity: TEntity) -> None:
66
+ """Save an entity to the database."""
67
+ # Implement entity persistence logic using SQLAlchemy
68
+ # This is a placeholder implementation and should be replaced with actual logic
69
+ pass
70
+
71
+ def _update(self, conn: Connection, entity: TEntity) -> None:
72
+ """Update an existing entity in the database."""
73
+ # Implement entity update logic using SQLAlchemy
74
+ # This is a placeholder implementation and should be replaced with actual logic
75
+ pass
76
+
77
+ def _delete(self, conn: Connection, id: TIdEntity) -> None:
78
+ """Delete an entity from the database."""
79
+ # Implement entity deletion logic using SQLAlchemy
80
+ # This is a placeholder implementation and should be replaced with actual logic
81
+ pass
82
+
83
+ def get(self, id: TIdEntity) -> TEntity:
84
+ """Get an entity by its ID."""
85
+ self.verify()
86
+ with self.connection_manager.cursor() as conn:
87
+ entity = self._get(conn, id)
88
+ if entity is None:
89
+ raise AggregateNotFound(f"Entity with id {id} not found")
90
+ return entity
91
+
92
+ def save(self, entity: TEntity):
93
+ """Save an entity to the repository."""
94
+ self.verify()
95
+ with self.connection_manager.cursor() as conn:
96
+ existing = self._get(conn, entity.id)
97
+ if existing is None:
98
+ self._insert(conn, entity)
99
+ else:
100
+ self._update(conn, entity)
101
+
102
+ def delete(self, id: TIdEntity) -> TEntity:
103
+ """Delete an entity from the repository."""
104
+ self.verify()
105
+ with self.connection_manager.cursor() as conn:
106
+ entity = self._get(conn, id)
107
+ if entity is None:
108
+ raise AggregateNotFound(f"Entity with id {id} not found")
109
+ self._delete(conn, id)
110
+ return entity
111
+
112
+
32
113
  class SQLAlchemyRepositoryAdapter(
33
114
  BaseAggregateRepositoryAdapter[
34
115
  SQLAlchemyConnectionContextManager, TAggregate, TIdEntity
@@ -322,3 +403,40 @@ class SQLAlchemyRepositoryAdapter(
322
403
  self._save_event_history(conn, events)
323
404
  self._delete(conn, id)
324
405
  return agg
406
+
407
+
408
+ class SQLAlchemySearchRepositoryAdapter(
409
+ BaseSearchRepositoryAdapter[SQLAlchemyConnectionContextManager, TQuery, TView]
410
+ ):
411
+ """SQLAlchemy repository adapter for search queries.
412
+
413
+ This adapter implements the ISearchRepository interface using SQLAlchemy
414
+ as the backing store. It handles executing search queries and returning
415
+ results in a view format.
416
+
417
+ Supports multiple database backends (PostgreSQL, MySQL, SQLite) through
418
+ SQLAlchemy's abstraction layer.
419
+
420
+ Args:
421
+ mapper: Mapper for converting between domain and persistence models
422
+ connection_manager: SQLAlchemy connection context manager
423
+ """
424
+
425
+ @abstractmethod
426
+ def _search(self, conn: Connection, query: TQuery) -> Sequence[TView]: ...
427
+
428
+ def search(self, query: TQuery) -> Sequence[TView]:
429
+ """Execute a search query against the repository.
430
+
431
+ Args:
432
+ query: The search query to execute
433
+
434
+ Returns:
435
+ A sequence of view objects matching the query criteria
436
+
437
+ Raises:
438
+ RuntimeError: If not attached to a unit of work
439
+ """
440
+ self.verify()
441
+ with self.connection_manager.cursor() as conn:
442
+ return self._search(conn, query)
@@ -1,15 +1,31 @@
1
- from .api import BaseAPI, GetEvent
1
+ from .api import BaseAPI, GetEvent, TBaseApp
2
2
  from .app import Application
3
3
  from .bus_app import BusAppGroup, ComposableBusApp
4
- from .handlers import CommandHandler, EventHandler, MessageHandler, QueryHandler
4
+ from .handlers import (
5
+ CommandHandler,
6
+ CommandHandlerBase,
7
+ EventHandler,
8
+ EventHandlerBase,
9
+ MessageHandler,
10
+ QueryHandler,
11
+ )
5
12
  from .infrastructure import (
6
13
  ComposableInfrastructure,
7
14
  Infrastructure,
8
15
  InfrastructureGroup,
9
16
  )
10
- from .query import AggregateView, GetById, GetByIdHandler, SearchAggregateRepository
17
+ from .query import (
18
+ AggregateView,
19
+ GetAggregateByIdHandler,
20
+ GetById,
21
+ GetByIdHandler,
22
+ GetEntityByIdHandler,
23
+ SearchAggregateRepository,
24
+ )
25
+ from .topics import RegisterTopics
11
26
 
12
27
  __all__ = [
28
+ "TBaseApp",
13
29
  "BaseAPI",
14
30
  "GetEvent",
15
31
  "Application",
@@ -26,4 +42,9 @@ __all__ = [
26
42
  "SearchAggregateRepository",
27
43
  "AggregateView",
28
44
  "GetByIdHandler",
45
+ "RegisterTopics",
46
+ "GetAggregateByIdHandler",
47
+ "GetEntityByIdHandler",
48
+ "CommandHandlerBase",
49
+ "EventHandlerBase",
29
50
  ]