[ty] Implement module-level __getattr__ support (#19791)

fix https://github.com/astral-sh/ty/issues/943

## Summary

Add module-level `__getattr__` support for ty's type checker, fixing
issue https://github.com/astral-sh/ty/issues/943.
Module-level `__getattr__` functions ([PEP
562](https://peps.python.org/pep-0562/)) are now respected when
resolving dynamic attributes, matching the behavior of mypy and pyright.

## Implementation

Thanks @sharkdp for the guidance in
https://github.com/astral-sh/ty/issues/943#issuecomment-3157566579
- Adds module-specific `__getattr__` resolution in
`ModuleLiteral.static_member()`
- Maintains proper attribute precedence: explicit attributes >
submodules > `__getattr__`

## Test Plan
- New mdtest covering basic functionality, type annotations, attribute
precedence, and edge cases
(run ```cargo nextest run -p ty_python_semantic
mdtest__import_module_getattr```)
- All new tests pass, verifying `__getattr__` is called correctly and
returns proper types
  - Existing test suite passes, ensuring no regressions introduced
This commit is contained in:
Eric Jolibois 2025-08-08 19:39:37 +02:00 committed by GitHub
parent 44755e6e86
commit 0095ff4c1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 124 additions and 2 deletions

View file

@ -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: <module 'mod.sub'>
```

View file

@ -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
}
}