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.
Files changed (61) hide show
  1. pbi_cli/__init__.py +3 -0
  2. pbi_cli/_audit.py +57 -0
  3. pbi_cli/_snapshot.py +95 -0
  4. pbi_cli/backends/__init__.py +1 -0
  5. pbi_cli/backends/mock_backend.py +323 -0
  6. pbi_cli/backends/pbir_backend.py +813 -0
  7. pbi_cli/backends/protocol.py +52 -0
  8. pbi_cli/backends/tom_backend.py +650 -0
  9. pbi_cli/backends/xmla_backend.py +627 -0
  10. pbi_cli/cli.py +332 -0
  11. pbi_cli/commands/__init__.py +1 -0
  12. pbi_cli/commands/_doctor.py +84 -0
  13. pbi_cli/commands/_shared.py +88 -0
  14. pbi_cli/commands/calendar_cmd.py +186 -0
  15. pbi_cli/commands/connections.py +153 -0
  16. pbi_cli/commands/custom_visual.py +325 -0
  17. pbi_cli/commands/database.py +76 -0
  18. pbi_cli/commands/dax.py +174 -0
  19. pbi_cli/commands/deploy.py +193 -0
  20. pbi_cli/commands/docs.py +57 -0
  21. pbi_cli/commands/filter_cmd.py +235 -0
  22. pbi_cli/commands/govern.py +124 -0
  23. pbi_cli/commands/layout.py +104 -0
  24. pbi_cli/commands/measure.py +185 -0
  25. pbi_cli/commands/model.py +499 -0
  26. pbi_cli/commands/partition.py +89 -0
  27. pbi_cli/commands/repl.py +209 -0
  28. pbi_cli/commands/report.py +561 -0
  29. pbi_cli/commands/security.py +90 -0
  30. pbi_cli/commands/server_cmd.py +30 -0
  31. pbi_cli/commands/skills_cmd.py +168 -0
  32. pbi_cli/commands/source.py +581 -0
  33. pbi_cli/commands/theme.py +60 -0
  34. pbi_cli/commands/trace.py +142 -0
  35. pbi_cli/commands/visual.py +507 -0
  36. pbi_cli/commands/watch.py +145 -0
  37. pbi_cli/docs_gen/__init__.py +1 -0
  38. pbi_cli/docs_gen/confluence.py +24 -0
  39. pbi_cli/docs_gen/markdown.py +36 -0
  40. pbi_cli/governance/__init__.py +1 -0
  41. pbi_cli/governance/engine.py +70 -0
  42. pbi_cli/governance/rules/__init__.py +85 -0
  43. pbi_cli/governance/rules/measure_brackets.py +27 -0
  44. pbi_cli/governance/rules/measure_description.py +41 -0
  45. pbi_cli/governance/rules/measure_format.py +38 -0
  46. pbi_cli/governance/rules/measure_naming.py +93 -0
  47. pbi_cli/governance/rules/table_pascal_case.py +44 -0
  48. pbi_cli/intelligence/__init__.py +1 -0
  49. pbi_cli/intelligence/layout_engine.py +192 -0
  50. pbi_cli/intelligence/measure_generator.py +40 -0
  51. pbi_cli/intelligence/theme_generator.py +193 -0
  52. pbi_cli/intelligence/visual_builder.py +429 -0
  53. pbi_cli/intelligence/visual_recommender.py +42 -0
  54. pbi_cli/server/__init__.py +1 -0
  55. pbi_cli/server/api.py +185 -0
  56. pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
  57. pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
  58. pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  59. pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  60. pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  61. 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