flowevent 1.2__py3-none-any.whl

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.
eventflow/base.py ADDED
@@ -0,0 +1,180 @@
1
+ """Common contracts and pure helpers shared by both event buses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import math
7
+ import threading
8
+ import weakref
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Callable
11
+ from typing import Any, Generic, TypeVar
12
+
13
+ from .contracts import ErrorPolicy, EventBusStats
14
+ from .errors import EventBusClosedError
15
+ from .filtering import EventFilter, EventType, coerce_event_filter
16
+ from .history import EventHistory, MemoryEventHistory, _validate_query_limit
17
+
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def validate_positive_capacity(value: int, *, name: str) -> int:
23
+ """Validate a bounded collection/schedule capacity."""
24
+
25
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
26
+ raise ValueError(f"{name} must be a positive integer")
27
+ return value
28
+
29
+
30
+ def normalize_replay_capacity(value: int) -> int:
31
+ """Validate a replay buffer capacity as a positive integer."""
32
+
33
+ return validate_positive_capacity(value, name="Replay buffer limit")
34
+
35
+
36
+ def validate_positive_timeout(value: float, *, name: str) -> float:
37
+ """Validate and normalize a finite timeout value."""
38
+
39
+ if (
40
+ isinstance(value, bool)
41
+ or not isinstance(value, (int, float))
42
+ or not math.isfinite(value)
43
+ or value <= 0
44
+ ):
45
+ raise ValueError(f"{name} must be positive and finite")
46
+ return float(value)
47
+
48
+
49
+ def validate_nonnegative_count(value: int, *, name: str) -> int:
50
+ """Validate a count that may be zero."""
51
+
52
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
53
+ raise ValueError(f"{name} must be a non-negative integer")
54
+ return value
55
+
56
+
57
+ def validate_nonnegative_timeout(value: float, *, name: str) -> float:
58
+ """Validate and normalize a finite timeout that may be zero."""
59
+
60
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
61
+ raise ValueError(f"{name} must be non-negative and finite")
62
+ try:
63
+ normalized = float(value)
64
+ except OverflowError as exc:
65
+ raise ValueError(f"{name} must be non-negative and finite") from exc
66
+ if not math.isfinite(normalized) or normalized < 0:
67
+ raise ValueError(f"{name} must be non-negative and finite")
68
+ return normalized
69
+
70
+
71
+ class BaseEventBus(Generic[T], ABC):
72
+ def __init__(
73
+ self,
74
+ *,
75
+ history: EventHistory[T] | int | None = None,
76
+ error_policy: ErrorPolicy = ErrorPolicy.ISOLATE,
77
+ ) -> None:
78
+ if history is None or isinstance(history, EventHistory):
79
+ self._history = history
80
+ elif isinstance(history, bool) or not isinstance(history, int):
81
+ raise TypeError("Event history must be a positive capacity or EventHistory")
82
+ else:
83
+ self._history = MemoryEventHistory[T](history)
84
+ self._error_policy = ErrorPolicy(error_policy)
85
+ self._engine_owner_lock = threading.Lock()
86
+ self._engine_owner: weakref.ReferenceType[Any] | None = None
87
+ self._published = 0
88
+ self._delivered = 0
89
+ self._failed = 0
90
+
91
+ def _claim_engine_ownership(self, owner: Any) -> bool:
92
+ """Atomically assign this bus to one live engine without retaining it."""
93
+ with self._engine_owner_lock:
94
+ current_owner = self._engine_owner() if self._engine_owner is not None else None
95
+ if current_owner is not None and current_owner is not owner:
96
+ return False
97
+ self._engine_owner = weakref.ref(owner)
98
+ return True
99
+
100
+ @property
101
+ def history(self) -> EventHistory[T] | None:
102
+ return self._history
103
+
104
+ @property
105
+ def error_policy(self) -> ErrorPolicy:
106
+ return self._error_policy
107
+
108
+ @property
109
+ @abstractmethod
110
+ def closed(self) -> bool: ...
111
+
112
+ @property
113
+ @abstractmethod
114
+ def subscriber_count(self) -> int: ...
115
+
116
+ @property
117
+ @abstractmethod
118
+ def stats(self) -> EventBusStats: ...
119
+
120
+ def _ensure_open(self) -> None:
121
+ if self.closed:
122
+ raise EventBusClosedError("Event bus is closed")
123
+
124
+ def _record(self, event: T) -> None:
125
+ if self._history is not None:
126
+ self._history.append(event)
127
+
128
+ @staticmethod
129
+ def _delivery_limit(*, once: bool, times: int | None) -> int | None:
130
+ if times is not None:
131
+ if isinstance(times, bool) or not isinstance(times, int) or times <= 0:
132
+ raise ValueError("Subscription times must be a positive integer")
133
+ if once and times != 1:
134
+ raise ValueError("Use once=True or times, not both")
135
+ return times
136
+ return 1 if once else None
137
+
138
+ def latest(
139
+ self,
140
+ *,
141
+ event_filter: EventFilter[T] | None = None,
142
+ event_type: EventType | None = None,
143
+ predicate: Callable[[T], bool] | None = None,
144
+ ) -> T | None:
145
+ normalized_filter = coerce_event_filter(
146
+ event_filter,
147
+ event_type=event_type,
148
+ predicate=predicate,
149
+ )
150
+ if self._history is None:
151
+ return None
152
+ if event_filter is None and event_type is None and predicate is None:
153
+ return self._history.latest()
154
+ return self._history.latest(normalized_filter)
155
+
156
+ def query(
157
+ self,
158
+ *,
159
+ event_filter: EventFilter[T] | None = None,
160
+ event_type: EventType | None = None,
161
+ predicate: Callable[[T], bool] | None = None,
162
+ limit: int | None = None,
163
+ ) -> tuple[T, ...]:
164
+ _validate_query_limit(limit)
165
+ normalized_filter = coerce_event_filter(
166
+ event_filter,
167
+ event_type=event_type,
168
+ predicate=predicate,
169
+ )
170
+ if self._history is None:
171
+ return ()
172
+ if event_filter is None and event_type is None and predicate is None:
173
+ return self._history.query(limit=limit)
174
+ return self._history.query(normalized_filter, limit=limit)
175
+
176
+ @staticmethod
177
+ def _is_async_handler(handler: Any) -> bool:
178
+ if inspect.iscoroutinefunction(handler):
179
+ return True
180
+ return callable(handler) and inspect.iscoroutinefunction(type(handler).__call__)
eventflow/callback.py ADDED
@@ -0,0 +1,318 @@
1
+ """Configurable callback registrations, independent of transport and event bus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import math
8
+ import threading
9
+ import time
10
+ import sys
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from ._callbacks import synchronous_result
16
+ from .compatibility import ExceptionGroup
17
+ from .errors import CallbackTimeoutError
18
+ from ._lifecycle import finish_cleanup
19
+ from .subscription import Subscription
20
+
21
+
22
+ async def _cancel_task(task: asyncio.Future[Any]) -> None:
23
+ task.cancel()
24
+ await finish_cleanup(asyncio.gather(task, return_exceptions=True))
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class CallbackContext:
29
+ event: Any
30
+ result: Any = None
31
+ error: BaseException | None = None
32
+ elapsed: float = 0.0
33
+
34
+
35
+ class CallbackSubscription(Subscription):
36
+ """Callable decorator result with execution policy and per-registration hooks.
37
+
38
+ Predicates run at delivery time. Rate-limited/disabled/filtered events do
39
+ not consume max_calls. Sync timeout is observational, never thread killing.
40
+ Hooks receive CallbackContext and run outside the registration lock.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ identifier: int,
46
+ cancel: Callable[[], None],
47
+ callback: Callable,
48
+ *,
49
+ asynchronous: bool = False,
50
+ once: bool = False,
51
+ max_calls: int | None = None,
52
+ frequency_hz: float | None = None,
53
+ timeout: float | None = None,
54
+ predicate: Callable | None = None,
55
+ enabled: bool = True,
56
+ on_before: Callable | None = None,
57
+ on_success: Callable | None = None,
58
+ on_error: Callable | None = None,
59
+ on_timeout: Callable | None = None,
60
+ on_after: Callable | None = None,
61
+ ) -> None:
62
+ super().__init__(identifier, cancel)
63
+ if once and max_calls is not None:
64
+ raise ValueError("once and max_calls cannot be combined")
65
+ if max_calls is not None and (
66
+ isinstance(max_calls, bool) or not isinstance(max_calls, int) or max_calls < 1
67
+ ):
68
+ raise ValueError("max_calls must be a positive integer")
69
+ for value in (frequency_hz, timeout):
70
+ if value is not None and (
71
+ isinstance(value, bool) or not math.isfinite(value) or value <= 0
72
+ ):
73
+ raise ValueError("frequency_hz and timeout must be positive and finite")
74
+ if not isinstance(enabled, bool):
75
+ raise TypeError("enabled must be a bool")
76
+ if predicate is not None and (
77
+ not callable(predicate) or inspect.iscoroutinefunction(predicate)
78
+ ):
79
+ raise TypeError("predicate must be a synchronous callable")
80
+ self.callback = callback
81
+ self.asynchronous = asynchronous
82
+ self.timeout = timeout
83
+ self.predicate = predicate
84
+ self._enabled = enabled
85
+ self._maximum = 1 if once else max_calls
86
+ self._interval = 0.0 if frequency_hz is None else 1.0 / frequency_hz
87
+ self._last = float("-inf")
88
+ self._calls = 0
89
+ self._policy_lock = threading.RLock()
90
+ self._hooks: dict[str, list[Callable]] = {}
91
+ for name, hook in (
92
+ ("before", on_before),
93
+ ("success", on_success),
94
+ ("error", on_error),
95
+ ("timeout", on_timeout),
96
+ ("after", on_after),
97
+ ):
98
+ if hook is not None:
99
+ self._hook(name, hook)
100
+
101
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
102
+ return self.callback(*args, **kwargs)
103
+
104
+ @property
105
+ def enabled(self) -> bool:
106
+ with self._policy_lock:
107
+ return self._enabled
108
+
109
+ def enable(self) -> None:
110
+ with self._policy_lock:
111
+ self._enabled = True
112
+
113
+ def disable(self) -> None:
114
+ with self._policy_lock:
115
+ self._enabled = False
116
+
117
+ def _hook(self, name: str, callback: Callable) -> Callable:
118
+ if not callable(callback):
119
+ raise TypeError("Hook must be callable")
120
+ asynchronous = inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction(
121
+ type(callback).__call__
122
+ )
123
+ if asynchronous != self.asynchronous:
124
+ raise TypeError("Hook must match callback sync/async mode")
125
+ with self._policy_lock:
126
+ self._hooks.setdefault(name, []).append(callback)
127
+ return callback
128
+
129
+ def on_before(self, callback: Callable) -> Callable:
130
+ return self._hook("before", callback)
131
+
132
+ def on_success(self, callback: Callable) -> Callable:
133
+ return self._hook("success", callback)
134
+
135
+ def on_error(self, callback: Callable) -> Callable:
136
+ return self._hook("error", callback)
137
+
138
+ def on_timeout(self, callback: Callable) -> Callable:
139
+ return self._hook("timeout", callback)
140
+
141
+ def on_after(self, callback: Callable) -> Callable:
142
+ return self._hook("after", callback)
143
+
144
+ def _accept(self, event: Any) -> bool:
145
+ if not self.active or not self.enabled:
146
+ return False
147
+ if self.predicate is not None:
148
+ accepted = self.predicate(event)
149
+ if inspect.isawaitable(accepted):
150
+ if inspect.iscoroutine(accepted):
151
+ accepted.close()
152
+ raise TypeError("predicate returned an awaitable")
153
+ if not isinstance(accepted, bool):
154
+ raise TypeError("predicate must return bool")
155
+ if not accepted:
156
+ return False
157
+ with self._policy_lock:
158
+ now = time.monotonic()
159
+ if not self.active or not self._enabled or now - self._last < self._interval:
160
+ return False
161
+ if self._maximum is not None and self._calls >= self._maximum:
162
+ return False
163
+ self._calls += 1
164
+ self._last = now
165
+ exhausted = self._calls == self._maximum
166
+ if exhausted:
167
+ self.cancel()
168
+ return True
169
+
170
+ def _snapshot(self, name: str) -> tuple[Callable, ...]:
171
+ with self._policy_lock:
172
+ return tuple(self._hooks.get(name, ()))
173
+
174
+ def _run_hooks(self, name: str, context: CallbackContext) -> None:
175
+ errors = []
176
+ for hook in self._snapshot(name):
177
+ try:
178
+ synchronous_result(hook(context))
179
+ except Exception as error:
180
+ errors.append(error)
181
+ self._raise_errors(errors)
182
+
183
+ async def _run_hooks_async(self, name: str, context: CallbackContext) -> None:
184
+ errors = []
185
+ for hook in self._snapshot(name):
186
+ try:
187
+ await hook(context)
188
+ except Exception as error:
189
+ errors.append(error)
190
+ self._raise_errors(errors)
191
+
192
+ @staticmethod
193
+ def _raise_errors(errors: list[Exception]) -> None:
194
+ if len(errors) == 1:
195
+ raise errors[0]
196
+ if errors:
197
+ raise ExceptionGroup("Callback and hook failures", errors)
198
+
199
+ def _report_failure(self, name: str, context: CallbackContext, error: Exception) -> None:
200
+ context.error = error
201
+ try:
202
+ self._run_hooks(name, context)
203
+ except Exception as hook_error:
204
+ raise ExceptionGroup("Callback and error hook failed", [error, hook_error]) from None
205
+
206
+ async def _report_failure_async(
207
+ self, name: str, context: CallbackContext, error: Exception
208
+ ) -> None:
209
+ context.error = error
210
+ try:
211
+ await self._run_hooks_async(name, context)
212
+ except Exception as hook_error:
213
+ raise ExceptionGroup("Callback and error hook failed", [error, hook_error]) from None
214
+
215
+ def invoke(self, event: Any) -> Any:
216
+ context = CallbackContext(event)
217
+ started = time.monotonic()
218
+ try:
219
+ if not self._accept(event):
220
+ return None
221
+ except Exception as error:
222
+ self._report_failure("error", context, error)
223
+ raise
224
+ try:
225
+ self._run_hooks("before", context)
226
+ context.result = self.callback(event)
227
+ if inspect.isawaitable(context.result):
228
+ if inspect.iscoroutine(context.result):
229
+ context.result.close()
230
+ raise TypeError("Sync callback returned an awaitable")
231
+ context.elapsed = time.monotonic() - started
232
+ if self.timeout is not None and context.elapsed > self.timeout:
233
+ raise CallbackTimeoutError("Callback exceeded its synchronous time budget")
234
+ self._run_hooks("success", context)
235
+ except Exception as error:
236
+ self._report_failure(
237
+ "timeout" if isinstance(error, CallbackTimeoutError) else "error", context, error
238
+ )
239
+ raise
240
+ else:
241
+ return context.result
242
+ finally:
243
+ context.elapsed = time.monotonic() - started
244
+ pending = sys.exc_info()[1]
245
+ try:
246
+ self._run_hooks("after", context)
247
+ except Exception as error:
248
+ failures = [pending] if isinstance(pending, Exception) else []
249
+ try:
250
+ self._report_failure("error", context, error)
251
+ except Exception as report_error:
252
+ failures.append(report_error)
253
+ else:
254
+ failures.append(error)
255
+ if pending is not None and not isinstance(pending, Exception):
256
+ raise pending from ExceptionGroup("Cleanup failed", failures)
257
+ self._raise_errors(failures)
258
+
259
+ async def invoke_async(self, event: Any) -> Any:
260
+ context = CallbackContext(event)
261
+ started = time.monotonic()
262
+ try:
263
+ if not self._accept(event):
264
+ return None
265
+ except Exception as error:
266
+ await self._report_failure_async("error", context, error)
267
+ raise
268
+ try:
269
+ await self._run_hooks_async("before", context)
270
+ operation = self.callback(event)
271
+ if self.timeout is None:
272
+ context.result = await operation
273
+ else:
274
+ remaining = self.timeout - (time.monotonic() - started)
275
+ if remaining <= 0:
276
+ if inspect.iscoroutine(operation):
277
+ operation.close()
278
+ else:
279
+ await _cancel_task(asyncio.ensure_future(operation))
280
+ raise CallbackTimeoutError("Callback exceeded its asynchronous time budget")
281
+ task = asyncio.ensure_future(operation)
282
+ try:
283
+ done, _ = await asyncio.wait({task}, timeout=remaining)
284
+ except asyncio.CancelledError:
285
+ await _cancel_task(task)
286
+ raise
287
+ if task not in done:
288
+ await _cancel_task(task)
289
+ raise CallbackTimeoutError("Callback exceeded its asynchronous time budget")
290
+ context.result = await task
291
+ context.elapsed = time.monotonic() - started
292
+ await self._run_hooks_async("success", context)
293
+ except asyncio.CancelledError as error:
294
+ context.error = error
295
+ raise
296
+ except Exception as error:
297
+ await self._report_failure_async(
298
+ "timeout" if isinstance(error, CallbackTimeoutError) else "error", context, error
299
+ )
300
+ raise
301
+ else:
302
+ return context.result
303
+ finally:
304
+ context.elapsed = time.monotonic() - started
305
+ pending = sys.exc_info()[1]
306
+ try:
307
+ await self._run_hooks_async("after", context)
308
+ except Exception as error:
309
+ failures = [pending] if isinstance(pending, Exception) else []
310
+ try:
311
+ await self._report_failure_async("error", context, error)
312
+ except Exception as report_error:
313
+ failures.append(report_error)
314
+ else:
315
+ failures.append(error)
316
+ if pending is not None and not isinstance(pending, Exception):
317
+ raise pending from ExceptionGroup("Cleanup failed", failures)
318
+ self._raise_errors(failures)
@@ -0,0 +1,14 @@
1
+ """Expose standard-library features across supported Python versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ if sys.version_info >= (3, 11):
8
+ from builtins import BaseExceptionGroup, ExceptionGroup
9
+ from enum import StrEnum
10
+ else: # pragma: no cover - exercised by the Python 3.10 environment
11
+ from backports.strenum import StrEnum
12
+ from exceptiongroup import BaseExceptionGroup, ExceptionGroup
13
+
14
+ __all__ = ["BaseExceptionGroup", "ExceptionGroup", "StrEnum"]
eventflow/context.py ADDED
@@ -0,0 +1,103 @@
1
+ """Optional immutable event envelopes and task-local correlation identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from copy import deepcopy
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from types import MappingProxyType
12
+ from typing import Any, Generic, TypeVar
13
+ from uuid import uuid4
14
+
15
+ T = TypeVar("T")
16
+ _correlation: ContextVar[str | None] = ContextVar("eventflow_correlation", default=None)
17
+ __all__ = ["EventEnvelope", "correlation_context", "current_correlation_id"]
18
+
19
+
20
+ def current_correlation_id() -> str | None:
21
+ """Return the identifier in the current context, if one was bound."""
22
+ return _correlation.get()
23
+
24
+
25
+ class _ReadOnlyMetadata(Mapping[str, object]):
26
+ """Copied read-only metadata that supports deepcopy and pickle."""
27
+
28
+ __slots__ = ("_mapping",)
29
+
30
+ def __init__(self, values: Mapping[str, object]) -> None:
31
+ self._mapping = MappingProxyType(dict(values))
32
+
33
+ def __getitem__(self, key: str) -> object:
34
+ return self._mapping[key]
35
+
36
+ def __iter__(self) -> Iterator[str]:
37
+ return iter(self._mapping)
38
+
39
+ def __len__(self) -> int:
40
+ return len(self._mapping)
41
+
42
+ def __repr__(self) -> str:
43
+ return f"mappingproxy({dict(self._mapping)!r})"
44
+
45
+ def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, object]:
46
+ return deepcopy(dict(self._mapping), memo)
47
+
48
+ def __reduce__(self) -> tuple[type[_ReadOnlyMetadata], tuple[dict[str, object]]]:
49
+ return type(self), (dict(self._mapping),)
50
+
51
+
52
+ @contextmanager
53
+ def correlation_context(correlation_id: str) -> Iterator[str]:
54
+ """Bind an identifier until exit; nested and async task contexts are isolated."""
55
+ if not isinstance(correlation_id, str) or not correlation_id.strip():
56
+ raise ValueError("Correlation ID must be a non-empty string")
57
+ token = _correlation.set(correlation_id)
58
+ try:
59
+ yield correlation_id
60
+ finally:
61
+ _correlation.reset(token)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class EventEnvelope(Generic[T]):
66
+ """Opt-in event metadata; metadata is copied and shallowly read-only.
67
+
68
+ The payload and nested metadata values retain their original mutability.
69
+ A missing correlation ID inherits the current context or the envelope ID.
70
+ """
71
+
72
+ event: T
73
+ id: str = field(default_factory=lambda: uuid4().hex)
74
+ correlation_id: str | None = None
75
+ source: str | None = None
76
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
77
+ metadata: Mapping[str, object] = field(default_factory=dict)
78
+
79
+ def __post_init__(self) -> None:
80
+ if not isinstance(self.id, str) or not self.id.strip():
81
+ raise ValueError("Envelope ID must be a non-empty string")
82
+ correlation = self.correlation_id
83
+ if correlation is None:
84
+ correlation = current_correlation_id() or self.id
85
+ if not isinstance(correlation, str) or not correlation.strip():
86
+ raise ValueError("Correlation ID must be a non-empty string")
87
+ if not isinstance(self.timestamp, datetime) or self.timestamp.utcoffset() is None:
88
+ raise ValueError("Envelope timestamp must be timezone-aware")
89
+ object.__setattr__(self, "timestamp", self.timestamp.astimezone(timezone.utc))
90
+ object.__setattr__(self, "correlation_id", correlation)
91
+ object.__setattr__(self, "metadata", _ReadOnlyMetadata(self.metadata))
92
+
93
+ def __deepcopy__(self, memo: dict[int, Any]) -> EventEnvelope[T]:
94
+ existing = memo.get(id(self))
95
+ if existing is not None:
96
+ return existing
97
+ copied = type(self).__new__(type(self))
98
+ memo[id(self)] = copied
99
+ for name in ("event", "id", "correlation_id", "source", "timestamp"):
100
+ object.__setattr__(copied, name, deepcopy(getattr(self, name), memo))
101
+ metadata = deepcopy(dict(self.metadata), memo)
102
+ object.__setattr__(copied, "metadata", _ReadOnlyMetadata(metadata))
103
+ return copied
eventflow/contracts.py ADDED
@@ -0,0 +1,40 @@
1
+ """Small shared contracts used by synchronous and asynchronous event buses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .compatibility import StrEnum
8
+
9
+
10
+ class ErrorPolicy(StrEnum):
11
+ """Define how subscriber failures are reported by publish operations."""
12
+
13
+ ISOLATE = "isolate"
14
+ RAISE = "raise"
15
+
16
+
17
+ class DeliveryMode(StrEnum):
18
+ """Define ordering for asynchronous subscriber delivery."""
19
+
20
+ SEQUENTIAL = "sequential"
21
+ CONCURRENT = "concurrent"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PublishResult:
26
+ matched: int = 0
27
+ delivered: int = 0
28
+ failed: int = 0
29
+ errors: tuple[Exception, ...] = ()
30
+
31
+ @property
32
+ def successful(self) -> bool:
33
+ return self.failed == 0
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class EventBusStats:
38
+ published: int = 0
39
+ delivered: int = 0
40
+ failed: int = 0