diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 685f930443..a7541f7e65 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -359,10 +359,13 @@ def _(y: type[typing.NamedTuple]): def _(z: typing.NamedTuple[int]): ... ``` -Because `_replace` is synthesized with specific keyword-only parameters (to enable error detection -for invalid keyword arguments), NamedTuple instances are not strictly assignable to -`NamedTupleLike`. This is a trade-off: we gain compile-time detection of invalid `_replace` -arguments at the cost of strict protocol compatibility. +NamedTuples are assignable to `NamedTupleLike`, even though the synthesized `_replace` signature +`(*, x: int = ..., y: int = ...)` is not strictly a subtype of `(**kwargs: Any)`. We special-case +this in protocol checking because: + +1. The specific `_replace` signature enables compile-time detection of invalid keyword arguments +1. In practice, all valid calls to `_replace` on a NamedTupleLike will also work on any concrete + NamedTuple ```py from typing import NamedTuple, Protocol, Iterable, Any @@ -376,16 +379,13 @@ reveal_type(Point._make) # revealed: bound method ._make(iterabl reveal_type(Point._asdict) # revealed: def _asdict(self) -> dict[str, Any] reveal_type(Point._replace) # revealed: (self: Point, *, x: int = ..., y: int = ...) -> Point -# Point is not assignable to NamedTuple because the synthesized `_replace` signature -# `(*, x: int = ..., y: int = ...)` is not a subtype of `(**kwargs: Any)`. This is a -# deliberate trade-off: detecting invalid `_replace` arguments at the cost of protocol -# compatibility. Mypy makes the same trade-off. -# error: [static-assert-error] +# Point is assignable to NamedTuple (we special-case _replace in protocol checking). static_assert(is_assignable_to(Point, NamedTuple)) -# error: [invalid-argument-type] "Argument to function `expects_named_tuple` is incorrect: Expected `tuple[object, ...] & NamedTupleLike`, found `Point`" +# NamedTuple instances can be passed to functions expecting NamedTupleLike. expects_named_tuple(Point(x=42, y=56)) +# But plain tuples are not NamedTupleLike (they don't have _make, _asdict, _replace, etc.). # error: [invalid-argument-type] "Argument to function `expects_named_tuple` is incorrect: Expected `tuple[object, ...] & NamedTupleLike`, found `tuple[Literal[1], Literal[2]]`" expects_named_tuple((1, 2)) ``` diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 9e674065b9..11780e6fc6 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -3,6 +3,7 @@ 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; @@ -132,6 +133,16 @@ 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, @@ -146,6 +157,16 @@ 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,