[ty] Improve and document equivalence for module-literal types (#19243)

This commit is contained in:
Alex Waygood 2025-07-10 10:11:10 +01:00 committed by GitHub
parent 59aa869724
commit 934aaa23f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 18 deletions

View file

@ -502,5 +502,55 @@ def f6(a, /): ...
static_assert(not is_equivalent_to(CallableTypeOf[f1], CallableTypeOf[f6]))
```
## Module-literal types
Two "copies" of a single-file module are considered equivalent types, even if the different copies
were originally imported in different first-party modules:
`module.py`:
```py
import typing
```
`main.py`:
```py
import typing
from module import typing as other_typing
from ty_extensions import TypeOf, static_assert, is_equivalent_to
static_assert(is_equivalent_to(TypeOf[typing], TypeOf[other_typing]))
static_assert(is_equivalent_to(TypeOf[typing] | int | str, str | int | TypeOf[other_typing]))
```
We currently do not consider module-literal types to be equivalent if the underlying module is a
package and the different "copies" of the module were originally imported in different modules. This
is because we might consider submodules to be available as attributes on one copy but not on the
other, depending on whether those submodules were explicitly imported in the original importing
module:
`module2.py`:
```py
import importlib
import importlib.abc
```
`main2.py`:
```py
import importlib
from module2 import importlib as other_importlib
from ty_extensions import TypeOf, static_assert, is_equivalent_to
# error: [unresolved-attribute] "Type `<module 'importlib'>` has no attribute `abc`"
reveal_type(importlib.abc) # revealed: Unknown
reveal_type(other_importlib.abc) # revealed: <module 'importlib.abc'>
static_assert(not is_equivalent_to(TypeOf[importlib], TypeOf[other_importlib]))
```
[materializations]: https://typing.python.org/en/latest/spec/glossary.html#term-materialize
[the equivalence relation]: https://typing.python.org/en/latest/spec/glossary.html#term-equivalent

View file

@ -818,7 +818,11 @@ impl<'db> Type<'db> {
}
pub fn module_literal(db: &'db dyn Db, importing_file: File, submodule: &Module) -> Self {
Self::ModuleLiteral(ModuleLiteralType::new(db, importing_file, submodule))
Self::ModuleLiteral(ModuleLiteralType::new(
db,
submodule,
submodule.kind().is_package().then_some(importing_file),
))
}
pub const fn into_module_literal(self) -> Option<ModuleLiteralType<'db>> {
@ -7503,20 +7507,34 @@ pub enum WrapperDescriptorKind {
#[salsa::interned(debug)]
#[derive(PartialOrd, Ord)]
pub struct ModuleLiteralType<'db> {
/// The file in which this module was imported.
///
/// We need this in order to know which submodules should be attached to it as attributes
/// (because the submodules were also imported in this file).
pub importing_file: File,
/// The imported module.
pub module: Module,
/// The file in which this module was imported.
///
/// If the module is a module that could have submodules (a package),
/// we need this in order to know which submodules should be attached to it as attributes
/// (because the submodules were also imported in this file). For a package, this should
/// therefore always be `Some()`. If the module is not a package, however, this should
/// always be `None`: this helps reduce memory usage (the information is redundant for
/// single-file modules), and ensures that two module-literal types that both refer to
/// the same underlying single-file module are understood by ty as being equivalent types
/// in all situations.
_importing_file: Option<File>,
}
// The Salsa heap is tracked separately.
impl get_size2::GetSize for ModuleLiteralType<'_> {}
impl<'db> ModuleLiteralType<'db> {
fn importing_file(self, db: &'db dyn Db) -> Option<File> {
debug_assert_eq!(
self._importing_file(db).is_some(),
self.module(db).kind().is_package()
);
self._importing_file(db)
}
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`,
@ -7536,8 +7554,8 @@ impl<'db> ModuleLiteralType<'db> {
// the parent module's `__init__.py` file being evaluated. That said, we have
// chosen to always have the submodule take priority. (This matches pyright's
// current behavior, but is the opposite of mypy's current behavior.)
if let Some(importing_file) = self.importing_file(db) {
if let Some(submodule_name) = ModuleName::new(name) {
let importing_file = self.importing_file(db);
let imported_submodules = imported_modules(db, importing_file);
let mut full_submodule_name = self.module(db).name().clone();
full_submodule_name.extend(&submodule_name);
@ -7548,6 +7566,7 @@ impl<'db> ModuleLiteralType<'db> {
}
}
}
}
self.module(db)
.file()

View file

@ -199,8 +199,10 @@ impl<'db> AllMembers<'db> {
let module_name = module.name();
self.members.extend(
imported_modules(db, literal.importing_file(db))
.iter()
literal
.importing_file(db)
.into_iter()
.flat_map(|file| imported_modules(db, file))
.filter_map(|submodule_name| {
let module = resolve_module(db, submodule_name)?;
let ty = Type::module_literal(db, file, &module);