diff --git a/changelog/2388.improvement.rst b/changelog/2388.improvement.rst new file mode 100644 index 00000000000..cdf0b55730b --- /dev/null +++ b/changelog/2388.improvement.rst @@ -0,0 +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. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index fee4468cf0f..ca73557cddb 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -494,6 +494,18 @@ 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.""" + 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 + + @final @dataclasses.dataclass class ExceptionInfo(Generic[E]): @@ -687,6 +699,18 @@ 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 = loc + assert isinstance(self.value, SyntaxError) + return ReprFileLocation( + filename, + lineno, + f"{self.typename}: {self.value.msg}", + 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): @@ -1104,7 +1128,16 @@ def repr_traceback_entry( message = (excinfo and excinfo.typename) or "" entry_path = entry.path path = self._makepath(entry_path) - reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) + 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) localsrepr = self.repr_locals(entry.locals) return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) elif style == "value": @@ -1496,7 +1529,7 @@ def __str__(self) -> str: @dataclasses.dataclass(eq=False) class ReprFileLocation(TerminalRepr): - """A message at a file location, using the `:: ` + """A message at a file location, using the `:[:]: ` format that most editors understand. Only the first line of the message is emitted. @@ -1505,6 +1538,7 @@ class ReprFileLocation(TerminalRepr): path: str lineno: int message: str + column: int | None = None def __post_init__(self) -> None: self.path = str(self.path) @@ -1515,7 +1549,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/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 f4cc6d7e73a..4dfd353adb5 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -339,6 +339,70 @@ 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_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_excinfo_exconly(): with pytest.raises(ValueError) as excinfo: @@ -1116,6 +1180,82 @@ 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(["file.py:1:5: 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(["file.py:1:5: 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_repr_traceback_recursion(self, importasmod): mod = importasmod( """