Skip to content

Commit c16c04b

Browse files
fix
1 parent b83ffef commit c16c04b

File tree

4 files changed

+80
-4
lines changed

4 files changed

+80
-4
lines changed

pyrefly/lib/alt/class/class_metadata.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
285285

286286
let named_tuple_metadata =
287287
self.named_tuple_metadata(cls, bases, &bases_with_metadata, errors);
288-
if named_tuple_metadata.is_some() && bases_with_metadata.len() > 1 {
288+
if named_tuple_metadata.is_some()
289+
&& bases_with_metadata.len() > 1
290+
&& !cls.module().path().is_interface()
291+
{
292+
// Typeshed models some stdlib namedtuple result objects, such as urllib.parse.ParseResult,
293+
// by mixing methods into a NamedTuple subclass inside a `.pyi`. Keep rejecting this in
294+
// user code, but allow it in stubs so we can type-check those result objects precisely.
289295
self.error(
290296
errors,
291297
cls.range(),

pyrefly/lib/alt/class/classdef.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,22 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
192192
pub fn as_superclass(&self, class: &ClassType, want: &Class) -> Option<ClassType> {
193193
if class.class_object() == want {
194194
Some(class.clone())
195+
} else if !class.class_object().is_builtin("tuple")
196+
&& let Some(tuple) = self.as_tuple(class)
197+
&& self.has_superclass(
198+
self.stdlib
199+
.tuple(self.heap.mk_any_implicit())
200+
.class_object(),
201+
want,
202+
)
203+
{
204+
// NamedTuple subclasses support precise tuple operations via `as_tuple`, but their
205+
// nominal base-class hierarchy still flows through `NamedTupleFallback`, whose tuple
206+
// ancestor is `tuple[Any, ...]`. Route tuple-related superclass lookups through an
207+
// erased tuple class so assignability to Sequence/Iterable/etc. uses the actual
208+
// element types instead of `Any`.
209+
let tuple_class = self.erase_tuple_type(tuple);
210+
self.as_superclass(&tuple_class, want)
195211
} else {
196212
self.get_ancestor(class.class_object(), want)
197213
.map(|ancestor| ancestor.substitute_with(&class.substitution()))

pyrefly/lib/test/named_tuple.rs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
use crate::test::util::TestEnv;
89
use crate::testcase;
910

1011
testcase!(
@@ -270,16 +271,53 @@ def test(p: Pair, p2: Pair2[bytes]):
270271
);
271272

272273
testcase!(
273-
bug = "NamedTuple extends tuple[Any, ...], making it a subtype of too many things",
274274
test_named_tuple_subclass,
275275
r#"
276276
from typing import NamedTuple, Sequence, Never
277277
class Pair(NamedTuple):
278278
x: int
279279
y: str
280280
p: Pair = Pair(1, "")
281-
x1: Sequence[int|str] = p # should succeed
282-
x2: Sequence[Never] = p # should fail
281+
x1: Sequence[int|str] = p
282+
def f(x: Sequence[Never]) -> None: ...
283+
f(p) # E: Argument `Pair` is not assignable to parameter `x` with type `Sequence[Never]` in function `f`
284+
"#,
285+
);
286+
287+
fn env_named_tuple_stub_mixins() -> TestEnv {
288+
let mut t = TestEnv::new();
289+
t.add_with_path(
290+
"foo",
291+
"foo.pyi",
292+
r#"
293+
from typing import Generic, NamedTuple, TypeVar
294+
295+
T = TypeVar("T")
296+
297+
class Base(NamedTuple, Generic[T]):
298+
x: T
299+
300+
class Mixin: ...
301+
302+
class Derived(Base[str], Mixin): ...
303+
"#,
304+
);
305+
t
306+
}
307+
308+
testcase!(
309+
test_named_tuple_stub_mixins_preserve_tuple_subtyping,
310+
env_named_tuple_stub_mixins(),
311+
r#"
312+
from typing import Iterable
313+
from foo import Derived
314+
315+
def takes_none(xs: Iterable[None]) -> None: ...
316+
def takes_str(xs: Iterable[str]) -> None: ...
317+
318+
def f(d: Derived) -> None:
319+
takes_str(d)
320+
takes_none(d) # E: Argument `Derived` is not assignable to parameter `xs` with type `Iterable[None]` in function `takes_none`
283321
"#,
284322
);
285323

pyrefly/lib/test/overload.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ def anywhere():
3131
"#,
3232
);
3333

34+
// Regression test for https://github.com/facebook/pyrefly/issues/2867
35+
testcase!(
36+
test_urlunparse_prefers_string_overload_for_parse_result,
37+
r#"
38+
from typing import assert_type
39+
from urllib.parse import urlparse, urlunparse
40+
41+
def sanitize_url(url: str) -> str:
42+
parsed = urlparse(url)
43+
assert_type(urlunparse(parsed), str)
44+
sanitized = parsed._replace(netloc="example.com")
45+
assert_type(urlunparse(sanitized), str)
46+
return urlunparse(sanitized)
47+
"#,
48+
);
49+
3450
testcase!(
3551
test_branches,
3652
r#"

0 commit comments

Comments
 (0)