santati 0.2.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.
- santati/__init__.py +79 -0
- santati/_client.py +522 -0
- santati/_errors.py +68 -0
- santati/_outbox.py +278 -0
- santati/_retry.py +67 -0
- santati/_types.py +94 -0
- santati/integrations/__init__.py +41 -0
- santati/integrations/_core.py +247 -0
- santati/integrations/claude_agent_sdk.py +82 -0
- santati/integrations/django.py +258 -0
- santati/integrations/langchain.py +165 -0
- santati/integrations/openai_agents.py +112 -0
- santati/integrations/pydantic_ai.py +92 -0
- santati/outbox/__init__.py +12 -0
- santati/outbox/redis.py +123 -0
- santati/py.typed +0 -0
- santati-0.2.0.dist-info/METADATA +213 -0
- santati-0.2.0.dist-info/RECORD +41 -0
- santati-0.2.0.dist-info/WHEEL +4 -0
- santati-0.2.0.dist-info/licenses/LICENSE +190 -0
- santati_core/__init__.py +72 -0
- santati_core/api/__init__.py +5 -0
- santati_core/api/audit_events_api.py +843 -0
- santati_core/api_client.py +833 -0
- santati_core/api_response.py +21 -0
- santati_core/configuration.py +679 -0
- santati_core/exceptions.py +218 -0
- santati_core/models/__init__.py +28 -0
- santati_core/models/audit_event.py +128 -0
- santati_core/models/error_body.py +97 -0
- santati_core/models/event_actor.py +94 -0
- santati_core/models/event_actor_request.py +94 -0
- santati_core/models/event_batch_item_result.py +105 -0
- santati_core/models/event_batch_request.py +95 -0
- santati_core/models/event_batch_result.py +99 -0
- santati_core/models/event_envelope_request.py +122 -0
- santati_core/models/event_ingest_request.py +137 -0
- santati_core/models/event_target.py +94 -0
- santati_core/models/event_target_request.py +94 -0
- santati_core/models/paginated_audit_event_list.py +109 -0
- santati_core/rest.py +334 -0
santati/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Official Python SDK for the Santati audit-log API.
|
|
2
|
+
|
|
3
|
+
import santati
|
|
4
|
+
|
|
5
|
+
with santati.Santati("sat_sk_...", trail="billing") as client:
|
|
6
|
+
result = client.events.emit("invoice.voided", organization_id="org_acme")
|
|
7
|
+
print(result.event.id, result.duplicate)
|
|
8
|
+
|
|
9
|
+
# With an outbox, emit returns at once and a background worker sends the event.
|
|
10
|
+
with santati.Santati("sat_sk_...", trail="billing", outbox=santati.MemoryOutbox()) as client:
|
|
11
|
+
client.events.emit("invoice.paid", organization_id="org_acme")
|
|
12
|
+
|
|
13
|
+
See ``docs/sdk-surface.md`` in the repository for the surface every Santati SDK
|
|
14
|
+
implements.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from santati_core.models.audit_event import AuditEvent
|
|
20
|
+
from santati_core.models.event_actor import EventActor
|
|
21
|
+
from santati_core.models.event_target import EventTarget
|
|
22
|
+
|
|
23
|
+
from ._client import DEFAULT_BASE_URL, Events, Santati
|
|
24
|
+
from ._client import __version__ as __version__
|
|
25
|
+
from ._errors import (
|
|
26
|
+
ApiError,
|
|
27
|
+
AuthError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
OutboxError,
|
|
30
|
+
RateLimitedError,
|
|
31
|
+
SantatiError,
|
|
32
|
+
ServerError,
|
|
33
|
+
TransportError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from ._outbox import MemoryOutbox, OutboxEntry, OutboxStore, PostSendHook, PreSendHook, SendOutcome
|
|
37
|
+
from ._types import (
|
|
38
|
+
ActorInput,
|
|
39
|
+
BatchItem,
|
|
40
|
+
BatchItemError,
|
|
41
|
+
BatchResult,
|
|
42
|
+
EmitResult,
|
|
43
|
+
EventInput,
|
|
44
|
+
EventPage,
|
|
45
|
+
TargetInput,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DEFAULT_BASE_URL",
|
|
50
|
+
"ActorInput",
|
|
51
|
+
"ApiError",
|
|
52
|
+
"AuditEvent",
|
|
53
|
+
"AuthError",
|
|
54
|
+
"BatchItem",
|
|
55
|
+
"BatchItemError",
|
|
56
|
+
"BatchResult",
|
|
57
|
+
"EmitResult",
|
|
58
|
+
"EventActor",
|
|
59
|
+
"EventInput",
|
|
60
|
+
"EventPage",
|
|
61
|
+
"EventTarget",
|
|
62
|
+
"Events",
|
|
63
|
+
"MemoryOutbox",
|
|
64
|
+
"NotFoundError",
|
|
65
|
+
"OutboxEntry",
|
|
66
|
+
"OutboxError",
|
|
67
|
+
"OutboxStore",
|
|
68
|
+
"PostSendHook",
|
|
69
|
+
"PreSendHook",
|
|
70
|
+
"RateLimitedError",
|
|
71
|
+
"Santati",
|
|
72
|
+
"SantatiError",
|
|
73
|
+
"SendOutcome",
|
|
74
|
+
"ServerError",
|
|
75
|
+
"TargetInput",
|
|
76
|
+
"TransportError",
|
|
77
|
+
"ValidationError",
|
|
78
|
+
"__version__",
|
|
79
|
+
]
|
santati/_client.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""The Santati client and its ``events`` resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
import urllib3
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
17
|
+
from typing_extensions import Self
|
|
18
|
+
|
|
19
|
+
from santati_core import ApiClient, AuditEventsApi, Configuration
|
|
20
|
+
from santati_core.models.audit_event import AuditEvent
|
|
21
|
+
from santati_core.models.event_batch_item_result import EventBatchItemResult
|
|
22
|
+
from santati_core.models.event_batch_request import EventBatchRequest
|
|
23
|
+
from santati_core.models.event_batch_result import EventBatchResult
|
|
24
|
+
from santati_core.models.event_envelope_request import EventEnvelopeRequest
|
|
25
|
+
from santati_core.models.event_ingest_request import EventIngestRequest
|
|
26
|
+
from santati_core.models.paginated_audit_event_list import PaginatedAuditEventList
|
|
27
|
+
|
|
28
|
+
from ._errors import (
|
|
29
|
+
ApiError,
|
|
30
|
+
AuthError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitedError,
|
|
33
|
+
SantatiError,
|
|
34
|
+
ServerError,
|
|
35
|
+
TransportError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
)
|
|
38
|
+
from ._outbox import OutboxStore, PostSendHook, PreSendHook, _Outbox
|
|
39
|
+
from ._retry import RetryPolicy
|
|
40
|
+
from ._types import (
|
|
41
|
+
ActorInput,
|
|
42
|
+
BatchItem,
|
|
43
|
+
BatchItemError,
|
|
44
|
+
BatchResult,
|
|
45
|
+
EmitResult,
|
|
46
|
+
EventInput,
|
|
47
|
+
EventPage,
|
|
48
|
+
TargetInput,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__version__: str = importlib.metadata.version("santati")
|
|
52
|
+
"""This SDK's version, read from the installed distribution metadata."""
|
|
53
|
+
|
|
54
|
+
DEFAULT_BASE_URL = "https://api.santati.io"
|
|
55
|
+
"""Where requests go when no ``base_url`` is given."""
|
|
56
|
+
|
|
57
|
+
_MODEL = TypeVar("_MODEL", bound=BaseModel)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Santati:
|
|
61
|
+
"""A Santati client.
|
|
62
|
+
|
|
63
|
+
Construction is the only place the options are read; :attr:`trail`, the
|
|
64
|
+
default trail for emits, stays live. Use it as a context manager, or call
|
|
65
|
+
:meth:`close` yourself, to send what a queued :meth:`Events.emit` left in the
|
|
66
|
+
outbox and release the pooled connections.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: str,
|
|
72
|
+
*,
|
|
73
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
74
|
+
trail: str | None = None,
|
|
75
|
+
timeout_ms: int = 10000,
|
|
76
|
+
max_retries: int = 2,
|
|
77
|
+
initial_backoff_ms: int = 250,
|
|
78
|
+
max_backoff_ms: int = 8000,
|
|
79
|
+
headers: Mapping[str, str] | None = None,
|
|
80
|
+
outbox: OutboxStore | None = None,
|
|
81
|
+
batch_size: int = 100,
|
|
82
|
+
flush_interval_ms: int = 1000,
|
|
83
|
+
pre_send: PreSendHook | None = None,
|
|
84
|
+
post_send: PostSendHook | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
if not api_key:
|
|
87
|
+
raise ValidationError("api_key must be a non-empty string", field="api_key")
|
|
88
|
+
for name in headers or {}:
|
|
89
|
+
if name.lower() == "authorization":
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
"headers must not set Authorization: the SDK sets it on every request",
|
|
92
|
+
field="headers",
|
|
93
|
+
)
|
|
94
|
+
if not 1 <= batch_size <= 500:
|
|
95
|
+
raise ValidationError("batch_size must be between 1 and 500", field="batch_size")
|
|
96
|
+
if flush_interval_ms <= 0:
|
|
97
|
+
raise ValidationError("flush_interval_ms must be above zero", field="flush_interval_ms")
|
|
98
|
+
|
|
99
|
+
self.api_key = api_key
|
|
100
|
+
self.base_url = base_url.rstrip("/")
|
|
101
|
+
self.trail = trail
|
|
102
|
+
self.headers = dict(headers or {})
|
|
103
|
+
|
|
104
|
+
self._timeout_seconds = timeout_ms / 1000
|
|
105
|
+
self._retry = RetryPolicy(max_retries, initial_backoff_ms, max_backoff_ms)
|
|
106
|
+
# The generated core applies the spec's `ApiKeyAuth` scheme itself: it
|
|
107
|
+
# reads the key and the `Api-Key` prefix from the configuration.
|
|
108
|
+
self._api_client = ApiClient(
|
|
109
|
+
Configuration(
|
|
110
|
+
host=self.base_url,
|
|
111
|
+
api_key={"ApiKeyAuth": api_key},
|
|
112
|
+
api_key_prefix={"ApiKeyAuth": "Api-Key"},
|
|
113
|
+
retries=urllib3.util.Retry(total=0, redirect=False),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
self._api_client.user_agent = f"santati-python/{__version__}"
|
|
117
|
+
_apply_default_headers(self._api_client, self.headers)
|
|
118
|
+
self._api = AuditEventsApi(self._api_client)
|
|
119
|
+
|
|
120
|
+
self.events = Events(self)
|
|
121
|
+
"""The audit-event operations: ``emit``, ``emit_batch``, ``list``, ``iterate``."""
|
|
122
|
+
|
|
123
|
+
self._outbox: _Outbox | None = (
|
|
124
|
+
None
|
|
125
|
+
if outbox is None
|
|
126
|
+
else _Outbox(
|
|
127
|
+
self,
|
|
128
|
+
outbox,
|
|
129
|
+
batch_size=batch_size,
|
|
130
|
+
flush_interval_ms=flush_interval_ms,
|
|
131
|
+
pre_send=pre_send,
|
|
132
|
+
post_send=post_send,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def flush(self) -> None:
|
|
137
|
+
"""Send what the outbox holds now: one pass, in batches of ``batch_size``.
|
|
138
|
+
|
|
139
|
+
Returns at once when the client has no ``outbox``.
|
|
140
|
+
"""
|
|
141
|
+
if self._outbox is not None:
|
|
142
|
+
self._outbox.flush()
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
"""Stop the outbox worker (if any), flush the outbox once, and release the pooled connections.
|
|
146
|
+
|
|
147
|
+
A queued :meth:`Events.emit` after ``close`` raises :class:`OutboxError`
|
|
148
|
+
(``closed``); without an ``outbox`` only the connections are released
|
|
149
|
+
and ``emit`` keeps working.
|
|
150
|
+
"""
|
|
151
|
+
if self._outbox is not None:
|
|
152
|
+
self._outbox.close()
|
|
153
|
+
self._api_client.rest_client.pool_manager.clear()
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> Self:
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(
|
|
159
|
+
self,
|
|
160
|
+
exc_type: type[BaseException] | None,
|
|
161
|
+
exc_value: BaseException | None,
|
|
162
|
+
traceback: TracebackType | None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Events:
|
|
168
|
+
"""Audit-event operations, reached through ``client.events``."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, client: Santati) -> None:
|
|
171
|
+
self._client = client
|
|
172
|
+
|
|
173
|
+
def emit(
|
|
174
|
+
self,
|
|
175
|
+
event: str,
|
|
176
|
+
*,
|
|
177
|
+
trail: str | None = None,
|
|
178
|
+
organization_id: str | None = None,
|
|
179
|
+
actor: ActorInput | None = None,
|
|
180
|
+
targets: Sequence[TargetInput] | None = None,
|
|
181
|
+
metadata: Mapping[str, str] | None = None,
|
|
182
|
+
data: Any = None,
|
|
183
|
+
context: Mapping[str, Any] | None = None,
|
|
184
|
+
created_at: str | None = None,
|
|
185
|
+
idempotency_key: str | None = None,
|
|
186
|
+
) -> EmitResult:
|
|
187
|
+
"""Index one event, or with an ``outbox`` store it for the background worker and return at once.
|
|
188
|
+
|
|
189
|
+
A repeated ``idempotency_key`` returns the stored event. A queued emit
|
|
190
|
+
never makes a request: the result has ``event=None`` and ``queued=True``.
|
|
191
|
+
Raises :class:`OutboxError` when the store refuses the event or the
|
|
192
|
+
client is closed.
|
|
193
|
+
"""
|
|
194
|
+
envelope = _build_envelope(
|
|
195
|
+
event=event,
|
|
196
|
+
trail=trail,
|
|
197
|
+
organization_id=organization_id,
|
|
198
|
+
actor=actor,
|
|
199
|
+
targets=targets,
|
|
200
|
+
metadata=metadata,
|
|
201
|
+
data=data,
|
|
202
|
+
context=context,
|
|
203
|
+
created_at=created_at,
|
|
204
|
+
idempotency_key=idempotency_key,
|
|
205
|
+
default_trail=self._client.trail,
|
|
206
|
+
field_prefix="",
|
|
207
|
+
)
|
|
208
|
+
body = _envelope_model(envelope)
|
|
209
|
+
key = envelope["idempotency_key"]
|
|
210
|
+
outbox = self._client._outbox
|
|
211
|
+
if outbox is not None:
|
|
212
|
+
outbox.enqueue(cast(EventInput, envelope))
|
|
213
|
+
return EmitResult(event=None, duplicate=False, idempotency_key=key, queued=True)
|
|
214
|
+
|
|
215
|
+
def attempt() -> EmitResult:
|
|
216
|
+
status, headers, raw = _attempt(
|
|
217
|
+
lambda: self._client._api.events_create_without_preload_content(
|
|
218
|
+
event_ingest_request=EventIngestRequest(actual_instance=body),
|
|
219
|
+
_request_timeout=self._client._timeout_seconds,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
if status in (200, 201):
|
|
223
|
+
return EmitResult(
|
|
224
|
+
event=_decode(AuditEvent, raw, status),
|
|
225
|
+
duplicate=status == 200,
|
|
226
|
+
idempotency_key=key,
|
|
227
|
+
)
|
|
228
|
+
raise _error_from_response(status, headers, raw)
|
|
229
|
+
|
|
230
|
+
return self._client._retry.run(attempt)
|
|
231
|
+
|
|
232
|
+
def emit_batch(self, events: Sequence[EventInput]) -> BatchResult:
|
|
233
|
+
"""Index up to 500 events in one request, one generated key per item."""
|
|
234
|
+
return self._emit_batch(events)[1]
|
|
235
|
+
|
|
236
|
+
def _emit_batch(self, events: Sequence[EventInput], *, retries: bool = True) -> tuple[int, BatchResult]:
|
|
237
|
+
""":meth:`emit_batch`, plus the response status (202 or 207) the outbox reports; ``retries=False`` sends once."""
|
|
238
|
+
if not events:
|
|
239
|
+
raise ValidationError("events must not be empty", field="events")
|
|
240
|
+
envelopes = [
|
|
241
|
+
_build_envelope(
|
|
242
|
+
event=item.get("event"),
|
|
243
|
+
trail=item.get("trail"),
|
|
244
|
+
organization_id=item.get("organization_id"),
|
|
245
|
+
actor=item.get("actor"),
|
|
246
|
+
targets=item.get("targets"),
|
|
247
|
+
metadata=item.get("metadata"),
|
|
248
|
+
data=item.get("data"),
|
|
249
|
+
context=item.get("context"),
|
|
250
|
+
created_at=item.get("created_at"),
|
|
251
|
+
idempotency_key=item.get("idempotency_key"),
|
|
252
|
+
default_trail=self._client.trail,
|
|
253
|
+
field_prefix=f"events[{index}].",
|
|
254
|
+
)
|
|
255
|
+
for index, item in enumerate(events)
|
|
256
|
+
]
|
|
257
|
+
try:
|
|
258
|
+
batch = EventBatchRequest.model_validate({"events": envelopes})
|
|
259
|
+
except PydanticValidationError as err:
|
|
260
|
+
raise _validation_error(err) from err
|
|
261
|
+
|
|
262
|
+
def attempt() -> tuple[int, BatchResult]:
|
|
263
|
+
status, headers, raw = _attempt(
|
|
264
|
+
lambda: self._client._api.events_create_without_preload_content(
|
|
265
|
+
event_ingest_request=EventIngestRequest(actual_instance=batch),
|
|
266
|
+
_request_timeout=self._client._timeout_seconds,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
if status in (202, 207):
|
|
270
|
+
result = _decode(EventBatchResult, raw, status)
|
|
271
|
+
return status, BatchResult(
|
|
272
|
+
accepted=result.accepted,
|
|
273
|
+
rejected=result.rejected,
|
|
274
|
+
results=[_batch_item(item) for item in result.results],
|
|
275
|
+
)
|
|
276
|
+
raise _error_from_response(status, headers, raw)
|
|
277
|
+
|
|
278
|
+
return self._client._retry.run(attempt) if retries else attempt()
|
|
279
|
+
|
|
280
|
+
def list(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
trail: str | None = None,
|
|
284
|
+
event: str | None = None,
|
|
285
|
+
event_prefix: str | None = None,
|
|
286
|
+
organization_id: str | None = None,
|
|
287
|
+
actor_id: str | None = None,
|
|
288
|
+
actor_type: str | None = None,
|
|
289
|
+
target_type: str | None = None,
|
|
290
|
+
target_id: str | None = None,
|
|
291
|
+
created_after: str | None = None,
|
|
292
|
+
created_before: str | None = None,
|
|
293
|
+
q: str | None = None,
|
|
294
|
+
sort: str | None = None,
|
|
295
|
+
limit: int | None = None,
|
|
296
|
+
cursor: str | None = None,
|
|
297
|
+
) -> EventPage:
|
|
298
|
+
"""Read one page of events; the client's default trail does not apply."""
|
|
299
|
+
params: dict[str, Any] = {}
|
|
300
|
+
for name, value in (
|
|
301
|
+
("actor_id", actor_id),
|
|
302
|
+
("actor_type", actor_type),
|
|
303
|
+
("created_after", created_after),
|
|
304
|
+
("created_before", created_before),
|
|
305
|
+
("cursor", cursor),
|
|
306
|
+
("event", event),
|
|
307
|
+
("event_prefix", event_prefix),
|
|
308
|
+
("limit", limit),
|
|
309
|
+
("organization_id", organization_id),
|
|
310
|
+
("q", q),
|
|
311
|
+
("sort", sort),
|
|
312
|
+
("target_id", target_id),
|
|
313
|
+
("target_type", target_type),
|
|
314
|
+
("trail", trail),
|
|
315
|
+
):
|
|
316
|
+
if value is not None:
|
|
317
|
+
params[name] = value
|
|
318
|
+
|
|
319
|
+
def attempt() -> EventPage:
|
|
320
|
+
try:
|
|
321
|
+
response = self._client._api.events_list_without_preload_content(
|
|
322
|
+
**params, _request_timeout=self._client._timeout_seconds
|
|
323
|
+
)
|
|
324
|
+
except PydanticValidationError as err:
|
|
325
|
+
raise _validation_error(err) from err
|
|
326
|
+
status, headers, raw = _read(response)
|
|
327
|
+
if status != 200:
|
|
328
|
+
raise _error_from_response(status, headers, raw)
|
|
329
|
+
page = _decode(PaginatedAuditEventList, raw, status)
|
|
330
|
+
return EventPage(results=page.results, next_cursor=_next_cursor(page.next))
|
|
331
|
+
|
|
332
|
+
return self._client._retry.run(attempt)
|
|
333
|
+
|
|
334
|
+
def iterate(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
trail: str | None = None,
|
|
338
|
+
event: str | None = None,
|
|
339
|
+
event_prefix: str | None = None,
|
|
340
|
+
organization_id: str | None = None,
|
|
341
|
+
actor_id: str | None = None,
|
|
342
|
+
actor_type: str | None = None,
|
|
343
|
+
target_type: str | None = None,
|
|
344
|
+
target_id: str | None = None,
|
|
345
|
+
created_after: str | None = None,
|
|
346
|
+
created_before: str | None = None,
|
|
347
|
+
q: str | None = None,
|
|
348
|
+
sort: str | None = None,
|
|
349
|
+
limit: int | None = None,
|
|
350
|
+
) -> Iterator[AuditEvent]:
|
|
351
|
+
"""Lazily yield every matching event, page by page."""
|
|
352
|
+
cursor: str | None = None
|
|
353
|
+
while True:
|
|
354
|
+
page = self.list(
|
|
355
|
+
trail=trail,
|
|
356
|
+
event=event,
|
|
357
|
+
event_prefix=event_prefix,
|
|
358
|
+
organization_id=organization_id,
|
|
359
|
+
actor_id=actor_id,
|
|
360
|
+
actor_type=actor_type,
|
|
361
|
+
target_type=target_type,
|
|
362
|
+
target_id=target_id,
|
|
363
|
+
created_after=created_after,
|
|
364
|
+
created_before=created_before,
|
|
365
|
+
q=q,
|
|
366
|
+
sort=sort,
|
|
367
|
+
limit=limit,
|
|
368
|
+
cursor=cursor,
|
|
369
|
+
)
|
|
370
|
+
yield from page.results
|
|
371
|
+
if page.next_cursor is None:
|
|
372
|
+
return
|
|
373
|
+
cursor = page.next_cursor
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _apply_default_headers(client: ApiClient, headers: Mapping[str, str]) -> None:
|
|
377
|
+
"""Set headers every request carries; defaults beat the generated per-call ones."""
|
|
378
|
+
for name, value in headers.items():
|
|
379
|
+
client.set_default_header(name, value) # type: ignore[no-untyped-call] # generated
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _build_envelope(
|
|
383
|
+
*,
|
|
384
|
+
event: str | None,
|
|
385
|
+
trail: str | None,
|
|
386
|
+
organization_id: str | None,
|
|
387
|
+
actor: ActorInput | None,
|
|
388
|
+
targets: Sequence[TargetInput] | None,
|
|
389
|
+
metadata: Mapping[str, str] | None,
|
|
390
|
+
data: Any,
|
|
391
|
+
context: Mapping[str, Any] | None,
|
|
392
|
+
created_at: str | None,
|
|
393
|
+
idempotency_key: str | None,
|
|
394
|
+
default_trail: str | None,
|
|
395
|
+
field_prefix: str,
|
|
396
|
+
) -> dict[str, Any]:
|
|
397
|
+
"""The wire envelope: resolved trail, a key, and only the supplied members."""
|
|
398
|
+
if not event:
|
|
399
|
+
raise ValidationError("event must be a non-empty string", field=f"{field_prefix}event")
|
|
400
|
+
resolved_trail = trail or default_trail
|
|
401
|
+
if not resolved_trail:
|
|
402
|
+
raise ValidationError(
|
|
403
|
+
"trail must be resolved: supply one on the event or set a client default",
|
|
404
|
+
field=f"{field_prefix}trail",
|
|
405
|
+
)
|
|
406
|
+
envelope: dict[str, Any] = {
|
|
407
|
+
"event": event,
|
|
408
|
+
"trail": resolved_trail,
|
|
409
|
+
"idempotency_key": idempotency_key or str(uuid.uuid4()),
|
|
410
|
+
}
|
|
411
|
+
for name, value in (
|
|
412
|
+
("organization_id", organization_id),
|
|
413
|
+
("actor", actor),
|
|
414
|
+
("targets", list(targets) if targets is not None else None),
|
|
415
|
+
("metadata", metadata),
|
|
416
|
+
("data", data),
|
|
417
|
+
("context", context),
|
|
418
|
+
("created_at", created_at),
|
|
419
|
+
):
|
|
420
|
+
if value is not None:
|
|
421
|
+
envelope[name] = value
|
|
422
|
+
return envelope
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _envelope_model(envelope: Mapping[str, Any]) -> EventEnvelopeRequest:
|
|
426
|
+
"""Validate the envelope with the generated request model."""
|
|
427
|
+
try:
|
|
428
|
+
return EventEnvelopeRequest.model_validate(dict(envelope))
|
|
429
|
+
except PydanticValidationError as err:
|
|
430
|
+
raise _validation_error(err) from err
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validation_error(err: PydanticValidationError) -> ValidationError:
|
|
434
|
+
location = err.errors()[0]["loc"]
|
|
435
|
+
return ValidationError(str(err), field=".".join(str(part) for part in location))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _attempt(request: Callable[[], urllib3.HTTPResponse]) -> tuple[int, Any, bytes]:
|
|
439
|
+
"""Make one request and read its body, mapping transport failures."""
|
|
440
|
+
try:
|
|
441
|
+
response = request()
|
|
442
|
+
try:
|
|
443
|
+
return response.status, response.headers, response.data
|
|
444
|
+
finally:
|
|
445
|
+
response.release_conn()
|
|
446
|
+
except urllib3.exceptions.HTTPError as err:
|
|
447
|
+
raise TransportError(f"{type(err).__name__}: {err}") from err
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _read(response: urllib3.HTTPResponse) -> tuple[int, Any, bytes]:
|
|
451
|
+
"""Read a response that was already made (see :func:`_attempt`)."""
|
|
452
|
+
return _attempt(lambda: response)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _decode(model: type[_MODEL], body: bytes, status: int) -> _MODEL:
|
|
456
|
+
"""Parse a success body with the generated read model."""
|
|
457
|
+
try:
|
|
458
|
+
parsed = json.loads(body)
|
|
459
|
+
except ValueError as err:
|
|
460
|
+
raise ApiError(f"HTTP {status}: response body is not JSON", status=status) from err
|
|
461
|
+
try:
|
|
462
|
+
return model.model_validate(parsed)
|
|
463
|
+
except PydanticValidationError as err:
|
|
464
|
+
raise ApiError(f"HTTP {status}: response body does not match the schema", status=status) from err
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _batch_item(item: EventBatchItemResult) -> BatchItem:
|
|
468
|
+
error = None
|
|
469
|
+
if item.error is not None:
|
|
470
|
+
error = BatchItemError(code=item.error.code, message=item.error.message, field=item.error.var_field)
|
|
471
|
+
return BatchItem(index=item.index, status=item.status, id=item.id, error=error)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _next_cursor(next_url: str | None) -> str | None:
|
|
475
|
+
"""The decoded ``cursor`` of a page's ``next`` URL, or ``None``."""
|
|
476
|
+
if not next_url:
|
|
477
|
+
return None
|
|
478
|
+
values = urllib.parse.parse_qs(urllib.parse.urlsplit(next_url).query).get("cursor")
|
|
479
|
+
return values[0] if values else None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _error_from_response(status: int, headers: Any, body: bytes) -> SantatiError:
|
|
483
|
+
kind = _error_kind(status)
|
|
484
|
+
code: str | None = None
|
|
485
|
+
field: str | None = None
|
|
486
|
+
message = f"HTTP {status}"
|
|
487
|
+
try:
|
|
488
|
+
parsed = json.loads(body)
|
|
489
|
+
except ValueError:
|
|
490
|
+
parsed = None
|
|
491
|
+
if isinstance(parsed, dict):
|
|
492
|
+
error = parsed.get("error")
|
|
493
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str):
|
|
494
|
+
code = error["code"]
|
|
495
|
+
if isinstance(error.get("field"), str):
|
|
496
|
+
field = error["field"]
|
|
497
|
+
if isinstance(error.get("message"), str):
|
|
498
|
+
message = error["message"]
|
|
499
|
+
elif isinstance(parsed.get("detail"), str):
|
|
500
|
+
message = parsed["detail"]
|
|
501
|
+
return kind(message, status=status, code=code, field=field, retry_after=_retry_after(headers))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _error_kind(status: int) -> type[SantatiError]:
|
|
505
|
+
if status in (400, 413, 422):
|
|
506
|
+
return ValidationError
|
|
507
|
+
if status in (401, 403):
|
|
508
|
+
return AuthError
|
|
509
|
+
if status == 404:
|
|
510
|
+
return NotFoundError
|
|
511
|
+
if status == 429:
|
|
512
|
+
return RateLimitedError
|
|
513
|
+
if 500 <= status <= 599:
|
|
514
|
+
return ServerError
|
|
515
|
+
return ApiError
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _retry_after(headers: Any) -> int | None:
|
|
519
|
+
value = headers.get("Retry-After")
|
|
520
|
+
if isinstance(value, str) and re.fullmatch(r"\d+", value):
|
|
521
|
+
return int(value)
|
|
522
|
+
return None
|
santati/_errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Errors raised by the Santati SDK.
|
|
2
|
+
|
|
3
|
+
Every failure is a :class:`SantatiError`; the eight subclasses are the kinds
|
|
4
|
+
described in ``docs/sdk-surface.md``. ``status`` is ``None`` for local
|
|
5
|
+
validation failures, transport errors and outbox errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SantatiError(Exception):
|
|
12
|
+
"""Base class for every error this SDK raises."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
status: int | None = None,
|
|
19
|
+
code: str | None = None,
|
|
20
|
+
field: str | None = None,
|
|
21
|
+
retry_after: int | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
"""Human-readable explanation of the failure."""
|
|
26
|
+
self.status = status
|
|
27
|
+
"""HTTP status of the failing response, or ``None`` when there was none."""
|
|
28
|
+
self.code = code
|
|
29
|
+
"""The server's machine-readable error code, when it sent one."""
|
|
30
|
+
self.field = field
|
|
31
|
+
"""The request field at fault, when the server named one."""
|
|
32
|
+
self.retry_after = retry_after
|
|
33
|
+
"""Seconds from the response's ``Retry-After`` header, or ``None``."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationError(SantatiError):
|
|
37
|
+
"""The request was rejected locally or by the server (400, 413, 422)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthError(SantatiError):
|
|
41
|
+
"""The API key is missing, invalid or not scoped to the request (401, 403)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotFoundError(SantatiError):
|
|
45
|
+
"""The addressed resource does not exist (404)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RateLimitedError(SantatiError):
|
|
49
|
+
"""The team is over its rate limit (429)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ServerError(SantatiError):
|
|
53
|
+
"""Santati failed to serve the request (500-599)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TransportError(SantatiError):
|
|
57
|
+
"""No HTTP response arrived: refused, DNS, TLS or timeout."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ApiError(SantatiError):
|
|
61
|
+
"""Any other failure: an unexpected status or an undecodable success body."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OutboxError(SantatiError):
|
|
65
|
+
"""The outbox store refused or failed, or a ``pre_send`` hook raised (status ``None``).
|
|
66
|
+
|
|
67
|
+
``code`` is ``outbox_full``, ``store_unavailable``, ``closed`` or ``hook_failed``.
|
|
68
|
+
"""
|