Skip to content

Fix SyntaxError crash line to show file, line, and column - #14899

Open
SemTiOne wants to merge 13 commits into
pytest-dev:mainfrom
SemTiOne:fix-2388-syntax-error-crash-line
Open

Fix SyntaxError crash line to show file, line, and column#14899
SemTiOne wants to merge 13 commits into
pytest-dev:mainfrom
SemTiOne:fix-2388-syntax-error-crash-line

Conversation

@SemTiOne

@SemTiOne SemTiOne commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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.

Before:

    E     File "package/lib.py", line 1
    E       def broken(:
    E                  ^
    E   SyntaxError: invalid syntax
    package/lib.py:1: SyntaxError

After:

    E     File "package/lib.py", line 1
    E       def broken(:
    E                  ^
    E   SyntaxError: invalid syntax
    package/lib.py:1:12: SyntaxError: invalid syntax

The traceback entry location and collection errors use the same file:line:col format. The ReprFileLocation dataclass gains an optional column field (column: int | None = None); existing output is unchanged when the field is absent.

Note: This PR doesn't solve the removal of the now-redundant File..., line N block. This is a deliberate design decision; it comes from stdlib format_exception_only(); deferred to a follow-up issue.

  • Include documentation when adding new features.
  • Include new tests or update existing tests when applicable.
  • Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.
  • Closes Format SyntaxErrors like Tracebacklines #2388
  • If AI agents were used, they are credited in Co-authored-by commit trailers.
  • Create a new changelog file in the changelog directory, with a name like <ISSUE NUMBER>.<TYPE>.rst. See changelog/README.rst for details.
  • Add yourself to AUTHORS in alphabetical order.

Co-authored-by: Claude <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Aug 18, 2026
@SemTiOne
SemTiOne force-pushed the fix-2388-syntax-error-crash-line branch from 37b18b6 to 427a995 Compare August 18, 2026 15:08
@SemTiOne
SemTiOne marked this pull request as ready for review August 18, 2026 15:55
@SemTiOne
SemTiOne marked this pull request as draft August 22, 2026 06:22
Co-authored-by: Claude <noreply@anthropic.com>
@SemTiOne
SemTiOne marked this pull request as ready for review August 23, 2026 03:49
@SemTiOne

Copy link
Copy Markdown
Contributor Author

Hello @RonnyPfannschmidt, could you please review this PR when you have a moment? Thank you for your time.

Comment thread changelog/2388.improvement.rst Outdated
Comment thread src/_pytest/_code/code.py
Comment on lines +1140 to +1141
else:
column = 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.

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.

Comment thread src/_pytest/python.py
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) 

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.

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?

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Thanks for picking this up — the _getreprcrash() half of this PR is exactly the right fix for #2388, and it verifies nicely: --tb=line now gives gen.py:1:12: SyntaxError: invalid syntax where main gives the useless test_raise.py:2: File "gen.py", line 1, and the short summary improves the same way. --tb=native is correctly untouched, and report serialization round-trips the new column field.

The repr_traceback_entry change, however, needs another look: it overwrites the last traceback entry's file:line with the SyntaxError's own location while the source snippet and in <funcname> still describe the real frame — so the one line editors parse for jump-to-location now points somewhere that doesn't contain the displayed code.

Reproducer (validated against the current head, 4d8030b, and against main):

# test_pr14899_repro.py
def helper():
    compile("def broken(:", "gen.py", "exec")

def test_issue1_wrong_frame_attribution():
    helper()

def test_issue3_chained_cause_link():
    try:
        compile("def broken(:", "gen.py", "exec")
    except SyntaxError as e:
        raise RuntimeError("wrapper") from e
# test_issue2_broken_syntax.py  (a real collection-time SyntaxError)
def broken(:
    pass
# test_issue7_import_no_offset.py  (import-time SyntaxError without offset)
raise SyntaxError("custom import error", ("weird.py", 3, None, "src", 3, None))

Issue 1 — frame attribution (--tb=short)

Actual (this PR):

gen.py:1:12: in helper
    compile("def broken(:", "gen.py", "exec")
E     File "gen.py", line 1
...

helper is not in gen.py, and gen.py:1 does not contain that source line. Expected — the frame keeps its real location (as on main), since the E-block and the crash line already carry the syntax-error location:

test_pr14899_repro.py:2: in helper
    compile("def broken(:", "gen.py", "exec")
E     File "gen.py", line 1
...

The same mislabelled line appears as the trailing gen.py:1:12: SyntaxError under the last frame in --tb=long and --fulltrace, and inside the "direct cause" section for the chained case (gen.py:1:12: in test_issue3_chained_cause_link).

Issue 2 — collection error mixes three files into one entry

Actual (this PR, pytest test_issue2_broken_syntax.py):

test_issue2_broken_syntax.py:1:12: in parse
    return compile(source, filename, mode, flags,
E     File ".../test_issue2_broken_syntax.py", line 1
E       def broken(:
E                  ^
E   SyntaxError: invalid syntax
test_issue2_broken_syntax.py:1:12: SyntaxError: invalid syntax

The entry line fuses the user file's location, stdlib ast.parse's function name, and stdlib source — and the location then appears three times. Expected: entry lines keep their real frames; the new file:line:col crash line appended once at the end is the actual improvement:

.../ast.py:54: in parse
    return compile(source, filename, mode, flags,
E     File ".../test_issue2_broken_syntax.py", line 1
E       def broken(:
E                  ^
E   SyntaxError: invalid syntax
test_issue2_broken_syntax.py:1:12: SyntaxError: invalid syntax

Issue 3 — the crash-line append in python.py is gated on column

test_issue7_import_no_offset.py (filename and lineno present, offset absent) gets no appended crash line at all, while issue 2's case gets one — the if reprcrash.column is not None gate should be if reprcrash is not None, and arguably the append belongs in the repr machinery rather than in importtestmodule. Expected final line:

weird.py:3: SyntaxError: custom import error

Smaller points

  • raise SyntaxError(None, ("file.py", 1, 5, ...)) renders the crash line as SyntaxError: None (the E-block correctly says <no detail available>).
  • offset=0 passes the is not None guard and prints a zero column (file.py:1:0: ...); 0 should probably be treated like None.
  • filename or path in repr_traceback_entry is dead code — _syntax_error_location already guarantees a truthy filename.

My suggestion: drop (or narrow to the bare-typename long-style case) the repr_traceback_entry rewrite, keep _getreprcrash(), and make the collection append unconditional. That keeps every win shown in the PR description's after-example except the rewritten per-frame line, without the misattribution.


This analysis and comment were produced by an AI (Claude, via Claude Code) at my request; I have reviewed the findings. — Ronny

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Format SyntaxErrors like Tracebacklines

3 participants