python-neva 3.2.0__tar.gz → 3.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 (146) hide show
  1. python_neva-3.3.0/.envrc +2 -0
  2. {python_neva-3.2.0 → python_neva-3.3.0}/CHANGELOG.md +35 -0
  3. {python_neva-3.2.0 → python_neva-3.3.0}/PKG-INFO +6 -1
  4. python_neva-3.3.0/README.md +3 -0
  5. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/__init__.py +2 -1
  6. python_neva-3.3.0/neva/events/contracts/__init__.py +7 -0
  7. python_neva-3.3.0/neva/events/contracts/dispatcher.py +24 -0
  8. python_neva-3.3.0/neva/events/contracts/event.py +7 -0
  9. python_neva-3.3.0/neva/events/contracts/handler.py +14 -0
  10. python_neva-3.3.0/neva/events/contracts/listener.py +19 -0
  11. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/dispatcher.py +55 -23
  12. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/event.py +2 -1
  13. python_neva-3.3.0/neva/events/event_registry.py +59 -0
  14. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/listener.py +10 -33
  15. python_neva-3.3.0/neva/events/policy.py +8 -0
  16. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/provider.py +3 -2
  17. {python_neva-3.2.0 → python_neva-3.3.0}/pyproject.toml +1 -5
  18. {python_neva-3.2.0 → python_neva-3.3.0}/scripts/retag-with-changelog.sh +1 -1
  19. python_neva-3.3.0/tests/events/test_binding.py +11 -0
  20. python_neva-3.3.0/tests/events/test_listen_on_parent_class.py +77 -0
  21. {python_neva-3.2.0 → python_neva-3.3.0}/tests/obs/test_correlation.py +4 -0
  22. {python_neva-3.2.0 → python_neva-3.3.0}/tests/obs/test_profiler.py +4 -0
  23. python_neva-3.3.0/uv.lock +2436 -0
  24. python_neva-3.2.0/README.md +0 -0
  25. python_neva-3.2.0/neva/events/event_registry.py +0 -44
  26. python_neva-3.2.0/tests/events/test_event_registry.py +0 -101
  27. {python_neva-3.2.0 → python_neva-3.3.0}/.gitignore +0 -0
  28. {python_neva-3.2.0 → python_neva-3.3.0}/.pre-commit-config.yaml +0 -0
  29. {python_neva-3.2.0 → python_neva-3.3.0}/.python-version +0 -0
  30. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/__init__.py +0 -0
  31. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/application.py +0 -0
  32. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/config.py +0 -0
  33. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/facade.py +0 -0
  34. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/integrations/__init__.py +0 -0
  35. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/integrations/faststream.py +0 -0
  36. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/markers.py +0 -0
  37. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/py.typed +0 -0
  38. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/scopes.py +0 -0
  39. {python_neva-3.2.0 → python_neva-3.3.0}/neva/arch/service_provider.py +0 -0
  40. {python_neva-3.2.0 → python_neva-3.3.0}/neva/config/__init__.py +0 -0
  41. {python_neva-3.2.0 → python_neva-3.3.0}/neva/config/base_providers.py +0 -0
  42. {python_neva-3.2.0 → python_neva-3.3.0}/neva/config/loader.py +0 -0
  43. {python_neva-3.2.0 → python_neva-3.3.0}/neva/config/py.typed +0 -0
  44. {python_neva-3.2.0 → python_neva-3.3.0}/neva/config/repository.py +0 -0
  45. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/__init__.py +0 -0
  46. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/config.py +0 -0
  47. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/connection.py +0 -0
  48. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/manager.py +0 -0
  49. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/provider.py +0 -0
  50. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/py.typed +0 -0
  51. {python_neva-3.2.0 → python_neva-3.3.0}/neva/database/transaction.py +0 -0
  52. {python_neva-3.2.0 → python_neva-3.3.0}/neva/events/py.typed +0 -0
  53. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/__init__.py +0 -0
  54. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/instrumentation/__init__.py +0 -0
  55. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
  56. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/logging/__init__.py +0 -0
  57. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/logging/manager.py +0 -0
  58. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/logging/provider.py +0 -0
  59. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/middleware/__init__.py +0 -0
  60. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/middleware/correlation.py +0 -0
  61. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/middleware/profiler.py +0 -0
  62. {python_neva-3.2.0 → python_neva-3.3.0}/neva/obs/py.typed +0 -0
  63. {python_neva-3.2.0 → python_neva-3.3.0}/neva/polyfactory/__init__.py +0 -0
  64. {python_neva-3.2.0 → python_neva-3.3.0}/neva/polyfactory/factories.py +0 -0
  65. {python_neva-3.2.0 → python_neva-3.3.0}/neva/polyfactory/persistence.py +0 -0
  66. {python_neva-3.2.0 → python_neva-3.3.0}/neva/polyfactory/py.typed +0 -0
  67. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/__init__.py +0 -0
  68. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/encryption/__init__.py +0 -0
  69. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/encryption/encrypter.py +0 -0
  70. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/encryption/protocol.py +0 -0
  71. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/__init__.py +0 -0
  72. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/config.py +0 -0
  73. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/hash_manager.py +0 -0
  74. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/hashers/__init__.py +0 -0
  75. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/hashers/argon2.py +0 -0
  76. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  77. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/hashing/hashers/protocol.py +0 -0
  78. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/provider.py +0 -0
  79. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/py.typed +0 -0
  80. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/tokens/__init__.py +0 -0
  81. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/tokens/generate_token.py +0 -0
  82. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/tokens/hash_token.py +0 -0
  83. {python_neva-3.2.0 → python_neva-3.3.0}/neva/security/tokens/verify_token.py +0 -0
  84. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/__init__.py +0 -0
  85. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/accessors.py +0 -0
  86. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/__init__.py +0 -0
  87. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/app.py +0 -0
  88. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/app.pyi +0 -0
  89. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/config.py +0 -0
  90. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/config.pyi +0 -0
  91. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/crypt.py +0 -0
  92. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/crypt.pyi +0 -0
  93. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/db.py +0 -0
  94. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/db.pyi +0 -0
  95. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/event.py +0 -0
  96. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/event.pyi +0 -0
  97. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/hash.py +0 -0
  98. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/hash.pyi +0 -0
  99. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/log.py +0 -0
  100. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/facade/log.pyi +0 -0
  101. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/py.typed +0 -0
  102. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/results.py +0 -0
  103. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/strategy.py +0 -0
  104. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/strconv.py +0 -0
  105. {python_neva-3.2.0 → python_neva-3.3.0}/neva/support/time.py +0 -0
  106. {python_neva-3.2.0 → python_neva-3.3.0}/neva/testing/__init__.py +0 -0
  107. {python_neva-3.2.0 → python_neva-3.3.0}/neva/testing/fakes.py +0 -0
  108. {python_neva-3.2.0 → python_neva-3.3.0}/neva/testing/fixtures.py +0 -0
  109. {python_neva-3.2.0 → python_neva-3.3.0}/neva/testing/py.typed +0 -0
  110. {python_neva-3.2.0 → python_neva-3.3.0}/neva/testing/test_case.py +0 -0
  111. {python_neva-3.2.0 → python_neva-3.3.0}/ruff.toml +0 -0
  112. {python_neva-3.2.0 → python_neva-3.3.0}/tests/__init__.py +0 -0
  113. {python_neva-3.2.0 → python_neva-3.3.0}/tests/arch/__init__.py +0 -0
  114. {python_neva-3.2.0 → python_neva-3.3.0}/tests/arch/test_cache.py +0 -0
  115. {python_neva-3.2.0 → python_neva-3.3.0}/tests/arch/test_extends.py +0 -0
  116. {python_neva-3.2.0 → python_neva-3.3.0}/tests/arch/test_scope.py +0 -0
  117. {python_neva-3.2.0 → python_neva-3.3.0}/tests/config/__init__.py +0 -0
  118. {python_neva-3.2.0 → python_neva-3.3.0}/tests/config/test_loader.py +0 -0
  119. {python_neva-3.2.0 → python_neva-3.3.0}/tests/config/test_repository.py +0 -0
  120. {python_neva-3.2.0 → python_neva-3.3.0}/tests/conftest.py +0 -0
  121. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/__init__.py +0 -0
  122. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_connection_manager.py +0 -0
  123. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_database_manager.py +0 -0
  124. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_edge_cases.py +0 -0
  125. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_multi_connection.py +0 -0
  126. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  127. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_transaction.py +0 -0
  128. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_transaction_context.py +0 -0
  129. {python_neva-3.2.0 → python_neva-3.3.0}/tests/database/test_transaction_registry.py +0 -0
  130. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/__init__.py +0 -0
  131. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/conftest.py +0 -0
  132. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/test_deferred.py +0 -0
  133. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/test_dispatch.py +0 -0
  134. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/test_event.py +0 -0
  135. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/test_function_listener.py +0 -0
  136. {python_neva-3.2.0 → python_neva-3.3.0}/tests/events/test_immediate.py +0 -0
  137. {python_neva-3.2.0 → python_neva-3.3.0}/tests/obs/__init__.py +0 -0
  138. {python_neva-3.2.0 → python_neva-3.3.0}/tests/security/__init__.py +0 -0
  139. {python_neva-3.2.0 → python_neva-3.3.0}/tests/security/test_encrypter.py +0 -0
  140. {python_neva-3.2.0 → python_neva-3.3.0}/tests/security/test_hash_manager.py +0 -0
  141. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/__init__.py +0 -0
  142. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/test_event_fake.py +0 -0
  143. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/test_facade_restore.py +0 -0
  144. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/test_fixtures.py +0 -0
  145. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/test_refresh_database.py +0 -0
  146. {python_neva-3.2.0 → python_neva-3.3.0}/tests/testing/test_test_case.py +0 -0
