From 67d03a5447cbf01944911ea7c2ff069fefe352d1 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:55:42 -0400 Subject: [PATCH] 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.
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. --- .../lazy-import-immediately-resolved.md | 86 +++++++++++ .../lazy-import-mismatch.md | 142 +++++++++++++++++- .../mdtest/pylint/manual-from-import.md | 139 +++++++++++++++++ .../pyupgrade/deprecated-c-element-tree.md | 75 +++++++++ .../mdtest/pyupgrade/deprecated-import.md | 52 +++++++ .../pyupgrade/deprecated-mock-import.md | 64 ++++++++ crates/ruff_linter/src/checkers/ast/mod.rs | 58 ++++++- .../src/rules/flake8_tidy_imports/mod.rs | 41 +++++ .../rules/lazy_import_immediately_resolved.rs | 58 ++++--- .../rules/lazy_import_mismatch.rs | 121 ++++++--------- ...red_lazy_relative_imports___init__.py.snap | 22 +++ ...red_lazy_relative_imports_mymodule.py.snap | 22 +++ .../rules/pylint/rules/manual_import_from.rs | 3 + .../rules/deprecated_c_element_tree.rs | 27 ++-- .../pyupgrade/rules/deprecated_import.rs | 19 ++- .../pyupgrade/rules/deprecated_mock_import.rs | 34 ++++- crates/ruff_python_semantic/src/binding.rs | 8 + crates/ruff_python_semantic/src/model.rs | 133 +++++++++++++++- 18 files changed, 969 insertions(+), 135 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-c-element-tree.md create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-import.md create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-mock-import.md create mode 100644 crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports___init__.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_tidy_imports/snapshots/ruff_linter__rules__flake8_tidy_imports__tests__declared_lazy_relative_imports_mymodule.py.snap 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 {