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,124 @@
1
+ """pbi govern — governance rules, lint, auto-fix (Epic D)."""
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._audit import write_audit_entry
12
+ from pbi_cli.commands._shared import dry_run_echo, get_backend, output_json_or_table
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group()
18
+ def govern() -> None:
19
+ """Enforce naming conventions, required metadata, and model quality rules."""
20
+
21
+
22
+ @govern.command("init")
23
+ @click.pass_context
24
+ def govern_init(ctx: click.Context) -> None:
25
+ """Create ~/.pbi-cli/governance.json with default rules."""
26
+ config_dir = Path.home() / ".pbi-cli"
27
+ config_dir.mkdir(exist_ok=True)
28
+ config_file = config_dir / "governance.json"
29
+ if config_file.exists():
30
+ console.print(f"[yellow]Already exists:[/yellow] {config_file}")
31
+ return
32
+ defaults = {
33
+ "naming": {
34
+ "tables": "PascalCase",
35
+ "measures": "Title Case in [Brackets]",
36
+ "hiddenPrefix": "_",
37
+ "factPrefix": "FACT_",
38
+ "dimPrefix": "DIM_",
39
+ },
40
+ "required": {
41
+ "measureDescription": True,
42
+ "tableDataCategory": False,
43
+ },
44
+ "complexity": {
45
+ "maxMeasureLength": 500,
46
+ "maxIteratorDepth": 3,
47
+ },
48
+ }
49
+ config_file.write_text(json.dumps(defaults, indent=2), encoding="utf-8")
50
+ console.print(f"[green]Created:[/green] {config_file}")
51
+
52
+
53
+ @govern.command("check")
54
+ @click.pass_context
55
+ def govern_check(ctx: click.Context) -> None:
56
+ """Run all governance rules; output violations with severity (error/warning/info)."""
57
+ from pbi_cli.governance.engine import GovernanceEngine
58
+
59
+ backend = get_backend(ctx)
60
+ engine = GovernanceEngine(backend)
61
+ violations = engine.run_all()
62
+ is_json = ctx.obj and ctx.obj.get("output_json")
63
+ if violations:
64
+ errors = [v for v in violations if v["severity"] == "error"]
65
+ warnings_list = [v for v in violations if v["severity"] == "warning"]
66
+ if not is_json:
67
+ console.print(
68
+ f"[red]{len(errors)} errors[/red], [yellow]{len(warnings_list)} warnings[/yellow]"
69
+ )
70
+ output_json_or_table(violations, ctx, title="Governance Violations")
71
+ if errors:
72
+ raise SystemExit(1)
73
+ else:
74
+ if not is_json:
75
+ console.print("[green]All governance checks pass.[/green]")
76
+ else:
77
+ import click
78
+
79
+ click.echo("[]")
80
+
81
+
82
+ @govern.command("fix")
83
+ @click.option("--auto", is_flag=True, help="Auto-fix safe violations.")
84
+ @click.pass_context
85
+ def govern_fix(ctx: click.Context, auto: bool) -> None:
86
+ """Auto-fix safe violations: PascalCase, FORMAT strings, folder sort."""
87
+ from pbi_cli.governance.engine import GovernanceEngine
88
+
89
+ backend = get_backend(ctx)
90
+ engine = GovernanceEngine(backend)
91
+ violations = engine.run_all()
92
+ fixable = [v for v in violations if v.get("autoFixable")]
93
+ console.print(f"[cyan]{len(fixable)} auto-fixable violations[/cyan]")
94
+ if not auto:
95
+ console.print("Use --auto to apply fixes.")
96
+ return
97
+ if dry_run_echo(ctx, f"apply {len(fixable)} auto-fixes"):
98
+ return
99
+ fixed = engine.auto_fix(fixable)
100
+ write_audit_entry("govern fix", extra={"violations_fixed": fixed})
101
+ console.print(f"[green]Fixed {fixed} violations.[/green]")
102
+
103
+
104
+ @govern.command("rules")
105
+ @click.pass_context
106
+ def govern_rules(ctx: click.Context) -> None:
107
+ """List all registered governance rules (built-in and plugins).
108
+
109
+ \b
110
+ Plugin rules are loaded from: ~/.pbi-cli/rules/*.py
111
+ Each plugin file must expose: RULE_ID (str) and check(backend) -> list[dict]
112
+ """
113
+ from pbi_cli.commands._shared import output_json_or_table
114
+ from pbi_cli.governance.engine import GovernanceEngine
115
+
116
+ rules = GovernanceEngine.list_rules()
117
+ output_json_or_table(rules, ctx, title="Governance Rules")
118
+ plugin_count = sum(1 for r in rules if r["source"] == "plugin")
119
+ if plugin_count:
120
+ console.print(f"\n[cyan]{plugin_count} plugin rule(s) loaded[/cyan] from ~/.pbi-cli/rules/")
121
+ else:
122
+ console.print(
123
+ "\n[yellow]No plugin rules loaded.[/yellow] Place *.py rule files in ~/.pbi-cli/rules/"
124
+ )
@@ -0,0 +1,104 @@
1
+ """pbi layout — auto-layout engine commands (Epic C)."""
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, output_json_or_table
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def layout() -> None:
15
+ """Auto-position visuals using the shelf-packing layout engine."""
16
+
17
+
18
+ @layout.command("auto")
19
+ @click.option("--pbip", required=True, help="Path to the .pbip project folder or file.")
20
+ @click.option("--page", required=True, help="Page name to layout.")
21
+ @click.option("--canvas-width", default=1280, show_default=True)
22
+ @click.option("--canvas-height", default=720, show_default=True)
23
+ @click.pass_context
24
+ def layout_auto(
25
+ ctx: click.Context, pbip: str, page: str, canvas_width: int, canvas_height: int
26
+ ) -> None:
27
+ """Load all visuals from a PBIR page, classify them, and repack onto the canvas."""
28
+ from pbi_cli.backends.pbir_backend import PbirBackend
29
+ from pbi_cli.intelligence.layout_engine import LayoutEngine
30
+
31
+ console.print(f"[cyan]Auto-layout:[/cyan] page '{page}' ({canvas_width}x{canvas_height})")
32
+ pbir = PbirBackend(pbip)
33
+ visuals = pbir.visual_list(page)
34
+
35
+ if not visuals:
36
+ console.print(f"[yellow]No visuals found on page '{page}'.[/yellow]")
37
+ return
38
+
39
+ console.print(f"[dim]Found {len(visuals)} visuals — classifying and packing...[/dim]")
40
+ engine = LayoutEngine(canvas_width=canvas_width, canvas_height=canvas_height)
41
+ positions = engine.pack(visuals)
42
+
43
+ if dry_run_echo(ctx, f"apply {len(positions)} visual positions to page '{page}'"):
44
+ output_json_or_table(positions, ctx, title="Visual Layout (dry run)")
45
+ return
46
+
47
+ # Write positions back to PBIR files
48
+ from pbi_cli._audit import write_audit_entry
49
+
50
+ for pos in positions:
51
+ vd = pbir._ga_visuals_dir(page) # type: ignore[attr-defined]
52
+ if vd is None:
53
+ continue
54
+ for vdir in vd.iterdir():
55
+ if not vdir.is_dir():
56
+ continue
57
+ vj = vdir / "visual.json"
58
+ if not vj.exists():
59
+ continue
60
+ import json
61
+
62
+ data = json.loads(vj.read_text(encoding="utf-8"))
63
+ if data.get("name") == pos["name"]:
64
+ data.setdefault("position", {}).update(
65
+ {
66
+ "x": pos["x"],
67
+ "y": pos["y"],
68
+ "width": pos["width"],
69
+ "height": pos["height"],
70
+ }
71
+ )
72
+ vj.write_text(json.dumps(data, indent=2), encoding="utf-8")
73
+
74
+ write_audit_entry("layout auto", extra={"page": page, "visuals_repositioned": len(positions)})
75
+ console.print(f"[green]Repositioned[/green] {len(positions)} visuals on page '{page}'")
76
+ output_json_or_table(positions, ctx, title="Visual Layout")
77
+
78
+
79
+ @layout.command("template")
80
+ @click.option(
81
+ "--name",
82
+ required=True,
83
+ type=click.Choice(
84
+ ["executive-dashboard", "operational-monitor", "financial-report", "drill-through-detail"]
85
+ ),
86
+ )
87
+ @click.option("--page", required=True, help="Page name.")
88
+ @click.pass_context
89
+ def layout_template(ctx: click.Context, name: str, page: str) -> None:
90
+ """Apply a named layout template to a page."""
91
+ templates = {
92
+ "executive-dashboard": [
93
+ "KPI Strip (top 15%)",
94
+ "Main Chart (center 60%)",
95
+ "Table (bottom 25%)",
96
+ "Slicer Rail (right 20%)",
97
+ ],
98
+ "operational-monitor": ["KPI Strip", "Real-time Chart", "Alert Table"],
99
+ "financial-report": ["Header", "YTD KPIs", "Trend Chart", "Variance Table"],
100
+ "drill-through-detail": ["Filter Panel", "Detail Table", "Supporting Chart"],
101
+ }
102
+ console.print(f"[cyan]Template:[/cyan] {name} -> page '{page}'")
103
+ for zone in templates[name]:
104
+ console.print(f" [dim]Zone:[/dim] {zone}")
@@ -0,0 +1,185 @@
1
+ """pbi measure — DAX measure CRUD commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from pbi_cli._audit import write_audit_entry
9
+ from pbi_cli.commands._shared import (
10
+ dry_run_echo,
11
+ get_backend,
12
+ output_json_or_table,
13
+ snapshot_before_write,
14
+ )
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.group()
20
+ def measure() -> None:
21
+ """Manage DAX measures: add, list, update, delete, generate, audit."""
22
+
23
+
24
+ @measure.command("list")
25
+ @click.option("--table", default=None, help="Filter to a specific table.")
26
+ @click.pass_context
27
+ def measure_list(ctx: click.Context, table: str | None) -> None:
28
+ """List all measures in the connected model."""
29
+ backend = get_backend(ctx)
30
+ if not backend.is_connected():
31
+ console.print("[red]Not connected. Use 'pbi connect' first.[/red]")
32
+ raise click.Abort()
33
+ data = backend.measure_list(table=table)
34
+ output_json_or_table(data, ctx, title="Measures")
35
+
36
+
37
+ @measure.command("add")
38
+ @click.option("--table", required=True, help="Table to add the measure to.")
39
+ @click.option("--name", required=True, help="Measure name (use [Brackets] convention).")
40
+ @click.option("--expression", required=True, help="DAX expression.")
41
+ @click.option("--format-string", default=None, help="Format string (e.g. #,0.00).")
42
+ @click.option("--description", default=None, help="Measure description.")
43
+ @click.pass_context
44
+ def measure_add(
45
+ ctx: click.Context,
46
+ table: str,
47
+ name: str,
48
+ expression: str,
49
+ format_string: str | None,
50
+ description: str | None,
51
+ ) -> None:
52
+ """Add a new DAX measure."""
53
+ detail = f"measure '{name}' to '{table}': {expression}"
54
+ if dry_run_echo(ctx, f"add measure '{name}' to table '{table}'", detail):
55
+ return
56
+ backend = get_backend(ctx)
57
+ snapshot_before_write(ctx)
58
+ kwargs = {}
59
+ if format_string:
60
+ kwargs["formatString"] = format_string
61
+ if description:
62
+ kwargs["description"] = description
63
+ result = backend.measure_add(table=table, name=name, expression=expression, **kwargs)
64
+ write_audit_entry("measure add", after=result)
65
+ if not (ctx.obj and ctx.obj.get("output_json")):
66
+ console.print(f"[green]Added[/green] measure '{name}' to '{table}'")
67
+ output_json_or_table(result, ctx)
68
+
69
+
70
+ @measure.command("update")
71
+ @click.option("--table", required=True, help="Table containing the measure.")
72
+ @click.option("--name", required=True, help="Measure name to update.")
73
+ @click.option("--expression", default=None, help="New DAX expression.")
74
+ @click.option("--format-string", default=None, help="New format string.")
75
+ @click.option("--description", default=None, help="New description.")
76
+ @click.pass_context
77
+ def measure_update(
78
+ ctx: click.Context,
79
+ table: str,
80
+ name: str,
81
+ expression: str | None,
82
+ format_string: str | None,
83
+ description: str | None,
84
+ ) -> None:
85
+ """Update an existing DAX measure (expression, format string, or description)."""
86
+ kwargs: dict = {}
87
+ if expression:
88
+ kwargs["expression"] = expression
89
+ if format_string:
90
+ kwargs["formatString"] = format_string
91
+ if description:
92
+ kwargs["description"] = description
93
+ if not kwargs:
94
+ console.print("[yellow]Nothing to update — provide at least one option.[/yellow]")
95
+ return
96
+ detail = f"update measure '{name}' in '{table}': {kwargs}"
97
+ if dry_run_echo(ctx, f"update measure '{name}' in table '{table}'", detail):
98
+ return
99
+ backend = get_backend(ctx)
100
+ snapshot_before_write(ctx)
101
+ before = next((m for m in backend.measure_list(table=table) if m["name"] == name), None)
102
+ result = backend.measure_update(table=table, name=name, **kwargs)
103
+ write_audit_entry("measure update", before=before, after=result)
104
+ if not (ctx.obj and ctx.obj.get("output_json")):
105
+ console.print(f"[green]Updated[/green] measure '{name}' in '{table}'")
106
+ output_json_or_table(result, ctx)
107
+
108
+
109
+ @measure.command("delete")
110
+ @click.option("--table", required=True, help="Table containing the measure.")
111
+ @click.option("--name", required=True, help="Measure name to delete.")
112
+ @click.pass_context
113
+ def measure_delete(ctx: click.Context, table: str, name: str) -> None:
114
+ """Delete a measure."""
115
+ if dry_run_echo(ctx, f"delete measure '{name}' from '{table}'"):
116
+ return
117
+ backend = get_backend(ctx)
118
+ snapshot_before_write(ctx)
119
+ before = next((m for m in backend.measure_list(table=table) if m["name"] == name), None)
120
+ backend.measure_delete(table=table, name=name)
121
+ write_audit_entry("measure delete", before=before)
122
+ console.print(f"[red]Deleted[/red] measure '{name}' from '{table}'")
123
+
124
+
125
+ @measure.command("generate")
126
+ @click.argument("description")
127
+ @click.option("--table", required=True, help="Target table.")
128
+ @click.option("--name", required=True, help="Measure name.")
129
+ @click.pass_context
130
+ def measure_generate(ctx: click.Context, description: str, table: str, name: str) -> None:
131
+ """Generate a DAX measure from a natural language description using Claude."""
132
+ from pbi_cli.intelligence.measure_generator import MeasureGenerator
133
+
134
+ console.print(f"[cyan]Generating DAX for:[/cyan] {description}")
135
+ backend = get_backend(ctx)
136
+ schema = backend.column_list() if backend.is_connected() else []
137
+ gen = MeasureGenerator()
138
+ result = gen.generate(description=description, schema=schema)
139
+ expression = result.get("expression", "")
140
+ console.print(f"[bold]Generated DAX:[/bold]\n{expression}")
141
+
142
+ if not result.get("valid"):
143
+ console.print(f"[yellow]Generation failed:[/yellow] {result.get('error')}")
144
+ console.print("Review the expression above and add manually.")
145
+ return
146
+
147
+ # Validate the generated DAX via the backend before writing
148
+ console.print("[cyan]Validating DAX...[/cyan]")
149
+ validation = backend.dax_validate(expression)
150
+ if not validation.get("valid", True):
151
+ console.print("[yellow]DAX validation failed — showing expression for review:[/yellow]")
152
+ console.print(f" {validation.get('error', 'Syntax error')}")
153
+ console.print("Use 'pbi measure add' to write it manually after correction.")
154
+ return
155
+
156
+ if dry_run_echo(ctx, f"add measure '{name}' to '{table}'", expression):
157
+ return
158
+ backend.measure_add(table=table, name=name, expression=expression)
159
+ write_audit_entry(
160
+ "measure generate", after={"table": table, "name": name, "expression": expression}
161
+ )
162
+ console.print(f"[green]Written[/green] measure '{name}' to '{table}'")
163
+
164
+
165
+ @measure.command("audit")
166
+ @click.pass_context
167
+ def measure_audit(ctx: click.Context) -> None:
168
+ """Audit all measures: unused, circular deps, missing FORMAT, hardcoded dates."""
169
+ backend = get_backend(ctx)
170
+ measures = backend.measure_list()
171
+ issues: list[dict] = []
172
+ for m in measures:
173
+ expr = m.get("expression", "")
174
+ if not m.get("formatString"):
175
+ issues.append(
176
+ {"measure": m["name"], "issue": "missing FORMAT string", "severity": "warning"}
177
+ )
178
+ if "NOW()" in expr.upper() or "TODAY()" in expr.upper():
179
+ issues.append(
180
+ {"measure": m["name"], "issue": "hardcoded date function", "severity": "warning"}
181
+ )
182
+ if issues:
183
+ output_json_or_table(issues, ctx, title="Measure Audit Issues")
184
+ else:
185
+ console.print("[green]No issues found.[/green]")