@@ -0,0 +1,2 @@
1
+ export VIRTUAL_ENV="$PWD/.venv"
2
+ layout python
@@ -1,3 +1,38 @@
1
+ ## 3.3.0 (2026-06-05)
2
+
3
+ ### ✨ Features
4
+
5
+ - **tooling**: placeholder commit
6
+
7
+ ### 💚👷 CI & Build
8
+
9
+ - **tooling**: default to base tag version for commitizen
10
+
11
+ ### 📝💡 Documentation
12
+
13
+ - **project**: update doc
14
+ - **core**: placeholde readme
15
+
16
+ ### 🔧🔨📦️ Configuration, Scripts, Packages
17
+
18
+ - **tooling**: update changelog script
19
+ - **project**: update versioning config
20
+
21
+ ## 3.2.0 (2026-06-05)
22
+
23
+ ### ✨ Features
24
+
25
+ - **tooling**: placeholder commit
26
+
27
+ ### 🔧🔨📦️ Configuration, Scripts, Packages
28
+
29
+ - **tooling**: update changelog script
30
+ - **project**: update versioning config
31
+
32
+ ### 📝💡 Documentation
33
+
34
+ - **core**: placeholde readme
35
+
1
36
  ## 3.1.1 (2026-05-11)
2
37
 
