datacurrent 1.0.0__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.
Files changed (43) hide show
  1. datacurrent/__init__.py +157 -0
  2. datacurrent/_version.py +6 -0
  3. datacurrent/buffering/__init__.py +8 -0
  4. datacurrent/buffering/backpressure.py +47 -0
  5. datacurrent/buffering/queue.py +304 -0
  6. datacurrent/concurrency/__init__.py +8 -0
  7. datacurrent/concurrency/limits.py +106 -0
  8. datacurrent/concurrency/tasks.py +86 -0
  9. datacurrent/config.py +86 -0
  10. datacurrent/core/__init__.py +17 -0
  11. datacurrent/core/_util.py +42 -0
  12. datacurrent/core/lifecycle.py +81 -0
  13. datacurrent/core/operators.py +844 -0
  14. datacurrent/core/pipeline.py +64 -0
  15. datacurrent/core/sink.py +70 -0
  16. datacurrent/core/stream.py +566 -0
  17. datacurrent/events/__init__.py +9 -0
  18. datacurrent/events/bus.py +380 -0
  19. datacurrent/events/routing.py +59 -0
  20. datacurrent/events/subscriptions.py +75 -0
  21. datacurrent/exceptions.py +191 -0
  22. datacurrent/integrations/__init__.py +18 -0
  23. datacurrent/integrations/fastapi.py +76 -0
  24. datacurrent/integrations/starlette.py +75 -0
  25. datacurrent/observability/__init__.py +17 -0
  26. datacurrent/observability/logging.py +128 -0
  27. datacurrent/observability/metrics.py +127 -0
  28. datacurrent/py.typed +1 -0
  29. datacurrent/reliability/__init__.py +14 -0
  30. datacurrent/reliability/retry.py +134 -0
  31. datacurrent/reliability/timeout.py +39 -0
  32. datacurrent/transports/__init__.py +31 -0
  33. datacurrent/transports/_deps.py +23 -0
  34. datacurrent/transports/base.py +80 -0
  35. datacurrent/transports/http.py +211 -0
  36. datacurrent/transports/memory.py +171 -0
  37. datacurrent/transports/sse.py +374 -0
  38. datacurrent/transports/websocket.py +277 -0
  39. datacurrent-1.0.0.dist-info/METADATA +250 -0
  40. datacurrent-1.0.0.dist-info/RECORD +43 -0
  41. datacurrent-1.0.0.dist-info/WHEEL +5 -0
  42. datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
  43. datacurrent-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ """Event processing: envelope, routing, subscriptions and the in-memory bus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .bus import Event, EventBus
6
+ from .routing import matches
7
+ from .subscriptions import Subscription
8
+
9
+ __all__ = ["Event", "EventBus", "Subscription", "matches"]
@@ -0,0 +1,380 @@
1
+ """A small, fast, in-process event bus.
2
+
3
+ Delivery is **at most once** and entirely in memory: there is no persistence, no
4
+ acknowledgement and no replay. If that is not enough, use a durable broker
5
+ adapter; DataCurrent never pretends an in-memory bus is durable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import uuid
12
+ from collections.abc import AsyncIterator, Callable, Mapping
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from types import MappingProxyType
16
+ from typing import Any, Generic, TypeVar, overload
17
+
18
+ from ..buffering.backpressure import BufferPolicy
19
+ from ..config import DEFAULT_BUFFER_SIZE, OverflowPolicy, StreamConfig
20
+ from ..core._util import maybe_await
21
+ from ..core.stream import Stream
22
+ from ..exceptions import (
23
+ ConfigurationError,
24
+ QueueClosedError,
25
+ StreamClosedError,
26
+ )
27
+ from ..observability.logging import get_logger
28
+ from ..observability.metrics import StreamStats
29
+ from .routing import validate_pattern, validate_topic
30
+ from .subscriptions import Subscription
31
+
32
+ T = TypeVar("T")
33
+
34
+ __all__ = ["Event", "EventBus"]
35
+
36
+ _log = get_logger("events")
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Event(Generic[T]):
41
+ """An immutable event envelope."""
42
+
43
+ topic: str
44
+ data: T
45
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
46
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
47
+ metadata: Mapping[str, Any] = field(default_factory=dict)
48
+ source: str | None = None
49
+
50
+ def __post_init__(self) -> None:
51
+ if not isinstance(self.topic, str) or not self.topic:
52
+ raise ConfigurationError("event topic must be a non-empty string")
53
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata or {})))
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return {
57
+ "id": self.id,
58
+ "topic": self.topic,
59
+ "data": self.data,
60
+ "timestamp": self.timestamp.isoformat(),
61
+ "metadata": dict(self.metadata),
62
+ "source": self.source,
63
+ }
64
+
65
+
66
+ class EventBus:
67
+ """Fan-out events to handlers and streams.
68
+
69
+ Args:
70
+ buffer_size: Per-subscriber queue capacity.
71
+ overflow: Overflow policy; the default ``BLOCK`` applies backpressure.
72
+ on_error: Optional ``callable(error, subscription)`` invoked when a
73
+ handler raises. Without it, errors are logged and isolated.
74
+ name: Optional bus name used in logs.
75
+
76
+ Example::
77
+
78
+ bus = EventBus()
79
+
80
+ @bus.subscribe("user.*")
81
+ async def on_user(event):
82
+ print(event.topic, event.data)
83
+
84
+ await bus.publish("user.created", {"user_id": 123})
85
+ await bus.aclose(drain=True)
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ buffer_size: int = DEFAULT_BUFFER_SIZE,
92
+ overflow: OverflowPolicy | str = OverflowPolicy.BLOCK,
93
+ on_error: Callable[[Exception, Subscription], Any] | None = None,
94
+ name: str | None = None,
95
+ ) -> None:
96
+ if on_error is not None and not callable(on_error):
97
+ raise TypeError("on_error must be callable or None")
98
+ self._policy = BufferPolicy(size=buffer_size, overflow=OverflowPolicy.coerce(overflow))
99
+ self._on_error = on_error
100
+ self._name = name
101
+ self._subscriptions: list[Subscription] = []
102
+ self._closed = False
103
+ self._published = 0
104
+ self._delivered = 0
105
+ self._failed = 0
106
+
107
+ # ------------------------------------------------------------------ #
108
+ # Subscriptions
109
+ # ------------------------------------------------------------------ #
110
+ @overload
111
+ def subscribe( # pragma: no cover - typing overload
112
+ self,
113
+ topic: str,
114
+ handler: Callable[[Event[Any]], Any],
115
+ *,
116
+ buffer_size: int | None = None,
117
+ overflow: OverflowPolicy | str | None = None,
118
+ ) -> Subscription: ...
119
+
120
+ @overload
121
+ def subscribe( # pragma: no cover - typing overload
122
+ self,
123
+ topic: str = "*",
124
+ handler: None = None,
125
+ *,
126
+ buffer_size: int | None = None,
127
+ overflow: OverflowPolicy | str | None = None,
128
+ ) -> Callable[[Callable[[Event[Any]], Any]], Subscription]: ...
129
+
130
+ def subscribe(
131
+ self,
132
+ topic: str = "*",
133
+ handler: Callable[[Event[Any]], Any] | None = None,
134
+ *,
135
+ buffer_size: int | None = None,
136
+ overflow: OverflowPolicy | str | None = None,
137
+ ) -> Subscription | Callable[[Callable[[Event[Any]], Any]], Subscription]:
138
+ """Subscribe a handler. Also works as a decorator.
139
+
140
+ The handler receives an :class:`Event` and may be sync or async.
141
+ Handler errors are isolated per event and reported through ``on_error``.
142
+ """
143
+ validate_pattern(topic)
144
+ if handler is None:
145
+
146
+ def decorator(func: Callable[[Event[Any]], Any]) -> Subscription:
147
+ return self._add_subscription(topic, func, buffer_size, overflow)
148
+
149
+ return decorator
150
+ if not callable(handler):
151
+ raise TypeError(f"handler must be callable, got {type(handler).__name__}")
152
+ return self._add_subscription(topic, handler, buffer_size, overflow)
153
+
154
+ def _add_subscription(
155
+ self,
156
+ topic: str,
157
+ handler: Callable[[Event[Any]], Any] | None,
158
+ buffer_size: int | None,
159
+ overflow: OverflowPolicy | str | None,
160
+ ) -> Subscription:
161
+ if self._closed:
162
+ raise StreamClosedError(f"event bus {self._name!r} is closed")
163
+ resolved_overflow = OverflowPolicy.coerce(
164
+ self._policy.overflow if overflow is None else overflow
165
+ )
166
+ policy = BufferPolicy(
167
+ size=self._policy.size if buffer_size is None else buffer_size,
168
+ overflow=resolved_overflow,
169
+ )
170
+ queue = policy.build(name=f"eventbus:{topic}")
171
+ subscription = Subscription(topic=topic, queue=queue, handler=handler, bus=self)
172
+ self._subscriptions.append(subscription)
173
+ if handler is not None:
174
+ self._ensure_worker(subscription)
175
+ return subscription
176
+
177
+ def stream(
178
+ self,
179
+ topic: str = "*",
180
+ *,
181
+ buffer_size: int | None = None,
182
+ overflow: OverflowPolicy | str | None = None,
183
+ name: str | None = None,
184
+ ) -> Stream[Event[Any]]:
185
+ """Consume matching events as a single-consumer stream."""
186
+ subscription = self._add_subscription(topic, None, buffer_size, overflow)
187
+ stats = StreamStats(name=name or f"eventbus:{topic}")
188
+ config = StreamConfig(
189
+ name=name or f"eventbus:{topic}",
190
+ buffer_size=self._policy.size if buffer_size is None else buffer_size,
191
+ overflow=OverflowPolicy.coerce(self._policy.overflow if overflow is None else overflow),
192
+ observe=True,
193
+ )
194
+
195
+ async def factory() -> AsyncIterator[Event[Any]]:
196
+ try:
197
+ async for event in subscription.queue:
198
+ stats.items_out += 1
199
+ yield event
200
+ finally:
201
+ await self.unsubscribe(subscription)
202
+
203
+ stream: Stream[Event[Any]] = Stream(factory, config=config, replayable=False)
204
+ stream._stats = stats
205
+ return stream
206
+
207
+ async def unsubscribe(self, subscription: Subscription) -> None:
208
+ """Remove a subscription; safe to call more than once."""
209
+ if subscription in self._subscriptions:
210
+ self._subscriptions.remove(subscription)
211
+ await subscription.aclose()
212
+
213
+ @property
214
+ def subscriber_count(self) -> int:
215
+ return sum(1 for subscription in self._subscriptions if subscription.active)
216
+
217
+ @property
218
+ def subscriptions(self) -> tuple[Subscription, ...]:
219
+ return tuple(self._subscriptions)
220
+
221
+ # ------------------------------------------------------------------ #
222
+ # Publishing
223
+ # ------------------------------------------------------------------ #
224
+ async def publish(
225
+ self,
226
+ topic: str,
227
+ data: Any = None,
228
+ *,
229
+ metadata: Mapping[str, Any] | None = None,
230
+ event_id: str | None = None,
231
+ source: str | None = None,
232
+ ) -> int:
233
+ """Publish ``data`` on ``topic``; returns the number of subscribers reached."""
234
+ event: Event[Any] = Event(
235
+ topic=validate_topic(topic),
236
+ data=data,
237
+ metadata=dict(metadata or {}),
238
+ id=event_id or uuid.uuid4().hex,
239
+ source=source,
240
+ )
241
+ return await self.publish_event(event)
242
+
243
+ async def publish_event(self, event: Event[Any]) -> int:
244
+ """Publish a pre-built event."""
245
+ if self._closed:
246
+ raise StreamClosedError(f"event bus {self._name!r} is closed")
247
+ if not isinstance(event, Event):
248
+ raise TypeError(f"publish_event() expects an Event, got {type(event).__name__}")
249
+ self._published += 1
250
+ delivered = 0
251
+ for subscription in list(self._subscriptions):
252
+ if not subscription.active or not subscription.matches(event.topic):
253
+ continue
254
+ if subscription.handler is not None:
255
+ self._ensure_worker(subscription)
256
+ try:
257
+ stored = await subscription.queue.put(event)
258
+ except QueueClosedError:
259
+ subscription.active = False
260
+ continue
261
+ if stored:
262
+ delivered += 1
263
+ self._delivered += delivered
264
+ return delivered
265
+
266
+ def _ensure_worker(self, subscription: Subscription) -> None:
267
+ if subscription.task is not None and not subscription.task.done():
268
+ return
269
+ try:
270
+ loop = asyncio.get_running_loop()
271
+ except RuntimeError: # pragma: no cover - publish() always runs in a loop
272
+ return
273
+ subscription.task = loop.create_task(
274
+ self._dispatch(subscription),
275
+ name=f"datacurrent-eventbus-{subscription.topic}",
276
+ )
277
+
278
+ async def _dispatch(self, subscription: Subscription) -> None:
279
+ handler = subscription.handler
280
+ if handler is None: # pragma: no cover - stream subscriptions have no worker
281
+ return
282
+ try:
283
+ async for event in subscription.queue:
284
+ subscription.in_flight += 1
285
+ try:
286
+ await maybe_await(handler(event))
287
+ subscription.delivered += 1
288
+ except asyncio.CancelledError:
289
+ raise
290
+ except Exception as error:
291
+ subscription.failed += 1
292
+ self._failed += 1
293
+ await self._report(error, subscription)
294
+ finally:
295
+ subscription.in_flight -= 1
296
+ except asyncio.CancelledError:
297
+ raise
298
+ except Exception as error: # pragma: no cover - queue surfaced a failure
299
+ await self._report(error, subscription)
300
+
301
+ async def _report(self, error: Exception, subscription: Subscription) -> None:
302
+ if self._on_error is not None:
303
+ try:
304
+ await maybe_await(self._on_error(error, subscription))
305
+ return
306
+ except asyncio.CancelledError:
307
+ raise
308
+ except Exception:
309
+ _log.exception("on_error handler failed")
310
+ return
311
+ _log.error(
312
+ "event handler failed topic=%s subscription=%r: %r",
313
+ subscription.topic,
314
+ subscription,
315
+ error,
316
+ )
317
+
318
+ # ------------------------------------------------------------------ #
319
+ # Lifecycle
320
+ # ------------------------------------------------------------------ #
321
+ async def aclose(
322
+ self,
323
+ *,
324
+ drain: bool = False,
325
+ timeout: float = 5.0, # noqa: ASYNC109 - drain deadline, not a single await
326
+ ) -> None:
327
+ """Close the bus.
328
+
329
+ With ``drain=True`` queued events are processed before shutting down.
330
+ """
331
+ if self._closed:
332
+ return
333
+ if drain:
334
+ await self._drain(timeout)
335
+ self._closed = True
336
+ subscriptions = list(self._subscriptions)
337
+ self._subscriptions.clear()
338
+ for subscription in subscriptions:
339
+ await subscription.aclose()
340
+
341
+ async def _drain(self, timeout: float) -> None: # noqa: ASYNC109 - polling deadline
342
+ loop = asyncio.get_running_loop()
343
+ deadline = loop.time() + timeout
344
+ while True:
345
+ pending = [
346
+ subscription
347
+ for subscription in self._subscriptions
348
+ if subscription.active
349
+ and (subscription.queue.qsize() > 0 or subscription.in_flight > 0)
350
+ ]
351
+ if not pending:
352
+ return
353
+ if loop.time() >= deadline:
354
+ _log.warning("event bus drain timed out with %d queued event(s)", len(pending))
355
+ return
356
+ await asyncio.sleep(0.005)
357
+
358
+ async def __aenter__(self) -> EventBus:
359
+ return self
360
+
361
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
362
+ await self.aclose(drain=True)
363
+
364
+ def stats(self) -> dict[str, Any]:
365
+ dropped = sum(subscription.queue.stats().dropped for subscription in self._subscriptions)
366
+ return {
367
+ "name": self._name,
368
+ "published": self._published,
369
+ "delivered": self._delivered,
370
+ "failed": self._failed,
371
+ "dropped": dropped,
372
+ "subscribers": self.subscriber_count,
373
+ "closed": self._closed,
374
+ }
375
+
376
+ def __repr__(self) -> str:
377
+ return (
378
+ f"EventBus(name={self._name!r}, subscribers={self.subscriber_count}, "
379
+ f"published={self._published})"
380
+ )
@@ -0,0 +1,59 @@
1
+ """Topic matching for the event bus.
2
+
3
+ Topics are dot-separated strings such as ``user.created``. Patterns support
4
+ exact segments, ``*`` for exactly one segment and a trailing ``**`` (or ``#``)
5
+ for any remaining segments. ``*`` alone matches every topic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..exceptions import ConfigurationError
11
+
12
+ __all__ = ["matches", "validate_pattern", "validate_topic"]
13
+
14
+
15
+ def _validate(value: str, *, name: str) -> str:
16
+ if not isinstance(value, str):
17
+ raise ConfigurationError(f"{name} must be a string, got {type(value).__name__}")
18
+ if not value or value.strip() != value:
19
+ raise ConfigurationError(f"{name} must be non-empty and have no surrounding whitespace")
20
+ return value
21
+
22
+
23
+ def validate_topic(topic: str) -> str:
24
+ """Validate an event topic."""
25
+ return _validate(topic, name="topic")
26
+
27
+
28
+ def validate_pattern(pattern: str) -> str:
29
+ """Validate a subscription pattern."""
30
+ return _validate(pattern, name="pattern")
31
+
32
+
33
+ def matches(pattern: str, topic: str) -> bool:
34
+ """Return whether ``topic`` matches the subscription ``pattern``.
35
+
36
+ Examples:
37
+ >>> matches("user.created", "user.created")
38
+ True
39
+ >>> matches("user.*", "user.created")
40
+ True
41
+ >>> matches("user.*", "user.created.v2")
42
+ False
43
+ >>> matches("user.**", "user.created.v2")
44
+ True
45
+ >>> matches("*", "anything.at.all")
46
+ True
47
+ """
48
+ if pattern in ("*", "#", "**"):
49
+ return True
50
+ pattern_parts = pattern.split(".")
51
+ topic_parts = topic.split(".")
52
+ for index, part in enumerate(pattern_parts):
53
+ if part in ("**", "#"):
54
+ return index == len(pattern_parts) - 1
55
+ if index >= len(topic_parts):
56
+ return False
57
+ if part != "*" and part != topic_parts[index]:
58
+ return False
59
+ return len(pattern_parts) == len(topic_parts)
@@ -0,0 +1,75 @@
1
+ """Subscriptions for the in-memory event bus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from ..buffering.queue import BoundedQueue
11
+ from ..exceptions import QueueClosedError
12
+ from .routing import matches
13
+
14
+ if TYPE_CHECKING:
15
+ from .bus import EventBus
16
+
17
+ __all__ = ["Subscription"]
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Subscription:
22
+ """A single subscriber: a topic pattern, a bounded queue and a handler.
23
+
24
+ ``handler`` is ``None`` for stream subscriptions, where the application
25
+ consumes the queue directly through ``EventBus.stream()``.
26
+ """
27
+
28
+ topic: str
29
+ queue: BoundedQueue[Any]
30
+ handler: Callable[[Any], Any] | None
31
+ bus: EventBus
32
+ task: asyncio.Task[Any] | None = None
33
+ active: bool = True
34
+ delivered: int = 0
35
+ failed: int = 0
36
+ in_flight: int = 0
37
+ metadata: dict[str, Any] = field(default_factory=dict)
38
+
39
+ def matches(self, topic: str) -> bool:
40
+ return matches(self.topic, topic)
41
+
42
+ async def aclose(self) -> None:
43
+ """Stop delivery for this subscription and release its queue."""
44
+ if not self.active and self.task is None:
45
+ return
46
+ self.active = False
47
+ task, self.task = self.task, None
48
+ if task is not None and not task.done():
49
+ task.cancel()
50
+ try:
51
+ await asyncio.gather(task, return_exceptions=True)
52
+ except asyncio.CancelledError:
53
+ raise
54
+ try:
55
+ await self.queue.aclose()
56
+ except QueueClosedError: # pragma: no cover - already closed
57
+ pass
58
+
59
+ def stats(self) -> dict[str, Any]:
60
+ buffer = self.queue.stats()
61
+ return {
62
+ "topic": self.topic,
63
+ "active": self.active,
64
+ "delivered": self.delivered,
65
+ "failed": self.failed,
66
+ "queued": buffer.size,
67
+ "dropped": buffer.dropped,
68
+ "high_watermark": buffer.high_watermark,
69
+ }
70
+
71
+ def __repr__(self) -> str: # pragma: no cover - trivial
72
+ return (
73
+ f"Subscription(topic={self.topic!r}, active={self.active}, "
74
+ f"delivered={self.delivered}, failed={self.failed})"
75
+ )