Do not rebuild a warning whose first argument is not its message - #1372
Do not rebuild a warning whose first argument is not its message#1372dprada wants to merge 5 commits into
Conversation
`unserialize_warning_message` recreates the warning on the controller as
`cls(*message_args)`, where `message_args` is the original instance's `args`.
That assumes the first argument is the message. A `Warning` subclass is free not
to do that: it may name a field of its own and build the message out of it, a
common shape in libraries that render diagnostics from structured data.
For such a class the rebuilt instance is a different warning. The rendered text
goes back into whatever field the first argument names, and `__str__` renders
around it a second time:
class ResourceWarning(UserWarning):
def __init__(self, resource):
super().__init__(f"{resource!r} is not available")
# controller: ResourceWarning("'gpu' is not available")
# reports: "'gpu' is not available" is not available
A subclass whose parameters all have defaults is worse: it rebuilds without
error and reports the *default* message, so the text is wrong without looking
wrong. Only subclasses that reject the call are handled today, by the
`except TypeError` fallback.
Keep the rebuilt instance only when it still says what the original said, and
otherwise take the existing "could not recreate the original warning instance"
path, which reports the original text once. `category` is unaffected: it is
rebuilt from its own fields.
Found when a test suite run under `-n 12` reported warning text nested inside
itself while the same suite run serially did not.
…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.
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.
|
Ill give a more detailed reply when I get back to the computer But the premise here is wrong as is |
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
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.dumpscan carry - rebuild via
__new__+__setstate__instead ofcls(*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
Warningonly 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.
Replaces the check added in the first version of this branch. That one compared `str()` against the transferred text and discarded the rebuilt instance when they differed, which detected that the transfer was lossy without making it any less so, and paid for the detection by dropping the message class and prefixing the text. The loss is in what we send and how we rebuild it. `serialize_warning_message` sends only `args`; `BaseException.__reduce__` carries the instance dictionary as a third element and `BaseException.__setstate__` applies it, and we transfer neither. So a warning keeping anything beside its `args` arrives without it, and one deriving its message from that state arrives rendered from its defaults — reading like a real message rather than failing. `-n0` serializes nothing and is therefore the reference for what a warning says. A class rendering its message from `self.code` reports `code 42 tripped` there and `code unknown tripped` under `-n1`, while round-tripping correctly under both `pickle` and `copy`. - the reduce state travels alongside `args`, each gated on what `execnet.dumps` can carry, so a payload that cannot cross is reported absent rather than half-sent; - the instance is rebuilt with `__new__` + `BaseException.__init__` + `__setstate__`, so a re-rendering `__init__` never runs; - the `(dict, slots)` two-tuple that `__slots__` classes reduce to is applied by hand, since `BaseException.__setstate__` does not take it; - a class-provided `__reduce__` whose callable is not the class itself is left alone and falls back, because `execnet` cannot carry that callable; - the generic `Warning` fallback is reached only when the state will not transfer. `test_state_beyond_args_survives` covers it end to end over `-n0`/`-n1`, and two unit tests cover the field-first and state-only shapes, asserting the resulting type, the exact text and the restored attribute. All three fail without this change.
|
Still reproducible, now differently explained: on 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, 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
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))That class round-trips correctly under The changeFollowing your five items:
Tests
|
for more information, see https://pre-commit.ci
`mypy` rejected the new parametrized test: its `factory` parameter carried no annotation, and `no-untyped-def` applies to `testing/` as well as `src/`. Typed as `Callable[[], UserWarning]`, which is what both ids pass. The formatting half of this was already handled by pre-commit.ci. Checked with the hook's own dependency set: mypy clean over 27 files, ruff and ruff format clean, unit tests passing.
…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.
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.
On the controller,
unserialize_warning_messagerecreates a warning ascls(*message_args), wheremessage_argsis the original instance'sargs. That assumes the first constructor argument is the message. AWarningsubclass is free not to do that — it may name a field of its own and build the message out of it, which is a common shape in libraries that render diagnostics from structured data.For such a class the rebuilt instance is a different warning: the rendered text goes back into whatever field the first argument names, and
__str__renders around it a second time.A subclass whose parameters all have defaults is quieter and worse: it rebuilds without error and reports the default message, so the text is wrong without looking wrong. Only subclasses that reject the call are handled today, by the existing
except TypeErrorfallback.Change
Keep the rebuilt instance only when it still says what the original said; otherwise take the existing "could not recreate the original warning instance" path, which reports the original text once.
categoryis unaffected — it is rebuilt from its own fields.The check is one string comparison against data already carried in the payload, and it does not change behaviour for any warning that rebuilds faithfully.
How it turned up
A test suite reported warning text nested inside itself under
-n 12while the same suite run serially did not. Across the 15 warning classes in that project the reconstruction split three ways: 5 re-rendered, 7 raisedTypeErrorinto the generic fallback, and 3 happened to round-trip.Tests
test_unserialize_warning_msg_that_cannot_round_tripcovers it, and fails without the change. Existingtesting/test_workermanage.pypasses unchanged.I will add the changelog fragment once this PR has a number, unless you would rather it be filed against a separate issue.