3
38
  ### 📌➕⬇️➖⬆️ Dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -20,3 +20,8 @@ Requires-Dist: neva-fastapi>=1.0.0; extra == 'fastapi'
20
20
  Provides-Extra: testing
21
21
  Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
22
22
  Requires-Dist: pytest>=9.0.2; extra == 'testing'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # The Neva framework
26
+
27
+ PLACEHOLDER
@@ -0,0 +1,3 @@
1
+ # The Neva framework
2
+
3
+ PLACEHOLDER
@@ -9,7 +9,8 @@ Three components are of particular interest:
9
9
 
10
10
  from neva.events.dispatcher import EventDispatcher
11
11
  from neva.events.event import Event
12
- from neva.events.listener import EventListener, HandlingPolicy, listener
12
+ from neva.events.listener import EventListener, listener
13
+ from neva.events.policy import HandlingPolicy
13
14
  from neva.events.provider import EventServiceProvider
14
15
 
15
16
 
@@ -0,0 +1,7 @@
1
+ from neva.events.contracts.dispatcher import EventDispatcher
2
+ from neva.events.contracts.event import Event
3
+ from neva.events.contracts.handler import EventHandler
4
+ from neva.events.contracts.listener import EventListener
5
+
6
+
7
+ __all__ = ["Event", "EventDispatcher", "EventHandler", "EventListener"]
@@ -0,0 +1,24 @@
1
+ from typing import Protocol
2
+
3
+ from neva.events.contracts.event import Event
4
+ from neva.events.contracts.listener import EventListener
5
+ from neva.support import Result
6
+
7
+
8
+ class EventDispatcher(Protocol):
9
+ """Event dispatcher protocol."""
10
+
11
+ async def dispatch(
12
+ self,
13
+ event: Event,
14
+ ) -> list[Result[None, str]]:
15
+ """Dispatch an event to all registered listeners."""
16
+ ...
17
+
18
+ def listen[T: Event](
19
+ self,
20
+ event_cls: type[T],
21
+ listener_cls: type[EventListener[T]],
22
+ ) -> None:
23
+ """Register a listener for an event."""
24
+ ...
@@ -0,0 +1,7 @@
1
+ import pydantic
2
+
3
+
4
+ class Event(pydantic.BaseModel):
5
+ """Base Event class."""
6
+
7
+ pass
@@ -0,0 +1,14 @@
1
+ from typing import Protocol
2
+
3
+ from neva.events.contracts.event import Event
4
+ from neva.support import Result
5
+
6
+
7
+ class EventHandler[T: Event](Protocol):
8
+ """Define a valid function for event handling."""
9
+
10
+ __name__: str
11
+
12
+ async def __call__(self, event: T) -> Result[None, str]:
13
+ """Handle an event."""
14
+ ...
@@ -0,0 +1,19 @@
1
+ from typing import Protocol
2
+
3
+ from neva.events.contracts.event import Event
4
+ from neva.events.policy import HandlingPolicy
5
+ from neva.support import Result
6
+
7
+
8
+ class EventListener[T: Event](Protocol):
9
+ """Event listener protocol."""
10
+
11
+ policy: HandlingPolicy
12
+
13
+ async def handle(self, event: T) -> Result[None, str]:
14
+ """Handle an event.
15
+
16
+ Returns:
17
+ A result indicating whether the event was handled successfully.
18
+ """
19
+ ...
@@ -1,15 +1,29 @@
1
1
  """Base implementation of the event dispatcher."""
