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,191 @@
|
|
|
1
|
+
"""Exception hierarchy used across DataCurrent.
|
|
2
|
+
|
|
3
|
+
Every exception raised deliberately by the library derives from
|
|
4
|
+
:class:`DataCurrentError`. Applications can therefore catch one base class
|
|
5
|
+
without accidentally swallowing programming errors such as ``TypeError`` or
|
|
6
|
+
control-flow exceptions such as ``asyncio.CancelledError``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BufferOverflowError",
|
|
15
|
+
"ConfigurationError",
|
|
16
|
+
"ConnectionFailedError",
|
|
17
|
+
"DataCurrentError",
|
|
18
|
+
"EmptyStreamError",
|
|
19
|
+
"EventBusError",
|
|
20
|
+
"MessageTooLargeError",
|
|
21
|
+
"NonReplayableStreamError",
|
|
22
|
+
"OptionalDependencyError",
|
|
23
|
+
"QueueClosedError",
|
|
24
|
+
"QueueEmptyError",
|
|
25
|
+
"RetryError",
|
|
26
|
+
"SSEProtocolError",
|
|
27
|
+
"StreamClosedError",
|
|
28
|
+
"StreamConsumedError",
|
|
29
|
+
"StreamError",
|
|
30
|
+
"StreamProtocolError",
|
|
31
|
+
"StreamTimeoutError",
|
|
32
|
+
"SubscriptionClosedError",
|
|
33
|
+
"TransportError",
|
|
34
|
+
"WebSocketClosedError",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DataCurrentError(Exception):
|
|
39
|
+
"""Base class for every error raised by DataCurrent."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigurationError(DataCurrentError, ValueError):
|
|
43
|
+
"""The caller supplied an invalid configuration value."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OptionalDependencyError(DataCurrentError, ImportError):
|
|
47
|
+
"""An optional integration was used without its dependency installed."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
package: str,
|
|
52
|
+
*,
|
|
53
|
+
extra: str | None = None,
|
|
54
|
+
purpose: str = "This feature",
|
|
55
|
+
) -> None:
|
|
56
|
+
self.package = package
|
|
57
|
+
self.extra = extra
|
|
58
|
+
hint = f'pip install "datacurrent[{extra}]"' if extra else f"pip install {package}"
|
|
59
|
+
super().__init__(
|
|
60
|
+
f"{purpose} requires the optional dependency {package!r}. Install it with: {hint}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# Streams
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
class StreamError(DataCurrentError):
|
|
68
|
+
"""Base class for errors related to stream consumption."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StreamConsumedError(StreamError):
|
|
72
|
+
"""A single-consumer stream was iterated more than once."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NonReplayableStreamError(StreamError):
|
|
76
|
+
"""An operation required restarting a stream that cannot be replayed."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EmptyStreamError(StreamError):
|
|
80
|
+
"""A terminal operation required at least one item but the stream was empty."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StreamTimeoutError(StreamError, TimeoutError):
|
|
84
|
+
"""No item arrived within the configured timeout."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StreamClosedError(StreamError):
|
|
88
|
+
"""The stream or its underlying queue has been closed."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
# Buffering
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
class BufferOverflowError(StreamError, BufferError):
|
|
95
|
+
"""A bounded buffer was full and the configured policy is ``error``.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
maxsize: Capacity of the buffer that overflowed.
|
|
99
|
+
policy: Overflow policy that produced the error.
|
|
100
|
+
dropped: Number of items dropped in total by that buffer.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
maxsize: int,
|
|
106
|
+
*,
|
|
107
|
+
policy: Any = "error",
|
|
108
|
+
dropped: int = 0,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.maxsize = maxsize
|
|
111
|
+
self.policy = getattr(policy, "value", policy)
|
|
112
|
+
self.dropped = dropped
|
|
113
|
+
super().__init__(
|
|
114
|
+
f"buffer is full (maxsize={maxsize}, policy={self.policy!r}, "
|
|
115
|
+
f"dropped={dropped}); slow down the producer or enlarge the buffer"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class QueueClosedError(DataCurrentError):
|
|
120
|
+
"""An operation was attempted on a closed queue."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class QueueEmptyError(DataCurrentError):
|
|
124
|
+
"""A non-blocking read was attempted on an empty queue."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Transports
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
class TransportError(DataCurrentError):
|
|
131
|
+
"""Base class for networking and transport failures."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ConnectionFailedError(TransportError):
|
|
135
|
+
"""A connection could not be established or was lost."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class StreamProtocolError(TransportError):
|
|
139
|
+
"""The peer sent data that violates the expected protocol."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SSEProtocolError(StreamProtocolError):
|
|
143
|
+
"""A Server-Sent Events response was malformed or had the wrong content type."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MessageTooLargeError(TransportError):
|
|
147
|
+
"""A received message exceeded the configured limit."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, size: int, limit: int) -> None:
|
|
150
|
+
self.size = size
|
|
151
|
+
self.limit = limit
|
|
152
|
+
super().__init__(f"message of {size} bytes exceeds the limit of {limit} bytes")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class WebSocketClosedError(TransportError):
|
|
156
|
+
"""The WebSocket connection was closed before reading finished."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, code: int | None = None, reason: str | None = None) -> None:
|
|
159
|
+
self.code = code
|
|
160
|
+
self.reason = reason
|
|
161
|
+
detail = []
|
|
162
|
+
if code is not None:
|
|
163
|
+
detail.append(f"code={code}")
|
|
164
|
+
if reason:
|
|
165
|
+
detail.append(f"reason={reason}")
|
|
166
|
+
suffix = f" ({', '.join(detail)})" if detail else ""
|
|
167
|
+
super().__init__(f"websocket connection closed{suffix}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
# Reliability
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
class RetryError(DataCurrentError):
|
|
174
|
+
"""All retry attempts were exhausted."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, attempts: int, last_error: BaseException | None = None) -> None:
|
|
177
|
+
self.attempts = attempts
|
|
178
|
+
self.last_error = last_error
|
|
179
|
+
suffix = f": {last_error!r}" if last_error is not None else ""
|
|
180
|
+
super().__init__(f"giving up after {attempts} attempt(s){suffix}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
# Events
|
|
185
|
+
# --------------------------------------------------------------------------- #
|
|
186
|
+
class EventBusError(DataCurrentError):
|
|
187
|
+
"""Base class for event bus failures."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class SubscriptionClosedError(EventBusError):
|
|
191
|
+
"""The subscription is no longer able to receive events."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Framework integrations.
|
|
2
|
+
|
|
3
|
+
The helpers here import FastAPI/Starlette lazily, so ``import datacurrent`` and
|
|
4
|
+
this package work in applications that do not use a web framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .fastapi import sse_response, websocket_messages, websocket_stream
|
|
10
|
+
from .starlette import StreamLifespan, streaming_response
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"StreamLifespan",
|
|
14
|
+
"sse_response",
|
|
15
|
+
"streaming_response",
|
|
16
|
+
"websocket_messages",
|
|
17
|
+
"websocket_stream",
|
|
18
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""FastAPI helpers: SSE responses and WebSocket message streams.
|
|
2
|
+
|
|
3
|
+
FastAPI is optional and imported lazily inside the functions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..core._util import aclose
|
|
12
|
+
from ..core.stream import Stream
|
|
13
|
+
from ..transports.sse import SSEEvent, format_sse
|
|
14
|
+
|
|
15
|
+
__all__ = ["sse_response", "websocket_messages", "websocket_stream"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sse_response(
|
|
19
|
+
events: AsyncIterable[SSEEvent] | Stream[SSEEvent],
|
|
20
|
+
*,
|
|
21
|
+
headers: Mapping[str, str] | None = None,
|
|
22
|
+
status_code: int = 200,
|
|
23
|
+
) -> Any:
|
|
24
|
+
"""Return a ``StreamingResponse`` that encodes events as ``text/event-stream``."""
|
|
25
|
+
from starlette.responses import StreamingResponse
|
|
26
|
+
|
|
27
|
+
default_headers = {
|
|
28
|
+
"Cache-Control": "no-cache",
|
|
29
|
+
"Connection": "keep-alive",
|
|
30
|
+
"X-Accel-Buffering": "no",
|
|
31
|
+
}
|
|
32
|
+
merged = {**default_headers, **dict(headers or {})}
|
|
33
|
+
|
|
34
|
+
async def body() -> AsyncIterator[bytes]:
|
|
35
|
+
source: AsyncIterable[SSEEvent] = events
|
|
36
|
+
iterator = source.__aiter__()
|
|
37
|
+
try:
|
|
38
|
+
async for event in iterator:
|
|
39
|
+
yield format_sse(event).encode("utf-8")
|
|
40
|
+
finally:
|
|
41
|
+
await aclose(iterator)
|
|
42
|
+
|
|
43
|
+
return StreamingResponse(
|
|
44
|
+
body(),
|
|
45
|
+
media_type="text/event-stream",
|
|
46
|
+
headers=merged,
|
|
47
|
+
status_code=status_code,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def websocket_messages(websocket: Any) -> AsyncIterator[Any]:
|
|
52
|
+
"""Yield text and binary messages from a Starlette/FastAPI WebSocket."""
|
|
53
|
+
while True:
|
|
54
|
+
message = await websocket.receive()
|
|
55
|
+
message_type = message.get("type")
|
|
56
|
+
if message_type == "websocket.disconnect":
|
|
57
|
+
return
|
|
58
|
+
if "text" in message and message["text"] is not None:
|
|
59
|
+
yield message["text"]
|
|
60
|
+
elif "bytes" in message and message["bytes"] is not None:
|
|
61
|
+
yield message["bytes"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def websocket_stream(websocket: Any, *, config: Any = None) -> Stream[Any]:
|
|
65
|
+
"""Expose an incoming WebSocket as a single-consumer stream."""
|
|
66
|
+
|
|
67
|
+
async def factory() -> AsyncIterator[Any]:
|
|
68
|
+
try:
|
|
69
|
+
async for message in websocket_messages(websocket):
|
|
70
|
+
yield message
|
|
71
|
+
finally:
|
|
72
|
+
closer = getattr(websocket, "close", None)
|
|
73
|
+
if closer is not None:
|
|
74
|
+
await aclose(websocket)
|
|
75
|
+
|
|
76
|
+
return Stream(factory, config=config, replayable=False)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Starlette helpers (also used by the FastAPI integration).
|
|
2
|
+
|
|
3
|
+
Starlette is optional: importing this module never imports Starlette itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterable, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..core._util import aclose
|
|
12
|
+
from ..core.stream import Stream
|
|
13
|
+
|
|
14
|
+
__all__ = ["StreamLifespan", "streaming_response"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def streaming_response(
|
|
18
|
+
stream: Stream[Any] | AsyncIterable[Any],
|
|
19
|
+
*,
|
|
20
|
+
media_type: str = "text/plain; charset=utf-8",
|
|
21
|
+
headers: Mapping[str, str] | None = None,
|
|
22
|
+
status_code: int = 200,
|
|
23
|
+
) -> Any:
|
|
24
|
+
"""Return a Starlette ``StreamingResponse`` backed by a stream.
|
|
25
|
+
|
|
26
|
+
Bytes are forwarded untouched and strings are encoded as UTF-8. The stream
|
|
27
|
+
is closed when the response finishes or the client disconnects.
|
|
28
|
+
"""
|
|
29
|
+
from starlette.responses import StreamingResponse
|
|
30
|
+
|
|
31
|
+
source = stream.__aiter__() if isinstance(stream, Stream) else stream
|
|
32
|
+
|
|
33
|
+
async def body() -> Any:
|
|
34
|
+
iterator = source.__aiter__()
|
|
35
|
+
try:
|
|
36
|
+
async for chunk in iterator:
|
|
37
|
+
yield chunk.encode("utf-8") if isinstance(chunk, str) else chunk
|
|
38
|
+
finally:
|
|
39
|
+
await aclose(iterator)
|
|
40
|
+
|
|
41
|
+
return StreamingResponse(
|
|
42
|
+
body(),
|
|
43
|
+
media_type=media_type,
|
|
44
|
+
headers=dict(headers or {}),
|
|
45
|
+
status_code=status_code,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StreamLifespan:
|
|
50
|
+
"""Run async closables during application startup/shutdown.
|
|
51
|
+
|
|
52
|
+
Example::
|
|
53
|
+
|
|
54
|
+
lifespan = StreamLifespan(bus, channel)
|
|
55
|
+
|
|
56
|
+
app = FastAPI(lifespan=lifespan.as_context_manager())
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, *resources: Any) -> None:
|
|
60
|
+
self._resources = list(resources)
|
|
61
|
+
|
|
62
|
+
async def __aenter__(self) -> StreamLifespan:
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
66
|
+
await self.aclose()
|
|
67
|
+
|
|
68
|
+
async def aclose(self) -> None:
|
|
69
|
+
resources, self._resources = self._resources, []
|
|
70
|
+
for resource in reversed(resources):
|
|
71
|
+
await aclose(resource)
|
|
72
|
+
|
|
73
|
+
def as_context_manager(self) -> Any:
|
|
74
|
+
"""Return the lifespan as an async context manager for ASGI frameworks."""
|
|
75
|
+
return self
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Observability: metrics, statistics and safe logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .logging import get_logger, redact_headers, redact_secret, redact_url
|
|
6
|
+
from .metrics import Counter, Metrics, StreamStats, snapshot_stats
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Counter",
|
|
10
|
+
"Metrics",
|
|
11
|
+
"StreamStats",
|
|
12
|
+
"get_logger",
|
|
13
|
+
"redact_headers",
|
|
14
|
+
"redact_secret",
|
|
15
|
+
"redact_url",
|
|
16
|
+
"snapshot_stats",
|
|
17
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Logging helpers.
|
|
2
|
+
|
|
3
|
+
DataCurrent attaches a :class:`logging.NullHandler` so importing the library
|
|
4
|
+
never changes application logging. URLs and headers are redacted before they
|
|
5
|
+
are handed to the logging module: tokens in query strings, user info in URLs,
|
|
6
|
+
``Authorization`` and cookie headers never reach log records.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"LOGGER_NAME",
|
|
18
|
+
"get_logger",
|
|
19
|
+
"redact_headers",
|
|
20
|
+
"redact_secret",
|
|
21
|
+
"redact_url",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
LOGGER_NAME = "datacurrent"
|
|
25
|
+
|
|
26
|
+
_SENSITIVE_FRAGMENTS = (
|
|
27
|
+
"token",
|
|
28
|
+
"secret",
|
|
29
|
+
"password",
|
|
30
|
+
"passwd",
|
|
31
|
+
"api_key",
|
|
32
|
+
"apikey",
|
|
33
|
+
"access_key",
|
|
34
|
+
"private_key",
|
|
35
|
+
"credential",
|
|
36
|
+
"signature",
|
|
37
|
+
"session",
|
|
38
|
+
"authorization",
|
|
39
|
+
"auth",
|
|
40
|
+
"code",
|
|
41
|
+
"key",
|
|
42
|
+
)
|
|
43
|
+
_SENSITIVE_HEADERS = {
|
|
44
|
+
"authorization",
|
|
45
|
+
"proxy-authorization",
|
|
46
|
+
"cookie",
|
|
47
|
+
"set-cookie",
|
|
48
|
+
"x-api-key",
|
|
49
|
+
"x-auth-token",
|
|
50
|
+
"api-key",
|
|
51
|
+
"sec-websocket-key",
|
|
52
|
+
"sec-websocket-protocol",
|
|
53
|
+
"x-amz-security-token",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_root_logger = logging.getLogger(LOGGER_NAME)
|
|
57
|
+
if not any(isinstance(handler, logging.NullHandler) for handler in _root_logger.handlers):
|
|
58
|
+
_root_logger.addHandler(logging.NullHandler())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
62
|
+
"""Return a namespaced logger. Never configure the logging system here."""
|
|
63
|
+
if not name:
|
|
64
|
+
return _root_logger
|
|
65
|
+
if name.startswith(LOGGER_NAME):
|
|
66
|
+
return logging.getLogger(name)
|
|
67
|
+
return logging.getLogger(f"{LOGGER_NAME}.{name}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_sensitive(name: str) -> bool:
|
|
71
|
+
lowered = name.lower()
|
|
72
|
+
return any(fragment in lowered for fragment in _SENSITIVE_FRAGMENTS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def redact_url(url: str) -> str:
|
|
76
|
+
"""Return ``url`` with user info and sensitive query values replaced by ``***``.
|
|
77
|
+
|
|
78
|
+
The structure (scheme, host, port, path, non-sensitive parameters) is kept so
|
|
79
|
+
the value is still useful in logs.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(url, str): # defensive: never log an object's repr
|
|
82
|
+
return "<redacted-url>"
|
|
83
|
+
try:
|
|
84
|
+
parts = urlsplit(url)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return "<invalid-url>"
|
|
87
|
+
|
|
88
|
+
netloc = parts.netloc
|
|
89
|
+
if "@" in netloc:
|
|
90
|
+
host = netloc.rsplit("@", 1)[1]
|
|
91
|
+
netloc = f"***@{host}"
|
|
92
|
+
|
|
93
|
+
query = parts.query
|
|
94
|
+
if query:
|
|
95
|
+
rendered: list[str] = []
|
|
96
|
+
try:
|
|
97
|
+
pairs = parse_qsl(query, keep_blank_values=True)
|
|
98
|
+
except ValueError:
|
|
99
|
+
pairs = []
|
|
100
|
+
if pairs:
|
|
101
|
+
for key, value in pairs:
|
|
102
|
+
rendered.append(f"{key}=***" if _is_sensitive(key) else f"{key}={value}")
|
|
103
|
+
query = "&".join(rendered)
|
|
104
|
+
else:
|
|
105
|
+
query = "***"
|
|
106
|
+
|
|
107
|
+
return parts._replace(netloc=netloc, query=query).geturl()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def redact_headers(headers: Mapping[str, Any] | None) -> dict[str, str]:
|
|
111
|
+
"""Return a copy of ``headers`` with sensitive values replaced by ``***``."""
|
|
112
|
+
if not headers:
|
|
113
|
+
return {}
|
|
114
|
+
redacted: dict[str, str] = {}
|
|
115
|
+
for key, value in headers.items():
|
|
116
|
+
name = str(key)
|
|
117
|
+
redacted[name] = "***" if name.lower() in _SENSITIVE_HEADERS else str(value)
|
|
118
|
+
return redacted
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def redact_secret(value: Any) -> str:
|
|
122
|
+
"""Render a secret for logs without revealing its contents."""
|
|
123
|
+
if value is None:
|
|
124
|
+
return "<none>"
|
|
125
|
+
text = str(value)
|
|
126
|
+
if not text:
|
|
127
|
+
return "<empty>"
|
|
128
|
+
return f"*** ({len(text)} chars)"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Small, dependency-free metrics primitives.
|
|
2
|
+
|
|
3
|
+
DataCurrent never ships metrics to a hosted service on its own. You expose the
|
|
4
|
+
snapshot to whatever system you already use (Prometheus, OpenTelemetry, logs).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = ["Counter", "Metrics", "StreamStats", "snapshot_stats"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class StreamStats:
|
|
20
|
+
"""Mutable counters for a single stream instance."""
|
|
21
|
+
|
|
22
|
+
name: str | None = None
|
|
23
|
+
items_out: int = 0
|
|
24
|
+
errors: int = 0
|
|
25
|
+
retries: int = 0
|
|
26
|
+
dropped: int = 0
|
|
27
|
+
|
|
28
|
+
def snapshot(self) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"items_out": self.items_out,
|
|
32
|
+
"errors": self.errors,
|
|
33
|
+
"retries": self.retries,
|
|
34
|
+
"dropped": self.dropped,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def snapshot_stats(stats: StreamStats | None) -> dict[str, Any] | None:
|
|
39
|
+
return None if stats is None else stats.snapshot()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Counter:
|
|
43
|
+
"""A monotonically increasing counter."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("_value",)
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._value = 0
|
|
49
|
+
|
|
50
|
+
def inc(self, amount: int = 1) -> int:
|
|
51
|
+
self._value += amount
|
|
52
|
+
return self._value
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def value(self) -> int:
|
|
56
|
+
return self._value
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
self._value = 0
|
|
60
|
+
|
|
61
|
+
def __int__(self) -> int:
|
|
62
|
+
return self._value
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
65
|
+
return f"Counter(value={self._value})"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class Metrics:
|
|
70
|
+
"""A tiny registry of counters and gauges.
|
|
71
|
+
|
|
72
|
+
Keys are plain strings; tags are folded into the key so the output can be
|
|
73
|
+
logged as-is or exported to a metrics backend.
|
|
74
|
+
|
|
75
|
+
Example::
|
|
76
|
+
|
|
77
|
+
metrics = Metrics()
|
|
78
|
+
metrics.inc("stream.items", tags={"stream": "orders"})
|
|
79
|
+
metrics.snapshot()["stream.items{stream=orders}"] == 1
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
prefix: str = "datacurrent"
|
|
83
|
+
counters: dict[str, int] = field(default_factory=dict)
|
|
84
|
+
gauges: dict[str, float] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _key(name: str, tags: dict[str, Any] | None) -> str:
|
|
88
|
+
if not tags:
|
|
89
|
+
return name
|
|
90
|
+
rendered = ",".join(f"{key}={value}" for key, value in sorted(tags.items()))
|
|
91
|
+
return f"{name}{{{rendered}}}"
|
|
92
|
+
|
|
93
|
+
def inc(self, name: str, amount: int = 1, **tags: Any) -> int:
|
|
94
|
+
key = self._key(name, tags or None)
|
|
95
|
+
self.counters[key] = self.counters.get(key, 0) + amount
|
|
96
|
+
return self.counters[key]
|
|
97
|
+
|
|
98
|
+
def set(self, name: str, value: float, **tags: Any) -> float:
|
|
99
|
+
key = self._key(name, tags or None)
|
|
100
|
+
self.gauges[key] = float(value)
|
|
101
|
+
return self.gauges[key]
|
|
102
|
+
|
|
103
|
+
def get(self, name: str, default: int = 0, **tags: Any) -> int:
|
|
104
|
+
return self.counters.get(self._key(name, tags or None), default)
|
|
105
|
+
|
|
106
|
+
@contextmanager
|
|
107
|
+
def timer(self, name: str, **tags: Any) -> Iterator[None]:
|
|
108
|
+
started = time.perf_counter()
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
self.observe_duration(name, time.perf_counter() - started, **tags)
|
|
113
|
+
|
|
114
|
+
def observe_duration(self, name: str, seconds: float, **tags: Any) -> None:
|
|
115
|
+
key = self._key(f"{name}.milliseconds", tags or None)
|
|
116
|
+
self.gauges[key] = seconds * 1000.0
|
|
117
|
+
|
|
118
|
+
def snapshot(self) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"prefix": self.prefix,
|
|
121
|
+
"counters": dict(self.counters),
|
|
122
|
+
"gauges": dict(self.gauges),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def reset(self) -> None:
|
|
126
|
+
self.counters.clear()
|
|
127
|
+
self.gauges.clear()
|
datacurrent/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561: DataCurrent ships inline type information.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Reliability: retries, backoff and timeouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .retry import RetryPolicy, is_retryable, retry_async
|
|
6
|
+
from .timeout import validate_timeout, with_timeout
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"RetryPolicy",
|
|
10
|
+
"is_retryable",
|
|
11
|
+
"retry_async",
|
|
12
|
+
"validate_timeout",
|
|
13
|
+
"with_timeout",
|
|
14
|
+
]
|