diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e5870..fc0a89d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.6] - 2026-04-07 + +### Fixed +- `visual bind` no longer writes the legacy `Commands` block (SemanticQueryDataShapeCommand) to `visual.json`. PBIR 2.7.0 uses `additionalProperties: false` on the query object, so the `Commands` field is a hard schema violation. Only `queryState` projections are now written. +- `pbi report validate` and the full PBIR validator no longer flag a missing `layoutOptimization` field as an error. The real Microsoft schema does not list it as required; the previous check was against a stale internal schema. +- `pbi report set-background` now always writes `transparency: 0` alongside the color. Power BI Desktop defaults a missing `transparency` property to 100 (fully invisible), making the color silently unrendered. The new `--transparency` flag (0-100, default 0) lets callers override for semi-transparent backgrounds. + +### Added +- `--no-sync` flag on `report`, `visual`, `filters`, and `bookmarks` command groups. Suppresses the per-command Desktop auto-sync for scripted multi-step builds. Use `pbi report reload` for a single explicit sync at the end of the script. + ## [3.10.5] - 2026-04-06 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 23f15c5..78016d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pbi-cli-tool" -version = "3.10.5" +version = "3.10.6" description = "CLI for Power BI semantic models and PBIR reports - direct .NET connection for token-efficient AI agent usage" readme = "README.pypi.md" license = "MIT AND LicenseRef-Microsoft-AS-Client-Libraries" diff --git a/src/pbi_cli/commands/_helpers.py b/src/pbi_cli/commands/_helpers.py index 9012b51..278eadd 100644 --- a/src/pbi_cli/commands/_helpers.py +++ b/src/pbi_cli/commands/_helpers.py @@ -62,7 +62,7 @@ def run_command( def _is_report_write(result: Any) -> bool: - """Check if the result indicates a report-layer write.""" + """Check if the result indicates a report-layer write that should trigger sync.""" if not isinstance(result, dict): return False status = result.get("status", "") @@ -74,11 +74,13 @@ def _is_report_write(result: Any) -> bool: if click_ctx is None: return False - # Walk up to the group to find report_path + # Walk up to the group to find report_path; also check for --no-sync flag parent = click_ctx.parent while parent is not None: obj = parent.obj if isinstance(obj, dict) and "report_path" in obj: + if obj.get("no_sync", False): + return False return True parent = parent.parent return False diff --git a/src/pbi_cli/commands/bookmarks.py b/src/pbi_cli/commands/bookmarks.py index 2ae8390..521d928 100644 --- a/src/pbi_cli/commands/bookmarks.py +++ b/src/pbi_cli/commands/bookmarks.py @@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context default=None, help="Path to .Report folder (auto-detected from CWD if omitted).", ) +@click.option( + "--no-sync", + is_flag=True, + default=False, + help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.", +) @click.pass_context -def bookmarks(ctx: click.Context, path: str | None) -> None: +def bookmarks(ctx: click.Context, path: str | None, no_sync: bool) -> None: """Manage report bookmarks.""" ctx.ensure_object(dict) ctx.obj["report_path"] = path + ctx.obj["no_sync"] = no_sync @bookmarks.command(name="list") diff --git a/src/pbi_cli/commands/filters.py b/src/pbi_cli/commands/filters.py index 33a6525..175fa55 100644 --- a/src/pbi_cli/commands/filters.py +++ b/src/pbi_cli/commands/filters.py @@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context default=None, help="Path to .Report folder (auto-detected from CWD if omitted).", ) +@click.option( + "--no-sync", + is_flag=True, + default=False, + help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.", +) @click.pass_context -def filters(ctx: click.Context, path: str | None) -> None: +def filters(ctx: click.Context, path: str | None, no_sync: bool) -> None: """Manage page and visual filters.""" ctx.ensure_object(dict) ctx.obj["report_path"] = path + ctx.obj["no_sync"] = no_sync @filters.command(name="list") diff --git a/src/pbi_cli/commands/report.py b/src/pbi_cli/commands/report.py index 4ea94ca..21571b6 100644 --- a/src/pbi_cli/commands/report.py +++ b/src/pbi_cli/commands/report.py @@ -17,11 +17,18 @@ from pbi_cli.main import PbiContext, pass_context default=None, help="Path to .Report folder (auto-detected from CWD if omitted).", ) +@click.option( + "--no-sync", + is_flag=True, + default=False, + help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.", +) @click.pass_context -def report(ctx: click.Context, path: str | None) -> None: +def report(ctx: click.Context, path: str | None, no_sync: bool) -> None: """Manage Power BI PBIR reports (pages, themes, validation).""" ctx.ensure_object(dict) ctx.obj["report_path"] = path + ctx.obj["no_sync"] = no_sync @report.command() @@ -195,9 +202,19 @@ def diff_theme(ctx: PbiContext, click_ctx: click.Context, file: str) -> None: @report.command(name="set-background") @click.argument("page_name") @click.option("--color", "-c", required=True, help="Hex color e.g. '#F8F9FA'.") +@click.option( + "--transparency", + "-t", + default=0, + show_default=True, + type=click.IntRange(0, 100), + help="Transparency 0 (opaque) to 100 (invisible). Defaults to 0 so the color is visible.", +) @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, transparency: int +) -> 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 @@ -210,6 +227,7 @@ def set_background(ctx: PbiContext, click_ctx: click.Context, page_name: str, co definition_path=definition_path, page_name=page_name, color=color, + transparency=transparency, ) diff --git a/src/pbi_cli/commands/visual.py b/src/pbi_cli/commands/visual.py index a1936d7..75118d8 100644 --- a/src/pbi_cli/commands/visual.py +++ b/src/pbi_cli/commands/visual.py @@ -15,11 +15,18 @@ from pbi_cli.main import PbiContext, pass_context default=None, help="Path to .Report folder (auto-detected from CWD if omitted).", ) +@click.option( + "--no-sync", + is_flag=True, + default=False, + help="Skip Desktop auto-sync after write commands. Use for scripted multi-step builds.", +) @click.pass_context -def visual(ctx: click.Context, path: str | None) -> None: +def visual(ctx: click.Context, path: str | None, no_sync: bool) -> None: """Manage visuals in PBIR report pages.""" ctx.ensure_object(dict) ctx.obj["report_path"] = path + ctx.obj["no_sync"] = no_sync def _get_report_path(click_ctx: click.Context) -> str | None: diff --git a/src/pbi_cli/core/pbir_validators.py b/src/pbi_cli/core/pbir_validators.py index bf5276a..5615b90 100644 --- a/src/pbi_cli/core/pbir_validators.py +++ b/src/pbi_cli/core/pbir_validators.py @@ -163,11 +163,6 @@ def _validate_report_json(definition_path: Path) -> list[ValidationResult]: ValidationResult("warning", "report.json", "themeCollection missing 'baseTheme'") ) - if "layoutOptimization" not in data: - findings.append( - ValidationResult("error", "report.json", "Missing required 'layoutOptimization'") - ) - return findings diff --git a/src/pbi_cli/core/report_backend.py b/src/pbi_cli/core/report_backend.py index d954158..c81a3c6 100644 --- a/src/pbi_cli/core/report_backend.py +++ b/src/pbi_cli/core/report_backend.py @@ -240,8 +240,6 @@ def report_validate(definition_path: Path) -> dict[str, Any]: data = _read_json(report_json) if "themeCollection" not in data: errors.append("report.json missing required 'themeCollection'") - if "layoutOptimization" not in data: - errors.append("report.json missing required 'layoutOptimization'") except json.JSONDecodeError: pass # Already caught above @@ -420,14 +418,21 @@ def page_set_background( definition_path: Path, page_name: str, color: str, + transparency: int = 0, ) -> dict[str, Any]: """Set the background color of a page. Updates the ``objects.background`` property in ``page.json``. The color must be a hex string, e.g. ``'#F8F9FA'``. + + ``transparency`` is 0 (fully opaque) to 100 (fully transparent). Desktop + defaults missing transparency to 100 (invisible), so this function always + writes it explicitly. Pass a value to override. """ if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", color): raise PbiCliError(f"Invalid color '{color}' -- expected hex format like '#F8F9FA'.") + if not 0 <= transparency <= 100: + raise PbiCliError(f"Invalid transparency '{transparency}' -- must be 0-100.") page_dir = get_page_dir(definition_path, page_name) page_json_path = page_dir / "page.json" @@ -437,12 +442,18 @@ 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}'"}}}}}, + "transparency": {"expr": {"Literal": {"Value": f"{transparency}D"}}}, } } objects = {**page_data.get("objects", {}), "background": [background_entry]} _write_json(page_json_path, {**page_data, "objects": objects}) - return {"status": "updated", "page": page_name, "background_color": color} + return { + "status": "updated", + "page": page_name, + "background_color": color, + "transparency": transparency, + } def page_set_visibility( diff --git a/src/pbi_cli/core/visual_backend.py b/src/pbi_cli/core/visual_backend.py index f49b2e3..e3142ed 100644 --- a/src/pbi_cli/core/visual_backend.py +++ b/src/pbi_cli/core/visual_backend.py @@ -206,10 +206,10 @@ def _build_visual_json( """Fill placeholders in a template string and return parsed JSON.""" filled = ( template_str.replace("__VISUAL_NAME__", name) - .replace("__X__", str(x)) - .replace("__Y__", str(y)) - .replace("__WIDTH__", str(width)) - .replace("__HEIGHT__", str(height)) + .replace("__X__", str(int(x))) + .replace("__Y__", str(int(y))) + .replace("__WIDTH__", str(int(width))) + .replace("__HEIGHT__", str(int(height))) .replace("__Z__", str(z)) .replace("__TAB_ORDER__", str(tab_order)) ) @@ -566,11 +566,6 @@ def visual_bind( query = visual_config.setdefault("query", {}) query_state = query.setdefault("queryState", {}) - # Collect existing Commands From/Select to merge (fix: don't overwrite) - from_entities: dict[str, dict[str, Any]] = {} - select_items: list[dict[str, Any]] = [] - _collect_existing_commands(query, from_entities, select_items) - role_map = ROLE_ALIASES.get(visual_type, {}) applied: list[dict[str, str]] = [] @@ -588,14 +583,6 @@ def visual_bind( # Determine measure vs column: explicit flag, or role-based heuristic is_measure = force_measure or pbir_role in MEASURE_ROLES - # Track source alias for Commands block (use full name to avoid collisions) - source_alias = table.replace(" ", "_").lower() if table else "t" - from_entities[source_alias] = { - "Name": source_alias, - "Entity": table, - "Type": 0, - } - # Build queryState projection (uses Entity directly, matching Desktop) query_ref = f"{table}.{column}" if is_measure: @@ -613,38 +600,18 @@ def visual_bind( } } - projection = { + projection: dict[str, Any] = { "field": field_expr, "queryRef": query_ref, "nativeQueryRef": column, } + if not is_measure: + projection["active"] = True # Add to query state role_state = query_state.setdefault(pbir_role, {"projections": []}) role_state["projections"].append(projection) - # Build Commands select item (uses Source alias) - if is_measure: - cmd_field_expr: dict[str, Any] = { - "Measure": { - "Expression": {"SourceRef": {"Source": source_alias}}, - "Property": column, - } - } - else: - cmd_field_expr = { - "Column": { - "Expression": {"SourceRef": {"Source": source_alias}}, - "Property": column, - } - } - select_items.append( - { - **cmd_field_expr, - "Name": query_ref, - } - ) - applied.append( { "role": pbir_role, @@ -653,20 +620,6 @@ def visual_bind( } ) - # 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, - } - } - } - ] - data["visual"] = visual_config _write_json(vfile, data) @@ -714,21 +667,6 @@ def _summarize_field(field: dict[str, Any]) -> str: return str(field) -def _collect_existing_commands( - query: dict[str, Any], - from_entities: dict[str, dict[str, Any]], - select_items: list[dict[str, Any]], -) -> None: - """Extract existing From entities and Select items from Commands block.""" - for cmd in query.get("Commands", []): - sq = cmd.get("SemanticQueryDataShapeCommand", {}).get("Query", {}) - for entity in sq.get("From", []): - name = entity.get("Name", "") - if name: - from_entities[name] = entity - select_items.extend(sq.get("Select", [])) - - def _next_y_position(definition_path: Path, page_name: str) -> float: """Calculate the next y position to avoid overlap with existing visuals.""" visuals_dir = definition_path / "pages" / page_name / "visuals" diff --git a/src/pbi_cli/templates/visuals/actionButton.json b/src/pbi_cli/templates/visuals/actionButton.json index 7ee5a01..8e6eac6 100644 --- a/src/pbi_cli/templates/visuals/actionButton.json +++ b/src/pbi_cli/templates/visuals/actionButton.json @@ -11,7 +11,6 @@ }, "visual": { "visualType": "actionButton", - "objects": {}, "visualContainerObjects": {}, "drillFilterOtherVisuals": true }, diff --git a/src/pbi_cli/templates/visuals/advancedSlicerVisual.json b/src/pbi_cli/templates/visuals/advancedSlicerVisual.json index 924a009..e807e50 100644 --- a/src/pbi_cli/templates/visuals/advancedSlicerVisual.json +++ b/src/pbi_cli/templates/visuals/advancedSlicerVisual.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/areaChart.json b/src/pbi_cli/templates/visuals/areaChart.json index 873f3c1..a65cc27 100644 --- a/src/pbi_cli/templates/visuals/areaChart.json +++ b/src/pbi_cli/templates/visuals/areaChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/azureMap.json b/src/pbi_cli/templates/visuals/azureMap.json index 2c85ee3..62a1e27 100644 --- a/src/pbi_cli/templates/visuals/azureMap.json +++ b/src/pbi_cli/templates/visuals/azureMap.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/barChart.json b/src/pbi_cli/templates/visuals/barChart.json index 2dca43c..5c9e693 100644 --- a/src/pbi_cli/templates/visuals/barChart.json +++ b/src/pbi_cli/templates/visuals/barChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/card.json b/src/pbi_cli/templates/visuals/card.json index cbfd2c8..acd85ac 100644 --- a/src/pbi_cli/templates/visuals/card.json +++ b/src/pbi_cli/templates/visuals/card.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/cardNew.json b/src/pbi_cli/templates/visuals/cardNew.json index b9d9cdd..672c357 100644 --- a/src/pbi_cli/templates/visuals/cardNew.json +++ b/src/pbi_cli/templates/visuals/cardNew.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/cardVisual.json b/src/pbi_cli/templates/visuals/cardVisual.json index 0b7c2e2..8bbeec2 100644 --- a/src/pbi_cli/templates/visuals/cardVisual.json +++ b/src/pbi_cli/templates/visuals/cardVisual.json @@ -22,7 +22,6 @@ "isDefaultSort": true } }, - "objects": {}, "visualContainerObjects": {}, "drillFilterOtherVisuals": true } diff --git a/src/pbi_cli/templates/visuals/clusteredBarChart.json b/src/pbi_cli/templates/visuals/clusteredBarChart.json index 63603b0..d42551b 100644 --- a/src/pbi_cli/templates/visuals/clusteredBarChart.json +++ b/src/pbi_cli/templates/visuals/clusteredBarChart.json @@ -18,7 +18,6 @@ "Legend": {"projections": []} } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/clusteredColumnChart.json b/src/pbi_cli/templates/visuals/clusteredColumnChart.json index dc99792..bee086a 100644 --- a/src/pbi_cli/templates/visuals/clusteredColumnChart.json +++ b/src/pbi_cli/templates/visuals/clusteredColumnChart.json @@ -18,7 +18,6 @@ "Legend": {"projections": []} } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/columnChart.json b/src/pbi_cli/templates/visuals/columnChart.json index c8a2a2e..9706972 100644 --- a/src/pbi_cli/templates/visuals/columnChart.json +++ b/src/pbi_cli/templates/visuals/columnChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/donutChart.json b/src/pbi_cli/templates/visuals/donutChart.json index 80da37e..5532643 100644 --- a/src/pbi_cli/templates/visuals/donutChart.json +++ b/src/pbi_cli/templates/visuals/donutChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/funnelChart.json b/src/pbi_cli/templates/visuals/funnelChart.json index 9d3348b..87d7a92 100644 --- a/src/pbi_cli/templates/visuals/funnelChart.json +++ b/src/pbi_cli/templates/visuals/funnelChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/gauge.json b/src/pbi_cli/templates/visuals/gauge.json index 640a8bb..4fb0b44 100644 --- a/src/pbi_cli/templates/visuals/gauge.json +++ b/src/pbi_cli/templates/visuals/gauge.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/image.json b/src/pbi_cli/templates/visuals/image.json index 5a38429..654a681 100644 --- a/src/pbi_cli/templates/visuals/image.json +++ b/src/pbi_cli/templates/visuals/image.json @@ -11,7 +11,6 @@ }, "visual": { "visualType": "image", - "objects": {}, "visualContainerObjects": {}, "drillFilterOtherVisuals": true }, diff --git a/src/pbi_cli/templates/visuals/kpi.json b/src/pbi_cli/templates/visuals/kpi.json index c7832be..0e13a6e 100644 --- a/src/pbi_cli/templates/visuals/kpi.json +++ b/src/pbi_cli/templates/visuals/kpi.json @@ -24,7 +24,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/lineChart.json b/src/pbi_cli/templates/visuals/lineChart.json index 1abb4f3..f0f1826 100644 --- a/src/pbi_cli/templates/visuals/lineChart.json +++ b/src/pbi_cli/templates/visuals/lineChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/lineStackedColumnComboChart.json b/src/pbi_cli/templates/visuals/lineStackedColumnComboChart.json index 971090d..b39d6ba 100644 --- a/src/pbi_cli/templates/visuals/lineStackedColumnComboChart.json +++ b/src/pbi_cli/templates/visuals/lineStackedColumnComboChart.json @@ -24,7 +24,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/listSlicer.json b/src/pbi_cli/templates/visuals/listSlicer.json index edda0bd..432d6c6 100644 --- a/src/pbi_cli/templates/visuals/listSlicer.json +++ b/src/pbi_cli/templates/visuals/listSlicer.json @@ -16,7 +16,6 @@ "Values": {"projections": [], "active": true} } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/multiRowCard.json b/src/pbi_cli/templates/visuals/multiRowCard.json index 004cc20..411a221 100644 --- a/src/pbi_cli/templates/visuals/multiRowCard.json +++ b/src/pbi_cli/templates/visuals/multiRowCard.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/pageNavigator.json b/src/pbi_cli/templates/visuals/pageNavigator.json index a103b88..b70677e 100644 --- a/src/pbi_cli/templates/visuals/pageNavigator.json +++ b/src/pbi_cli/templates/visuals/pageNavigator.json @@ -11,7 +11,6 @@ }, "visual": { "visualType": "pageNavigator", - "objects": {}, "visualContainerObjects": {}, "drillFilterOtherVisuals": true }, diff --git a/src/pbi_cli/templates/visuals/pivotTable.json b/src/pbi_cli/templates/visuals/pivotTable.json index 5e31080..9a0f0c0 100644 --- a/src/pbi_cli/templates/visuals/pivotTable.json +++ b/src/pbi_cli/templates/visuals/pivotTable.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/ribbonChart.json b/src/pbi_cli/templates/visuals/ribbonChart.json index 4eb073d..c6893d4 100644 --- a/src/pbi_cli/templates/visuals/ribbonChart.json +++ b/src/pbi_cli/templates/visuals/ribbonChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/scatterChart.json b/src/pbi_cli/templates/visuals/scatterChart.json index 4c49108..f455848 100644 --- a/src/pbi_cli/templates/visuals/scatterChart.json +++ b/src/pbi_cli/templates/visuals/scatterChart.json @@ -24,7 +24,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/shape.json b/src/pbi_cli/templates/visuals/shape.json index 0db88ef..0d0a709 100644 --- a/src/pbi_cli/templates/visuals/shape.json +++ b/src/pbi_cli/templates/visuals/shape.json @@ -11,7 +11,6 @@ }, "visual": { "visualType": "shape", - "objects": {}, "visualContainerObjects": {}, "drillFilterOtherVisuals": true }, diff --git a/src/pbi_cli/templates/visuals/slicer.json b/src/pbi_cli/templates/visuals/slicer.json index 04911d5..363aedb 100644 --- a/src/pbi_cli/templates/visuals/slicer.json +++ b/src/pbi_cli/templates/visuals/slicer.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/stackedBarChart.json b/src/pbi_cli/templates/visuals/stackedBarChart.json index 0715c5d..aa8434c 100644 --- a/src/pbi_cli/templates/visuals/stackedBarChart.json +++ b/src/pbi_cli/templates/visuals/stackedBarChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/tableEx.json b/src/pbi_cli/templates/visuals/tableEx.json index 7e571fa..817d50f 100644 --- a/src/pbi_cli/templates/visuals/tableEx.json +++ b/src/pbi_cli/templates/visuals/tableEx.json @@ -18,7 +18,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/textSlicer.json b/src/pbi_cli/templates/visuals/textSlicer.json index 124ca03..a52e9cd 100644 --- a/src/pbi_cli/templates/visuals/textSlicer.json +++ b/src/pbi_cli/templates/visuals/textSlicer.json @@ -16,7 +16,6 @@ "Values": {"projections": []} } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/textbox.json b/src/pbi_cli/templates/visuals/textbox.json index 3d390ae..5d047b8 100644 --- a/src/pbi_cli/templates/visuals/textbox.json +++ b/src/pbi_cli/templates/visuals/textbox.json @@ -11,7 +11,6 @@ }, "visual": { "visualType": "textbox", - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/treemap.json b/src/pbi_cli/templates/visuals/treemap.json index 0638bdc..e21fca3 100644 --- a/src/pbi_cli/templates/visuals/treemap.json +++ b/src/pbi_cli/templates/visuals/treemap.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/src/pbi_cli/templates/visuals/waterfallChart.json b/src/pbi_cli/templates/visuals/waterfallChart.json index 78a7a37..fb44448 100644 --- a/src/pbi_cli/templates/visuals/waterfallChart.json +++ b/src/pbi_cli/templates/visuals/waterfallChart.json @@ -21,7 +21,6 @@ } } }, - "objects": {}, "drillFilterOtherVisuals": true } } diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 41b0034..1799341 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -159,14 +159,8 @@ class TestBindMerge: assert len(query["queryState"]["Category"]["projections"]) == 1 assert len(query["queryState"]["Y"]["projections"]) == 1 - # Commands block should have both From entities - cmds = query["Commands"][0]["SemanticQueryDataShapeCommand"]["Query"] - from_names = {e["Entity"] for e in cmds["From"]} - assert "Date" in from_names - assert "Sales" in from_names - - # Commands Select should have both fields - assert len(cmds["Select"]) == 2 + # PBIR 2.7.0: Commands is a legacy binary format field - must not be present + assert "Commands" not in query # --------------------------------------------------------------------------- diff --git a/tests/test_pbir_validators.py b/tests/test_pbir_validators.py index e515d3a..8e00747 100644 --- a/tests/test_pbir_validators.py +++ b/tests/test_pbir_validators.py @@ -115,18 +115,6 @@ class TestValidateReportFull: 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"}}, - }, - ) - 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", diff --git a/tests/test_report_backend.py b/tests/test_report_backend.py index 2269219..807da7f 100644 --- a/tests/test_report_backend.py +++ b/tests/test_report_backend.py @@ -442,20 +442,6 @@ class TestReportValidate: assert result["valid"] is False assert any("themeCollection" in e for e in result["errors"]) - 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": {}}, - }, - ) - - result = report_validate(sample_report) - assert result["valid"] is False - assert any("layoutOptimization" in e for e in result["errors"]) - def test_report_validate_page_missing_page_json(self, sample_report: Path) -> None: """A page folder without page.json is flagged as invalid.""" orphan_page = sample_report / "pages" / "orphan_page" @@ -1020,9 +1006,24 @@ def test_page_set_background_writes_color(sample_report: Path) -> None: result = page_set_background(sample_report, "page1", "#F8F9FA") assert result["status"] == "updated" assert result["background_color"] == "#F8F9FA" + assert result["transparency"] == 0 page_data = _read(sample_report / "pages" / "page1" / "page.json") - bg = page_data["objects"]["background"][0]["properties"]["color"] - assert bg["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'" + props = page_data["objects"]["background"][0]["properties"] + assert props["color"]["solid"]["color"]["expr"]["Literal"]["Value"] == "'#F8F9FA'" + # transparency must always be written so Desktop renders the color as opaque + assert props["transparency"]["expr"]["Literal"]["Value"] == "0D" + + +def test_page_set_background_custom_transparency(sample_report: Path) -> None: + result = page_set_background(sample_report, "page1", "#0E1117", transparency=50) + assert result["transparency"] == 50 + props = _read(sample_report / "pages" / "page1" / "page.json")["objects"]["background"][0]["properties"] + assert props["transparency"]["expr"]["Literal"]["Value"] == "50D" + + +def test_page_set_background_rejects_invalid_transparency(sample_report: Path) -> None: + with pytest.raises(PbiCliError, match="Invalid transparency"): + page_set_background(sample_report, "page1", "#000000", transparency=101) def test_page_set_background_preserves_other_objects(sample_report: Path) -> None: @@ -1110,6 +1111,7 @@ def test_page_set_background_accepts_valid_color(sample_report: Path) -> None: result = page_set_background(sample_report, "page1", "#F8F9FA") assert result["status"] == "updated" assert result["background_color"] == "#F8F9FA" + assert result["transparency"] == 0 # --------------------------------------------------------------------------- diff --git a/tests/test_visual_backend.py b/tests/test_visual_backend.py index cbcb7e0..89a91cf 100644 --- a/tests/test_visual_backend.py +++ b/tests/test_visual_backend.py @@ -194,7 +194,7 @@ def test_visual_add_matrix(report_with_page: Path) -> None: def test_visual_add_custom_position(report_with_page: Path) -> None: - """Explicitly provided x, y, width, height are stored verbatim.""" + """Explicitly provided x, y, width, height are stored as integers, not floats.""" result = visual_add( report_with_page, "test_page", @@ -206,18 +206,35 @@ def test_visual_add_custom_position(report_with_page: Path) -> None: height=450.0, ) - assert result["x"] == 100.0 - assert result["y"] == 200.0 - assert result["width"] == 600.0 - assert result["height"] == 450.0 + assert result["x"] == 100 + assert result["y"] == 200 + assert result["width"] == 600 + assert result["height"] == 450 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 - assert pos["y"] == 200.0 - assert pos["width"] == 600.0 - assert pos["height"] == 450.0 + assert pos["x"] == 100 + assert pos["y"] == 200 + assert pos["width"] == 600 + assert pos["height"] == 450 + # Positions must be integers, not floats (Desktop normalises to int) + assert isinstance(pos["x"], int) + assert isinstance(pos["y"], int) + + +# --------------------------------------------------------------------------- +# 7b. visual_add - no empty objects key +# --------------------------------------------------------------------------- + + +def test_visual_add_no_empty_objects(report_with_page: Path) -> None: + """Scaffolded visuals must not contain an empty 'objects' key.""" + visual_add(report_with_page, "test_page", "bar_chart", name="clean_bar") + vfile = report_with_page / "pages" / "test_page" / "visuals" / "clean_bar" / "visual.json" + data = json.loads(vfile.read_text(encoding="utf-8")) + assert "objects" not in data.get("visual", {}), \ + "visual.json must not contain empty 'objects: {}' -- Desktop strips it and schema validators reject it" # --------------------------------------------------------------------------- @@ -357,19 +374,19 @@ def test_visual_update_position(report_with_page: Path) -> None: assert result["status"] == "updated" assert result["name"] == "movable" - assert result["position"]["x"] == 50.0 - assert result["position"]["y"] == 75.0 - assert result["position"]["width"] == 350.0 - assert result["position"]["height"] == 250.0 + assert result["position"]["x"] == 50 + assert result["position"]["y"] == 75 + assert result["position"]["width"] == 350 + assert result["position"]["height"] == 250 # Confirm the file on disk reflects the change 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 - assert pos["y"] == 75.0 - assert pos["width"] == 350.0 - assert pos["height"] == 250.0 + assert pos["x"] == 50 + assert pos["y"] == 75 + assert pos["width"] == 350 + assert pos["height"] == 250 # --------------------------------------------------------------------------- @@ -469,8 +486,15 @@ def test_visual_bind_category_value(report_with_page: Path) -> None: assert cat_proj["queryRef"] == "Date.Year" assert cat_proj["nativeQueryRef"] == "Year" - # The semantic query Commands block should be present - assert "Commands" in data["visual"]["query"] + # PBIR 2.7.0: Commands is a legacy binary format field - must not be present + assert "Commands" not in data["visual"]["query"] + + # Column (category) projections must have active: true so Desktop renders the axis + assert cat_proj.get("active") is True + + # Measure (value) projections must NOT have active: true + val_proj = query_state["Y"]["projections"][0] + assert "active" not in val_proj # ---------------------------------------------------------------------------