From 5acb3f33e3b9fa6d975505d5522db95d20b04820 Mon Sep 17 00:00:00 2001 From: MinaSaad1 Date: Thu, 2 Apr 2026 15:59:49 +0200 Subject: [PATCH] fix: resolve CI failures (formatting, mypy, test import) - Run ruff format on all 26 unformatted files - Fix mypy strict errors: add explicit typing for json.loads returns, add pywin32/websockets to mypy ignore_missing_imports - Remove yaml dependency from test_skill_triggering.py (use regex parser) - Fix skill triggering test to handle both single-line and multi-line description formats in YAML frontmatter --- pyproject.toml | 8 + src/pbi_cli/commands/_helpers.py | 17 +- src/pbi_cli/commands/report.py | 17 +- src/pbi_cli/commands/visual.py | 3 +- src/pbi_cli/core/bookmark_backend.py | 15 +- src/pbi_cli/core/errors.py | 3 +- src/pbi_cli/core/filter_backend.py | 15 +- src/pbi_cli/core/format_backend.py | 40 +--- src/pbi_cli/core/pbir_models.py | 82 +++---- src/pbi_cli/core/pbir_path.py | 4 +- src/pbi_cli/core/pbir_validators.py | 117 +++++----- src/pbi_cli/core/report_backend.py | 319 ++++++++++++------------- src/pbi_cli/core/tmdl_diff.py | 6 +- src/pbi_cli/core/visual_backend.py | 197 ++++++++-------- src/pbi_cli/preview/renderer.py | 20 +- src/pbi_cli/preview/server.py | 2 +- src/pbi_cli/utils/desktop_reload.py | 8 +- src/pbi_cli/utils/desktop_sync.py | 47 ++-- tests/test_bookmark_backend.py | 59 ++--- tests/test_bulk_backend.py | 4 +- tests/test_filter_backend.py | 200 ++++++++++------ tests/test_format_backend.py | 71 +++--- tests/test_hardening.py | 86 ++++--- tests/test_pbir_path.py | 22 +- tests/test_pbir_validators.py | 336 ++++++++++++++++----------- tests/test_preview.py | 106 +++++---- tests/test_report_backend.py | 283 ++++++++++++---------- tests/test_skill_triggering.py | 16 +- tests/test_tmdl_diff.py | 17 +- tests/test_visual_backend.py | 208 +++++++---------- tests/test_visual_calc.py | 16 +- 31 files changed, 1258 insertions(+), 1086 deletions(-) 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'{bars}' @@ -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'{polyline}' 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 == []