Skip to content

Transfer the warning's state, and rebuild without re-running __init__ - #1372

Open
dprada wants to merge 1 commit into
pytest-dev:masterfrom
dprada:fix/warning-reconstruction-roundtrip
Open

dprada wants to merge 1 commit into
pytest-dev:masterfrom
dprada:fix/warning-reconstruction-roundtrip

Conversation

@dprada

@dprada dprada commented Aug 16, 2026 •

Copy link
Copy Markdown

On the controller, unserialize_warning_message rebuilt a warning as cls(*message_args), and serialize_warning_message sends only args. But args alone does not describe a warning: the instance keeps state beside them — in its __dict__, or in __slots__ — and we transferred none of it.

So a warning keeping anything beside its args arrived without it, and one deriving its message from that state arrived silently wrong. -n0 serializes nothing and is the reference for what the warning actually says:

class CodeWarning(UserWarning):
    def __init__(self, code=None):
        self.code = code
        super().__init__()
    def __str__(self):
        return "code {} tripped".format(self.code or "unknown")
-n0:  code 42 tripped
-n1:  code unknown tripped

What this does

Transfer the state, and rebuild without re-running __init__. _serializable_warning_state collects what the instance keeps — its __dict__ plus the slots along its MRO — and the controller applies it with cls.__new__(cls) + BaseException.__init__ + __setstate__, whose setattr per key reaches slots as well as the dictionary. args and state are each gated on what execnet.dumps can carry.

Read the instance, not __reduce__. The one place where I did not follow the review literally, because __reduce__ cannot answer the question. It reports only self.__dict__, so a __slots__ class reduces to a two-tuple and its slots read as "keeps nothing": the controller rebuilds an instance whose own __str__ then raises AttributeError where it renders it — an INTERNALERROR, not merely a wrong message. In the other direction, a class that replaces __reduce__ reads as "state we cannot send" even when it keeps nothing, so its class would be dropped and its text prefixed where master rebuilds it exactly. Reading the instance makes both exact, and settles the __reduce__ item without running a callable named by the payload: nothing consults __reduce__ any more.

Drop the class only when its state cannot cross. A warning that keeps nothing has nothing to lose and is rebuilt exactly; one whose state will not serialize takes the existing generic-Warning path, for the reason above. That second case carries a deliberate cost: a warning keeping something unserializable but rendering from its args alone loses its class, where master kept it. Telling those apart means comparing the rendered text across the boundary, which your review rejected — say the word and I will add it as a tie-breaker for exactly that case.

Tests

Assert the resulting type and the exact text, per the review. Round-trip fidelity for four shapes — field-first, state-only, __slots__, and a class that provides its own __reduce__; the generic-Warning fallback for state that will not serialize; exact rebuild for a warning with no state, so the fallback does not widen to warnings with nothing to lose; and -n0/-n1 parity for the two shapes that used to disagree.

Mutation-verified: reading the state from __reduce__ instead of from the instance fails 3 tests, dropping the lost-state check 1.

Full suite green. ruff check, ruff format and mypy clean on the changed files.


Rebased onto master and narrowed to this one question. The guard against a warning's class failing to resolve, which used to be the second half of this PR, is now #1387: it closes #404, and neither half needs the other.

The two red py311-pytestmain checks are #1386 (--dist=loadgroup silently losing its grouping on pytest main): unrelated to this change, and red on every open PR in the repo since pytest-dev/pytest#14758 merged on 2026-09-06.

dprada added a commit to dprada/pytest-xdist that referenced this pull request Aug 16, 2026
dprada added a commit to uibcdf/molsysmt that referenced this pull request Aug 16, 2026
…tocol

The report was committed without an issue, against reporting_protocol.md, and
with `status: guarded`, which is not one of the seven values that document
defines. Both corrected: the theme is #158, and the state is
`blocked`, on the upstream fix proposed as pytest-dev/pytest-xdist#1372 — which
the acceptance section now names, so a reader can follow it without leaving the
document.

`area` moves from the invented `test-tooling` to `tests`, which the rest of the
queue already uses, and `verification` from `measured` to `reproduced`, which is
what actually happened: serial and parallel runs of the same tests differ.

