From 55a49822f38709c571fc6239385ea00b77a583a7 Mon Sep 17 00:00:00 2001 From: Aryan Bagade Date: Fri, 2 Jan 2026 19:19:19 -0800 Subject: [PATCH 1/2] Support module-level __getattr__ in stubs for from-imports fixes #1988 Signed-off-by: Aryan Bagade --- pyrefly/lib/alt/solve.rs | 10 ++++++++++ pyrefly/lib/binding/binding.rs | 6 +++++- pyrefly/lib/binding/bindings.rs | 3 ++- pyrefly/lib/binding/stmt.rs | 5 +++++ pyrefly/lib/state/ide.rs | 10 ++++++++++ pyrefly/lib/test/attributes.rs | 30 ++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index a7be1f9b2b..884802649f 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -3561,6 +3561,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { Binding::Import(m, name, _aliased) => self .get_from_export(*m, None, &KeyExport(name.clone())) .arc_clone(), + Binding::ImportViaGetattr(m, _name) => { + // Import via module-level __getattr__ for incomplete stubs. + // Get the return type of __getattr__. + let getattr_ty = self + .get_from_export(*m, None, &KeyExport(dunder::GETATTR.clone())) + .arc_clone(); + getattr_ty + .callable_return_type() + .unwrap_or(Type::any_implicit()) + } Binding::ClassDef(x, _decorators) => match &self.get_idx(*x).0 { None => Type::any_implicit(), Some(cls) => { diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index 411937dd52..597ee14f43 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -1337,6 +1337,9 @@ pub enum Binding { /// The option range tracks the original name's location for renamed import. /// e.g. in `from foo import bar as baz`, we should track the range of `bar`. Import(ModuleName, Name, Option), + /// An import via module-level __getattr__ for incomplete stubs. + /// See: https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs + ImportViaGetattr(ModuleName, Name), /// A class definition, points to a BindingClass and any decorators. ClassDef(Idx, Box<[Idx]>), /// A forward reference to another binding. @@ -1531,6 +1534,7 @@ impl DisplayWith for Binding { } Self::Function(x, _pred, _class) => write!(f, "Function({})", ctx.display(*x)), Self::Import(m, n, original_name) => write!(f, "Import({m}, {n}, {original_name:?})"), + Self::ImportViaGetattr(m, n) => write!(f, "ImportViaGetattr({m}, {n})"), Self::ClassDef(x, _) => write!(f, "ClassDef({})", ctx.display(*x)), Self::Forward(k) => write!(f, "Forward({})", ctx.display(*k)), Self::AugAssign(a, s) => write!(f, "AugAssign({}, {})", ann(a), m.display(s)), @@ -1757,7 +1761,7 @@ impl Binding { Some(SymbolKind::Function) } } - Binding::Import(_, _, _) => { + Binding::Import(_, _, _) | Binding::ImportViaGetattr(_, _) => { // TODO: maybe we can resolve it to see its symbol kind Some(SymbolKind::Variable) } diff --git a/pyrefly/lib/binding/bindings.rs b/pyrefly/lib/binding/bindings.rs index 71c30efb93..5ef4abebcb 100644 --- a/pyrefly/lib/binding/bindings.rs +++ b/pyrefly/lib/binding/bindings.rs @@ -1501,7 +1501,8 @@ impl<'a> BindingsBuilder<'a> { Binding::TypeVar(..) | Binding::ParamSpec(..) | Binding::TypeVarTuple(..) - | Binding::Import(..), + | Binding::Import(..) + | Binding::ImportViaGetattr(..), ) | None => Some(( KeyLegacyTypeParam(ShortIdentifier::new(name)), diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index dda2ac7b5c..1c8f7d7743 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -7,6 +7,7 @@ use pyrefly_graph::index::Idx; use pyrefly_python::ast::Ast; +use pyrefly_python::dunder; use pyrefly_python::module_name::ModuleName; use pyrefly_python::nesting_context::NestingContext; use pyrefly_python::short_identifier::ShortIdentifier; @@ -1190,6 +1191,10 @@ impl<'a> BindingsBuilder<'a> { self.error_multiline(x.range, ErrorInfo::Kind(ErrorKind::Deprecated), msg); } Binding::Import(m, x.name.id.clone(), original_name_range) + } else if exported.contains_key(&dunder::GETATTR) { + // Module has __getattr__, which means any attribute can be accessed. + // See: https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs + Binding::ImportViaGetattr(m, x.name.id.clone()) } else { let x_as_module_name = m.append(&x.name.id); let (finding, error) = match self.lookup.get(x_as_module_name) { diff --git a/pyrefly/lib/state/ide.rs b/pyrefly/lib/state/ide.rs index 2fe7ff22a6..3769c33242 100644 --- a/pyrefly/lib/state/ide.rs +++ b/pyrefly/lib/state/ide.rs @@ -127,6 +127,16 @@ fn create_intermediate_definition_from( *original_name_range, )); } + Binding::ImportViaGetattr(m, _name) => { + // For __getattr__ imports, the name doesn't exist directly in the module, + // so we point to __getattr__ instead. + return Some(IntermediateDefinition::NamedImport( + def_key.range(), + *m, + pyrefly_python::dunder::GETATTR.clone(), + None, + )); + } Binding::Module(name, path, ..) => { let imported_module_name = if path.len() == 1 { // This corresponds to the case for `import x.y` -- the corresponding key would diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index 3bbfb999de..d7889a11dd 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -940,6 +940,36 @@ del foo.y # E: No attribute `y` in module `foo` "#, ); +testcase!( + test_module_getattr_from_import, + TestEnv::one("foo", "def __getattr__(name: str) -> int: ..."), + r#" +from typing import assert_type +from foo import x, y +assert_type(x, int) +assert_type(y, int) + "#, +); + +testcase!( + test_module_getattr_stub_incomplete, + TestEnv::one_with_path( + "foo", + "foo.pyi", + r#" +from _typeshed import Incomplete +def __getattr__(name: str) -> Incomplete: ... +"#, + ), + r#" +from typing import assert_type, Any +from foo import x, y +# Incomplete is essentially Any, so x and y should be Any +assert_type(x, Any) +assert_type(y, Any) + "#, +); + testcase!( test_any_subclass, r#" From d6e523e8c8bba85c590b1fa94c5a4d95c45c9579 Mon Sep 17 00:00:00 2001 From: Aryan Bagade Date: Mon, 5 Jan 2026 19:28:58 -0800 Subject: [PATCH 2/2] Address review feedback: add explicit export priority test and fix submodule precedence Signed-off-by: Aryan Bagade --- pyrefly/lib/binding/stmt.rs | 31 ++++++++++++++++--------------- pyrefly/lib/test/attributes.rs | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index 1c8f7d7743..d57d3ab80e 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -1191,30 +1191,31 @@ impl<'a> BindingsBuilder<'a> { self.error_multiline(x.range, ErrorInfo::Kind(ErrorKind::Deprecated), msg); } Binding::Import(m, x.name.id.clone(), original_name_range) - } else if exported.contains_key(&dunder::GETATTR) { - // Module has __getattr__, which means any attribute can be accessed. - // See: https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs - Binding::ImportViaGetattr(m, x.name.id.clone()) } else { + // Try submodule lookup first, then fall back to __getattr__ let x_as_module_name = m.append(&x.name.id); let (finding, error) = match self.lookup.get(x_as_module_name) { FindingOrError::Finding(finding) => (true, finding.error), FindingOrError::Error(error) => (false, Some(error)), }; - let error = error.is_some_and(|e| matches!(e, FindError::NotFound(..))); - if error { - self.error( - x.range, - ErrorInfo::Kind(ErrorKind::MissingModuleAttribute), - format!("Could not import `{}` from `{m}`", x.name.id), - ); - } + let is_not_found = error.is_some_and(|e| matches!(e, FindError::NotFound(..))); if finding { Binding::Module(x_as_module_name, x_as_module_name.components(), None) - } else if error { - Binding::Type(Type::any_error()) + } else if exported.contains_key(&dunder::GETATTR) { + // Module has __getattr__, which means any attribute can be accessed. + // See: https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs + Binding::ImportViaGetattr(m, x.name.id.clone()) } else { - Binding::Type(Type::any_explicit()) + if is_not_found { + self.error( + x.range, + ErrorInfo::Kind(ErrorKind::MissingModuleAttribute), + format!("Could not import `{}` from `{m}`", x.name.id), + ); + Binding::Type(Type::any_error()) + } else { + Binding::Type(Type::any_explicit()) + } } }; // __future__ imports have side effects even if not explicitly used, diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index d7889a11dd..ab0c614ff7 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -970,6 +970,26 @@ assert_type(y, Any) "#, ); +testcase!( + test_module_getattr_explicit_export_priority, + TestEnv::one_with_path( + "foo", + "foo.pyi", + r#" +x: str +def __getattr__(name: str) -> int: ... +"#, + ), + r#" +from typing import assert_type +from foo import x, y +# x is explicitly defined as str, should not use __getattr__ +assert_type(x, str) +# y is not defined, should use __getattr__ and be int +assert_type(y, int) + "#, +); + testcase!( test_any_subclass, r#"