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.
Files changed (64) hide show
  1. querri/__init__.py +51 -0
  2. querri/_auth.py +818 -0
  3. querri/_base_client.py +379 -0
  4. querri/_client.py +395 -0
  5. querri/_config.py +140 -0
  6. querri/_convenience.py +538 -0
  7. querri/_exceptions.py +180 -0
  8. querri/_pagination.py +278 -0
  9. querri/_streaming.py +663 -0
  10. querri/_user_client.py +193 -0
  11. querri/_version.py +1 -0
  12. querri/cli/__init__.py +21 -0
  13. querri/cli/_app.py +246 -0
  14. querri/cli/_context.py +216 -0
  15. querri/cli/_image.py +234 -0
  16. querri/cli/_output.py +293 -0
  17. querri/cli/audit.py +68 -0
  18. querri/cli/auth.py +556 -0
  19. querri/cli/chat.py +1255 -0
  20. querri/cli/chats.py +358 -0
  21. querri/cli/dashboards.py +294 -0
  22. querri/cli/embed.py +220 -0
  23. querri/cli/files.py +214 -0
  24. querri/cli/keys.py +304 -0
  25. querri/cli/policies.py +424 -0
  26. querri/cli/projects.py +916 -0
  27. querri/cli/sharing.py +403 -0
  28. querri/cli/sources.py +532 -0
  29. querri/cli/steps.py +123 -0
  30. querri/cli/usage.py +99 -0
  31. querri/cli/users.py +223 -0
  32. querri/cli/views.py +449 -0
  33. querri/cli/whoami.py +78 -0
  34. querri/py.typed +0 -0
  35. querri/resources/__init__.py +1 -0
  36. querri/resources/audit.py +119 -0
  37. querri/resources/dashboards.py +295 -0
  38. querri/resources/embed.py +268 -0
  39. querri/resources/files.py +145 -0
  40. querri/resources/keys.py +191 -0
  41. querri/resources/policies.py +492 -0
  42. querri/resources/projects.py +808 -0
  43. querri/resources/sharing.py +330 -0
  44. querri/resources/sources.py +511 -0
  45. querri/resources/usage.py +106 -0
  46. querri/resources/users.py +303 -0
  47. querri/resources/views.py +347 -0
  48. querri/types/__init__.py +104 -0
  49. querri/types/audit.py +21 -0
  50. querri/types/chat.py +51 -0
  51. querri/types/dashboard.py +51 -0
  52. querri/types/data.py +61 -0
  53. querri/types/embed.py +38 -0
  54. querri/types/file.py +18 -0
  55. querri/types/key.py +36 -0
  56. querri/types/policy.py +105 -0
  57. querri/types/project.py +158 -0
  58. querri/types/sharing.py +19 -0
  59. querri/types/usage.py +39 -0
  60. querri/types/user.py +33 -0
  61. querri-1.0.2.dist-info/METADATA +310 -0
  62. querri-1.0.2.dist-info/RECORD +64 -0
  63. querri-1.0.2.dist-info/WHEEL +4 -0
  64. 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()