Index regenerated with devtools/scripts/devguide_index.py rather than by hand;
`--check` had been reporting it stale since the report landed.
dprada added a commit to uibcdf/molsysmt that referenced this pull request Aug 16, 2026
The guard was unconditional, and pytest-xdist pull requests wait months. Left
alone it would have outlived its cause silently — and worse than silently: with
the upstream fix in place the guard still fires, because xdist's fallback text
carries a `module.Class: ` prefix and so still differs from the original. The
reported output is identical either way, so nothing downstream could ever reveal
that the workaround had become dead code. Watching the report was the obvious
mechanism and it does not work.

So ask the behaviour instead. `conftest.py` now probes the installed xdist
before patching anything — a real catalog warning through
`serialize_warning_message` and the untouched `unserialize_warning_message` —
and tells re-rendering apart from every other outcome by the type that comes
back: the original class with grown text is the defect, a generic `Warning` or
unchanged text is not. The guard installs only on the first. An unreadable
answer keeps the guard, since not knowing is not the same as knowing it is
fixed.

Announcing the retirement took a second attempt. A `warnings.warn` from
`pytest_configure` is raised before pytest installs its capture and never
reaches the report; measured, not assumed. It is now
`test_the_xdist_workaround_is_still_needed`, which fails the day the probe says
the defect is gone and carries the removal steps in its message. Among 130
warnings per run another line would be scrolled past; a red test is not, and the
failure is good news.

`test_catalog_warnings_are_not_re_rendered` is the other half and retires in the
opposite direction: it fails if the doubled text ever returns, whether the guard
goes too early or a new warning class is written in a shape that defeats it. It
becomes the `guard:` field of #158, which until now named the
workaround's own function rather than a test.

Full suite under `-n 12`: 9986 passed, 11 skipped, no doubled text. Both states
verified — the probe answers True against the installed xdist and False against
a checkout carrying pytest-dev/pytest-xdist#1372.
@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Ill give a more detailed reply when I get back to the computer

But the premise here is wrong as is

@RonnyPfannschmidt RonnyPfannschmidt left a comment

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.

up front: the analysis below and the cell were put together by claude at my direction, and the output block is what it printed running that cell in its own sandbox on cpython 3.12.3. i have not rerun it locally, so treat the numbers as something to check rather than something established. the reasoning and the conclusions are mine. whats put here is iteration 7


this misunderstands how warnings and exceptions get reconstructed.

cls(*args) is not something xdist made up. BaseException.__reduce__ returns (cls, self.args), plus the instance dict as a third element when non-empty, and pickle calls the class with those args. your first case therefore doubles under plain pickle and copy too, no xdist involved. and warnings.warn(msg, category) builds the instance as category(msg), so neither of your classes can be used as a category at all - one re-renders the message, the other swallows it.

the check in this pr is also at the wrong end. what we get wrong is the state: __reduce__ carries a third element, BaseException implements __setstate__ (setattr per key, the branch pickle takes for exceptions), and we transfer neither. rebuilding without re-running __init__ gets both of your cases back exact, class included.

paste this in a cell:

import copy
import pickle
import warnings


class BrokenResourceWarning(UserWarning):
    """field first, message rendered in __init__"""
    def __init__(self, resource):
        self.resource = resource
        super().__init__(f"{resource!r} is not available")


class BrokenCodeWarning(UserWarning):
    """no args at all, text derived from state"""
    def __init__(self, code=None):
        self.code = code
        super().__init__()

    def __str__(self):
        return f"code {self.code or 'unknown'} tripped"


class FixedResourceWarning(UserWarning):
    """message first, structured data kept alongside"""
    def __init__(self, message, resource=None):
        super().__init__(message)
        self.resource = resource

    @classmethod
    def for_resource(cls, resource):
        return cls(f"{resource!r} is not available", resource=resource)


def xdist_today(w):
    """class + args, what unserialize_warning_message does"""
    return type(w)(*w.args)


def via_reduce(w):
    """args plus reduce state, rebuilt without re-running __init__"""
    red = w.__reduce__()
    cls, args = red[0], red[1]
    state = red[2] if len(red) > 2 else None
    new = cls.__new__(cls)
    BaseException.__init__(new, *args)
    if state is not None:
        new.__setstate__(state)
    return new


