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.
- datacurrent/__init__.py +157 -0
- datacurrent/_version.py +6 -0
- datacurrent/buffering/__init__.py +8 -0
- datacurrent/buffering/backpressure.py +47 -0
- datacurrent/buffering/queue.py +304 -0
- datacurrent/concurrency/__init__.py +8 -0
- datacurrent/concurrency/limits.py +106 -0
- datacurrent/concurrency/tasks.py +86 -0
- datacurrent/config.py +86 -0
- datacurrent/core/__init__.py +17 -0
- datacurrent/core/_util.py +42 -0
- datacurrent/core/lifecycle.py +81 -0
- datacurrent/core/operators.py +844 -0
- datacurrent/core/pipeline.py +64 -0
- datacurrent/core/sink.py +70 -0
- datacurrent/core/stream.py +566 -0
- datacurrent/events/__init__.py +9 -0
- datacurrent/events/bus.py +380 -0
- datacurrent/events/routing.py +59 -0
- datacurrent/events/subscriptions.py +75 -0
- datacurrent/exceptions.py +191 -0
- datacurrent/integrations/__init__.py +18 -0
- datacurrent/integrations/fastapi.py +76 -0
- datacurrent/integrations/starlette.py +75 -0
- datacurrent/observability/__init__.py +17 -0
- datacurrent/observability/logging.py +128 -0
- datacurrent/observability/metrics.py +127 -0
- datacurrent/py.typed +1 -0
- datacurrent/reliability/__init__.py +14 -0
- datacurrent/reliability/retry.py +134 -0
- datacurrent/reliability/timeout.py +39 -0
- datacurrent/transports/__init__.py +31 -0
- datacurrent/transports/_deps.py +23 -0
- datacurrent/transports/base.py +80 -0
- datacurrent/transports/http.py +211 -0
- datacurrent/transports/memory.py +171 -0
- datacurrent/transports/sse.py +374 -0
- datacurrent/transports/websocket.py +277 -0
- datacurrent-1.0.0.dist-info/METADATA +250 -0
- datacurrent-1.0.0.dist-info/RECORD +43 -0
- datacurrent-1.0.0.dist-info/WHEEL +5 -0
- datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
- 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()
|