querri 1.0.2__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.
- querri/__init__.py +51 -0
- querri/_auth.py +818 -0
- querri/_base_client.py +379 -0
- querri/_client.py +395 -0
- querri/_config.py +140 -0
- querri/_convenience.py +538 -0
- querri/_exceptions.py +180 -0
- querri/_pagination.py +278 -0
- querri/_streaming.py +663 -0
- querri/_user_client.py +193 -0
- querri/_version.py +1 -0
- querri/cli/__init__.py +21 -0
- querri/cli/_app.py +246 -0
- querri/cli/_context.py +216 -0
- querri/cli/_image.py +234 -0
- querri/cli/_output.py +293 -0
- querri/cli/audit.py +68 -0
- querri/cli/auth.py +556 -0
- querri/cli/chat.py +1255 -0
- querri/cli/chats.py +358 -0
- querri/cli/dashboards.py +294 -0
- querri/cli/embed.py +220 -0
- querri/cli/files.py +214 -0
- querri/cli/keys.py +304 -0
- querri/cli/policies.py +424 -0
- querri/cli/projects.py +916 -0
- querri/cli/sharing.py +403 -0
- querri/cli/sources.py +532 -0
- querri/cli/steps.py +123 -0
- querri/cli/usage.py +99 -0
- querri/cli/users.py +223 -0
- querri/cli/views.py +449 -0
- querri/cli/whoami.py +78 -0
- querri/py.typed +0 -0
- querri/resources/__init__.py +1 -0
- querri/resources/audit.py +119 -0
- querri/resources/dashboards.py +295 -0
- querri/resources/embed.py +268 -0
- querri/resources/files.py +145 -0
- querri/resources/keys.py +191 -0
- querri/resources/policies.py +492 -0
- querri/resources/projects.py +808 -0
- querri/resources/sharing.py +330 -0
- querri/resources/sources.py +511 -0
- querri/resources/usage.py +106 -0
- querri/resources/users.py +303 -0
- querri/resources/views.py +347 -0
- querri/types/__init__.py +104 -0
- querri/types/audit.py +21 -0
- querri/types/chat.py +51 -0
- querri/types/dashboard.py +51 -0
- querri/types/data.py +61 -0
- querri/types/embed.py +38 -0
- querri/types/file.py +18 -0
- querri/types/key.py +36 -0
- querri/types/policy.py +105 -0
- querri/types/project.py +158 -0
- querri/types/sharing.py +19 -0
- querri/types/usage.py +39 -0
- querri/types/user.py +33 -0
- querri-1.0.2.dist-info/METADATA +310 -0
- querri-1.0.2.dist-info/RECORD +64 -0
- querri-1.0.2.dist-info/WHEEL +4 -0
- querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_base_client.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Base HTTP client with auth, retry, and error handling.
|
|
2
|
+
|
|
3
|
+
Both sync and async clients inherit from this to share configuration
|
|
4
|
+
and header injection logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._config import ClientConfig
|
|
17
|
+
from ._exceptions import (
|
|
18
|
+
APIError,
|
|
19
|
+
raise_for_status,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("querri")
|
|
23
|
+
|
|
24
|
+
# Methods that are safe to retry on 5xx (idempotent)
|
|
25
|
+
_IDEMPOTENT_METHODS = {"GET", "PUT", "DELETE", "HEAD", "OPTIONS"}
|
|
26
|
+
|
|
27
|
+
# Status codes that trigger automatic retry
|
|
28
|
+
_RETRYABLE_STATUSES = {429, 500, 502, 503}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _default_headers(config: ClientConfig) -> dict[str, str]:
|
|
32
|
+
"""Build default HTTP headers including auth, tenant ID, user agent, and Accept.
|
|
33
|
+
|
|
34
|
+
Auth priority:
|
|
35
|
+
1. Session token (``X-Embed-Session``) — user-scoped client via ``as_user()``.
|
|
36
|
+
2. JWT access token (``Bearer ey*``) — OAuth login, no X-Tenant-ID needed.
|
|
37
|
+
3. API key (``Bearer qk_*``) — API key auth with X-Tenant-ID for tenant isolation.
|
|
38
|
+
"""
|
|
39
|
+
headers: dict[str, str] = {
|
|
40
|
+
"User-Agent": config.user_agent,
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
}
|
|
43
|
+
if config.session_token:
|
|
44
|
+
headers["X-Embed-Session"] = config.session_token
|
|
45
|
+
elif config.access_token:
|
|
46
|
+
headers["Authorization"] = f"Bearer {config.access_token}"
|
|
47
|
+
elif config.api_key:
|
|
48
|
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
|
49
|
+
if config.org_id:
|
|
50
|
+
headers["X-Tenant-ID"] = config.org_id
|
|
51
|
+
return headers
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _should_retry(status: int, method: str) -> bool:
|
|
55
|
+
"""Check if a failed request should be retried.
|
|
56
|
+
|
|
57
|
+
429 (rate limit) always retries. 5xx only retries for idempotent methods
|
|
58
|
+
(GET, PUT, DELETE, HEAD, OPTIONS) to avoid duplicate side effects from POST.
|
|
59
|
+
"""
|
|
60
|
+
if status == 429:
|
|
61
|
+
return True
|
|
62
|
+
return status in _RETRYABLE_STATUSES and method.upper() in _IDEMPOTENT_METHODS
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _backoff_delay(attempt: int, retry_after: float | None = None) -> float:
|
|
66
|
+
"""Exponential backoff with jitter.
|
|
67
|
+
|
|
68
|
+
Uses 2^attempt capped at 30s, plus 0-0.5s random jitter to prevent
|
|
69
|
+
thundering herd. If the server sent a Retry-After header, uses that
|
|
70
|
+
value instead.
|
|
71
|
+
"""
|
|
72
|
+
if retry_after is not None and retry_after > 0:
|
|
73
|
+
return retry_after
|
|
74
|
+
base: float = min(2**attempt, 30)
|
|
75
|
+
jitter = random.random() * 0.5 # noqa: S311
|
|
76
|
+
return base + jitter
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_error_response(response: httpx.Response) -> dict[str, Any]:
|
|
80
|
+
"""Try to parse JSON error body, fall back to empty dict."""
|
|
81
|
+
try:
|
|
82
|
+
return response.json() # type: ignore[no-any-return]
|
|
83
|
+
except Exception:
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_retry_after(response: httpx.Response) -> float | None:
|
|
88
|
+
"""Parse Retry-After header as seconds.
|
|
89
|
+
|
|
90
|
+
Returns None if absent or unparseable.
|
|
91
|
+
"""
|
|
92
|
+
header = response.headers.get("retry-after")
|
|
93
|
+
if header is None:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return float(header)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SyncHTTPClient:
|
|
102
|
+
"""Synchronous HTTP client with retry and error handling."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
105
|
+
"""Initialize with auth headers and connection pool from config."""
|
|
106
|
+
self._config = config
|
|
107
|
+
self._client = httpx.Client(
|
|
108
|
+
base_url=config.base_url,
|
|
109
|
+
headers=_default_headers(config),
|
|
110
|
+
timeout=httpx.Timeout(
|
|
111
|
+
connect=config.timeout,
|
|
112
|
+
read=600.0, # Long read timeout for streaming
|
|
113
|
+
write=config.timeout,
|
|
114
|
+
pool=config.timeout,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
json: Any | None = None,
|
|
124
|
+
params: dict[str, Any] | None = None,
|
|
125
|
+
data: dict[str, Any] | None = None,
|
|
126
|
+
files: Any | None = None,
|
|
127
|
+
headers: dict[str, str] | None = None,
|
|
128
|
+
stream: bool = False,
|
|
129
|
+
) -> httpx.Response:
|
|
130
|
+
"""Make an HTTP request with automatic retry."""
|
|
131
|
+
max_retries = self._config.max_retries
|
|
132
|
+
last_exc: Exception | None = None
|
|
133
|
+
|
|
134
|
+
for attempt in range(max_retries + 1):
|
|
135
|
+
try:
|
|
136
|
+
if stream:
|
|
137
|
+
# For streaming, caller manages the response lifecycle
|
|
138
|
+
req = self._client.build_request(
|
|
139
|
+
method,
|
|
140
|
+
path,
|
|
141
|
+
json=json,
|
|
142
|
+
params=params,
|
|
143
|
+
data=data,
|
|
144
|
+
files=files,
|
|
145
|
+
headers=headers,
|
|
146
|
+
)
|
|
147
|
+
response = self._client.send(req, stream=True)
|
|
148
|
+
else:
|
|
149
|
+
response = self._client.request(
|
|
150
|
+
method,
|
|
151
|
+
path,
|
|
152
|
+
json=json,
|
|
153
|
+
params=params,
|
|
154
|
+
data=data,
|
|
155
|
+
files=files,
|
|
156
|
+
headers=headers,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if response.status_code < 400:
|
|
160
|
+
return response
|
|
161
|
+
|
|
162
|
+
# Check if we should retry
|
|
163
|
+
if attempt < max_retries and _should_retry(
|
|
164
|
+
response.status_code, method
|
|
165
|
+
):
|
|
166
|
+
retry_after = _get_retry_after(response)
|
|
167
|
+
delay = _backoff_delay(attempt, retry_after)
|
|
168
|
+
logger.debug(
|
|
169
|
+
"Retrying %s %s (attempt %d/%d, status %d, delay %.1fs)",
|
|
170
|
+
method,
|
|
171
|
+
path,
|
|
172
|
+
attempt + 1,
|
|
173
|
+
max_retries,
|
|
174
|
+
response.status_code,
|
|
175
|
+
delay,
|
|
176
|
+
)
|
|
177
|
+
time.sleep(delay)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Not retryable — raise
|
|
181
|
+
body = _parse_error_response(response)
|
|
182
|
+
request_id = response.headers.get("x-request-id")
|
|
183
|
+
retry_after = _get_retry_after(response)
|
|
184
|
+
raise_for_status(
|
|
185
|
+
response.status_code,
|
|
186
|
+
body,
|
|
187
|
+
request_id=request_id,
|
|
188
|
+
retry_after=retry_after,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
192
|
+
last_exc = exc
|
|
193
|
+
if attempt < max_retries:
|
|
194
|
+
delay = _backoff_delay(attempt)
|
|
195
|
+
logger.debug(
|
|
196
|
+
"Connection error on %s %s (attempt %d/%d, delay %.1fs): %s",
|
|
197
|
+
method,
|
|
198
|
+
path,
|
|
199
|
+
attempt + 1,
|
|
200
|
+
max_retries,
|
|
201
|
+
delay,
|
|
202
|
+
exc,
|
|
203
|
+
)
|
|
204
|
+
time.sleep(delay)
|
|
205
|
+
continue
|
|
206
|
+
raise APIError(
|
|
207
|
+
f"Connection failed after {max_retries + 1} attempts: {exc}",
|
|
208
|
+
status=0,
|
|
209
|
+
) from last_exc
|
|
210
|
+
|
|
211
|
+
# Loop exhausted all retries without returning
|
|
212
|
+
# or raising -- re-raise the last error.
|
|
213
|
+
raise APIError(
|
|
214
|
+
f"Request failed after {max_retries + 1} attempts",
|
|
215
|
+
status=0,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
219
|
+
"""Shorthand for :meth:`request` with method=GET."""
|
|
220
|
+
return self.request("GET", path, **kwargs)
|
|
221
|
+
|
|
222
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
223
|
+
"""Shorthand for :meth:`request` with method=POST."""
|
|
224
|
+
return self.request("POST", path, **kwargs)
|
|
225
|
+
|
|
226
|
+
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
227
|
+
"""Shorthand for :meth:`request` with method=PUT."""
|
|
228
|
+
return self.request("PUT", path, **kwargs)
|
|
229
|
+
|
|
230
|
+
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
231
|
+
"""Shorthand for :meth:`request` with method=PATCH."""
|
|
232
|
+
return self.request("PATCH", path, **kwargs)
|
|
233
|
+
|
|
234
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
235
|
+
"""Shorthand for :meth:`request` with method=DELETE."""
|
|
236
|
+
return self.request("DELETE", path, **kwargs)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
self._client.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class AsyncHTTPClient:
|
|
243
|
+
"""Asynchronous HTTP client with retry and error handling."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
246
|
+
"""Initialize with auth headers and connection pool from config."""
|
|
247
|
+
self._config = config
|
|
248
|
+
self._client = httpx.AsyncClient(
|
|
249
|
+
base_url=config.base_url,
|
|
250
|
+
headers=_default_headers(config),
|
|
251
|
+
timeout=httpx.Timeout(
|
|
252
|
+
connect=config.timeout,
|
|
253
|
+
read=600.0,
|
|
254
|
+
write=config.timeout,
|
|
255
|
+
pool=config.timeout,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def request(
|
|
260
|
+
self,
|
|
261
|
+
method: str,
|
|
262
|
+
path: str,
|
|
263
|
+
*,
|
|
264
|
+
json: Any | None = None,
|
|
265
|
+
params: dict[str, Any] | None = None,
|
|
266
|
+
data: dict[str, Any] | None = None,
|
|
267
|
+
files: Any | None = None,
|
|
268
|
+
headers: dict[str, str] | None = None,
|
|
269
|
+
stream: bool = False,
|
|
270
|
+
) -> httpx.Response:
|
|
271
|
+
"""Make an async HTTP request with automatic retry."""
|
|
272
|
+
import asyncio
|
|
273
|
+
|
|
274
|
+
max_retries = self._config.max_retries
|
|
275
|
+
last_exc: Exception | None = None
|
|
276
|
+
|
|
277
|
+
for attempt in range(max_retries + 1):
|
|
278
|
+
try:
|
|
279
|
+
if stream:
|
|
280
|
+
req = self._client.build_request(
|
|
281
|
+
method,
|
|
282
|
+
path,
|
|
283
|
+
json=json,
|
|
284
|
+
params=params,
|
|
285
|
+
data=data,
|
|
286
|
+
files=files,
|
|
287
|
+
headers=headers,
|
|
288
|
+
)
|
|
289
|
+
response = await self._client.send(req, stream=True)
|
|
290
|
+
else:
|
|
291
|
+
response = await self._client.request(
|
|
292
|
+
method,
|
|
293
|
+
path,
|
|
294
|
+
json=json,
|
|
295
|
+
params=params,
|
|
296
|
+
data=data,
|
|
297
|
+
files=files,
|
|
298
|
+
headers=headers,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if response.status_code < 400:
|
|
302
|
+
return response
|
|
303
|
+
|
|
304
|
+
if attempt < max_retries and _should_retry(
|
|
305
|
+
response.status_code, method
|
|
306
|
+
):
|
|
307
|
+
retry_after = _get_retry_after(response)
|
|
308
|
+
delay = _backoff_delay(attempt, retry_after)
|
|
309
|
+
logger.debug(
|
|
310
|
+
"Retrying %s %s (attempt %d/%d, status %d, delay %.1fs)",
|
|
311
|
+
method,
|
|
312
|
+
path,
|
|
313
|
+
attempt + 1,
|
|
314
|
+
max_retries,
|
|
315
|
+
response.status_code,
|
|
316
|
+
delay,
|
|
317
|
+
)
|
|
318
|
+
await asyncio.sleep(delay)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
body = _parse_error_response(response)
|
|
322
|
+
request_id = response.headers.get("x-request-id")
|
|
323
|
+
retry_after = _get_retry_after(response)
|
|
324
|
+
raise_for_status(
|
|
325
|
+
response.status_code,
|
|
326
|
+
body,
|
|
327
|
+
request_id=request_id,
|
|
328
|
+
retry_after=retry_after,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
332
|
+
last_exc = exc
|
|
333
|
+
if attempt < max_retries:
|
|
334
|
+
delay = _backoff_delay(attempt)
|
|
335
|
+
logger.debug(
|
|
336
|
+
"Connection error on %s %s (attempt %d/%d, delay %.1fs): %s",
|
|
337
|
+
method,
|
|
338
|
+
path,
|
|
339
|
+
attempt + 1,
|
|
340
|
+
max_retries,
|
|
341
|
+
delay,
|
|
342
|
+
exc,
|
|
343
|
+
)
|
|
344
|
+
await asyncio.sleep(delay)
|
|
345
|
+
continue
|
|
346
|
+
raise APIError(
|
|
347
|
+
f"Connection failed after {max_retries + 1} attempts: {exc}",
|
|
348
|
+
status=0,
|
|
349
|
+
) from last_exc
|
|
350
|
+
|
|
351
|
+
# Loop exhausted all retries without returning
|
|
352
|
+
# or raising -- re-raise the last error.
|
|
353
|
+
raise APIError(
|
|
354
|
+
f"Request failed after {max_retries + 1} attempts",
|
|
355
|
+
status=0,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
async def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
359
|
+
"""Shorthand for :meth:`request` with method=GET."""
|
|
360
|
+
return await self.request("GET", path, **kwargs)
|
|
361
|
+
|
|
362
|
+
async def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
363
|
+
"""Shorthand for :meth:`request` with method=POST."""
|
|
364
|
+
return await self.request("POST", path, **kwargs)
|
|
365
|
+
|
|
366
|
+
async def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
367
|
+
"""Shorthand for :meth:`request` with method=PUT."""
|
|
368
|
+
return await self.request("PUT", path, **kwargs)
|
|
369
|
+
|
|
370
|
+
async def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
371
|
+
"""Shorthand for :meth:`request` with method=PATCH."""
|
|
372
|
+
return await self.request("PATCH", path, **kwargs)
|
|
373
|
+
|
|
374
|
+
async def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
375
|
+
"""Shorthand for :meth:`request` with method=DELETE."""
|
|
376
|
+
return await self.request("DELETE", path, **kwargs)
|
|
377
|
+
|
|
378
|
+
async def close(self) -> None:
|
|
379
|
+
await self._client.aclose()
|