feat: add visual_set_container and pbi visual set-container command

Implements Task 4: container-level property control for PBIR visuals.
visual_set_container() updates border/background show and title text in
visual.visualContainerObjects using immutable dict spreading. The CLI
command pbi visual set-container exposes all three options as optional
flags, leaving unspecified keys unchanged.
This commit is contained in:
MinaSaad1 2026-04-01 19:16:17 +02:00
parent bd82712f64
commit 24539335c5
3 changed files with 799 additions and 0 deletions

View file

@ -0,0 +1,652 @@
"""PBIR visual CRUD commands."""
from __future__ import annotations
import click
from pbi_cli.commands._helpers import run_command
from pbi_cli.main import PbiContext, pass_context
@click.group()
@click.option(
"--path",
"-p",
default=None,
help="Path to .Report folder (auto-detected from CWD if omitted).",
)
@click.pass_context
def visual(ctx: click.Context, path: str | None) -> None:
"""Manage visuals in PBIR report pages."""
ctx.ensure_object(dict)
ctx.obj["report_path"] = path
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")
return None
@visual.command(name="list")
@click.option("--page", required=True, help="Page name/ID.")
@click.pass_context
@pass_context
def visual_list(ctx: PbiContext, click_ctx: click.Context, page: str) -> None:
"""List all visuals on a page."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_list as _visual_list
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(ctx, _visual_list, definition_path=definition_path, page_name=page)
@visual.command()
@click.argument("name")
@click.option("--page", required=True, help="Page name/ID.")
@click.pass_context
@pass_context
def get(ctx: PbiContext, click_ctx: click.Context, name: str, page: str) -> None:
"""Get detailed information about a visual."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_get
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_get,
definition_path=definition_path,
page_name=page,
visual_name=name,
)
@visual.command()
@click.option("--page", required=True, help="Page name/ID.")
@click.option(
"--type",
"visual_type",
required=True,
help="Visual type (bar_chart, line_chart, card, table, matrix).",
)
@click.option("--name", "-n", default=None, help="Visual name (auto-generated if omitted).")
@click.option("--x", type=float, default=None, help="X position on canvas.")
@click.option("--y", type=float, default=None, help="Y position on canvas.")
@click.option("--width", type=float, default=None, help="Visual width in pixels.")
@click.option("--height", type=float, default=None, help="Visual height in pixels.")
@click.pass_context
@pass_context
def add(
ctx: PbiContext,
click_ctx: click.Context,
page: str,
visual_type: str,
name: str | None,
x: float | None,
y: float | None,
width: float | None,
height: float | None,
) -> None:
"""Add a new visual to a page."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_add
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_add,
definition_path=definition_path,
page_name=page,
visual_type=visual_type,
name=name,
x=x,
y=y,
width=width,
height=height,
)
@visual.command()
@click.argument("name")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--x", type=float, default=None, help="New X position.")
@click.option("--y", type=float, default=None, help="New Y position.")
@click.option("--width", type=float, default=None, help="New width.")
@click.option("--height", type=float, default=None, help="New height.")
@click.option("--hidden/--visible", default=None, help="Toggle visibility.")
@click.pass_context
@pass_context
def update(
ctx: PbiContext,
click_ctx: click.Context,
name: str,
page: str,
x: float | None,
y: float | None,
width: float | None,
height: float | None,
hidden: bool | None,
) -> None:
"""Update visual position, size, or visibility."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_update
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_update,
definition_path=definition_path,
page_name=page,
visual_name=name,
x=x,
y=y,
width=width,
height=height,
hidden=hidden,
)
@visual.command()
@click.argument("name")
@click.option("--page", required=True, help="Page name/ID.")
@click.pass_context
@pass_context
def delete(ctx: PbiContext, click_ctx: click.Context, name: str, page: str) -> None:
"""Delete a visual from a page."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_delete
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_delete,
definition_path=definition_path,
page_name=page,
visual_name=name,
)
@visual.command()
@click.argument("name")
@click.option("--page", required=True, help="Page name/ID.")
@click.option(
"--category",
multiple=True,
help="Category/axis column: bar, line, donut charts. Table[Column] format.",
)
@click.option(
"--value",
multiple=True,
help="Value/measure: all chart types. Treated as measure. Table[Measure] format.",
)
@click.option(
"--row",
multiple=True,
help="Row grouping column: matrix only. Table[Column] format.",
)
@click.option(
"--field",
multiple=True,
help="Data field: card, slicer. Treated as measure for cards. Table[Field] format.",
)
@click.option(
"--legend",
multiple=True,
help="Legend/series column: bar, line, donut charts. Table[Column] format.",
)
@click.option(
"--indicator",
multiple=True,
help="KPI indicator measure. Table[Measure] format.",
)
@click.option(
"--goal",
multiple=True,
help="KPI goal measure. Table[Measure] format.",
)
@click.pass_context
@pass_context
def bind(
ctx: PbiContext,
click_ctx: click.Context,
name: str,
page: str,
category: tuple[str, ...],
value: tuple[str, ...],
row: tuple[str, ...],
field: tuple[str, ...],
legend: tuple[str, ...],
indicator: tuple[str, ...],
goal: tuple[str, ...],
) -> None:
"""Bind semantic model fields to a visual's data roles.
Examples:
pbi visual bind mychart --page p1 --category "Geo[Region]" --value "Sales[Amount]"
pbi visual bind mycard --page p1 --field "Sales[Total Revenue]"
pbi visual bind mymatrix --page p1 --row "Product[Category]" --value "Sales[Qty]"
pbi visual bind mykpi --page p1 --indicator "Sales[Revenue]" --goal "Sales[Target]"
"""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_bind
bindings: list[dict[str, str]] = []
for f in category:
bindings.append({"role": "category", "field": f})
for f in value:
bindings.append({"role": "value", "field": f})
for f in row:
bindings.append({"role": "row", "field": f})
for f in field:
bindings.append({"role": "field", "field": f})
for f in legend:
bindings.append({"role": "legend", "field": f})
for f in indicator:
bindings.append({"role": "indicator", "field": f})
for f in goal:
bindings.append({"role": "goal", "field": f})
if not bindings:
raise click.UsageError(
"At least one binding required "
"(--category, --value, --row, --field, --legend, --indicator, or --goal)."
)
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_bind,
definition_path=definition_path,
page_name=page,
visual_name=name,
bindings=bindings,
)
# ---------------------------------------------------------------------------
# v3.1.0 Bulk operations
# ---------------------------------------------------------------------------
@visual.command()
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--type", "visual_type", default=None, help="Filter by PBIR visual type or alias.")
@click.option("--name-pattern", default=None, help="fnmatch glob on visual name (e.g. 'Chart_*').")
@click.option("--x-min", type=float, default=None, help="Minimum x position.")
@click.option("--x-max", type=float, default=None, help="Maximum x position.")
@click.option("--y-min", type=float, default=None, help="Minimum y position.")
@click.option("--y-max", type=float, default=None, help="Maximum y position.")
@click.pass_context
@pass_context
def where(
ctx: PbiContext,
click_ctx: click.Context,
page: str,
visual_type: str | None,
name_pattern: str | None,
x_min: float | None,
x_max: float | None,
y_min: float | None,
y_max: float | None,
) -> None:
"""Filter visuals by type and/or position bounds.
Examples:
pbi visual where --page overview --type barChart
pbi visual where --page overview --x-max 640
pbi visual where --page overview --type kpi --name-pattern "KPI_*"
"""
from pbi_cli.core.bulk_backend import visual_where
from pbi_cli.core.pbir_path import resolve_report_path
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_where,
definition_path=definition_path,
page_name=page,
visual_type=visual_type,
name_pattern=name_pattern,
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
)
@visual.command(name="bulk-bind")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--type", "visual_type", required=True, help="Target PBIR visual type or alias.")
@click.option("--name-pattern", default=None, help="Restrict to visuals matching fnmatch pattern.")
@click.option("--category", multiple=True, help="Category/axis. Table[Column].")
@click.option("--value", multiple=True, help="Value/measure: all chart types. Table[Measure].")
@click.option("--row", multiple=True, help="Row grouping: matrix only. Table[Column].")
@click.option("--field", multiple=True, help="Data field: card, slicer. Table[Field].")
@click.option("--legend", multiple=True, help="Legend/series. Table[Column].")
@click.option("--indicator", multiple=True, help="KPI indicator measure. Table[Measure].")
@click.option("--goal", multiple=True, help="KPI goal measure. Table[Measure].")
@click.option("--column", "col_value", multiple=True, help="Combo column Y. Table[Measure].")
@click.option("--line", multiple=True, help="Line Y axis for combo chart. Table[Measure].")
@click.option("--x", "x_field", multiple=True, help="X axis for scatter chart. Table[Measure].")
@click.option("--y", "y_field", multiple=True, help="Y axis for scatter chart. Table[Measure].")
@click.pass_context
@pass_context
def bulk_bind(
ctx: PbiContext,
click_ctx: click.Context,
page: str,
visual_type: str,
name_pattern: str | None,
category: tuple[str, ...],
value: tuple[str, ...],
row: tuple[str, ...],
field: tuple[str, ...],
legend: tuple[str, ...],
indicator: tuple[str, ...],
goal: tuple[str, ...],
col_value: tuple[str, ...],
line: tuple[str, ...],
x_field: tuple[str, ...],
y_field: tuple[str, ...],
) -> None:
"""Bind fields to ALL visuals of a given type on a page.
Examples:
pbi visual bulk-bind --page overview --type barChart \\
--category "Date[Month]" --value "Sales[Revenue]"
pbi visual bulk-bind --page overview --type kpi \\
--indicator "Sales[Revenue]" --goal "Sales[Target]"
pbi visual bulk-bind --page overview --type lineStackedColumnComboChart \\
--column "Sales[Revenue]" --line "Sales[Margin]"
"""
from pbi_cli.core.bulk_backend import visual_bulk_bind
from pbi_cli.core.pbir_path import resolve_report_path
bindings: list[dict[str, str]] = []
for f in category:
bindings.append({"role": "category", "field": f})
for f in value:
bindings.append({"role": "value", "field": f})
for f in row:
bindings.append({"role": "row", "field": f})
for f in field:
bindings.append({"role": "field", "field": f})
for f in legend:
bindings.append({"role": "legend", "field": f})
for f in indicator:
bindings.append({"role": "indicator", "field": f})
for f in goal:
bindings.append({"role": "goal", "field": f})
for f in col_value:
bindings.append({"role": "column", "field": f})
for f in line:
bindings.append({"role": "line", "field": f})
for f in x_field:
bindings.append({"role": "x", "field": f})
for f in y_field:
bindings.append({"role": "y", "field": f})
if not bindings:
raise click.UsageError("At least one binding role required.")
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_bulk_bind,
definition_path=definition_path,
page_name=page,
visual_type=visual_type,
bindings=bindings,
name_pattern=name_pattern,
)
@visual.command(name="bulk-update")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--type", "visual_type", default=None, help="Filter by visual type or alias.")
@click.option("--name-pattern", default=None, help="fnmatch filter on visual name.")
@click.option("--width", type=float, default=None, help="Set width for all matching visuals.")
@click.option("--height", type=float, default=None, help="Set height for all matching visuals.")
@click.option("--x", "set_x", type=float, default=None, help="Set x position.")
@click.option("--y", "set_y", type=float, default=None, help="Set y position.")
@click.option("--hidden/--visible", default=None, help="Show or hide all matching visuals.")
@click.pass_context
@pass_context
def bulk_update(
ctx: PbiContext,
click_ctx: click.Context,
page: str,
visual_type: str | None,
name_pattern: str | None,
width: float | None,
height: float | None,
set_x: float | None,
set_y: float | None,
hidden: bool | None,
) -> None:
"""Update dimensions or visibility for ALL visuals matching the filter.
Examples:
pbi visual bulk-update --page overview --type kpi --height 200 --width 300
pbi visual bulk-update --page overview --name-pattern "Temp_*" --hidden
"""
from pbi_cli.core.bulk_backend import visual_bulk_update
from pbi_cli.core.pbir_path import resolve_report_path
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_bulk_update,
definition_path=definition_path,
page_name=page,
where_type=visual_type,
where_name_pattern=name_pattern,
set_hidden=hidden,
set_width=width,
set_height=height,
set_x=set_x,
set_y=set_y,
)
@visual.command(name="bulk-delete")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--type", "visual_type", default=None, help="Filter by visual type or alias.")
@click.option("--name-pattern", default=None, help="fnmatch filter on visual name.")
@click.pass_context
@pass_context
def bulk_delete(
ctx: PbiContext,
click_ctx: click.Context,
page: str,
visual_type: str | None,
name_pattern: str | None,
) -> None:
"""Delete ALL visuals matching the filter (requires --type or --name-pattern).
Examples:
pbi visual bulk-delete --page overview --type barChart
pbi visual bulk-delete --page overview --name-pattern "Draft_*"
"""
from pbi_cli.core.bulk_backend import visual_bulk_delete
from pbi_cli.core.pbir_path import resolve_report_path
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_bulk_delete,
definition_path=definition_path,
page_name=page,
where_type=visual_type,
where_name_pattern=name_pattern,
)
# ---------------------------------------------------------------------------
# v3.2.0 Visual Calculations (Phase 7)
# ---------------------------------------------------------------------------
@visual.command(name="calc-add")
@click.argument("visual_name")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--name", "calc_name", required=True, help="Display name for the calculation.")
@click.option("--expression", required=True, help="DAX expression for the calculation.")
@click.option("--role", default="Y", show_default=True, help="Target data role (e.g. Y, Values).")
@click.pass_context
@pass_context
def calc_add(
ctx: PbiContext,
click_ctx: click.Context,
visual_name: str,
page: str,
calc_name: str,
expression: str,
role: str,
) -> None:
"""Add a visual calculation to a data role's projections.
Examples:
pbi visual calc-add MyChart --page overview --name "Running sum" \\
--expression "RUNNINGSUM([Sum of Sales])"
pbi visual calc-add MyChart --page overview --name "Rank" \\
--expression "RANK()" --role Y
"""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_calc_add
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_calc_add,
definition_path=definition_path,
page_name=page,
visual_name=visual_name,
calc_name=calc_name,
expression=expression,
role=role,
)
@visual.command(name="calc-list")
@click.argument("visual_name")
@click.option("--page", required=True, help="Page name/ID.")
@click.pass_context
@pass_context
def calc_list(
ctx: PbiContext,
click_ctx: click.Context,
visual_name: str,
page: str,
) -> None:
"""List all visual calculations on a visual across all roles.
Examples:
pbi visual calc-list MyChart --page overview
"""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_calc_list
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_calc_list,
definition_path=definition_path,
page_name=page,
visual_name=visual_name,
)
@visual.command(name="set-container")
@click.argument("name")
@click.option("--page", "-g", required=True, help="Page name/ID.")
@click.option(
"--border-show",
type=bool,
default=None,
help="Show (true) or hide (false) the visual border.",
)
@click.option(
"--background-show",
type=bool,
default=None,
help="Show (true) or hide (false) the visual background.",
)
@click.option("--title", default=None, help="Set container title text.")
@click.pass_context
@pass_context
def set_container(
ctx: PbiContext,
click_ctx: click.Context,
name: str,
page: str,
border_show: bool | None,
background_show: bool | None,
title: str | None,
) -> None:
"""Set container-level border, background, or title on a visual."""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_set_container
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_set_container,
definition_path=definition_path,
page_name=page,
visual_name=name,
border_show=border_show,
background_show=background_show,
title=title,
)
@visual.command(name="calc-delete")
@click.argument("visual_name")
@click.option("--page", required=True, help="Page name/ID.")
@click.option("--name", "calc_name", required=True, help="Name of the calculation to delete.")
@click.pass_context
@pass_context
def calc_delete(
ctx: PbiContext,
click_ctx: click.Context,
visual_name: str,
page: str,
calc_name: str,
) -> None:
"""Delete a visual calculation by name.
Examples:
pbi visual calc-delete MyChart --page overview --name "Running sum"
"""
from pbi_cli.core.pbir_path import resolve_report_path
from pbi_cli.core.visual_backend import visual_calc_delete
definition_path = resolve_report_path(_get_report_path(click_ctx))
run_command(
ctx,
visual_calc_delete,
definition_path=definition_path,
page_name=page,
visual_name=visual_name,
calc_name=calc_name,
)

View file

@ -379,6 +379,69 @@ def visual_update(
}
def visual_set_container(
definition_path: Path,
page_name: str,
visual_name: str,
border_show: bool | None = None,
background_show: bool | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Set container-level properties (border, background, title) on a visual.
Only the keyword arguments that are provided (not None) are updated.
Other ``visualContainerObjects`` keys are preserved unchanged.
The ``visualContainerObjects`` key is separate from ``visual.objects`` --
it controls the container chrome (border, background, header title) rather
than the visual's own formatting.
"""
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}'."
)
data = _read_json(visual_json_path)
visual = data["visual"]
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()}}
}
}
}]
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}'"}}
}
}
}]}
updated_visual = {**visual, "visualContainerObjects": vco}
_write_json(visual_json_path, {**data, "visual": updated_visual})
return {
"status": "updated",
"visual": visual_name,
"page": page_name,
"border_show": border_show,
"background_show": background_show,
"title": title,
}
def visual_delete(
definition_path: Path, page_name: str, visual_name: str
) -> dict[str, Any]:

