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,134 @@
|
|
|
1
|
+
"""Retry policies with exponential backoff and jitter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from ..exceptions import ConfigurationError
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
__all__ = ["RetryPolicy", "is_retryable", "retry_async"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_retryable(predicate: Any, error: BaseException) -> bool:
|
|
19
|
+
"""Return whether ``error`` matches a retry predicate.
|
|
20
|
+
|
|
21
|
+
``predicate`` may be an exception class, a tuple of classes, or a callable
|
|
22
|
+
taking the exception and returning a bool.
|
|
23
|
+
"""
|
|
24
|
+
if predicate is None:
|
|
25
|
+
return True
|
|
26
|
+
if isinstance(predicate, tuple):
|
|
27
|
+
return isinstance(error, predicate)
|
|
28
|
+
if isinstance(predicate, type) and issubclass(predicate, BaseException):
|
|
29
|
+
return isinstance(error, predicate)
|
|
30
|
+
if callable(predicate):
|
|
31
|
+
return bool(predicate(error))
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
f"retry predicate must be an exception class, tuple or callable, got {predicate!r}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class RetryPolicy:
|
|
39
|
+
"""Declarative retry configuration.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
max_attempts: Total attempts including the initial one (>= 1).
|
|
43
|
+
base_delay: Delay before the first retry, in seconds.
|
|
44
|
+
max_delay: Upper bound for a single delay, in seconds.
|
|
45
|
+
multiplier: Exponential growth factor (>= 1).
|
|
46
|
+
jitter: Randomization fraction in ``[0, 1]``; 0 disables jitter.
|
|
47
|
+
retry_on: Exception class, tuple of classes or predicate.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
max_attempts: int = 3
|
|
51
|
+
base_delay: float = 0.1
|
|
52
|
+
max_delay: float = 10.0
|
|
53
|
+
multiplier: float = 2.0
|
|
54
|
+
jitter: float = 0.1
|
|
55
|
+
retry_on: Any = Exception
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if isinstance(self.max_attempts, bool) or not isinstance(self.max_attempts, int):
|
|
59
|
+
raise ConfigurationError("max_attempts must be an integer")
|
|
60
|
+
if self.max_attempts < 1:
|
|
61
|
+
raise ConfigurationError("max_attempts must be >= 1")
|
|
62
|
+
for name in ("base_delay", "max_delay", "multiplier", "jitter"):
|
|
63
|
+
value = getattr(self, name)
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
65
|
+
raise ConfigurationError(f"{name} must be a number")
|
|
66
|
+
if value < 0:
|
|
67
|
+
raise ConfigurationError(f"{name} must be >= 0")
|
|
68
|
+
if self.multiplier < 1:
|
|
69
|
+
raise ConfigurationError("multiplier must be >= 1")
|
|
70
|
+
if self.jitter > 1:
|
|
71
|
+
raise ConfigurationError("jitter must be between 0 and 1")
|
|
72
|
+
|
|
73
|
+
def should_retry(self, error: BaseException) -> bool:
|
|
74
|
+
return is_retryable(self.retry_on, error)
|
|
75
|
+
|
|
76
|
+
def delay_for(self, attempt: int, *, rng: random.Random | None = None) -> float:
|
|
77
|
+
"""Delay before retry number ``attempt`` (1 = first retry)."""
|
|
78
|
+
if attempt < 1:
|
|
79
|
+
raise ConfigurationError("attempt must be >= 1")
|
|
80
|
+
if self.base_delay == 0:
|
|
81
|
+
return 0.0
|
|
82
|
+
exponent = attempt - 1
|
|
83
|
+
delay = min(float(self.max_delay), float(self.base_delay) * (self.multiplier**exponent))
|
|
84
|
+
if self.jitter:
|
|
85
|
+
source = rng.random() if rng is not None else random.random()
|
|
86
|
+
spread = delay * self.jitter
|
|
87
|
+
delay = delay - spread + (2 * spread * source)
|
|
88
|
+
return max(0.0, delay)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def coerce(cls, value: RetryPolicy | int | Mapping[str, Any] | None) -> RetryPolicy:
|
|
92
|
+
"""Build a policy from a policy, an attempt count or a mapping."""
|
|
93
|
+
if isinstance(value, cls):
|
|
94
|
+
return value
|
|
95
|
+
if value is None:
|
|
96
|
+
return cls()
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
raise ConfigurationError("retry policy must not be a bool")
|
|
99
|
+
if isinstance(value, int):
|
|
100
|
+
return cls(max_attempts=value)
|
|
101
|
+
if isinstance(value, Mapping):
|
|
102
|
+
return cls(**dict(value))
|
|
103
|
+
raise ConfigurationError(
|
|
104
|
+
f"cannot interpret {value!r} as a RetryPolicy; pass RetryPolicy(...), "
|
|
105
|
+
"an integer attempt count, or a mapping of policy fields"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def retry_async(
|
|
110
|
+
func: Callable[[], Awaitable[T]],
|
|
111
|
+
policy: RetryPolicy | int | Mapping[str, Any] | None = None,
|
|
112
|
+
*,
|
|
113
|
+
on: Any = None,
|
|
114
|
+
) -> T:
|
|
115
|
+
"""Call ``func`` until it succeeds or the policy is exhausted.
|
|
116
|
+
|
|
117
|
+
The final error is re-raised unchanged, so callers keep the original
|
|
118
|
+
traceback and exception type.
|
|
119
|
+
"""
|
|
120
|
+
resolved = RetryPolicy.coerce(policy)
|
|
121
|
+
predicate = resolved.retry_on if on is None else on
|
|
122
|
+
attempt = 1
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
return await func()
|
|
126
|
+
except asyncio.CancelledError:
|
|
127
|
+
raise
|
|
128
|
+
except Exception as error:
|
|
129
|
+
if attempt >= resolved.max_attempts or not is_retryable(predicate, error):
|
|
130
|
+
raise
|
|
131
|
+
delay = resolved.delay_for(attempt)
|
|
132
|
+
attempt += 1
|
|
133
|
+
if delay:
|
|
134
|
+
await asyncio.sleep(delay)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Timeout helpers that raise DataCurrent's timeout error type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ConfigurationError, StreamTimeoutError
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
__all__ = ["validate_timeout", "with_timeout"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_timeout(seconds: Any, *, name: str = "timeout") -> float:
|
|
17
|
+
if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
|
|
18
|
+
raise ConfigurationError(f"{name} must be a number, got {type(seconds).__name__}")
|
|
19
|
+
if seconds <= 0:
|
|
20
|
+
raise ConfigurationError(f"{name} must be > 0, got {seconds}")
|
|
21
|
+
return float(seconds)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def with_timeout(
|
|
25
|
+
awaitable: Awaitable[T],
|
|
26
|
+
seconds: float,
|
|
27
|
+
*,
|
|
28
|
+
what: str = "operation",
|
|
29
|
+
) -> T:
|
|
30
|
+
"""Await ``awaitable`` for at most ``seconds``.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
StreamTimeoutError: The deadline elapsed.
|
|
34
|
+
"""
|
|
35
|
+
timeout = validate_timeout(seconds)
|
|
36
|
+
try:
|
|
37
|
+
return await asyncio.wait_for(awaitable, timeout=timeout)
|
|
38
|
+
except TimeoutError:
|
|
39
|
+
raise StreamTimeoutError(f"{what} exceeded {timeout:g}s") from None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Transports: in-memory fan-out, SSE, HTTP streaming and WebSocket clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import StreamTransport
|
|
6
|
+
from .http import HTTPStream
|
|
7
|
+
from .memory import Channel
|
|
8
|
+
from .sse import (
|
|
9
|
+
SSEEvent,
|
|
10
|
+
SSELineDecoder,
|
|
11
|
+
SSEParser,
|
|
12
|
+
SSEStream,
|
|
13
|
+
format_heartbeat,
|
|
14
|
+
format_sse,
|
|
15
|
+
parse_sse,
|
|
16
|
+
)
|
|
17
|
+
from .websocket import WebSocketStream
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Channel",
|
|
21
|
+
"HTTPStream",
|
|
22
|
+
"SSEEvent",
|
|
23
|
+
"SSELineDecoder",
|
|
24
|
+
"SSEParser",
|
|
25
|
+
"SSEStream",
|
|
26
|
+
"StreamTransport",
|
|
27
|
+
"WebSocketStream",
|
|
28
|
+
"format_heartbeat",
|
|
29
|
+
"format_sse",
|
|
30
|
+
"parse_sse",
|
|
31
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Lazy imports for optional transport dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
|
|
8
|
+
from ..exceptions import OptionalDependencyError
|
|
9
|
+
|
|
10
|
+
__all__ = ["require_module"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def require_module(
|
|
14
|
+
name: str,
|
|
15
|
+
*,
|
|
16
|
+
extra: str | None = None,
|
|
17
|
+
purpose: str,
|
|
18
|
+
) -> ModuleType:
|
|
19
|
+
"""Import a dependency or raise a helpful install error."""
|
|
20
|
+
try:
|
|
21
|
+
return importlib.import_module(name)
|
|
22
|
+
except ImportError as error: # pragma: no cover - depends on environment
|
|
23
|
+
raise OptionalDependencyError(name.split(".")[0], extra=extra, purpose=purpose) from error
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Extension point for custom transports.
|
|
2
|
+
|
|
3
|
+
A transport is just an object that produces messages and can be closed. Build
|
|
4
|
+
one when your protocol is not covered by the SSE, HTTP or WebSocket adapters::
|
|
5
|
+
|
|
6
|
+
class MyTransport(StreamTransport[dict]):
|
|
7
|
+
async def open(self): ...
|
|
8
|
+
async def close(self): ...
|
|
9
|
+
async def messages(self): yield {"...": "..."}
|
|
10
|
+
|
|
11
|
+
stream = MyTransport().as_stream()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
from typing import Any, Generic, TypeVar
|
|
19
|
+
|
|
20
|
+
from ..core.stream import Stream
|
|
21
|
+
from ..exceptions import StreamClosedError
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
__all__ = ["StreamTransport"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StreamTransport(ABC, Generic[T]):
|
|
29
|
+
"""Base class for transports that expose an async stream of messages."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, name: str | None = None) -> None:
|
|
32
|
+
self.name = name or type(self).__name__
|
|
33
|
+
self._opened = False
|
|
34
|
+
self._closed = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_open(self) -> bool:
|
|
38
|
+
return self._opened and not self._closed
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def open(self) -> None:
|
|
42
|
+
"""Establish the connection / start producing."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
"""Release every resource. Must be idempotent."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def messages(self) -> AsyncIterator[T]:
|
|
50
|
+
"""Yield messages until the transport is closed."""
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> StreamTransport[T]:
|
|
53
|
+
await self.open()
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
57
|
+
await self.close()
|
|
58
|
+
|
|
59
|
+
def as_stream(self, **stream_kwargs: Any) -> Stream[T]:
|
|
60
|
+
"""Expose the transport as a single-consumer stream.
|
|
61
|
+
|
|
62
|
+
Closing the stream closes the transport, so ``break`` cannot leak a
|
|
63
|
+
connection.
|
|
64
|
+
"""
|
|
65
|
+
transport = self
|
|
66
|
+
|
|
67
|
+
async def factory() -> AsyncIterator[T]:
|
|
68
|
+
if not transport.is_open:
|
|
69
|
+
await transport.open()
|
|
70
|
+
try:
|
|
71
|
+
async for message in transport.messages():
|
|
72
|
+
yield message
|
|
73
|
+
finally:
|
|
74
|
+
await transport.close()
|
|
75
|
+
|
|
76
|
+
return Stream(factory, replayable=False, **stream_kwargs)
|
|
77
|
+
|
|
78
|
+
async def _ensure_open(self) -> None:
|
|
79
|
+
if self._closed:
|
|
80
|
+
raise StreamClosedError(f"transport {self.name!r} is closed")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""HTTP streaming over ``httpx``.
|
|
2
|
+
|
|
3
|
+
Use :class:`HTTPStream` to consume slow endpoints, streaming downloads or
|
|
4
|
+
newline-delimited JSON without buffering the whole response in memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..config import DEFAULT_TIMEOUT
|
|
13
|
+
from ..core.stream import Stream
|
|
14
|
+
from ..exceptions import ConfigurationError, MessageTooLargeError, StreamClosedError
|
|
15
|
+
from ._deps import require_module
|
|
16
|
+
|
|
17
|
+
__all__ = ["HTTPStream"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HTTPStream:
|
|
21
|
+
"""An HTTP response consumed incrementally.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
async with HTTPStream("https://example.com/feed.ndjson") as response:
|
|
26
|
+
async for line in response.iter_lines():
|
|
27
|
+
print(line)
|
|
28
|
+
|
|
29
|
+
The object must be opened with ``async with``; closing it releases the
|
|
30
|
+
response and the owned HTTP client.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
url: str,
|
|
36
|
+
*,
|
|
37
|
+
method: str = "GET",
|
|
38
|
+
headers: Mapping[str, str] | None = None,
|
|
39
|
+
params: Mapping[str, Any] | None = None,
|
|
40
|
+
json: Any = None,
|
|
41
|
+
data: Any = None,
|
|
42
|
+
content: Any = None,
|
|
43
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
44
|
+
chunk_size: int = 64 * 1024,
|
|
45
|
+
client: Any = None,
|
|
46
|
+
max_bytes: int | None = None,
|
|
47
|
+
raise_for_status: bool = True,
|
|
48
|
+
follow_redirects: bool = True,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
|
51
|
+
raise ConfigurationError("HTTPStream requires an http:// or https:// URL")
|
|
52
|
+
if isinstance(chunk_size, bool) or not isinstance(chunk_size, int) or chunk_size < 1:
|
|
53
|
+
raise ConfigurationError("chunk_size must be an integer >= 1")
|
|
54
|
+
if max_bytes is not None and max_bytes < 1:
|
|
55
|
+
raise ConfigurationError("max_bytes must be >= 1 when provided")
|
|
56
|
+
self._url = url
|
|
57
|
+
self._method = method.upper()
|
|
58
|
+
self._headers = dict(headers or {})
|
|
59
|
+
self._params = dict(params) if params else None
|
|
60
|
+
self._json = json
|
|
61
|
+
self._data = data
|
|
62
|
+
self._content = content
|
|
63
|
+
self._timeout = float(timeout)
|
|
64
|
+
self._chunk_size = chunk_size
|
|
65
|
+
self._client = client
|
|
66
|
+
self._owned_client: Any = None
|
|
67
|
+
self._max_bytes = max_bytes
|
|
68
|
+
self._raise_for_status = raise_for_status
|
|
69
|
+
self._follow_redirects = follow_redirects
|
|
70
|
+
self._response: Any = None
|
|
71
|
+
self._closed = False
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------ #
|
|
74
|
+
# Lifecycle
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
76
|
+
@classmethod
|
|
77
|
+
def get(cls, url: str, **kwargs: Any) -> HTTPStream:
|
|
78
|
+
"""Convenience constructor for GET requests."""
|
|
79
|
+
kwargs.setdefault("method", "GET")
|
|
80
|
+
return cls(url, **kwargs)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def url(self) -> str:
|
|
84
|
+
return self._url
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def status_code(self) -> int:
|
|
88
|
+
return int(self._require_response().status_code)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def response(self) -> Any:
|
|
92
|
+
return self._require_response()
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self) -> HTTPStream:
|
|
95
|
+
httpx = require_module("httpx", purpose="HTTPStream")
|
|
96
|
+
if self._client is not None:
|
|
97
|
+
client = self._client
|
|
98
|
+
else:
|
|
99
|
+
client = httpx.AsyncClient(
|
|
100
|
+
follow_redirects=self._follow_redirects,
|
|
101
|
+
timeout=self._timeout,
|
|
102
|
+
)
|
|
103
|
+
self._owned_client = client
|
|
104
|
+
self._client = client
|
|
105
|
+
request = client.build_request(
|
|
106
|
+
self._method,
|
|
107
|
+
self._url,
|
|
108
|
+
headers=self._headers,
|
|
109
|
+
params=self._params,
|
|
110
|
+
json=self._json,
|
|
111
|
+
data=self._data,
|
|
112
|
+
content=self._content,
|
|
113
|
+
timeout=self._timeout,
|
|
114
|
+
)
|
|
115
|
+
self._response = await client.send(request, stream=True)
|
|
116
|
+
if self._raise_for_status and self._response.is_error:
|
|
117
|
+
await self._response.aread()
|
|
118
|
+
try:
|
|
119
|
+
self._response.raise_for_status()
|
|
120
|
+
finally:
|
|
121
|
+
await self._close_response()
|
|
122
|
+
await self._close_owned_client()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
126
|
+
await self.aclose()
|
|
127
|
+
|
|
128
|
+
async def _close_response(self) -> None:
|
|
129
|
+
response, self._response = self._response, None
|
|
130
|
+
if response is not None:
|
|
131
|
+
await response.aclose()
|
|
132
|
+
|
|
133
|
+
async def _close_owned_client(self) -> None:
|
|
134
|
+
client, self._owned_client = self._owned_client, None
|
|
135
|
+
if client is not None:
|
|
136
|
+
await client.aclose()
|
|
137
|
+
|
|
138
|
+
async def aclose(self) -> None:
|
|
139
|
+
"""Close the response and the owned client. Idempotent."""
|
|
140
|
+
if self._closed:
|
|
141
|
+
return
|
|
142
|
+
self._closed = True
|
|
143
|
+
await self._close_response()
|
|
144
|
+
await self._close_owned_client()
|
|
145
|
+
|
|
146
|
+
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
147
|
+
return self.iter_bytes()
|
|
148
|
+
|
|
149
|
+
def _require_response(self) -> Any:
|
|
150
|
+
if self._response is None:
|
|
151
|
+
raise StreamClosedError(
|
|
152
|
+
"HTTPStream is not open; use 'async with HTTPStream(...) as response'"
|
|
153
|
+
)
|
|
154
|
+
return self._response
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ #
|
|
157
|
+
# Body iteration
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
async def iter_bytes(self, *, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
|
160
|
+
"""Yield decoded response chunks as they arrive."""
|
|
161
|
+
response = self._require_response()
|
|
162
|
+
size = self._chunk_size if chunk_size is None else chunk_size
|
|
163
|
+
async for chunk in response.aiter_bytes(size):
|
|
164
|
+
yield chunk
|
|
165
|
+
|
|
166
|
+
async def iter_text(self, encoding: str = "utf-8") -> AsyncIterator[str]:
|
|
167
|
+
"""Yield text chunks decoded with ``encoding``."""
|
|
168
|
+
async for chunk in self.iter_bytes():
|
|
169
|
+
yield chunk.decode(encoding, errors="replace")
|
|
170
|
+
|
|
171
|
+
async def iter_lines(self) -> AsyncIterator[str]:
|
|
172
|
+
"""Yield response lines without their terminators."""
|
|
173
|
+
response = self._require_response()
|
|
174
|
+
async for line in response.aiter_lines():
|
|
175
|
+
yield line
|
|
176
|
+
|
|
177
|
+
async def read_all(self, *, max_bytes: int | None = None) -> bytes:
|
|
178
|
+
"""Read the whole body, honouring the configured byte limit."""
|
|
179
|
+
limit = self._max_bytes if max_bytes is None else max_bytes
|
|
180
|
+
chunks: list[bytes] = []
|
|
181
|
+
total = 0
|
|
182
|
+
async for chunk in self.iter_bytes():
|
|
183
|
+
total += len(chunk)
|
|
184
|
+
if limit is not None and total > limit:
|
|
185
|
+
raise MessageTooLargeError(total, limit)
|
|
186
|
+
chunks.append(chunk)
|
|
187
|
+
return b"".join(chunks)
|
|
188
|
+
|
|
189
|
+
def as_stream(self, *, config: Any = None) -> Stream[bytes]:
|
|
190
|
+
"""Expose the body as a stream of chunks.
|
|
191
|
+
|
|
192
|
+
The stream owns the response and closes it when iteration ends.
|
|
193
|
+
"""
|
|
194
|
+
http = self
|
|
195
|
+
|
|
196
|
+
async def factory() -> AsyncIterator[bytes]:
|
|
197
|
+
if http._response is None:
|
|
198
|
+
await http.__aenter__()
|
|
199
|
+
try:
|
|
200
|
+
async for chunk in http.iter_bytes():
|
|
201
|
+
yield chunk
|
|
202
|
+
finally:
|
|
203
|
+
await http.aclose()
|
|
204
|
+
|
|
205
|
+
return Stream(factory, config=config, replayable=False)
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
208
|
+
return (
|
|
209
|
+
f"HTTPStream(method={self._method!r}, url={self._url!r}, "
|
|
210
|
+
f"open={self._response is not None})"
|
|
211
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""In-memory fan-out with explicit delivery semantics.
|
|
2
|
+
|
|
3
|
+
Use :class:`Channel` when several consumers must each receive every message.
|
|
4
|
+
A ``Stream`` is single-consumer; a channel gives every subscriber its own bounded
|
|
5
|
+
queue. Delivery is *at most once* and lives only in this process: a subscriber
|
|
6
|
+
that is not connected when a message is published will not see it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from ..buffering.backpressure import BufferPolicy
|
|
14
|
+
from ..buffering.queue import BoundedQueue
|
|
15
|
+
from ..config import OverflowPolicy, StreamConfig
|
|
16
|
+
from ..core.stream import Stream
|
|
17
|
+
from ..exceptions import QueueClosedError, StreamClosedError
|
|
18
|
+
from ..observability.metrics import StreamStats
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
__all__ = ["Channel"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Channel(Generic[T]):
|
|
26
|
+
"""A bounded broadcast channel.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
buffer_size: Per-subscriber queue capacity.
|
|
30
|
+
overflow: Policy used when a subscriber queue is full. The default
|
|
31
|
+
``BLOCK`` applies backpressure to publishers instead of losing data.
|
|
32
|
+
name: Optional name used in reprs and errors.
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
channel = Channel(buffer_size=32)
|
|
37
|
+
first = channel.subscribe()
|
|
38
|
+
second = channel.subscribe()
|
|
39
|
+
|
|
40
|
+
await channel.publish({"n": 1}) # both subscribers receive it
|
|
41
|
+
|
|
42
|
+
await channel.aclose()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
buffer_size: int = 256,
|
|
49
|
+
overflow: OverflowPolicy | str = OverflowPolicy.BLOCK,
|
|
50
|
+
name: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._policy = BufferPolicy(size=buffer_size, overflow=OverflowPolicy.coerce(overflow))
|
|
53
|
+
self._name = name
|
|
54
|
+
self._subscribers: set[BoundedQueue[T]] = set()
|
|
55
|
+
self._closed = False
|
|
56
|
+
self._published = 0
|
|
57
|
+
self._delivered = 0
|
|
58
|
+
self._dropped = 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def name(self) -> str | None:
|
|
62
|
+
return self._name
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def subscriber_count(self) -> int:
|
|
66
|
+
return len(self._subscribers)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def closed(self) -> bool:
|
|
70
|
+
return self._closed
|
|
71
|
+
|
|
72
|
+
def subscribe(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
buffer_size: int | None = None,
|
|
76
|
+
overflow: OverflowPolicy | str | None = None,
|
|
77
|
+
name: str | None = None,
|
|
78
|
+
) -> Stream[T]:
|
|
79
|
+
"""Create a new single-consumer stream of published messages."""
|
|
80
|
+
if self._closed:
|
|
81
|
+
raise StreamClosedError(f"channel {self._name!r} is closed")
|
|
82
|
+
policy = BufferPolicy(
|
|
83
|
+
size=self._policy.size if buffer_size is None else buffer_size,
|
|
84
|
+
overflow=OverflowPolicy.coerce(self._policy.overflow if overflow is None else overflow),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def record_drop() -> None:
|
|
88
|
+
self._dropped += 1
|
|
89
|
+
|
|
90
|
+
queue: BoundedQueue[T] = policy.build(name=self._name or "channel", on_drop=record_drop)
|
|
91
|
+
self._subscribers.add(queue)
|
|
92
|
+
stats = StreamStats(name=name or self._name or "channel")
|
|
93
|
+
config = StreamConfig(
|
|
94
|
+
name=name or self._name,
|
|
95
|
+
buffer_size=policy.size,
|
|
96
|
+
overflow=policy.overflow,
|
|
97
|
+
observe=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
channel = self
|
|
101
|
+
|
|
102
|
+
async def factory() -> Any:
|
|
103
|
+
try:
|
|
104
|
+
async for item in queue:
|
|
105
|
+
stats.items_out += 1
|
|
106
|
+
yield item
|
|
107
|
+
finally:
|
|
108
|
+
channel._subscribers.discard(queue)
|
|
109
|
+
await queue.aclose()
|
|
110
|
+
|
|
111
|
+
stream: Stream[T] = Stream(factory, config=config, replayable=False)
|
|
112
|
+
stream._stats = stats
|
|
113
|
+
return stream
|
|
114
|
+
|
|
115
|
+
async def publish(self, item: T) -> int:
|
|
116
|
+
"""Deliver ``item`` to every active subscriber.
|
|
117
|
+
|
|
118
|
+
Returns the number of subscriber queues that accepted the item. A
|
|
119
|
+
subscriber whose queue is closed is removed automatically.
|
|
120
|
+
"""
|
|
121
|
+
if self._closed:
|
|
122
|
+
raise StreamClosedError(f"channel {self._name!r} is closed")
|
|
123
|
+
self._published += 1
|
|
124
|
+
delivered = 0
|
|
125
|
+
for queue in list(self._subscribers):
|
|
126
|
+
try:
|
|
127
|
+
if await queue.put(item):
|
|
128
|
+
delivered += 1
|
|
129
|
+
except QueueClosedError:
|
|
130
|
+
self._subscribers.discard(queue)
|
|
131
|
+
self._delivered += delivered
|
|
132
|
+
return delivered
|
|
133
|
+
|
|
134
|
+
async def publish_many(self, items: Any) -> int:
|
|
135
|
+
"""Publish every item in ``items``; returns the number of deliveries."""
|
|
136
|
+
total = 0
|
|
137
|
+
for item in items:
|
|
138
|
+
total += await self.publish(item)
|
|
139
|
+
return total
|
|
140
|
+
|
|
141
|
+
async def aclose(self) -> None:
|
|
142
|
+
"""Close the channel and every subscriber queue."""
|
|
143
|
+
if self._closed:
|
|
144
|
+
return
|
|
145
|
+
self._closed = True
|
|
146
|
+
subscribers = list(self._subscribers)
|
|
147
|
+
self._subscribers.clear()
|
|
148
|
+
for queue in subscribers:
|
|
149
|
+
await queue.aclose()
|
|
150
|
+
|
|
151
|
+
async def __aenter__(self) -> Channel[T]:
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
155
|
+
await self.aclose()
|
|
156
|
+
|
|
157
|
+
def stats(self) -> dict[str, Any]:
|
|
158
|
+
return {
|
|
159
|
+
"name": self._name,
|
|
160
|
+
"published": self._published,
|
|
161
|
+
"delivered": self._delivered,
|
|
162
|
+
"dropped": self._dropped,
|
|
163
|
+
"subscribers": len(self._subscribers),
|
|
164
|
+
"closed": self._closed,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return (
|
|
169
|
+
f"Channel(name={self._name!r}, subscribers={len(self._subscribers)}, "
|
|
170
|
+
f"buffer_size={self._policy.size}, overflow={self._policy.overflow.value!r})"
|
|
171
|
+
)
|