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.
Files changed (32) hide show
  1. {databar-2.2.0/src/databar.egg-info → databar-2.5.0}/PKG-INFO +34 -5
  2. {databar-2.2.0 → databar-2.5.0}/README.md +33 -4
  3. {databar-2.2.0 → databar-2.5.0}/pyproject.toml +1 -1
  4. {databar-2.2.0 → databar-2.5.0}/src/databar/__init__.py +17 -1
  5. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_auth.py +3 -2
  6. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_guide.py +19 -1
  7. databar-2.5.0/src/databar/cli/_output.py +332 -0
  8. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/app.py +3 -0
  9. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/enrichments.py +18 -10
  10. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/flows.py +75 -4
  11. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/tables.py +118 -46
  12. databar-2.5.0/src/databar/cli/tasks.py +117 -0
  13. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/waterfalls.py +8 -6
  14. {databar-2.2.0 → databar-2.5.0}/src/databar/client.py +234 -15
  15. {databar-2.2.0 → databar-2.5.0}/src/databar/exceptions.py +29 -0
  16. {databar-2.2.0 → databar-2.5.0}/src/databar/models.py +106 -4
  17. {databar-2.2.0 → databar-2.5.0/src/databar.egg-info}/PKG-INFO +34 -5
  18. databar-2.5.0/tests/test_cli.py +719 -0
  19. {databar-2.2.0 → databar-2.5.0}/tests/test_client.py +129 -0
  20. {databar-2.2.0 → databar-2.5.0}/tests/test_new_features.py +60 -1
  21. databar-2.2.0/src/databar/cli/_output.py +0 -147
  22. databar-2.2.0/src/databar/cli/tasks.py +0 -60
  23. databar-2.2.0/tests/test_cli.py +0 -376
  24. {databar-2.2.0 → databar-2.5.0}/LICENSE +0 -0
  25. {databar-2.2.0 → databar-2.5.0}/setup.cfg +0 -0
  26. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/__init__.py +0 -0
  27. {databar-2.2.0 → databar-2.5.0}/src/databar/cli/_onboard.py +0 -0
  28. {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/SOURCES.txt +0 -0
  29. {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/dependency_links.txt +0 -0
  30. {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/entry_points.txt +0 -0
  31. {databar-2.2.0 → databar-2.5.0}/src/databar.egg-info/requires.txt +0 -0
  32. {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.2.0
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.2.0"
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.2.0"
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(str(e))
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