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,429 @@
|
|
|
1
|
+
"""Build Power BI PBIR GA visual JSON blobs.
|
|
2
|
+
|
|
3
|
+
Schema reference:
|
|
4
|
+
visualContainer: https://developer.microsoft.com/json-schemas/fabric/item/
|
|
5
|
+
report/definition/visualContainer/2.7.0/schema.json
|
|
6
|
+
visualConfiguration (embedded inside "visual" key):
|
|
7
|
+
- visualType, query, objects, visualContainerObjects
|
|
8
|
+
query.queryState:
|
|
9
|
+
- keys are visual-role names (e.g. Category, Y, Values)
|
|
10
|
+
- each role has a "projections" array
|
|
11
|
+
- each projection: { "field": <QueryExpressionContainer>, "queryRef": str }
|
|
12
|
+
Field expressions:
|
|
13
|
+
- Measure: {"Measure": {"Expression": {"SourceRef": {"Entity": tbl}}, "Property": prop}}
|
|
14
|
+
- Column: {"Column": {"Expression": {"SourceRef": {"Entity": tbl}}, "Property": prop}}
|
|
15
|
+
- Aggregation: {"Aggregation": {"Expression": {"Column": {...}}, "Function": 0}}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
AGG_SUM = 0
|
|
25
|
+
AGG_AVG = 1
|
|
26
|
+
AGG_MIN = 2
|
|
27
|
+
AGG_MAX = 3
|
|
28
|
+
AGG_COUNT = 4
|
|
29
|
+
AGG_NAMES = {AGG_SUM: "Sum", AGG_AVG: "Avg", AGG_MIN: "Min", AGG_MAX: "Max", AGG_COUNT: "Count"}
|
|
30
|
+
|
|
31
|
+
VISUAL_CONTAINER_SCHEMA = (
|
|
32
|
+
"https://developer.microsoft.com/json-schemas/fabric/item/report/definition/"
|
|
33
|
+
"visualContainer/2.7.0/schema.json"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FieldDef:
|
|
39
|
+
"""One field to bind to a visual role slot."""
|
|
40
|
+
|
|
41
|
+
entity: str # table name, e.g. "financials"
|
|
42
|
+
property: str # column or measure name, e.g. "Sales"
|
|
43
|
+
is_measure: bool = False # True = explicit DAX measure
|
|
44
|
+
agg: int | None = AGG_SUM # None = no aggregation (plain column)
|
|
45
|
+
|
|
46
|
+
@property # type: ignore[operator]
|
|
47
|
+
def query_ref(self) -> str:
|
|
48
|
+
"""Unique queryRef string for this field within a visual."""
|
|
49
|
+
if self.is_measure or self.agg is None:
|
|
50
|
+
return f"{self.entity}.{self.property}"
|
|
51
|
+
agg_name = AGG_NAMES.get(self.agg, "Sum")
|
|
52
|
+
return f"{agg_name}({self.entity}[{self.property}])"
|
|
53
|
+
|
|
54
|
+
def to_field_expr(self) -> dict[str, Any]:
|
|
55
|
+
"""Build the QueryExpressionContainer for this field."""
|
|
56
|
+
src = {"SourceRef": {"Entity": self.entity}}
|
|
57
|
+
if self.is_measure:
|
|
58
|
+
return {"Measure": {"Expression": src, "Property": self.property}}
|
|
59
|
+
if self.agg is None:
|
|
60
|
+
return {"Column": {"Expression": src, "Property": self.property}}
|
|
61
|
+
return {
|
|
62
|
+
"Aggregation": {
|
|
63
|
+
"Expression": {"Column": {"Expression": src, "Property": self.property}},
|
|
64
|
+
"Function": self.agg,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def to_projection(self) -> dict[str, Any]:
|
|
69
|
+
"""Build a projection entry for query.queryState.{role}.projections."""
|
|
70
|
+
return {"field": self.to_field_expr(), "queryRef": self.query_ref}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _new_id() -> str:
|
|
74
|
+
return uuid.uuid4().hex[:12]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _query_state(roles: dict[str, list[FieldDef]]) -> dict[str, Any]:
|
|
78
|
+
"""Build query.queryState from a role-name → FieldDef list mapping."""
|
|
79
|
+
return {
|
|
80
|
+
role: {"projections": [f.to_projection() for f in fields]}
|
|
81
|
+
for role, fields in roles.items()
|
|
82
|
+
if fields
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Visual body builders ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_card(value: FieldDef) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"visualType": "card",
|
|
92
|
+
"query": {"queryState": _query_state({"Values": [value]})},
|
|
93
|
+
"objects": {},
|
|
94
|
+
"visualContainerObjects": {},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_bar_chart(category: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"visualType": "barChart",
|
|
101
|
+
"query": {"queryState": _query_state({"Category": [category], "Y": [value]})},
|
|
102
|
+
"objects": {},
|
|
103
|
+
"visualContainerObjects": {},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_column_chart(category: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"visualType": "columnChart",
|
|
110
|
+
"query": {"queryState": _query_state({"Category": [category], "Y": [value]})},
|
|
111
|
+
"objects": {},
|
|
112
|
+
"visualContainerObjects": {},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_line_chart(axis: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"visualType": "lineChart",
|
|
119
|
+
"query": {"queryState": _query_state({"Category": [axis], "Y": [value]})},
|
|
120
|
+
"objects": {},
|
|
121
|
+
"visualContainerObjects": {},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_slicer(value: FieldDef) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"visualType": "slicer",
|
|
128
|
+
"query": {"queryState": _query_state({"Values": [value]})},
|
|
129
|
+
"objects": {
|
|
130
|
+
"data": [{"properties": {"mode": {"expr": {"Literal": {"Value": "'Basic'"}}}}}]
|
|
131
|
+
},
|
|
132
|
+
"visualContainerObjects": {},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_table(columns: list[FieldDef]) -> dict[str, Any]:
|
|
137
|
+
return {
|
|
138
|
+
"visualType": "tableEx",
|
|
139
|
+
"query": {"queryState": _query_state({"Values": columns})},
|
|
140
|
+
"objects": {},
|
|
141
|
+
"visualContainerObjects": {},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_multi_row_card(fields: list[FieldDef]) -> dict[str, Any]:
|
|
146
|
+
return {
|
|
147
|
+
"visualType": "multiRowCard",
|
|
148
|
+
"query": {"queryState": _query_state({"Values": fields})},
|
|
149
|
+
"objects": {},
|
|
150
|
+
"visualContainerObjects": {},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_scatter_chart(
|
|
155
|
+
x_axis: FieldDef,
|
|
156
|
+
y_axis: FieldDef,
|
|
157
|
+
details: FieldDef | None = None,
|
|
158
|
+
size: FieldDef | None = None,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
roles: dict[str, list[FieldDef]] = {"X": [x_axis], "Y": [y_axis]}
|
|
161
|
+
if details:
|
|
162
|
+
roles["Details"] = [details]
|
|
163
|
+
if size:
|
|
164
|
+
roles["Size"] = [size]
|
|
165
|
+
return {
|
|
166
|
+
"visualType": "scatterChart",
|
|
167
|
+
"query": {"queryState": _query_state(roles)},
|
|
168
|
+
"objects": {},
|
|
169
|
+
"visualContainerObjects": {},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_gauge(
|
|
174
|
+
value: FieldDef,
|
|
175
|
+
target: FieldDef | None = None,
|
|
176
|
+
min_val: FieldDef | None = None,
|
|
177
|
+
max_val: FieldDef | None = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
roles: dict[str, list[FieldDef]] = {"Y": [value]}
|
|
180
|
+
if target:
|
|
181
|
+
roles["TargetValue"] = [target]
|
|
182
|
+
if min_val:
|
|
183
|
+
roles["MinValue"] = [min_val]
|
|
184
|
+
if max_val:
|
|
185
|
+
roles["MaxValue"] = [max_val]
|
|
186
|
+
return {
|
|
187
|
+
"visualType": "gauge",
|
|
188
|
+
"query": {"queryState": _query_state(roles)},
|
|
189
|
+
"objects": {},
|
|
190
|
+
"visualContainerObjects": {},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_donut_chart(category: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
195
|
+
return {
|
|
196
|
+
"visualType": "donutChart",
|
|
197
|
+
"query": {"queryState": _query_state({"Category": [category], "Y": [value]})},
|
|
198
|
+
"objects": {},
|
|
199
|
+
"visualContainerObjects": {},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_pie_chart(category: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
204
|
+
return {
|
|
205
|
+
"visualType": "pieChart",
|
|
206
|
+
"query": {"queryState": _query_state({"Category": [category], "Y": [value]})},
|
|
207
|
+
"objects": {},
|
|
208
|
+
"visualContainerObjects": {},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_treemap(group: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
213
|
+
return {
|
|
214
|
+
"visualType": "treemap",
|
|
215
|
+
"query": {"queryState": _query_state({"Group": [group], "Values": [value]})},
|
|
216
|
+
"objects": {},
|
|
217
|
+
"visualContainerObjects": {},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def build_funnel(category: FieldDef, value: FieldDef) -> dict[str, Any]:
|
|
222
|
+
return {
|
|
223
|
+
"visualType": "funnel",
|
|
224
|
+
"query": {"queryState": _query_state({"Category": [category], "Y": [value]})},
|
|
225
|
+
"objects": {},
|
|
226
|
+
"visualContainerObjects": {},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_waterfall(
|
|
231
|
+
category: FieldDef, value: FieldDef, breakdown: FieldDef | None = None
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
roles: dict[str, list[FieldDef]] = {"Category": [category], "Y": [value]}
|
|
234
|
+
if breakdown:
|
|
235
|
+
roles["Breakdown"] = [breakdown]
|
|
236
|
+
return {
|
|
237
|
+
"visualType": "waterfallChart",
|
|
238
|
+
"query": {"queryState": _query_state(roles)},
|
|
239
|
+
"objects": {},
|
|
240
|
+
"visualContainerObjects": {},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_matrix(
|
|
245
|
+
rows: list[FieldDef], values: list[FieldDef], columns: list[FieldDef] | None = None
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
roles: dict[str, list[FieldDef]] = {"Rows": rows, "Values": values}
|
|
248
|
+
if columns:
|
|
249
|
+
roles["Columns"] = columns
|
|
250
|
+
return {
|
|
251
|
+
"visualType": "pivotTable",
|
|
252
|
+
"query": {"queryState": _query_state(roles)},
|
|
253
|
+
"objects": {},
|
|
254
|
+
"visualContainerObjects": {},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_ribbon_chart(
|
|
259
|
+
category: FieldDef, value: FieldDef, series: FieldDef | None = None
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
roles: dict[str, list[FieldDef]] = {"Category": [category], "Y": [value]}
|
|
262
|
+
if series:
|
|
263
|
+
roles["Series"] = [series]
|
|
264
|
+
return {
|
|
265
|
+
"visualType": "ribbonChart",
|
|
266
|
+
"query": {"queryState": _query_state(roles)},
|
|
267
|
+
"objects": {},
|
|
268
|
+
"visualContainerObjects": {},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── VisualSpec and serialisation ───────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class VisualSpec:
|
|
277
|
+
visual_type: str
|
|
278
|
+
visual_body: dict[str, Any]
|
|
279
|
+
x: int = 0
|
|
280
|
+
y: int = 0
|
|
281
|
+
width: int = 300
|
|
282
|
+
height: int = 200
|
|
283
|
+
tab_order: int = 0
|
|
284
|
+
name: str = field(default_factory=_new_id)
|
|
285
|
+
title: str = ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def spec_to_pbir_visual(spec: VisualSpec) -> dict[str, Any]:
|
|
289
|
+
"""Produce the visual.json dict for PBIR GA format."""
|
|
290
|
+
body = dict(spec.visual_body) # shallow copy
|
|
291
|
+
|
|
292
|
+
if spec.title:
|
|
293
|
+
body.setdefault("visualContainerObjects", {})
|
|
294
|
+
body["visualContainerObjects"]["title"] = [
|
|
295
|
+
{
|
|
296
|
+
"properties": {
|
|
297
|
+
"show": {"expr": {"Literal": {"Value": "true"}}},
|
|
298
|
+
"text": {"expr": {"Literal": {"Value": f"'{spec.title}'"}}},
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"$schema": VISUAL_CONTAINER_SCHEMA,
|
|
305
|
+
"name": spec.name,
|
|
306
|
+
"position": {
|
|
307
|
+
"x": spec.x,
|
|
308
|
+
"y": spec.y,
|
|
309
|
+
"z": 0,
|
|
310
|
+
"width": spec.width,
|
|
311
|
+
"height": spec.height,
|
|
312
|
+
"tabOrder": spec.tab_order,
|
|
313
|
+
},
|
|
314
|
+
"visual": body,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def spec_to_old_pbip_container(spec: VisualSpec) -> dict[str, Any]:
|
|
319
|
+
"""Produce a visualContainer for old single-file PBIP report.json."""
|
|
320
|
+
import json as _json
|
|
321
|
+
|
|
322
|
+
# Old format still uses projections/prototypeQuery inside singleVisual config
|
|
323
|
+
body = spec.visual_body
|
|
324
|
+
qs = body.get("query", {}).get("queryState", {})
|
|
325
|
+
|
|
326
|
+
projections: dict[str, Any] = {}
|
|
327
|
+
select_items = []
|
|
328
|
+
from_aliases: dict[str, str] = {}
|
|
329
|
+
|
|
330
|
+
for role, role_data in qs.items():
|
|
331
|
+
role_projs = []
|
|
332
|
+
for proj in role_data.get("projections", []):
|
|
333
|
+
qr = proj["queryRef"]
|
|
334
|
+
role_projs.append({"queryRef": qr})
|
|
335
|
+
fe = proj["field"]
|
|
336
|
+
select_item, alias = _field_expr_to_select(fe, from_aliases)
|
|
337
|
+
select_item["Name"] = qr
|
|
338
|
+
select_items.append(select_item)
|
|
339
|
+
projections[role] = role_projs
|
|
340
|
+
|
|
341
|
+
from_items = [
|
|
342
|
+
{"Name": alias, "Entity": entity, "Type": 0} for entity, alias in from_aliases.items()
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
config = {
|
|
346
|
+
"name": spec.name,
|
|
347
|
+
"layouts": [
|
|
348
|
+
{
|
|
349
|
+
"id": 0,
|
|
350
|
+
"position": {
|
|
351
|
+
"x": spec.x,
|
|
352
|
+
"y": spec.y,
|
|
353
|
+
"z": 0,
|
|
354
|
+
"width": spec.width,
|
|
355
|
+
"height": spec.height,
|
|
356
|
+
"tabOrder": spec.tab_order,
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
],
|
|
360
|
+
"singleVisual": {
|
|
361
|
+
"visualType": body.get("visualType", spec.visual_type),
|
|
362
|
+
"projections": projections,
|
|
363
|
+
"prototypeQuery": {"Version": 2, "From": from_items, "Select": select_items},
|
|
364
|
+
"columnProperties": {},
|
|
365
|
+
"objects": body.get("objects", {}),
|
|
366
|
+
"vcObjects": {},
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
if spec.title:
|
|
370
|
+
config["singleVisual"]["objects"].setdefault( # type: ignore[index]
|
|
371
|
+
"title",
|
|
372
|
+
[
|
|
373
|
+
{
|
|
374
|
+
"properties": {
|
|
375
|
+
"show": {"expr": {"Literal": {"Value": "true"}}},
|
|
376
|
+
"text": {"expr": {"Literal": {"Value": f"'{spec.title}'"}}},
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
],
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"x": spec.x,
|
|
384
|
+
"y": spec.y,
|
|
385
|
+
"z": 0,
|
|
386
|
+
"width": spec.width,
|
|
387
|
+
"height": spec.height,
|
|
388
|
+
"config": _json.dumps(config, separators=(",", ":")),
|
|
389
|
+
"filters": "[]",
|
|
390
|
+
"tabOrder": spec.tab_order,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _field_expr_to_select(
|
|
395
|
+
fe: dict[str, Any],
|
|
396
|
+
from_aliases: dict[str, str],
|
|
397
|
+
) -> tuple[dict[str, Any], str]:
|
|
398
|
+
"""Convert a PBIR GA field expression to an old-format SELECT item."""
|
|
399
|
+
|
|
400
|
+
def _alias(entity: str) -> str:
|
|
401
|
+
if entity not in from_aliases:
|
|
402
|
+
from_aliases[entity] = entity[0].lower() + str(len(from_aliases))
|
|
403
|
+
return from_aliases[entity]
|
|
404
|
+
|
|
405
|
+
if "Measure" in fe:
|
|
406
|
+
m = fe["Measure"]
|
|
407
|
+
entity = m["Expression"]["SourceRef"]["Entity"]
|
|
408
|
+
alias = _alias(entity)
|
|
409
|
+
src = {"SourceRef": {"Name": alias}}
|
|
410
|
+
return {"Measure": {"Expression": src, "Property": m["Property"]}}, alias
|
|
411
|
+
|
|
412
|
+
if "Column" in fe:
|
|
413
|
+
c = fe["Column"]
|
|
414
|
+
entity = c["Expression"]["SourceRef"]["Entity"]
|
|
415
|
+
alias = _alias(entity)
|
|
416
|
+
src = {"SourceRef": {"Name": alias}}
|
|
417
|
+
return {"Column": {"Expression": src, "Property": c["Property"]}}, alias
|
|
418
|
+
|
|
419
|
+
if "Aggregation" in fe:
|
|
420
|
+
a = fe["Aggregation"]
|
|
421
|
+
inner, alias = _field_expr_to_select(a["Expression"], from_aliases)
|
|
422
|
+
return {
|
|
423
|
+
"Aggregation": {
|
|
424
|
+
"Expression": inner["Column"] if "Column" in inner else inner,
|
|
425
|
+
"Function": a["Function"],
|
|
426
|
+
}
|
|
427
|
+
}, alias
|
|
428
|
+
|
|
429
|
+
return fe, ""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Recommend Power BI visual types for a given set of measures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
VISUAL_RULES = [
|
|
6
|
+
(
|
|
7
|
+
{"revenue", "sales", "amount", "total"},
|
|
8
|
+
"Clustered Bar Chart",
|
|
9
|
+
"Good for comparing amounts across categories",
|
|
10
|
+
),
|
|
11
|
+
({"ytd", "yoy", "mom", "trend", "growth"}, "Line Chart", "Time series and trend analysis"),
|
|
12
|
+
({"kpi", "target", "actual", "vs"}, "KPI Card", "Show performance against a target"),
|
|
13
|
+
({"rate", "ratio", "%", "percent"}, "Gauge", "Proportion and completion rates"),
|
|
14
|
+
({"region", "country", "city", "location"}, "Map", "Geographic distribution"),
|
|
15
|
+
({"rank", "top", "bottom"}, "Bar Chart (sorted)", "Ranking visualisation"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VisualRecommender:
|
|
20
|
+
def recommend(self, measures: list[str]) -> list[dict]:
|
|
21
|
+
measure_text = " ".join(measures).lower()
|
|
22
|
+
recommendations = []
|
|
23
|
+
for keywords, visual_type, rationale in VISUAL_RULES:
|
|
24
|
+
if any(kw in measure_text for kw in keywords):
|
|
25
|
+
recommendations.append(
|
|
26
|
+
{
|
|
27
|
+
"visual": visual_type,
|
|
28
|
+
"rationale": rationale,
|
|
29
|
+
"matchedMeasures": [
|
|
30
|
+
m for m in measures if any(kw in m.lower() for kw in keywords)
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
if not recommendations:
|
|
35
|
+
recommendations.append(
|
|
36
|
+
{
|
|
37
|
+
"visual": "Table",
|
|
38
|
+
"rationale": "Default fallback for tabular data",
|
|
39
|
+
"matchedMeasures": measures,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
return recommendations
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI server package for pbi-cli."""
|
pbi_cli/server/api.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""FastAPI REST server — wraps all pbi-cli commands as HTTP endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, HTTPException
|
|
13
|
+
from fastapi.responses import FileResponse
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
|
|
16
|
+
app = FastAPI(title="pbi-server", version="0.1.0.dev0", docs_url="/api/docs")
|
|
17
|
+
|
|
18
|
+
# ── Singleton backend ──────────────────────────────────────────────────
|
|
19
|
+
_backend: Any = None
|
|
20
|
+
|
|
21
|
+
def get_backend() -> Any:
|
|
22
|
+
global _backend
|
|
23
|
+
if _backend is None:
|
|
24
|
+
from pbi_cli.backends.tom_backend import TomBackend
|
|
25
|
+
|
|
26
|
+
_backend = TomBackend()
|
|
27
|
+
if not _backend.is_connected():
|
|
28
|
+
try:
|
|
29
|
+
_backend.connect()
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=503, detail=f"Not connected to Power BI Desktop: {exc}"
|
|
33
|
+
)
|
|
34
|
+
return _backend
|
|
35
|
+
|
|
36
|
+
# ── Status ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
@app.get("/api/status")
|
|
39
|
+
def status() -> dict:
|
|
40
|
+
from pbi_cli.backends.tom_backend import find_pbi_port
|
|
41
|
+
|
|
42
|
+
port = find_pbi_port()
|
|
43
|
+
if port is None:
|
|
44
|
+
return {"connected": False, "message": "No running Power BI Desktop found"}
|
|
45
|
+
try:
|
|
46
|
+
b = get_backend()
|
|
47
|
+
info = b.model_info()
|
|
48
|
+
return {
|
|
49
|
+
"connected": True,
|
|
50
|
+
"port": port,
|
|
51
|
+
"model": info["name"],
|
|
52
|
+
"compatibilityLevel": info.get("compatibilityLevel"),
|
|
53
|
+
}
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
return {"connected": False, "error": str(exc)}
|
|
56
|
+
|
|
57
|
+
# ── Model ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@app.get("/api/tables")
|
|
60
|
+
def list_tables() -> list[dict]:
|
|
61
|
+
return get_backend().table_list()
|
|
62
|
+
|
|
63
|
+
@app.get("/api/columns")
|
|
64
|
+
def list_columns(table: str | None = None) -> list[dict]:
|
|
65
|
+
return get_backend().column_list(table=table)
|
|
66
|
+
|
|
67
|
+
@app.get("/api/relationships")
|
|
68
|
+
def list_relationships() -> list[dict]:
|
|
69
|
+
return get_backend().relationship_list()
|
|
70
|
+
|
|
71
|
+
# ── Measures ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@app.get("/api/measures")
|
|
74
|
+
def list_measures(table: str | None = None) -> list[dict]:
|
|
75
|
+
return get_backend().measure_list(table=table)
|
|
76
|
+
|
|
77
|
+
class MeasureCreate(BaseModel):
|
|
78
|
+
table: str
|
|
79
|
+
name: str
|
|
80
|
+
expression: str
|
|
81
|
+
formatString: str = ""
|
|
82
|
+
description: str = ""
|
|
83
|
+
|
|
84
|
+
@app.post("/api/measures", status_code=201)
|
|
85
|
+
def create_measure(body: MeasureCreate) -> dict:
|
|
86
|
+
kwargs: dict[str, Any] = {}
|
|
87
|
+
if body.formatString:
|
|
88
|
+
kwargs["formatString"] = body.formatString
|
|
89
|
+
if body.description:
|
|
90
|
+
kwargs["description"] = body.description
|
|
91
|
+
try:
|
|
92
|
+
return get_backend().measure_add(body.table, body.name, body.expression, **kwargs)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
95
|
+
|
|
96
|
+
class MeasureUpdate(BaseModel):
|
|
97
|
+
expression: str | None = None
|
|
98
|
+
formatString: str | None = None
|
|
99
|
+
description: str | None = None
|
|
100
|
+
|
|
101
|
+
@app.patch("/api/measures/{table}/{name}")
|
|
102
|
+
def update_measure(table: str, name: str, body: MeasureUpdate) -> dict:
|
|
103
|
+
kwargs = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
104
|
+
try:
|
|
105
|
+
return get_backend().measure_update(table, name, **kwargs)
|
|
106
|
+
except KeyError:
|
|
107
|
+
raise HTTPException(status_code=404, detail=f"Measure '{name}' not found in '{table}'")
|
|
108
|
+
|
|
109
|
+
@app.delete("/api/measures/{table}/{name}", status_code=204)
|
|
110
|
+
def delete_measure(table: str, name: str) -> None:
|
|
111
|
+
try:
|
|
112
|
+
get_backend().measure_delete(table, name)
|
|
113
|
+
except KeyError:
|
|
114
|
+
raise HTTPException(status_code=404, detail=f"Measure '{name}' not found")
|
|
115
|
+
|
|
116
|
+
# ── DAX ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
class DaxQuery(BaseModel):
|
|
119
|
+
expression: str
|
|
120
|
+
|
|
121
|
+
@app.post("/api/dax/query")
|
|
122
|
+
def dax_query(body: DaxQuery) -> list[dict]:
|
|
123
|
+
try:
|
|
124
|
+
return get_backend().dax_query(body.expression)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
127
|
+
|
|
128
|
+
@app.post("/api/dax/validate")
|
|
129
|
+
def dax_validate(body: DaxQuery) -> dict:
|
|
130
|
+
return get_backend().dax_validate(body.expression)
|
|
131
|
+
|
|
132
|
+
# ── Governance ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
@app.get("/api/govern/check")
|
|
135
|
+
def govern_check() -> list[dict]:
|
|
136
|
+
from pbi_cli.governance.engine import GovernanceEngine
|
|
137
|
+
|
|
138
|
+
return GovernanceEngine(get_backend()).run_all()
|
|
139
|
+
|
|
140
|
+
@app.post("/api/govern/fix")
|
|
141
|
+
def govern_fix() -> dict:
|
|
142
|
+
from pbi_cli.governance.engine import GovernanceEngine
|
|
143
|
+
|
|
144
|
+
engine = GovernanceEngine(get_backend())
|
|
145
|
+
violations = engine.run_all()
|
|
146
|
+
fixable = [v for v in violations if v.get("autoFixable")]
|
|
147
|
+
fixed = engine.auto_fix(fixable)
|
|
148
|
+
return {"fixed": fixed}
|
|
149
|
+
|
|
150
|
+
# ── Docs ───────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
@app.get("/api/docs/markdown", response_class=__import__("fastapi").responses.PlainTextResponse)
|
|
153
|
+
def docs_markdown() -> str:
|
|
154
|
+
from pbi_cli.docs_gen.markdown import MarkdownDocsGenerator
|
|
155
|
+
|
|
156
|
+
return MarkdownDocsGenerator(get_backend()).generate()
|
|
157
|
+
|
|
158
|
+
# ── Suggest ────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
@app.get("/api/suggest/measures")
|
|
161
|
+
def suggest_measures() -> list[dict]:
|
|
162
|
+
b = get_backend()
|
|
163
|
+
from pbi_cli.commands.model import _build_measure_suggestions
|
|
164
|
+
|
|
165
|
+
return _build_measure_suggestions(b.table_list(), b.column_list())
|
|
166
|
+
|
|
167
|
+
@app.post("/api/suggest/visuals")
|
|
168
|
+
def suggest_visuals(body: dict) -> list[dict]:
|
|
169
|
+
from pbi_cli.intelligence.visual_recommender import VisualRecommender
|
|
170
|
+
|
|
171
|
+
measures = body.get("measures", [])
|
|
172
|
+
return VisualRecommender().recommend(measures)
|
|
173
|
+
|
|
174
|
+
# ── Static frontend ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
_static_dir = Path(__file__).parent / "static"
|
|
177
|
+
if _static_dir.exists():
|
|
178
|
+
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
|
179
|
+
|
|
180
|
+
@app.get("/", include_in_schema=False)
|
|
181
|
+
def index() -> FileResponse:
|
|
182
|
+
return FileResponse(str(_static_dir / "index.html"))
|
|
183
|
+
|
|
184
|
+
except ImportError:
|
|
185
|
+
app = None # type: ignore[assignment]
|