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`))
wkt/replay_provider_e2e
Gud Boi 2026-08-31 22:11:27 -04:00
parent 233fa590f9
commit b1f372bad1
3 changed files with 468 additions and 7 deletions

View File

@ -1,17 +1,47 @@
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from collections.abc import Callable from collections.abc import (
Callable,
Iterator,
)
from functools import partial from functools import partial
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import sys
from tempfile import TemporaryDirectory
from weakref import ( from weakref import (
ReferenceType, ReferenceType,
ref, ref,
) )
# These must be selected before test modules import Qt. # These must be selected before test modules import Qt or Piker.
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') _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') 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 pytest
import tractor 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') @pytest.fixture(scope='session')
def qapp_args( def qapp_args(
tmp_path_factory: pytest.TempPathFactory, isolated_xdg_config_home: Path,
) -> list[str]: ) -> 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') assert isolated_xdg_config_home == _xdg_config_home
os.environ['XDG_CONFIG_HOME'] = str(config_home)
return ['piker-tests'] return ['piker-tests']
def pytest_addoption(parser): 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', parser.addoption("--ll", action="store", dest='loglevel',
default=None, help="logging level to set when testing") default=None, help="logging level to set when testing")
parser.addoption("--confdir", default=None, parser.addoption("--confdir", default=None,

View File

@ -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,
)

View File

@ -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(),
)