2
2
 
3
+ from typing import Callable, Protocol, override, runtime_checkable
4
+
3
5
  from neva.arch.application import Application
4
6
  from neva.database.manager import DatabaseManager
5
7
  from neva.database.transaction import TransactionCallback
6
- from neva.events.event import Event
8
+ from neva.events import contracts
7
9
  from neva.events.event_registry import EventRegistry
8
- from neva.events.listener import EventListener
9
10
  from neva.support import Err, Nothing, Result, Some
10
11
 
11
12
 
12
- class EventDispatcher:
13
+ @runtime_checkable
14
+ class AsyncBeforeDispatchHook(Protocol):
15
+ async def __call__(self, context: contracts.Event) -> None: ...
16
+
17
+
18
+ @runtime_checkable
19
+ class SyncBeforeDispatchHook(Protocol):
20
+ def __call__(self, context: contracts.Event) -> None: ...
21
+
22
+
23
+ BeforeDispatchHook = AsyncBeforeDispatchHook | SyncBeforeDispatchHook
24
+
25
+
26
+ class EventDispatcher(contracts.EventDispatcher):
13
27
  """Event dispatcher implementation."""
14
28
 
15
29
  def __init__(
@@ -18,11 +32,12 @@ class EventDispatcher:
18
32
  db: DatabaseManager,
19
33
  registry: EventRegistry,
20
34
  ) -> None:
21
- self._registry = registry
22
- self._app = app
23
- self._db = db
35
+ self._registry: EventRegistry = registry
36
+ self._app: Application = app
37
+ self._db: DatabaseManager = db
38
+ self._before_dispatch_hooks: list[BeforeDispatchHook] = []
24
39
 
