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/_convenience.py ADDED
@@ -0,0 +1,538 @@
1
+ """High-level convenience methods that compose multiple API calls.
2
+
3
+ The flagship method is `get_session()` which handles the complete white-label
4
+ embedding workflow: upsert user by external_id, apply access policies, and
5
+ create an embed session — all in one call.
6
+
7
+ These functions are called by resource classes (e.g., Embed.get_session())
8
+ and should not be imported directly by end users.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ from typing import Any
17
+
18
+ from ._base_client import AsyncHTTPClient, SyncHTTPClient
19
+ from .types.policy import Policy
20
+
21
+ logger = logging.getLogger("querri")
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Internal helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _hash_access_spec(access: dict[str, Any]) -> str:
30
+ """Create a deterministic 8-char hex hash from an access spec.
31
+
32
+ Used to generate stable policy names (``sdk_auto_<hash8>``) so that
33
+ repeated calls with the same inline spec reuse the same policy instead
34
+ of creating duplicates.
35
+
36
+ The input dict is JSON-serialised with sorted keys and compact separators
37
+ to guarantee determinism regardless of insertion order.
38
+ """
39
+ # Only hash the parts that define the policy content — not meta-keys
40
+ # like ``policy_ids`` which reference existing policies.
41
+ hashable = {k: v for k, v in access.items() if k in ("sources", "filters")}
42
+ normalized = json.dumps(hashable, sort_keys=True, separators=(",", ":"))
43
+ return hashlib.sha256(normalized.encode()).hexdigest()[:8]
44
+
45
+
46
+ def _build_policy_body(access: dict[str, Any], policy_name: str) -> dict[str, Any]:
47
+ """Convert an inline access dict to a policy creation request body.
48
+
49
+ Maps the user-friendly SDK format::
50
+
51
+ {
52
+ "sources": ["src_abc", "src_def"],
53
+ "filters": {
54
+ "region": ["APAC", "EMEA"],
55
+ "department": "Sales",
56
+ },
57
+ }
58
+
59
+ to the server's ``CreatePolicyRequest`` shape::
60
+
61
+ {
62
+ "name": "sdk_auto_a1b2c3d4",
63
+ "source_ids": ["src_abc", "src_def"],
64
+ "row_filters": [
65
+ {"column": "region", "values": ["APAC", "EMEA"]},
66
+ {"column": "department", "values": ["Sales"]},
67
+ ],
68
+ }
69
+ """
70
+ body: dict[str, Any] = {"name": policy_name}
71
+
72
+ sources = access.get("sources")
73
+ if sources:
74
+ body["source_ids"] = list(sources)
75
+
76
+ filters = access.get("filters")
77
+ if filters and isinstance(filters, dict):
78
+ row_filters: list[dict[str, Any]] = []
79
+ for column, values in filters.items():
80
+ if isinstance(values, list):
81
+ row_filters.append({"column": column, "values": values})
82
+ else:
83
+ # Single value — wrap in a list for the server
84
+ row_filters.append({"column": column, "values": [values]})
85
+ body["row_filters"] = row_filters
86
+
87
+ return body
88
+
89
+
90
+ def _resolve_user_param(
91
+ user: str | dict[str, Any],
92
+ ) -> tuple[str, dict[str, Any] | None]:
93
+ """Normalise the ``user`` parameter into (external_id, creation_body | None).
94
+
95
+ Returns:
96
+ A tuple of ``(external_id, body_for_put_or_none)``.
97
+ When *user* is a plain string the body is ``None`` — the caller must
98
+ look up the user by external_id and raise if not found.
99
+ When *user* is a dict the body contains the fields needed for
100
+ ``PUT /users/external/{external_id}``.
101
+ """
102
+ if isinstance(user, str):
103
+ return user, None
104
+
105
+ if not isinstance(user, dict):
106
+ raise TypeError(
107
+ f"'user' must be a string (external_id) "
108
+ f"or a dict, got {type(user).__name__}"
109
+ )
110
+
111
+ external_id = user.get("external_id")
112
+ if not external_id:
113
+ raise ValueError(
114
+ "User dict must contain 'external_id'. "
115
+ "Example: {'external_id': 'cust-42', 'email': 'alice@example.com'}"
116
+ )
117
+
118
+ body: dict[str, Any] = {"role": user.get("role", "member")}
119
+ email = user.get("email")
120
+ if email:
121
+ body["email"] = email
122
+ if user.get("first_name"):
123
+ body["first_name"] = user["first_name"]
124
+ if user.get("last_name"):
125
+ body["last_name"] = user["last_name"]
126
+
127
+ return external_id, body
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Sync convenience methods
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def sync_get_session(
136
+ http: SyncHTTPClient,
137
+ *,
138
+ user: str | dict[str, Any],
139
+ access: dict[str, Any] | None = None,
140
+ origin: str | None = None,
141
+ ttl: int = 3600,
142
+ ) -> dict[str, Any]:
143
+ """Flagship convenience method — get-or-create user, apply policy, create session.
144
+
145
+ This is the single most important method in the SDK. It encapsulates the
146
+ entire white-label embedding workflow in one call:
147
+
148
+ 1. **Resolve user** — look up or create a user by ``external_id``.
149
+ 2. **Apply access** — assign existing policies or find-or-create an
150
+ inline policy from a spec dict.
151
+ 3. **Create session** — mint an embed session token for the resolved user.
152
+
153
+ Args:
154
+ http: The synchronous HTTP client (injected by the resource class).
155
+ user: Either a string ``external_id`` (user must already exist) or a
156
+ dict with at least ``external_id`` and ``email`` (get-or-create).
157
+ Optional keys: ``first_name``, ``last_name``, ``role``.
158
+ access: Access policy configuration. Accepts two shapes:
159
+
160
+ * ``{"policy_ids": ["pol_abc"]}`` — assign user to existing policies.
161
+ * ``{"sources": [...], "filters": {...}}`` — find-or-create an
162
+ auto-named policy matching this spec, then assign.
163
+
164
+ If ``None``, no policy changes are made (user keeps existing access).
165
+ origin: Allowed origin for the embed session (e.g. ``https://app.customer.com``).
166
+ ttl: Session time-to-live in seconds (default 3600, min 900, max 86400).
167
+
168
+ Returns:
169
+ Embed session dict with ``session_token``, ``expires_in``, ``user_id``,
170
+ and ``external_id``.
171
+
172
+ Raises:
173
+ ValueError: If ``user`` is a string and no user with that external_id exists.
174
+ ValidationError: If request parameters are invalid.
175
+ APIError: On any HTTP error from the Querri API.
176
+
177
+ Example — string shorthand (user must exist)::
178
+
179
+ session = client.embed.get_session(user="customer-42", ttl=7200)
180
+
181
+ Example — full user dict (auto-creates if needed)::
182
+
183
+ session = client.embed.get_session(
184
+ user={
185
+ "external_id": "customer-42",
186
+ "email": "alice@customer.com",
187
+ "first_name": "Alice",
188
+ },
189
+ access={
190
+ "sources": ["src_sales"],
191
+ "filters": {"region": ["APAC"]},
192
+ },
193
+ origin="https://app.customer.com",
194
+ )
195
+ """
196
+ external_id, creation_body = _resolve_user_param(user)
197
+
198
+ # ------------------------------------------------------------------
199
+ # Step 1: Resolve user
200
+ # ------------------------------------------------------------------
201
+ if creation_body is not None:
202
+ # Dict form — use idempotent PUT (get-or-create)
203
+ resp = http.put(f"/users/external/{external_id}", json=creation_body)
204
+ result = resp.json()
205
+ user_id: str = result["id"]
206
+ logger.debug(
207
+ "Resolved user external_id=%s → id=%s (created=%s)",
208
+ external_id,
209
+ user_id,
210
+ result.get("created", "unknown"),
211
+ )
212
+ else:
213
+ # String shorthand — look up existing user by external_id
214
+ resp = http.get("/users", params={"external_id": external_id, "limit": 1})
215
+ data = resp.json().get("data", [])
216
+ if not data:
217
+ raise ValueError(
218
+ f"User with external_id '{external_id}' not found. "
219
+ "Pass a dict with at least 'external_id' and 'email' to "
220
+ "create the user automatically. Example:\n"
221
+ " client.embed.get_session(\n"
222
+ " user={'external_id': 'cust-42', 'email': 'alice@example.com'},\n"
223
+ " ...\n"
224
+ " )"
225
+ )
226
+ user_id = data[0]["id"]
227
+ logger.debug(
228
+ "Looked up user external_id=%s → id=%s",
229
+ external_id,
230
+ user_id,
231
+ )
232
+
233
+ # ------------------------------------------------------------------
234
+ # Step 2: Handle access policies
235
+ # ------------------------------------------------------------------
236
+ if access is not None:
237
+ _sync_apply_access(http, user_id=user_id, access=access)
238
+
239
+ # ------------------------------------------------------------------
240
+ # Step 3: Create embed session
241
+ # ------------------------------------------------------------------
242
+ session_body: dict[str, Any] = {"user_id": user_id, "ttl": ttl}
243
+ if origin is not None:
244
+ session_body["origin"] = origin
245
+
246
+ resp = http.post("/embed/sessions", json=session_body)
247
+ session = resp.json()
248
+
249
+ # Enrich response with the external_id the caller passed in
250
+ session["external_id"] = external_id
251
+
252
+ logger.debug(
253
+ "Created embed session for user_id=%s, expires_in=%s",
254
+ user_id,
255
+ session.get("expires_in"),
256
+ )
257
+ return session # type: ignore[no-any-return]
258
+
259
+
260
+ def _sync_apply_access(
261
+ http: SyncHTTPClient,
262
+ *,
263
+ user_id: str,
264
+ access: dict[str, Any],
265
+ ) -> None:
266
+ """Apply access policies to a user (sync).
267
+
268
+ Handles both ``policy_ids`` references and inline specs.
269
+ Uses atomic replace (PUT) to prevent stale policy accumulation.
270
+ """
271
+ all_policy_ids: list[str] = list(access.get("policy_ids") or [])
272
+
273
+ # Inline spec — find-or-create a policy by deterministic content hash
274
+ if access.get("sources") or access.get("filters"):
275
+ spec_hash = _hash_access_spec(access)
276
+ policy_name = f"sdk_auto_{spec_hash}"
277
+
278
+ # Try to find an existing policy with this name
279
+ resp = http.get("/access/policies", params={"name": policy_name})
280
+ policies = resp.json().get("data", [])
281
+
282
+ if policies:
283
+ policy_id = policies[0]["id"]
284
+ logger.debug(
285
+ "Reusing existing auto-policy %s (name=%s)", policy_id, policy_name
286
+ )
287
+ else:
288
+ # Create a new policy
289
+ policy_body = _build_policy_body(access, policy_name)
290
+ resp = http.post("/access/policies", json=policy_body)
291
+ policy_id = resp.json()["id"]
292
+ logger.debug("Created auto-policy %s (name=%s)", policy_id, policy_name)
293
+
294
+ all_policy_ids.append(policy_id)
295
+
296
+ if not all_policy_ids:
297
+ logger.debug("Empty access spec, skipping policy assignment")
298
+ return
299
+
300
+ # Atomically replace all policy assignments for the user
301
+ http.put(
302
+ f"/access/users/{user_id}/policies",
303
+ json={"policy_ids": all_policy_ids},
304
+ )
305
+ logger.debug(
306
+ "Replaced policies for user %s with %s",
307
+ user_id,
308
+ all_policy_ids,
309
+ )
310
+
311
+
312
+ def sync_setup_policy(
313
+ http: SyncHTTPClient,
314
+ *,
315
+ name: str,
316
+ sources: list[str] | None = None,
317
+ row_filters: dict[str, Any] | None = None,
318
+ description: str | None = None,
319
+ users: list[str] | None = None,
320
+ ) -> Policy:
321
+ """Create a policy and optionally assign users in one call.
322
+
323
+ Args:
324
+ http: The synchronous HTTP client.
325
+ name: Human-readable policy name.
326
+ sources: List of source IDs to include in the policy scope.
327
+ row_filters: Dict mapping column names to allowed values.
328
+ Values can be a single string or a list of strings.
329
+ description: Optional policy description.
330
+ users: Optional list of user IDs (WorkOS or external) to assign.
331
+
332
+ Returns:
333
+ The created policy dict from the API.
334
+
335
+ Example::
336
+
337
+ policy = client.policies.setup(
338
+ name="APAC Sales Team",
339
+ sources=["src_abc", "src_def"],
340
+ row_filters={"region": ["APAC"], "department": "Sales"},
341
+ users=["user-1", "user-2"],
342
+ )
343
+ """
344
+ body: dict[str, Any] = {"name": name}
345
+ if description is not None:
346
+ body["description"] = description
347
+ if sources:
348
+ body["source_ids"] = list(sources)
349
+ if row_filters:
350
+ filters: list[dict[str, Any]] = []
351
+ for col, val in row_filters.items():
352
+ if isinstance(val, list):
353
+ filters.append({"column": col, "values": val})
354
+ else:
355
+ filters.append({"column": col, "values": [val]})
356
+ body["row_filters"] = filters
357
+
358
+ resp = http.post("/access/policies", json=body)
359
+ policy = Policy.model_validate(resp.json())
360
+
361
+ if users:
362
+ http.post(
363
+ f"/access/policies/{policy.id}/users",
364
+ json={"user_ids": users},
365
+ )
366
+ logger.debug("Assigned %d users to policy %s", len(users), policy.id)
367
+
368
+ return policy
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Async convenience methods
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ async def async_get_session(
377
+ http: AsyncHTTPClient,
378
+ *,
379
+ user: str | dict[str, Any],
380
+ access: dict[str, Any] | None = None,
381
+ origin: str | None = None,
382
+ ttl: int = 3600,
383
+ ) -> dict[str, Any]:
384
+ """Async version of :func:`sync_get_session`.
385
+
386
+ Identical logic and parameters — see :func:`sync_get_session` for full
387
+ documentation.
388
+ """
389
+ external_id, creation_body = _resolve_user_param(user)
390
+
391
+ # ------------------------------------------------------------------
392
+ # Step 1: Resolve user
393
+ # ------------------------------------------------------------------
394
+ if creation_body is not None:
395
+ resp = await http.put(f"/users/external/{external_id}", json=creation_body)
396
+ result = resp.json()
397
+ user_id: str = result["id"]
398
+ logger.debug(
399
+ "Resolved user external_id=%s → id=%s (created=%s)",
400
+ external_id,
401
+ user_id,
402
+ result.get("created", "unknown"),
403
+ )
404
+ else:
405
+ resp = await http.get("/users", params={"external_id": external_id, "limit": 1})
406
+ data = resp.json().get("data", [])
407
+ if not data:
408
+ raise ValueError(
409
+ f"User with external_id '{external_id}' not found. "
410
+ "Pass a dict with at least 'external_id' and 'email' to "
411
+ "create the user automatically. Example:\n"
412
+ " await client.embed.get_session(\n"
413
+ " user={'external_id': 'cust-42', 'email': 'alice@example.com'},\n"
414
+ " ...\n"
415
+ " )"
416
+ )
417
+ user_id = data[0]["id"]
418
+ logger.debug(
419
+ "Looked up user external_id=%s → id=%s",
420
+ external_id,
421
+ user_id,
422
+ )
423
+
424
+ # ------------------------------------------------------------------
425
+ # Step 2: Handle access policies
426
+ # ------------------------------------------------------------------
427
+ if access is not None:
428
+ await _async_apply_access(http, user_id=user_id, access=access)
429
+
430
+ # ------------------------------------------------------------------
431
+ # Step 3: Create embed session
432
+ # ------------------------------------------------------------------
433
+ session_body: dict[str, Any] = {"user_id": user_id, "ttl": ttl}
434
+ if origin is not None:
435
+ session_body["origin"] = origin
436
+
437
+ resp = await http.post("/embed/sessions", json=session_body)
438
+ session = resp.json()
439
+
440
+ session["external_id"] = external_id
441
+
442
+ logger.debug(
443
+ "Created embed session for user_id=%s, expires_in=%s",
444
+ user_id,
445
+ session.get("expires_in"),
446
+ )
447
+ return session # type: ignore[no-any-return]
448
+
449
+
450
+ async def _async_apply_access(
451
+ http: AsyncHTTPClient,
452
+ *,
453
+ user_id: str,
454
+ access: dict[str, Any],
455
+ ) -> None:
456
+ """Apply access policies to a user (async).
457
+
458
+ Handles both ``policy_ids`` references and inline specs.
459
+ Uses atomic replace (PUT) to prevent stale policy accumulation.
460
+ """
461
+ all_policy_ids: list[str] = list(access.get("policy_ids") or [])
462
+
463
+ # Inline spec — find-or-create a policy by deterministic content hash
464
+ if access.get("sources") or access.get("filters"):
465
+ spec_hash = _hash_access_spec(access)
466
+ policy_name = f"sdk_auto_{spec_hash}"
467
+
468
+ resp = await http.get("/access/policies", params={"name": policy_name})
469
+ policies = resp.json().get("data", [])
470
+
471
+ if policies:
472
+ policy_id = policies[0]["id"]
473
+ logger.debug(
474
+ "Reusing existing auto-policy %s (name=%s)", policy_id, policy_name
475
+ )
476
+ else:
477
+ policy_body = _build_policy_body(access, policy_name)
478
+ resp = await http.post("/access/policies", json=policy_body)
479
+ policy_id = resp.json()["id"]
480
+ logger.debug("Created auto-policy %s (name=%s)", policy_id, policy_name)
481
+
482
+ all_policy_ids.append(policy_id)
483
+
484
+ if not all_policy_ids:
485
+ logger.debug("Empty access spec, skipping policy assignment")
486
+ return
487
+
488
+ # Atomically replace all policy assignments for the user
489
+ await http.put(
490
+ f"/access/users/{user_id}/policies",
491
+ json={"policy_ids": all_policy_ids},
492
+ )
493
+ logger.debug(
494
+ "Replaced policies for user %s with %s",
495
+ user_id,
496
+ all_policy_ids,
497
+ )
498
+
499
+
500
+ async def async_setup_policy(
501
+ http: AsyncHTTPClient,
502
+ *,
503
+ name: str,
504
+ sources: list[str] | None = None,
505
+ row_filters: dict[str, Any] | None = None,
506
+ description: str | None = None,
507
+ users: list[str] | None = None,
508
+ ) -> Policy:
509
+ """Async version of :func:`sync_setup_policy`.
510
+
511
+ Identical logic and parameters — see :func:`sync_setup_policy` for full
512
+ documentation.
513
+ """
514
+ body: dict[str, Any] = {"name": name}
515
+ if description is not None:
516
+ body["description"] = description
517
+ if sources:
518
+ body["source_ids"] = list(sources)
519
+ if row_filters:
520
+ filters: list[dict[str, Any]] = []
521
+ for col, val in row_filters.items():
522
+ if isinstance(val, list):
523
+ filters.append({"column": col, "values": val})
524
+ else:
525
+ filters.append({"column": col, "values": [val]})
526
+ body["row_filters"] = filters
527
+
528
+ resp = await http.post("/access/policies", json=body)
529
+ policy = Policy.model_validate(resp.json())
530
+
531
+ if users:
532
+ await http.post(
533
+ f"/access/policies/{policy.id}/users",
534
+ json={"user_ids": users},
535
+ )
536
+ logger.debug("Assigned %d users to policy %s", len(users), policy.id)
537
+
538
+ return policy