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/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
|
+
)
|