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
parent
233fa590f9
commit
b1f372bad1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
Loading…
Reference in New Issue