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,153 @@
1
+ """pbi connections — named connection profiles (~/.pbi-cli/connections.json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from pbi_cli.commands._shared import output_json_or_table
13
+
14
+ console = Console()
15
+
16
+ _CONFIG_PATH = Path.home() / ".pbi-cli" / "connections.json"
17
+
18
+
19
+ def _load() -> dict[str, Any]:
20
+ if _CONFIG_PATH.exists():
21
+ return json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
22
+ return {"connections": [], "last": None}
23
+
24
+
25
+ def _save(data: dict[str, Any]) -> None:
26
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
27
+ _CONFIG_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
28
+
29
+
30
+ @click.group()
31
+ def connections() -> None:
32
+ """Manage named Power BI connection profiles (~/.pbi-cli/connections.json)."""
33
+
34
+
35
+ @connections.command("list")
36
+ @click.pass_context
37
+ def connections_list(ctx: click.Context) -> None:
38
+ """List all saved named connections."""
39
+ data = _load()
40
+ conns = data.get("connections", [])
41
+ if not conns:
42
+ console.print("[yellow]No saved connections.[/yellow]")
43
+ console.print("Use 'pbi connections add' to save one.")
44
+ return
45
+ # Mask secrets
46
+ display = []
47
+ for c in conns:
48
+ row = dict(c)
49
+ if row.get("client_secret"):
50
+ row["client_secret"] = "***"
51
+ display.append(row)
52
+ output_json_or_table(display, ctx, title="Saved Connections")
53
+ if data.get("last"):
54
+ console.print(f"\n[dim]Last used:[/dim] [cyan]{data['last']}[/cyan]")
55
+
56
+
57
+ @connections.command("last")
58
+ def connections_last() -> None:
59
+ """Show the most recently used connection name."""
60
+ data = _load()
61
+ last = data.get("last")
62
+ if last:
63
+ console.print(f"[cyan]Last connection:[/cyan] {last}")
64
+ else:
65
+ console.print("[yellow]No connection has been used yet.[/yellow]")
66
+
67
+
68
+ @connections.command("add")
69
+ @click.option("--name", required=True, help="Short alias for this connection.")
70
+ @click.option(
71
+ "--type",
72
+ "conn_type",
73
+ required=True,
74
+ type=click.Choice(["desktop", "xmla"]),
75
+ help="Connection type.",
76
+ )
77
+ @click.option("--endpoint", default=None, help="XMLA endpoint URL.")
78
+ @click.option("--catalog", default=None, help="Dataset / semantic model name.")
79
+ @click.option(
80
+ "--auth",
81
+ default="device_flow",
82
+ type=click.Choice(["device_flow", "service_principal", "token"]),
83
+ help="Auth mode (XMLA only).",
84
+ )
85
+ @click.option("--client-id", default=None, help="AAD client ID (service_principal).")
86
+ @click.option("--client-secret", default=None, help="AAD client secret (service_principal).")
87
+ @click.option("--tenant-id", default=None, help="AAD tenant ID (service_principal).")
88
+ @click.option("--port", default=None, type=int, help="Desktop local server port.")
89
+ def connections_add(
90
+ name: str,
91
+ conn_type: str,
92
+ endpoint: str | None,
93
+ catalog: str | None,
94
+ auth: str,
95
+ client_id: str | None,
96
+ client_secret: str | None,
97
+ tenant_id: str | None,
98
+ port: int | None,
99
+ ) -> None:
100
+ """Save a named connection profile."""
101
+ data = _load()
102
+ # Remove existing with same name
103
+ data["connections"] = [c for c in data.get("connections", []) if c["name"] != name]
104
+ record: dict[str, Any] = {"name": name, "type": conn_type}
105
+ if conn_type == "xmla":
106
+ if not endpoint:
107
+ raise click.UsageError("--endpoint is required for xmla connections.")
108
+ record.update({"endpoint": endpoint, "catalog": catalog or "", "auth": auth})
109
+ if auth == "service_principal":
110
+ record.update(
111
+ {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id}
112
+ )
113
+ else:
114
+ if port:
115
+ record["port"] = port
116
+ data.setdefault("connections", []).append(record)
117
+ _save(data)
118
+ console.print(f"[green]Connection saved:[/green] '{name}' ({conn_type})")
119
+
120
+
121
+ @connections.command("remove")
122
+ @click.argument("name")
123
+ def connections_remove(name: str) -> None:
124
+ """Remove a saved connection profile by name."""
125
+ data = _load()
126
+ before = len(data.get("connections", []))
127
+ data["connections"] = [c for c in data.get("connections", []) if c["name"] != name]
128
+ if len(data["connections"]) == before:
129
+ console.print(f"[yellow]Connection '{name}' not found.[/yellow]")
130
+ return
131
+ if data.get("last") == name:
132
+ data["last"] = None
133
+ _save(data)
134
+ console.print(f"[red]Removed[/red] connection '{name}'.")
135
+
136
+
137
+ @connections.command("use")
138
+ @click.argument("name")
139
+ def connections_use(name: str) -> None:
140
+ """Set a connection as the active default and print its connect command."""
141
+ data = _load()
142
+ conn = next((c for c in data.get("connections", []) if c["name"] == name), None)
143
+ if not conn:
144
+ console.print(f"[red]Connection '{name}' not found.[/red]")
145
+ raise SystemExit(1)
146
+ data["last"] = name
147
+ _save(data)
148
+ console.print(f"[green]Active connection:[/green] {name}")
149
+ if conn["type"] == "xmla":
150
+ console.print(f" pbi --backend xmla model info # uses {conn.get('endpoint', '')}")
151
+ else:
152
+ port_flag = f" --port {conn['port']}" if conn.get("port") else ""
153
+ console.print(f" pbi{port_flag} model info")
@@ -0,0 +1,325 @@
1
+ """pbi custom-visual — TypeScript custom visual SDK scaffolding, build, package, import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group("custom-visual")
17
+ def custom_visual() -> None:
18
+ """Scaffold, build, package, and import Power BI custom visuals (TypeScript SDK)."""
19
+
20
+
21
+ @custom_visual.command("scaffold")
22
+ @click.option("--name", required=True, help="Visual name (PascalCase, e.g. MyBarChart).")
23
+ @click.option(
24
+ "--display", default=None, help="Display name shown in Power BI (defaults to --name)."
25
+ )
26
+ @click.option("--guid", default=None, help="Visual GUID (auto-generated if omitted).")
27
+ @click.option(
28
+ "--output", default=".", type=click.Path(), help="Parent directory for the new project."
29
+ )
30
+ @click.option("--author", default="", help="Author name for package.json.")
31
+ def custom_visual_scaffold(
32
+ name: str, display: str | None, guid: str | None, output: str, author: str
33
+ ) -> None:
34
+ """Create a new custom visual TypeScript project from the pbi-cli template.
35
+
36
+ \b
37
+ Produces the standard pbiviz project structure:
38
+ <name>/
39
+ package.json
40
+ pbiviz.json
41
+ tsconfig.json
42
+ src/
43
+ visual.ts
44
+ settings.ts
45
+ style/
46
+ visual.less
47
+ assets/
48
+ icon.png (placeholder)
49
+ capabilities.json
50
+
51
+ \b
52
+ Example:
53
+ pbi custom-visual scaffold --name MyBarChart --author "Mudassir"
54
+ """
55
+ import uuid
56
+
57
+ display_name = display or name
58
+ visual_guid = guid or str(uuid.uuid4())
59
+ project_dir = Path(output) / name
60
+ if project_dir.exists():
61
+ console.print(f"[yellow]Directory already exists:[/yellow] {project_dir}")
62
+ raise SystemExit(1)
63
+
64
+ project_dir.mkdir(parents=True)
65
+ (project_dir / "src").mkdir()
66
+ (project_dir / "style").mkdir()
67
+ (project_dir / "assets").mkdir()
68
+
69
+ # pbiviz.json
70
+ (project_dir / "pbiviz.json").write_text(
71
+ json.dumps(
72
+ {
73
+ "visual": {
74
+ "name": name,
75
+ "displayName": display_name,
76
+ "guid": visual_guid,
77
+ "visualClassName": name,
78
+ "version": "1.0.0",
79
+ "description": f"{display_name} Power BI custom visual",
80
+ "supportUrl": "",
81
+ "gitHubUrl": "",
82
+ },
83
+ "apiVersion": "5.3.0",
84
+ "author": {"name": author, "email": ""},
85
+ "assets": {"icon": "assets/icon.png"},
86
+ "stringResources": [],
87
+ "capabilities": "capabilities.json",
88
+ "stringResourcesPath": "",
89
+ "externalJS": [],
90
+ "style": "style/visual.less",
91
+ "sources": ["src/visual.ts"],
92
+ },
93
+ indent=2,
94
+ ),
95
+ encoding="utf-8",
96
+ )
97
+
98
+ # package.json
99
+ (project_dir / "package.json").write_text(
100
+ json.dumps(
101
+ {
102
+ "name": name.lower(),
103
+ "version": "1.0.0",
104
+ "description": f"{display_name} custom visual",
105
+ "scripts": {
106
+ "build": "tsc --noEmit",
107
+ "package": "pbiviz package",
108
+ "start": "pbiviz start",
109
+ },
110
+ "devDependencies": {
111
+ "powerbi-visuals-tools": "^5.2.0",
112
+ "typescript": "^5.0.0",
113
+ },
114
+ },
115
+ indent=2,
116
+ ),
117
+ encoding="utf-8",
118
+ )
119
+
120
+ # tsconfig.json
121
+ (project_dir / "tsconfig.json").write_text(
122
+ json.dumps(
123
+ {
124
+ "compilerOptions": {
125
+ "target": "ES6",
126
+ "module": "commonjs",
127
+ "lib": ["es2015", "dom"],
128
+ "strict": True,
129
+ "outDir": ".tmp/build",
130
+ "declaration": True,
131
+ "sourceMap": True,
132
+ },
133
+ "include": ["src/**/*.ts"],
134
+ "exclude": ["node_modules"],
135
+ },
136
+ indent=2,
137
+ ),
138
+ encoding="utf-8",
139
+ )
140
+
141
+ # capabilities.json
142
+ (project_dir / "capabilities.json").write_text(
143
+ json.dumps(
144
+ {
145
+ "dataRoles": [{"name": "Values", "kind": "Measure", "displayName": "Values"}],
146
+ "dataViewMappings": [{"single": {"role": "Values"}}],
147
+ "objects": {},
148
+ "supportsHighlight": True,
149
+ "sorting": {"default": {}},
150
+ },
151
+ indent=2,
152
+ ),
153
+ encoding="utf-8",
154
+ )
155
+
156
+ # src/visual.ts
157
+ (project_dir / "src" / "visual.ts").write_text(
158
+ f"""\
159
+ "use strict";
160
+
161
+ import powerbi from "powerbi-visuals-api";
162
+ import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions;
163
+ import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions;
164
+ import IVisual = powerbi.extensibility.visual.IVisual;
165
+
166
+ export class {name} implements IVisual {{
167
+ private target: HTMLElement;
168
+
169
+ constructor(options: VisualConstructorOptions) {{
170
+ this.target = options.element;
171
+ }}
172
+
173
+ public update(options: VisualUpdateOptions): void {{
174
+ const dataView = options.dataViews?.[0];
175
+ if (!dataView?.single?.value) return;
176
+ this.target.innerHTML = `<p style="font-size:24px">${{dataView.single.value}}</p>`;
177
+ }}
178
+ }}
179
+ """,
180
+ encoding="utf-8",
181
+ )
182
+
183
+ # style/visual.less
184
+ (project_dir / "style" / "visual.less").write_text(
185
+ f"/** {display_name} styles */\n.visual-container {{\n font-family: 'Segoe UI', sans-serif;\n}}\n", # noqa: E501
186
+ encoding="utf-8",
187
+ )
188
+
189
+ # assets/icon.png placeholder (empty file)
190
+ (project_dir / "assets" / "icon.png").write_bytes(b"")
191
+
192
+ console.print(f"[green]Scaffolded:[/green] {project_dir}")
193
+ console.print(f" GUID: {visual_guid}")
194
+ console.print(" Version: 1.0.0")
195
+ console.print("\n[cyan]Next steps:[/cyan]")
196
+ console.print(f" cd {project_dir}")
197
+ console.print(" npm install")
198
+ console.print(" pbi custom-visual build --path .")
199
+ console.print(" pbi custom-visual package --path .")
200
+
201
+
202
+ @custom_visual.command("build")
203
+ @click.option("--path", default=".", type=click.Path(exists=True), help="Visual project directory.")
204
+ @click.option("--watch", is_flag=True, help="Watch for changes and rebuild automatically.")
205
+ def custom_visual_build(path: str, watch: bool) -> None:
206
+ """Run tsc --noEmit to type-check the visual TypeScript source.
207
+
208
+ Requires Node.js and the devDependencies installed (npm install).
209
+
210
+ \b
211
+ Example:
212
+ pbi custom-visual build --path ./MyBarChart
213
+ """
214
+ cmd = ["npx", "tsc", "--noEmit"]
215
+ if watch:
216
+ cmd.append("--watch")
217
+ console.print(f"[cyan]Type-checking:[/cyan] {path}")
218
+ try:
219
+ result = subprocess.run(cmd, cwd=path)
220
+ if result.returncode == 0:
221
+ console.print("[green]Type check passed.[/green]")
222
+ else:
223
+ console.print("[red]Type errors found.[/red] Fix the issues above and re-run.")
224
+ raise SystemExit(result.returncode)
225
+ except FileNotFoundError:
226
+ console.print("[red]npx / Node.js not found.[/red] Install Node.js from https://nodejs.org")
227
+ raise SystemExit(1)
228
+
229
+
230
+ @custom_visual.command("package")
231
+ @click.option("--path", default=".", type=click.Path(exists=True), help="Visual project directory.")
232
+ @click.option(
233
+ "--output", default=None, type=click.Path(), help="Output .pbiviz path (default: dist/)."
234
+ )
235
+ def custom_visual_package(path: str, output: str | None) -> None:
236
+ """Package the visual into a .pbiviz file ready for Power BI import.
237
+
238
+ Requires powerbi-visuals-tools (pbiviz) installed: npm install -g powerbi-visuals-tools
239
+
240
+ \b
241
+ Example:
242
+ pbi custom-visual package --path ./MyBarChart
243
+ """
244
+ project = Path(path)
245
+ pbiviz_json = project / "pbiviz.json"
246
+ if not pbiviz_json.exists():
247
+ console.print("[red]pbiviz.json not found.[/red] Run 'pbi custom-visual scaffold' first.")
248
+ raise SystemExit(1)
249
+
250
+ meta = json.loads(pbiviz_json.read_text(encoding="utf-8"))
251
+ name = meta["visual"]["name"]
252
+ version = meta["visual"]["version"]
253
+
254
+ console.print(f"[cyan]Packaging:[/cyan] {name} v{version}")
255
+ try:
256
+ result = subprocess.run(["pbiviz", "package"], cwd=path)
257
+ if result.returncode == 0:
258
+ dist_file = project / "dist" / f"{name}.pbiviz"
259
+ if output and dist_file.exists():
260
+ shutil.copy(dist_file, output)
261
+ console.print(f"[green]Packaged →[/green] {output}")
262
+ else:
263
+ console.print(f"[green]Packaged →[/green] {dist_file}")
264
+ else:
265
+ console.print("[red]Packaging failed.[/red]")
266
+ raise SystemExit(result.returncode)
267
+ except FileNotFoundError:
268
+ console.print("[red]pbiviz not found.[/red] Run: npm install -g powerbi-visuals-tools")
269
+ raise SystemExit(1)
270
+
271
+
272
+ @custom_visual.command("import")
273
+ @click.option("--pbip", required=True, help="Path to the .pbip report project.")
274
+ @click.option(
275
+ "--pbiviz",
276
+ required=True,
277
+ type=click.Path(exists=True),
278
+ help="Path to the .pbiviz package file.",
279
+ )
280
+ @click.pass_context
281
+ def custom_visual_import(ctx: click.Context, pbip: str, pbiviz: str) -> None:
282
+ """Import a .pbiviz package into a .pbip report project.
283
+
284
+ Registers the custom visual in the report so it can be used on any page.
285
+
286
+ \b
287
+ Example:
288
+ pbi custom-visual import --pbip ./MyReport --pbiviz ./MyBarChart/dist/MyBarChart.pbiviz
289
+ """
290
+ from pbi_cli.commands._shared import dry_run_echo
291
+
292
+ if dry_run_echo(ctx, f"import custom visual '{pbiviz}' into '{pbip}'"):
293
+ return
294
+
295
+ pbiviz_path = Path(pbiviz)
296
+ if not pbiviz_path.suffix == ".pbiviz":
297
+ console.print("[red]File must have .pbiviz extension.[/red]")
298
+ raise SystemExit(1)
299
+
300
+ # Extract the pbiviz (it's a zip) to find the visual GUID
301
+ import zipfile
302
+
303
+ with zipfile.ZipFile(pbiviz_path) as z:
304
+ names = z.namelist()
305
+ pbiviz_json_name = next((n for n in names if n.endswith("pbiviz.json")), None)
306
+ if not pbiviz_json_name:
307
+ console.print("[red]Invalid .pbiviz file — pbiviz.json not found inside archive.[/red]")
308
+ raise SystemExit(1)
309
+ meta = json.loads(z.read(pbiviz_json_name).decode("utf-8"))
310
+
311
+ visual_name = meta.get("visual", {}).get("name", pbiviz_path.stem)
312
+ visual_guid = meta.get("visual", {}).get("guid", "unknown")
313
+ version = meta.get("visual", {}).get("version", "1.0.0")
314
+
315
+ # Copy pbiviz into report's custom visuals folder
316
+ report_path = Path(pbip)
317
+ cv_dir = report_path / "definition" / "customVisuals"
318
+ cv_dir.mkdir(parents=True, exist_ok=True)
319
+ dest = cv_dir / pbiviz_path.name
320
+ shutil.copy(pbiviz_path, dest)
321
+
322
+ console.print(f"[green]Custom visual imported:[/green] {visual_name} v{version}")
323
+ console.print(f" GUID: {visual_guid}")
324
+ console.print(f" Stored: {dest}")
325
+ console.print("[dim]Reload the report in Power BI Desktop to use the visual.[/dim]")
@@ -0,0 +1,76 @@
1
+ """pbi database — TMDL export/import 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
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def database() -> None:
15
+ """Export and import TMDL snapshots."""
16
+
17
+
18
+ @database.command("export-tmdl")
19
+ @click.argument("path")
20
+ @click.pass_context
21
+ def export_tmdl(ctx: click.Context, path: str) -> None:
22
+ """Export the current model as TMDL files to a directory."""
23
+ if dry_run_echo(ctx, f"export TMDL to {path}"):
24
+ return
25
+ backend = get_backend(ctx)
26
+ backend.tmdl_export(path)
27
+ console.print(f"[green]TMDL exported to:[/green] {path}")
28
+
29
+
30
+ @database.command("import-tmdl")
31
+ @click.argument("path")
32
+ @click.pass_context
33
+ def import_tmdl(ctx: click.Context, path: str) -> None:
34
+ """Import TMDL files from a directory into the connected model."""
35
+ if dry_run_echo(ctx, f"import TMDL from {path}"):
36
+ return
37
+ backend = get_backend(ctx)
38
+ backend.tmdl_import(path)
39
+ console.print(f"[green]TMDL imported from:[/green] {path}")
40
+
41
+
42
+ @database.command("diff-tmdl")
43
+ @click.argument("snapshot_path", type=click.Path(exists=True))
44
+ @click.option("--output", default=None, type=click.Path(), help="Save diff report to a JSON file.")
45
+ @click.pass_context
46
+ def diff_tmdl(ctx: click.Context, snapshot_path: str, output: str | None) -> None:
47
+ """Compare the live model against a TMDL snapshot directory.
48
+
49
+ Reports added, removed, and changed objects (tables, measures, columns,
50
+ relationships) relative to the snapshot.
51
+
52
+ \b
53
+ Example:
54
+ pbi database diff-tmdl ./snapshots/2024-01-01/
55
+ """
56
+ import json as _json
57
+ from pathlib import Path
58
+
59
+ from pbi_cli.commands._shared import output_json_or_table
60
+
61
+ backend = get_backend(ctx)
62
+ diff = backend.model_diff(snapshot_path)
63
+
64
+ if output:
65
+ Path(output).write_text(_json.dumps(diff, indent=2, default=str), encoding="utf-8")
66
+ console.print(f"[green]Diff saved to:[/green] {output}")
67
+ else:
68
+ if not diff.get("has_changes"):
69
+ console.print("[green]No changes detected[/green] — model matches snapshot.")
70
+ else:
71
+ console.print(
72
+ f"[yellow]Changes detected:[/yellow] {len(diff.get('added', []))} added, "
73
+ f"{len(diff.get('removed', []))} removed, "
74
+ f"{len(diff.get('changed', []))} modified"
75
+ )
76
+ output_json_or_table(diff, ctx, title="TMDL Diff")