from collections.abc import Iterator from pathlib import Path import shutil from types import SimpleNamespace from PyQt6.QtCore import ( QCoreApplication, QEvent, QSettings, ) from PyQt6.QtWidgets import ( QApplication, QWidget, ) import pyqtgraph as pg from pyqtgraph import ViewBox import pytest from pytestqt.qtbot import QtBot from piker import config _PREEXISTING_SETTING: str = 'pytest/preexisting' _OWNED_SETTING: str = 'pytest/owned' @pytest.fixture(scope='session', autouse=True) def ui_process_sentinel( isolated_xdg_config_home: Path, ) -> Iterator[Path]: ''' Keep known process-owned state across all UI tests. ''' # TODO: expose public config-dir get/set APIs like # `modden.config.dirs.get_conf_dir()` and `set_conf_dir()` so # tests do not reach into Piker's import-cached path globals. config_dir: Path = config._click_config_dir config_dir.mkdir(parents=True, exist_ok=True) sentinel_path: Path = config_dir / 'pytest-preexisting' sentinel_existed: bool = sentinel_path.exists() sentinel_bytes: bytes|None = None if sentinel_existed: sentinel_bytes = sentinel_path.read_bytes() sentinel_path.write_text('keep-me', encoding='utf-8') settings: QSettings = QSettings('pikers', 'piker') settings.setFallbacksEnabled(False) setting_existed: bool = settings.contains( _PREEXISTING_SETTING, ) setting_value: object = settings.value( _PREEXISTING_SETTING, ) settings.setValue(_PREEXISTING_SETTING, 'keep-me') settings.sync() try: assert config_dir.is_relative_to(isolated_xdg_config_home) yield sentinel_path finally: if setting_existed: settings.setValue( _PREEXISTING_SETTING, setting_value, ) else: settings.remove(_PREEXISTING_SETTING) settings.sync() if sentinel_existed: assert sentinel_bytes is not None sentinel_path.write_bytes(sentinel_bytes) else: sentinel_path.unlink(missing_ok=True) @pytest.fixture(autouse=True) def isolated_ui_state( qapp: QApplication, qtbot: QtBot, tmp_path: Path, isolated_xdg_config_home: Path, ui_process_sentinel: Path, ) -> Iterator[SimpleNamespace]: ''' Restore mutable process state after one real Qt test. Pytest-qt owns the shared `QApplication` and closes widgets before this fixture unwinds. This guard snapshots the remaining process globals, removes exact test-owned config, and reports any widget or PyQtGraph object that survived normal pytest-qt cleanup. ''' top_levels: set[QWidget] = set( qapp.topLevelWidgets() ) all_views: set[ViewBox] = set(ViewBox.AllViews) named_views: dict[str, ViewBox] = dict(ViewBox.NamedViews) pg_options: dict[str, object] = dict(pg.CONFIG_OPTIONS) quit_on_last_window: bool = ( qapp.quitOnLastWindowClosed() ) config_paths: dict[str, Path] = { '_click_config_dir': config._click_config_dir, '_config_dir': config._config_dir, '_watchlists_data_path': config._watchlists_data_path, } settings: QSettings = QSettings('pikers', 'piker') settings.setFallbacksEnabled(False) settings_state: dict[str, object] = { key: settings.value(key) for key in settings.allKeys() } test_config_dir: Path = tmp_path / 'piker-config' test_config_dir.mkdir() state: SimpleNamespace = SimpleNamespace( qapp=qapp, process_config_home=isolated_xdg_config_home, preexisting_config=ui_process_sentinel, test_config_dir=test_config_dir, preexisting_setting=_PREEXISTING_SETTING, owned_setting=_OWNED_SETTING, ) try: yield state finally: # Collect every isolation failure so cleanup of one process # global cannot prevent restoration of the remaining globals. errors: list[Exception] = [] # Pytest-qt closes registered widgets before fixture # teardown, but Qt completes `QWidget.deleteLater()` # asynchronously. QCoreApplication.sendPostedEvents( None, QEvent.Type.DeferredDelete, ) qapp.processEvents() # Any addition to `QApplication.topLevelWidgets()` after the # deferred-delete drain is a widget pytest-qt failed to own. leaked_widgets: set[QWidget] = ( set(qapp.topLevelWidgets()) - top_levels ) for widget in leaked_widgets: widget.close() widget.deleteLater() QCoreApplication.sendPostedEvents( None, QEvent.Type.DeferredDelete, ) qapp.processEvents() if leaked_widgets: errors.append(AssertionError( 'pytest-qt left test-owned top-level widgets alive: ' f'{leaked_widgets!r}' )) # `ViewBox.AllViews` and `ViewBox.NamedViews` are # process-wide weak registries used for linked-axis # discovery. # Close leaked views, report either registry delta, then # restore the exact baseline so a failure cannot contaminate # the next test. leaked_views: set[ViewBox] = ( set(ViewBox.AllViews) - all_views ) for view in leaked_views: view.close() if leaked_views: errors.append(AssertionError( 'test-owned `ViewBox.AllViews` entries survived: ' f'{leaked_views!r}' )) named_views_changed: bool = ( dict(ViewBox.NamedViews) != named_views ) if named_views_changed: errors.append(AssertionError( 'test mutated `ViewBox.NamedViews` without cleanup' )) ViewBox.AllViews.clear() ViewBox.AllViews.update({ view: None for view in all_views }) ViewBox.NamedViews.clear() ViewBox.NamedViews.update(named_views) ViewBox.updateAllViewLists() # PyQtGraph options and Qt's last-window policy are mutable # process globals, not properties owned by one test widget. pg.CONFIG_OPTIONS.clear() pg.CONFIG_OPTIONS.update(pg_options) qapp.setQuitOnLastWindowClosed(quit_on_last_window) # Rebuild this test application's `QSettings` keys exactly: # test-owned keys disappear while pre-existing values # survive. for key in settings.allKeys(): settings.remove(key) for key, value in settings_state.items(): settings.setValue(key, value) settings.sync() # Restore Piker's import-cached path globals before deleting # the function-owned config tree to prevent a dangling path. for name, path in config_paths.items(): setattr(config, name, path) shutil.rmtree(test_config_dir, ignore_errors=True) # Re-read every restored process value instead of trusting # the cleanup assignments; each mismatch joins one report. if dict(pg.CONFIG_OPTIONS) != pg_options: errors.append(AssertionError( '`pyqtgraph.CONFIG_OPTIONS` was not restored' )) if qapp.quitOnLastWindowClosed() != quit_on_last_window: errors.append(AssertionError( '`QApplication.quitOnLastWindowClosed()` changed' )) if { name: getattr(config, name) for name in config_paths } != config_paths: errors.append(AssertionError( 'Piker config path globals were not restored' )) if { key: settings.value(key) for key in settings.allKeys() } != settings_state: errors.append(AssertionError( '`QSettings` keys were not restored exactly' )) if test_config_dir.exists(): errors.append(AssertionError( 'test-owned config directory survived teardown' )) # Raise only after all restoration and verification # completes, preserving each leak signal for diagnosis. if errors: raise ExceptionGroup( 'UI isolation teardown failed', errors, )