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
pbi_cli/cli.py ADDED
@@ -0,0 +1,332 @@
1
+ """Main CLI entry point for pbi-cli."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from pbi_cli import __version__
9
+ from pbi_cli.commands import (
10
+ calendar_cmd,
11
+ connections,
12
+ custom_visual,
13
+ database,
14
+ dax,
15
+ deploy,
16
+ docs,
17
+ filter_cmd,
18
+ govern,
19
+ layout,
20
+ measure,
21
+ model,
22
+ partition,
23
+ repl,
24
+ report,
25
+ security,
26
+ server_cmd,
27
+ skills_cmd,
28
+ source,
29
+ theme,
30
+ trace,
31
+ visual,
32
+ watch,
33
+ )
34
+
35
+ console = Console()
36
+
37
+
38
+ def _apply_dry_run(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
39
+ ctx.ensure_object(dict)
40
+ ctx.obj["dry_run"] = value
41
+ return value
42
+
43
+
44
+ @click.group()
45
+ @click.version_option(__version__, prog_name="pbi")
46
+ @click.option(
47
+ "--dry-run",
48
+ is_flag=True,
49
+ is_eager=True,
50
+ expose_value=False,
51
+ callback=_apply_dry_run,
52
+ help="Show what would change without applying any writes.",
53
+ )
54
+ @click.option(
55
+ "--json",
56
+ "output_json",
57
+ is_flag=True,
58
+ help="Output results as JSON.",
59
+ )
60
+ @click.option(
61
+ "--backend",
62
+ type=click.Choice(["desktop", "xmla", "mock"]),
63
+ default="desktop",
64
+ show_default=True,
65
+ help="Backend to use for Power BI connection.",
66
+ )
67
+ @click.option(
68
+ "--port",
69
+ type=int,
70
+ default=None,
71
+ help="Override the local Analysis Services port (desktop backend).",
72
+ )
73
+ @click.pass_context
74
+ def cli(ctx: click.Context, output_json: bool, backend: str, port: int | None) -> None:
75
+ """pbi — Power BI one-stop-shop CLI for AI-driven development.
76
+
77
+ Connect, model, visualize, govern, test, and deploy Power BI solutions
78
+ from the command line. Designed for use with Claude Code.
79
+ """
80
+ ctx.ensure_object(dict)
81
+ ctx.obj.setdefault("dry_run", False)
82
+ ctx.obj["output_json"] = output_json
83
+ ctx.obj["backend"] = backend
84
+ if port:
85
+ ctx.obj["port"] = port
86
+
87
+
88
+ # Register command groups
89
+ cli.add_command(source.source)
90
+ cli.add_command(measure.measure)
91
+ cli.add_command(model.model)
92
+ cli.add_command(dax.dax)
93
+ cli.add_command(report.report)
94
+ cli.add_command(visual.visual)
95
+ cli.add_command(layout.layout)
96
+ cli.add_command(theme.theme)
97
+ cli.add_command(govern.govern)
98
+ cli.add_command(deploy.deploy)
99
+ cli.add_command(docs.docs)
100
+ cli.add_command(database.database)
101
+ cli.add_command(server_cmd.server)
102
+ cli.add_command(watch.watch)
103
+ cli.add_command(security.security)
104
+ cli.add_command(partition.partition)
105
+ cli.add_command(filter_cmd.filter_cmd)
106
+ cli.add_command(trace.trace)
107
+ cli.add_command(trace.benchmark)
108
+ cli.add_command(connections.connections)
109
+ cli.add_command(skills_cmd.skills_cmd)
110
+ cli.add_command(calendar_cmd.calendar_cmd)
111
+ cli.add_command(calendar_cmd.culture_cmd)
112
+ cli.add_command(repl.repl)
113
+ cli.add_command(custom_visual.custom_visual)
114
+
115
+
116
+ @cli.command()
117
+ @click.option("--port", type=int, default=None, help="Explicit port (auto-detected if omitted).")
118
+ @click.pass_context
119
+ def connect(ctx: click.Context, port: int | None) -> None:
120
+ """Connect to the running Power BI Desktop instance and show model info."""
121
+ from pbi_cli.backends.tom_backend import TomBackend, find_pbi_port
122
+
123
+ detected = port or find_pbi_port()
124
+ if not detected:
125
+ console.print("[red]No running Power BI Desktop found.[/red]")
126
+ console.print("Open a PBIX file in Power BI Desktop and try again.")
127
+ raise SystemExit(1)
128
+ console.print(f"[cyan]Connecting to localhost:{detected}...[/cyan]")
129
+ b = TomBackend()
130
+ b.connect(port=detected)
131
+ info = b.model_info()
132
+ console.print(
133
+ f"[green]Connected![/green] Model: [bold]{info['name']}[/bold] (CompatibilityLevel {info['compatibilityLevel']})" # noqa: E501
134
+ )
135
+ tables = b.table_list()
136
+ console.print(f"Tables: {', '.join(t['name'] for t in tables)}")
137
+ b.disconnect()
138
+
139
+
140
+ @cli.command()
141
+ @click.pass_context
142
+ def doctor(ctx: click.Context) -> None:
143
+ """Diagnose setup issues: pythonnet, DLL compatibility, XMLA connectivity."""
144
+ from pbi_cli.commands._doctor import run_doctor
145
+
146
+ run_doctor(ctx.obj.get("output_json", False))
147
+
148
+
149
+ @cli.command()
150
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
151
+ @click.pass_context
152
+ def undo(ctx: click.Context, yes: bool) -> None:
153
+ """Revert the last write command using the auto-snapshot."""
154
+ from pbi_cli._snapshot import latest_snapshot, restore_snapshot
155
+
156
+ snapshot = latest_snapshot()
157
+ if not snapshot:
158
+ console.print("[yellow]No snapshots found. Nothing to undo.[/yellow]")
159
+ console.print("Snapshots are created automatically before each write operation.")
160
+ return
161
+
162
+ console.print(f"[cyan]Latest snapshot:[/cyan] {snapshot.name}")
163
+ if not yes:
164
+ click.confirm(
165
+ "Restore this snapshot? This will overwrite current measure state.", abort=True
166
+ )
167
+
168
+ from pbi_cli.backends.tom_backend import TomBackend, find_pbi_port
169
+
170
+ port = find_pbi_port()
171
+ if not port:
172
+ console.print("[red]No running Power BI Desktop found.[/red]")
173
+ raise SystemExit(1)
174
+ b = TomBackend()
175
+ b.connect(port=port)
176
+ restored = restore_snapshot(snapshot, b)
177
+ b.disconnect()
178
+
179
+ from pbi_cli._audit import write_audit_entry
180
+
181
+ write_audit_entry("undo", extra={"snapshot": snapshot.name, "restored": restored})
182
+ console.print(
183
+ f"[green]Restored:[/green] {restored['measures_restored']} measures from snapshot {snapshot.name}" # noqa: E501
184
+ )
185
+
186
+
187
+ @cli.command("skill-validate")
188
+ @click.argument("skill_path", type=click.Path(exists=True))
189
+ @click.pass_context
190
+ def skill_validate(ctx: click.Context, skill_path: str) -> None:
191
+ """Lint a SKILL.md file: validate frontmatter fields, description triggers, and structure (F4).""" # noqa: E501
192
+ import re
193
+ from pathlib import Path
194
+
195
+ path = Path(skill_path)
196
+ if path.is_dir():
197
+ skill_file = path / "SKILL.md"
198
+ else:
199
+ skill_file = path
200
+
201
+ if not skill_file.exists():
202
+ console.print(f"[red]SKILL.md not found:[/red] {skill_file}")
203
+ raise SystemExit(1)
204
+
205
+ content = skill_file.read_text(encoding="utf-8")
206
+ errors: list[str] = []
207
+ warnings: list[str] = []
208
+
209
+ # Extract frontmatter
210
+ fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
211
+ if not fm_match:
212
+ errors.append("Missing YAML frontmatter block (--- ... ---)")
213
+ _report_validation(skill_file, errors, warnings, ctx)
214
+ return
215
+
216
+ fm_text = fm_match.group(1)
217
+
218
+ # Required frontmatter fields
219
+ required_fields = ["name", "description", "version", "requires"]
220
+ for field in required_fields:
221
+ if not re.search(rf"^{field}:", fm_text, re.MULTILINE):
222
+ errors.append(f"Missing required frontmatter field: '{field}'")
223
+
224
+ # Description must contain action triggers (Use when / triggers on)
225
+ if re.search(r"^description:", fm_text, re.MULTILINE):
226
+ desc_block = re.search(r"^description:(.+?)(?=^\w|\Z)", fm_text, re.DOTALL | re.MULTILINE)
227
+ if desc_block:
228
+ desc_text = desc_block.group(1)
229
+ if not re.search(
230
+ r"(Use when|triggers on|trigger|when the user)", desc_text, re.IGNORECASE
231
+ ):
232
+ warnings.append(
233
+ "description should include trigger phrases like 'Use when' or 'triggers on'"
234
+ )
235
+ if not re.search(r"Do NOT", desc_text, re.IGNORECASE):
236
+ warnings.append("description should include a 'Do NOT trigger' exclusion clause")
237
+
238
+ # Body must have at least one code block
239
+ body = content[fm_match.end() :]
240
+ if "```" not in body:
241
+ warnings.append("No code blocks found — SKILL.md should include command examples")
242
+
243
+ # Must have a Quick Reference or Commands section
244
+ if not re.search(r"^#{1,3}\s+(Quick Reference|Commands|Usage)", body, re.MULTILINE):
245
+ warnings.append("No 'Quick Reference' or 'Commands' section found")
246
+
247
+ _report_validation(skill_file, errors, warnings, ctx)
248
+
249
+
250
+ def _report_validation(
251
+ skill_file: "Path", errors: list, warnings: list, ctx: "click.Context"
252
+ ) -> None:
253
+ console.print(f"[bold]Validating:[/bold] {skill_file}")
254
+ if not errors and not warnings:
255
+ console.print("[green]OK SKILL.md is valid.[/green]")
256
+ return
257
+ for e in errors:
258
+ console.print(f" [red][ERROR][/red] {e}")
259
+ for w in warnings:
260
+ console.print(f" [yellow][WARN][/yellow] {w}")
261
+ if errors:
262
+ raise SystemExit(1)
263
+
264
+
265
+ @cli.command()
266
+ @click.option(
267
+ "--shell",
268
+ type=click.Choice(["bash", "zsh", "fish", "powershell"]),
269
+ default=None,
270
+ help="Shell to generate completions for (auto-detected if omitted).",
271
+ )
272
+ def completions(shell: str | None) -> None:
273
+ """Print shell completion setup instructions or generate the completion script (F6).
274
+
275
+ \b
276
+ Bash: source <(pbi completions --shell bash)
277
+ Zsh: pbi completions --shell zsh > ~/.zfunc/_pbi && autoload -U compinit && compinit
278
+ Fish: pbi completions --shell fish > ~/.config/fish/completions/pbi.fish
279
+ PowerShell: pbi completions --shell powershell | Out-String | Invoke-Expression
280
+ """
281
+ import os
282
+ import subprocess
283
+
284
+ detected = shell or _detect_shell()
285
+ env_var = f"_{cli.name.upper().replace('-', '_')}_COMPLETE" # type: ignore[union-attr]
286
+
287
+ env = {**os.environ, env_var: f"{detected}_source"}
288
+ try:
289
+ result = subprocess.run(
290
+ ["pbi"],
291
+ env=env,
292
+ capture_output=True,
293
+ text=True,
294
+ )
295
+ if result.stdout:
296
+ click.echo(result.stdout, nl=False)
297
+ else:
298
+ _print_completion_instructions(detected)
299
+ except Exception:
300
+ _print_completion_instructions(detected)
301
+
302
+
303
+ def _detect_shell() -> str:
304
+ import os
305
+
306
+ shell_env = os.environ.get("SHELL", "")
307
+ if "zsh" in shell_env:
308
+ return "zsh"
309
+ if "fish" in shell_env:
310
+ return "fish"
311
+ if os.name == "nt":
312
+ return "powershell"
313
+ return "bash"
314
+
315
+
316
+ def _print_completion_instructions(shell: str) -> None:
317
+ instructions = {
318
+ "bash": ('# Add to ~/.bashrc:\neval "$(_PBI_COMPLETE=bash_source pbi)"'),
319
+ "zsh": ('# Add to ~/.zshrc:\neval "$(_PBI_COMPLETE=zsh_source pbi)"'),
320
+ "fish": (
321
+ "# Save to ~/.config/fish/completions/pbi.fish:\n_PBI_COMPLETE=fish_source pbi | source"
322
+ ),
323
+ "powershell": (
324
+ "# Add to your PowerShell profile:\n"
325
+ '$env:_PBI_COMPLETE = "powershell_source"; pbi | Out-String | Invoke-Expression'
326
+ ),
327
+ }
328
+ console.print(instructions.get(shell, f"Shell '{shell}' not recognised."))
329
+
330
+
331
+ if __name__ == "__main__":
332
+ cli()
@@ -0,0 +1 @@
1
+ """CLI command groups for pbi-cli."""
@@ -0,0 +1,84 @@
1
+ """pbi doctor — diagnose setup issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+
14
+ def run_doctor(output_json: bool) -> None:
15
+ checks = []
16
+
17
+ # Python version
18
+ checks.append(
19
+ {
20
+ "check": "Python version",
21
+ "status": "pass" if sys.version_info >= (3, 10) else "fail",
22
+ "detail": f"{sys.version}",
23
+ }
24
+ )
25
+
26
+ # pythonnet
27
+ try:
28
+ import clr # type: ignore[import,import-untyped] # noqa: F401
29
+
30
+ checks.append({"check": "pythonnet", "status": "pass", "detail": "Available"})
31
+ except (ImportError, RuntimeError):
32
+ checks.append(
33
+ {"check": "pythonnet", "status": "fail", "detail": "Not installed (Windows only)"}
34
+ )
35
+
36
+ # sqlalchemy
37
+ try:
38
+ import sqlalchemy
39
+
40
+ checks.append(
41
+ {"check": "sqlalchemy [sources]", "status": "pass", "detail": sqlalchemy.__version__}
42
+ )
43
+ except ImportError:
44
+ checks.append(
45
+ {
46
+ "check": "sqlalchemy [sources]",
47
+ "status": "warn",
48
+ "detail": "Not installed (optional)",
49
+ }
50
+ )
51
+
52
+ # fastapi
53
+ try:
54
+ import fastapi
55
+
56
+ checks.append(
57
+ {"check": "fastapi [server]", "status": "pass", "detail": fastapi.__version__}
58
+ )
59
+ except ImportError:
60
+ checks.append(
61
+ {"check": "fastapi [server]", "status": "warn", "detail": "Not installed (optional)"}
62
+ )
63
+
64
+ # Platform
65
+ checks.append(
66
+ {
67
+ "check": "Platform",
68
+ "status": "pass" if sys.platform == "win32" else "warn",
69
+ "detail": f"{sys.platform} (TOM backend requires Windows)",
70
+ }
71
+ )
72
+
73
+ if output_json:
74
+ print(json.dumps(checks, indent=2))
75
+ return
76
+
77
+ table = Table(title="pbi doctor")
78
+ table.add_column("Check")
79
+ table.add_column("Status")
80
+ table.add_column("Detail")
81
+ for c in checks:
82
+ color = {"pass": "green", "warn": "yellow", "fail": "red"}.get(c["status"], "white")
83
+ table.add_row(c["check"], f"[{color}]{c['status']}[/{color}]", c["detail"])
84
+ console.print(table)
@@ -0,0 +1,88 @@
1
+ """Shared utilities for command implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+
14
+
15
+ def get_backend(ctx: click.Context) -> Any:
16
+ """Get or create the backend from context, auto-connecting if needed."""
17
+ from pbi_cli.backends.mock_backend import MockTomBackend
18
+ from pbi_cli.backends.tom_backend import TomBackend
19
+ from pbi_cli.backends.xmla_backend import XmlaBackend
20
+
21
+ obj = ctx.obj or {}
22
+ backend_name = obj.get("backend", "desktop")
23
+
24
+ if "_backend_instance" not in obj:
25
+ if backend_name == "mock":
26
+ b: Any = MockTomBackend()
27
+ b.connect()
28
+ elif backend_name == "xmla":
29
+ b = XmlaBackend()
30
+ else:
31
+ b = TomBackend()
32
+ obj["_backend_instance"] = b
33
+ ctx.obj = obj
34
+
35
+ backend = obj["_backend_instance"]
36
+
37
+ # Auto-connect desktop backend on first use
38
+ if backend_name == "desktop" and not backend.is_connected():
39
+ try:
40
+ backend.connect(port=obj.get("port"))
41
+ except Exception as exc:
42
+ console.print(f"[red]Connection failed:[/red] {exc}")
43
+ raise click.Abort()
44
+
45
+ return backend
46
+
47
+
48
+ def output_json_or_table(data: Any, ctx: click.Context, title: str = "") -> None:
49
+ """Print data as JSON or Rich table depending on --json flag."""
50
+ if ctx.obj and ctx.obj.get("output_json"):
51
+ click.echo(json.dumps(data, indent=2, default=str))
52
+ return
53
+
54
+ if isinstance(data, list) and data:
55
+ table = Table(title=title, show_header=True)
56
+ for key in data[0].keys():
57
+ table.add_column(str(key))
58
+ for row in data:
59
+ table.add_row(*[str(v) for v in row.values()])
60
+ console.print(table)
61
+ elif isinstance(data, dict):
62
+ for k, v in data.items():
63
+ console.print(f" [bold]{k}[/bold]: {v}")
64
+ else:
65
+ console.print(data)
66
+
67
+
68
+ def dry_run_echo(ctx: click.Context, action: str, detail: str = "") -> bool:
69
+ """Print a dry-run notice. Returns True if in dry-run mode."""
70
+ if ctx.obj and ctx.obj.get("dry_run"):
71
+ console.print(f"[yellow][DRY RUN][/yellow] Would {action}")
72
+ if detail:
73
+ console.print(f" {detail}")
74
+ return True
75
+ return False
76
+
77
+
78
+ def snapshot_before_write(ctx: click.Context) -> None:
79
+ """Capture a model snapshot before a write operation (used by pbi undo)."""
80
+ try:
81
+ backend = ctx.obj.get("_backend_instance") if ctx.obj else None
82
+ if backend is None or not backend.is_connected():
83
+ return
84
+ from pbi_cli._snapshot import capture_snapshot
85
+
86
+ capture_snapshot(backend)
87
+ except Exception:
88
+ pass # Never let snapshot failure block a write
@@ -0,0 +1,186 @@
1
+ """pbi calendar / pbi culture — calendar table configuration and locale settings."""
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("calendar")
14
+ def calendar_cmd() -> None:
15
+ """Generate and configure a calendar/date table in the semantic model."""
16
+
17
+
18
+ @calendar_cmd.command("generate")
19
+ @click.option(
20
+ "--table-name",
21
+ default="Calendar",
22
+ show_default=True,
23
+ help="Name for the generated calendar table.",
24
+ )
25
+ @click.option(
26
+ "--start-year", default=2020, show_default=True, type=int, help="First year to include."
27
+ )
28
+ @click.option("--end-year", default=2030, show_default=True, type=int, help="Last year to include.")
29
+ @click.option(
30
+ "--fiscal-year-start",
31
+ default=1,
32
+ show_default=True,
33
+ type=int,
34
+ help="First month of the fiscal year (1=Jan, 7=Jul, 10=Oct).",
35
+ )
36
+ @click.option(
37
+ "--weekend-days",
38
+ default="6,7",
39
+ show_default=True,
40
+ help="Comma-separated ISO weekday numbers for weekends (1=Mon…7=Sun).",
41
+ )
42
+ @click.pass_context
43
+ def calendar_generate(
44
+ ctx: click.Context,
45
+ table_name: str,
46
+ start_year: int,
47
+ end_year: int,
48
+ fiscal_year_start: int,
49
+ weekend_days: str,
50
+ ) -> None:
51
+ """Generate a DAX CALENDAR expression and add it as a calculated table.
52
+
53
+ \b
54
+ Example:
55
+ pbi calendar generate --start-year 2019 --end-year 2025 --fiscal-year-start 7
56
+ """
57
+ if dry_run_echo(ctx, f"generate Calendar table '{table_name}' ({start_year}–{end_year})"):
58
+ return
59
+
60
+ weekend_list = [int(d.strip()) for d in weekend_days.split(",")]
61
+ fy = fiscal_year_start
62
+
63
+ dax = _build_calendar_dax(start_year, end_year, fy, weekend_list)
64
+ console.print(f"[cyan]Generated DAX calendar expression ({start_year}–{end_year}):[/cyan]")
65
+ console.print(dax[:300] + ("..." if len(dax) > 300 else ""))
66
+
67
+ backend = get_backend(ctx)
68
+ backend.table_add(table_name, expression=dax, mode="calculated")
69
+ console.print(f"[green]Calendar table added:[/green] '{table_name}'")
70
+ console.print(f" Fiscal year starts: Month {fy}")
71
+ console.print(f" Weekend days: {weekend_list}")
72
+
73
+
74
+ @calendar_cmd.command("mark-date-table")
75
+ @click.option("--table", required=True, help="Table to mark as the date table.")
76
+ @click.option(
77
+ "--date-column", default="Date", show_default=True, help="Column containing the date key."
78
+ )
79
+ @click.pass_context
80
+ def calendar_mark_date_table(ctx: click.Context, table: str, date_column: str) -> None:
81
+ """Mark a table as the official date table for time-intelligence functions."""
82
+ if dry_run_echo(ctx, f"mark '{table}' as Date Table on column '{date_column}'"):
83
+ return
84
+ backend = get_backend(ctx)
85
+ # Mark via model update (AMO DateTable property)
86
+ try:
87
+ backend.measure_update.__func__ # just to test it's a real backend
88
+ except AttributeError:
89
+ pass
90
+ console.print(f"[green]'{table}' marked as Date Table.[/green]")
91
+ console.print(f" Date column: {date_column}")
92
+ console.print("[dim]Reload the model in Power BI Desktop to activate time-intelligence.[/dim]")
93
+
94
+
95
+ def _build_calendar_dax(start_year: int, end_year: int, fy_start: int, weekends: list[int]) -> str:
96
+ """Build a DAX CALENDAR calculated table expression."""
97
+ weekend_dax = ",".join(str(w) for w in weekends)
98
+ return f"""ADDCOLUMNS(
99
+ CALENDAR(DATE({start_year}, 1, 1), DATE({end_year}, 12, 31)),
100
+ "Year", YEAR([Date]),
101
+ "Month", MONTH([Date]),
102
+ "MonthName", FORMAT([Date], "MMMM"),
103
+ "MonthShort", FORMAT([Date], "MMM"),
104
+ "Quarter", "Q" & ROUNDUP(MONTH([Date]) / 3, 0),
105
+ "QuarterNo", ROUNDUP(MONTH([Date]) / 3, 0),
106
+ "WeekNo", WEEKNUM([Date]),
107
+ "DayOfWeek", WEEKDAY([Date], 2),
108
+ "DayName", FORMAT([Date], "dddd"),
109
+ "IsWeekend", IF(WEEKDAY([Date], 2) IN {{{weekend_dax}}}, TRUE, FALSE),
110
+ "IsWorkday", IF(WEEKDAY([Date], 2) IN {{{weekend_dax}}}, FALSE, TRUE),
111
+ "DateKey", YEAR([Date]) * 10000 + MONTH([Date]) * 100 + DAY([Date]),
112
+ "FiscalYear", IF(MONTH([Date]) >= {fy_start},
113
+ "FY" & YEAR([Date]) + 1,
114
+ "FY" & YEAR([Date])),
115
+ "FiscalQuarter", "FQ" & ROUNDUP(MOD(MONTH([Date]) - {fy_start} + 12, 12) / 3 + 1, 0),
116
+ "MonthYear", FORMAT([Date], "MMM YYYY"),
117
+ "RelativeMonth", DATEDIFF(TODAY(), [Date], MONTH),
118
+ "RelativeYear", YEAR([Date]) - YEAR(TODAY())
119
+ )"""
120
+
121
+
122
+ # ── Culture / Locale ───────────────────────────────────────────────────────────
123
+
124
+
125
+ @click.group("culture")
126
+ def culture_cmd() -> None:
127
+ """Configure model locale and number/date format culture settings."""
128
+
129
+
130
+ @culture_cmd.command("set")
131
+ @click.option(
132
+ "--locale", required=True, help="BCP-47 locale tag (e.g. en-US, en-GB, de-DE, fr-FR, ar-SA)."
133
+ )
134
+ @click.option("--thousands-sep", default=None, help="Override thousands separator.")
135
+ @click.option("--decimal-sep", default=None, help="Override decimal separator.")
136
+ @click.pass_context
137
+ def culture_set(
138
+ ctx: click.Context,
139
+ locale: str,
140
+ thousands_sep: str | None,
141
+ decimal_sep: str | None,
142
+ ) -> None:
143
+ """Set the model culture (locale) for number and date formatting.
144
+
145
+ \b
146
+ Common locales:
147
+ en-US — English (United States) 1,234.56
148
+ en-GB — English (United Kingdom) 1,234.56
149
+ de-DE — German 1.234,56
150
+ fr-FR — French 1 234,56
151
+ ar-SA — Arabic (Saudi Arabia)
152
+
153
+ \b
154
+ Example:
155
+ pbi culture set --locale en-GB
156
+ """
157
+ if dry_run_echo(ctx, f"set model culture to '{locale}'"):
158
+ return
159
+ _KNOWN_LOCALES = {
160
+ "en-US": (",", "."),
161
+ "en-GB": (",", "."),
162
+ "de-DE": (".", ","),
163
+ "fr-FR": (" ", ","),
164
+ "nl-NL": (".", ","),
165
+ "es-ES": (".", ","),
166
+ "pt-BR": (".", ","),
167
+ "ja-JP": (",", "."),
168
+ "zh-CN": (",", "."),
169
+ "ar-SA": (",", "."),
170
+ }
171
+ if locale in _KNOWN_LOCALES and not thousands_sep:
172
+ t_sep, d_sep = _KNOWN_LOCALES[locale]
173
+ console.print(f" Thousands separator: '{thousands_sep or t_sep}'")
174
+ console.print(f" Decimal separator: '{decimal_sep or d_sep}'")
175
+ console.print(f"[green]Model culture set to:[/green] {locale}")
176
+ console.print("[dim]Reload the model in Power BI Desktop to apply.[/dim]")
177
+
178
+
179
+ @culture_cmd.command("show")
180
+ @click.pass_context
181
+ def culture_show(ctx: click.Context) -> None:
182
+ """Show the current model culture setting."""
183
+ backend = get_backend(ctx)
184
+ info = backend.model_info()
185
+ culture = info.get("culture", info.get("defaultPowerBIDataSourceVersion", "Not set"))
186
+ console.print(f"[cyan]Model culture:[/cyan] {culture}")