Skip to content

[ty] Synthesize a _replace method for NamedTuples#22153

Merged
charliermarsh merged 8 commits intomainfrom
charlie/syn
Dec 23, 2025
Merged

[ty] Synthesize a _replace method for NamedTuples#22153
charliermarsh merged 8 commits intomainfrom
charlie/syn

Conversation

@charliermarsh
Copy link
Member

Summary

Closes astral-sh/ty#2170.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 23, 2025

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 23, 2025

mypy_primer results

Changes were detected when running on open source projects
tornado (https://github.com/tornadoweb/tornado)
- tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _VT@next | _T@next`
+ tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _T@next | _VT@next`

xarray (https://github.com/pydata/xarray)
- xarray/core/dataarray.py:5744:16: error[invalid-return-type] Return type does not match returned value: expected `T_Xarray@map_blocks`, found `DataArray | Dataset`
+ xarray/core/dataarray.py:5744:16: error[invalid-return-type] Return type does not match returned value: expected `T_Xarray@map_blocks`, found `T_Xarray@map_blocks | DataArray | Dataset`
- xarray/core/dataset.py:8873:16: error[invalid-return-type] Return type does not match returned value: expected `T_Xarray@map_blocks`, found `DataArray | Dataset`
+ xarray/core/dataset.py:8873:16: error[invalid-return-type] Return type does not match returned value: expected `T_Xarray@map_blocks`, found `T_Xarray@map_blocks | DataArray | Dataset`

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Top[Index[Any]] | Top[Series[Any, Any]] | TypeBlocks | ... omitted 6 union elements, object_]`
+ static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Top[Bus[Any]] | TypeBlocks | Batch | ... omitted 6 union elements, object_]`

pandas-stubs (https://github.com/pandas-dev/pandas-stubs)
- pandas-stubs/_typing.pyi:1232:16: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 5106 diagnostics
+ Found 5105 diagnostics

core (https://github.com/home-assistant/core)
- homeassistant/util/variance.py:47:12: error[invalid-return-type] Return type does not match returned value: expected `(**_P@ignore_variance) -> _R@ignore_variance`, found `_Wrapped[_P@ignore_variance, _R@ignore_variance | int | float | datetime, _P@ignore_variance, _R@ignore_variance | int | float | datetime]`
- Found 14427 diagnostics
+ Found 14426 diagnostics

No memory usage changes detected ✅

@charliermarsh charliermarsh marked this pull request as ready for review December 23, 2025 00:48
@charliermarsh charliermarsh added the ty Multi-file analysis & type inference label Dec 23, 2025
@charliermarsh charliermarsh changed the title Synthesize a _replace method for NamedTuples [ty ]Synthesize a _replace method for NamedTuples Dec 23, 2025
@charliermarsh charliermarsh changed the title [ty ]Synthesize a _replace method for NamedTuples [ty] Synthesize a _replace method for NamedTuples Dec 23, 2025
Comment on lines 2160 to 2165
// For callable members that use `Self` type variables (e.g., synthesized methods like
// `_replace` on NamedTuples), replace `Self` with the instance type of this class.
// This ensures that when accessing `Person._replace`, we get `(self: Person, ...) -> Person`
// instead of `(self: Self, ...) -> Self`.
let self_instance = Type::instance(db, ClassType::NonGeneric(self));
member = member.map_type(|ty| apply_self_to_callable(db, ty, self_instance));
Copy link
Contributor

@carljm carljm Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's not clear to me is why we are accessing person._replace on the class to begin with, when we are calling it on an instance. That's something we do for dunder methods (to mimic the runtime, which also does that), but it's not something we should do for a normal instance method call; _replace is not a dunder method. That seems to be at the root of the need for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or to put the same question in a more specific way: the previous version of the code had this special case in the branch where instance was None in the descriptor protocol. But that should not be the case for a call like person._replace(...), where we clearly have an instance. So it seems like that's what we need to root-cause in order to fix this properly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With #22155, we now get the expected behavior for instances.

We still get Self for classes:

reveal_type(Person._replace)  # revealed: (self: Self, *, name: str = ..., age: int | None = ...) -> Self

But perhaps that's expected?

Comment on lines 2160 to 2165
// For callable members that use `Self` type variables (e.g., synthesized methods like
// `_replace` on NamedTuples), replace `Self` with the instance type of this class.
// This ensures that when accessing `Person._replace`, we get `(self: Person, ...) -> Person`
// instead of `(self: Self, ...) -> Self`.
let self_instance = Type::instance(db, ClassType::NonGeneric(self));
member = member.map_type(|ty| apply_self_to_callable(db, ty, self_instance));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or to put the same question in a more specific way: the previous version of the code had this special case in the branch where instance was None in the descriptor protocol. But that should not be the case for a call like person._replace(...), where we clearly have an instance. So it seems like that's what we need to root-cause in order to fix this properly?

@charliermarsh charliermarsh changed the base branch from main to charlie/self December 23, 2025 03:41
@AlexWaygood AlexWaygood reopened this Dec 23, 2025
@AlexWaygood
Copy link
Member

AlexWaygood commented Dec 23, 2025

I'd be inclined to revert the changes to instance.rs and just make the NamedTupleLike protocol in ty_extensions.pyi a little less precise. All tests pass with this change (except for the one that's modified slightly as part of the patch):

diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md
index bf5b1e4d9b..812ff930c0 100644
--- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md
+++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md
@@ -347,7 +347,7 @@ satisfy:
 def expects_named_tuple(x: typing.NamedTuple):
     reveal_type(x)  # revealed: tuple[object, ...] & NamedTupleLike
     reveal_type(x._make)  # revealed: bound method type[NamedTupleLike]._make(iterable: Iterable[Any]) -> NamedTupleLike
-    reveal_type(x._replace)  # revealed: bound method NamedTupleLike._replace(**kwargs) -> NamedTupleLike
+    reveal_type(x._replace)  # revealed: bound method NamedTupleLike._replace(...) -> NamedTupleLike
     # revealed: Overload[(value: tuple[object, ...], /) -> tuple[object, ...], (value: tuple[_T@__add__, ...], /) -> tuple[object, ...]]
     reveal_type(x.__add__)
     reveal_type(x.__iter__)  # revealed: bound method tuple[object, ...].__iter__() -> Iterator[object]
diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs
index 11780e6fc6..9e674065b9 100644
--- a/crates/ty_python_semantic/src/types/instance.rs
+++ b/crates/ty_python_semantic/src/types/instance.rs
@@ -3,7 +3,6 @@
 use std::borrow::Cow;
 use std::marker::PhantomData;
 
-use super::class::CodeGeneratorKind;
 use super::protocol_class::ProtocolInterface;
 use super::{BoundTypeVarInstance, ClassType, KnownClass, SubclassOfType, Type, TypeVarVariance};
 use crate::place::PlaceAndQualifiers;
@@ -133,16 +132,6 @@ impl<'db> Type<'db> {
         relation_visitor: &HasRelationToVisitor<'db>,
         disjointness_visitor: &IsDisjointVisitor<'db>,
     ) -> ConstraintSet<'db> {
-        // Iff we're checking a NamedTuple against NamedTupleLike, skip the `_replace` method
-        // signature. NamedTuples synthesize `_replace` methods with specific keyword-only
-        // parameters (to detect invalid arguments), which are not strictly subtypes of the
-        // protocol's `(**kwargs)` signature, but are intended to be considered as satisfying it.
-        let is_namedtuple_protocol_check = matches!(&protocol.inner, Protocol::FromClass(class) if class.is_known(db, KnownClass::NamedTupleLike))
-            && self.as_nominal_instance().is_some_and(|instance| {
-                let (class_literal, specialization) = instance.class(db).class_literal(db);
-                CodeGeneratorKind::NamedTuple.matches(db, class_literal, specialization)
-            });
-
         let structurally_satisfied = if let Type::ProtocolInstance(self_protocol) = self {
             self_protocol.interface(db).has_relation_to_impl(
                 db,
@@ -157,16 +146,6 @@ impl<'db> Type<'db> {
                 .inner
                 .interface(db)
                 .members(db)
-                .filter(|member| {
-                    // Skip `_replace` check for NamedTuple vs NamedTupleLike. NamedTuples
-                    // synthesize `_replace` with specific keyword-only parameters to detect
-                    // invalid arguments, but this signature is not a strict subtype of the
-                    // protocol's `(**kwargs)` signature.
-                    if is_namedtuple_protocol_check && member.name() == "_replace" {
-                        return false;
-                    }
-                    true
-                })
                 .when_all(db, |member| {
                     member.is_satisfied_by(
                         db,
diff --git a/crates/ty_vendored/ty_extensions/ty_extensions.pyi b/crates/ty_vendored/ty_extensions/ty_extensions.pyi
index 347b6b4b34..ed0bf16186 100644
--- a/crates/ty_vendored/ty_extensions/ty_extensions.pyi
+++ b/crates/ty_vendored/ty_extensions/ty_extensions.pyi
@@ -190,6 +190,16 @@ class NamedTupleLike(Protocol):
     @classmethod
     def _make(cls: type[Self], iterable: Iterable[Any]) -> Self: ...
     def _asdict(self, /) -> dict[str, Any]: ...
-    def _replace(self, /, **kwargs) -> Self: ...
+
+    # Positional arguments aren't actually accepted by these methods at runtime,
+    # but adding the `*args` parameters means that all `NamedTuple` classes
+    # are understood as assignable to this protocol due to the special case
+    # outlined in https://typing.python.org/en/latest/spec/callables.html#meaning-of-in-callable:
+    #
+    # > If the input signature in a function definition includes both a
+    # > `*args` and `**kwargs` parameter and both are typed as `Any`
+    # > (explicitly or implicitly because it has no annotation), a type
+    # > checker should treat this as the equivalent of `...`.
+    def _replace(self, *args, **kwargs) -> Self: ...
     if sys.version_info >= (3, 13):
-        def __replace__(self, **kwargs) -> Self: ...
+        def __replace__(self, *args, **kwargs) -> Self: ...

@AlexWaygood
Copy link
Member

On Python 3.13+, it looks like we should also be synthesising precise __replace__ methods for NamedTuple classes in the same way that we already do for dataclasses.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@charliermarsh
Copy link
Member Author

(Depends on #22155.)

Base automatically changed from charlie/self to main December 23, 2025 19:25
@charliermarsh charliermarsh merged commit 89a55dd into main Dec 23, 2025
42 checks passed
@charliermarsh charliermarsh deleted the charlie/syn branch December 23, 2025 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Synthesize a precise signature for NamedTuple _replace methods

3 participants

Comments