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.
Files changed (85) hide show
  1. dataplat/__init__.py +3 -0
  2. dataplat/cli/__init__.py +1 -0
  3. dataplat/cli/_missing.py +108 -0
  4. dataplat/cli/bi/__init__.py +1 -0
  5. dataplat/cli/bi/app.py +14 -0
  6. dataplat/cli/bi/superset.py +558 -0
  7. dataplat/cli/ci/__init__.py +1 -0
  8. dataplat/cli/ci/app.py +14 -0
  9. dataplat/cli/ci/github/__init__.py +1 -0
  10. dataplat/cli/ci/github/app.py +10 -0
  11. dataplat/cli/ci/github/runner.py +399 -0
  12. dataplat/cli/cloud/__init__.py +1 -0
  13. dataplat/cli/cloud/app.py +14 -0
  14. dataplat/cli/cloud/aws/__init__.py +1 -0
  15. dataplat/cli/cloud/aws/_common.py +77 -0
  16. dataplat/cli/cloud/aws/app.py +12 -0
  17. dataplat/cli/cloud/aws/rds.py +686 -0
  18. dataplat/cli/cloud/aws/redshift.py +312 -0
  19. dataplat/cli/cloud/aws/secrets.py +875 -0
  20. dataplat/cli/config.py +492 -0
  21. dataplat/cli/db/__init__.py +342 -0
  22. dataplat/cli/db/_common.py +130 -0
  23. dataplat/cli/db/_report.py +180 -0
  24. dataplat/cli/db/dbt_orphans.py +842 -0
  25. dataplat/cli/db/describe.py +939 -0
  26. dataplat/cli/db/long_queries.py +299 -0
  27. dataplat/cli/db/role.py +434 -0
  28. dataplat/cli/db/role_create.py +452 -0
  29. dataplat/cli/db/role_drop.py +290 -0
  30. dataplat/cli/db/role_list.py +147 -0
  31. dataplat/cli/db/top_tables.py +337 -0
  32. dataplat/cli/ingest/__init__.py +1 -0
  33. dataplat/cli/ingest/airbyte/__init__.py +5 -0
  34. dataplat/cli/ingest/airbyte/_common.py +36 -0
  35. dataplat/cli/ingest/airbyte/_cursor.py +268 -0
  36. dataplat/cli/ingest/airbyte/_resource.py +192 -0
  37. dataplat/cli/ingest/airbyte/app.py +31 -0
  38. dataplat/cli/ingest/airbyte/connections.py +1234 -0
  39. dataplat/cli/ingest/airbyte/definitions.py +122 -0
  40. dataplat/cli/ingest/airbyte/destinations.py +26 -0
  41. dataplat/cli/ingest/airbyte/enums.py +45 -0
  42. dataplat/cli/ingest/airbyte/jobs.py +112 -0
  43. dataplat/cli/ingest/airbyte/sources.py +26 -0
  44. dataplat/cli/ingest/airbyte/tags.py +57 -0
  45. dataplat/cli/ingest/airbyte/templates.py +144 -0
  46. dataplat/cli/ingest/airbyte/tui.py +123 -0
  47. dataplat/cli/ingest/airbyte/workspaces.py +80 -0
  48. dataplat/cli/ingest/app.py +14 -0
  49. dataplat/cli/open.py +117 -0
  50. dataplat/cli/status.py +308 -0
  51. dataplat/core/__init__.py +1 -0
  52. dataplat/core/deps.py +138 -0
  53. dataplat/core/envrc.py +125 -0
  54. dataplat/core/errors.py +23 -0
  55. dataplat/main.py +87 -0
  56. dataplat/services/__init__.py +1 -0
  57. dataplat/services/airbyte/__init__.py +1 -0
  58. dataplat/services/airbyte/_resource.py +113 -0
  59. dataplat/services/airbyte/client.py +280 -0
  60. dataplat/services/airbyte/connections.py +306 -0
  61. dataplat/services/airbyte/definitions.py +64 -0
  62. dataplat/services/airbyte/destinations.py +68 -0
  63. dataplat/services/airbyte/jobs.py +72 -0
  64. dataplat/services/airbyte/sources.py +58 -0
  65. dataplat/services/airbyte/tags.py +120 -0
  66. dataplat/services/airbyte/workspaces.py +47 -0
  67. dataplat/services/aws/__init__.py +1 -0
  68. dataplat/services/aws/auth.py +80 -0
  69. dataplat/services/db/__init__.py +1 -0
  70. dataplat/services/db/connection.py +169 -0
  71. dataplat/services/db/describe.py +1034 -0
  72. dataplat/services/db/long_queries.py +378 -0
  73. dataplat/services/db/orphans.py +429 -0
  74. dataplat/services/db/role.py +812 -0
  75. dataplat/services/db/role_admin.py +458 -0
  76. dataplat/services/db/role_dialects.py +521 -0
  77. dataplat/services/db/targets.py +128 -0
  78. dataplat/services/db/top_tables.py +193 -0
  79. dataplat/services/superset/__init__.py +1 -0
  80. dataplat/services/superset/client.py +319 -0
  81. dataplat-0.1.0.dist-info/METADATA +326 -0
  82. dataplat-0.1.0.dist-info/RECORD +85 -0
  83. dataplat-0.1.0.dist-info/WHEEL +4 -0
  84. dataplat-0.1.0.dist-info/entry_points.txt +2 -0
  85. 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
+ )