fabric-dw 0.1.dev77__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 (42) hide show
  1. fabric_dw/__init__.py +5 -0
  2. fabric_dw/_version.py +24 -0
  3. fabric_dw/auth.py +112 -0
  4. fabric_dw/cache.py +259 -0
  5. fabric_dw/cli/__init__.py +10 -0
  6. fabric_dw/cli/_context.py +29 -0
  7. fabric_dw/cli/_main.py +82 -0
  8. fabric_dw/cli/_render.py +104 -0
  9. fabric_dw/cli/commands/__init__.py +0 -0
  10. fabric_dw/cli/commands/_utils.py +90 -0
  11. fabric_dw/cli/commands/audit.py +144 -0
  12. fabric_dw/cli/commands/cache.py +26 -0
  13. fabric_dw/cli/commands/completion.py +94 -0
  14. fabric_dw/cli/commands/config.py +120 -0
  15. fabric_dw/cli/commands/endpoints.py +112 -0
  16. fabric_dw/cli/commands/queries.py +109 -0
  17. fabric_dw/cli/commands/snapshots.py +228 -0
  18. fabric_dw/cli/commands/warehouses.py +236 -0
  19. fabric_dw/cli/commands/workspaces.py +126 -0
  20. fabric_dw/config.py +159 -0
  21. fabric_dw/exceptions.py +34 -0
  22. fabric_dw/http_client.py +337 -0
  23. fabric_dw/logging.py +90 -0
  24. fabric_dw/mcp/__init__.py +13 -0
  25. fabric_dw/mcp/server.py +602 -0
  26. fabric_dw/models.py +119 -0
  27. fabric_dw/py.typed +0 -0
  28. fabric_dw/resolver.py +241 -0
  29. fabric_dw/services/__init__.py +5 -0
  30. fabric_dw/services/audit.py +154 -0
  31. fabric_dw/services/ownership.py +40 -0
  32. fabric_dw/services/queries.py +121 -0
  33. fabric_dw/services/snapshots.py +311 -0
  34. fabric_dw/services/sql_endpoints.py +124 -0
  35. fabric_dw/services/warehouses.py +238 -0
  36. fabric_dw/services/workspaces.py +131 -0
  37. fabric_dw/sql.py +193 -0
  38. fabric_dw-0.1.dev77.dist-info/METADATA +143 -0
  39. fabric_dw-0.1.dev77.dist-info/RECORD +42 -0
  40. fabric_dw-0.1.dev77.dist-info/WHEEL +4 -0
  41. fabric_dw-0.1.dev77.dist-info/entry_points.txt +3 -0
  42. fabric_dw-0.1.dev77.dist-info/licenses/LICENSE +21 -0
