Skip to content

Commit 934aaa2

Browse files
authored
[ty] Improve and document equivalence for module-literal types (astral-sh#19243)
1 parent 59aa869 commit 934aaa2

File tree

3 files changed

+89
-18
lines changed

3 files changed

+89
-18
lines changed

crates/ty_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,5 +502,55 @@ def f6(a, /): ...
502502
static_assert(not is_equivalent_to(CallableTypeOf[f1], CallableTypeOf[f6]))
503503
```
504504

505+
## Module-literal types
506+
507+
Two "copies" of a single-file module are considered equivalent types, even if the different copies
508+
were originally imported in different first-party modules:
509+
510+
`module.py`:
511+
512+
```py
513+
import typing
514+
```
515+
516+
`main.py`:
517+
518+
```py
519+
import typing
520+
from module import typing as other_typing
521+
from ty_extensions import TypeOf, static_assert, is_equivalent_to
522+
523+
static_assert(is_equivalent_to(TypeOf[typing], TypeOf[other_typing]))
524+
static_assert(is_equivalent_to(TypeOf[typing] | int | str, str | int | TypeOf[other_typing]))
525+
```
526+
527+
We currently do not consider module-literal types to be equivalent if the underlying module is a
528+
package and the different "copies" of the module were originally imported in different modules. This
529+
is because we might consider submodules to be available as attributes on one copy but not on the
530+
other, depending on whether those submodules were explicitly imported in the original importing
531+
module:
532+
533+
`module2.py`:
534+
535+
```py
536+
import importlib
537+
import importlib.abc
538+
```
539+
540+
`main2.py`:
541+
542+
```py
543+
import importlib
544+
from module2 import importlib as other_importlib
545+
from ty_extensions import TypeOf, static_assert, is_equivalent_to
546+
547+
# error: [unresolved-attribute] "Type `<module 'importlib'>` has no attribute `abc`"
548+
reveal_type(importlib.abc) # revealed: Unknown
549+
550+
reveal_type(other_importlib.abc) # revealed: <module 'importlib.abc'>
551+
552+
static_assert(not is_equivalent_to(TypeOf[importlib], TypeOf[other_importlib]))
553+
```
554+
505555
[materializations]: https://typing.python.org/en/latest/spec/glossary.html#term-materialize
506556
[the equivalence relation]: https://typing.python.org/en/latest/spec/glossary.html#term-equivalent

crates/ty_python_semantic/src/types.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,11 @@ impl<'db> Type<'db> {
818818
}
819819

820820
pub fn module_literal(db: &'db dyn Db, importing_file: File, submodule: &Module) -> Self {
821-
Self::ModuleLiteral(ModuleLiteralType::new(db, importing_file, submodule))
821+
Self::ModuleLiteral(ModuleLiteralType::new(
822+
db,
823+
submodule,
824+
submodule.kind().is_package().then_some(importing_file),
825+
))
822826
}
823827

824828
pub const fn into_module_literal(self) -> Option<ModuleLiteralType<'db>> {
@@ -7503,20 +7507,34 @@ pub enum WrapperDescriptorKind {
75037507
#[salsa::interned(debug)]
75047508
#[derive(PartialOrd, Ord)]
75057509
pub struct ModuleLiteralType<'db> {
7506-
/// The file in which this module was imported.
7507-
///
7508-
/// We need this in order to know which submodules should be attached to it as attributes
7509-
/// (because the submodules were also imported in this file).
7510-
pub importing_file: File,
7511-
75127510
/// The imported module.
75137511
pub module: Module,
7512+
7513+
/// The file in which this module was imported.
7514+
///
7515+
/// If the module is a module that could have submodules (a package),
7516+
/// we need this in order to know which submodules should be attached to it as attributes
7517+
/// (because the submodules were also imported in this file). For a package, this should
7518+
/// therefore always be `Some()`. If the module is not a package, however, this should
7519+
/// always be `None`: this helps reduce memory usage (the information is redundant for
7520+
/// single-file modules), and ensures that two module-literal types that both refer to
7521+
/// the same underlying single-file module are understood by ty as being equivalent types
7522+
/// in all situations.
7523+
_importing_file: Option<File>,
75147524
}
75157525

75167526
// The Salsa heap is tracked separately.
75177527
impl get_size2::GetSize for ModuleLiteralType<'_> {}
75187528

75197529
impl<'db> ModuleLiteralType<'db> {
7530+
fn importing_file(self, db: &'db dyn Db) -> Option<File> {
7531+
debug_assert_eq!(
7532+
self._importing_file(db).is_some(),
7533+
self.module(db).kind().is_package()
7534+
);
7535+
self._importing_file(db)
7536+
}
7537+
75207538
fn static_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> {
75217539
// `__dict__` is a very special member that is never overridden by module globals;
75227540
// we should always look it up directly as an attribute on `types.ModuleType`,
@@ -7536,15 +7554,16 @@ impl<'db> ModuleLiteralType<'db> {
75367554
// the parent module's `__init__.py` file being evaluated. That said, we have
75377555
// chosen to always have the submodule take priority. (This matches pyright's
75387556
// current behavior, but is the opposite of mypy's current behavior.)
7539-
if let Some(submodule_name) = ModuleName::new(name) {
7540-
let importing_file = self.importing_file(db);
7541-
let imported_submodules = imported_modules(db, importing_file);
7542-
let mut full_submodule_name = self.module(db).name().clone();
7543-
full_submodule_name.extend(&submodule_name);
7544-
if imported_submodules.contains(&full_submodule_name) {
7545-
if let Some(submodule) = resolve_module(db, &full_submodule_name) {
7546-
return Place::bound(Type::module_literal(db, importing_file, &submodule))
7547-
.into();
7557+
if let Some(importing_file) = self.importing_file(db) {
7558+
if let Some(submodule_name) = ModuleName::new(name) {
7559+
let imported_submodules = imported_modules(db, importing_file);
7560+
let mut full_submodule_name = self.module(db).name().clone();
7561+
full_submodule_name.extend(&submodule_name);
7562+
if imported_submodules.contains(&full_submodule_name) {
7563+
if let Some(submodule) = resolve_module(db, &full_submodule_name) {
7564+
return Place::bound(Type::module_literal(db, importing_file, &submodule))
7565+
.into();
7566+
}
75487567
}
75497568
}
75507569
}

crates/ty_python_semantic/src/types/ide_support.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ impl<'db> AllMembers<'db> {
199199

200200
let module_name = module.name();
201201
self.members.extend(
202-
imported_modules(db, literal.importing_file(db))
203-
.iter()
202+
literal
203+
.importing_file(db)
204+
.into_iter()
205+
.flat_map(|file| imported_modules(db, file))
204206
.filter_map(|submodule_name| {
205207
let module = resolve_module(db, submodule_name)?;
206208
let ty = Type::module_literal(db, file, &module);

0 commit comments

Comments
 (0)