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