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,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.")
|