def report(w):
    print(f"{type(w).__name__}")
    print(f"    {'original':12} {str(w)!r}")
    for name, fn in [
        ("pickle", lambda x: pickle.loads(pickle.dumps(x))),
        ("copy", copy.copy),
        ("xdist today", xdist_today),
        ("via reduce", via_reduce),
    ]:
        try:
            out = repr(str(fn(w)))
        except Exception as exc:
            out = f"{type(exc).__name__}: {exc}"
        print(f"    {name:12} {out}")
    print()


report(BrokenResourceWarning("gpu"))
report(BrokenCodeWarning(42))
report(FixedResourceWarning.for_resource("gpu"))

for cls in (BrokenResourceWarning, BrokenCodeWarning, FixedResourceWarning):
    with warnings.catch_warnings(record=True) as rec:
        warnings.simplefilter("always")
        try:
            warnings.warn("boom", cls)
            got = repr(str(rec[0].message))
        except Exception as exc:
            got = f"{type(exc).__name__}: {exc}"
    print(f"warn('boom', {cls.__name__}) -> {got}")

reported output, cpython 3.12.3, sandbox run as noted above:

BrokenResourceWarning
    original     "'gpu' is not available"
    pickle       '"\'gpu\' is not available" is not available'
    copy         '"\'gpu\' is not available" is not available'
    xdist today  '"\'gpu\' is not available" is not available'
    via reduce   "'gpu' is not available"

BrokenCodeWarning
    original     'code 42 tripped'
    pickle       'code 42 tripped'
    copy         'code 42 tripped'
    xdist today  'code unknown tripped'
    via reduce   'code 42 tripped'

FixedResourceWarning
    original     "'gpu' is not available"
    pickle       "'gpu' is not available"
    copy         "'gpu' is not available"
    xdist today  "'gpu' is not available"
    via reduce   "'gpu' is not available"

warn('boom', BrokenResourceWarning) -> "'boom' is not available"
warn('boom', BrokenCodeWarning) -> 'code boom tripped'
warn('boom', FixedResourceWarning) -> 'boom'

FixedResourceWarning is the shape that holds up everywhere: message first, structured datum as a keyword, a classmethod for the convenient call. it survives pickle, copy, the category api and our current serializer unchanged, and needs no xdist patch at all.

what i'd review on our side:

  • serialize args and the reduce state, each gated on what execnet.dumps can carry
  • rebuild via __new__ + __setstate__ instead of cls(*args), so a re-rendering __init__ never runs
  • honour a class-provided __reduce__ when its callable isn't the class itself
  • slots state arrives as a (dict, slots) 2-tuple - handle it or fall back
  • fall back to a plain Warning only when the state won't transfer

comparing str() and discarding the result detects that our transfer is lossy without making it less lossy, and pays for it by dropping the message class and prefixing the text with module.Class: .

tests should assert the resulting type and the exact text, not a substring and an occurrence count.

@dprada

dprada commented Aug 17, 2026

Copy link
Copy Markdown
Author

Still reproducible, now differently explained: on master, -n0 and -n1 disagree about what the same warning says. The first version of this PR misread why, and its check detected the loss instead of repairing it — @RonnyPfannschmidt's review is correct on every point.

Thank you for it. The write-up did more than reject an approach: it explained where the mechanism actually lives, which shape of warning class holds up everywhere, and what you would want to see on your side. I ran your cell on CPython 3.13.14 and it reproduces exactly, BrokenCodeWarning included — the case that shows this is not only about badly shaped classes.

I have kept this open rather than closing it because the behaviour it was opened for is still observable, and I believe the five items you listed close it properly. If you conclude otherwise, closing this is entirely reasonable and the analysis you wrote was worth more than the patch either way.

The defect

serialize_warning_message sends only args, and unserialize_warning_message rebuilds as cls(*message_args). BaseException.__reduce__ carries the instance dictionary as a third element and BaseException.__setstate__ applies it; we transfer neither. A warning keeping anything beside its args therefore arrives without it, and one deriving its message from that state arrives silently wrong.

-n0 serializes nothing, so it is the reference for what the warning actually says:

