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,52 @@
1
+ """Protocol definition for TOM backends (Desktop, XMLA, Mock)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class TomBackendProtocol(Protocol):
10
+ """Defines the contract all TOM-compatible backends must satisfy."""
11
+
12
+ # --- Connection ---
13
+ def connect(self, **kwargs: Any) -> None: ...
14
+ def disconnect(self) -> None: ...
15
+ def is_connected(self) -> bool: ...
16
+
17
+ # --- Model info ---
18
+ def model_info(self) -> dict[str, Any]: ...
19
+ def table_list(self) -> list[dict[str, Any]]: ...
20
+ def column_list(self, table: str | None = None) -> list[dict[str, Any]]: ...
21
+ def relationship_list(self) -> list[dict[str, Any]]: ...
22
+
23
+ # --- Measures ---
24
+ def measure_list(self, table: str | None = None) -> list[dict[str, Any]]: ...
25
+ def measure_add(
26
+ self, table: str, name: str, expression: str, **kwargs: Any
27
+ ) -> dict[str, Any]: ...
28
+ def measure_update(self, table: str, name: str, **kwargs: Any) -> dict[str, Any]: ...
29
+ def measure_delete(self, table: str, name: str) -> None: ...
30
+
31
+ # --- Tables ---
32
+ def table_add(self, name: str, **kwargs: Any) -> dict[str, Any]: ...
33
+ def table_delete(self, name: str) -> None: ...
34
+
35
+ # --- Columns ---
36
+ def column_add(
37
+ self, table: str, name: str, data_type: str, **kwargs: Any
38
+ ) -> dict[str, Any]: ...
39
+ def column_delete(self, table: str, name: str) -> None: ...
40
+
41
+ # --- Relationships ---
42
+ def relationship_add(
43
+ self, from_table: str, from_column: str, to_table: str, to_column: str, **kwargs: Any
44
+ ) -> dict[str, Any]: ...
45
+
46
+ # --- DAX ---
47
+ def dax_query(self, expression: str) -> list[dict[str, Any]]: ...
48
+ def dax_validate(self, expression: str) -> dict[str, Any]: ...
49
+
50
+ # --- TMDL ---
51
+ def tmdl_export(self, path: str) -> None: ...
52
+ def tmdl_import(self, path: str) -> None: ...
@@ -0,0 +1,650 @@
1
+ """TOM backend connecting to Power BI Desktop via pythonnet + AMO."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rich.console import Console
12
+
13
+ console = Console(stderr=True)
14
+
15
+ _DLL_DIR = Path(__file__).parent.parent / "dlls"
16
+
17
+
18
+ def _load_amo() -> None:
19
+ """Add AMO DLL directory to path and load assemblies."""
20
+ import clr # type: ignore[import]
21
+
22
+ dll_dir = str(_DLL_DIR)
23
+ if dll_dir not in sys.path:
24
+ sys.path.append(dll_dir)
25
+ clr.AddReference("Microsoft.AnalysisServices.Tabular")
26
+ clr.AddReference("Microsoft.AnalysisServices.Core")
27
+ clr.AddReference("Microsoft.AnalysisServices.AdomdClient")
28
+
29
+
30
+ def find_pbi_port() -> int | None:
31
+ """Auto-discover the local Analysis Services port used by Power BI Desktop."""
32
+ try:
33
+ tasklist = subprocess.run(
34
+ ["tasklist", "/FI", "IMAGENAME eq msmdsrv.exe", "/FO", "CSV"],
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=5,
38
+ )
39
+ pid_match = re.search(r'"msmdsrv\.exe","(\d+)"', tasklist.stdout)
40
+ if not pid_match:
41
+ return None
42
+ pid = pid_match.group(1)
43
+
44
+ netstat = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, timeout=5)
45
+ for line in netstat.stdout.splitlines():
46
+ if pid in line and "LISTENING" in line:
47
+ m = re.search(r"(?:127\.0\.0\.1|0\.0\.0\.0):(\d+)", line)
48
+ if m and m.group(1) != "0":
49
+ return int(m.group(1))
50
+ except Exception:
51
+ pass
52
+ return None
53
+
54
+
55
+ class TomBackend:
56
+ """Connects to a running Power BI Desktop process via pythonnet + AMO.
57
+
58
+ Auto-discovers the local Analysis Services port from the MSMDSRV.exe process.
59
+ Pass port= to connect() to override.
60
+ """
61
+
62
+ def __init__(self) -> None:
63
+ self._connected = False
64
+ self._server: Any = None
65
+ self._db: Any = None
66
+ self._model: Any = None
67
+ self._port: int | None = None
68
+
69
+ def connect(self, port: int | None = None, **kwargs: Any) -> None:
70
+ if sys.platform != "win32":
71
+ raise RuntimeError("TOM backend requires Windows and Power BI Desktop.")
72
+
73
+ _load_amo()
74
+ from Microsoft.AnalysisServices.Tabular import Server # type: ignore[import]
75
+
76
+ target_port = port or find_pbi_port()
77
+ if not target_port:
78
+ raise RuntimeError(
79
+ "Could not find a running Power BI Desktop instance. "
80
+ "Open a PBIX file in Power BI Desktop and try again."
81
+ )
82
+
83
+ self._server = Server()
84
+ self._server.Connect(f"Data Source=localhost:{target_port}")
85
+ self._port = target_port
86
+
87
+ # Get the first non-hidden database (the user's model)
88
+ dbs = [db for db in self._server.Databases if not db.Name.startswith("$")]
89
+ if not dbs:
90
+ dbs = list(self._server.Databases)
91
+ self._db = dbs[0]
92
+ self._model = self._db.Model
93
+ self._connected = True
94
+
95
+ def disconnect(self) -> None:
96
+ if self._server:
97
+ try:
98
+ self._server.Disconnect()
99
+ except Exception:
100
+ pass
101
+ self._connected = False
102
+ self._server = None
103
+ self._db = None
104
+ self._model = None
105
+
106
+ def is_connected(self) -> bool:
107
+ return self._connected
108
+
109
+ # --- Model info ---
110
+
111
+ def model_info(self) -> dict[str, Any]:
112
+ self._require_connection()
113
+ return {
114
+ "name": self._db.Name,
115
+ "compatibilityLevel": self._db.CompatibilityLevel,
116
+ "port": self._port,
117
+ "server": str(self._server.Name),
118
+ }
119
+
120
+ def table_list(self) -> list[dict[str, Any]]:
121
+ self._require_connection()
122
+ return [
123
+ {"name": t.Name, "isHidden": t.IsHidden, "description": t.Description or ""}
124
+ for t in self._model.Tables
125
+ if not t.IsHidden
126
+ ]
127
+
128
+ def column_list(self, table: str | None = None) -> list[dict[str, Any]]:
129
+ self._require_connection()
130
+ results = []
131
+ for t in self._model.Tables:
132
+ if t.IsHidden:
133
+ continue
134
+ if table and t.Name != table:
135
+ continue
136
+ for col in t.Columns:
137
+ if col.IsHidden or col.Type.ToString() == "RowNumber":
138
+ continue
139
+ results.append(
140
+ {
141
+ "table": t.Name,
142
+ "name": col.Name,
143
+ "dataType": col.DataType.ToString(),
144
+ "isHidden": col.IsHidden,
145
+ "description": col.Description or "",
146
+ }
147
+ )
148
+ return results
149
+
150
+ def relationship_list(self) -> list[dict[str, Any]]:
151
+ self._require_connection()
152
+ return [
153
+ {
154
+ "from": f"{r.FromTable.Name}[{r.FromColumn.Name}]",
155
+ "to": f"{r.ToTable.Name}[{r.ToColumn.Name}]",
156
+ "isActive": r.IsActive,
157
+ "crossFilteringBehavior": r.CrossFilteringBehavior.ToString(),
158
+ }
159
+ for r in self._model.Relationships
160
+ ]
161
+
162
+ # --- Measures ---
163
+
164
+ def measure_list(self, table: str | None = None) -> list[dict[str, Any]]:
165
+ self._require_connection()
166
+ results = []
167
+ for t in self._model.Tables:
168
+ if table and t.Name != table:
169
+ continue
170
+ for m in t.Measures:
171
+ results.append(
172
+ {
173
+ "table": t.Name,
174
+ "name": m.Name,
175
+ "expression": m.Expression,
176
+ "formatString": m.FormatString or "",
177
+ "description": m.Description or "",
178
+ "isHidden": m.IsHidden,
179
+ }
180
+ )
181
+ return results
182
+
183
+ def measure_add(self, table: str, name: str, expression: str, **kwargs: Any) -> dict[str, Any]:
184
+ self._require_connection()
185
+ from Microsoft.AnalysisServices.Tabular import Measure # type: ignore[import]
186
+
187
+ target = self._model.Tables.Find(table)
188
+ if not target:
189
+ raise ValueError(f"Table '{table}' not found.")
190
+ m = Measure()
191
+ m.Name = name
192
+ m.Expression = expression
193
+ if "formatString" in kwargs:
194
+ m.FormatString = kwargs["formatString"]
195
+ if "description" in kwargs:
196
+ m.Description = kwargs["description"]
197
+ target.Measures.Add(m)
198
+ self._model.SaveChanges()
199
+ return {"table": table, "name": name, "expression": expression}
200
+
201
+ def measure_update(self, table: str, name: str, **kwargs: Any) -> dict[str, Any]:
202
+ self._require_connection()
203
+ target = self._model.Tables.Find(table)
204
+ if not target:
205
+ raise ValueError(f"Table '{table}' not found.")
206
+ m = target.Measures.Find(name)
207
+ if not m:
208
+ raise KeyError(f"Measure '{name}' not found in '{table}'.")
209
+ if "expression" in kwargs:
210
+ m.Expression = kwargs["expression"]
211
+ if "formatString" in kwargs:
212
+ m.FormatString = kwargs["formatString"]
213
+ if "description" in kwargs:
214
+ m.Description = kwargs["description"]
215
+ self._model.SaveChanges()
216
+ return {"table": table, "name": name, **kwargs}
217
+
218
+ def measure_delete(self, table: str, name: str) -> None:
219
+ self._require_connection()
220
+ target = self._model.Tables.Find(table)
221
+ if not target:
222
+ raise ValueError(f"Table '{table}' not found.")
223
+ m = target.Measures.Find(name)
224
+ if not m:
225
+ raise KeyError(f"Measure '{name}' not found in '{table}'.")
226
+ target.Measures.Remove(m)
227
+ self._model.SaveChanges()
228
+
229
+ # --- Tables ---
230
+
231
+ def table_add(self, name: str, **kwargs: Any) -> dict[str, Any]:
232
+ self._require_connection()
233
+ from Microsoft.AnalysisServices.Tabular import Table # type: ignore[import]
234
+
235
+ t = Table()
236
+ t.Name = name
237
+ self._model.Tables.Add(t)
238
+ self._model.SaveChanges()
239
+ return {"name": name}
240
+
241
+ def table_delete(self, name: str) -> None:
242
+ self._require_connection()
243
+ t = self._model.Tables.Find(name)
244
+ if t:
245
+ self._model.Tables.Remove(t)
246
+ self._model.SaveChanges()
247
+
248
+ # --- Columns ---
249
+
250
+ def column_add(self, table: str, name: str, data_type: str, **kwargs: Any) -> dict[str, Any]:
251
+ self._require_connection()
252
+ from Microsoft.AnalysisServices.Tabular import DataColumn, DataType # type: ignore[import]
253
+
254
+ target = self._model.Tables.Find(table)
255
+ if not target:
256
+ raise ValueError(f"Table '{table}' not found.")
257
+ col = DataColumn()
258
+ col.Name = name
259
+ col.DataType = getattr(DataType, data_type, DataType.String)
260
+ target.Columns.Add(col)
261
+ self._model.SaveChanges()
262
+ return {"table": table, "name": name, "dataType": data_type}
263
+
264
+ def column_delete(self, table: str, name: str) -> None:
265
+ self._require_connection()
266
+ target = self._model.Tables.Find(table)
267
+ if target:
268
+ col = target.Columns.Find(name)
269
+ if col:
270
+ target.Columns.Remove(col)
271
+ self._model.SaveChanges()
272
+
273
+ # --- Relationships ---
274
+
275
+ def relationship_add(
276
+ self, from_table: str, from_column: str, to_table: str, to_column: str, **kwargs: Any
277
+ ) -> dict[str, Any]:
278
+ self._require_connection()
279
+ from Microsoft.AnalysisServices.Tabular import (
280
+ SingleColumnRelationship, # type: ignore[import]
281
+ )
282
+
283
+ ft = self._model.Tables.Find(from_table)
284
+ tt = self._model.Tables.Find(to_table)
285
+ if not ft or not tt:
286
+ raise ValueError(f"Tables '{from_table}' or '{to_table}' not found.")
287
+ rel = SingleColumnRelationship()
288
+ rel.FromColumn = ft.Columns.Find(from_column)
289
+ rel.ToColumn = tt.Columns.Find(to_column)
290
+ self._model.Relationships.Add(rel)
291
+ self._model.SaveChanges()
292
+ return {"from": f"{from_table}[{from_column}]", "to": f"{to_table}[{to_column}]"}
293
+
294
+ # --- DAX ---
295
+
296
+ def dax_query(self, expression: str) -> list[dict[str, Any]]:
297
+ self._require_connection()
298
+ from Microsoft.AnalysisServices.AdomdClient import ( # type: ignore[import]
299
+ AdomdCommand,
300
+ AdomdConnection,
301
+ )
302
+
303
+ conn_str = f"Data Source=localhost:{self._port}"
304
+ conn = AdomdConnection(conn_str)
305
+ try:
306
+ conn.Open()
307
+ cmd = AdomdCommand(expression, conn)
308
+ reader = cmd.ExecuteReader()
309
+ # Strip surrounding [brackets] that ADOMD adds to column names
310
+ cols = [reader.GetName(i).strip("[]") for i in range(reader.FieldCount)]
311
+ rows = []
312
+ while reader.Read():
313
+ row: dict[str, Any] = {}
314
+ for i, c in enumerate(cols):
315
+ try:
316
+ val = reader.GetValue(i)
317
+ # Convert .NET types to Python primitives
318
+ row[c] = (
319
+ float(val)
320
+ if hasattr(val, "ToString") and not isinstance(val, str)
321
+ else val
322
+ )
323
+ except Exception:
324
+ row[c] = None
325
+ rows.append(row)
326
+ reader.Close()
327
+ return rows
328
+ finally:
329
+ conn.Close()
330
+
331
+ def dax_validate(self, expression: str) -> dict[str, Any]:
332
+ self._require_connection()
333
+ # Wrap in EVALUATE to test parse — use a DAX query that returns nothing if invalid
334
+ try:
335
+ test_expr = f'EVALUATE ROW("__test", {expression})'
336
+ self.dax_query(test_expr)
337
+ return {"valid": True, "expression": expression}
338
+ except Exception as e:
339
+ return {"valid": False, "expression": expression, "error": str(e)}
340
+
341
+ # --- Hierarchies ---
342
+
343
+ def hierarchy_list(self, table: str | None = None) -> list[dict[str, Any]]:
344
+ self._require_connection()
345
+ results = []
346
+ for t in self._model.Tables:
347
+ if table and t.Name != table:
348
+ continue
349
+ for h in t.Hierarchies:
350
+ levels = [
351
+ {"name": lv.Name, "column": lv.Column.Name, "ordinal": lv.Ordinal}
352
+ for lv in h.Levels
353
+ ]
354
+ results.append({"table": t.Name, "name": h.Name, "levels": levels})
355
+ return results
356
+
357
+ def hierarchy_add(self, table: str, name: str, levels: list[dict[str, Any]]) -> dict[str, Any]:
358
+ self._require_connection()
359
+ from Microsoft.AnalysisServices.Tabular import Hierarchy, Level # type: ignore[import]
360
+
361
+ t = self._model.Tables.Find(table)
362
+ if not t:
363
+ raise ValueError(f"Table '{table}' not found.")
364
+ h = Hierarchy()
365
+ h.Name = name
366
+ for i, lv_def in enumerate(levels):
367
+ lv = Level()
368
+ lv.Name = lv_def["name"]
369
+ lv.Column = t.Columns.Find(lv_def["column"])
370
+ lv.Ordinal = i
371
+ h.Levels.Add(lv)
372
+ t.Hierarchies.Add(h)
373
+ self._model.SaveChanges()
374
+ return {"table": table, "name": name, "levels": levels}
375
+
376
+ def hierarchy_delete(self, table: str, name: str) -> None:
377
+ self._require_connection()
378
+ t = self._model.Tables.Find(table)
379
+ if t:
380
+ h = t.Hierarchies.Find(name)
381
+ if h:
382
+ t.Hierarchies.Remove(h)
383
+ self._model.SaveChanges()
384
+
385
+ # --- Calculation Groups ---
386
+
387
+ def calc_group_list(self) -> list[dict[str, Any]]:
388
+ self._require_connection()
389
+ results = []
390
+ for t in self._model.Tables:
391
+ if not t.CalculationGroup:
392
+ continue
393
+ items = [
394
+ {"name": ci.Name, "expression": ci.Expression, "ordinal": ci.Ordinal}
395
+ for ci in t.CalculationGroup.CalculationItems
396
+ ]
397
+ results.append(
398
+ {"table": t.Name, "precedence": t.CalculationGroup.Precedence, "items": items}
399
+ )
400
+ return results
401
+
402
+ def calc_group_add(self, name: str, precedence: int = 0) -> dict[str, Any]:
403
+ self._require_connection()
404
+ from Microsoft.AnalysisServices.Tabular import ( # type: ignore[import]
405
+ CalculationGroup,
406
+ CalculationGroupColumn,
407
+ DataType,
408
+ Table,
409
+ )
410
+
411
+ t = Table()
412
+ t.Name = name
413
+ cg = CalculationGroup()
414
+ cg.Precedence = precedence
415
+ t.CalculationGroup = cg
416
+ col = CalculationGroupColumn()
417
+ col.Name = "Name"
418
+ col.DataType = DataType.String
419
+ t.Columns.Add(col)
420
+ self._model.Tables.Add(t)
421
+ self._model.SaveChanges()
422
+ return {"table": name, "precedence": precedence, "items": []}
423
+
424
+ def calc_item_add(
425
+ self, group_table: str, name: str, expression: str, ordinal: int = 0
426
+ ) -> dict[str, Any]:
427
+ self._require_connection()
428
+ from Microsoft.AnalysisServices.Tabular import CalculationItem # type: ignore[import]
429
+
430
+ t = self._model.Tables.Find(group_table)
431
+ if not t or not t.CalculationGroup:
432
+ raise ValueError(f"Calculation group '{group_table}' not found.")
433
+ ci = CalculationItem()
434
+ ci.Name = name
435
+ ci.Expression = expression
436
+ ci.Ordinal = ordinal
437
+ t.CalculationGroup.CalculationItems.Add(ci)
438
+ self._model.SaveChanges()
439
+ return {"group": group_table, "name": name, "expression": expression, "ordinal": ordinal}
440
+
441
+ def calc_item_delete(self, group_table: str, name: str) -> None:
442
+ self._require_connection()
443
+ t = self._model.Tables.Find(group_table)
444
+ if not t or not t.CalculationGroup:
445
+ raise ValueError(f"Calculation group '{group_table}' not found.")
446
+ ci = t.CalculationGroup.CalculationItems.Find(name)
447
+ if ci:
448
+ t.CalculationGroup.CalculationItems.Remove(ci)
449
+ self._model.SaveChanges()
450
+
451
+ # --- RLS Roles ---
452
+
453
+ def role_list(self) -> list[dict[str, Any]]:
454
+ self._require_connection()
455
+ results = []
456
+ for role in self._model.Roles:
457
+ table_perms = [
458
+ {"table": tp.Table.Name, "filterExpression": tp.FilterExpression or ""}
459
+ for tp in role.TablePermissions
460
+ ]
461
+ results.append(
462
+ {
463
+ "name": role.Name,
464
+ "modelPermission": role.ModelPermission.ToString(),
465
+ "tablePermissions": table_perms,
466
+ }
467
+ )
468
+ return results
469
+
470
+ def role_add(self, name: str, table: str, filter_expression: str) -> dict[str, Any]:
471
+ self._require_connection()
472
+ from Microsoft.AnalysisServices.Tabular import ( # type: ignore[import]
473
+ ModelPermission,
474
+ ModelRole,
475
+ TablePermission,
476
+ )
477
+
478
+ role = ModelRole()
479
+ role.Name = name
480
+ role.ModelPermission = ModelPermission.Read
481
+ tp = TablePermission()
482
+ tp.Table = self._model.Tables.Find(table)
483
+ if tp.Table is None:
484
+ raise ValueError(f"Table '{table}' not found.")
485
+ tp.FilterExpression = filter_expression
486
+ role.TablePermissions.Add(tp)
487
+ self._model.Roles.Add(role)
488
+ self._model.SaveChanges()
489
+ return {"name": name, "table": table, "filterExpression": filter_expression}
490
+
491
+ def role_delete(self, name: str) -> None:
492
+ self._require_connection()
493
+ role = self._model.Roles.Find(name)
494
+ if role:
495
+ self._model.Roles.Remove(role)
496
+ self._model.SaveChanges()
497
+
498
+ def role_test(self, role_name: str, dax_expression: str) -> dict[str, Any]:
499
+ """Execute a DAX query with a specific role applied to verify RLS filtering."""
500
+ self._require_connection()
501
+ from Microsoft.AnalysisServices.AdomdClient import ( # type: ignore[import]
502
+ AdomdCommand,
503
+ AdomdConnection,
504
+ )
505
+
506
+ conn_str = f"Data Source=localhost:{self._port};Roles={role_name}"
507
+ conn = AdomdConnection(conn_str)
508
+ try:
509
+ conn.Open()
510
+ cmd = AdomdCommand(dax_expression, conn)
511
+ reader = cmd.ExecuteReader()
512
+ cols = [reader.GetName(i).strip("[]") for i in range(reader.FieldCount)]
513
+ rows = []
514
+ while reader.Read():
515
+ row: dict[str, Any] = {}
516
+ for i, c in enumerate(cols):
517
+ try:
518
+ val = reader.GetValue(i)
519
+ row[c] = (
520
+ float(val)
521
+ if hasattr(val, "ToString") and not isinstance(val, str)
522
+ else val
523
+ )
524
+ except Exception:
525
+ row[c] = None
526
+ rows.append(row)
527
+ reader.Close()
528
+ return {"role": role_name, "rowCount": len(rows), "rows": rows}
529
+ finally:
530
+ conn.Close()
531
+
532
+ # --- Partitions ---
533
+
534
+ def partition_list(self, table: str | None = None) -> list[dict[str, Any]]:
535
+ self._require_connection()
536
+ results = []
537
+ for t in self._model.Tables:
538
+ if table and t.Name != table:
539
+ continue
540
+ for p in t.Partitions:
541
+ source_expr = ""
542
+ src = p.Source
543
+ if src is not None:
544
+ source_expr = getattr(src, "Expression", "") or getattr(src, "Query", "") or ""
545
+ results.append(
546
+ {
547
+ "table": t.Name,
548
+ "name": p.Name,
549
+ "mode": p.Mode.ToString(),
550
+ "state": p.State.ToString(),
551
+ "source": source_expr,
552
+ }
553
+ )
554
+ return results
555
+
556
+ def partition_add(self, table: str, name: str, query: str) -> dict[str, Any]:
557
+ self._require_connection()
558
+ from Microsoft.AnalysisServices.Tabular import ( # type: ignore[import]
559
+ MPartitionSource,
560
+ Partition,
561
+ )
562
+
563
+ t = self._model.Tables.Find(table)
564
+ if not t:
565
+ raise ValueError(f"Table '{table}' not found.")
566
+ p = Partition()
567
+ p.Name = name
568
+ src = MPartitionSource()
569
+ src.Expression = query
570
+ p.Source = src
571
+ t.Partitions.Add(p)
572
+ self._model.SaveChanges()
573
+ return {"table": table, "name": name, "query": query}
574
+
575
+ def partition_delete(self, table: str, name: str) -> None:
576
+ self._require_connection()
577
+ t = self._model.Tables.Find(table)
578
+ if t:
579
+ p = t.Partitions.Find(name)
580
+ if p:
581
+ t.Partitions.Remove(p)
582
+ self._model.SaveChanges()
583
+
584
+ def partition_refresh(self, table: str, name: str) -> dict[str, Any]:
585
+ self._require_connection()
586
+ from Microsoft.AnalysisServices.Tabular import RefreshType # type: ignore[import]
587
+
588
+ t = self._model.Tables.Find(table)
589
+ if not t:
590
+ raise ValueError(f"Table '{table}' not found.")
591
+ p = t.Partitions.Find(name)
592
+ if not p:
593
+ raise KeyError(f"Partition '{name}' not found in '{table}'.")
594
+ p.RequestRefresh(RefreshType.Full)
595
+ self._model.SaveChanges()
596
+ return {"table": table, "partition": name, "status": "refresh_requested"}
597
+
598
+ # --- Model Diff ---
599
+
600
+ def model_diff(self, snapshot_path: str) -> dict[str, Any]:
601
+ """Compare the current model against a TMDL snapshot directory."""
602
+ import pathlib
603
+ import tempfile
604
+
605
+ snap = pathlib.Path(snapshot_path)
606
+ if not snap.exists():
607
+ raise FileNotFoundError(f"Snapshot path not found: {snapshot_path}")
608
+
609
+ with tempfile.TemporaryDirectory() as tmp:
610
+ self.tmdl_export(tmp)
611
+ current: dict[str, str] = {
612
+ f.name: f.read_text(encoding="utf-8") for f in pathlib.Path(tmp).rglob("*.tmdl")
613
+ }
614
+
615
+ baseline: dict[str, str] = {
616
+ f.name: f.read_text(encoding="utf-8") for f in snap.rglob("*.tmdl")
617
+ }
618
+
619
+ added = sorted(k for k in current if k not in baseline)
620
+ removed = sorted(k for k in baseline if k not in current)
621
+ changed = sorted(k for k in current if k in baseline and current[k] != baseline[k])
622
+ unchanged = sorted(k for k in current if k in baseline and current[k] == baseline[k])
623
+
624
+ return {
625
+ "snapshot": str(snap),
626
+ "added": added,
627
+ "removed": removed,
628
+ "changed": changed,
629
+ "unchanged_count": len(unchanged),
630
+ "has_changes": bool(added or removed or changed),
631
+ }
632
+
633
+ # --- TMDL ---
634
+
635
+ def tmdl_export(self, path: str) -> None:
636
+ self._require_connection()
637
+ from Microsoft.AnalysisServices.Tabular import TmdlSerializer # type: ignore[import]
638
+
639
+ TmdlSerializer.SerializeDatabaseToFolder(self._db, path)
640
+
641
+ def tmdl_import(self, path: str) -> None:
642
+ self._require_connection()
643
+ from Microsoft.AnalysisServices.Tabular import TmdlSerializer # type: ignore[import]
644
+
645
+ TmdlSerializer.DeserializeDatabaseFromFolder(path, self._db)
646
+ self._model.SaveChanges()
647
+
648
+ def _require_connection(self) -> None:
649
+ if not self._connected:
650
+ raise RuntimeError("Not connected to Power BI Desktop. Run 'pbi connect' first.")