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,499 @@
|
|
|
1
|
+
"""pbi model — semantic model commands."""
|
|
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 model() -> None:
|
|
15
|
+
"""Inspect and manage the semantic model: tables, columns, relationships, lint."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@model.command("info")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def model_info(ctx: click.Context) -> None:
|
|
21
|
+
"""Show model name and compatibility level."""
|
|
22
|
+
backend = get_backend(ctx)
|
|
23
|
+
data = backend.model_info()
|
|
24
|
+
output_json_or_table(data, ctx, title="Model Info")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@model.command("tables")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def model_tables(ctx: click.Context) -> None:
|
|
30
|
+
"""List all tables."""
|
|
31
|
+
backend = get_backend(ctx)
|
|
32
|
+
data = backend.table_list()
|
|
33
|
+
output_json_or_table(data, ctx, title="Tables")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@model.command("columns")
|
|
37
|
+
@click.option("--table", default=None, help="Filter to a specific table.")
|
|
38
|
+
@click.pass_context
|
|
39
|
+
def model_columns(ctx: click.Context, table: str | None) -> None:
|
|
40
|
+
"""List columns."""
|
|
41
|
+
backend = get_backend(ctx)
|
|
42
|
+
data = backend.column_list(table=table)
|
|
43
|
+
output_json_or_table(data, ctx, title="Columns")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@model.command("relationships")
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def model_relationships(ctx: click.Context) -> None:
|
|
49
|
+
"""List relationships."""
|
|
50
|
+
backend = get_backend(ctx)
|
|
51
|
+
data = backend.relationship_list()
|
|
52
|
+
output_json_or_table(data, ctx, title="Relationships")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@model.command("lint")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def model_lint(ctx: click.Context) -> None:
|
|
58
|
+
"""Check naming conventions (PascalCase tables, [Measure] brackets, _ hidden prefix)."""
|
|
59
|
+
from pbi_cli.governance.engine import GovernanceEngine
|
|
60
|
+
|
|
61
|
+
backend = get_backend(ctx)
|
|
62
|
+
engine = GovernanceEngine(backend)
|
|
63
|
+
violations = engine.run_naming_rules()
|
|
64
|
+
if violations:
|
|
65
|
+
output_json_or_table(violations, ctx, title="Lint Violations")
|
|
66
|
+
else:
|
|
67
|
+
console.print("[green]All naming conventions pass.[/green]")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@model.command("suggest-measures")
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def model_suggest_measures(ctx: click.Context) -> None:
|
|
73
|
+
"""Suggest standard measures: Time Intelligence, % of total, MoM, YoY, Running Total."""
|
|
74
|
+
backend = get_backend(ctx)
|
|
75
|
+
tables = backend.table_list()
|
|
76
|
+
columns = backend.column_list()
|
|
77
|
+
suggestions = _build_measure_suggestions(tables, columns)
|
|
78
|
+
output_json_or_table(suggestions, ctx, title="Suggested Measures")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@model.command("lineage")
|
|
82
|
+
@click.option("--format", "fmt", type=click.Choice(["json", "mermaid"]), default="json")
|
|
83
|
+
@click.pass_context
|
|
84
|
+
def model_lineage(ctx: click.Context, fmt: str) -> None:
|
|
85
|
+
"""Output measure dependency graph as JSON or Mermaid diagram."""
|
|
86
|
+
backend = get_backend(ctx)
|
|
87
|
+
measures = backend.measure_list()
|
|
88
|
+
if fmt == "mermaid":
|
|
89
|
+
console.print("graph TD")
|
|
90
|
+
for m in measures:
|
|
91
|
+
console.print(f" {m['table']}_{m['name'].replace(' ', '_')}[{m['name']}]")
|
|
92
|
+
else:
|
|
93
|
+
output_json_or_table(measures, ctx, title="Measure Lineage")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@model.command("hierarchies")
|
|
97
|
+
@click.option("--table", default=None, help="Filter to a specific table.")
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def model_hierarchies(ctx: click.Context, table: str | None) -> None:
|
|
100
|
+
"""List hierarchies in the model."""
|
|
101
|
+
backend = get_backend(ctx)
|
|
102
|
+
data = backend.hierarchy_list(table=table)
|
|
103
|
+
output_json_or_table(data, ctx, title="Hierarchies")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@model.command("hierarchy-add")
|
|
107
|
+
@click.option("--table", required=True, help="Table to add hierarchy to.")
|
|
108
|
+
@click.option("--name", required=True, help="Hierarchy name.")
|
|
109
|
+
@click.option(
|
|
110
|
+
"--levels",
|
|
111
|
+
required=True,
|
|
112
|
+
help='JSON array: [{"name":"Year","column":"Year"},{"name":"Month","column":"Month Name"}]',
|
|
113
|
+
)
|
|
114
|
+
@click.pass_context
|
|
115
|
+
def model_hierarchy_add(ctx: click.Context, table: str, name: str, levels: str) -> None:
|
|
116
|
+
"""Add a hierarchy to a table."""
|
|
117
|
+
import json
|
|
118
|
+
|
|
119
|
+
if dry_run_echo(ctx, f"add hierarchy '{name}' to '{table}'"):
|
|
120
|
+
return
|
|
121
|
+
backend = get_backend(ctx)
|
|
122
|
+
levels_list = json.loads(levels)
|
|
123
|
+
result = backend.hierarchy_add(table=table, name=name, levels=levels_list)
|
|
124
|
+
output_json_or_table(result, ctx, title="Hierarchy Added")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@model.command("hierarchy-delete")
|
|
128
|
+
@click.option("--table", required=True)
|
|
129
|
+
@click.option("--name", required=True)
|
|
130
|
+
@click.pass_context
|
|
131
|
+
def model_hierarchy_delete(ctx: click.Context, table: str, name: str) -> None:
|
|
132
|
+
"""Delete a hierarchy."""
|
|
133
|
+
if dry_run_echo(ctx, f"delete hierarchy '{name}' from '{table}'"):
|
|
134
|
+
return
|
|
135
|
+
backend = get_backend(ctx)
|
|
136
|
+
backend.hierarchy_delete(table=table, name=name)
|
|
137
|
+
console.print(f"[green]Deleted[/green] hierarchy '{name}' from '{table}'.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@model.command("calc-groups")
|
|
141
|
+
@click.pass_context
|
|
142
|
+
def model_calc_groups(ctx: click.Context) -> None:
|
|
143
|
+
"""List calculation groups in the model."""
|
|
144
|
+
backend = get_backend(ctx)
|
|
145
|
+
data = backend.calc_group_list()
|
|
146
|
+
if not data:
|
|
147
|
+
console.print("[yellow]No calculation groups found.[/yellow]")
|
|
148
|
+
return
|
|
149
|
+
output_json_or_table(data, ctx, title="Calculation Groups")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@model.command("calc-group-add")
|
|
153
|
+
@click.option("--name", required=True, help="Name of the new calculation group table.")
|
|
154
|
+
@click.option(
|
|
155
|
+
"--precedence",
|
|
156
|
+
default=0,
|
|
157
|
+
show_default=True,
|
|
158
|
+
help="Calculation group precedence (higher = evaluated first).",
|
|
159
|
+
)
|
|
160
|
+
@click.pass_context
|
|
161
|
+
def model_calc_group_add(ctx: click.Context, name: str, precedence: int) -> None:
|
|
162
|
+
"""Create a new calculation group table."""
|
|
163
|
+
if dry_run_echo(ctx, f"create calculation group '{name}' (precedence={precedence})"):
|
|
164
|
+
return
|
|
165
|
+
backend = get_backend(ctx)
|
|
166
|
+
result = backend.calc_group_add(name=name, precedence=precedence)
|
|
167
|
+
output_json_or_table(result, ctx, title="Calculation Group Created")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@model.command("calc-item-add")
|
|
171
|
+
@click.option("--group", required=True, help="Calculation group table name.")
|
|
172
|
+
@click.option("--name", required=True, help="Calculation item name (e.g. 'YTD', 'MTD').")
|
|
173
|
+
@click.option("--expression", required=True, help="DAX expression for the calculation item.")
|
|
174
|
+
@click.option("--ordinal", default=0, show_default=True, help="Display order.")
|
|
175
|
+
@click.pass_context
|
|
176
|
+
def model_calc_item_add(
|
|
177
|
+
ctx: click.Context, group: str, name: str, expression: str, ordinal: int
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Add a calculation item to a calculation group."""
|
|
180
|
+
if dry_run_echo(ctx, f"add calc item '{name}' to group '{group}'"):
|
|
181
|
+
return
|
|
182
|
+
backend = get_backend(ctx)
|
|
183
|
+
result = backend.calc_item_add(
|
|
184
|
+
group_table=group, name=name, expression=expression, ordinal=ordinal
|
|
185
|
+
)
|
|
186
|
+
output_json_or_table(result, ctx, title="Calculation Item Added")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@model.command("calc-item-delete")
|
|
190
|
+
@click.option("--group", required=True)
|
|
191
|
+
@click.option("--name", required=True)
|
|
192
|
+
@click.pass_context
|
|
193
|
+
def model_calc_item_delete(ctx: click.Context, group: str, name: str) -> None:
|
|
194
|
+
"""Delete a calculation item from a calculation group."""
|
|
195
|
+
if dry_run_echo(ctx, f"delete calc item '{name}' from '{group}'"):
|
|
196
|
+
return
|
|
197
|
+
backend = get_backend(ctx)
|
|
198
|
+
backend.calc_item_delete(group_table=group, name=name)
|
|
199
|
+
console.print(f"[green]Deleted[/green] calc item '{name}' from '{group}'.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@model.command("stats")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
def model_stats(ctx: click.Context) -> None:
|
|
205
|
+
"""Show health statistics for the model: object counts, complexity score, warnings."""
|
|
206
|
+
backend = get_backend(ctx)
|
|
207
|
+
|
|
208
|
+
tables = backend.table_list()
|
|
209
|
+
columns = backend.column_list()
|
|
210
|
+
measures = backend.measure_list()
|
|
211
|
+
relationships = backend.relationship_list()
|
|
212
|
+
|
|
213
|
+
hidden_tables = [t for t in tables if t.get("isHidden")]
|
|
214
|
+
hidden_cols = [c for c in columns if c.get("isHidden")]
|
|
215
|
+
hidden_measures = [m for m in measures if m.get("isHidden")]
|
|
216
|
+
|
|
217
|
+
# Relationship complexity: count many-to-many and bidirectional
|
|
218
|
+
m2m = [
|
|
219
|
+
r
|
|
220
|
+
for r in relationships
|
|
221
|
+
if r.get("fromCardinality") == "Many" and r.get("toCardinality") == "Many"
|
|
222
|
+
]
|
|
223
|
+
bidir = [r for r in relationships if r.get("crossFilteringBehavior") == "BothDirections"]
|
|
224
|
+
|
|
225
|
+
# Warnings
|
|
226
|
+
warnings: list[str] = []
|
|
227
|
+
if m2m:
|
|
228
|
+
warnings.append(
|
|
229
|
+
f"{len(m2m)} many-to-many relationship(s) — may cause unexpected aggregation"
|
|
230
|
+
)
|
|
231
|
+
if bidir:
|
|
232
|
+
warnings.append(
|
|
233
|
+
f"{len(bidir)} bidirectional relationship(s) — can degrade query performance"
|
|
234
|
+
)
|
|
235
|
+
no_desc = [m for m in measures if not m.get("description")]
|
|
236
|
+
if no_desc:
|
|
237
|
+
warnings.append(f"{len(no_desc)} measure(s) missing descriptions")
|
|
238
|
+
no_fmt = [m for m in measures if not m.get("formatString")]
|
|
239
|
+
if no_fmt:
|
|
240
|
+
warnings.append(f"{len(no_fmt)} measure(s) missing format strings")
|
|
241
|
+
|
|
242
|
+
# Complexity score: simple heuristic
|
|
243
|
+
complexity = (
|
|
244
|
+
len(tables) * 2 + len(relationships) + len(measures) * 3 + len(m2m) * 10 + len(bidir) * 5
|
|
245
|
+
)
|
|
246
|
+
complexity_label = "Low" if complexity < 100 else "Medium" if complexity < 300 else "High"
|
|
247
|
+
|
|
248
|
+
stats = {
|
|
249
|
+
"tables": len(tables),
|
|
250
|
+
"columns": len(columns),
|
|
251
|
+
"measures": len(measures),
|
|
252
|
+
"relationships": len(relationships),
|
|
253
|
+
"hidden_tables": len(hidden_tables),
|
|
254
|
+
"hidden_columns": len(hidden_cols),
|
|
255
|
+
"hidden_measures": len(hidden_measures),
|
|
256
|
+
"many_to_many_relationships": len(m2m),
|
|
257
|
+
"bidirectional_relationships": len(bidir),
|
|
258
|
+
"complexity_score": complexity,
|
|
259
|
+
"complexity_label": complexity_label,
|
|
260
|
+
"warnings": warnings,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if ctx.obj and ctx.obj.get("output_json"):
|
|
264
|
+
import json as _json
|
|
265
|
+
|
|
266
|
+
console.print(_json.dumps(stats, indent=2))
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
from rich.table import Table as RichTable
|
|
270
|
+
|
|
271
|
+
tbl = RichTable(title="Model Statistics", show_header=True, header_style="bold cyan")
|
|
272
|
+
tbl.add_column("Metric", style="bold")
|
|
273
|
+
tbl.add_column("Value")
|
|
274
|
+
tbl.add_row("Tables", str(len(tables)))
|
|
275
|
+
tbl.add_row("Columns", str(len(columns)))
|
|
276
|
+
tbl.add_row("Measures", str(len(measures)))
|
|
277
|
+
tbl.add_row("Relationships", str(len(relationships)))
|
|
278
|
+
tbl.add_row("Hidden tables", str(len(hidden_tables)))
|
|
279
|
+
tbl.add_row("Hidden columns", str(len(hidden_cols)))
|
|
280
|
+
tbl.add_row("Hidden measures", str(len(hidden_measures)))
|
|
281
|
+
tbl.add_row("Many-to-many rels", str(len(m2m)))
|
|
282
|
+
tbl.add_row("Bidirectional rels", str(len(bidir)))
|
|
283
|
+
tbl.add_row("Complexity score", f"{complexity} ({complexity_label})")
|
|
284
|
+
console.print(tbl)
|
|
285
|
+
|
|
286
|
+
if warnings:
|
|
287
|
+
console.print("\n[yellow]Warnings:[/yellow]")
|
|
288
|
+
for w in warnings:
|
|
289
|
+
console.print(f" [yellow]![/yellow] {w}")
|
|
290
|
+
else:
|
|
291
|
+
console.print("\n[green]No warnings — model looks healthy.[/green]")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@model.command("diff")
|
|
295
|
+
@click.option(
|
|
296
|
+
"--snapshot", required=True, help="Path to a TMDL snapshot directory to compare against."
|
|
297
|
+
)
|
|
298
|
+
@click.pass_context
|
|
299
|
+
def model_diff(ctx: click.Context, snapshot: str) -> None:
|
|
300
|
+
"""Compare current model against a saved TMDL snapshot and show what changed."""
|
|
301
|
+
backend = get_backend(ctx)
|
|
302
|
+
result = backend.model_diff(snapshot_path=snapshot)
|
|
303
|
+
if not result["has_changes"]:
|
|
304
|
+
console.print("[green]No changes detected[/green] — model matches snapshot.")
|
|
305
|
+
return
|
|
306
|
+
output_json_or_table(result, ctx, title="Model Diff")
|
|
307
|
+
console.print(
|
|
308
|
+
f"[yellow]Changes:[/yellow] {len(result['added'])} added, {len(result['removed'])} removed, {len(result['changed'])} modified" # noqa: E501
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@model.command("impact")
|
|
313
|
+
@click.option("--measure", default=None, help="Measure name to trace impact for.")
|
|
314
|
+
@click.option(
|
|
315
|
+
"--column", default=None, help="Column name (format: Table[Column]) to trace impact for."
|
|
316
|
+
)
|
|
317
|
+
@click.option(
|
|
318
|
+
"--pbip", default=None, help="Optional .pbip path to also scan report visuals for references."
|
|
319
|
+
)
|
|
320
|
+
@click.pass_context
|
|
321
|
+
def model_impact(
|
|
322
|
+
ctx: click.Context, measure: str | None, column: str | None, pbip: str | None
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Trace downstream impact of a measure or column change.
|
|
325
|
+
|
|
326
|
+
Shows which other measures reference it and which report visuals use it.
|
|
327
|
+
|
|
328
|
+
\b
|
|
329
|
+
Examples:
|
|
330
|
+
pbi model impact --measure "Total Sales"
|
|
331
|
+
pbi model impact --column "financials[Sales]" --pbip MyReport
|
|
332
|
+
"""
|
|
333
|
+
if not measure and not column:
|
|
334
|
+
raise click.UsageError("Provide --measure or --column.")
|
|
335
|
+
|
|
336
|
+
target_name = measure or column
|
|
337
|
+
assert target_name
|
|
338
|
+
|
|
339
|
+
backend = get_backend(ctx)
|
|
340
|
+
|
|
341
|
+
# 1. Find direct DAX dependents in the model
|
|
342
|
+
dax_dependents: list[dict] = []
|
|
343
|
+
try:
|
|
344
|
+
all_measures = backend.measure_list()
|
|
345
|
+
for m in all_measures:
|
|
346
|
+
expr = m.get("expression", "") or ""
|
|
347
|
+
# Check if target is referenced in the DAX expression
|
|
348
|
+
search_terms = _impact_search_terms(target_name, is_measure=bool(measure))
|
|
349
|
+
if any(term.lower() in expr.lower() for term in search_terms):
|
|
350
|
+
dax_dependents.append(
|
|
351
|
+
{
|
|
352
|
+
"type": "measure",
|
|
353
|
+
"table": m.get("table", ""),
|
|
354
|
+
"name": m.get("name", ""),
|
|
355
|
+
"expression_snippet": expr[:120].replace("\n", " "),
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
console.print(f"[yellow]Could not scan DAX expressions: {e}[/yellow]")
|
|
360
|
+
|
|
361
|
+
# 2. Scan PBIR visuals if --pbip provided
|
|
362
|
+
visual_refs: list[dict] = []
|
|
363
|
+
if pbip:
|
|
364
|
+
try:
|
|
365
|
+
visual_refs = _scan_pbir_for_field(pbip, target_name, is_measure=bool(measure))
|
|
366
|
+
except Exception as e:
|
|
367
|
+
console.print(f"[yellow]Could not scan PBIR visuals: {e}[/yellow]")
|
|
368
|
+
|
|
369
|
+
# 3. Report results
|
|
370
|
+
console.print(f"\n[bold]Impact analysis:[/bold] {target_name}")
|
|
371
|
+
console.print(f" Type: {'measure' if measure else 'column'}")
|
|
372
|
+
console.print()
|
|
373
|
+
|
|
374
|
+
if dax_dependents:
|
|
375
|
+
console.print(f"[cyan]DAX dependents[/cyan] ({len(dax_dependents)}):")
|
|
376
|
+
for dep in dax_dependents:
|
|
377
|
+
console.print(f" [{dep['table']}].[{dep['name']}]")
|
|
378
|
+
console.print(f" {dep['expression_snippet']}...")
|
|
379
|
+
else:
|
|
380
|
+
console.print("[green]No DAX dependents found.[/green]")
|
|
381
|
+
|
|
382
|
+
if pbip:
|
|
383
|
+
if visual_refs:
|
|
384
|
+
console.print(f"\n[cyan]Report visual references[/cyan] ({len(visual_refs)}):")
|
|
385
|
+
for ref in visual_refs:
|
|
386
|
+
console.print(
|
|
387
|
+
f" Page '{ref['page']}' -> {ref['visual_type']} ({ref['visual_name']})"
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
console.print("[green]No report visual references found.[/green]")
|
|
391
|
+
|
|
392
|
+
if ctx.obj and ctx.obj.get("output_json"):
|
|
393
|
+
import json as _json
|
|
394
|
+
|
|
395
|
+
result = {
|
|
396
|
+
"target": target_name,
|
|
397
|
+
"type": "measure" if measure else "column",
|
|
398
|
+
"dax_dependents": dax_dependents,
|
|
399
|
+
"visual_refs": visual_refs,
|
|
400
|
+
}
|
|
401
|
+
console.print(_json.dumps(result, indent=2))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _impact_search_terms(name: str, is_measure: bool) -> list[str]:
|
|
405
|
+
"""Return search patterns to look for in DAX expressions."""
|
|
406
|
+
terms = [name]
|
|
407
|
+
if is_measure:
|
|
408
|
+
terms.append(f"[{name}]")
|
|
409
|
+
else:
|
|
410
|
+
# column: "Table[Col]" or just "[Col]"
|
|
411
|
+
if "[" in name:
|
|
412
|
+
table, col = name.split("[", 1)
|
|
413
|
+
col = col.rstrip("]")
|
|
414
|
+
terms.extend([f"{table}[{col}]", f"[{col}]", col])
|
|
415
|
+
return terms
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _scan_pbir_for_field(pbip: str, name: str, is_measure: bool) -> list[dict]:
|
|
419
|
+
"""Scan all PBIR visual.json files for references to a field or measure."""
|
|
420
|
+
import json as _json
|
|
421
|
+
from pathlib import Path
|
|
422
|
+
|
|
423
|
+
results: list[dict] = []
|
|
424
|
+
root = Path(pbip)
|
|
425
|
+
# Handle both .pbip file path and directory path
|
|
426
|
+
if root.is_file() and root.suffix == ".pbip":
|
|
427
|
+
root = root.parent
|
|
428
|
+
report_dirs = list(root.glob("*.Report"))
|
|
429
|
+
if not report_dirs:
|
|
430
|
+
return results
|
|
431
|
+
|
|
432
|
+
report_dir = report_dirs[0]
|
|
433
|
+
pages_dir = report_dir / "definition" / "pages"
|
|
434
|
+
if not pages_dir.exists():
|
|
435
|
+
return results
|
|
436
|
+
|
|
437
|
+
# Determine what to look for
|
|
438
|
+
prop_name = name
|
|
439
|
+
if "[" in name:
|
|
440
|
+
prop_name = name.split("[", 1)[1].rstrip("]")
|
|
441
|
+
|
|
442
|
+
for page_dir in pages_dir.iterdir():
|
|
443
|
+
if not page_dir.is_dir():
|
|
444
|
+
continue
|
|
445
|
+
pj = page_dir / "page.json"
|
|
446
|
+
page_name = page_dir.name
|
|
447
|
+
if pj.exists():
|
|
448
|
+
try:
|
|
449
|
+
pdata = _json.loads(pj.read_text(encoding="utf-8"))
|
|
450
|
+
page_name = pdata.get("displayName", page_dir.name)
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
visuals_dir = page_dir / "visuals"
|
|
455
|
+
if not visuals_dir.exists():
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
for vdir in visuals_dir.iterdir():
|
|
459
|
+
if not vdir.is_dir():
|
|
460
|
+
continue
|
|
461
|
+
vj = vdir / "visual.json"
|
|
462
|
+
if not vj.exists():
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
vdata = _json.loads(vj.read_text(encoding="utf-8"))
|
|
466
|
+
content = _json.dumps(vdata)
|
|
467
|
+
# Look for the property name in the JSON content
|
|
468
|
+
if f'"Property": "{prop_name}"' in content:
|
|
469
|
+
results.append(
|
|
470
|
+
{
|
|
471
|
+
"page": page_name,
|
|
472
|
+
"visual_name": vdata.get("name", vdir.name),
|
|
473
|
+
"visual_type": vdata.get("visual", {}).get("visualType", "unknown"),
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _build_measure_suggestions(tables: list, columns: list) -> list[dict]:
|
|
482
|
+
suggestions = []
|
|
483
|
+
numeric_cols = [c for c in columns if c.get("dataType") in ("Decimal", "Double", "Int64")]
|
|
484
|
+
for col in numeric_cols[:3]:
|
|
485
|
+
suggestions.append(
|
|
486
|
+
{
|
|
487
|
+
"name": f"YTD {col['name']}",
|
|
488
|
+
"expression": f"TOTALYTD(SUM({col['table']}[{col['name']}]), Calendar[Date])",
|
|
489
|
+
"category": "Time Intelligence",
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
suggestions.append(
|
|
493
|
+
{
|
|
494
|
+
"name": f"MoM {col['name']} %",
|
|
495
|
+
"expression": f"DIVIDE(SUM({col['table']}[{col['name']}]) - CALCULATE(SUM({col['table']}[{col['name']}]), DATEADD(Calendar[Date], -1, MONTH)), CALCULATE(SUM({col['table']}[{col['name']}]), DATEADD(Calendar[Date], -1, MONTH)))", # noqa: E501
|
|
496
|
+
"category": "Time Intelligence",
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
return suggestions
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""pbi partition — manage table partitions and incremental refresh."""
|
|
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 partition() -> None:
|
|
15
|
+
"""Manage table partitions and trigger selective refreshes."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@partition.command("list")
|
|
19
|
+
@click.option("--table", default=None, help="Filter to a specific table.")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def partition_list(ctx: click.Context, table: str | None) -> None:
|
|
22
|
+
"""List all partitions (and their refresh state) across all tables."""
|
|
23
|
+
backend = get_backend(ctx)
|
|
24
|
+
data = backend.partition_list(table=table)
|
|
25
|
+
if not data:
|
|
26
|
+
console.print("[yellow]No partitions found.[/yellow]")
|
|
27
|
+
return
|
|
28
|
+
output_json_or_table(data, ctx, title="Partitions")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@partition.command("add")
|
|
32
|
+
@click.option("--table", required=True, help="Table to add the partition to.")
|
|
33
|
+
@click.option("--name", required=True, help="Partition name.")
|
|
34
|
+
@click.option("--query", required=True, help="M (Power Query) expression for the partition source.")
|
|
35
|
+
@click.pass_context
|
|
36
|
+
def partition_add(ctx: click.Context, table: str, name: str, query: str) -> None:
|
|
37
|
+
"""Add a new M-query partition to a table.
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
Example — date-range partition:
|
|
41
|
+
pbi partition add --table Sales --name "Sales 2024" \\
|
|
42
|
+
--query 'let src = Sales_All,
|
|
43
|
+
filtered = Table.SelectRows(src, each [Year] = 2024) in filtered'
|
|
44
|
+
"""
|
|
45
|
+
if dry_run_echo(ctx, f"add partition '{name}' to '{table}'"):
|
|
46
|
+
return
|
|
47
|
+
backend = get_backend(ctx)
|
|
48
|
+
result = backend.partition_add(table=table, name=name, query=query)
|
|
49
|
+
from pbi_cli._audit import write_audit_entry
|
|
50
|
+
|
|
51
|
+
write_audit_entry("partition add", extra={"table": table, "name": name})
|
|
52
|
+
output_json_or_table(result, ctx, title="Partition Added")
|
|
53
|
+
console.print(f"[green]Partition added:[/green] '{name}' -> '{table}'")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@partition.command("delete")
|
|
57
|
+
@click.option("--table", required=True)
|
|
58
|
+
@click.option("--name", required=True)
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def partition_delete(ctx: click.Context, table: str, name: str) -> None:
|
|
61
|
+
"""Delete a partition from a table."""
|
|
62
|
+
if dry_run_echo(ctx, f"delete partition '{name}' from '{table}'"):
|
|
63
|
+
return
|
|
64
|
+
backend = get_backend(ctx)
|
|
65
|
+
backend.partition_delete(table=table, name=name)
|
|
66
|
+
from pbi_cli._audit import write_audit_entry
|
|
67
|
+
|
|
68
|
+
write_audit_entry("partition delete", extra={"table": table, "name": name})
|
|
69
|
+
console.print(f"[green]Deleted[/green] partition '{name}' from '{table}'.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@partition.command("refresh")
|
|
73
|
+
@click.option("--table", required=True, help="Table containing the partition.")
|
|
74
|
+
@click.option("--name", required=True, help="Partition name to refresh.")
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def partition_refresh(ctx: click.Context, table: str, name: str) -> None:
|
|
77
|
+
"""Trigger a full refresh of a single partition (selective refresh).
|
|
78
|
+
|
|
79
|
+
More efficient than refreshing the entire table when only recent data changed.
|
|
80
|
+
"""
|
|
81
|
+
if dry_run_echo(ctx, f"refresh partition '{name}' in '{table}'"):
|
|
82
|
+
return
|
|
83
|
+
backend = get_backend(ctx)
|
|
84
|
+
result = backend.partition_refresh(table=table, name=name)
|
|
85
|
+
from pbi_cli._audit import write_audit_entry
|
|
86
|
+
|
|
87
|
+
write_audit_entry("partition refresh", extra={"table": table, "name": name})
|
|
88
|
+
console.print(f"[green]Refresh requested:[/green] '{name}' in '{table}'")
|
|
89
|
+
output_json_or_table(result, ctx, title="Partition Refresh")
|