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/commands/dax.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""pbi dax — DAX query, validate, and test commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import yaml # type: ignore[import-untyped]
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from pbi_cli.commands._shared import get_backend, output_json_or_table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def dax() -> None:
|
|
18
|
+
"""Execute, validate, and unit-test DAX expressions."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dax.command("query")
|
|
22
|
+
@click.argument("expression")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def dax_query(ctx: click.Context, expression: str) -> None:
|
|
25
|
+
"""Execute a DAX query and return results."""
|
|
26
|
+
backend = get_backend(ctx)
|
|
27
|
+
results = backend.dax_query(expression)
|
|
28
|
+
output_json_or_table(results, ctx, title="DAX Results")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dax.command("validate")
|
|
32
|
+
@click.argument("expression")
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def dax_validate(ctx: click.Context, expression: str) -> None:
|
|
35
|
+
"""Validate a DAX expression syntax without executing."""
|
|
36
|
+
backend = get_backend(ctx)
|
|
37
|
+
result = backend.dax_validate(expression)
|
|
38
|
+
if result.get("valid"):
|
|
39
|
+
console.print("[green]Valid DAX expression.[/green]")
|
|
40
|
+
else:
|
|
41
|
+
console.print(f"[red]Invalid:[/red] {result.get('error', 'Unknown error')}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dax.command("test")
|
|
45
|
+
@click.option(
|
|
46
|
+
"--suite", required=True, type=click.Path(exists=True), help="Path to YAML test suite."
|
|
47
|
+
)
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def dax_test(ctx: click.Context, suite: str) -> None:
|
|
50
|
+
"""Run DAX unit tests from a YAML fixture file."""
|
|
51
|
+
import math
|
|
52
|
+
|
|
53
|
+
data = yaml.safe_load(Path(suite).read_text(encoding="utf-8"))
|
|
54
|
+
tests = data.get("tests", [])
|
|
55
|
+
console.print(f"[cyan]Running suite:[/cyan] {suite} ({len(tests)} tests)\n")
|
|
56
|
+
backend = get_backend(ctx)
|
|
57
|
+
passed = 0
|
|
58
|
+
failed = 0
|
|
59
|
+
|
|
60
|
+
for test in tests:
|
|
61
|
+
name = test["name"]
|
|
62
|
+
dax_expr = test.get("dax", "").strip()
|
|
63
|
+
asserts = test.get("assert", [])
|
|
64
|
+
try:
|
|
65
|
+
rows = backend.dax_query(dax_expr)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
console.print(f" [red]FAIL[/red] {name}")
|
|
68
|
+
console.print(f" Query error: {exc}")
|
|
69
|
+
failed += 1
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
test_failed = False
|
|
73
|
+
fail_reasons: list[str] = []
|
|
74
|
+
|
|
75
|
+
for assertion in asserts:
|
|
76
|
+
# row_count: exact number of rows
|
|
77
|
+
if "row_count" in assertion:
|
|
78
|
+
expected_count = assertion["row_count"]
|
|
79
|
+
if len(rows) != expected_count:
|
|
80
|
+
fail_reasons.append(f"row_count: expected {expected_count}, got {len(rows)}")
|
|
81
|
+
test_failed = True
|
|
82
|
+
|
|
83
|
+
# min_rows
|
|
84
|
+
if "min_rows" in assertion:
|
|
85
|
+
if len(rows) < assertion["min_rows"]:
|
|
86
|
+
fail_reasons.append(
|
|
87
|
+
f"min_rows: expected >= {assertion['min_rows']}, got {len(rows)}"
|
|
88
|
+
)
|
|
89
|
+
test_failed = True
|
|
90
|
+
|
|
91
|
+
# max_rows
|
|
92
|
+
if "max_rows" in assertion:
|
|
93
|
+
if len(rows) > assertion["max_rows"]:
|
|
94
|
+
fail_reasons.append(
|
|
95
|
+
f"max_rows: expected <= {assertion['max_rows']}, got {len(rows)}"
|
|
96
|
+
)
|
|
97
|
+
test_failed = True
|
|
98
|
+
|
|
99
|
+
col = assertion.get("column")
|
|
100
|
+
row_idx = assertion.get("row", 0)
|
|
101
|
+
|
|
102
|
+
if col and rows:
|
|
103
|
+
# Get column values (rows is list of dicts)
|
|
104
|
+
col_values = [r.get(col) for r in rows if col in r]
|
|
105
|
+
|
|
106
|
+
# not_blank
|
|
107
|
+
if assertion.get("not_blank"):
|
|
108
|
+
blanks = [
|
|
109
|
+
v
|
|
110
|
+
for v in col_values
|
|
111
|
+
if v is None or (isinstance(v, float) and math.isnan(v))
|
|
112
|
+
]
|
|
113
|
+
if blanks:
|
|
114
|
+
fail_reasons.append(
|
|
115
|
+
f"not_blank: column '{col}' has {len(blanks)} blank values"
|
|
116
|
+
)
|
|
117
|
+
test_failed = True
|
|
118
|
+
|
|
119
|
+
# all_rows_between
|
|
120
|
+
if "all_rows_between" in assertion:
|
|
121
|
+
lo, hi = assertion["all_rows_between"]
|
|
122
|
+
out_of_range = [
|
|
123
|
+
v
|
|
124
|
+
for v in col_values
|
|
125
|
+
if v is not None and not math.isnan(float(v)) and not (lo <= float(v) <= hi)
|
|
126
|
+
]
|
|
127
|
+
if out_of_range:
|
|
128
|
+
fail_reasons.append(
|
|
129
|
+
f"all_rows_between [{lo},{hi}]: {len(out_of_range)} values out of range"
|
|
130
|
+
)
|
|
131
|
+
test_failed = True
|
|
132
|
+
|
|
133
|
+
# expected: value at specific row
|
|
134
|
+
if "expected" in assertion and row_idx < len(rows):
|
|
135
|
+
actual = rows[row_idx].get(col)
|
|
136
|
+
expected = assertion["expected"]
|
|
137
|
+
tolerance = assertion.get("tolerance", 0)
|
|
138
|
+
if actual is None:
|
|
139
|
+
fail_reasons.append(f"expected {expected} in '{col}'[{row_idx}], got None")
|
|
140
|
+
test_failed = True
|
|
141
|
+
elif tolerance:
|
|
142
|
+
tol_abs = abs(expected) * tolerance if tolerance < 1 else tolerance
|
|
143
|
+
if abs(float(actual) - expected) > tol_abs:
|
|
144
|
+
fail_reasons.append(
|
|
145
|
+
f"expected {expected} ± {tol_abs} in '{col}'[{row_idx}], got {actual}" # noqa: E501
|
|
146
|
+
)
|
|
147
|
+
test_failed = True
|
|
148
|
+
elif actual != expected:
|
|
149
|
+
fail_reasons.append(
|
|
150
|
+
f"expected {expected!r} in '{col}'[{row_idx}], got {actual!r}"
|
|
151
|
+
)
|
|
152
|
+
test_failed = True
|
|
153
|
+
|
|
154
|
+
# expected_string
|
|
155
|
+
if "expected_string" in assertion and row_idx < len(rows):
|
|
156
|
+
actual = rows[row_idx].get(col)
|
|
157
|
+
if str(actual) != assertion["expected_string"]:
|
|
158
|
+
fail_reasons.append(
|
|
159
|
+
f"expected '{assertion['expected_string']}' in '{col}'[{row_idx}], got '{actual}'" # noqa: E501
|
|
160
|
+
)
|
|
161
|
+
test_failed = True
|
|
162
|
+
|
|
163
|
+
if test_failed:
|
|
164
|
+
console.print(f" [red]FAIL[/red] {name}")
|
|
165
|
+
for reason in fail_reasons:
|
|
166
|
+
console.print(f" {reason}")
|
|
167
|
+
failed += 1
|
|
168
|
+
else:
|
|
169
|
+
console.print(f" [green]PASS[/green] {name}")
|
|
170
|
+
passed += 1
|
|
171
|
+
|
|
172
|
+
console.print(f"\n[bold]{passed} passed, {failed} failed[/bold] out of {len(tests)} tests")
|
|
173
|
+
if failed:
|
|
174
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""pbi deploy — deployment pipeline commands (Epic E)."""
|
|
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 deploy() -> None:
|
|
15
|
+
"""Deploy and promote models via XMLA: push, diff, promote, snapshot."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@deploy.command("push")
|
|
19
|
+
@click.option("--workspace", required=True, help="Target workspace name.")
|
|
20
|
+
@click.option("--xmla", default=None, help="XMLA endpoint URL (overrides config).")
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def deploy_push(ctx: click.Context, workspace: str, xmla: str | None) -> None:
|
|
23
|
+
"""Export TMDL and deploy to a workspace via XMLA with transaction safety.
|
|
24
|
+
|
|
25
|
+
\b
|
|
26
|
+
Prerequisites:
|
|
27
|
+
pip install pbi-enterprise-cli[server]
|
|
28
|
+
Set XMLA endpoint in ~/.pbi-cli/config.toml:
|
|
29
|
+
[xmla]
|
|
30
|
+
endpoint = "powerbi://api.powerbi.com/v1.0/myorg/MyWorkspace"
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Example:
|
|
34
|
+
pbi deploy push --workspace "Production"
|
|
35
|
+
"""
|
|
36
|
+
console.print(f"[cyan]Deploying to:[/cyan] {workspace}")
|
|
37
|
+
if dry_run_echo(ctx, f"push model to workspace '{workspace}' via XMLA"):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
endpoint = xmla or _get_xmla_endpoint()
|
|
41
|
+
if not endpoint:
|
|
42
|
+
console.print(
|
|
43
|
+
"[yellow]XMLA endpoint not configured.[/yellow]\n"
|
|
44
|
+
"Set it in [bold]~/.pbi-cli/config.toml[/bold]:\n"
|
|
45
|
+
" [xmla]\n"
|
|
46
|
+
' endpoint = "powerbi://api.powerbi.com/v1.0/myorg/MyWorkspace"'
|
|
47
|
+
)
|
|
48
|
+
console.print(
|
|
49
|
+
"\n[yellow]XMLA backend required (v6.0). Install pbi-enterprise-cli[server].[/yellow]"
|
|
50
|
+
)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
console.print(f" Endpoint: {endpoint}")
|
|
54
|
+
console.print("[yellow]XMLA push not yet implemented — endpoint resolved.[/yellow]")
|
|
55
|
+
console.print("Use 'pbi deploy snapshot' to save a local snapshot first.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@deploy.command("diff")
|
|
59
|
+
@click.option("--workspace", default=None, help="Workspace to compare against (XMLA).")
|
|
60
|
+
@click.option("--snapshot", default=None, help="Local TMDL snapshot directory to compare against.")
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def deploy_diff(ctx: click.Context, workspace: str | None, snapshot: str | None) -> None:
|
|
63
|
+
"""Compare local model against deployed model or a local snapshot.
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
Examples:
|
|
67
|
+
pbi deploy diff --snapshot ./snapshots/2025-01-01
|
|
68
|
+
pbi deploy diff --workspace "Staging"
|
|
69
|
+
"""
|
|
70
|
+
if snapshot:
|
|
71
|
+
# Use local model_diff (already implemented in TOM backend)
|
|
72
|
+
backend = get_backend(ctx)
|
|
73
|
+
result = backend.model_diff(snapshot_path=snapshot)
|
|
74
|
+
if not result.get("has_changes"):
|
|
75
|
+
console.print("[green]No changes detected[/green] — model matches snapshot.")
|
|
76
|
+
return
|
|
77
|
+
output_json_or_table(result, ctx, title="Model Diff vs Snapshot")
|
|
78
|
+
console.print(
|
|
79
|
+
f"[yellow]Changes:[/yellow] "
|
|
80
|
+
f"{len(result['added'])} added, "
|
|
81
|
+
f"{len(result['removed'])} removed, "
|
|
82
|
+
f"{len(result['changed'])} modified"
|
|
83
|
+
)
|
|
84
|
+
elif workspace:
|
|
85
|
+
console.print(f"[cyan]Diffing against workspace:[/cyan] {workspace}")
|
|
86
|
+
endpoint = _get_xmla_endpoint()
|
|
87
|
+
if not endpoint:
|
|
88
|
+
console.print(
|
|
89
|
+
"[yellow]XMLA endpoint not configured — cannot diff against workspace.[/yellow]"
|
|
90
|
+
)
|
|
91
|
+
console.print("Use --snapshot to diff against a local TMDL snapshot instead.")
|
|
92
|
+
return
|
|
93
|
+
console.print("[yellow]XMLA diff not yet implemented (XMLA backend required).[/yellow]")
|
|
94
|
+
console.print(
|
|
95
|
+
"Tip: Use 'pbi deploy snapshot' to capture a baseline, then 'pbi deploy diff --snapshot'." # noqa: E501
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
raise click.UsageError("Provide --snapshot or --workspace.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@deploy.command("snapshot")
|
|
102
|
+
@click.option(
|
|
103
|
+
"--output",
|
|
104
|
+
default=None,
|
|
105
|
+
help="Output directory for the TMDL snapshot (default: ./snapshots/<timestamp>).",
|
|
106
|
+
)
|
|
107
|
+
@click.pass_context
|
|
108
|
+
def deploy_snapshot(ctx: click.Context, output: str | None) -> None:
|
|
109
|
+
"""Export the current model as a TMDL snapshot to a local directory.
|
|
110
|
+
|
|
111
|
+
Snapshots can be compared with 'pbi deploy diff --snapshot' or 'pbi model diff --snapshot'.
|
|
112
|
+
|
|
113
|
+
\b
|
|
114
|
+
Example:
|
|
115
|
+
pbi deploy snapshot --output ./snapshots/before-refactor
|
|
116
|
+
"""
|
|
117
|
+
import datetime
|
|
118
|
+
from pathlib import Path
|
|
119
|
+
|
|
120
|
+
if output is None:
|
|
121
|
+
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
122
|
+
output = str(Path(".") / "snapshots" / ts)
|
|
123
|
+
|
|
124
|
+
out_path = Path(output)
|
|
125
|
+
if dry_run_echo(ctx, f"export TMDL snapshot to '{out_path}'"):
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
backend = get_backend(ctx)
|
|
129
|
+
try:
|
|
130
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
backend.tmdl_export(str(out_path))
|
|
132
|
+
file_count = len(list(out_path.rglob("*.tmdl")))
|
|
133
|
+
console.print(f"[green]Snapshot saved:[/green] {out_path}")
|
|
134
|
+
console.print(f" Files: {file_count} .tmdl file(s)")
|
|
135
|
+
console.print("\nCompare later with:")
|
|
136
|
+
console.print(f" pbi deploy diff --snapshot {out_path}")
|
|
137
|
+
console.print(f" pbi model diff --snapshot {out_path}")
|
|
138
|
+
except Exception as e:
|
|
139
|
+
console.print(f"[red]Snapshot failed:[/red] {e}")
|
|
140
|
+
console.print("Ensure Power BI Desktop is running with the model open.")
|
|
141
|
+
raise SystemExit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@deploy.command("promote")
|
|
145
|
+
@click.option("--from", "from_workspace", required=True, help="Source workspace.")
|
|
146
|
+
@click.option("--to", "to_workspace", required=True, help="Target workspace.")
|
|
147
|
+
@click.pass_context
|
|
148
|
+
def deploy_promote(ctx: click.Context, from_workspace: str, to_workspace: str) -> None:
|
|
149
|
+
"""Parameterised promotion: swap connections, update partitions, deploy.
|
|
150
|
+
|
|
151
|
+
\b
|
|
152
|
+
Example:
|
|
153
|
+
pbi deploy promote --from Staging --to Production
|
|
154
|
+
"""
|
|
155
|
+
console.print(f"[cyan]Promoting:[/cyan] {from_workspace} -> {to_workspace}")
|
|
156
|
+
if dry_run_echo(ctx, f"promote model from '{from_workspace}' to '{to_workspace}'"):
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
endpoint = _get_xmla_endpoint()
|
|
160
|
+
if not endpoint:
|
|
161
|
+
console.print(
|
|
162
|
+
"[yellow]XMLA endpoint not configured.[/yellow]\n"
|
|
163
|
+
"Set it in [bold]~/.pbi-cli/config.toml[/bold]:\n"
|
|
164
|
+
" [xmla]\n"
|
|
165
|
+
' endpoint = "powerbi://api.powerbi.com/v1.0/myorg/MyWorkspace"'
|
|
166
|
+
)
|
|
167
|
+
console.print("[yellow]Promotion (XMLA backend required — v6.0).[/yellow]")
|
|
168
|
+
console.print("Steps that will run when XMLA is connected:")
|
|
169
|
+
console.print(" 1. Export TMDL from source workspace")
|
|
170
|
+
console.print(" 2. Swap connection strings (dev -> prod data sources)")
|
|
171
|
+
console.print(" 3. Update partition queries")
|
|
172
|
+
console.print(" 4. Deploy to target workspace via XMLA transaction")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_xmla_endpoint() -> str | None:
|
|
176
|
+
"""Read XMLA endpoint from ~/.pbi-cli/config.toml if available."""
|
|
177
|
+
try:
|
|
178
|
+
from pathlib import Path
|
|
179
|
+
|
|
180
|
+
config_path = Path.home() / ".pbi-cli" / "config.toml"
|
|
181
|
+
if not config_path.exists():
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
import tomllib # Python 3.11+
|
|
185
|
+
except ImportError:
|
|
186
|
+
try:
|
|
187
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
188
|
+
except ImportError:
|
|
189
|
+
return None
|
|
190
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
191
|
+
return data.get("xmla", {}).get("endpoint")
|
|
192
|
+
except Exception:
|
|
193
|
+
return None
|
pbi_cli/commands/docs.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""pbi docs — data dictionary generation and audit log (Epic D)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from pbi_cli.commands._shared import get_backend, output_json_or_table
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def docs() -> None:
|
|
15
|
+
"""Generate data dictionaries, documentation, and view the audit log."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@docs.command("generate")
|
|
19
|
+
@click.option("--format", "fmt", type=click.Choice(["markdown", "confluence"]), default="markdown")
|
|
20
|
+
@click.option("--output", default=None, help="Output file path.")
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def docs_generate(ctx: click.Context, fmt: str, output: str | None) -> None:
|
|
23
|
+
"""Generate a full data dictionary for the model."""
|
|
24
|
+
backend = get_backend(ctx)
|
|
25
|
+
if fmt == "markdown":
|
|
26
|
+
from pbi_cli.docs_gen.markdown import MarkdownDocsGenerator
|
|
27
|
+
|
|
28
|
+
gen = MarkdownDocsGenerator(backend)
|
|
29
|
+
else:
|
|
30
|
+
from pbi_cli.docs_gen.confluence import ConfluenceDocsGenerator
|
|
31
|
+
|
|
32
|
+
gen = ConfluenceDocsGenerator(backend) # type: ignore[assignment]
|
|
33
|
+
content = gen.generate()
|
|
34
|
+
if output:
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
Path(output).write_text(content, encoding="utf-8")
|
|
38
|
+
console.print(f"[green]Written:[/green] {output}")
|
|
39
|
+
else:
|
|
40
|
+
console.print(content)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@docs.command("audit-log")
|
|
44
|
+
@click.option("--limit", default=50, show_default=True, help="Number of recent entries to show.")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def docs_audit_log(ctx: click.Context, limit: int) -> None:
|
|
47
|
+
"""Display the audit log of all write operations (~/.pbi-cli/audit.jsonl)."""
|
|
48
|
+
from pbi_cli._audit import read_audit_log
|
|
49
|
+
|
|
50
|
+
entries = read_audit_log(limit=limit)
|
|
51
|
+
if not entries:
|
|
52
|
+
console.print("[yellow]Audit log is empty.[/yellow]")
|
|
53
|
+
console.print(
|
|
54
|
+
"Write operations (measure add/update/delete, scaffold, deploy) are logged automatically." # noqa: E501
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
output_json_or_table(entries, ctx, title="Audit Log")
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""pbi filter — add relative-date, TopN, and basic filters to report pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from pbi_cli.commands._shared import dry_run_echo, output_json_or_table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group("filter")
|
|
17
|
+
def filter_cmd() -> None:
|
|
18
|
+
"""Add and manage filters on report pages (relative-date, TopN, basic value)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _page_json_path(pbip: str, page: str) -> Path:
|
|
22
|
+
from pbi_cli.backends.pbir_backend import PbirBackend
|
|
23
|
+
|
|
24
|
+
b = PbirBackend(pbip)
|
|
25
|
+
pages = b.page_list()
|
|
26
|
+
match = next((p for p in pages if p["displayName"] == page), None)
|
|
27
|
+
if not match:
|
|
28
|
+
raise click.ClickException(f"Page '{page}' not found.")
|
|
29
|
+
report_dir = b._report_dir # type: ignore[attr-defined]
|
|
30
|
+
assert report_dir is not None
|
|
31
|
+
pages_dir = report_dir / "definition" / "pages"
|
|
32
|
+
for page_dir in pages_dir.iterdir():
|
|
33
|
+
pj = page_dir / "page.json"
|
|
34
|
+
if pj.exists():
|
|
35
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
36
|
+
if data.get("displayName") == page or data.get("name") == match["name"]:
|
|
37
|
+
return pj
|
|
38
|
+
raise click.ClickException(f"page.json not found for page '{page}'.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _append_filter(page_json: Path, filter_obj: dict) -> None:
|
|
42
|
+
data = json.loads(page_json.read_text(encoding="utf-8"))
|
|
43
|
+
filters = data.setdefault("filters", [])
|
|
44
|
+
filters.append(filter_obj)
|
|
45
|
+
page_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@filter_cmd.command("list")
|
|
49
|
+
@click.option("--pbip", required=True)
|
|
50
|
+
@click.option("--page", required=True)
|
|
51
|
+
@click.pass_context
|
|
52
|
+
def filter_list(ctx: click.Context, pbip: str, page: str) -> None:
|
|
53
|
+
"""List all filters applied to a report page."""
|
|
54
|
+
pj = _page_json_path(pbip, page)
|
|
55
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
56
|
+
filters = data.get("filters", [])
|
|
57
|
+
if not filters:
|
|
58
|
+
console.print(f"[yellow]No filters on page '{page}'.[/yellow]")
|
|
59
|
+
return
|
|
60
|
+
output_json_or_table(filters, ctx, title=f"Filters on '{page}'")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@filter_cmd.command("add-relative-date")
|
|
64
|
+
@click.option("--pbip", required=True)
|
|
65
|
+
@click.option("--page", required=True, help="Page to apply filter to.")
|
|
66
|
+
@click.option("--table", required=True, help="Table containing the date column.")
|
|
67
|
+
@click.option("--column", required=True, help="Date column name.")
|
|
68
|
+
@click.option("--last", type=int, required=True, help="Number of time units (e.g. 30).")
|
|
69
|
+
@click.option(
|
|
70
|
+
"--unit",
|
|
71
|
+
type=click.Choice(["Days", "Weeks", "Months", "Quarters", "Years"]),
|
|
72
|
+
default="Days",
|
|
73
|
+
show_default=True,
|
|
74
|
+
)
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def filter_add_relative_date(
|
|
77
|
+
ctx: click.Context,
|
|
78
|
+
pbip: str,
|
|
79
|
+
page: str,
|
|
80
|
+
table: str,
|
|
81
|
+
column: str,
|
|
82
|
+
last: int,
|
|
83
|
+
unit: str,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Add a relative-date filter to a page (e.g. 'last 30 days').
|
|
86
|
+
|
|
87
|
+
\b
|
|
88
|
+
Example:
|
|
89
|
+
pbi filter add-relative-date --pbip MyReport --page "Sales" \\
|
|
90
|
+
--table Calendar --column Date --last 30 --unit Days
|
|
91
|
+
"""
|
|
92
|
+
if dry_run_echo(ctx, f"add relative-date filter: last {last} {unit} on {table}[{column}]"):
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
filter_obj = {
|
|
96
|
+
"type": "RelativeDate",
|
|
97
|
+
"field": {
|
|
98
|
+
"Column": {
|
|
99
|
+
"Expression": {"SourceRef": {"Entity": table}},
|
|
100
|
+
"Property": column,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"operator": "InTheLast",
|
|
104
|
+
"timeUnitsCount": last,
|
|
105
|
+
"timeUnitType": unit,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pj = _page_json_path(pbip, page)
|
|
109
|
+
_append_filter(pj, filter_obj)
|
|
110
|
+
console.print(
|
|
111
|
+
f"[green]Filter added:[/green] last {last} {unit} on {table}[{column}] -> page '{page}'"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@filter_cmd.command("add-topn")
|
|
116
|
+
@click.option("--pbip", required=True)
|
|
117
|
+
@click.option("--page", required=True, help="Page to apply filter to.")
|
|
118
|
+
@click.option("--table", required=True, help="Table containing the category field.")
|
|
119
|
+
@click.option("--column", required=True, help="Column to filter (e.g. Product).")
|
|
120
|
+
@click.option("--n", type=int, required=True, help="Number of top items to keep.")
|
|
121
|
+
@click.option("--by-table", required=True, help="Table containing the measure to order by.")
|
|
122
|
+
@click.option("--by-measure", required=True, help="Measure name to order by (e.g. Total Sales).")
|
|
123
|
+
@click.option("--direction", type=click.Choice(["Top", "Bottom"]), default="Top", show_default=True)
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def filter_add_topn(
|
|
126
|
+
ctx: click.Context,
|
|
127
|
+
pbip: str,
|
|
128
|
+
page: str,
|
|
129
|
+
table: str,
|
|
130
|
+
column: str,
|
|
131
|
+
n: int,
|
|
132
|
+
by_table: str,
|
|
133
|
+
by_measure: str,
|
|
134
|
+
direction: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Add a TopN filter to keep only the top (or bottom) N items by a measure.
|
|
137
|
+
|
|
138
|
+
\b
|
|
139
|
+
Example:
|
|
140
|
+
pbi filter add-topn --pbip MyReport --page "Sales" \\
|
|
141
|
+
--table Products --column Product --n 10 \\
|
|
142
|
+
--by-table Sales --by-measure "Total Sales"
|
|
143
|
+
"""
|
|
144
|
+
if dry_run_echo(ctx, f"add TopN filter: {direction} {n} {table}[{column}] by {by_measure}"):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
operator = "TopCount" if direction == "Top" else "BottomCount"
|
|
148
|
+
filter_obj = {
|
|
149
|
+
"type": "TopN",
|
|
150
|
+
"field": {
|
|
151
|
+
"Column": {
|
|
152
|
+
"Expression": {"SourceRef": {"Entity": table}},
|
|
153
|
+
"Property": column,
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"operator": operator,
|
|
157
|
+
"itemCount": {"Literal": {"Value": str(n)}},
|
|
158
|
+
"orderByField": {
|
|
159
|
+
"Measure": {
|
|
160
|
+
"Expression": {"SourceRef": {"Entity": by_table}},
|
|
161
|
+
"Property": by_measure,
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
pj = _page_json_path(pbip, page)
|
|
167
|
+
_append_filter(pj, filter_obj)
|
|
168
|
+
console.print(
|
|
169
|
+
f"[green]Filter added:[/green] {direction} {n} {table}[{column}] by '{by_measure}' -> page '{page}'" # noqa: E501
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@filter_cmd.command("add-value")
|
|
174
|
+
@click.option("--pbip", required=True)
|
|
175
|
+
@click.option("--page", required=True)
|
|
176
|
+
@click.option("--table", required=True)
|
|
177
|
+
@click.option("--column", required=True)
|
|
178
|
+
@click.option("--values", required=True, help="Comma-separated list of values to include.")
|
|
179
|
+
@click.pass_context
|
|
180
|
+
def filter_add_value(
|
|
181
|
+
ctx: click.Context,
|
|
182
|
+
pbip: str,
|
|
183
|
+
page: str,
|
|
184
|
+
table: str,
|
|
185
|
+
column: str,
|
|
186
|
+
values: str,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Add a basic value-in filter to a page.
|
|
189
|
+
|
|
190
|
+
\b
|
|
191
|
+
Example:
|
|
192
|
+
pbi filter add-value --pbip MyReport --page "Sales" \\
|
|
193
|
+
--table financials --column Segment --values "Enterprise,Government"
|
|
194
|
+
"""
|
|
195
|
+
if dry_run_echo(ctx, f"add value filter: {table}[{column}] in [{values}]"):
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
value_list = [v.strip() for v in values.split(",") if v.strip()]
|
|
199
|
+
filter_conditions = [
|
|
200
|
+
{"operator": "Is", "value": {"Literal": {"Value": f"'{v}'"}}} for v in value_list
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
filter_obj = {
|
|
204
|
+
"type": "BasicFilter",
|
|
205
|
+
"field": {
|
|
206
|
+
"Column": {
|
|
207
|
+
"Expression": {"SourceRef": {"Entity": table}},
|
|
208
|
+
"Property": column,
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"operator": "In",
|
|
212
|
+
"values": filter_conditions,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
pj = _page_json_path(pbip, page)
|
|
216
|
+
_append_filter(pj, filter_obj)
|
|
217
|
+
console.print(
|
|
218
|
+
f"[green]Filter added:[/green] {table}[{column}] in {value_list} -> page '{page}'"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@filter_cmd.command("clear")
|
|
223
|
+
@click.option("--pbip", required=True)
|
|
224
|
+
@click.option("--page", required=True)
|
|
225
|
+
@click.pass_context
|
|
226
|
+
def filter_clear(ctx: click.Context, pbip: str, page: str) -> None:
|
|
227
|
+
"""Remove all filters from a report page."""
|
|
228
|
+
if dry_run_echo(ctx, f"clear all filters from page '{page}'"):
|
|
229
|
+
return
|
|
230
|
+
pj = _page_json_path(pbip, page)
|
|
231
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
232
|
+
count = len(data.get("filters", []))
|
|
233
|
+
data["filters"] = []
|
|
234
|
+
pj.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
235
|
+
console.print(f"[green]Cleared[/green] {count} filter(s) from page '{page}'.")
|