driftstack-sdk 0.1.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.
- driftstack/__init__.py +60 -0
- driftstack/_generated/__init__.py +6 -0
- driftstack/_generated/models.py +494 -0
- driftstack/_version.py +10 -0
- driftstack/client.py +120 -0
- driftstack/errors.py +194 -0
- driftstack/http.py +291 -0
- driftstack/py.typed +0 -0
- driftstack/resources/__init__.py +29 -0
- driftstack/resources/_common.py +38 -0
- driftstack/resources/api_keys.py +61 -0
- driftstack/resources/sessions.py +156 -0
- driftstack/resources/usage.py +29 -0
- driftstack/resources/webhooks.py +110 -0
- driftstack/retry.py +105 -0
- driftstack/webhook_signature.py +106 -0
- driftstack_sdk-0.1.0.dist-info/METADATA +226 -0
- driftstack_sdk-0.1.0.dist-info/RECORD +19 -0
- driftstack_sdk-0.1.0.dist-info/WHEEL +4 -0
driftstack/errors.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Error class hierarchy for the Driftstack Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the server's RFC 7807 problem-types (apps/server/src/lib/errors.ts).
|
|
4
|
+
The HTTP layer maps `application/problem+json` responses to the right
|
|
5
|
+
subclass; non-HTTP failures (timeouts, parse errors, network) raise
|
|
6
|
+
``TransportError``.
|
|
7
|
+
|
|
8
|
+
Callers can catch with the granularity they need::
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
client.sessions.create()
|
|
12
|
+
except RateLimitError as e:
|
|
13
|
+
time.sleep(e.retry_after_seconds or 1)
|
|
14
|
+
except DriftstackError as e:
|
|
15
|
+
# any other typed problem
|
|
16
|
+
log.error("driftstack call failed: %s", e)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DriftstackError(Exception):
|
|
25
|
+
"""Base for every error raised by the Driftstack SDK.
|
|
26
|
+
|
|
27
|
+
All HTTP-derived errors carry the parsed problem document so callers
|
|
28
|
+
can read additional fields (``e.problem.get("retry_after_seconds")``,
|
|
29
|
+
``e.problem.get("current_sessions")``, etc.) without knowing the
|
|
30
|
+
specific subclass shape.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
status: int | None = None,
|
|
38
|
+
problem_type: str | None = None,
|
|
39
|
+
problem: dict[str, Any] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.status = status
|
|
44
|
+
self.problem_type = problem_type
|
|
45
|
+
self.problem: dict[str, Any] = problem or {}
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
48
|
+
return f"{type(self).__name__}({self.message!r}, status={self.status})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Auth (401, 403) ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuthError(DriftstackError):
|
|
55
|
+
"""Base for authentication / authorisation failures."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InvalidKeyError(AuthError):
|
|
59
|
+
"""The provided API key was not recognised (malformed or unknown)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExpiredKeyError(AuthError):
|
|
63
|
+
"""The API key passed its ``expires_at`` deadline."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RevokedKeyError(AuthError):
|
|
67
|
+
"""The API key was revoked (DELETE /v1/api-keys/:id)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ForbiddenError(AuthError):
|
|
71
|
+
"""The caller is authenticated but lacks the required scope."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Validation / domain (400, 404, 409, 410) ──────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ValidationError(DriftstackError):
|
|
78
|
+
"""Request body or query parameters failed schema validation."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NotFoundError(DriftstackError):
|
|
82
|
+
"""The targeted resource doesn't exist."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ConflictError(DriftstackError):
|
|
86
|
+
"""The request would violate an invariant (duplicate, capacity, etc.)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SessionNotFoundError(NotFoundError):
|
|
90
|
+
"""Specifically: the addressed session id has no row in our store."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SessionDestroyedError(DriftstackError):
|
|
94
|
+
"""The session was destroyed; further operations on it are rejected (410)."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Rate / quota (429) ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RateLimitError(DriftstackError):
|
|
101
|
+
"""Token-bucket rate limit hit. ``retry_after_seconds`` is the hint."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
message: str,
|
|
106
|
+
*,
|
|
107
|
+
retry_after_seconds: int | None = None,
|
|
108
|
+
status: int | None = 429,
|
|
109
|
+
problem_type: str | None = None,
|
|
110
|
+
problem: dict[str, Any] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
super().__init__(message, status=status, problem_type=problem_type, problem=problem)
|
|
113
|
+
self.retry_after_seconds = retry_after_seconds
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class QuotaExceededError(DriftstackError):
|
|
117
|
+
"""Per-period usage quota exhausted."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
message: str,
|
|
122
|
+
*,
|
|
123
|
+
current: int | None = None,
|
|
124
|
+
limit: int | None = None,
|
|
125
|
+
record_type: str | None = None,
|
|
126
|
+
status: int | None = 429,
|
|
127
|
+
problem_type: str | None = None,
|
|
128
|
+
problem: dict[str, Any] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
super().__init__(message, status=status, problem_type=problem_type, problem=problem)
|
|
131
|
+
self.current = current
|
|
132
|
+
self.limit = limit
|
|
133
|
+
self.record_type = record_type
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ConcurrencyLimitError(DriftstackError):
|
|
137
|
+
"""Active-session count would exceed the tier's concurrent limit."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
message: str,
|
|
142
|
+
*,
|
|
143
|
+
current_sessions: int | None = None,
|
|
144
|
+
limit: int | None = None,
|
|
145
|
+
status: int | None = 429,
|
|
146
|
+
problem_type: str | None = None,
|
|
147
|
+
problem: dict[str, Any] | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
super().__init__(message, status=status, problem_type=problem_type, problem=problem)
|
|
150
|
+
self.current_sessions = current_sessions
|
|
151
|
+
self.limit = limit
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── Driver / upstream (502) ───────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DriverError(DriftstackError):
|
|
158
|
+
"""The driver returned an unrecoverable error during the operation."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── Transport (network, timeout, parse) ───────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TransportError(DriftstackError):
|
|
165
|
+
"""A network-level or response-parsing failure that didn't reach the server.
|
|
166
|
+
|
|
167
|
+
Distinguished from server-returned errors so retry logic can decide
|
|
168
|
+
whether the request was idempotent enough to retry without surprises.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Mapping problem-type URI → subclass ──────────────────────────────────
|
|
173
|
+
|
|
174
|
+
# Keep the mapping in one place for ease of audit + extension. The HTTP
|
|
175
|
+
# layer in `driftstack.http` consults this; the keys match the server
|
|
176
|
+
# constants in apps/server/src/lib/problem-types.ts.
|
|
177
|
+
|
|
178
|
+
PROBLEM_TYPE_TO_ERROR: dict[str, type[DriftstackError]] = {
|
|
179
|
+
"https://errors.driftstack.dev/bad-request": ValidationError,
|
|
180
|
+
"https://errors.driftstack.dev/unauthorized": AuthError,
|
|
181
|
+
"https://errors.driftstack.dev/forbidden": ForbiddenError,
|
|
182
|
+
"https://errors.driftstack.dev/not-found": NotFoundError,
|
|
183
|
+
"https://errors.driftstack.dev/conflict": ConflictError,
|
|
184
|
+
"https://errors.driftstack.dev/rate-limited": RateLimitError,
|
|
185
|
+
"https://errors.driftstack.dev/concurrency-limit": ConcurrencyLimitError,
|
|
186
|
+
"https://errors.driftstack.dev/tier-limit": QuotaExceededError,
|
|
187
|
+
"https://errors.driftstack.dev/revoked-key": RevokedKeyError,
|
|
188
|
+
"https://errors.driftstack.dev/expired-key": ExpiredKeyError,
|
|
189
|
+
"https://errors.driftstack.dev/invalid-key": InvalidKeyError,
|
|
190
|
+
"https://errors.driftstack.dev/session-destroyed": SessionDestroyedError,
|
|
191
|
+
"https://errors.driftstack.dev/driver-error": DriverError,
|
|
192
|
+
"https://errors.driftstack.dev/driver-not-integrated": DriverError,
|
|
193
|
+
"https://errors.driftstack.dev/validation-failed": ValidationError,
|
|
194
|
+
}
|
driftstack/http.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""HTTP client wrapper.
|
|
2
|
+
|
|
3
|
+
Customers don't construct this directly — they get a :class:`Driftstack`
|
|
4
|
+
or :class:`AsyncDriftstack`, which wraps an :class:`HttpClient`
|
|
5
|
+
internally. The wrapper handles:
|
|
6
|
+
|
|
7
|
+
* Bearer auth header injection
|
|
8
|
+
* RFC 7807 problem-json → typed error mapping (``error_from_response``)
|
|
9
|
+
* Per-request timeout
|
|
10
|
+
* Retry policy delegation (see :mod:`driftstack.retry`)
|
|
11
|
+
|
|
12
|
+
Both sync and async variants share the same problem-mapping logic in
|
|
13
|
+
:func:`_error_from_response_data`, so a future shape change to the
|
|
14
|
+
server's error envelope updates both paths in one place.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from driftstack._version import __version__
|
|
25
|
+
from driftstack.errors import (
|
|
26
|
+
PROBLEM_TYPE_TO_ERROR,
|
|
27
|
+
ConcurrencyLimitError,
|
|
28
|
+
DriftstackError,
|
|
29
|
+
QuotaExceededError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
TransportError,
|
|
32
|
+
)
|
|
33
|
+
from driftstack.retry import RetryConfig, with_retry, with_retry_async
|
|
34
|
+
|
|
35
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
36
|
+
USER_AGENT = f"driftstack-sdk-python/{__version__}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_headers(api_key: str, has_body: bool) -> dict[str, str]:
|
|
40
|
+
headers = {
|
|
41
|
+
"authorization": f"Bearer {api_key}",
|
|
42
|
+
"user-agent": USER_AGENT,
|
|
43
|
+
"accept": "application/json",
|
|
44
|
+
}
|
|
45
|
+
if has_body:
|
|
46
|
+
headers["content-type"] = "application/json"
|
|
47
|
+
return headers
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _problem_from_text(text: str, status: int) -> dict[str, Any] | None:
|
|
51
|
+
"""Parse a response body as RFC 7807 problem+json. Return None on parse fail."""
|
|
52
|
+
if not text:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(text)
|
|
56
|
+
except (json.JSONDecodeError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
if not isinstance(parsed, dict):
|
|
59
|
+
return None
|
|
60
|
+
if "type" not in parsed or "title" not in parsed or "status" not in parsed:
|
|
61
|
+
return None
|
|
62
|
+
return parsed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _error_from_response_data(
|
|
66
|
+
status: int,
|
|
67
|
+
text: str,
|
|
68
|
+
retry_after_header: str | None,
|
|
69
|
+
) -> DriftstackError:
|
|
70
|
+
"""Map a non-2xx response to the right :class:`DriftstackError` subclass.
|
|
71
|
+
|
|
72
|
+
Falls back to :class:`TransportError` when the body isn't a proper
|
|
73
|
+
problem document — that surfaces as a server contract violation,
|
|
74
|
+
which is the right diagnostic for "we got a 500 with HTML in it."
|
|
75
|
+
"""
|
|
76
|
+
problem = _problem_from_text(text, status)
|
|
77
|
+
if problem is None:
|
|
78
|
+
return TransportError(
|
|
79
|
+
f"non-2xx response ({status}) with non-problem body",
|
|
80
|
+
status=status,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
problem_type = str(problem.get("type", ""))
|
|
84
|
+
title = str(problem.get("title", ""))
|
|
85
|
+
detail = str(problem.get("detail") or title)
|
|
86
|
+
|
|
87
|
+
# Retry-After can come from either the header or from a problem field.
|
|
88
|
+
retry_after_seconds: int | None = None
|
|
89
|
+
if retry_after_header is not None:
|
|
90
|
+
try:
|
|
91
|
+
retry_after_seconds = int(retry_after_header)
|
|
92
|
+
except ValueError:
|
|
93
|
+
retry_after_seconds = None
|
|
94
|
+
if retry_after_seconds is None and "retry_after_seconds" in problem:
|
|
95
|
+
try:
|
|
96
|
+
retry_after_seconds = int(problem["retry_after_seconds"])
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
retry_after_seconds = None
|
|
99
|
+
|
|
100
|
+
error_cls = PROBLEM_TYPE_TO_ERROR.get(problem_type, DriftstackError)
|
|
101
|
+
|
|
102
|
+
if error_cls is RateLimitError:
|
|
103
|
+
return RateLimitError(
|
|
104
|
+
detail,
|
|
105
|
+
retry_after_seconds=retry_after_seconds,
|
|
106
|
+
status=status,
|
|
107
|
+
problem_type=problem_type,
|
|
108
|
+
problem=problem,
|
|
109
|
+
)
|
|
110
|
+
if error_cls is QuotaExceededError:
|
|
111
|
+
return QuotaExceededError(
|
|
112
|
+
detail,
|
|
113
|
+
current=_int_or_none(problem.get("current")),
|
|
114
|
+
limit=_int_or_none(problem.get("limit")),
|
|
115
|
+
record_type=str(problem["record_type"]) if problem.get("record_type") else None,
|
|
116
|
+
status=status,
|
|
117
|
+
problem_type=problem_type,
|
|
118
|
+
problem=problem,
|
|
119
|
+
)
|
|
120
|
+
if error_cls is ConcurrencyLimitError:
|
|
121
|
+
return ConcurrencyLimitError(
|
|
122
|
+
detail,
|
|
123
|
+
current_sessions=_int_or_none(problem.get("current_sessions")),
|
|
124
|
+
limit=_int_or_none(problem.get("limit")),
|
|
125
|
+
status=status,
|
|
126
|
+
problem_type=problem_type,
|
|
127
|
+
problem=problem,
|
|
128
|
+
)
|
|
129
|
+
return error_cls(detail, status=status, problem_type=problem_type, problem=problem)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _int_or_none(value: Any) -> int | None:
|
|
133
|
+
if value is None:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return int(value)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
142
|
+
# Sync HTTP client (httpx.Client)
|
|
143
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class HttpClient:
|
|
147
|
+
"""Thin wrapper around ``httpx.Client`` for the sync :class:`Driftstack`."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
api_key: str,
|
|
152
|
+
*,
|
|
153
|
+
base_url: str,
|
|
154
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
155
|
+
retry: RetryConfig | None = None,
|
|
156
|
+
client: httpx.Client | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._api_key = api_key
|
|
159
|
+
self._base_url = base_url.rstrip("/")
|
|
160
|
+
self._retry = retry
|
|
161
|
+
self._client = client or httpx.Client(timeout=timeout_s)
|
|
162
|
+
self._owns_client = client is None
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
if self._owns_client:
|
|
166
|
+
self._client.close()
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> HttpClient:
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *_excinfo: Any) -> None:
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
def request(
|
|
175
|
+
self,
|
|
176
|
+
method: str,
|
|
177
|
+
path: str,
|
|
178
|
+
*,
|
|
179
|
+
params: dict[str, Any] | None = None,
|
|
180
|
+
json_body: Any | None = None,
|
|
181
|
+
retry: RetryConfig | None = None,
|
|
182
|
+
) -> Any:
|
|
183
|
+
url = self._base_url + path
|
|
184
|
+
headers = _build_headers(self._api_key, has_body=json_body is not None)
|
|
185
|
+
|
|
186
|
+
def _do() -> Any:
|
|
187
|
+
try:
|
|
188
|
+
response = self._client.request(
|
|
189
|
+
method,
|
|
190
|
+
url,
|
|
191
|
+
params=params,
|
|
192
|
+
json=json_body,
|
|
193
|
+
headers=headers,
|
|
194
|
+
)
|
|
195
|
+
except httpx.TimeoutException as err:
|
|
196
|
+
raise TransportError("request timed out", status=0) from err
|
|
197
|
+
except httpx.HTTPError as err:
|
|
198
|
+
raise TransportError(str(err), status=0) from err
|
|
199
|
+
|
|
200
|
+
return _decode_or_raise(response)
|
|
201
|
+
|
|
202
|
+
return with_retry(_do, retry or self._retry)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
206
|
+
# Async HTTP client (httpx.AsyncClient)
|
|
207
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class AsyncHttpClient:
|
|
211
|
+
"""Async analogue of :class:`HttpClient`."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
api_key: str,
|
|
216
|
+
*,
|
|
217
|
+
base_url: str,
|
|
218
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
219
|
+
retry: RetryConfig | None = None,
|
|
220
|
+
client: httpx.AsyncClient | None = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
self._api_key = api_key
|
|
223
|
+
self._base_url = base_url.rstrip("/")
|
|
224
|
+
self._retry = retry
|
|
225
|
+
self._client = client or httpx.AsyncClient(timeout=timeout_s)
|
|
226
|
+
self._owns_client = client is None
|
|
227
|
+
|
|
228
|
+
async def aclose(self) -> None:
|
|
229
|
+
if self._owns_client:
|
|
230
|
+
await self._client.aclose()
|
|
231
|
+
|
|
232
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
async def __aexit__(self, *_excinfo: Any) -> None:
|
|
236
|
+
await self.aclose()
|
|
237
|
+
|
|
238
|
+
async def request(
|
|
239
|
+
self,
|
|
240
|
+
method: str,
|
|
241
|
+
path: str,
|
|
242
|
+
*,
|
|
243
|
+
params: dict[str, Any] | None = None,
|
|
244
|
+
json_body: Any | None = None,
|
|
245
|
+
retry: RetryConfig | None = None,
|
|
246
|
+
) -> Any:
|
|
247
|
+
url = self._base_url + path
|
|
248
|
+
headers = _build_headers(self._api_key, has_body=json_body is not None)
|
|
249
|
+
|
|
250
|
+
async def _do() -> Any:
|
|
251
|
+
try:
|
|
252
|
+
response = await self._client.request(
|
|
253
|
+
method,
|
|
254
|
+
url,
|
|
255
|
+
params=params,
|
|
256
|
+
json=json_body,
|
|
257
|
+
headers=headers,
|
|
258
|
+
)
|
|
259
|
+
except httpx.TimeoutException as err:
|
|
260
|
+
raise TransportError("request timed out", status=0) from err
|
|
261
|
+
except httpx.HTTPError as err:
|
|
262
|
+
raise TransportError(str(err), status=0) from err
|
|
263
|
+
|
|
264
|
+
return _decode_or_raise(response)
|
|
265
|
+
|
|
266
|
+
return await with_retry_async(_do, retry or self._retry)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
270
|
+
# Shared response handling
|
|
271
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _decode_or_raise(response: httpx.Response) -> Any:
|
|
275
|
+
"""2xx → parsed JSON (or None on 204). Anything else → raise typed error."""
|
|
276
|
+
if 200 <= response.status_code < 300:
|
|
277
|
+
if response.status_code == 204 or not response.content:
|
|
278
|
+
return None
|
|
279
|
+
try:
|
|
280
|
+
return response.json()
|
|
281
|
+
except (json.JSONDecodeError, ValueError) as err:
|
|
282
|
+
raise TransportError(
|
|
283
|
+
"failed to parse JSON response body",
|
|
284
|
+
status=response.status_code,
|
|
285
|
+
) from err
|
|
286
|
+
|
|
287
|
+
raise _error_from_response_data(
|
|
288
|
+
status=response.status_code,
|
|
289
|
+
text=response.text,
|
|
290
|
+
retry_after_header=response.headers.get("retry-after"),
|
|
291
|
+
)
|
driftstack/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Resource accessors mounted on the top-level Driftstack clients.
|
|
2
|
+
|
|
3
|
+
Each module exposes two classes — a sync resource and an async one —
|
|
4
|
+
that share the same method signatures but back onto :class:`HttpClient`
|
|
5
|
+
or :class:`AsyncHttpClient` respectively.
|
|
6
|
+
|
|
7
|
+
Customers don't import these directly; they reach them through the
|
|
8
|
+
client::
|
|
9
|
+
|
|
10
|
+
client = Driftstack(api_key="…")
|
|
11
|
+
client.sessions.create() # sync
|
|
12
|
+
await async_client.sessions.create() # async (separate client)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from driftstack.resources.api_keys import ApiKeysResource, AsyncApiKeysResource
|
|
16
|
+
from driftstack.resources.sessions import AsyncSessionsResource, SessionsResource
|
|
17
|
+
from driftstack.resources.usage import AsyncUsageResource, UsageResource
|
|
18
|
+
from driftstack.resources.webhooks import AsyncWebhooksResource, WebhooksResource
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ApiKeysResource",
|
|
22
|
+
"AsyncApiKeysResource",
|
|
23
|
+
"SessionsResource",
|
|
24
|
+
"AsyncSessionsResource",
|
|
25
|
+
"UsageResource",
|
|
26
|
+
"AsyncUsageResource",
|
|
27
|
+
"WebhooksResource",
|
|
28
|
+
"AsyncWebhooksResource",
|
|
29
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared helpers for the resource layer.
|
|
2
|
+
|
|
3
|
+
Customers can pass either a Pydantic model OR a dict to mutating
|
|
4
|
+
methods. Both serialise to the same JSON shape on the wire — the
|
|
5
|
+
helper here normalises before hand-off to httpx.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def coerce_body(body: BaseModel | dict[str, Any] | None) -> dict[str, Any] | None:
|
|
16
|
+
"""Convert a Pydantic model or dict to the dict that httpx will JSON-encode.
|
|
17
|
+
|
|
18
|
+
``None`` round-trips as ``None`` so a route with no body works
|
|
19
|
+
without callers having to pass an empty dict.
|
|
20
|
+
|
|
21
|
+
Pydantic models go through ``model_dump(mode="json", exclude_none=True)``
|
|
22
|
+
so optional unset fields don't pollute the wire payload (e.g.
|
|
23
|
+
``CreateSessionRequest()`` shouldn't emit ``{"label": null}``).
|
|
24
|
+
"""
|
|
25
|
+
if body is None:
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(body, BaseModel):
|
|
28
|
+
return body.model_dump(mode="json", exclude_none=True)
|
|
29
|
+
return body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def coerce_query(query: BaseModel | dict[str, Any] | None) -> dict[str, Any] | None:
|
|
33
|
+
"""Same as :func:`coerce_body` but for query-string params."""
|
|
34
|
+
if query is None:
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(query, BaseModel):
|
|
37
|
+
return query.model_dump(mode="json", exclude_none=True)
|
|
38
|
+
return {k: v for k, v in query.items() if v is not None}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""API keys resource — /v1/api-keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from driftstack._generated.models import ApiKey, CreateApiKeyRequest, CreateApiKeyResponse
|
|
11
|
+
from driftstack.http import AsyncHttpClient, HttpClient
|
|
12
|
+
from driftstack.resources._common import coerce_body
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiKeyList(BaseModel):
|
|
16
|
+
"""Response shape for ``GET /v1/api-keys``."""
|
|
17
|
+
|
|
18
|
+
data: list[ApiKey]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiKeysResource:
|
|
22
|
+
"""Synchronous API keys resource."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: HttpClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
def create(self, body: CreateApiKeyRequest | dict[str, Any]) -> CreateApiKeyResponse:
|
|
28
|
+
"""Create an API key.
|
|
29
|
+
|
|
30
|
+
Plaintext is in the response — store it now, it cannot be
|
|
31
|
+
retrieved later. Requires the ``admin`` scope on the calling key.
|
|
32
|
+
"""
|
|
33
|
+
data = self._http.request("POST", "/v1/api-keys", json_body=coerce_body(body))
|
|
34
|
+
return CreateApiKeyResponse.model_validate(data)
|
|
35
|
+
|
|
36
|
+
def list(self) -> ApiKeyList:
|
|
37
|
+
"""List API keys for the current account. Plaintext never included."""
|
|
38
|
+
data = self._http.request("GET", "/v1/api-keys")
|
|
39
|
+
return ApiKeyList.model_validate(data)
|
|
40
|
+
|
|
41
|
+
def revoke(self, key_id: str) -> None:
|
|
42
|
+
"""Revoke an API key. Idempotent — revoking an already-revoked key is a no-op."""
|
|
43
|
+
self._http.request("DELETE", f"/v1/api-keys/{quote(key_id, safe='')}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncApiKeysResource:
|
|
47
|
+
"""Async API keys resource."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
50
|
+
self._http = http
|
|
51
|
+
|
|
52
|
+
async def create(self, body: CreateApiKeyRequest | dict[str, Any]) -> CreateApiKeyResponse:
|
|
53
|
+
data = await self._http.request("POST", "/v1/api-keys", json_body=coerce_body(body))
|
|
54
|
+
return CreateApiKeyResponse.model_validate(data)
|
|
55
|
+
|
|
56
|
+
async def list(self) -> ApiKeyList:
|
|
57
|
+
data = await self._http.request("GET", "/v1/api-keys")
|
|
58
|
+
return ApiKeyList.model_validate(data)
|
|
59
|
+
|
|
60
|
+
async def revoke(self, key_id: str) -> None:
|
|
61
|
+
await self._http.request("DELETE", f"/v1/api-keys/{quote(key_id, safe='')}")
|