Add support for __lazy_modules__ (#28459)

Summary
--

For a bit of background, `__lazy_modules__` is a backwards compatibility
mechanism for lazy imports, introduced in [PEP
810](https://peps.python.org/pep-0810/) alongside the `lazy` keyword
itself. Instead of declaring an import `lazy` with the keyword, it can
also be listed in a module-level constant named `__lazy_modules__`:

```py
lazy import foo

# equivalently
__lazy_modules__ = ["foo"]
import foo
```

This can be used in projects that support older Python versions, as well
as 3.15.

It also supports more dynamic approaches (that this PR doesn't), as
noted in one of my inline comments, such as:

```py
class LazyImporter:
    def __contains__(self, name): return True

__lazy_modules__ = LazyImporter()
```

As one example from the PEP for emulating wildcard lazy imports.

<hr>

The PR is pretty big in total, but it's intended to be reviewed
commit-by-commit. As usual, the very
first commit is empty for filling the PR summary, so the numbers below
refer to the remaining
non-empty commits.

The first commit adds `__lazy_modules__` tracking to the semantic model.
Because the declaration
affects later imports and can also be overwritten, we just store
whatever was assigned to it last
for later lookup when visiting other code.

The second commit expands [TID254] to recognize `__lazy_modules__` and
includes a few helper methods
on the semantic model that are used by later rules as well.

The third commit adds `__lazy_modules__` support to [TID255], which also
relies on a new
`BindingFlags::LAZY` variant. This marks bindings as originating from a
lazy import, which is easier
than trying to resolve a sequence of potentially shadowed
`__lazy_modules__` bindings to check if an
import was lazy. For example, we set the `LAZY` flag on `foo` here,
which would otherwise require
resolve multiple previous bindings:

```py
__lazy_modules__ = ["foo"]
import foo

__lazy_modules__ = []

class C(foo.Something): ...
```

The last four commits make smaller changes to four other rules. As with
TID254 and TID255, the
primary change is that we withhold a fix that involves
`__lazy_modules__` when it would change the
module's lazy status. Initially I had hoped to add fixes that modified
`__lazy_modules__` (adding to
it when we'd otherwise add `lazy`, removing from it when we'd remove
`lazy`), but this turned out to
be quite complicated. Unlike the `lazy` keyword, `__lazy_modules__` has
nonlocal effects that could
change the behavior of _any_ import from the same module in the file.
Trying to resolve all of that
to be sure the fix is reasaonable was a real mess, and reverting Codex's
attempt saved almost 700
lines for TID255 alone.

Similarly, in case this comes up in someone else's Codex review, my
Codex initially tried to handle
even more edge cases with `__lazy_modules__` definitions, such as
definitions in a loop or
redefining it with `*` imports and things like that. I tried to rein it
in toward something more
like our `__all__` and `from __future__ import annotations` handling,
which again seemed like the
better tradeoff to me.

Closes #25170.

[TID254]: https://docs.astral.sh/ruff/rules/lazy-import-mismatch/
[TID255]:
https://docs.astral.sh/ruff/rules/lazy-import-immediately-resolved/

Test Plan
--

New mdtests. The ecosystem results all look correct. They are cases
where `TID255` now fires in modules where `__lazy_modules__` is defined.
This commit is contained in:
Brent Westbrook authored and GitHub committed 2026-09-11 09:55:42 -04:00
1 parent 44d69ec29c
commit 67d03a5447
18 files changed
+969 -135

No files matched your search

@@ -58,3 +58,89 @@ RequiredBase()
bar.value # error: [lazy-import-immediately-resolved]
OtherBase() # error: [lazy-import-immediately-resolved]
```
## Module declarations
```toml
target-version = "py315"
[lint]
preview = true
select = ["TID255"]
```
### From imports
A declaration makes members imported from the listed module lazy.
```py
__lazy_modules__ = ["json"]
from json import loads
loads("{}") # error: [lazy-import-immediately-resolved]
```
### Mixed imports
Only the listed module is lazy, even when the same statement imports an eager module. No fix is
offered for imports governed by `__lazy_modules__`.
```py
__lazy_modules__ = ["json"]
import json, pathlib
json.dumps({}) # snapshot: lazy-import-immediately-resolved
pathlib.Path(".")
```
```snapshot
error[TID255]: Lazy import `json` is resolved immediately
--> src/mdtest_snippet.py:4:1
|
4 | json.dumps({}) # snapshot: lazy-import-immediately-resolved
| ^^^^
```
### Reassigned declarations
Clearing the declaration does not make an earlier import eager.
```py
__lazy_modules__ = ["json"]
import json
__lazy_modules__ = []
json.dumps({}) # error: [lazy-import-immediately-resolved]
```
### Declarations after imports
A declaration does not retroactively make an earlier import lazy.
```py
import json
__lazy_modules__ = ["json"]
json.dumps({})
```
### Older target versions
A declaration expresses intended lazy imports even when the target version predates the `lazy`
keyword.
```toml
target-version = "py314"
[lint]
preview = true
select = ["TID255"]
```
```py
__lazy_modules__ = ["json"]
import json
json.dumps({}) # error: [lazy-import-immediately-resolved]
```
@@ -1,5 +1,7 @@
# `lazy-import-mismatch` (`TID254`)
## Explicit lazy imports
```toml
target-version = "py315"
@@ -12,7 +14,7 @@ require-lazy = ["typing", "package.deferred"]
ban-lazy = ["this", "package.eager"]
```
## Multiple imports
### Multiple imports
When names in one statement have conflicting policies, we report the mismatch without a fix.
Changing the entire statement would violate the other name's policy and cause a fix loop.
@@ -34,7 +36,7 @@ error[TID254]: `typing` should be imported lazily
help: Convert to a lazy import
```
## Multiple imported members
### Multiple imported members
The same restriction applies to importing multiple members from one module, whether the statement
is eager or lazy.
@@ -55,3 +57,139 @@ error[TID254]: `package.deferred` should be imported lazily
| ^^^^^^^^
help: Convert to a lazy import
```
## Module declarations
```toml
target-version = "py314"
[lint]
preview = true
select = ["TID254"]
[lint.flake8-tidy-imports]
require-lazy = "all"
```
### Literal declarations
[PEP 810](https://peps.python.org/pep-0810/#semantics) allows a module-level `__lazy_modules__`
collection to identify lazy imports. Ruff recognizes literal lists, tuples, and sets, including
annotated assignments.
```py
__lazy_modules__ = ["json"]
import json
__lazy_modules__ = ("pathlib",)
import pathlib
__lazy_modules__: set[str] = {"collections"}
import collections
import math # error: [lazy-import-mismatch]
```
### Exact module matching
Entries match module names independently of local aliases. A `from` import checks its containing
module, and listing a package does not include its submodules.
```py
__lazy_modules__ = ["json", "xml"]
import json as renamed
from json import dumps, loads
import xml.dom # error: [lazy-import-mismatch]
```
### Empty declarations on older Python versions
An empty declaration enables lazy-import policies even when the target version predates the `lazy`
keyword.
```py
__lazy_modules__ = []
import json # error: [lazy-import-mismatch]
```
### Older Python versions without a declaration
Without a declaration, the policy is skipped on target versions that do not support `lazy` syntax.
In the future, the rule could create a `__lazy_modules__` declaration to offer fixes on older targets
without requiring an existing declaration.
```py
import json
```
### Reassigned declarations
Each import uses the declaration present when the checker reaches it. An annotation without a value
does not replace the declaration.
```py
__lazy_modules__ = ["json"]
import json
__lazy_modules__: list[str]
import json as still_lazy
__lazy_modules__ = []
import json as eager # error: [lazy-import-mismatch]
```
### Unknown declarations
An unsupported declaration value does not establish whether the import violates the policy, so no
diagnostic is emitted.
```py
__lazy_modules__ = configured_modules()
import json
```
### Per-alias policies
Each alias is checked independently. Here `json` must become eager and `pathlib` must become lazy.
```toml
target-version = "py315"
[lint]
preview = true
select = ["TID254"]
[lint.flake8-tidy-imports]
require-lazy = ["pathlib"]
ban-lazy = ["json"]
```
```py
__lazy_modules__ = ["json"]
# error: [lazy-import-mismatch] "`json` should be imported eagerly"
# error: [lazy-import-mismatch] "`pathlib` should be imported lazily"
import json, pathlib
```
### From-import member policies
A declaration makes every member imported from its module lazy, but policies still apply to
individual members.
```toml
target-version = "py314"
[lint]
preview = true
select = ["TID254"]
[lint.flake8-tidy-imports]
require-lazy = ["pkg.First"]
ban-lazy = ["pkg.Second"]
```
```py
__lazy_modules__ = ["pkg"]
from pkg import First
from pkg import Second # error: [lazy-import-mismatch]
```
@@ -0,0 +1,139 @@
# `manual-from-import` (`PLR0402`)
```toml
target-version = "py315"
[lint]
select = ["PLR0402"]
```
## Both modules listed
When `__lazy_modules__` contains both the package and its submodule, the fix preserves laziness.
```py
__lazy_modules__ = ["foo", "foo.bar"]
import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
1 | __lazy_modules__ = ["foo", "foo.bar"]
- import foo.bar as bar # snapshot: manual-from-import
2 + from foo import bar # snapshot: manual-from-import
|
```
## Only the submodule listed
A `from` import checks its containing module for membership in `__lazy_modules__`. Rewriting this
import would make it eager, so the diagnostic has no fix.
```py
__lazy_modules__ = ["foo.bar"]
import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```
## Only the package listed
Listing a package does not make imports of its submodules lazy. Rewriting this import would make it
lazy, so the diagnostic has no fix.
```py
__lazy_modules__ = ["foo"]
import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```
## Explicit lazy imports
The fix preserves the `lazy` keyword, so the import remains lazy even when the package is absent
from `__lazy_modules__`.
```py
__lazy_modules__ = ["foo.bar"]
lazy import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:13
|
2 | lazy import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
1 | __lazy_modules__ = ["foo.bar"]
- lazy import foo.bar as bar # snapshot: manual-from-import
2 + lazy from foo import bar # snapshot: manual-from-import
|
```
## Unknown declarations
When the declaration's contents are unknown, the rule cannot establish whether the fix preserves
laziness, so no fix is offered.
```py
__lazy_modules__ = configured_lazy_modules
import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```
## Imports inside functions
Imports inside functions are eager regardless of `__lazy_modules__`, so an unknown declaration does
not prevent a fix.
```py
__lazy_modules__ = configured_lazy_modules
def import_in_function():
import foo.bar as bar # snapshot: manual-from-import
```
```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:5:12
|
5 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
4 | def import_in_function():
- import foo.bar as bar # snapshot: manual-from-import
5 + from foo import bar # snapshot: manual-from-import
|
```
@@ -0,0 +1,75 @@
# `deprecated-c-element-tree` (`UP023`)
```toml
target-version = "py315"
[lint]
select = ["UP023"]
```
## Only the deprecated module listed
When only `cElementTree` is listed, replacing it with `ElementTree` would make the import eager, so
the diagnostic has no fix.
```py
__lazy_modules__ = ["xml.etree.cElementTree"]
import xml.etree.cElementTree as ET # snapshot: deprecated-c-element-tree
```
```snapshot
error[UP023]: `cElementTree` is deprecated, use `ElementTree`
--> src/mdtest_snippet.py:2:8
|
2 | import xml.etree.cElementTree as ET # snapshot: deprecated-c-element-tree
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Replace with `ElementTree`
```
## Members of the same containing module
Replacing a member imported from `xml.etree` leaves the containing module unchanged. The import
remains eager, so the diagnostic includes a fix.
```py
__lazy_modules__ = ["xml.etree.cElementTree"]
from xml.etree import cElementTree as ET # snapshot: deprecated-c-element-tree
```
```snapshot
error[UP023]: `cElementTree` is deprecated, use `ElementTree`
--> src/mdtest_snippet.py:2:23
|
2 | from xml.etree import cElementTree as ET # snapshot: deprecated-c-element-tree
| ^^^^^^^^^^^^^^^^^^
help: Replace with `ElementTree`
|
1 | __lazy_modules__ = ["xml.etree.cElementTree"]
- from xml.etree import cElementTree as ET # snapshot: deprecated-c-element-tree
2 + from xml.etree import ElementTree as ET # snapshot: deprecated-c-element-tree
|
```
## Star imports
Star imports remain eager regardless of `__lazy_modules__`, so replacing their module preserves
laziness and the diagnostic includes a fix.
```py
__lazy_modules__ = ["xml.etree.cElementTree"]
from xml.etree.cElementTree import * # snapshot: deprecated-c-element-tree
```
```snapshot
error[UP023]: `cElementTree` is deprecated, use `ElementTree`
--> src/mdtest_snippet.py:2:1
|
2 | from xml.etree.cElementTree import * # snapshot: deprecated-c-element-tree
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Replace with `ElementTree`
|
1 | __lazy_modules__ = ["xml.etree.cElementTree"]
- from xml.etree.cElementTree import * # snapshot: deprecated-c-element-tree
2 + from xml.etree.ElementTree import * # snapshot: deprecated-c-element-tree
|
```
@@ -0,0 +1,52 @@
# `deprecated-import` (`UP035`)
```toml
target-version = "py315"
[lint]
select = ["UP035"]
```
## Only the original module listed
Rewriting this import would make it eager, so the diagnostic has no fix.
```py
__lazy_modules__ = ["typing"]
from typing import Iterable # snapshot: deprecated-import
```
```snapshot
error[UP035]: Import from `collections.abc` instead: `Iterable`
--> src/mdtest_snippet.py:2:1
|
2 | from typing import Iterable # snapshot: deprecated-import
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Import from `collections.abc`
```
## Splitting explicit lazy imports
When only some members move to another module, both resulting import statements retain the `lazy`
keyword, even when the replacement module is absent from `__lazy_modules__`.
```py
__lazy_modules__ = ["typing"]
# snapshot: deprecated-import
lazy from typing import Iterable, cast
```
```snapshot
error[UP035]: Import from `collections.abc` instead: `Iterable`
--> src/mdtest_snippet.py:3:1
|
3 | lazy from typing import Iterable, cast
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Import from `collections.abc`
|
2 | # snapshot: deprecated-import
- lazy from typing import Iterable, cast
3 + lazy from typing import cast
4 + lazy from collections.abc import Iterable
|
```
@@ -0,0 +1,64 @@
# `deprecated-mock-import` (`UP026`)
```toml
target-version = "py315"
[lint]
select = ["UP026"]
```
## Only the original module listed
When `unittest` is absent from `__lazy_modules__`, the replacement would make the import eager, so
the diagnostic has no fix.
```py
__lazy_modules__ = ["mock"]
import mock # snapshot: deprecated-mock-import
```
```snapshot
error[UP026]: `mock` is deprecated, use `unittest.mock`
--> src/mdtest_snippet.py:2:8
|
2 | import mock # snapshot: deprecated-mock-import
| ^^^^
help: Import from `unittest.mock` instead
```
## Splitting an import with one replacement module listed
Listing `unittest` preserves laziness for `mock`, but the fix would make `patch` eager because
`unittest.mock` is not listed. The diagnostic has no fix.
```py
__lazy_modules__ = ["mock", "unittest"]
from mock import mock, patch # snapshot: deprecated-mock-import
```
```snapshot
error[UP026]: `mock` is deprecated, use `unittest.mock`
--> src/mdtest_snippet.py:2:1
|
2 | from mock import mock, patch # snapshot: deprecated-mock-import
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Import from `unittest.mock` instead
```
## Explicit lazy imports
An explicit lazy import is diagnosed, but the fixer does not support the `lazy` keyword, so no fix
is offered.
```py
lazy from mock import patch # snapshot: deprecated-mock-import
```
```snapshot
error[UP026]: `mock` is deprecated, use `unittest.mock`
--> src/mdtest_snippet.py:1:1
|
1 | lazy from mock import patch # snapshot: deprecated-mock-import
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Import from `unittest.mock` instead
```
+54 -4
View File
@@ -56,8 +56,8 @@ use ruff_python_semantic::all::{DunderAllDefinition, DunderAllFlags};
use ruff_python_semantic::analyze::{imports, typing};
use ruff_python_semantic::{
BindingFlags, BindingId, BindingKind, Exceptions, Export, FromImport, GeneratorKind, Globals,
Import, Module, ModuleKind, ModuleSource, NodeId, ScopeId, ScopeKind, SemanticModel,
SemanticModelFlags, StarImport, SubmoduleImport,
Import, ImportLaziness, Module, ModuleKind, ModuleSource, NodeId, ScopeId, ScopeKind,
SemanticModel, SemanticModelFlags, StarImport, SubmoduleImport,
};
use ruff_python_trivia::CommentRanges;
use ruff_source_file::{OneIndexed, SourceFile, SourceFileBuilder, SourceRow};
@@ -361,6 +361,24 @@ impl<'a> Checker<'a> {
None
}
/// Whether changing this import's module preserves membership in `__lazy_modules__`.
pub(crate) fn import_rewrite_preserves_laziness(&self, original: &str, target: &str) -> bool {
if self.lazy_import_context().is_some()
|| matches!(self.semantic.current_statement(), Stmt::ImportFrom(import)
if import.names.iter().any(|alias| alias.name.as_str() == "*"))
{
return true;
}
matches!(
(
self.semantic.module_laziness(original),
self.semantic.module_laziness(target)
),
(ImportLaziness::Lazy, ImportLaziness::Lazy)
| (ImportLaziness::Eager, ImportLaziness::Eager)
)
}
/// Return the preferred quote for a generated `StringLiteral` node, given where we are in the
/// AST.
fn preferred_quote(&self) -> Quote {
@@ -1094,6 +1112,13 @@ impl<'a> Visitor<'a> for Checker<'a> {
// Mark the top-level module as "seen" by the semantic model.
self.semantic.add_module(module);
let mut flags = BindingFlags::EXTERNAL;
if self.lazy_import_context().is_none()
&& self.semantic.import_laziness(stmt, alias).is_lazy()
{
flags |= BindingFlags::LAZY;
}
if alias.asname.is_none() && alias.name.contains('.') {
let qualified_name = QualifiedName::user_defined(&alias.name);
self.add_binding(
@@ -1102,10 +1127,9 @@ impl<'a> Visitor<'a> for Checker<'a> {
BindingKind::SubmoduleImport(SubmoduleImport {
qualified_name: Box::new(qualified_name),
}),
BindingFlags::EXTERNAL,
flags,
);
} else {
let mut flags = BindingFlags::EXTERNAL;
if alias.asname.is_some() {
flags |= BindingFlags::ALIAS;
}
@@ -1168,6 +1192,11 @@ impl<'a> Visitor<'a> for Checker<'a> {
.add_star_import(StarImport { level, module });
} else {
let mut flags = BindingFlags::EXTERNAL;
if self.lazy_import_context().is_none()
&& self.semantic.import_laziness(stmt, alias).is_lazy()
{
flags |= BindingFlags::LAZY;
}
if alias.asname.is_some() {
flags |= BindingFlags::ALIAS;
}
@@ -2839,6 +2868,27 @@ impl<'a> Checker<'a> {
return;
}
if id == "__lazy_modules__" && self.semantic.current_scope().kind.is_module() {
match parent {
Stmt::Assign(ast::StmtAssign { targets, value, .. })
if let [Expr::Name(name)] = targets.as_slice()
&& name.id == id =>
{
self.semantic.set_lazy_modules(value);
}
Stmt::AnnAssign(ast::StmtAnnAssign {
target,
value: Some(value),
..
}) if let Expr::Name(name) = target.as_ref()
&& name.id == id =>
{
self.semantic.set_lazy_modules(value);
}
_ => {}
}
}
// Match the left-hand side of an annotated assignment without a value,
// like `x` in `x: int`. N.B. In stub files, these should be viewed
// as assignments on par with statements such as `x: int = 5`.
@@ -11,6 +11,7 @@ mod tests {
use ruff_python_ast::PythonVersion;
use ruff_python_trivia::textwrap::dedent;
use rustc_hash::FxHashMap;
use test_case::test_case;
use crate::assert_diagnostics;
use crate::registry::Rule;
@@ -223,6 +224,46 @@ mod tests {
Ok(())
}
/// Test that `__lazy_modules__` properly handles relative imports.
///
/// In this example, `.` resolves to `mypackage`, so both `helper` and `func` are imported from
/// modules that have been declared lazy. As with other lazy imports, the full module path has
/// to match, so `mypackage.sub` is _not_ lazy.
#[test_case("mymodule.py")]
#[test_case("__init__.py")]
fn declared_lazy_relative_imports(filename: &str) {
let source_kind = SourceKind::Python {
code: dedent(
r#"
__lazy_modules__ = ["mypackage", "mypackage.sub.utils"]
from . import helper
from .sub.utils import func
from .sub import eager
helper()
func()
eager()
"#,
)
.to_string(),
is_stub: false,
};
let (diagnostics, _) = test_contents(
&source_kind,
&Path::new("mypackage").join(filename),
&LinterSettings {
namespace_packages: vec![Path::new("mypackage").to_path_buf()],
..LinterSettings::for_rule(Rule::LazyImportImmediatelyResolved)
.with_target_version(PythonVersion::PY315)
},
);
assert_diagnostics!(
format!("declared_lazy_relative_imports_{filename}"),
diagnostics
);
}
#[test]
fn preview_lazy_import_immediately_resolved_fix() {
let source = dedent(
@@ -3,7 +3,7 @@ use ruff_python_ast::{
ExprName, PySourceType, PythonVersion, Stmt, StmtImport, StmtImportFrom, helpers,
token::{TokenKind, Tokens},
};
use ruff_python_semantic::{Binding, BindingKind, GeneratorKind, ScopeKind, SemanticModel};
use ruff_python_semantic::{GeneratorKind, ScopeKind, SemanticModel};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
@@ -23,6 +23,12 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// lazy. This is commonly caused by using a lazily imported module in a module
/// global or in a top-level class definition.
///
/// The rule also recognizes imports made lazy by a literal `__lazy_modules__`
/// declaration, even when the target version is older than Python 3.15.
/// Such imports remain eager on older Python versions. Dynamic assignments
/// to `__lazy_modules__` (e.g. `__lazy_modules__ = non_literal()`) are ignored,
/// as their effects cannot be determined statically.
///
/// ## Example
/// ```python
/// lazy import foo
@@ -39,15 +45,17 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// class Bar(foo.Foo): ...
/// ```
///
/// ## Fix availability
/// The fix is only available when the lazy import statement imports a single
/// member, since removing `lazy` from a multi-member import would make every
/// imported member eager, including names that may not be resolved immediately.
/// Fixes are unavailable for imports governed by a `__lazy_modules__` declaration.
///
/// ## Fix safety
/// This rule's fix is marked as unsafe because converting a lazy import to an
/// eager import changes when the imported module is executed, which can change
/// runtime behavior if the module has import-time side effects.
///
/// The fix is only available when the lazy import statement imports a single
/// member, since removing `lazy` from a multi-member import would make every
/// imported member eager, including names that may not be resolved immediately.
///
/// ## Options
///
/// The rule ignores imports required to be lazy by the following setting:
@@ -78,10 +86,6 @@ impl Violation for LazyImportImmediatelyResolved {
/// TID255
/// Check whether a name load forces a lazy import during module import execution.
pub(crate) fn lazy_import_immediately_resolved(checker: &Checker, name: &ExprName) {
if checker.target_version() < PythonVersion::PY315 {
return;
}
let semantic = checker.semantic();
if !is_immediate_resolution_context(semantic, checker.source_type) {
@@ -93,9 +97,21 @@ pub(crate) fn lazy_import_immediately_resolved(checker: &Checker, name: &ExprNam
};
let binding = semantic.binding(binding_id);
let Some(import) = lazy_import_statement(binding, semantic) else {
if !binding.is_lazy() {
return;
}
let Some(import) = binding.statement(semantic) else {
return;
};
if checker.target_version() < PythonVersion::PY315
&& matches!(
import,
Stmt::Import(StmtImport { is_lazy: true, .. })
| Stmt::ImportFrom(StmtImportFrom { is_lazy: true, .. })
)
{
return;
}
// Ignore imports that are required to be lazy.
let require_lazy = &checker.settings().flake8_tidy_imports.require_lazy;
@@ -110,7 +126,7 @@ pub(crate) fn lazy_import_immediately_resolved(checker: &Checker, name: &ExprNam
}
}
let fix_range = if is_single_member_import(import) {
let fix_range = if is_single_member_import(import) && semantic.lazy_modules.is_none() {
lazy_import_prefix_range(import, checker.source_tokens())
} else {
None
@@ -128,26 +144,8 @@ pub(crate) fn lazy_import_immediately_resolved(checker: &Checker, name: &ExprNam
}
}
/// Return the import statement if the binding was created by a `lazy import`.
fn lazy_import_statement<'a>(binding: &Binding, semantic: &SemanticModel<'a>) -> Option<&'a Stmt> {
if !matches!(
binding.kind,
BindingKind::Import(_) | BindingKind::SubmoduleImport(_) | BindingKind::FromImport(_)
) {
return None;
}
let stmt = binding.statement(semantic)?;
matches!(
stmt,
Stmt::Import(StmtImport { is_lazy: true, .. })
| Stmt::ImportFrom(StmtImportFrom { is_lazy: true, .. })
)
.then_some(stmt)
}
/// Return `true` if changing `lazy` affects only one imported name.
pub(super) fn is_single_member_import(stmt: &Stmt) -> bool {
fn is_single_member_import(stmt: &Stmt) -> bool {
match stmt {
Stmt::Import(StmtImport { names, .. }) | Stmt::ImportFrom(StmtImportFrom { names, .. }) => {
names.len() == 1
@@ -1,8 +1,8 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{PythonVersion, Stmt, StmtImport, StmtImportFrom};
use ruff_python_ast::{PythonVersion, Stmt};
use ruff_python_semantic::ImportLaziness;
use ruff_text_size::{Ranged, TextRange, TextSize};
use super::lazy_import_immediately_resolved::is_single_member_import;
use crate::checkers::ast::Checker;
use crate::codes::Category;
use crate::rules::flake8_tidy_imports::rules::BannedModuleImportPolicies;
@@ -20,6 +20,13 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// import work until the name is first used, while others should remain eager
/// to preserve import-time side effects.
///
/// The rule also recognizes imports made lazy by a literal `__lazy_modules__`
/// declaration, including when the target version is older than Python 3.15.
/// This declaration allows a module to use lazy imports on Python 3.15 and
/// later while retaining eager imports on older versions. Dynamic assignments
/// to `__lazy_modules__` (e.g. `__lazy_modules__ = non_literal()`) are ignored,
/// as their effects cannot be determined statically.
///
/// This rule ignores contexts in which `lazy import` is invalid, such as
/// functions, classes, `try`/`except` blocks, `__future__` imports, and
/// `from ... import *` statements.
@@ -39,6 +46,9 @@ use crate::{Edit, Fix, FixAvailability, Violation};
/// The fix is only available for statements that import a single name, since
/// changing `lazy` on a multi-member import could violate another name's policy.
///
/// The fix is also unavailable for imports marked lazy by a `__lazy_modules__`
/// declaration.
///
/// ## Fix safety
///
/// This rule's fix is marked as unsafe because changing when a module is
@@ -91,86 +101,44 @@ impl Violation for LazyImportMismatch {
/// TID254
pub(crate) fn lazy_import_mismatch(checker: &Checker, stmt: &Stmt) {
let Some(policy) = lazy_import_policy(checker, stmt) else {
return;
};
let selector = match policy {
LazyImportPolicy::RequireLazy => &checker.settings().flake8_tidy_imports.require_lazy,
LazyImportPolicy::BanLazy => &checker.settings().flake8_tidy_imports.ban_lazy,
};
if selector.includes_all() {
report_all_matching_imports(checker, stmt, policy, selector);
if checker.lazy_import_context().is_some()
|| (checker.target_version() < PythonVersion::PY315
&& checker.semantic().lazy_modules.is_none())
{
return;
}
let names = match stmt {
Stmt::Import(import) => &import.names,
Stmt::ImportFrom(import)
if import.module.as_deref() != Some("__future__")
&& !import.names.iter().any(|alias| alias.name.as_str() == "*") =>
{
&import.names
}
_ => return,
};
for (import_policy, node) in &BannedModuleImportPolicies::new(stmt, checker) {
let Some(alias) = node.as_alias().copied().or_else(|| names.first()) else {
continue;
};
let policy = match checker.semantic().import_laziness(stmt, alias) {
ImportLaziness::Lazy => LazyImportPolicy::BanLazy,
ImportLaziness::Eager => LazyImportPolicy::RequireLazy,
ImportLaziness::Unknown => continue,
};
let selector = match policy {
LazyImportPolicy::RequireLazy => &checker.settings().flake8_tidy_imports.require_lazy,
LazyImportPolicy::BanLazy => &checker.settings().flake8_tidy_imports.ban_lazy,
};
if selector.includes_all() && stmt.is_import_from_stmt() && node.is_alias() {
continue;
}
if let Some(m) = selector.find(&import_policy) {
report_lazy_import_policy(checker, stmt, node.range(), m.name(), policy);
}
}
}
fn lazy_import_policy(checker: &Checker, stmt: &Stmt) -> Option<LazyImportPolicy> {
if checker.target_version() < PythonVersion::PY315 || checker.lazy_import_context().is_some() {
return None;
}
match stmt {
Stmt::Import(StmtImport { is_lazy, .. }) => Some(if *is_lazy {
LazyImportPolicy::BanLazy
} else {
LazyImportPolicy::RequireLazy
}),
Stmt::ImportFrom(StmtImportFrom {
module,
names,
is_lazy,
..
}) => {
if matches!(module.as_deref(), Some("__future__"))
|| names.iter().any(|alias| alias.name.as_str() == "*")
{
None
} else {
Some(if *is_lazy {
LazyImportPolicy::BanLazy
} else {
LazyImportPolicy::RequireLazy
})
}
}
_ => None,
}
}
fn report_all_matching_imports(
checker: &Checker,
stmt: &Stmt,
policy: LazyImportPolicy,
selector: &crate::rules::flake8_tidy_imports::settings::ImportSelector,
) {
match stmt {
Stmt::Import(_) => {
for (import_policy, node) in &BannedModuleImportPolicies::new(stmt, checker) {
if let Some(m) = selector.find(&import_policy) {
report_lazy_import_policy(checker, stmt, node.range(), m.name(), policy);
}
}
}
Stmt::ImportFrom(_) => {
for (import_policy, node) in &BannedModuleImportPolicies::new(stmt, checker) {
if !node.is_alias() && selector.find(&import_policy).is_some() {
report_lazy_import_policy(checker, stmt, node.range(), None, policy);
break;
}
}
}
_ => {}
}
}
fn report_lazy_import_policy(
checker: &Checker,
stmt: &Stmt,
@@ -179,8 +147,13 @@ fn report_lazy_import_policy(
policy: LazyImportPolicy,
) {
let mut diagnostic = checker.report_diagnostic(LazyImportMismatch { policy, name }, range);
let names = match stmt {
Stmt::Import(import) => &import.names,
Stmt::ImportFrom(import) => &import.names,
_ => return,
};
// Changing the entire statement could violate another imported name's policy.
if !is_single_member_import(stmt) {
if names.len() != 1 || checker.semantic().lazy_modules.is_some() {
return;
}
match policy {
@@ -0,0 +1,22 @@
---
source: crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs
---
TID255 Lazy import `helper` is resolved immediately
--> __init__.py:7:1
|
5 | from .sub import eager
6 |
7 | helper()
| ^^^^^^
8 | func()
9 | eager()
|
TID255 Lazy import `func` is resolved immediately
--> __init__.py:8:1
|
7 | helper()
8 | func()
| ^^^^
9 | eager()
|
@@ -0,0 +1,22 @@
---
source: crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs
---
TID255 Lazy import `helper` is resolved immediately
--> mymodule.py:7:1
|
5 | from .sub import eager
6 |
7 | helper()
| ^^^^^^
8 | func()
9 | eager()
|
TID255 Lazy import `func` is resolved immediately
--> mymodule.py:8:1
|
7 | helper()
8 | func()
| ^^^^
9 | eager()
|
@@ -87,6 +87,9 @@ pub(crate) fn manual_from_import(checker: &Checker, stmt: &Stmt, alias: &Alias,
let is_lazy = stmt
.as_import_stmt()
.is_some_and(|import_stmt| import_stmt.is_lazy);
if !is_lazy && !checker.import_rewrite_preserves_laziness(&alias.name, module) {
return;
}
let node = ast::StmtImportFrom {
module: Some(Identifier::new(module.to_string(), TextRange::default())),
names: vec![Alias {
@@ -4,7 +4,7 @@ use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::codes::Category;
use crate::{AlwaysFixableViolation, Edit, Fix};
use crate::{Edit, Fix, FixAvailability, Violation};
/// ## What it does
/// Checks for uses of the `xml.etree.cElementTree` module.
@@ -29,23 +29,28 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
#[violation_metadata(stable_since = "v0.0.199", category = Category::Suspicious)]
pub(crate) struct DeprecatedCElementTree;
impl AlwaysFixableViolation for DeprecatedCElementTree {
impl Violation for DeprecatedCElementTree {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`cElementTree` is deprecated, use `ElementTree`".to_string()
}
fn fix_title(&self) -> String {
"Replace with `ElementTree`".to_string()
fn fix_title(&self) -> Option<String> {
Some("Replace with `ElementTree`".to_string())
}
}
fn add_check_for_node<T>(checker: &Checker, node: &T)
fn add_check_for_node<T>(checker: &Checker, node: &T, preserves_laziness: bool)
where
T: Ranged,
{
let mut diagnostic = checker.report_diagnostic(DeprecatedCElementTree, node.range());
diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated);
if !preserves_laziness {
return;
}
let contents = checker.locator().slice(node);
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
contents.replacen("cElementTree", "ElementTree", 1),
@@ -55,17 +60,19 @@ where
/// UP023
pub(crate) fn deprecated_c_element_tree(checker: &Checker, stmt: &Stmt) {
let preserves_laziness = checker
.import_rewrite_preserves_laziness("xml.etree.cElementTree", "xml.etree.ElementTree");
match stmt {
Stmt::Import(ast::StmtImport {
names,
is_lazy: _,
is_lazy,
range: _,
node_index: _,
}) => {
// Ex) `import xml.etree.cElementTree as ET`
for name in names {
if &name.name == "xml.etree.cElementTree" && name.asname.is_some() {
add_check_for_node(checker, name);
add_check_for_node(checker, name, *is_lazy || preserves_laziness);
}
}
}
@@ -73,7 +80,7 @@ pub(crate) fn deprecated_c_element_tree(checker: &Checker, stmt: &Stmt) {
module,
names,
level,
is_lazy: _,
is_lazy,
range: _,
node_index: _,
}) => {
@@ -82,12 +89,12 @@ pub(crate) fn deprecated_c_element_tree(checker: &Checker, stmt: &Stmt) {
} else if let Some(module) = module {
if module == "xml.etree.cElementTree" {
// Ex) `from xml.etree.cElementTree import XML`
add_check_for_node(checker, stmt);
add_check_for_node(checker, stmt, *is_lazy || preserves_laziness);
} else if module == "xml.etree" {
// Ex) `from xml.etree import cElementTree as ET`
for name in names {
if &name.name == "cElementTree" && name.asname.is_some() {
add_check_for_node(checker, name);
add_check_for_node(checker, name, true);
}
}
}
@@ -643,7 +643,7 @@ impl<'a> ImportReplacer<'a> {
}
if unmatched_names.is_empty() {
let matched = ImportReplacer::format_import_from(&matched_names, target);
let matched = self.format_import_from(&matched_names, target);
let operation = WithoutRename {
target: target.to_string(),
members: matched_names
@@ -673,7 +673,7 @@ impl<'a> ImportReplacer<'a> {
return Some((operation, fix));
};
let matched = ImportReplacer::format_import_from(&matched_names, target);
let matched = self.format_import_from(&matched_names, target);
let unmatched = fixes::remove_import_members(
self.locator,
self.import_from_stmt,
@@ -723,7 +723,7 @@ impl<'a> ImportReplacer<'a> {
/// Converts a list of names and a module into an `import from`-style
/// import.
fn format_import_from(names: &[&Alias], module: &str) -> String {
fn format_import_from(&self, names: &[&Alias], module: &str) -> String {
// Construct the whitespace strings.
// Generate the formatted names.
let qualified_names: String = names
@@ -733,7 +733,12 @@ impl<'a> ImportReplacer<'a> {
None => format!("{}", name.name),
})
.join(", ");
format!("from {module} import {qualified_names}")
let prefix = if self.import_from_stmt.is_lazy {
"lazy "
} else {
""
};
format!("{prefix}from {module} import {qualified_names}")
}
}
@@ -769,6 +774,8 @@ pub(crate) fn deprecated_import(checker: &Checker, import_from_stmt: &StmtImport
);
for (operation, fix) in fixer.without_renames() {
let preserves_laziness = import_from_stmt.is_lazy
|| checker.import_rewrite_preserves_laziness(module, &operation.target);
let mut diagnostic = checker.report_diagnostic(
DeprecatedImport {
deprecation: Deprecation::WithoutRename(operation),
@@ -776,7 +783,9 @@ pub(crate) fn deprecated_import(checker: &Checker, import_from_stmt: &StmtImport
import_from_stmt.range(),
);
diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated);
if let Some(content) = fix {
if let Some(content) = fix
&& preserves_laziness
{
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
content,
import_from_stmt.range(),
@@ -19,7 +19,7 @@ use crate::codes::Category;
use crate::cst::matchers::{match_import, match_import_from, match_statement};
use crate::fix::codemods::CodegenStylist;
use crate::rules::pyupgrade::rules::is_import_required_by_isort;
use crate::{AlwaysFixableViolation, Edit, Fix};
use crate::{Edit, Fix, FixAvailability, Violation};
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub(crate) enum MockReference {
@@ -61,18 +61,20 @@ pub(crate) struct DeprecatedMockImport {
reference_type: MockReference,
}
impl AlwaysFixableViolation for DeprecatedMockImport {
impl Violation for DeprecatedMockImport {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`mock` is deprecated, use `unittest.mock`".to_string()
}
fn fix_title(&self) -> String {
fn fix_title(&self) -> Option<String> {
let DeprecatedMockImport { reference_type } = self;
match reference_type {
Some(match reference_type {
MockReference::Import => "Import from `unittest.mock` instead".to_string(),
MockReference::Attribute => "Replace `mock.mock` with `mock`".to_string(),
}
})
}
}
@@ -287,15 +289,21 @@ pub(crate) fn deprecated_mock_import(checker: &Checker, stmt: &Stmt) {
// Find all `mock` imports.
Stmt::Import(ast::StmtImport {
names,
is_lazy: _,
is_lazy,
range: _,
node_index: _,
}) if names
.iter()
.any(|name| &name.name == "mock" || &name.name == "mock.mock") =>
{
// The CST-based fixer does not support explicit lazy import syntax.
let can_fix = !*is_lazy
&& names
.iter()
.filter(|name| matches!(name.name.as_str(), "mock" | "mock.mock"))
.all(|name| checker.import_rewrite_preserves_laziness(&name.name, "unittest"));
// Generate the fix, if needed, which is shared between all `mock` imports.
let content = if let Some(indent) = indentation(checker.source(), stmt) {
let content = if can_fix && let Some(indent) = indentation(checker.source(), stmt) {
match format_import(stmt, indent, checker.locator(), checker.stylist()) {
Ok(content) => Some(content),
Err(e) => {
@@ -336,6 +344,7 @@ pub(crate) fn deprecated_mock_import(checker: &Checker, stmt: &Stmt) {
module: Some(module),
level,
names,
is_lazy,
..
}) => {
if *level > 0 {
@@ -361,7 +370,16 @@ pub(crate) fn deprecated_mock_import(checker: &Checker, stmt: &Stmt) {
stmt.range(),
);
diagnostic.add_primary_tag(ruff_db::diagnostic::DiagnosticTag::Deprecated);
if let Some(indent) = indentation(checker.source(), stmt) {
let can_fix = !*is_lazy
&& names.iter().all(|name| {
let target = if name.name.as_str() == "mock" {
"unittest"
} else {
"unittest.mock"
};
checker.import_rewrite_preserves_laziness(module, target)
});
if can_fix && let Some(indent) = indentation(checker.source(), stmt) {
diagnostic.try_set_fix(|| {
format_import_from(stmt, indent, checker.locator(), checker.stylist())
.map(|content| Edit::range_replacement(content, stmt.range()))
@@ -66,6 +66,11 @@ impl<'a> Binding<'a> {
self.flags.intersects(BindingFlags::EXTERNAL)
}
/// Return `true` if this binding was imported lazily, including through `__lazy_modules__`.
pub const fn is_lazy(&self) -> bool {
self.flags.intersects(BindingFlags::LAZY)
}
/// Return `true` if this [`Binding`] represents an aliased symbol
/// (e.g., `app` in `from fastapi import FastAPI as app`).
pub const fn is_alias(&self) -> bool {
@@ -431,6 +436,9 @@ bitflags! {
/// ```
const IN_ASSERT_STATEMENT = 1 << 13;
/// The import was lazy when its binding was created.
const LAZY = 1 << 14;
/// The binding represents any type alias.
const TYPE_ALIAS = Self::ANNOTATED_TYPE_ALIAS.bits() | Self::DEFERRED_TYPE_ALIAS.bits();
}
+131 -2
View File
@@ -1,11 +1,12 @@
use std::borrow::Cow;
use std::path::Path;
use bitflags::bitflags;
use rustc_hash::FxHashMap;
use ruff_python_ast::helpers::{from_relative_import, map_subscript};
use ruff_python_ast::helpers::{from_relative_import, map_subscript, resolve_imported_module_path};
use ruff_python_ast::name::{QualifiedName, UnqualifiedName};
use ruff_python_ast::{self as ast, Expr, ExprContext, PySourceType, PythonVersion, Stmt};
use ruff_python_ast::{self as ast, Alias, Expr, ExprContext, PySourceType, PythonVersion, Stmt};
use ruff_python_stdlib::builtins::{is_python_builtin, python_builtins, python_magic_globals};
use ruff_text_size::{Ranged, TextRange, TextSize};
@@ -154,6 +155,17 @@ pub struct SemanticModel<'a> {
/// Modules that have been seen by the semantic model.
pub seen: Modules,
/// The module names from the most recently visited module-level `__lazy_modules__` assignment.
///
/// A declaration affects subsequent imports, without changing earlier imports:
///
/// ```python
/// import json # Eager.
/// __lazy_modules__ = ["json", "pathlib"]
/// import pathlib # Lazy.
/// ```
pub lazy_modules: Option<LazyModules<'a>>,
/// Exceptions that are handled by the current `try` block.
///
/// For example, if we're visiting the `x = 1` assignment below,
@@ -208,6 +220,7 @@ impl<'a> SemanticModel<'a> {
rebinding_scopes: FxHashMap::default(),
flags: SemanticModelFlags::new(path),
seen: Modules::empty(),
lazy_modules: None,
handled_exceptions: Vec::default(),
resolved_names: FxHashMap::default(),
};
@@ -2396,6 +2409,122 @@ impl<'a> SemanticModel<'a> {
_ => false,
})
}
/// Classify an import using its syntax and the current `__lazy_modules__` declaration.
/// The caller must check that the import occurs in a context where laziness is allowed.
pub fn import_laziness(&self, statement: &Stmt, alias: &Alias) -> ImportLaziness {
let explicit = match statement {
Stmt::Import(import) => import.is_lazy,
Stmt::ImportFrom(import) => import.is_lazy,
_ => return ImportLaziness::Unknown,
};
if explicit {
return ImportLaziness::Lazy;
}
if self.lazy_modules.is_none() {
return ImportLaziness::Eager;
}
let Some(module) = self.import_module_name(statement, alias) else {
return ImportLaziness::Unknown;
};
self.module_laziness(&module)
}
/// Test exact module membership in the current `__lazy_modules__` declaration.
/// Returns [`ImportLaziness::Unknown`] when the declaration is not a literal collection of strings.
pub fn module_laziness(&self, module: &str) -> ImportLaziness {
match &self.lazy_modules {
None => ImportLaziness::Eager,
Some(LazyModules::Unknown) => ImportLaziness::Unknown,
Some(LazyModules::Known(modules)) => {
if modules.contains(&module) {
ImportLaziness::Lazy
} else {
ImportLaziness::Eager
}
}
}
}
/// Extract literal module names when visiting a `__lazy_modules__` assignment.
pub fn set_lazy_modules(&mut self, value: &'a Expr) {
let names = match value {
Expr::List(ast::ExprList { elts, .. })
| Expr::Tuple(ast::ExprTuple { elts, .. })
| Expr::Set(ast::ExprSet { elts, .. }) => elts
.iter()
.map(|element| {
element
.as_string_literal_expr()
.map(|literal| literal.value.to_str())
})
.collect::<Option<Vec<_>>>(),
_ => None,
};
self.lazy_modules = Some(names.map_or(LazyModules::Unknown, LazyModules::Known));
}
/// Return the module tested for membership in `__lazy_modules__`.
/// A `from package import member` statement tests `package`, not `package.member`.
fn import_module_name<'b>(
&self,
statement: &'b Stmt,
alias: &'b Alias,
) -> Option<Cow<'b, str>> {
match statement {
Stmt::Import(_) => Some(Cow::Borrowed(alias.name.as_str())),
Stmt::ImportFrom(ast::StmtImportFrom { level, module, .. }) => {
resolve_imported_module_path(
*level,
module.as_deref(),
self.module.qualified_name(),
)
}
_ => None,
}
}
}
/// Statically known module names in a `__lazy_modules__` declaration.
#[derive(Debug)]
pub enum LazyModules<'a> {
/// A literal list, set, or tuple that can be analyzed.
///
/// For example:
///
/// ```py
/// __lazy_modules__ = ["a", "list"]
/// ```
Known(Vec<&'a str>),
/// The declaration is present but not a literal collection that can be analyzed.
///
/// For example:
///
/// ```py
/// class LazyImporter:
/// def __contains__(self, name): return True
///
/// __lazy_modules__ = LazyImporter()
/// ```
Unknown,
}
/// Whether an import is lazy, as determined statically.
///
/// Dynamic assignments to `__lazy_modules__` are classified as unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportLaziness {
Lazy,
Eager,
Unknown,
}
impl ImportLaziness {
/// Returns `true` if the import laziness is [`Self::Lazy`].
pub fn is_lazy(&self) -> bool {
matches!(self, Self::Lazy)
}
}
pub struct ShadowedBinding {