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