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,561 @@
|
|
|
1
|
+
"""pbi report — manage pages and scaffold full reports in .pbip projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from pbi_cli.commands._shared import dry_run_echo, output_json_or_table
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def report() -> None:
|
|
15
|
+
"""Manage report pages and scaffold complete reports from .pbip projects."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Pages ──────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@report.command("pages")
|
|
22
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def report_pages(ctx: click.Context, pbip: str) -> None:
|
|
25
|
+
"""List all pages in a .pbip report."""
|
|
26
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
27
|
+
|
|
28
|
+
b = PbirBackend(pbip)
|
|
29
|
+
pages = b.page_list()
|
|
30
|
+
if not pages:
|
|
31
|
+
console.print("[yellow]No pages found.[/yellow]")
|
|
32
|
+
return
|
|
33
|
+
output_json_or_table(pages, ctx, title="Report Pages")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@report.command("page-add")
|
|
37
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
38
|
+
@click.option("--name", required=True, help="Display name for the new page.")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def report_page_add(ctx: click.Context, pbip: str, name: str) -> None:
|
|
41
|
+
"""Add a blank page to a .pbip report."""
|
|
42
|
+
if dry_run_echo(ctx, f"add page '{name}'"):
|
|
43
|
+
return
|
|
44
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
45
|
+
|
|
46
|
+
b = PbirBackend(pbip)
|
|
47
|
+
result = b.page_add(name)
|
|
48
|
+
console.print(f"[green]Page added:[/green] '{name}' (id: {result['name']})")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@report.command("page-delete")
|
|
52
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
53
|
+
@click.option("--name", required=True, help="Display name of the page to delete.")
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def report_page_delete(ctx: click.Context, pbip: str, name: str) -> None:
|
|
56
|
+
"""Delete a page from a .pbip report."""
|
|
57
|
+
if dry_run_echo(ctx, f"delete page '{name}'"):
|
|
58
|
+
return
|
|
59
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
60
|
+
|
|
61
|
+
b = PbirBackend(pbip)
|
|
62
|
+
b.page_delete(name)
|
|
63
|
+
console.print(f"[green]Deleted[/green] page '{name}'.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@report.command("clear-page")
|
|
67
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
68
|
+
@click.option("--page", required=True, help="Page display name to clear.")
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def report_clear_page(ctx: click.Context, pbip: str, page: str) -> None:
|
|
71
|
+
"""Remove all visuals from a page."""
|
|
72
|
+
if dry_run_echo(ctx, f"clear all visuals on page '{page}'"):
|
|
73
|
+
return
|
|
74
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
75
|
+
|
|
76
|
+
b = PbirBackend(pbip)
|
|
77
|
+
b.page_clear(page)
|
|
78
|
+
console.print(f"[green]Cleared[/green] all visuals on '{page}'.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Scaffold ───────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@report.command("scaffold")
|
|
85
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
86
|
+
@click.option(
|
|
87
|
+
"--model",
|
|
88
|
+
default="financials",
|
|
89
|
+
help="Semantic model table name containing the data (default: financials).",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--pages", default=3, show_default=True, help="Number of pages to create (1-3).")
|
|
92
|
+
@click.option("--replace", is_flag=True, help="Delete existing pages before scaffolding.")
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def report_scaffold(ctx: click.Context, pbip: str, model: str, pages: int, replace: bool) -> None:
|
|
95
|
+
"""Scaffold a complete multi-page report in a .pbip project.
|
|
96
|
+
|
|
97
|
+
Creates up to 3 pages with pre-built visuals tailored for the
|
|
98
|
+
Financials sample model (or any model with Sales/Profit columns).
|
|
99
|
+
|
|
100
|
+
\b
|
|
101
|
+
Prerequisites:
|
|
102
|
+
1. Open your .pbix in Power BI Desktop
|
|
103
|
+
2. File → Save as → Power BI project (.pbip)
|
|
104
|
+
3. Run this command pointing at the saved .pbip folder
|
|
105
|
+
4. Power BI Desktop will prompt to reload — click Reload
|
|
106
|
+
|
|
107
|
+
\b
|
|
108
|
+
Example:
|
|
109
|
+
pbi report scaffold --pbip "C:/Users/Me/Reports/Financials.pbip"
|
|
110
|
+
"""
|
|
111
|
+
if dry_run_echo(ctx, f"scaffold {pages}-page report in '{pbip}'", f"model table: {model}"):
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
115
|
+
from pbi_cli.intelligence.visual_builder import (
|
|
116
|
+
AGG_SUM,
|
|
117
|
+
FieldDef,
|
|
118
|
+
VisualSpec,
|
|
119
|
+
build_bar_chart,
|
|
120
|
+
build_card,
|
|
121
|
+
build_line_chart,
|
|
122
|
+
build_multi_row_card,
|
|
123
|
+
build_slicer,
|
|
124
|
+
build_table,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
b = PbirBackend(pbip)
|
|
128
|
+
console.print(f"[cyan]PBIP format:[/cyan] {b.format}")
|
|
129
|
+
console.print(f"[cyan]Report dir:[/cyan] {b.report_dir}")
|
|
130
|
+
|
|
131
|
+
if replace:
|
|
132
|
+
for p in b.page_list():
|
|
133
|
+
b.page_delete(p["displayName"])
|
|
134
|
+
console.print("[yellow]Existing pages cleared.[/yellow]")
|
|
135
|
+
|
|
136
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
137
|
+
def field(prop: str, is_measure: bool = False, agg: int | None = AGG_SUM) -> FieldDef:
|
|
138
|
+
return FieldDef(entity=model, property=prop, is_measure=is_measure, agg=agg)
|
|
139
|
+
|
|
140
|
+
def col(prop: str) -> FieldDef:
|
|
141
|
+
"""Plain column, no aggregation."""
|
|
142
|
+
return FieldDef(entity=model, property=prop, is_measure=False, agg=None)
|
|
143
|
+
|
|
144
|
+
def add(page_name: str, spec: VisualSpec) -> None:
|
|
145
|
+
b.visual_add(page_name, spec)
|
|
146
|
+
|
|
147
|
+
GUTTER = 16
|
|
148
|
+
CARD_W, CARD_H = 280, 120
|
|
149
|
+
CHART_W, CHART_H = 600, 360
|
|
150
|
+
SLIM_W = 580
|
|
151
|
+
SLICER_W = 200
|
|
152
|
+
TABLE_W, TABLE_H = 1248, 300
|
|
153
|
+
|
|
154
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
155
|
+
# PAGE 1 — Executive Summary
|
|
156
|
+
# Layout:
|
|
157
|
+
# Row 1 (y=16): [Card Sales][Card Profit][Card Units][Card Margin]
|
|
158
|
+
# Row 2 (y=152): [Bar Sales by Country (600)][Slicer Year (200)][Line Sales by Month (600)]
|
|
159
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
160
|
+
if pages >= 1:
|
|
161
|
+
PAGE = "Executive Summary"
|
|
162
|
+
console.print(f"\n[bold]Page 1:[/bold] {PAGE}")
|
|
163
|
+
b.page_add(PAGE)
|
|
164
|
+
|
|
165
|
+
# Row 1: KPI cards
|
|
166
|
+
cards = [
|
|
167
|
+
("Sales", field("Sales"), "Total Sales"),
|
|
168
|
+
("Profit", field("Profit"), "Total Profit"),
|
|
169
|
+
("Units", field("Units Sold"), "Units Sold"),
|
|
170
|
+
("COGS", field("COGS"), "Total COGS"),
|
|
171
|
+
]
|
|
172
|
+
for i, (_, value_field, title) in enumerate(cards):
|
|
173
|
+
add(
|
|
174
|
+
PAGE,
|
|
175
|
+
VisualSpec(
|
|
176
|
+
visual_type="card",
|
|
177
|
+
visual_body=build_card(value_field),
|
|
178
|
+
x=GUTTER + i * (CARD_W + GUTTER),
|
|
179
|
+
y=GUTTER,
|
|
180
|
+
width=CARD_W,
|
|
181
|
+
height=CARD_H,
|
|
182
|
+
title=title,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
y2 = GUTTER + CARD_H + GUTTER
|
|
187
|
+
# Sales by Country bar
|
|
188
|
+
add(
|
|
189
|
+
PAGE,
|
|
190
|
+
VisualSpec(
|
|
191
|
+
visual_type="barChart",
|
|
192
|
+
visual_body=build_bar_chart(col("Country"), field("Sales")),
|
|
193
|
+
x=GUTTER,
|
|
194
|
+
y=y2,
|
|
195
|
+
width=CHART_W,
|
|
196
|
+
height=CHART_H,
|
|
197
|
+
title="Sales by Country",
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
# Year slicer
|
|
201
|
+
add(
|
|
202
|
+
PAGE,
|
|
203
|
+
VisualSpec(
|
|
204
|
+
visual_type="slicer",
|
|
205
|
+
visual_body=build_slicer(col("Year")),
|
|
206
|
+
x=GUTTER + CHART_W + GUTTER,
|
|
207
|
+
y=y2,
|
|
208
|
+
width=SLICER_W,
|
|
209
|
+
height=CHART_H,
|
|
210
|
+
title="Year",
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
# Sales by Month line
|
|
214
|
+
add(
|
|
215
|
+
PAGE,
|
|
216
|
+
VisualSpec(
|
|
217
|
+
visual_type="lineChart",
|
|
218
|
+
visual_body=build_line_chart(col("Month Name"), field("Sales")),
|
|
219
|
+
x=GUTTER + CHART_W + GUTTER + SLICER_W + GUTTER,
|
|
220
|
+
y=y2,
|
|
221
|
+
width=SLIM_W,
|
|
222
|
+
height=CHART_H,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
console.print(f" [green]OK[/green] {len(cards) + 3} visuals")
|
|
226
|
+
|
|
227
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
228
|
+
# PAGE 2 — Sales Analysis
|
|
229
|
+
# Layout:
|
|
230
|
+
# Row 1: [Bar Segment (600)] [Bar Product (600)]
|
|
231
|
+
# Row 2: [Table: Segment/Country/Product/Sales/Profit (full width)]
|
|
232
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
233
|
+
if pages >= 2:
|
|
234
|
+
PAGE = "Sales Analysis"
|
|
235
|
+
console.print(f"\n[bold]Page 2:[/bold] {PAGE}")
|
|
236
|
+
b.page_add(PAGE)
|
|
237
|
+
|
|
238
|
+
add(
|
|
239
|
+
PAGE,
|
|
240
|
+
VisualSpec(
|
|
241
|
+
visual_type="barChart",
|
|
242
|
+
visual_body=build_bar_chart(col("Segment"), field("Sales")),
|
|
243
|
+
x=GUTTER,
|
|
244
|
+
y=GUTTER,
|
|
245
|
+
width=CHART_W,
|
|
246
|
+
height=CHART_H,
|
|
247
|
+
title="Sales by Segment",
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
add(
|
|
251
|
+
PAGE,
|
|
252
|
+
VisualSpec(
|
|
253
|
+
visual_type="barChart",
|
|
254
|
+
visual_body=build_bar_chart(col("Product"), field("Sales")),
|
|
255
|
+
x=GUTTER + CHART_W + GUTTER,
|
|
256
|
+
y=GUTTER,
|
|
257
|
+
width=CHART_W,
|
|
258
|
+
height=CHART_H,
|
|
259
|
+
title="Sales by Product",
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Summary table
|
|
264
|
+
table_cols = [
|
|
265
|
+
col("Segment"),
|
|
266
|
+
col("Country"),
|
|
267
|
+
col("Product"),
|
|
268
|
+
field("Sales"),
|
|
269
|
+
field("Profit"),
|
|
270
|
+
FieldDef(entity=model, property="Units Sold", agg=AGG_SUM),
|
|
271
|
+
]
|
|
272
|
+
from pbi_cli.intelligence.visual_builder import build_table
|
|
273
|
+
|
|
274
|
+
add(
|
|
275
|
+
PAGE,
|
|
276
|
+
VisualSpec(
|
|
277
|
+
visual_type="tableEx",
|
|
278
|
+
visual_body=build_table(table_cols),
|
|
279
|
+
x=GUTTER,
|
|
280
|
+
y=GUTTER + CHART_H + GUTTER,
|
|
281
|
+
width=TABLE_W,
|
|
282
|
+
height=TABLE_H,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
console.print(" [green]OK[/green] 3 visuals")
|
|
286
|
+
|
|
287
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
288
|
+
# PAGE 3 — Profit Analysis
|
|
289
|
+
# Layout:
|
|
290
|
+
# Row 1: [Bar Profit by Country (600)] [Bar Profit by Segment (600)]
|
|
291
|
+
# Row 2: [Multi-row card: Sales/Profit/COGS/Units] [Line Profit by Month]
|
|
292
|
+
# ════════════════════════════════════════════════════════════════════════
|
|
293
|
+
if pages >= 3:
|
|
294
|
+
PAGE = "Profit Analysis"
|
|
295
|
+
console.print(f"\n[bold]Page 3:[/bold] {PAGE}")
|
|
296
|
+
b.page_add(PAGE)
|
|
297
|
+
|
|
298
|
+
add(
|
|
299
|
+
PAGE,
|
|
300
|
+
VisualSpec(
|
|
301
|
+
visual_type="barChart",
|
|
302
|
+
visual_body=build_bar_chart(col("Country"), field("Profit")),
|
|
303
|
+
x=GUTTER,
|
|
304
|
+
y=GUTTER,
|
|
305
|
+
width=CHART_W,
|
|
306
|
+
height=CHART_H,
|
|
307
|
+
title="Profit by Country",
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
add(
|
|
311
|
+
PAGE,
|
|
312
|
+
VisualSpec(
|
|
313
|
+
visual_type="barChart",
|
|
314
|
+
visual_body=build_bar_chart(col("Segment"), field("Profit")),
|
|
315
|
+
x=GUTTER + CHART_W + GUTTER,
|
|
316
|
+
y=GUTTER,
|
|
317
|
+
width=CHART_W,
|
|
318
|
+
height=CHART_H,
|
|
319
|
+
title="Profit by Segment",
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
y2 = GUTTER + CHART_H + GUTTER
|
|
324
|
+
summary_fields = [
|
|
325
|
+
field("Sales"),
|
|
326
|
+
field("Profit"),
|
|
327
|
+
field("COGS"),
|
|
328
|
+
FieldDef(entity=model, property="Gross Sales", agg=AGG_SUM),
|
|
329
|
+
]
|
|
330
|
+
from pbi_cli.intelligence.visual_builder import build_multi_row_card
|
|
331
|
+
|
|
332
|
+
add(
|
|
333
|
+
PAGE,
|
|
334
|
+
VisualSpec(
|
|
335
|
+
visual_type="multiRowCard",
|
|
336
|
+
visual_body=build_multi_row_card(summary_fields),
|
|
337
|
+
x=GUTTER,
|
|
338
|
+
y=y2,
|
|
339
|
+
width=CHART_W,
|
|
340
|
+
height=200,
|
|
341
|
+
title="P&L Summary",
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
add(
|
|
345
|
+
PAGE,
|
|
346
|
+
VisualSpec(
|
|
347
|
+
visual_type="lineChart",
|
|
348
|
+
visual_body=build_line_chart(col("Month Name"), field("Profit")),
|
|
349
|
+
x=GUTTER + CHART_W + GUTTER,
|
|
350
|
+
y=y2,
|
|
351
|
+
width=CHART_W,
|
|
352
|
+
height=CHART_H,
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
console.print(" [green]OK[/green] 4 visuals")
|
|
356
|
+
|
|
357
|
+
console.print("\n[bold green]Report scaffold complete![/bold green]")
|
|
358
|
+
console.print(
|
|
359
|
+
"\n[cyan]Next step:[/cyan] In Power BI Desktop, click [bold]Reload[/bold] "
|
|
360
|
+
"when prompted, or close and re-open the .pbip file."
|
|
361
|
+
)
|
|
362
|
+
console.print(
|
|
363
|
+
"If Power BI Desktop doesn't prompt automatically, press [bold]Ctrl+Z[/bold] "
|
|
364
|
+
"then [bold]Ctrl+Y[/bold] to trigger a refresh."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ── Bookmarks ──────────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@report.command("bookmark-list")
|
|
372
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
373
|
+
@click.pass_context
|
|
374
|
+
def report_bookmark_list(ctx: click.Context, pbip: str) -> None:
|
|
375
|
+
"""List all bookmarks in a .pbip report."""
|
|
376
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
377
|
+
|
|
378
|
+
b = PbirBackend(pbip)
|
|
379
|
+
bookmarks = b.bookmark_list()
|
|
380
|
+
if not bookmarks:
|
|
381
|
+
console.print("[yellow]No bookmarks found.[/yellow]")
|
|
382
|
+
return
|
|
383
|
+
output_json_or_table(bookmarks, ctx, title="Report Bookmarks")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@report.command("bookmark-add")
|
|
387
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
388
|
+
@click.option("--name", required=True, help="Display name for the bookmark.")
|
|
389
|
+
@click.option("--page", default=None, help="Page to associate with the bookmark.")
|
|
390
|
+
@click.pass_context
|
|
391
|
+
def report_bookmark_add(ctx: click.Context, pbip: str, name: str, page: str | None) -> None:
|
|
392
|
+
"""Add a named bookmark to a .pbip report.
|
|
393
|
+
|
|
394
|
+
The bookmark captures the current report state when reloaded in Power BI Desktop.
|
|
395
|
+
|
|
396
|
+
\b
|
|
397
|
+
Example:
|
|
398
|
+
pbi report bookmark-add --pbip MyReport --name "Q4 View" --page "Sales"
|
|
399
|
+
"""
|
|
400
|
+
if dry_run_echo(ctx, f"add bookmark '{name}'" + (f" on page '{page}'" if page else "")):
|
|
401
|
+
return
|
|
402
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
403
|
+
|
|
404
|
+
b = PbirBackend(pbip)
|
|
405
|
+
result = b.bookmark_add(name, page=page)
|
|
406
|
+
console.print(f"[green]Bookmark added:[/green] '{name}' (id: {result['name']})")
|
|
407
|
+
if page:
|
|
408
|
+
console.print(f" Linked to page: {page}")
|
|
409
|
+
console.print(
|
|
410
|
+
"[yellow]Tip:[/yellow] Reload the report in Power BI Desktop to capture visual state."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@report.command("bookmark-delete")
|
|
415
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
416
|
+
@click.option("--name", required=True, help="Display name of the bookmark to delete.")
|
|
417
|
+
@click.pass_context
|
|
418
|
+
def report_bookmark_delete(ctx: click.Context, pbip: str, name: str) -> None:
|
|
419
|
+
"""Delete a bookmark from a .pbip report."""
|
|
420
|
+
if dry_run_echo(ctx, f"delete bookmark '{name}'"):
|
|
421
|
+
return
|
|
422
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
423
|
+
|
|
424
|
+
b = PbirBackend(pbip)
|
|
425
|
+
deleted = b.bookmark_delete(name)
|
|
426
|
+
if deleted:
|
|
427
|
+
console.print(f"[green]Deleted[/green] bookmark '{name}'.")
|
|
428
|
+
else:
|
|
429
|
+
console.print(f"[yellow]Bookmark '{name}' not found.[/yellow]")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@report.command("bookmark-get")
|
|
433
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
434
|
+
@click.option("--name", required=True, help="Display name of the bookmark.")
|
|
435
|
+
@click.pass_context
|
|
436
|
+
def report_bookmark_get(ctx: click.Context, pbip: str, name: str) -> None:
|
|
437
|
+
"""Get full details of a single bookmark by display name."""
|
|
438
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
439
|
+
from pbi_cli.commands._shared import output_json_or_table
|
|
440
|
+
|
|
441
|
+
b = PbirBackend(pbip)
|
|
442
|
+
bookmarks = b.bookmark_list()
|
|
443
|
+
match = next((bm for bm in bookmarks if bm.get("displayName") == name), None)
|
|
444
|
+
if not match:
|
|
445
|
+
console.print(f"[red]Bookmark '{name}' not found.[/red]")
|
|
446
|
+
raise SystemExit(1)
|
|
447
|
+
output_json_or_table(match, ctx, title=f"Bookmark: {name}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@report.command("bookmark-set-visibility")
|
|
451
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
452
|
+
@click.option("--name", required=True, help="Display name of the bookmark.")
|
|
453
|
+
@click.option(
|
|
454
|
+
"--visible",
|
|
455
|
+
is_flag=True,
|
|
456
|
+
default=True,
|
|
457
|
+
help="Set bookmark visible in the selection pane (default).",
|
|
458
|
+
)
|
|
459
|
+
@click.option(
|
|
460
|
+
"--hidden", is_flag=True, default=False, help="Hide bookmark from the selection pane."
|
|
461
|
+
)
|
|
462
|
+
@click.pass_context
|
|
463
|
+
def report_bookmark_set_visibility(
|
|
464
|
+
ctx: click.Context, pbip: str, name: str, visible: bool, hidden: bool
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Show or hide a bookmark in the Power BI bookmark selection pane."""
|
|
467
|
+
if dry_run_echo(ctx, f"set visibility of bookmark '{name}'"):
|
|
468
|
+
return
|
|
469
|
+
import json as _json
|
|
470
|
+
|
|
471
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
472
|
+
|
|
473
|
+
b = PbirBackend(pbip)
|
|
474
|
+
bdir = b._ga_bookmarks_dir()
|
|
475
|
+
for entry in bdir.iterdir():
|
|
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
|
+
if data.get("displayName") == name:
|
|
480
|
+
data.setdefault("options", {})["isHidden"] = hidden
|
|
481
|
+
entry.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
|
482
|
+
state = "hidden" if hidden else "visible"
|
|
483
|
+
console.print(f"[green]Bookmark '{name}' is now {state}.[/green]")
|
|
484
|
+
return
|
|
485
|
+
console.print(f"[red]Bookmark '{name}' not found.[/red]")
|
|
486
|
+
raise SystemExit(1)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── Drillthrough & Tooltip ─────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@report.command("drillthrough-setup")
|
|
493
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
494
|
+
@click.option("--page", required=True, help="Page name to convert to a drillthrough page.")
|
|
495
|
+
@click.option(
|
|
496
|
+
"--table",
|
|
497
|
+
required=True,
|
|
498
|
+
help="Source entity table (e.g. 'financials') for the drillthrough field.",
|
|
499
|
+
)
|
|
500
|
+
@click.pass_context
|
|
501
|
+
def report_drillthrough_setup(ctx: click.Context, pbip: str, page: str, table: str) -> None:
|
|
502
|
+
"""Convert a report page into a drillthrough destination.
|
|
503
|
+
|
|
504
|
+
Users can right-click a data point and drill through to this page,
|
|
505
|
+
automatically filtered to the selected context.
|
|
506
|
+
|
|
507
|
+
\b
|
|
508
|
+
Example:
|
|
509
|
+
pbi report drillthrough-setup --pbip MyReport --page "Product Detail" --table financials
|
|
510
|
+
"""
|
|
511
|
+
if dry_run_echo(ctx, f"set page '{page}' as Drillthrough (source: {table})"):
|
|
512
|
+
return
|
|
513
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
514
|
+
|
|
515
|
+
b = PbirBackend(pbip)
|
|
516
|
+
b.page_set_type(page, "Drillthrough", drillthrough_table=table)
|
|
517
|
+
console.print(f"[green]Drillthrough enabled:[/green] '{page}' is now a drillthrough page.")
|
|
518
|
+
console.print(f" Source entity: [cyan]{table}[/cyan]")
|
|
519
|
+
console.print(
|
|
520
|
+
"[yellow]Tip:[/yellow] Reload the report in Power BI Desktop to see the drillthrough option." # noqa: E501
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@report.command("tooltip-setup")
|
|
525
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
526
|
+
@click.option("--page", required=True, help="Page name to convert to a tooltip page.")
|
|
527
|
+
@click.pass_context
|
|
528
|
+
def report_tooltip_setup(ctx: click.Context, pbip: str, page: str) -> None:
|
|
529
|
+
"""Convert a report page into a custom report tooltip.
|
|
530
|
+
|
|
531
|
+
Other visuals can then use this page as a hover tooltip.
|
|
532
|
+
|
|
533
|
+
\b
|
|
534
|
+
Example:
|
|
535
|
+
pbi report tooltip-setup --pbip MyReport --page "Sales Tooltip"
|
|
536
|
+
"""
|
|
537
|
+
if dry_run_echo(ctx, f"set page '{page}' as ReportTooltip"):
|
|
538
|
+
return
|
|
539
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
540
|
+
|
|
541
|
+
b = PbirBackend(pbip)
|
|
542
|
+
b.page_set_type(page, "ReportTooltip")
|
|
543
|
+
console.print(f"[green]Tooltip page enabled:[/green] '{page}' is now a report tooltip page.")
|
|
544
|
+
console.print(
|
|
545
|
+
"[yellow]Tip:[/yellow] Assign this tooltip to a visual in Power BI Desktop via Format > Tooltip > Page." # noqa: E501
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@report.command("page-type-reset")
|
|
550
|
+
@click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
|
|
551
|
+
@click.option("--page", required=True, help="Page name to reset to a normal report page.")
|
|
552
|
+
@click.pass_context
|
|
553
|
+
def report_page_type_reset(ctx: click.Context, pbip: str, page: str) -> None:
|
|
554
|
+
"""Reset a drillthrough or tooltip page back to a normal report page."""
|
|
555
|
+
if dry_run_echo(ctx, f"reset page '{page}' to Normal type"):
|
|
556
|
+
return
|
|
557
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
558
|
+
|
|
559
|
+
b = PbirBackend(pbip)
|
|
560
|
+
b.page_set_type(page, "Normal")
|
|
561
|
+
console.print(f"[green]Page reset:[/green] '{page}' is now a normal report page.")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""pbi security — Row-Level Security role management and testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from pbi_cli.commands._shared import dry_run_echo, get_backend, output_json_or_table
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def security() -> None:
|
|
15
|
+
"""Manage RLS roles and test row-level security filters."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@security.command("roles")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def security_roles(ctx: click.Context) -> None:
|
|
21
|
+
"""List all RLS roles and their table filter expressions."""
|
|
22
|
+
backend = get_backend(ctx)
|
|
23
|
+
data = backend.role_list()
|
|
24
|
+
if not data:
|
|
25
|
+
console.print("[yellow]No RLS roles defined.[/yellow]")
|
|
26
|
+
return
|
|
27
|
+
output_json_or_table(data, ctx, title="RLS Roles")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@security.command("role-add")
|
|
31
|
+
@click.option("--name", required=True, help="Role name (e.g. 'Region Manager').")
|
|
32
|
+
@click.option("--table", required=True, help="Table to apply the filter to.")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--filter",
|
|
35
|
+
"filter_expression",
|
|
36
|
+
required=True,
|
|
37
|
+
help='DAX filter expression (e.g. "[Region] = USERNAME()").',
|
|
38
|
+
)
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def security_role_add(ctx: click.Context, name: str, table: str, filter_expression: str) -> None:
|
|
41
|
+
"""Add an RLS role with a DAX row filter on a table."""
|
|
42
|
+
if dry_run_echo(ctx, f"add RLS role '{name}' on '{table}'", f"filter: {filter_expression}"):
|
|
43
|
+
return
|
|
44
|
+
backend = get_backend(ctx)
|
|
45
|
+
result = backend.role_add(name=name, table=table, filter_expression=filter_expression)
|
|
46
|
+
from pbi_cli._audit import write_audit_entry
|
|
47
|
+
|
|
48
|
+
write_audit_entry("security role-add", extra={"name": name, "table": table})
|
|
49
|
+
output_json_or_table(result, ctx, title="RLS Role Added")
|
|
50
|
+
console.print(f"[green]Role added:[/green] '{name}' — {table}: {filter_expression}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@security.command("role-delete")
|
|
54
|
+
@click.option("--name", required=True, help="Role name to delete.")
|
|
55
|
+
@click.pass_context
|
|
56
|
+
def security_role_delete(ctx: click.Context, name: str) -> None:
|
|
57
|
+
"""Delete an RLS role."""
|
|
58
|
+
if dry_run_echo(ctx, f"delete RLS role '{name}'"):
|
|
59
|
+
return
|
|
60
|
+
backend = get_backend(ctx)
|
|
61
|
+
backend.role_delete(name=name)
|
|
62
|
+
from pbi_cli._audit import write_audit_entry
|
|
63
|
+
|
|
64
|
+
write_audit_entry("security role-delete", extra={"name": name})
|
|
65
|
+
console.print(f"[green]Deleted[/green] RLS role '{name}'.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@security.command("test")
|
|
69
|
+
@click.option("--role", required=True, help="Role name to test.")
|
|
70
|
+
@click.option(
|
|
71
|
+
"--query",
|
|
72
|
+
required=True,
|
|
73
|
+
help='DAX EVALUATE query to run under the role (e.g. "EVALUATE Sales").',
|
|
74
|
+
)
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def security_test(ctx: click.Context, role: str, query: str) -> None:
|
|
77
|
+
"""Execute a DAX query with a specific RLS role applied and show the filtered result.
|
|
78
|
+
|
|
79
|
+
Use this to verify that a role restricts data correctly.
|
|
80
|
+
|
|
81
|
+
\b
|
|
82
|
+
Example:
|
|
83
|
+
pbi security test --role "Region Manager" --query "EVALUATE SUMMARIZE(Sales, Sales[Region])"
|
|
84
|
+
"""
|
|
85
|
+
backend = get_backend(ctx)
|
|
86
|
+
console.print(f"[cyan]Testing RLS:[/cyan] role='{role}'")
|
|
87
|
+
result = backend.role_test(role_name=role, dax_expression=query)
|
|
88
|
+
console.print(f" Rows returned under role: [bold]{result['rowCount']}[/bold]")
|
|
89
|
+
if result["rows"]:
|
|
90
|
+
output_json_or_table(result["rows"], ctx, title=f"Query Result (role: {role})")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""pbi server — FastAPI REST server (Epic E)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def server() -> None:
|
|
13
|
+
"""Start the pbi-cli REST server for pipeline integration."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@server.command("start")
|
|
17
|
+
@click.option("--port", default=7788, show_default=True)
|
|
18
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
19
|
+
def server_start(port: int, host: str) -> None:
|
|
20
|
+
"""Start the FastAPI REST server on the specified port."""
|
|
21
|
+
try:
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
from pbi_cli.server.api import app
|
|
25
|
+
|
|
26
|
+
console.print(f"[green]Starting pbi-server[/green] on {host}:{port}")
|
|
27
|
+
uvicorn.run(app, host=host, port=port)
|
|
28
|
+
except ImportError:
|
|
29
|
+
console.print("[red]Server dependencies not installed.[/red]")
|
|
30
|
+
console.print("Run: pip install pbi-enterprise-cli[server]")
|