View file

@ -19,6 +19,7 @@ from pbi_cli.core.visual_backend import (
visual_delete,
visual_get,
visual_list,
visual_set_container,
visual_update,
)
@ -696,3 +697,86 @@ def test_visual_add_action_button_aliases(report_with_page: Path) -> None:
report_with_page, "test_page", alias, x=0, y=0
)
assert result["visual_type"] == "actionButton"
# ---------------------------------------------------------------------------
# Task 4 -- visual_set_container
# ---------------------------------------------------------------------------
@pytest.fixture
def page_with_bar_visual(report_with_page: Path) -> tuple[Path, str]:
"""Returns (definition_path, visual_name) for a barChart visual."""
result = visual_add(report_with_page, "test_page", "barChart", x=0, y=0)
return report_with_page, result["name"]
def test_visual_set_container_border_hide(
page_with_bar_visual: tuple[Path, str],
) -> None:
defn, vname = page_with_bar_visual
result = visual_set_container(defn, "test_page", vname, border_show=False)
assert result["status"] == "updated"
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
border = data["visual"]["visualContainerObjects"]["border"]
val = border[0]["properties"]["show"]["expr"]["Literal"]["Value"]
assert val == "false"
def test_visual_set_container_background_hide(
page_with_bar_visual: tuple[Path, str],
) -> None:
defn, vname = page_with_bar_visual
visual_set_container(defn, "test_page", vname, background_show=False)
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
bg = data["visual"]["visualContainerObjects"]["background"]
val = bg[0]["properties"]["show"]["expr"]["Literal"]["Value"]
assert val == "false"
def test_visual_set_container_title_text(
page_with_bar_visual: tuple[Path, str],
) -> None:
defn, vname = page_with_bar_visual
visual_set_container(defn, "test_page", vname, title="Revenue by Month")
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
title = data["visual"]["visualContainerObjects"]["title"]
val = title[0]["properties"]["text"]["expr"]["Literal"]["Value"]
assert val == "'Revenue by Month'"
def test_visual_set_container_preserves_other_keys(
page_with_bar_visual: tuple[Path, str],
) -> None:
defn, vname = page_with_bar_visual
visual_set_container(defn, "test_page", vname, border_show=False)
visual_set_container(defn, "test_page", vname, title="My Chart")
vfile = defn / "pages" / "test_page" / "visuals" / vname / "visual.json"
data = json.loads(vfile.read_text())
vco = data["visual"]["visualContainerObjects"]
assert "border" in vco
assert "title" in vco
def test_visual_set_container_border_show(
page_with_bar_visual: tuple[Path, str],
) -> None:
defn, vname = page_with_bar_visual
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"]
assert val == "true"
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
)