Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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` crashes now contain an extra `FILE:LINE:COLUMN ERROR` line, as in other traceback lines. This format is widely supported by most editors/IDEs.
41 changes: 38 additions & 3 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,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]):
Expand Down Expand Up @@ -689,6 +701,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):
Expand Down Expand Up @@ -1106,7 +1130,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
Comment on lines +1140 to +1141

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move path = self._makepath(entry_path) from above to here:

Suggested change
else:
column = None
else:
path = self._makepath(entry_path)
column = None

@SemTiOne SemTiOne Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm this is good. But when I think about it, is this a bandaid? Because if _syntax_error_location is ever relaxed to return loc even when exc.filename is falsy, then filename or path would evaluate the undefined path, and immediately raise a NameError? Yes mine is also a bandaid I believe, the or path is a dead code, should be deleted and be like this: self._makepath(filename)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then filename or path would evaluate the undefined path, and immediately raise a NameError?

Not sure I follow... if _syntax_error_location changes, mypy should catch any discrepancies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow... if _syntax_error_location changes, mypy should catch any discrepancies.

Ok fair enough.

the or path is a dead code, should be deleted and be like this: self._makepath(filename)

What do you think about this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this?

Let's leave it for now.

reprfileloc = ReprFileLocation(path, lineno, message, column=column)
localsrepr = self.repr_locals(entry.locals)
return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style)
elif style == "value":
Expand Down Expand Up @@ -1495,7 +1528,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 +1537,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 +1548,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
10 changes: 7 additions & 3 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the reprcrash.column guard? If not None, we call str(reprcrash) anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. str(reprcrash) is safe with column=None, but the guard isn't about safety. Dropping the guard here would append a near-duplicate location line in the no-column case. The column is the signal that we have the SyntaxError's own precise location worth appending, without it we keep the existing traceback line. What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make the intent more explicit then, preferably without just slapping a comment there... 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, here's my code proposal, wdyt?

msg = str(repr_)
syntax_error_has_precise_location = reprcrash is not None and reprcrash.column is not None
if syntax_error_has_precise_location:
    msg += "\n" + str(reprcrash) 

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
140 changes: 140 additions & 0 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,70 @@ def f():
f()
assert excinfo._getreprcrash() is None

def test_getreprcrash_syntax_error(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the tests are constructing a SyntexError manually... can we construct them instead via actual code and exec()? Seems more resilient/reliable that way.

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:
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we raising manual SyntaxErrors here, instead of just writing code with syntax errors?

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(
"""
Expand Down