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