pbi-enterprise-cli 0.1.0.dev0__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.
- pbi_cli/__init__.py +3 -0
- pbi_cli/_audit.py +57 -0
- pbi_cli/_snapshot.py +95 -0
- pbi_cli/backends/__init__.py +1 -0
- pbi_cli/backends/mock_backend.py +323 -0
- pbi_cli/backends/pbir_backend.py +813 -0
- pbi_cli/backends/protocol.py +52 -0
- pbi_cli/backends/tom_backend.py +650 -0
- pbi_cli/backends/xmla_backend.py +627 -0
- pbi_cli/cli.py +332 -0
- pbi_cli/commands/__init__.py +1 -0
- pbi_cli/commands/_doctor.py +84 -0
- pbi_cli/commands/_shared.py +88 -0
- pbi_cli/commands/calendar_cmd.py +186 -0
- pbi_cli/commands/connections.py +153 -0
- pbi_cli/commands/custom_visual.py +325 -0
- pbi_cli/commands/database.py +76 -0
- pbi_cli/commands/dax.py +174 -0
- pbi_cli/commands/deploy.py +193 -0
- pbi_cli/commands/docs.py +57 -0
- pbi_cli/commands/filter_cmd.py +235 -0
- pbi_cli/commands/govern.py +124 -0
- pbi_cli/commands/layout.py +104 -0
- pbi_cli/commands/measure.py +185 -0
- pbi_cli/commands/model.py +499 -0
- pbi_cli/commands/partition.py +89 -0
- pbi_cli/commands/repl.py +209 -0
- pbi_cli/commands/report.py +561 -0
- pbi_cli/commands/security.py +90 -0
- pbi_cli/commands/server_cmd.py +30 -0
- pbi_cli/commands/skills_cmd.py +168 -0
- pbi_cli/commands/source.py +581 -0
- pbi_cli/commands/theme.py +60 -0
- pbi_cli/commands/trace.py +142 -0
- pbi_cli/commands/visual.py +507 -0
- pbi_cli/commands/watch.py +145 -0
- pbi_cli/docs_gen/__init__.py +1 -0
- pbi_cli/docs_gen/confluence.py +24 -0
- pbi_cli/docs_gen/markdown.py +36 -0
- pbi_cli/governance/__init__.py +1 -0
- pbi_cli/governance/engine.py +70 -0
- pbi_cli/governance/rules/__init__.py +85 -0
- pbi_cli/governance/rules/measure_brackets.py +27 -0
- pbi_cli/governance/rules/measure_description.py +41 -0
- pbi_cli/governance/rules/measure_format.py +38 -0
- pbi_cli/governance/rules/measure_naming.py +93 -0
- pbi_cli/governance/rules/table_pascal_case.py +44 -0
- pbi_cli/intelligence/__init__.py +1 -0
- pbi_cli/intelligence/layout_engine.py +192 -0
- pbi_cli/intelligence/measure_generator.py +40 -0
- pbi_cli/intelligence/theme_generator.py +193 -0
- pbi_cli/intelligence/visual_builder.py +429 -0
- pbi_cli/intelligence/visual_recommender.py +42 -0
- pbi_cli/server/__init__.py +1 -0
- pbi_cli/server/api.py +185 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""XMLA backend — connects to Power BI Service/Fabric via XMLA over HTTPS.
|
|
2
|
+
|
|
3
|
+
Connection string format (Power BI Premium / Fabric):
|
|
4
|
+
powerbi://api.powerbi.com/v1.0/myorg/<WorkspaceName>
|
|
5
|
+
|
|
6
|
+
Authentication modes
|
|
7
|
+
--------------------
|
|
8
|
+
``device_flow`` Interactive browser login via MSAL device-code flow.
|
|
9
|
+
``service_principal`` Non-interactive; requires ``client_id``, ``client_secret``,
|
|
10
|
+
and ``tenant_id``.
|
|
11
|
+
``token`` Pass a pre-acquired Bearer token directly.
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
from pbi_cli.backends.xmla_backend import XmlaBackend
|
|
16
|
+
b = XmlaBackend()
|
|
17
|
+
b.connect(
|
|
18
|
+
"powerbi://api.powerbi.com/v1.0/myorg/MySales",
|
|
19
|
+
catalog="MySalesDataset",
|
|
20
|
+
auth_mode="service_principal",
|
|
21
|
+
client_id="...", client_secret="...", tenant_id="...",
|
|
22
|
+
)
|
|
23
|
+
print(b.table_list())
|
|
24
|
+
|
|
25
|
+
Requires ``pythonnet`` (in core deps) plus ``msal`` for token-based auth.
|
|
26
|
+
Install the optional extra for the full stack::
|
|
27
|
+
|
|
28
|
+
pip install pbi-enterprise-cli[xmla]
|
|
29
|
+
|
|
30
|
+
The .NET AMO/ADOMD assemblies ship with Power BI Desktop (Windows) or can be
|
|
31
|
+
installed via the NuGet packages ``Microsoft.AnalysisServices.retail.amd64`` and
|
|
32
|
+
``Microsoft.AnalysisServices.AdomdClient.retail.amd64``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import threading
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Connection pool — keyed by (data_source, catalog). Protected by _pool_lock.
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
_pool_lock = threading.Lock()
|
|
44
|
+
_connection_pool: dict[tuple[str, str], Any] = {} # value: AMO Server object
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Auth
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class XmlaAuth:
|
|
53
|
+
"""Acquires and caches an MSAL access token for the XMLA endpoint.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
mode:
|
|
58
|
+
``"device_flow"`` | ``"service_principal"`` | ``"token"``
|
|
59
|
+
client_id:
|
|
60
|
+
AAD application (client) ID. Defaults to the Power BI Desktop app ID
|
|
61
|
+
when using ``device_flow``.
|
|
62
|
+
client_secret:
|
|
63
|
+
Required for ``service_principal``.
|
|
64
|
+
tenant_id:
|
|
65
|
+
AAD tenant ID. Defaults to ``"common"`` for ``device_flow``.
|
|
66
|
+
access_token:
|
|
67
|
+
Pre-acquired Bearer token string; used directly when ``mode="token"``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
#: The AAD scope for Power BI XMLA endpoints.
|
|
71
|
+
_PBI_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
|
|
72
|
+
#: Default public client ID (Power BI Desktop app) for device-flow.
|
|
73
|
+
_DEFAULT_CLIENT_ID = "ea0616ba-638b-4df5-95b9-636659ae5121"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
mode: str = "device_flow",
|
|
78
|
+
*,
|
|
79
|
+
client_id: str | None = None,
|
|
80
|
+
client_secret: str | None = None,
|
|
81
|
+
tenant_id: str | None = None,
|
|
82
|
+
access_token: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
if mode not in ("device_flow", "service_principal", "token"):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unknown auth mode {mode!r}. "
|
|
87
|
+
"Expected: 'device_flow', 'service_principal', or 'token'."
|
|
88
|
+
)
|
|
89
|
+
self.mode = mode
|
|
90
|
+
self.client_id = client_id
|
|
91
|
+
self.client_secret = client_secret
|
|
92
|
+
self.tenant_id = tenant_id
|
|
93
|
+
self._access_token = access_token
|
|
94
|
+
self._cached_token: str | None = None
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
def get_token(self) -> str:
|
|
98
|
+
"""Return a valid access token, acquiring one if necessary."""
|
|
99
|
+
if self._cached_token:
|
|
100
|
+
return self._cached_token
|
|
101
|
+
|
|
102
|
+
if self.mode == "token":
|
|
103
|
+
if not self._access_token:
|
|
104
|
+
raise ValueError("access_token must be provided when mode='token'.")
|
|
105
|
+
self._cached_token = self._access_token
|
|
106
|
+
return self._cached_token
|
|
107
|
+
|
|
108
|
+
self._cached_token = self._acquire_via_msal()
|
|
109
|
+
return self._cached_token
|
|
110
|
+
|
|
111
|
+
def _acquire_via_msal(self) -> str:
|
|
112
|
+
try:
|
|
113
|
+
import msal # noqa: PLC0415
|
|
114
|
+
except ImportError:
|
|
115
|
+
raise ImportError(
|
|
116
|
+
"msal is required for device_flow / service_principal auth.\n"
|
|
117
|
+
"Install with: pip install pbi-enterprise-cli[xmla]"
|
|
118
|
+
) from None
|
|
119
|
+
|
|
120
|
+
authority = f"https://login.microsoftonline.com/{self.tenant_id or 'common'}"
|
|
121
|
+
|
|
122
|
+
if self.mode == "service_principal":
|
|
123
|
+
if not (self.client_id and self.client_secret and self.tenant_id):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"client_id, client_secret, and tenant_id are all required "
|
|
126
|
+
"for service_principal auth."
|
|
127
|
+
)
|
|
128
|
+
app = msal.ConfidentialClientApplication(
|
|
129
|
+
self.client_id,
|
|
130
|
+
authority=authority,
|
|
131
|
+
client_credential=self.client_secret,
|
|
132
|
+
)
|
|
133
|
+
result = app.acquire_token_for_client(scopes=[self._PBI_SCOPE])
|
|
134
|
+
|
|
135
|
+
else: # device_flow
|
|
136
|
+
app = msal.PublicClientApplication(
|
|
137
|
+
self.client_id or self._DEFAULT_CLIENT_ID,
|
|
138
|
+
authority=authority,
|
|
139
|
+
)
|
|
140
|
+
flow = app.initiate_device_flow(scopes=[self._PBI_SCOPE])
|
|
141
|
+
print(flow.get("message", "Open a browser and authenticate."))
|
|
142
|
+
result = app.acquire_token_by_device_flow(flow)
|
|
143
|
+
|
|
144
|
+
if "access_token" not in result:
|
|
145
|
+
err = result.get("error_description") or result.get("error") or str(result)
|
|
146
|
+
raise RuntimeError(f"XMLA authentication failed: {err}")
|
|
147
|
+
return str(result["access_token"])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# AMO helpers (lazy-imported so tests can patch them)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_amo(): # type: ignore[return]
|
|
156
|
+
"""Import and return the AMO Server class via pythonnet.
|
|
157
|
+
|
|
158
|
+
Raises ImportError with install instructions if pythonnet or the AMO
|
|
159
|
+
assemblies are not available.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
import clr # type: ignore[import] # noqa: PLC0415
|
|
163
|
+
|
|
164
|
+
clr.AddReference("Microsoft.AnalysisServices.Core")
|
|
165
|
+
clr.AddReference("Microsoft.AnalysisServices.Tabular")
|
|
166
|
+
from Microsoft.AnalysisServices.Tabular import Server # type: ignore[import]
|
|
167
|
+
|
|
168
|
+
return Server
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
raise ImportError(
|
|
171
|
+
"The XMLA backend requires pythonnet and the AMO .NET assemblies.\n"
|
|
172
|
+
"Install with: pip install pbi-enterprise-cli[xmla]\n"
|
|
173
|
+
"The AMO assemblies ship with Power BI Desktop (Windows) or can be\n"
|
|
174
|
+
"installed via NuGet: Microsoft.AnalysisServices.retail.amd64"
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_adomd(): # type: ignore[return]
|
|
179
|
+
"""Import and return AdomdConnection + AdomdCommand via pythonnet."""
|
|
180
|
+
try:
|
|
181
|
+
import clr # type: ignore[import] # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
clr.AddReference("Microsoft.AnalysisServices.AdomdClient")
|
|
184
|
+
from Microsoft.AnalysisServices.AdomdClient import ( # type: ignore[import]
|
|
185
|
+
AdomdCommand,
|
|
186
|
+
AdomdConnection,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return AdomdConnection, AdomdCommand
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise ImportError(
|
|
192
|
+
"AdomdClient .NET assembly not found.\n"
|
|
193
|
+
"Install with: pip install pbi-enterprise-cli[xmla]"
|
|
194
|
+
) from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Backend
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class XmlaBackend:
|
|
203
|
+
"""Power BI Premium / Fabric XMLA backend.
|
|
204
|
+
|
|
205
|
+
All read operations use the AMO tabular object model via pythonnet.
|
|
206
|
+
DAX queries use ADOMD.NET (also via pythonnet).
|
|
207
|
+
Both paths require Windows + the AMO/ADOMD NuGet assemblies; on other
|
|
208
|
+
platforms every method raises ``ImportError``.
|
|
209
|
+
|
|
210
|
+
Connection pool
|
|
211
|
+
~~~~~~~~~~~~~~~
|
|
212
|
+
A module-level pool reuses live ``Server`` connections across
|
|
213
|
+
``XmlaBackend`` instances that share the same ``(data_source, catalog)``
|
|
214
|
+
pair. Call :meth:`clear_pool` to drain it (e.g. between tests).
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self) -> None:
|
|
218
|
+
self._connected = False
|
|
219
|
+
self._data_source: str = ""
|
|
220
|
+
self._catalog: str = ""
|
|
221
|
+
self._server: Any = None # AMO Server object
|
|
222
|
+
self._auth: XmlaAuth | None = None
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Connection
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def connect(
|
|
229
|
+
self,
|
|
230
|
+
data_source: str,
|
|
231
|
+
*,
|
|
232
|
+
catalog: str = "",
|
|
233
|
+
auth_mode: str = "device_flow",
|
|
234
|
+
client_id: str | None = None,
|
|
235
|
+
client_secret: str | None = None,
|
|
236
|
+
tenant_id: str | None = None,
|
|
237
|
+
access_token: str | None = None,
|
|
238
|
+
**kwargs: Any,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Open (or reuse a pooled) connection to an XMLA endpoint.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
data_source:
|
|
245
|
+
XMLA endpoint URL, e.g.
|
|
246
|
+
``powerbi://api.powerbi.com/v1.0/myorg/MySalesWorkspace``
|
|
247
|
+
catalog:
|
|
248
|
+
The dataset / semantic model name.
|
|
249
|
+
auth_mode:
|
|
250
|
+
``"device_flow"``, ``"service_principal"``, or ``"token"``.
|
|
251
|
+
"""
|
|
252
|
+
self._auth = XmlaAuth(
|
|
253
|
+
mode=auth_mode,
|
|
254
|
+
client_id=client_id,
|
|
255
|
+
client_secret=client_secret,
|
|
256
|
+
tenant_id=tenant_id,
|
|
257
|
+
access_token=access_token,
|
|
258
|
+
)
|
|
259
|
+
self._data_source = data_source.rstrip("/")
|
|
260
|
+
self._catalog = catalog
|
|
261
|
+
|
|
262
|
+
pool_key = (self._data_source, self._catalog)
|
|
263
|
+
with _pool_lock:
|
|
264
|
+
server = _connection_pool.get(pool_key)
|
|
265
|
+
if server is None or not server.Connected:
|
|
266
|
+
server = self._open_server(pool_key)
|
|
267
|
+
self._server = server
|
|
268
|
+
|
|
269
|
+
self._connected = True
|
|
270
|
+
|
|
271
|
+
def _open_server(self, pool_key: tuple[str, str]) -> Any:
|
|
272
|
+
"""Create a new AMO Server, connect, and store it in the pool."""
|
|
273
|
+
Server = _load_amo()
|
|
274
|
+
server = Server()
|
|
275
|
+
token = self._auth.get_token() # type: ignore[union-attr]
|
|
276
|
+
conn_str = f"Data Source={self._data_source};Password={token};" + (
|
|
277
|
+
f"Initial Catalog={self._catalog};" if self._catalog else ""
|
|
278
|
+
)
|
|
279
|
+
server.Connect(conn_str)
|
|
280
|
+
_connection_pool[pool_key] = server
|
|
281
|
+
return server
|
|
282
|
+
|
|
283
|
+
def disconnect(self) -> None:
|
|
284
|
+
"""Disconnect from the XMLA endpoint and remove from pool."""
|
|
285
|
+
if self._server is not None:
|
|
286
|
+
pool_key = (self._data_source, self._catalog)
|
|
287
|
+
with _pool_lock:
|
|
288
|
+
_connection_pool.pop(pool_key, None)
|
|
289
|
+
try:
|
|
290
|
+
self._server.Disconnect()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
self._server = None
|
|
294
|
+
self._connected = False
|
|
295
|
+
|
|
296
|
+
def is_connected(self) -> bool:
|
|
297
|
+
return self._connected
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def clear_pool() -> None:
|
|
301
|
+
"""Drain the connection pool (useful in tests and CLI --reset)."""
|
|
302
|
+
with _pool_lock:
|
|
303
|
+
for server in _connection_pool.values():
|
|
304
|
+
try:
|
|
305
|
+
server.Disconnect()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
_connection_pool.clear()
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Internal helpers
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _require_connection(self) -> None:
|
|
315
|
+
if not self._connected or self._server is None:
|
|
316
|
+
raise RuntimeError("Not connected to an XMLA endpoint. Call connect() first.")
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def _model(self) -> Any:
|
|
320
|
+
"""Return the AMO Tabular database model for the active catalog."""
|
|
321
|
+
self._require_connection()
|
|
322
|
+
if self._catalog:
|
|
323
|
+
db = self._server.Databases[self._catalog]
|
|
324
|
+
else:
|
|
325
|
+
if self._server.Databases.Count == 0:
|
|
326
|
+
raise RuntimeError("No databases found on the XMLA endpoint.")
|
|
327
|
+
db = self._server.Databases[0]
|
|
328
|
+
return db.Model
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Model metadata
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def model_info(self) -> dict[str, Any]:
|
|
335
|
+
model = self._model
|
|
336
|
+
return {
|
|
337
|
+
"name": str(model.Database.Name),
|
|
338
|
+
"compatibility_level": int(model.Database.CompatibilityLevel),
|
|
339
|
+
"catalog": self._catalog,
|
|
340
|
+
"data_source": self._data_source,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def table_list(self) -> list[dict[str, Any]]:
|
|
344
|
+
model = self._model
|
|
345
|
+
result = []
|
|
346
|
+
for tbl in model.Tables:
|
|
347
|
+
result.append(
|
|
348
|
+
{
|
|
349
|
+
"name": str(tbl.Name),
|
|
350
|
+
"isHidden": bool(tbl.IsHidden),
|
|
351
|
+
"description": str(tbl.Description or ""),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
return result
|
|
355
|
+
|
|
356
|
+
def column_list(self, table: str | None = None) -> list[dict[str, Any]]:
|
|
357
|
+
model = self._model
|
|
358
|
+
result = []
|
|
359
|
+
tables = [model.Tables[table]] if table else list(model.Tables)
|
|
360
|
+
for tbl in tables:
|
|
361
|
+
for col in tbl.Columns:
|
|
362
|
+
result.append(
|
|
363
|
+
{
|
|
364
|
+
"table": str(tbl.Name),
|
|
365
|
+
"name": str(col.Name),
|
|
366
|
+
"dataType": str(col.DataType),
|
|
367
|
+
"isHidden": bool(col.IsHidden),
|
|
368
|
+
"description": str(col.Description or ""),
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
def relationship_list(self) -> list[dict[str, Any]]:
|
|
374
|
+
model = self._model
|
|
375
|
+
result = []
|
|
376
|
+
for rel in model.Relationships:
|
|
377
|
+
result.append(
|
|
378
|
+
{
|
|
379
|
+
"from": f"{rel.FromTable.Name}[{rel.FromColumn.Name}]",
|
|
380
|
+
"to": f"{rel.ToTable.Name}[{rel.ToColumn.Name}]",
|
|
381
|
+
"cardinality": str(rel.FromCardinality),
|
|
382
|
+
"isActive": bool(rel.IsActive),
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
# Measures
|
|
389
|
+
# ------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def measure_list(self, table: str | None = None) -> list[dict[str, Any]]:
|
|
392
|
+
model = self._model
|
|
393
|
+
result = []
|
|
394
|
+
tables = [model.Tables[table]] if table else list(model.Tables)
|
|
395
|
+
for tbl in tables:
|
|
396
|
+
for m in tbl.Measures:
|
|
397
|
+
result.append(
|
|
398
|
+
{
|
|
399
|
+
"table": str(tbl.Name),
|
|
400
|
+
"name": str(m.Name),
|
|
401
|
+
"expression": str(m.Expression),
|
|
402
|
+
"formatString": str(m.FormatString or ""),
|
|
403
|
+
"description": str(m.Description or ""),
|
|
404
|
+
"isHidden": bool(m.IsHidden),
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
def measure_add(self, table: str, name: str, expression: str, **kwargs: Any) -> dict[str, Any]:
|
|
410
|
+
from Microsoft.AnalysisServices.Tabular import Measure # type: ignore[import]
|
|
411
|
+
|
|
412
|
+
model = self._model
|
|
413
|
+
tbl = model.Tables[table]
|
|
414
|
+
m = Measure()
|
|
415
|
+
m.Name = name
|
|
416
|
+
m.Expression = expression
|
|
417
|
+
if "formatString" in kwargs:
|
|
418
|
+
m.FormatString = kwargs["formatString"]
|
|
419
|
+
if "description" in kwargs:
|
|
420
|
+
m.Description = kwargs["description"]
|
|
421
|
+
tbl.Measures.Add(m)
|
|
422
|
+
model.SaveChanges()
|
|
423
|
+
return {"table": table, "name": name, "expression": expression, **kwargs}
|
|
424
|
+
|
|
425
|
+
def measure_update(self, table: str, name: str, **kwargs: Any) -> dict[str, Any]:
|
|
426
|
+
model = self._model
|
|
427
|
+
m = model.Tables[table].Measures[name]
|
|
428
|
+
new_name = kwargs.pop("new_name", None)
|
|
429
|
+
if new_name:
|
|
430
|
+
m.Name = new_name
|
|
431
|
+
if "expression" in kwargs:
|
|
432
|
+
m.Expression = kwargs["expression"]
|
|
433
|
+
if "formatString" in kwargs:
|
|
434
|
+
m.FormatString = kwargs["formatString"]
|
|
435
|
+
if "description" in kwargs:
|
|
436
|
+
m.Description = kwargs["description"]
|
|
437
|
+
model.SaveChanges()
|
|
438
|
+
return {"table": table, "name": new_name or name, **kwargs}
|
|
439
|
+
|
|
440
|
+
def measure_delete(self, table: str, name: str) -> None:
|
|
441
|
+
model = self._model
|
|
442
|
+
tbl = model.Tables[table]
|
|
443
|
+
tbl.Measures.Remove(tbl.Measures[name])
|
|
444
|
+
model.SaveChanges()
|
|
445
|
+
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
# Tables
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def table_add(self, name: str, **kwargs: Any) -> dict[str, Any]:
|
|
451
|
+
from Microsoft.AnalysisServices.Tabular import Table # type: ignore[import]
|
|
452
|
+
|
|
453
|
+
model = self._model
|
|
454
|
+
tbl = Table()
|
|
455
|
+
tbl.Name = name
|
|
456
|
+
model.Tables.Add(tbl)
|
|
457
|
+
model.SaveChanges()
|
|
458
|
+
return {"name": name, **kwargs}
|
|
459
|
+
|
|
460
|
+
def table_delete(self, name: str) -> None:
|
|
461
|
+
model = self._model
|
|
462
|
+
model.Tables.Remove(model.Tables[name])
|
|
463
|
+
model.SaveChanges()
|
|
464
|
+
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
# Columns
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def column_add(self, table: str, name: str, data_type: str, **kwargs: Any) -> dict[str, Any]:
|
|
470
|
+
from Microsoft.AnalysisServices.Tabular import ( # type: ignore[import]
|
|
471
|
+
DataColumn,
|
|
472
|
+
DataType,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
model = self._model
|
|
476
|
+
col = DataColumn()
|
|
477
|
+
col.Name = name
|
|
478
|
+
col.DataType = getattr(DataType, data_type, DataType.String)
|
|
479
|
+
model.Tables[table].Columns.Add(col)
|
|
480
|
+
model.SaveChanges()
|
|
481
|
+
return {"table": table, "name": name, "dataType": data_type, **kwargs}
|
|
482
|
+
|
|
483
|
+
def column_delete(self, table: str, name: str) -> None:
|
|
484
|
+
model = self._model
|
|
485
|
+
tbl = model.Tables[table]
|
|
486
|
+
tbl.Columns.Remove(tbl.Columns[name])
|
|
487
|
+
model.SaveChanges()
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------
|
|
490
|
+
# Relationships
|
|
491
|
+
# ------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
def relationship_add(
|
|
494
|
+
self,
|
|
495
|
+
from_table: str,
|
|
496
|
+
from_column: str,
|
|
497
|
+
to_table: str,
|
|
498
|
+
to_column: str,
|
|
499
|
+
**kwargs: Any,
|
|
500
|
+
) -> dict[str, Any]:
|
|
501
|
+
from Microsoft.AnalysisServices.Tabular import ( # type: ignore[import]
|
|
502
|
+
RelationshipEndCardinality,
|
|
503
|
+
SingleColumnRelationship,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
model = self._model
|
|
507
|
+
rel = SingleColumnRelationship()
|
|
508
|
+
rel.FromTable = model.Tables[from_table]
|
|
509
|
+
rel.FromColumn = model.Tables[from_table].Columns[from_column]
|
|
510
|
+
rel.ToTable = model.Tables[to_table]
|
|
511
|
+
rel.ToColumn = model.Tables[to_table].Columns[to_column]
|
|
512
|
+
cardinality = kwargs.get("cardinality", "ManyToOne")
|
|
513
|
+
rel.FromCardinality = getattr(
|
|
514
|
+
RelationshipEndCardinality, cardinality, RelationshipEndCardinality.Many
|
|
515
|
+
)
|
|
516
|
+
model.Relationships.Add(rel)
|
|
517
|
+
model.SaveChanges()
|
|
518
|
+
return {
|
|
519
|
+
"from": f"{from_table}[{from_column}]",
|
|
520
|
+
"to": f"{to_table}[{to_column}]",
|
|
521
|
+
"cardinality": cardinality,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
# DAX
|
|
526
|
+
# ------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def dax_query(self, expression: str) -> list[dict[str, Any]]:
|
|
529
|
+
"""Execute a DAX query via ADOMD.NET and return rows as dicts."""
|
|
530
|
+
self._require_connection()
|
|
531
|
+
AdomdConnection, AdomdCommand = _load_adomd()
|
|
532
|
+
|
|
533
|
+
token = self._auth.get_token() # type: ignore[union-attr]
|
|
534
|
+
conn_str = f"Data Source={self._data_source};Password={token};" + (
|
|
535
|
+
f"Initial Catalog={self._catalog};" if self._catalog else ""
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
rows: list[dict[str, Any]] = []
|
|
539
|
+
with AdomdConnection(conn_str) as conn:
|
|
540
|
+
conn.Open()
|
|
541
|
+
cmd = AdomdCommand(expression, conn)
|
|
542
|
+
reader = cmd.ExecuteReader()
|
|
543
|
+
field_count = reader.FieldCount
|
|
544
|
+
columns = [reader.GetName(i) for i in range(field_count)]
|
|
545
|
+
while reader.Read():
|
|
546
|
+
rows.append({col: reader[col] for col in columns})
|
|
547
|
+
reader.Close()
|
|
548
|
+
return rows
|
|
549
|
+
|
|
550
|
+
def dax_validate(self, expression: str) -> dict[str, Any]:
|
|
551
|
+
"""Lightweight syntax check: run EVALUATE with a row limit of 0."""
|
|
552
|
+
try:
|
|
553
|
+
# Wrap in an always-false filter so no data is transferred
|
|
554
|
+
self.dax_query(f"EVALUATE TOPN(0, ({expression}))")
|
|
555
|
+
return {"valid": True, "expression": expression}
|
|
556
|
+
except Exception as exc:
|
|
557
|
+
return {"valid": False, "expression": expression, "error": str(exc)}
|
|
558
|
+
|
|
559
|
+
# ------------------------------------------------------------------
|
|
560
|
+
# TMDL
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
def tmdl_export(self, path: str) -> None:
|
|
564
|
+
"""Export the model as TMDL files to *path* via AMO serialisation."""
|
|
565
|
+
from Microsoft.AnalysisServices.Tabular import TmdlSerializer # type: ignore[import]
|
|
566
|
+
|
|
567
|
+
model = self._model
|
|
568
|
+
TmdlSerializer.SerializeDatabase(model.Database, path)
|
|
569
|
+
|
|
570
|
+
def tmdl_import(self, path: str) -> None:
|
|
571
|
+
"""Import TMDL from *path* and deploy to the connected endpoint."""
|
|
572
|
+
from Microsoft.AnalysisServices.Tabular import TmdlSerializer # type: ignore[import]
|
|
573
|
+
|
|
574
|
+
model = self._model
|
|
575
|
+
TmdlSerializer.DeserializeDatabase(path, model.Database)
|
|
576
|
+
model.SaveChanges()
|
|
577
|
+
|
|
578
|
+
# ------------------------------------------------------------------
|
|
579
|
+
# Hierarchies (bonus — not in base protocol but mirrors TomBackend)
|
|
580
|
+
# ------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def hierarchy_list(self, table: str | None = None) -> list[dict[str, Any]]:
|
|
583
|
+
model = self._model
|
|
584
|
+
result = []
|
|
585
|
+
tables = [model.Tables[table]] if table else list(model.Tables)
|
|
586
|
+
for tbl in tables:
|
|
587
|
+
for h in tbl.Hierarchies:
|
|
588
|
+
result.append(
|
|
589
|
+
{
|
|
590
|
+
"table": str(tbl.Name),
|
|
591
|
+
"name": str(h.Name),
|
|
592
|
+
"levels": [
|
|
593
|
+
{
|
|
594
|
+
"ordinal": int(lv.Ordinal),
|
|
595
|
+
"name": str(lv.Name),
|
|
596
|
+
"column": str(lv.Column.Name),
|
|
597
|
+
}
|
|
598
|
+
for lv in h.Levels
|
|
599
|
+
],
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
return result
|
|
603
|
+
|
|
604
|
+
# ------------------------------------------------------------------
|
|
605
|
+
# Roles (bonus)
|
|
606
|
+
# ------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
def role_list(self) -> list[dict[str, Any]]:
|
|
609
|
+
model = self._model
|
|
610
|
+
result = []
|
|
611
|
+
for role in model.Roles:
|
|
612
|
+
perms = []
|
|
613
|
+
for tp in role.TablePermissions:
|
|
614
|
+
perms.append(
|
|
615
|
+
{
|
|
616
|
+
"table": str(tp.Table.Name),
|
|
617
|
+
"filterExpression": str(tp.FilterExpression or ""),
|
|
618
|
+
}
|
|
619
|
+
)
|
|
620
|
+
result.append(
|
|
621
|
+
{
|
|
622
|
+
"name": str(role.Name),
|
|
623
|
+
"modelPermission": str(role.ModelPermission),
|
|
624
|
+
"tablePermissions": perms,
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
return result
|