`analyze`: Add option to skip over imports in `TYPE_CHECKING` blocks (#21472)

Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Gautham Venkataraman 2025-11-16 13:30:24 +01:00 committed by GitHub
parent f5fb5c388a
commit 665f68036c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 282 additions and 12 deletions

View File

@ -167,6 +167,7 @@ pub enum AnalyzeCommand {
} }
#[derive(Clone, Debug, clap::Parser)] #[derive(Clone, Debug, clap::Parser)]
#[expect(clippy::struct_excessive_bools)]
pub struct AnalyzeGraphCommand { pub struct AnalyzeGraphCommand {
/// List of files or directories to include. /// List of files or directories to include.
#[clap(help = "List of files or directories to include [default: .]")] #[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 /// Path to a virtual environment to use for resolving additional dependencies
#[arg(long)] #[arg(long)]
python: Option<PathBuf>, python: Option<PathBuf>,
/// 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 // 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, string_imports_min_dots: self.min_dots,
preview: resolve_bool_arg(self.preview, self.no_preview).map(PreviewMode::from), preview: resolve_bool_arg(self.preview, self.no_preview).map(PreviewMode::from),
target_version: self.target_version.map(ast::PythonVersion::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() ..ExplicitConfigOverrides::default()
}; };
@ -1335,6 +1346,7 @@ struct ExplicitConfigOverrides {
extension: Option<Vec<ExtensionPair>>, extension: Option<Vec<ExtensionPair>>,
detect_string_imports: Option<bool>, detect_string_imports: Option<bool>,
string_imports_min_dots: Option<usize>, string_imports_min_dots: Option<usize>,
type_checking_imports: Option<bool>,
} }
impl ConfigurationTransformer for ExplicitConfigOverrides { impl ConfigurationTransformer for ExplicitConfigOverrides {
@ -1425,6 +1437,9 @@ impl ConfigurationTransformer for ExplicitConfigOverrides {
if let Some(string_imports_min_dots) = &self.string_imports_min_dots { if let Some(string_imports_min_dots) = &self.string_imports_min_dots {
config.analyze.string_imports_min_dots = Some(*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 config
} }

View File

@ -105,6 +105,7 @@ pub(crate) fn analyze_graph(
let settings = resolver.resolve(path); let settings = resolver.resolve(path);
let string_imports = settings.analyze.string_imports; let string_imports = settings.analyze.string_imports;
let include_dependencies = settings.analyze.include_dependencies.get(path).cloned(); let include_dependencies = settings.analyze.include_dependencies.get(path).cloned();
let type_checking_imports = settings.analyze.type_checking_imports;
// Skip excluded files. // Skip excluded files.
if (settings.file_resolver.force_exclude || !resolved_file.is_root()) if (settings.file_resolver.force_exclude || !resolved_file.is_root())
@ -167,6 +168,7 @@ pub(crate) fn analyze_graph(
&path, &path,
package.as_deref(), package.as_deref(),
string_imports, string_imports,
type_checking_imports,
) )
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
warn!("Failed to generate import map for {path}: {err}"); warn!("Failed to generate import map for {path}: {err}");

View File

@ -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
}
}

View File

@ -15,6 +15,7 @@ use std::{
}; };
use tempfile::TempDir; use tempfile::TempDir;
mod analyze_graph;
mod format; mod format;
mod lint; mod lint;

View File

@ -9,7 +9,6 @@ info:
- concise - concise
- "--show-settings" - "--show-settings"
- test.py - test.py
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -284,5 +283,6 @@ analyze.target_version = 3.10
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -12,7 +12,6 @@ info:
- UP007 - UP007
- test.py - test.py
- "-" - "-"
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -286,5 +285,6 @@ analyze.target_version = 3.11
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -13,7 +13,6 @@ info:
- UP007 - UP007
- test.py - test.py
- "-" - "-"
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -288,5 +287,6 @@ analyze.target_version = 3.11
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -14,7 +14,6 @@ info:
- py310 - py310
- test.py - test.py
- "-" - "-"
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -288,5 +287,6 @@ analyze.target_version = 3.10
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -11,7 +11,6 @@ info:
- "--select" - "--select"
- UP007 - UP007
- foo/test.py - foo/test.py
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -285,5 +284,6 @@ analyze.target_version = 3.11
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -11,7 +11,6 @@ info:
- "--select" - "--select"
- UP007 - UP007
- foo/test.py - foo/test.py
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -285,5 +284,6 @@ analyze.target_version = 3.10
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -283,5 +283,6 @@ analyze.target_version = 3.10
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -283,5 +283,6 @@ analyze.target_version = 3.10
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -9,7 +9,6 @@ info:
- concise - concise
- test.py - test.py
- "--show-settings" - "--show-settings"
snapshot_kind: text
--- ---
success: true success: true
exit_code: 0 exit_code: 0
@ -284,5 +283,6 @@ analyze.target_version = 3.11
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -396,5 +396,6 @@ analyze.target_version = 3.7
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}
analyze.type_checking_imports = true
----- stderr ----- ----- stderr -----

View File

