Skip to content

Fix copy.replace widening a bound TypeVar to its bound - #15997

Open
apoorva-01 wants to merge 2 commits into
python:mainfrom
apoorva-01:fix-15973-copy-replace-self-typevar
Open

apoorva-01 wants to merge 2 commits into
python:mainfrom
apoorva-01:fix-15973-copy-replace-self-typevar

Conversation

@apoorva-01

Copy link
Copy Markdown

Fixes #15973

copy.replace drops a bound TypeVar when __replace__ returns Self, so replace(config: T, ...) comes back as the bound instead of T.

#14786 made the protocol covariant for the Box[int] -> Box[str] case, which broke this one. Rather than pick between them, I added a Self-returning overload first that keeps T; anything like Box falls through to the covariant overload. Both work now, and the new test cases fail without it.

(#14819 touches the same stub, so one of us will need a rebase.)

apoorva-01 and others added 2 commits July 11, 2026 06:01
The covariant protocol from python#14786 loses a bound TypeVar whose __replace__
returns Self (dataclasses, namedtuples). Add a Self overload that keeps the
argument's type, falling back to the covariant one so Box[int] -> Box[str]
still works.
@github-actions

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

JPHutchins added a commit to JPHutchins/salix that referenced this pull request Oct 20, 2026
Round 1 on #355 (2.89). One nit fixed, three findings dismissed with
measurements. Checkers: mypy 2.4.0, pyright 1.1.414 and ty 0.0.63, all at
a 3.15 target.

post-init-reanchored: inserting SeededByDefault had moved Seeded's
__post_init__ onto the new class. Each class now has its own. This
changes no checker result.

param-spec-self-inference (dismissed): mypy widens a Self or bound-TypeVar
argument to its bound whenever it infers through a protocol, so
`def bumped(self) -> Self: return replace(self, x=...)` gets S. This is
python/mypy#21672 (open), and stdlib copy.replace hits it too: the same
mypy reports "got D, expected Self" on a frozen dataclass's
copy.replace(self, ...), while pyright and ty pass both. Variants measured
without salix:
- a covariant protocol over __replace__: S;
- a protocol over Struct's Self-returning __copy__: S;
- a self-typed protocol method: Never;
- an invariant protocol variable: S, plus mypy's "covariant one is
  expected".
The bound-TypeVar signature that keeps Self can't capture a ParamSpec.
The overload python/typeshed#15997 proposes for copy.replace puts an
untyped Self overload first, and every struct would match it, so all
change checking would be lost. Typed changes on all three checkers win
over Self on mypy for a TypeVar argument. Neither the repo nor its README
uses the Self pattern.

initvar-required-not-enforced (dismissed): replace(Seeded(1, 2), a=3) is
refused by mypy ("Missing named argument seed") but not by pyright or ty,
whose synthesized __replace__ makes every InitVar optional. The refusal
can't be pinned in rejected.py: pyright checks that file with
reportUnnecessaryTypeIgnoreComment = "error", so the ignore that mypy
needs would fail pyright. The PR body now names ty alongside pyright.

replace-positional-surface-ty (dismissed): PEP 612 requires P.args and
P.kwargs together. A replace taking only **changes: P.kwargs fails both
checkers:
- mypy: "ParamSpec must have *args typed as P.args and **kwargs typed as
  P.kwargs";
- pyright: "args and kwargs attributes of ParamSpec must both appear".
With a user __replace__(self, y: int) override, mypy ("Too many
positional arguments") and pyright ("Expected 0 positional arguments")
refuse replace(Custom(1), 5). ty alone accepts it. ty never checks
rejected.py, because it reads `# type: ignore[call-arg]` as no
suppression: 40 diagnostics at 3.15 with --error unused-ignore-comment.

type_check 7/7.

Review-Response: post-init-reanchored fixed — Seeded and SeededByDefault each declare their own __post_init__.
Review-Response: param-spec-self-inference dismissed — mypy widens a Self or TypeVar argument to its bound through any protocol inference (python/mypy#21672, open; stdlib copy.replace shows the same error on this mypy); no ParamSpec-capturing signature keeps Self on mypy, and typeshed#15997's Self-first overload would match every struct and drop the typed changes; pyright and ty keep Self.
Review-Response: initvar-required-not-enforced dismissed — pyright and ty synthesize every InitVar as optional; a mypy-only refusal can't be pinned in rejected.py because pyright checks it with reportUnnecessaryTypeIgnoreComment = "error"; the PR body now names ty too.
Review-Response: replace-positional-surface-ty dismissed — PEP 612 requires P.args with P.kwargs (mypy and pyright both refuse kwargs alone); mypy and pyright refuse the positional override call; ty alone accepts it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hx61B1n6epyo8YeeAUWktH

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

copy.replace: _SupportsReplace[RT] breaks Self-returning __replace__ with bound TypeVar

1 participant