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

View File

@ -1454,6 +1454,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
// * `from .x.y import z` (must be relative!)
// * 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 we're in the global scope
//
// We introduce a local definition `x = <module 'thispackage.x'>` that occurs
// 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.
//
// TODO: Also support `from thispackage.x.y import z`?
// TODO: `seen_submodule_imports` should be per-scope and not per-file
// (if two functions import `.x`, they both should believe `x` is defined)
if node.level == 1
if self.current_scope() == FileScopeId::global()
&& node.level == 1
&& let Some(submodule) = &node.module
&& let Some(parsed_submodule) = ModuleName::new(submodule.as_str())
&& let Some(direct_submodule) = parsed_submodule.components().next()

View File

@ -5911,20 +5911,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
submodule: &Name,
definition: Definition<'db>,
) {
// Although the *actual* runtime semantic of this kind of statement is to
// introduce a variable in the global scope of this module, we want to
// encourage users to write code that doesn't have dependence on execution-order.
//
// 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!)
// The runtime semantic of this kind of statement is to introduce a variable in the global
// scope of this module, so we do just that. (Actually we introduce a local variable, but
// this type of Definition is only created when a `from..import` is in global scope.)
// Get this package's module by resolving `.`
let Ok(module_name) = ModuleName::from_identifier_parts(self.db(), self.file(), None, 1)