From 48bf985a31cdcaef61b686734aa3e2ab9bf4ca94 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:04:22 +0800 Subject: [PATCH] Prefer precise init signatures over inherited catch-all new --- pyrefly/lib/alt/call.rs | 8 +++- pyrefly/lib/alt/class/class_field.rs | 18 ++++++++ pyrefly/lib/test/lsp/completion.rs | 38 ++++++++++++++++ pyrefly/lib/test/lsp/hover.rs | 45 +++++++++++++++++++ .../test_laziness/test_attribute_inherited.md | 2 +- .../test_attribute_on_class_itself.md | 2 +- .../test_import_class_instantiated.md | 2 +- 7 files changed, 111 insertions(+), 4 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index f81d4b94d0..a1c83a0342 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1124,6 +1124,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { // Tracks whether we've already recorded a trace for IDE features. // Priority: metaclass __call__ > overridden __new__ > __init__. let mut recorded_trace = false; + let prefer_init_trace = self.constructor_prefers_init_over_inherited_new(&cls); let errors = self.error_collector(); if let Some(ret) = self.call_metaclass( &cls, @@ -1220,7 +1221,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { AttributeReferenceKind::ConstructorCall, ); } - if !recorded_trace { + if !recorded_trace && !prefer_init_trace { self.record_resolved_trace(arguments_range, &new_method); recorded_trace = true; } @@ -2268,6 +2269,11 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } else { (default_constructor(), false) }; + if overrides_init && self.constructor_prefers_init_over_inherited_new(cls) { + // An inherited catch-all `__new__` should not obscure a more useful `__init__` + // signature. Direct construction still checks both methods independently. + return init_attr_ty; + } if !overrides_new && overrides_init { // If `__init__` is overridden and `__new__` is inherited from object, use `__init__` init_attr_ty diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index dfa2988700..f87236d78b 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -5095,6 +5095,24 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } + /// Whether an inherited permissive `__new__` should yield to an overridden `__init__` + /// when presenting the class as a callable. + pub(crate) fn constructor_prefers_init_over_inherited_new(&self, cls: &ClassType) -> bool { + let Some(new_member) = + self.get_class_member_with_defining_class(cls.class_object(), &dunder::NEW) + else { + return false; + }; + self.get_dunder_init(cls, false).is_some() + && new_member.defining_class != *cls.class_object() + && new_member.value.is_function_without_return_annotation() + && new_member + .value + .ty() + .visit_toplevel_func_metadata::(&|meta| { + meta.flags.has_gradual_variadic_params + }) + } fn get_dunder_init_helper(&self, instance: &Instance, get_object_init: bool) -> Option { let init_method = self.get_class_member_with_defining_class(instance.class, &dunder::INIT)?; diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index e270db327b..6df63ae457 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -1666,6 +1666,44 @@ Completion Results: ); } +#[test] +fn kwargs_completion_pydantic_constructor_ignores_inherited_unannotated_new() { + let sqlmodel = r#" +from typing import Any +from pydantic import BaseModel + +class SQLModel(BaseModel): + def __new__(cls, *args: Any, **kwargs: Any): + return object.__new__(cls) + + def __init__(self, **data: Any) -> None: ... +"#; + let main = r#" +from sqlmodel import SQLModel + +class A(SQLModel): + a: int + b: str + +A( +# ^ +"#; + let pydantic_path = + std::env::var("PYDANTIC_TEST_PATH").expect("PYDANTIC_TEST_PATH must be set"); + let mut test_env = TestEnv::new_with_site_package_paths(&[&pydantic_path]); + test_env.add("sqlmodel", sqlmodel); + test_env.add("main", main); + let (state, handle) = test_env + .with_default_require_level(Require::Exports) + .to_state(); + let report = + get_default_test_report()(&state, &handle("main"), extract_cursors_for_test(main)[0]); + assert!(report.contains("- (Variable) a=:"), "{report}"); + assert!(report.contains("- (Variable) b=:"), "{report}"); + assert!(!report.contains("args="), "{report}"); + assert!(!report.contains("kwargs="), "{report}"); +} + #[test] fn kwargs_completion_dunder_call_metaclass_constructor() { let code = r#" diff --git a/pyrefly/lib/test/lsp/hover.rs b/pyrefly/lib/test/lsp/hover.rs index 3ee2f21c9e..9715d6ba67 100644 --- a/pyrefly/lib/test/lsp/hover.rs +++ b/pyrefly/lib/test/lsp/hover.rs @@ -2168,6 +2168,51 @@ Person("Alice", 25) ); } +#[test] +fn hover_on_pydantic_constructor_ignores_inherited_unannotated_new() { + let sqlmodel = r#" +from typing import Any +from pydantic import BaseModel + +class SQLModel(BaseModel): + def __new__(cls, *args: Any, **kwargs: Any): + return object.__new__(cls) + + def __init__(self, **data: Any) -> None: ... +"#; + let main = r#" +from sqlmodel import SQLModel + +class A(SQLModel): + a: int + b: str + +value = A +# ^ +A(a=1, b="") +#^ +"#; + let pydantic_path = + std::env::var("PYDANTIC_TEST_PATH").expect("PYDANTIC_TEST_PATH must be set"); + let mut test_env = TestEnv::new_with_site_package_paths(&[&pydantic_path]); + test_env.add("sqlmodel", sqlmodel); + test_env.add("main", main); + let (state, handle) = test_env + .with_default_require_level(Require::Exports) + .to_state(); + for position in extract_cursors_for_test(main) { + let report = get_test_report(&state, &handle("main"), position); + assert!( + report.contains("a:") && report.contains("b:"), + "Expected Pydantic constructor hover to show synthesized fields, got: {report}" + ); + assert!( + !report.contains("*args: Any") && !report.contains("**kwargs: Any"), + "Expected Pydantic constructor hover to hide inherited broad __new__, got: {report}" + ); + } +} + #[test] fn hover_on_namedtuple_constructor_shows_field_signature() { let code = r#" diff --git a/pyrefly/test_laziness/test_attribute_inherited.md b/pyrefly/test_laziness/test_attribute_inherited.md index 72de6cbda7..7af2bdf304 100644 --- a/pyrefly/test_laziness/test_attribute_inherited.md +++ b/pyrefly/test_laziness/test_attribute_inherited.md @@ -50,7 +50,7 @@ a: Solutions b: Answers c: Answers -(64 builtin demands hidden) +(66 builtin demands hidden) a -> b::Exports(is_special_export) a -> b::Load(module_exists) a -> b::Exports(export_exists) diff --git a/pyrefly/test_laziness/test_attribute_on_class_itself.md b/pyrefly/test_laziness/test_attribute_on_class_itself.md index 0723dceacc..147584320c 100644 --- a/pyrefly/test_laziness/test_attribute_on_class_itself.md +++ b/pyrefly/test_laziness/test_attribute_on_class_itself.md @@ -52,7 +52,7 @@ a: Solutions b: Answers c: Answers -(80 builtin demands hidden) +(82 builtin demands hidden) a -> b::Exports(is_special_export) a -> b::Load(module_exists) a -> b::Exports(export_exists) diff --git a/pyrefly/test_laziness/test_import_class_instantiated.md b/pyrefly/test_laziness/test_import_class_instantiated.md index b56c01fa36..f35f582656 100644 --- a/pyrefly/test_laziness/test_import_class_instantiated.md +++ b/pyrefly/test_laziness/test_import_class_instantiated.md @@ -34,7 +34,7 @@ class Foo: a: Solutions b: Answers -(34 builtin demands hidden) +(36 builtin demands hidden) a -> b::Exports(is_special_export) a -> b::Load(module_exists) a -> b::Exports(export_exists)