From 0c888480321bcd8fcf44bb34c06900458668e1b1 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Tue, 18 Aug 2026 12:52:16 +0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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( """