class CodeWarning(UserWarning):
    def __init__(self, code=None):
        self.code = code
        super().__init__()
    def __str__(self):
        return "code {} tripped".format(self.code or "unknown")

def test_func():
    warnings.warn(CodeWarning(42))
-n0   code 42 tripped
-n1   code unknown tripped

That class round-trips correctly under pickle and under copy. It only breaks crossing a worker boundary, and the result reads like a legitimate message rather than an error.

The change

Following your five items:

  • Serialize the reduce state alongside args, each gated on what execnet.dumps can carry, so a payload that cannot cross is reported absent rather than half-sent.
  • Rebuild with cls.__new__ + BaseException.__init__ + __setstate__, so a re-rendering __init__ never runs.
  • Handle the (dict, slots) two-tuple that __slots__ classes reduce to.
  • Leave a class-provided __reduce__ alone when its callable is not the class itself, falling back to the generic Warning. This is my reading of your fourth point and may not be what you meant: execnet cannot carry that callable, so I could not find a way to honour such a protocol rather than override it. If you had something else in mind I would rather follow it than guess.
  • Fall back to the generic Warning only when the state will not transfer.

Tests

test_state_beyond_args_survives is the acceptance test, parametrized over -n0/-n1; it fails on master at -n1 while passing at -n0. Two unit tests cover the shapes from your review — field-first and state-only — asserting the resulting type, the exact text and the restored attribute, rather than a substring and an occurrence count. All three fail without the change.

dprada added a commit to uibcdf/smonitor that referenced this pull request Aug 17, 2026
…tent

`CatalogWarning` and `CatalogException` appended the resolved hint to the
message and stored the result as their `args`. Python rebuilds an exception as
`type(e)(*e.args)` — `pickle`, `copy.deepcopy` and pytest-xdist all take that
route — so the constructor received a string it had already transformed and
appended the hint again, with the placeholders of the second copy unresolved:

    "No digester for x  Define a digester for 'x'.  Define a digester for 'unknown'."

Reordering the subclasses' parameters does not fix this. ArgDigest's already
take the message first and doubled just the same, because the transformation is
in the base class.

`args` now holds the message before the hint, `__str__` renders the two
together, and `.hint` keeps the hint reachable on its own. The visible text is
unchanged and the class is idempotent: `type(e)(*e.args)` reproduces it.

This replaces the `__reduce__` added earlier in this same unreleased window,
which reached exactness by bypassing the constructor. It only ever covered
`pickle` and `copy`: a rebuilder calling the class directly never reaches
`__reduce__`, and pytest-dev/pytest-xdist#1372 has to fall back — losing the
class — when it finds a custom one. Repairing the class repairs every rebuilder
at once, which is what the review of that PR was pointing at.

Two test defects fixed alongside. The round-trip cases were instantiated inside
`parametrize`, which runs at collection time before the fixture loads the codes,
so they were asserting over warnings that rendered to nothing. And the classes
under test now take the message first, with a classmethod keeping the per-field
argument checking; a test asserting the opposite shape records why that matters.
dprada added a commit to uibcdf/smonitor that referenced this pull request Aug 17, 2026
The report still said "diagnosed, not ours to fix" and credited a `__reduce__`
that has since been removed. Neither is true: the defect was in
`CatalogWarning.__init__` transforming its own input, and it is fixed in 0.13.0.

It also recorded two reasons for rejecting the fix that eventually worked, and
both were wrong. Reordering the subclass parameters was dismissed as a workaround
spread across every library, when it is the shape Python's rebuild protocol
requires — and ArgDigest, whose classes already took the message first and
doubled anyway, is what located the defect in the base class. The second
rejection argued that removing the subclasses' `__init__` would cost per-field
argument checking and could not serve classes that compute their message; both
objected to a variant nobody proposed, since keyword-only fields keep the
checking and a classmethod covers the computed case.

They are left in the document as refuted rather than deleted. A rejected option
that turned out to be the answer is worth more to the next reader than a clean
record, and the review on pytest-dev/pytest-xdist#1372 — which surfaced all of
it — is named there.

The entry stays open for the residue only: a hint interpolating a field cannot be
re-rendered from `args` alone, which needs the upstream transfer.
@dprada

dprada commented Sep 5, 2026

