From 4cf58823d1678b0e3c9fc163566943b47f9806fb Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 28 Oct 2026 19:46:24 +0900 Subject: [PATCH 1/2] test --- pyrefly/lib/test/class_subtyping.rs | 103 ++++++++++++++++++++++++++++ pyrefly/lib/test/new_type.rs | 18 +++++ 2 files changed, 121 insertions(+) diff --git a/pyrefly/lib/test/class_subtyping.rs b/pyrefly/lib/test/class_subtyping.rs index 8e15432308..8ae9a36185 100644 --- a/pyrefly/lib/test/class_subtyping.rs +++ b/pyrefly/lib/test/class_subtyping.rs @@ -303,6 +303,109 @@ assert_type(B.x, int) "#, ); +testcase!( + test_call_returning_type_in_base_class_list, + r#" +from typing import Any, assert_type + +class Mixin: + def greet(self) -> str: + return "hi" + +def factory(name: str) -> type[Mixin]: ... +def untyped_factory(name: str) -> Any: ... + +class C(factory("x")): + pass + +class D(untyped_factory("x")): + pass + +assert_type(C().greet(), str) + "#, +); + +testcase!( + test_call_base_legacy_typevar_scoping, + r#" +from typing import Any, TypeVar, assert_type + +T = TypeVar("T") +S = TypeVar("S") + +def ident(x: type[S]) -> type[S]: ... + +class C(ident(list[T])): # E: Type variable `T` is not in scope + pass + +assert_type(C()[0], Any) + +class D(ident(list[int])): + pass + +assert_type(D()[0], int) + "#, +); + +testcase!( + test_call_base_bound_legacy_typevar, + r#" +from typing import Generic, TypeVar, assert_type + +T = TypeVar("T") +S = TypeVar("S") + +def ident(x: type[S]) -> type[S]: ... + +class C(ident(list[T]), Generic[T]): + pass + +assert_type(C[int]()[0], int) +assert_type(C[str]()[0], str) + "#, +); + +testcase!( + test_call_base_imported_legacy_typevars, + TestEnv::one( + "params", + r#" +from typing import TypeVar +T = TypeVar("T") +"#, + ), + r#" +from typing import Any, Generic, TypeVar, assert_type +import params + +T = TypeVar("T") +S = TypeVar("S") + +def ident(x: type[S]) -> type[S]: ... + +class C(ident(list[params.T]), Generic[params.T]): + pass + +assert_type(C[int]()[0], int) + +class D(ident(list[T]), Generic[params.T]): # E: Type variable `T` is not in scope + pass + +assert_type(D[int]()[0], Any) + "#, +); + +testcase!( + test_call_base_argument_validation, + r#" +def factory(name: str) -> type[int]: + return int + +class C(factory(1)): # E: Argument `Literal[1]` is not assignable to parameter `name` with type `str` + pass + "#, +); + testcase!( test_type_function_in_base_class_list_self_cycle, r#" diff --git a/pyrefly/lib/test/new_type.rs b/pyrefly/lib/test/new_type.rs index fd0b7c8464..8eca4049b8 100644 --- a/pyrefly/lib/test/new_type.rs +++ b/pyrefly/lib/test/new_type.rs @@ -68,6 +68,24 @@ userId = NewType() # E: Missing argument `name` # E: Missing argument `tp` "#, ); +testcase!( + test_new_type_call_base, + r#" +from typing import NewType + +def factory(name: str) -> type[int]: + name.upper() + return int + +def returns_none() -> None: + return None + +ValidCall = NewType("ValidCall", factory("x")) # E: Second argument to NewType is invalid +InvalidCall = NewType("InvalidCall", factory(1)) # E: Second argument to NewType is invalid +NoneCall = NewType("NoneCall", returns_none()) # E: Second argument to NewType is invalid + "#, +); + testcase!( test_new_type_not_allowed, r#" From a92472420a338c0a985bc0e1670a7b1c759ed752 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 25 Jun 2026 06:20:37 +0900 Subject: [PATCH 2/2] fix --- pyrefly/lib/alt/class/class_metadata.rs | 9 +++++++ pyrefly/lib/alt/solve.rs | 35 ++++++++++++++----------- pyrefly/lib/alt/types/class_bases.rs | 33 +++++++++++++++++++++++ pyrefly/lib/binding/base_class.rs | 3 +++ pyrefly/lib/binding/class.rs | 17 ++++++++++-- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/pyrefly/lib/alt/class/class_metadata.rs b/pyrefly/lib/alt/class/class_metadata.rs index 1abc0c7e3c..eca192fb84 100644 --- a/pyrefly/lib/alt/class/class_metadata.rs +++ b/pyrefly/lib/alt/class/class_metadata.rs @@ -1822,6 +1822,15 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { Some(ty) => parse_base_class_type(ty), } } + BaseClass::Call(x) => { + // Ignore all type errors here since they'll be reported in `class_bases_of` anyway. + let errors = ErrorCollector::new(self.module().dupe(), ErrorStyle::Never); + let ty = self.expr_infer(x, &errors); + match self.untype_opt(ty.clone(), x.range(), &errors) { + None => BaseClassParseResult::InvalidType(ty, x.range()), + Some(ty) => parse_base_class_type(ty), + } + } BaseClass::NamedTuple(..) => parse_base_class_type( self.heap .mk_class_type(self.stdlib.named_tuple_fallback().clone()), diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 32a82afc6f..2c516d84fe 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -496,20 +496,9 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } - /// Solve a `BindingLegacyTypeParam`, producing a `LegacyTypeParameterLookup` that tells the - /// caller whether the name is a type parameter and, if so, which `Quantified` to use. - /// - /// The `scope_anchor` is the range of the `KeyLegacyTypeParam` key itself — the first - /// occurrence of the TypeVar name in this scope. It is unique per (scope, TypeVar) pair: - /// two functions that both use an imported `T` will have different first-occurrence ranges, - /// so their `Quantified`s will have different identities even though they share the same - /// module-level `TypeVar` declaration. - pub fn solve_legacy_tparam( - &self, - binding: &BindingLegacyTypeParam, - scope_anchor: TextRange, - ) -> LegacyTypeParameterLookup { - let maybe_parameter = match binding { + /// Resolve the original value before a possible legacy type parameter is quantified. + pub(crate) fn legacy_tparam_value(&self, binding: &BindingLegacyTypeParam) -> TypeInfo { + match binding { BindingLegacyTypeParam::ParamKeyed(k) => self.get_idx(*k).clone(), BindingLegacyTypeParam::ModuleKeyed(module) => { // Errors in attribute lookup are reported elsewhere. @@ -526,7 +515,23 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ) }) } - }; + } + } + + /// Solve a `BindingLegacyTypeParam`, producing a `LegacyTypeParameterLookup` that tells the + /// caller whether the name is a type parameter and, if so, which `Quantified` to use. + /// + /// The `scope_anchor` is the range of the `KeyLegacyTypeParam` key itself — the first + /// occurrence of the TypeVar name in this scope. It is unique per (scope, TypeVar) pair: + /// two functions that both use an imported `T` will have different first-occurrence ranges, + /// so their `Quantified`s will have different identities even though they share the same + /// module-level `TypeVar` declaration. + pub fn solve_legacy_tparam( + &self, + binding: &BindingLegacyTypeParam, + scope_anchor: TextRange, + ) -> LegacyTypeParameterLookup { + let maybe_parameter = self.legacy_tparam_value(binding); // Use the scope_anchor (the KeyLegacyTypeParam's own range, i.e. the first occurrence // of this TypeVar name in the enclosing function/class/alias scope) as the identity // anchor. This gives each (scope, TypeVar) pair a distinct Quantified even when multiple diff --git a/pyrefly/lib/alt/types/class_bases.rs b/pyrefly/lib/alt/types/class_bases.rs index c99d3474a8..40fba6e939 100644 --- a/pyrefly/lib/alt/types/class_bases.rs +++ b/pyrefly/lib/alt/types/class_bases.rs @@ -23,6 +23,8 @@ use pyrefly_util::display::commas_iter; use ruff_python_ast::Expr; use ruff_text_size::Ranged; use ruff_text_size::TextRange; +use starlark_map::Hashed; +use starlark_map::small_map::SmallMap; use crate::alt::answers::LookupAnswer; use crate::alt::answers_solver::AnswersSolver; @@ -30,6 +32,7 @@ use crate::alt::solve::TypeFormContext; use crate::binding::base_class::BaseClass; use crate::binding::base_class::BaseClassExpr; use crate::binding::binding::Key; +use crate::binding::binding::KeyTParams; use crate::config::error_kind::ErrorKind; use crate::error::collector::ErrorCollector; use crate::error::style::ErrorStyle; @@ -347,6 +350,36 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } Some((ty, x.range())) } + BaseClass::Call(x) => { + let mut ty = + self.expr_untype(x, TypeFormContext::BaseClassList, &fake_error_collector); + // Call arguments retain raw TypeVars, including parameters bound by this class. + if let Some(idx) = self + .bindings() + .key_to_idx_hashed_opt(Hashed::new(&KeyTParams(cls.index()))) + { + let quantified = self + .bindings() + .get(idx) + .legacy_tparams + .iter() + .filter_map(|key| { + let parameter = self.get_idx(*key).parameter()?; + let value = self.legacy_tparam_value(self.bindings().get(*key)); + Some((value.into_ty(), parameter)) + }) + .collect::>(); + ty.transform_types_in_type_variable_positions(&mut |t| { + if let Some(parameter) = quantified.get(t) { + *t = (*parameter).clone().to_type(self.heap); + } + }); + } + if !is_new_type { + self.check_legacy_typevar_scoping(&mut ty, x.range(), errors); + } + Some((ty, x.range())) + } BaseClass::NamedTuple(..) => Some(( self.heap .mk_class_type(self.stdlib.named_tuple_fallback().clone()), diff --git a/pyrefly/lib/binding/base_class.rs b/pyrefly/lib/binding/base_class.rs index 67eb86653a..2e8a41d49c 100644 --- a/pyrefly/lib/binding/base_class.rs +++ b/pyrefly/lib/binding/base_class.rs @@ -95,6 +95,8 @@ pub enum BaseClass { TypedDict(TextRange), Generic(BaseClassGeneric), BaseClassExpr(BaseClassExpr), + /// A call expression used as a base of an ordinary class statement. + Call(Expr), InvalidExpr(Expr), /// A namedtuple base class. /// The bool indicates whether fields were dynamically generated @@ -131,6 +133,7 @@ impl Ranged for BaseClass { BaseClass::TypedDict(range) => *range, BaseClass::Generic(x) => x.range(), BaseClass::BaseClassExpr(base_expr) => base_expr.range(), + BaseClass::Call(expr) => expr.range(), BaseClass::InvalidExpr(expr) => expr.range(), BaseClass::NamedTuple(range, _) => *range, BaseClass::SynthesizedBase(_, range) => *range, diff --git a/pyrefly/lib/binding/class.rs b/pyrefly/lib/binding/class.rs index f6449c5db8..cf0f5deae0 100644 --- a/pyrefly/lib/binding/class.rs +++ b/pyrefly/lib/binding/class.rs @@ -360,7 +360,20 @@ impl<'a> BindingsBuilder<'a> { }, ); - let base_class = self.base_class_of(base.clone()); + // Call bases require the delayed validation installed for ordinary class statements. + let base_class = match self.base_class_of(base.clone()) { + BaseClass::InvalidExpr(Expr::Call(call)) + if !matches!( + self.as_special_export(&call.func), + Some( + SpecialExport::CollectionsNamedTuple | SpecialExport::TypingNamedTuple + ) + ) => + { + BaseClass::Call(Expr::Call(call)) + } + base_class => base_class, + }; // NOTE(grievejia): If any of the class base is a specialized generic class (e.g. `Foo[Bar]`), and if the tparam of the // generic class has a bound or constraint, we won't be validating the type `Bar` against that bound/constraint of the // tparam eagerly in order to avoid dependency cycle. But the validation needs to happen somewhere. @@ -373,7 +386,7 @@ impl<'a> BindingsBuilder<'a> { // usage tracking. if matches!( base_class, - BaseClass::BaseClassExpr(..) | BaseClass::TypeOf(..) + BaseClass::BaseClassExpr(..) | BaseClass::Call(..) | BaseClass::TypeOf(..) ) { self.insert_binding( KeyExpect::TypeCheckBaseClassExpr(base.range()),