dcr-client 0.4.3__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.
- dcr_client/__init__.py +138 -0
- dcr_client/_http.py +475 -0
- dcr_client/ai.py +382 -0
- dcr_client/alerts.py +142 -0
- dcr_client/api_keys.py +153 -0
- dcr_client/auth.py +73 -0
- dcr_client/client.py +153 -0
- dcr_client/encrypt.py +192 -0
- dcr_client/exceptions.py +67 -0
- dcr_client/governance.py +438 -0
- dcr_client/hashing.py +129 -0
- dcr_client/ingestion.py +455 -0
- dcr_client/matching.py +197 -0
- dcr_client/models.py +459 -0
- dcr_client/normalize.py +206 -0
- dcr_client/prepare.py +225 -0
- dcr_client/query.py +304 -0
- dcr_client/reports.py +195 -0
- dcr_client-0.4.3.dist-info/METADATA +715 -0
- dcr_client-0.4.3.dist-info/RECORD +23 -0
- dcr_client-0.4.3.dist-info/WHEEL +5 -0
- dcr_client-0.4.3.dist-info/licenses/LICENSE +21 -0
- dcr_client-0.4.3.dist-info/top_level.txt +1 -0
dcr_client/__init__.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""DCR Platform Python SDK.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from dcr_client import DCRClient
|
|
6
|
+
|
|
7
|
+
client = DCRClient(
|
|
8
|
+
base_url="http://localhost:8000",
|
|
9
|
+
username="admin@example.com",
|
|
10
|
+
password="secret",
|
|
11
|
+
tenant_id="your-tenant-uuid",
|
|
12
|
+
)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dcr_client.client import DCRClient
|
|
16
|
+
from dcr_client.exceptions import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
DCRError,
|
|
20
|
+
ForbiddenError,
|
|
21
|
+
InsufficientCreditsError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServerError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from dcr_client.hashing import (
|
|
28
|
+
hash_identifier,
|
|
29
|
+
hash_identifier_legacy,
|
|
30
|
+
hash_activation_key,
|
|
31
|
+
hash_activation_key_for_platforms,
|
|
32
|
+
verify_hash_format,
|
|
33
|
+
)
|
|
34
|
+
from dcr_client.normalize import (
|
|
35
|
+
normalize_email,
|
|
36
|
+
normalize_phone,
|
|
37
|
+
normalize_name,
|
|
38
|
+
normalize_identifier,
|
|
39
|
+
normalize_for_platform,
|
|
40
|
+
SUPPORTED_PLATFORMS,
|
|
41
|
+
)
|
|
42
|
+
from dcr_client.prepare import prepare_csv
|
|
43
|
+
from dcr_client.encrypt import encrypt_csv, encrypt_rows, EncryptResult
|
|
44
|
+
from dcr_client.models import (
|
|
45
|
+
AIQueryUsageResponse,
|
|
46
|
+
AIStatusResponse,
|
|
47
|
+
AlertHistoryEntry,
|
|
48
|
+
AlertRule,
|
|
49
|
+
AnalyzeResponse,
|
|
50
|
+
BulkUploadResult,
|
|
51
|
+
ChunkUploadResult,
|
|
52
|
+
CompileIRResponse,
|
|
53
|
+
Dataset,
|
|
54
|
+
DictionaryStatusResponse,
|
|
55
|
+
DSRRequest,
|
|
56
|
+
DUA,
|
|
57
|
+
Job,
|
|
58
|
+
MatchResult,
|
|
59
|
+
Notification,
|
|
60
|
+
QueryAIResponse,
|
|
61
|
+
QueryResult,
|
|
62
|
+
RecommendationResponse,
|
|
63
|
+
RecommendedQuery,
|
|
64
|
+
ReportRun,
|
|
65
|
+
ReportTemplate,
|
|
66
|
+
RetentionPolicy,
|
|
67
|
+
Room,
|
|
68
|
+
TextToSQLResponse,
|
|
69
|
+
TokenResponse,
|
|
70
|
+
Upload,
|
|
71
|
+
UserInfo,
|
|
72
|
+
WebhookSubscription,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__version__ = "0.4.3"
|
|
76
|
+
SDK_VERSION = __version__
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
# Client
|
|
80
|
+
"DCRClient",
|
|
81
|
+
"SDK_VERSION",
|
|
82
|
+
# Exceptions
|
|
83
|
+
"DCRError",
|
|
84
|
+
"AuthenticationError",
|
|
85
|
+
"ForbiddenError",
|
|
86
|
+
"NotFoundError",
|
|
87
|
+
"ConflictError",
|
|
88
|
+
"ValidationError",
|
|
89
|
+
"InsufficientCreditsError",
|
|
90
|
+
"RateLimitError",
|
|
91
|
+
"ServerError",
|
|
92
|
+
# AI Models
|
|
93
|
+
"QueryAIResponse",
|
|
94
|
+
"CompileIRResponse",
|
|
95
|
+
"TextToSQLResponse",
|
|
96
|
+
"AnalyzeResponse",
|
|
97
|
+
"AIStatusResponse",
|
|
98
|
+
"AIQueryUsageResponse",
|
|
99
|
+
"DictionaryStatusResponse",
|
|
100
|
+
"RecommendedQuery",
|
|
101
|
+
"RecommendationResponse",
|
|
102
|
+
# Models
|
|
103
|
+
"TokenResponse",
|
|
104
|
+
"UserInfo",
|
|
105
|
+
"Upload",
|
|
106
|
+
"BulkUploadResult",
|
|
107
|
+
"ChunkUploadResult",
|
|
108
|
+
"Dataset",
|
|
109
|
+
"Room",
|
|
110
|
+
"MatchResult",
|
|
111
|
+
"Job",
|
|
112
|
+
"QueryResult",
|
|
113
|
+
"DUA",
|
|
114
|
+
"ReportTemplate",
|
|
115
|
+
"ReportRun",
|
|
116
|
+
"AlertRule",
|
|
117
|
+
"AlertHistoryEntry",
|
|
118
|
+
"RetentionPolicy",
|
|
119
|
+
"WebhookSubscription",
|
|
120
|
+
"DSRRequest",
|
|
121
|
+
"Notification",
|
|
122
|
+
# Normalization & Hashing
|
|
123
|
+
"normalize_email",
|
|
124
|
+
"normalize_phone",
|
|
125
|
+
"normalize_name",
|
|
126
|
+
"normalize_identifier",
|
|
127
|
+
"normalize_for_platform",
|
|
128
|
+
"SUPPORTED_PLATFORMS",
|
|
129
|
+
"hash_identifier",
|
|
130
|
+
"hash_identifier_legacy",
|
|
131
|
+
"hash_activation_key",
|
|
132
|
+
"hash_activation_key_for_platforms",
|
|
133
|
+
"verify_hash_format",
|
|
134
|
+
"prepare_csv",
|
|
135
|
+
"encrypt_csv",
|
|
136
|
+
"encrypt_rows",
|
|
137
|
+
"EncryptResult",
|
|
138
|
+
]
|
dcr_client/_http.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Internal HTTP wrapper with retry, envelope unwrapping, and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from requests.adapters import HTTPAdapter
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("dcr_client")
|
|
15
|
+
|
|
16
|
+
_SDK_VERSION = "0.4.3"
|
|
17
|
+
|
|
18
|
+
from dcr_client.exceptions import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
DCRError,
|
|
22
|
+
ForbiddenError,
|
|
23
|
+
InsufficientCreditsError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
ServerError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_STATUS_MAP: dict[int, type[DCRError]] = {
|
|
31
|
+
400: ValidationError,
|
|
32
|
+
401: AuthenticationError,
|
|
33
|
+
402: InsufficientCreditsError,
|
|
34
|
+
403: ForbiddenError,
|
|
35
|
+
404: NotFoundError,
|
|
36
|
+
409: ConflictError,
|
|
37
|
+
422: ValidationError,
|
|
38
|
+
429: RateLimitError,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _APIKeyAdapter(HTTPAdapter):
|
|
43
|
+
"""The adapter of the API key endpoints: a key's create and rotate on the
|
|
44
|
+
no-POST-retry policy it is built with, a key's revoke (``.../revoke``) on
|
|
45
|
+
the policy every other endpoint has. Revoking twice ends in the same
|
|
46
|
+
state, so a 502/503/504 of a rolling deploy is retried for it; a create
|
|
47
|
+
or rotate retried after a commit whose answer was lost would issue a
|
|
48
|
+
second key."""
|
|
49
|
+
|
|
50
|
+
#: The last path segment of a key's revoke.
|
|
51
|
+
REVOKE_SUFFIX = "/revoke"
|
|
52
|
+
|
|
53
|
+
def __init__(self, issue_retries: Retry, revoke_retries: Retry) -> None:
|
|
54
|
+
super().__init__(max_retries=issue_retries)
|
|
55
|
+
self._revoke = HTTPAdapter(max_retries=revoke_retries)
|
|
56
|
+
|
|
57
|
+
def adapter_for(self, url: str) -> HTTPAdapter:
|
|
58
|
+
"""The adapter that sends a request to ``url``."""
|
|
59
|
+
if urlsplit(url).path.rstrip("/").endswith(self.REVOKE_SUFFIX):
|
|
60
|
+
return self._revoke
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def send(self, request, **kwargs): # type: ignore[no-untyped-def]
|
|
64
|
+
target = self.adapter_for(request.url or "")
|
|
65
|
+
if target is self:
|
|
66
|
+
return super().send(request, **kwargs)
|
|
67
|
+
return target.send(request, **kwargs)
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
self._revoke.close()
|
|
71
|
+
super().close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _CompileAdapter(HTTPAdapter):
|
|
75
|
+
"""The adapter of the IR run (``/api/v1/query/ai/compile``): a run sent
|
|
76
|
+
with an AI answer's ``credit_ref`` on the no-POST-retry policy it is built
|
|
77
|
+
with, any other run on the policy every other endpoint has.
|
|
78
|
+
|
|
79
|
+
The query engine gives the answer's credits back when it refuses the run
|
|
80
|
+
of that plan — a 503 while the room's overlap is refreshed
|
|
81
|
+
(QUERY_UNAVAILABLE_013) as much as any other refusal. It charges them
|
|
82
|
+
again once a later run of the plan is accepted, so a retry the caller did
|
|
83
|
+
not see would run the plan and charge it unseen once the 503 lifted: the
|
|
84
|
+
caller sees the 503 and decides whether to send the run again. A 502, 503
|
|
85
|
+
or 504 the gateway answers itself (the engine never saw the run, as while
|
|
86
|
+
its pods restart) gives nothing back and is not retried either: the caller
|
|
87
|
+
sends the run again with the same ``credit_ref``, which the engine judges
|
|
88
|
+
as new. A run without a reference keeps the retry of a 503 a retry can
|
|
89
|
+
lift."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, referenced_retries: Retry, other_retries: Retry) -> None:
|
|
92
|
+
super().__init__(max_retries=referenced_retries)
|
|
93
|
+
self._other = HTTPAdapter(max_retries=other_retries)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def carries_credit_ref(body: Any) -> bool:
|
|
97
|
+
"""Whether a request body is a JSON object naming a ``credit_ref``."""
|
|
98
|
+
if not body:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
payload = _json.loads(body)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return False
|
|
104
|
+
return isinstance(payload, dict) and bool(payload.get("credit_ref"))
|
|
105
|
+
|
|
106
|
+
def adapter_for(self, body: Any) -> HTTPAdapter:
|
|
107
|
+
"""The adapter that sends a run whose body is ``body``."""
|
|
108
|
+
if self.carries_credit_ref(body):
|
|
109
|
+
return self
|
|
110
|
+
return self._other
|
|
111
|
+
|
|
112
|
+
def send(self, request, **kwargs): # type: ignore[no-untyped-def]
|
|
113
|
+
target = self.adapter_for(request.body)
|
|
114
|
+
if target is self:
|
|
115
|
+
return super().send(request, **kwargs)
|
|
116
|
+
return target.send(request, **kwargs)
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
self._other.close()
|
|
120
|
+
super().close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class HTTPClient:
|
|
124
|
+
"""Low-level HTTP client that wraps ``requests.Session``.
|
|
125
|
+
|
|
126
|
+
Features:
|
|
127
|
+
- Automatic retry on 502/503/504 (3 attempts with backoff). A response
|
|
128
|
+
that carries ``Retry-After`` is retried after the time it names: the
|
|
129
|
+
query engine sends one with a 503 a retry can lift — the room's overlap
|
|
130
|
+
is being refreshed, or could not be checked (QUERY_UNAVAILABLE_013) —
|
|
131
|
+
so a retry waits for it instead of asking again at once.
|
|
132
|
+
- The AI endpoints (``/api/v1/ai/``) are not retried on a status, nor on
|
|
133
|
+
a read error after the request was sent: each call is charged and runs a
|
|
134
|
+
provider call, and their 503 is the AI provider failing
|
|
135
|
+
(AI_UNAVAILABLE_001 / AI_UNAVAILABLE_003), which three retries in three
|
|
136
|
+
seconds do not lift: each retry would be charged, call the provider and
|
|
137
|
+
be refunded again. A connection that never reached the server is still
|
|
138
|
+
retried.
|
|
139
|
+
- A key's create and rotate (``POST`` under
|
|
140
|
+
``/api/v1/auth/org/api-keys``) are not retried on a status, nor on a
|
|
141
|
+
read error after the request was sent: their 503 (AUTH_UNAVAILABLE_002)
|
|
142
|
+
and 504 can come after a commit whose answer was lost, so the key may
|
|
143
|
+
have been saved, and an unseen retry could issue a second key or rotate
|
|
144
|
+
the deprecated one again. The caller lists the keys and decides. A key's
|
|
145
|
+
revoke is retried as everywhere (revoking twice ends in the same state),
|
|
146
|
+
and so are listing and reading keys (``GET``); a connection that never
|
|
147
|
+
reached the server is still retried.
|
|
148
|
+
- The IR run (``/api/v1/query/ai/compile``) sent with an AI answer's
|
|
149
|
+
``credit_ref`` is not retried on a status, nor on a read error after the
|
|
150
|
+
request was sent: a refusal of that plan's run, its 503 included, gives
|
|
151
|
+
the answer's credits back and a later accepted run charges them again,
|
|
152
|
+
so a retry the caller did not see would run the plan and charge it
|
|
153
|
+
unseen once the refusal lifted. A run without a reference keeps every
|
|
154
|
+
endpoint's policy.
|
|
155
|
+
- When the retries run out, the last response is raised as its typed
|
|
156
|
+
:class:`DCRError` (a :class:`ServerError` carrying the platform's
|
|
157
|
+
``error_code``), not as urllib3's untyped ``RetryError``.
|
|
158
|
+
- Unwraps the DCR ``{"success": true, "data": ...}`` envelope.
|
|
159
|
+
- Maps HTTP error codes to typed :class:`DCRError` subclasses.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
#: Path prefix of the AI endpoints, mounted on an adapter of their own.
|
|
163
|
+
AI_PATH_PREFIX = "/api/v1/ai/"
|
|
164
|
+
|
|
165
|
+
#: Path of the IR run, mounted on an adapter of its own
|
|
166
|
+
#: (:class:`_CompileAdapter`): a run that carries an AI answer's
|
|
167
|
+
#: ``credit_ref`` is not retried on a status.
|
|
168
|
+
AI_COMPILE_PATH = "/api/v1/query/ai/compile"
|
|
169
|
+
|
|
170
|
+
#: Path prefix of the API key endpoints, mounted on an adapter of their own
|
|
171
|
+
#: (:class:`_APIKeyAdapter`): a POST under it issues, rotates or revokes a
|
|
172
|
+
#: key.
|
|
173
|
+
API_KEYS_PATH_PREFIX = "/api/v1/auth/org/api-keys"
|
|
174
|
+
|
|
175
|
+
def __init__(self, base_url: str, timeout: float = 30.0, api_version: str | None = None) -> None:
|
|
176
|
+
self._base_url = base_url.rstrip("/")
|
|
177
|
+
self._timeout = timeout
|
|
178
|
+
self._token: Optional[str] = None
|
|
179
|
+
self._tenant_id: Optional[str] = None
|
|
180
|
+
self._api_key: Optional[str] = None
|
|
181
|
+
self._api_version = api_version
|
|
182
|
+
self._server_api_version: str | None = None
|
|
183
|
+
|
|
184
|
+
self._session = requests.Session()
|
|
185
|
+
|
|
186
|
+
retry_strategy = Retry(
|
|
187
|
+
total=3,
|
|
188
|
+
backoff_factor=0.5,
|
|
189
|
+
status_forcelist=[502, 503, 504],
|
|
190
|
+
allowed_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
191
|
+
raise_on_status=False,
|
|
192
|
+
)
|
|
193
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
194
|
+
self._session.mount("http://", adapter)
|
|
195
|
+
self._session.mount("https://", adapter)
|
|
196
|
+
|
|
197
|
+
# The AI endpoints: a request that never reached the server is
|
|
198
|
+
# retried for every method; a 502/503/504 or a read error only for a
|
|
199
|
+
# GET (status, usage, a dictionary's status: no charge and no
|
|
200
|
+
# provider call). A POST is charged and calls the AI provider, so a
|
|
201
|
+
# retry would be charged, call the provider and be refunded again; a
|
|
202
|
+
# GET that a rolling deploy answered 503 once is asked again, as on
|
|
203
|
+
# every other endpoint. urllib3 applies read and status retries only
|
|
204
|
+
# to allowed_methods. requests picks the longest mounted prefix, so
|
|
205
|
+
# this adapter serves every URL under it.
|
|
206
|
+
ai_retry_strategy = Retry(
|
|
207
|
+
total=3,
|
|
208
|
+
connect=3,
|
|
209
|
+
read=3,
|
|
210
|
+
status=3,
|
|
211
|
+
other=0,
|
|
212
|
+
backoff_factor=0.5,
|
|
213
|
+
status_forcelist=[502, 503, 504],
|
|
214
|
+
allowed_methods=["GET"],
|
|
215
|
+
raise_on_status=False,
|
|
216
|
+
)
|
|
217
|
+
self._session.mount(self._base_url + self.AI_PATH_PREFIX, HTTPAdapter(max_retries=ai_retry_strategy))
|
|
218
|
+
|
|
219
|
+
# The IR run: sent with an AI answer's credit_ref it is on the AI
|
|
220
|
+
# endpoints' rule (no status or read retry of a POST), since a refusal
|
|
221
|
+
# of the run — a 503 a retry can lift included — gave the answer's
|
|
222
|
+
# credits back, and a later accepted run charges them again: a retry
|
|
223
|
+
# the caller did not see would run and charge it unseen. Without one
|
|
224
|
+
# it keeps every endpoint's policy.
|
|
225
|
+
self._session.mount(self._base_url + self.AI_COMPILE_PATH, _CompileAdapter(ai_retry_strategy, retry_strategy))
|
|
226
|
+
|
|
227
|
+
# The API key endpoints, on the same rule as the AI ones: a create or
|
|
228
|
+
# rotate is retried only when it never reached the server. Answered
|
|
229
|
+
# 503/504 it may have committed (the platform's message says to check
|
|
230
|
+
# the key list), so a retry the caller does not see could issue a
|
|
231
|
+
# second key, or rotate the now deprecated key again and re-time its
|
|
232
|
+
# grace. A GET (list, get, scopes) is retried, and a revoke keeps every
|
|
233
|
+
# endpoint's policy.
|
|
234
|
+
key_retry_strategy = Retry(
|
|
235
|
+
total=3,
|
|
236
|
+
connect=3,
|
|
237
|
+
read=3,
|
|
238
|
+
status=3,
|
|
239
|
+
other=0,
|
|
240
|
+
backoff_factor=0.5,
|
|
241
|
+
status_forcelist=[502, 503, 504],
|
|
242
|
+
allowed_methods=["GET"],
|
|
243
|
+
raise_on_status=False,
|
|
244
|
+
)
|
|
245
|
+
self._session.mount(self._base_url + self.API_KEYS_PATH_PREFIX, _APIKeyAdapter(key_retry_strategy, retry_strategy))
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Auth helpers
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def set_auth(self, token: str, tenant_id: Optional[str] = None) -> None:
|
|
252
|
+
"""Store the access token and optional tenant ID for subsequent requests."""
|
|
253
|
+
self._token = token
|
|
254
|
+
if tenant_id is not None:
|
|
255
|
+
self._tenant_id = tenant_id
|
|
256
|
+
|
|
257
|
+
def set_tenant(self, tenant_id: str) -> None:
|
|
258
|
+
"""Update only the tenant ID."""
|
|
259
|
+
self._tenant_id = tenant_id
|
|
260
|
+
|
|
261
|
+
def set_api_key(self, key: str) -> None:
|
|
262
|
+
"""Store an API key for use in the ``Authorization: ApiKey`` header.
|
|
263
|
+
|
|
264
|
+
When an API key is set it takes precedence over any Bearer token.
|
|
265
|
+
"""
|
|
266
|
+
self._api_key = key
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Header construction
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _headers(self) -> dict[str, str]:
|
|
273
|
+
headers: dict[str, str] = {
|
|
274
|
+
"Content-Type": "application/json",
|
|
275
|
+
}
|
|
276
|
+
if self._api_key:
|
|
277
|
+
headers["Authorization"] = f"ApiKey {self._api_key}"
|
|
278
|
+
elif self._token:
|
|
279
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
280
|
+
if self._tenant_id:
|
|
281
|
+
headers["X-Tenant-ID"] = self._tenant_id
|
|
282
|
+
headers["User-Agent"] = f"dcr-python-sdk/{_SDK_VERSION}"
|
|
283
|
+
if self._api_version:
|
|
284
|
+
headers["X-API-Version"] = self._api_version
|
|
285
|
+
return headers
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
# Deprecation & version tracking
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _check_deprecation_headers(self, resp: requests.Response) -> None:
|
|
292
|
+
"""Log deprecation and version mismatch warnings from response headers."""
|
|
293
|
+
warning = resp.headers.get("X-API-Deprecation-Warning")
|
|
294
|
+
if warning:
|
|
295
|
+
sunset = resp.headers.get("Sunset", "unknown")
|
|
296
|
+
logger.warning("[DCR] Endpoint deprecated: %s (Sunset: %s)", warning, sunset)
|
|
297
|
+
|
|
298
|
+
version_warning = resp.headers.get("X-API-Version-Warning")
|
|
299
|
+
if version_warning:
|
|
300
|
+
logger.warning("[DCR] API version mismatch: %s", version_warning)
|
|
301
|
+
|
|
302
|
+
server_version = resp.headers.get("X-API-Version")
|
|
303
|
+
if server_version:
|
|
304
|
+
self._server_api_version = server_version
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def server_api_version(self) -> str | None:
|
|
308
|
+
"""The API version reported by the server in the last response."""
|
|
309
|
+
return self._server_api_version
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
# Error handling
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _handle_error(self, resp: requests.Response) -> None:
|
|
316
|
+
"""Raise a typed :class:`DCRError` based on the HTTP status code."""
|
|
317
|
+
if resp.ok:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# Try to extract structured error info from the response body.
|
|
321
|
+
message = resp.reason or "Unknown error"
|
|
322
|
+
error_code = ""
|
|
323
|
+
try:
|
|
324
|
+
body = resp.json()
|
|
325
|
+
if isinstance(body, dict):
|
|
326
|
+
# Format 1: {"success": false, "error": {"code": "...", "message": "..."}}
|
|
327
|
+
if "error" in body and isinstance(body["error"], dict):
|
|
328
|
+
error_obj = body["error"]
|
|
329
|
+
message = error_obj.get("message", message)
|
|
330
|
+
error_code = error_obj.get("code", "")
|
|
331
|
+
# Format 2: {"error_code": "...", "message": "..."}
|
|
332
|
+
elif "message" in body:
|
|
333
|
+
message = body["message"]
|
|
334
|
+
error_code = body.get("error_code", "")
|
|
335
|
+
except (ValueError, KeyError):
|
|
336
|
+
# Body wasn't valid JSON, or didn't carry the envelope keys
|
|
337
|
+
# we look for. Keep the upstream status-code-derived `message`
|
|
338
|
+
# value so the eventual exception still has something useful.
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
status = resp.status_code
|
|
342
|
+
|
|
343
|
+
# 5xx → ServerError
|
|
344
|
+
if status >= 500:
|
|
345
|
+
raise ServerError(message, status_code=status, error_code=error_code)
|
|
346
|
+
|
|
347
|
+
exc_cls = _STATUS_MAP.get(status, DCRError)
|
|
348
|
+
raise exc_cls(message, status_code=status, error_code=error_code)
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
# Envelope unwrapping
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _unwrap(data: Any) -> Any:
|
|
356
|
+
"""Unwrap the DCR API envelope.
|
|
357
|
+
|
|
358
|
+
The backend wraps successful responses as::
|
|
359
|
+
|
|
360
|
+
{"success": true, "data": <payload>, "meta": {...}}
|
|
361
|
+
|
|
362
|
+
This helper returns the inner ``data`` value when the envelope is
|
|
363
|
+
detected; otherwise the raw value is returned untouched.
|
|
364
|
+
"""
|
|
365
|
+
if isinstance(data, dict) and data.get("success") is True and "data" in data:
|
|
366
|
+
return data["data"]
|
|
367
|
+
return data
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
# HTTP verbs
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def _url(self, path: str) -> str:
|
|
374
|
+
if path.startswith("/"):
|
|
375
|
+
return f"{self._base_url}{path}"
|
|
376
|
+
return f"{self._base_url}/{path}"
|
|
377
|
+
|
|
378
|
+
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
379
|
+
"""Perform a GET request and return the unwrapped JSON body."""
|
|
380
|
+
resp = self._session.get(
|
|
381
|
+
self._url(path),
|
|
382
|
+
headers=self._headers(),
|
|
383
|
+
params=params,
|
|
384
|
+
timeout=self._timeout,
|
|
385
|
+
)
|
|
386
|
+
self._check_deprecation_headers(resp)
|
|
387
|
+
self._handle_error(resp)
|
|
388
|
+
if not resp.content:
|
|
389
|
+
return None
|
|
390
|
+
return self._unwrap(resp.json())
|
|
391
|
+
|
|
392
|
+
def post(
|
|
393
|
+
self,
|
|
394
|
+
path: str,
|
|
395
|
+
json: Optional[dict[str, Any]] = None,
|
|
396
|
+
files: Optional[dict[str, Any]] = None,
|
|
397
|
+
data: Optional[dict[str, Any]] = None,
|
|
398
|
+
timeout: Optional[float] = None,
|
|
399
|
+
) -> Any:
|
|
400
|
+
"""Perform a POST request and return the unwrapped JSON body."""
|
|
401
|
+
headers = self._headers()
|
|
402
|
+
# When uploading files, let requests set the Content-Type with boundary.
|
|
403
|
+
if files is not None:
|
|
404
|
+
headers.pop("Content-Type", None)
|
|
405
|
+
|
|
406
|
+
resp = self._session.post(
|
|
407
|
+
self._url(path),
|
|
408
|
+
headers=headers,
|
|
409
|
+
json=json if files is None and data is None else None,
|
|
410
|
+
data=data,
|
|
411
|
+
files=files,
|
|
412
|
+
timeout=timeout or self._timeout,
|
|
413
|
+
)
|
|
414
|
+
self._check_deprecation_headers(resp)
|
|
415
|
+
self._handle_error(resp)
|
|
416
|
+
if not resp.content:
|
|
417
|
+
return None
|
|
418
|
+
return self._unwrap(resp.json())
|
|
419
|
+
|
|
420
|
+
def put(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
|
|
421
|
+
"""Perform a PUT request and return the unwrapped JSON body."""
|
|
422
|
+
resp = self._session.put(
|
|
423
|
+
self._url(path),
|
|
424
|
+
headers=self._headers(),
|
|
425
|
+
json=json,
|
|
426
|
+
timeout=self._timeout,
|
|
427
|
+
)
|
|
428
|
+
self._check_deprecation_headers(resp)
|
|
429
|
+
self._handle_error(resp)
|
|
430
|
+
if not resp.content:
|
|
431
|
+
return None
|
|
432
|
+
return self._unwrap(resp.json())
|
|
433
|
+
|
|
434
|
+
def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
|
|
435
|
+
"""Perform a PATCH request and return the unwrapped JSON body."""
|
|
436
|
+
resp = self._session.patch(
|
|
437
|
+
self._url(path),
|
|
438
|
+
headers=self._headers(),
|
|
439
|
+
json=json,
|
|
440
|
+
timeout=self._timeout,
|
|
441
|
+
)
|
|
442
|
+
self._check_deprecation_headers(resp)
|
|
443
|
+
self._handle_error(resp)
|
|
444
|
+
if not resp.content:
|
|
445
|
+
return None
|
|
446
|
+
return self._unwrap(resp.json())
|
|
447
|
+
|
|
448
|
+
def delete(self, path: str) -> Any:
|
|
449
|
+
"""Perform a DELETE request and return the unwrapped JSON body."""
|
|
450
|
+
resp = self._session.delete(
|
|
451
|
+
self._url(path),
|
|
452
|
+
headers=self._headers(),
|
|
453
|
+
timeout=self._timeout,
|
|
454
|
+
)
|
|
455
|
+
self._check_deprecation_headers(resp)
|
|
456
|
+
self._handle_error(resp)
|
|
457
|
+
if not resp.content:
|
|
458
|
+
return None
|
|
459
|
+
return self._unwrap(resp.json())
|
|
460
|
+
|
|
461
|
+
def get_stream(self, path: str, params: Optional[dict[str, Any]] = None) -> requests.Response:
|
|
462
|
+
"""Perform a streaming GET request and return the raw Response.
|
|
463
|
+
|
|
464
|
+
The caller is responsible for iterating over the response.
|
|
465
|
+
"""
|
|
466
|
+
resp = self._session.get(
|
|
467
|
+
self._url(path),
|
|
468
|
+
headers=self._headers(),
|
|
469
|
+
params=params,
|
|
470
|
+
timeout=self._timeout,
|
|
471
|
+
stream=True,
|
|
472
|
+
)
|
|
473
|
+
self._check_deprecation_headers(resp)
|
|
474
|
+
self._handle_error(resp)
|
|
475
|
+
return resp
|