Compare commits

..

22 commits

Author SHA1 Message Date
github-actions[bot]
6671155ec7 chore: refresh downloads chart
Some checks are pending
CI / test (windows-latest, 3.10) (push) Waiting to run
CI / test (windows-latest, 3.12) (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (windows-latest, 3.13) (push) Waiting to run
CI / lint (push) Waiting to run
2026-04-21 06:56:46 +00:00
github-actions[bot]
61aa3f4994 chore: refresh downloads chart
Some checks are pending
CI / test (windows-latest, 3.13) (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (windows-latest, 3.10) (push) Waiting to run
CI / test (windows-latest, 3.12) (push) Waiting to run
2026-04-20 07:04:46 +00:00
github-actions[bot]
68c6611e30 chore: refresh downloads chart
Some checks are pending
CI / test (windows-latest, 3.13) (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (windows-latest, 3.10) (push) Waiting to run
CI / test (windows-latest, 3.12) (push) Waiting to run
2026-04-19 06:51:52 +00:00
github-actions[bot]
890eafb883 chore: refresh downloads chart
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (windows-latest, 3.10) (push) Waiting to run
CI / test (windows-latest, 3.12) (push) Waiting to run
CI / test (windows-latest, 3.13) (push) Waiting to run
2026-04-18 06:42:41 +00:00
github-actions[bot]
d1bb583274 chore: refresh downloads chart 2026-04-17 06:56:36 +00:00
github-actions[bot]
04447e5a15 chore: refresh downloads chart 2026-04-16 06:56:14 +00:00
github-actions[bot]
2f1ad3b06e chore: refresh downloads chart 2026-04-15 06:55:24 +00:00
github-actions[bot]
d9851295e8 chore: refresh downloads chart 2026-04-14 06:54:54 +00:00
github-actions[bot]
f3ab0aaa76 chore: refresh downloads chart 2026-04-13 07:03:12 +00:00
github-actions[bot]
a81cf9cd5b chore: refresh downloads chart 2026-04-12 10:28:56 +00:00
MinaSaad1
3c6261dc9b fix: use PAT to bypass branch protection in downloads chart workflow 2026-04-12 12:28:37 +02:00
MinaSaad1
6642cf12d2 fix: use unicode chars instead of html entities in downloads chart
• and → are HTML entities, not valid XML, so GitHub's
image renderer failed to load the SVG and showed a broken image.
Replaced with literal U+2022 and U+2192 characters.
2026-04-10 22:09:18 +02:00
MinaSaad1
c6bc1b9338 chore: bump checkout/setup-python in downloads-chart workflow
Node.js 20 versions (checkout@v4, setup-python@v5) are deprecated.
2026-04-10 22:08:25 +02:00
MinaSaad1
cb71ba1cf7 feat: add cumulative downloads chart to README
- scripts/generate_downloads_chart.py fetches pypistats.org data
  (mirrors excluded, stdlib only) and renders a dark-theme SVG
  matching the existing asset style
- assets/downloads-chart.svg seeded with the current 15-day history
- .github/workflows/downloads-chart.yml runs daily at 06:15 UTC
  and commits only when the chart actually changes
- README shows the chart directly under stats.svg
2026-04-10 22:06:32 +02:00
MinaSaad1
ff96cc3f3b chore: bump version to 3.10.10 2026-04-07 22:42:10 +02:00
MinaSaad1
b723a134a7 chore: bump version to 3.10.9 2026-04-07 22:24:16 +02:00
MinaSaad1
f7ca7d87e7 docs: add --no-sync batching guide to report, pages, visuals, filters skills 2026-04-07 22:15:51 +02:00
MinaSaad1
dcb48fde7c chore: bump version to 3.10.8 2026-04-07 21:24:02 +02:00
MinaSaad1
e677b018cf fix: apply ruff format to desktop_reload.py 2026-04-07 21:21:46 +02:00
MinaSaad1
849d309228 fix: fix PBI Desktop window detection and fallback chain in desktop_reload
- _find_pbi_window_pywin32: also match by PBIDesktop.exe process name so
  newer Desktop versions that title windows with just the report name are
  found correctly
- _try_pywin32: return None (not an error dict) when window is not found,
  so reload_desktop() falls through to the PowerShell fallback as intended
- bump version to 3.10.7; sync __init__.py (was stale at 3.10.5)
2026-04-07 21:18:10 +02:00
MinaSaad1
93c4275848 fix: wrap long lines in tests to pass ruff E501 (>100 chars) 2026-04-07 17:17:13 +02:00
MinaSaad1
895e90d710 fix: correct 7 PBIR report-layer issues found during Desktop testing
- visual bind: remove legacy Commands/SemanticQueryDataShapeCommand block
  (PBIR 2.7.0 uses additionalProperties:false -- Commands is a hard schema error)
- visual bind: add active:true to column (category/row/detail) projections
  so Desktop treats the field as the active axis
- visual add: remove empty "objects:{}" from all 32 visual templates
  (noisy and rejected by strict schema validators)
- visual add: write position coordinates as integers not floats
  (Desktop normalises to int; 320.0 vs 320 caused inconsistency)
- report set-background: always write transparency:0 alongside color
  (Desktop defaults missing transparency to 100 = fully invisible)
- report validate: drop false-positive layoutOptimization required error
  (real Microsoft 3.2.0 schema does not require this field)
- all write commands: add --no-sync flag to report/visual/filters/bookmarks
  groups to suppress per-command Desktop reload during scripted builds;
  use pbi report reload for a single sync at the end
2026-04-07 17:13:41 +02:00
56 changed files with 551 additions and 183 deletions

37
.github/workflows/downloads-chart.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Update downloads chart
on:
schedule:
# Daily at 06:15 UTC (after pypistats has processed the prior day)
- cron: "15 6 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
regenerate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.PAT }}
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Regenerate cumulative downloads chart
run: python scripts/generate_downloads_chart.py
- name: Commit and push updated chart
run: |
if git diff --quiet assets/downloads-chart.svg; then
echo "No changes to downloads chart."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add assets/downloads-chart.svg
git commit -m "chore: refresh downloads chart"
git push

View file

@ -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

View file

@ -41,6 +41,14 @@
<img src="https://raw.githubusercontent.com/MinaSaad1/pbi-cli/master/assets/stats.svg" alt="pbi-cli at a Glance" width="850"/>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MinaSaad1/pbi-cli/master/assets/downloads-chart.svg" alt="pbi-cli cumulative downloads from PyPI" width="850"/>
</p>
<p align="center">
<sub>Cumulative downloads, refreshed daily from <a href="https://pypistats.org/packages/pbi-cli-tool">pypistats.org</a> via GitHub Actions.</sub>
</p>
---
## Get Started

View file

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="850" height="340" viewBox="0 0 850 340">
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#58a6ff" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#58a6ff" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="#0d1117" rx="8"/>
<!-- Title -->
<text x="425" y="33" font-family="'Segoe UI', Arial, sans-serif" font-size="17" fill="#F2C811" text-anchor="middle" font-weight="bold">pbi-cli Downloads Over Time</text>
<text x="425" y="52" font-family="'Segoe UI', Arial, sans-serif" font-size="11" fill="#8b949e" text-anchor="middle">Cumulative installs from PyPI • mirrors excluded • source: pypistats.org</text>
<!-- Gridlines & y-labels -->
<line x1="70" y1="280.0" x2="810" y2="280.0" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="239.6" x2="810" y2="239.6" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="199.2" x2="810" y2="199.2" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="158.8" x2="810" y2="158.8" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="118.4" x2="810" y2="118.4" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/><line x1="70" y1="78.0" x2="810" y2="78.0" stroke="#21262d" stroke-width="1" stroke-dasharray="2,3"/>
<text x="62" y="284.0" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">0</text><text x="62" y="243.6" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">1,000</text><text x="62" y="203.2" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">2,000</text><text x="62" y="162.8" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">3,000</text><text x="62" y="122.4" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">4,000</text><text x="62" y="82.0" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">5,000</text>
<!-- Area fill -->
<path d="M 70.00,280 L 70.00,255.48 L 99.60,244.77 L 129.20,243.48 L 158.80,243.24 L 188.40,241.90 L 218.00,241.22 L 247.60,239.92 L 277.20,236.81 L 306.80,230.35 L 336.40,226.75 L 366.00,215.04 L 395.60,206.84 L 425.20,182.51 L 454.80,172.66 L 484.40,166.76 L 514.00,160.94 L 543.60,159.45 L 573.20,155.89 L 602.80,150.28 L 632.40,141.15 L 662.00,137.87 L 691.60,133.11 L 721.20,128.38 L 750.80,126.80 L 780.40,125.03 L 810.00,119.05 L 810.00,280 Z" fill="url(#areaFill)"/>
<!-- Line -->
<path d="M 70.00,255.48 L 99.60,244.77 L 129.20,243.48 L 158.80,243.24 L 188.40,241.90 L 218.00,241.22 L 247.60,239.92 L 277.20,236.81 L 306.80,230.35 L 336.40,226.75 L 366.00,215.04 L 395.60,206.84 L 425.20,182.51 L 454.80,172.66 L 484.40,166.76 L 514.00,160.94 L 543.60,159.45 L 573.20,155.89 L 602.80,150.28 L 632.40,141.15 L 662.00,137.87 L 691.60,133.11 L 721.20,128.38 L 750.80,126.80 L 780.40,125.03 L 810.00,119.05" fill="none" stroke="#58a6ff" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<!-- Data points -->
<circle cx="70.00" cy="255.48" r="2.5" fill="#58a6ff"/><circle cx="99.60" cy="244.77" r="2.5" fill="#58a6ff"/><circle cx="129.20" cy="243.48" r="2.5" fill="#58a6ff"/><circle cx="158.80" cy="243.24" r="2.5" fill="#58a6ff"/><circle cx="188.40" cy="241.90" r="2.5" fill="#58a6ff"/><circle cx="218.00" cy="241.22" r="2.5" fill="#58a6ff"/><circle cx="247.60" cy="239.92" r="2.5" fill="#58a6ff"/><circle cx="277.20" cy="236.81" r="2.5" fill="#58a6ff"/><circle cx="306.80" cy="230.35" r="2.5" fill="#58a6ff"/><circle cx="336.40" cy="226.75" r="2.5" fill="#58a6ff"/><circle cx="366.00" cy="215.04" r="2.5" fill="#58a6ff"/><circle cx="395.60" cy="206.84" r="2.5" fill="#58a6ff"/><circle cx="425.20" cy="182.51" r="2.5" fill="#58a6ff"/><circle cx="454.80" cy="172.66" r="2.5" fill="#58a6ff"/><circle cx="484.40" cy="166.76" r="2.5" fill="#58a6ff"/><circle cx="514.00" cy="160.94" r="2.5" fill="#58a6ff"/><circle cx="543.60" cy="159.45" r="2.5" fill="#58a6ff"/><circle cx="573.20" cy="155.89" r="2.5" fill="#58a6ff"/><circle cx="602.80" cy="150.28" r="2.5" fill="#58a6ff"/><circle cx="632.40" cy="141.15" r="2.5" fill="#58a6ff"/><circle cx="662.00" cy="137.87" r="2.5" fill="#58a6ff"/><circle cx="691.60" cy="133.11" r="2.5" fill="#58a6ff"/><circle cx="721.20" cy="128.38" r="2.5" fill="#58a6ff"/><circle cx="750.80" cy="126.80" r="2.5" fill="#58a6ff"/><circle cx="780.40" cy="125.03" r="2.5" fill="#58a6ff"/><circle cx="810.00" cy="119.05" r="4" fill="#F2C811"/>
<!-- Last-value callout -->
<rect x="720.0" y="92.0" width="82" height="24" rx="4" fill="#0d1a2a" stroke="#F2C811" stroke-width="1"/>
<text x="761.0" y="108.0" font-family="'Segoe UI', Arial, sans-serif" font-size="12" fill="#F2C811" text-anchor="middle" font-weight="bold">3,984 total</text>
<!-- X-axis labels -->
<text x="70.0" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Mar 26</text><text x="247.6" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 01</text><text x="454.8" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 08</text><text x="632.4" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 14</text><text x="810.0" y="298" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="middle">Apr 20</text>
<!-- Footer: date range -->
<text x="70" y="328" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e">Mar 26, 2026 → Apr 20, 2026</text>
<text x="810" y="328" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="#8b949e" text-anchor="end">26 days of data</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pbi-cli-tool"
version = "3.10.5"
version = "3.10.10"
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"

View file

@ -0,0 +1,220 @@
"""Regenerate assets/downloads-chart.svg from pypistats.org data.
Fetches daily download counts for pbi-cli-tool (mirrors excluded), computes a
cumulative series, and writes a dark-theme SVG line chart that matches the
visual style of the other assets in this repo.
Runs with stdlib only so it works in CI without extra dependencies.
Usage:
python scripts/generate_downloads_chart.py
"""
from __future__ import annotations
import json
import sys
import urllib.request
from datetime import date, datetime
from pathlib import Path
PACKAGE = "pbi-cli-tool"
API_URL = f"https://pypistats.org/api/packages/{PACKAGE}/overall?mirrors=false"
OUTPUT_PATH = Path(__file__).resolve().parent.parent / "assets" / "downloads-chart.svg"
# Chart geometry
WIDTH = 850
HEIGHT = 340
PLOT_LEFT = 70
PLOT_RIGHT = 810
PLOT_TOP = 78
PLOT_BOTTOM = 280
# Colors (match stats.svg / banner.svg palette)
BG = "#0d1117"
ACCENT_YELLOW = "#F2C811"
LINE_BLUE = "#58a6ff"
GRID = "#21262d"
TEXT_PRIMARY = "#c9d1d9"
TEXT_SECONDARY = "#8b949e"
CARD_BG = "#0d1a2a"
def fetch_downloads() -> list[tuple[date, int]]:
"""Return sorted list of (date, daily_downloads) from pypistats."""
with urllib.request.urlopen(API_URL, timeout=30) as resp:
payload = json.loads(resp.read())
rows = [
(datetime.strptime(r["date"], "%Y-%m-%d").date(), int(r["downloads"]))
for r in payload["data"]
if r["category"] == "without_mirrors"
]
rows.sort(key=lambda item: item[0])
return rows
def to_cumulative(rows: list[tuple[date, int]]) -> list[tuple[date, int]]:
total = 0
out: list[tuple[date, int]] = []
for d, n in rows:
total += n
out.append((d, total))
return out
def nice_ceiling(value: int) -> int:
"""Round up to a nice axis maximum (1-2-5 * 10^n)."""
if value <= 0:
return 10
import math
exp = math.floor(math.log10(value))
base = 10**exp
for step in (1, 2, 2.5, 5, 10):
candidate = int(step * base)
if candidate >= value:
return candidate
return int(10 * base)
def build_svg(series: list[tuple[date, int]]) -> str:
if not series:
raise RuntimeError("No download data returned from pypistats")
n = len(series)
max_val = series[-1][1]
y_max = nice_ceiling(int(max_val * 1.15))
plot_width = PLOT_RIGHT - PLOT_LEFT
plot_height = PLOT_BOTTOM - PLOT_TOP
def x_at(i: int) -> float:
if n == 1:
return PLOT_LEFT + plot_width / 2
return PLOT_LEFT + (i / (n - 1)) * plot_width
def y_at(v: int) -> float:
return PLOT_BOTTOM - (v / y_max) * plot_height
points = [(x_at(i), y_at(v)) for i, (_, v) in enumerate(series)]
line_path = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in points)
area_path = (
f"M {points[0][0]:.2f},{PLOT_BOTTOM} "
+ "L "
+ " L ".join(f"{x:.2f},{y:.2f}" for x, y in points)
+ f" L {points[-1][0]:.2f},{PLOT_BOTTOM} Z"
)
# Y-axis gridlines (5 steps)
gridlines = []
y_labels = []
for step in range(6):
v = y_max * step / 5
y = PLOT_BOTTOM - (v / y_max) * plot_height
gridlines.append(
f'<line x1="{PLOT_LEFT}" y1="{y:.1f}" x2="{PLOT_RIGHT}" y2="{y:.1f}" '
f'stroke="{GRID}" stroke-width="1" stroke-dasharray="2,3"/>'
)
label = f"{int(v):,}"
y_labels.append(
f'<text x="{PLOT_LEFT - 8}" y="{y + 4:.1f}" font-family="\'Segoe UI\', Arial, sans-serif" '
f'font-size="10" fill="{TEXT_SECONDARY}" text-anchor="end">{label}</text>'
)
# X-axis labels: first, last, and ~3 intermediate
label_indices = sorted({0, n - 1, n // 4, n // 2, (3 * n) // 4})
x_labels = []
for i in label_indices:
d, _ = series[i]
x = x_at(i)
x_labels.append(
f'<text x="{x:.1f}" y="{PLOT_BOTTOM + 18}" font-family="\'Segoe UI\', Arial, sans-serif" '
f'font-size="10" fill="{TEXT_SECONDARY}" text-anchor="middle">{d.strftime("%b %d")}</text>'
)
# Data point dots + highlight on last
dots = []
for i, (x, y) in enumerate(points):
is_last = i == n - 1
r = 4 if is_last else 2.5
fill = ACCENT_YELLOW if is_last else LINE_BLUE
dots.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{r}" fill="{fill}"/>')
# Last-value callout
last_x, last_y = points[-1]
last_val = series[-1][1]
callout_x = min(last_x + 10, PLOT_RIGHT - 90)
callout_y = max(last_y - 28, PLOT_TOP + 14)
# Summary stats
first_date = series[0][0].strftime("%b %d, %Y")
last_date = series[-1][0].strftime("%b %d, %Y")
total_str = f"{max_val:,}"
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="{LINE_BLUE}" stop-opacity="0.45"/>
<stop offset="100%" stop-color="{LINE_BLUE}" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="{BG}" rx="8"/>
<!-- Title -->
<text x="{WIDTH // 2}" y="33" font-family="'Segoe UI', Arial, sans-serif" font-size="17" fill="{ACCENT_YELLOW}" text-anchor="middle" font-weight="bold">pbi-cli Downloads Over Time</text>
<text x="{WIDTH // 2}" y="52" font-family="'Segoe UI', Arial, sans-serif" font-size="11" fill="{TEXT_SECONDARY}" text-anchor="middle">Cumulative installs from PyPI \u2022 mirrors excluded \u2022 source: pypistats.org</text>
<!-- Gridlines & y-labels -->
{"".join(gridlines)}
{"".join(y_labels)}
<!-- Area fill -->
<path d="{area_path}" fill="url(#areaFill)"/>
<!-- Line -->
<path d="{line_path}" fill="none" stroke="{LINE_BLUE}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<!-- Data points -->
{"".join(dots)}
<!-- Last-value callout -->
<rect x="{callout_x:.1f}" y="{callout_y:.1f}" width="82" height="24" rx="4" fill="{CARD_BG}" stroke="{ACCENT_YELLOW}" stroke-width="1"/>
<text x="{callout_x + 41:.1f}" y="{callout_y + 16:.1f}" font-family="'Segoe UI', Arial, sans-serif" font-size="12" fill="{ACCENT_YELLOW}" text-anchor="middle" font-weight="bold">{total_str} total</text>
<!-- X-axis labels -->
{"".join(x_labels)}
<!-- Footer: date range -->
<text x="{PLOT_LEFT}" y="{HEIGHT - 12}" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="{TEXT_SECONDARY}">{first_date} \u2192 {last_date}</text>
<text x="{PLOT_RIGHT}" y="{HEIGHT - 12}" font-family="'Segoe UI', Arial, sans-serif" font-size="10" fill="{TEXT_SECONDARY}" text-anchor="end">{n} days of data</text>
</svg>
"""
return svg
def main() -> int:
try:
daily = fetch_downloads()
except Exception as exc: # noqa: BLE001
print(f"Failed to fetch pypistats data: {exc}", file=sys.stderr)
return 1
if not daily:
print("pypistats returned no rows", file=sys.stderr)
return 1
cumulative = to_cumulative(daily)
svg = build_svg(cumulative)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(svg, encoding="utf-8")
print(f"Wrote {OUTPUT_PATH} ({len(cumulative)} days, {cumulative[-1][1]:,} total downloads)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,3 +1,3 @@
"""pbi-cli: CLI for Power BI semantic models via direct .NET interop."""
__version__ = "3.10.5"
__version__ = "3.10.10"

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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,
)

View file

@ -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:

View file

@ -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

View file

@ -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(

View file

@ -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"

View file

@ -130,6 +130,24 @@ pbi filters add-topn --page overview \
pbi filters list --page overview
```
## Suppressing Auto-Sync (--no-sync)
By default, every write command automatically syncs Power BI Desktop. When
applying filters to multiple pages or visuals in sequence, Desktop reloads
after each command.
Use `--no-sync` on the `filters` command group to batch all filter changes,
then call `pbi report reload` once at the end:
```bash
# Suppress sync while applying filters
pbi filters --no-sync add-categorical --page overview --table "Calendar Lookup" --column "Year" --values "2024"
pbi filters --no-sync add-categorical --page details --table "Product Lookup" --column "Category" --values "Bikes"
# Single reload when all filters are done
pbi report reload
```
## JSON Output
```bash

View file

@ -142,6 +142,25 @@ Page commands inherit the report path from the parent `pbi report` group:
2. Auto-detect: walks up from CWD looking for `*.Report/definition/`
3. From `.pbip`: finds sibling `.Report` folder from `.pbip` file
## Suppressing Auto-Sync (--no-sync)
By default, every write command automatically syncs Power BI Desktop. When
setting up multiple pages in sequence, Desktop reloads after each one.
Use `--no-sync` on the `report` command group to batch all page changes, then
call `pbi report reload` once at the end:
```bash
# Suppress sync while setting up pages
pbi report --no-sync add-page --display-name "Overview" --name overview
pbi report --no-sync add-page --display-name "Details" --name details
pbi report --no-sync set-background overview --color "#F2F2F2"
pbi report --no-sync set-background details --color "#F2F2F2"
# Single reload when all page setup is done
pbi report reload
```
## JSON Output
```bash

View file

@ -94,6 +94,30 @@ Power BI Desktop's Developer Mode auto-detects TMDL changes but not PBIR
changes. This command sends a keyboard shortcut to the Desktop window to
trigger a reload. Requires the `reload` optional dependency: `pip install pbi-cli-tool[reload]`
## Suppressing Auto-Sync (--no-sync)
By default, every write command (`add-page`, `delete-page`, `set-background`,
`set-theme`, etc.) automatically syncs Power BI Desktop after each operation.
When building a report in multiple steps, this causes Desktop to reload after
every single command.
Use `--no-sync` on the `report` command group to suppress per-command syncs,
then call `pbi report reload` once at the end:
```bash
# BAD: Desktop reloads after every command
pbi report add-page --display-name "Overview" --name overview
pbi report set-background overview --color "#F2F2F2"
# GOOD: suppress sync during build, reload once at the end
pbi report --no-sync add-page --display-name "Overview" --name overview
pbi report --no-sync set-background overview --color "#F2F2F2"
pbi report reload
```
`--no-sync` is available on: `report`, `visual`, `filters`, and `bookmarks`
command groups.
## Convert
```bash

View file

@ -212,6 +212,25 @@ accidental mass operations.
| textbox | textbox | (no data binding) |
| page_navigator | pageNavigator | (no data binding) |
## Suppressing Auto-Sync (--no-sync)
By default, every write command automatically syncs Power BI Desktop. When
adding or binding many visuals in sequence, Desktop reloads after each one.
Use `--no-sync` on the `visual` command group to batch all changes, then call
`pbi report reload` once at the end:
```bash
# Suppress sync while building visuals
pbi visual --no-sync add --page overview --type card --name rev_card
pbi visual --no-sync bind rev_card --page overview --field "Sales[Total Revenue]"
pbi visual --no-sync add --page overview --type bar --name sales_bar
pbi visual --no-sync bind sales_bar --page overview --category "Product[Category]" --value "Sales[Revenue]"
# Single reload when all visuals are done
pbi report reload
```
## JSON Output
All commands support `--json` for agent consumption:

View file

@ -11,7 +11,6 @@
},
"visual": {
"visualType": "actionButton",
"objects": {},
"visualContainerObjects": {},
"drillFilterOtherVisuals": true
},

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -22,7 +22,6 @@
"isDefaultSort": true
}
},
"objects": {},
"visualContainerObjects": {},
"drillFilterOtherVisuals": true
}

View file

@ -18,7 +18,6 @@
"Legend": {"projections": []}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -18,7 +18,6 @@
"Legend": {"projections": []}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -11,7 +11,6 @@
},
"visual": {
"visualType": "image",
"objects": {},
"visualContainerObjects": {},
"drillFilterOtherVisuals": true
},

View file

@ -24,7 +24,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -24,7 +24,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -16,7 +16,6 @@
"Values": {"projections": [], "active": true}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -11,7 +11,6 @@
},
"visual": {
"visualType": "pageNavigator",
"objects": {},
"visualContainerObjects": {},
"drillFilterOtherVisuals": true
},

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -24,7 +24,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -11,7 +11,6 @@
},
"visual": {
"visualType": "shape",
"objects": {},
"visualContainerObjects": {},
"drillFilterOtherVisuals": true
},

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -18,7 +18,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -16,7 +16,6 @@
"Values": {"projections": []}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -11,7 +11,6 @@
},
"visual": {
"visualType": "textbox",
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -21,7 +21,6 @@
}
}
},
"objects": {},
"drillFilterOtherVisuals": true
}
}

View file

@ -56,11 +56,7 @@ def _try_pywin32() -> dict[str, Any] | None:
hwnd = _find_pbi_window_pywin32()
if hwnd == 0:
return {
"status": "error",
"method": "pywin32",
"message": "Power BI Desktop window not found. Is it running?",
}
return None # window not found; let fallback chain continue
try:
# Bring window to foreground
@ -93,17 +89,33 @@ def _try_pywin32() -> dict[str, Any] | None:
def _find_pbi_window_pywin32() -> int:
"""Find Power BI Desktop's main window handle via pywin32."""
import win32api
import win32con
import win32gui
import win32process
result = 0
def callback(hwnd: int, _: Any) -> bool:
nonlocal result
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if "Power BI Desktop" in title:
if not win32gui.IsWindowVisible(hwnd):
return True
title = win32gui.GetWindowText(hwnd)
if "Power BI Desktop" in title:
result = hwnd
return False
# Newer PBI Desktop versions title the window with just the report
# name (e.g. "Sales_Demo") -- fall back to matching by process name.
try:
_, pid = win32process.GetWindowThreadProcessId(hwnd)
h_proc = win32api.OpenProcess(win32con.PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
exe_path = win32process.GetModuleFileNameEx(h_proc, 0)
win32api.CloseHandle(h_proc)
if exe_path.lower().endswith("pbidesktop.exe"):
result = hwnd
return False # Stop enumeration
return False
except Exception:
pass
return True
try:

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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",

View file

@ -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,25 @@ 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
page_data = _read(sample_report / "pages" / "page1" / "page.json")
props = page_data["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 +1112,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
# ---------------------------------------------------------------------------

View file

@ -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"))
# Desktop strips empty objects and schema validators reject it
assert "objects" not in data.get("visual", {})
# ---------------------------------------------------------------------------
@ -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
# ---------------------------------------------------------------------------