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