Skip to content

Commit 37b18b6

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix-2388-syntax-error-crash-line
2 parents 0c88848 + 1ae99ac commit 37b18b6

13 files changed

Lines changed: 376 additions & 115 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
minimum_pre_commit_version: "4.4.0"
22
repos:
33
- repo: https://github.com/astral-sh/ruff-pre-commit
4-
rev: "v0.16.2"
4+
rev: "v0.16.3"
55
hooks:
66
- id: ruff-check
77
args: ["--fix"]
@@ -34,7 +34,7 @@ repos:
3434
hooks:
3535
- id: python-use-type-annotations
3636
- repo: https://github.com/pre-commit/mirrors-mypy
37-
rev: v2.3.0
37+
rev: v2.3.1
3838
hooks:
3939
- id: mypy
4040
files: ^(src/|testing/|scripts/)

‎AUTHORS‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ Roland Puntaier
422422
Romain Dorgueil
423423
Roman Bolshakov
424424
Ronny Pfannschmidt
425+
Roqia Salah
425426
Ross Lawley
426427
Ruaridh Williamson
427428
Russel Winder

‎changelog/14828.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Exception groups raised from another exception now print their cause chain only once in the traceback output.

‎changelog/6997.bugfix.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
XFAIL reports from teardown phases now include the phase in the short test
2+
summary.

‎doc/en/reference/plugin_list.rst‎

Lines changed: 164 additions & 76 deletions
Large diffs are not rendered by default.

‎pyproject.toml‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ src = [
113113
"src",
114114
]
115115
format.docstring-code-format = true
116-
lint.select = [
116+
lint.extend-select = [
117117
"B", # bugbear
118118
"D", # pydocstyle
119119
"E", # pycodestyle
@@ -134,13 +134,24 @@ lint.select = [
134134
"W", # pycodestyle
135135
]
136136
lint.ignore = [
137+
# flake8-async ignore
138+
"ASYNC115", # Use `lowlevel.checkpoint()` instead of `sleep(0)`
137139
# bugbear ignore
138140
"B004", # Using `hasattr(x, "__call__")` to test if x is callable is unreliable.
139141
"B007", # Loop control variable `i` not used within loop body
140142
"B009", # Do not call `getattr` with a constant attribute value
141143
"B010", # [*] Do not call `setattr` with a constant attribute value.
142144
"B011", # Do not `assert False` (`python -O` removes these calls)
143145
"B028", # No explicit `stacklevel` keyword argument found
146+
# flake8-blind-except ignore
147+
"BLE001", # Do not catch blind exception
148+
# flake8-comprehensions ignore
149+
"C400", # Unnecessary generator (rewrite using `list()`)
150+
"C408", # Unnecessary `dict()`/`list()`/`tuple()` call (rewrite as a literal)
151+
"C409", # Unnecessary list literal passed to `tuple()` (rewrite as a tuple literal)
152+
"C410", # Unnecessary list literal passed to `list()` (remove the outer call to `list()`)
153+
"C414", # Unnecessary double cast or process
154+
"C417", # Unnecessary `map()` usage (rewrite using a comprehension)
144155
# pydocstyle ignore
145156
"D100", # Missing docstring in public module
146157
"D101", # Missing docstring in public class
@@ -157,9 +168,30 @@ lint.ignore = [
157168
"D402", # First line should not be the function's signature
158169
"D404", # First word of the docstring should not be "This"
159170
"D415", # First line should end with a period, question mark, or exclamation point
171+
# flake8-datetimez ignore
172+
"DTZ001", # `datetime.datetime()` called without a `tzinfo` argument
173+
"DTZ005", # `datetime.datetime.now()` called without a `tz` argument
160174
# pytest can do weird low-level things, and we usually know
161175
# what we're doing when we use type(..) is ...
162176
"E721", # Do not compare types, use `isinstance()`
177+
# flake8-future-annotations ignore
178+
"FA102", # Missing `from __future__ import annotations`, but uses PEP 585/604 syntax
179+
# flynt ignore
180+
"FLY002", # Consider an f-string instead of string join
181+
# refurb ignore
182+
"FURB157", # Verbose expression in `Decimal` constructor
183+
"FURB188", # Prefer `str.removeprefix()` over conditionally replacing with slice
184+
# flake8-implicit-str-concat ignore
185+
"ISC004", # Unparenthesized implicit string concatenation in collection
186+
# flake8-logging ignore
187+
"LOG009", # Use of undocumented `logging.WARN` constant
188+
"LOG015", # Call on root logger
189+
# pep8-naming ignore
190+
"N999", # Invalid module name
191+
# perflint ignore
192+
"PERF102", # When using only the keys or values of a dict use the `keys()`/`values()` method
193+
# pygrep-hooks ignore
194+
"PGH005", # Mock method should be called
163195
# pylint ignore
164196
"PLC0105", # `TypeVar` name "E" does not reflect its covariance;
165197
"PLC0414", # Import alias does not rename original package
@@ -179,9 +211,41 @@ lint.ignore = [
179211
"PLW0603", # Using the global statement
180212
"PLW1641", # Does not implement the __hash__ method
181213
"PLW2901", # for loop variable overwritten by assignment target
214+
# flake8-pytest-style ignore
215+
"PT020", # `@pytest.yield_fixture` is deprecated, use `@pytest.fixture`
216+
"PT025", # `pytest.mark.usefixtures` has no effect on fixtures
217+
"PT031", # `pytest.warns()` block should contain a single simple statement
218+
# flake8-use-pathlib ignore
219+
"PTH124", # `py.path` is in maintenance mode, use `pathlib` instead
220+
"PTH210", # Invalid suffix passed to `.with_suffix()`
221+
# flake8-return ignore
222+
"RET501", # Do not explicitly `return None` in function if it is the only possible return value
182223
# ruff ignore
183224
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
184225
"RUF061", # Use context-manager form of `pytest.raises()`
226+
# flake8-bandit ignore
227+
"S102", # Use of `exec` detected
228+
"S110", # `try`-`except`-`pass` detected, consider logging the exception
229+
"S112", # `try`-`except`-`continue` detected, consider logging the exception
230+
# flake8-simplify ignore
231+
"SIM102", # Use a single `if` statement instead of nested `if` statements
232+
"SIM103", # Return the condition directly
233+
"SIM114", # Combine `if` branches using logical `or` operator
234+
"SIM115", # Use a context manager for opening files
235+
"SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements
236+
"SIM118", # Use `key in dict` instead of `key in dict.keys()`
237+
"SIM201", # Use `!=` instead of `not ... == ...`
238+
"SIM202", # Use `==` instead of `not ... != ...`
239+
"SIM210", # Remove unnecessary `True if ... else False`
240+
"SIM211", # Use `not ...` instead of `False if ... else True`
241+
"SIM222", # Use the simplified expression instead of `... or True`
242+
"SIM223", # Use the simplified expression instead of `... and False`
243+
"SIM905", # Consider using a list literal instead of `str.split()`
244+
# flake8-type-checking ignore
245+
"TC005", # Found empty type-checking block
246+
# tryceratops ignore
247+
"TRY002", # Create your own exception
248+
"TRY004", # Prefer `TypeError` exception for invalid type
185249
]
186250
lint.per-file-ignores."doc/**/*.py" = [
187251
"PLR0133", # Two constants compared in a comparison,

‎src/_pytest/_code/code.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,9 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR
12411241
reprcrash = None
12421242
repr_chain.append((reprtraceback, reprcrash, description))
12431243

1244-
if e.__cause__ is not None and self.chain:
1244+
if isinstance(e, BaseExceptionGroup) and self.chain:
1245+
e = None
1246+
elif e.__cause__ is not None and self.chain:
12451247
e = e.__cause__
12461248
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
12471249
description = "The above exception was the direct cause of the following exception:"

‎src/_pytest/assertion/util.py‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Callable
77
from collections.abc import Iterator
88
from collections.abc import Sequence
9+
from collections.abc import Set as AbstractSet
910
from unicodedata import normalize
1011

1112
from _pytest import outcomes
@@ -14,8 +15,6 @@
1415
from _pytest._io.saferepr import saferepr_unlimited
1516
from _pytest.assertion._compare_any import _compare_eq_any
1617
from _pytest.assertion._compare_set import SET_COMPARISON_FUNCTIONS
17-
from _pytest.assertion._guards import isset
18-
from _pytest.assertion._guards import istext
1918
from _pytest.assertion._typing import _AssertionTextDiffStyle
2019
from _pytest.assertion._typing import _HighlightFunc
2120
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
@@ -151,21 +150,22 @@ def assertrepr_compare(
151150
summary = f"{left_repr} {op} {right_repr}"
152151

153152
try:
154-
if op == "==":
155-
source = _compare_eq_any(
156-
left,
157-
right,
158-
highlighter,
159-
verbose,
160-
assertion_text_diff_style,
161-
truncation_budget,
162-
)
163-
elif op == "not in" and istext(left) and istext(right):
164-
source = _notin_text(left, right, verbose, truncation_budget)
165-
elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right):
166-
source = SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
167-
else:
168-
source = iter(())
153+
match (left, op, right):
154+
case (_, "==", _):
155+
source = _compare_eq_any(
156+
left,
157+
right,
158+
highlighter,
159+
verbose,
160+
assertion_text_diff_style,
161+
truncation_budget,
162+
)
163+
case (str(), "not in", str()):
164+
source = _notin_text(left, right, verbose, truncation_budget)
165+
case (AbstractSet(), "!=" | ">=" | "<=" | ">" | "<", AbstractSet()):
166+
source = SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
167+
case _:
168+
source = iter(())
169169

170170
# Only yield the summary if there is a detailed explanation.
171171
# Make sure there's a separating empty line after the summary.

‎src/_pytest/terminal.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,7 +1328,12 @@ def show_xfailed(lines: list[str]) -> None:
13281328
)
13291329
markup_word = self._tw.markup(verbose_word, **verbose_markup)
13301330
nodeid = _get_node_id_with_markup(self._tw, self.config, rep)
1331-
line = f"{markup_word} {nodeid}"
1331+
1332+
if rep.when == "call":
1333+
line = f"{markup_word} {nodeid}"
1334+
else:
1335+
line = f"{markup_word} at {rep.when} of {nodeid}"
1336+
13321337
reason = rep.wasxfail
13331338
if reason:
13341339
line += " - " + str(reason)

‎testing/code/test_excinfo.py‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,6 +1631,66 @@ def g():
16311631
]
16321632
)
16331633

1634+
def _render_output(self, excinfo: ExceptionInfo[BaseException]) -> str:
1635+
r = excinfo.getrepr()
1636+
file = io.StringIO()
1637+
tw = TerminalWriter(file=file)
1638+
tw.hasmarkup = False
1639+
r.toterminal(tw)
1640+
return file.getvalue()
1641+
1642+
def test_exc_chain_repr_exception_group_with_cause(self) -> None:
1643+
"""An exception group raised from another exception must not print that
1644+
exception's cause chain twice."""
1645+
try:
1646+
try:
1647+
raise RuntimeError("original cause")
1648+
except RuntimeError as exc:
1649+
raise ExceptionGroup("group", [ValueError("inner")]) from exc
1650+
except ExceptionGroup:
1651+
excinfo = ExceptionInfo.from_current()
1652+
1653+
output = self._render_output(excinfo)
1654+
assert output.count("RuntimeError: original cause") == 1
1655+
assert output.count("ExceptionGroup: group") == 1
1656+
assert output.count("ValueError: inner") == 1
1657+
assert output.count("The above exception was the direct cause") == 1
1658+
1659+
def test_exc_chain_repr_exception_group_with_context(self) -> None:
1660+
"""An exception group raised during handling must not print the
1661+
implicit context chain twice."""
1662+
exc1 = RuntimeError("implicit context")
1663+
try:
1664+
raise exc1
1665+
except RuntimeError as exc:
1666+
group = ExceptionGroup("group", [ValueError("inner")])
1667+
group.__context__ = exc
1668+
try:
1669+
raise group
1670+
except ExceptionGroup:
1671+
excinfo = ExceptionInfo.from_current()
1672+
1673+
output = self._render_output(excinfo)
1674+
assert output.count("RuntimeError: implicit context") == 1
1675+
assert output.count("During handling of the above exception") == 1
1676+
1677+
def test_exc_chain_repr_nested_exception_group_with_cause(self) -> None:
1678+
"""A nested exception group raised from another exception must not
1679+
print the cause chain twice."""
1680+
try:
1681+
try:
1682+
raise RuntimeError("root cause")
1683+
except RuntimeError as exc:
1684+
raise ExceptionGroup(
1685+
"outer", [ExceptionGroup("inner", [ValueError("v")])]
1686+
) from exc
1687+
except ExceptionGroup:
1688+
excinfo = ExceptionInfo.from_current()
1689+
1690+
output = self._render_output(excinfo)
1691+
assert output.count("RuntimeError: root cause") == 1
1692+
assert output.count("The above exception was the direct cause") == 1
1693+
16341694
def test_exc_chain_repr_without_traceback_multiple_links(self) -> None:
16351695
"""
16361696
Exceptions without a traceback that are themselves part of a longer
@@ -1652,13 +1712,7 @@ def test_exc_chain_repr_without_traceback_multiple_links(self) -> None:
16521712
except ValueError:
16531713
excinfo = ExceptionInfo.from_current()
16541714

1655-
r = excinfo.getrepr()
1656-
file = io.StringIO()
1657-
tw = TerminalWriter(file=file)
1658-
tw.hasmarkup = False
1659-
r.toterminal(tw)
1660-
1661-
output = file.getvalue()
1715+
output = self._render_output(excinfo)
16621716
for message in (
16631717
"ValueError: abcd",
16641718
"IndexError: efgh",
@@ -1687,13 +1741,7 @@ def test_exc_chain_repr_mixed_traceback(self) -> None:
16871741
except ValueError:
16881742
excinfo = ExceptionInfo.from_current()
16891743

1690-
r = excinfo.getrepr()
1691-
file = io.StringIO()
1692-
tw = TerminalWriter(file=file)
1693-
tw.hasmarkup = False
1694-
r.toterminal(tw)
1695-
1696-
output = file.getvalue()
1744+
output = self._render_output(excinfo)
16971745
assert output.count("ValueError: outer without traceback") == 1
16981746
assert output.count("RuntimeError: inner with traceback") == 1
16991747
assert output.count("The above exception was the direct cause") == 1

0 commit comments

Comments
 (0)