[ty] Consider from thispackage import y to re-export y in __init__.pyi (#21387)

Fixes https://github.com/astral-sh/ty/issues/1487

This one is a true extension of non-standard semantics, and is therefore
a certified Hot Take we might conclude is simply a Bad Take (let's see
what ecosystem tests say...).
This commit is contained in:
Aria Desires 2025-11-11 14:41:14 -05:00 committed by GitHub
parent 03bd0619e9
commit e4374f14ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 30 additions and 27 deletions

View file

@ -22,7 +22,10 @@ This file currently covers the following details:
- **dot re-exports**: `from . import a` in an `__init__.pyi` is considered a re-export of `a`
(equivalent to `from . import a as a`). This is required to properly handle many stubs in the
wild. Currently it must be *exactly* `from . import ...`.
wild. Equivalent imports like `from whatever.thispackage import a` also introduce a re-export
(this has essentially zero ecosystem impact, we just felt it was more consistent). The only way
to opt out of this is to rename the import to something else (`from . import a as b`).
`from .a import b` and equivalent does *not* introduce a re-export.
Note: almost all tests in here have a stub and non-stub version, because we're interested in both
defining symbols *at all* and re-exporting them.
@ -94,8 +97,7 @@ reveal_type(mypackage.fails.Y) # revealed: Unknown
## Absolute `from` Import of Direct Submodule in `__init__`
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
decision and can be changed!)
`from . import y`) we also treat it as a re-export.
### In Stub
@ -122,9 +124,7 @@ Y: int = 47
```py
import mypackage
# TODO: this could work and would be nice to have?
# error: "has no member `imported`"
reveal_type(mypackage.imported.X) # revealed: Unknown
reveal_type(mypackage.imported.X) # revealed: int
# error: "has no member `fails`"
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
// that `x` can be freely overwritten, and that we don't assume that an import
// in one function is visible in another function.
if node.module.is_some()
&& self.current_scope().is_global()
&& self.file.is_package(self.db)
let mut is_self_import = false;
if self.file.is_package(self.db)
&& let Ok(module_name) = ModuleName::from_identifier_parts(
self.db,
self.file,
@ -1475,19 +1474,26 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
node.level,
)
&& let Ok(thispackage) = ModuleName::package_for_file(self.db, self.file)
&& let Some(relative_submodule) = module_name.relative_to(&thispackage)
&& let Some(direct_submodule) = relative_submodule.components().next()
&& !self.seen_submodule_imports.contains(direct_submodule)
{
self.seen_submodule_imports
.insert(direct_submodule.to_owned());
// Record whether this is equivalent to `from . import ...`
is_self_import = module_name == thispackage;
let direct_submodule_name = Name::new(direct_submodule);
let symbol = self.add_symbol(direct_submodule_name);
self.add_definition(
symbol.into(),
ImportFromSubmoduleDefinitionNodeRef { node },
);
if node.module.is_some()
&& let Some(relative_submodule) = module_name.relative_to(&thispackage)
&& let Some(direct_submodule) = relative_submodule.components().next()
&& !self.seen_submodule_imports.contains(direct_submodule)
&& self.current_scope().is_global()
{
self.seen_submodule_imports
.insert(direct_submodule.to_owned());
let direct_submodule_name = Name::new(direct_submodule);
let symbol = self.add_symbol(direct_submodule_name);
self.add_definition(
symbol.into(),
ImportFromSubmoduleDefinitionNodeRef { node },
);
}
}
let mut found_star = false;
@ -1599,13 +1605,10 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// It's re-exported if it's `from ... import x as x`
(&asname.id, asname.id == alias.name.id)
} else {
// It's re-exported if it's `from . import x` in an `__init__.pyi`
(
&alias.name.id,
node.level == 1
&& node.module.is_none()
&& self.file.is_package(self.db),
)
// As a non-standard rule to handle stubs in the wild, we consider
// `from . import x` and `from whatever.thispackage import x` in an
// `__init__.pyi` to re-export `x` (as long as it wasn't renamed)
(&alias.name.id, is_self_import)
};
// Look for imports `from __future__ import annotations`, ignore `as ...`