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,813 @@
1
+ """PBIR backend — reads and writes Power BI Project (.pbip) report files.
2
+
3
+ Supports two formats:
4
+ - PBIR GA (new): {Name}.Report/definition/pages/{Page}/visuals/{id}.visual/visual.json
5
+ - Old PBIP (legacy): {Name}.Report/report.json with embedded visualContainers
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import shutil
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _slug(name: str) -> str:
19
+ """Sanitise a display name for use as a directory name."""
20
+ return re.sub(r"[^\w\- ]", "", name).strip().replace(" ", "_")
21
+
22
+
23
+ def _new_id() -> str:
24
+ return uuid.uuid4().hex[:12]
25
+
26
+
27
+ class PbirBackend:
28
+ """Read/write Power BI report JSON files in a .pbip project folder."""
29
+
30
+ PAGE_W = 1280
31
+ PAGE_H = 720
32
+
33
+ def __init__(self, pbip_path: str | Path | None = None) -> None:
34
+ self._root: Path | None = None
35
+ self._report_dir: Path | None = None
36
+ self._format: str = "unknown" # "pbir_ga" | "old_pbip"
37
+ self._report_data: dict[str, Any] = {} # only used for old_pbip
38
+ if pbip_path:
39
+ self.load(pbip_path)
40
+
41
+ # ── Loading ────────────────────────────────────────────────────────────────
42
+
43
+ def load(self, pbip_path: str | Path) -> None:
44
+ """Load a .pbip project folder or the .pbip file itself."""
45
+ p = Path(pbip_path)
46
+ if p.is_file() and p.suffix == ".pbip":
47
+ p = p.parent
48
+
49
+ self._root = p
50
+ # Find the .Report subfolder
51
+ report_dirs = list(p.glob("*.Report"))
52
+ if not report_dirs:
53
+ raise FileNotFoundError(
54
+ f"No *.Report folder found in {p}. "
55
+ "Save your file as a Power BI Project (.pbip) in Power BI Desktop first."
56
+ )
57
+ self._report_dir = report_dirs[0]
58
+
59
+ # Detect format
60
+ if (self._report_dir / "definition").exists():
61
+ self._format = "pbir_ga"
62
+ elif (self._report_dir / "report.json").exists():
63
+ self._format = "old_pbip"
64
+ self._report_data = json.loads(
65
+ (self._report_dir / "report.json").read_text(encoding="utf-8")
66
+ )
67
+ else:
68
+ # Create definition structure for a brand-new report folder
69
+ self._format = "pbir_ga"
70
+ (self._report_dir / "definition" / "pages").mkdir(parents=True, exist_ok=True)
71
+ self._write_ga_report_json()
72
+
73
+ # ── Pages ──────────────────────────────────────────────────────────────────
74
+
75
+ def page_list(self) -> list[dict[str, Any]]:
76
+ self._require_load()
77
+ if self._format == "pbir_ga":
78
+ return self._ga_page_list()
79
+ return self._old_page_list()
80
+
81
+ def page_add(self, display_name: str) -> dict[str, Any]:
82
+ self._require_load()
83
+ if self._format == "pbir_ga":
84
+ return self._ga_page_add(display_name)
85
+ return self._old_page_add(display_name)
86
+
87
+ def page_delete(self, display_name: str) -> None:
88
+ self._require_load()
89
+ if self._format == "pbir_ga":
90
+ self._ga_page_delete(display_name)
91
+ else:
92
+ self._old_page_delete(display_name)
93
+
94
+ # ── Visuals ────────────────────────────────────────────────────────────────
95
+
96
+ def visual_list(self, page: str) -> list[dict[str, Any]]:
97
+ self._require_load()
98
+ if self._format == "pbir_ga":
99
+ return self._ga_visual_list(page)
100
+ return self._old_visual_list(page)
101
+
102
+ def visual_add(self, page: str, spec: Any) -> dict[str, Any]:
103
+ """Add a visual to a page. spec is a VisualSpec from visual_builder."""
104
+ self._require_load()
105
+ if self._format == "pbir_ga":
106
+ return self._ga_visual_add(page, spec)
107
+ return self._old_visual_add(page, spec)
108
+
109
+ def visual_delete(self, page: str, visual_name: str) -> None:
110
+ self._require_load()
111
+ if self._format == "pbir_ga":
112
+ self._ga_visual_delete(page, visual_name)
113
+ else:
114
+ self._old_visual_delete(page, visual_name)
115
+
116
+ def page_clear(self, page: str) -> None:
117
+ """Remove all visuals from a page."""
118
+ self._require_load()
119
+ for v in self.visual_list(page):
120
+ try:
121
+ self.visual_delete(page, v["name"])
122
+ except Exception:
123
+ pass
124
+
125
+ # ── Themes ─────────────────────────────────────────────────────────────────
126
+
127
+ def theme_apply(self, theme_json: dict[str, Any]) -> None:
128
+ """Write a theme JSON to the report's theme file."""
129
+ self._require_load()
130
+ assert self._report_dir
131
+ theme_dir = self._report_dir / "StaticResources" / "SharedResources" / "BaseThemes"
132
+ theme_dir.mkdir(parents=True, exist_ok=True)
133
+ (theme_dir / "CY24SU10.json").write_text(json.dumps(theme_json, indent=2), encoding="utf-8")
134
+
135
+ # ── PBIR GA implementation ─────────────────────────────────────────────────
136
+ # Pages are stored in folders named by their GUID (= page `name` field).
137
+ # A pages.json file at the pages/ level tracks page order.
138
+
139
+ def _ga_pages_dir(self) -> Path:
140
+ assert self._report_dir
141
+ d = self._report_dir / "definition" / "pages"
142
+ d.mkdir(parents=True, exist_ok=True)
143
+ return d
144
+
145
+ def _ga_read_pages_json(self) -> dict[str, Any]:
146
+ pj = self._ga_pages_dir() / "pages.json"
147
+ if pj.exists():
148
+ return json.loads(pj.read_text(encoding="utf-8"))
149
+ return {"pageOrder": [], "activePageName": ""}
150
+
151
+ def _ga_write_pages_json(self, data: dict[str, Any]) -> None:
152
+ pj = self._ga_pages_dir() / "pages.json"
153
+ if "$schema" not in data:
154
+ data["$schema"] = (
155
+ "https://developer.microsoft.com/json-schemas/fabric/item/"
156
+ "report/definition/pagesMetadata/1.0.0/schema.json"
157
+ )
158
+ pj.write_text(json.dumps(data, indent=2), encoding="utf-8")
159
+
160
+ def _ga_page_list(self) -> list[dict[str, Any]]:
161
+ pages_meta = self._ga_read_pages_json()
162
+ order = pages_meta.get("pageOrder", [])
163
+ pages_dir = self._ga_pages_dir()
164
+
165
+ # Collect all valid pages, preserving order from pages.json
166
+ by_id: dict[str, dict[str, Any]] = {}
167
+ for page_dir in pages_dir.iterdir():
168
+ if not page_dir.is_dir():
169
+ continue
170
+ pj = page_dir / "page.json"
171
+ if not pj.exists():
172
+ continue
173
+ data = json.loads(pj.read_text(encoding="utf-8"))
174
+ pid = data.get("name", page_dir.name)
175
+ by_id[pid] = {
176
+ "name": pid,
177
+ "displayName": data.get("displayName", page_dir.name),
178
+ "width": data.get("width", self.PAGE_W),
179
+ "height": data.get("height", self.PAGE_H),
180
+ }
181
+
182
+ # Return in declared order, then any extras
183
+ result = [by_id[pid] for pid in order if pid in by_id]
184
+ result += [v for k, v in by_id.items() if k not in order]
185
+ return result
186
+
187
+ def _ga_page_add(self, display_name: str) -> dict[str, Any]:
188
+ # Folder name = page GUID (same value as `name` field)
189
+ page_id = uuid.uuid4().hex
190
+ page_dir = self._ga_pages_dir() / page_id
191
+ page_dir.mkdir(parents=True, exist_ok=True)
192
+ (page_dir / "visuals").mkdir(exist_ok=True)
193
+
194
+ page_json: dict[str, Any] = {
195
+ "$schema": (
196
+ "https://developer.microsoft.com/json-schemas/fabric/item/"
197
+ "report/definition/page/2.1.0/schema.json"
198
+ ),
199
+ "name": page_id,
200
+ "displayName": display_name,
201
+ "displayOption": "FitToPage",
202
+ "width": self.PAGE_W,
203
+ "height": self.PAGE_H,
204
+ }
205
+ (page_dir / "page.json").write_text(json.dumps(page_json, indent=2), encoding="utf-8")
206
+
207
+ # Update pages.json
208
+ meta = self._ga_read_pages_json()
209
+ meta.setdefault("pageOrder", []).append(page_id)
210
+ if not meta.get("activePageName"):
211
+ meta["activePageName"] = page_id
212
+ self._ga_write_pages_json(meta)
213
+
214
+ return {"name": page_id, "displayName": display_name}
215
+
216
+ def _ga_page_delete(self, display_name: str) -> None:
217
+ for page_dir in self._ga_pages_dir().iterdir():
218
+ if not page_dir.is_dir():
219
+ continue
220
+ pj = page_dir / "page.json"
221
+ if not pj.exists():
222
+ continue
223
+ data = json.loads(pj.read_text(encoding="utf-8"))
224
+ if data.get("displayName") == display_name:
225
+ page_id = data.get("name", page_dir.name)
226
+ shutil.rmtree(page_dir)
227
+ # Remove from pages.json
228
+ meta = self._ga_read_pages_json()
229
+ meta["pageOrder"] = [p for p in meta.get("pageOrder", []) if p != page_id]
230
+ if meta.get("activePageName") == page_id:
231
+ meta["activePageName"] = meta["pageOrder"][0] if meta["pageOrder"] else ""
232
+ self._ga_write_pages_json(meta)
233
+ return
234
+
235
+ def _ga_find_page_dir(self, page: str) -> Path | None:
236
+ """Find a page directory by displayName or page GUID."""
237
+ for page_dir in self._ga_pages_dir().iterdir():
238
+ if not page_dir.is_dir():
239
+ continue
240
+ pj = page_dir / "page.json"
241
+ if not pj.exists():
242
+ continue
243
+ data = json.loads(pj.read_text(encoding="utf-8"))
244
+ if data.get("displayName") == page or data.get("name") == page:
245
+ return page_dir
246
+ return None
247
+
248
+ def _ga_visuals_dir(self, page: str) -> Path | None:
249
+ page_dir = self._ga_find_page_dir(page)
250
+ if not page_dir:
251
+ return None
252
+ vd = page_dir / "visuals"
253
+ vd.mkdir(exist_ok=True)
254
+ return vd
255
+
256
+ def _ga_visual_list(self, page: str) -> list[dict[str, Any]]:
257
+ vd = self._ga_visuals_dir(page)
258
+ if not vd:
259
+ return []
260
+ results = []
261
+ for vdir in sorted(vd.iterdir()):
262
+ if not vdir.is_dir():
263
+ continue
264
+ vj = vdir / "visual.json"
265
+ if not vj.exists():
266
+ continue
267
+ data = json.loads(vj.read_text(encoding="utf-8"))
268
+ pos = data.get("position", {})
269
+ results.append(
270
+ {
271
+ "name": data.get("name", vdir.name),
272
+ "visualType": data.get("visual", {}).get("visualType", ""),
273
+ "x": pos.get("x", 0),
274
+ "y": pos.get("y", 0),
275
+ "width": pos.get("width", 0),
276
+ "height": pos.get("height", 0),
277
+ }
278
+ )
279
+ return results
280
+
281
+ def _ga_visual_add(self, page: str, spec: Any) -> dict[str, Any]:
282
+ from pbi_cli.intelligence.visual_builder import spec_to_pbir_visual
283
+
284
+ vd = self._ga_visuals_dir(page)
285
+ if vd is None:
286
+ self._ga_page_add(page)
287
+ vd = self._ga_visuals_dir(page)
288
+ assert vd is not None
289
+
290
+ visual_json = spec_to_pbir_visual(spec)
291
+ # Visual folder name = visual name (GUID)
292
+ vdir = vd / spec.name
293
+ vdir.mkdir(exist_ok=True)
294
+ (vdir / "visual.json").write_text(json.dumps(visual_json, indent=2), encoding="utf-8")
295
+ return {"name": spec.name, "visualType": spec.visual_type, "page": page}
296
+
297
+ def _ga_visual_delete(self, page: str, visual_name: str) -> None:
298
+ vd = self._ga_visuals_dir(page)
299
+ if not vd:
300
+ return
301
+ for vdir in vd.iterdir():
302
+ if not vdir.is_dir():
303
+ continue
304
+ vj = vdir / "visual.json"
305
+ if not vj.exists():
306
+ continue
307
+ data = json.loads(vj.read_text(encoding="utf-8"))
308
+ if data.get("name") == visual_name or vdir.name == visual_name:
309
+ shutil.rmtree(vdir)
310
+ return
311
+
312
+ def _write_ga_report_json(self) -> None:
313
+ assert self._report_dir
314
+ report_json = {
315
+ "$schema": (
316
+ "https://developer.microsoft.com/json-schemas/fabric/item/"
317
+ "report/definition/report/3.2.0/schema.json"
318
+ ),
319
+ "themeCollection": {
320
+ "baseTheme": {
321
+ "name": "Fluent2-CY26SU04",
322
+ "reportVersionAtImport": {
323
+ "visual": "2.8.0",
324
+ "report": "3.2.0",
325
+ "page": "2.3.1",
326
+ },
327
+ "type": "SharedResources",
328
+ }
329
+ },
330
+ }
331
+ rj = self._report_dir / "definition" / "report.json"
332
+ rj.parent.mkdir(parents=True, exist_ok=True)
333
+ rj.write_text(json.dumps(report_json, indent=2), encoding="utf-8")
334
+
335
+ # ── Old PBIP implementation ────────────────────────────────────────────────
336
+
337
+ def _old_page_list(self) -> list[dict[str, Any]]:
338
+ return [
339
+ {
340
+ "name": s.get("name", s.get("id", "")),
341
+ "displayName": s.get("displayName", s.get("name", "")),
342
+ "width": s.get("width", self.PAGE_W),
343
+ "height": s.get("height", self.PAGE_H),
344
+ }
345
+ for s in self._report_data.get("sections", [])
346
+ ]
347
+
348
+ def _old_page_add(self, display_name: str) -> dict[str, Any]:
349
+ sections = self._report_data.setdefault("sections", [])
350
+ ordinal = len(sections)
351
+ page_id = f"ReportSection{ordinal + 1}"
352
+ section: dict[str, Any] = {
353
+ "id": page_id,
354
+ "name": page_id,
355
+ "displayName": display_name,
356
+ "filters": "[]",
357
+ "ordinal": ordinal,
358
+ "visualContainers": [],
359
+ "config": json.dumps(
360
+ {"defaultVisualInteraction": "includeFilters"}, separators=(",", ":")
361
+ ),
362
+ "width": self.PAGE_W,
363
+ "height": self.PAGE_H,
364
+ }
365
+ sections.append(section)
366
+ self._save_old()
367
+ return {"name": page_id, "displayName": display_name}
368
+
369
+ def _old_page_delete(self, display_name: str) -> None:
370
+ sections = self._report_data.get("sections", [])
371
+ self._report_data["sections"] = [
372
+ s
373
+ for s in sections
374
+ if s.get("displayName") != display_name and s.get("name") != display_name
375
+ ]
376
+ self._save_old()
377
+
378
+ def _old_find_section(self, page: str) -> dict[str, Any] | None:
379
+ for s in self._report_data.get("sections", []):
380
+ if s.get("displayName") == page or s.get("name") == page:
381
+ return s
382
+ return None
383
+
384
+ def _old_visual_list(self, page: str) -> list[dict[str, Any]]:
385
+ section = self._old_find_section(page)
386
+ if not section:
387
+ return []
388
+ results = []
389
+ for vc in section.get("visualContainers", []):
390
+ try:
391
+ cfg = json.loads(vc.get("config", "{}"))
392
+ results.append(
393
+ {
394
+ "name": cfg.get("name", ""),
395
+ "visualType": cfg.get("singleVisual", {}).get("visualType", ""),
396
+ "x": vc.get("x", 0),
397
+ "y": vc.get("y", 0),
398
+ "width": vc.get("width", 0),
399
+ "height": vc.get("height", 0),
400
+ }
401
+ )
402
+ except Exception:
403
+ pass
404
+ return results
405
+
406
+ def _old_visual_add(self, page: str, spec: Any) -> dict[str, Any]:
407
+ from pbi_cli.intelligence.visual_builder import spec_to_old_pbip_container
408
+
409
+ section = self._old_find_section(page)
410
+ if section is None:
411
+ self._old_page_add(page)
412
+ section = self._old_find_section(page)
413
+ assert section is not None
414
+ container = spec_to_old_pbip_container(spec)
415
+ section.setdefault("visualContainers", []).append(container)
416
+ self._save_old()
417
+ return {"name": spec.name, "visualType": spec.visual_type, "page": page}
418
+
419
+ def _old_visual_delete(self, page: str, visual_name: str) -> None:
420
+ section = self._old_find_section(page)
421
+ if not section:
422
+ return
423
+ kept = []
424
+ for vc in section.get("visualContainers", []):
425
+ try:
426
+ cfg = json.loads(vc.get("config", "{}"))
427
+ if cfg.get("name") != visual_name:
428
+ kept.append(vc)
429
+ except Exception:
430
+ kept.append(vc)
431
+ section["visualContainers"] = kept
432
+ self._save_old()
433
+
434
+ def _save_old(self) -> None:
435
+ assert self._report_dir
436
+ (self._report_dir / "report.json").write_text(
437
+ json.dumps(self._report_data, indent=2, ensure_ascii=False),
438
+ encoding="utf-8",
439
+ )
440
+
441
+ # ── Bookmarks ──────────────────────────────────────────────────────────────
442
+ # PBIR GA stores bookmarks as flat files: definition/bookmarks/{id}.bookmark.json
443
+ # with an index at definition/bookmarks/bookmarks.json
444
+ # Desktop uses schema 2.1.0, explorationState version "1.3".
445
+
446
+ def _ga_bookmarks_dir(self) -> Path:
447
+ assert self._report_dir
448
+ d = self._report_dir / "definition" / "bookmarks"
449
+ d.mkdir(parents=True, exist_ok=True)
450
+ return d
451
+
452
+ def _ga_read_bookmarks_json(self) -> dict[str, Any]:
453
+ bj = self._ga_bookmarks_dir() / "bookmarks.json"
454
+ if bj.exists():
455
+ return json.loads(bj.read_text(encoding="utf-8"))
456
+ return {"items": []}
457
+
458
+ def _ga_write_bookmarks_json(self, data: dict[str, Any]) -> None:
459
+ bj = self._ga_bookmarks_dir() / "bookmarks.json"
460
+ data["$schema"] = (
461
+ "https://developer.microsoft.com/json-schemas/fabric/item/"
462
+ "report/definition/bookmarksMetadata/1.0.0/schema.json"
463
+ )
464
+ bj.write_text(json.dumps(data, indent=2), encoding="utf-8")
465
+
466
+ def bookmark_list(self) -> list[dict[str, Any]]:
467
+ """List all bookmarks in the report (PBIR GA only)."""
468
+ self._require_load()
469
+ bdir = self._ga_bookmarks_dir()
470
+ meta = self._ga_read_bookmarks_json()
471
+ ordered_ids = [item["name"] for item in meta.get("items", []) if "name" in item]
472
+
473
+ by_id: dict[str, dict[str, Any]] = {}
474
+ for entry in bdir.iterdir():
475
+ # Flat file format: {id}.bookmark.json
476
+ if not entry.is_file() or not entry.name.endswith(".bookmark.json"):
477
+ continue
478
+ data = json.loads(entry.read_text(encoding="utf-8"))
479
+ bid = data.get("name", entry.stem.replace(".bookmark", ""))
480
+ active = data.get("explorationState", {}).get("activeSection", "")
481
+ by_id[bid] = {
482
+ "name": bid,
483
+ "displayName": data.get("displayName", bid),
484
+ "page": active,
485
+ }
486
+
487
+ result = [by_id[bid] for bid in ordered_ids if bid in by_id]
488
+ result += [v for k, v in by_id.items() if k not in ordered_ids]
489
+ return result
490
+
491
+ def bookmark_add(self, display_name: str, page: str | None = None) -> dict[str, Any]:
492
+ """Add a named bookmark as a flat {id}.bookmark.json file.
493
+
494
+ Desktop format: schema 2.1.0, explorationState version "1.3",
495
+ options.targetVisualNames: [].
496
+ """
497
+ self._require_load()
498
+ bm_id = uuid.uuid4().hex[:20] # Desktop uses 20-char hex ids
499
+
500
+ # Resolve active page GUID
501
+ active_section = ""
502
+ if page:
503
+ page_info = next((p for p in self.page_list() if p["displayName"] == page), None)
504
+ if page_info:
505
+ active_section = page_info["name"]
506
+ else:
507
+ pages = self.page_list()
508
+ if pages:
509
+ active_section = pages[0]["name"]
510
+
511
+ bm: dict[str, Any] = {
512
+ "$schema": (
513
+ "https://developer.microsoft.com/json-schemas/fabric/item/"
514
+ "report/definition/bookmark/2.1.0/schema.json"
515
+ ),
516
+ "displayName": display_name,
517
+ "name": bm_id,
518
+ "options": {"targetVisualNames": []},
519
+ "explorationState": {
520
+ "version": "1.3",
521
+ "activeSection": active_section,
522
+ "sections": {},
523
+ },
524
+ }
525
+
526
+ # Flat file: {id}.bookmark.json in bookmarks dir
527
+ bm_file = self._ga_bookmarks_dir() / f"{bm_id}.bookmark.json"
528
+ bm_file.write_text(json.dumps(bm, indent=2), encoding="utf-8")
529
+
530
+ meta = self._ga_read_bookmarks_json()
531
+ items: list[dict[str, Any]] = meta.get("items", [])
532
+ items.append({"name": bm_id})
533
+ meta["items"] = items
534
+ self._ga_write_bookmarks_json(meta)
535
+
536
+ return {"name": bm_id, "displayName": display_name, "page": active_section}
537
+
538
+ def bookmark_delete(self, display_name: str) -> bool:
539
+ """Delete a bookmark by display name. Returns True if found and deleted."""
540
+ self._require_load()
541
+ bdir = self._ga_bookmarks_dir()
542
+ for entry in bdir.iterdir():
543
+ if not entry.is_file() or not entry.name.endswith(".bookmark.json"):
544
+ continue
545
+ data = json.loads(entry.read_text(encoding="utf-8"))
546
+ if data.get("displayName") == display_name:
547
+ bm_id = data.get("name", entry.stem.replace(".bookmark", ""))
548
+ entry.unlink()
549
+ meta = self._ga_read_bookmarks_json()
550
+ meta["items"] = [
551
+ item for item in meta.get("items", []) if item.get("name") != bm_id
552
+ ]
553
+ self._ga_write_bookmarks_json(meta)
554
+ return True
555
+ return False
556
+
557
+ # ── Drillthrough / Tooltip page setup ─────────────────────────────────────
558
+
559
+ def page_set_type(
560
+ self, page: str, page_type: str, drillthrough_table: str | None = None
561
+ ) -> None:
562
+ """Set a page to Drillthrough or ReportTooltip type.
563
+
564
+ page_type: "Drillthrough" | "ReportTooltip" | "Normal"
565
+ drillthrough_table: for Drillthrough pages, the source entity to filter.
566
+ """
567
+ self._require_load()
568
+ page_dir = self._ga_find_page_dir(page)
569
+ if not page_dir:
570
+ raise ValueError(f"Page '{page}' not found.")
571
+ pj = page_dir / "page.json"
572
+ data = json.loads(pj.read_text(encoding="utf-8"))
573
+ if page_type == "Normal":
574
+ data.pop("pageType", None)
575
+ data.pop("drillthroughFields", None)
576
+ else:
577
+ data["pageType"] = page_type
578
+ if page_type == "Drillthrough" and drillthrough_table:
579
+ data["drillthroughFields"] = [
580
+ {
581
+ "Column": {
582
+ "Expression": {"SourceRef": {"Entity": drillthrough_table}},
583
+ "Property": drillthrough_table,
584
+ }
585
+ }
586
+ ]
587
+ pj.write_text(json.dumps(data, indent=2), encoding="utf-8")
588
+
589
+ # ── Conditional Formatting ─────────────────────────────────────────────────
590
+
591
+ @staticmethod
592
+ def _find_projection(
593
+ visual_data: dict[str, Any], table: str, field: str
594
+ ) -> tuple[str, dict[str, Any]] | None:
595
+ """Scan a visual's queryState projections to find the queryRef and field expression.
596
+
597
+ Returns (queryRef, field_dict) or None. The field_dict can be used
598
+ directly as the FillRule Input expression (Measure, Aggregation, or Column).
599
+ """
600
+ query_state = visual_data.get("visual", {}).get("query", {}).get("queryState", {})
601
+ field_lower = field.lower()
602
+ table_lower = table.lower()
603
+ for _role, role_data in query_state.items():
604
+ for proj in role_data.get("projections", []):
605
+ query_ref: str = proj.get("queryRef", "")
606
+ f = proj.get("field", {})
607
+ # Plain Column
608
+ col = f.get("Column")
609
+ if col:
610
+ entity = col.get("Expression", {}).get("SourceRef", {}).get("Entity", "")
611
+ prop = col.get("Property", "")
612
+ if entity.lower() == table_lower and prop.lower() == field_lower:
613
+ return query_ref, f
614
+ # Aggregated Column (e.g. SUM)
615
+ agg_col = f.get("Aggregation", {}).get("Expression", {}).get("Column")
616
+ if agg_col:
617
+ entity = agg_col.get("Expression", {}).get("SourceRef", {}).get("Entity", "")
618
+ prop = agg_col.get("Property", "")
619
+ if entity.lower() == table_lower and prop.lower() == field_lower:
620
+ return query_ref, f
621
+ # Explicit Measure
622
+ meas = f.get("Measure")
623
+ if meas:
624
+ entity = meas.get("Expression", {}).get("SourceRef", {}).get("Entity", "")
625
+ prop = meas.get("Property", "")
626
+ if entity.lower() == table_lower and prop.lower() == field_lower:
627
+ return query_ref, f
628
+ return None
629
+
630
+ @staticmethod
631
+ def _find_projection_query_ref(
632
+ visual_data: dict[str, Any], table: str, field: str
633
+ ) -> str | None:
634
+ """Backwards-compatible wrapper — returns only the queryRef."""
635
+ result = PbirBackend._find_projection(visual_data, table, field)
636
+ return result[0] if result else None
637
+
638
+ def _ga_find_visual_json(
639
+ self, page: str, visual_name: str
640
+ ) -> tuple[Path, dict[str, Any]] | None:
641
+ """Return (path, data) for a named visual on a page, or None if not found."""
642
+ vd = self._ga_visuals_dir(page)
643
+ if not vd:
644
+ return None
645
+ for vdir in vd.iterdir():
646
+ if not vdir.is_dir():
647
+ continue
648
+ vj = vdir / "visual.json"
649
+ if not vj.exists():
650
+ continue
651
+ data = json.loads(vj.read_text(encoding="utf-8"))
652
+ if data.get("name") == visual_name or vdir.name == visual_name:
653
+ return vj, data
654
+ return None
655
+
656
+ def visual_format_color_scale(
657
+ self,
658
+ page: str,
659
+ visual_name: str,
660
+ table: str,
661
+ measure: str,
662
+ low_color: str = "#FF0000",
663
+ mid_color: str | None = "#FFFF00",
664
+ high_color: str = "#00FF00",
665
+ ) -> bool:
666
+ """Apply color-scale conditional formatting to a field in a table/matrix visual.
667
+
668
+ Uses the exact PBIR format that Power BI Desktop writes:
669
+ - Property name: ``backColor`` (not ``background``)
670
+ - FillRule: nested ``FillRule`` key with ``linearGradient3`` (lowercase)
671
+ - Color values: single-quoted hex strings inside Literal nodes
672
+ - Selector: both ``data`` (dataViewWildcard) and ``metadata`` (queryRef)
673
+ - Input: derived from the projection's actual field type (Aggregation or Measure)
674
+
675
+ Returns True if the visual was found and updated.
676
+ """
677
+ self._require_load()
678
+ found = self._ga_find_visual_json(page, visual_name)
679
+ if not found:
680
+ return False
681
+ vj, data = found
682
+
683
+ proj = self._find_projection(data, table, measure)
684
+ query_ref = proj[0] if proj else f"Sum({table}[{measure}])"
685
+ field_expr = (
686
+ proj[1]
687
+ if proj
688
+ else {
689
+ "Aggregation": {
690
+ "Expression": {
691
+ "Column": {
692
+ "Expression": {"SourceRef": {"Entity": table}},
693
+ "Property": measure,
694
+ }
695
+ },
696
+ "Function": 0,
697
+ }
698
+ }
699
+ )
700
+
701
+ # Selector: Desktop always writes both data (wildcard) + metadata
702
+ selector: dict[str, Any] = {
703
+ "data": [{"dataViewWildcard": {"matchingOption": 1}}],
704
+ "metadata": query_ref,
705
+ }
706
+
707
+ def _color_literal(hex_color: str) -> dict[str, Any]:
708
+ return {"Literal": {"Value": f"'{hex_color}'"}}
709
+
710
+ # linearGradient3 (3-stop) or linearGradient2 (2-stop) — lowercase keys
711
+ if mid_color:
712
+ gradient_rule: dict[str, Any] = {
713
+ "linearGradient3": {
714
+ "min": {"color": _color_literal(low_color)},
715
+ "mid": {"color": _color_literal(mid_color)},
716
+ "max": {"color": _color_literal(high_color)},
717
+ "nullColoringStrategy": {"strategy": {"Literal": {"Value": "'asZero'"}}},
718
+ }
719
+ }
720
+ else:
721
+ gradient_rule = {
722
+ "linearGradient2": {
723
+ "min": {"color": _color_literal(low_color)},
724
+ "max": {"color": _color_literal(high_color)},
725
+ "nullColoringStrategy": {"strategy": {"Literal": {"Value": "'asZero'"}}},
726
+ }
727
+ }
728
+
729
+ # FillRule expression — nested FillRule key (not FillRuleDef)
730
+ gradient_expr: dict[str, Any] = {
731
+ "FillRule": {
732
+ "Input": field_expr,
733
+ "FillRule": gradient_rule,
734
+ }
735
+ }
736
+
737
+ visual = data.setdefault("visual", {})
738
+ objects = visual.setdefault("objects", {})
739
+ values_obj = objects.setdefault("values", [])
740
+ # Remove ALL existing entries for this field (deduplication)
741
+ values_obj[:] = [
742
+ v for v in values_obj if v.get("selector", {}).get("metadata") != query_ref
743
+ ]
744
+ values_obj.append(
745
+ {
746
+ "selector": selector,
747
+ "properties": {"backColor": {"solid": {"color": {"expr": gradient_expr}}}},
748
+ }
749
+ )
750
+ vj.write_text(json.dumps(data, indent=2), encoding="utf-8")
751
+ return True
752
+
753
+ def visual_format_data_bar(
754
+ self,
755
+ page: str,
756
+ visual_name: str,
757
+ table: str,
758
+ measure: str,
759
+ positive_color: str = "#118DFF",
760
+ negative_color: str = "#FC4E2A",
761
+ ) -> bool:
762
+ """Enable data bar conditional formatting for a measure in a table/matrix visual."""
763
+ self._require_load()
764
+ found = self._ga_find_visual_json(page, visual_name)
765
+ if not found:
766
+ return False
767
+ vj, data = found
768
+
769
+ query_ref = self._find_projection_query_ref(data, table, measure)
770
+ if not query_ref:
771
+ query_ref = f"Sum({table}[{measure}])"
772
+ # Selector: both data (wildcard) + metadata — matching Desktop format
773
+ selector: dict[str, Any] = {
774
+ "data": [{"dataViewWildcard": {"matchingOption": 1}}],
775
+ "metadata": query_ref,
776
+ }
777
+
778
+ visual = data.setdefault("visual", {})
779
+ objects = visual.setdefault("objects", {})
780
+ values_obj = objects.setdefault("values", [])
781
+ values_obj[:] = [
782
+ v for v in values_obj if v.get("selector", {}).get("metadata") != query_ref
783
+ ]
784
+ values_obj.append(
785
+ {
786
+ "selector": selector,
787
+ "properties": {
788
+ "dataBarEnabled": {"expr": {"Literal": {"Value": "true"}}},
789
+ "positiveColor": {
790
+ "solid": {"color": {"expr": {"Literal": {"Value": f"'{positive_color}'"}}}}
791
+ },
792
+ "negativeColor": {
793
+ "solid": {"color": {"expr": {"Literal": {"Value": f"'{negative_color}'"}}}}
794
+ },
795
+ },
796
+ }
797
+ )
798
+ vj.write_text(json.dumps(data, indent=2), encoding="utf-8")
799
+ return True
800
+
801
+ # ── Helpers ────────────────────────────────────────────────────────────────
802
+
803
+ def _require_load(self) -> None:
804
+ if self._report_dir is None:
805
+ raise RuntimeError("No PBIP folder loaded. Call load() or pass pbip_path= first.")
806
+
807
+ @property
808
+ def format(self) -> str:
809
+ return self._format
810
+
811
+ @property
812
+ def report_dir(self) -> Path | None:
813
+ return self._report_dir