diff --git a/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md b/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md new file mode 100644 index 0000000000..8e947ab655 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/import/module_getattr.md @@ -0,0 +1,95 @@ +# Module-level `__getattr__` + +## Basic functionality + +```py +import module_with_getattr + +# Should work: module `__getattr__` returns `str` +reveal_type(module_with_getattr.whatever) # revealed: str +``` + +`module_with_getattr.py`: + +```py +def __getattr__(name: str) -> str: + return "hi" +``` + +## `from import` with `__getattr__` + +At runtime, if `module` has a `__getattr__` implementation, you can do `from module import whatever` +and it will exercise the `__getattr__` when `whatever` is not found as a normal attribute. + +```py +from module_with_getattr import nonexistent_attr + +reveal_type(nonexistent_attr) # revealed: int +``` + +`module_with_getattr.py`: + +```py +def __getattr__(name: str) -> int: + return 42 +``` + +## Precedence: explicit attributes take priority over `__getattr__` + +```py +import mixed_module + +# Explicit attribute should take precedence +reveal_type(mixed_module.explicit_attr) # revealed: Unknown | Literal["explicit"] + +# `__getattr__` should handle unknown attributes +reveal_type(mixed_module.dynamic_attr) # revealed: str +``` + +`mixed_module.py`: + +```py +explicit_attr = "explicit" + +def __getattr__(name: str) -> str: + return "dynamic" +``` + +## Precedence: submodules vs `__getattr__` + +If a package's `__init__.py` (e.g. `mod/__init__.py`) defines a `__getattr__` function, and there is +also a submodule file present (e.g. `mod/sub.py`), then: + +- If you do `import mod` (without importing the submodule directly), accessing `mod.sub` will call + `mod.__getattr__('sub')`, so `reveal_type(mod.sub)` will show the return type of `__getattr__`. +- If you do `import mod.sub` (importing the submodule directly), then `mod.sub` refers to the actual + submodule, so `reveal_type(mod.sub)` will show the type of the submodule itself. + +`mod/__init__.py`: + +```py +def __getattr__(name: str) -> str: + return "from_getattr" +``` + +`mod/sub.py`: + +```py +value = 42 +``` + +`test_import_mod.py`: + +```py +import mod + +reveal_type(mod.sub) # revealed: str +``` + +`test_import_mod_sub.py`: + +```py +import mod.sub + +reveal_type(mod.sub) # revealed: +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index ce1642171b..33b7c2ab27 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8335,6 +8335,25 @@ impl<'db> ModuleLiteralType<'db> { Some(Type::module_literal(db, importing_file, submodule)) } + fn try_module_getattr(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + // For module literals, we want to try calling the module's own `__getattr__` function + // if it exists. First, we need to look up the `__getattr__` function in the module's scope. + if let Some(file) = self.module(db).file(db) { + let getattr_symbol = imported_symbol(db, file, "__getattr__", None); + if let Place::Type(getattr_type, boundness) = getattr_symbol.place { + // If we found a __getattr__ function, try to call it with the name argument + if let Ok(outcome) = getattr_type.try_call( + db, + &CallArguments::positional([Type::string_literal(db, name)]), + ) { + return Place::Type(outcome.return_type(db), boundness).into(); + } + } + } + + Place::Unbound.into() + } + fn static_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { // `__dict__` is a very special member that is never overridden by module globals; // we should always look it up directly as an attribute on `types.ModuleType`, @@ -8360,10 +8379,18 @@ impl<'db> ModuleLiteralType<'db> { } } - self.module(db) + let place_and_qualifiers = self + .module(db) .file(db) .map(|file| imported_symbol(db, file, name, None)) - .unwrap_or_default() + .unwrap_or_default(); + + // If the normal lookup failed, try to call the module's `__getattr__` function + if place_and_qualifiers.place.is_unbound() { + return self.try_module_getattr(db, name); + } + + place_and_qualifiers } }