From 0c888480321bcd8fcf44bb34c06900458668e1b1 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Tue, 18 Aug 2026 12:52:16 +0700 Subject: [PATCH 01/13] Fix SyntaxError crash line to show file, line, and column Co-authored-by: Claude --- changelog/2388.improvement.rst | 1 + src/_pytest/_code/code.py | 13 +++++++++++- testing/code/test_excinfo.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 changelog/2388.improvement.rst diff --git a/changelog/2388.improvement.rst b/changelog/2388.improvement.rst new file mode 100644 index 00000000000..c139c56bf60 --- /dev/null +++ b/changelog/2388.improvement.rst @@ -0,0 +1 @@ +SyntaxError crash lines now use the ``FILE:LINE:COLUMN: MSG`` format that editors understand. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index e37a1324c67..a252c3c9eec 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -688,6 +688,15 @@ 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). + if isinstance(self.value, SyntaxError) and self.value.offset: + return ReprFileLocation( + self.value.filename or "", + self.value.lineno or 0, + f"SyntaxError: {self.value.msg}", + column=self.value.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): @@ -1492,6 +1501,7 @@ class ReprFileLocation(TerminalRepr): path: str lineno: int message: str + column: int | None = None def __post_init__(self) -> None: self.path = str(self.path) @@ -1502,7 +1512,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) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index d3872068a86..2092c2f3cc8 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -339,6 +339,28 @@ 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_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_excinfo_exconly(): with pytest.raises(ValueError) as excinfo: @@ -1116,6 +1138,23 @@ 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_repr_traceback_recursion(self, importasmod): mod = importasmod( """ From 3f240dc305d51c44eb391d73b945396d06d6d941 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 22 Aug 2026 07:54:16 +0700 Subject: [PATCH 02/13] Fall back to frame location when SyntaxError has no filename Co-authored-by: Claude --- src/_pytest/_code/code.py | 4 ++-- testing/code/test_excinfo.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 493e6e038e8..259effae4a6 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -496,8 +496,8 @@ def stringify_exception( def _syntax_error_location(exc: BaseException) -> tuple[str, int, int] | None: """Return (filename, lineno, offset) for a SyntaxError with location info, else None.""" - if isinstance(exc, SyntaxError) and exc.offset is not None: - return (exc.filename or "", exc.lineno or 0, exc.offset) + if isinstance(exc, SyntaxError) and exc.offset is not None and exc.filename: + return (exc.filename, exc.lineno or 0, exc.offset) return None diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index c43e0a89390..345a5759793 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -361,6 +361,20 @@ def f(): 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_excinfo_exconly(): with pytest.raises(ValueError) as excinfo: From e4c4ac76f3646c25657d2c3c696a12bc15d8e186 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 22 Aug 2026 15:03:52 +0700 Subject: [PATCH 03/13] Hardened fixes Co-authored-by: Claude --- src/_pytest/_code/code.py | 11 ++++++++--- src/_pytest/python.py | 10 +++++++--- testing/code/test_excinfo.py | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 259effae4a6..ca73557cddb 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -496,8 +496,13 @@ def stringify_exception( def _syntax_error_location(exc: BaseException) -> tuple[str, int, int] | None: """Return (filename, lineno, offset) for a SyntaxError with location info, else None.""" - if isinstance(exc, SyntaxError) and exc.offset is not None and exc.filename: - return (exc.filename, exc.lineno or 0, exc.offset) + if ( + isinstance(exc, SyntaxError) + and exc.offset is not None + and exc.lineno is not None + and exc.filename + ): + return (exc.filename, exc.lineno, exc.offset) return None @@ -703,7 +708,7 @@ def _getreprcrash(self) -> ReprFileLocation | None: return ReprFileLocation( filename, lineno, - f"SyntaxError: {self.value.msg}", + f"{self.typename}: {self.value.msg}", column=offset, ) # Find last non-hidden traceback entry that led to the exception of the diff --git a/src/_pytest/python.py b/src/_pytest/python.py index be0ea5b4d05..44fce30421c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -532,9 +532,13 @@ 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_) + 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" diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 345a5759793..4dfd353adb5 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -375,6 +375,34 @@ def f(): 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_excinfo_exconly(): with pytest.raises(ValueError) as excinfo: @@ -1218,6 +1246,16 @@ def test_x(): 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_repr_traceback_recursion(self, importasmod): mod = importasmod( """ From 6b40d165dfc48f721e9ae3603ee240a6d11ef04b Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 29 Aug 2026 20:26:32 +0700 Subject: [PATCH 04/13] Update changelog/2388.improvement.rst Co-authored-by: Bruno Oliveira --- changelog/2388.improvement.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/2388.improvement.rst b/changelog/2388.improvement.rst index cdf0b55730b..5f49f2427f6 100644 --- a/changelog/2388.improvement.rst +++ b/changelog/2388.improvement.rst @@ -1 +1 @@ -SyntaxError crash lines now use the error's own file, line, and column instead of the location of the traceback entry where it was raised. +:class:`SyntaxError` crashes now contain an extra `FILE:LINE:COLUMN ERROR` line, as in other traceback lines. This format is widely supported by most editors/IDEs. From b0252e9c50e1b97c61a4ffdae65c6cc0c0c815e8 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Fri, 4 Sep 2026 23:02:31 +0700 Subject: [PATCH 05/13] Update code.py --- src/_pytest/_code/code.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index ae3f6b21af0..981e55e7a36 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -710,7 +710,7 @@ def _getreprcrash(self) -> ReprFileLocation | None: return ReprFileLocation( filename, lineno, - f"{self.typename}: {self.value.msg}", + f"{self.typename}: {self.value.msg or ''}", column=offset, ) # Find last non-hidden traceback entry that led to the exception of the @@ -1130,16 +1130,7 @@ def repr_traceback_entry( message = (excinfo and excinfo.typename) or "" entry_path = entry.path path = self._makepath(entry_path) - lineno = entry.lineno + 1 - # A SyntaxError carries its own location, which is more useful - # than the traceback entry where it was raised (#2388). - loc = _syntax_error_location(excinfo.value) if excinfo else None - if loc is not None: - filename, lineno, column = loc - path = self._makepath(filename or path) - else: - column = None - reprfileloc = ReprFileLocation(path, lineno, message, column=column) + reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) localsrepr = self.repr_locals(entry.locals) return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) elif style == "value": @@ -1548,7 +1539,7 @@ def toterminal(self, tw: TerminalWriter) -> None: if i != -1: msg = msg[:i] tw.write(self.path, bold=True, red=True) - column = f":{self.column}" if self.column is not None else "" + column = f":{self.column}" if self.column else "" tw.line(f":{self.lineno}{column}: {msg}") From 454d593755d4edc4359d4666ae63cf047e1df62b Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Fri, 4 Sep 2026 23:13:29 +0700 Subject: [PATCH 06/13] Update test_excinfo.py --- testing/code/test_excinfo.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 71123e3d063..afb58c040ca 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -350,6 +350,14 @@ def test_getreprcrash_syntax_error(self): 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: + raise SyntaxError(None, ("file.py", 1, 5, "def foo(:", 1, 6)) + reprcrash = excinfo._getreprcrash() + assert reprcrash is not None + assert reprcrash.message == "SyntaxError: " + assert str(reprcrash) == "file.py:1:5: SyntaxError: " + def test_getreprcrash_syntax_error_without_offset(self): def f(): raise SyntaxError("no location") @@ -1207,7 +1215,7 @@ def test_x(): """ ) result = pytester.runpytest("--tb=long") - result.stdout.fnmatch_lines(["file.py:1:5: SyntaxError"]) + result.stdout.fnmatch_lines(["*.py:2: SyntaxError"]) def test_syntax_error_default_tb_short(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -1219,7 +1227,7 @@ def test_x(): """ ) result = pytester.runpytest("--tb=short") - result.stdout.fnmatch_lines(["file.py:1:5: in entry"]) + 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: From b60e3d050d6c86f9f13e831cd73cd43f16c28eba Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Tue, 8 Sep 2026 20:04:14 +0700 Subject: [PATCH 07/13] Treat zero SyntaxError offset as unknown; fix strict-typing errors Co-authored-by: Claude Sonnet 5 --- src/_pytest/_code/code.py | 4 ++-- testing/code/test_excinfo.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 981e55e7a36..3f1d751e982 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -500,7 +500,7 @@ def _syntax_error_location(exc: BaseException) -> tuple[str, int, int] | None: """Return (filename, lineno, offset) for a SyntaxError with location info, else None.""" if ( isinstance(exc, SyntaxError) - and exc.offset is not None + and exc.offset and exc.lineno is not None and exc.filename ): @@ -1539,7 +1539,7 @@ def toterminal(self, tw: TerminalWriter) -> None: if i != -1: msg = msg[:i] tw.write(self.path, bold=True, red=True) - column = f":{self.column}" if self.column else "" + column = f":{self.column}" if self.column is not None else "" tw.line(f":{self.lineno}{column}: {msg}") diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index afb58c040ca..e87c3909adb 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -352,7 +352,8 @@ def test_getreprcrash_syntax_error(self): def test_getreprcrash_syntax_error_none_msg(self): with pytest.raises(SyntaxError) as excinfo: - raise SyntaxError(None, ("file.py", 1, 5, "def foo(:", 1, 6)) + details = ("file.py", 1, 5, "def foo(:", 1, 6) + raise SyntaxError(None, details) # type: ignore[call-overload] reprcrash = excinfo._getreprcrash() assert reprcrash is not None assert reprcrash.message == "SyntaxError: " @@ -411,6 +412,20 @@ def 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: @@ -854,7 +869,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 cast(str, reprfuncargs.args[0][1]) def test_repr_tracebackentry_lines(self, importasmod) -> None: mod = importasmod( From 7489cf747c8d641f97e8d3c6350e65101f61b89f Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Wed, 9 Sep 2026 20:46:48 +0700 Subject: [PATCH 08/13] Portable none-msg test; version-proof typing Co-authored-by: Claude Sonnet 5 --- testing/code/test_excinfo.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e87c3909adb..50c1610820a 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -352,8 +352,9 @@ def test_getreprcrash_syntax_error(self): def test_getreprcrash_syntax_error_none_msg(self): with pytest.raises(SyntaxError) as excinfo: - details = ("file.py", 1, 5, "def foo(:", 1, 6) - raise SyntaxError(None, details) # type: ignore[call-overload] + 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: " @@ -869,7 +870,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 cast(str, reprfuncargs.args[0][1]) + assert "..." not in str(reprfuncargs.args[0][1]) def test_repr_tracebackentry_lines(self, importasmod) -> None: mod = importasmod( From fa23c7c82aebfbd687a4b7ad8589bfa97a03e7d1 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Mon, 14 Sep 2026 19:07:30 +0700 Subject: [PATCH 09/13] Address Ronny review nits Co-authored-by: Claude --- changelog/2388.improvement.rst | 2 +- src/_pytest/_code/code.py | 16 ++++++++++------ src/_pytest/python.py | 2 ++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/changelog/2388.improvement.rst b/changelog/2388.improvement.rst index 5f49f2427f6..c257c7c72d0 100644 --- a/changelog/2388.improvement.rst +++ b/changelog/2388.improvement.rst @@ -1 +1 @@ -:class:`SyntaxError` crashes now contain an extra `FILE:LINE:COLUMN ERROR` line, as in other traceback lines. This format is widely supported by most editors/IDEs. +: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. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3f1d751e982..907fd05ed96 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -496,15 +496,20 @@ def stringify_exception( E = TypeVar("E", bound=BaseException, covariant=True) -def _syntax_error_location(exc: BaseException) -> tuple[str, int, int] | None: - """Return (filename, lineno, offset) for a SyntaxError with location info, else None.""" +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) + return ( + exc.filename, + exc.lineno, + exc.offset, + exc.msg or "", + ) return None @@ -705,12 +710,11 @@ def _getreprcrash(self) -> ReprFileLocation | None: # the traceback entry where it was raised (#2388). loc = _syntax_error_location(self.value) if loc is not None: - filename, lineno, offset = loc - assert isinstance(self.value, SyntaxError) + filename, lineno, offset, message = loc return ReprFileLocation( filename, lineno, - f"{self.typename}: {self.value.msg or ''}", + f"{self.typename}: {message}", column=offset, ) # Find last non-hidden traceback entry that led to the exception of the diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a24f64b47da..b76006f12c8 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -536,6 +536,8 @@ def importtestmodule( 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 From b5812a7458451c4441075bb65502ada1cb56a937 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Tue, 22 Sep 2026 22:55:40 +0200 Subject: [PATCH 10/13] Omit redundant File block in SyntaxError output when crash line has location When the crash line already carries the SyntaxError's own file:line:column (see #14899), the File "...", line N / source / caret block emitted by traceback.format_exception_only() in the E-lines is redundant. Drop it in ExceptionInfoFormatter.get_exconly, keeping it on the fallback path (no location info) so no information is lost. Closes #14994. --- changelog/14994.improvement.rst | 4 ++++ src/_pytest/_code/code.py | 28 ++++++++++++++++++++++++++++ testing/code/test_excinfo.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 changelog/14994.improvement.rst diff --git a/changelog/14994.improvement.rst b/changelog/14994.improvement.rst new file mode 100644 index 00000000000..be392b14b63 --- /dev/null +++ b/changelog/14994.improvement.rst @@ -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. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 907fd05ed96..b867aeb64cf 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -513,6 +513,28 @@ def _syntax_error_location(exc: BaseException) -> tuple[str, int, int, str] | No 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]): @@ -1059,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) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 50c1610820a..b3eb9f2074a 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1280,6 +1280,37 @@ def test_indentation_error_collection(self, pytester: Pytester) -> None: 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_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( """ From 130f6dd642ff02dfe6b9a8d8c3073e492dfe6f58 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 14:00:32 +0200 Subject: [PATCH 11/13] Update test_syntax_error_module for omitted File block (#14994) The File/source/caret block is now intentionally omitted from SyntaxError collection errors when the crash line carries the error's own file:line:column location. Update the test to assert the new output instead of the removed source line. --- testing/test_session.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/testing/test_session.py b/testing/test_session.py index 94738837831..8975f94a0e3 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -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( From cda81f11328ba0961af24a10449a0fe6cff0645f Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 23 Sep 2026 14:53:52 +0200 Subject: [PATCH 12/13] test: cover module-prefixed and no-match branches of _strip_syntax_error_file_block --- testing/code/test_excinfo.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index b3eb9f2074a..284f9937f3d 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -18,6 +18,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ExceptionInfoFormatter +from _pytest._code.code import _strip_syntax_error_file_block from _pytest._io import TerminalWriter from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import bestrelpath @@ -1300,6 +1301,31 @@ def test_get_exconly_syntax_error_keeps_file_block_on_fallback(self) -> None: " 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: From f4f528644725fb44ba5e4991b4d444e6c45595d8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:54:13 +0000 Subject: [PATCH 13/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/code/test_excinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 284f9937f3d..c717e520132 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -15,10 +15,10 @@ 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 -from _pytest._code.code import _strip_syntax_error_file_block from _pytest._io import TerminalWriter from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import bestrelpath