Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Module-level `__getattr__`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of one other case we are missing. We don't necessarily need to add support for it in this PR (though if you are up for it, that's also fine!), but I think we should at least add a test for it with TODO comment to help us keep track of the fact that it's missing.

At runtime if module mod.py has __getattr__ implementation, you can also do from mod import whatever and it will exercise the __getattr__. Currently this PR doesn't implement that, it only implements attribute access. To do it for imports as well, we'd need to add a similar case in the imported_symbol function (in place.rs) as a fallback. We would probably want to refactor your try_module_getattr method to be a standalone function instead that takes a &Db and a File, so we can call it also from imported_symbol.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I didn't understand properly what you meant.
The flow is already from mod import whateverinfer_import_from_definition()module_ty.member()module.static_member()try_module_getattr() (if normal lookup failed)
I added a test that passes that does what you wrote (again I may have misunderstood)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! My bad, I thought I had tested this and it wasn't working, but I must have done something wrong. That's great that we actually only need to do this in one place :) The new test looks excellent, thank you!


## 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'>
```
31 changes: 29 additions & 2 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8294,6 +8294,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`,
Expand All @@ -8319,10 +8338,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
}
}

Expand Down
Loading