diff --git a/docs/source/upcoming_release_notes/566-screenshot.rst b/docs/source/upcoming_release_notes/566-screenshot.rst new file mode 100644 index 00000000..e882cc8c --- /dev/null +++ b/docs/source/upcoming_release_notes/566-screenshot.rst @@ -0,0 +1,27 @@ +566 screenshot +################# + +API Changes +----------- +- Added ``TyphosSuite.save_screenshot`` which takes a screenshot of the entire + suite as-displayed. +- Added ``TyphosSuite.save_device_screenshots`` which takes individual + screenshots of each device display in the suite and saves them to the + provided formatted filename. + +Features +-------- +- Add ``typhos --screenshot filename_pattern`` to take screenshots of typhos + displays prior to exiting early (in combination with ``--exit-after``). + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- N/A + +Contributors +------------ +- klauer diff --git a/typhos/cli.py b/typhos/cli.py index fa9c9322..cc98e6d8 100644 --- a/typhos/cli.py +++ b/typhos/cli.py @@ -1,4 +1,6 @@ """This module defines the ``typhos`` command line utility""" +from __future__ import annotations + import argparse import ast import inspect @@ -6,6 +8,7 @@ import re import signal import sys +import types from typing import Optional import coloredlogs @@ -25,6 +28,31 @@ logger = logging.getLogger(__name__) + +class TyphosArguments(types.SimpleNamespace): + """Type hints for ``typhos`` CLI entrypoint arguments.""" + + devices: list[str] + layout: str + cols: int + display_type: str + scrollable: str + size: Optional[str] + hide_displays: bool + happi_cfg: Optional[str] + fake_device: bool + version: bool + verbose: bool + dark: bool + stylesheet_override: Optional[list[str]] + stylesheet_add: Optional[list[str]] + profile_modules: Optional[list[str]] + profile_output: Optional[str] + benchmark: Optional[list[str]] + exit_after: Optional[float] + screenshot_filename: Optional[str] + + # Argument Parser Setup parser = argparse.ArgumentParser( description=( @@ -181,6 +209,16 @@ "seconds" ), ) +parser.add_argument( + '--screenshot', + dest="screenshot_filename", + help=( + "Save screenshot(s) of all contained TyphosDeviceDisplay instances to " + "this filename pattern prior to exiting early. This name may contain " + "f-string style variables, including: suite_title, widget_title, " + "device, and name." + ), +) # Append to module docs @@ -484,7 +522,8 @@ def typhos_run( initial_size: Optional[str] = None, show_displays: bool = True, exit_after: Optional[float] = None, -) -> QtWidgets.QMainWindow: + screenshot_filename: Optional[str] = None, +) -> Optional[QtWidgets.QMainWindow]: """ Run the central typhos part of typhos. @@ -516,6 +555,11 @@ def typhos_run( show_displays : bool, optional If True (default), open all the included device displays. If False, do not open any of the displays. + screenshot_filename : str, optional + Save screenshot(s) of all contained TyphosDeviceDisplay instances to + this filename pattern prior to exiting early. This name may contain + f-string style variables, including: suite_title, widget_title, + device, and name. Returns ------- @@ -534,34 +578,42 @@ def typhos_run( scroll_option=scroll_option, show_displays=show_displays, ) - if suite: - if initial_size is not None: - try: - initial_size = QtCore.QSize( - *(int(opt) for opt in initial_size.split(',')) - ) - except TypeError as exc: - raise ValueError( - "Invalid --size argument. Expected a two-element pair " - "of comma-separated integers, e.g. --size 1000,1000" - ) from exc - - def exit_early(): - logger.warning( - "Exiting typhos early due to --exit-after=%s CLI argument.", - exit_after + + if suite is None: + logger.debug("Suite creation failure") + return None + + if initial_size is not None: + try: + initial_size = QtCore.QSize( + *(int(opt) for opt in initial_size.split(',')) ) - sys.exit(0) + except TypeError as exc: + raise ValueError( + "Invalid --size argument. Expected a two-element pair " + "of comma-separated integers, e.g. --size 1000,1000" + ) from exc + + def exit_early(): + logger.warning( + "Exiting typhos early due to --exit-after=%s CLI argument.", + exit_after + ) + + if screenshot_filename is not None: + suite.save_device_screenshots(screenshot_filename) + + sys.exit(0) - if exit_after is not None and exit_after >= 0: - QtCore.QTimer.singleShot(exit_after * 1000.0, exit_early) + if exit_after is not None and exit_after >= 0: + QtCore.QTimer.singleShot(exit_after * 1000.0, exit_early) - return launch_suite(suite, initial_size=initial_size) + return launch_suite(suite, initial_size=initial_size) def typhos_cli(args): """Command Line Application for Typhos.""" - args = parser.parse_args(args) + args = parser.parse_args(args, TyphosArguments()) if args.version: print(f'Typhos: Version {typhos.__version__} from {typhos.__file__}') @@ -602,6 +654,7 @@ def typhos_cli(args): initial_size=args.size, show_displays=not args.hide_displays, exit_after=args.exit_after, + screenshot_filename=args.screenshot_filename, ) return suite diff --git a/typhos/display.py b/typhos/display.py index 85176fea..bdeb61d1 100644 --- a/typhos/display.py +++ b/typhos/display.py @@ -1405,6 +1405,9 @@ def add_device(self, device, macros=None): self.macros = self._build_macros_from_device(device, macros=macros) self.load_best_template() + if not self.windowTitle(): + self.setWindowTitle(getattr(device, "name", "")) + def search_for_templates(self): """Search the filesystem for device-specific templates.""" device = self.device diff --git a/typhos/suite.py b/typhos/suite.py index a4aa9382..04b6c5a9 100644 --- a/typhos/suite.py +++ b/typhos/suite.py @@ -384,7 +384,7 @@ def get_subdisplay(self, display): Parameters ---------- - display :str or Device + display : str or Device Name of screen or device Returns @@ -767,6 +767,63 @@ def save(self): # Add the template to the docstring save.__doc__ += textwrap.indent('\n' + utils.saved_template, '\t\t') + def save_screenshot( + self, + filename: str, + ) -> bool: + """Save a screenshot of this widget to ``filename``.""" + + image = utils.take_widget_screenshot(self) + if image is None: + logger.warning("Failed to take screenshot") + return False + + logger.info( + "Saving screenshot of suite titled '%s' to '%s'", + self.windowTitle(), filename, + ) + image.save(filename) + return True + + def save_device_screenshots( + self, + filename_format: str, + ) -> dict[str, str]: + """Save screenshot(s) of devices to ``filename_format``.""" + + filenames = {} + for device in self.devices: + display = self.get_subdisplay(device) + + if hasattr(display, "to_image"): + image = display.to_image() + else: + # This is a fallback for if/when we don't have a TyphosDisplay + image = utils.take_widget_screenshot(display) + + suite_title = self.windowTitle() + widget_title = display.windowTitle() + if image is None: + logger.warning( + "Failed to take screenshot of device: %s in %s", + device.name, suite_title, + ) + continue + + filename = filename_format.format( + suite_title=suite_title, + widget_title=widget_title, + device=device, + name=device.name, + ) + logger.info( + "Saving screenshot of '%s': '%s' to '%s'", + suite_title, widget_title, filename, + ) + image.save(filename) + filenames[device.name] = filename + return filenames + def _get_sidebar(self, widget): items = {} for group in self.top_level_groups.values(): diff --git a/typhos/tests/test_suite.py b/typhos/tests/test_suite.py index 15cbed99..9d055a1e 100644 --- a/typhos/tests/test_suite.py +++ b/typhos/tests/test_suite.py @@ -12,7 +12,7 @@ from typhos.suite import DeviceParameter, TyphosSuite from typhos.utils import save_suite -from .conftest import show_widget +from .conftest import MockDevice, show_widget @pytest.fixture(scope='function') @@ -23,7 +23,7 @@ def suite(qtbot, device): @show_widget -def test_suite_with_child_devices(suite, device): +def test_suite_with_child_devices(suite: TyphosSuite, device: MockDevice): assert device in suite.devices device_group = suite.top_level_groups['Devices'] assert len(device_group.childs) == 1 @@ -47,22 +47,22 @@ def test_suite_tools(device, qtbot): assert len(suite.tools[0].devices) == 1 -def test_suite_get_subdisplay_by_device(suite, device): +def test_suite_get_subdisplay_by_device(suite: TyphosSuite, device: MockDevice): display = suite.get_subdisplay(device) assert device in display.devices -def test_suite_subdisplay_parentage(suite, device): +def test_suite_subdisplay_parentage(suite: TyphosSuite, device: MockDevice): display = suite.get_subdisplay(device) assert display in suite.findChildren(TyphosDeviceDisplay) -def test_suite_get_subdisplay_by_name(suite, device): +def test_suite_get_subdisplay_by_name(suite: TyphosSuite, device: MockDevice): display = suite.get_subdisplay(device.name) assert device in display.devices -def test_suite_show_display_by_device(suite, device): +def test_suite_show_display_by_device(suite: TyphosSuite, device: MockDevice): suite.show_subdisplay(device.x) dock = suite._content_frame.layout().itemAt( suite.layout().count() - 1).widget() @@ -80,7 +80,7 @@ def test_suite_show_display_by_parameter(suite): assert dock.receivers(dock.closing) == 1 -def test_suite_hide_subdisplay_by_device(suite, device, qtbot): +def test_suite_hide_subdisplay_by_device(suite: TyphosSuite, device, qtbot): display = suite.get_subdisplay(device) suite.show_subdisplay(device) with qtbot.waitSignal(display.parent().closing): @@ -88,7 +88,7 @@ def test_suite_hide_subdisplay_by_device(suite, device, qtbot): assert display.parent().isHidden() -def test_suite_hide_subdisplay_by_parameter(suite, qtbot): +def test_suite_hide_subdisplay_by_parameter(suite: TyphosSuite, qtbot): device_param = suite.top_level_groups['Devices'].childs[0] suite.show_subdisplay(device_param) display = suite.get_subdisplay(device_param.device) @@ -98,7 +98,7 @@ def test_suite_hide_subdisplay_by_parameter(suite, qtbot): assert display.parent().isHidden() -def test_suite_hide_subdisplays(suite, device): +def test_suite_hide_subdisplays(suite: TyphosSuite, device: MockDevice): suite.show_subdisplay(device) suite.show_subdisplay(device.x) suite.show_subdisplay(device.y) @@ -123,19 +123,19 @@ def test_device_parameter_tree(qtbot, motor, device): devices.addChild(dev_param) -def test_suite_embed_device(suite, device): +def test_suite_embed_device(suite: TyphosSuite, device: MockDevice): suite.embed_subdisplay(device.x) dock_layout = suite.embedded_dock.widget().layout() assert dock_layout.itemAt(0).widget().devices[0] == device.x -def test_suite_embed_device_by_name(suite, device): +def test_suite_embed_device_by_name(suite: TyphosSuite, device: MockDevice): suite.embed_subdisplay(device.name) dock_layout = suite.embedded_dock.widget().layout() assert dock_layout.itemAt(0).widget().devices[0] == device -def test_hide_embedded_display(suite, device): +def test_hide_embedded_display(suite: TyphosSuite, device: MockDevice): suite.embed_subdisplay(device.x) suite.hide_subdisplay(device.x) display = suite.get_subdisplay(device.x) @@ -143,7 +143,7 @@ def test_hide_embedded_display(suite, device): assert display.isHidden() -def test_suite_save_util(suite, device): +def test_suite_save_util(suite: TyphosSuite, device: MockDevice): handle = io.StringIO() save_suite(suite, handle) handle.seek(0) @@ -151,7 +151,23 @@ def test_suite_save_util(suite, device): assert str(devices) in handle.read() -def test_suite_save(suite, monkeypatch): +def test_suite_save_screenshot(suite: TyphosSuite, device: MockDevice): + with tempfile.NamedTemporaryFile(mode="wb") as fp: + assert suite.save_screenshot(fp.name) + # We could check that the file isn't empty, but this may fail on CI + # or headless setups, so let's just trust that qt does its best to + # save as we request. + + +def test_suite_save_device_screenshots(suite: TyphosSuite, device: MockDevice): + with tempfile.NamedTemporaryFile(mode="wb") as fp: + # There's only one device, so we don't need to pass in a format here + screenshots = suite.save_device_screenshots(fp.name) + assert device.name in screenshots + assert len(screenshots) == 1 + + +def test_suite_save(suite: TyphosSuite, monkeypatch: pytest.MonkeyPatch): tfile = Path(tempfile.gettempdir()) / 'test.py' monkeypatch.setattr(QtWidgets.QFileDialog, 'getSaveFileName', @@ -164,7 +180,7 @@ def test_suite_save(suite, monkeypatch): os.remove(str(tfile)) -def test_suite_save_cancel_smoke(suite, monkeypatch): +def test_suite_save_cancel_smoke(suite: TyphosSuite, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(QtWidgets.QFileDialog, 'getSaveFileName', lambda *args: None) diff --git a/typhos/tests/test_utils.py b/typhos/tests/test_utils.py index 32ac3a1f..a881c018 100644 --- a/typhos/tests/test_utils.py +++ b/typhos/tests/test_utils.py @@ -5,18 +5,18 @@ import tempfile import pytest +import pytestqt.qtbot from ophyd import Component as Cpt from ophyd import Device from qtpy.QtCore import QRect from qtpy.QtGui import QColor, QPaintEvent, QPalette from qtpy.QtWidgets import QLineEdit, QWidget -import typhos -import typhos.utils -from typhos.utils import (TyphosBase, apply_standard_stylesheets, clean_name, - compose_stylesheets, load_suite, no_device_lazy_load, - saved_template, use_stylesheet) - +from .. import utils +from ..suite import TyphosSuite +from ..utils import (TyphosBase, apply_standard_stylesheets, clean_name, + compose_stylesheets, load_suite, no_device_lazy_load, + saved_template, use_stylesheet) from . import conftest @@ -38,7 +38,7 @@ def test_clean_name(): assert clean_name(device.radial.phi, strip_parent=device) == 'radial phi' -def test_compose_stylesheets(qtbot, qapp): +def test_compose_stylesheets(qtbot: pytestqt.qtbot.QtBot, qapp): """ With conflicting sheets, first sheet given has priority All non-conflicting sheets should be included @@ -102,7 +102,7 @@ def test_compose_stylesheets(qtbot, qapp): [None, [str(conftest.MODULE_PATH / "utils" / "tiny_stylesheet.qss")]], ) def test_stylesheet( - qtbot, + qtbot: pytestqt.qtbot.QtBot, monkeypatch, dark: bool, include_pydm: bool, @@ -115,8 +115,8 @@ def test_stylesheet( original_stylesheet = "QPushButton { color: red }" widget.setStyleSheet(original_stylesheet) - monkeypatch.setattr(typhos.utils, "PYDM_INCLUDE_DEFAULT", pydm_include_default) - monkeypatch.setattr(typhos.utils, "PYDM_USER_STYLESHEET", pydm_stylesheet) + monkeypatch.setattr(utils, "PYDM_INCLUDE_DEFAULT", pydm_include_default) + monkeypatch.setattr(utils, "PYDM_USER_STYLESHEET", pydm_stylesheet) apply_standard_stylesheets( dark=dark, @@ -148,21 +148,21 @@ def test_stylesheet( assert "tiny test stylesheet" not in new_stylesheet, "Explicit user stylesheet loaded unexpectedly" -def test_stylesheet_legacy(qtbot): +def test_stylesheet_legacy(qtbot: pytestqt.qtbot.QtBot): widget = QWidget() qtbot.addWidget(widget) use_stylesheet(widget=widget) use_stylesheet(widget=widget, dark=True) -def test_typhosbase_repaint_smoke(qtbot): +def test_typhosbase_repaint_smoke(qtbot: pytestqt.qtbot.QtBot): tp = TyphosBase() qtbot.addWidget(tp) pe = QPaintEvent(QRect(1, 2, 3, 4)) tp.paintEvent(pe) -def test_load_suite(qtbot, happi_cfg): +def test_load_suite(qtbot: pytestqt.qtbot.QtBot, happi_cfg): # Setup new saved file module = saved_template.format(devices=['test_motor']) module_file = str(pathlib.Path(tempfile.gettempdir()) / 'my_suite.py') @@ -171,7 +171,7 @@ def test_load_suite(qtbot, happi_cfg): suite = load_suite(module_file, happi_cfg) qtbot.addWidget(suite) - assert isinstance(suite, typhos.TyphosSuite) + assert isinstance(suite, TyphosSuite) assert len(suite.devices) == 1 assert suite.devices[0].name == 'test_motor' os.remove(module_file) @@ -179,7 +179,7 @@ def test_load_suite(qtbot, happi_cfg): def test_load_suite_with_bad_py_file(): with pytest.raises(AttributeError): - load_suite(typhos.utils.__file__) + load_suite(utils.__file__) def test_no_device_lazy_load(): @@ -257,7 +257,22 @@ def test_path_search(tmpdir, cls, view_type, create, expected): file = tmpdir.join(to_create) file.write('') - results = typhos.utils.find_templates_for_class( + results = utils.find_templates_for_class( cls, view_type, paths=[tmpdir]) assert list(r.name for r in results) == expected + + +def test_take_widget_screenshot(qtbot: pytestqt.qtbot.QtBot): + widget = QWidget() + qtbot.addWidget(widget) + image = utils.take_widget_screenshot(widget) + assert image is not None + + +def test_take_top_level_widget_screenshots(qtbot: pytestqt.qtbot.QtBot): + widget = QWidget() + qtbot.addWidget(widget) + screenshots = list(utils.take_top_level_widget_screenshots(visible_only=False)) + assert len(screenshots) >= 1 + assert any(w is widget for w, _ in screenshots) diff --git a/typhos/utils.py b/typhos/utils.py index c32ed90a..d9740950 100644 --- a/typhos/utils.py +++ b/typhos/utils.py @@ -20,7 +20,7 @@ import threading import weakref from types import MethodType -from typing import Iterable +from typing import Generator, Iterable, Optional import entrypoints import ophyd @@ -1704,3 +1704,62 @@ def set_no_edit_style(object: QtWidgets.QLineEdit): "QLineEdit { background: transparent }" ) object.setReadOnly(True) + + +def take_widget_screenshot(widget: QtWidgets.QWidget) -> Optional[QtGui.QImage]: + """Take a screenshot of the given widget, returning a QImage.""" + + app = QtWidgets.QApplication.instance() + if app is None: + # No apps, no screenshots! + return None + + primary_screen = app.primaryScreen() + logger.debug("Primary screen: %s", primary_screen) + + screen = ( + widget.screen() + if hasattr(widget, "screen") + else primary_screen + ) + + logger.info("Primary screen: %s widget screen: %s", primary_screen, screen) + return screen.grabWindow(widget.winId()) + + +def take_top_level_widget_screenshots( + *, visible_only: bool = True, +) -> Generator[ + tuple[QtWidgets.QWidget, QtGui.QImage], None, None +]: + """ + Yield screenshots of all top-level widgets. + + Parameters + ---------- + visible_only : bool, optional + Only take screenshots of visible widgets. + + Yields + ------ + widget : QtWidgets.QWidget + The widget relating to the screenshot. + + screenshot : QtGui.QImage + The screenshot image. + """ + app = QtWidgets.QApplication.instance() + if app is None: + # No apps, no screenshots! + return + + for screen_idx, screen in enumerate(app.screens(), 1): + logger.debug("Screen %d: %s %s", screen_idx, screen, screen.geometry()) + + def by_title(widget): + return widget.windowTitle() or str(id(widget)) + + for widget in sorted(app.topLevelWidgets(), key=by_title): + if visible_only and not widget.isVisible(): + continue + yield widget, take_widget_screenshot(widget)