replace() and copy() of an object are one capability
Found 2026-09-20 (frankH) censusing That Space Program under NilPy.
dataclasses.replace walls two TSP files, and they are one site, not two:
both report pascal26:115, and bpedit.py's own line 115 is a docstring —
the diagnostic is printing shape.py's line number with no file name. The site is
shape.py:115, with a second at shape.py:135.
Why the cheap implementation does not reach it
The obvious desugar is parse-time: with the receiver's class known, build
Cls(f0, f1, ...) taking each argument from the keyword if given and from
recv.fi otherwise. Measured, that does not fix either real call site:
self.by_part[iid + suffix] = [
dataclasses.replace(sol, sweep=sweep, cap_top=False, cap_bottom=False)
for sol in full] # full = self.by_part[iid]
self.by_part types as a TPyDict (PXXDBG=n.flddecl says tk=6 rec=53) and
the element class is not carried through the dict, so sol is a variant. Both
sites have this shape. A fixture with a statically typed receiver would pass
while shape.py still walls — the reduction would fix an axis the real program
does not have.
What is actually missing, and what is not
Not missing: attribute lookup BY NAME on a variant-held instance. Measured —
sol.name on a dataclass instance pulled out of a dict of lists prints
correctly, so per-object field metadata exists for lookup.
Missing: enumerating an instance's fields, and allocating an instance of
a class known only at run time. copy.copy needs exactly the same two, and
lib/rtl/mimic_copy.py already says so in its own refusal — which is worth
reading before designing this, because it also states the reason a partial
implementation is worse than none: returning the object unchanged would silently
share the state the caller asked to copy.
Note on semantics, so a later implementation does not get it subtly wrong
CPython's replace() calls the class's __init__, so __post_init__ runs and
init=False fields are rejected. A clone-and-set implementation skips both.
That difference is invisible on a plain dataclass and visible the moment one has
__post_init__, so it belongs in the design rather than in a later bug report.
2026-09-21 — dataclasses.replace MAY NOT NEED RUNTIME FIELD ENUMERATION AT ALL
Measured against CPython 3.14.4 while working TSP wall rows 4–8 as a group
(devdocs/dev/tsp-rows-4-8-what-shares-a-cause.md). This does not retire the
ticket; it may halve it, and it changes which machinery a taker should open
first.
CPython 3.13+ generates __replace__ on every dataclass, and
dataclasses.replace(obj, **kw) is DEFINED in terms of it. Verified, not
recalled:
@dataclasses.dataclass
class P:
a: int
b: int = 2
p = P(1)
p.__replace__(b=9) -> P(a=1, b=9)
dataclasses.replace(p, b=9) -> P(a=1, b=9)
hasattr(p, '__replace__') -> True (and copy.replace exists too)
So lowering dataclasses.replace(x, k=v, ...) to x.__replace__(k=v, ...) is
CPython's own semantics rather than an approximation of them. And each
generated __replace__ knows ITS OWN fields at compile time — the class that
declares it is the class whose fields it copies. The reflective capability this
ticket is named for is not needed for replace: only the DISPATCH is dynamic,
and a dynamic method call on a variant receiver is machinery that already
exists. copy.copy may still want the general capability; the two are less
joined than the title says.
THIS COUPLES ROW 4 TO ROW 8, THROUGH THE IMPLEMENTATION AND NOT THE SYMPTOM
x.__replace__(k=v, ...) is a KEYWORD call through a dynamic receiver, which is
exactly wall row 8 (pyvar_callv_kw). The two rows look unrelated on the board
and one is a prerequisite of the other's cheap path. Anyone taking row 4 by this
route takes row 8 first, or takes the reflective route instead.
AND THE HAZARD, WHICH IS THE PART TO READ BEFORE STARTING
A dynamic-receiver method call resolves by scanning declared classes FOR THE
NAME and emitting an unconditional hard cast to the first hit, with no runtime
class test — the family behind lekkerzeilen blockers 03 and 04
(bug-n-a-variant-field-is-claimed-as-the-callee-...,
bug-n-a-field-of-the-same-name-in-an-unrelated-class-...).
Generating __replace__ on every dataclass creates the exact population that
defeats a first-wins scan: one name declared by N unrelated classes. Today
that family fires on a name two or three classes happen to share; here it would
be every dataclass in the program, and the failure is not a diagnostic — it is a
hard cast to the wrong class, i.e. constructing an instance of a class the
program never named. tsp/shape.py:115's receiver is sol, a dict value in a
comprehension, so it is dynamic exactly where this bites.
So the order is: row 8, then the receiver-scan fix, then this. Taking this first delivers a silent wrong object in place of a loud refusal, which is a worse position than today's.
Filed by frankH, Track N, while grouping rows 4–8 rather than holding row 4 alone — the coupling is invisible from inside either ticket.