diff --git a/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md index 6214a05b8d..8704bef123 100644 --- a/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md +++ b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-immediately-resolved.md @@ -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] +``` diff --git a/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-mismatch.md b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-mismatch.md index 0d20e53059..b9106227bd 100644 --- a/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-mismatch.md +++ b/crates/ruff_linter/resources/mdtest/flake8-tidy-imports/lazy-import-mismatch.md @@ -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] +``` diff --git a/crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md b/crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md new file mode 100644 index 0000000000..715a9b627d --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md @@ -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 + | +``` diff --git a/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-c-element-tree.md b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-c-element-tree.md new file mode 100644 index 0000000000..8129f7a8e0 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-c-element-tree.md @@ -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 + | +``` diff --git a/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-import.md b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-import.md new file mode 100644 index 0000000000..0584a5a341 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-import.md @@ -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 + | +``` diff --git a/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-mock-import.md b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-mock-import.md new file mode 100644 index 0000000000..213b8adf1a --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-mock-import.md @@ -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 +``` diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 7b77bde0cb..9a2ebb5860 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -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`. diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs index 9106f37320..fbbf52d375 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs @@ -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( diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs index 85b93a96e4..6892662f25 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_immediately_resolved.rs @@ -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 diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs index 61b750163f..529c240ea9 100644 --- a/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs @@ -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 { - 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 { diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports___init__.py.snap b/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports___init__.py.snap new file mode 100644 index 0000000000..0b50087195 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports___init__.py.snap @@ -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() + | diff --git a/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports_mymodule.py.snap b/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports_mymodule.py.snap new file mode 100644 index 0000000000..aea647eee3 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports_mymodule.py.snap @@ -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() + | diff --git a/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs b/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs index 3334fefa31..2a9b5d51a7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/manual_import_from.rs @@ -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 { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs index dfcd64f88b..8ecac83a42 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_c_element_tree.rs @@ -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 { + Some("Replace with `ElementTree`".to_string()) } } -fn add_check_for_node(checker: &Checker, node: &T) +fn add_check_for_node(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); } } } diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs index d739822a9f..5939a2e34f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs @@ -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(), diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs index 30f5cc2fac..7d55390635 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs @@ -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 { 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())) diff --git a/crates/ruff_python_semantic/src/binding.rs b/crates/ruff_python_semantic/src/binding.rs index 83d42a20f2..e146b6032e 100644 --- a/crates/ruff_python_semantic/src/binding.rs +++ b/crates/ruff_python_semantic/src/binding.rs @@ -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(); } diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 57844a8776..64ebceb84c 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -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>, + /// 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::>>(), + _ => 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> { + 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 {