Copy link
Copy Markdown
Author

@RonnyPfannschmidt — ready for another look, and I owe you an apology for part of the delay.

The code has implemented your direction since 2026-08-17, but I never rewrote the PR description, so it still argued the first approach — the one you said was at the wrong end. Anyone returning to this PR was reading a design that is no longer in it. That is fixed now; the body above describes what is actually here.

Since your review

Your five items, and where each landed:

item state
serialize args and the reduce state, each gated on execnet.dumps done
rebuild via __new__ + __setstate__ done
slots state as a (dict, slots) two-tuple done
fall back to a plain Warning only when the state won't transfer was incomplete — now done
honour a class-provided __reduce__ when its callable isn't the class was missed — see below

Two of them were not actually finished when I said the rework was complete, and together they left something worse than the original defect:

CustomReduce      worker: "code 7 tripped"  ->  controller: AttributeError: no attribute "code"
UnDumpableState   worker: "code 9 tripped"  ->  controller: AttributeError: no attribute "code"

_serializable_reduce_state returned None both for a warning that carries no state and for one whose state cannot cross, and the controller rebuilt the class either way. Rebuilding without the attributes a class's own __str__ reads means the controller raises when it renders the warning — a crash introduced while fixing a rendering defect. It returns (state, lost) now, and a lost state takes the fallback.

The __reduce__ item was missed for a reason worth naming: the tuple-length check ran before the callable check, and such a class commonly reduces to a two-tuple carrying its state inside the args. That read as "no state, nothing lost". The callable is checked first now.

One question, on that item

I am not sure I read "honour" correctly. It could mean rebuild through it, which is what pickle does. I did not, because it calls an arbitrary callable named by the payload, in the controller's receiver thread — the same place the second half of this PR is about protecting.

What it does instead is treat it as a loss rather than an absence, so the class is dropped and the text reported once, instead of an instance arriving broken. If you meant the stronger reading, say so and I will implement it.

The second half, and #404

Separable — happy to split it into its own PR if you would rather review them apart.

Resolving the warning's class is importlib.import_module plus a getattr, both outside every guard, both in the receiver thread. This is #404: open since 2019, with your own "I believe we need some kind of fix" on it, and its 2022 report names the getattr line and shows it arriving as an INTERNALERROR. test_warning_serialization_tweaked_module was added alongside that report and asserted pytest.raises(ModuleNotFoundError) — it characterised the defect rather than fixing it. It now asserts the degradation, and a new test covers the AttributeError shape, which is the one that report actually hit.

The category resolution below it had no guard at all and no test, and category is what pytest filters and reports on.

Verification

Rebased onto the current branch head after the master merge and re-run there: 235 passed, 2 skipped, 10 xfailed. ruff check, ruff format and mypy clean on the changed files.

Each guard is mutation-verified — removing the message-class guard fails 3 tests, the category guard 2, the lost-state check 2 — because a guard nobody has watched fail is not a guard.

dprada added a commit to uibcdf/smonitor that referenced this pull request Sep 7, 2026
MolSysMT's `reporting_protocol.md` declares itself ecosystem-wide and names
this repository in scope: "the same front matter and the same vocabularies
apply to argdigest, depdigest, pyunitwizard, smonitor and molsysviewer".
MolSysViewer adopted it in August. We had none of it -- no front matter, no
issues on our own documents, no queue indexes, and closed documents removed
rather than archived, which is the one alignment the protocol calls required.

The vocabulary is taken unchanged. `status` and `verification` are theirs word
for word, because `uibcdf/<repo>#<number>` is a reliable reference in every
direction only while the words mean the same thing everywhere. Three
differences are ours and all are tooling, stated in the document: the
validator is a test rather than a script, since our release gate is pytest;
the archive is flat, as MolSysViewer argued; and the board is worked by hand.

Applied to what we had:

- Three documents had no issue. Opened as #4 (the xdist residue, blocked on
  pytest-dev/pytest-xdist#1372), #5 (hint ownership, `partial` -- decided and
  not implemented, which is a state we were carrying in prose) and #6 (the
  pytest bridge). #3 already covered the fourth.
- The resolved reach-through report moved to `archive/` instead of waiting to
  be deleted.