25
- async def _before_dispatch(self, event: Event) -> None:
40
+ async def _apply_before_dispatch(self, event: contracts.Event) -> None:
26
41
  """Extension hook called before listeners are invoked.
27
42
 
28
43
  Override in a subclass to add cross-cutting behaviour such as
@@ -32,8 +47,19 @@ class EventDispatcher:
32
47
  Args:
33
48
  event: The event about to be dispatched.
34
49
  """
35
-
36
- async def dispatch(self, event: Event) -> list[Result[None, str]]:
50
+ for hook in self._before_dispatch_hooks:
51
+ match hook:
52
+ case AsyncBeforeDispatchHook():
53
+ await hook(event)
54
+ case SyncBeforeDispatchHook():
55
+ hook(event)
56
+
57
+ async def before_dispatch(self, hook: BeforeDispatchHook) -> None:
58
+ """Register a hook to be called before listeners are invoked."""
59
+ self._before_dispatch_hooks.append(hook)
60
+
61
+ @override
62
+ async def dispatch(self, event: contracts.Event) -> list[Result[None, str]]:
37
63
  """Dispatch an event to all registered listeners.
38
64
 
39
65
  Listeners are resolved from the DI container when available,
@@ -45,20 +71,23 @@ class EventDispatcher:
45
71
  Returns:
46
72
  A list of results, one per listener invocation.
47
73
  """
48
- await self._before_dispatch(event)
74
+ await self._apply_before_dispatch(event)
49
75
  results: list[Result[None, str]] = []
50
- listeners = self._registry.get_listeners(event)
76
+ listeners = self._registry.resolve_listeners(event)
51
77
 
52
- to_execute = listeners.immediate.copy()
78
+ immediate: list[type[contracts.EventListener[contracts.Event]]] = []
79
+ deferred: list[type[contracts.EventListener[contracts.Event]]] = []
80
+ [immediate.extend(listener.immediate.copy()) for listener in listeners]
81
+ [deferred.extend(listener.deferred.copy()) for listener in listeners]
53
82
 
54
83
  match self._db.current():
55
84
  case Some(tx):
56
- for listener_cls in listeners.deferred:
85
+ for listener_cls in deferred:
57
86
  _ = tx.on_commit(self._build_callback(event, listener_cls))
58
87
  case Nothing():
59
- to_execute += listeners.deferred
88
+ immediate.extend(deferred)
60
89
 
61
- for listener_cls in to_execute:
90
+ for listener_cls in immediate:
62
91
  listener = self._resolve_listener(listener_cls)
63
92
  try:
64
93
  results.append(await listener.handle(event))
@@ -67,17 +96,20 @@ class EventDispatcher:
67
96
 
68
97
  return results
69
98
 
