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.
- fabric_dw/__init__.py +5 -0
- fabric_dw/_version.py +24 -0
- fabric_dw/auth.py +112 -0
- fabric_dw/cache.py +259 -0
- fabric_dw/cli/__init__.py +10 -0
- fabric_dw/cli/_context.py +29 -0
- fabric_dw/cli/_main.py +82 -0
- fabric_dw/cli/_render.py +104 -0
- fabric_dw/cli/commands/__init__.py +0 -0
- fabric_dw/cli/commands/_utils.py +90 -0
- fabric_dw/cli/commands/audit.py +144 -0
- fabric_dw/cli/commands/cache.py +26 -0
- fabric_dw/cli/commands/completion.py +94 -0
- fabric_dw/cli/commands/config.py +120 -0
- fabric_dw/cli/commands/endpoints.py +112 -0
- fabric_dw/cli/commands/queries.py +109 -0
- fabric_dw/cli/commands/snapshots.py +228 -0
- fabric_dw/cli/commands/warehouses.py +236 -0
- fabric_dw/cli/commands/workspaces.py +126 -0
- fabric_dw/config.py +159 -0
- fabric_dw/exceptions.py +34 -0
- fabric_dw/http_client.py +337 -0
- fabric_dw/logging.py +90 -0
- fabric_dw/mcp/__init__.py +13 -0
- fabric_dw/mcp/server.py +602 -0
- fabric_dw/models.py +119 -0
- fabric_dw/py.typed +0 -0
- fabric_dw/resolver.py +241 -0
- fabric_dw/services/__init__.py +5 -0
- fabric_dw/services/audit.py +154 -0
- fabric_dw/services/ownership.py +40 -0
- fabric_dw/services/queries.py +121 -0
- fabric_dw/services/snapshots.py +311 -0
- fabric_dw/services/sql_endpoints.py +124 -0
- fabric_dw/services/warehouses.py +238 -0
- fabric_dw/services/workspaces.py +131 -0
- fabric_dw/sql.py +193 -0
- fabric_dw-0.1.dev77.dist-info/METADATA +143 -0
- fabric_dw-0.1.dev77.dist-info/RECORD +42 -0
- fabric_dw-0.1.dev77.dist-info/WHEEL +4 -0
- fabric_dw-0.1.dev77.dist-info/entry_points.txt +3 -0
- fabric_dw-0.1.dev77.dist-info/licenses/LICENSE +21 -0
fabric_dw/__init__.py
ADDED
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,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)
|
fabric_dw/cli/_render.py
ADDED
|
@@ -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
|