databar 2.2.0__tar.gz → 2.5.0__tar.gz
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.
- {databar-2.2.0/src/databar.egg-info → databar-2.5.0}/PKG-INFO +34 -5
- {databar-2.2.0 → databar-2.5.0}/README.md +33 -4
- {databar-2.2.0 → databar-2.5.0}/pyproject.toml +1 -1
- {databar-2.2.0 → databar-2.5.0}/src/databar/__init__.py +17 -1
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_auth.py +3 -2
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_guide.py +19 -1
- databar-2.5.0/src/databar/cli/_output.py +332 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/app.py +3 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/enrichments.py +18 -10
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/flows.py +75 -4
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/tables.py +118 -46
- databar-2.5.0/src/databar/cli/tasks.py +117 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/waterfalls.py +8 -6
- {databar-2.2.0 → databar-2.5.0}/src/databar/client.py +234 -15
- {databar-2.2.0 → databar-2.5.0}/src/databar/exceptions.py +29 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/models.py +106 -4
- {databar-2.2.0 → databar-2.5.0/src/databar.egg-info}/PKG-INFO +34 -5
- databar-2.5.0/tests/test_cli.py +719 -0
- {databar-2.2.0 → databar-2.5.0}/tests/test_client.py +129 -0
- {databar-2.2.0 → databar-2.5.0}/tests/test_new_features.py +60 -1
- databar-2.2.0/src/databar/cli/_output.py +0 -147
- databar-2.2.0/src/databar/cli/tasks.py +0 -60
- databar-2.2.0/tests/test_cli.py +0 -376
- {databar-2.2.0 → databar-2.5.0}/LICENSE +0 -0
- {databar-2.2.0 → databar-2.5.0}/setup.cfg +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/__init__.py +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_onboard.py +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/SOURCES.txt +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/dependency_links.txt +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/entry_points.txt +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/requires.txt +0 -0
- {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: databar
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Official Databar.ai Python SDK and CLI — connect to enrichments, waterfalls, and tables via api.databar.ai
|
|
5
5
|
Author-email: "Databar.ai Team" <info@databar.ai>
|
|
6
6
|
License: MIT License
|
|
@@ -360,6 +360,22 @@ client.move_table_to_folder(table.identifier) # remove from folder
|
|
|
360
360
|
client.delete_folder(folder.id) # tables move to root
|
|
361
361
|
```
|
|
362
362
|
|
|
363
|
+
### Tasks
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
task = client.run_enrichment_bulk(123, [{"email": "a@b.com"}, {"email": "c@d.com"}])
|
|
367
|
+
|
|
368
|
+
# While a bulk run is going, progress tells you whether it is advancing or stuck
|
|
369
|
+
status = client.get_task(task.task_id)
|
|
370
|
+
status.progress # {"total": 2, "completed": 1, "failed": 0, "processing": 1}
|
|
371
|
+
|
|
372
|
+
# Rows that have already finished, without waiting for the rest
|
|
373
|
+
client.get_task(task.task_id, include_partial=True).data
|
|
374
|
+
|
|
375
|
+
# Stop it — unfinished requests are refunded, finished rows keep their results
|
|
376
|
+
client.cancel_task(task.task_id)
|
|
377
|
+
```
|
|
378
|
+
|
|
363
379
|
### Error handling
|
|
364
380
|
|
|
365
381
|
```python
|
|
@@ -368,6 +384,7 @@ from databar import (
|
|
|
368
384
|
DatabarAuthError,
|
|
369
385
|
DatabarInsufficientCreditsError,
|
|
370
386
|
DatabarNotFoundError,
|
|
387
|
+
DatabarTaskCancelledError,
|
|
371
388
|
DatabarTaskFailedError,
|
|
372
389
|
DatabarTimeoutError,
|
|
373
390
|
)
|
|
@@ -382,6 +399,9 @@ except DatabarNotFoundError:
|
|
|
382
399
|
print("Enrichment not found")
|
|
383
400
|
except DatabarTaskFailedError as e:
|
|
384
401
|
print(f"Task failed: {e.message}")
|
|
402
|
+
except DatabarTaskCancelledError as e:
|
|
403
|
+
# Only the rows that finished, and not aligned to the inputs.
|
|
404
|
+
print(f"Cancelled, got {len(e.partial_data or [])} rows")
|
|
385
405
|
except DatabarTimeoutError as e:
|
|
386
406
|
print(f"Timed out after polling {e.max_attempts} times")
|
|
387
407
|
```
|
|
@@ -499,20 +519,29 @@ databar table run-enrichment <uuid> --enrichment-id <table-enrichment-id>
|
|
|
499
519
|
### Tasks
|
|
500
520
|
|
|
501
521
|
```bash
|
|
502
|
-
# Check a task status
|
|
522
|
+
# Check a task status — a bulk task also reports how many inputs are done
|
|
503
523
|
databar task get <task-id>
|
|
504
524
|
|
|
505
525
|
# Poll until complete
|
|
506
526
|
databar task get <task-id> --poll
|
|
527
|
+
|
|
528
|
+
# Collect the rows a running bulk task has already finished
|
|
529
|
+
databar task get <task-id> --partial
|
|
530
|
+
|
|
531
|
+
# Stop a running task; finished rows keep their results
|
|
532
|
+
databar task cancel <task-id>
|
|
507
533
|
```
|
|
508
534
|
|
|
509
535
|
### Output formats
|
|
510
536
|
|
|
511
|
-
All commands support `--format table|json|csv` (default: `table`)
|
|
537
|
+
All commands support `--format table|json|csv` (default: `table`).
|
|
538
|
+
|
|
539
|
+
`--format json` prints one envelope on stdout:
|
|
540
|
+
`{"ok": true, "data": ...}` or `{"ok": false, "error": {"code", "message", ...}}`.
|
|
512
541
|
|
|
513
542
|
```bash
|
|
514
|
-
# Pipe JSON output
|
|
515
|
-
databar table rows <uuid> --format json | jq '.[].email'
|
|
543
|
+
# Pipe JSON output (payload is under .data)
|
|
544
|
+
databar table rows <uuid> --format json | jq '.data[].email'
|
|
516
545
|
|
|
517
546
|
# Save to CSV
|
|
518
547
|
databar enrich bulk 123 --input input.csv --format csv --out output.csv
|
|
@@ -305,6 +305,22 @@ client.move_table_to_folder(table.identifier) # remove from folder
|
|
|
305
305
|
client.delete_folder(folder.id) # tables move to root
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
+
### Tasks
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
task = client.run_enrichment_bulk(123, [{"email": "a@b.com"}, {"email": "c@d.com"}])
|
|
312
|
+
|
|
313
|
+
# While a bulk run is going, progress tells you whether it is advancing or stuck
|
|
314
|
+
status = client.get_task(task.task_id)
|
|
315
|
+
status.progress # {"total": 2, "completed": 1, "failed": 0, "processing": 1}
|
|
316
|
+
|
|
317
|
+
# Rows that have already finished, without waiting for the rest
|
|
318
|
+
client.get_task(task.task_id, include_partial=True).data
|
|
319
|
+
|
|
320
|
+
# Stop it — unfinished requests are refunded, finished rows keep their results
|
|
321
|
+
client.cancel_task(task.task_id)
|
|
322
|
+
```
|
|
323
|
+
|
|
308
324
|
### Error handling
|
|
309
325
|
|
|
310
326
|
```python
|
|
@@ -313,6 +329,7 @@ from databar import (
|
|
|
313
329
|
DatabarAuthError,
|
|
314
330
|
DatabarInsufficientCreditsError,
|
|
315
331
|
DatabarNotFoundError,
|
|
332
|
+
DatabarTaskCancelledError,
|
|
316
333
|
DatabarTaskFailedError,
|
|
317
334
|
DatabarTimeoutError,
|
|
318
335
|
)
|
|
@@ -327,6 +344,9 @@ except DatabarNotFoundError:
|
|
|
327
344
|
print("Enrichment not found")
|
|
328
345
|
except DatabarTaskFailedError as e:
|
|
329
346
|
print(f"Task failed: {e.message}")
|
|
347
|
+
except DatabarTaskCancelledError as e:
|
|
348
|
+
# Only the rows that finished, and not aligned to the inputs.
|
|
349
|
+
print(f"Cancelled, got {len(e.partial_data or [])} rows")
|
|
330
350
|
except DatabarTimeoutError as e:
|
|
331
351
|
print(f"Timed out after polling {e.max_attempts} times")
|
|
332
352
|
```
|
|
@@ -444,20 +464,29 @@ databar table run-enrichment <uuid> --enrichment-id <table-enrichment-id>
|
|
|
444
464
|
### Tasks
|
|
445
465
|
|
|
446
466
|
```bash
|
|
447
|
-
# Check a task status
|
|
467
|
+
# Check a task status — a bulk task also reports how many inputs are done
|
|
448
468
|
databar task get <task-id>
|
|
449
469
|
|
|
450
470
|
# Poll until complete
|
|
451
471
|
databar task get <task-id> --poll
|
|
472
|
+
|
|
473
|
+
# Collect the rows a running bulk task has already finished
|
|
474
|
+
databar task get <task-id> --partial
|
|
475
|
+
|
|
476
|
+
# Stop a running task; finished rows keep their results
|
|
477
|
+
databar task cancel <task-id>
|
|
452
478
|
```
|
|
453
479
|
|
|
454
480
|
### Output formats
|
|
455
481
|
|
|
456
|
-
All commands support `--format table|json|csv` (default: `table`)
|
|
482
|
+
All commands support `--format table|json|csv` (default: `table`).
|
|
483
|
+
|
|
484
|
+
`--format json` prints one envelope on stdout:
|
|
485
|
+
`{"ok": true, "data": ...}` or `{"ok": false, "error": {"code", "message", ...}}`.
|
|
457
486
|
|
|
458
487
|
```bash
|
|
459
|
-
# Pipe JSON output
|
|
460
|
-
databar table rows <uuid> --format json | jq '.[].email'
|
|
488
|
+
# Pipe JSON output (payload is under .data)
|
|
489
|
+
databar table rows <uuid> --format json | jq '.data[].email'
|
|
461
490
|
|
|
462
491
|
# Save to CSV
|
|
463
492
|
databar enrich bulk 123 --input input.csv --format csv --out output.csv
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "databar"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "Official Databar.ai Python SDK and CLI — connect to enrichments, waterfalls, and tables via api.databar.ai"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -27,10 +27,12 @@ from .client import DatabarClient
|
|
|
27
27
|
from .exceptions import (
|
|
28
28
|
DatabarAuthError,
|
|
29
29
|
DatabarError,
|
|
30
|
+
DatabarConflictError,
|
|
30
31
|
DatabarGoneError,
|
|
31
32
|
DatabarInsufficientCreditsError,
|
|
32
33
|
DatabarNotFoundError,
|
|
33
34
|
DatabarRateLimitError,
|
|
35
|
+
DatabarTaskCancelledError,
|
|
34
36
|
DatabarTaskFailedError,
|
|
35
37
|
DatabarTimeoutError,
|
|
36
38
|
DatabarValidationError,
|
|
@@ -61,8 +63,13 @@ from .models import (
|
|
|
61
63
|
WaterfallEnrichment,
|
|
62
64
|
# Flows
|
|
63
65
|
Flow,
|
|
66
|
+
FlowConfigOpsResult,
|
|
67
|
+
FlowDetail,
|
|
64
68
|
FlowInput,
|
|
65
69
|
FlowOutput,
|
|
70
|
+
FlowVersion,
|
|
71
|
+
FlowVersionDetail,
|
|
72
|
+
RestoreFlowVersionResult,
|
|
66
73
|
# Tables
|
|
67
74
|
Table,
|
|
68
75
|
Column,
|
|
@@ -88,6 +95,7 @@ from .models import (
|
|
|
88
95
|
Exporter,
|
|
89
96
|
ExporterListResponse,
|
|
90
97
|
ExporterParam,
|
|
98
|
+
ExporterAdditionalParam,
|
|
91
99
|
ExporterResponseField,
|
|
92
100
|
Connection,
|
|
93
101
|
AuthorizationInfo,
|
|
@@ -99,7 +107,7 @@ from .models import (
|
|
|
99
107
|
Folder,
|
|
100
108
|
)
|
|
101
109
|
|
|
102
|
-
__version__ = "2.
|
|
110
|
+
__version__ = "2.5.0"
|
|
103
111
|
__all__ = [
|
|
104
112
|
"DatabarClient",
|
|
105
113
|
# exceptions
|
|
@@ -107,9 +115,11 @@ __all__ = [
|
|
|
107
115
|
"DatabarAuthError",
|
|
108
116
|
"DatabarNotFoundError",
|
|
109
117
|
"DatabarInsufficientCreditsError",
|
|
118
|
+
"DatabarConflictError",
|
|
110
119
|
"DatabarGoneError",
|
|
111
120
|
"DatabarValidationError",
|
|
112
121
|
"DatabarRateLimitError",
|
|
122
|
+
"DatabarTaskCancelledError",
|
|
113
123
|
"DatabarTaskFailedError",
|
|
114
124
|
"DatabarTimeoutError",
|
|
115
125
|
# pricing / category
|
|
@@ -137,8 +147,13 @@ __all__ = [
|
|
|
137
147
|
"WaterfallEnrichment",
|
|
138
148
|
# flows
|
|
139
149
|
"Flow",
|
|
150
|
+
"FlowConfigOpsResult",
|
|
151
|
+
"FlowDetail",
|
|
140
152
|
"FlowInput",
|
|
141
153
|
"FlowOutput",
|
|
154
|
+
"FlowVersion",
|
|
155
|
+
"FlowVersionDetail",
|
|
156
|
+
"RestoreFlowVersionResult",
|
|
142
157
|
# tables
|
|
143
158
|
"Table",
|
|
144
159
|
"Column",
|
|
@@ -164,6 +179,7 @@ __all__ = [
|
|
|
164
179
|
"Exporter",
|
|
165
180
|
"ExporterListResponse",
|
|
166
181
|
"ExporterParam",
|
|
182
|
+
"ExporterAdditionalParam",
|
|
167
183
|
"ExporterResponseField",
|
|
168
184
|
"Connection",
|
|
169
185
|
"AuthorizationInfo",
|
|
@@ -46,7 +46,8 @@ def get_api_key() -> str:
|
|
|
46
46
|
"No API key found.\n"
|
|
47
47
|
" Run [bold]databar login[/bold] to save your key, or set the "
|
|
48
48
|
"[bold]DATABAR_API_KEY[/bold] environment variable.\n"
|
|
49
|
-
" Get your key at [link=https://databar.ai]databar.ai[/link] → Integrations."
|
|
49
|
+
" Get your key at [link=https://databar.ai]databar.ai[/link] → Integrations.",
|
|
50
|
+
code="auth_missing",
|
|
50
51
|
)
|
|
51
52
|
raise typer.Exit(1) # unreachable but satisfies type checkers
|
|
52
53
|
|
|
@@ -111,7 +112,7 @@ def whoami(
|
|
|
111
112
|
try:
|
|
112
113
|
user = client.get_user()
|
|
113
114
|
except DatabarError as e:
|
|
114
|
-
error(
|
|
115
|
+
error(e)
|
|
115
116
|
finally:
|
|
116
117
|
client.close()
|
|
117
118
|
|
|
@@ -110,6 +110,11 @@ echo 'export PATH="$(python3 -m site --user-base)/bin:$PATH"' >> ~/.zshrc
|
|
|
110
110
|
**Always use `--format json` when parsing or piping output.**
|
|
111
111
|
The default `table` format uses Rich terminal markup — not machine-parseable.
|
|
112
112
|
|
|
113
|
+
JSON output is always one envelope on stdout:
|
|
114
|
+
`{"ok": true, "data": ...}` or `{"ok": false, "error": {"code", "message", "hint"?}}`.
|
|
115
|
+
Read results from `.data` (e.g. `jq '.data[].name'`). Exit codes: 2 usage, 3 auth,
|
|
116
|
+
4 not found, 5 validation, 1 other.
|
|
117
|
+
|
|
113
118
|
### Enrichments
|
|
114
119
|
```bash
|
|
115
120
|
databar enrich list --format json
|
|
@@ -157,10 +162,17 @@ databar table run-enrichment <table-uuid> --enrichment-id <TABLE-ENRICHMENT-ID>
|
|
|
157
162
|
NOTE: `run-enrichment` takes the TABLE-ENRICHMENT ID (from `add-enrichment` or
|
|
158
163
|
`table enrichments`), NOT the catalog enrichment ID. These are different numbers.
|
|
159
164
|
|
|
165
|
+
NOTE: `<table-uuid>` and `<task-id>` must be UUIDs
|
|
166
|
+
(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). Bad values fail client-side with
|
|
167
|
+
`code: validation` / exit 5 (not a 404). `--out` paths are resolved; null bytes
|
|
168
|
+
are rejected.
|
|
169
|
+
|
|
160
170
|
### Tasks
|
|
161
171
|
```bash
|
|
162
|
-
databar task get <task-id> --format json # check once
|
|
172
|
+
databar task get <task-id> --format json # check once; bulk tasks report progress
|
|
163
173
|
databar task get <task-id> --poll # poll until complete
|
|
174
|
+
databar task get <task-id> --partial # rows a running bulk task already finished
|
|
175
|
+
databar task cancel <task-id> # stop it; finished rows keep their results
|
|
164
176
|
```
|
|
165
177
|
|
|
166
178
|
---
|
|
@@ -202,6 +214,12 @@ resp = client.get_rows(table.identifier)
|
|
|
202
214
|
|
|
203
215
|
from databar import InsertRow
|
|
204
216
|
client.create_rows(table.identifier, [InsertRow(fields={"email": "alice@example.com"})])
|
|
217
|
+
|
|
218
|
+
# Tasks
|
|
219
|
+
task = client.get_task(task_id)
|
|
220
|
+
# task.progress → {"total", "completed", "failed", "processing"} while a bulk run is going
|
|
221
|
+
# client.get_task(task_id, include_partial=True).data → rows already finished
|
|
222
|
+
client.cancel_task(task_id) # stops it; poll_task then raises DatabarTaskCancelledError
|
|
205
223
|
```
|
|
206
224
|
|
|
207
225
|
---
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared output formatting helpers for the Databar CLI.
|
|
3
|
+
|
|
4
|
+
All CLI commands use these helpers to ensure consistent output.
|
|
5
|
+
Supports three output formats:
|
|
6
|
+
- table (default) — rich-rendered terminal table
|
|
7
|
+
- json — envelope JSON to stdout, pipe-friendly
|
|
8
|
+
- csv — CSV to stdout or --out file
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, NoReturn, Optional, Union
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
import typer
|
|
24
|
+
from rich import print_json as rich_print_json
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.markup import escape
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
|
|
30
|
+
from databar.exceptions import (
|
|
31
|
+
DatabarAuthError,
|
|
32
|
+
DatabarError,
|
|
33
|
+
DatabarGoneError,
|
|
34
|
+
DatabarInsufficientCreditsError,
|
|
35
|
+
DatabarNotFoundError,
|
|
36
|
+
DatabarRateLimitError,
|
|
37
|
+
DatabarTaskCancelledError,
|
|
38
|
+
DatabarTaskFailedError,
|
|
39
|
+
DatabarTimeoutError,
|
|
40
|
+
DatabarValidationError,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Canonical 8-4-4-4-12 only — uuid.UUID() also accepts braces / urn:uuid:.
|
|
44
|
+
_UUID_RE = re.compile(
|
|
45
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Force UTF-8 on stdout/stderr before any Console is created. On Windows, a
|
|
49
|
+
# redirected pipe uses the locale encoding (e.g. cp1251) and crashes on
|
|
50
|
+
# characters outside that codepage (DEV-5080).
|
|
51
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
52
|
+
if hasattr(_stream, "reconfigure"):
|
|
53
|
+
try:
|
|
54
|
+
_stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
console = Console()
|
|
59
|
+
# soft_wrap=True: don't hard-wrap mid-word (breaks naive stderr matching).
|
|
60
|
+
err_console = Console(stderr=True, soft_wrap=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OutputFormat(str, Enum):
|
|
64
|
+
TABLE = "table"
|
|
65
|
+
JSON = "json"
|
|
66
|
+
CSV = "csv"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Exit codes by error class (ticket DEV-5133).
|
|
70
|
+
_EXIT_USAGE = 2
|
|
71
|
+
_EXIT_AUTH = 3
|
|
72
|
+
_EXIT_NOT_FOUND = 4
|
|
73
|
+
_EXIT_VALIDATION = 5
|
|
74
|
+
_EXIT_GENERIC = 1
|
|
75
|
+
|
|
76
|
+
_HINTS = {
|
|
77
|
+
"auth_missing": "Run `databar login` or set the DATABAR_API_KEY environment variable.",
|
|
78
|
+
"auth_invalid": "Check your API key at databar.ai → Settings → API Keys.",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _current_format() -> OutputFormat:
|
|
83
|
+
"""Read --format from the active Click/Typer context, defaulting to table."""
|
|
84
|
+
ctx = click.get_current_context(silent=True)
|
|
85
|
+
if ctx is None:
|
|
86
|
+
return OutputFormat.TABLE
|
|
87
|
+
fmt = ctx.params.get("fmt")
|
|
88
|
+
if isinstance(fmt, OutputFormat):
|
|
89
|
+
return fmt
|
|
90
|
+
if isinstance(fmt, str):
|
|
91
|
+
try:
|
|
92
|
+
return OutputFormat(fmt)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return OutputFormat.TABLE
|
|
95
|
+
return OutputFormat.TABLE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _plain(message: str) -> str:
|
|
99
|
+
"""Strip Rich markup so JSON/stderr consumers get clean text."""
|
|
100
|
+
try:
|
|
101
|
+
return Text.from_markup(message).plain
|
|
102
|
+
except Exception:
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _classify(
|
|
107
|
+
err: Union[str, DatabarError, BaseException],
|
|
108
|
+
code: Optional[str],
|
|
109
|
+
) -> tuple[str, int, str, Optional[str]]:
|
|
110
|
+
"""Return (code, exit_code, message, hint)."""
|
|
111
|
+
if isinstance(err, DatabarError):
|
|
112
|
+
message = _plain(err.message if hasattr(err, "message") else str(err))
|
|
113
|
+
if code is None:
|
|
114
|
+
if isinstance(err, DatabarAuthError):
|
|
115
|
+
code = "auth_invalid"
|
|
116
|
+
elif isinstance(err, DatabarNotFoundError):
|
|
117
|
+
code = "not_found"
|
|
118
|
+
elif isinstance(err, DatabarValidationError):
|
|
119
|
+
code = "validation"
|
|
120
|
+
elif isinstance(err, DatabarRateLimitError):
|
|
121
|
+
code = "rate_limit"
|
|
122
|
+
elif isinstance(err, DatabarInsufficientCreditsError):
|
|
123
|
+
code = "insufficient_credits"
|
|
124
|
+
elif isinstance(err, DatabarGoneError):
|
|
125
|
+
code = "gone"
|
|
126
|
+
elif isinstance(err, DatabarTaskFailedError):
|
|
127
|
+
code = "task_failed"
|
|
128
|
+
elif isinstance(err, DatabarTaskCancelledError):
|
|
129
|
+
code = "task_cancelled"
|
|
130
|
+
elif isinstance(err, DatabarTimeoutError):
|
|
131
|
+
code = "timeout"
|
|
132
|
+
else:
|
|
133
|
+
code = "server_error"
|
|
134
|
+
else:
|
|
135
|
+
message = _plain(str(err))
|
|
136
|
+
if code is None:
|
|
137
|
+
code = "usage"
|
|
138
|
+
|
|
139
|
+
exit_map = {
|
|
140
|
+
"auth_missing": _EXIT_AUTH,
|
|
141
|
+
"auth_invalid": _EXIT_AUTH,
|
|
142
|
+
"not_found": _EXIT_NOT_FOUND,
|
|
143
|
+
"usage": _EXIT_USAGE,
|
|
144
|
+
"validation": _EXIT_VALIDATION,
|
|
145
|
+
}
|
|
146
|
+
exit_code = exit_map.get(code, _EXIT_GENERIC)
|
|
147
|
+
hint = _HINTS.get(code)
|
|
148
|
+
return code, exit_code, message, hint
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Core output functions
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _print_json(payload: Any) -> None:
|
|
157
|
+
"""Print a JSON payload to stdout (Rich-highlighted when on a TTY)."""
|
|
158
|
+
rich_print_json(json.dumps(payload, default=str))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def output_json(data: Any) -> None:
|
|
162
|
+
"""Print data as a success envelope: {"ok": true, "data": ...}."""
|
|
163
|
+
_print_json({"ok": True, "data": data})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def output_table(rows: list[dict], columns: list[str] | None = None) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Render a list of dicts as a rich table.
|
|
169
|
+
|
|
170
|
+
columns controls the column order/subset. If None, all keys from the
|
|
171
|
+
first row are used.
|
|
172
|
+
"""
|
|
173
|
+
if not rows:
|
|
174
|
+
console.print("[dim]No results.[/dim]")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
cols = columns or list(rows[0].keys())
|
|
178
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
179
|
+
for col in cols:
|
|
180
|
+
table.add_column(col)
|
|
181
|
+
|
|
182
|
+
for row in rows:
|
|
183
|
+
table.add_row(*[_cell(row.get(col)) for col in cols])
|
|
184
|
+
|
|
185
|
+
console.print(table)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def require_uuid(value: str, kind: str = "identifier") -> str:
|
|
189
|
+
"""Reject non-UUID path args before they hit the API (DEV-5135)."""
|
|
190
|
+
if not _UUID_RE.fullmatch(value):
|
|
191
|
+
error(
|
|
192
|
+
f"Invalid {kind}: {value!r}.",
|
|
193
|
+
code="validation",
|
|
194
|
+
hint="Expected a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).",
|
|
195
|
+
)
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def normalize_out(out: Union[str, Path]) -> Path:
|
|
200
|
+
"""Resolve --out; reject null bytes; note when the path leaves CWD."""
|
|
201
|
+
raw = out if isinstance(out, str) else os.fspath(out)
|
|
202
|
+
if "\0" in raw:
|
|
203
|
+
error("Invalid --out path: contains a null byte.", code="validation")
|
|
204
|
+
resolved = Path(raw).expanduser().resolve()
|
|
205
|
+
try:
|
|
206
|
+
resolved.relative_to(Path.cwd().resolve())
|
|
207
|
+
except ValueError:
|
|
208
|
+
info(f"Note: --out resolves outside the current directory: {resolved}")
|
|
209
|
+
return resolved
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def output_csv(
|
|
213
|
+
rows: list[dict],
|
|
214
|
+
columns: list[str] | None = None,
|
|
215
|
+
out: Union[str, Path] | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Write rows as CSV.
|
|
219
|
+
|
|
220
|
+
If out is given, writes to that file. Otherwise writes to stdout.
|
|
221
|
+
"""
|
|
222
|
+
if not rows:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
cols = columns or list(rows[0].keys())
|
|
226
|
+
if out is not None:
|
|
227
|
+
out = normalize_out(out)
|
|
228
|
+
dest = open(out, "w", newline="", encoding="utf-8") if out else sys.stdout
|
|
229
|
+
writer = csv.DictWriter(dest, fieldnames=cols, extrasaction="ignore")
|
|
230
|
+
writer.writeheader()
|
|
231
|
+
writer.writerows(rows)
|
|
232
|
+
if out and not dest.closed:
|
|
233
|
+
dest.close()
|
|
234
|
+
console.print(f"[green]Saved to {out}[/green]")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def output(
|
|
238
|
+
data: Any,
|
|
239
|
+
fmt: OutputFormat,
|
|
240
|
+
table_columns: list[str] | None = None,
|
|
241
|
+
out: Union[str, Path] | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""
|
|
244
|
+
Unified output dispatcher — routes to the right format handler.
|
|
245
|
+
|
|
246
|
+
data may be:
|
|
247
|
+
- a list of dicts → table/csv renders rows
|
|
248
|
+
- a dict → wrapped in a list for table, raw for json
|
|
249
|
+
- any other value → rendered as json
|
|
250
|
+
"""
|
|
251
|
+
if fmt == OutputFormat.JSON:
|
|
252
|
+
output_json(data)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
rows = _to_rows(data)
|
|
256
|
+
|
|
257
|
+
if fmt == OutputFormat.CSV:
|
|
258
|
+
output_csv(rows, columns=table_columns, out=out)
|
|
259
|
+
else:
|
|
260
|
+
output_table(rows, columns=table_columns)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def error(
|
|
264
|
+
message: Union[str, DatabarError, BaseException],
|
|
265
|
+
*,
|
|
266
|
+
code: Optional[str] = None,
|
|
267
|
+
hint: Optional[str] = None,
|
|
268
|
+
exit_code: Optional[int] = None,
|
|
269
|
+
) -> NoReturn:
|
|
270
|
+
"""
|
|
271
|
+
Emit an error and exit.
|
|
272
|
+
|
|
273
|
+
When --format json: write {"ok": false, "error": {...}} to stdout.
|
|
274
|
+
Otherwise: styled prose to stderr (no mid-word hard wrap).
|
|
275
|
+
"""
|
|
276
|
+
err_code, default_exit, plain_msg, default_hint = _classify(message, code)
|
|
277
|
+
final_hint = hint if hint is not None else default_hint
|
|
278
|
+
final_exit = exit_code if exit_code is not None else default_exit
|
|
279
|
+
|
|
280
|
+
if _current_format() == OutputFormat.JSON:
|
|
281
|
+
payload: dict[str, Any] = {
|
|
282
|
+
"ok": False,
|
|
283
|
+
"error": {"code": err_code, "message": plain_msg},
|
|
284
|
+
}
|
|
285
|
+
if final_hint:
|
|
286
|
+
payload["error"]["hint"] = final_hint
|
|
287
|
+
_print_json(payload)
|
|
288
|
+
else:
|
|
289
|
+
# Escape user text so accidental [bold] in messages isn't re-parsed;
|
|
290
|
+
# auth_missing still embeds intentional Rich markup in the source string.
|
|
291
|
+
if isinstance(message, str) and "[" in message and not isinstance(message, DatabarError):
|
|
292
|
+
# Keep intentional Rich markup from our own string literals.
|
|
293
|
+
err_console.print(f"[bold red]Error:[/bold red] {message}")
|
|
294
|
+
else:
|
|
295
|
+
err_console.print(f"[bold red]Error:[/bold red] {escape(plain_msg)}")
|
|
296
|
+
|
|
297
|
+
raise typer.Exit(code=final_exit)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def success(message: str) -> None:
|
|
301
|
+
"""Print a styled success message (no-op in JSON mode — keep stdout clean)."""
|
|
302
|
+
if _current_format() == OutputFormat.JSON:
|
|
303
|
+
return
|
|
304
|
+
console.print(f"[bold green]{message}[/bold green]")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def info(message: str) -> None:
|
|
308
|
+
"""Print a dim informational message (no-op in JSON mode)."""
|
|
309
|
+
if _current_format() == OutputFormat.JSON:
|
|
310
|
+
return
|
|
311
|
+
console.print(f"[dim]{message}[/dim]")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# Internal helpers
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cell(value: Any) -> str:
|
|
320
|
+
if value is None:
|
|
321
|
+
return ""
|
|
322
|
+
if isinstance(value, (dict, list)):
|
|
323
|
+
return json.dumps(value, default=str)
|
|
324
|
+
return str(value)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _to_rows(data: Any) -> list[dict]:
|
|
328
|
+
if isinstance(data, list):
|
|
329
|
+
return [r if isinstance(r, dict) else {"value": r} for r in data]
|
|
330
|
+
if isinstance(data, dict):
|
|
331
|
+
return [data]
|
|
332
|
+
return [{"value": str(data)}]
|
|
@@ -6,6 +6,8 @@ Registers all subcommand groups and exposes top-level login/whoami commands.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
10
|
+
|
|
9
11
|
import typer
|
|
10
12
|
|
|
11
13
|
from databar import __version__
|
|
@@ -21,6 +23,7 @@ app = typer.Typer(
|
|
|
21
23
|
),
|
|
22
24
|
no_args_is_help=True,
|
|
23
25
|
rich_markup_mode="rich",
|
|
26
|
+
pretty_exceptions_enable=os.environ.get("DATABAR_DEBUG") == "1",
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
# Top-level auth commands (login + whoami) live directly on the root app
|