Repository navigation
Fix copy.replace widening a bound TypeVar to its bound - #15997
Open
apoorva-01 wants to merge 2 commits into
Open
apoorva-01 wants to merge 2 commits into
apoorva-01 wants to merge 2 commits into
Conversation
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.
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 was referenced Oct 20, 2026
Merged
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15973
copy.replacedrops a boundTypeVarwhen__replace__returnsSelf, soreplace(config: T, ...)comes back as the bound instead ofT.#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 keepsT; anything likeBoxfalls 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.)