dataplat 0.1.0__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.
- dataplat/__init__.py +3 -0
- dataplat/cli/__init__.py +1 -0
- dataplat/cli/_missing.py +108 -0
- dataplat/cli/bi/__init__.py +1 -0
- dataplat/cli/bi/app.py +14 -0
- dataplat/cli/bi/superset.py +558 -0
- dataplat/cli/ci/__init__.py +1 -0
- dataplat/cli/ci/app.py +14 -0
- dataplat/cli/ci/github/__init__.py +1 -0
- dataplat/cli/ci/github/app.py +10 -0
- dataplat/cli/ci/github/runner.py +399 -0
- dataplat/cli/cloud/__init__.py +1 -0
- dataplat/cli/cloud/app.py +14 -0
- dataplat/cli/cloud/aws/__init__.py +1 -0
- dataplat/cli/cloud/aws/_common.py +77 -0
- dataplat/cli/cloud/aws/app.py +12 -0
- dataplat/cli/cloud/aws/rds.py +686 -0
- dataplat/cli/cloud/aws/redshift.py +312 -0
- dataplat/cli/cloud/aws/secrets.py +875 -0
- dataplat/cli/config.py +492 -0
- dataplat/cli/db/__init__.py +342 -0
- dataplat/cli/db/_common.py +130 -0
- dataplat/cli/db/_report.py +180 -0
- dataplat/cli/db/dbt_orphans.py +842 -0
- dataplat/cli/db/describe.py +939 -0
- dataplat/cli/db/long_queries.py +299 -0
- dataplat/cli/db/role.py +434 -0
- dataplat/cli/db/role_create.py +452 -0
- dataplat/cli/db/role_drop.py +290 -0
- dataplat/cli/db/role_list.py +147 -0
- dataplat/cli/db/top_tables.py +337 -0
- dataplat/cli/ingest/__init__.py +1 -0
- dataplat/cli/ingest/airbyte/__init__.py +5 -0
- dataplat/cli/ingest/airbyte/_common.py +36 -0
- dataplat/cli/ingest/airbyte/_cursor.py +268 -0
- dataplat/cli/ingest/airbyte/_resource.py +192 -0
- dataplat/cli/ingest/airbyte/app.py +31 -0
- dataplat/cli/ingest/airbyte/connections.py +1234 -0
- dataplat/cli/ingest/airbyte/definitions.py +122 -0
- dataplat/cli/ingest/airbyte/destinations.py +26 -0
- dataplat/cli/ingest/airbyte/enums.py +45 -0
- dataplat/cli/ingest/airbyte/jobs.py +112 -0
- dataplat/cli/ingest/airbyte/sources.py +26 -0
- dataplat/cli/ingest/airbyte/tags.py +57 -0
- dataplat/cli/ingest/airbyte/templates.py +144 -0
- dataplat/cli/ingest/airbyte/tui.py +123 -0
- dataplat/cli/ingest/airbyte/workspaces.py +80 -0
- dataplat/cli/ingest/app.py +14 -0
- dataplat/cli/open.py +117 -0
- dataplat/cli/status.py +308 -0
- dataplat/core/__init__.py +1 -0
- dataplat/core/deps.py +138 -0
- dataplat/core/envrc.py +125 -0
- dataplat/core/errors.py +23 -0
- dataplat/main.py +87 -0
- dataplat/services/__init__.py +1 -0
- dataplat/services/airbyte/__init__.py +1 -0
- dataplat/services/airbyte/_resource.py +113 -0
- dataplat/services/airbyte/client.py +280 -0
- dataplat/services/airbyte/connections.py +306 -0
- dataplat/services/airbyte/definitions.py +64 -0
- dataplat/services/airbyte/destinations.py +68 -0
- dataplat/services/airbyte/jobs.py +72 -0
- dataplat/services/airbyte/sources.py +58 -0
- dataplat/services/airbyte/tags.py +120 -0
- dataplat/services/airbyte/workspaces.py +47 -0
- dataplat/services/aws/__init__.py +1 -0
- dataplat/services/aws/auth.py +80 -0
- dataplat/services/db/__init__.py +1 -0
- dataplat/services/db/connection.py +169 -0
- dataplat/services/db/describe.py +1034 -0
- dataplat/services/db/long_queries.py +378 -0
- dataplat/services/db/orphans.py +429 -0
- dataplat/services/db/role.py +812 -0
- dataplat/services/db/role_admin.py +458 -0
- dataplat/services/db/role_dialects.py +521 -0
- dataplat/services/db/targets.py +128 -0
- dataplat/services/db/top_tables.py +193 -0
- dataplat/services/superset/__init__.py +1 -0
- dataplat/services/superset/client.py +319 -0
- dataplat-0.1.0.dist-info/METADATA +326 -0
- dataplat-0.1.0.dist-info/RECORD +85 -0
- dataplat-0.1.0.dist-info/WHEEL +4 -0
- dataplat-0.1.0.dist-info/entry_points.txt +2 -0
- dataplat-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Database query CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from shutil import get_terminal_size
|
|
11
|
+
from time import perf_counter
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from psycopg.abc import Query
|
|
16
|
+
from rich import box
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from dataplat.cli.db._common import (
|
|
22
|
+
ConnCliParams,
|
|
23
|
+
DatabaseOption,
|
|
24
|
+
EngineOption,
|
|
25
|
+
EnvPrefixOption,
|
|
26
|
+
HostOption,
|
|
27
|
+
PasswordOption,
|
|
28
|
+
PortOption,
|
|
29
|
+
SslmodeOption,
|
|
30
|
+
TargetOption,
|
|
31
|
+
UserOption,
|
|
32
|
+
db_session,
|
|
33
|
+
resolve_params_or_exit,
|
|
34
|
+
)
|
|
35
|
+
from dataplat.cli.db.dbt_orphans import app as dbt_orphans_app
|
|
36
|
+
from dataplat.cli.db.describe import app as describe_app
|
|
37
|
+
from dataplat.cli.db.long_queries import kill_command, long_queries_command
|
|
38
|
+
from dataplat.cli.db.role import app as role_app
|
|
39
|
+
from dataplat.cli.db.top_tables import top_tables_command
|
|
40
|
+
from dataplat.services.db.connection import SqlEngine
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
name="db",
|
|
44
|
+
help="Database query commands",
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
)
|
|
47
|
+
app.add_typer(dbt_orphans_app, name="dbt-orphans")
|
|
48
|
+
app.add_typer(describe_app, name="describe")
|
|
49
|
+
app.add_typer(role_app, name="role")
|
|
50
|
+
app.command(
|
|
51
|
+
"top-tables",
|
|
52
|
+
help="Rank largest tables in schemas matching a prefix (default: dev_*).",
|
|
53
|
+
)(top_tables_command)
|
|
54
|
+
app.command(
|
|
55
|
+
"long-queries",
|
|
56
|
+
help="Show long-running (and recently failed) queries per target.",
|
|
57
|
+
)(long_queries_command)
|
|
58
|
+
app.command(
|
|
59
|
+
"kill",
|
|
60
|
+
help="Cancel or terminate running queries by PID.",
|
|
61
|
+
)(kill_command)
|
|
62
|
+
|
|
63
|
+
console = Console()
|
|
64
|
+
err_console = Console(stderr=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OutputFormat(str, Enum):
|
|
68
|
+
"""Result rendering for ``dp db query``."""
|
|
69
|
+
|
|
70
|
+
table = "table"
|
|
71
|
+
csv = "csv"
|
|
72
|
+
json = "json"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_SQL_COMMENT_RE = re.compile(r"(--[^\n]*)|(/\*.*?\*/)", re.DOTALL)
|
|
76
|
+
_WRITE_KEYWORD_RE = re.compile(r"\b(insert|update|delete|merge)\b", re.IGNORECASE)
|
|
77
|
+
_READ_FIRST_KEYWORDS = {"select", "show", "explain", "table", "values"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _strip_sql_comments(sql: str) -> str:
|
|
81
|
+
return _SQL_COMMENT_RE.sub(" ", sql)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _classify_sql(sql: str) -> str:
|
|
85
|
+
"""Classify a statement as ``read`` or ``write``.
|
|
86
|
+
|
|
87
|
+
Conservative: a WITH query containing any data-modifying keyword is
|
|
88
|
+
treated as a write even if the keyword only appears in a literal.
|
|
89
|
+
"""
|
|
90
|
+
cleaned = _strip_sql_comments(sql).strip()
|
|
91
|
+
if not cleaned:
|
|
92
|
+
return "read"
|
|
93
|
+
first = cleaned.split(None, 1)[0].lower().rstrip(";")
|
|
94
|
+
if first in _READ_FIRST_KEYWORDS:
|
|
95
|
+
return "read"
|
|
96
|
+
if first == "with":
|
|
97
|
+
return "write" if _WRITE_KEYWORD_RE.search(cleaned) else "read"
|
|
98
|
+
return "write"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _supports_live_query_progress() -> bool:
|
|
102
|
+
return isinstance(console, Console) and console.is_terminal and not console.is_dumb_terminal
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_sql(sql: str | None) -> str:
|
|
106
|
+
if sql and sql.strip():
|
|
107
|
+
return sql
|
|
108
|
+
if not sys.stdin.isatty():
|
|
109
|
+
return sys.stdin.read()
|
|
110
|
+
console.print(
|
|
111
|
+
"[red]Error: SQL is required. Provide it as an argument or via stdin.[/red]"
|
|
112
|
+
)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rows_fit_terminal(row_count: int) -> bool:
|
|
117
|
+
return row_count <= _max_rows_for_terminal()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _max_rows_for_terminal() -> int:
|
|
121
|
+
term_lines = get_terminal_size((120, 30)).lines
|
|
122
|
+
max_rows = term_lines - 6
|
|
123
|
+
return max(1, max_rows)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _default_page_limit() -> int:
|
|
127
|
+
return min(100, _max_rows_for_terminal())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_cell(value: object, max_length: int) -> str:
|
|
131
|
+
if value is None:
|
|
132
|
+
return ""
|
|
133
|
+
text = str(value)
|
|
134
|
+
if max_length > 0 and len(text) > max_length:
|
|
135
|
+
return text[: max_length - 1] + "…"
|
|
136
|
+
return text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _render_rows(columns: list[str], rows: list[tuple], start_index: int = 1) -> None:
|
|
140
|
+
display_rows = rows
|
|
141
|
+
if rows and not _rows_fit_terminal(len(rows)):
|
|
142
|
+
max_rows = _max_rows_for_terminal()
|
|
143
|
+
display_rows = rows[:max_rows]
|
|
144
|
+
console.print(
|
|
145
|
+
"[yellow]Result too large for terminal. "
|
|
146
|
+
f"Showing top {len(display_rows)} rows.[/yellow]"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
term_width = get_terminal_size((120, 30)).columns
|
|
150
|
+
available_width = max(40, term_width - 6)
|
|
151
|
+
per_col = max(12, min(40, available_width // max(1, len(columns) + 1)))
|
|
152
|
+
max_cell_length = max(40, per_col * 4)
|
|
153
|
+
|
|
154
|
+
table = Table(
|
|
155
|
+
show_header=True,
|
|
156
|
+
header_style="bold cyan",
|
|
157
|
+
box=box.SIMPLE,
|
|
158
|
+
show_lines=True,
|
|
159
|
+
)
|
|
160
|
+
table.add_column("#", style="dim", justify="right", no_wrap=True, width=4)
|
|
161
|
+
for column in columns:
|
|
162
|
+
table.add_column(column, overflow="fold", max_width=per_col)
|
|
163
|
+
|
|
164
|
+
for idx, row in enumerate(display_rows, start=start_index):
|
|
165
|
+
table.add_row(
|
|
166
|
+
str(idx), *[_format_cell(value, max_cell_length) for value in row]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
console.print(table)
|
|
170
|
+
console.print(f"[dim]Rows: {len(rows)}[/dim]")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _emit_csv(columns: list[str], rows: list[tuple]) -> None:
|
|
174
|
+
writer = csv.writer(sys.stdout)
|
|
175
|
+
writer.writerow(columns)
|
|
176
|
+
writer.writerows(rows)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _emit_json(columns: list[str], rows: list[tuple]) -> None:
|
|
180
|
+
payload = [dict(zip(columns, row, strict=False)) for row in rows]
|
|
181
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _confirm_write(sql_text: str, allow_write: bool) -> None:
|
|
185
|
+
"""Gate data-modifying statements behind --write or an interactive confirm."""
|
|
186
|
+
if allow_write:
|
|
187
|
+
return
|
|
188
|
+
preview = " ".join(sql_text.split())
|
|
189
|
+
if len(preview) > 120:
|
|
190
|
+
preview = preview[:119] + "…"
|
|
191
|
+
if sys.stdin.isatty():
|
|
192
|
+
console.print(f"[yellow]This statement can modify data:[/yellow] {preview}")
|
|
193
|
+
if typer.confirm("Continue?", default=False):
|
|
194
|
+
return
|
|
195
|
+
raise typer.Exit(code=1)
|
|
196
|
+
console.print(
|
|
197
|
+
"[red]Error: this statement can modify data. "
|
|
198
|
+
"Pass --write to run it non-interactively.[/red]"
|
|
199
|
+
)
|
|
200
|
+
raise typer.Exit(code=1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _execute_query(
|
|
204
|
+
*,
|
|
205
|
+
sql: str | None,
|
|
206
|
+
conn_cli: ConnCliParams,
|
|
207
|
+
limit: int | None,
|
|
208
|
+
page: int,
|
|
209
|
+
write: bool,
|
|
210
|
+
fmt: OutputFormat,
|
|
211
|
+
) -> None:
|
|
212
|
+
sql_text = _load_sql(sql)
|
|
213
|
+
if _classify_sql(sql_text) == "write":
|
|
214
|
+
_confirm_write(sql_text, write)
|
|
215
|
+
|
|
216
|
+
cleaned = sql_text.strip().rstrip(";")
|
|
217
|
+
stripped = _strip_sql_comments(cleaned).strip()
|
|
218
|
+
first_keyword = stripped.split(None, 1)[0].lower() if stripped else ""
|
|
219
|
+
is_select = first_keyword in {"select", "with"} and _classify_sql(cleaned) == "read"
|
|
220
|
+
|
|
221
|
+
# --limit 0 disables the LIMIT/OFFSET wrapper entirely.
|
|
222
|
+
paginate = is_select and (limit is None or limit > 0)
|
|
223
|
+
safe_limit = 1
|
|
224
|
+
offset = 0
|
|
225
|
+
if paginate:
|
|
226
|
+
effective_limit = limit if limit is not None else _default_page_limit()
|
|
227
|
+
safe_limit = max(1, effective_limit)
|
|
228
|
+
safe_page = max(1, page)
|
|
229
|
+
offset = (safe_page - 1) * safe_limit
|
|
230
|
+
# Newlines guard against a trailing `-- comment` swallowing the paren.
|
|
231
|
+
sql_text = (
|
|
232
|
+
f"SELECT * FROM (\n{cleaned}\n) AS dna_sql"
|
|
233
|
+
f" LIMIT {safe_limit + 1} OFFSET {offset}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
conn_params = resolve_params_or_exit(conn_cli)
|
|
237
|
+
# Decorative output goes to stderr for machine-readable formats.
|
|
238
|
+
note = console if fmt == OutputFormat.table else err_console
|
|
239
|
+
|
|
240
|
+
with db_session(conn_params) as conn, conn.cursor() as cursor:
|
|
241
|
+
started = perf_counter()
|
|
242
|
+
rows: list[tuple] = []
|
|
243
|
+
columns: list[str] = []
|
|
244
|
+
has_result_set = False
|
|
245
|
+
|
|
246
|
+
if _supports_live_query_progress():
|
|
247
|
+
with Progress(
|
|
248
|
+
SpinnerColumn(),
|
|
249
|
+
TextColumn("[cyan]Running query...[/cyan]"),
|
|
250
|
+
TimeElapsedColumn(),
|
|
251
|
+
console=console,
|
|
252
|
+
transient=True,
|
|
253
|
+
) as progress:
|
|
254
|
+
progress.add_task("query", total=None)
|
|
255
|
+
cursor.execute(cast(Query, sql_text))
|
|
256
|
+
if cursor.description:
|
|
257
|
+
has_result_set = True
|
|
258
|
+
rows = cursor.fetchall()
|
|
259
|
+
columns = [desc.name for desc in cursor.description]
|
|
260
|
+
else:
|
|
261
|
+
cursor.execute(cast(Query, sql_text))
|
|
262
|
+
if cursor.description:
|
|
263
|
+
has_result_set = True
|
|
264
|
+
rows = cursor.fetchall()
|
|
265
|
+
columns = [desc.name for desc in cursor.description]
|
|
266
|
+
|
|
267
|
+
if has_result_set:
|
|
268
|
+
visible = rows
|
|
269
|
+
more = False
|
|
270
|
+
if paginate and len(rows) > safe_limit:
|
|
271
|
+
visible = rows[:safe_limit]
|
|
272
|
+
more = True
|
|
273
|
+
if fmt == OutputFormat.csv:
|
|
274
|
+
_emit_csv(columns, visible)
|
|
275
|
+
elif fmt == OutputFormat.json:
|
|
276
|
+
_emit_json(columns, visible)
|
|
277
|
+
else:
|
|
278
|
+
start_index = offset + 1 if paginate else 1
|
|
279
|
+
_render_rows(columns, visible, start_index=start_index)
|
|
280
|
+
if more:
|
|
281
|
+
note.print(
|
|
282
|
+
"[yellow]More rows available. "
|
|
283
|
+
f"Use --page {page + 1} to fetch the next page.[/yellow]"
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
note.print(f"[green]✓ {cursor.rowcount} rows affected[/green]")
|
|
287
|
+
elapsed = perf_counter() - started
|
|
288
|
+
note.print(f"[dim]Execution time: {elapsed:.3f}s[/dim]")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command("query")
|
|
292
|
+
def query(
|
|
293
|
+
sql: str | None = typer.Argument(
|
|
294
|
+
None, help="SQL to execute. If omitted, SQL is read from stdin."
|
|
295
|
+
),
|
|
296
|
+
target: str | None = TargetOption,
|
|
297
|
+
engine: SqlEngine | None = EngineOption,
|
|
298
|
+
user: str | None = UserOption,
|
|
299
|
+
password: str | None = PasswordOption,
|
|
300
|
+
database: str | None = DatabaseOption,
|
|
301
|
+
host: str | None = HostOption,
|
|
302
|
+
port: int | None = PortOption,
|
|
303
|
+
sslmode: str | None = SslmodeOption,
|
|
304
|
+
env_prefix: str | None = EnvPrefixOption,
|
|
305
|
+
limit: int | None = typer.Option(
|
|
306
|
+
None,
|
|
307
|
+
"--limit",
|
|
308
|
+
"-n",
|
|
309
|
+
help=(
|
|
310
|
+
"Max rows per page for SELECT queries "
|
|
311
|
+
"(default: min(100, terminal height); 0 = no limit)"
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
page: int = typer.Option(1, "--page", help="Page number for SELECT queries"),
|
|
315
|
+
fmt: OutputFormat = typer.Option(
|
|
316
|
+
OutputFormat.table, "--format", help="Output format: table, csv, or json."
|
|
317
|
+
),
|
|
318
|
+
write: bool = typer.Option(
|
|
319
|
+
False,
|
|
320
|
+
"--write",
|
|
321
|
+
help="Allow statements that modify data without prompting.",
|
|
322
|
+
),
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Run ad-hoc SQL against Postgres or Redshift."""
|
|
325
|
+
_execute_query(
|
|
326
|
+
sql=sql,
|
|
327
|
+
conn_cli=ConnCliParams(
|
|
328
|
+
target=target,
|
|
329
|
+
engine=engine,
|
|
330
|
+
user=user,
|
|
331
|
+
password=password,
|
|
332
|
+
database=database,
|
|
333
|
+
host=host,
|
|
334
|
+
port=port,
|
|
335
|
+
sslmode=sslmode,
|
|
336
|
+
env_prefix=env_prefix,
|
|
337
|
+
),
|
|
338
|
+
limit=limit,
|
|
339
|
+
page=page,
|
|
340
|
+
write=write,
|
|
341
|
+
fmt=fmt,
|
|
342
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Shared Typer options and connection plumbing for db commands.
|
|
2
|
+
|
|
3
|
+
Every db command takes the same connection surface: a named ``--target``
|
|
4
|
+
plus low-level overrides. Declaring the options here keeps flags, shorts,
|
|
5
|
+
and help text identical across commands, and ``db_session`` centralizes the
|
|
6
|
+
resolve/connect/error-exit dance that used to be copy-pasted per command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import psycopg
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from dataplat.core.errors import DataplatError
|
|
21
|
+
from dataplat.services.db.connection import (
|
|
22
|
+
DbConnectionParams,
|
|
23
|
+
SqlEngine,
|
|
24
|
+
resolve_connection_params,
|
|
25
|
+
)
|
|
26
|
+
from dataplat.services.db.targets import default_target_name, resolve_target
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
TargetOption = typer.Option(
|
|
31
|
+
None,
|
|
32
|
+
"--target",
|
|
33
|
+
"-t",
|
|
34
|
+
help="Named DB target from DP_TARGETS (default: DP_DEFAULT_TARGET). "
|
|
35
|
+
"Sets engine and env prefix.",
|
|
36
|
+
)
|
|
37
|
+
EngineOption = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--engine",
|
|
40
|
+
"-e",
|
|
41
|
+
help="postgresql or redshift. Overrides the target/<PREFIX>_ENGINE default.",
|
|
42
|
+
)
|
|
43
|
+
UserOption = typer.Option(None, "--user", "-u", help="Database username.")
|
|
44
|
+
PasswordOption = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
"--password",
|
|
47
|
+
help="Database password (prefer <PREFIX>_PASSWORD env var).",
|
|
48
|
+
)
|
|
49
|
+
DatabaseOption = typer.Option(None, "--database", "-d", help="Database name.")
|
|
50
|
+
HostOption = typer.Option(None, "--host", "-H", help="Database host.")
|
|
51
|
+
PortOption = typer.Option(None, "--port", help="Database port.")
|
|
52
|
+
SslmodeOption = typer.Option(
|
|
53
|
+
None, "--sslmode", help="SSL mode (e.g., require, prefer, disable)."
|
|
54
|
+
)
|
|
55
|
+
EnvPrefixOption = typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
"--env-prefix",
|
|
58
|
+
help="Env var prefix for connection settings (default: the target's prefix).",
|
|
59
|
+
)
|
|
60
|
+
JsonOption = typer.Option(False, "--json", help="Emit JSON instead of tables.")
|
|
61
|
+
YesOption = typer.Option(
|
|
62
|
+
False, "--yes", "-y", help="Skip the confirmation prompt."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def limit_option(default: int, help_text: str) -> Any:
|
|
67
|
+
"""A per-command row cap with the shared --limit/-n spelling."""
|
|
68
|
+
return typer.Option(default, "--limit", "-n", help=help_text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ConnCliParams:
|
|
73
|
+
"""Raw connection-related CLI values, before resolution."""
|
|
74
|
+
|
|
75
|
+
target: str | None = None
|
|
76
|
+
engine: SqlEngine | None = None
|
|
77
|
+
user: str | None = None
|
|
78
|
+
password: str | None = None
|
|
79
|
+
database: str | None = None
|
|
80
|
+
host: str | None = None
|
|
81
|
+
port: int | None = None
|
|
82
|
+
sslmode: str | None = None
|
|
83
|
+
env_prefix: str | None = None
|
|
84
|
+
|
|
85
|
+
def resolve(self) -> DbConnectionParams:
|
|
86
|
+
"""Resolve to concrete connection params.
|
|
87
|
+
|
|
88
|
+
Precedence: explicit flags > target-derived defaults > env vars.
|
|
89
|
+
"""
|
|
90
|
+
env_prefix = self.env_prefix
|
|
91
|
+
engine = self.engine
|
|
92
|
+
target_name = self.target
|
|
93
|
+
if target_name is None and env_prefix is None:
|
|
94
|
+
target_name = default_target_name()
|
|
95
|
+
if target_name:
|
|
96
|
+
target = resolve_target(target_name)
|
|
97
|
+
if env_prefix is None:
|
|
98
|
+
env_prefix = target.env_prefix
|
|
99
|
+
if engine is None:
|
|
100
|
+
engine = target.engine
|
|
101
|
+
return resolve_connection_params(
|
|
102
|
+
engine=engine,
|
|
103
|
+
env_prefix=env_prefix,
|
|
104
|
+
user=self.user,
|
|
105
|
+
password=self.password,
|
|
106
|
+
host=self.host,
|
|
107
|
+
port=self.port,
|
|
108
|
+
database=self.database,
|
|
109
|
+
sslmode=self.sslmode,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_params_or_exit(params: ConnCliParams) -> DbConnectionParams:
|
|
114
|
+
"""Resolve connection params, printing a friendly error on failure."""
|
|
115
|
+
try:
|
|
116
|
+
return params.resolve()
|
|
117
|
+
except DataplatError as exc:
|
|
118
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
119
|
+
raise typer.Exit(code=1) from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@contextmanager
|
|
123
|
+
def db_session(params: DbConnectionParams) -> Iterator[psycopg.Connection]:
|
|
124
|
+
"""Open a connection; translate psycopg errors into a clean exit."""
|
|
125
|
+
try:
|
|
126
|
+
with psycopg.connect(**params.as_psycopg_kwargs()) as conn: # type: ignore[arg-type]
|
|
127
|
+
yield conn
|
|
128
|
+
except psycopg.Error as exc:
|
|
129
|
+
console.print(f"[red]Database error: {exc}[/red]")
|
|
130
|
+
raise typer.Exit(code=1) from exc
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Shared Rich rendering helpers for the db command group's report style.
|
|
2
|
+
|
|
3
|
+
Used by ``describe``, ``role``, and ``top-tables`` so all printed reports
|
|
4
|
+
share one visual language: numbered sections with captions, a rounded title
|
|
5
|
+
card, HORIZONTALS tables, and consistent size/row formatting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich import box as _box
|
|
14
|
+
from rich.align import Align
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.padding import Padding
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
DASH = "[dim]—[/dim]"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dim(text: str) -> str:
|
|
24
|
+
return f"[dim]{text}[/dim]"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def green(text: str) -> str:
|
|
28
|
+
return f"[green]{text}[/green]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fmt_size(n: int | None, *, colored: bool = True) -> str:
|
|
32
|
+
"""Human-friendly byte count; wrapped in [green] when colored."""
|
|
33
|
+
if n is None:
|
|
34
|
+
return DASH if colored else "—"
|
|
35
|
+
size = float(n)
|
|
36
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
37
|
+
if size < 1024:
|
|
38
|
+
text = f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
39
|
+
return green(text) if colored else text
|
|
40
|
+
size /= 1024
|
|
41
|
+
text = f"{size:.1f} PiB"
|
|
42
|
+
return green(text) if colored else text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fmt_size_plain(n: int | None) -> str:
|
|
46
|
+
"""Uncolored byte size — for use inside already-styled strings."""
|
|
47
|
+
return fmt_size(n, colored=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fmt_rows(n: int | None, *, colored: bool = True) -> str:
|
|
51
|
+
if n is None or n < 0:
|
|
52
|
+
return DASH if colored else "—"
|
|
53
|
+
text = f"{n:,}"
|
|
54
|
+
return green(text) if colored else text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def indent(renderable: Any, cols: int = 2) -> Padding:
|
|
58
|
+
"""Indent a renderable by the body gutter."""
|
|
59
|
+
return Padding(renderable, (0, 0, 0, cols))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SectionCounter:
|
|
63
|
+
"""Monotonically increasing section number, reset per report."""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("_n",)
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._n = 0
|
|
69
|
+
|
|
70
|
+
def next(self) -> int:
|
|
71
|
+
self._n += 1
|
|
72
|
+
return self._n
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def print_section_heading(
|
|
76
|
+
console: Console, counter: SectionCounter, title: str, caption: str
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Print ` N. Title` + caption below it, with the report spacing rules.
|
|
79
|
+
|
|
80
|
+
Caller is responsible for printing a blank line between caption and body
|
|
81
|
+
(the body itself may begin with a blank line if desired).
|
|
82
|
+
"""
|
|
83
|
+
n = counter.next()
|
|
84
|
+
if n > 1:
|
|
85
|
+
# Two blank lines between end-of-section body and next heading.
|
|
86
|
+
console.print()
|
|
87
|
+
console.print()
|
|
88
|
+
console.print(f" [bold cyan]{n}.[/bold cyan] [bold]{title}[/bold]")
|
|
89
|
+
console.print(f" [dim italic]{caption}[/dim italic]")
|
|
90
|
+
console.print()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def metadata_grid(pairs: Iterable[tuple[str, str]], *, two_column: bool) -> Table:
|
|
94
|
+
"""Label/value grid used inside the title card.
|
|
95
|
+
|
|
96
|
+
Labels are right-aligned and dim; values default color.
|
|
97
|
+
"""
|
|
98
|
+
grid = Table.grid(expand=False)
|
|
99
|
+
items = [(k, v) for k, v in pairs if v]
|
|
100
|
+
if not items:
|
|
101
|
+
grid.add_column()
|
|
102
|
+
return grid
|
|
103
|
+
|
|
104
|
+
if two_column and len(items) > 1:
|
|
105
|
+
# Two logical columns: [label, gap, value, wide gap, label, gap, value]
|
|
106
|
+
grid.add_column(justify="right", style="dim", no_wrap=True)
|
|
107
|
+
grid.add_column(width=2)
|
|
108
|
+
grid.add_column(justify="left")
|
|
109
|
+
grid.add_column(width=6)
|
|
110
|
+
grid.add_column(justify="right", style="dim", no_wrap=True)
|
|
111
|
+
grid.add_column(width=2)
|
|
112
|
+
grid.add_column(justify="left")
|
|
113
|
+
# Split items into left/right columns (roughly balanced).
|
|
114
|
+
mid = (len(items) + 1) // 2
|
|
115
|
+
left = items[:mid]
|
|
116
|
+
right = items[mid:]
|
|
117
|
+
for i in range(mid):
|
|
118
|
+
lk, lv = left[i]
|
|
119
|
+
if i < len(right):
|
|
120
|
+
rk, rv = right[i]
|
|
121
|
+
grid.add_row(lk, "", lv, "", rk, "", rv)
|
|
122
|
+
else:
|
|
123
|
+
grid.add_row(lk, "", lv, "", "", "", "")
|
|
124
|
+
else:
|
|
125
|
+
grid.add_column(justify="right", style="dim", no_wrap=True)
|
|
126
|
+
grid.add_column(width=2)
|
|
127
|
+
grid.add_column(justify="left")
|
|
128
|
+
for k, v in items:
|
|
129
|
+
grid.add_row(k, "", v)
|
|
130
|
+
return grid
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def title_card(
|
|
134
|
+
console: Console,
|
|
135
|
+
*,
|
|
136
|
+
title: str,
|
|
137
|
+
subtitle: str,
|
|
138
|
+
metadata: list[tuple[str, str]],
|
|
139
|
+
) -> Align | Panel:
|
|
140
|
+
"""Build the cover card renderable."""
|
|
141
|
+
width = console.size.width or 80
|
|
142
|
+
two_column = width >= 100
|
|
143
|
+
|
|
144
|
+
inner = Table.grid(expand=True)
|
|
145
|
+
inner.add_column(justify="center")
|
|
146
|
+
inner.add_row(f"[bold]{title}[/bold]")
|
|
147
|
+
inner.add_row(f"[dim italic]{subtitle}[/dim italic]")
|
|
148
|
+
if metadata:
|
|
149
|
+
inner.add_row("")
|
|
150
|
+
grid = metadata_grid(metadata, two_column=two_column)
|
|
151
|
+
inner.add_row(Align.center(grid))
|
|
152
|
+
|
|
153
|
+
panel_width: int | None = None
|
|
154
|
+
if width >= 120:
|
|
155
|
+
panel_width = 120
|
|
156
|
+
panel = Panel(
|
|
157
|
+
inner,
|
|
158
|
+
box=_box.ROUNDED,
|
|
159
|
+
border_style="cyan",
|
|
160
|
+
padding=(1, 4),
|
|
161
|
+
expand=(panel_width is None),
|
|
162
|
+
width=panel_width,
|
|
163
|
+
)
|
|
164
|
+
if panel_width is not None:
|
|
165
|
+
return Align.center(panel)
|
|
166
|
+
return panel
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def report_table(*, zebra: bool = False) -> Table:
|
|
170
|
+
"""Base Table configured with the report's paper feel."""
|
|
171
|
+
row_styles = ["", "on grey11"] if zebra else None
|
|
172
|
+
return Table(
|
|
173
|
+
box=_box.HORIZONTALS,
|
|
174
|
+
show_header=True,
|
|
175
|
+
header_style="bold",
|
|
176
|
+
padding=(0, 2),
|
|
177
|
+
pad_edge=False,
|
|
178
|
+
row_styles=row_styles,
|
|
179
|
+
show_edge=False,
|
|
180
|
+
)
|