From 54d698cd7a2436bfd9b7f430d1428a2143843c3f Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Tue, 8 Aug 2023 13:18:33 -0700 Subject: [PATCH 1/7] ENH: add in some screenshotting utilities --- typhos/cli.py | 93 +++++++++++++++++++++++++++++++++++++------------ typhos/suite.py | 55 ++++++++++++++++++++++++++++- typhos/utils.py | 61 +++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 24 deletions(-) diff --git a/typhos/cli.py b/typhos/cli.py index fa9c9322..0f5d1f5f 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,8 @@ import re import signal import sys +import types +import typing from typing import Optional import coloredlogs @@ -25,6 +29,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 +210,14 @@ "seconds" ), ) +parser.add_argument( + '--screenshot', + dest="screenshot_filename", + help=( + "Save a screenshot of the generated display(s) prior to exiting to " + "this filename" + ), +) # Append to module docs @@ -484,7 +521,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 +554,8 @@ 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 a screenshot to this file prior to exiting early. Returns ------- @@ -534,34 +574,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 = typing.cast(TyphosArguments, parser.parse_args(args)) if args.version: print(f'Typhos: Version {typhos.__version__} from {typhos.__file__}') @@ -602,6 +650,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/suite.py b/typhos/suite.py index a4aa9382..c6cb6efa 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,59 @@ 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, + ) -> bool: + """Save screenshot(s) of devices to ``filename_format``.""" + + 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) + + if image is None: + logger.warning("Failed to take screenshot") + return False + + suite_title = self.windowTitle() + widget_title = display.windowTitle() + 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) + return True + def _get_sidebar(self, widget): items = {} for group in self.top_level_groups.values(): 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) From 5f9e00f795a96c3212362eff7e00c0f70efa091a Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Tue, 8 Aug 2023 13:21:44 -0700 Subject: [PATCH 2/7] MNT: warning with title info --- typhos/suite.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/typhos/suite.py b/typhos/suite.py index c6cb6efa..c922a2e9 100644 --- a/typhos/suite.py +++ b/typhos/suite.py @@ -789,9 +789,10 @@ def save_screenshot( def save_device_screenshots( self, filename_format: str, - ) -> bool: + ) -> dict[str, str]: """Save screenshot(s) of devices to ``filename_format``.""" + filenames = {} for device in self.devices: display = self.get_subdisplay(device) @@ -801,12 +802,15 @@ def save_device_screenshots( # This is a fallback for if/when we don't have a TyphosDisplay image = utils.take_widget_screenshot(display) - if image is None: - logger.warning("Failed to take screenshot") - return False - 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, @@ -818,7 +822,8 @@ def save_device_screenshots( suite_title, widget_title, filename, ) image.save(filename) - return True + filenames[device.name] = filename + return filenames def _get_sidebar(self, widget): items = {} From 881dff4b82dd74d80f7a5ef4525c6ee7b08c9044 Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Tue, 8 Aug 2023 13:55:18 -0700 Subject: [PATCH 3/7] MNT: set TyphosDeviceDisplay title to device name if unset --- typhos/display.py | 3 +++ 1 file changed, 3 insertions(+) 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 From bd02669c2207c3cd7dea9bfbf86e6e1134ca2684 Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Wed, 9 Aug 2023 09:35:46 -0700 Subject: [PATCH 4/7] TST: screenshots/utils --- typhos/suite.py | 1 - typhos/tests/test_suite.py | 46 +++++++++++++++++++++++++------------ typhos/tests/test_utils.py | 47 +++++++++++++++++++++++++------------- 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/typhos/suite.py b/typhos/suite.py index c922a2e9..04b6c5a9 100644 --- a/typhos/suite.py +++ b/typhos/suite.py @@ -776,7 +776,6 @@ def save_screenshot( image = utils.take_widget_screenshot(self) if image is None: logger.warning("Failed to take screenshot") - return False logger.info( 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) From 2013b3d54c11719d7dce90fa3155541a1fce1a49 Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Wed, 9 Aug 2023 09:43:50 -0700 Subject: [PATCH 5/7] DOC: pre-release notes --- .../upcoming_release_notes/566-screenshot.rst | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/source/upcoming_release_notes/566-screenshot.rst 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 From 599911b4aef254648f873e00e9f3de15f523407b Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Wed, 9 Aug 2023 13:55:36 -0700 Subject: [PATCH 6/7] MNT: use built-in parse_args support for ns --- typhos/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/typhos/cli.py b/typhos/cli.py index 0f5d1f5f..f9a8c9cc 100644 --- a/typhos/cli.py +++ b/typhos/cli.py @@ -9,7 +9,6 @@ import signal import sys import types -import typing from typing import Optional import coloredlogs @@ -609,7 +608,7 @@ def exit_early(): def typhos_cli(args): """Command Line Application for Typhos.""" - args = typing.cast(TyphosArguments, parser.parse_args(args)) + args = parser.parse_args(args, TyphosArguments()) if args.version: print(f'Typhos: Version {typhos.__version__} from {typhos.__file__}') From 93dadca1f55409ea101397212c1b49abef97bb31 Mon Sep 17 00:00:00 2001 From: Ken Lauer Date: Wed, 9 Aug 2023 14:00:35 -0700 Subject: [PATCH 7/7] MNT: better document CLI parameter --- typhos/cli.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/typhos/cli.py b/typhos/cli.py index f9a8c9cc..cc98e6d8 100644 --- a/typhos/cli.py +++ b/typhos/cli.py @@ -213,8 +213,10 @@ class TyphosArguments(types.SimpleNamespace): '--screenshot', dest="screenshot_filename", help=( - "Save a screenshot of the generated display(s) prior to exiting to " - "this filename" + "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." ), ) @@ -554,7 +556,10 @@ def typhos_run( If True (default), open all the included device displays. If False, do not open any of the displays. screenshot_filename : str, optional - Save a screenshot to this file prior to exiting early. + 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 -------