[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

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