From 0f57f47ea4411d97c21b61c9a200a970a44a7774 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Wed, 9 Sep 2026 10:29:17 -0400 Subject: [PATCH 01/12] Add support for `__lazy_modules__` Summary -- 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 From dafb8b837a913585013c81981533ae0fd672fab3 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:15:26 -0400 Subject: [PATCH 02/12] Track module-level __lazy_modules__ assignments --- crates/ruff_linter/src/checkers/ast/mod.rs | 21 +++++++++++++++++++++ crates/ruff_python_semantic/src/model.rs | 12 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 7b77bde0cb3564..add734ffd94265 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -2839,6 +2839,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.lazy_modules = Some(value); + } + Stmt::AnnAssign(ast::StmtAnnAssign { + target, + value: Some(value), + .. + }) if let Expr::Name(name) = target.as_ref() + && name.id == id => + { + self.semantic.lazy_modules = Some(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_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 57844a87762cce..4646a32b472e68 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -154,6 +154,17 @@ pub struct SemanticModel<'a> { /// Modules that have been seen by the semantic model. pub seen: Modules, + /// The value of 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<&'a Expr>, + /// Exceptions that are handled by the current `try` block. /// /// For example, if we're visiting the `x = 1` assignment below, @@ -208,6 +219,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(), }; From 4d577603113a9fd1102d9f4588ce9fffeb161cdc Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:17:12 -0400 Subject: [PATCH 03/12] Recognize module-declared lazy imports in TID254 --- .../lazy-import-mismatch.md | 142 +++++++++++++++++- .../rules/lazy_import_immediately_resolved.rs | 2 +- .../rules/lazy_import_mismatch.rs | 119 ++++++--------- crates/ruff_python_semantic/src/model.rs | 81 +++++++++- 4 files changed, 265 insertions(+), 79 deletions(-) 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 0d20e53059d11e..f6369521f4f685 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] +# error: [lazy-import-mismatch] +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/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 85b93a96e4e084..9496b7332ffcca 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 @@ -147,7 +147,7 @@ fn lazy_import_statement<'a>(binding: &Binding, semantic: &SemanticModel<'a>) -> } /// 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 61b750163f2592..a27e6b5540d983 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,11 @@ 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. +/// /// 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 +44,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 +99,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 +145,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_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 4646a32b472e68..8d0799db465ee7 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}; @@ -2408,6 +2409,82 @@ 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 { + let Some(value) = self.lazy_modules else { + return ImportLaziness::Eager; + }; + let (Expr::List(ast::ExprList { elts: elements, .. }) + | Expr::Tuple(ast::ExprTuple { elts: elements, .. }) + | Expr::Set(ast::ExprSet { elts: elements, .. })) = value + else { + return ImportLaziness::Unknown; + }; + if !elements.iter().all(Expr::is_string_literal_expr) { + return ImportLaziness::Unknown; + } + if elements.iter().any(|element| { + element + .as_string_literal_expr() + .is_some_and(|literal| literal.value.to_str() == module) + }) { + ImportLaziness::Lazy + } else { + ImportLaziness::Eager + } + } + + /// 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, + } + } +} + +/// 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, } pub struct ShadowedBinding { From 0b0d49aec52244bb2c45a131ae85d64b374bf694 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:18:29 -0400 Subject: [PATCH 04/12] Remember effective import laziness for TID255 --- .../lazy-import-immediately-resolved.md | 86 +++++++++++++++++++ crates/ruff_linter/src/checkers/ast/mod.rs | 15 +++- .../rules/lazy_import_immediately_resolved.rs | 54 ++++++------ crates/ruff_python_semantic/src/binding.rs | 8 ++ crates/ruff_python_semantic/src/model.rs | 7 ++ 5 files changed, 139 insertions(+), 31 deletions(-) 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 6214a05b8df9ff..8704bef123e009 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/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index add734ffd94265..7198cdf6362f6d 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -1094,6 +1094,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 +1109,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 +1174,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; } 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 9496b7332ffcca..a354f7217851f2 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,10 @@ 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. +/// /// ## Example /// ```python /// lazy import foo @@ -39,15 +43,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 +84,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 +95,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 +124,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,24 +142,6 @@ 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. fn is_single_member_import(stmt: &Stmt) -> bool { match stmt { diff --git a/crates/ruff_python_semantic/src/binding.rs b/crates/ruff_python_semantic/src/binding.rs index 83d42a20f2542f..e146b6032e2eb3 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 8d0799db465ee7..b68c386cd639b3 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -2487,6 +2487,13 @@ pub enum ImportLaziness { 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 { /// The binding that is shadowing another binding. binding_id: BindingId, From e0bed850e6bea310672675359807ace3c566bd35 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:21:42 -0400 Subject: [PATCH 05/12] Preserve module-declared laziness in PLR0402 fixes --- .../mdtest/pylint/manual-from-import.md | 139 ++++++++++++++++++ crates/ruff_linter/src/checkers/ast/mod.rs | 19 ++- .../rules/pylint/rules/manual_import_from.rs | 3 + 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md 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 00000000000000..715a9b627d6ff5 --- /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/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 7198cdf6362f6d..4cb6d8287d0445 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,21 @@ 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() { + 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 { 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 3334fefa31f475..2a9b5d51a7484c 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 { From 0b4d67de773c50878c756000640cca0ab76c4150 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:22:16 -0400 Subject: [PATCH 06/12] Preserve module-declared laziness in UP023 fixes --- .../pyupgrade/deprecated-c-element-tree.md | 75 +++++++++++++++++++ crates/ruff_linter/src/checkers/ast/mod.rs | 5 +- .../rules/deprecated_c_element_tree.rs | 27 ++++--- 3 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-c-element-tree.md 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 00000000000000..8129f7a8e07515 --- /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/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 4cb6d8287d0445..24c0d7380ab659 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -363,7 +363,10 @@ impl<'a> Checker<'a> { /// 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() { + 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!( 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 dfcd64f88b21d7..8ecac83a42f551 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); } } } From b58614122f0a9c2b1bd172073532f59a64f671e3 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:22:38 -0400 Subject: [PATCH 07/12] Preserve module-declared laziness in UP026 fixes --- .../pyupgrade/deprecated-mock-import.md | 64 +++++++++++++++++++ .../pyupgrade/rules/deprecated_mock_import.rs | 34 +++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-mock-import.md 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 00000000000000..213b8adf1ae55f --- /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/rules/pyupgrade/rules/deprecated_mock_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_mock_import.rs index 30f5cc2fac4e17..7d5539063509cc 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())) From e30f6a48467d7725fafd9fad6e477c677e2fe634 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Tue, 8 Sep 2026 17:23:11 -0400 Subject: [PATCH 08/12] Preserve laziness in UP035 import replacements --- .../mdtest/pyupgrade/deprecated-import.md | 52 +++++++++++++++++++ .../pyupgrade/rules/deprecated_import.rs | 19 +++++-- 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 crates/ruff_linter/resources/mdtest/pyupgrade/deprecated-import.md 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 00000000000000..0584a5a3418cd7 --- /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/src/rules/pyupgrade/rules/deprecated_import.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs index 8cd4b12f13a5e2..2245a4d74d6faf 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs @@ -645,7 +645,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 @@ -675,7 +675,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, @@ -725,7 +725,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 @@ -735,7 +735,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}") } } @@ -771,6 +776,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), @@ -778,7 +785,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(), From 3b97118c8a625e175668418bd02a0e464634e848 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Thu, 10 Sep 2026 09:15:11 -0400 Subject: [PATCH 09/12] assert on messages --- .../mdtest/flake8-tidy-imports/lazy-import-mismatch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f6369521f4f685..b9106227bddd78 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 @@ -166,8 +166,8 @@ ban-lazy = ["json"] ```py __lazy_modules__ = ["json"] -# error: [lazy-import-mismatch] -# error: [lazy-import-mismatch] +# error: [lazy-import-mismatch] "`json` should be imported eagerly" +# error: [lazy-import-mismatch] "`pathlib` should be imported lazily" import json, pathlib ``` From d3e4552279e917c1eebe6c928e3df8e2121abb98 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Thu, 10 Sep 2026 09:22:48 -0400 Subject: [PATCH 10/12] document dynamic assignment limitation --- .../rules/lazy_import_immediately_resolved.rs | 4 +++- .../rules/flake8_tidy_imports/rules/lazy_import_mismatch.rs | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 a354f7217851f2..6892662f253c30 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 @@ -25,7 +25,9 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// 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. +/// 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 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 a27e6b5540d983..529c240ea9ba31 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 @@ -22,8 +22,10 @@ use crate::{Edit, Fix, FixAvailability, Violation}; /// /// 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. +/// 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 dd77ba8824f3378d5d9b622b4f7ff87d8c4de7e7 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Thu, 10 Sep 2026 09:37:47 -0400 Subject: [PATCH 11/12] add relative import tests --- .../src/rules/flake8_tidy_imports/mod.rs | 41 +++++++++++++++++++ ...red_lazy_relative_imports___init__.py.snap | 22 ++++++++++ ...red_lazy_relative_imports_mymodule.py.snap | 22 ++++++++++ 3 files changed, 85 insertions(+) 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/src/rules/flake8_tidy_imports/mod.rs b/crates/ruff_linter/src/rules/flake8_tidy_imports/mod.rs index 9106f37320f1aa..fbbf52d375c183 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/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 00000000000000..0b500871957e2a --- /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 00000000000000..aea647eee3b72d --- /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() + | From dd6cbf2e88cf71c36f3b0ad71b920248dacf2bdf Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Thu, 10 Sep 2026 09:55:25 -0400 Subject: [PATCH 12/12] eagerly process lazy modules --- crates/ruff_linter/src/checkers/ast/mod.rs | 4 +- crates/ruff_python_semantic/src/model.rs | 77 +++++++++++++++------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 24c0d7380ab659..9a2ebb58601b6f 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -2874,7 +2874,7 @@ impl<'a> Checker<'a> { if let [Expr::Name(name)] = targets.as_slice() && name.id == id => { - self.semantic.lazy_modules = Some(value); + self.semantic.set_lazy_modules(value); } Stmt::AnnAssign(ast::StmtAnnAssign { target, @@ -2883,7 +2883,7 @@ impl<'a> Checker<'a> { }) if let Expr::Name(name) = target.as_ref() && name.id == id => { - self.semantic.lazy_modules = Some(value); + self.semantic.set_lazy_modules(value); } _ => {} } diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index b68c386cd639b3..64ebceb84c2490 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -155,7 +155,7 @@ pub struct SemanticModel<'a> { /// Modules that have been seen by the semantic model. pub seen: Modules, - /// The value of the most recently visited module-level `__lazy_modules__` assignment. + /// The module names from the most recently visited module-level `__lazy_modules__` assignment. /// /// A declaration affects subsequent imports, without changing earlier imports: /// @@ -164,7 +164,7 @@ pub struct SemanticModel<'a> { /// __lazy_modules__ = ["json", "pathlib"] /// import pathlib # Lazy. /// ``` - pub lazy_modules: Option<&'a Expr>, + pub lazy_modules: Option>, /// Exceptions that are handled by the current `try` block. /// @@ -2433,29 +2433,37 @@ impl<'a> SemanticModel<'a> { /// 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 { - let Some(value) = self.lazy_modules else { - return ImportLaziness::Eager; - }; - let (Expr::List(ast::ExprList { elts: elements, .. }) - | Expr::Tuple(ast::ExprTuple { elts: elements, .. }) - | Expr::Set(ast::ExprSet { elts: elements, .. })) = value - else { - return ImportLaziness::Unknown; - }; - if !elements.iter().all(Expr::is_string_literal_expr) { - return ImportLaziness::Unknown; - } - if elements.iter().any(|element| { - element - .as_string_literal_expr() - .is_some_and(|literal| literal.value.to_str() == module) - }) { - ImportLaziness::Lazy - } else { - ImportLaziness::Eager + 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>( @@ -2477,6 +2485,31 @@ impl<'a> SemanticModel<'a> { } } +/// 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.