70
- def listen[T: Event](
99
+ @override
100
+ def listen[T: contracts.Event](
71
101
  self,
72
- event_cls: type[T],
73
- listener_cls: type[EventListener[T]],
102
+ event_cls: type[T] | list[type[T]],
103
+ listener_cls: type[contracts.EventListener[T]],
74
104
  ) -> None:
75
105
  """Register a listener for an event."""
106
+ if not isinstance(event_cls, list):
107
+ event_cls = [event_cls]
76
108
  self._registry.register(event_cls, listener_cls)
77
109
 
78
- def _resolve_listener[T: Event](
79
- self, listener_cls: type[EventListener[T]]
80
- ) -> EventListener[T]:
110
+ def _resolve_listener[T: contracts.Event](
111
+ self, listener_cls: type[contracts.EventListener[T]]
112
+ ) -> contracts.EventListener[T]:
81
113
  """Resolve a listener from the container.
82
114
 
83
115
  Listeners are resolved from the DI container when available,
@@ -91,10 +123,10 @@ class EventDispatcher:
91
123
  return result.unwrap()
92
124
  return listener_cls()
93
125
 
94
- def _build_callback[T: Event](
126
+ def _build_callback[T: contracts.Event](
95
127
  self,
96
128
  event: T,
97
- listener_cls: type[EventListener[T]],
129
+ listener_cls: type[contracts.EventListener[T]],
98
130
  ) -> TransactionCallback:
99
131
  async def callback() -> Result[None, str]:
100
132
  listener = self._resolve_listener(listener_cls)
@@ -4,10 +4,11 @@ import datetime as dt
4
4
 
5
5
  import pydantic
6
6
 
7
+ from neva.events import contracts
7
8
  from neva.support import time
8
9
 
9
10
 
10
- class Event[T](pydantic.BaseModel):
11
+ class Event[T](contracts.Event):
11
12
  """Base event class."""
12
13
 
13
14
  event_id: T
@@ -0,0 +1,59 @@
1
+ """Event/listener registry."""
2
+
3
+ import inspect
4
+ from typing import Any, TypeVar
5
+
6
+ from neva.events import contracts, policy
7
+
8
+
9
+ class EventListenerRegistry[T: contracts.Event]:
10
+ """Event listener registry for a given event."""
11
+
12
+ def __init__(self) -> None:
13
+ self.immediate: list[type[contracts.EventListener[T]]] = []
14
+ self.deferred: list[type[contracts.EventListener[T]]] = []
15
+
16
+ def add_listener(self, listener_cls: type[contracts.EventListener[T]]) -> None:
17
+ """Add a listener to the correct category in the registry."""
18
+ match listener_cls.policy:
19
+ case policy.HandlingPolicy.IMMEDIATE:
20
+ self.immediate.append(listener_cls)
21
+ case policy.HandlingPolicy.DEFERRED:
22
+ self.deferred.append(listener_cls)
23
+
24
+
25
+ T = TypeVar("T", bound=contracts.Event, covariant=True)
26
+
27
+
28
+ class EventRegistry:
29
+ """Event/listener registry."""
30
+
31
+ __listeners: dict[type[Any], EventListenerRegistry[Any]]
32
+
33
+ def __init__(self) -> None:
34
+ """Initialize the registry."""
35
+ self.__listeners = {}
36
+
37
+ def register(
38
+ self,
39
+ event_cls: list[type[T]],
40
+ listener_cls: type[contracts.EventListener[T]],
41
+ ) -> None:
42
+ """Register a listener for an event."""
43
+ for cls in event_cls:
44
+ self.__listeners.setdefault(cls, EventListenerRegistry()).add_listener(
45
+ listener_cls
46
+ )
47
+
48
+ def resolve_listeners(self, event: T) -> list[EventListenerRegistry[T]]:
49
+ """Return all listeners registries for an event type."""
50
+ registries = [
51
+ self.__listeners[cls]
52
+ for cls in inspect.getmro(type(event))
53
+ if cls in self.__listeners
54
+ ]
55
+ return registries
56
+
57
+ def get_listeners(self, event: T) -> EventListenerRegistry[T]:
58
+ """Return all listeners registered for an event type."""
59
+ return self.__listeners.get(type(event), EventListenerRegistry[T]())
@@ -1,49 +1,26 @@
1
1
  """Event listener protocol."""
2
2
 
3
- import abc
4
- from enum import StrEnum
5
3
  from types import new_class
6
- from typing import Callable, Protocol
4
+ from typing import Callable, override
7
5
 
8
- from neva.events.event import Event
6
+ from neva.events import contracts
7
+ from neva.events.policy import HandlingPolicy
9
8
  from neva.support import Result
10
9
  from neva.support.strconv import snake2pascal
11
10
 
12
11
 
13
- class HandlingPolicy(StrEnum):
14
- """Event dispatch policy."""
15
-
16
- IMMEDIATE = "immediate"
17
- DEFERRED = "deferred"
18
-
19
-
20
- class EventListener[T: Event](abc.ABC):
21
- """Event listener protocol."""
12
+ class EventListener[T: contracts.Event](contracts.EventListener[T]):
13
+ """Base event listener class."""
22
14
 
23
15
  policy: HandlingPolicy = HandlingPolicy.IMMEDIATE
24
16
 
25
- @abc.abstractmethod
26
- async def handle(self, event: T) -> Result[None, str]:
27
- """Handle an event.
28
-
29
- Returns:
30
- A result indicating whether the event was handled successfully.
31
- """
32
-
33
-
34
- class EventHandler[T: Event](Protocol):
35
- """Define a valid function for event handling."""
36
-
37
- __name__: str
38
-
39
- async def __call__(self, event: T) -> Result[None, str]:
40
- """Handle an event."""
41
- ...
17
+ @override
18
+ async def handle(self, event: contracts.Event) -> Result[None, str]: ...
42
19
 
43
20
 
44
- def listener[T: Event](
21
+ def listener[T: contracts.Event](
45
22
  policy: HandlingPolicy = HandlingPolicy.IMMEDIATE,
46
- ) -> Callable[[EventHandler[T]], type[EventListener[T]]]:
23
+ ) -> Callable[[contracts.EventHandler[T]], type[EventListener[T]]]:
47
24
  """Create a listener from a function.
48
25
 
49
26
  Returns:
@@ -51,7 +28,7 @@ def listener[T: Event](
51
28
  """
52
29
 
53
30
  def decorator(
54
- func: EventHandler[T],
31
+ func: contracts.EventHandler[T],
55
32
  ) -> type[EventListener[T]]:
56
33
  async def handle(_: EventListener[T], event: T) -> Result[None, str]:
57
34
  return await func(event)
@@ -0,0 +1,8 @@
1
+ import enum
2
+
3
+
4
+ class HandlingPolicy(enum.StrEnum):
5
+ """Determines when a listener is invoked."""
6
+
7
+ IMMEDIATE = "immediate"
8
+ DEFERRED = "deferred"
@@ -3,6 +3,8 @@
3
3
  from typing import Self, override
4
4
 
5
5
  from neva.arch.service_provider import ServiceProvider
6
+ from neva.events import contracts
7
+ from neva.events.dispatcher import EventDispatcher
6
8
  from neva.events.event_registry import EventRegistry
7
9
  from neva.support import Ok, Result
8
10
 
@@ -22,8 +24,7 @@ class EventServiceProvider(ServiceProvider):
22
24
  Returns:
23
25
  Result[Self, str]: The result of the registration.
24
26
  """
25
- from neva.events.dispatcher import EventDispatcher
26
-
27
27
  self.bind(EventRegistry)
28
28
  self.bind(EventDispatcher)
29
+ self.bind(EventDispatcher, interface=contracts.EventDispatcher)
29
30
  return Ok(self)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-neva"
3
- version = "3.2.0"
3
+ version = "3.3.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -49,14 +49,10 @@ packages = ["neva"]
49
49
  [tool.commitizen]
50
50
  name = "cz_gitmoji"
51
51
  version_provider = "uv"
52
- tag_format = "python-neva-v$version"
53
52
  version_scheme = "pep440"
54
53
  update_changelog_on_bump = true
55
54
  annotated_tag = true
56
55
  post_bump_hooks = ["scripts/retag-with-changelog.sh"]
57
- # Only commits scoped to `core` (alone or in a comma-separated scope list) feed
58
- # this package's changelog. See CONTRIBUTING.md.
59
- changelog_pattern = '''^(?::[\w_]+:\s+)?\w+\([^)]*\bcore\b[^)]*\)!?:\s'''
60
56
 
61
57
  [tool.basedpyright]
62
58
  enableExperimentalFeatures = true
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env sh
2
2
  set -e
3
3
 
4
- TAG="python-neva-v${CZ_POST_CURRENT_VERSION}"
4
+ TAG="${CZ_POST_CURRENT_VERSION}"
5
5
 
6
6
  # Generate the changelog section for this version only
7
7
  CHANGELOG=$(cz changelog "${CZ_POST_CURRENT_VERSION}" --dry-run |
@@ -0,0 +1,11 @@
1
+ from neva.events import contracts
2
+ from neva.events.dispatcher import EventDispatcher
3
+ from neva.testing.test_case import TestCase
4
+
5
+
6
+ class TestEventBinding(TestCase):
7
+ async def test_binding(self) -> None:
8
+ dispatcher_from_interface = self.app.make(contracts.EventDispatcher)
9
+ assert dispatcher_from_interface.is_ok
10
+ dispatcher_from_implementation = self.app.make(EventDispatcher)
11
+ assert dispatcher_from_implementation.is_ok
@@ -0,0 +1,77 @@
1
+ from random import randint
2
+ from typing import override
3
+
4
+ import pydantic
5
+
6
+ from neva.events import Event, EventDispatcher, EventListener, contracts
7
+ from neva.support import Ok, Result
8
+
9
+
10
+ class OrderEvent(Event[int]):
11
+ id: int = pydantic.Field(default=randint(0, 200)) # noqa: S311
12
+ order_id: int
13
+
14
+
15
+ class RecordableEvent(contracts.Event):
16
+ pass
17
+
18
+
19
+ class OrderDeleted(OrderEvent, RecordableEvent):
20
+ pass
21
+
22
+
23
+ class OrderDeleting(OrderEvent):
24
+ pass
25
+
26
+
27
+ class TestListenOnParentEvent:
28
+ async def test_dispatching_event_invokes_listener_on_parent(
29
+ self, dispatcher: EventDispatcher
30
+ ) -> None:
31
+ called = False
32
+
33
+ class OrderEventListener(EventListener[OrderEvent]):
34
+ @override
35
+ async def handle(self, event: OrderEvent) -> Result[None, str]:
36
+ nonlocal called
37
+ called = True
38
+ return Ok(None)
39
+
40
+ dispatcher.listen(OrderEvent, OrderEventListener)
41
+ _ = await dispatcher.dispatch(OrderDeleted(event_id=1, order_id=1))
42
+
43
+ assert called
44
+
45
+ async def test_dispatching_event_invokes_listener_on_all_parents(
46
+ self, dispatcher: EventDispatcher
47
+ ) -> None:
48
+ called = False
49
+
50
+ class RecordableEventListener(EventListener[RecordableEvent]):
51
+ @override
52
+ async def handle(self, event: RecordableEvent) -> Result[None, str]:
53
+ nonlocal called
54
+ called = True
55
+ return Ok(None)
56
+
57
+ dispatcher.listen(RecordableEvent, RecordableEventListener)
58
+ _ = await dispatcher.dispatch(OrderDeleted(event_id=1, order_id=1))
59
+
60
+ assert called
61
+
62
+ async def test_listening_on_multiple_event_types(
63
+ self, dispatcher: EventDispatcher
64
+ ) -> None:
65
+ called = False
66
+
67
+ class OrderEventListener(EventListener[OrderEvent]):
68
+ @override
69
+ async def handle(self, event: OrderEvent) -> Result[None, str]:
70
+ nonlocal called
71
+ called = True
72
+ return Ok(None)
73
+
74
+ dispatcher.listen([OrderDeleted, OrderDeleting], OrderEventListener)
75
+ _ = await dispatcher.dispatch(OrderDeleted(event_id=1, order_id=1))
76
+
77
+ assert called
@@ -2,6 +2,7 @@
2
2
 
3
3
  import uuid
4
4
 
5
+ import pytest
5
6
  from starlette.applications import Starlette
6
7
  from starlette.requests import Request
7
8
  from starlette.responses import JSONResponse
@@ -12,6 +13,9 @@ from starlette.types import Receive, Scope, Send
12
13
  from neva.obs.middleware.correlation import CorrelationMiddleware, is_valid_uuid
13
14
 
14
15
 
16
+ pytestmark = pytest.mark.skip
17
+
18
+
15
19
  def _make_app(
16
20
  middleware: CorrelationMiddleware | None = None,
17
21
  ) -> TestClient:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ import pytest
5
6
  from starlette.applications import Starlette
6
7
  from starlette.requests import Request
7
8
  from starlette.responses import PlainTextResponse
@@ -12,6 +13,9 @@ from starlette.types import Receive, Scope, Send
12
13
  from neva.obs.middleware.profiler import ProfilerMiddleware
13
14
 
14
15
 
16
+ pytestmark = pytest.mark.skip
17
+
18
+
15
19
  def _make_app(tmp_path: Path, **kwargs: object) -> TestClient:
16
20
  async def index(request: Request) -> PlainTextResponse:
17
21
  return PlainTextResponse("ok")