mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-29 13:24:57 +00:00
[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:
parent
44755e6e86
commit
0095ff4c1a
2 changed files with 124 additions and 2 deletions
|
@ -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'>
|
||||
```
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue