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.
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/PKG-INFO +1 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/README.md +18 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/pyproject.toml +4 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/__init__.py +1 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/govern.py +96 -0
- pbi_enterprise_cli-0.1.0.dev1/src/pbi_cli/governance/bpa.py +330 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/server/api.py +1 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/PKG-INFO +1 -1
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/SOURCES.txt +1 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/LICENSE +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/README.pypi.md +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/setup.cfg +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/_audit.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/_snapshot.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/mock_backend.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/pbir_backend.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/protocol.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/tom_backend.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/xmla_backend.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/cli.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_doctor.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_shared.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/calendar_cmd.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/connections.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/custom_visual.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/database.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/dax.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/deploy.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/docs.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/filter_cmd.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/layout.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/measure.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/model.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/partition.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/repl.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/report.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/security.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/server_cmd.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/skills_cmd.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/source.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/theme.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/trace.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/visual.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/watch.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/confluence.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/markdown.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/engine.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_brackets.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_description.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_format.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/rules/measure_naming.py +0 -0
- {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
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/__init__.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/layout_engine.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/measure_generator.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/theme_generator.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/visual_builder.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/visual_recommender.py +0 -0
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/server/__init__.py +0 -0
- {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
- {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
- {pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_enterprise_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|

|
|
7
7
|

|
|
8
8
|

|
|
9
|
-

|
|
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.
|
|
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"
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/govern.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/__init__.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/mock_backend.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/pbir_backend.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/protocol.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/tom_backend.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/backends/xmla_backend.py
RENAMED
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/__init__.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_doctor.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/_shared.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/calendar_cmd.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/connections.py
RENAMED
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/database.py
RENAMED
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/deploy.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/docs.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/filter_cmd.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/layout.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/measure.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/model.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/partition.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/repl.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/report.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/security.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/server_cmd.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/skills_cmd.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/source.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/theme.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/trace.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/visual.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/commands/watch.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/__init__.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/confluence.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/docs_gen/markdown.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/__init__.py
RENAMED
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/governance/engine.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/intelligence/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pbi_enterprise_cli-0.1.0.dev0 → pbi_enterprise_cli-0.1.0.dev1}/src/pbi_cli/server/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|