pbi-enterprise-cli 0.1.0.dev0__tar.gz → 0.1.0.dev1__tar.gz

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 (68) hide show
  1. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/PKG-INFO +1 -1
  2. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/README.md +18 -1
  3. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/pyproject.toml +4 -1
  4. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/__init__.py +1 -1
  5. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/govern.py +96 -0
  6. pbi_enterprise_cli-0.1.0.dev1/src/pbi_cli/governance/bpa.py +330 -0
  7. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/server/api.py +1 -1
  8. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/PKG-INFO +1 -1
  9. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/SOURCES.txt +1 -0
  10. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/LICENSE +0 -0
  11. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/README.pypi.md +0 -0
  12. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/setup.cfg +0 -0
  13. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/_audit.py +0 -0
  14. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/_snapshot.py +0 -0
  15. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/__init__.py +0 -0
  16. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/mock_backend.py +0 -0
  17. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/pbir_backend.py +0 -0
  18. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/protocol.py +0 -0
  19. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/tom_backend.py +0 -0
  20. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/xmla_backend.py +0 -0
  21. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/cli.py +0 -0
  22. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/__init__.py +0 -0
  23. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_doctor.py +0 -0
  24. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_shared.py +0 -0
  25. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/calendar_cmd.py +0 -0
  26. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/connections.py +0 -0
  27. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/custom_visual.py +0 -0
  28. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/database.py +0 -0
  29. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/dax.py +0 -0
  30. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/deploy.py +0 -0
  31. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/docs.py +0 -0
  32. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/filter_cmd.py +0 -0
  33. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/layout.py +0 -0
  34. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/measure.py +0 -0
  35. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/model.py +0 -0
  36. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/partition.py +0 -0
  37. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/repl.py +0 -0
  38. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/report.py +0 -0
  39. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/security.py +0 -0
  40. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/server_cmd.py +0 -0
  41. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/skills_cmd.py +0 -0
  42. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/source.py +0 -0
  43. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/theme.py +0 -0
  44. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/trace.py +0 -0
  45. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/visual.py +0 -0
  46. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/watch.py +0 -0
  47. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/__init__.py +0 -0
  48. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/confluence.py +0 -0
  49. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/markdown.py +0 -0
  50. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/__init__.py +0 -0
  51. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/engine.py +0 -0
  52. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/__init__.py +0 -0
  53. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_brackets.py +0 -0
  54. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_description.py +0 -0
  55. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_format.py +0 -0
  56. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_naming.py +0 -0
  57. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/table_pascal_case.py +0 -0
  58. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/__init__.py +0 -0
  59. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/layout_engine.py +0 -0
  60. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/measure_generator.py +0 -0
  61. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/theme_generator.py +0 -0
  62. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/visual_builder.py +0 -0
  63. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/visual_recommender.py +0 -0
  64. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/server/__init__.py +0 -0
  65. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/dependency_links.txt +0 -0
  66. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/entry_points.txt +0 -0
  67. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/requires.txt +0 -0
  68. {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pbi-enterprise-cli
3
- Version: 0.1.0.dev0
3
+ Version: 0.1.0.dev1
4
4
  Summary: Power BI enterprise CLI — AI-driven model management, governance, PBIR authoring, XMLA, and DAX testing
5
5
  Author-email: Mudassir <mir.mudassir1@gmail.com>
6
6
  License: MIT AND LicenseRef-Microsoft-AS-Client-Libraries
@@ -6,7 +6,7 @@
6
6
  ![License](https://img.shields.io/badge/license-MIT-green)
7
7
  ![Tests](https://img.shields.io/badge/tests-547%20passing-brightgreen)
8
8
  ![Coverage](https://img.shields.io/badge/coverage-65%25%2B-yellowgreen)
9
- ![Version](https://img.shields.io/badge/version-0.1.0--dev-orange)
9
+ ![Version](https://img.shields.io/badge/version-0.1.0.dev1-orange)
10
10
 
11
11
  ---
12
12
 
@@ -178,6 +178,23 @@ pbi govern check # exit code 1 on errors — use as a CI gate
178
178
  pbi govern fix --auto
179
179
  ```
180
180
 
181
+ ### BPA (Best Practice Analyzer) Compatibility
182
+
183
+ Run the community-maintained BPA rule set — the same rules used by Tabular Editor — without installing any .NET tooling:
184
+
185
+ ```bash
186
+ # Run the official Microsoft community BPA rules (fetched live)
187
+ pbi govern bpa check
188
+
189
+ # Run rules from a local BPARules.json (e.g. your org's custom set)
190
+ pbi govern bpa check --file ./BPARules.json
191
+
192
+ # Filter by severity or category
193
+ pbi govern bpa check --severity error --category Performance
194
+ ```
195
+
196
+ `pbi govern bpa` is the first Python-native BPA runner — cross-platform, CI-ready, no Tabular Editor required.
197
+
181
198
  ---
182
199
 
183
200
  ## XMLA Backend
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pbi-enterprise-cli"
7
- version = "0.1.0.dev0"
7
+ version = "0.1.0.dev1"
8
8
  description = "Power BI enterprise CLI — AI-driven model management, governance, PBIR authoring, XMLA, and DAX testing"
9
9
  readme = "README.pypi.md"
10
10
  requires-python = ">=3.10"
@@ -57,6 +57,9 @@ pbi = "pbi_cli.cli:cli"
57
57
  [tool.setuptools.packages.find]
58
58
  where = ["src"]
59
59
 
60
+ [tool.setuptools.package-data]
61
+ pbi_cli = ["dlls/*.dll"]
62
+
60
63
  [tool.ruff]
61
64
  line-length = 100
62
65
  target-version = "py310"
@@ -1,3 +1,3 @@
1
1
  """pbi-cli: Power BI one-stop-shop platform for AI-driven development."""
2
2
 
3
- __version__ = "0.1.0.dev0"
3
+ __version__ = "0.1.0.dev1"
@@ -10,6 +10,7 @@ from rich.console import Console
10
10
 
11
11
  from pbi_cli._audit import write_audit_entry
12
12
  from pbi_cli.commands._shared import dry_run_echo, get_backend, output_json_or_table
13
+ from pbi_cli.governance.bpa import COMMUNITY_BPA_URL
13
14
 
14
15
  console = Console()
15
16
 
@@ -101,6 +102,101 @@ def govern_fix(ctx: click.Context, auto: bool) -> None:
101
102
  console.print(f"[green]Fixed {fixed} violations.[/green]")
102
103
 
103
104
 
105
+ @govern.group("bpa")
106
+ @click.pass_context
107
+ def govern_bpa(ctx: click.Context) -> None:
108
+ """Run BPA (Best Practice Analyzer) rules — the same rules as Tabular Editor."""
109
+
110
+
111
+ @govern_bpa.command("check")
112
+ @click.option("--file", "rules_file", default=None, help="Path to a local BPARules.json file.")
113
+ @click.option(
114
+ "--url",
115
+ "rules_url",
116
+ default=None,
117
+ help="URL of a BPARules.json to fetch (default: Microsoft community rules).",
118
+ )
119
+ @click.option(
120
+ "--severity",
121
+ default=None,
122
+ type=click.Choice(["info", "warning", "error"]),
123
+ help="Only show violations at this severity level.",
124
+ )
125
+ @click.option("--category", default=None, help="Filter violations by category name.")
126
+ @click.pass_context
127
+ def bpa_check(
128
+ ctx: click.Context,
129
+ rules_file: str | None,
130
+ rules_url: str | None,
131
+ severity: str | None,
132
+ category: str | None,
133
+ ) -> None:
134
+ """Run BPA rules against the current model.
135
+
136
+ \b
137
+ Sources (in priority order):
138
+ 1. --file PATH — local BPARules.json
139
+ 2. --url URL — custom remote URL
140
+ 3. (default) — Microsoft community BPA rules fetched live
141
+ """
142
+ from pbi_cli.governance.bpa import BpaEvaluator, load_rules_from_file, load_rules_from_url
143
+
144
+ backend = get_backend(ctx)
145
+ is_json = ctx.obj and ctx.obj.get("output_json")
146
+
147
+ # Load rules
148
+ try:
149
+ if rules_file:
150
+ rules = load_rules_from_file(rules_file)
151
+ source_label = rules_file
152
+ else:
153
+ url = rules_url or COMMUNITY_BPA_URL
154
+ if not is_json:
155
+ console.print(f"[cyan]Fetching BPA rules from:[/cyan] {url}")
156
+ rules = load_rules_from_url(url)
157
+ source_label = url
158
+ except Exception as exc:
159
+ console.print(f"[red]Failed to load BPA rules:[/red] {exc}")
160
+ raise SystemExit(1)
161
+
162
+ if not is_json:
163
+ console.print(f"[cyan]Loaded {len(rules)} rules from:[/cyan] {source_label}")
164
+
165
+ evaluator = BpaEvaluator()
166
+ violations, skipped = evaluator.evaluate(
167
+ rules, backend, severity_filter=severity, category_filter=category
168
+ )
169
+
170
+ if not is_json:
171
+ errors = [v for v in violations if v["severity"] == "error"]
172
+ warnings_list = [v for v in violations if v["severity"] == "warning"]
173
+ infos = [v for v in violations if v["severity"] == "info"]
174
+ console.print(
175
+ f"[red]{len(errors)} errors[/red], "
176
+ f"[yellow]{len(warnings_list)} warnings[/yellow], "
177
+ f"[blue]{len(infos)} info[/blue] "
178
+ f"([dim]{skipped} rules skipped — unsupported expressions[/dim])"
179
+ )
180
+
181
+ if violations:
182
+ output_json_or_table(violations, ctx, title="BPA Violations")
183
+ else:
184
+ if not is_json:
185
+ console.print("[green]No BPA violations found.[/green]")
186
+ else:
187
+ click.echo("[]")
188
+
189
+ if not is_json:
190
+ console.print(
191
+ f"\n[dim]{len(violations)} violations found, {skipped} rules skipped "
192
+ f"(unsupported expressions)[/dim]"
193
+ )
194
+
195
+ # Exit 1 if any errors found
196
+ if any(v["severity"] == "error" for v in violations):
197
+ raise SystemExit(1)
198
+
199
+
104
200
  @govern.command("rules")
105
201
  @click.pass_context
106
202
  def govern_rules(ctx: click.Context) -> None:
@@ -0,0 +1,330 @@
1
+ """BPA (Best Practice Analyzer) — loader and Python expression evaluator.
2
+
3
+ Supports the BPARules.json schema used by Tabular Editor and the Microsoft
4
+ community rule set. Runs the same rules without any .NET tooling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Constants
16
+ # ---------------------------------------------------------------------------
17
+
18
+ COMMUNITY_BPA_URL = (
19
+ "https://raw.githubusercontent.com/microsoft/Analysis-Services"
20
+ "/master/BestPracticeRules/BPARules.json"
21
+ )
22
+
23
+ _SEVERITY_MAP = {1: "warning", 2: "error", 3: "info"}
24
+
25
+ # Scopes we can evaluate — anything else is skipped
26
+ _SUPPORTED_SCOPES = {"Column", "Table", "Measure", "Relationship"}
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Data model
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ @dataclass
35
+ class BpaRule:
36
+ id: str
37
+ name: str
38
+ category: str
39
+ description: str
40
+ severity: int # 1=warning, 2=error, 3=info
41
+ scope: str
42
+ expression: str
43
+ fix_expression: str | None = None
44
+ compatibility_level: int = 1200
45
+
46
+ @property
47
+ def severity_label(self) -> str:
48
+ return _SEVERITY_MAP.get(self.severity, "warning")
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Loaders
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _parse_rules(data: list[dict[str, Any]]) -> list[BpaRule]:
57
+ rules: list[BpaRule] = []
58
+ for item in data:
59
+ rules.append(
60
+ BpaRule(
61
+ id=item.get("ID", ""),
62
+ name=item.get("Name", ""),
63
+ category=item.get("Category", ""),
64
+ description=item.get("Description", ""),
65
+ severity=item.get("Severity", 1),
66
+ scope=item.get("Scope", ""),
67
+ expression=item.get("Expression", ""),
68
+ fix_expression=item.get("FixExpression"),
69
+ compatibility_level=item.get("CompatibilityLevel", 1200),
70
+ )
71
+ )
72
+ return rules
73
+
74
+
75
+ def load_rules_from_file(path: str) -> list[BpaRule]:
76
+ """Load BPA rules from a local BPARules.json file."""
77
+ with open(path, encoding="utf-8") as fh:
78
+ data = json.load(fh)
79
+ if isinstance(data, dict) and "Rules" in data:
80
+ data = data["Rules"]
81
+ return _parse_rules(data)
82
+
83
+
84
+ def load_rules_from_url(url: str) -> list[BpaRule]:
85
+ """Fetch BPA rules from a URL. Uses httpx if available, falls back to urllib."""
86
+ try:
87
+ import httpx # type: ignore
88
+
89
+ response = httpx.get(url, timeout=30, follow_redirects=True)
90
+ response.raise_for_status()
91
+ data = response.json()
92
+ except ImportError:
93
+ import urllib.request
94
+
95
+ with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
96
+ raw = resp.read().decode("utf-8")
97
+ data = json.loads(raw)
98
+
99
+ if isinstance(data, dict) and "Rules" in data:
100
+ data = data["Rules"]
101
+ return _parse_rules(data)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Expression evaluator
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ def _build_context(scope: str, obj: dict[str, Any]) -> dict[str, Any]:
110
+ """Build the local variable dict for eval() based on scope and object."""
111
+ if scope == "Column":
112
+ return {
113
+ "Name": obj.get("name", ""),
114
+ "DataType": obj.get("dataType", ""),
115
+ "IsHidden": obj.get("isHidden", False),
116
+ "Description": obj.get("description", ""),
117
+ "Table": obj.get("table", ""),
118
+ # lower-case aliases for convenience
119
+ "name": obj.get("name", ""),
120
+ "data_type": obj.get("dataType", ""),
121
+ "hidden": obj.get("isHidden", False),
122
+ "description": obj.get("description", ""),
123
+ }
124
+ if scope == "Table":
125
+ return {
126
+ "Name": obj.get("name", ""),
127
+ "IsHidden": obj.get("isHidden", False),
128
+ "Description": obj.get("description", ""),
129
+ "name": obj.get("name", ""),
130
+ "hidden": obj.get("isHidden", False),
131
+ "description": obj.get("description", ""),
132
+ }
133
+ if scope == "Measure":
134
+ return {
135
+ "Name": obj.get("name", ""),
136
+ "Expression": obj.get("expression", ""),
137
+ "Description": obj.get("description", ""),
138
+ "FormatString": obj.get("formatString", ""),
139
+ "IsHidden": obj.get("isHidden", False),
140
+ "Table": obj.get("table", ""),
141
+ "name": obj.get("name", ""),
142
+ "expression": obj.get("expression", ""),
143
+ "description": obj.get("description", ""),
144
+ "format_string": obj.get("formatString", ""),
145
+ "hidden": obj.get("isHidden", False),
146
+ }
147
+ if scope == "Relationship":
148
+ return {
149
+ "From": obj.get("from", ""),
150
+ "To": obj.get("to", ""),
151
+ "Cardinality": obj.get("cardinality", ""),
152
+ "name": obj.get("from", ""),
153
+ "cardinality": obj.get("cardinality", ""),
154
+ }
155
+ return {}
156
+
157
+
158
+ def _translate_expression(expr: str) -> str:
159
+ """Translate a C# BPA expression string into a Python expression string.
160
+
161
+ Handles the most common patterns from the community rule set.
162
+ Raises NotImplementedError for patterns we cannot safely translate.
163
+ """
164
+ # Reject expressions that contain method calls we don't support yet
165
+ # (LINQ-style .Count(), .Any(), etc.)
166
+ _unsupported_methods = re.compile(
167
+ r"\.(Count|Sum|Average|Any|All|Select|Where|OrderBy|First|Last|Min|Max)\s*\(",
168
+ re.IGNORECASE,
169
+ )
170
+ if _unsupported_methods.search(expr):
171
+ raise NotImplementedError(f"Unsupported LINQ expression: {expr!r}")
172
+
173
+ result = expr
174
+
175
+ # Logical operators
176
+ result = re.sub(r"\s*&&\s*", " and ", result)
177
+ result = re.sub(r"\s*\|\|\s*", " or ", result)
178
+
179
+ # not keyword (case-insensitive)
180
+ result = re.sub(r"\bnot\b\s*", "not ", result, flags=re.IGNORECASE)
181
+
182
+ # [PropName].StartsWith("x") → PropName.startswith("x")
183
+ result = re.sub(
184
+ r'\[(\w+)\]\.StartsWith\("([^"]*)"\)',
185
+ lambda m: f'{m.group(1)}.startswith("{m.group(2)}")',
186
+ result,
187
+ flags=re.IGNORECASE,
188
+ )
189
+
190
+ # [PropName].EndsWith("x") → PropName.endswith("x")
191
+ result = re.sub(
192
+ r'\[(\w+)\]\.EndsWith\("([^"]*)"\)',
193
+ lambda m: f'{m.group(1)}.endswith("{m.group(2)}")',
194
+ result,
195
+ flags=re.IGNORECASE,
196
+ )
197
+
198
+ # [PropName].Contains("x") → "x" in PropName
199
+ result = re.sub(
200
+ r'\[(\w+)\]\.Contains\("([^"]*)"\)',
201
+ lambda m: f'"{m.group(2)}" in {m.group(1)}',
202
+ result,
203
+ flags=re.IGNORECASE,
204
+ )
205
+
206
+ # PropName.Contains("x") → "x" in PropName (no brackets variant)
207
+ result = re.sub(
208
+ r'(\w+)\.Contains\("([^"]*)"\)',
209
+ lambda m: f'"{m.group(2)}" in {m.group(1)}',
210
+ result,
211
+ flags=re.IGNORECASE,
212
+ )
213
+
214
+ # PropName = "value" → PropName == "value"
215
+ # Must not already be == and must not be preceded by < or >
216
+ result = re.sub(r'(?<![=<>!])=(?![=])', "==", result)
217
+
218
+ # PropName <> "value" → PropName != "value"
219
+ result = result.replace("<>", "!=")
220
+
221
+ # [PropName] == "value" → PropName == "value" (comparison context — strip brackets)
222
+ result = re.sub(
223
+ r'\[(\w+)\](\s*(?:==|!=|<=|>=|<(?!>)|>))',
224
+ lambda m: f"{m.group(1)}{m.group(2)}",
225
+ result,
226
+ )
227
+
228
+ # [BoolProp] → bool(BoolProp) — standalone bracketed reference (not method, not comparison)
229
+ result = re.sub(
230
+ r'\[(\w+)\](?![\.\[])',
231
+ lambda m: f"bool({m.group(1)})",
232
+ result,
233
+ )
234
+
235
+ return result
236
+
237
+
238
+ def _evaluate_expression(expr: str, ctx: dict[str, Any]) -> bool:
239
+ """Evaluate a translated Python expression against ctx. Returns True if violated."""
240
+ try:
241
+ py_expr = _translate_expression(expr)
242
+ safe_builtins = {"bool": bool, "str": str, "int": int, "float": float, "len": len}
243
+ return bool(eval(py_expr, {"__builtins__": safe_builtins}, ctx)) # noqa: S307
244
+ except NotImplementedError:
245
+ raise
246
+ except Exception as exc:
247
+ raise NotImplementedError(f"Cannot evaluate expression {expr!r}: {exc}") from exc
248
+
249
+
250
+ def _object_path(scope: str, obj: dict[str, Any]) -> str:
251
+ """Build a human-readable object path string."""
252
+ if scope == "Column":
253
+ return f"{obj.get('table', '?')}[{obj.get('name', '?')}]"
254
+ if scope == "Measure":
255
+ return f"{obj.get('table', '?')}[{obj.get('name', '?')}]"
256
+ if scope == "Table":
257
+ return obj.get("name", "?")
258
+ if scope == "Relationship":
259
+ return f"{obj.get('from', '?')} → {obj.get('to', '?')}"
260
+ return str(obj)
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Evaluator
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ class BpaEvaluator:
269
+ """Evaluate a list of BpaRules against a backend and return violations."""
270
+
271
+ def evaluate(
272
+ self,
273
+ rules: list[BpaRule],
274
+ backend: Any,
275
+ severity_filter: str | None = None,
276
+ category_filter: str | None = None,
277
+ ) -> tuple[list[dict[str, Any]], int]:
278
+ """Run all rules against the backend.
279
+
280
+ Returns:
281
+ (violations, skipped_count)
282
+ """
283
+ # Pre-fetch backend objects once
284
+ objects_by_scope: dict[str, list[dict[str, Any]]] = {
285
+ "Column": backend.column_list(),
286
+ "Table": backend.table_list(),
287
+ "Measure": backend.measure_list(),
288
+ "Relationship": backend.relationship_list(),
289
+ }
290
+
291
+ violations: list[dict[str, Any]] = []
292
+ skipped = 0
293
+
294
+ for rule in rules:
295
+ # Apply filters early
296
+ if severity_filter and rule.severity_label != severity_filter:
297
+ continue
298
+ if category_filter and rule.category.lower() != category_filter.lower():
299
+ continue
300
+
301
+ if rule.scope not in _SUPPORTED_SCOPES:
302
+ skipped += 1
303
+ continue
304
+
305
+ objects = objects_by_scope.get(rule.scope, [])
306
+ for obj in objects:
307
+ ctx = _build_context(rule.scope, obj)
308
+ try:
309
+ violated = _evaluate_expression(rule.expression, ctx)
310
+ except NotImplementedError:
311
+ skipped += 1
312
+ break # skip entire rule (not per-object)
313
+ else:
314
+ if violated:
315
+ violations.append(self._make_violation(rule, obj))
316
+
317
+ return violations, skipped
318
+
319
+ @staticmethod
320
+ def _make_violation(rule: BpaRule, obj: dict[str, Any]) -> dict[str, Any]:
321
+ return {
322
+ "rule": f"bpa.{rule.id.lower()}",
323
+ "bpa_id": rule.id,
324
+ "object": _object_path(rule.scope, obj),
325
+ "message": rule.name,
326
+ "description": rule.description,
327
+ "severity": rule.severity_label,
328
+ "category": rule.category,
329
+ "autoFixable": False,
330
+ }
@@ -13,7 +13,7 @@ try:
13
13
  from fastapi.responses import FileResponse
14
14
  from fastapi.staticfiles import StaticFiles
15
15
 
16
- app = FastAPI(title="pbi-server", version="0.1.0.dev0", docs_url="/api/docs")
16
+ app = FastAPI(title="pbi-server", version="0.1.0.dev1", docs_url="/api/docs")
17
17
 
18
18
  # ── Singleton backend ──────────────────────────────────────────────────
19
19
  _backend: Any = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pbi-enterprise-cli
3
- Version: 0.1.0.dev0
3
+ Version: 0.1.0.dev1
4
4
  Summary: Power BI enterprise CLI — AI-driven model management, governance, PBIR authoring, XMLA, and DAX testing
5
5
  Author-email: Mudassir <mir.mudassir1@gmail.com>
6
6
  License: MIT AND LicenseRef-Microsoft-AS-Client-Libraries
@@ -42,6 +42,7 @@ src/pbi_cli/docs_gen/__init__.py
42
42
  src/pbi_cli/docs_gen/confluence.py
43
43
  src/pbi_cli/docs_gen/markdown.py
44
44
  src/pbi_cli/governance/__init__.py
45
+ src/pbi_cli/governance/bpa.py
45
46
  src/pbi_cli/governance/engine.py
46
47
  src/pbi_cli/governance/rules/__init__.py
47
48
  src/pbi_cli/governance/rules/measure_brackets.py