[ty] Make implicit submodule imports only occur in global scope (#21370)

This loses any ability to have "per-function" implicit submodule
imports, to avoid the "ok but now we need per-scope imports" and "ok but
this should actually introduce a global that only exists during this
function" problems. A simple and clean implementation with no weird
corners.

Fixes https://github.com/astral-sh/ty/issues/1482
This commit is contained in:
Aria Desires 2025-11-10 18:59:48 -05:00 committed by GitHub
parent 2bc6c78e26
commit 9ce3230add
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 16 additions and 29 deletions

View File

@ -9,18 +9,16 @@ This file currently covers the following details:
- **froms are locals**: a `from..import` can only define locals, it does not have global - **froms are locals**: a `from..import` can only define locals, it does not have global
side-effects. Specifically any submodule attribute `a` that's implicitly introduced by either side-effects. Specifically any submodule attribute `a` that's implicitly introduced by either
`from .a import b` or `from . import a as b` (in an `__init__.py(i)`) is a local and not a `from .a import b` or `from . import a as b` (in an `__init__.py(i)`) is a local and not a
global. If you do such an import at the top of a file you won't notice this. However if you do global. However we only introduce this symbol if the `from..import` is in global-scope. This
such an import in a function, that means it will only be function-scoped (so you'll need to do means imports at the start of a file work as you'd expect, while imports in a function don't
it in every function that wants to access it, making your code less sensitive to execution introduce submodule attributes.
order).
- **first from first serve**: only the *first* `from..import` in an `__init__.py(i)` that imports a - **first from first serve**: only the *first* `from..import` in an `__init__.py(i)` that imports a
particular direct submodule of the current package introduces that submodule as a local. particular direct submodule of the current package introduces that submodule as a local.
Subsequent imports of the submodule will not introduce that local. This reflects the fact that Subsequent imports of the submodule will not introduce that local. This reflects the fact that
in actual python only the first import of a submodule (in the entire execution of the program) in actual python only the first import of a submodule (in the entire execution of the program)
introduces it as an attribute of the package. By "first" we mean "the first time in this scope introduces it as an attribute of the package. By "first" we mean "the first time in global
(or any parent scope)". This pairs well with the fact that we are specifically introducing a scope".
local (as long as you don't accidentally shadow or overwrite the local).
- **dot re-exports**: `from . import a` in an `__init__.pyi` is considered a re-export of `a` - **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 (equivalent to `from . import a as a`). This is required to properly handle many stubs in the
@ -949,9 +947,8 @@ def funcmod(x: int) -> int:
## LHS `from` Imports In Functions ## LHS `from` Imports In Functions
If a `from` import occurs in a function, LHS symbols should only be visible in that function. This If a `from` import occurs in a function, we simply ignore its LHS effects to avoid modeling
very blatantly is not runtime-accurate, but exists to try to force you to write "obviously execution-order-specific behaviour (and to discourage people writing code that has it).
deterministically correct" imports instead of relying on execution order.
`mypackage/__init__.py`: `mypackage/__init__.py`:
@ -959,13 +956,14 @@ deterministically correct" imports instead of relying on execution order.
def run1(): def run1():
from .funcmod import other from .funcmod import other
# TODO: this would be nice to support
# error: [unresolved-reference]
funcmod.funcmod(1) funcmod.funcmod(1)
def run2(): def run2():
from .funcmod import other from .funcmod import other
# TODO: this is just a bug! We only register the first # TODO: this would be nice to support
# import of `funcmod` in the entire file, and not per-scope!
# error: [unresolved-reference] # error: [unresolved-reference]
funcmod.funcmod(2) funcmod.funcmod(2)

View File

@ -1454,6 +1454,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// * `from .x.y import z` (must be relative!) // * `from .x.y import z` (must be relative!)
// * And we are in an `__init__.py(i)` (hereafter `thispackage`) // * And we are in an `__init__.py(i)` (hereafter `thispackage`)
// * And this is the first time we've seen `from .x` in this module // * And this is the first time we've seen `from .x` in this module
// * And we're in the global scope
// //
// We introduce a local definition `x = <module 'thispackage.x'>` that occurs // We introduce a local definition `x = <module 'thispackage.x'>` that occurs
// before the `z = ...` declaration the import introduces. This models the fact // before the `z = ...` declaration the import introduces. This models the fact
@ -1466,9 +1467,8 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// in one function is visible in another function. // in one function is visible in another function.
// //
// TODO: Also support `from thispackage.x.y import z`? // TODO: Also support `from thispackage.x.y import z`?
// TODO: `seen_submodule_imports` should be per-scope and not per-file if self.current_scope() == FileScopeId::global()
// (if two functions import `.x`, they both should believe `x` is defined) && node.level == 1
if node.level == 1
&& let Some(submodule) = &node.module && let Some(submodule) = &node.module
&& let Some(parsed_submodule) = ModuleName::new(submodule.as_str()) && let Some(parsed_submodule) = ModuleName::new(submodule.as_str())
&& let Some(direct_submodule) = parsed_submodule.components().next() && let Some(direct_submodule) = parsed_submodule.components().next()

View File

@ -5911,20 +5911,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
submodule: &Name, submodule: &Name,
definition: Definition<'db>, definition: Definition<'db>,
) { ) {
// Although the *actual* runtime semantic of this kind of statement is to // The runtime semantic of this kind of statement is to introduce a variable in the global
// introduce a variable in the global scope of this module, we want to // scope of this module, so we do just that. (Actually we introduce a local variable, but
// encourage users to write code that doesn't have dependence on execution-order. // this type of Definition is only created when a `from..import` is in global scope.)
//
// By introducing it as a local variable in the scope the import occurs in,
// we effectively require the developer to either do the import at the start of
// the file where it belongs, or to repeat the import in every function that
// wants to use it, which "definitely" works.
//
// (It doesn't actually "definitely" work because only the first import of `thispackage.x`
// will ever set `x`, and any subsequent overwrites of it will permanently clobber it.
// Also, a local variable `x` in a function should always shadow the submodule because
// the submodule is defined at file-scope. However, both of these issues are much more
// narrow, so this approach seems to work well in practice!)
// Get this package's module by resolving `.` // Get this package's module by resolving `.`
let Ok(module_name) = ModuleName::from_identifier_parts(self.db(), self.file(), None, 1) let Ok(module_name) = ModuleName::from_identifier_parts(self.db(), self.file(), None, 1)