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 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