Skip to content

Commit 8fa86c2

Browse files
FBumannclaude
andcommitted
feat: plot -o exports an image when the suffix is static (png/svg/pdf/...)
`plot -o out.png` always wrote HTML regardless of the extension. Branch on the output suffix instead: .png/.svg/.pdf/.jpg/.jpeg/.webp go through kaleido's write_image, .html (or a bare name) stays interactive HTML, and an unrecognised suffix is rejected rather than silently written as HTML. kaleido lives in a new optional [plot-static] extra so interactive-HTML [plot] stays lightweight; a missing-kaleido image export fails with a clear Exit(2) naming the install. Closes #145 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e600841 commit 8fa86c2

4 files changed

Lines changed: 106 additions & 3 deletions

File tree

docs/reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ live from the typer app as it actually renders in a terminal:
181181
:width: 100
182182
:scheme: osx
183183

184+
`plot -o` picks the writer from the suffix: `.html` (the default) is an interactive plotly
185+
page, while `.png` / `.svg` / `.pdf` / `.jpg` / `.webp` export a static image for a PR comment,
186+
README, or docs page. Static export needs kaleido — install `pytest-benchmem[plot-static]`.
187+
184188
## Public Python API
185189

186190
Light to import — `pytest_benchmem` re-exports only the engine and the readers;

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ dependencies = [
2727

2828
[project.optional-dependencies]
2929
plot = ["pandas", "plotly>=5", "typer>=0.12"]
30+
# kaleido (and a Chromium it drives) is only needed to export a plot as a static image
31+
# (plot -o out.png / .svg / .pdf); kept out of [plot] so interactive HTML stays lightweight.
32+
plot-static = ["pytest-benchmem[plot]", "kaleido"]
3033

3134
[project.scripts]
3235
benchmem = "pytest_benchmem.cli:app"
@@ -49,7 +52,7 @@ source = "vcs"
4952

5053
[dependency-groups]
5154
# mypy <2.1: 2.1.0 errors on numpy's PEP 695 stub under python_version=3.11. Lift once fixed upstream.
52-
dev = ["ruff", "mypy<2.2", "pre-commit", "pytest-benchmem[plot]", { include-group = "docs" }]
55+
dev = ["ruff", "mypy<2.2", "pre-commit", "pytest-benchmem[plot-static]", { include-group = "docs" }]
5356
docs = [
5457
"jupytext",
5558
"ipykernel",

src/pytest_benchmem/cli.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,35 @@ def _need_plotly() -> None:
7878
raise _fail("plotting needs extras: pip install 'pytest-benchmem[plot]'", 2)
7979

8080

81+
# Static raster/vector suffixes go through kaleido's write_image; .html (or a bare name) stays
82+
# interactive HTML. Anything else is a typo we refuse rather than silently writing HTML.
83+
_IMAGE_SUFFIXES = frozenset({".png", ".svg", ".pdf", ".jpg", ".jpeg", ".webp"})
84+
_HTML_SUFFIXES = frozenset({"", ".html", ".htm"})
85+
86+
87+
def _write_figure(fig: object, output: Path) -> None:
88+
"""Export ``fig`` to ``output``, choosing write_image vs write_html by the suffix.
89+
90+
Image suffixes need kaleido (the ``[plot-static]`` extra); raise a clear Exit(2) naming
91+
the install when it is absent. An unrecognised suffix is rejected rather than silently
92+
written as HTML.
93+
"""
94+
suffix = output.suffix.lower()
95+
if suffix in _IMAGE_SUFFIXES:
96+
if importlib.util.find_spec("kaleido") is None:
97+
raise _fail(
98+
f"{suffix} export needs kaleido: pip install 'pytest-benchmem[plot-static]' "
99+
f"(or use an .html output for the interactive plot)",
100+
2,
101+
)
102+
fig.write_image(output) # type: ignore[attr-defined]
103+
elif suffix in _HTML_SUFFIXES:
104+
fig.write_html(output) # type: ignore[attr-defined]
105+
else:
106+
supported = ", ".join(sorted(_IMAGE_SUFFIXES | {".html"}))
107+
raise _fail(f"unsupported output suffix {suffix!r}; use one of: {supported}", 2)
108+
109+
81110
def _parse_where(items: list[str] | None) -> dict[str, str] | None:
82111
"""Parse repeatable ``KEY=VALUE`` filters into a dict; Exit(2) on a malformed entry."""
83112
if not items:
@@ -133,7 +162,14 @@ def plot(
133162
"--label", "-l", help="Series label per run, in order (repeat). Default: stem."
134163
),
135164
] = None,
136-
output: Annotated[Path | None, typer.Option("--output", "-o", help="HTML out.")] = None,
165+
output: Annotated[
166+
Path | None,
167+
typer.Option(
168+
"--output",
169+
"-o",
170+
help="Out file; .html is interactive, .png/.svg/.pdf/.jpg/.webp export a static image.",
171+
),
172+
] = None,
137173
open_browser: Annotated[bool, typer.Option("--open/--no-open")] = False,
138174
) -> None:
139175
"""Render an interactive plotly view from one or more pytest-benchmark runs."""
@@ -202,7 +238,7 @@ def plot(
202238

203239
output = output or Path(".benchmarks") / "plots" / f"{chosen}-{metric}.html"
204240
output.parent.mkdir(parents=True, exist_ok=True)
205-
fig.write_html(output)
241+
_write_figure(fig, output)
206242
typer.secho(f"{chosen} ({metric}): {n} ids → {output}", fg=typer.colors.GREEN)
207243
if open_browser:
208244
import webbrowser

tests/test_cli.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ def _bm(name, *, t=1.0, peak=None):
4646
class _FakeFig:
4747
def write_html(self, path):
4848
self.path = path
49+
self.kind = "html"
50+
51+
def write_image(self, path):
52+
self.path = path
53+
self.kind = "image"
4954

5055

5156
# --- compare ---------------------------------------------------------------------
@@ -334,6 +339,61 @@ def test_plot_missing_file_exits_2(tmp_path):
334339
assert "missing" in _text(result)
335340

336341

342+
def _stub_view(plotting_mod, monkeypatch, fig):
343+
"""Point every plot_* view at a stub returning ``fig``, so -o export is what's exercised."""
344+
for name in ("plot_compare", "plot_scatter", "plot_sweep", "plot_scaling"):
345+
monkeypatch.setattr(plotting_mod, name, lambda *a, _f=fig, **k: (_f, 1))
346+
347+
348+
def test_plot_image_suffix_calls_write_image(tmp_path, monkeypatch):
349+
"""An image suffix routes through write_image (kaleido) instead of write_html."""
350+
import pytest_benchmem.cli as cli
351+
352+
monkeypatch.setattr(cli.importlib.util, "find_spec", lambda name: object())
353+
fig = _FakeFig()
354+
_stub_view(plotting, monkeypatch, fig)
355+
r = _run(tmp_path, "r.json", [_bm("x")])
356+
out = tmp_path / "out.png"
357+
result = runner.invoke(app, ["plot", str(r), "-o", str(out)])
358+
assert result.exit_code == 0, _text(result)
359+
assert fig.kind == "image" and fig.path == out
360+
361+
362+
def test_plot_image_suffix_without_kaleido_exits_2(tmp_path, monkeypatch):
363+
"""A static suffix without kaleido is a clean error naming the extra, not a crash."""
364+
import pytest_benchmem.cli as cli
365+
366+
# plotly present (plot gate passes), kaleido absent (image gate fails).
367+
monkeypatch.setattr(
368+
cli.importlib.util,
369+
"find_spec",
370+
lambda name: None if name == "kaleido" else object(),
371+
)
372+
_stub_view(plotting, monkeypatch, _FakeFig())
373+
r = _run(tmp_path, "r.json", [_bm("x")])
374+
result = runner.invoke(app, ["plot", str(r), "-o", str(tmp_path / "out.png")])
375+
assert result.exit_code == 2
376+
assert "kaleido" in _text(result) and "plot-static" in _text(result)
377+
378+
379+
def test_plot_unsupported_suffix_exits_2(tmp_path, monkeypatch):
380+
"""A typo'd suffix is rejected rather than silently written as HTML."""
381+
_stub_view(plotting, monkeypatch, _FakeFig())
382+
r = _run(tmp_path, "r.json", [_bm("x")])
383+
result = runner.invoke(app, ["plot", str(r), "-o", str(tmp_path / "out.pong")])
384+
assert result.exit_code == 2
385+
assert "unsupported output suffix" in _text(result)
386+
387+
388+
def test_plot_html_suffix_calls_write_html(tmp_path, monkeypatch):
389+
fig = _FakeFig()
390+
_stub_view(plotting, monkeypatch, fig)
391+
r = _run(tmp_path, "r.json", [_bm("x")])
392+
result = runner.invoke(app, ["plot", str(r), "-o", str(tmp_path / "out.html")])
393+
assert result.exit_code == 0, _text(result)
394+
assert fig.kind == "html"
395+
396+
337397
def test_plot_value_error_exits_1(tmp_path, monkeypatch):
338398
def boom(*a, **k):
339399
raise ValueError("no ids in common")

0 commit comments

Comments
 (0)