mersal-polling 0.1.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.
@@ -0,0 +1,196 @@
1
+
2
+ # Created by https://www.toptal.com/developers/gitignore/api/python,emacs
3
+ # Edit at https://www.toptal.com/developers/gitignore?templates=python,emacs
4
+
5
+ *.DS_Store
6
+ ### Emacs ###
7
+ # -*- mode: gitignore; -*-
8
+ *~
9
+ \#*\#
10
+ /.emacs.desktop
11
+ /.emacs.desktop.lock
12
+ *.elc
13
+ auto-save-list
14
+ tramp
15
+ .\#*
16
+
17
+ # Org-mode
18
+ .org-id-locations
19
+ *_archive
20
+
21
+ # flymake-mode
22
+ *_flymake.*
23
+
24
+ # eshell files
25
+ /eshell/history
26
+ /eshell/lastdir
27
+
28
+ # elpa packages
29
+ /elpa/
30
+
31
+ # reftex files
32
+ *.rel
33
+
34
+ # AUCTeX auto folder
35
+ /auto/
36
+
37
+ # cask packages
38
+ .cask/
39
+ dist/
40
+
41
+ # Flycheck
42
+ flycheck_*.el
43
+
44
+ # server auth directory
45
+ /server/
46
+
47
+ # projectiles files
48
+ .projectile
49
+
50
+ # directory configuration
51
+ .dir-locals.el
52
+
53
+ # network security
54
+ /network-security.data
55
+
56
+
57
+ ### Python ###
58
+ # Byte-compiled / optimized / DLL files
59
+ __pycache__/
60
+ *.py[cod]
61
+ *$py.class
62
+
63
+ # C extensions
64
+ *.so
65
+
66
+ # Distribution / packaging
67
+ .Python
68
+ build/
69
+ develop-eggs/
70
+ downloads/
71
+ eggs/
72
+ .eggs/
73
+ lib/
74
+ lib64/
75
+ parts/
76
+ sdist/
77
+ var/
78
+ wheels/
79
+ share/python-wheels/
80
+ *.egg-info/
81
+ .installed.cfg
82
+ *.egg
83
+ MANIFEST
84
+
85
+ # PyInstaller
86
+ # Usually these files are written by a python script from a template
87
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
88
+ *.manifest
89
+ *.spec
90
+
91
+ # Installer logs
92
+ pip-log.txt
93
+ pip-delete-this-directory.txt
94
+
95
+ # Unit test / coverage reports
96
+ htmlcov/
97
+ .tox/
98
+ .nox/
99
+ .coverage
100
+ .coverage.*
101
+ .cache
102
+ nosetests.xml
103
+ coverage.xml
104
+ *.cover
105
+ *.py,cover
106
+ .hypothesis/
107
+ .pytest_cache/
108
+ cover/
109
+
110
+ # Translations
111
+ *.mo
112
+ *.pot
113
+
114
+ # Django stuff:
115
+ *.log
116
+ local_settings.py
117
+ db.sqlite3
118
+ db.sqlite3-journal
119
+
120
+ # Flask stuff:
121
+ instance/
122
+ .webassets-cache
123
+
124
+ # Scrapy stuff:
125
+ .scrapy
126
+
127
+ # Sphinx documentation
128
+ docs/_build/
129
+
130
+ # PyBuilder
131
+ .pybuilder/
132
+ target/
133
+
134
+ # Jupyter Notebook
135
+ .ipynb_checkpoints
136
+
137
+ # IPython
138
+ profile_default/
139
+ ipython_config.py
140
+
141
+ # pyenv
142
+ # For a library or package, you might want to ignore these files since the code is
143
+ # intended to run in multiple environments; otherwise, check them in:
144
+ # .python-version
145
+
146
+ # pipenv
147
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
148
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
149
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
150
+ # install all needed dependencies.
151
+ #Pipfile.lock
152
+
153
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
154
+ __pypackages__/
155
+
156
+ # Celery stuff
157
+ celerybeat-schedule
158
+ celerybeat.pid
159
+
160
+ # SageMath parsed files
161
+ *.sage.py
162
+
163
+ # Environments
164
+ .env
165
+ .venv
166
+ env/
167
+ venv/
168
+ ENV/
169
+ env.bak/
170
+ venv.bak/
171
+
172
+ # Spyder project settings
173
+ .spyderproject
174
+ .spyproject
175
+
176
+ # Rope project settings
177
+ .ropeproject
178
+
179
+ # mkdocs documentation
180
+ /site
181
+
182
+ # mypy
183
+ .mypy_cache/
184
+ .dmypy.json
185
+ dmypy.json
186
+
187
+ # Pyre type checker
188
+ .pyre/
189
+
190
+ # pytype static type analyzer
191
+ .pytype/
192
+
193
+ # Cython debug symbols
194
+ cython_debug/
195
+
196
+ # End of https://www.toptal.com/developers/gitignore/api/python,emacs
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: mersal-polling
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: Abdulhaq Emhemmed <abdulhaq.emhemmed@gmail.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mersal
8
+ Description-Content-Type: text/markdown
9
+
10
+ # mersal-polling
11
+
12
+ Describe your project here.
@@ -0,0 +1,3 @@
1
+ # mersal-polling
2
+
3
+ Describe your project here.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "mersal-polling"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ authors = [
6
+ { name = "Abdulhaq Emhemmed", email = "abdulhaq.emhemmed@gmail.com" }
7
+ ]
8
+ dependencies = [
9
+ "mersal",
10
+ ]
11
+ readme = "README.md"
12
+ requires-python = ">= 3.10"
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pytest",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.uv]
24
+ managed = true
25
+
26
+ [tool.uv.sources]
27
+ mersal = { workspace = true }
28
+
29
+ [tool.hatch.metadata]
30
+ allow-direct-references = true
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/mersal_polling"]
@@ -0,0 +1,21 @@
1
+ from .config import (
2
+ PollingConfig,
3
+ )
4
+ from .default_poller import DefaultPoller
5
+ from .message_completion_handler import (
6
+ message_completion_event_publisher,
7
+ register_message_completion_publishers,
8
+ )
9
+ from .poller import Poller, PollingResult
10
+ from .poller_with_timeout import PollerWithTimeout, PollingTimeoutError
11
+
12
+ __all__ = [
13
+ "DefaultPoller",
14
+ "Poller",
15
+ "PollerWithTimeout",
16
+ "PollingConfig",
17
+ "PollingResult",
18
+ "PollingTimeoutError",
19
+ "message_completion_event_publisher",
20
+ "register_message_completion_publishers",
21
+ ]
@@ -0,0 +1,65 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Generic, TypeVar
4
+
5
+ from mersal_polling.plugin import PollingPlugin
6
+ from mersal_polling.poller import Poller
7
+
8
+ __all__ = (
9
+ "FailedCompletionCorrelation",
10
+ "PollingConfig",
11
+ "SuccessfulCompletionCorrelation",
12
+ )
13
+
14
+
15
+ EventType = TypeVar("EventType")
16
+
17
+
18
+ @dataclass
19
+ class SuccessfulCompletionCorrelation(Generic[EventType]):
20
+ """Correlates an event with the successful completion of a message (usually a command).
21
+
22
+ The correlation is either based on a custom callback that is given the event
23
+ and should return the message id or by default; the correlation id will be used.
24
+ """
25
+
26
+ message_id_getter: Callable[[EventType], Any] | None = None
27
+
28
+
29
+ @dataclass
30
+ class FailedCompletionCorrelation(Generic[EventType]):
31
+ """Correlates an event with the failed completion of a message (usually a command).
32
+
33
+ The correlation is either based on a custom callback that is given the event
34
+ and should return the message id or by default; the correlation id will be used.
35
+ """
36
+
37
+ message_id_getter: Callable[[EventType], Any] | None = None
38
+
39
+ """
40
+ Callback that builds a custom exception to pass to the poller for the failed case.
41
+ """
42
+ exception_builder: Callable[[EventType], Exception] | None = None
43
+
44
+
45
+ @dataclass
46
+ class PollingConfig:
47
+ """Configuration for the polling functionality.
48
+
49
+ Args:
50
+ poller: The poller instance to use
51
+ successful_completion_events_map: Map of event types to successful completion correlations
52
+ failed_completion_events_map: Map of event types to failed completion correlations
53
+ auto_publish_completion_events: Whether to automatically publish message completion events
54
+ exclude_from_completion_events: Message types to exclude from automatic completion events
55
+ """
56
+
57
+ poller: Poller
58
+ successful_completion_events_map: dict[type, SuccessfulCompletionCorrelation] = field(default_factory=dict)
59
+ failed_completion_events_map: dict[type, FailedCompletionCorrelation] = field(default_factory=dict)
60
+ auto_publish_completion_events: bool = True
61
+ exclude_from_completion_events: set[type] = field(default_factory=set)
62
+
63
+ @property
64
+ def plugin(self) -> PollingPlugin:
65
+ return PollingPlugin(self)
@@ -0,0 +1,37 @@
1
+ import uuid
2
+
3
+ from anyio import Event
4
+
5
+ from .poller import Poller, PollingResult
6
+
7
+ __all__ = ("DefaultPoller",)
8
+
9
+
10
+ class DefaultPoller(Poller):
11
+ def __init__(self) -> None:
12
+ self.results: dict[uuid.UUID, PollingResult] = {}
13
+ self.events: dict[uuid.UUID, Event] = {}
14
+
15
+ async def poll(self, message_id: uuid.UUID) -> PollingResult:
16
+ # Check if result already exists
17
+ message = self.results.get(message_id)
18
+ if message:
19
+ return message
20
+
21
+ # Create an event for this message_id if it doesn't exist
22
+ if message_id not in self.events:
23
+ self.events[message_id] = Event()
24
+
25
+ # Wait for the event to be set
26
+ await self.events[message_id].wait()
27
+
28
+ # Return the result (should be available now)
29
+ return self.results[message_id]
30
+
31
+ async def push(self, message_id: uuid.UUID, exception: Exception | None = None) -> None:
32
+ # Store the result
33
+ self.results[message_id] = PollingResult(message_id, exception)
34
+
35
+ # If there's a waiting event, trigger it
36
+ if message_id in self.events:
37
+ self.events[message_id].set()
@@ -0,0 +1,21 @@
1
+ from mersal.messages import TransportMessage
2
+ from mersal.retry import ErrorHandler
3
+ from mersal.transport import TransactionContext
4
+ from mersal_polling.poller import Poller
5
+
6
+ __all__ = ("ErrorHandlerPollerWrapper",)
7
+
8
+
9
+ class ErrorHandlerPollerWrapper(ErrorHandler):
10
+ def __init__(self, poller: Poller, error_handler: ErrorHandler) -> None:
11
+ self.poller = poller
12
+ self.error_handler = error_handler
13
+
14
+ async def handle_poison_message(
15
+ self,
16
+ message: TransportMessage,
17
+ transaction_context: TransactionContext,
18
+ exception: Exception,
19
+ ) -> None:
20
+ await self.error_handler.handle_poison_message(message, transaction_context, exception)
21
+ await self.poller.push(message.headers.message_id, exception)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from mersal.messages.message_completed_event import MessageCompletedEvent
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ from mersal.activation import HandlerActivator
12
+ from mersal.app import Mersal
13
+ from mersal.pipeline import MessageContext
14
+ from mersal.types import AsyncAnyCallable
15
+
16
+ __all__ = (
17
+ "message_completion_event_publisher",
18
+ "register_message_completion_publishers",
19
+ )
20
+
21
+
22
+ def message_completion_event_publisher(
23
+ message_context: MessageContext,
24
+ app: Mersal,
25
+ _: list[AsyncAnyCallable],
26
+ ) -> Callable[[Any], Awaitable[None]]:
27
+ """Create a handler that publishes a MessageCompletedEvent.
28
+
29
+ This function creates a message handler that publishes a MessageCompletedEvent
30
+ when invoked, allowing for tracking message processing completion.
31
+
32
+ Args:
33
+ message_context: The message context for the current message
34
+ app: The Mersal application instance
35
+ _: Unused parameter for compatibility with handler factory signature
36
+
37
+ Returns:
38
+ A message handler function that publishes a completion event
39
+ """
40
+
41
+ async def handler(_: Any) -> None:
42
+ """Handler that publishes a MessageCompletedEvent.
43
+
44
+ Args:
45
+ _: The message (not used in this handler)
46
+ """
47
+ completed_message_id = message_context.headers.message_id
48
+ published_message_id = uuid.uuid4()
49
+ await app.publish(
50
+ MessageCompletedEvent(completed_message_id=completed_message_id),
51
+ headers={"message_id": published_message_id},
52
+ )
53
+
54
+ return handler
55
+
56
+
57
+ def register_message_completion_publishers(
58
+ activator: HandlerActivator,
59
+ exclude_types: set[type] | None = None,
60
+ ) -> None:
61
+ """Register message completion event publishers for all message types.
62
+
63
+ This function registers handlers that publish MessageCompletedEvent for all
64
+ message types that already have handlers registered in the activator, except
65
+ for those in the exclude_types set.
66
+
67
+ Args:
68
+ activator: The handler activator to register completion publishers with
69
+ exclude_types: Message types to exclude from completion event publishing
70
+ """
71
+ # Create exclude set (always exclude MessageCompletedEvent itself)
72
+ exclude: set[type] = set() if exclude_types is None else exclude_types.copy()
73
+ exclude.add(MessageCompletedEvent)
74
+
75
+ # Get all registered message types
76
+ completion_event_registry: set[type] = set()
77
+
78
+ # Register completion event publishers for all message types
79
+ for message_type in activator.registered_message_types:
80
+ if message_type not in exclude and message_type not in completion_event_registry:
81
+ # For each message type, register a handler that publishes completion events
82
+ def completion_publisher_factory(
83
+ message_context: MessageContext,
84
+ app: Mersal,
85
+ ) -> Callable[[Any], Awaitable[None]]:
86
+ return message_completion_event_publisher(
87
+ message_context=message_context,
88
+ app=app,
89
+ _=[],
90
+ )
91
+
92
+ activator.register(message_type, completion_publisher_factory)
93
+ completion_event_registry.add(message_type)
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from mersal.activation import HandlerActivator
6
+ from mersal.lifespan import LifespanHandler
7
+ from mersal.messages import MessageCompletedEvent
8
+ from mersal.plugins import Plugin
9
+ from mersal.retry import ErrorHandler
10
+ from mersal_polling.error_handler_poller_wrapper import (
11
+ ErrorHandlerPollerWrapper,
12
+ )
13
+ from mersal_polling.message_completion_handler import register_message_completion_publishers
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Awaitable, Callable
17
+
18
+ from mersal.app import Mersal
19
+ from mersal.configuration import StandardConfigurator
20
+ from mersal.pipeline.message_context import MessageContext
21
+ from mersal_polling.config import (
22
+ FailedCompletionCorrelation,
23
+ PollingConfig,
24
+ SuccessfulCompletionCorrelation,
25
+ )
26
+
27
+ __all__ = ("PollingPlugin",)
28
+
29
+
30
+ class PollingPlugin(Plugin):
31
+ """Plugin that adds polling functionality to a Mersal application.
32
+
33
+ This plugin enables waiting for message processing completion through polling.
34
+ It registers handlers for MessageCompletedEvent and custom completion events,
35
+ and can automatically add completion event publishers for registered message types.
36
+ """
37
+
38
+ def __init__(self, config: PollingConfig) -> None:
39
+ """Initialize a new polling plugin.
40
+
41
+ Args:
42
+ config: The configuration for the polling plugin
43
+ """
44
+ self._poller = config.poller
45
+ self._successfull_completion_events_map = config.successful_completion_events_map
46
+ self._failed_completion_events_map = config.failed_completion_events_map
47
+ self._auto_publish_completion_events = config.auto_publish_completion_events
48
+ self._exclude_from_completion_events = config.exclude_from_completion_events
49
+
50
+ def __call__(self, configurator: StandardConfigurator) -> None:
51
+ """Configure the Mersal application with polling functionality.
52
+
53
+ Args:
54
+ configurator: The standard configurator for the application
55
+ """
56
+ # Configure event subscriptions for MessageCompletedEvent and other events
57
+ self._configure_event_subscriptions(configurator)
58
+
59
+ # Integrate with error handling
60
+ self._configure_error_handler(configurator)
61
+
62
+ # Register handlers for completion events
63
+ self._configure_completion_event_handlers(configurator)
64
+
65
+ # If auto-publish is enabled, register completion event publishers for all message types
66
+ if self._auto_publish_completion_events:
67
+ self._configure_auto_completion_event_publishing(configurator)
68
+
69
+ def _configure_event_subscriptions(self, configurator: StandardConfigurator) -> None:
70
+ """Configure event subscriptions for the polling plugin.
71
+
72
+ Args:
73
+ configurator: The standard configurator for the application
74
+ """
75
+
76
+ def decorate(configurator: StandardConfigurator) -> Any:
77
+ events_to_subscribe_to: list[type] = [
78
+ MessageCompletedEvent,
79
+ ]
80
+ lifespan_handler: LifespanHandler = configurator.get(LifespanHandler) # type: ignore[type-abstract]
81
+ app: Mersal = configurator.mersal
82
+
83
+ # Add all custom completion events
84
+ for event in self._successfull_completion_events_map:
85
+ events_to_subscribe_to.append(event)
86
+ for event in self._failed_completion_events_map:
87
+ events_to_subscribe_to.append(event)
88
+
89
+ # Register a startup hook to subscribe to all events
90
+ lifespan_handler.register_on_startup_hook(self._subscribe(app, events_to_subscribe_to)) # type: ignore[arg-type]
91
+
92
+ return lifespan_handler
93
+
94
+ configurator.decorate(LifespanHandler, decorate)
95
+
96
+ def _configure_error_handler(self, configurator: StandardConfigurator) -> None:
97
+ """Configure error handling integration with polling.
98
+
99
+ Args:
100
+ configurator: The standard configurator for the application
101
+ """
102
+
103
+ def decorate_error_handler(configurator: StandardConfigurator) -> Any:
104
+ error_handler: ErrorHandler = configurator.get(ErrorHandler) # type: ignore[type-abstract]
105
+ return ErrorHandlerPollerWrapper(self._poller, error_handler)
106
+
107
+ configurator.decorate(ErrorHandler, decorate_error_handler)
108
+
109
+ def _configure_completion_event_handlers(self, configurator: StandardConfigurator) -> None:
110
+ """Configure handlers for message completion events.
111
+
112
+ Args:
113
+ configurator: The standard configurator for the application
114
+ """
115
+
116
+ def decorate_activator(configurator: StandardConfigurator) -> Any:
117
+ activator: HandlerActivator = configurator.get(HandlerActivator) # type: ignore[type-abstract]
118
+
119
+ # Register handler for MessageCompletedEvent
120
+ activator.register(
121
+ MessageCompletedEvent,
122
+ lambda __, _: self._message_completed_event_handler,
123
+ )
124
+
125
+ # Register handlers for custom success completion events
126
+ for (
127
+ event_type,
128
+ success_correlator,
129
+ ) in self._successfull_completion_events_map.items():
130
+ activator.register(
131
+ event_type,
132
+ lambda message_context, # type: ignore[misc]
133
+ _,
134
+ sc=success_correlator: self._successfull_custom_completion_event_handler_factory(
135
+ sc, message_context
136
+ ),
137
+ )
138
+
139
+ # Register handlers for custom failure completion events
140
+ for (
141
+ event_type,
142
+ failure_correlator,
143
+ ) in self._failed_completion_events_map.items():
144
+ activator.register(
145
+ event_type,
146
+ lambda message_context, # type: ignore[misc]
147
+ _,
148
+ fc=failure_correlator: self._failed_custom_completion_event_handler_factory(fc, message_context),
149
+ )
150
+
151
+ return activator
152
+
153
+ configurator.decorate(HandlerActivator, decorate_activator)
154
+
155
+ def _configure_auto_completion_event_publishing(self, configurator: StandardConfigurator) -> None:
156
+ """Configure automatic completion event publishing.
157
+
158
+ Args:
159
+ configurator: The standard configurator for the application
160
+ """
161
+
162
+ def decorate_activator(configurator: StandardConfigurator) -> Any:
163
+ activator: HandlerActivator = configurator.get(HandlerActivator) # type: ignore[type-abstract]
164
+ register_message_completion_publishers(activator, self._exclude_from_completion_events)
165
+ return activator
166
+
167
+ configurator.decorate(HandlerActivator, decorate_activator)
168
+
169
+ async def _message_completed_event_handler(self, event: MessageCompletedEvent) -> None:
170
+ """Handle MessageCompletedEvent by updating the poller.
171
+
172
+ Args:
173
+ event: The message completed event
174
+ """
175
+ await self._poller.push(event.completed_message_id, None)
176
+
177
+ def _successfull_custom_completion_event_handler_factory(
178
+ self,
179
+ correlator: SuccessfulCompletionCorrelation,
180
+ message_context: MessageContext,
181
+ ) -> Callable[[Any], Awaitable[None]]:
182
+ """Create a handler for custom successful completion events.
183
+
184
+ Args:
185
+ correlator: The correlation configuration
186
+ message_context: The message context
187
+
188
+ Returns:
189
+ A handler function that processes the event
190
+ """
191
+
192
+ async def _custom_completion_event_handler(event: Any) -> None:
193
+ if message_id_getter := correlator.message_id_getter:
194
+ message_id = message_id_getter(event)
195
+ else:
196
+ message_id = message_context.headers.correlation_id
197
+ await self._poller.push(message_id, None)
198
+
199
+ return _custom_completion_event_handler
200
+
201
+ def _failed_custom_completion_event_handler_factory(
202
+ self, correlator: FailedCompletionCorrelation, message_context: MessageContext
203
+ ) -> Callable[[Any], Awaitable[None]]:
204
+ """Create a handler for custom failure completion events.
205
+
206
+ Args:
207
+ correlator: The correlation configuration
208
+ message_context: The message context
209
+
210
+ Returns:
211
+ A handler function that processes the event
212
+ """
213
+
214
+ async def _custom_completion_event_handler(event: Any) -> None:
215
+ if message_id_getter := correlator.message_id_getter:
216
+ message_id = message_id_getter(event)
217
+ else:
218
+ message_id = message_context.headers.correlation_id
219
+
220
+ if exception_builder := correlator.exception_builder:
221
+ exception = exception_builder(event)
222
+ else:
223
+ exception = Exception("Event error")
224
+ await self._poller.push(message_id, exception)
225
+
226
+ return _custom_completion_event_handler
227
+
228
+ def _subscribe(self, app: Mersal, events: list[type]) -> Callable[[list[type]], Awaitable[None]]:
229
+ """Create a function that subscribes to the given events.
230
+
231
+ Args:
232
+ app: The Mersal application
233
+ events: The list of event types to subscribe to
234
+
235
+ Returns:
236
+ A function that performs the subscriptions
237
+ """
238
+
239
+ async def subscribe(
240
+ events: list[type] = events,
241
+ ) -> None:
242
+ for e in events:
243
+ await app.subscribe(e)
244
+
245
+ return subscribe
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Protocol
3
+
4
+ __all__ = (
5
+ "Poller",
6
+ "PollingResult",
7
+ )
8
+
9
+
10
+ @dataclass
11
+ class PollingResult:
12
+ message_id: Any
13
+ exception: Exception | None
14
+
15
+
16
+ class Poller(Protocol):
17
+ async def poll(self, message_id: Any) -> PollingResult: ...
18
+
19
+ async def push(
20
+ self,
21
+ message_id: Any,
22
+ exception: Exception | None = None,
23
+ ) -> None: ...
@@ -0,0 +1,28 @@
1
+ import uuid
2
+
3
+ from anyio import fail_after
4
+
5
+ from mersal.exceptions import MersalExceptionError
6
+
7
+ from .poller import Poller, PollingResult
8
+
9
+ __all__ = (
10
+ "PollerWithTimeout",
11
+ "PollingTimeoutError",
12
+ )
13
+
14
+
15
+ class PollingTimeoutError(MersalExceptionError):
16
+ pass
17
+
18
+
19
+ class PollerWithTimeout:
20
+ def __init__(self, poller: Poller) -> None:
21
+ self._poller = poller
22
+
23
+ async def poll(self, message_id: uuid.UUID, timeout: float = 30) -> PollingResult:
24
+ try:
25
+ with fail_after(timeout):
26
+ return await self._poller.poll(message_id)
27
+ except TimeoutError as e:
28
+ raise PollingTimeoutError() from e
File without changes
@@ -0,0 +1,3 @@
1
+ # pyright: reportWildcardImportFromLibrary=false
2
+
3
+ from mersal_testing._internal.conftest import *
@@ -0,0 +1,385 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ import anyio
6
+ import pytest
7
+
8
+ from mersal.activation import (
9
+ BuiltinHandlerActivator,
10
+ )
11
+ from mersal.app import Mersal
12
+ from mersal.lifespan.autosubscribe import AutosubscribeConfig
13
+ from mersal.messages import MessageCompletedEvent
14
+ from mersal.persistence.in_memory import (
15
+ InMemorySubscriptionStorage,
16
+ )
17
+ from mersal.pipeline import MessageContext
18
+ from mersal.serialization.serializers import Serializer
19
+ from mersal.transport.in_memory import InMemoryTransport
20
+ from mersal_polling import (
21
+ DefaultPoller,
22
+ PollerWithTimeout,
23
+ PollingConfig,
24
+ PollingTimeoutError,
25
+ )
26
+ from mersal_polling.config import (
27
+ FailedCompletionCorrelation,
28
+ SuccessfulCompletionCorrelation,
29
+ )
30
+ from mersal_testing.message_handlers.message_handler_that_counts import MessageHandlerThatCounts
31
+
32
+ __all__ = (
33
+ "Message1",
34
+ "Message1CompletedSuccessfully",
35
+ "Message1FailedToComplete",
36
+ "MessageHandler",
37
+ "MessageHandlerThatPublishes",
38
+ "SlowHandler",
39
+ "TestPollingPlugin",
40
+ "ThrowingMessageHandler",
41
+ )
42
+
43
+
44
+ pytestmark = pytest.mark.anyio
45
+
46
+
47
+ class Message1:
48
+ pass
49
+
50
+
51
+ class Message2:
52
+ pass
53
+
54
+
55
+ @dataclass
56
+ class Message1CompletedSuccessfully:
57
+ pass
58
+
59
+
60
+ @dataclass
61
+ class Message2CompletedSuccessfully:
62
+ pass
63
+
64
+
65
+ @dataclass
66
+ class Message1FailedToComplete:
67
+ pass
68
+
69
+
70
+ @dataclass
71
+ class Message2FailedToComplete:
72
+ pass
73
+
74
+
75
+ class MessageHandler:
76
+ def __init__(self) -> None:
77
+ self.calls = 0
78
+
79
+ async def __call__(self, message: Any):
80
+ self.calls += 1
81
+
82
+
83
+ class SlowHandler:
84
+ def __init__(self, delay: int) -> None:
85
+ self.calls = 0
86
+ self.delay = delay
87
+
88
+ async def __call__(self, message: Any):
89
+ self.calls += 1
90
+ await anyio.sleep(self.delay)
91
+
92
+
93
+ class ThrowingMessageHandler:
94
+ async def __call__(self, message: Any):
95
+ raise Exception()
96
+
97
+
98
+ class MessageHandlerThatPublishes:
99
+ def __init__(self, message_context: MessageContext, app: Mersal, published_message: Any) -> None:
100
+ self.calls = 0
101
+ self.app = app
102
+ self.message_context = message_context
103
+ self.published_message = published_message
104
+
105
+ async def __call__(self, message: Any):
106
+ self.calls += 1
107
+ await self.app.publish(self.published_message)
108
+
109
+
110
+ class TestPollingPlugin:
111
+ async def test_polling(
112
+ self,
113
+ in_memory_transport: InMemoryTransport,
114
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
115
+ serializer: Serializer,
116
+ ):
117
+ activator = BuiltinHandlerActivator()
118
+ poller = DefaultPoller()
119
+ message_handler = MessageHandlerThatCounts()
120
+ completion_event_handler = MessageHandlerThatCounts()
121
+ activator.register(Message1, lambda __, _: message_handler)
122
+ activator.register(MessageCompletedEvent, lambda _, __: completion_event_handler)
123
+ message1_id = uuid.uuid4()
124
+
125
+ app = Mersal(
126
+ "m1",
127
+ activator,
128
+ transport=in_memory_transport,
129
+ serializer=serializer,
130
+ subscription_storage=in_memory_subscription_storage,
131
+ autosubscribe=AutosubscribeConfig(set()),
132
+ plugins=[
133
+ PollingConfig(
134
+ poller,
135
+ auto_publish_completion_events=True,
136
+ ).plugin
137
+ ],
138
+ )
139
+ await app.start()
140
+
141
+ await anyio.sleep(0.5)
142
+
143
+ await app.send_local(Message1(), headers={"message_id": message1_id})
144
+
145
+ await anyio.sleep(0.5)
146
+
147
+ assert message_handler.count == 1
148
+ assert completion_event_handler.count == 1
149
+ assert isinstance(completion_event_handler.message, MessageCompletedEvent)
150
+ assert completion_event_handler.message.completed_message_id == message1_id
151
+
152
+ result = await poller.poll(message1_id)
153
+ assert result
154
+ assert not result.exception
155
+
156
+ await app.send_local(Message1(), headers={"message_id": uuid.uuid4()})
157
+ await anyio.sleep(0.5)
158
+ assert message_handler.count == 2
159
+ assert completion_event_handler.count == 2
160
+
161
+ await app.stop()
162
+
163
+ async def test_polling_with_custom_success_completion_event(
164
+ self,
165
+ in_memory_transport: InMemoryTransport,
166
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
167
+ serializer: Serializer,
168
+ ):
169
+ activator = BuiltinHandlerActivator()
170
+ poller = DefaultPoller()
171
+ app = Mersal(
172
+ "m1",
173
+ activator,
174
+ transport=in_memory_transport,
175
+ serializer=serializer,
176
+ subscription_storage=in_memory_subscription_storage,
177
+ autosubscribe=AutosubscribeConfig(set()),
178
+ plugins=[
179
+ PollingConfig(
180
+ poller,
181
+ successful_completion_events_map={
182
+ Message1CompletedSuccessfully: SuccessfulCompletionCorrelation(),
183
+ Message2CompletedSuccessfully: SuccessfulCompletionCorrelation(),
184
+ },
185
+ exclude_from_completion_events={
186
+ Message1,
187
+ Message2,
188
+ },
189
+ ).plugin
190
+ ],
191
+ )
192
+ activator.register(
193
+ Message1,
194
+ lambda m, b: MessageHandlerThatPublishes(m, b, Message1CompletedSuccessfully()),
195
+ )
196
+ activator.register(
197
+ Message2,
198
+ lambda m, b: MessageHandlerThatPublishes(m, b, Message2CompletedSuccessfully()),
199
+ )
200
+ message1_id = uuid.uuid4()
201
+ message2_id = uuid.uuid4()
202
+ await app.start()
203
+
204
+ await app.send_local(Message1(), headers={"message_id": message1_id})
205
+ await app.send_local(Message2(), headers={"message_id": message2_id})
206
+ await anyio.sleep(0.5)
207
+ result1 = await poller.poll(message1_id)
208
+ result2 = await poller.poll(message2_id)
209
+ assert result1
210
+ assert not result1.exception
211
+ assert result2
212
+ assert not result2.exception
213
+
214
+ await app.stop()
215
+
216
+ async def test_polling_with_custom_failure_completion_event(
217
+ self,
218
+ in_memory_transport: InMemoryTransport,
219
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
220
+ serializer: Serializer,
221
+ ):
222
+ activator = BuiltinHandlerActivator()
223
+ poller = DefaultPoller()
224
+ app = Mersal(
225
+ "m1",
226
+ activator,
227
+ transport=in_memory_transport,
228
+ serializer=serializer,
229
+ subscription_storage=in_memory_subscription_storage,
230
+ autosubscribe=AutosubscribeConfig(set()),
231
+ plugins=[
232
+ PollingConfig(
233
+ poller,
234
+ failed_completion_events_map={
235
+ Message1FailedToComplete: FailedCompletionCorrelation(
236
+ exception_builder=lambda event: ValueError("hi")
237
+ ),
238
+ Message2FailedToComplete: FailedCompletionCorrelation(
239
+ exception_builder=lambda event: ValueError("hi-bye")
240
+ ),
241
+ },
242
+ exclude_from_completion_events={
243
+ Message1,
244
+ Message2,
245
+ },
246
+ ).plugin
247
+ ],
248
+ )
249
+ activator.register(
250
+ Message1,
251
+ lambda m, b: MessageHandlerThatPublishes(m, b, Message1FailedToComplete()),
252
+ )
253
+ activator.register(
254
+ Message2,
255
+ lambda m, b: MessageHandlerThatPublishes(m, b, Message2FailedToComplete()),
256
+ )
257
+ message1_id = uuid.uuid4()
258
+ message2_id = uuid.uuid4()
259
+ await app.start()
260
+
261
+ await app.send_local(Message1(), headers={"message_id": message1_id})
262
+ await app.send_local(Message2(), headers={"message_id": message2_id})
263
+ await anyio.sleep(0.5)
264
+
265
+ result1 = await poller.poll(message1_id)
266
+ result2 = await poller.poll(message2_id)
267
+
268
+ assert result1
269
+ assert result1.exception
270
+ assert type(result1.exception) is ValueError
271
+ assert result2
272
+ assert result2.exception
273
+ assert type(result2.exception) is ValueError
274
+
275
+ await app.stop()
276
+
277
+ async def test_polling_with_exception(
278
+ self,
279
+ in_memory_transport: InMemoryTransport,
280
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
281
+ serializer: Serializer,
282
+ ):
283
+ activator = BuiltinHandlerActivator()
284
+ poller = DefaultPoller()
285
+ app = Mersal(
286
+ "m1",
287
+ activator,
288
+ transport=in_memory_transport,
289
+ serializer=serializer,
290
+ subscription_storage=in_memory_subscription_storage,
291
+ autosubscribe=AutosubscribeConfig(set()),
292
+ plugins=[PollingConfig(poller).plugin],
293
+ )
294
+
295
+ handler = ThrowingMessageHandler()
296
+ activator.register(Message1, lambda __, _: handler)
297
+ message_id = uuid.uuid4()
298
+ await app.start()
299
+
300
+ await app.send_local(Message1(), headers={"message_id": message_id})
301
+ await anyio.sleep(0.1)
302
+ result = await poller.poll(message_id)
303
+ assert result
304
+ assert result.exception
305
+
306
+ await app.stop()
307
+
308
+ async def test_polling_with_timeout(
309
+ self,
310
+ in_memory_transport: InMemoryTransport,
311
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
312
+ serializer: Serializer,
313
+ ):
314
+ activator = BuiltinHandlerActivator()
315
+ _poller = DefaultPoller()
316
+ poller = PollerWithTimeout(_poller)
317
+ app = Mersal(
318
+ "m1",
319
+ activator,
320
+ transport=in_memory_transport,
321
+ serializer=serializer,
322
+ subscription_storage=in_memory_subscription_storage,
323
+ autosubscribe=AutosubscribeConfig(set()),
324
+ plugins=[PollingConfig(_poller).plugin],
325
+ )
326
+
327
+ handler = SlowHandler(1)
328
+ activator.register(Message1, lambda __, _: handler)
329
+ message_id = uuid.uuid4()
330
+ await app.start()
331
+
332
+ await app.send_local(Message1(), headers={"message_id": message_id})
333
+ with pytest.raises(PollingTimeoutError):
334
+ await poller.poll(message_id, timeout=0.5)
335
+
336
+ await app.stop()
337
+
338
+ async def test_auto_completion_event_with_polling_plugin_excluded(
339
+ self,
340
+ in_memory_transport: InMemoryTransport,
341
+ in_memory_subscription_storage: InMemorySubscriptionStorage,
342
+ serializer: Serializer,
343
+ ):
344
+ activator = BuiltinHandlerActivator()
345
+ message = Message1()
346
+
347
+ completion_event_handler = MessageHandlerThatCounts()
348
+ activator.register(MessageCompletedEvent, lambda _, __: completion_event_handler)
349
+
350
+ message_handler = MessageHandlerThatCounts()
351
+ activator.register(Message1, lambda m, b: message_handler)
352
+
353
+ poller = DefaultPoller()
354
+
355
+ plugins = [
356
+ PollingConfig(
357
+ poller,
358
+ auto_publish_completion_events=True,
359
+ exclude_from_completion_events={Message1},
360
+ ).plugin,
361
+ ]
362
+
363
+ app = Mersal(
364
+ "m1",
365
+ activator,
366
+ transport=in_memory_transport,
367
+ serializer=serializer,
368
+ subscription_storage=in_memory_subscription_storage,
369
+ plugins=plugins,
370
+ )
371
+
372
+ await app.start()
373
+
374
+ message_id = uuid.uuid4()
375
+ await app.send_local(message, headers={"message_id": message_id})
376
+
377
+ await anyio.sleep(0.1)
378
+ await app.stop()
379
+
380
+ assert message_handler.count == 1
381
+ assert completion_event_handler.count == 0
382
+
383
+ _poller = PollerWithTimeout(poller)
384
+ with pytest.raises(PollingTimeoutError):
385
+ _ = await _poller.poll(message_id, 1)