mirror of
https://github.com/MinaSaad1/pbi-cli
synced 2026-04-21 13:37:19 +00:00
Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6671155ec7 | ||
|
|
61aa3f4994 | ||
|
|
68c6611e30 | ||
|
|
890eafb883 | ||
|
|
d1bb583274 | ||
|
|
04447e5a15 | ||
|
|
2f1ad3b06e | ||
|
|
d9851295e8 | ||
|
|
f3ab0aaa76 | ||
|
|
a81cf9cd5b | ||
|
|
3c6261dc9b | ||
|
|
6642cf12d2 | ||
|
|
c6bc1b9338 | ||
|
|
cb71ba1cf7 | ||
|
|
ff96cc3f3b | ||
|
|
b723a134a7 | ||
|
|
f7ca7d87e7 | ||
|
|
dcb48fde7c | ||
|
|
e677b018cf | ||
|
|
849d309228 | ||
|
|
93c4275848 | ||
|
|
895e90d710 | ||
|
|
63f4738a2e |
57 changed files with 562 additions and 189 deletions
37
.github/workflows/downloads-chart.yml
vendored
Normal file
37
.github/workflows/downloads-chart.yml
vendored
Normal 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
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -5,6 +5,21 @@ 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
|
||||
- ASCII banner: replaced incorrect hand-crafted art with the official design from `assets/banner.svg`. The `I` in PBI is now correctly narrow, and a small `███╗/╚══╝` block serves as the visible `-` separator between PBI and CLI.
|
||||
|
||||
## [3.10.4] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
assets/downloads-chart.svg
Normal file
39
assets/downloads-chart.svg
Normal 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 |
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "pbi-cli-tool"
|
||||
version = "3.10.4"
|
||||
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"
|
||||
|
|
|
|||
220
scripts/generate_downloads_chart.py
Normal file
220
scripts/generate_downloads_chart.py
Normal 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())
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
"""pbi-cli: CLI for Power BI semantic models via direct .NET interop."""
|
||||
|
||||
__version__ = "3.10.4"
|
||||
__version__ = "3.10.10"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ _DIM = "\033[2m"
|
|||
_RESET = "\033[0m"
|
||||
|
||||
_ART = r"""
|
||||
██████╗ ██████╗ ██╗ ██████╗██╗ ██╗
|
||||
██╔══██╗██╔══██╗██║ ██╔════╝██║ ██║
|
||||
██████╔╝██████╔╝██║ ██║ ██║ ██║
|
||||
██╔═══╝ ██╔══██╗██║ ██║ ██║ ██║
|
||||
██║ ██████╔╝███████╗╚██████╗███████╗██║
|
||||
╚═╝ ╚═════╝ ╚══════╝ ╚═════╝╚══════╝╚═╝
|
||||
██████╗ ██████╗ ██╗ ██████╗ ██╗ ██╗
|
||||
██╔══██╗ ██╔══██╗ ██║ ██╔════╝ ██║ ██║
|
||||
██████╔╝ ██████╔╝ ██║ ███╗ ██║ ██║ ██║
|
||||
██╔═══╝ ██╔══██╗ ██║ ╚══╝ ██║ ██║ ██║
|
||||
██║ ██████╔╝ ██║ ╚██████╗ ███████╗ ██║
|
||||
╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝
|
||||
"""
|
||||
|
||||
_TAGLINE = "Power BI CLI · Direct .NET interop · Built for Claude Code"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
},
|
||||
"visual": {
|
||||
"visualType": "actionButton",
|
||||
"objects": {},
|
||||
"visualContainerObjects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
"isDefaultSort": true
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"visualContainerObjects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"Legend": {"projections": []}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"Legend": {"projections": []}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
},
|
||||
"visual": {
|
||||
"visualType": "image",
|
||||
"objects": {},
|
||||
"visualContainerObjects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
"Values": {"projections": [], "active": true}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
},
|
||||
"visual": {
|
||||
"visualType": "pageNavigator",
|
||||
"objects": {},
|
||||
"visualContainerObjects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
},
|
||||
"visual": {
|
||||
"visualType": "shape",
|
||||
"objects": {},
|
||||
"visualContainerObjects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
"Values": {"projections": []}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
},
|
||||
"visual": {
|
||||
"visualType": "textbox",
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"objects": {},
|
||||
"drillFilterOtherVisuals": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue