Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0c88848
Fix SyntaxError crash line to show file, line, and column
SemTiOne Aug 18, 2026
427a995
Merge remote-tracking branch 'upstream/main' into fix-2388-syntax-err…
SemTiOne Aug 18, 2026
4a35f0e
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 18, 2026
8ca36dd
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 19, 2026
384afbf
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 22, 2026
58b358d
Merge remote-tracking branch 'upstream/main' into fix-2388-syntax-err…
SemTiOne Aug 22, 2026
88f44c3
Merge branch 'fix-2388-syntax-error-crash-line' of https://github.com…
SemTiOne Aug 22, 2026
3f240dc
Fall back to frame location when SyntaxError has no filename
SemTiOne Aug 22, 2026
e4c4ac7
Hardened fixes
SemTiOne Aug 22, 2026
c1d5871
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 25, 2026
fe93273
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 27, 2026
6b40d16
Update changelog/2388.improvement.rst
SemTiOne Aug 29, 2026
4d8030b
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 29, 2026
d73a429
Merge branch 'pytest-dev:main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 30, 2026
9bb0a6b
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Aug 31, 2026
c2d1c2e
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Sep 4, 2026
b0252e9
Update code.py
SemTiOne Sep 4, 2026
454d593
Update test_excinfo.py
SemTiOne Sep 4, 2026
db30214
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Sep 8, 2026
b60e3d0
Treat zero SyntaxError offset as unknown; fix strict-typing errors
SemTiOne Sep 8, 2026
7489cf7
Portable none-msg test; version-proof typing
SemTiOne Sep 9, 2026
a9afb36
Merge branch 'main' into fix-2388-syntax-error-crash-line
SemTiOne Sep 14, 2026
fa23c7c
Address Ronny review nits
SemTiOne Sep 14, 2026
b5812a7
Omit redundant File block in SyntaxError output when crash line has l…
Dextheking1 Sep 22, 2026
130f6dd
Update test_syntax_error_module for omitted File block (#14994)
Dextheking1 Sep 23, 2026
cda81f1
test: cover module-prefixed and no-match branches of _strip_syntax_er…
Dextheking1 Sep 23, 2026
f4f5286
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 23, 2026
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
4 changes: 4 additions & 0 deletions changelog/14994.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
The redundant ``File "...", line N`` / source / caret block is no longer shown
in ``SyntaxError`` output when the crash line already reports the error's own
``FILE:LINE:COLUMN`` location. The block is still shown as a fallback when the
error does not carry its own location, so no information is lost.
1 change: 1 addition & 0 deletions changelog/2388.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:class:`SyntaxError` crash lines now report the error's own `FILE:LINE:COLUMN` location instead of the traceback entry that raised it, in the format most editors/IDEs understand.
62 changes: 60 additions & 2 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,45 @@ def stringify_exception(
E = TypeVar("E", bound=BaseException, covariant=True)


def _syntax_error_location(exc: BaseException) -> tuple[str, int, int, str] | None:
"""Return (filename, lineno, offset, message) for a SyntaxError with location info, else None."""
if (
isinstance(exc, SyntaxError)
and exc.offset
and exc.lineno is not None
and exc.filename
):
return (
exc.filename,
exc.lineno,
exc.offset,
exc.msg or "<no detail available>",
)
return None


def _strip_syntax_error_file_block(exlines: list[str], exc: BaseException) -> list[str]:
"""Drop the leading ``File "...", line N`` / source / caret lines from
:func:`traceback.format_exception_only` output for :class:`SyntaxError`.

The exception line itself (and any lines after it, such as notes) is kept
verbatim. If the exception line cannot be found, the lines are returned
unchanged.
"""
# Mirror the exception type name computation of
# traceback.format_exception_only().
stype = type(exc).__qualname__
smod = type(exc).__module__
if smod not in ("__main__", "builtins"):
stype = f"{smod}.{stype}"
for i, line in enumerate(exlines):
# The location block lines are always indented; the exception line
# starts at column 0 with the exception type name.
if line.startswith(stype):
return exlines[i:]
return exlines


@final
@dataclasses.dataclass
class ExceptionInfo(Generic[E]):
Expand Down Expand Up @@ -689,6 +728,17 @@ def errisinstance(self, exc: EXCEPTION_OR_MORE) -> bool:
return isinstance(self.value, exc)

def _getreprcrash(self) -> ReprFileLocation | None:
# A SyntaxError carries its own location, which is more useful than
# the traceback entry where it was raised (#2388).
loc = _syntax_error_location(self.value)
if loc is not None:
filename, lineno, offset, message = loc
return ReprFileLocation(
filename,
lineno,
f"{self.typename}: {message}",
column=offset,
)
# Find last non-hidden traceback entry that led to the exception of the
# traceback, or None if all hidden.
for i in range(-1, -len(self.traceback) - 1, -1):
Expand Down Expand Up @@ -1031,6 +1081,12 @@ def get_exconly(
indentstr = " " * indent
# Get the real exception information out.
exlines = excinfo.exconly(tryshort=True).split("\n")
if _syntax_error_location(excinfo.value) is not None:
# The crash line already reports the error's own file:line:column
# location (see ExceptionInfo._getreprcrash), so the File/source/
# caret block emitted by format_exception_only() would only
# repeat it (#14994).
exlines = _strip_syntax_error_file_block(exlines, excinfo.value)
failindent = self.fail_marker + indentstr[1:]
for line in exlines:
lines.append(failindent + line)
Expand Down Expand Up @@ -1495,7 +1551,7 @@ def __str__(self) -> str:

@dataclasses.dataclass(eq=False)
class ReprFileLocation(TerminalRepr):
"""A message at a file location, using the `<path>:<lineno>: <message>`
"""A message at a file location, using the `<path>:<lineno>[:<column>]: <message>`
format that most editors understand.

Only the first line of the message is emitted.
Expand All @@ -1504,6 +1560,7 @@ class ReprFileLocation(TerminalRepr):
path: str
lineno: int
message: str
column: int | None = None

def __post_init__(self) -> None:
self.path = str(self.path)
Expand All @@ -1514,7 +1571,8 @@ def toterminal(self, tw: TerminalWriter) -> None:
if i != -1:
msg = msg[:i]
tw.write(self.path, bold=True, red=True)
tw.line(f":{self.lineno}: {msg}")
column = f":{self.column}" if self.column is not None else ""
tw.line(f":{self.lineno}{column}: {msg}")


@dataclasses.dataclass(eq=False)
Expand Down
12 changes: 9 additions & 3 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,9 +532,15 @@ def importtestmodule(
consider_namespace_packages=config.getini("consider_namespace_packages"),
)
except SyntaxError as e:
raise nodes.Collector.CollectError(
ExceptionInfo.from_current().getrepr(style="short")
) from e
excinfo = ExceptionInfo.from_current()
repr_ = excinfo.getrepr(style="short")
reprcrash = excinfo._getreprcrash()
msg = str(repr_)
# A column is present only when the SyntaxError carried its own
# location; a fallback crash line would just repeat the last entry.
if reprcrash is not None and reprcrash.column is not None:
msg += "\n" + str(reprcrash)
raise nodes.Collector.CollectError(msg) from e
except ImportPathMismatchError as e:
raise nodes.Collector.CollectError(
"import file mismatch:\n"
Expand Down
223 changes: 222 additions & 1 deletion testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import TYPE_CHECKING

import _pytest._code
from _pytest._code.code import _strip_syntax_error_file_block
from _pytest._code.code import ExceptionChainRepr
from _pytest._code.code import ExceptionInfo
from _pytest._code.code import ExceptionInfoFormatter
Expand Down Expand Up @@ -339,6 +340,94 @@ def f():
f()
assert excinfo._getreprcrash() is None

def test_getreprcrash_syntax_error(self):
with pytest.raises(SyntaxError) as excinfo:
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.path == "file.py"
assert reprcrash.lineno == 1
assert reprcrash.column == 5
assert reprcrash.message == "SyntaxError: bad syntax"
assert str(reprcrash) == "file.py:1:5: SyntaxError: bad syntax"

def test_getreprcrash_syntax_error_none_msg(self):
with pytest.raises(SyntaxError) as excinfo:
err = SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
err.msg = cast(Any, None)
raise err
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.message == "SyntaxError: <no detail available>"
assert str(reprcrash) == "file.py:1:5: SyntaxError: <no detail available>"

def test_getreprcrash_syntax_error_without_offset(self):
def f():
raise SyntaxError("no location")

with pytest.raises(SyntaxError) as excinfo:
f()
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.column is None
assert reprcrash.message == "SyntaxError: no location"

def test_getreprcrash_syntax_error_without_filename(self):
def f():
raise SyntaxError("bad syntax", (None, 1, 5, "def foo(:", 1, 6))

with pytest.raises(SyntaxError) as excinfo:
f()
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
co = _pytest._code.Code.from_function(f)
assert reprcrash.path == str(co.path)
assert reprcrash.lineno == co.firstlineno + 1 + 1
assert reprcrash.column is None
assert reprcrash.message.endswith("SyntaxError: bad syntax")

def test_getreprcrash_indentation_error(self):
with pytest.raises(IndentationError) as excinfo:
raise IndentationError(
"unexpected indent", ("file.py", 3, 5, " foo", 3, 6)
)
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.path == "file.py"
assert reprcrash.lineno == 3
assert reprcrash.column == 5
assert reprcrash.message == "IndentationError: unexpected indent"
assert str(reprcrash) == "file.py:3:5: IndentationError: unexpected indent"

def test_getreprcrash_syntax_error_without_lineno(self):
def f():
raise SyntaxError(
"bad syntax", ("file.py", None, 5, "def foo(:", None, None)
)

with pytest.raises(SyntaxError) as excinfo:
f()
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.column is None
co = _pytest._code.Code.from_function(f)
assert reprcrash.path == str(co.path)
assert reprcrash.lineno == co.firstlineno + 1 + 1

def test_getreprcrash_syntax_error_zero_offset(self):
def f():
raise SyntaxError("bad syntax", ("file.py", 1, 0, "def foo(:", 1, 0))

with pytest.raises(SyntaxError) as excinfo:
f()
reprcrash = excinfo._getreprcrash()
assert reprcrash is not None
assert reprcrash.column is None
co = _pytest._code.Code.from_function(f)
assert reprcrash.path == str(co.path)
assert reprcrash.lineno == co.firstlineno + 1 + 1
assert ":0:" not in str(reprcrash)


def test_excinfo_exconly():
with pytest.raises(ValueError) as excinfo:
Expand Down Expand Up @@ -782,7 +871,7 @@ def func1(m):
reprfuncargs = p.repr_args(entry)
assert reprfuncargs is not None
assert reprfuncargs.args[0] == ("m", repr("m" * 500))
assert "..." not in reprfuncargs.args[0][1]
assert "..." not in str(reprfuncargs.args[0][1])

def test_repr_tracebackentry_lines(self, importasmod) -> None:
mod = importasmod(
Expand Down Expand Up @@ -1116,6 +1205,138 @@ def entry():
assert repr.reprcrash.message == "ValueError"
assert str(repr.reprcrash).endswith("mod.py:3: ValueError")

def test_repr_excinfo_reprcrash_syntax_error(self, importasmod) -> None:
mod = importasmod(
"""
def entry():
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
"""
)
with pytest.raises(SyntaxError) as excinfo:
mod.entry()
repr = excinfo.getrepr()
assert repr.reprcrash is not None
assert repr.reprcrash.path == "file.py"
assert repr.reprcrash.lineno == 1
assert repr.reprcrash.column == 5
assert repr.reprcrash.message == "SyntaxError: bad syntax"
assert str(repr.reprcrash) == "file.py:1:5: SyntaxError: bad syntax"

def test_syntax_error_default_tb_long(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def entry():
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
def test_x():
entry()
"""
)
result = pytester.runpytest("--tb=long")
result.stdout.fnmatch_lines(["*.py:2: SyntaxError"])

def test_syntax_error_default_tb_short(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def entry():
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
def test_x():
entry()
"""
)
result = pytester.runpytest("--tb=short")
result.stdout.fnmatch_lines(["*.py:2: in entry"])
result.stdout.fnmatch_lines(["*SyntaxError: bad syntax*"])

def test_syntax_error_tb_line(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def entry():
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
def test_x():
entry()
"""
)
result = pytester.runpytest("--tb=line")
result.stdout.fnmatch_lines(["file.py:1:5: SyntaxError: bad syntax"])

def test_syntax_error_no_offset_fallback(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def entry():
raise SyntaxError("no location")
def test_x():
entry()
"""
)
result = pytester.runpytest("--tb=long")
result.stdout.fnmatch_lines(["*SyntaxError: no location*"])

def test_syntax_error_collection(self, pytester: Pytester) -> None:
pytester.makepyfile("def broken(:\n pass\n")
result = pytester.runpytest()
result.stdout.fnmatch_lines(["*.py:1:*: SyntaxError*"])

def test_indentation_error_collection(self, pytester: Pytester) -> None:
pytester.makepyfile("def f():\n x = 1\n y = 2\n")
result = pytester.runpytest()
result.stdout.fnmatch_lines(["*.py:3:*: IndentationError*"])

def test_get_exconly_syntax_error_strips_file_block(self) -> None:
# The File/source/caret block is redundant when the crash line already
# carries the error's own file:line:column (#14994).
with pytest.raises(SyntaxError) as excinfo:
raise SyntaxError("bad syntax", ("file.py", 1, 5, "def foo(:", 1, 6))
lines = ExceptionInfoFormatter().get_exconly(excinfo)
assert lines == ["E SyntaxError: bad syntax"]

def test_get_exconly_syntax_error_keeps_file_block_on_fallback(self) -> None:
# Without column info the crash line falls back to the traceback entry,
# so the File block must be kept to not lose the location (#14994).
with pytest.raises(SyntaxError) as excinfo:
raise SyntaxError("bad syntax", ("file.py", 1, None, "def foo(:", 1, None))
lines = ExceptionInfoFormatter().get_exconly(excinfo)
assert lines == [
'E File "file.py", line 1',
" def foo(:",
" SyntaxError: bad syntax",
]

def test_strip_syntax_error_file_block_module_prefixed_type(self) -> None:
# Mirrors traceback.format_exception_only(): the exception type name
# is module-prefixed for non-builtin exception types.
class PluginSyntaxError(SyntaxError):
pass

PluginSyntaxError.__module__ = "some_plugin"
exc = PluginSyntaxError("bad", ("file.py", 1, 1, "def foo(:", 1, 2))
type_name = f"{type(exc).__module__}.{type(exc).__qualname__}"
lines = [
' File "file.py", line 1',
" def foo(:",
" ^",
f"{type_name}: bad",
]
assert _strip_syntax_error_file_block(lines, exc) == [f"{type_name}: bad"]

def test_strip_syntax_error_file_block_returns_unchanged_without_match(
self,
) -> None:
# If the exception line cannot be found, the lines are kept verbatim.
exc = SyntaxError("bad", ("file.py", 1, 1, "def foo(:", 1, 2))
lines = ["something unexpected"]
assert _strip_syntax_error_file_block(lines, exc) == lines

def test_syntax_error_collection_omits_redundant_file_block(
self, pytester: Pytester
) -> None:
pytester.makepyfile("def broken(:\n pass\n")
result = pytester.runpytest()
# The crash line carries file:line:column ...
result.stdout.fnmatch_lines(["*.py:1:*: SyntaxError: invalid syntax"])
# ... so the redundant File/source/caret E-block is omitted (#14994).
result.stdout.no_fnmatch_line('E*File "*", line *')
result.stdout.fnmatch_lines(["E SyntaxError: invalid syntax"])

def test_repr_traceback_recursion(self, importasmod):
mod = importasmod(
"""
Expand Down
7 changes: 6 additions & 1 deletion testing/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ def test_syntax_error_module(self, pytester: Pytester) -> None:
values = reprec.getfailedcollections()
assert len(values) == 1
out = str(values[0].longrepr)
assert out.find("not python") != -1
# The crash line carries the SyntaxError's own file:line:column
# location (#2388); the redundant File/source/caret block is
# omitted (#14994).
assert "test_syntax_error_module.py:1:" in out
assert "SyntaxError: invalid syntax" in out
assert 'File "' not in out

def test_exit_first_problem(self, pytester: Pytester) -> None:
reprec = pytester.inline_runsource(
Expand Down
Loading