- `devguide/decisions/`, created yesterday, is gone. It duplicated what the
  protocol already handles with `status` and `normative`, and I had invented it
  without reading the convention that existed. Its document is back in
  `pending_proposals/` as `partial`, with `normative:` naming the guide section
  that absorbed its rules.

That move broke a path reference from a comment on uibcdf/argdigest#2 -- which
is precisely the argument the protocol makes for issue numbers over paths, and
the document now says so, at our own expense.

`devtools/devguide_index.py` renders each queue's table from front matter, and
`tests/test_devguide_reports.py` checks the vocabulary, the issue references,
the date consistency, that a named guard or normative document exists, that a
closed entry sits in the archive, and that the indexes are current. Each check
was verified against a deliberately broken entry: a bad status, a malformed
issue, a guard that does not exist and a non-ISO date are each caught by the
test that should catch them.

One check is deliberately wider than the protocol requires. It asks for a
guard that exists on `resolved` entries; a stale path is wrong in any status,
and the narrower version missed exactly that.
`unserialize_warning_message` rebuilt a warning as `cls(*message_args)`, and
`serialize_warning_message` sends only `args`. But `args` alone does not
describe a warning: the instance keeps state beside them -- in its `__dict__`,
or in `__slots__` -- and we transferred none of it.

So a warning keeping anything beside its `args` arrived without it, and one
deriving its message from that state arrived rendered from defaults, reading
like a legitimate message while saying something else. `-n0` serializes nothing,
and disagreed with `-n1` about what the same warning said.

`_serializable_warning_state` collects what the instance keeps, and the
controller applies it with `cls.__new__(cls)` + `BaseException.__init__` +
`__setstate__`, whose `setattr` per key reaches slots as well as the instance
dictionary. `args` and state are each gated on what `execnet.dumps` can carry.

The state is read from the instance rather than from `__reduce__`, which cannot
answer the question. `BaseException.__reduce__` reports only `self.__dict__`, so
a `__slots__` class reduces to a two-tuple and its slots read as "keeps
nothing" -- and rebuilding without them leaves an instance whose own `__str__`
raises `AttributeError` where the controller renders it. In the other direction,
a class that replaces `__reduce__` reads as "state we cannot send" even when it
keeps nothing at all, and dropping its class costs the caller a type it could
have kept.

The class is dropped only when its state will not serialize, because an instance
rebuilt without the attributes its own `__str__` reads is worse than the defect
this fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dprada
dprada force-pushed the fix/warning-reconstruction-roundtrip branch from 3b3bf1f to db2a6f0 Compare September 20, 2026 09:25
@dprada dprada changed the title Do not rebuild a warning whose first argument is not its message Transfer the warning's state, and rebuild without re-running __init__ Sep 20, 2026
@dprada

dprada commented Sep 20, 2026

Copy link
Copy Markdown
Author

@RonnyPfannschmidt — rebased, narrowed to one question, and two defects of my own fixed.

The guard has moved out. What used to be the second half of this PR is #1387, which closes #404 and needs nothing from here. This PR is now the state question alone.

Two shapes came out of my rework worse than they went in. I found them writing tests for shapes I had not covered:

shape master as I had it now
__slots__, text from a slot wrong text INTERNALERROR exact
own __reduce__, keeps nothing exact class dropped, text prefixed exact

The cause was reading the state from __reduce__. BaseException.__reduce__ reports only self.__dict__, so slots read as "keeps nothing", and the controller rebuilt an instance whose own __str__ then raised AttributeError where it renders it — the run-ending failure the other half exists to prevent, introduced by this one. The (dict, slots) two-tuple I had handled turns out to be unreachable: only a class-provided __reduce__ produces it, and those were rejected one check earlier.

Reading the instance instead — __dict__ plus the slots along the MRO — makes both exact, and moots the question I asked you about "honour": nothing consults __reduce__ any more, so there is no callable to decide about.

One residual cost, stated in the description. A warning that keeps unserializable state but renders from its args alone now loses its class, where master kept it. That follows your fifth item literally. Telling that case apart means comparing the rendered text across the boundary — the mechanism you rejected — so I did not add it on my own; say the word and I will.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

'module' object has no attribute 'GtkWarning'

2 participants