Skip to content

Add an error_later warning filter action - #15135

Draft
RonnyPfannschmidt wants to merge 9 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:feat/error-later
Draft

RonnyPfannschmidt wants to merge 9 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:feat/error-later

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Written by Claude Opus 5.5 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.

Draft: the name, exit code 7 and the xdist hook in core are open questions, listed below.

A test-runner version of -W error, as suggested in #14931 (review): track where warnings that are errors come from, let the current test continue, and report the full picture as an error.

error raises at the warnings.warn call site. That aborts the code under test halfway through, so a test that emits several warnings reports only the first. error_later records the warning instead, lets the code run to completion, and fails afterwards.

Behaviour

  • error_later is accepted wherever a warning filter is: -W, the filterwarnings ini option and @pytest.mark.filterwarnings.
  • The stdlib itself decides whether error_later applies, with its own filter matching: message, category, module and line fields all work, and precedence is the stdlib's, including filters installed by the code under test, fixtures and conftest. See "How matching works" below.
  • error_later_report = test (the default) fails the phase that emitted the warning. If that phase already failed, its failure gets a "late warning errors" section instead of a second report.
  • error_later_report = session lets tests pass and fails the run at the end with a new ExitCode.LATE_WARNING_ERROR (7).
  • Warnings that belong to no test end up in the end-of-run section and set exit code 7 in both modes. That covers collection, conftest and plugin imports, pytest_configure, pytest_sessionfinish, and skipped or xfailed tests.
  • The summary line counts them as "N late warning error(s)", in red.
  • pytest-xdist: workers pass their late warnings to the controller through workeroutput, merged in pytest_testnodedown, so both modes behave the same with -n.
  • Suites without an error_later filter pay nothing: the check returns immediately.

Open questions

  • Name. error_later has an underscore, while the stdlib actions are single words. It is also rejected by PYTHONWARNINGS and python -W at interpreter start.

  • Exit code. ExitCode.LATE_WARNING_ERROR = 7 is new public API.

  • xdist hook. Core now implements an xdist hook, pytest_testnodedown, the same way pytest-cov does. It is only registered when the hookspec exists.

  • Filters installed by the test outrank error_later. A test's own simplefilter("always") or catch_warnings now takes precedence over an error_later filter, just as it already does over error. Warnings re-emitted by pytest.warns carry a module name derived from the file path, so module-restricted filters miss them, again as for error.

How matching works

Each error_later filter is installed as a real always filter whose module slot holds a probe object. The stdlib calls .match() on it, so the probe sees the exact module name and records a verdict when the filter matches. The stdlib also calls .match() on filters that go on to lose on category or line, so a never-matching loss probe follows each one and clears the verdict. The probe only leaves the verdict pending. A wrapper around warnings._showwarnmsg, which the stdlib calls for every warning it shows, makes it current for that one call, so pytest's recording picks it up whether the warning arrives directly or through a showwarning hook that forwards it (numpy's suppress_warnings, #15139). When the call returns the verdict is gone, so a warning taken by pytest.warns, by the test's own catch_warnings or by a hook that discards it leaves nothing behind for the next one. Without an error_later filter, recording stays a plain list.append.

Relies on

Behaviour that is not documented, checked against the CPython 3.10–3.14 sources:

  • _warnings.c:check_matched (and the pure-Python loop) calls .match() on whatever object sits in a filter tuple's message and module slots; only the 5-tuple shape and a str action are validated.
  • With action always, the warning is shown synchronously in the emitting thread, with no other filter search in between.
  • _warnings.c:call_show_warning (and the pure-Python warn_explicit) looks up warnings._showwarnmsg on every shown warning, so replacing it takes effect at once. catch_warnings does not restore it, so pytest puts it back when its own warnings context exits.
  • Private names: warnings.filters (direct insertion), warnings._filters_mutated(), warnings._showwarnmsg, warnings._showwarnmsg_impl, and on 3.14 warnings._get_filters() and warnings._get_context().log.
  • The probes are visible in warnings.filters; they expose .pattern but not the rest of re.Pattern.

Verified on 3.10, 3.14, 3.14 with context-aware warnings and free-threaded 3.14t; 3.11–3.13 only through a standalone check of the mechanism. The _showwarnmsg wrapper is verified on 3.11–3.14 and on 3.14 with context-aware warnings, not on 3.10 or 3.14t.

Related

Known gaps

  • Warnings from pytest_sessionfinish on xdist workers are still lost.
  • On 3.14 with context-aware warnings, threads do not inherit the warnings context, so their warnings never reach pytest. That is stdlib behaviour.

Not verified

The Sphinx build, PyPy, 3.13t, and interaction with --pdb and -x.

RonnyPfannschmidt and others added 4 commits October 5, 2026 00:52
The 'error' action raises at the warnings.warn() call site. That aborts the
code under test halfway through and anchors the failure on the frame that
emitted the warning rather than on what the test was doing. It stays the
right choice for people who want to fix warnings immediately.

