Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextRange>),
/// 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<KeyClass>, Box<[Idx<KeyDecorator>]>),
/// A forward reference to another binding.
Expand Down Expand Up @@ -1531,6 +1534,7 @@ impl DisplayWith<Bindings> 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)),
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion pyrefly/lib/binding/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
28 changes: 17 additions & 11 deletions pyrefly/lib/binding/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1191,25 +1192,30 @@ impl<'a> BindingsBuilder<'a> {
}
Binding::Import(m, x.name.id.clone(), original_name_range)
} 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,
Expand Down
10 changes: 10 additions & 0 deletions pyrefly/lib/state/ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions pyrefly/lib/test/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,56 @@ 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_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#"
Expand Down
Loading