From 665f68036c33301c6d60801b309def48cb3c8894 Mon Sep 17 00:00:00 2001 From: Gautham Venkataraman <26820345+gauthsvenkat@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:30:24 +0100 Subject: [PATCH] `analyze`: Add option to skip over imports in `TYPE_CHECKING` blocks (#21472) Co-authored-by: Micha Reiser --- crates/ruff/src/args.rs | 15 ++ crates/ruff/src/commands/analyze_graph.rs | 2 + crates/ruff/tests/cli/analyze_graph.rs | 157 ++++++++++++++++++ crates/ruff/tests/cli/main.rs | 1 + ...ires_python_extend_from_shared_config.snap | 2 +- .../cli__lint__requires_python_no_tool.snap | 2 +- ...quires_python_no_tool_preview_enabled.snap | 2 +- ...ython_no_tool_target_version_override.snap | 2 +- ..._requires_python_pyproject_toml_above.snap | 2 +- ...python_pyproject_toml_above_with_tool.snap | 2 +- ...nt__requires_python_ruff_toml_above-2.snap | 1 + ...lint__requires_python_ruff_toml_above.snap | 1 + ...s_python_ruff_toml_no_target_fallback.snap | 2 +- ...ow_settings__display_default_settings.snap | 1 + crates/ruff_graph/src/collector.rs | 50 +++++- crates/ruff_graph/src/lib.rs | 9 +- crates/ruff_graph/src/settings.rs | 18 +- crates/ruff_workspace/src/configuration.rs | 6 + crates/ruff_workspace/src/options.rs | 12 ++ ruff.schema.json | 7 + 20 files changed, 282 insertions(+), 12 deletions(-) create mode 100644 crates/ruff/tests/cli/analyze_graph.rs diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index f1d38336f2..370186a0b4 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -167,6 +167,7 @@ pub enum AnalyzeCommand { } #[derive(Clone, Debug, clap::Parser)] +#[expect(clippy::struct_excessive_bools)] pub struct AnalyzeGraphCommand { /// List of files or directories to include. #[clap(help = "List of files or directories to include [default: .]")] @@ -193,6 +194,12 @@ pub struct AnalyzeGraphCommand { /// Path to a virtual environment to use for resolving additional dependencies #[arg(long)] python: Option, + /// Include imports that are only used for type checking (i.e., imports within `if TYPE_CHECKING:` blocks). + /// Use `--no-type-checking-imports` to exclude imports that are only used for type checking. + #[arg(long, overrides_with("no_type_checking_imports"))] + type_checking_imports: bool, + #[arg(long, overrides_with("type_checking_imports"), hide = true)] + no_type_checking_imports: bool, } // The `Parser` derive is for ruff_dev, for ruff `Args` would be sufficient @@ -839,6 +846,10 @@ impl AnalyzeGraphCommand { string_imports_min_dots: self.min_dots, preview: resolve_bool_arg(self.preview, self.no_preview).map(PreviewMode::from), target_version: self.target_version.map(ast::PythonVersion::from), + type_checking_imports: resolve_bool_arg( + self.type_checking_imports, + self.no_type_checking_imports, + ), ..ExplicitConfigOverrides::default() }; @@ -1335,6 +1346,7 @@ struct ExplicitConfigOverrides { extension: Option>, detect_string_imports: Option, string_imports_min_dots: Option, + type_checking_imports: Option, } impl ConfigurationTransformer for ExplicitConfigOverrides { @@ -1425,6 +1437,9 @@ impl ConfigurationTransformer for ExplicitConfigOverrides { if let Some(string_imports_min_dots) = &self.string_imports_min_dots { config.analyze.string_imports_min_dots = Some(*string_imports_min_dots); } + if let Some(type_checking_imports) = &self.type_checking_imports { + config.analyze.type_checking_imports = Some(*type_checking_imports); + } config } diff --git a/crates/ruff/src/commands/analyze_graph.rs b/crates/ruff/src/commands/analyze_graph.rs index d4085e8ed0..26a9f38133 100644 --- a/crates/ruff/src/commands/analyze_graph.rs +++ b/crates/ruff/src/commands/analyze_graph.rs @@ -105,6 +105,7 @@ pub(crate) fn analyze_graph( let settings = resolver.resolve(path); let string_imports = settings.analyze.string_imports; let include_dependencies = settings.analyze.include_dependencies.get(path).cloned(); + let type_checking_imports = settings.analyze.type_checking_imports; // Skip excluded files. if (settings.file_resolver.force_exclude || !resolved_file.is_root()) @@ -167,6 +168,7 @@ pub(crate) fn analyze_graph( &path, package.as_deref(), string_imports, + type_checking_imports, ) .unwrap_or_else(|err| { warn!("Failed to generate import map for {path}: {err}"); diff --git a/crates/ruff/tests/cli/analyze_graph.rs b/crates/ruff/tests/cli/analyze_graph.rs new file mode 100644 index 0000000000..f0edb291c7 --- /dev/null +++ b/crates/ruff/tests/cli/analyze_graph.rs @@ -0,0 +1,157 @@ +use insta_cmd::assert_cmd_snapshot; + +use crate::CliTest; + +#[test] +fn type_checking_imports() -> anyhow::Result<()> { + let test = CliTest::with_files([ + ("ruff/__init__.py", ""), + ( + "ruff/a.py", + r#" + from typing import TYPE_CHECKING + + import ruff.b + + if TYPE_CHECKING: + import ruff.c + "#, + ), + ( + "ruff/b.py", + r#" + if TYPE_CHECKING: + from ruff import c + "#, + ), + ("ruff/c.py", ""), + ])?; + + assert_cmd_snapshot!(test.analyze_graph_command(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py", + "ruff/c.py" + ], + "ruff/b.py": [ + "ruff/c.py" + ], + "ruff/c.py": [] + } + + ----- stderr ----- + "###); + + assert_cmd_snapshot!( + test.analyze_graph_command() + .arg("--no-type-checking-imports"), + @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [], + "ruff/c.py": [] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +#[test] +fn type_checking_imports_from_config() -> anyhow::Result<()> { + let test = CliTest::with_files([ + ("ruff/__init__.py", ""), + ( + "ruff/a.py", + r#" + from typing import TYPE_CHECKING + + import ruff.b + + if TYPE_CHECKING: + import ruff.c + "#, + ), + ( + "ruff/b.py", + r#" + if TYPE_CHECKING: + from ruff import c + "#, + ), + ("ruff/c.py", ""), + ( + "ruff.toml", + r#" + [analyze] + type-checking-imports = false + "#, + ), + ])?; + + assert_cmd_snapshot!(test.analyze_graph_command(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [], + "ruff/c.py": [] + } + + ----- stderr ----- + "###); + + test.write_file( + "ruff.toml", + r#" + [analyze] + type-checking-imports = true + "#, + )?; + + assert_cmd_snapshot!(test.analyze_graph_command(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py", + "ruff/c.py" + ], + "ruff/b.py": [ + "ruff/c.py" + ], + "ruff/c.py": [] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +impl CliTest { + fn analyze_graph_command(&self) -> std::process::Command { + let mut command = self.command(); + command.arg("analyze").arg("graph").arg("--preview"); + command + } +} diff --git a/crates/ruff/tests/cli/main.rs b/crates/ruff/tests/cli/main.rs index ef7313253e..9e66a71933 100644 --- a/crates/ruff/tests/cli/main.rs +++ b/crates/ruff/tests/cli/main.rs @@ -15,6 +15,7 @@ use std::{ }; use tempfile::TempDir; +mod analyze_graph; mod format; mod lint; diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap index 62bde10fe3..ce6ae89c1a 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap @@ -9,7 +9,6 @@ info: - concise - "--show-settings" - test.py -snapshot_kind: text --- success: true exit_code: 0 @@ -284,5 +283,6 @@ analyze.target_version = 3.10 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap index a7b4b2c978..a1236947bd 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap @@ -12,7 +12,6 @@ info: - UP007 - test.py - "-" -snapshot_kind: text --- success: true exit_code: 0 @@ -286,5 +285,6 @@ analyze.target_version = 3.11 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap index 4e33c123a4..5868ceb04f 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap @@ -13,7 +13,6 @@ info: - UP007 - test.py - "-" -snapshot_kind: text --- success: true exit_code: 0 @@ -288,5 +287,6 @@ analyze.target_version = 3.11 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap index 929943558e..726abc733e 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap @@ -14,7 +14,6 @@ info: - py310 - test.py - "-" -snapshot_kind: text --- success: true exit_code: 0 @@ -288,5 +287,6 @@ analyze.target_version = 3.10 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap index 291cb62d6e..db8a289004 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap @@ -11,7 +11,6 @@ info: - "--select" - UP007 - foo/test.py -snapshot_kind: text --- success: true exit_code: 0 @@ -285,5 +284,6 @@ analyze.target_version = 3.11 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap index d9f9402895..193aac85f3 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap @@ -11,7 +11,6 @@ info: - "--select" - UP007 - foo/test.py -snapshot_kind: text --- success: true exit_code: 0 @@ -285,5 +284,6 @@ analyze.target_version = 3.10 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap index da3eb66afc..e7914052c3 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap @@ -283,5 +283,6 @@ analyze.target_version = 3.10 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap index e9ca7bd400..76a57bae28 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap @@ -283,5 +283,6 @@ analyze.target_version = 3.10 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap index a3f9343314..ecdc9bfc62 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap @@ -9,7 +9,6 @@ info: - concise - test.py - "--show-settings" -snapshot_kind: text --- success: true exit_code: 0 @@ -284,5 +283,6 @@ analyze.target_version = 3.11 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index 28a6607816..94d6cbc603 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -396,5 +396,6 @@ analyze.target_version = 3.7 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} +analyze.type_checking_imports = true ----- stderr ----- diff --git a/crates/ruff_graph/src/collector.rs b/crates/ruff_graph/src/collector.rs index 49e52a95fa..e7349ae072 100644 --- a/crates/ruff_graph/src/collector.rs +++ b/crates/ruff_graph/src/collector.rs @@ -14,14 +14,21 @@ pub(crate) struct Collector<'a> { string_imports: StringImports, /// The collected imports from the Python AST. imports: Vec, + /// Whether to detect type checking imports + type_checking_imports: bool, } impl<'a> Collector<'a> { - pub(crate) fn new(module_path: Option<&'a [String]>, string_imports: StringImports) -> Self { + pub(crate) fn new( + module_path: Option<&'a [String]>, + string_imports: StringImports, + type_checking_imports: bool, + ) -> Self { Self { module_path, string_imports, imports: Vec::new(), + type_checking_imports, } } @@ -91,10 +98,25 @@ impl<'ast> SourceOrderVisitor<'ast> for Collector<'_> { } } } + Stmt::If(ast::StmtIf { + test, + body, + elif_else_clauses, + range: _, + node_index: _, + }) => { + // Skip TYPE_CHECKING blocks if not requested + if self.type_checking_imports || !is_type_checking_condition(test) { + self.visit_body(body); + } + + for clause in elif_else_clauses { + self.visit_elif_else_clause(clause); + } + } Stmt::FunctionDef(_) | Stmt::ClassDef(_) | Stmt::While(_) - | Stmt::If(_) | Stmt::With(_) | Stmt::Match(_) | Stmt::Try(_) @@ -152,6 +174,30 @@ impl<'ast> SourceOrderVisitor<'ast> for Collector<'_> { } } +/// Check if an expression is a `TYPE_CHECKING` condition. +/// +/// Returns `true` for: +/// - `TYPE_CHECKING` +/// - `typing.TYPE_CHECKING` +/// +/// NOTE: Aliased `TYPE_CHECKING`, i.e. `import typing.TYPE_CHECKING as TC; if TC: ...` +/// will not be detected! +fn is_type_checking_condition(expr: &Expr) -> bool { + match expr { + // `if TYPE_CHECKING:` + Expr::Name(ast::ExprName { id, .. }) => id.as_str() == "TYPE_CHECKING", + // `if typing.TYPE_CHECKING:` + Expr::Attribute(ast::ExprAttribute { value, attr, .. }) => { + attr.as_str() == "TYPE_CHECKING" + && matches!( + value.as_ref(), + Expr::Name(ast::ExprName { id, .. }) if id.as_str() == "typing" + ) + } + _ => false, + } +} + #[derive(Debug)] pub(crate) enum CollectedImport { /// The import was part of an `import` statement. diff --git a/crates/ruff_graph/src/lib.rs b/crates/ruff_graph/src/lib.rs index 377f1e89e9..64647c8b17 100644 --- a/crates/ruff_graph/src/lib.rs +++ b/crates/ruff_graph/src/lib.rs @@ -30,6 +30,7 @@ impl ModuleImports { path: &SystemPath, package: Option<&SystemPath>, string_imports: StringImports, + type_checking_imports: bool, ) -> Result { // Parse the source code. let parsed = parse(source, ParseOptions::from(source_type))?; @@ -38,8 +39,12 @@ impl ModuleImports { package.and_then(|package| to_module_path(package.as_std_path(), path.as_std_path())); // Collect the imports. - let imports = - Collector::new(module_path.as_deref(), string_imports).collect(parsed.syntax()); + let imports = Collector::new( + module_path.as_deref(), + string_imports, + type_checking_imports, + ) + .collect(parsed.syntax()); // Resolve the imports. let mut resolved_imports = ModuleImports::default(); diff --git a/crates/ruff_graph/src/settings.rs b/crates/ruff_graph/src/settings.rs index 23d4b07f96..45d290d7fc 100644 --- a/crates/ruff_graph/src/settings.rs +++ b/crates/ruff_graph/src/settings.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use std::fmt; use std::path::PathBuf; -#[derive(Debug, Default, Clone, CacheKey)] +#[derive(Debug, Clone, CacheKey)] pub struct AnalyzeSettings { pub exclude: FilePatternSet, pub preview: PreviewMode, @@ -14,6 +14,21 @@ pub struct AnalyzeSettings { pub string_imports: StringImports, pub include_dependencies: BTreeMap)>, pub extension: ExtensionMapping, + pub type_checking_imports: bool, +} + +impl Default for AnalyzeSettings { + fn default() -> Self { + Self { + exclude: FilePatternSet::default(), + preview: PreviewMode::default(), + target_version: PythonVersion::default(), + string_imports: StringImports::default(), + include_dependencies: BTreeMap::default(), + extension: ExtensionMapping::default(), + type_checking_imports: true, + } + } } impl fmt::Display for AnalyzeSettings { @@ -29,6 +44,7 @@ impl fmt::Display for AnalyzeSettings { self.string_imports, self.extension | debug, self.include_dependencies | debug, + self.type_checking_imports, ] } Ok(()) diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 8ca5350d11..bf50749a45 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -232,6 +232,9 @@ impl Configuration { include_dependencies: analyze .include_dependencies .unwrap_or(analyze_defaults.include_dependencies), + type_checking_imports: analyze + .type_checking_imports + .unwrap_or(analyze_defaults.type_checking_imports), }; let lint = self.lint; @@ -1277,6 +1280,7 @@ pub struct AnalyzeConfiguration { pub detect_string_imports: Option, pub string_imports_min_dots: Option, pub include_dependencies: Option)>>, + pub type_checking_imports: Option, } impl AnalyzeConfiguration { @@ -1303,6 +1307,7 @@ impl AnalyzeConfiguration { }) .collect::>() }), + type_checking_imports: options.type_checking_imports, }) } @@ -1317,6 +1322,7 @@ impl AnalyzeConfiguration { .string_imports_min_dots .or(config.string_imports_min_dots), include_dependencies: self.include_dependencies.or(config.include_dependencies), + type_checking_imports: self.type_checking_imports.or(config.type_checking_imports), } } } diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 708d6dcf0b..472b0e66f4 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -3892,6 +3892,18 @@ pub struct AnalyzeOptions { "# )] pub include_dependencies: Option>>, + /// Whether to include imports that are only used for type checking (i.e., imports within `if TYPE_CHECKING:` blocks). + /// When enabled (default), type-checking-only imports are included in the import graph. + /// When disabled, they are excluded. + #[option( + default = "true", + value_type = "bool", + example = r#" + # Exclude type-checking-only imports from the graph + type-checking-imports = false + "# + )] + pub type_checking_imports: Option, } /// Like [`LintCommonOptions`], but with any `#[serde(flatten)]` fields inlined. This leads to far, diff --git a/ruff.schema.json b/ruff.schema.json index a16e91fbd7..5ef059e122 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -818,6 +818,13 @@ ], "format": "uint", "minimum": 0 + }, + "type-checking-imports": { + "description": "Whether to include imports that are only used for type checking (i.e., imports within `if TYPE_CHECKING:` blocks).\nWhen enabled (default), type-checking-only imports are included in the import graph.\nWhen disabled, they are excluded.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false