Conversation
…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.
|
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
|
…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.
|
@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 reviewYour five items, and where each landed:
Two of them were not actually finished when I said the rework was complete, and together they left something worse than the original defect:
The One question, on that itemI 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 #404Separable — happy to split it into its own PR if you would rather review them apart. Resolving the warning's class is The category resolution below it had no guard at all and no test, and VerificationRebased onto the current branch head after the master merge and re-run there: 235 passed, 2 skipped, 10 xfailed. 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. |
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>
3b3bf1f to
db2a6f0
Compare
__init__
|
@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:
The cause was reading the state from Reading the instance instead — One residual cost, stated in the description. A warning that keeps unserializable state but renders from its |
On the controller,
unserialize_warning_messagerebuilt a warning ascls(*message_args), andserialize_warning_messagesends onlyargs. Butargsalone 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
argsarrived without it, and one deriving its message from that state arrived silently wrong.-n0serializes nothing and is the reference for what the warning actually says:What this does
Transfer the state, and rebuild without re-running
__init__._serializable_warning_statecollects what the instance keeps — its__dict__plus the slots along its MRO — and the controller applies it withcls.__new__(cls)+BaseException.__init__+__setstate__, whosesetattrper key reaches slots as well as the dictionary.argsand state are each gated on whatexecnet.dumpscan 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 onlyself.__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 raisesAttributeErrorwhere it renders it — anINTERNALERROR, 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 wheremasterrebuilds 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-
Warningpath, for the reason above. That second case carries a deliberate cost: a warning keeping something unserializable but rendering from itsargsalone loses its class, wheremasterkept 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-Warningfallback 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/-n1parity 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 formatandmypyclean on the changed files.Rebased onto
masterand 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-pytestmainchecks are #1386 (--dist=loadgroupsilently 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.