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,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
+ )