pbi-enterprise-cli 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pbi_cli/__init__.py +3 -0
- pbi_cli/_audit.py +57 -0
- pbi_cli/_snapshot.py +95 -0
- pbi_cli/backends/__init__.py +1 -0
- pbi_cli/backends/mock_backend.py +323 -0
- pbi_cli/backends/pbir_backend.py +813 -0
- pbi_cli/backends/protocol.py +52 -0
- pbi_cli/backends/tom_backend.py +650 -0
- pbi_cli/backends/xmla_backend.py +627 -0
- pbi_cli/cli.py +332 -0
- pbi_cli/commands/__init__.py +1 -0
- pbi_cli/commands/_doctor.py +84 -0
- pbi_cli/commands/_shared.py +88 -0
- pbi_cli/commands/calendar_cmd.py +186 -0
- pbi_cli/commands/connections.py +153 -0
- pbi_cli/commands/custom_visual.py +325 -0
- pbi_cli/commands/database.py +76 -0
- pbi_cli/commands/dax.py +174 -0
- pbi_cli/commands/deploy.py +193 -0
- pbi_cli/commands/docs.py +57 -0
- pbi_cli/commands/filter_cmd.py +235 -0
- pbi_cli/commands/govern.py +124 -0
- pbi_cli/commands/layout.py +104 -0
- pbi_cli/commands/measure.py +185 -0
- pbi_cli/commands/model.py +499 -0
- pbi_cli/commands/partition.py +89 -0
- pbi_cli/commands/repl.py +209 -0
- pbi_cli/commands/report.py +561 -0
- pbi_cli/commands/security.py +90 -0
- pbi_cli/commands/server_cmd.py +30 -0
- pbi_cli/commands/skills_cmd.py +168 -0
- pbi_cli/commands/source.py +581 -0
- pbi_cli/commands/theme.py +60 -0
- pbi_cli/commands/trace.py +142 -0
- pbi_cli/commands/visual.py +507 -0
- pbi_cli/commands/watch.py +145 -0
- pbi_cli/docs_gen/__init__.py +1 -0
- pbi_cli/docs_gen/confluence.py +24 -0
- pbi_cli/docs_gen/markdown.py +36 -0
- pbi_cli/governance/__init__.py +1 -0
- pbi_cli/governance/engine.py +70 -0
- pbi_cli/governance/rules/__init__.py +85 -0
- pbi_cli/governance/rules/measure_brackets.py +27 -0
- pbi_cli/governance/rules/measure_description.py +41 -0
- pbi_cli/governance/rules/measure_format.py +38 -0
- pbi_cli/governance/rules/measure_naming.py +93 -0
- pbi_cli/governance/rules/table_pascal_case.py +44 -0
- pbi_cli/intelligence/__init__.py +1 -0
- pbi_cli/intelligence/layout_engine.py +192 -0
- pbi_cli/intelligence/measure_generator.py +40 -0
- pbi_cli/intelligence/theme_generator.py +193 -0
- pbi_cli/intelligence/visual_builder.py +429 -0
- pbi_cli/intelligence/visual_recommender.py +42 -0
- pbi_cli/server/__init__.py +1 -0
- pbi_cli/server/api.py +185 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -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]")
|