Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/13965.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed quadratic-time behavior when handling ``unittest`` subtests in Python 3.10.
48 changes: 31 additions & 17 deletions src/_pytest/unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ def setup(self) -> None:
# A bound method to be called during teardown() if set (see 'runtest()').
self._explicit_tearDown: Callable[[], None] | None = None
super().setup()
if sys.version_info < (3, 11):
# A cache of the subTest errors and non-subtest skips in self._outcome.
# Compute and cache these lists once, instead of computing them again and again for each subtest (#13965).
self._cached_errors_and_skips: tuple[list[Any], list[Any]] | None = None

def teardown(self) -> None:
if self._explicit_tearDown is not None:
Expand Down Expand Up @@ -313,11 +317,7 @@ def add_skip() -> None:
# We also need to check if `self.instance._outcome` is `None` (this happens if the test
# class/method is decorated with `unittest.skip`, see pytest-dev/pytest-subtests#173).
if sys.version_info < (3, 11) and self.instance._outcome is not None:
subtest_errors = [
x
for x, y in self.instance._outcome.errors
if isinstance(x, _SubTest) and y is not None
]
subtest_errors, _ = self._obtain_errors_and_skips()
if len(subtest_errors) == 0:
add_skip()
else:
Expand Down Expand Up @@ -443,18 +443,8 @@ def addSubTest(

# For python < 3.11: add non-subtest skips once all subtest failures are processed by # `_addSubTest`.
if sys.version_info < (3, 11):
from unittest.case import _SubTest # type: ignore[attr-defined]

non_subtest_skip = [
(x, y)
for x, y in self.instance._outcome.skipped
if not isinstance(x, _SubTest)
]
subtest_errors = [
(x, y)
for x, y in self.instance._outcome.errors
if isinstance(x, _SubTest) and y is not None
]
subtest_errors, non_subtest_skip = self._obtain_errors_and_skips()

# Check if we have non-subtest skips: if there are also sub failures, non-subtest skips are not treated in
# `_addSubTest` and have to be added using `add_skip` after all subtest failures are processed.
if len(non_subtest_skip) > 0 and len(subtest_errors) > 0:
Expand All @@ -465,6 +455,30 @@ def addSubTest(
for testcase, reason in non_subtest_skip:
self.addSkip(testcase, reason, handle_subtests=False)

def _obtain_errors_and_skips(self) -> tuple[list[Any], list[Any]]:
"""Compute or obtain the cached values for subtest errors and non-subtest skips."""
from unittest.case import _SubTest # type: ignore[attr-defined]

assert sys.version_info < (3, 11), (
"This workaround only should be used in Python 3.10"
)
if self._cached_errors_and_skips is not None:
return self._cached_errors_and_skips

subtest_errors = [
(x, y)
for x, y in self.instance._outcome.errors
if isinstance(x, _SubTest) and y is not None
]

non_subtest_skips = [
(x, y)
for x, y in self.instance._outcome.skipped
if not isinstance(x, _SubTest)
]
self._cached_errors_and_skips = (subtest_errors, non_subtest_skips)
return subtest_errors, non_subtest_skips


@hookimpl(tryfirst=True)
def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None:
Expand Down