fabric_dw/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """fabric-dw — Python CLI + MCP server for Microsoft Fabric Data Warehouse."""
2
+
3
+ from fabric_dw._version import __version__
4
+
5
+ __all__ = ["__version__"]
fabric_dw/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.dev77'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev77')
23
+
24
+ __commit_id__ = commit_id = None
fabric_dw/auth.py ADDED
@@ -0,0 +1,112 @@
1
+ """Azure credential chain with persistent token cache."""
2
+
3
+ import asyncio
4
+ import os
5
+ from enum import StrEnum
6
+
7
+ from azure.core.credentials import AccessToken, TokenCredential
8
+ from azure.identity import (
9
+ ClientSecretCredential,
10
+ DefaultAzureCredential,
11
+ InteractiveBrowserCredential,
12
+ TokenCachePersistenceOptions,
13
+ )
14
+
15
+ from fabric_dw.exceptions import ConfigError
16
+
17
+ FABRIC_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
18
+ SQL_SCOPE = "https://database.windows.net/.default"
19
+
20
+ #: Shared multi-tenant Entra app for the interactive browser sign-in path.
21
+ #: Users on any tenant can sign in without registering their own app.
22
+ #: Override with the ``FABRIC_INTERACTIVE_CLIENT_ID`` environment variable.
23
+ DEFAULT_INTERACTIVE_CLIENT_ID = "f666e5ee-2149-4c6a-87eb-13c9e1fdc70d"
24
+
25
+ _CACHE_OPTIONS = TokenCachePersistenceOptions(name="fabric-dw", allow_unencrypted_storage=True)
26
+
27
+
28
+ class CredentialMode(StrEnum):
29
+ DEFAULT = "default"
30
+ SERVICE_PRINCIPAL = "sp"
31
+ INTERACTIVE = "interactive"
32
+
33
+
34
+ def _resolve_interactive_kwargs() -> dict[str, str]:
35
+ """Build keyword arguments for the interactive browser credential path.
36
+
37
+ Reads ``FABRIC_INTERACTIVE_CLIENT_ID`` (defaults to the shared app) and
38
+ ``FABRIC_INTERACTIVE_TENANT_ID`` (omitted when not set) from the environment.
39
+
40
+ Returns:
41
+ A dict with at least ``client_id`` and optionally ``tenant_id``.
42
+ """
43
+ kwargs: dict[str, str] = {
44
+ "client_id": os.environ.get("FABRIC_INTERACTIVE_CLIENT_ID", DEFAULT_INTERACTIVE_CLIENT_ID)
45
+ }
46
+ tenant = os.environ.get("FABRIC_INTERACTIVE_TENANT_ID")
47
+ if tenant:
48
+ kwargs["tenant_id"] = tenant
49
+ return kwargs
50
+
51
+
52
+ def get_credential(mode: CredentialMode = CredentialMode.DEFAULT) -> TokenCredential:
53
+ """Return an Azure credential for the given mode.
54
+
55
+ Args:
56
+ mode: The credential mode to use. Defaults to DEFAULT.
57
+
58
+ Returns:
59
+ A TokenCredential appropriate for the given mode.
60
+
61
+ Raises:
62
+ ConfigError: If mode is SERVICE_PRINCIPAL and any of AZURE_TENANT_ID,
63
+ AZURE_CLIENT_ID, or AZURE_CLIENT_SECRET are missing from the environment.
64
+ """
65
+ if mode == CredentialMode.DEFAULT:
66
+ interactive_kwargs = _resolve_interactive_kwargs()
67
+ dac_kwargs: dict[str, object] = {
68
+ "cache_persistence_options": _CACHE_OPTIONS,
69
+ "exclude_interactive_browser_credential": False,
70
+ "interactive_browser_client_id": interactive_kwargs["client_id"],
71
+ }
72
+ if "tenant_id" in interactive_kwargs:
73
+ dac_kwargs["interactive_browser_tenant_id"] = interactive_kwargs["tenant_id"]
74
+ return DefaultAzureCredential(**dac_kwargs)
75
+
76
+ if mode == CredentialMode.SERVICE_PRINCIPAL:
77
+ env_vars = {
78
+ "AZURE_TENANT_ID": os.environ.get("AZURE_TENANT_ID"),
79
+ "AZURE_CLIENT_ID": os.environ.get("AZURE_CLIENT_ID"),
80
+ "AZURE_CLIENT_SECRET": os.environ.get("AZURE_CLIENT_SECRET"),
81
+ }
82
+ missing = [name for name, value in env_vars.items() if not value]
83
+ if missing:
84
+ raise ConfigError.missing_env_vars(missing)
85
+
86
+ return ClientSecretCredential(
87
+ tenant_id=env_vars["AZURE_TENANT_ID"] or "",
88
+ client_id=env_vars["AZURE_CLIENT_ID"] or "",
89
+ client_secret=env_vars["AZURE_CLIENT_SECRET"] or "",
90
+ )
91
+
92
+ # CredentialMode.INTERACTIVE
93
+ return InteractiveBrowserCredential(
94
+ cache_persistence_options=_CACHE_OPTIONS,
95
+ **_resolve_interactive_kwargs(),
96
+ )
97
+
98
+
99
+ async def get_token(credential: TokenCredential, scope: str) -> AccessToken:
100
+ """Retrieve an access token from the credential asynchronously.
101
+
102
+ Wraps the synchronous ``credential.get_token`` call in ``asyncio.to_thread``
103
+ so it does not block the event loop.
104
+
105
+ Args:
106
+ credential: The Azure credential to use.
107
+ scope: The OAuth2 scope to request a token for.
108
+
109
+ Returns:
110
+ The raw AccessToken returned by the credential.
111
+ """
112
+ return await asyncio.to_thread(credential.get_token, scope)
fabric_dw/cache.py ADDED
@@ -0,0 +1,259 @@
1
+ """Persistent 24-hour filesystem name<->ID lookup cache.
2
+
3
+ Stores workspace and item (Warehouse / SQLEndpoint / WarehouseSnapshot)
4
+ name-to-UUID mappings in a single JSON file, protected by a FileLock so
5
+ multiple concurrent CLI or MCP processes can share the cache safely.
6
+
7
+ JSON shape::
8
+
9
+ {
10
+ "version": 1,
11
+ "workspaces": {
12
+ "<name_lower>": {"id": "<guid>", "fetched_at": "<iso8601>"}
13
+ },
14
+ "items": {
15
+ "<ws_uuid>": {
16
+ "<name_lower_or_guid_lower>": {
17
+ "id": "<guid>",
18
+ "kind": "<WarehouseKind>",
19
+ "connection_string": "<str | null>",
20
+ "fetched_at": "<iso8601>"
21
+ }
22
+ }
23
+ }
24
+ }
25
+
26
+ Names are stripped of leading/trailing whitespace and lower-cased at the
27
+ cache boundary. GUID keys are stored lower-cased (the canonical UUID
28
+ string form is lower-case hex with hyphens).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import json
35
+ import logging
36
+ import os
37
+ import tempfile
38
+ from dataclasses import dataclass
39
+ from datetime import UTC, datetime, timedelta
40
+ from pathlib import Path
41
+ from typing import Any
42
+ from uuid import UUID
43
+
44
+ import filelock
45
+
46
+ from fabric_dw.models import WarehouseKind
47
+
48
+ __all__ = [
49
+ "ItemEntry",
50
+ "LookupCache",
51
+ "WorkspaceEntry",
52
+ ]
53
+
54
+ _log = logging.getLogger(__name__)
55
+
56
+ _SCHEMA_VERSION = 1
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class WorkspaceEntry:
61
+ """A cached workspace name→UUID mapping."""
62
+
63
+ id: UUID
64
+ fetched_at: datetime
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ItemEntry:
69
+ """A cached item (Warehouse / SQLEndpoint / Snapshot) name→detail mapping."""
70
+
71
+ id: UUID
72
+ kind: WarehouseKind
73
+ connection_string: str | None
74
+ fetched_at: datetime
75
+ display_name: str = ""
76
+
77
+
78
+ class LookupCache:
79
+ """Persistent filesystem name<->UUID cache with TTL and file locking.
80
+
81
+ All name keys are normalised to lower-case at read and write time so
82
+ that lookups are case-insensitive.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ path: Path | None = None,
88
+ ttl: timedelta = timedelta(hours=24),
89
+ ) -> None:
90
+ if path is None:
91
+ xdg = os.environ.get("XDG_CACHE_HOME")
92
+ base = Path(xdg) if xdg else Path.home() / ".cache"
93
+ path = base / "fabric-dw" / "lookup.json"
94
+ self._path = path
95
+ self._ttl = ttl
96
+ self._lock = filelock.FileLock(str(path) + ".lock", timeout=5)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Internal helpers
100
+ # ------------------------------------------------------------------
101
+
102
+ @staticmethod
103
+ def _empty() -> dict[str, Any]:
104
+ """Return a fresh empty cache skeleton (never share the same inner dicts)."""
105
+ return {"version": _SCHEMA_VERSION, "workspaces": {}, "items": {}}
106
+
107
+ def _read(self) -> dict[str, Any]:
108
+ """Read and parse the cache file; return empty skeleton on missing/corrupt."""
109
+ if not self._path.exists():
110
+ return self._empty()
111
+ try:
112
+ raw = self._path.read_text(encoding="utf-8")
113
+ data: dict[str, Any] = json.loads(raw)
114
+ except Exception:
115
+ _log.warning("Cache file %s is missing or corrupt; treating as empty", self._path)
116
+ return self._empty()
117
+ else:
118
+ if not isinstance(data, dict):
119
+ _log.warning("Cache file %s has unexpected shape; treating as empty", self._path)
120
+ return self._empty()
121
+ return data
122
+
123
+ def _write(self, data: dict[str, Any]) -> None:
124
+ """Atomically write *data* to the cache file, creating parent dirs as needed.
125
+
126
+ Uses a temp file + os.replace() so readers always see either the old or
127
+ the new complete file — a crash during write can never leave a truncated
128
+ or partially-written JSON file on disk.
129
+ """
130
+ self._path.parent.mkdir(parents=True, exist_ok=True)
131
+ fd, tmp_name = tempfile.mkstemp(
132
+ dir=self._path.parent,
133
+ prefix=".lookup_tmp_",
134
+ suffix=".json",
135
+ )
136
+ try:
137
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
138
+ fh.write(json.dumps(data, indent=None))
139
+ os.replace(tmp_name, self._path)
140
+ except Exception:
141
+ with contextlib.suppress(OSError):
142
+ os.unlink(tmp_name)
143
+ raise
144
+
145
+ def _is_fresh(self, fetched_at_str: str) -> bool:
146
+ """Return True when *fetched_at_str* is within the TTL window."""
147
+ try:
148
+ fetched_at = datetime.fromisoformat(fetched_at_str)
149
+ except (ValueError, TypeError):
150
+ return False
151
+ now = datetime.now(tz=UTC)
152
+ # Handle naive datetimes by assuming UTC
153
+ if fetched_at.tzinfo is None:
154
+ fetched_at = fetched_at.replace(tzinfo=UTC)
155
+ return (now - fetched_at) < self._ttl
156
+
157
+ # ------------------------------------------------------------------
158
+ # Public API
159
+ # ------------------------------------------------------------------
160
+
161
+ def get_workspace(self, name: str) -> WorkspaceEntry | None:
162
+ """Return a fresh cached workspace entry or *None* on miss/expiry."""
163
+ with self._lock:
164
+ data = self._read()
165
+ workspaces: dict[str, Any] = data.get("workspaces", {})
166
+ record = workspaces.get(name.strip().lower())
167
+ if not isinstance(record, dict):
168
+ return None
169
+ if not self._is_fresh(record.get("fetched_at", "")):
170
+ return None
171
+ try:
172
+ return WorkspaceEntry(
173
+ id=UUID(record["id"]),
174
+ fetched_at=datetime.fromisoformat(record["fetched_at"]),
175
+ )
176
+ except (KeyError, ValueError, TypeError):
177
+ return None
178
+
179
+ def put_workspace(self, name: str, id: UUID) -> None:
180
+ """Store a workspace name→UUID mapping with the current timestamp."""
181
+ key = name.strip().lower()
182
+ with self._lock:
183
+ data = self._read()
184
+ workspaces: dict[str, Any] = data.setdefault("workspaces", {})
185
+ workspaces[key] = {
186
+ "id": str(id),
187
+ "fetched_at": datetime.now(tz=UTC).isoformat(),
188
+ }
189
+ self._write(data)
190
+
191
+ def get_item(self, workspace_id: UUID, name: str) -> ItemEntry | None:
192
+ """Return a fresh cached item entry or *None* on miss/expiry.
193
+
194
+ *name* may be a display name or a GUID string; both are normalised to
195
+ lower-case (and stripped) before lookup so the same key is found
196
+ regardless of how the entry was stored.
197
+ """
198
+ with self._lock:
199
+ data = self._read()
200
+ items: dict[str, Any] = data.get("items", {})
201
+ ws_items: dict[str, Any] = items.get(str(workspace_id), {})
202
+ record = ws_items.get(name.strip().lower())
203
+ if not isinstance(record, dict):
204
+ return None
205
+ if not self._is_fresh(record.get("fetched_at", "")):
206
+ return None
207
+ try:
208
+ conn = record.get("connection_string")
209
+ dn = record.get("display_name", "")
210
+ return ItemEntry(
211
+ id=UUID(record["id"]),
212
+ kind=WarehouseKind(record["kind"]),
213
+ connection_string=conn if isinstance(conn, str) else None,
214
+ fetched_at=datetime.fromisoformat(record["fetched_at"]),
215
+ display_name=dn if isinstance(dn, str) else "",
216
+ )
217
+ except (KeyError, ValueError, TypeError):
218
+ return None
219
+
220
+ def put_item(self, workspace_id: UUID, name: str, entry: ItemEntry) -> None:
221
+ """Store an item entry under *workspace_id* / *name*.
222
+
223
+ *name* is stripped and lower-cased. Pass either the display name or
224
+ the GUID string to store an alias entry under the GUID key.
225
+ """
226
+ key = name.strip().lower()
227
+ with self._lock:
228
+ data = self._read()
229
+ items: dict[str, Any] = data.setdefault("items", {})
230
+ ws_items: dict[str, Any] = items.setdefault(str(workspace_id), {})
231
+ ws_items[key] = {
232
+ "id": str(entry.id),
233
+ "kind": str(entry.kind),
234
+ "connection_string": entry.connection_string,
235
+ "fetched_at": entry.fetched_at.isoformat(),
236
+ "display_name": entry.display_name,
237
+ }
238
+ self._write(data)
239
+
240
+ def clear(self) -> None:
241
+ """Erase all cached entries by writing an empty skeleton file."""
242
+ with self._lock:
243
+ self._path.parent.mkdir(parents=True, exist_ok=True)
244
+ self._write(self._empty())
245
+
246
+ def invalidate_workspace(self, workspace_id: UUID) -> None:
247
+ """Remove *workspace_id* and all items cached under it."""
248
+ ws_id_str = str(workspace_id)
249
+ with self._lock:
250
+ data = self._read()
251
+ # Remove workspace entry whose id field matches
252
+ workspaces: dict[str, Any] = data.get("workspaces", {})
253
+ to_remove = [k for k, v in workspaces.items() if v.get("id") == ws_id_str]
254
+ for key in to_remove:
255
+ del workspaces[key]
256
+ # Drop all items under this workspace
257
+ items: dict[str, Any] = data.get("items", {})
258
+ items.pop(ws_id_str, None)
259
+ self._write(data)
@@ -0,0 +1,10 @@
1
+ """CLI entrypoint for fabric-dw."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fabric_dw.cli._main import cli
6
+
7
+
8
+ def main() -> None:
9
+ """Entrypoint registered in pyproject.toml [project.scripts]."""
10
+ cli()
@@ -0,0 +1,29 @@
1
+ """Shared CLI context dataclass passed to all commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from fabric_dw.auth import CredentialMode
8
+ from fabric_dw.config import UserConfig, load_config
9
+
10
+
11
+ @dataclass
12
+ class CliContext:
13
+ """Carries parsed global options and lazily-constructed service clients.
14
+
15
+ Passed through Click's ``ctx.obj`` to every sub-command.
16
+ """
17
+
18
+ json_output: bool = False
19
+ yes: bool = False
20
+ auth: CredentialMode = field(default_factory=lambda: CredentialMode.DEFAULT)
21
+ verbose: bool = False
22
+ _config: UserConfig | None = field(default=None, repr=False, compare=False)
23
+
24
+ @property
25
+ def config(self) -> UserConfig:
26
+ """Lazily load the user config from disk on first access."""
27
+ if self._config is None:
28
+ self._config = load_config()
29
+ return self._config
fabric_dw/cli/_main.py ADDED
@@ -0,0 +1,82 @@
1
+ """Click group definition for the fabric-dw CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import click
8
+
9
+ from fabric_dw.auth import CredentialMode
10
+ from fabric_dw.cli._context import CliContext
11
+ from fabric_dw.cli.commands.audit import audit_group
12
+ from fabric_dw.cli.commands.cache import cache_group
13
+ from fabric_dw.cli.commands.completion import completion_group
14
+ from fabric_dw.cli.commands.config import config_group
15
+ from fabric_dw.cli.commands.endpoints import endpoints_group
16
+ from fabric_dw.cli.commands.queries import queries_group
17
+ from fabric_dw.cli.commands.snapshots import snapshots_group
18
+ from fabric_dw.cli.commands.warehouses import warehouses_group
19
+ from fabric_dw.cli.commands.workspaces import workspaces_group
20
+ from fabric_dw.logging import setup_logging
21
+
22
+
23
+ @click.group(invoke_without_command=False)
24
+ @click.option(
25
+ "--json",
26
+ "json_output",
27
+ is_flag=True,
28
+ default=False,
29
+ help="Emit machine-readable JSON instead of Rich tables.",
30
+ )
31
+ @click.option(
32
+ "--auth",
33
+ "auth_mode",
34
+ type=click.Choice([m.value for m in CredentialMode], case_sensitive=False),
35
+ default=CredentialMode.DEFAULT.value,
36
+ show_default=True,
37
+ help="Authentication mode.",
38
+ )
39
+ @click.option(
40
+ "--yes",
41
+ "-y",
42
+ "yes",
43
+ is_flag=True,
44
+ default=False,
45
+ help="Skip confirmation prompts.",
46
+ )
47
+ @click.option(
48
+ "--verbose",
49
+ "-v",
50
+ "verbose",
51
+ is_flag=True,
52
+ default=False,
53
+ help="Enable debug logging.",
54
+ )
55
+ @click.pass_context
56
+ def cli(
57
+ ctx: click.Context,
58
+ json_output: bool,
59
+ auth_mode: str,
60
+ yes: bool,
61
+ verbose: bool,
62
+ ) -> None:
63
+ """Microsoft Fabric Data Warehouse CLI."""
64
+ setup_logging(logging.DEBUG if verbose else logging.INFO)
65
+
66
+ ctx.obj = CliContext(
67
+ json_output=json_output,
68
+ yes=yes,
69
+ auth=CredentialMode(auth_mode),
70
+ verbose=verbose,
71
+ )
72
+
73
+
74
+ cli.add_command(cache_group)
75
+ cli.add_command(completion_group)
76
+ cli.add_command(config_group)
77
+ cli.add_command(workspaces_group)
78
+ cli.add_command(warehouses_group)
79
+ cli.add_command(endpoints_group)
80
+ cli.add_command(audit_group)
81
+ cli.add_command(queries_group)
82
+ cli.add_command(snapshots_group)
@@ -0,0 +1,104 @@
1
+ """Rich + JSON rendering helpers for CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ __all__ = [
13
+ "confirm",
14
+ "render",
15
+ ]
16
+
17
+ _DEFAULT_CONSOLE = Console()
18
+
19
+
20
+ def render(
21
+ data: object,
22
+ *,
23
+ json_output: bool,
24
+ console: Console | None = None,
25
+ table_title: str | None = None,
26
+ ) -> None:
27
+ """Print *data* to stdout using JSON or Rich formatting.
28
+
29
+ Args:
30
+ data: The data to render. Supported shapes:
31
+ - ``list[dict]`` → Rich Table (or JSON array).
32
+ - ``dict`` → Rich Panel (or JSON object).
33
+ - primitives → ``repr()`` string (or JSON scalar).
34
+ json_output: When *True*, emit indented JSON via ``click.echo``.
35
+ When *False*, use Rich for human-friendly output.
36
+ console: Optional Rich Console instance. When *None* the module-level
37
+ default console (stdout) is used. Ignored when *json_output=True*.
38
+ table_title: Optional title shown above the Rich Table.
39
+ Ignored when *json_output=True* or when *data* is not a list.
40
+ """
41
+ if json_output:
42
+ click.echo(_json.dumps(data, indent=2, default=str))
43
+ return
44
+
45
+ con = console if console is not None else _DEFAULT_CONSOLE
46
+
47
+ if isinstance(data, list):
48
+ _render_table(data, console=con, title=table_title)
49
+ elif isinstance(data, dict):
50
+ _render_panel(data, console=con, title=table_title)
51
+ else:
52
+ click.echo(repr(data))
53
+
54
+
55
+ def _render_table(rows: list[object], *, console: Console, title: str | None) -> None:
56
+ """Render a list of dicts as a Rich Table."""
57
+ table = Table(title=title, show_header=True, header_style="bold")
58
+
59
+ if not rows:
60
+ console.print(table)
61
+ return
62
+
63
+ # Collect all column names in insertion order (union of all keys)
64
+ columns: list[str] = []
65
+ seen: set[str] = set()
66
+ for row in rows:
67
+ if isinstance(row, dict):
68
+ for key in row:
69
+ if key not in seen:
70
+ columns.append(str(key))
71
+ seen.add(key)
72
+
73
+ for col in columns:
74
+ table.add_column(col)
75
+
76
+ for row in rows:
77
+ if isinstance(row, dict):
78
+ table.add_row(*[str(row.get(col, "")) for col in columns])
79
+ else:
80
+ table.add_row(str(row))
81
+
82
+ console.print(table)
83
+
84
+
85
+ def _render_panel(data: dict[str, object], *, console: Console, title: str | None) -> None:
86
+ """Render a single dict as a Rich Panel with key: value lines."""
87
+ lines = "\n".join(f"[bold]{k}[/bold]: {v}" for k, v in data.items())
88
+ panel = Panel(lines, title=title)
89
+ console.print(panel)
90
+
91
+
92
+ def confirm(message: str, *, yes: bool) -> bool:
93
+ """Ask the user for confirmation, skipping the prompt when *yes=True*.
94
+
95
+ Args:
96
+ message: The confirmation message shown to the user.
97
+ yes: When *True*, return ``True`` immediately without prompting.
98
+
99
+ Returns:
100
+ ``True`` if the action should proceed, ``False`` otherwise.
101
+ """
102
+ if yes:
103
+ return True
104
+ return click.confirm(message, default=False)
File without changes