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,86 @@
1
+ """Structured task management: no unbounded background tasks, no leaks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Coroutine
7
+ from typing import Any, TypeVar
8
+
9
+ from ..core.lifecycle import cancel_tasks
10
+ from ..exceptions import StreamClosedError
11
+ from ..observability.logging import get_logger
12
+
13
+ T = TypeVar("T")
14
+
15
+ __all__ = ["TaskTracker"]
16
+
17
+ _log = get_logger("concurrency")
18
+
19
+
20
+ class TaskTracker:
21
+ """Track background tasks so they can always be cancelled deterministically.
22
+
23
+ DataCurrent uses this for buffering producers, ``merge()`` pumps and event
24
+ bus dispatchers. Closing the tracker cancels every task and waits for it,
25
+ which is what keeps cancellation from leaking work into a stopped loop.
26
+ """
27
+
28
+ __slots__ = ("_closed", "_name", "_tasks")
29
+
30
+ def __init__(self, *, name: str = "datacurrent") -> None:
31
+ self._tasks: set[asyncio.Task[Any]] = set()
32
+ self._name = name
33
+ self._closed = False
34
+
35
+ @property
36
+ def active(self) -> int:
37
+ return len(self._tasks)
38
+
39
+ @property
40
+ def tasks(self) -> tuple[asyncio.Task[Any], ...]:
41
+ return tuple(self._tasks)
42
+
43
+ @property
44
+ def closed(self) -> bool:
45
+ return self._closed
46
+
47
+ def spawn(
48
+ self,
49
+ coro: Awaitable[T] | Coroutine[Any, Any, T],
50
+ *,
51
+ name: str | None = None,
52
+ ) -> asyncio.Task[T]:
53
+ """Schedule ``coro`` and keep a strong reference until it finishes."""
54
+ if self._closed:
55
+ if asyncio.iscoroutine(coro):
56
+ coro.close()
57
+ raise StreamClosedError(f"task tracker {self._name!r} is closed")
58
+ task = asyncio.ensure_future(coro)
59
+ task.set_name(name or f"{self._name}-{len(self._tasks) + 1}")
60
+ self._tasks.add(task)
61
+ task.add_done_callback(self._on_done)
62
+ return task
63
+
64
+ def _on_done(self, task: asyncio.Task[Any]) -> None:
65
+ self._tasks.discard(task)
66
+ if task.cancelled():
67
+ return
68
+ error = task.exception()
69
+ if error is not None:
70
+ _log.warning("%s: background task failed: %r", self._name, error)
71
+
72
+ async def aclose(self, *, timeout: float | None = 5.0) -> None: # noqa: ASYNC109
73
+ """Cancel and await every tracked task."""
74
+ self._closed = True
75
+ tasks = list(self._tasks)
76
+ self._tasks.clear()
77
+ await cancel_tasks(tasks, timeout=timeout)
78
+
79
+ async def __aenter__(self) -> TaskTracker:
80
+ return self
81
+
82
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
83
+ await self.aclose()
84
+
85
+ def __repr__(self) -> str: # pragma: no cover - trivial
86
+ return f"TaskTracker(name={self._name!r}, active={len(self._tasks)}, closed={self._closed})"
datacurrent/config.py ADDED
@@ -0,0 +1,86 @@
1
+ """Configuration objects shared by streams, buffers, channels and the event bus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Any, Final
8
+
9
+ from .exceptions import ConfigurationError
10
+
11
+ DEFAULT_BUFFER_SIZE: Final[int] = 1024
12
+ DEFAULT_CONCURRENCY: Final[int] = 1
13
+ DEFAULT_MAX_MESSAGE_SIZE: Final[int] = 8 * 1024 * 1024
14
+ DEFAULT_TIMEOUT: Final[float] = 30.0
15
+
16
+ __all__ = [
17
+ "DEFAULT_BUFFER_SIZE",
18
+ "DEFAULT_CONCURRENCY",
19
+ "DEFAULT_MAX_MESSAGE_SIZE",
20
+ "DEFAULT_TIMEOUT",
21
+ "OverflowPolicy",
22
+ "StreamConfig",
23
+ ]
24
+
25
+
26
+ class OverflowPolicy(StrEnum):
27
+ """What a bounded buffer does when it is full.
28
+
29
+ ``BLOCK`` (the default) applies real backpressure: the producer waits.
30
+ Drops are only ever performed when explicitly configured.
31
+ """
32
+
33
+ BLOCK = "block"
34
+ DROP_NEWEST = "drop_newest"
35
+ DROP_OLDEST = "drop_oldest"
36
+ ERROR = "error"
37
+
38
+ @classmethod
39
+ def coerce(cls, value: Any) -> OverflowPolicy:
40
+ """Normalize ``value`` (enum or string) into an :class:`OverflowPolicy`."""
41
+ if isinstance(value, cls):
42
+ return value
43
+ if isinstance(value, str):
44
+ normalized = value.strip().lower().replace("-", "_")
45
+ try:
46
+ return cls(normalized)
47
+ except ValueError:
48
+ pass
49
+ allowed = ", ".join(policy.value for policy in cls)
50
+ raise ConfigurationError(f"unknown overflow policy {value!r}; expected one of: {allowed}")
51
+
52
+
53
+ def _require_int(name: str, value: Any, minimum: int) -> int:
54
+ if isinstance(value, bool) or not isinstance(value, int):
55
+ raise ConfigurationError(f"{name} must be an integer, got {type(value).__name__}")
56
+ if value < minimum:
57
+ raise ConfigurationError(f"{name} must be >= {minimum}, got {value}")
58
+ return value
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class StreamConfig:
63
+ """Immutable configuration attached to a stream.
64
+
65
+ Attributes:
66
+ name: Optional human readable name used in logs and reprs.
67
+ buffer_size: Capacity used by buffering operators such as ``buffer()``.
68
+ overflow: Policy applied when a buffer is full.
69
+ concurrency: Default worker count for operators that support it.
70
+ observe: Collect per-stream counters (items out, errors, drops, retries).
71
+ """
72
+
73
+ name: str | None = None
74
+ buffer_size: int = DEFAULT_BUFFER_SIZE
75
+ overflow: OverflowPolicy = OverflowPolicy.BLOCK
76
+ concurrency: int = DEFAULT_CONCURRENCY
77
+ observe: bool = False
78
+
79
+ def __post_init__(self) -> None:
80
+ object.__setattr__(self, "overflow", OverflowPolicy.coerce(self.overflow))
81
+ _require_int("buffer_size", self.buffer_size, 1)
82
+ _require_int("concurrency", self.concurrency, 1)
83
+ if self.name is not None and not isinstance(self.name, str):
84
+ raise ConfigurationError(
85
+ f"name must be a string or None, got {type(self.name).__name__}"
86
+ )
@@ -0,0 +1,17 @@
1
+ """Internal implementation modules for the DataCurrent core engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .pipeline import concat_sources, merge_sources, pipeline
6
+ from .sink import consume, drain_into
7
+ from .stream import Stream, StreamIterator
8
+
9
+ __all__ = [
10
+ "Stream",
11
+ "StreamIterator",
12
+ "concat_sources",
13
+ "consume",
14
+ "drain_into",
15
+ "merge_sources",
16
+ "pipeline",
17
+ ]
@@ -0,0 +1,42 @@
1
+ """Internal async helpers shared by the stream engine and operators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import AsyncIterator, Iterable
7
+ from typing import Any, TypeVar
8
+
9
+ from .lifecycle import aclose
10
+
11
+ T = TypeVar("T")
12
+
13
+ __all__ = ["aclose", "as_aiterator", "from_sync_iterable", "maybe_await"]
14
+
15
+
16
+ async def maybe_await(value: Any) -> Any:
17
+ """Await ``value`` when it is awaitable, otherwise return it unchanged.
18
+
19
+ This is what allows every operator callback to be either a plain function
20
+ or a coroutine function.
21
+ """
22
+ if inspect.isawaitable(value):
23
+ return await value
24
+ return value
25
+
26
+
27
+ async def from_sync_iterable(iterable: Iterable[T]) -> AsyncIterator[T]:
28
+ """Adapt a synchronous iterable to an async iterator without a thread."""
29
+ for item in iterable:
30
+ yield item
31
+
32
+
33
+ def as_aiterator(source: Any) -> AsyncIterator[Any]:
34
+ """Return an async iterator for an async iterable, iterable or stream."""
35
+ aiter = getattr(source, "__aiter__", None)
36
+ if aiter is not None:
37
+ return aiter()
38
+ if hasattr(source, "__iter__"):
39
+ return from_sync_iterable(source)
40
+ raise TypeError(
41
+ f"expected an async iterable, an iterable, or a Stream, got {type(source).__name__}"
42
+ )
@@ -0,0 +1,81 @@
1
+ """Deterministic cleanup helpers for async resources and tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import Iterable
8
+ from typing import Any
9
+
10
+ __all__ = ["aclose", "aclose_many", "cancel_task", "cancel_tasks"]
11
+
12
+
13
+ async def aclose(obj: Any, *, suppress: bool = True) -> None:
14
+ """Close an object that may expose ``aclose()`` or ``close()``.
15
+
16
+ Used in ``finally`` blocks of every operator so early exits (``break``,
17
+ cancellation, exceptions) never leak generators, responses or connections.
18
+ """
19
+ if obj is None:
20
+ return
21
+ close = getattr(obj, "aclose", None)
22
+ if close is None:
23
+ close = getattr(obj, "close", None)
24
+ if close is None:
25
+ return
26
+ try:
27
+ result = close()
28
+ if inspect.isawaitable(result):
29
+ await result
30
+ except asyncio.CancelledError:
31
+ raise
32
+ except Exception:
33
+ if not suppress:
34
+ raise
35
+
36
+
37
+ async def aclose_many(objs: Iterable[Any], *, suppress: bool = True) -> None:
38
+ for obj in objs:
39
+ await aclose(obj, suppress=suppress)
40
+
41
+
42
+ async def cancel_task(
43
+ task: asyncio.Task[Any] | None,
44
+ *,
45
+ timeout: float | None = None, # noqa: ASYNC109 - cleanup deadline
46
+ ) -> None:
47
+ """Cancel a single task and wait for it to finish."""
48
+ if task is None:
49
+ return
50
+ if not task.done():
51
+ task.cancel()
52
+ try:
53
+ await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
54
+ except (asyncio.CancelledError, TimeoutError):
55
+ if not task.done():
56
+ # Give the loop a chance to run the cancellation callbacks.
57
+ await asyncio.sleep(0)
58
+ except Exception:
59
+ # Exceptions are the caller's concern; cleanup must still succeed.
60
+ pass
61
+
62
+
63
+ async def cancel_tasks(
64
+ tasks: Iterable[asyncio.Task[Any]],
65
+ *,
66
+ timeout: float | None = None, # noqa: ASYNC109 - cleanup deadline
67
+ ) -> None:
68
+ """Cancel every task in ``tasks`` and wait for their completion."""
69
+ pending = [task for task in tasks if not task.done()]
70
+ for task in pending:
71
+ task.cancel()
72
+ if not pending:
73
+ return
74
+ done, still_pending = await asyncio.wait(pending, timeout=timeout)
75
+ for task in done:
76
+ if task.cancelled():
77
+ continue
78
+ # Retrieve exceptions so asyncio does not emit "never retrieved" warnings.
79
+ task.exception()
80
+ for task in still_pending:
81
+ task.cancel()