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