"""Render pyqtgraph figures without an interactive window.
Regression plots are rendered with pyqtgraph in the graphical interface. This
module renders the same plot specification through the same renderer in an
offscreen widget, allowing interactive and pipeline-generated figures to
share one implementation.
:func:`spacr.figures.scene.pyqtgraph_ready` supplies a ``QApplication`` on
Qt's ``offscreen`` platform when no display is available. If Qt cannot create
a scene safely, rendering returns an explanatory refusal instead of reporting
a nonexistent output. Output format and resolution follow
:func:`spacr.plot.figure_output_preferences`, and completed files are
published through :func:`spacr.figure_sink.publish_file` for inclusion in the
run gallery.
"""
from __future__ import annotations
import logging
import os
from typing import Optional, Tuple
LOG = logging.getLogger("spacr.figures.headless")
#: Pixel size the offscreen widget is laid out at before it is exported.
#:
#: BIG ENOUGH THAT THE AXES ARE NOT CRAMPED. The vector export scales from
#: the scene, so this is not the resolution of the file -- but tick spacing,
#: label elision and legend layout are all decided at this size, and a plot
#: laid out at 200 px wide exports its cramped decisions faithfully.
RENDER_SIZE: Tuple[int, int] = (1400, 900)
#: What to say when there is no Qt at all. A run that silently stops
#: writing figures is the worst outcome, so the refusal is loud and names
#: the fix.
NO_QT = ("PySide6 is not importable, so the pyqtgraph figures cannot be "
"rendered. Install it with `pip install spacr` (it is a core "
"dependency) or run with `regression_qc=False` to skip them.")
#: What to say when Qt is here but no platform plugin will start.
NO_PLATFORM = ("Qt could not start any window platform, not even offscreen, "
"so the pyqtgraph figures cannot be rendered. Set "
"QT_QPA_PLATFORM=offscreen, or install the system Qt "
"libraries (libgl1, libegl1, libxkbcommon0).")
[docs]
def application():
"""The ``QApplication`` to render under, or ``(None, reason)``.
DELEGATED TO :func:`spacr.figures.scene.pyqtgraph_ready`, which is the
one place that answers "can a scene be built here and now". It knows two
things this module must not get wrong on its own:
* pyqtgraph binds its Qt library on FIRST import, and this environment
also has PyQt6 -- so a bare ``import pyqtgraph`` can leave PySide6
unloadable. It sets ``PYQTGRAPH_QT_LIB`` before anything imports it.
* a QWidget must be built on the GUI thread. The regression QC suite runs
on the run's worker thread under a live application, and a widget built
there lives on a thread that is about to end -- traced from two
segfaults that landed nowhere near the cause.
A second answer to that question is how one of those two rules gets
forgotten, so there is only one.
:returns: ``(app, "")`` on success, ``(None, reason)`` when rendering is
impossible. The reason is a sentence for the user, not a traceback.
"""
from .scene import pyqtgraph_ready
ok, reason = pyqtgraph_ready()
if not ok:
return None, reason or NO_PLATFORM
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return None, NO_PLATFORM
return app, ""
[docs]
def render_offscreen(spec, path: str, *, size: Optional[Tuple[int, int]] = None,
fmt: Optional[str] = None, title: str = "",
x_label: str = "", y_label: str = "",
publish: bool = True) -> Optional[str]:
"""Draw ``spec`` offscreen and write it to ``path``.
:param spec: a :class:`spacr.qt.widgets.grouped_plot.PlotSpec`.
:param path: destination. The extension is rewritten to the user's
figure-format preference unless ``fmt`` names one.
:param size: layout size in pixels; :data:`RENDER_SIZE` by default.
:param fmt: ``"pdf"``, ``"png"`` or ``"svg"`` to override the preference.
:param title: plot title. ``spec.title`` is used when empty.
:param x_label: horizontal axis label. ``spec.x_label`` when empty.
:param y_label: vertical axis label. ``spec.y_label`` when empty.
:param publish: announce the file to the figure sink. True by default,
because saved and visible are the same event.
:returns: the path written, or None with the reason logged at WARNING
when there is no Qt to render under.
"""
app, refusal = application()
if app is None:
LOG.warning("%s", refusal)
return None
from ..plot import figure_output_preferences
from ..qt.widgets.grouped_plot import GroupedPlot
chosen = str(fmt or figure_output_preferences()[0]).lower().lstrip(".")
stem, _ = os.path.splitext(str(path))
target = f"{stem}.{chosen}"
parent = os.path.dirname(os.path.abspath(target))
os.makedirs(parent, exist_ok=True)
plot = GroupedPlot(title=title or getattr(spec, "title", "") or "",
x_label=x_label or getattr(spec, "x_label", "") or "",
y_label=y_label or getattr(spec, "y_label", "") or "")
try:
plot.resize(*(size or RENDER_SIZE))
if not plot.show_spec(spec):
LOG.debug("nothing to draw for %s", target)
return None
app.processEvents()
written = plot.export(target)
finally:
plot.deleteLater()
if written and publish:
from ..figure_sink import publish_file
publish_file(written, title or getattr(spec, "title", "") or None)
return written
[docs]
def render_bundle(spec, folder: str, name: str, **kwargs) -> Optional[str]:
"""Render ``spec`` and write the whole folder beside it.
The same bundle :meth:`FastPlot.export_bundle` writes from the screen --
figure, data, statistics, settings -- produced by a run that has no
screen, so a generated figure and a saved one are the same thing.
:param spec: a :class:`spacr.qt.widgets.grouped_plot.PlotSpec`.
:param folder: parent directory for the bundle.
:param name: bundle name; also the figure's name inside it.
:returns: the bundle directory, or None when there is no Qt.
"""
app, refusal = application()
if app is None:
LOG.warning("%s", refusal)
return None
from ..qt.widgets.grouped_plot import GroupedPlot
plot = GroupedPlot(title=kwargs.pop("title", "") or name,
x_label=kwargs.pop("x_label", "") or "",
y_label=kwargs.pop("y_label", "") or "")
try:
plot.resize(*kwargs.pop("size", RENDER_SIZE))
if not plot.show_spec(spec):
return None
app.processEvents()
return plot.export_bundle(folder, name)
finally:
plot.deleteLater()