Skip to content

Commit 8fcac0f

Browse files
Recognize all symbols named TYPE_CHECKING for in_type_checking_block (#15719)
Closes #15681 ## Summary This changes `analyze::typing::is_type_checking_block` to recognize all symbols named "TYPE_CHECKING". This matches the current behavior of mypy and pyright as well as `flake8-type-checking`. It also drops support for detecting `if False:` and `if 0:` as type checking blocks. This used to be an option for providing backwards compatibility with Python versions that did not have a `typing` module, but has since been removed from the typing spec and is no longer supported by any of the mainstream type checkers. ## Test Plan `cargo nextest run` --------- Co-authored-by: Micha Reiser <[email protected]>
1 parent 81059d0 commit 8fcac0f

File tree

8 files changed

+192
-15
lines changed

8 files changed

+192
-15
lines changed

crates/ruff_linter/src/checkers/ast/mod.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ impl<'a> Checker<'a> {
248248
cell_offsets: Option<&'a CellOffsets>,
249249
notebook_index: Option<&'a NotebookIndex>,
250250
) -> Checker<'a> {
251+
let mut semantic = SemanticModel::new(&settings.typing_modules, path, module);
252+
if settings.preview.is_enabled() {
253+
// Set the feature flag to test `TYPE_CHECKING` semantic changes
254+
semantic.flags |= SemanticModelFlags::NEW_TYPE_CHECKING_BLOCK_DETECTION;
255+
}
251256
Self {
252257
parsed,
253258
parsed_type_annotation: None,
@@ -263,7 +268,7 @@ impl<'a> Checker<'a> {
263268
stylist,
264269
indexer,
265270
importer: Importer::new(parsed, locator, stylist),
266-
semantic: SemanticModel::new(&settings.typing_modules, path, module),
271+
semantic,
267272
visit: deferred::Visit::default(),
268273
analyze: deferred::Analyze::default(),
269274
diagnostics: Vec::default(),

crates/ruff_linter/src/importer/mod.rs

+56-14
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use anyhow::Result;
99
use libcst_native::{ImportAlias, Name as cstName, NameOrAttribute};
1010

1111
use ruff_diagnostics::Edit;
12-
use ruff_python_ast::{self as ast, ModModule, Stmt};
12+
use ruff_python_ast::{self as ast, Expr, ModModule, Stmt};
1313
use ruff_python_codegen::Stylist;
1414
use ruff_python_parser::{Parsed, Tokens};
1515
use ruff_python_semantic::{
@@ -125,7 +125,7 @@ impl<'a> Importer<'a> {
125125
&self,
126126
import: &ImportedMembers,
127127
at: TextSize,
128-
semantic: &SemanticModel,
128+
semantic: &SemanticModel<'a>,
129129
) -> Result<TypingImportEdit> {
130130
// Generate the modified import statement.
131131
let content = fix::codemods::retain_imports(
@@ -135,6 +135,39 @@ impl<'a> Importer<'a> {
135135
self.stylist,
136136
)?;
137137

138+
// Add the import to an existing `TYPE_CHECKING` block.
139+
if let Some(block) = self.preceding_type_checking_block(at) {
140+
// Add the import to the existing `TYPE_CHECKING` block.
141+
let type_checking_edit =
142+
if let Some(statement) = Self::type_checking_binding_statement(semantic, block) {
143+
if statement == import.statement {
144+
// Special-case: if the `TYPE_CHECKING` symbol is imported as part of the same
145+
// statement that we're modifying, avoid adding a no-op edit. For example, here,
146+
// the `TYPE_CHECKING` no-op edit would overlap with the edit to remove `Final`
147+
// from the import:
148+
// ```python
149+
// from __future__ import annotations
150+
//
151+
// from typing import Final, TYPE_CHECKING
152+
//
153+
// Const: Final[dict] = {}
154+
// ```
155+
None
156+
} else {
157+
Some(Edit::range_replacement(
158+
self.locator.slice(statement.range()).to_string(),
159+
statement.range(),
160+
))
161+
}
162+
} else {
163+
None
164+
};
165+
return Ok(TypingImportEdit {
166+
type_checking_edit,
167+
add_import_edit: self.add_to_type_checking_block(&content, block.start()),
168+
});
169+
}
170+
138171
// Import the `TYPE_CHECKING` symbol from the typing module.
139172
let (type_checking_edit, type_checking) =
140173
if let Some(type_checking) = Self::find_type_checking(at, semantic)? {
@@ -179,27 +212,36 @@ impl<'a> Importer<'a> {
179212
(Some(edit), name)
180213
};
181214

182-
// Add the import to a `TYPE_CHECKING` block.
183-
let add_import_edit = if let Some(block) = self.preceding_type_checking_block(at) {
184-
// Add the import to the `TYPE_CHECKING` block.
185-
self.add_to_type_checking_block(&content, block.start())
186-
} else {
187-
// Add the import to a new `TYPE_CHECKING` block.
188-
self.add_type_checking_block(
215+
// Add the import to a new `TYPE_CHECKING` block.
216+
Ok(TypingImportEdit {
217+
type_checking_edit,
218+
add_import_edit: self.add_type_checking_block(
189219
&format!(
190220
"{}if {type_checking}:{}{}",
191221
self.stylist.line_ending().as_str(),
192222
self.stylist.line_ending().as_str(),
193223
indent(&content, self.stylist.indentation())
194224
),
195225
at,
196-
)?
226+
)?,
227+
})
228+
}
229+
230+
fn type_checking_binding_statement(
231+
semantic: &SemanticModel<'a>,
232+
type_checking_block: &Stmt,
233+
) -> Option<&'a Stmt> {
234+
let Stmt::If(ast::StmtIf { test, .. }) = type_checking_block else {
235+
return None;
197236
};
198237

199-
Ok(TypingImportEdit {
200-
type_checking_edit,
201-
add_import_edit,
202-
})
238+
let mut source = test;
239+
while let Expr::Attribute(ast::ExprAttribute { value, .. }) = source.as_ref() {
240+
source = value;
241+
}
242+
semantic
243+
.binding(semantic.resolve_name(source.as_name_expr()?)?)
244+
.statement(semantic)
203245
}
204246

205247
/// Find a reference to `typing.TYPE_CHECKING`.

crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__preview__SIM108_SIM108.py.snap

+27
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,33 @@ SIM108.py:167:1: SIM108 [*] Use ternary operator `z = 1 if True else other` inst
226226
172 169 | if False:
227227
173 170 | z = 1
228228

229+
SIM108.py:172:1: SIM108 [*] Use ternary operator `z = 1 if False else other` instead of `if`-`else`-block
230+
|
231+
170 | z = other
232+
171 |
233+
172 | / if False:
234+
173 | | z = 1
235+
174 | | else:
236+
175 | | z = other
237+
| |_____________^ SIM108
238+
176 |
239+
177 | if 1:
240+
|
241+
= help: Replace `if`-`else`-block with `z = 1 if False else other`
242+
243+
Unsafe fix
244+
169 169 | else:
245+
170 170 | z = other
246+
171 171 |
247+
172 |-if False:
248+
173 |- z = 1
249+
174 |-else:
250+
175 |- z = other
251+
172 |+z = 1 if False else other
252+
176 173 |
253+
177 174 | if 1:
254+
178 175 | z = True
255+
229256
SIM108.py:177:1: SIM108 [*] Use ternary operator `z = True if 1 else other` instead of `if`-`else`-block
230257
|
231258
175 | z = other

crates/ruff_linter/src/rules/flake8_type_checking/mod.rs

+37
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,41 @@ mod tests {
524524
);
525525
assert_messages!(snapshot, diagnostics);
526526
}
527+
528+
#[test_case(
529+
r"
530+
from __future__ import annotations
531+
532+
TYPE_CHECKING = False
533+
if TYPE_CHECKING:
534+
from types import TracebackType
535+
536+
def foo(tb: TracebackType): ...
537+
",
538+
"github_issue_15681_regression_test"
539+
)]
540+
#[test_case(
541+
r"
542+
from __future__ import annotations
543+
544+
import pathlib # TC003
545+
546+
TYPE_CHECKING = False
547+
if TYPE_CHECKING:
548+
from types import TracebackType
549+
550+
def foo(tb: TracebackType) -> pathlib.Path: ...
551+
",
552+
"github_issue_15681_fix_test"
553+
)]
554+
fn contents_preview(contents: &str, snapshot: &str) {
555+
let diagnostics = test_snippet(
556+
contents,
557+
&settings::LinterSettings {
558+
preview: settings::types::PreviewMode::Enabled,
559+
..settings::LinterSettings::for_rules(Linter::Flake8TypeChecking.rules())
560+
},
561+
);
562+
assert_messages!(snapshot, diagnostics);
563+
}
527564
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+
<filename>:4:8: TC003 [*] Move standard library import `pathlib` into a type-checking block
5+
|
6+
2 | from __future__ import annotations
7+
3 |
8+
4 | import pathlib # TC003
9+
| ^^^^^^^ TC003
10+
5 |
11+
6 | TYPE_CHECKING = False
12+
|
13+
= help: Move into type-checking block
14+
15+
ℹ Unsafe fix
16+
1 1 |
17+
2 2 | from __future__ import annotations
18+
3 3 |
19+
4 |-import pathlib # TC003
20+
5 4 |
21+
6 5 | TYPE_CHECKING = False
22+
7 6 | if TYPE_CHECKING:
23+
7 |+ import pathlib
24+
8 8 | from types import TracebackType
25+
9 9 |
26+
10 10 | def foo(tb: TracebackType) -> pathlib.Path: ...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+

crates/ruff_python_semantic/src/analyze/typing.rs

+16
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,22 @@ pub fn is_mutable_expr(expr: &Expr, semantic: &SemanticModel) -> bool {
382382
pub fn is_type_checking_block(stmt: &ast::StmtIf, semantic: &SemanticModel) -> bool {
383383
let ast::StmtIf { test, .. } = stmt;
384384

385+
if semantic.use_new_type_checking_block_detection_semantics() {
386+
return match test.as_ref() {
387+
// As long as the symbol's name is "TYPE_CHECKING" we will treat it like `typing.TYPE_CHECKING`
388+
// for this specific check even if it's defined somewhere else, like the current module.
389+
// Ex) `if TYPE_CHECKING:`
390+
Expr::Name(ast::ExprName { id, .. }) => {
391+
id == "TYPE_CHECKING"
392+
// Ex) `if TC:` with `from typing import TYPE_CHECKING as TC`
393+
|| semantic.match_typing_expr(test, "TYPE_CHECKING")
394+
}
395+
// Ex) `if typing.TYPE_CHECKING:`
396+
Expr::Attribute(ast::ExprAttribute { attr, .. }) => attr == "TYPE_CHECKING",
397+
_ => false,
398+
};
399+
}
400+
385401
// Ex) `if False:`
386402
if is_const_false(test) {
387403
return true;

crates/ruff_python_semantic/src/model.rs

+20
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,18 @@ impl<'a> SemanticModel<'a> {
20142014
.intersects(SemanticModelFlags::DEFERRED_CLASS_BASE)
20152015
}
20162016

2017+
/// Return `true` if we should use the new semantics to recognize
2018+
/// type checking blocks. Previously we only recognized type checking
2019+
/// blocks if `TYPE_CHECKING` was imported from a typing module.
2020+
///
2021+
/// With this feature flag enabled we recognize any symbol named
2022+
/// `TYPE_CHECKING`, regardless of where it comes from to mirror
2023+
/// what mypy and pyright do.
2024+
pub const fn use_new_type_checking_block_detection_semantics(&self) -> bool {
2025+
self.flags
2026+
.intersects(SemanticModelFlags::NEW_TYPE_CHECKING_BLOCK_DETECTION)
2027+
}
2028+
20172029
/// Return an iterator over all bindings shadowed by the given [`BindingId`], within the
20182030
/// containing scope, and across scopes.
20192031
pub fn shadowed_bindings(
@@ -2545,6 +2557,14 @@ bitflags! {
25452557
/// [#13824]: https://github.com/astral-sh/ruff/issues/13824
25462558
const NO_TYPE_CHECK = 1 << 30;
25472559

2560+
/// The model special-cases any symbol named `TYPE_CHECKING`.
2561+
///
2562+
/// Previously we only recognized `TYPE_CHECKING` if it was part of
2563+
/// one of the configured `typing` modules. This flag exists to
2564+
/// test out the semantic change only in preview. This flag will go
2565+
/// away once this change has been stabilized.
2566+
const NEW_TYPE_CHECKING_BLOCK_DETECTION = 1 << 31;
2567+
25482568
/// The context is in any type annotation.
25492569
const ANNOTATION = Self::TYPING_ONLY_ANNOTATION.bits() | Self::RUNTIME_EVALUATED_ANNOTATION.bits() | Self::RUNTIME_REQUIRED_ANNOTATION.bits();
25502570

0 commit comments

Comments
 (0)