@ -14,14 +14,21 @@ pub(crate) struct Collector<'a> {
string_imports: StringImports, string_imports: StringImports,
/// The collected imports from the Python AST. /// The collected imports from the Python AST.
imports: Vec<CollectedImport>, imports: Vec<CollectedImport>,
/// Whether to detect type checking imports
type_checking_imports: bool,
} }
impl<'a> Collector<'a> { 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 { Self {
module_path, module_path,
string_imports, string_imports,
imports: Vec::new(), 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::FunctionDef(_)
| Stmt::ClassDef(_) | Stmt::ClassDef(_)
| Stmt::While(_) | Stmt::While(_)
| Stmt::If(_)
| Stmt::With(_) | Stmt::With(_)
| Stmt::Match(_) | Stmt::Match(_)
| Stmt::Try(_) | 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)] #[derive(Debug)]
pub(crate) enum CollectedImport { pub(crate) enum CollectedImport {
/// The import was part of an `import` statement. /// The import was part of an `import` statement.

View File

@ -30,6 +30,7 @@ impl ModuleImports {
path: &SystemPath, path: &SystemPath,
package: Option<&SystemPath>, package: Option<&SystemPath>,
string_imports: StringImports, string_imports: StringImports,
type_checking_imports: bool,
) -> Result<Self> { ) -> Result<Self> {
// Parse the source code. // Parse the source code.
let parsed = parse(source, ParseOptions::from(source_type))?; 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())); package.and_then(|package| to_module_path(package.as_std_path(), path.as_std_path()));
// Collect the imports. // Collect the imports.
let imports = let imports = Collector::new(
Collector::new(module_path.as_deref(), string_imports).collect(parsed.syntax()); module_path.as_deref(),
string_imports,
type_checking_imports,
)
.collect(parsed.syntax());
// Resolve the imports. // Resolve the imports.
let mut resolved_imports = ModuleImports::default(); let mut resolved_imports = ModuleImports::default();

View File

@ -6,7 +6,7 @@ use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Default, Clone, CacheKey)] #[derive(Debug, Clone, CacheKey)]
pub struct AnalyzeSettings { pub struct AnalyzeSettings {
pub exclude: FilePatternSet, pub exclude: FilePatternSet,
pub preview: PreviewMode, pub preview: PreviewMode,
@ -14,6 +14,21 @@ pub struct AnalyzeSettings {
pub string_imports: StringImports, pub string_imports: StringImports,
pub include_dependencies: BTreeMap<PathBuf, (PathBuf, Vec<String>)>, pub include_dependencies: BTreeMap<PathBuf, (PathBuf, Vec<String>)>,
pub extension: ExtensionMapping, 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 { impl fmt::Display for AnalyzeSettings {
@ -29,6 +44,7 @@ impl fmt::Display for AnalyzeSettings {
self.string_imports, self.string_imports,
self.extension | debug, self.extension | debug,
self.include_dependencies | debug, self.include_dependencies | debug,
self.type_checking_imports,
] ]
} }
Ok(()) Ok(())

View File

@ -232,6 +232,9 @@ impl Configuration {
include_dependencies: analyze include_dependencies: analyze
.include_dependencies .include_dependencies
.unwrap_or(analyze_defaults.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; let lint = self.lint;
@ -1277,6 +1280,7 @@ pub struct AnalyzeConfiguration {
pub detect_string_imports: Option<bool>, pub detect_string_imports: Option<bool>,
pub string_imports_min_dots: Option<usize>, pub string_imports_min_dots: Option<usize>,
pub include_dependencies: Option<BTreeMap<PathBuf, (PathBuf, Vec<String>)>>, pub include_dependencies: Option<BTreeMap<PathBuf, (PathBuf, Vec<String>)>>,
pub type_checking_imports: Option<bool>,
} }
impl AnalyzeConfiguration { impl AnalyzeConfiguration {
@ -1303,6 +1307,7 @@ impl AnalyzeConfiguration {
}) })
.collect::<BTreeMap<_, _>>() .collect::<BTreeMap<_, _>>()
}), }),
type_checking_imports: options.type_checking_imports,
}) })
} }
@ -1317,6 +1322,7 @@ impl AnalyzeConfiguration {
.string_imports_min_dots .string_imports_min_dots
.or(config.string_imports_min_dots), .or(config.string_imports_min_dots),
include_dependencies: self.include_dependencies.or(config.include_dependencies), include_dependencies: self.include_dependencies.or(config.include_dependencies),
type_checking_imports: self.type_checking_imports.or(config.type_checking_imports),
} }
} }
} }

View File

@ -3892,6 +3892,18 @@ pub struct AnalyzeOptions {
"# "#
)] )]
pub include_dependencies: Option<BTreeMap<PathBuf, Vec<String>>>, pub include_dependencies: Option<BTreeMap<PathBuf, Vec<String>>>,
/// 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<bool>,
} }
/// Like [`LintCommonOptions`], but with any `#[serde(flatten)]` fields inlined. This leads to far, /// Like [`LintCommonOptions`], but with any `#[serde(flatten)]` fields inlined. This leads to far,

7
ruff.schema.json generated
View File

@ -818,6 +818,13 @@
], ],
"format": "uint", "format": "uint",
"minimum": 0 "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 "additionalProperties": false