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/cli/_image.py ADDED
@@ -0,0 +1,234 @@
1
+ """Terminal image rendering using Unicode half-block characters.
2
+
3
+ Downloads images to ``~/.querri/cache/images/`` and renders them as colored
4
+ ANSI art using the ``Pillow`` library and Unicode half-block characters (``\\u2580``
5
+ upper, ``\\u2584`` lower). Each character cell encodes two vertical pixels,
6
+ doubling the effective vertical resolution.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger("querri")
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Cache directory
19
+ # ---------------------------------------------------------------------------
20
+
21
+ CACHE_DIR = Path.home() / ".querri" / "cache" / "images"
22
+
23
+ # ANSI 256-color palette breakpoints for the 6x6x6 RGB cube (indices 16-231).
24
+ _PALETTE = [0, 95, 135, 175, 215, 255]
25
+
26
+
27
+ def _nearest_palette_index(value: int) -> int:
28
+ """Find the nearest index in the 6-level palette for a single channel."""
29
+ best = 0
30
+ best_dist = abs(value - _PALETTE[0])
31
+ for i in range(1, 6):
32
+ dist = abs(value - _PALETTE[i])
33
+ if dist < best_dist:
34
+ best_dist = dist
35
+ best = i
36
+ return best
37
+
38
+
39
+ def _rgb_to_ansi256(r: int, g: int, b: int) -> int:
40
+ """Convert an RGB triplet to the nearest ANSI 256 color index."""
41
+ ri = _nearest_palette_index(r)
42
+ gi = _nearest_palette_index(g)
43
+ bi = _nearest_palette_index(b)
44
+ return 16 + ri * 36 + gi * 6 + bi
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Download + cache
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def _cache_path(url: str) -> Path:
53
+ """Deterministic cache path for a URL."""
54
+ h = hashlib.sha256(url.encode()).hexdigest()[:16]
55
+ suffix = Path(url.split("?")[0]).suffix or ".png"
56
+ return CACHE_DIR / f"{h}{suffix}"
57
+
58
+
59
+ def download_image(
60
+ url: str,
61
+ *,
62
+ headers: dict[str, str] | None = None,
63
+ retries: int = 2,
64
+ ) -> Path | None:
65
+ """Download an image to the local cache. Returns the path or None on error.
66
+
67
+ Retries on failure (useful when the file was just created and may
68
+ not be immediately available from the storage backend).
69
+
70
+ Args:
71
+ url: Image URL to download.
72
+ headers: Optional auth headers for authenticated endpoints.
73
+ retries: Number of retry attempts on failure.
74
+ """
75
+ import time
76
+
77
+ import httpx
78
+
79
+ cached = _cache_path(url)
80
+ if cached.exists():
81
+ return cached
82
+
83
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
84
+
85
+ for attempt in range(retries + 1):
86
+ try:
87
+ resp = httpx.get(
88
+ url,
89
+ headers=headers or {},
90
+ follow_redirects=True,
91
+ timeout=15.0,
92
+ )
93
+ resp.raise_for_status()
94
+ cached.write_bytes(resp.content)
95
+ return cached
96
+ except Exception as exc:
97
+ logger.debug(
98
+ "Image download attempt %d/%d failed for %s: %s",
99
+ attempt + 1,
100
+ retries + 1,
101
+ url,
102
+ exc,
103
+ )
104
+ if attempt < retries:
105
+ time.sleep(1.0)
106
+
107
+ return None
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Half-block renderer
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def render_image(
116
+ path: Path,
117
+ *,
118
+ max_width: int = 60,
119
+ max_height: int = 30,
120
+ ) -> str | None:
121
+ """Render an image file as ANSI-colored Unicode half-block art.
122
+
123
+ Each character cell uses upper-half-block (``\\u2580``) with foreground for
124
+ the top pixel and background for the bottom pixel, giving 2x vertical
125
+ resolution.
126
+
127
+ Args:
128
+ path: Path to an image file (PNG, JPEG, etc.).
129
+ max_width: Maximum character width for the output.
130
+ max_height: Maximum character height (each char = 2 pixel rows).
131
+
132
+ Returns:
133
+ A string with ANSI escape codes, or None if rendering fails.
134
+ """
135
+ try:
136
+ from PIL import Image
137
+ except ImportError:
138
+ logger.debug("Pillow not installed — cannot render image")
139
+ return None
140
+
141
+ try:
142
+ img = Image.open(path).convert("RGB")
143
+ except Exception as exc:
144
+ logger.debug("Failed to open image %s: %s", path, exc)
145
+ return None
146
+
147
+ # Resize to fit terminal — height is doubled because each char = 2 rows
148
+ w, h = img.size
149
+ scale_w = max_width / w
150
+ scale_h = (max_height * 2) / h # 2 pixel rows per char row
151
+ scale = min(scale_w, scale_h, 1.0) # never upscale
152
+
153
+ new_w = max(1, int(w * scale))
154
+ new_h = max(2, int(h * scale))
155
+ # Ensure even height for half-block pairing
156
+ if new_h % 2 != 0:
157
+ new_h += 1
158
+
159
+ img = img.resize((new_w, new_h), Image.LANCZOS) # type: ignore[attr-defined]
160
+ pixels = img.load()
161
+ assert pixels is not None
162
+
163
+ lines: list[str] = []
164
+ for y in range(0, new_h, 2):
165
+ line_parts: list[str] = []
166
+ for x in range(new_w):
167
+ # Top pixel = foreground, bottom pixel = background
168
+ r1, g1, b1 = pixels[x, y] # type: ignore[misc]
169
+ if y + 1 < new_h:
170
+ r2, g2, b2 = pixels[x, y + 1] # type: ignore[misc]
171
+ else:
172
+ r2, g2, b2 = r1, g1, b1
173
+
174
+ fg = _rgb_to_ansi256(r1, g1, b1)
175
+ bg = _rgb_to_ansi256(r2, g2, b2)
176
+ # \u2580 = upper half block — fg colors top, bg colors bottom
177
+ line_parts.append(f"\033[38;5;{fg}m\033[48;5;{bg}m\u2580")
178
+
179
+ lines.append("".join(line_parts) + "\033[0m")
180
+
181
+ return "\n".join(lines)
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Rich integration
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ def render_image_rich(
190
+ url: str,
191
+ *,
192
+ caption: str = "",
193
+ max_width: int = 60,
194
+ max_height: int = 24,
195
+ headers: dict[str, str] | None = None,
196
+ ) -> object:
197
+ """Download and render an image as a Rich Panel with ANSI art.
198
+
199
+ Falls back to a clickable link if rendering fails.
200
+
201
+ Args:
202
+ url: Image URL to download and render.
203
+ caption: Optional caption text to show below the image.
204
+ max_width: Maximum character width.
205
+ max_height: Maximum character height.
206
+
207
+ Returns:
208
+ A Rich renderable (Panel or Text).
209
+ """
210
+ from rich.text import Text
211
+
212
+ path = download_image(url, headers=headers)
213
+ ansi_art = (
214
+ render_image(path, max_width=max_width, max_height=max_height) if path else None
215
+ )
216
+
217
+ if ansi_art:
218
+ # Rich Text.from_ansi parses ANSI escape codes into styled text
219
+ art = Text.from_ansi(ansi_art)
220
+ parts = [art]
221
+ if caption:
222
+ parts.append(Text(f"\n{caption}", style="italic"))
223
+ # Clickable link hint below
224
+ parts.append(
225
+ Text.from_markup(f"\n[dim][link={url}]Open full image[/link][/dim]")
226
+ )
227
+ return Text("\n").join(parts)
228
+ else:
229
+ # Fallback: clickable link + hint
230
+ return Text.from_markup(
231
+ f" [link={url}][bold #f15a24]View chart[/bold #f15a24][/link]"
232
+ f" [dim]{url}[/dim]\n"
233
+ f" [dim]Run [bold]querri chat show[/bold] to render inline[/dim]"
234
+ )
querri/cli/_output.py ADDED
@@ -0,0 +1,293 @@
1
+ """CLI output formatting — shared helpers for all commands.
2
+
3
+ Governs all CLI output behavior: Rich tables for TTY, plain text for pipes,
4
+ JSON for --json, IDs only for --quiet. Handles exit codes and error formatting.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from collections.abc import Iterable, Sequence
12
+ from typing import Any
13
+
14
+ import typer
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Querri brand colors
18
+ # ---------------------------------------------------------------------------
19
+
20
+ QUERRI_ORANGE = "#f15a24"
21
+ QUERRI_ORANGE_LIGHT = "#ff7a47"
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # TTY auto-detection — single check, drives all output behavior
25
+ # ---------------------------------------------------------------------------
26
+
27
+ IS_INTERACTIVE = sys.stdout.isatty()
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Exit code helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ EXIT_SUCCESS = 0
35
+ EXIT_ERROR = 1
36
+ EXIT_AUTH_ERROR = 2
37
+ EXIT_NOT_FOUND = 3
38
+ EXIT_RATE_LIMITED = 4
39
+
40
+
41
+ def exit_success() -> None:
42
+ raise typer.Exit(code=EXIT_SUCCESS)
43
+
44
+
45
+ def exit_error() -> None:
46
+ raise typer.Exit(code=EXIT_ERROR)
47
+
48
+
49
+ def exit_auth_error() -> None:
50
+ raise typer.Exit(code=EXIT_AUTH_ERROR)
51
+
52
+
53
+ def exit_not_found() -> None:
54
+ raise typer.Exit(code=EXIT_NOT_FOUND)
55
+
56
+
57
+ def exit_rate_limited() -> None:
58
+ raise typer.Exit(code=EXIT_RATE_LIMITED)
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Output helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def print_json(data: Any) -> None:
67
+ """Output pretty-printed JSON to stdout."""
68
+ if hasattr(data, "model_dump"):
69
+ data = data.model_dump(mode="json")
70
+ elif hasattr(data, "__dict__") and not isinstance(data, dict):
71
+ data = {k: v for k, v in data.__dict__.items() if not k.startswith("_")}
72
+ print(json.dumps(data, indent=2, default=str))
73
+
74
+
75
+ def print_id(id_value: str) -> None:
76
+ """Output bare ID string (for --quiet mode)."""
77
+ print(id_value)
78
+
79
+
80
+ def print_table(
81
+ data: Iterable[Any],
82
+ columns: list[tuple[str, str]],
83
+ *,
84
+ ctx: typer.Context | None = None,
85
+ ) -> None:
86
+ """Render data as a table.
87
+
88
+ In TTY mode, uses Rich tables. In non-TTY mode, outputs tab-delimited text.
89
+
90
+ Args:
91
+ data: Iterable of objects (dicts, Pydantic models, or cursor pages).
92
+ columns: List of (field_name, display_header) tuples.
93
+ ctx: Optional Typer context for checking --json/--quiet flags.
94
+ """
95
+ data = list(data)
96
+ obj = ctx.ensure_object(dict) if ctx else {}
97
+ is_interactive = obj.get("interactive", IS_INTERACTIVE)
98
+
99
+ if is_interactive:
100
+ _print_rich_table(data, columns)
101
+ else:
102
+ _print_plain_table(data, columns)
103
+
104
+
105
+ def _get_field(item: Any, field: str) -> str:
106
+ """Extract a field from a dict or object, return as string."""
107
+ val = item.get(field, "") if isinstance(item, dict) else getattr(item, field, "")
108
+ if val is None:
109
+ return "—"
110
+ if isinstance(val, list):
111
+ return ", ".join(str(v) for v in val)
112
+ return str(val)
113
+
114
+
115
+ def _print_rich_table(data: Sequence[Any], columns: list[tuple[str, str]]) -> None:
116
+ """Render a Rich table to stdout."""
117
+ from rich.console import Console
118
+ from rich.table import Table
119
+
120
+ table = Table(show_header=True, header_style=f"bold {QUERRI_ORANGE}")
121
+ for _, header in columns:
122
+ table.add_column(header)
123
+
124
+ for item in data:
125
+ row = [_get_field(item, field) for field, _ in columns]
126
+ table.add_row(*row)
127
+
128
+ console = Console()
129
+ console.print(table)
130
+
131
+
132
+ def _print_plain_table(data: Sequence[Any], columns: list[tuple[str, str]]) -> None:
133
+ """Output tab-delimited text (for piping)."""
134
+ # Header
135
+ print("\t".join(header for _, header in columns))
136
+ # Rows
137
+ for item in data:
138
+ row = [_get_field(item, field) for field, _ in columns]
139
+ print("\t".join(row))
140
+
141
+
142
+ def print_detail(data: Any, fields: list[tuple[str, str]]) -> None:
143
+ """Print a single object's details in a key-value format.
144
+
145
+ Args:
146
+ data: Dict or Pydantic model.
147
+ fields: List of (field_name, display_label) tuples.
148
+ """
149
+ if IS_INTERACTIVE:
150
+ from rich.console import Console
151
+ from rich.table import Table
152
+
153
+ table = Table(show_header=False, box=None, padding=(0, 2))
154
+ table.add_column("Field", style=f"bold {QUERRI_ORANGE}")
155
+ table.add_column("Value")
156
+
157
+ for field, label in fields:
158
+ table.add_row(label, _get_field(data, field))
159
+
160
+ console = Console()
161
+ console.print(table)
162
+ else:
163
+ for field, label in fields:
164
+ print(f"{label}: {_get_field(data, field)}")
165
+
166
+
167
+ def print_success(message: str) -> None:
168
+ """Print a success message."""
169
+ if IS_INTERACTIVE:
170
+ from rich.console import Console
171
+
172
+ Console(stderr=True).print(f"[{QUERRI_ORANGE}]✓[/{QUERRI_ORANGE}] {message}")
173
+ else:
174
+ print(message, file=sys.stderr)
175
+
176
+
177
+ def print_error(message: str) -> None:
178
+ """Print an error message to stderr (red in TTY mode)."""
179
+ if IS_INTERACTIVE:
180
+ from rich.console import Console
181
+
182
+ Console(stderr=True).print(f"[red]Error:[/red] {message}")
183
+ else:
184
+ print(f"Error: {message}", file=sys.stderr)
185
+
186
+
187
+ def print_json_error(
188
+ error_type: str,
189
+ message: str,
190
+ exit_code: int,
191
+ *,
192
+ hint: str | None = None,
193
+ ) -> None:
194
+ """Print a JSON-formatted error to stderr."""
195
+ error_obj: dict[str, Any] = {
196
+ "error": error_type,
197
+ "message": message,
198
+ "code": exit_code,
199
+ }
200
+ if hint:
201
+ error_obj["hint"] = hint
202
+ print(json.dumps(error_obj))
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Error handler — maps SDK exceptions to CLI output + exit codes
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ EXIT_SERVER_ERROR = 5
211
+
212
+
213
+ def handle_api_error(exc: Exception, *, is_json: bool | None = False) -> int:
214
+ """Handle an SDK exception, print appropriate output, return exit code.
215
+
216
+ Args:
217
+ exc: The exception from the SDK.
218
+ is_json: Whether --json mode is active. ``None`` is treated as ``False``.
219
+
220
+ Returns:
221
+ Exit code to use.
222
+ """
223
+ is_json = bool(is_json)
224
+ from querri._exceptions import (
225
+ APIError,
226
+ AuthenticationError,
227
+ NotFoundError,
228
+ RateLimitError,
229
+ ServerError,
230
+ )
231
+
232
+ if isinstance(exc, AuthenticationError):
233
+ exit_code = EXIT_AUTH_ERROR
234
+ error_type = "auth_failed"
235
+ message = (
236
+ "Authentication failed. Check your API key"
237
+ " or set QUERRI_API_KEY environment variable."
238
+ )
239
+ elif isinstance(exc, NotFoundError):
240
+ exit_code = EXIT_NOT_FOUND
241
+ error_type = "not_found"
242
+ message = str(exc)
243
+ elif isinstance(exc, RateLimitError):
244
+ exit_code = EXIT_RATE_LIMITED
245
+ error_type = "rate_limited"
246
+ retry_after = getattr(exc, "retry_after", None)
247
+ message = (
248
+ f"Rate limited. Retry after {retry_after}s."
249
+ if retry_after
250
+ else "Rate limited."
251
+ )
252
+ elif isinstance(exc, ServerError):
253
+ exit_code = EXIT_SERVER_ERROR
254
+ error_type = "server_error"
255
+ message = str(exc)
256
+ else:
257
+ exit_code = EXIT_ERROR
258
+ error_type = "api_error"
259
+ message = str(exc)
260
+
261
+ # Extract rich context from APIError subclasses
262
+ details: dict[str, str] = {}
263
+ if isinstance(exc, APIError):
264
+ if exc.status:
265
+ details["status"] = str(exc.status)
266
+ if exc.code:
267
+ details["code"] = exc.code
268
+ if exc.type:
269
+ details["type"] = exc.type
270
+ if exc.request_id:
271
+ details["request_id"] = exc.request_id
272
+ if exc.doc_url:
273
+ details["doc_url"] = exc.doc_url
274
+
275
+ if is_json:
276
+ error_obj: dict[str, object] = {
277
+ "error": error_type,
278
+ "message": message,
279
+ "code": exit_code,
280
+ }
281
+ error_obj.update(details)
282
+ print(json.dumps(error_obj))
283
+ else:
284
+ print_error(message)
285
+ # Show additional context for server errors and unexpected failures
286
+ if details.get("code"):
287
+ print_error(f" Code: {details['code']}")
288
+ if details.get("request_id"):
289
+ print_error(f" Request ID: {details['request_id']}")
290
+ if details.get("doc_url"):
291
+ print_error(f" Docs: {details['doc_url']}")
292
+
293
+ return exit_code
querri/cli/audit.py ADDED
@@ -0,0 +1,68 @@
1
+ """querri audit — view audit log events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from querri.cli._context import get_client
8
+ from querri.cli._output import (
9
+ handle_api_error,
10
+ print_json,
11
+ print_table,
12
+ )
13
+
14
+ audit_app = typer.Typer(
15
+ name="audit",
16
+ help="View audit log events.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+
21
+ @audit_app.command("list")
22
+ def list_events(
23
+ ctx: typer.Context,
24
+ actor_id: str | None = typer.Option(None, "--actor-id", help="Filter by actor ID."),
25
+ target_id: str | None = typer.Option(
26
+ None, "--target-id", help="Filter by target ID."
27
+ ),
28
+ action: str | None = typer.Option(None, "--action", help="Filter by action type."),
29
+ start_date: str | None = typer.Option(
30
+ None, "--start-date", help="Start date (ISO 8601)."
31
+ ),
32
+ end_date: str | None = typer.Option(
33
+ None, "--end-date", help="End date (ISO 8601)."
34
+ ),
35
+ limit: int = typer.Option(25, "--limit", "-l", help="Max results to return."),
36
+ after: str | None = typer.Option(None, "--after", help="Cursor for pagination."),
37
+ ) -> None:
38
+ """List audit log events."""
39
+ obj = ctx.ensure_object(dict)
40
+ client = get_client(ctx)
41
+ try:
42
+ items = client.audit.list(
43
+ actor_id=actor_id,
44
+ target_id=target_id,
45
+ action=action,
46
+ start_date=start_date,
47
+ end_date=end_date,
48
+ limit=limit,
49
+ after=after,
50
+ )
51
+ except Exception as exc:
52
+ raise typer.Exit(code=handle_api_error(exc, is_json=obj.get("json"))) from None
53
+
54
+ if obj.get("json"):
55
+ print_json([e.model_dump(mode="json") for e in items])
56
+ else:
57
+ print_table(
58
+ items,
59
+ [
60
+ ("id", "ID"),
61
+ ("action", "Action"),
62
+ ("actor_id", "Actor"),
63
+ ("target_type", "Target Type"),
64
+ ("target_id", "Target ID"),
65
+ ("timestamp", "Timestamp"),
66
+ ],
67
+ ctx=ctx,
68
+ )