From b1f372bad19356cc49d759b09777cb225294b0a5 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 31 Aug 2026 22:11:27 -0400 Subject: [PATCH] Isolate `Qt` tests from process state Own XDG config roots before Piker or Qt imports and restore the caller's process environment when pytest exits. Also, - force offscreen rendering through an explicit `--headless` flag - restore QSettings, config globals and PyQtGraph registries per test - prove one QApplication can serve repeated tests without state leaks - document each teardown phase and a future public config-path API (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- tests/conftest.py | 97 +++++++++++++-- tests/ui/conftest.py | 252 +++++++++++++++++++++++++++++++++++++++ tests/ui/test_harness.py | 126 ++++++++++++++++++++ 3 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 tests/ui/conftest.py create mode 100644 tests/ui/test_harness.py diff --git a/tests/conftest.py b/tests/conftest.py index 0b098a76..2e41b7b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,47 @@ from contextlib import asynccontextmanager as acm -from collections.abc import Callable +from collections.abc import ( + Callable, + Iterator, +) from functools import partial import logging import os from pathlib import Path +import sys +from tempfile import TemporaryDirectory from weakref import ( ReferenceType, ref, ) -# These must be selected before test modules import Qt. -os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +# These must be selected before test modules import Qt or Piker. +_original_qt_qpa_platform: str|None = os.environ.get( + 'QT_QPA_PLATFORM', +) +_original_pytest_qt_api: str|None = os.environ.get( + 'PYTEST_QT_API', +) +_headless_qt: bool = '--headless' in sys.argv +if _headless_qt: + os.environ['QT_QPA_PLATFORM'] = 'offscreen' +else: + os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') os.environ.setdefault('PYTEST_QT_API', 'pyqt6') +_original_xdg_config_home: str|None = os.environ.get( + 'XDG_CONFIG_HOME', +) +_original_xdg_config_dirs: str|None = os.environ.get( + 'XDG_CONFIG_DIRS', +) +_xdg_config_home_owner: TemporaryDirectory[str] = ( + TemporaryDirectory(prefix='piker-pytest-xdg-') +) +_xdg_config_home: Path = Path(_xdg_config_home_owner.name) +_xdg_config_dirs: Path = _xdg_config_home / 'system-config' +_xdg_config_dirs.mkdir() +os.environ['XDG_CONFIG_HOME'] = str(_xdg_config_home) +os.environ['XDG_CONFIG_DIRS'] = str(_xdg_config_dirs) +_xdg_config_restored: bool = False import pytest import tractor @@ -30,21 +60,74 @@ pytest_plugins: tuple[str] = ( ) +@pytest.fixture(scope='session') +def isolated_xdg_config_home() -> Iterator[Path]: + ''' + Own process-wide config isolation through session teardown. + + ''' + yield _xdg_config_home + + +def _restore_test_process_environment() -> None: + ''' + Release process-owned XDG isolation exactly once. + + ''' + global _xdg_config_restored + + if _xdg_config_restored: + return + _xdg_config_restored = True + + if _original_xdg_config_home is None: + os.environ.pop('XDG_CONFIG_HOME', None) + else: + os.environ['XDG_CONFIG_HOME'] = _original_xdg_config_home + if _original_xdg_config_dirs is None: + os.environ.pop('XDG_CONFIG_DIRS', None) + else: + os.environ['XDG_CONFIG_DIRS'] = _original_xdg_config_dirs + if _original_qt_qpa_platform is None: + os.environ.pop('QT_QPA_PLATFORM', None) + else: + os.environ['QT_QPA_PLATFORM'] = ( + _original_qt_qpa_platform + ) + if _original_pytest_qt_api is None: + os.environ.pop('PYTEST_QT_API', None) + else: + os.environ['PYTEST_QT_API'] = _original_pytest_qt_api + _xdg_config_home_owner.cleanup() + + +def pytest_configure(config: pytest.Config) -> None: + ''' + Guarantee import-time XDG ownership is released by pytest. + + ''' + config.add_cleanup(_restore_test_process_environment) + + @pytest.fixture(scope='session') def qapp_args( - tmp_path_factory: pytest.TempPathFactory, + isolated_xdg_config_home: Path, ) -> list[str]: ''' - Isolate Qt settings before pytest-qt creates `QApplication`. + Configure pytest-qt after process-wide config isolation. ''' - config_home: Path = tmp_path_factory.mktemp('qt-config') - os.environ['XDG_CONFIG_HOME'] = str(config_home) + assert isolated_xdg_config_home == _xdg_config_home return ['piker-tests'] def pytest_addoption(parser): + parser.addoption( + '--headless', + action='store_true', + help='Force Qt onto the offscreen platform before imports', + ) parser.addoption("--ll", action="store", dest='loglevel', default=None, help="logging level to set when testing") parser.addoption("--confdir", default=None, diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 00000000..0ce40d51 --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,252 @@ +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, + ) diff --git a/tests/ui/test_harness.py b/tests/ui/test_harness.py new file mode 100644 index 00000000..73df6f1b --- /dev/null +++ b/tests/ui/test_harness.py @@ -0,0 +1,126 @@ +from pathlib import Path +from types import SimpleNamespace + +from PyQt6.QtCore import QSettings +from PyQt6.QtWidgets import ( + QApplication, + QWidget, +) +import pyqtgraph as pg +import pytest +from pytestqt.qtbot import QtBot + +from piker import config + + +@pytest.fixture(scope='module') +def module_qapp( + qapp: QApplication, + +) -> QApplication: + ''' + Pin pytest-qt's session application for this test module. + + ''' + return qapp + + +def test_ui_state_is_process_isolated_and_restored( + isolated_ui_state: SimpleNamespace, + module_qapp: QApplication, + pytestconfig: pytest.Config, + qtbot: QtBot, + +) -> None: + ''' + Prove imports and one UI test cannot reach user config state. + + Previously `qapp_args` assigned `XDG_CONFIG_HOME` only after + Piker was imported, so `piker.config` cached real user paths + before Qt setup. This test verifies every cached path starts + below the process-owned temporary XDG root, then redirects those + globals and writes both a file and `QSettings` key. The fixture's + exact-state teardown proves those mutations disappear while its + process sentinels survive for the next test. + + ''' + state: SimpleNamespace = isolated_ui_state + assert module_qapp is state.qapp + assert QApplication.instance() is state.qapp + if pytestconfig.getoption('headless'): + assert state.qapp.platformName() == 'offscreen' + assert config._click_config_dir.is_relative_to( + state.process_config_home, + ) + assert config._config_dir.is_relative_to( + state.process_config_home, + ) + assert config._watchlists_data_path.is_relative_to( + state.process_config_home, + ) + + widget: QWidget = QWidget() + qtbot.addWidget(widget) + widget.show() + + owned_dir: Path = state.test_config_dir + config._click_config_dir = owned_dir + config._config_dir = owned_dir + config._watchlists_data_path = owned_dir / 'watchlists.json' + (owned_dir / 'owned').write_text('remove-me', encoding='utf-8') + + settings: QSettings = QSettings('pikers', 'piker') + settings.setFallbacksEnabled(False) + settings_path: Path = Path(settings.fileName()) + assert settings_path.is_relative_to(state.process_config_home) + assert settings.value(state.preexisting_setting) == 'keep-me' + settings.setValue(state.owned_setting, 'remove-me') + settings.sync() + + +def test_ui_state_reuses_qapp_without_previous_test_leaks( + isolated_ui_state: SimpleNamespace, + module_qapp: QApplication, + qtbot: QtBot, + +) -> None: + ''' + Prove repeated tests reuse Qt without inheriting mutable state. + + A session `QApplication` can retain widgets, settings and + PyQtGraph's `ViewBox.AllViews` entries from an earlier test. This + test runs after the mutation case, verifies the same application + and preserved process sentinel but no test-owned key, then + creates a real named `PlotWidget` and mutates Qt/PyQtGraph + options. Normal pytest-qt widget closure plus the isolation + fixture's registry assertions prove the second test also leaves + no state behind. + + ''' + state: SimpleNamespace = isolated_ui_state + assert module_qapp is state.qapp + assert QApplication.instance() is state.qapp + + settings: QSettings = QSettings('pikers', 'piker') + settings.setFallbacksEnabled(False) + assert settings.value(state.preexisting_setting) == 'keep-me' + assert not settings.contains(state.owned_setting) + assert state.preexisting_config.read_text( + encoding='utf-8', + ) == 'keep-me' + assert list(state.test_config_dir.iterdir()) == [] + + plot: pg.PlotWidget = pg.PlotWidget(name='pytest-owned-view') + qtbot.addWidget(plot) + plot.show() + assert pg.ViewBox.NamedViews['pytest-owned-view'] is ( + plot.plotItem.vb + ) + + pg.setConfigOption( + 'antialias', + not pg.getConfigOption('antialias'), + ) + state.qapp.setQuitOnLastWindowClosed( + not state.qapp.quitOnLastWindowClosed(), + )