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,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]