Consider `from thispackage import y` to re-export `y` in `__init__.pyi`

Fixes https://github.com/astral-sh/ty/issues/1487
This commit is contained in:
Aria Desires 2025-11-11 12:46:31 -05:00
parent 018febf444
commit f47b9f22f5
2 changed files with 28 additions and 27 deletions

View File

@ -29,7 +29,8 @@ defining symbols *at all* and re-exporting them.
## Relative `from` Import of Direct Submodule in `__init__` ## Relative `from` Import of Direct Submodule in `__init__`
We consider the `from . import submodule` idiom in an `__init__.pyi` an explicit re-export. We consider the `from . import submodule` idiom in an `__init__.pyi` an explicit re-export. This
pattern is observed in the wild with various stub packages.
### In Stub ### In Stub
@ -94,8 +95,7 @@ reveal_type(mypackage.fails.Y) # revealed: Unknown
## Absolute `from` Import of Direct Submodule in `__init__` ## Absolute `from` Import of Direct Submodule in `__init__`
If an absolute `from...import` happens to import a submodule (i.e. it's equivalent to If an absolute `from...import` happens to import a submodule (i.e. it's equivalent to
`from . import y`) we do not treat it as a re-export. We could, but we don't. (This is an arbitrary `from . import y`) we also treat it as a re-export.
decision and can be changed!)
### In Stub ### In Stub
@ -122,9 +122,7 @@ Y: int = 47
```py ```py
import mypackage import mypackage
# TODO: this could work and would be nice to have? reveal_type(mypackage.imported.X) # revealed: int
# error: "has no member `imported`"
reveal_type(mypackage.imported.X) # revealed: Unknown
# error: "has no member `fails`" # error: "has no member `fails`"
reveal_type(mypackage.fails.Y) # revealed: Unknown reveal_type(mypackage.fails.Y) # revealed: Unknown
``` ```

View File

@ -1465,9 +1465,8 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// reasons but it works well for most practical purposes. In particular it's nice // reasons but it works well for most practical purposes. In particular it's nice
// that `x` can be freely overwritten, and that we don't assume that an import // that `x` can be freely overwritten, and that we don't assume that an import
// in one function is visible in another function. // in one function is visible in another function.
if node.module.is_some() let mut is_self_import = false;
&& self.current_scope().is_global() if self.file.is_package(self.db)
&& self.file.is_package(self.db)
&& let Ok(module_name) = ModuleName::from_identifier_parts( && let Ok(module_name) = ModuleName::from_identifier_parts(
self.db, self.db,
self.file, self.file,
@ -1475,9 +1474,15 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
node.level, node.level,
) )
&& let Ok(thispackage) = ModuleName::package_for_file(self.db, self.file) && let Ok(thispackage) = ModuleName::package_for_file(self.db, self.file)
{
// Record whether this is equivalent to `from . import ...`
is_self_import = module_name == thispackage;
if node.module.is_some()
&& let Some(relative_submodule) = module_name.relative_to(&thispackage) && let Some(relative_submodule) = module_name.relative_to(&thispackage)
&& let Some(direct_submodule) = relative_submodule.components().next() && let Some(direct_submodule) = relative_submodule.components().next()
&& !self.seen_submodule_imports.contains(direct_submodule) && !self.seen_submodule_imports.contains(direct_submodule)
&& self.current_scope().is_global()
{ {
self.seen_submodule_imports self.seen_submodule_imports
.insert(direct_submodule.to_owned()); .insert(direct_submodule.to_owned());
@ -1489,6 +1494,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
ImportFromSubmoduleDefinitionNodeRef { node }, ImportFromSubmoduleDefinitionNodeRef { node },
); );
} }
}
let mut found_star = false; let mut found_star = false;
for (alias_index, alias) in node.names.iter().enumerate() { for (alias_index, alias) in node.names.iter().enumerate() {
@ -1599,13 +1605,10 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// It's re-exported if it's `from ... import x as x` // It's re-exported if it's `from ... import x as x`
(&asname.id, asname.id == alias.name.id) (&asname.id, asname.id == alias.name.id)
} else { } else {
// It's re-exported if it's `from . import x` in an `__init__.pyi` // As a non-standard rule to handle stubs in the wild, we consider
( // `from . import x` and `from whatever.thispackage import x` in an
&alias.name.id, // `__init__.pyi` to re-export `x` (as long as it wasn't renamed)
node.level == 1 (&alias.name.id, is_self_import)
&& node.module.is_none()
&& self.file.is_package(self.db),
)
}; };
// Look for imports `from __future__ import annotations`, ignore `as ...` // Look for imports `from __future__ import annotations`, ignore `as ...`