diff --git a/pyproject.toml b/pyproject.toml
index c4b6903..319e849 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -94,3 +94,11 @@ strict = true
[[tool.mypy.overrides]]
module = ["pythonnet", "clr", "clr_loader"]
ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["win32gui", "win32con", "win32api", "win32process", "win32com.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["websockets", "websockets.*"]
+ignore_missing_imports = true
diff --git a/src/pbi_cli/commands/_helpers.py b/src/pbi_cli/commands/_helpers.py
index e02d2f1..9012b51 100644
--- a/src/pbi_cli/commands/_helpers.py
+++ b/src/pbi_cli/commands/_helpers.py
@@ -14,10 +14,19 @@ if TYPE_CHECKING:
from pbi_cli.main import PbiContext
# Statuses that indicate a write operation (triggers Desktop sync)
-_WRITE_STATUSES = frozenset({
- "created", "deleted", "updated", "applied", "added",
- "cleared", "bound", "removed", "set",
-})
+_WRITE_STATUSES = frozenset(
+ {
+ "created",
+ "deleted",
+ "updated",
+ "applied",
+ "added",
+ "cleared",
+ "bound",
+ "removed",
+ "set",
+ }
+)
def run_command(
diff --git a/src/pbi_cli/commands/report.py b/src/pbi_cli/commands/report.py
index 172ba5d..4ea94ca 100644
--- a/src/pbi_cli/commands/report.py
+++ b/src/pbi_cli/commands/report.py
@@ -46,9 +46,7 @@ def info(ctx: PbiContext, click_ctx: click.Context) -> None:
help="Relative path to semantic model folder (e.g. ../MyModel.Dataset).",
)
@pass_context
-def create(
- ctx: PbiContext, target_path: str, name: str, dataset_path: str | None
-) -> None:
+def create(ctx: PbiContext, target_path: str, name: str, dataset_path: str | None) -> None:
"""Scaffold a new PBIR report project."""
from pbi_cli.core.report_backend import report_create
@@ -168,7 +166,10 @@ def get_theme(ctx: PbiContext, click_ctx: click.Context) -> None:
@report.command(name="diff-theme")
@click.option(
- "--file", "-f", required=True, type=click.Path(exists=True),
+ "--file",
+ "-f",
+ required=True,
+ type=click.Path(exists=True),
help="Proposed theme JSON file.",
)
@click.pass_context
@@ -196,9 +197,7 @@ def diff_theme(ctx: PbiContext, click_ctx: click.Context, file: str) -> None:
@click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.")
@click.pass_context
@pass_context
-def set_background(
- ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str
-) -> None:
+def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, color: str) -> None:
"""Set the background color of a page."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.report_backend import page_set_background
@@ -223,9 +222,7 @@ def set_background(
)
@click.pass_context
@pass_context
-def set_visibility(
- ctx: PbiContext, click_ctx: click.Context, page_name: str, hidden: bool
-) -> None:
+def set_visibility(ctx: PbiContext, click_ctx: click.Context, page_name: str, hidden: bool) -> None:
"""Hide or show a page in the report navigation."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.report_backend import page_set_visibility
diff --git a/src/pbi_cli/commands/visual.py b/src/pbi_cli/commands/visual.py
index 3ef3521..a1936d7 100644
--- a/src/pbi_cli/commands/visual.py
+++ b/src/pbi_cli/commands/visual.py
@@ -25,7 +25,8 @@ def visual(ctx: click.Context, path: str | None) -> None:
def _get_report_path(click_ctx: click.Context) -> str | None:
"""Extract report_path from parent context."""
if click_ctx.parent:
- return click_ctx.parent.obj.get("report_path")
+ result: str | None = click_ctx.parent.obj.get("report_path")
+ return result
return None
diff --git a/src/pbi_cli/core/bookmark_backend.py b/src/pbi_cli/core/bookmark_backend.py
index fcaf4e9..79cb6fa 100644
--- a/src/pbi_cli/core/bookmark_backend.py
+++ b/src/pbi_cli/core/bookmark_backend.py
@@ -34,7 +34,8 @@ SCHEMA_BOOKMARK = (
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -93,11 +94,13 @@ def bookmark_list(definition_path: Path) -> list[dict[str, Any]]:
continue
bm = _read_json(bm_file)
exploration = bm.get("explorationState", {})
- results.append({
- "name": name,
- "display_name": bm.get("displayName", ""),
- "active_section": exploration.get("activeSection"),
- })
+ results.append(
+ {
+ "name": name,
+ "display_name": bm.get("displayName", ""),
+ "active_section": exploration.get("activeSection"),
+ }
+ )
return results
diff --git a/src/pbi_cli/core/errors.py b/src/pbi_cli/core/errors.py
index dfaf855..61ceab2 100644
--- a/src/pbi_cli/core/errors.py
+++ b/src/pbi_cli/core/errors.py
@@ -48,8 +48,7 @@ class VisualTypeError(PbiCliError):
def __init__(self, visual_type: str) -> None:
self.visual_type = visual_type
super().__init__(
- f"Unknown visual type '{visual_type}'. "
- "Run 'pbi visual types' to see supported types."
+ f"Unknown visual type '{visual_type}'. Run 'pbi visual types' to see supported types."
)
diff --git a/src/pbi_cli/core/filter_backend.py b/src/pbi_cli/core/filter_backend.py
index be10078..e0d3346 100644
--- a/src/pbi_cli/core/filter_backend.py
+++ b/src/pbi_cli/core/filter_backend.py
@@ -25,7 +25,8 @@ from pbi_cli.core.pbir_path import get_page_dir, get_visual_dir
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -288,9 +289,7 @@ def filter_add_topn(
"Select": [
{
"Column": {
- "Expression": {
- "SourceRef": {"Source": cat_alias}
- },
+ "Expression": {"SourceRef": {"Source": cat_alias}},
"Property": column,
},
"Name": "field",
@@ -333,9 +332,7 @@ def filter_add_topn(
"Expressions": [
{
"Column": {
- "Expression": {
- "SourceRef": {"Source": cat_alias}
- },
+ "Expression": {"SourceRef": {"Source": cat_alias}},
"Property": column,
}
}
@@ -399,9 +396,7 @@ def filter_add_relative_date(
time_unit_lower = time_unit.strip().lower()
if time_unit_lower not in _RELATIVE_DATE_TIME_UNITS:
valid = ", ".join(_RELATIVE_DATE_TIME_UNITS)
- raise PbiCliError(
- f"time_unit must be one of {valid}, got '{time_unit}'."
- )
+ raise PbiCliError(f"time_unit must be one of {valid}, got '{time_unit}'.")
time_unit_code = _RELATIVE_DATE_TIME_UNITS[time_unit_lower]
days_code = _RELATIVE_DATE_TIME_UNITS["days"]
diff --git a/src/pbi_cli/core/format_backend.py b/src/pbi_cli/core/format_backend.py
index 9071853..f075c1a 100644
--- a/src/pbi_cli/core/format_backend.py
+++ b/src/pbi_cli/core/format_backend.py
@@ -21,7 +21,8 @@ from pbi_cli.core.pbir_path import get_visual_dir
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -42,8 +43,7 @@ def _load_visual(definition_path: Path, page_name: str, visual_name: str) -> dic
visual_path = get_visual_dir(definition_path, page_name, visual_name) / "visual.json"
if not visual_path.exists():
raise PbiCliError(
- f"Visual '{visual_name}' not found on page '{page_name}'. "
- f"Expected: {visual_path}"
+ f"Visual '{visual_name}' not found on page '{page_name}'. Expected: {visual_path}"
)
return _read_json(visual_path)
@@ -176,20 +176,10 @@ def format_background_gradient(
},
"FillRule": {
"linearGradient2": {
- "min": {
- "color": {
- "Literal": {"Value": f"'{min_color}'"}
- }
- },
- "max": {
- "color": {
- "Literal": {"Value": f"'{max_color}'"}
- }
- },
+ "min": {"color": {"Literal": {"Value": f"'{min_color}'"}}},
+ "max": {"color": {"Literal": {"Value": f"'{max_color}'"}}},
"nullColoringStrategy": {
- "strategy": {
- "Literal": {"Value": "'asZero'"}
- }
+ "strategy": {"Literal": {"Value": "'asZero'"}}
},
}
},
@@ -259,9 +249,7 @@ def format_background_conditional(
comparison_lower = comparison.strip().lower()
if comparison_lower not in _COMPARISON_KINDS:
valid = ", ".join(_COMPARISON_KINDS)
- raise PbiCliError(
- f"comparison must be one of {valid}, got '{comparison}'."
- )
+ raise PbiCliError(f"comparison must be one of {valid}, got '{comparison}'.")
comparison_kind = _COMPARISON_KINDS[comparison_lower]
if field_query_ref is None:
@@ -302,16 +290,10 @@ def format_background_conditional(
"Function": 0,
}
},
- "Right": {
- "Literal": {
- "Value": threshold_literal
- }
- },
+ "Right": {"Literal": {"Value": threshold_literal}},
}
},
- "Value": {
- "Literal": {"Value": f"'{color_hex}'"}
- },
+ "Value": {"Literal": {"Value": f"'{color_hex}'"}},
}
]
}
@@ -373,9 +355,7 @@ def format_background_measure(
"color": {
"expr": {
"Measure": {
- "Expression": {
- "SourceRef": {"Entity": measure_table}
- },
+ "Expression": {"SourceRef": {"Entity": measure_table}},
"Property": measure_property,
}
}
diff --git a/src/pbi_cli/core/pbir_models.py b/src/pbi_cli/core/pbir_models.py
index 9bafd93..507d89c 100644
--- a/src/pbi_cli/core/pbir_models.py
+++ b/src/pbi_cli/core/pbir_models.py
@@ -39,46 +39,48 @@ SCHEMA_BOOKMARK = (
# -- Visual type identifiers ------------------------------------------------
-SUPPORTED_VISUAL_TYPES: frozenset[str] = frozenset({
- # Original 9
- "barChart",
- "lineChart",
- "card",
- "pivotTable",
- "tableEx",
- "slicer",
- "kpi",
- "gauge",
- "donutChart",
- # v3.1.0 additions
- "columnChart",
- "areaChart",
- "ribbonChart",
- "waterfallChart",
- "scatterChart",
- "funnelChart",
- "multiRowCard",
- "treemap",
- "cardNew",
- "stackedBarChart",
- "lineStackedColumnComboChart",
- # v3.4.0 additions
- "cardVisual",
- "actionButton",
- # v3.5.0 additions (confirmed from HR Analysis Desktop export)
- "clusteredColumnChart",
- "clusteredBarChart",
- "textSlicer",
- "listSlicer",
- # v3.6.0 additions (confirmed from HR Analysis Desktop export)
- "image",
- "shape",
- "textbox",
- "pageNavigator",
- "advancedSlicerVisual",
- # v3.8.0 additions
- "azureMap",
-})
+SUPPORTED_VISUAL_TYPES: frozenset[str] = frozenset(
+ {
+ # Original 9
+ "barChart",
+ "lineChart",
+ "card",
+ "pivotTable",
+ "tableEx",
+ "slicer",
+ "kpi",
+ "gauge",
+ "donutChart",
+ # v3.1.0 additions
+ "columnChart",
+ "areaChart",
+ "ribbonChart",
+ "waterfallChart",
+ "scatterChart",
+ "funnelChart",
+ "multiRowCard",
+ "treemap",
+ "cardNew",
+ "stackedBarChart",
+ "lineStackedColumnComboChart",
+ # v3.4.0 additions
+ "cardVisual",
+ "actionButton",
+ # v3.5.0 additions (confirmed from HR Analysis Desktop export)
+ "clusteredColumnChart",
+ "clusteredBarChart",
+ "textSlicer",
+ "listSlicer",
+ # v3.6.0 additions (confirmed from HR Analysis Desktop export)
+ "image",
+ "shape",
+ "textbox",
+ "pageNavigator",
+ "advancedSlicerVisual",
+ # v3.8.0 additions
+ "azureMap",
+ }
+)
# Mapping from user-friendly names to PBIR visualType identifiers
VISUAL_TYPE_ALIASES: dict[str, str] = {
diff --git a/src/pbi_cli/core/pbir_path.py b/src/pbi_cli/core/pbir_path.py
index 464afb1..a44fecf 100644
--- a/src/pbi_cli/core/pbir_path.py
+++ b/src/pbi_cli/core/pbir_path.py
@@ -114,9 +114,7 @@ def get_visuals_dir(definition_path: Path, page_name: str) -> Path:
return visuals
-def get_visual_dir(
- definition_path: Path, page_name: str, visual_name: str
-) -> Path:
+def get_visual_dir(definition_path: Path, page_name: str, visual_name: str) -> Path:
"""Return the directory for a specific visual."""
return definition_path / "pages" / page_name / "visuals" / visual_name
diff --git a/src/pbi_cli/core/pbir_validators.py b/src/pbi_cli/core/pbir_validators.py
index 2c8f421..bf5276a 100644
--- a/src/pbi_cli/core/pbir_validators.py
+++ b/src/pbi_cli/core/pbir_validators.py
@@ -107,11 +107,13 @@ def validate_bindings_against_model(
ref = _extract_field_ref(sel, sources)
if ref and ref not in valid_fields:
rel = f"{page_dir.name}/visuals/{vdir.name}"
- findings.append(ValidationResult(
- "warning",
- rel,
- f"Field '{ref}' not found in semantic model",
- ))
+ findings.append(
+ ValidationResult(
+ "warning",
+ rel,
+ f"Field '{ref}' not found in semantic model",
+ )
+ )
except (json.JSONDecodeError, KeyError, TypeError):
continue
@@ -151,20 +153,20 @@ def _validate_report_json(definition_path: Path) -> list[ValidationResult]:
findings.append(ValidationResult("warning", "report.json", "Missing $schema reference"))
if "themeCollection" not in data:
- findings.append(ValidationResult(
- "error", "report.json", "Missing required 'themeCollection'"
- ))
+ findings.append(
+ ValidationResult("error", "report.json", "Missing required 'themeCollection'")
+ )
else:
tc = data["themeCollection"]
if "baseTheme" not in tc:
- findings.append(ValidationResult(
- "warning", "report.json", "themeCollection missing 'baseTheme'"
- ))
+ findings.append(
+ ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'")
+ )
if "layoutOptimization" not in data:
- findings.append(ValidationResult(
- "error", "report.json", "Missing required 'layoutOptimization'"
- ))
+ findings.append(
+ ValidationResult("error", "report.json", "Missing required 'layoutOptimization'")
+ )
return findings
@@ -201,9 +203,9 @@ def _validate_pages_metadata(definition_path: Path) -> list[ValidationResult]:
page_order = data.get("pageOrder", [])
if not isinstance(page_order, list):
- findings.append(ValidationResult(
- "error", "pages/pages.json", "'pageOrder' must be an array"
- ))
+ findings.append(
+ ValidationResult("error", "pages/pages.json", "'pageOrder' must be an array")
+ )
return findings
@@ -234,14 +236,15 @@ def _validate_all_pages(definition_path: Path) -> list[ValidationResult]:
findings.append(ValidationResult("error", rel, f"Missing required '{req}'"))
valid_options = {
- "FitToPage", "FitToWidth", "ActualSize",
- "ActualSizeTopLeft", "DeprecatedDynamic",
+ "FitToPage",
+ "FitToWidth",
+ "ActualSize",
+ "ActualSizeTopLeft",
+ "DeprecatedDynamic",
}
opt = data.get("displayOption")
if opt and opt not in valid_options:
- findings.append(ValidationResult(
- "warning", rel, f"Unknown displayOption '{opt}'"
- ))
+ findings.append(ValidationResult("warning", rel, f"Unknown displayOption '{opt}'"))
if opt != "DeprecatedDynamic":
if "width" not in data:
@@ -251,9 +254,9 @@ def _validate_all_pages(definition_path: Path) -> list[ValidationResult]:
name = data.get("name", "")
if name and len(name) > 50:
- findings.append(ValidationResult(
- "warning", rel, f"Name exceeds 50 chars: '{name[:20]}...'"
- ))
+ findings.append(
+ ValidationResult("warning", rel, f"Name exceeds 50 chars: '{name[:20]}...'")
+ )
return findings
@@ -294,18 +297,22 @@ def _validate_all_visuals(definition_path: Path) -> list[ValidationResult]:
pos = data["position"]
for req in ("x", "y", "width", "height"):
if req not in pos:
- findings.append(ValidationResult(
- "error", rel, f"Position missing required '{req}'"
- ))
+ findings.append(
+ ValidationResult("error", rel, f"Position missing required '{req}'")
+ )
visual_config = data.get("visual", {})
vtype = visual_config.get("visualType", "")
if not vtype:
# Could be a visualGroup, which is also valid
if "visualGroup" not in data:
- findings.append(ValidationResult(
- "warning", rel, "Missing 'visual.visualType' (not a visual group either)"
- ))
+ findings.append(
+ ValidationResult(
+ "warning",
+ rel,
+ "Missing 'visual.visualType' (not a visual group either)",
+ )
+ )
return findings
@@ -331,26 +338,28 @@ def _validate_page_order_consistency(definition_path: Path) -> list[ValidationRe
pages_dir = definition_path / "pages"
actual_pages = {
- d.name
- for d in pages_dir.iterdir()
- if d.is_dir() and (d / "page.json").exists()
+ d.name for d in pages_dir.iterdir() if d.is_dir() and (d / "page.json").exists()
}
for name in page_order:
if name not in actual_pages:
- findings.append(ValidationResult(
- "warning",
- "pages/pages.json",
- f"pageOrder references '{name}' but no such page folder exists",
- ))
+ findings.append(
+ ValidationResult(
+ "warning",
+ "pages/pages.json",
+ f"pageOrder references '{name}' but no such page folder exists",
+ )
+ )
unlisted = actual_pages - set(page_order)
for name in sorted(unlisted):
- findings.append(ValidationResult(
- "info",
- "pages/pages.json",
- f"Page '{name}' exists but is not listed in pageOrder",
- ))
+ findings.append(
+ ValidationResult(
+ "info",
+ "pages/pages.json",
+ f"Page '{name}' exists but is not listed in pageOrder",
+ )
+ )
return findings
@@ -381,11 +390,13 @@ def _validate_visual_name_uniqueness(definition_path: Path) -> list[ValidationRe
name = data.get("name", "")
if name in names_seen:
rel = f"pages/{page_dir.name}/visuals/{vdir.name}/visual.json"
- findings.append(ValidationResult(
- "error",
- rel,
- f"Duplicate visual name '{name}' (also in {names_seen[name]})",
- ))
+ findings.append(
+ ValidationResult(
+ "error",
+ rel,
+ f"Duplicate visual name '{name}' (also in {names_seen[name]})",
+ )
+ )
else:
names_seen[name] = vdir.name
except (json.JSONDecodeError, KeyError):
@@ -418,16 +429,12 @@ def _build_result(findings: list[ValidationResult]) -> dict[str, Any]:
}
-def _extract_field_ref(
- select_item: dict[str, Any], sources: dict[str, str]
-) -> str | None:
+def _extract_field_ref(select_item: dict[str, Any], sources: dict[str, str]) -> str | None:
"""Extract a Table[Column] reference from a semantic query select item."""
for kind in ("Column", "Measure"):
if kind in select_item:
item = select_item[kind]
- source_name = (
- item.get("Expression", {}).get("SourceRef", {}).get("Source", "")
- )
+ source_name = item.get("Expression", {}).get("SourceRef", {}).get("Source", "")
prop = item.get("Property", "")
table = sources.get(source_name, source_name)
if table and prop:
diff --git a/src/pbi_cli/core/report_backend.py b/src/pbi_cli/core/report_backend.py
index c790770..d954158 100644
--- a/src/pbi_cli/core/report_backend.py
+++ b/src/pbi_cli/core/report_backend.py
@@ -34,7 +34,8 @@ from pbi_cli.core.pbir_path import (
def _read_json(path: Path) -> dict[str, Any]:
"""Read and parse a JSON file."""
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -76,12 +77,14 @@ def report_info(definition_path: Path) -> dict[str, Any]:
for v in visuals_dir.iterdir()
if v.is_dir() and (v / "visual.json").exists()
)
- pages.append({
- "name": page_data.get("name", page_dir.name),
- "display_name": page_data.get("displayName", ""),
- "ordinal": page_data.get("ordinal", 0),
- "visual_count": visual_count,
- })
+ pages.append(
+ {
+ "name": page_data.get("name", page_dir.name),
+ "display_name": page_data.get("displayName", ""),
+ "ordinal": page_data.get("ordinal", 0),
+ "visual_count": visual_count,
+ }
+ )
theme = report_data.get("themeCollection", {}).get("baseTheme", {})
@@ -115,39 +118,48 @@ def report_create(
pages_dir.mkdir(parents=True, exist_ok=True)
# version.json
- _write_json(definition_dir / "version.json", {
- "$schema": SCHEMA_VERSION,
- "version": "2.0.0",
- })
+ _write_json(
+ definition_dir / "version.json",
+ {
+ "$schema": SCHEMA_VERSION,
+ "version": "2.0.0",
+ },
+ )
# report.json (matches Desktop defaults)
- _write_json(definition_dir / "report.json", {
- "$schema": SCHEMA_REPORT,
- "themeCollection": {
- "baseTheme": dict(DEFAULT_BASE_THEME),
+ _write_json(
+ definition_dir / "report.json",
+ {
+ "$schema": SCHEMA_REPORT,
+ "themeCollection": {
+ "baseTheme": dict(DEFAULT_BASE_THEME),
+ },
+ "layoutOptimization": "None",
+ "settings": {
+ "useStylableVisualContainerHeader": True,
+ "defaultDrillFilterOtherVisuals": True,
+ "allowChangeFilterTypes": True,
+ "useEnhancedTooltips": True,
+ "useDefaultAggregateDisplayName": True,
+ },
+ "slowDataSourceSettings": {
+ "isCrossHighlightingDisabled": False,
+ "isSlicerSelectionsButtonEnabled": False,
+ "isFilterSelectionsButtonEnabled": False,
+ "isFieldWellButtonEnabled": False,
+ "isApplyAllButtonEnabled": False,
+ },
},
- "layoutOptimization": "None",
- "settings": {
- "useStylableVisualContainerHeader": True,
- "defaultDrillFilterOtherVisuals": True,
- "allowChangeFilterTypes": True,
- "useEnhancedTooltips": True,
- "useDefaultAggregateDisplayName": True,
- },
- "slowDataSourceSettings": {
- "isCrossHighlightingDisabled": False,
- "isSlicerSelectionsButtonEnabled": False,
- "isFilterSelectionsButtonEnabled": False,
- "isFieldWellButtonEnabled": False,
- "isApplyAllButtonEnabled": False,
- },
- })
+ )
# pages.json (empty page order)
- _write_json(definition_dir / "pages" / "pages.json", {
- "$schema": SCHEMA_PAGES_METADATA,
- "pageOrder": [],
- })
+ _write_json(
+ definition_dir / "pages" / "pages.json",
+ {
+ "$schema": SCHEMA_PAGES_METADATA,
+ "pageOrder": [],
+ },
+ )
# Scaffold a blank semantic model if no dataset path provided
if not dataset_path:
@@ -155,38 +167,47 @@ def report_create(
_scaffold_blank_semantic_model(target_path, name)
# definition.pbir (datasetReference is REQUIRED by Desktop)
- _write_json(report_folder / "definition.pbir", {
- "version": "4.0",
- "datasetReference": {
- "byPath": {"path": dataset_path},
+ _write_json(
+ report_folder / "definition.pbir",
+ {
+ "version": "4.0",
+ "datasetReference": {
+ "byPath": {"path": dataset_path},
+ },
},
- })
+ )
# .platform file for the report
- _write_json(report_folder / ".platform", {
- "$schema": (
- "https://developer.microsoft.com/json-schemas/"
- "fabric/gitIntegration/platformProperties/2.0.0/schema.json"
- ),
- "metadata": {
- "type": "Report",
- "displayName": name,
+ _write_json(
+ report_folder / ".platform",
+ {
+ "$schema": (
+ "https://developer.microsoft.com/json-schemas/"
+ "fabric/gitIntegration/platformProperties/2.0.0/schema.json"
+ ),
+ "metadata": {
+ "type": "Report",
+ "displayName": name,
+ },
+ "config": {
+ "version": "2.0",
+ "logicalId": "00000000-0000-0000-0000-000000000000",
+ },
},
- "config": {
- "version": "2.0",
- "logicalId": "00000000-0000-0000-0000-000000000000",
- },
- })
+ )
# .pbip project file
- _write_json(target_path / f"{name}.pbip", {
- "version": "1.0",
- "artifacts": [
- {
- "report": {"path": f"{name}.Report"},
- }
- ],
- })
+ _write_json(
+ target_path / f"{name}.pbip",
+ {
+ "version": "1.0",
+ "artifacts": [
+ {
+ "report": {"path": f"{name}.Report"},
+ }
+ ],
+ },
+ )
return {
"status": "created",
@@ -236,9 +257,7 @@ def report_validate(definition_path: Path) -> dict[str, Any]:
pdata = _read_json(page_json)
for req in ("name", "displayName", "displayOption"):
if req not in pdata:
- errors.append(
- f"Page '{page_dir.name}' missing required '{req}'"
- )
+ errors.append(f"Page '{page_dir.name}' missing required '{req}'")
except json.JSONDecodeError:
pass
@@ -281,21 +300,21 @@ def page_list(definition_path: Path) -> list[dict[str, Any]]:
visuals_dir = page_dir / "visuals"
if visuals_dir.is_dir():
visual_count = sum(
- 1
- for v in visuals_dir.iterdir()
- if v.is_dir() and (v / "visual.json").exists()
+ 1 for v in visuals_dir.iterdir() if v.is_dir() and (v / "visual.json").exists()
)
- results.append({
- "name": data.get("name", page_dir.name),
- "display_name": data.get("displayName", ""),
- "ordinal": data.get("ordinal", 0),
- "width": data.get("width", 1280),
- "height": data.get("height", 720),
- "display_option": data.get("displayOption", "FitToPage"),
- "visual_count": visual_count,
- "is_hidden": data.get("visibility") == "HiddenInViewMode",
- "page_type": data.get("type", "Default"),
- })
+ results.append(
+ {
+ "name": data.get("name", page_dir.name),
+ "display_name": data.get("displayName", ""),
+ "ordinal": data.get("ordinal", 0),
+ "width": data.get("width", 1280),
+ "height": data.get("height", 720),
+ "display_option": data.get("displayOption", "FitToPage"),
+ "visual_count": visual_count,
+ "is_hidden": data.get("visibility") == "HiddenInViewMode",
+ "page_type": data.get("type", "Default"),
+ }
+ )
# Sort by page order if available, then by ordinal
if page_order:
@@ -327,14 +346,17 @@ def page_add(
(page_dir / "visuals").mkdir()
# Write page.json (no ordinal - Desktop uses pages.json pageOrder instead)
- _write_json(page_dir / "page.json", {
- "$schema": SCHEMA_PAGE,
- "name": page_name,
- "displayName": display_name,
- "displayOption": display_option,
- "height": height,
- "width": width,
- })
+ _write_json(
+ page_dir / "page.json",
+ {
+ "$schema": SCHEMA_PAGE,
+ "name": page_name,
+ "displayName": display_name,
+ "displayOption": display_option,
+ "height": height,
+ "width": width,
+ },
+ )
# Update pages.json
_update_page_order(definition_path, page_name, action="add")
@@ -375,9 +397,7 @@ def page_get(definition_path: Path, page_name: str) -> dict[str, Any]:
visuals_dir = page_dir / "visuals"
if visuals_dir.is_dir():
visual_count = sum(
- 1
- for v in visuals_dir.iterdir()
- if v.is_dir() and (v / "visual.json").exists()
+ 1 for v in visuals_dir.iterdir() if v.is_dir() and (v / "visual.json").exists()
)
return {
@@ -407,9 +427,7 @@ def page_set_background(
The color must be a hex string, e.g. ``'#F8F9FA'``.
"""
if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color):
- raise PbiCliError(
- f"Invalid color '{color}' -- expected hex format like '#F8F9FA'."
- )
+ raise PbiCliError(f"Invalid color '{color}' -- expected hex format like '#F8F9FA'.")
page_dir = get_page_dir(definition_path, page_name)
page_json_path = page_dir / "page.json"
@@ -419,15 +437,7 @@ def page_set_background(
page_data = _read_json(page_json_path)
background_entry = {
"properties": {
- "color": {
- "solid": {
- "color": {
- "expr": {
- "Literal": {"Value": f"'{color}'"}
- }
- }
- }
- }
+ "color": {"solid": {"color": {"expr": {"Literal": {"Value": f"'{color}'"}}}}}
}
}
objects = {**page_data.get("objects", {}), "background": [background_entry]}
@@ -464,9 +474,7 @@ def page_set_visibility(
# ---------------------------------------------------------------------------
-def theme_set(
- definition_path: Path, theme_path: Path
-) -> dict[str, Any]:
+def theme_set(definition_path: Path, theme_path: Path) -> dict[str, Any]:
"""Apply a custom theme JSON to the report."""
if not theme_path.exists():
raise PbiCliError(f"Theme file not found: {theme_path}")
@@ -489,9 +497,7 @@ def theme_set(
resources_dir = report_folder / "StaticResources" / "RegisteredResources"
resources_dir.mkdir(parents=True, exist_ok=True)
theme_dest = resources_dir / theme_path.name
- theme_dest.write_text(
- theme_path.read_text(encoding="utf-8"), encoding="utf-8"
- )
+ theme_dest.write_text(theme_path.read_text(encoding="utf-8"), encoding="utf-8")
# Update resource packages in report.json
resource_packages = report_data.get("resourcePackages", [])
@@ -513,15 +519,19 @@ def theme_set(
break
if not found:
- resource_packages.append({
- "name": "RegisteredResources",
- "type": "RegisteredResources",
- "items": [{
- "name": theme_path.name,
- "type": 202,
- "path": f"BaseThemes/{theme_path.name}",
- }],
- })
+ resource_packages.append(
+ {
+ "name": "RegisteredResources",
+ "type": "RegisteredResources",
+ "items": [
+ {
+ "name": theme_path.name,
+ "type": 202,
+ "path": f"BaseThemes/{theme_path.name}",
+ }
+ ],
+ }
+ )
report_data["resourcePackages"] = resource_packages
_write_json(report_json_path, report_data)
@@ -670,8 +680,7 @@ def report_convert(
if report_folder is None:
raise PbiCliError(
- f"No .Report folder found in '{source_path}'. "
- "Expected a folder ending in .Report."
+ f"No .Report folder found in '{source_path}'. Expected a folder ending in .Report."
)
name = report_folder.name.replace(".Report", "")
@@ -680,26 +689,22 @@ def report_convert(
# Create .pbip file
pbip_path = target / f"{name}.pbip"
if pbip_path.exists() and not force:
- raise PbiCliError(
- f".pbip file already exists at '{pbip_path}'. Use --force to overwrite."
- )
- _write_json(pbip_path, {
- "version": "1.0",
- "artifacts": [
- {"report": {"path": f"{name}.Report"}},
- ],
- })
+ raise PbiCliError(f".pbip file already exists at '{pbip_path}'. Use --force to overwrite.")
+ _write_json(
+ pbip_path,
+ {
+ "version": "1.0",
+ "artifacts": [
+ {"report": {"path": f"{name}.Report"}},
+ ],
+ },
+ )
# Create .gitignore if not present
gitignore = target / ".gitignore"
gitignore_created = not gitignore.exists()
if gitignore_created:
- gitignore_content = (
- "# Power BI local settings\n"
- ".pbi/\n"
- "*.pbix\n"
- "*.bak\n"
- )
+ gitignore_content = "# Power BI local settings\n.pbi/\n*.pbix\n*.bak\n"
gitignore.write_text(gitignore_content, encoding="utf-8")
# Validate the definition.pbir exists
@@ -728,38 +733,40 @@ def _scaffold_blank_semantic_model(target_path: Path, name: str) -> None:
# model.tmdl (minimal valid TMDL)
(defn_dir / "model.tmdl").write_text(
- "model Model\n"
- " culture: en-US\n"
- " defaultPowerBIDataSourceVersion: powerBI_V3\n",
+ "model Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n",
encoding="utf-8",
)
# .platform file (required by Desktop)
- _write_json(model_dir / ".platform", {
- "$schema": (
- "https://developer.microsoft.com/json-schemas/"
- "fabric/gitIntegration/platformProperties/2.0.0/schema.json"
- ),
- "metadata": {
- "type": "SemanticModel",
- "displayName": name,
+ _write_json(
+ model_dir / ".platform",
+ {
+ "$schema": (
+ "https://developer.microsoft.com/json-schemas/"
+ "fabric/gitIntegration/platformProperties/2.0.0/schema.json"
+ ),
+ "metadata": {
+ "type": "SemanticModel",
+ "displayName": name,
+ },
+ "config": {
+ "version": "2.0",
+ "logicalId": "00000000-0000-0000-0000-000000000000",
+ },
},
- "config": {
- "version": "2.0",
- "logicalId": "00000000-0000-0000-0000-000000000000",
- },
- })
+ )
# definition.pbism (matches Desktop format)
- _write_json(model_dir / "definition.pbism", {
- "version": "4.1",
- "settings": {},
- })
+ _write_json(
+ model_dir / "definition.pbism",
+ {
+ "version": "4.1",
+ "settings": {},
+ },
+ )
-def _update_page_order(
- definition_path: Path, page_name: str, action: str
-) -> None:
+def _update_page_order(definition_path: Path, page_name: str, action: str) -> None:
"""Update pages.json with page add/remove."""
pages_meta_path = definition_path / "pages" / "pages.json"
diff --git a/src/pbi_cli/core/tmdl_diff.py b/src/pbi_cli/core/tmdl_diff.py
index 814ae8a..86c47e2 100644
--- a/src/pbi_cli/core/tmdl_diff.py
+++ b/src/pbi_cli/core/tmdl_diff.py
@@ -128,9 +128,7 @@ def _list_tmdl_names(tables_dir: Path) -> set[str]:
return {p.stem for p in tables_dir.glob("*.tmdl")}
-def _diff_table_entities(
- base_text: str, head_text: str
-) -> dict[str, list[str]]:
+def _diff_table_entities(base_text: str, head_text: str) -> dict[str, list[str]]:
"""Compare entity blocks within two table TMDL files."""
base_entities = _parse_table_entities(base_text)
head_entities = _parse_table_entities(head_text)
@@ -226,7 +224,7 @@ def _extract_entity_name(keyword: str, declaration: str) -> str:
# e.g. "measure 'Total Revenue' = ..." -> "Total Revenue"
# e.g. "column ProductID" -> "ProductID"
# e.g. "partition Sales = m" -> "Sales"
- rest = declaration[len(keyword):].strip()
+ rest = declaration[len(keyword) :].strip()
if rest.startswith("'"):
end = rest.find("'", 1)
return rest[1:end] if end > 0 else rest[1:]
diff --git a/src/pbi_cli/core/visual_backend.py b/src/pbi_cli/core/visual_backend.py
index 02bc143..f49b2e3 100644
--- a/src/pbi_cli/core/visual_backend.py
+++ b/src/pbi_cli/core/visual_backend.py
@@ -26,7 +26,8 @@ from pbi_cli.core.pbir_path import get_visual_dir, get_visuals_dir
def _read_json(path: Path) -> dict[str, Any]:
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -87,16 +88,24 @@ VISUAL_DATA_ROLES: dict[str, list[str]] = {
}
# Roles that should default to Measure references (not Column)
-MEASURE_ROLES: frozenset[str] = frozenset({
- "Y", "Values", "Fields", # "Fields" is used by cardNew only
- "Indicator", "Goal",
- # v3.1.0 additions
- "ColumnY", "LineY", "X", "Size",
- # v3.4.0 additions
- "Data",
- # v3.8.0 additions
- "MaxValue",
-})
+MEASURE_ROLES: frozenset[str] = frozenset(
+ {
+ "Y",
+ "Values",
+ "Fields", # "Fields" is used by cardNew only
+ "Indicator",
+ "Goal",
+ # v3.1.0 additions
+ "ColumnY",
+ "LineY",
+ "X",
+ "Size",
+ # v3.4.0 additions
+ "Data",
+ # v3.8.0 additions
+ "MaxValue",
+ }
+)
# User-friendly role aliases to PBIR role names
ROLE_ALIASES: dict[str, dict[str, str]] = {
@@ -127,7 +136,11 @@ ROLE_ALIASES: dict[str, dict[str, str]] = {
"ribbonChart": {"category": "Category", "value": "Y", "legend": "Legend"},
"waterfallChart": {"category": "Category", "value": "Y", "breakdown": "Breakdown"},
"scatterChart": {
- "x": "X", "y": "Y", "detail": "Details", "size": "Size", "legend": "Legend",
+ "x": "X",
+ "y": "Y",
+ "detail": "Details",
+ "size": "Size",
+ "legend": "Legend",
"value": "Y",
},
"funnelChart": {"category": "Category", "value": "Y"},
@@ -192,8 +205,7 @@ def _build_visual_json(
) -> dict[str, Any]:
"""Fill placeholders in a template string and return parsed JSON."""
filled = (
- template_str
- .replace("__VISUAL_NAME__", name)
+ template_str.replace("__VISUAL_NAME__", name)
.replace("__X__", str(x))
.replace("__Y__", str(y))
.replace("__WIDTH__", str(width))
@@ -201,7 +213,8 @@ def _build_visual_json(
.replace("__Z__", str(z))
.replace("__TAB_ORDER__", str(tab_order))
)
- return json.loads(filled)
+ result: dict[str, Any] = json.loads(filled)
+ return result
# ---------------------------------------------------------------------------
@@ -255,9 +268,7 @@ DEFAULT_SIZES: dict[str, tuple[float, float]] = {
# ---------------------------------------------------------------------------
-def visual_list(
- definition_path: Path, page_name: str
-) -> list[dict[str, Any]]:
+def visual_list(definition_path: Path, page_name: str) -> list[dict[str, Any]]:
"""List all visuals on a page."""
visuals_dir = definition_path / "pages" / page_name / "visuals"
if not visuals_dir.is_dir():
@@ -274,33 +285,35 @@ def visual_list(
# Group container: has "visualGroup" key instead of "visual"
if "visualGroup" in data and "visual" not in data:
- results.append({
- "name": data.get("name", vdir.name),
- "visual_type": "group",
- "x": 0,
- "y": 0,
- "width": 0,
- "height": 0,
- })
+ results.append(
+ {
+ "name": data.get("name", vdir.name),
+ "visual_type": "group",
+ "x": 0,
+ "y": 0,
+ "width": 0,
+ "height": 0,
+ }
+ )
continue
pos = data.get("position", {})
visual_config = data.get("visual", {})
- results.append({
- "name": data.get("name", vdir.name),
- "visual_type": visual_config.get("visualType", "unknown"),
- "x": pos.get("x", 0),
- "y": pos.get("y", 0),
- "width": pos.get("width", 0),
- "height": pos.get("height", 0),
- })
+ results.append(
+ {
+ "name": data.get("name", vdir.name),
+ "visual_type": visual_config.get("visualType", "unknown"),
+ "x": pos.get("x", 0),
+ "y": pos.get("y", 0),
+ "width": pos.get("width", 0),
+ "height": pos.get("height", 0),
+ }
+ )
return results
-def visual_get(
- definition_path: Path, page_name: str, visual_name: str
-) -> dict[str, Any]:
+def visual_get(definition_path: Path, page_name: str, visual_name: str) -> dict[str, Any]:
"""Get detailed information about a visual."""
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
vfile = visual_dir / "visual.json"
@@ -320,11 +333,13 @@ def visual_get(
for proj in projections:
field = proj.get("field", {})
query_ref = proj.get("queryRef", "")
- bindings.append({
- "role": role,
- "query_ref": query_ref,
- "field": _summarize_field(field),
- })
+ bindings.append(
+ {
+ "role": role,
+ "query_ref": query_ref,
+ "field": _summarize_field(field),
+ }
+ )
return {
"name": data.get("name", visual_name),
@@ -465,16 +480,12 @@ def visual_set_container(
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
visual_json_path = visual_dir / "visual.json"
if not visual_json_path.exists():
- raise PbiCliError(
- f"Visual '{visual_name}' not found on page '{page_name}'."
- )
+ raise PbiCliError(f"Visual '{visual_name}' not found on page '{page_name}'.")
data = _read_json(visual_json_path)
visual = data.get("visual")
if visual is None:
- raise PbiCliError(
- f"Visual '{visual_name}' has invalid JSON -- missing 'visual' key."
- )
+ raise PbiCliError(f"Visual '{visual_name}' has invalid JSON -- missing 'visual' key.")
if border_show is None and background_show is None and title is None:
return {
@@ -489,26 +500,17 @@ def visual_set_container(
vco: dict[str, Any] = dict(visual.get("visualContainerObjects", {}))
def _bool_entry(value: bool) -> list[dict[str, Any]]:
- return [{
- "properties": {
- "show": {
- "expr": {"Literal": {"Value": str(value).lower()}}
- }
- }
- }]
+ return [{"properties": {"show": {"expr": {"Literal": {"Value": str(value).lower()}}}}}]
if border_show is not None:
vco = {**vco, "border": _bool_entry(border_show)}
if background_show is not None:
vco = {**vco, "background": _bool_entry(background_show)}
if title is not None:
- vco = {**vco, "title": [{
- "properties": {
- "text": {
- "expr": {"Literal": {"Value": f"'{title}'"}}
- }
- }
- }]}
+ vco = {
+ **vco,
+ "title": [{"properties": {"text": {"expr": {"Literal": {"Value": f"'{title}'"}}}}}],
+ }
updated_visual = {**visual, "visualContainerObjects": vco}
_write_json(visual_json_path, {**data, "visual": updated_visual})
@@ -523,9 +525,7 @@ def visual_set_container(
}
-def visual_delete(
- definition_path: Path, page_name: str, visual_name: str
-) -> dict[str, Any]:
+def visual_delete(definition_path: Path, page_name: str, visual_name: str) -> dict[str, Any]:
"""Delete a visual from a page."""
visual_dir = get_visual_dir(definition_path, page_name, visual_name)
@@ -638,28 +638,34 @@ def visual_bind(
"Property": column,
}
}
- select_items.append({
- **cmd_field_expr,
- "Name": query_ref,
- })
+ select_items.append(
+ {
+ **cmd_field_expr,
+ "Name": query_ref,
+ }
+ )
- applied.append({
- "role": pbir_role,
- "field": field_ref,
- "query_ref": query_ref,
- })
+ applied.append(
+ {
+ "role": pbir_role,
+ "field": field_ref,
+ "query_ref": query_ref,
+ }
+ )
# Set the semantic query Commands block (merges with existing)
if from_entities and select_items:
- query["Commands"] = [{
- "SemanticQueryDataShapeCommand": {
- "Query": {
- "Version": 2,
- "From": list(from_entities.values()),
- "Select": select_items,
+ query["Commands"] = [
+ {
+ "SemanticQueryDataShapeCommand": {
+ "Query": {
+ "Version": 2,
+ "From": list(from_entities.values()),
+ "Select": select_items,
+ }
}
}
- }]
+ ]
data["visual"] = visual_config
_write_json(vfile, data)
@@ -690,9 +696,7 @@ def _parse_field_ref(ref: str) -> tuple[str, str]:
column = match.group(2).strip()
return table, column
- raise PbiCliError(
- f"Invalid field reference '{ref}'. Expected 'Table[Column]' format."
- )
+ raise PbiCliError(f"Invalid field reference '{ref}'. Expected 'Table[Column]' format.")
def _summarize_field(field: dict[str, Any]) -> str:
@@ -878,12 +882,14 @@ def visual_calc_list(
for proj in state.get("projections", []):
nvc = proj.get("field", {}).get("NativeVisualCalculation")
if nvc is not None:
- results.append({
- "name": nvc.get("Name", ""),
- "expression": nvc.get("Expression", ""),
- "role": role,
- "query_ref": proj.get("queryRef", "select"),
- })
+ results.append(
+ {
+ "name": nvc.get("Name", ""),
+ "expression": nvc.get("Expression", ""),
+ "role": role,
+ "query_ref": proj.get("queryRef", "select"),
+ }
+ )
return results
@@ -905,15 +911,14 @@ def visual_calc_delete(
raise PbiCliError(f"Visual '{visual_name}' not found on page '{page_name}'.")
data = _read_json(vfile)
- query_state = (
- data.get("visual", {}).get("query", {}).get("queryState", {})
- )
+ query_state = data.get("visual", {}).get("query", {}).get("queryState", {})
found = False
for role, state in query_state.items():
projections: list[dict[str, Any]] = state.get("projections", [])
new_projections = [
- proj for proj in projections
+ proj
+ for proj in projections
if proj.get("field", {}).get("NativeVisualCalculation", {}).get("Name") != calc_name
]
if len(new_projections) < len(projections):
@@ -921,9 +926,7 @@ def visual_calc_delete(
found = True
if not found:
- raise PbiCliError(
- f"Visual calculation '{calc_name}' not found in visual '{visual_name}'."
- )
+ raise PbiCliError(f"Visual calculation '{calc_name}' not found in visual '{visual_name}'.")
_write_json(vfile, data)
return {"status": "deleted", "visual": visual_name, "name": calc_name}
diff --git a/src/pbi_cli/preview/renderer.py b/src/pbi_cli/preview/renderer.py
index 4a94f72..668f6cc 100644
--- a/src/pbi_cli/preview/renderer.py
+++ b/src/pbi_cli/preview/renderer.py
@@ -29,12 +29,12 @@ def render_report(definition_path: Path) -> str:
for page_dir in page_dirs:
pages_html.append(_render_page(page_dir))
- pages_content = "\n".join(pages_html) if pages_html else "
No pages in report
"
-
- return _HTML_TEMPLATE.replace("{{THEME}}", escape(theme)).replace(
- "{{PAGES}}", pages_content
+ pages_content = (
+ "\n".join(pages_html) if pages_html else "No pages in report
"
)
+ return _HTML_TEMPLATE.replace("{{THEME}}", escape(theme)).replace("{{PAGES}}", pages_content)
+
def render_page(definition_path: Path, page_name: str) -> str:
"""Render a single page as HTML."""
@@ -218,7 +218,7 @@ def _render_visual_content(vtype: str, w: float, h: float, bindings: list[str])
for i in range(num_bars):
bar_h = body_h * (0.3 + 0.5 * ((i * 37 + 13) % 7) / 7)
bars += (
- f''
)
return f''
@@ -230,7 +230,9 @@ def _render_visual_content(vtype: str, w: float, h: float, bindings: list[str])
px = (w / (num_points - 1)) * i
py = body_h * (0.2 + 0.6 * ((i * 47 + 23) % 11) / 11)
points.append(f"{px},{py}")
- polyline = f''
+ polyline = (
+ f''
+ )
return f''
if vtype == "card":
@@ -286,13 +288,15 @@ def _get_page_order(definition_path: Path) -> list[str]:
return []
try:
data = json.loads(pages_json.read_text(encoding="utf-8"))
- return data.get("pageOrder", [])
+ order: list[str] = data.get("pageOrder", [])
+ return order
except (json.JSONDecodeError, KeyError):
return []
def _read_json(path: Path) -> dict[str, Any]:
- return json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ return result
# ---------------------------------------------------------------------------
diff --git a/src/pbi_cli/preview/server.py b/src/pbi_cli/preview/server.py
index 1bbf4c2..5a2bc0c 100644
--- a/src/pbi_cli/preview/server.py
+++ b/src/pbi_cli/preview/server.py
@@ -27,7 +27,7 @@ def start_preview_server(
"""
# Check for websockets dependency
try:
- import websockets # type: ignore[import-untyped]
+ import websockets
except ImportError:
return {
"status": "error",
diff --git a/src/pbi_cli/utils/desktop_reload.py b/src/pbi_cli/utils/desktop_reload.py
index e86f86c..d79aa33 100644
--- a/src/pbi_cli/utils/desktop_reload.py
+++ b/src/pbi_cli/utils/desktop_reload.py
@@ -48,9 +48,9 @@ def reload_desktop() -> dict[str, Any]:
def _try_pywin32() -> dict[str, Any] | None:
"""Try to use pywin32 to send a reload shortcut to PBI Desktop."""
try:
- import win32api # type: ignore[import-untyped]
- import win32con # type: ignore[import-untyped]
- import win32gui # type: ignore[import-untyped]
+ import win32api
+ import win32con
+ import win32gui
except ImportError:
return None
@@ -93,7 +93,7 @@ def _try_pywin32() -> dict[str, Any] | None:
def _find_pbi_window_pywin32() -> int:
"""Find Power BI Desktop's main window handle via pywin32."""
- import win32gui # type: ignore[import-untyped]
+ import win32gui
result = 0
diff --git a/src/pbi_cli/utils/desktop_sync.py b/src/pbi_cli/utils/desktop_sync.py
index 1a4cefd..d8bfa79 100644
--- a/src/pbi_cli/utils/desktop_sync.py
+++ b/src/pbi_cli/utils/desktop_sync.py
@@ -37,8 +37,8 @@ def sync_desktop(
files here are snapshotted before Desktop saves and restored after.
"""
try:
- import win32con # type: ignore[import-untyped] # noqa: F401
- import win32gui # type: ignore[import-untyped] # noqa: F401
+ import win32con # noqa: F401
+ import win32gui # noqa: F401
except ImportError:
return {
"status": "manual",
@@ -84,6 +84,7 @@ def sync_desktop(
# Snapshot / Restore
# ---------------------------------------------------------------------------
+
def _snapshot_recent_changes(
definition_path: str | Path | None,
max_age_seconds: float = 5.0,
@@ -126,12 +127,13 @@ def _restore_snapshots(snapshots: dict[Path, bytes]) -> list[str]:
# Desktop process discovery
# ---------------------------------------------------------------------------
+
def _find_desktop_process(
pbip_hint: str | Path | None,
) -> dict[str, Any] | None:
"""Find the PBI Desktop window, its PID, and the .pbip file it has open."""
- import win32gui # type: ignore[import-untyped]
- import win32process # type: ignore[import-untyped]
+ import win32gui
+ import win32process
hint_stem = None
if pbip_hint is not None:
@@ -163,13 +165,15 @@ def _find_desktop_process(
if hint_stem not in Path(pbip_path).stem.lower():
return True
- matches.append({
- "hwnd": hwnd,
- "pid": pid,
- "title": title,
- "exe_path": exe_path,
- "pbip_path": pbip_path,
- })
+ matches.append(
+ {
+ "hwnd": hwnd,
+ "pid": pid,
+ "title": title,
+ "exe_path": exe_path,
+ "pbip_path": pbip_path,
+ }
+ )
return True
try:
@@ -185,8 +189,13 @@ def _get_process_info(pid: int) -> dict[str, str] | None:
try:
out = subprocess.check_output(
[
- "wmic", "process", "where", f"ProcessId={pid}",
- "get", "ExecutablePath,CommandLine", "/format:list",
+ "wmic",
+ "process",
+ "where",
+ f"ProcessId={pid}",
+ "get",
+ "ExecutablePath,CommandLine",
+ "/format:list",
],
text=True,
stderr=subprocess.DEVNULL,
@@ -215,13 +224,14 @@ def _get_process_info(pid: int) -> dict[str, str] | None:
# Close with save
# ---------------------------------------------------------------------------
+
def _close_with_save(hwnd: int, pid: int) -> dict[str, Any] | None:
"""Close Desktop via WM_CLOSE and click Save in the dialog.
Returns an error dict on failure, or None on success.
"""
- import win32con # type: ignore[import-untyped]
- import win32gui # type: ignore[import-untyped]
+ import win32con
+ import win32gui
win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
time.sleep(2)
@@ -252,7 +262,7 @@ def _accept_save_dialog() -> None:
[Save] [Don't Save] [Cancel]
'Save' is the default focused button, so Enter clicks it.
"""
- import win32gui # type: ignore[import-untyped]
+ import win32gui
dialog_found = False
@@ -287,10 +297,11 @@ def _accept_save_dialog() -> None:
# Reopen / utilities
# ---------------------------------------------------------------------------
+
def _reopen_pbip(pbip_path: str) -> dict[str, Any]:
"""Launch the .pbip file with the system default handler."""
try:
- os.startfile(pbip_path) # type: ignore[attr-defined]
+ os.startfile(pbip_path) # noqa: S606 # Windows-only API
return {
"status": "success",
"method": "pywin32",
@@ -321,6 +332,6 @@ def _process_alive(pid: int) -> bool:
def _get_wscript_shell() -> Any:
"""Get a WScript.Shell COM object for SendKeys."""
- import win32com.client # type: ignore[import-untyped]
+ import win32com.client
return win32com.client.Dispatch("WScript.Shell")
diff --git a/tests/test_bookmark_backend.py b/tests/test_bookmark_backend.py
index 098fe08..98ff037 100644
--- a/tests/test_bookmark_backend.py
+++ b/tests/test_bookmark_backend.py
@@ -216,19 +216,16 @@ def test_bookmark_set_visibility_hide_sets_display_mode(definition_path: Path) -
"""set_visibility with hidden=True writes display.mode='hidden' on singleVisual."""
bookmark_add(definition_path, "Hide Test", "page_a", name="bm_hide")
- result = bookmark_set_visibility(
- definition_path, "bm_hide", "page_a", "visual_x", hidden=True
- )
+ result = bookmark_set_visibility(definition_path, "bm_hide", "page_a", "visual_x", hidden=True)
assert result["status"] == "updated"
assert result["hidden"] is True
bm_file = definition_path / "bookmarks" / "bm_hide.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
- single = (
- data["explorationState"]["sections"]["page_a"]
- ["visualContainers"]["visual_x"]["singleVisual"]
- )
+ single = data["explorationState"]["sections"]["page_a"]["visualContainers"]["visual_x"][
+ "singleVisual"
+ ]
assert single["display"] == {"mode": "hidden"}
@@ -237,19 +234,14 @@ def test_bookmark_set_visibility_show_removes_display_key(definition_path: Path)
bookmark_add(definition_path, "Show Test", "page_b", name="bm_show")
# First hide it, then show it
- bookmark_set_visibility(
- definition_path, "bm_show", "page_b", "visual_y", hidden=True
- )
- bookmark_set_visibility(
- definition_path, "bm_show", "page_b", "visual_y", hidden=False
- )
+ bookmark_set_visibility(definition_path, "bm_show", "page_b", "visual_y", hidden=True)
+ bookmark_set_visibility(definition_path, "bm_show", "page_b", "visual_y", hidden=False)
bm_file = definition_path / "bookmarks" / "bm_show.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
- single = (
- data["explorationState"]["sections"]["page_b"]
- ["visualContainers"]["visual_y"]["singleVisual"]
- )
+ single = data["explorationState"]["sections"]["page_b"]["visualContainers"]["visual_y"][
+ "singleVisual"
+ ]
assert "display" not in single
@@ -258,16 +250,12 @@ def test_bookmark_set_visibility_creates_path_if_absent(definition_path: Path) -
bookmark_add(definition_path, "New Path", "page_c", name="bm_newpath")
# The bookmark was created without any sections; the function should create them.
- bookmark_set_visibility(
- definition_path, "bm_newpath", "page_c", "visual_z", hidden=True
- )
+ bookmark_set_visibility(definition_path, "bm_newpath", "page_c", "visual_z", hidden=True)
bm_file = definition_path / "bookmarks" / "bm_newpath.bookmark.json"
data = json.loads(bm_file.read_text(encoding="utf-8"))
assert "page_c" in data["explorationState"]["sections"]
- assert "visual_z" in (
- data["explorationState"]["sections"]["page_c"]["visualContainers"]
- )
+ assert "visual_z" in (data["explorationState"]["sections"]["page_c"]["visualContainers"])
def test_bookmark_set_visibility_preserves_existing_single_visual_keys(
@@ -277,28 +265,23 @@ def test_bookmark_set_visibility_preserves_existing_single_visual_keys(
bookmark_add(definition_path, "Preserve", "page_d", name="bm_preserve")
# Pre-populate a singleVisual with a visualType key via set_visibility helper
- bookmark_set_visibility(
- definition_path, "bm_preserve", "page_d", "visual_w", hidden=False
- )
+ bookmark_set_visibility(definition_path, "bm_preserve", "page_d", "visual_w", hidden=False)
# Manually inject a visualType into the singleVisual
bm_file = definition_path / "bookmarks" / "bm_preserve.bookmark.json"
raw = json.loads(bm_file.read_text(encoding="utf-8"))
- raw["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"][
- "singleVisual"
- ]["visualType"] = "barChart"
+ raw["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"]["singleVisual"][
+ "visualType"
+ ] = "barChart"
bm_file.write_text(json.dumps(raw, indent=2), encoding="utf-8")
# Now hide and verify visualType is retained
- bookmark_set_visibility(
- definition_path, "bm_preserve", "page_d", "visual_w", hidden=True
- )
+ bookmark_set_visibility(definition_path, "bm_preserve", "page_d", "visual_w", hidden=True)
updated = json.loads(bm_file.read_text(encoding="utf-8"))
- single = (
- updated["explorationState"]["sections"]["page_d"]
- ["visualContainers"]["visual_w"]["singleVisual"]
- )
+ single = updated["explorationState"]["sections"]["page_d"]["visualContainers"]["visual_w"][
+ "singleVisual"
+ ]
assert single["visualType"] == "barChart"
assert single["display"] == {"mode": "hidden"}
@@ -308,6 +291,4 @@ def test_bookmark_set_visibility_raises_for_unknown_bookmark(
) -> None:
"""set_visibility raises PbiCliError when the bookmark does not exist."""
with pytest.raises(PbiCliError, match="not found"):
- bookmark_set_visibility(
- definition_path, "nonexistent", "page_x", "visual_x", hidden=True
- )
+ bookmark_set_visibility(definition_path, "nonexistent", "page_x", "visual_x", hidden=True)
diff --git a/tests/test_bulk_backend.py b/tests/test_bulk_backend.py
index bb64cfa..d2f8a72 100644
--- a/tests/test_bulk_backend.py
+++ b/tests/test_bulk_backend.py
@@ -156,9 +156,7 @@ def test_where_by_y_min(multi_visual_page: Path) -> None:
def test_where_type_and_position_combined(multi_visual_page: Path) -> None:
"""Combining type and x_max narrows results correctly."""
- result = visual_where(
- multi_visual_page, "test_page", visual_type="barChart", x_max=400
- )
+ result = visual_where(multi_visual_page, "test_page", visual_type="barChart", x_max=400)
assert len(result) == 1
assert result[0]["name"] == "BarChart_1"
diff --git a/tests/test_filter_backend.py b/tests/test_filter_backend.py
index 38dd4d8..7f1fff2 100644
--- a/tests/test_filter_backend.py
+++ b/tests/test_filter_backend.py
@@ -58,15 +58,18 @@ def _make_page(definition_path: Path, page_name: str, display_name: str = "Overv
"""Create a minimal page folder and return the page dir."""
page_dir = definition_path / "pages" / page_name
page_dir.mkdir(parents=True, exist_ok=True)
- _write(page_dir / "page.json", {
- "$schema": _SCHEMA_PAGE,
- "name": page_name,
- "displayName": display_name,
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- "ordinal": 0,
- })
+ _write(
+ page_dir / "page.json",
+ {
+ "$schema": _SCHEMA_PAGE,
+ "name": page_name,
+ "displayName": display_name,
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ "ordinal": 0,
+ },
+ )
return page_dir
@@ -74,17 +77,20 @@ def _make_visual(page_dir: Path, visual_name: str) -> Path:
"""Create a minimal visual folder and return the visual dir."""
visual_dir = page_dir / "visuals" / visual_name
visual_dir.mkdir(parents=True, exist_ok=True)
- _write(visual_dir / "visual.json", {
- "$schema": _SCHEMA_VISUAL_CONTAINER,
- "name": visual_name,
- "position": {"x": 0, "y": 0, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
- "visual": {
- "$schema": _SCHEMA_VISUAL_CONFIG,
- "visualType": "barChart",
- "query": {"queryState": {}},
- "objects": {},
+ _write(
+ visual_dir / "visual.json",
+ {
+ "$schema": _SCHEMA_VISUAL_CONTAINER,
+ "name": visual_name,
+ "position": {"x": 0, "y": 0, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
+ "visual": {
+ "$schema": _SCHEMA_VISUAL_CONFIG,
+ "visualType": "barChart",
+ "query": {"queryState": {}},
+ "objects": {},
+ },
},
- })
+ )
return visual_dir
@@ -123,9 +129,7 @@ def test_filter_list_empty_visual(definition_path: Path) -> None:
def test_filter_list_with_filters(definition_path: Path) -> None:
"""filter_list returns the filters after one is added."""
- filter_add_categorical(
- definition_path, "page_overview", "Sales", "Region", ["North", "South"]
- )
+ filter_add_categorical(definition_path, "page_overview", "Sales", "Region", ["North", "South"])
result = filter_list(definition_path, "page_overview")
assert len(result) == 1
assert result[0]["type"] == "Categorical"
@@ -193,9 +197,7 @@ def test_filter_add_categorical_json_structure(definition_path: Path) -> None:
def test_filter_add_categorical_alias_from_table_name(definition_path: Path) -> None:
"""Source alias uses the first character of the table name, lowercased."""
- filter_add_categorical(
- definition_path, "page_overview", "Sales", "Product", ["Widget"]
- )
+ filter_add_categorical(definition_path, "page_overview", "Sales", "Product", ["Widget"])
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]
alias = f["filter"]["From"][0]["Name"]
@@ -243,7 +245,11 @@ def test_filter_add_categorical_visual_scope(definition_path: Path) -> None:
def test_filter_list_visual_after_add(definition_path: Path) -> None:
"""filter_list on a visual returns the added filter."""
filter_add_categorical(
- definition_path, "page_overview", "Sales", "Year", ["2024"],
+ definition_path,
+ "page_overview",
+ "Sales",
+ "Year",
+ ["2024"],
visual_name="visual_abc123",
)
result = filter_list(definition_path, "page_overview", visual_name="visual_abc123")
@@ -281,8 +287,13 @@ def test_filter_remove_raises_for_unknown_name(definition_path: Path) -> None:
def test_filter_remove_visual(definition_path: Path) -> None:
"""filter_remove works on visual-level filters."""
filter_add_categorical(
- definition_path, "page_overview", "Sales", "Year", ["2024"],
- visual_name="visual_abc123", name="vis_filter_x",
+ definition_path,
+ "page_overview",
+ "Sales",
+ "Year",
+ ["2024"],
+ visual_name="visual_abc123",
+ name="vis_filter_x",
)
result = filter_remove(
definition_path, "page_overview", "vis_filter_x", visual_name="visual_abc123"
@@ -298,9 +309,7 @@ def test_filter_remove_visual(definition_path: Path) -> None:
def test_filter_clear_removes_all(definition_path: Path) -> None:
"""filter_clear removes every filter and returns the correct count."""
- filter_add_categorical(
- definition_path, "page_overview", "Sales", "Region", ["East"], name="f1"
- )
+ filter_add_categorical(definition_path, "page_overview", "Sales", "Region", ["East"], name="f1")
filter_add_categorical(
definition_path, "page_overview", "Sales", "Product", ["Widget"], name="f2"
)
@@ -319,7 +328,11 @@ def test_filter_clear_empty_page(definition_path: Path) -> None:
def test_filter_clear_visual_scope(definition_path: Path) -> None:
"""filter_clear on a visual uses scope='visual'."""
filter_add_categorical(
- definition_path, "page_overview", "Sales", "Year", ["2024"],
+ definition_path,
+ "page_overview",
+ "Sales",
+ "Year",
+ ["2024"],
visual_name="visual_abc123",
)
result = filter_clear(definition_path, "page_overview", visual_name="visual_abc123")
@@ -346,7 +359,11 @@ def test_multiple_adds_accumulate(definition_path: Path) -> None:
"""Each call to filter_add_categorical appends rather than replaces."""
for i in range(3):
filter_add_categorical(
- definition_path, "page_overview", "Sales", "Region", [f"Region{i}"],
+ definition_path,
+ "page_overview",
+ "Sales",
+ "Region",
+ [f"Region{i}"],
name=f"filter_{i}",
)
result = filter_list(definition_path, "page_overview")
@@ -361,9 +378,13 @@ def test_multiple_adds_accumulate(definition_path: Path) -> None:
def test_filter_add_topn_returns_status(definition_path: Path) -> None:
"""filter_add_topn returns the expected status dict."""
result = filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=3, order_by_table="financials", order_by_column="Sales",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=3,
+ order_by_table="financials",
+ order_by_column="Sales",
)
assert result["status"] == "added"
assert result["type"] == "TopN"
@@ -376,9 +397,13 @@ def test_filter_add_topn_returns_status(definition_path: Path) -> None:
def test_filter_add_topn_persisted(definition_path: Path) -> None:
"""filter_add_topn writes a TopN filter entry to page.json."""
filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=3, order_by_table="financials", order_by_column="Sales",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=3,
+ order_by_table="financials",
+ order_by_column="Sales",
name="topn_test",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@@ -394,9 +419,13 @@ def test_filter_add_topn_persisted(definition_path: Path) -> None:
def test_filter_add_topn_subquery_structure(definition_path: Path) -> None:
"""The TopN filter has the correct Subquery/From/Where structure."""
filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=5, order_by_table="financials", order_by_column="Sales",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=5,
+ order_by_table="financials",
+ order_by_column="Sales",
name="topn_struct",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@@ -419,10 +448,15 @@ def test_filter_add_topn_subquery_structure(definition_path: Path) -> None:
def test_filter_add_topn_direction_bottom(definition_path: Path) -> None:
"""direction='Bottom' produces PBI Direction=1 in the OrderBy."""
filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=3, order_by_table="financials", order_by_column="Profit",
- direction="Bottom", name="topn_bottom",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=3,
+ order_by_table="financials",
+ order_by_column="Profit",
+ direction="Bottom",
+ name="topn_bottom",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@@ -434,9 +468,13 @@ def test_filter_add_topn_invalid_direction(definition_path: Path) -> None:
"""filter_add_topn raises PbiCliError for an unknown direction."""
with pytest.raises(PbiCliError):
filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=3, order_by_table="financials", order_by_column="Sales",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=3,
+ order_by_table="financials",
+ order_by_column="Sales",
direction="Middle",
)
@@ -444,9 +482,13 @@ def test_filter_add_topn_invalid_direction(definition_path: Path) -> None:
def test_filter_add_topn_visual_scope(definition_path: Path) -> None:
"""filter_add_topn adds a visual filter with scope='visual' and no howCreated."""
result = filter_add_topn(
- definition_path, "page_overview",
- table="financials", column="Country",
- n=3, order_by_table="financials", order_by_column="Sales",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Country",
+ n=3,
+ order_by_table="financials",
+ order_by_column="Sales",
visual_name="visual_abc123",
)
assert result["scope"] == "visual"
@@ -465,9 +507,12 @@ def test_filter_add_topn_visual_scope(definition_path: Path) -> None:
def test_filter_add_relative_date_returns_status(definition_path: Path) -> None:
"""filter_add_relative_date returns the expected status dict."""
result = filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=3, time_unit="months",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=3,
+ time_unit="months",
)
assert result["status"] == "added"
assert result["type"] == "RelativeDate"
@@ -479,9 +524,12 @@ def test_filter_add_relative_date_returns_status(definition_path: Path) -> None:
def test_filter_add_relative_date_persisted(definition_path: Path) -> None:
"""filter_add_relative_date writes a RelativeDate entry to page.json."""
filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=3, time_unit="months",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=3,
+ time_unit="months",
name="reldate_test",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
@@ -497,9 +545,12 @@ def test_filter_add_relative_date_persisted(definition_path: Path) -> None:
def test_filter_add_relative_date_between_structure(definition_path: Path) -> None:
"""The RelativeDate filter uses a Between/DateAdd/DateSpan/Now structure."""
filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=3, time_unit="months",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=3,
+ time_unit="months",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@@ -526,9 +577,12 @@ def test_filter_add_relative_date_between_structure(definition_path: Path) -> No
def test_filter_add_relative_date_time_unit_years(definition_path: Path) -> None:
"""time_unit='years' maps to TimeUnit=3 in the DateAdd."""
filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=1, time_unit="years",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=1,
+ time_unit="years",
)
page_json = definition_path / "pages" / "page_overview" / "page.json"
f = _read(page_json)["filterConfig"]["filters"][0]["filter"]
@@ -541,18 +595,24 @@ def test_filter_add_relative_date_invalid_unit(definition_path: Path) -> None:
"""filter_add_relative_date raises PbiCliError for an unknown time_unit."""
with pytest.raises(PbiCliError):
filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=3, time_unit="quarters",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=3,
+ time_unit="quarters",
)
def test_filter_add_relative_date_visual_scope(definition_path: Path) -> None:
"""filter_add_relative_date adds a visual filter with no howCreated key."""
result = filter_add_relative_date(
- definition_path, "page_overview",
- table="financials", column="Date",
- amount=7, time_unit="days",
+ definition_path,
+ "page_overview",
+ table="financials",
+ column="Date",
+ amount=7,
+ time_unit="days",
visual_name="visual_abc123",
)
assert result["scope"] == "visual"
diff --git a/tests/test_format_backend.py b/tests/test_format_backend.py
index cd510b5..ecb02a7 100644
--- a/tests/test_format_backend.py
+++ b/tests/test_format_backend.py
@@ -158,9 +158,7 @@ def report_with_visual(tmp_path: Path) -> Path:
def _read_visual(definition: Path) -> dict[str, Any]:
- path = (
- definition / "pages" / PAGE_NAME / "visuals" / VISUAL_NAME / "visual.json"
- )
+ path = definition / "pages" / PAGE_NAME / "visuals" / VISUAL_NAME / "visual.json"
return json.loads(path.read_text(encoding="utf-8"))
@@ -216,9 +214,7 @@ def test_format_background_gradient_correct_structure(report_with_visual: Path)
data = _read_visual(report_with_visual)
entry = data["visual"]["objects"]["values"][0]
- fill_rule_expr = (
- entry["properties"]["backColor"]["solid"]["color"]["expr"]["FillRule"]
- )
+ fill_rule_expr = entry["properties"]["backColor"]["solid"]["color"]["expr"]["FillRule"]
assert "linearGradient2" in fill_rule_expr["FillRule"]
linear = fill_rule_expr["FillRule"]["linearGradient2"]
assert "min" in linear
@@ -511,7 +507,9 @@ FIELD_UNITS = "Sum(financials.Units Sold)"
def test_format_background_conditional_adds_entry(report_with_visual: Path) -> None:
"""format_background_conditional creates an entry in objects.values."""
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@@ -527,7 +525,9 @@ def test_format_background_conditional_adds_entry(report_with_visual: Path) -> N
def test_format_background_conditional_correct_structure(report_with_visual: Path) -> None:
"""Conditional entry has Conditional.Cases with ComparisonKind and color."""
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@@ -549,7 +549,9 @@ def test_format_background_conditional_correct_structure(report_with_visual: Pat
def test_format_background_conditional_selector_metadata(report_with_visual: Path) -> None:
"""Conditional entry selector.metadata equals the supplied field_query_ref."""
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@@ -567,7 +569,9 @@ def test_format_background_conditional_default_field_query_ref(
) -> None:
"""When field_query_ref is omitted, it defaults to 'Sum(table.column)'."""
result = format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
input_table="financials",
input_column="Units Sold",
threshold=100000,
@@ -580,15 +584,23 @@ def test_format_background_conditional_default_field_query_ref(
def test_format_background_conditional_replaces_existing(report_with_visual: Path) -> None:
"""Applying conditional twice on same field_query_ref replaces the entry."""
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
- input_table="financials", input_column="Units Sold",
- threshold=100000, color_hex="#FF0000",
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
+ input_table="financials",
+ input_column="Units Sold",
+ threshold=100000,
+ color_hex="#FF0000",
field_query_ref=FIELD_UNITS,
)
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
- input_table="financials", input_column="Units Sold",
- threshold=50000, color_hex="#00FF00",
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
+ input_table="financials",
+ input_column="Units Sold",
+ threshold=50000,
+ color_hex="#00FF00",
field_query_ref=FIELD_UNITS,
)
@@ -602,19 +614,22 @@ def test_format_background_conditional_replaces_existing(report_with_visual: Pat
def test_format_background_conditional_comparison_lte(report_with_visual: Path) -> None:
"""comparison='lte' maps to ComparisonKind=5."""
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
- input_table="financials", input_column="Units Sold",
- threshold=10000, color_hex="#AABBCC",
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
+ input_table="financials",
+ input_column="Units Sold",
+ threshold=10000,
+ color_hex="#AABBCC",
comparison="lte",
field_query_ref=FIELD_UNITS,
)
data = _read_visual(report_with_visual)
entry = data["visual"]["objects"]["values"][0]
- kind = (
- entry["properties"]["backColor"]["solid"]["color"]["expr"]
- ["Conditional"]["Cases"][0]["Condition"]["Comparison"]["ComparisonKind"]
- )
+ kind = entry["properties"]["backColor"]["solid"]["color"]["expr"]["Conditional"]["Cases"][0][
+ "Condition"
+ ]["Comparison"]["ComparisonKind"]
assert kind == 5 # lte
@@ -624,8 +639,12 @@ def test_format_background_conditional_invalid_comparison(
"""An unknown comparison string raises PbiCliError."""
with pytest.raises(PbiCliError):
format_background_conditional(
- report_with_visual, PAGE_NAME, VISUAL_NAME,
- input_table="financials", input_column="Units Sold",
- threshold=100, color_hex="#000000",
+ report_with_visual,
+ PAGE_NAME,
+ VISUAL_NAME,
+ input_table="financials",
+ input_column="Units Sold",
+ threshold=100,
+ color_hex="#000000",
comparison="between",
)
diff --git a/tests/test_hardening.py b/tests/test_hardening.py
index f52f846..41b0034 100644
--- a/tests/test_hardening.py
+++ b/tests/test_hardening.py
@@ -27,26 +27,35 @@ def report_with_page(tmp_path: Path) -> Path:
defn = tmp_path / "Test.Report" / "definition"
defn.mkdir(parents=True)
_write(defn / "version.json", {"$schema": "...", "version": "2.0.0"})
- _write(defn / "report.json", {
- "$schema": "...",
- "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
- "layoutOptimization": "None",
- })
- _write(defn / "pages" / "pages.json", {
- "$schema": "...",
- "pageOrder": ["test_page"],
- "activePageName": "test_page",
- })
+ _write(
+ defn / "report.json",
+ {
+ "$schema": "...",
+ "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
+ "layoutOptimization": "None",
+ },
+ )
+ _write(
+ defn / "pages" / "pages.json",
+ {
+ "$schema": "...",
+ "pageOrder": ["test_page"],
+ "activePageName": "test_page",
+ },
+ )
page_dir = defn / "pages" / "test_page"
page_dir.mkdir(parents=True)
- _write(page_dir / "page.json", {
- "$schema": "...",
- "name": "test_page",
- "displayName": "Test Page",
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- })
+ _write(
+ page_dir / "page.json",
+ {
+ "$schema": "...",
+ "name": "test_page",
+ "displayName": "Test Page",
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ },
+ )
(page_dir / "visuals").mkdir()
return defn
@@ -55,12 +64,15 @@ def report_with_page(tmp_path: Path) -> Path:
# Fix #1: Measure detection via role heuristic
# ---------------------------------------------------------------------------
+
class TestMeasureDetection:
def test_value_role_creates_measure_ref(self, report_with_page: Path) -> None:
"""--value bindings should produce Measure references, not Column."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart1")
visual_bind(
- report_with_page, "test_page", "chart1",
+ report_with_page,
+ "test_page",
+ "chart1",
bindings=[{"role": "value", "field": "Sales[Amount]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart1" / "visual.json"
@@ -73,7 +85,9 @@ class TestMeasureDetection:
"""--category bindings should produce Column references."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart2")
visual_bind(
- report_with_page, "test_page", "chart2",
+ report_with_page,
+ "test_page",
+ "chart2",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart2" / "visual.json"
@@ -86,7 +100,9 @@ class TestMeasureDetection:
"""--field on card should be a Measure (Values role is the correct Desktop key)."""
visual_add(report_with_page, "test_page", "card", name="card1")
visual_bind(
- report_with_page, "test_page", "card1",
+ report_with_page,
+ "test_page",
+ "card1",
bindings=[{"role": "field", "field": "Sales[Revenue]"}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "card1" / "visual.json"
@@ -98,7 +114,9 @@ class TestMeasureDetection:
"""Explicit measure=True forces Measure even on category role."""
visual_add(report_with_page, "test_page", "bar_chart", name="chart3")
visual_bind(
- report_with_page, "test_page", "chart3",
+ report_with_page,
+ "test_page",
+ "chart3",
bindings=[{"role": "category", "field": "Sales[Calc]", "measure": True}],
)
vfile = report_with_page / "pages" / "test_page" / "visuals" / "chart3" / "visual.json"
@@ -111,6 +129,7 @@ class TestMeasureDetection:
# Fix #2: visual_bind merges with existing bindings
# ---------------------------------------------------------------------------
+
class TestBindMerge:
def test_second_bind_preserves_first(self, report_with_page: Path) -> None:
"""Calling bind twice should keep all bindings."""
@@ -118,13 +137,17 @@ class TestBindMerge:
# First bind: category
visual_bind(
- report_with_page, "test_page", "merged",
+ report_with_page,
+ "test_page",
+ "merged",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
# Second bind: value
visual_bind(
- report_with_page, "test_page", "merged",
+ report_with_page,
+ "test_page",
+ "merged",
bindings=[{"role": "value", "field": "Sales[Amount]"}],
)
@@ -150,12 +173,15 @@ class TestBindMerge:
# Fix #3: Table names with spaces
# ---------------------------------------------------------------------------
+
class TestFieldRefParsing:
def test_table_with_spaces(self, report_with_page: Path) -> None:
"""Table[Column] notation should work with spaces in table name."""
visual_add(report_with_page, "test_page", "bar_chart", name="spaces")
result = visual_bind(
- report_with_page, "test_page", "spaces",
+ report_with_page,
+ "test_page",
+ "spaces",
bindings=[{"role": "category", "field": "Sales Table[Region Name]"}],
)
assert result["bindings"][0]["query_ref"] == "Sales Table.Region Name"
@@ -164,7 +190,9 @@ class TestFieldRefParsing:
"""Standard Table[Column] still works."""
visual_add(report_with_page, "test_page", "bar_chart", name="simple")
result = visual_bind(
- report_with_page, "test_page", "simple",
+ report_with_page,
+ "test_page",
+ "simple",
bindings=[{"role": "category", "field": "Date[Year]"}],
)
assert result["bindings"][0]["query_ref"] == "Date.Year"
@@ -174,7 +202,9 @@ class TestFieldRefParsing:
visual_add(report_with_page, "test_page", "card", name="bad")
with pytest.raises(PbiCliError, match="Table\\[Column\\]"):
visual_bind(
- report_with_page, "test_page", "bad",
+ report_with_page,
+ "test_page",
+ "bad",
bindings=[{"role": "field", "field": "JustAName"}],
)
@@ -183,6 +213,7 @@ class TestFieldRefParsing:
# Fix #4: _find_from_pbip guard
# ---------------------------------------------------------------------------
+
class TestPbipGuard:
def test_nonexistent_dir_returns_none(self, tmp_path: Path) -> None:
result = _find_from_pbip(tmp_path / "does_not_exist")
@@ -199,6 +230,7 @@ class TestPbipGuard:
# Fix #9: report_convert overwrite guard
# ---------------------------------------------------------------------------
+
class TestConvertGuard:
def test_convert_blocks_overwrite(self, tmp_path: Path) -> None:
"""Second convert without --force should raise."""
diff --git a/tests/test_pbir_path.py b/tests/test_pbir_path.py
index ddb3210..1c83f8d 100644
--- a/tests/test_pbir_path.py
+++ b/tests/test_pbir_path.py
@@ -21,15 +21,19 @@ from pbi_cli.core.pbir_path import (
# Helpers
# ---------------------------------------------------------------------------
-_REPORT_JSON = json.dumps({
- "$schema": "https://developer.microsoft.com/json-schemas/"
- "fabric/item/report/definition/report/1.0.0/schema.json"
-})
-_VERSION_JSON = json.dumps({
- "$schema": "https://developer.microsoft.com/json-schemas/"
- "fabric/item/report/definition/version/1.0.0/schema.json",
- "version": "1.0.0",
-})
+_REPORT_JSON = json.dumps(
+ {
+ "$schema": "https://developer.microsoft.com/json-schemas/"
+ "fabric/item/report/definition/report/1.0.0/schema.json"
+ }
+)
+_VERSION_JSON = json.dumps(
+ {
+ "$schema": "https://developer.microsoft.com/json-schemas/"
+ "fabric/item/report/definition/version/1.0.0/schema.json",
+ "version": "1.0.0",
+ }
+)
def scaffold_valid_pbir(root: Path, report_name: str = "MyReport") -> Path:
diff --git a/tests/test_pbir_validators.py b/tests/test_pbir_validators.py
index 1fd068e..e515d3a 100644
--- a/tests/test_pbir_validators.py
+++ b/tests/test_pbir_validators.py
@@ -25,36 +25,48 @@ def valid_report(tmp_path: Path) -> Path:
defn.mkdir(parents=True)
_write(defn / "version.json", {"$schema": "...", "version": "1.0.0"})
- _write(defn / "report.json", {
- "$schema": "...",
- "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ defn / "report.json",
+ {
+ "$schema": "...",
+ "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
+ "layoutOptimization": "Disabled",
+ },
+ )
page_dir = defn / "pages" / "page1"
page_dir.mkdir(parents=True)
- _write(page_dir / "page.json", {
- "$schema": "...",
- "name": "page1",
- "displayName": "Page One",
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- "ordinal": 0,
- })
- _write(defn / "pages" / "pages.json", {
- "$schema": "...",
- "pageOrder": ["page1"],
- })
+ _write(
+ page_dir / "page.json",
+ {
+ "$schema": "...",
+ "name": "page1",
+ "displayName": "Page One",
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ "ordinal": 0,
+ },
+ )
+ _write(
+ defn / "pages" / "pages.json",
+ {
+ "$schema": "...",
+ "pageOrder": ["page1"],
+ },
+ )
vis_dir = page_dir / "visuals" / "vis1"
vis_dir.mkdir(parents=True)
- _write(vis_dir / "visual.json", {
- "$schema": "...",
- "name": "vis1",
- "position": {"x": 0, "y": 0, "width": 400, "height": 300},
- "visual": {"visualType": "barChart", "query": {}, "objects": {}},
- })
+ _write(
+ vis_dir / "visual.json",
+ {
+ "$schema": "...",
+ "name": "vis1",
+ "position": {"x": 0, "y": 0, "width": 400, "height": 300},
+ "visual": {"visualType": "barChart", "query": {}, "objects": {}},
+ },
+ )
return defn
@@ -92,63 +104,81 @@ class TestValidateReportFull:
assert any("Invalid JSON" in e["message"] for e in result["errors"])
def test_missing_theme_collection(self, valid_report: Path) -> None:
- _write(valid_report / "report.json", {
- "$schema": "...",
- "layoutOptimization": "Disabled",
- })
+ _write(
+ valid_report / "report.json",
+ {
+ "$schema": "...",
+ "layoutOptimization": "Disabled",
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("themeCollection" in e["message"] for e in result["errors"])
def test_missing_layout_optimization(self, valid_report: Path) -> None:
- _write(valid_report / "report.json", {
- "$schema": "...",
- "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
- })
+ _write(
+ valid_report / "report.json",
+ {
+ "$schema": "...",
+ "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("layoutOptimization" in e["message"] for e in result["errors"])
def test_page_missing_required_fields(self, valid_report: Path) -> None:
- _write(valid_report / "pages" / "page1" / "page.json", {
- "$schema": "...",
- "name": "page1",
- })
+ _write(
+ valid_report / "pages" / "page1" / "page.json",
+ {
+ "$schema": "...",
+ "name": "page1",
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("displayName" in e["message"] for e in result["errors"])
assert any("displayOption" in e["message"] for e in result["errors"])
def test_page_invalid_display_option(self, valid_report: Path) -> None:
- _write(valid_report / "pages" / "page1" / "page.json", {
- "$schema": "...",
- "name": "page1",
- "displayName": "P1",
- "displayOption": "InvalidOption",
- "width": 1280,
- "height": 720,
- })
+ _write(
+ valid_report / "pages" / "page1" / "page.json",
+ {
+ "$schema": "...",
+ "name": "page1",
+ "displayName": "P1",
+ "displayOption": "InvalidOption",
+ "width": 1280,
+ "height": 720,
+ },
+ )
result = validate_report_full(valid_report)
assert any("Unknown displayOption" in w["message"] for w in result["warnings"])
def test_visual_missing_position(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
- _write(vis_path, {
- "$schema": "...",
- "name": "vis1",
- "visual": {"visualType": "barChart"},
- })
+ _write(
+ vis_path,
+ {
+ "$schema": "...",
+ "name": "vis1",
+ "visual": {"visualType": "barChart"},
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("position" in e["message"] for e in result["errors"])
def test_visual_missing_name(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
- _write(vis_path, {
- "$schema": "...",
- "position": {"x": 0, "y": 0, "width": 100, "height": 100},
- "visual": {"visualType": "card"},
- })
+ _write(
+ vis_path,
+ {
+ "$schema": "...",
+ "position": {"x": 0, "y": 0, "width": 100, "height": 100},
+ "visual": {"visualType": "card"},
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("name" in e["message"] for e in result["errors"])
@@ -156,24 +186,30 @@ class TestValidateReportFull:
class TestPageOrderConsistency:
def test_phantom_page_in_order(self, valid_report: Path) -> None:
- _write(valid_report / "pages" / "pages.json", {
- "$schema": "...",
- "pageOrder": ["page1", "ghost_page"],
- })
+ _write(
+ valid_report / "pages" / "pages.json",
+ {
+ "$schema": "...",
+ "pageOrder": ["page1", "ghost_page"],
+ },
+ )
result = validate_report_full(valid_report)
assert any("ghost_page" in w["message"] for w in result["warnings"])
def test_unlisted_page_info(self, valid_report: Path) -> None:
page2 = valid_report / "pages" / "page2"
page2.mkdir(parents=True)
- _write(page2 / "page.json", {
- "$schema": "...",
- "name": "page2",
- "displayName": "Page Two",
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- })
+ _write(
+ page2 / "page.json",
+ {
+ "$schema": "...",
+ "name": "page2",
+ "displayName": "Page Two",
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ },
+ )
result = validate_report_full(valid_report)
assert any("page2" in i["message"] and "not listed" in i["message"] for i in result["info"])
@@ -182,12 +218,15 @@ class TestVisualNameUniqueness:
def test_duplicate_visual_names(self, valid_report: Path) -> None:
vis2_dir = valid_report / "pages" / "page1" / "visuals" / "vis2"
vis2_dir.mkdir(parents=True)
- _write(vis2_dir / "visual.json", {
- "$schema": "...",
- "name": "vis1", # Duplicate of vis1
- "position": {"x": 0, "y": 0, "width": 100, "height": 100},
- "visual": {"visualType": "card"},
- })
+ _write(
+ vis2_dir / "visual.json",
+ {
+ "$schema": "...",
+ "name": "vis1", # Duplicate of vis1
+ "position": {"x": 0, "y": 0, "width": 100, "height": 100},
+ "visual": {"visualType": "card"},
+ },
+ )
result = validate_report_full(valid_report)
assert result["valid"] is False
assert any("Duplicate visual name" in e["message"] for e in result["errors"])
@@ -196,62 +235,76 @@ class TestVisualNameUniqueness:
class TestBindingsAgainstModel:
def test_valid_binding_passes(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
- _write(vis_path, {
- "$schema": "...",
- "name": "vis1",
- "position": {"x": 0, "y": 0, "width": 400, "height": 300},
- "visual": {
- "visualType": "barChart",
- "query": {
- "Commands": [{
- "SemanticQueryDataShapeCommand": {
- "Query": {
- "Version": 2,
- "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
- "Select": [{
- "Column": {
- "Expression": {"SourceRef": {"Source": "s"}},
- "Property": "Region",
- },
- "Name": "s.Region",
- }],
+ _write(
+ vis_path,
+ {
+ "$schema": "...",
+ "name": "vis1",
+ "position": {"x": 0, "y": 0, "width": 400, "height": 300},
+ "visual": {
+ "visualType": "barChart",
+ "query": {
+ "Commands": [
+ {
+ "SemanticQueryDataShapeCommand": {
+ "Query": {
+ "Version": 2,
+ "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
+ "Select": [
+ {
+ "Column": {
+ "Expression": {"SourceRef": {"Source": "s"}},
+ "Property": "Region",
+ },
+ "Name": "s.Region",
+ }
+ ],
+ }
+ }
}
- }
- }],
+ ],
+ },
},
},
- })
+ )
model = [{"name": "Sales", "columns": [{"name": "Region"}], "measures": []}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 0
def test_invalid_binding_warns(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
- _write(vis_path, {
- "$schema": "...",
- "name": "vis1",
- "position": {"x": 0, "y": 0, "width": 400, "height": 300},
- "visual": {
- "visualType": "barChart",
- "query": {
- "Commands": [{
- "SemanticQueryDataShapeCommand": {
- "Query": {
- "Version": 2,
- "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
- "Select": [{
- "Column": {
- "Expression": {"SourceRef": {"Source": "s"}},
- "Property": "NonExistent",
- },
- "Name": "s.NonExistent",
- }],
+ _write(
+ vis_path,
+ {
+ "$schema": "...",
+ "name": "vis1",
+ "position": {"x": 0, "y": 0, "width": 400, "height": 300},
+ "visual": {
+ "visualType": "barChart",
+ "query": {
+ "Commands": [
+ {
+ "SemanticQueryDataShapeCommand": {
+ "Query": {
+ "Version": 2,
+ "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
+ "Select": [
+ {
+ "Column": {
+ "Expression": {"SourceRef": {"Source": "s"}},
+ "Property": "NonExistent",
+ },
+ "Name": "s.NonExistent",
+ }
+ ],
+ }
+ }
}
- }
- }],
+ ],
+ },
},
},
- })
+ )
model = [{"name": "Sales", "columns": [{"name": "Region"}], "measures": []}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 1
@@ -260,31 +313,38 @@ class TestBindingsAgainstModel:
def test_measure_binding(self, valid_report: Path) -> None:
vis_path = valid_report / "pages" / "page1" / "visuals" / "vis1" / "visual.json"
- _write(vis_path, {
- "$schema": "...",
- "name": "vis1",
- "position": {"x": 0, "y": 0, "width": 400, "height": 300},
- "visual": {
- "visualType": "card",
- "query": {
- "Commands": [{
- "SemanticQueryDataShapeCommand": {
- "Query": {
- "Version": 2,
- "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
- "Select": [{
- "Measure": {
- "Expression": {"SourceRef": {"Source": "s"}},
- "Property": "Total Revenue",
- },
- "Name": "s.Total Revenue",
- }],
+ _write(
+ vis_path,
+ {
+ "$schema": "...",
+ "name": "vis1",
+ "position": {"x": 0, "y": 0, "width": 400, "height": 300},
+ "visual": {
+ "visualType": "card",
+ "query": {
+ "Commands": [
+ {
+ "SemanticQueryDataShapeCommand": {
+ "Query": {
+ "Version": 2,
+ "From": [{"Name": "s", "Entity": "Sales", "Type": 0}],
+ "Select": [
+ {
+ "Measure": {
+ "Expression": {"SourceRef": {"Source": "s"}},
+ "Property": "Total Revenue",
+ },
+ "Name": "s.Total Revenue",
+ }
+ ],
+ }
+ }
}
- }
- }],
+ ],
+ },
},
},
- })
+ )
model = [{"name": "Sales", "columns": [], "measures": [{"name": "Total Revenue"}]}]
findings = validate_bindings_against_model(valid_report, model)
assert len(findings) == 0
diff --git a/tests/test_preview.py b/tests/test_preview.py
index 9180b21..10da76e 100644
--- a/tests/test_preview.py
+++ b/tests/test_preview.py
@@ -24,63 +24,78 @@ def preview_report(tmp_path: Path) -> Path:
defn = tmp_path / "Test.Report" / "definition"
defn.mkdir(parents=True)
- _write(defn / "report.json", {
- "$schema": "...",
- "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ defn / "report.json",
+ {
+ "$schema": "...",
+ "themeCollection": {"baseTheme": {"name": "CY24SU06"}},
+ "layoutOptimization": "Disabled",
+ },
+ )
_write(defn / "version.json", {"$schema": "...", "version": "1.0.0"})
- _write(defn / "pages" / "pages.json", {
- "$schema": "...",
- "pageOrder": ["overview"],
- })
+ _write(
+ defn / "pages" / "pages.json",
+ {
+ "$schema": "...",
+ "pageOrder": ["overview"],
+ },
+ )
page_dir = defn / "pages" / "overview"
page_dir.mkdir(parents=True)
- _write(page_dir / "page.json", {
- "$schema": "...",
- "name": "overview",
- "displayName": "Executive Overview",
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- "ordinal": 0,
- })
+ _write(
+ page_dir / "page.json",
+ {
+ "$schema": "...",
+ "name": "overview",
+ "displayName": "Executive Overview",
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ "ordinal": 0,
+ },
+ )
# Bar chart visual
bar_dir = page_dir / "visuals" / "bar1"
bar_dir.mkdir(parents=True)
- _write(bar_dir / "visual.json", {
- "$schema": "...",
- "name": "bar1",
- "position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0},
- "visual": {
- "visualType": "barChart",
- "query": {
- "queryState": {
- "Category": {"projections": [{"queryRef": "g.Region", "field": {}}]},
- "Y": {"projections": [{"queryRef": "s.Amount", "field": {}}]},
+ _write(
+ bar_dir / "visual.json",
+ {
+ "$schema": "...",
+ "name": "bar1",
+ "position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0},
+ "visual": {
+ "visualType": "barChart",
+ "query": {
+ "queryState": {
+ "Category": {"projections": [{"queryRef": "g.Region", "field": {}}]},
+ "Y": {"projections": [{"queryRef": "s.Amount", "field": {}}]},
+ },
},
},
},
- })
+ )
# Card visual
card_dir = page_dir / "visuals" / "card1"
card_dir.mkdir(parents=True)
- _write(card_dir / "visual.json", {
- "$schema": "...",
- "name": "card1",
- "position": {"x": 500, "y": 50, "width": 200, "height": 120, "z": 1},
- "visual": {
- "visualType": "card",
- "query": {
- "queryState": {
- "Fields": {"projections": [{"queryRef": "s.Revenue", "field": {}}]},
+ _write(
+ card_dir / "visual.json",
+ {
+ "$schema": "...",
+ "name": "card1",
+ "position": {"x": 500, "y": 50, "width": 200, "height": 120, "z": 1},
+ "visual": {
+ "visualType": "card",
+ "query": {
+ "queryState": {
+ "Fields": {"projections": [{"queryRef": "s.Revenue", "field": {}}]},
+ },
},
},
},
- })
+ )
return defn
@@ -122,11 +137,14 @@ class TestRenderReport:
def test_empty_report(self, tmp_path: Path) -> None:
defn = tmp_path / "Empty.Report" / "definition"
defn.mkdir(parents=True)
- _write(defn / "report.json", {
- "$schema": "...",
- "themeCollection": {"baseTheme": {"name": "Default"}},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ defn / "report.json",
+ {
+ "$schema": "...",
+ "themeCollection": {"baseTheme": {"name": "Default"}},
+ "layoutOptimization": "Disabled",
+ },
+ )
html = render_report(defn)
assert "No pages" in html
diff --git a/tests/test_report_backend.py b/tests/test_report_backend.py
index b462dce..2269219 100644
--- a/tests/test_report_backend.py
+++ b/tests/test_report_backend.py
@@ -87,15 +87,18 @@ def _make_page(
page_dir = pages_dir / page_name
page_dir.mkdir(parents=True, exist_ok=True)
- _write(page_dir / "page.json", {
- "$schema": _SCHEMA_PAGE,
- "name": page_name,
- "displayName": display_name,
- "displayOption": "FitToPage",
- "width": 1280,
- "height": 720,
- "ordinal": ordinal,
- })
+ _write(
+ page_dir / "page.json",
+ {
+ "$schema": _SCHEMA_PAGE,
+ "name": page_name,
+ "displayName": display_name,
+ "displayOption": "FitToPage",
+ "width": 1280,
+ "height": 720,
+ "ordinal": ordinal,
+ },
+ )
visuals_dir = page_dir / "visuals"
visuals_dir.mkdir(exist_ok=True)
@@ -103,22 +106,25 @@ def _make_page(
if with_visual:
visual_dir = visuals_dir / "visual_def456"
visual_dir.mkdir()
- _write(visual_dir / "visual.json", {
- "$schema": _SCHEMA_VISUAL_CONTAINER,
- "name": "vis1",
- "position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
- "visual": {
- "$schema": _SCHEMA_VISUAL_CONFIG,
- "visualType": "barChart",
- "query": {
- "queryState": {
- "Category": {"projections": []},
- "Y": {"projections": []},
+ _write(
+ visual_dir / "visual.json",
+ {
+ "$schema": _SCHEMA_VISUAL_CONTAINER,
+ "name": "vis1",
+ "position": {"x": 50, "y": 50, "width": 400, "height": 300, "z": 0, "tabOrder": 0},
+ "visual": {
+ "$schema": _SCHEMA_VISUAL_CONFIG,
+ "visualType": "barChart",
+ "query": {
+ "queryState": {
+ "Category": {"projections": []},
+ "Y": {"projections": []},
+ },
},
+ "objects": {},
},
- "objects": {},
},
- })
+ )
# ---------------------------------------------------------------------------
@@ -151,38 +157,50 @@ def sample_report(tmp_path: Path) -> Path:
pages_dir.mkdir(parents=True)
# version.json
- _write(definition_dir / "version.json", {
- "$schema": _SCHEMA_VERSION,
- "version": "1.0.0",
- })
+ _write(
+ definition_dir / "version.json",
+ {
+ "$schema": _SCHEMA_VERSION,
+ "version": "1.0.0",
+ },
+ )
# report.json
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {
- "baseTheme": {
- "name": "CY24SU06",
- "reportVersionAtImport": "5.55",
- "type": "SharedResources",
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {
+ "baseTheme": {
+ "name": "CY24SU06",
+ "reportVersionAtImport": "5.55",
+ "type": "SharedResources",
+ },
},
+ "layoutOptimization": "Disabled",
},
- "layoutOptimization": "Disabled",
- })
+ )
# pages.json
- _write(pages_dir / "pages.json", {
- "$schema": _SCHEMA_PAGES_METADATA,
- "pageOrder": ["page1"],
- })
+ _write(
+ pages_dir / "pages.json",
+ {
+ "$schema": _SCHEMA_PAGES_METADATA,
+ "pageOrder": ["page1"],
+ },
+ )
# definition.pbir
- _write(report_folder / "definition.pbir", {
- "$schema": (
- "https://developer.microsoft.com/json-schemas/"
- "fabric/item/report/definitionProperties/2.0.0/schema.json"
- ),
- "version": "4.0",
- })
+ _write(
+ report_folder / "definition.pbir",
+ {
+ "$schema": (
+ "https://developer.microsoft.com/json-schemas/"
+ "fabric/item/report/definitionProperties/2.0.0/schema.json"
+ ),
+ "version": "4.0",
+ },
+ )
# Page with one visual
_make_page(pages_dir, "page1", "Page One", ordinal=0, with_visual=True)
@@ -227,15 +245,20 @@ class TestReportInfo:
"""A report with no pages directory returns zero counts."""
definition_dir = tmp_path / "Empty.Report" / "definition"
definition_dir.mkdir(parents=True)
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {
- "baseTheme": {
- "name": "CY24SU06", "reportVersionAtImport": "5.55", "type": "SharedResources"
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {
+ "baseTheme": {
+ "name": "CY24SU06",
+ "reportVersionAtImport": "5.55",
+ "type": "SharedResources",
+ },
},
+ "layoutOptimization": "Disabled",
},
- "layoutOptimization": "Disabled",
- })
+ )
result = report_info(definition_dir)
assert result["page_count"] == 0
@@ -261,10 +284,13 @@ class TestReportInfo:
"""Returns 'Default' when themeCollection is absent from report.json."""
definition_dir = tmp_path / "Bare.Report" / "definition"
definition_dir.mkdir(parents=True)
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "layoutOptimization": "Disabled",
- })
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "layoutOptimization": "Disabled",
+ },
+ )
result = report_info(definition_dir)
assert result["theme"] == "Default"
@@ -319,8 +345,7 @@ class TestReportCreate:
data = _read(pbip_file)
assert data["version"] == "1.0"
assert any(
- a.get("report", {}).get("path") == "SalesReport.Report"
- for a in data["artifacts"]
+ a.get("report", {}).get("path") == "SalesReport.Report" for a in data["artifacts"]
)
def test_report_create_returns_definition_path(self, tmp_path: Path) -> None:
@@ -382,11 +407,14 @@ class TestReportValidate:
"""Validation fails when version.json is absent."""
definition_dir = tmp_path / "NoVer.Report" / "definition"
definition_dir.mkdir(parents=True)
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {"baseTheme": {}},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {"baseTheme": {}},
+ "layoutOptimization": "Disabled",
+ },
+ )
result = report_validate(definition_dir)
assert result["valid"] is False
@@ -402,10 +430,13 @@ class TestReportValidate:
def test_report_validate_missing_theme_collection(self, sample_report: Path) -> None:
"""report.json without 'themeCollection' is invalid."""
- _write(sample_report / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "layoutOptimization": "Disabled",
- })
+ _write(
+ sample_report / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "layoutOptimization": "Disabled",
+ },
+ )
result = report_validate(sample_report)
assert result["valid"] is False
@@ -413,10 +444,13 @@ class TestReportValidate:
def test_report_validate_missing_layout_optimization(self, sample_report: Path) -> None:
"""report.json without 'layoutOptimization' is invalid."""
- _write(sample_report / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {"baseTheme": {}},
- })
+ _write(
+ sample_report / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {"baseTheme": {}},
+ },
+ )
result = report_validate(sample_report)
assert result["valid"] is False
@@ -468,11 +502,14 @@ class TestPageList:
"""Returns empty list when the pages directory does not exist."""
definition_dir = tmp_path / "NoPages.Report" / "definition"
definition_dir.mkdir(parents=True)
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {"baseTheme": {}},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {"baseTheme": {}},
+ "layoutOptimization": "Disabled",
+ },
+ )
result = page_list(definition_dir)
assert result == []
@@ -492,10 +529,13 @@ class TestPageList:
pages_dir = sample_report / "pages"
_make_page(pages_dir, "page2", "Page Two", ordinal=1, with_visual=False)
# Set page2 first in the explicit order
- _write(pages_dir / "pages.json", {
- "$schema": _SCHEMA_PAGES_METADATA,
- "pageOrder": ["page2", "page1"],
- })
+ _write(
+ pages_dir / "pages.json",
+ {
+ "$schema": _SCHEMA_PAGES_METADATA,
+ "pageOrder": ["page2", "page1"],
+ },
+ )
result = page_list(sample_report)
assert result[0]["name"] == "page2"
@@ -519,20 +559,21 @@ class TestPageList:
# Add a second visual to page2
second_visual = pages_dir / "page2" / "visuals" / "visual_second"
second_visual.mkdir()
- _write(second_visual / "visual.json", {
- "$schema": _SCHEMA_VISUAL_CONTAINER,
- "name": "vis2",
- "position": {"x": 0, "y": 0, "width": 200, "height": 200, "z": 1, "tabOrder": 1},
- "visual": {"$schema": _SCHEMA_VISUAL_CONFIG, "visualType": "card", "objects": {}},
- })
+ _write(
+ second_visual / "visual.json",
+ {
+ "$schema": _SCHEMA_VISUAL_CONTAINER,
+ "name": "vis2",
+ "position": {"x": 0, "y": 0, "width": 200, "height": 200, "z": 1, "tabOrder": 1},
+ "visual": {"$schema": _SCHEMA_VISUAL_CONFIG, "visualType": "card", "objects": {}},
+ },
+ )
result = page_list(sample_report)
page2 = next(p for p in result if p["name"] == "page2")
assert page2["visual_count"] == 2
- def test_page_list_regular_page_type_is_default(
- self, sample_report: Path
- ) -> None:
+ def test_page_list_regular_page_type_is_default(self, sample_report: Path) -> None:
"""Regular pages (no type field) surface as page_type='Default'."""
pages = page_list(sample_report)
assert pages[0]["page_type"] == "Default"
@@ -579,9 +620,7 @@ class TestPageAdd:
assert data["height"] == 1080
def test_page_add_respects_display_option(self, sample_report: Path) -> None:
- page_add(
- sample_report, "Actual Size", name="actual_page", display_option="ActualSize"
- )
+ page_add(sample_report, "Actual Size", name="actual_page", display_option="ActualSize")
data = _read(sample_report / "pages" / "actual_page" / "page.json")
assert data["displayOption"] == "ActualSize"
@@ -725,9 +764,7 @@ class TestPageGet:
def test_page_get_surfaces_filter_config(self, sample_report: Path) -> None:
"""page_get returns filterConfig as-is when present."""
- filter_config = {
- "filters": [{"name": "Filter1", "type": "Categorical"}]
- }
+ filter_config = {"filters": [{"name": "Filter1", "type": "Categorical"}]}
page_json = sample_report / "pages" / "page1" / "page.json"
data = _read(page_json)
_write(page_json, {**data, "filterConfig": filter_config})
@@ -737,9 +774,7 @@ class TestPageGet:
def test_page_get_surfaces_visual_interactions(self, sample_report: Path) -> None:
"""page_get returns visualInteractions as-is when present."""
- interactions = [
- {"source": "visual_abc", "target": "visual_def", "type": "NoFilter"}
- ]
+ interactions = [{"source": "visual_abc", "target": "visual_def", "type": "NoFilter"}]
page_json = sample_report / "pages" / "page1" / "page.json"
data = _read(page_json)
_write(page_json, {**data, "visualInteractions": interactions})
@@ -747,9 +782,7 @@ class TestPageGet:
assert result["visual_interactions"] == interactions
assert result["visual_interactions"][0]["type"] == "NoFilter"
- def test_page_get_page_binding_none_for_regular_page(
- self, sample_report: Path
- ) -> None:
+ def test_page_get_page_binding_none_for_regular_page(self, sample_report: Path) -> None:
"""Regular pages have no pageBinding -- returns None."""
result = page_get(sample_report, "page1")
assert result["page_binding"] is None
@@ -783,23 +816,22 @@ class TestThemeSet:
def _make_theme_file(self, tmp_path: Path, name: str = "MyTheme") -> Path:
"""Create a minimal theme JSON file and return its path."""
theme_file = tmp_path / f"{name}.json"
- _write(theme_file, {
- "name": name,
- "dataColors": ["#118DFF", "#12239E"],
- "background": "#FFFFFF",
- })
+ _write(
+ theme_file,
+ {
+ "name": name,
+ "dataColors": ["#118DFF", "#12239E"],
+ "background": "#FFFFFF",
+ },
+ )
return theme_file
- def test_theme_set_returns_applied_status(
- self, sample_report: Path, tmp_path: Path
- ) -> None:
+ def test_theme_set_returns_applied_status(self, sample_report: Path, tmp_path: Path) -> None:
theme_file = self._make_theme_file(tmp_path)
result = theme_set(sample_report, theme_file)
assert result["status"] == "applied"
- def test_theme_set_returns_theme_name(
- self, sample_report: Path, tmp_path: Path
- ) -> None:
+ def test_theme_set_returns_theme_name(self, sample_report: Path, tmp_path: Path) -> None:
theme_file = self._make_theme_file(tmp_path, name="CorporateBlue")
result = theme_set(sample_report, theme_file)
assert result["theme"] == "CorporateBlue"
@@ -820,9 +852,7 @@ class TestThemeSet:
dest_data = _read(Path(result["file"]))
assert dest_data["name"] == "BrightTheme"
- def test_theme_set_updates_report_json(
- self, sample_report: Path, tmp_path: Path
- ) -> None:
+ def test_theme_set_updates_report_json(self, sample_report: Path, tmp_path: Path) -> None:
"""report.json must have a 'customTheme' entry after theme_set."""
theme_file = self._make_theme_file(tmp_path, name="Teal")
theme_set(sample_report, theme_file)
@@ -847,9 +877,7 @@ class TestThemeSet:
items = reg.get("items", [])
assert any(i["name"] == "Ocean.json" for i in items)
- def test_theme_set_idempotent_for_same_theme(
- self, sample_report: Path, tmp_path: Path
- ) -> None:
+ def test_theme_set_idempotent_for_same_theme(self, sample_report: Path, tmp_path: Path) -> None:
"""Applying the same theme twice does not duplicate resource entries."""
theme_file = self._make_theme_file(tmp_path, name="Stable")
theme_set(sample_report, theme_file)
@@ -862,9 +890,7 @@ class TestThemeSet:
# No duplicate entries for the same file
assert names.count("Stable.json") == 1
- def test_theme_set_missing_theme_file_raises(
- self, sample_report: Path, tmp_path: Path
- ) -> None:
+ def test_theme_set_missing_theme_file_raises(self, sample_report: Path, tmp_path: Path) -> None:
"""Referencing a theme file that does not exist raises PbiCliError."""
missing = tmp_path / "ghost_theme.json"
with pytest.raises(PbiCliError, match="ghost_theme.json"):
@@ -917,11 +943,14 @@ class TestThemeGet:
"""If themeCollection has no baseTheme, base_theme is an empty string."""
definition_dir = tmp_path / "NoBase.Report" / "definition"
definition_dir.mkdir(parents=True)
- _write(definition_dir / "report.json", {
- "$schema": _SCHEMA_REPORT,
- "themeCollection": {},
- "layoutOptimization": "Disabled",
- })
+ _write(
+ definition_dir / "report.json",
+ {
+ "$schema": _SCHEMA_REPORT,
+ "themeCollection": {},
+ "layoutOptimization": "Disabled",
+ },
+ )
result = theme_get(definition_dir)
assert result["base_theme"] == ""
assert result["custom_theme"] is None
diff --git a/tests/test_skill_triggering.py b/tests/test_skill_triggering.py
index 780228e..42b82a5 100644
--- a/tests/test_skill_triggering.py
+++ b/tests/test_skill_triggering.py
@@ -12,8 +12,6 @@ from __future__ import annotations
import importlib.resources
import re
-import yaml
-
def _load_skills() -> dict[str, str]:
"""Load all skill names and descriptions from bundled skills."""
@@ -24,8 +22,18 @@ def _load_skills() -> dict[str, str]:
content = (item / "SKILL.md").read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if match:
- fm = yaml.safe_load(match.group(1))
- skills[item.name] = fm.get("description", "").lower()
+ # Parse description from YAML frontmatter without pyyaml
+ # Handles both single-line and multi-line (>) formats
+ fm_text = match.group(1)
+ multi = re.search(r"description:\s*>?\s*\n((?:\s+.*\n)*)", fm_text)
+ single = re.search(r"description:\s+(.+)", fm_text)
+ if multi and multi.group(1).strip():
+ desc = " ".join(
+ line.strip() for line in multi.group(1).splitlines() if line.strip()
+ )
+ skills[item.name] = desc.lower()
+ elif single:
+ skills[item.name] = single.group(1).strip().lower()
return skills
diff --git a/tests/test_tmdl_diff.py b/tests/test_tmdl_diff.py
index 0e49355..ea1f934 100644
--- a/tests/test_tmdl_diff.py
+++ b/tests/test_tmdl_diff.py
@@ -81,10 +81,7 @@ _TOTAL_REVENUE_BLOCK = (
"\n\t\tlineageTag: msr-001\n"
)
_NEW_COL_SNIPPET = (
- "\n\tcolumn Region"
- "\n\t\tdataType: string"
- "\n\t\tsummarizeBy: none"
- "\n\t\tsourceColumn: Region\n"
+ "\n\tcolumn Region\n\t\tdataType: string\n\t\tsummarizeBy: none\n\t\tsourceColumn: Region\n"
)
_AMOUNT_COL_BLOCK = (
"\n\tcolumn Amount"
@@ -94,19 +91,13 @@ _AMOUNT_COL_BLOCK = (
"\n\t\tsourceColumn: Amount\n"
)
_NEW_REL_SNIPPET = (
- "\nrelationship abc-def-999"
- "\n\tfromColumn: Sales.RegionID"
- "\n\ttoColumn: Region.ID\n"
+ "\nrelationship abc-def-999\n\tfromColumn: Sales.RegionID\n\ttoColumn: Region.ID\n"
)
_TRIMMED_RELS = (
- "relationship abc-def-111"
- "\n\tfromColumn: Sales.ProductID"
- "\n\ttoColumn: Product.ProductID\n"
+ "relationship abc-def-111\n\tfromColumn: Sales.ProductID\n\ttoColumn: Product.ProductID\n"
)
_REL_222_BASE = (
- "relationship abc-def-222"
- "\n\tfromColumn: Sales.CustomerID"
- "\n\ttoColumn: Customer.CustomerID"
+ "relationship abc-def-222\n\tfromColumn: Sales.CustomerID\n\ttoColumn: Customer.CustomerID"
)
_REL_222_CHANGED = (
"relationship abc-def-222"
diff --git a/tests/test_visual_backend.py b/tests/test_visual_backend.py
index 0460dde..cbcb7e0 100644
--- a/tests/test_visual_backend.py
+++ b/tests/test_visual_backend.py
@@ -171,9 +171,7 @@ def test_visual_add_table(report_with_page: Path) -> None:
assert result["status"] == "created"
assert result["visual_type"] == "tableEx"
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / "mytable" / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "mytable" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == "tableEx"
@@ -185,9 +183,7 @@ def test_visual_add_matrix(report_with_page: Path) -> None:
assert result["status"] == "created"
assert result["visual_type"] == "pivotTable"
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / "mymatrix" / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "mymatrix" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == "pivotTable"
@@ -215,14 +211,7 @@ def test_visual_add_custom_position(report_with_page: Path) -> None:
assert result["width"] == 600.0
assert result["height"] == 450.0
- vfile = (
- report_with_page
- / "pages"
- / "test_page"
- / "visuals"
- / "positioned"
- / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "positioned" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"]
assert pos["x"] == 100.0
@@ -374,9 +363,7 @@ def test_visual_update_position(report_with_page: Path) -> None:
assert result["position"]["height"] == 250.0
# Confirm the file on disk reflects the change
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "movable" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
pos = data["position"]
assert pos["x"] == 50.0
@@ -470,14 +457,7 @@ def test_visual_bind_category_value(report_with_page: Path) -> None:
assert "Y" in roles_applied
# Verify the projections were written into visual.json
- vfile = (
- report_with_page
- / "pages"
- / "test_page"
- / "visuals"
- / "bound_bar"
- / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "bound_bar" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
query_state = data["visual"]["query"]["queryState"]
@@ -518,14 +498,7 @@ def test_visual_bind_multiple_values(report_with_page: Path) -> None:
assert all(b["role"] == "Values" for b in result["bindings"])
# Confirm all three projections landed in the Values role
- vfile = (
- report_with_page
- / "pages"
- / "test_page"
- / "visuals"
- / "bound_table"
- / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / "bound_table" / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
projections = data["visual"]["query"]["queryState"]["Values"]["projections"]
assert len(projections) == 3
@@ -562,18 +535,14 @@ def test_visual_bind_multiple_values(report_with_page: Path) -> None:
("combo_chart", "lineStackedColumnComboChart"),
],
)
-def test_visual_add_new_types(
- report_with_page: Path, alias: str, expected_type: str
-) -> None:
+def test_visual_add_new_types(report_with_page: Path, alias: str, expected_type: str) -> None:
"""visual_add resolves v3.1.0 type aliases and writes correct visualType."""
result = visual_add(report_with_page, "test_page", alias, name=f"v_{alias}")
assert result["status"] == "created"
assert result["visual_type"] == expected_type
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / f"v_{alias}" / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / f"v_{alias}" / "visual.json"
assert vfile.exists()
data = json.loads(vfile.read_text(encoding="utf-8"))
assert data["visual"]["visualType"] == expected_type
@@ -655,10 +624,9 @@ def test_visual_add_new_types_default_sizes(report_with_page: Path) -> None:
# Task 1 tests -- cardVisual and actionButton
# ---------------------------------------------------------------------------
+
def test_visual_add_card_visual(report_with_page: Path) -> None:
- result = visual_add(
- report_with_page, "test_page", "cardVisual", x=10, y=10
- )
+ result = visual_add(report_with_page, "test_page", "cardVisual", x=10, y=10)
assert result["status"] == "created"
assert result["visual_type"] == "cardVisual"
vdir = report_with_page / "pages" / "test_page" / "visuals" / result["name"]
@@ -671,16 +639,12 @@ def test_visual_add_card_visual(report_with_page: Path) -> None:
def test_visual_add_card_visual_alias(report_with_page: Path) -> None:
- result = visual_add(
- report_with_page, "test_page", "card_visual", x=10, y=10
- )
+ result = visual_add(report_with_page, "test_page", "card_visual", x=10, y=10)
assert result["visual_type"] == "cardVisual"
def test_visual_add_action_button(report_with_page: Path) -> None:
- result = visual_add(
- report_with_page, "test_page", "actionButton", x=0, y=0
- )
+ result = visual_add(report_with_page, "test_page", "actionButton", x=0, y=0)
assert result["status"] == "created"
assert result["visual_type"] == "actionButton"
vdir = report_with_page / "pages" / "test_page" / "visuals" / result["name"]
@@ -693,9 +657,7 @@ def test_visual_add_action_button(report_with_page: Path) -> None:
def test_visual_add_action_button_aliases(report_with_page: Path) -> None:
for alias in ("action_button", "button"):
- result = visual_add(
- report_with_page, "test_page", alias, x=0, y=0
- )
+ result = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert result["visual_type"] == "actionButton"
@@ -768,8 +730,9 @@ def test_visual_set_container_border_show(
visual_set_container(defn, "test_page", vname, border_show=True)
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
- val = data["visual"]["visualContainerObjects"]["border"][0][
- "properties"]["show"]["expr"]["Literal"]["Value"]
+ val = data["visual"]["visualContainerObjects"]["border"][0]["properties"]["show"]["expr"][
+ "Literal"
+ ]["Value"]
assert val == "true"
@@ -777,9 +740,7 @@ def test_visual_set_container_raises_for_missing_visual(
report_with_page: Path,
) -> None:
with pytest.raises(PbiCliError):
- visual_set_container(
- report_with_page, "test_page", "nonexistent_visual", border_show=False
- )
+ visual_set_container(report_with_page, "test_page", "nonexistent_visual", border_show=False)
def test_visual_set_container_no_op_returns_no_op_status(
@@ -797,10 +758,7 @@ def test_visual_set_container_no_op_returns_no_op_status(
def test_visual_add_uses_correct_schema_version(report_with_page: Path) -> None:
result = visual_add(report_with_page, "test_page", "barChart", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals"
- / result["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / result["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "2.7.0" in data["$schema"]
assert "1.5.0" not in data["$schema"]
@@ -816,11 +774,14 @@ def test_visual_list_tags_group_containers_as_group(report_with_page: Path) -> N
visuals_dir = report_with_page / "pages" / "test_page" / "visuals"
grp_dir = visuals_dir / "grp1"
grp_dir.mkdir()
- _write_json(grp_dir / "visual.json", {
- "$schema": "https://example.com/schema",
- "name": "grp1",
- "visualGroup": {"displayName": "Header Group", "visuals": []}
- })
+ _write_json(
+ grp_dir / "visual.json",
+ {
+ "$schema": "https://example.com/schema",
+ "name": "grp1",
+ "visualGroup": {"displayName": "Header Group", "visuals": []},
+ },
+ )
results = visual_list(report_with_page, "test_page")
grp = next(r for r in results if r["name"] == "grp1")
assert grp["visual_type"] == "group"
@@ -831,15 +792,16 @@ def test_visual_list_tags_group_containers_as_group(report_with_page: Path) -> N
# ---------------------------------------------------------------------------
-@pytest.mark.parametrize("vtype,alias", [
- ("clusteredColumnChart", "clustered_column"),
- ("clusteredBarChart", "clustered_bar"),
- ("textSlicer", "text_slicer"),
- ("listSlicer", "list_slicer"),
-])
-def test_visual_add_new_v35_types(
- report_with_page: Path, vtype: str, alias: str
-) -> None:
+@pytest.mark.parametrize(
+ "vtype,alias",
+ [
+ ("clusteredColumnChart", "clustered_column"),
+ ("clusteredBarChart", "clustered_bar"),
+ ("textSlicer", "text_slicer"),
+ ("listSlicer", "list_slicer"),
+ ],
+)
+def test_visual_add_new_v35_types(report_with_page: Path, vtype: str, alias: str) -> None:
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
assert r["visual_type"] == vtype
r2 = visual_add(report_with_page, "test_page", alias, x=50, y=0)
@@ -848,10 +810,7 @@ def test_visual_add_new_v35_types(
def test_list_slicer_template_has_active_flag(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "listSlicer", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals"
- / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
values = data["visual"]["query"]["queryState"]["Values"]
assert values.get("active") is True
@@ -862,13 +821,16 @@ def test_list_slicer_template_has_active_flag(report_with_page: Path) -> None:
# ---------------------------------------------------------------------------
-@pytest.mark.parametrize("vtype,alias", [
- ("image", "img"),
- ("textbox", "text_box"),
- ("pageNavigator", "page_navigator"),
- ("pageNavigator", "page_nav"),
- ("pageNavigator", "navigator"),
-])
+@pytest.mark.parametrize(
+ "vtype,alias",
+ [
+ ("image", "img"),
+ ("textbox", "text_box"),
+ ("pageNavigator", "page_navigator"),
+ ("pageNavigator", "page_nav"),
+ ("pageNavigator", "navigator"),
+ ],
+)
def test_visual_add_v36_alias_types(report_with_page: Path, vtype: str, alias: str) -> None:
r = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert r["visual_type"] == vtype
@@ -878,9 +840,7 @@ def test_visual_add_v36_alias_types(report_with_page: Path, vtype: str, alias: s
def test_visual_add_no_query_v36(report_with_page: Path, vtype: str) -> None:
"""No-query types must not have a 'query' key in the written visual.json."""
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "query" not in data["visual"]
assert data["$schema"].endswith("2.7.0/schema.json")
@@ -890,9 +850,7 @@ def test_visual_add_no_query_v36(report_with_page: Path, vtype: str) -> None:
def test_insert_visual_button_how_created(report_with_page: Path, vtype: str) -> None:
"""image, shape, pageNavigator must carry howCreated at top level."""
r = visual_add(report_with_page, "test_page", vtype, x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert data.get("howCreated") == "InsertVisualButton"
@@ -900,9 +858,7 @@ def test_insert_visual_button_how_created(report_with_page: Path, vtype: str) ->
def test_textbox_no_how_created(report_with_page: Path) -> None:
"""textbox is a content visual -- no howCreated key."""
r = visual_add(report_with_page, "test_page", "textbox", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "howCreated" not in data
@@ -912,9 +868,15 @@ def test_textbox_no_how_created(report_with_page: Path) -> None:
# ---------------------------------------------------------------------------
-@pytest.mark.parametrize("alias", [
- "advancedSlicerVisual", "advanced_slicer", "adv_slicer", "tile_slicer",
-])
+@pytest.mark.parametrize(
+ "alias",
+ [
+ "advancedSlicerVisual",
+ "advanced_slicer",
+ "adv_slicer",
+ "tile_slicer",
+ ],
+)
def test_advanced_slicer_aliases(report_with_page: Path, alias: str) -> None:
r = visual_add(report_with_page, "test_page", alias, x=0, y=0)
assert r["visual_type"] == "advancedSlicerVisual"
@@ -922,9 +884,7 @@ def test_advanced_slicer_aliases(report_with_page: Path, alias: str) -> None:
def test_advanced_slicer_has_values_querystate(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "advancedSlicerVisual", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
assert "query" in data["visual"]
assert "Values" in data["visual"]["query"]["queryState"]
@@ -939,9 +899,7 @@ def test_advanced_slicer_has_values_querystate(report_with_page: Path) -> None:
def test_card_template_uses_values_role(report_with_page: Path) -> None:
"""card visual queryState must use 'Values' not 'Fields' (Desktop compat)."""
r = visual_add(report_with_page, "test_page", "card", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "Values" in qs
@@ -952,9 +910,7 @@ def test_card_template_uses_values_role(report_with_page: Path) -> None:
def test_multi_row_card_template_uses_values_role(report_with_page: Path) -> None:
"""multiRowCard visual queryState must use 'Values' not 'Fields'."""
r = visual_add(report_with_page, "test_page", "multiRowCard", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "Values" in qs
@@ -969,9 +925,7 @@ def test_multi_row_card_template_uses_values_role(report_with_page: Path) -> Non
def test_kpi_template_has_trend_line_role(report_with_page: Path) -> None:
"""kpi template must include TrendLine queryState key (confirmed from Desktop)."""
r = visual_add(report_with_page, "test_page", "kpi", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "TrendLine" in qs
@@ -983,9 +937,7 @@ def test_kpi_template_has_trend_line_role(report_with_page: Path) -> None:
def test_gauge_template_has_max_value_role(report_with_page: Path) -> None:
"""gauge template must include MaxValue queryState key (confirmed from Desktop)."""
r = visual_add(report_with_page, "test_page", "gauge", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text())
qs = data["visual"]["query"]["queryState"]
assert "MaxValue" in qs
@@ -993,23 +945,31 @@ def test_gauge_template_has_max_value_role(report_with_page: Path) -> None:
assert "Y" in qs
-@pytest.mark.parametrize("alias,expected_role", [
- ("trend_line", "TrendLine"),
- ("trend", "TrendLine"),
- ("goal", "Goal"),
-])
+@pytest.mark.parametrize(
+ "alias,expected_role",
+ [
+ ("trend_line", "TrendLine"),
+ ("trend", "TrendLine"),
+ ("goal", "Goal"),
+ ],
+)
def test_kpi_role_aliases(alias: str, expected_role: str) -> None:
from pbi_cli.core.visual_backend import ROLE_ALIASES
+
assert ROLE_ALIASES["kpi"][alias] == expected_role
-@pytest.mark.parametrize("alias,expected_role", [
- ("max", "MaxValue"),
- ("max_value", "MaxValue"),
- ("target", "MaxValue"),
-])
+@pytest.mark.parametrize(
+ "alias,expected_role",
+ [
+ ("max", "MaxValue"),
+ ("max_value", "MaxValue"),
+ ("target", "MaxValue"),
+ ],
+)
def test_gauge_role_aliases(alias: str, expected_role: str) -> None:
from pbi_cli.core.visual_backend import ROLE_ALIASES
+
assert ROLE_ALIASES["gauge"][alias] == expected_role
@@ -1055,9 +1015,7 @@ def test_azure_map_aliases(report_with_page: Path, alias: str) -> None:
def test_azure_map_has_category_and_size_roles(report_with_page: Path) -> None:
r = visual_add(report_with_page, "test_page", "azureMap", x=0, y=0)
- vfile = (
- report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
- )
+ vfile = report_with_page / "pages" / "test_page" / "visuals" / r["name"] / "visual.json"
data = json.loads(vfile.read_text(encoding="utf-8"))
qs = data["visual"]["query"]["queryState"]
assert "Category" in qs
diff --git a/tests/test_visual_calc.py b/tests/test_visual_calc.py
index 9a5ff61..03755be 100644
--- a/tests/test_visual_calc.py
+++ b/tests/test_visual_calc.py
@@ -123,15 +123,11 @@ def test_visual_calc_add_correct_structure(
"""Added projection has correct NativeVisualCalculation fields."""
definition, page, visual = visual_on_page
- visual_calc_add(
- definition, page, visual, "Running sum", "RUNNINGSUM([Sum of Sales])", role="Y"
- )
+ visual_calc_add(definition, page, visual, "Running sum", "RUNNINGSUM([Sum of Sales])", role="Y")
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
- nvc_proj = next(
- p for p in projections if "NativeVisualCalculation" in p.get("field", {})
- )
+ nvc_proj = next(p for p in projections if "NativeVisualCalculation" in p.get("field", {}))
nvc = nvc_proj["field"]["NativeVisualCalculation"]
assert nvc["Language"] == "dax"
@@ -154,9 +150,7 @@ def test_visual_calc_add_query_refs(
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
- nvc_proj = next(
- p for p in projections if "NativeVisualCalculation" in p.get("field", {})
- )
+ nvc_proj = next(p for p in projections if "NativeVisualCalculation" in p.get("field", {}))
assert nvc_proj["queryRef"] == "select"
assert nvc_proj["nativeQueryRef"] == "My Calc"
@@ -296,9 +290,7 @@ def test_visual_calc_delete_removes_projection(
data = _read_json(_vfile(definition, page, visual))
projections = data["visual"]["query"]["queryState"]["Y"]["projections"]
- nvc_projections = [
- p for p in projections if "NativeVisualCalculation" in p.get("field", {})
- ]
+ nvc_projections = [p for p in projections if "NativeVisualCalculation" in p.get("field", {})]
assert nvc_projections == []