frostwolf 0.4.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.
- frostwolf/__init__.py +197 -0
- frostwolf/capture.py +56 -0
- frostwolf/client.py +247 -0
- frostwolf/detect.py +309 -0
- frostwolf/errors.py +74 -0
- frostwolf/guard/__init__.py +386 -0
- frostwolf/guard/decorate.py +518 -0
- frostwolf/guard/event.py +57 -0
- frostwolf/guard/providers.py +165 -0
- frostwolf/guard/redact.py +124 -0
- frostwolf/guard/request.py +256 -0
- frostwolf/guard/sanitise.py +211 -0
- frostwolf/guard/stream.py +193 -0
- frostwolf/telemetry.py +56 -0
- frostwolf/transport.py +215 -0
- frostwolf/types.py +439 -0
- frostwolf/version.py +11 -0
- frostwolf-0.4.0.dist-info/METADATA +225 -0
- frostwolf-0.4.0.dist-info/RECORD +21 -0
- frostwolf-0.4.0.dist-info/WHEEL +4 -0
- frostwolf-0.4.0.dist-info/licenses/LICENSE +21 -0
frostwolf/__init__.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""FrostWolf SDK.
|
|
2
|
+
|
|
3
|
+
One client for every FrostWolf capability. The guard namespace ships today:
|
|
4
|
+
prompt injection defense for AI applications, with no LLM call and no token
|
|
5
|
+
spend.
|
|
6
|
+
|
|
7
|
+
Detection runs on the FrostWolf control plane. The SDK sends text and receives a
|
|
8
|
+
verdict, so the detection set never leaves the server.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from frostwolf import FrostWolfClient
|
|
12
|
+
>>>
|
|
13
|
+
>>> fw = FrostWolfClient(api_key="sk-your-key-here")
|
|
14
|
+
>>> result = fw.guard.scan("Ignore all previous instructions.")
|
|
15
|
+
>>> result.blocked
|
|
16
|
+
True
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .capture import CAPTURE_PATH, CaptureClient
|
|
22
|
+
from .client import FrostWolfClient
|
|
23
|
+
from .detect import ME_PATH, SCAN_PATH, DetectionClient
|
|
24
|
+
from .errors import (
|
|
25
|
+
BLOCKED_CODE,
|
|
26
|
+
BLOCKED_MESSAGE,
|
|
27
|
+
SCAN_UNAVAILABLE_REASON,
|
|
28
|
+
BlockedError,
|
|
29
|
+
FrostWolfError,
|
|
30
|
+
build_blocked_response,
|
|
31
|
+
)
|
|
32
|
+
from .guard import (
|
|
33
|
+
DEFAULT_ON_MATCH,
|
|
34
|
+
DEFAULT_REPLACEMENT,
|
|
35
|
+
GuardNamespace,
|
|
36
|
+
WrapOptions,
|
|
37
|
+
unavailable_allow,
|
|
38
|
+
unavailable_block,
|
|
39
|
+
)
|
|
40
|
+
from .guard.decorate import DecorateContext, decorate_completion
|
|
41
|
+
from .guard.event import build_event, now_iso
|
|
42
|
+
from .guard.providers import (
|
|
43
|
+
NormalizedInput,
|
|
44
|
+
messages_to_text,
|
|
45
|
+
normalize_input,
|
|
46
|
+
parts_of,
|
|
47
|
+
text_of,
|
|
48
|
+
to_anthropic_payload,
|
|
49
|
+
to_openai_payload,
|
|
50
|
+
)
|
|
51
|
+
from .guard.redact import RedactionResult, Span, merge_spans, redact, redact_within
|
|
52
|
+
from .guard.request import (
|
|
53
|
+
MAX_INPUT_DEPTH,
|
|
54
|
+
RequestPlan,
|
|
55
|
+
RequestSlot,
|
|
56
|
+
Segment,
|
|
57
|
+
plan_request,
|
|
58
|
+
rebuild_request,
|
|
59
|
+
)
|
|
60
|
+
from .guard.sanitise import (
|
|
61
|
+
MessagePlan,
|
|
62
|
+
PayloadPlan,
|
|
63
|
+
RedactedMessage,
|
|
64
|
+
RedactedPlan,
|
|
65
|
+
Slot,
|
|
66
|
+
build_plan,
|
|
67
|
+
redact_plan,
|
|
68
|
+
)
|
|
69
|
+
from .guard.stream import (
|
|
70
|
+
is_async_iterable,
|
|
71
|
+
is_sync_iterable,
|
|
72
|
+
tap_stream,
|
|
73
|
+
tee_async_stream,
|
|
74
|
+
tee_stream,
|
|
75
|
+
)
|
|
76
|
+
from .telemetry import TELEMETRY_PATH, TelemetryClient
|
|
77
|
+
from .transport import BatchTransport, HttpResponse, Transport, urllib_transport
|
|
78
|
+
from .types import (
|
|
79
|
+
CATEGORIES,
|
|
80
|
+
SEVERITIES,
|
|
81
|
+
AnthropicPayload,
|
|
82
|
+
BlockedErrorBody,
|
|
83
|
+
BlockedResponse,
|
|
84
|
+
CaptureOptions,
|
|
85
|
+
CaptureRecord,
|
|
86
|
+
CompletionRequest,
|
|
87
|
+
Decision,
|
|
88
|
+
DecorateOptions,
|
|
89
|
+
GuardInput,
|
|
90
|
+
GuardOptions,
|
|
91
|
+
InitOptions,
|
|
92
|
+
InitResult,
|
|
93
|
+
InjectionCategory,
|
|
94
|
+
InspectionResult,
|
|
95
|
+
OnMatch,
|
|
96
|
+
OnScanError,
|
|
97
|
+
OpenAIPayload,
|
|
98
|
+
Principal,
|
|
99
|
+
ProviderMessage,
|
|
100
|
+
SanitiseReport,
|
|
101
|
+
SanitiseResult,
|
|
102
|
+
ScanResult,
|
|
103
|
+
Severity,
|
|
104
|
+
SignatureMatch,
|
|
105
|
+
TelemetryEvent,
|
|
106
|
+
)
|
|
107
|
+
from .version import SDK_NAME, SDK_VERSION
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"CATEGORIES",
|
|
111
|
+
"SEVERITIES",
|
|
112
|
+
"AnthropicPayload",
|
|
113
|
+
"BLOCKED_CODE",
|
|
114
|
+
"BLOCKED_MESSAGE",
|
|
115
|
+
"BatchTransport",
|
|
116
|
+
"BlockedError",
|
|
117
|
+
"BlockedErrorBody",
|
|
118
|
+
"BlockedResponse",
|
|
119
|
+
"CAPTURE_PATH",
|
|
120
|
+
"CaptureClient",
|
|
121
|
+
"CaptureOptions",
|
|
122
|
+
"CaptureRecord",
|
|
123
|
+
"CompletionRequest",
|
|
124
|
+
"DEFAULT_ON_MATCH",
|
|
125
|
+
"DEFAULT_REPLACEMENT",
|
|
126
|
+
"Decision",
|
|
127
|
+
"DecorateContext",
|
|
128
|
+
"DecorateOptions",
|
|
129
|
+
"DetectionClient",
|
|
130
|
+
"FrostWolfClient",
|
|
131
|
+
"FrostWolfError",
|
|
132
|
+
"GuardInput",
|
|
133
|
+
"GuardNamespace",
|
|
134
|
+
"GuardOptions",
|
|
135
|
+
"HttpResponse",
|
|
136
|
+
"InitOptions",
|
|
137
|
+
"InitResult",
|
|
138
|
+
"InjectionCategory",
|
|
139
|
+
"InspectionResult",
|
|
140
|
+
"MAX_INPUT_DEPTH",
|
|
141
|
+
"ME_PATH",
|
|
142
|
+
"MessagePlan",
|
|
143
|
+
"NormalizedInput",
|
|
144
|
+
"OnMatch",
|
|
145
|
+
"OnScanError",
|
|
146
|
+
"OpenAIPayload",
|
|
147
|
+
"PayloadPlan",
|
|
148
|
+
"Principal",
|
|
149
|
+
"ProviderMessage",
|
|
150
|
+
"RedactedMessage",
|
|
151
|
+
"RedactedPlan",
|
|
152
|
+
"RedactionResult",
|
|
153
|
+
"RequestPlan",
|
|
154
|
+
"RequestSlot",
|
|
155
|
+
"SCAN_PATH",
|
|
156
|
+
"SCAN_UNAVAILABLE_REASON",
|
|
157
|
+
"SDK_NAME",
|
|
158
|
+
"SDK_VERSION",
|
|
159
|
+
"SanitiseReport",
|
|
160
|
+
"SanitiseResult",
|
|
161
|
+
"ScanResult",
|
|
162
|
+
"Segment",
|
|
163
|
+
"Severity",
|
|
164
|
+
"SignatureMatch",
|
|
165
|
+
"Slot",
|
|
166
|
+
"Span",
|
|
167
|
+
"TELEMETRY_PATH",
|
|
168
|
+
"TelemetryClient",
|
|
169
|
+
"TelemetryEvent",
|
|
170
|
+
"Transport",
|
|
171
|
+
"WrapOptions",
|
|
172
|
+
"build_blocked_response",
|
|
173
|
+
"build_event",
|
|
174
|
+
"build_plan",
|
|
175
|
+
"decorate_completion",
|
|
176
|
+
"is_async_iterable",
|
|
177
|
+
"is_sync_iterable",
|
|
178
|
+
"merge_spans",
|
|
179
|
+
"messages_to_text",
|
|
180
|
+
"normalize_input",
|
|
181
|
+
"now_iso",
|
|
182
|
+
"parts_of",
|
|
183
|
+
"plan_request",
|
|
184
|
+
"rebuild_request",
|
|
185
|
+
"redact",
|
|
186
|
+
"redact_plan",
|
|
187
|
+
"redact_within",
|
|
188
|
+
"tap_stream",
|
|
189
|
+
"tee_async_stream",
|
|
190
|
+
"tee_stream",
|
|
191
|
+
"text_of",
|
|
192
|
+
"to_anthropic_payload",
|
|
193
|
+
"to_openai_payload",
|
|
194
|
+
"unavailable_allow",
|
|
195
|
+
"unavailable_block",
|
|
196
|
+
"urllib_transport",
|
|
197
|
+
]
|
frostwolf/capture.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Batched, fire-and-forget transport for captured completions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .transport import BatchTransport, Transport
|
|
9
|
+
from .types import CaptureRecord
|
|
10
|
+
|
|
11
|
+
"""Path appended to the endpoint for the capture ingest route."""
|
|
12
|
+
CAPTURE_PATH = "/v1/captures"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CaptureClient(BatchTransport[CaptureRecord]):
|
|
16
|
+
"""Batched, fire-and-forget transport for captured completions.
|
|
17
|
+
|
|
18
|
+
A capture record carries the base URL, the full request body, and the full
|
|
19
|
+
response body, so it is materially larger than a telemetry event and is
|
|
20
|
+
disabled unless the caller opts in. Bodies are serialized and truncated
|
|
21
|
+
before they are queued, so the queue holds bounded strings rather than live
|
|
22
|
+
references to caller-owned objects.
|
|
23
|
+
|
|
24
|
+
Like telemetry, this never sits on the request path and never raises.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
api_key: str,
|
|
31
|
+
endpoint: str,
|
|
32
|
+
enabled: bool,
|
|
33
|
+
flush_interval_ms: int,
|
|
34
|
+
max_batch_size: int,
|
|
35
|
+
max_queue_size: int,
|
|
36
|
+
timeout_ms: int,
|
|
37
|
+
transport: Transport,
|
|
38
|
+
on_error: Callable[[Exception], None],
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(
|
|
41
|
+
api_key=api_key,
|
|
42
|
+
endpoint=endpoint,
|
|
43
|
+
path=CAPTURE_PATH,
|
|
44
|
+
enabled=enabled,
|
|
45
|
+
flush_interval_ms=flush_interval_ms,
|
|
46
|
+
max_batch_size=max_batch_size,
|
|
47
|
+
max_queue_size=max_queue_size,
|
|
48
|
+
timeout_ms=timeout_ms,
|
|
49
|
+
transport=transport,
|
|
50
|
+
on_error=on_error,
|
|
51
|
+
serializer=_serialize,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _serialize(record: CaptureRecord) -> dict[str, Any]:
|
|
56
|
+
return record.to_dict()
|
frostwolf/client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""FrostWolf SDK entry point.
|
|
2
|
+
|
|
3
|
+
One client covers every FrostWolf capability. The guard namespace is available
|
|
4
|
+
today; the hunt namespace lands in a later release.
|
|
5
|
+
|
|
6
|
+
Detection runs on the FrostWolf control plane, not in this process. The SDK
|
|
7
|
+
holds no patterns and no matcher, so the detection set cannot be read off a
|
|
8
|
+
client and probed for a near-miss. The API key authenticates every detection
|
|
9
|
+
call, and it also authenticates telemetry and capture, both of which are batched
|
|
10
|
+
and flushed in the background.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import random
|
|
17
|
+
import string
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from .capture import CaptureClient
|
|
22
|
+
from .detect import DetectionClient
|
|
23
|
+
from .errors import FrostWolfError
|
|
24
|
+
from .guard import GuardNamespace
|
|
25
|
+
from .telemetry import TelemetryClient
|
|
26
|
+
from .transport import Transport, urllib_transport
|
|
27
|
+
from .types import (
|
|
28
|
+
CaptureOptions,
|
|
29
|
+
GuardOptions,
|
|
30
|
+
InitOptions,
|
|
31
|
+
InitResult,
|
|
32
|
+
OnScanError,
|
|
33
|
+
Principal,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_ENDPOINT = "https://api.frostwolf.app"
|
|
37
|
+
DEFAULT_FLUSH_INTERVAL_MS = 5_000
|
|
38
|
+
DEFAULT_MAX_BATCH_SIZE = 50
|
|
39
|
+
DEFAULT_MAX_QUEUE_SIZE = 1_000
|
|
40
|
+
DEFAULT_TIMEOUT_MS = 5_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FrostWolfClient:
|
|
44
|
+
"""FrostWolf SDK entry point.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> fw = FrostWolfClient(api_key="sk-your-key-here")
|
|
48
|
+
>>> result = fw.guard.scan("Ignore all previous instructions.")
|
|
49
|
+
>>> result.blocked
|
|
50
|
+
True
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
"""Prompt injection defense for AI applications."""
|
|
54
|
+
guard: GuardNamespace
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
*,
|
|
60
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
61
|
+
telemetry: bool = True,
|
|
62
|
+
flush_interval_ms: int = DEFAULT_FLUSH_INTERVAL_MS,
|
|
63
|
+
max_batch_size: int = DEFAULT_MAX_BATCH_SIZE,
|
|
64
|
+
max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
|
|
65
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
66
|
+
include_evidence: bool = False,
|
|
67
|
+
on_scan_error: OnScanError = "block",
|
|
68
|
+
capture: bool | CaptureOptions = False,
|
|
69
|
+
transport: Transport | None = None,
|
|
70
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
71
|
+
guard: GuardOptions | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
key = api_key.strip() if isinstance(api_key, str) else ""
|
|
74
|
+
if len(key) == 0:
|
|
75
|
+
raise FrostWolfError(
|
|
76
|
+
'A FrostWolf API key is required. Pass api_key="sk-...".',
|
|
77
|
+
"missing_api_key",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self._endpoint = endpoint.rstrip("/")
|
|
81
|
+
self._transport = transport if transport is not None else urllib_transport
|
|
82
|
+
self._on_error = on_error if on_error is not None else _ignore_error
|
|
83
|
+
guard_options = guard if guard is not None else GuardOptions()
|
|
84
|
+
|
|
85
|
+
self._detect = DetectionClient(
|
|
86
|
+
api_key=key,
|
|
87
|
+
endpoint=self._endpoint,
|
|
88
|
+
transport=self._transport,
|
|
89
|
+
timeout_ms=timeout_ms,
|
|
90
|
+
block_severity=guard_options.block_severity,
|
|
91
|
+
max_scan_chars=guard_options.max_scan_chars,
|
|
92
|
+
include_evidence=include_evidence,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._telemetry = TelemetryClient(
|
|
96
|
+
api_key=key,
|
|
97
|
+
endpoint=self._endpoint,
|
|
98
|
+
enabled=telemetry,
|
|
99
|
+
flush_interval_ms=flush_interval_ms,
|
|
100
|
+
max_batch_size=max_batch_size,
|
|
101
|
+
max_queue_size=max_queue_size,
|
|
102
|
+
timeout_ms=timeout_ms,
|
|
103
|
+
transport=self._transport,
|
|
104
|
+
on_error=self._on_error,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._capture: CaptureClient | None = None
|
|
108
|
+
self._capture_factory: Callable[[], CaptureClient] = lambda: CaptureClient(
|
|
109
|
+
api_key=key,
|
|
110
|
+
endpoint=self._endpoint,
|
|
111
|
+
enabled=True,
|
|
112
|
+
flush_interval_ms=flush_interval_ms,
|
|
113
|
+
max_batch_size=max_batch_size,
|
|
114
|
+
max_queue_size=max_queue_size,
|
|
115
|
+
timeout_ms=timeout_ms,
|
|
116
|
+
transport=self._transport,
|
|
117
|
+
on_error=self._on_error,
|
|
118
|
+
)
|
|
119
|
+
if capture is not False and capture is not None:
|
|
120
|
+
self._capture = self._capture_factory()
|
|
121
|
+
|
|
122
|
+
self.guard = GuardNamespace(
|
|
123
|
+
options=guard_options,
|
|
124
|
+
include_evidence=include_evidence,
|
|
125
|
+
capture=capture,
|
|
126
|
+
on_scan_error=on_scan_error,
|
|
127
|
+
detect=self._detect,
|
|
128
|
+
emit=self._telemetry.enqueue,
|
|
129
|
+
capture_record=self._enqueue_capture,
|
|
130
|
+
next_event_id=_create_event_id_factory(),
|
|
131
|
+
on_error=self._on_error,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def signature_version(self) -> str | None:
|
|
136
|
+
"""Version of the signature set behind the most recent verdict, or None."""
|
|
137
|
+
return self._detect.last_signature_version
|
|
138
|
+
|
|
139
|
+
def init(self, options: InitOptions | None = None) -> InitResult:
|
|
140
|
+
"""Authenticate the API key and report the caller behind it.
|
|
141
|
+
|
|
142
|
+
Never raises. A rejected key or an unreachable control plane is reported
|
|
143
|
+
in the result.
|
|
144
|
+
|
|
145
|
+
Passing ``InitOptions(capture=True)`` also sets the capture flag on the
|
|
146
|
+
key, server-side, and starts the local capture pipeline. Capture is off
|
|
147
|
+
by default, so this is where a caller opts in::
|
|
148
|
+
|
|
149
|
+
>>> fw.init(InitOptions(capture=True))
|
|
150
|
+
|
|
151
|
+
Detection does not depend on this call: every guard method authenticates
|
|
152
|
+
itself, so a caller that never calls ``init`` still gets verdicts.
|
|
153
|
+
"""
|
|
154
|
+
principal: Principal | None = None
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
principal = self._detect.verify()
|
|
158
|
+
except Exception as error: # noqa: BLE001 - reported, not raised
|
|
159
|
+
self._on_error(to_error(error))
|
|
160
|
+
return InitResult(authenticated=False, principal=None)
|
|
161
|
+
|
|
162
|
+
requested = options.capture if options is not None else None
|
|
163
|
+
if requested is not None and requested != principal.capture_enabled:
|
|
164
|
+
try:
|
|
165
|
+
principal = self._detect.set_capture(requested)
|
|
166
|
+
except Exception as error: # noqa: BLE001 - reported, not raised
|
|
167
|
+
# The key is still valid, so this is reported rather than fatal.
|
|
168
|
+
# The result carries the flag the server actually holds, not the
|
|
169
|
+
# one that was asked for.
|
|
170
|
+
self._on_error(to_error(error))
|
|
171
|
+
|
|
172
|
+
if principal.capture_enabled:
|
|
173
|
+
self._enable_capture()
|
|
174
|
+
|
|
175
|
+
return InitResult(
|
|
176
|
+
authenticated=True,
|
|
177
|
+
principal=principal,
|
|
178
|
+
capture_enabled=principal.capture_enabled,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _enable_capture(self) -> None:
|
|
182
|
+
"""Start shipping captured bodies.
|
|
183
|
+
|
|
184
|
+
Idempotent, so a caller that calls ``init`` with capture on every boot
|
|
185
|
+
does not accumulate transports.
|
|
186
|
+
"""
|
|
187
|
+
if self._capture is None:
|
|
188
|
+
self._capture = self._capture_factory()
|
|
189
|
+
self.guard.set_capture(True)
|
|
190
|
+
|
|
191
|
+
def flush(self) -> None:
|
|
192
|
+
"""Send everything queued."""
|
|
193
|
+
self._telemetry.flush()
|
|
194
|
+
if self._capture is not None:
|
|
195
|
+
self._capture.flush()
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
"""Flush what is queued and stop the background flush timers."""
|
|
199
|
+
self._telemetry.close()
|
|
200
|
+
if self._capture is not None:
|
|
201
|
+
self._capture.close()
|
|
202
|
+
|
|
203
|
+
def _enqueue_capture(self, record: object) -> None:
|
|
204
|
+
"""Queue a capture record, unless capture is off."""
|
|
205
|
+
if self._capture is not None:
|
|
206
|
+
self._capture.enqueue(record) # type: ignore[arg-type]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ignore_error(error: Exception) -> None:
|
|
210
|
+
"""Default error sink. Reporting failures are silent unless a caller opts in."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _create_event_id_factory() -> Callable[[], str]:
|
|
214
|
+
"""Monotonic, collision-resistant event id.
|
|
215
|
+
|
|
216
|
+
Not a UUID: the id only needs to be unique within a customer's stream so the
|
|
217
|
+
console can correlate a local decision with its record. Avoiding ``uuid``
|
|
218
|
+
keeps the id short and readable in a log.
|
|
219
|
+
"""
|
|
220
|
+
counter = itertools.count(1)
|
|
221
|
+
|
|
222
|
+
def next_id() -> str:
|
|
223
|
+
value = next(counter) & 0xFFFFFFFF
|
|
224
|
+
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
|
225
|
+
return f"{_base36(int(time.time() * 1000))}-{_base36(value)}-{suffix}"
|
|
226
|
+
|
|
227
|
+
return next_id
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _base36(value: int) -> str:
|
|
231
|
+
"""Render a non-negative integer in base 36."""
|
|
232
|
+
if value <= 0:
|
|
233
|
+
return "0"
|
|
234
|
+
|
|
235
|
+
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
236
|
+
out = ""
|
|
237
|
+
while value > 0:
|
|
238
|
+
value, remainder = divmod(value, 36)
|
|
239
|
+
out = digits[remainder] + out
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def to_error(value: object) -> Exception:
|
|
244
|
+
"""Normalise an unknown thrown value into an Exception."""
|
|
245
|
+
if isinstance(value, Exception):
|
|
246
|
+
return value
|
|
247
|
+
return Exception(str(value))
|