From 934aaa23f3a12b1a9a7096e15e06fc76bf62c828 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 10 Jul 2025 10:11:10 +0100 Subject: [PATCH] [ty] Improve and document equivalence for module-literal types (#19243) --- .../type_properties/is_equivalent_to.md | 50 ++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 51 +++++++++++++------ .../src/types/ide_support.rs | 6 ++- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md index 3a5314fbac..b309c266be 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md @@ -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 `` has no attribute `abc`" +reveal_type(importlib.abc) # revealed: Unknown + +reveal_type(other_importlib.abc) # revealed: + +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 diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 743b744e00..f8a6f62366 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -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> { @@ -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, } // 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 { + 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,15 +7554,16 @@ 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(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); - if imported_submodules.contains(&full_submodule_name) { - if let Some(submodule) = resolve_module(db, &full_submodule_name) { - return Place::bound(Type::module_literal(db, importing_file, &submodule)) - .into(); + if let Some(importing_file) = self.importing_file(db) { + if let Some(submodule_name) = ModuleName::new(name) { + let imported_submodules = imported_modules(db, importing_file); + let mut full_submodule_name = self.module(db).name().clone(); + full_submodule_name.extend(&submodule_name); + if imported_submodules.contains(&full_submodule_name) { + if let Some(submodule) = resolve_module(db, &full_submodule_name) { + return Place::bound(Type::module_literal(db, importing_file, &submodule)) + .into(); + } } } } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index bd934aa41f..95bf7f300b 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -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);