Special-case _replace in protocol checking

This commit is contained in:
Charlie Marsh 2025-12-22 21:29:28 -05:00
parent 2c74fa4ba5
commit b1c21256a9
2 changed files with 31 additions and 10 deletions

View file

@ -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 <class 'Point'>._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))
```

View file

@ -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,