'error_later' is accepted wherever a filter is (-W, the filterwarnings ini
option, @pytest.mark.filterwarnings). It is installed as 'always', so the
warning is recorded and the code runs to completion; pytest then decides
against the filters it applied, walked last-first like warnings.filters and
matched like warnings.filterwarnings (message with re.I, module regex against
the emitting module, lineno), whether the recorded warning errors later. The
action itself rejects the module and line fields, because a recorded warning
carries a filename, not the emitting module's __name__.

Each runtest phase is wrapped, not followed by a trylast hook, so a phase
that raised is drained too: its warnings are added to that phase's failure
report instead of leaking into teardown as a second, separate error.

Warnings with no test phase to fail -- collection, plugin and conftest
import, pytest_configure, issue_config_time_warning, pytest_sessionfinish,
a skipped or xfailed test -- are listed in a "late warning errors" section
at the end, counted in the summary line and fail the run with the new
ExitCode.LATE_WARNING_ERROR (7). The record=False context that _do_configure
keeps open diverts them through showwarning, until the session can no longer
report; after that they are shown as before rather than dropped.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Failing every test that emits a matching warning is the right default for a
suite that is already clean, but it turns a newly deprecated API into a wall
of red. 'session' lets the tests pass and only fails the run: every late
warning goes into the end-of-run "late warning errors" section, is counted
in the summary line, and the exit code becomes LATE_WARNING_ERROR unless the
run already failed for another reason.

The value is validated in pytest_configure, so a typo is a usage error up
front rather than once a warning first matches.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Under pytest-xdist every worker has its own late-warning list and exit
status, and the controller uses neither: 'session' mode, and the end-of-run
section in either mode, passed silently.

Workers now put their late warnings into config.workeroutput, which xdist
already serialises and hands to the controller with pytest_testnodedown. The
controller merges them into its own list, so the section, the summary line
and the exit code come out as without xdist. Test reports cannot carry this:
xdist only forwards collect reports that did not pass, so warnings emitted
while collecting would still be lost. Every worker collects and configures,
so warnings with no test node are merged once.

The collector is only registered when the pytest_testnodedown hook spec
exists, since a builtin plugin implementing an unknown hook fails validation.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Covers the action, both error_later_report modes, which warnings land in
the end-of-run section, xdist, and the limits: no module or line field,
not accepted by PYTHONWARNINGS or python -W, and filters the code under
test installs itself can only ignore a warning, not stop it from erroring
later. Exit code 7 is added to the exit code reference.

The changelog fragment is an orphan (+error-later) until the pull request
has a number; the changelog filename hook rejects it until then.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Oct 5, 2026
RonnyPfannschmidt and others added 2 commits October 5, 2026 12:11
error_later decided afterwards whether a recorded warning should error,
by re-walking the filters pytest had applied. That walk could not see
filters installed by the code under test, conftest or fixtures, and had
to guess the emitting module from the file name, which is wrong for
exec'd code, __main__ and modules that rebind __name__. So module and
line fields were rejected outright.

Install each error_later filter as a real "always" filter instead, with
a probe object in the module slot: the stdlib calls .match() on it, so
the probe sees the exact module name, and records a verdict when the
filter matches. The stdlib also calls .match() on filters that go on to
lose on category or line, so a never-matching loss probe follows each
one and clears the verdict. A verdict is only recorded when the warning
will reach pytest's recording, so a warning taken by pytest.warns or by
the test's own catch_warnings leaves nothing behind.

Module and line fields now work, and precedence is exactly the stdlib's:
filters the test installs itself outrank error_later as they outrank
error. Without an error_later filter, recording stays a plain
list.append.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
RonnyPfannschmidt and others added 3 commits October 5, 2026 13:35
The xdist collector was registered when config.hook had
pytest_testnodedown, taken to mean xdist's hookspec is present. But
pytest-cov implements that hook as optional too, which creates the hook
caller without a spec when xdist is not installed, so the collector's
non-optional implementation failed validation: INTERNALERROR "unknown
hook 'pytest_testnodedown'" in check_pending (the plugins CI job).

Mark the implementation optional and register it unconditionally, as
pytest-cov does; without xdist it is simply never called.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
`:option:`-W <pytest -W>`` does not resolve (pytest's option is
referenced as plain `:option:`-W``), and warnings.catch_warnings is a
class. Both failed the Sphinx -W build on Read the Docs.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>
A test that replaces warnings.showwarning with a wrapper forwarding to the
original, as numpy.testing.suppress_warnings does, turned an error_later
warning into a plain one (pytest-dev#15139). The win probe only
recorded a verdict when pytest's recording was the direct destination, so
that a warning taken elsewhere could not leave one behind.

The probe now always leaves the verdict pending, and a wrapper around
warnings._showwarnmsg, which the stdlib calls for every shown warning,
makes it current for the length of that call only. Whatever reaches
pytest's recording during the call consumes it, and it is gone when the
call returns, so a hook that discards the warning cannot hand it to the
next one. Keying the verdict on the message text instead still mis-tags a
later warning with the same text that a filter in front of error_later
takes.

The wrapper is installed with the first error_later filter and put back
when the pytest warnings context that installed it exits, since
catch_warnings() does not restore _showwarnmsg.

Co-Authored-By: Claude Opus 5.5 via Claude Code <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

error_later is silently dropped when a test wraps warnings.showwarning

1 participant