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,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
@@ -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}'.")