Skip to content

Commit 6591775

Browse files
[flake8-type-checking] Skip quoting annotation if it becomes invalid syntax (TCH001) (#14285)
Fix: #13934 ## Summary Current implementation has a bug when the current annotation contains a string with single and double quotes. TL;DR: I think these cases happen less than other use cases of Literal. So instead of fixing them we skip the fix in those cases. One of the problematic cases: ``` from typing import Literal from third_party import Type def error(self, type1: Type[Literal["'"]]): pass ``` The outcome is: ``` - def error(self, type1: Type[Literal["'"]]): + def error(self, type1: "Type[Literal[''']]"): ``` While it should be: ``` "Type[Literal['\'']" ``` The solution in this case is that we check if there’s any quotes same as the quote style we want to use for this Literal parameter then escape that same quote used in the string. Also this case is not uncommon to have: <https://grep.app/search?current=2&q=Literal["'> But this can get more complicated for example in case of: ``` - def error(self, type1: Type[Literal["\'"]]): + def error(self, type1: "Type[Literal[''']]"): ``` Here we escaped the inner quote but in the generated annotation it gets removed. Then we flip the quote style of the Literal paramter and the formatting is wrong. In this case the solution is more complicated. 1. When generating the string of the source code preserve the backslash. 2. After we have the annotation check if there isn’t any escaped quote of the same type we want to use for the Literal parameter. In this case check if we have any `’` without `\` before them. This can get more complicated since there can be multiple backslashes so checking for only `\’` won’t be enough. Another problem is when the string contains `\n`. In case of `Type[Literal["\n"]]` we generate `'Type[Literal["\n"]]'` and both pyright and mypy reject this annotation. https://pyright-play.net/?code=GYJw9gtgBALgngBwJYDsDmUkQWEMoAySMApiAIYA2AUAMaXkDOjUAKoiQNqsC6AXFAB0w6tQAmJYLBKMYAfQCOAVzCk5tMChjlUjOQCNytANaMGjABYAKRiUrAANLA4BGAQHJ2CLkVIVKnABEADoogTw87gCUfNRQ8VAITIyiElKksooqahpaOih6hiZmTNa29k7w3m5sHJy%2BZFRBoeE8MXEJScxAA ## Test Plan I added test cases for the original code in the reported issue and two more cases for backslash and new line. --------- Co-authored-by: Dhruv Manilawala <[email protected]>
1 parent 1f82731 commit 6591775

5 files changed

+72
-14
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote3.py

+15
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,18 @@ def f():
5353
def test_annotated_non_typing_reference(user: Annotated[str, Depends(get_foo)]):
5454
pass
5555

56+
57+
def f():
58+
from typing import Literal
59+
from third_party import Type
60+
61+
def test_string_contains_opposite_quote_do_not_fix(self, type1: Type[Literal["'"]], type2: Type[Literal["\'"]]):
62+
pass
63+
64+
65+
def f():
66+
from typing import Literal
67+
from third_party import Type
68+
69+
def test_quote_contains_backslash(self, type1: Type[Literal["\n"]], type2: Type[Literal["\""]]):
70+
pass

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

+32-13
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
use anyhow::Result;
2-
use ast::visitor::source_order;
3-
use ruff_python_ast::visitor::source_order::SourceOrderVisitor;
41
use std::cmp::Reverse;
52

3+
use anyhow::Result;
4+
65
use ruff_diagnostics::Edit;
76
use ruff_python_ast::helpers::{map_callable, map_subscript};
87
use ruff_python_ast::name::QualifiedName;
8+
use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal};
99
use ruff_python_ast::{self as ast, Decorator, Expr};
1010
use ruff_python_codegen::{Generator, Stylist};
1111
use ruff_python_semantic::{
@@ -221,8 +221,8 @@ pub(crate) fn is_singledispatch_implementation(
221221
/// This requires more than just wrapping the reference itself in quotes. For example:
222222
/// - When quoting `Series` in `Series[pd.Timestamp]`, we want `"Series[pd.Timestamp]"`.
223223
/// - When quoting `kubernetes` in `kubernetes.SecurityContext`, we want `"kubernetes.SecurityContext"`.
224-
/// - When quoting `Series` in `Series["pd.Timestamp"]`, we want `"Series[pd.Timestamp]"`. (This is currently unsupported.)
225-
/// - When quoting `Series` in `Series[Literal["pd.Timestamp"]]`, we want `"Series[Literal['pd.Timestamp']]"`. (This is currently unsupported.)
224+
/// - When quoting `Series` in `Series["pd.Timestamp"]`, we want `"Series[pd.Timestamp]"`.
225+
/// - When quoting `Series` in `Series[Literal["pd.Timestamp"]]`, we want `"Series[Literal['pd.Timestamp']]"`.
226226
///
227227
/// In general, when expanding a component of a call chain, we want to quote the entire call chain.
228228
pub(crate) fn quote_annotation(
@@ -272,7 +272,7 @@ pub(crate) fn quote_annotation(
272272
let quote = stylist.quote();
273273
let mut quote_annotator = QuoteAnnotator::new(semantic, stylist);
274274
quote_annotator.visit_expr(expr);
275-
let annotation = quote_annotator.into_annotation();
275+
let annotation = quote_annotator.into_annotation()?;
276276

277277
Ok(Edit::range_replacement(
278278
format!("{quote}{annotation}{quote}"),
@@ -313,6 +313,7 @@ pub(crate) struct QuoteAnnotator<'a> {
313313
semantic: &'a SemanticModel<'a>,
314314
state: Vec<QuoteAnnotatorState>,
315315
annotation: String,
316+
cannot_fix: bool,
316317
}
317318

318319
impl<'a> QuoteAnnotator<'a> {
@@ -322,15 +323,30 @@ impl<'a> QuoteAnnotator<'a> {
322323
semantic,
323324
state: Vec::new(),
324325
annotation: String::new(),
326+
cannot_fix: false,
325327
}
326328
}
327329

328-
fn into_annotation(self) -> String {
329-
self.annotation
330+
fn into_annotation(self) -> Result<String> {
331+
if self.cannot_fix {
332+
Err(anyhow::anyhow!(
333+
"Cannot quote annotation because it already contains opposite quote or escape character"
334+
))
335+
} else {
336+
Ok(self.annotation)
337+
}
330338
}
331339
}
332340

333-
impl<'a> source_order::SourceOrderVisitor<'a> for QuoteAnnotator<'a> {
341+
impl<'a> SourceOrderVisitor<'a> for QuoteAnnotator<'a> {
342+
fn enter_node(&mut self, _node: ast::AnyNodeRef<'a>) -> TraversalSignal {
343+
if self.cannot_fix {
344+
TraversalSignal::Skip
345+
} else {
346+
TraversalSignal::Traverse
347+
}
348+
}
349+
334350
fn visit_expr(&mut self, expr: &'a Expr) {
335351
let generator = Generator::from(self.stylist);
336352

@@ -388,10 +404,13 @@ impl<'a> source_order::SourceOrderVisitor<'a> for QuoteAnnotator<'a> {
388404
let source = match self.state.last().copied() {
389405
Some(QuoteAnnotatorState::Literal | QuoteAnnotatorState::AnnotatedRest) => {
390406
let mut source = generator.expr(expr);
391-
source = source.replace(
392-
self.stylist.quote().as_char(),
393-
&self.stylist.quote().opposite().as_char().to_string(),
394-
);
407+
let opposite_quote = &self.stylist.quote().opposite().as_char().to_string();
408+
// If the quotes we are going to insert in this source already exists set the auto quote outcome
409+
// to failed. Because this means we are inserting quotes that are in the string and they collect.
410+
if source.contains(opposite_quote) || source.contains('\\') {
411+
self.cannot_fix = true;
412+
}
413+
source = source.replace(self.stylist.quote().as_char(), opposite_quote);
395414
source
396415
}
397416
None

crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
snapshot_kind: text
34
---
45
quote.py:2:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block
56
|

crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote2.py.snap

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
snapshot_kind: text
34
---
45
quote2.py:2:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block
56
|

crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote3.py.snap

+23-1
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,26 @@ quote3.py:40:37: TCH002 [*] Move third-party import `django.contrib.auth.models`
150150
45 |+ def test_attribute_typing_literal(arg: 'models.AbstractBaseUser[Literal["admin"]]'):
151151
43 46 | pass
152152
44 47 |
153-
45 48 |
153+
45 48 |
154+
155+
quote3.py:59:29: TCH002 Move third-party import `third_party.Type` into a type-checking block
156+
|
157+
57 | def f():
158+
58 | from typing import Literal
159+
59 | from third_party import Type
160+
| ^^^^ TCH002
161+
60 |
162+
61 | def test_string_contains_opposite_quote_do_not_fix(self, type1: Type[Literal["'"]], type2: Type[Literal["\'"]]):
163+
|
164+
= help: Move into type-checking block
165+
166+
quote3.py:67:29: TCH002 Move third-party import `third_party.Type` into a type-checking block
167+
|
168+
65 | def f():
169+
66 | from typing import Literal
170+
67 | from third_party import Type
171+
| ^^^^ TCH002
172+
68 |
173+
69 | def test_quote_contains_backslash(self, type1: Type[Literal["\n"]], type2: Type[Literal["\""]]):
174+
|
175+
= help: Move into type-checking block

0 commit comments

Comments
 (0)