Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prevent Config.add_cleanup callbacks preventing other cleanups running #12982

Merged
merged 8 commits into from
Nov 24, 2024
1 change: 1 addition & 0 deletions changelog/12981.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevent exceptions in :func:`pytest.Config.add_cleanup` preventing further cleanups.
graingert marked this conversation as resolved.
Show resolved Hide resolved
35 changes: 24 additions & 11 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import collections.abc
import contextlib
import copy
import dataclasses
import enum
Expand Down Expand Up @@ -33,6 +34,7 @@
from typing import TextIO
from typing import Type
from typing import TYPE_CHECKING
from typing import TypeVar
import warnings

import pluggy
Expand Down Expand Up @@ -73,6 +75,8 @@
from _pytest.cacheprovider import Cache
from _pytest.terminal import TerminalReporter

_T_callback = TypeVar("_T_callback", bound=Callable[[], None])


_PluggyPlugin = object
"""A type to represent plugin objects.
Expand Down Expand Up @@ -1077,7 +1081,7 @@ def __init__(
self._inicache: dict[str, Any] = {}
self._override_ini: Sequence[str] = ()
self._opt2dest: dict[str, str] = {}
self._cleanup: list[Callable[[], None]] = []
self._cleanup_stack = contextlib.ExitStack()
self.pluginmanager.register(self, "pytestconfig")
self._configured = False
self.hook.pytest_addoption.call_historic(
Expand All @@ -1104,10 +1108,14 @@ def inipath(self) -> pathlib.Path | None:
"""
return self._inipath

def add_cleanup(self, func: Callable[[], None]) -> None:
def add_cleanup(self, func: _T_callback) -> _T_callback:
"""Add a function to be called when the config object gets out of
use (usually coinciding with pytest_unconfigure)."""
self._cleanup.append(func)
use (usually coinciding with pytest_unconfigure).

Returns the passed function.
graingert marked this conversation as resolved.
Show resolved Hide resolved
"""
self._cleanup_stack.callback(func)
return func

def _do_configure(self) -> None:
assert not self._configured
Expand All @@ -1117,13 +1125,18 @@ def _do_configure(self) -> None:
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))

def _ensure_unconfigure(self) -> None:
if self._configured:
self._configured = False
self.hook.pytest_unconfigure(config=self)
self.hook.pytest_configure._call_history = []
while self._cleanup:
fin = self._cleanup.pop()
fin()
try:
if self._configured:
self._configured = False
try:
self.hook.pytest_unconfigure(config=self)
finally:
self.hook.pytest_configure._call_history = []
finally:
try:
self._cleanup_stack.close()
finally:
self._cleanup_stack = contextlib.ExitStack()

def get_terminal_writer(self) -> TerminalWriter:
terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin(
Expand Down
31 changes: 31 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,37 @@ def test_confcutdir_check_isdir(self, pytester: Pytester) -> None:
def test_iter_rewritable_modules(self, names, expected) -> None:
assert list(_iter_rewritable_modules(names)) == expected

def test_add_cleanup(self, pytester: Pytester) -> None:
config = Config.fromdictargs({}, [])
config._do_configure()
report = []

class MyError(BaseException):
pass

@config.add_cleanup
def cleanup_last():
report.append("cleanup_last")

@config.add_cleanup
def raise_2():
report.append("raise_2")
raise MyError("raise_2")

@config.add_cleanup
def raise_1():
report.append("raise_1")
raise MyError("raise_1")

@config.add_cleanup
def cleanup_first():
report.append("cleanup_first")

with pytest.raises(MyError, match=r"raise_2"):
config._ensure_unconfigure()

assert report == ["cleanup_first", "raise_1", "raise_2", "cleanup_last"]


class TestConfigFromdictargs:
def test_basic_behavior(self, _sys_snapshot) -> None:
Expand Down
Loading