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,142 @@
1
+ """pbi trace — query trace start/stop/fetch/export and benchmarking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from pbi_cli.commands._shared import get_backend, output_json_or_table
15
+
16
+ console = Console()
17
+
18
+ # In-memory trace buffer (per process)
19
+ _trace_buffer: list[dict[str, Any]] = []
20
+ _trace_active: bool = False
21
+
22
+
23
+ @click.group()
24
+ def trace() -> None:
25
+ """Capture and analyse DAX query execution traces."""
26
+
27
+
28
+ @trace.command("start")
29
+ @click.option(
30
+ "--events",
31
+ default="QueryBegin,QueryEnd,ProgressReportEnd",
32
+ help="Comma-separated trace event classes to capture.",
33
+ )
34
+ @click.pass_context
35
+ def trace_start(ctx: click.Context, events: str) -> None:
36
+ """Start capturing DAX query trace events.
37
+
38
+ \b
39
+ Example:
40
+ pbi trace start
41
+ pbi dax query "EVALUATE TOPN(10, Sales)"
42
+ pbi trace fetch
43
+ pbi trace stop
44
+ """
45
+ global _trace_active, _trace_buffer
46
+ _trace_buffer = []
47
+ _trace_active = True
48
+ console.print(f"[green]Trace started.[/green] Capturing: {events}")
49
+ console.print("[dim]Run DAX queries, then use 'pbi trace fetch' to view events.[/dim]")
50
+
51
+
52
+ @trace.command("stop")
53
+ def trace_stop() -> None:
54
+ """Stop the active trace session."""
55
+ global _trace_active
56
+ _trace_active = False
57
+ console.print(f"[yellow]Trace stopped.[/yellow] {len(_trace_buffer)} events captured.")
58
+ console.print("Use 'pbi trace fetch' to view or 'pbi trace export' to save.")
59
+
60
+
61
+ @trace.command("fetch")
62
+ @click.option("--limit", default=50, show_default=True, help="Max events to display.")
63
+ @click.pass_context
64
+ def trace_fetch(ctx: click.Context, limit: int) -> None:
65
+ """Display captured trace events (most recent first)."""
66
+ if not _trace_buffer:
67
+ console.print("[yellow]No trace events captured.[/yellow]")
68
+ console.print("Run 'pbi trace start' first, execute some DAX queries, then fetch.")
69
+ return
70
+ events = _trace_buffer[-limit:]
71
+ output_json_or_table(events, ctx, title=f"Trace Events (last {len(events)})")
72
+
73
+
74
+ @trace.command("export")
75
+ @click.option("--output", required=True, type=click.Path(), help="Output JSON file path.")
76
+ def trace_export(output: str) -> None:
77
+ """Export captured trace events to a JSON file."""
78
+ if not _trace_buffer:
79
+ console.print("[yellow]No trace events to export.[/yellow]")
80
+ return
81
+ Path(output).write_text(json.dumps(_trace_buffer, indent=2, default=str), encoding="utf-8")
82
+ console.print(f"[green]Exported {len(_trace_buffer)} events to:[/green] {output}")
83
+
84
+
85
+ @trace.command("clear")
86
+ def trace_clear() -> None:
87
+ """Clear the trace buffer without stopping the session."""
88
+ global _trace_buffer
89
+ count = len(_trace_buffer)
90
+ _trace_buffer = []
91
+ console.print(f"[green]Cleared {count} events from trace buffer.[/green]")
92
+
93
+
94
+ # ── Benchmarking ───────────────────────────────────────────────────────────────
95
+
96
+
97
+ @click.command("benchmark")
98
+ @click.argument("expression")
99
+ @click.option("--runs", default=5, show_default=True, help="Number of executions to average.")
100
+ @click.option("--warmup", default=1, show_default=True, help="Warm-up runs (not counted).")
101
+ @click.pass_context
102
+ def benchmark(ctx: click.Context, expression: str, runs: int, warmup: int) -> None:
103
+ """Benchmark a DAX expression — run it N times and report timing statistics.
104
+
105
+ \b
106
+ Example:
107
+ pbi benchmark "EVALUATE SUMMARIZE(Sales, Sales[Year])" --runs 10
108
+ """
109
+ backend = get_backend(ctx)
110
+ timings: list[float] = []
111
+
112
+ console.print(
113
+ f"[cyan]Benchmarking DAX:[/cyan] {expression[:80]}{'...' if len(expression) > 80 else ''}"
114
+ )
115
+
116
+ if warmup:
117
+ console.print(f"[dim]Warm-up ({warmup} run(s))...[/dim]")
118
+ for _ in range(warmup):
119
+ backend.dax_query(expression)
120
+
121
+ console.print(f"[dim]Measuring ({runs} run(s))...[/dim]")
122
+ for i in range(runs):
123
+ t0 = time.perf_counter()
124
+ backend.dax_query(expression)
125
+ elapsed = (time.perf_counter() - t0) * 1000
126
+ timings.append(elapsed)
127
+ console.print(f" Run {i + 1}: {elapsed:.1f} ms")
128
+
129
+ avg = sum(timings) / len(timings)
130
+ mn = min(timings)
131
+ mx = max(timings)
132
+ p95 = sorted(timings)[int(len(timings) * 0.95)]
133
+
134
+ table = Table(title="Benchmark Results")
135
+ table.add_column("Metric")
136
+ table.add_column("Value", justify="right")
137
+ table.add_row("Runs", str(runs))
138
+ table.add_row("Average", f"{avg:.1f} ms")
139
+ table.add_row("Min", f"{mn:.1f} ms")
140
+ table.add_row("Max", f"{mx:.1f} ms")
141
+ table.add_row("P95", f"{p95:.1f} ms")
142
+ console.print(table)
@@ -0,0 +1,507 @@
1
+ """pbi visual — add and list visuals on report pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from pbi_cli.commands._shared import dry_run_echo, output_json_or_table
11
+ from pbi_cli.intelligence.layout_engine import VISUAL_SIZES
12
+
13
+ console = Console()
14
+ # Visual types the CLI accepts → internal Power BI visual type
15
+ VISUAL_TYPE_MAP: dict[str, str] = {
16
+ # ── Core ──────────────────────────────────────────────────────────────
17
+ "card": "card",
18
+ "kpi": "kpiVisual",
19
+ "multirow": "multiRowCard",
20
+ # ── Bar / Column ───────────────────────────────────────────────────────
21
+ "bar": "barChart",
22
+ "column": "columnChart",
23
+ "stackedbar": "stackedBarChart",
24
+ "stackedcolumn": "stackedColumnChart",
25
+ "100percentbar": "hundredPercentStackedBarChart",
26
+ "100percentcolumn": "hundredPercentStackedColumnChart",
27
+ # ── Line / Area ────────────────────────────────────────────────────────
28
+ "line": "lineChart",
29
+ "area": "areaChart",
30
+ "stackedarea": "stackedAreaChart",
31
+ # ── Combo ─────────────────────────────────────────────────────────────
32
+ "combo": "lineClusteredColumnComboChart",
33
+ # ── Scatter / Bubble ───────────────────────────────────────────────────
34
+ "scatter": "scatterChart",
35
+ "bubble": "scatterChart",
36
+ # ── Pie / Donut ────────────────────────────────────────────────────────
37
+ "pie": "pieChart",
38
+ "donut": "donutChart",
39
+ # ── Other charts ───────────────────────────────────────────────────────
40
+ "gauge": "gauge",
41
+ "waterfall": "waterfallChart",
42
+ "funnel": "funnel",
43
+ "ribbon": "ribbonChart",
44
+ "treemap": "treemap",
45
+ # ── Matrix / Table ─────────────────────────────────────────────────────
46
+ "table": "tableEx",
47
+ "matrix": "pivotTable",
48
+ # ── Slicer ─────────────────────────────────────────────────────────────
49
+ "slicer": "slicer",
50
+ # ── Map ────────────────────────────────────────────────────────────────
51
+ "map": "map",
52
+ "filledmap": "filledMap",
53
+ "azuremap": "azureMap",
54
+ # ── AI / Smart ─────────────────────────────────────────────────────────
55
+ "decomptree": "decompositionTreeVisual",
56
+ "keyinfluencers": "keyDrivers",
57
+ "smartnarrative": "narrativeVisual",
58
+ "qanda": "qnaVisual",
59
+ }
60
+
61
+ AGG_MAP: dict[str, int] = {
62
+ "sum": 0,
63
+ "avg": 1,
64
+ "min": 2,
65
+ "max": 3,
66
+ "count": 4,
67
+ "none": -1,
68
+ }
69
+
70
+
71
+ @click.group()
72
+ def visual() -> None:
73
+ """Add, list, and configure visuals on report pages."""
74
+
75
+
76
+ @visual.command("list")
77
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
78
+ @click.option("--page", required=True, help="Page display name.")
79
+ @click.pass_context
80
+ def visual_list(ctx: click.Context, pbip: str, page: str) -> None:
81
+ """List all visuals on a report page."""
82
+ from pbi_cli.backends.pbir_backend import PbirBackend
83
+
84
+ b = PbirBackend(pbip)
85
+ visuals = b.visual_list(page)
86
+ if not visuals:
87
+ console.print(f"[yellow]No visuals found on page '{page}'.[/yellow]")
88
+ return
89
+ output_json_or_table(visuals, ctx, title=f"Visuals on '{page}'")
90
+
91
+
92
+ @visual.command("add")
93
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
94
+ @click.option("--page", required=True, help="Page display name to add the visual to.")
95
+ @click.option(
96
+ "--type",
97
+ "vtype",
98
+ type=click.Choice(list(VISUAL_TYPE_MAP.keys())),
99
+ required=True,
100
+ help="Visual type.",
101
+ )
102
+ @click.option("--table", required=True, help="Power BI table name (e.g. Financials).")
103
+ @click.option("--value", required=True, help="Measure or column name for the main value/Y axis.")
104
+ @click.option("--category", default=None, help="Category column for X axis / bars (charts only).")
105
+ @click.option("--measure", is_flag=True, help="Treat --value as an explicit DAX measure.")
106
+ @click.option(
107
+ "--agg",
108
+ default="sum",
109
+ type=click.Choice(list(AGG_MAP.keys())),
110
+ help="Aggregation for column values (ignored when --measure).",
111
+ )
112
+ @click.option(
113
+ "--extra-columns",
114
+ default="",
115
+ help="Comma-separated extra columns/rows for table, matrix, multirow visuals.",
116
+ )
117
+ @click.option("--series", default=None, help="Series/legend field (scatter, ribbon).")
118
+ @click.option("--size", default=None, help="Bubble size field (scatter only).")
119
+ @click.option("--title", default="", help="Visual title text.")
120
+ @click.option("--x", default=None, type=int, help="Canvas X position (auto if omitted).")
121
+ @click.option("--y", default=None, type=int, help="Canvas Y position (auto if omitted).")
122
+ @click.option("--width", default=None, type=int, help="Width in pixels.")
123
+ @click.option("--height", default=None, type=int, help="Height in pixels.")
124
+ @click.pass_context
125
+ def visual_add(
126
+ ctx: click.Context,
127
+ pbip: str,
128
+ page: str,
129
+ vtype: str,
130
+ table: str,
131
+ value: str,
132
+ category: str | None,
133
+ measure: bool,
134
+ agg: str,
135
+ extra_columns: str,
136
+ series: str | None,
137
+ size: str | None,
138
+ title: str,
139
+ x: int | None,
140
+ y: int | None,
141
+ width: int | None,
142
+ height: int | None,
143
+ ) -> None:
144
+ """Add a visual to a report page in a .pbip project."""
145
+ from pbi_cli.backends.pbir_backend import PbirBackend
146
+ from pbi_cli.intelligence.visual_builder import (
147
+ FieldDef,
148
+ VisualSpec,
149
+ build_bar_chart,
150
+ build_card,
151
+ build_column_chart,
152
+ build_donut_chart,
153
+ build_funnel,
154
+ build_gauge,
155
+ build_line_chart,
156
+ build_matrix,
157
+ build_multi_row_card,
158
+ build_pie_chart,
159
+ build_ribbon_chart,
160
+ build_scatter_chart,
161
+ build_slicer,
162
+ build_table,
163
+ build_treemap,
164
+ build_waterfall,
165
+ )
166
+
167
+ if dry_run_echo(
168
+ ctx,
169
+ f"add {vtype} visual to page '{page}'",
170
+ f"table={table} value={value} category={category}",
171
+ ):
172
+ return
173
+
174
+ agg_func: int | None = None if (measure or agg == "none") else AGG_MAP.get(agg, 0)
175
+ value_field = FieldDef(entity=table, property=value, is_measure=measure, agg=agg_func)
176
+
177
+ pbi_type = VISUAL_TYPE_MAP[vtype]
178
+ default_w, default_h = VISUAL_SIZES.get(vtype, (300, 200))
179
+
180
+ if vtype == "card":
181
+ body = build_card(value_field)
182
+ elif vtype == "bar":
183
+ if not category:
184
+ raise click.UsageError("--category is required for bar visuals.")
185
+ cat_field = FieldDef(entity=table, property=category, is_measure=False, agg=None)
186
+ body = build_bar_chart(cat_field, value_field)
187
+ elif vtype == "column":
188
+ if not category:
189
+ raise click.UsageError("--category is required for column visuals.")
190
+ cat_field = FieldDef(entity=table, property=category, is_measure=False, agg=None)
191
+ body = build_column_chart(cat_field, value_field)
192
+ elif vtype == "line":
193
+ if not category:
194
+ raise click.UsageError("--category is required for line visuals.")
195
+ cat_field = FieldDef(entity=table, property=category, is_measure=False, agg=None)
196
+ body = build_line_chart(cat_field, value_field)
197
+ elif vtype == "slicer":
198
+ body = build_slicer(FieldDef(entity=table, property=value, is_measure=False, agg=None))
199
+ elif vtype == "table":
200
+ cols = [value_field]
201
+ if extra_columns:
202
+ for col_name in extra_columns.split(","):
203
+ col_name = col_name.strip()
204
+ if col_name:
205
+ cols.append(FieldDef(entity=table, property=col_name, agg=agg_func))
206
+ body = build_table(cols)
207
+ elif vtype == "multirow":
208
+ fields = [value_field]
209
+ if extra_columns:
210
+ for col_name in extra_columns.split(","):
211
+ col_name = col_name.strip()
212
+ if col_name:
213
+ fields.append(FieldDef(entity=table, property=col_name, agg=agg_func))
214
+ body = build_multi_row_card(fields)
215
+ elif vtype == "scatter":
216
+ if not category:
217
+ raise click.UsageError("--category is required for scatter (X axis).")
218
+ x_field = FieldDef(entity=table, property=category, is_measure=False, agg=agg_func)
219
+ size_field = FieldDef(entity=table, property=size, agg=agg_func) if size else None
220
+ series_field = FieldDef(entity=table, property=series, agg=None) if series else None
221
+ body = build_scatter_chart(x_field, value_field, details=series_field, size=size_field)
222
+ elif vtype == "gauge":
223
+ target_field = FieldDef(entity=table, property=series, agg=agg_func) if series else None
224
+ body = build_gauge(value_field, target=target_field)
225
+ elif vtype == "donut":
226
+ if not category:
227
+ raise click.UsageError("--category is required for donut chart.")
228
+ cat_field = FieldDef(entity=table, property=category, agg=None)
229
+ body = build_donut_chart(cat_field, value_field)
230
+ elif vtype == "pie":
231
+ if not category:
232
+ raise click.UsageError("--category is required for pie chart.")
233
+ cat_field = FieldDef(entity=table, property=category, agg=None)
234
+ body = build_pie_chart(cat_field, value_field)
235
+ elif vtype == "treemap":
236
+ if not category:
237
+ raise click.UsageError("--category is required for treemap (group field).")
238
+ group_field = FieldDef(entity=table, property=category, agg=None)
239
+ body = build_treemap(group_field, value_field)
240
+ elif vtype == "funnel":
241
+ if not category:
242
+ raise click.UsageError("--category is required for funnel chart.")
243
+ cat_field = FieldDef(entity=table, property=category, agg=None)
244
+ body = build_funnel(cat_field, value_field)
245
+ elif vtype == "waterfall":
246
+ if not category:
247
+ raise click.UsageError("--category is required for waterfall chart.")
248
+ cat_field = FieldDef(entity=table, property=category, agg=None)
249
+ breakdown_field = FieldDef(entity=table, property=series, agg=None) if series else None
250
+ body = build_waterfall(cat_field, value_field, breakdown=breakdown_field)
251
+ elif vtype == "matrix":
252
+ if not category:
253
+ raise click.UsageError("--category is required for matrix (row field).")
254
+ row_field = FieldDef(entity=table, property=category, agg=None)
255
+ col_fields = []
256
+ if extra_columns:
257
+ for col_name in extra_columns.split(","):
258
+ col_name = col_name.strip()
259
+ if col_name:
260
+ col_fields.append(FieldDef(entity=table, property=col_name, agg=None))
261
+ body = build_matrix([row_field], [value_field], columns=col_fields or None)
262
+ elif vtype == "ribbon":
263
+ if not category:
264
+ raise click.UsageError("--category is required for ribbon chart.")
265
+ cat_field = FieldDef(entity=table, property=category, agg=None)
266
+ series_field = FieldDef(entity=table, property=series, agg=None) if series else None
267
+ body = build_ribbon_chart(cat_field, value_field, series=series_field)
268
+ else:
269
+ raise click.UsageError(f"Unsupported visual type: {vtype}")
270
+
271
+ # Auto-position: find next available slot on the page
272
+ b = PbirBackend(pbip)
273
+ if x is None or y is None:
274
+ x, y = _next_position(b, page, width or default_w, height or default_h)
275
+
276
+ spec = VisualSpec(
277
+ visual_type=pbi_type,
278
+ visual_body=body,
279
+ x=x,
280
+ y=y,
281
+ width=width or default_w,
282
+ height=height or default_h,
283
+ title=title,
284
+ )
285
+ result = b.visual_add(page, spec)
286
+ console.print(f"[green]Visual added:[/green] {vtype} -> page '{page}' @ ({x}, {y})")
287
+ console.print(f" name: {result['name']}")
288
+ if vtype == "slicer":
289
+ console.print(
290
+ "[yellow]Note:[/yellow] slicer defaults to list style. "
291
+ "Edit visual.json and set mode 'Basic' -> 'Dropdown' for a compact filter bar."
292
+ )
293
+
294
+
295
+ @visual.command("delete")
296
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
297
+ @click.option("--page", required=True, help="Page display name.")
298
+ @click.option("--name", "visual_name", required=True, help="Visual name (from pbi visual list).")
299
+ @click.pass_context
300
+ def visual_delete(ctx: click.Context, pbip: str, page: str, visual_name: str) -> None:
301
+ """Remove a visual from a report page."""
302
+ if dry_run_echo(ctx, f"delete visual '{visual_name}' from page '{page}'"):
303
+ return
304
+ from pbi_cli.backends.pbir_backend import PbirBackend
305
+
306
+ b = PbirBackend(pbip)
307
+ b.visual_delete(page, visual_name)
308
+ console.print(f"[green]Deleted[/green] visual '{visual_name}' from '{page}'.")
309
+
310
+
311
+ @visual.command("recommend")
312
+ @click.option("--measures", required=True, help="Comma-separated measure names.")
313
+ @click.pass_context
314
+ def visual_recommend(ctx: click.Context, measures: str) -> None:
315
+ """Recommend visual types for a set of measures."""
316
+ from pbi_cli.intelligence.visual_recommender import VisualRecommender
317
+
318
+ measure_list = [m.strip() for m in measures.split(",")]
319
+ rec = VisualRecommender()
320
+ recommendations = rec.recommend(measure_list)
321
+ output_json_or_table(recommendations, ctx, title="Visual Recommendations")
322
+
323
+
324
+ @visual.command("screenshot")
325
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
326
+ @click.option("--page", required=True, help="Page display name to screenshot.")
327
+ @click.option("--output", default=None, help="Output PNG path (default: <page>.png).")
328
+ @click.option("--width", default=1280, show_default=True, help="Viewport width.")
329
+ @click.option("--height", default=720, show_default=True, help="Viewport height.")
330
+ @click.pass_context
331
+ def visual_screenshot(
332
+ ctx: click.Context,
333
+ pbip: str,
334
+ page: str,
335
+ output: str | None,
336
+ width: int,
337
+ height: int,
338
+ ) -> None:
339
+ """Render a report page to PNG via headless Playwright (requires pbi-enterprise-cli[viz]).
340
+
341
+ Power BI Desktop must be running with the report open, and pbi server must
342
+ be started (pbi server start) so the page can be rendered via the REST API.
343
+ """
344
+ try:
345
+ from playwright.sync_api import sync_playwright # type: ignore[import]
346
+ except ImportError:
347
+ raise click.ClickException(
348
+ "Playwright not installed. Run: pip install pbi-enterprise-cli[viz] && playwright install chromium" # noqa: E501
349
+ )
350
+
351
+ import re
352
+ from pathlib import Path
353
+
354
+ out_path = Path(output) if output else Path(re.sub(r"[^\w\-]", "_", page) + ".png")
355
+ console.print(f"[cyan]Screenshotting:[/cyan] page '{page}' → {out_path}")
356
+
357
+ server_url = "http://localhost:7788"
358
+
359
+ with sync_playwright() as p:
360
+ browser = p.chromium.launch(headless=True)
361
+ page_obj = browser.new_page(viewport={"width": width, "height": height})
362
+ page_obj.goto(f"{server_url}/?page={page}", wait_until="networkidle")
363
+ page_obj.screenshot(path=str(out_path), full_page=False)
364
+ browser.close()
365
+
366
+ console.print(f"[green]Screenshot saved:[/green] {out_path}")
367
+
368
+
369
+ # ── Helpers ────────────────────────────────────────────────────────────────────
370
+
371
+
372
+ def _next_position(backend: Any, page: str, w: int, h: int) -> tuple[int, int]:
373
+ """Find the next free position on the page using simple row-packing."""
374
+ GUTTER = 16
375
+ CANVAS_W = 1280
376
+
377
+ existing = backend.visual_list(page)
378
+ if not existing:
379
+ return GUTTER, GUTTER
380
+
381
+ # Find bottom-right of all existing visuals
382
+ max_y = max((v["y"] + v["height"] for v in existing), default=0)
383
+
384
+ # Try to fit on the same row
385
+ rightmost = max((v["x"] + v["width"] for v in existing), default=0)
386
+ candidate_x = rightmost + GUTTER
387
+
388
+ if candidate_x + w <= CANVAS_W:
389
+ # Find the max y of visuals in the same row region
390
+ row_y = min(
391
+ (v["y"] for v in existing if v["x"] + v["width"] + GUTTER == candidate_x),
392
+ default=GUTTER,
393
+ )
394
+ return candidate_x, row_y
395
+
396
+ # Start a new row
397
+ return GUTTER, max_y + GUTTER
398
+
399
+
400
+ # ── Conditional Formatting ─────────────────────────────────────────────────────
401
+
402
+
403
+ @visual.command("format")
404
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
405
+ @click.option("--page", required=True, help="Page display name.")
406
+ @click.option("--visual", "visual_name", required=True, help="Visual name (from pbi visual list).")
407
+ @click.option(
408
+ "--type",
409
+ "fmt_type",
410
+ type=click.Choice(["color-scale", "data-bar"]),
411
+ required=True,
412
+ help="Conditional formatting type.",
413
+ )
414
+ @click.option("--table", required=True, help="Table name containing the measure.")
415
+ @click.option("--measure", required=True, help="Measure name to apply formatting to.")
416
+ @click.option(
417
+ "--low-color", default="#FF0000", show_default=True, help="Low value color (color-scale)."
418
+ )
419
+ @click.option(
420
+ "--mid-color",
421
+ default="#FFFF00",
422
+ show_default=True,
423
+ help="Mid value color (color-scale, omit to skip).",
424
+ )
425
+ @click.option(
426
+ "--high-color", default="#00FF00", show_default=True, help="High value color (color-scale)."
427
+ )
428
+ @click.option(
429
+ "--positive-color",
430
+ default="#118DFF",
431
+ show_default=True,
432
+ help="Positive value color (data-bar).",
433
+ )
434
+ @click.option(
435
+ "--negative-color",
436
+ default="#FC4E2A",
437
+ show_default=True,
438
+ help="Negative value color (data-bar).",
439
+ )
440
+ @click.pass_context
441
+ def visual_format(
442
+ ctx: click.Context,
443
+ pbip: str,
444
+ page: str,
445
+ visual_name: str,
446
+ fmt_type: str,
447
+ table: str,
448
+ measure: str,
449
+ low_color: str,
450
+ mid_color: str,
451
+ high_color: str,
452
+ positive_color: str,
453
+ negative_color: str,
454
+ ) -> None:
455
+ """Apply conditional formatting to a measure in a table or matrix visual.
456
+
457
+ \b
458
+ Color-scale example (red-yellow-green gradient):
459
+ pbi visual format --pbip MyReport --page "Sales" --visual abc123 \\
460
+ --type color-scale --table financials --measure Sales \\
461
+ --low-color "#FF0000" --mid-color "#FFFF00" --high-color "#00FF00"
462
+
463
+ \b
464
+ Data-bar example:
465
+ pbi visual format --pbip MyReport --page "Sales" --visual abc123 \\
466
+ --type data-bar --table financials --measure Profit \\
467
+ --positive-color "#118DFF" --negative-color "#FC4E2A"
468
+ """
469
+ if dry_run_echo(
470
+ ctx,
471
+ f"apply {fmt_type} conditional format to '{measure}' in visual '{visual_name}'",
472
+ ):
473
+ return
474
+
475
+ from pbi_cli.backends.pbir_backend import PbirBackend
476
+
477
+ b = PbirBackend(pbip)
478
+
479
+ if fmt_type == "color-scale":
480
+ found = b.visual_format_color_scale(
481
+ page,
482
+ visual_name,
483
+ table,
484
+ measure,
485
+ low_color=low_color,
486
+ mid_color=mid_color if mid_color else None,
487
+ high_color=high_color,
488
+ )
489
+ else: # data-bar
490
+ found = b.visual_format_data_bar(
491
+ page,
492
+ visual_name,
493
+ table,
494
+ measure,
495
+ positive_color=positive_color,
496
+ negative_color=negative_color,
497
+ )
498
+
499
+ if found:
500
+ console.print(
501
+ f"[green]Conditional format applied:[/green] "
502
+ f"{fmt_type} on {table}[{measure}] in visual '{visual_name}'"
503
+ )
504
+ console.print("[yellow]Tip:[/yellow] Reload the report in Power BI Desktop to see changes.")
505
+ else:
506
+ console.print(f"[red]Visual '{visual_name}' not found on page '{page}'.[/red]")
507
+ raise SystemExit(1)