[flake8-type-checking] Fix syntax error introduced by fix (TC008) (#19150)

<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

I noticed this while working on #18972. If the string targeted by
[quoted-type-alias
(TC008)](https://docs.astral.sh/ruff/rules/quoted-type-alias/#quoted-type-alias-tc008)
is a multiline string, the fix would introduce a syntax error. This PR
fixes that by adding parenthesis around the resulting replacement if the
string contained any newline characters (`\n`, `\r`) if it doesn't
already have parenthesis outside `("""...""")` or inside `"""(...)"""`
the annotation.

Failing examples:
https://play.ruff.rs/8793eb95-860a-4bb3-9cbc-6a042fee2946
```
PS D:\rust_projects\ruff> Get-Content issue.py
```
```py
from typing import TypeAlias

OptInt: TypeAlias = """int
| None"""

type OptInt = """int
| None"""
```
```
PS D:\rust_projects\ruff> uvx ruff check issue.py --isolated --select TC008 --fix --diff --preview
```
```

error: Fix introduced a syntax error. Reverting all changes.

This indicates a bug in Ruff. If you could open an issue at:

    https://github.com/astral-sh/ruff/issues/new?title=%5BFix%20error%5D

...quoting the contents of `issue.py`, the rule codes TC008, along with the `pyproject.toml` settings and executed command, we'd be very appreciative!
```

This PR also makes the example error out-of-the-box for #18972

Old example: https://play.ruff.rs/f6cd5adb-7f9b-444d-bb3e-8c045241d93e
```py
OptInt: TypeAlias = "int | None"
```

New example: https://play.ruff.rs/906c1056-72c0-4777-b70b-2114eb9e6eaf
```py
from typing import TypeAlias

OptInt: TypeAlias = "int | None"
```

The import was also added to the "Use instead" section.

## Test Plan

<!-- How was it tested? -->

Added multiple test cases
This commit is contained in:
GiGaGon 2025-07-07 13:34:14 -07:00 committed by GitHub
parent 6e77e1b760
commit 47f88b3008
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 235 additions and 1 deletions

View file

@ -50,3 +50,23 @@ class Baz:
class Nested:
a: TypeAlias = 'Baz' # OK
type A = 'Baz' # TC008
# O should have parenthesis added
o: TypeAlias = """int
| None"""
type O = """int
| None"""
# P, Q, and R should not have parenthesis added
p: TypeAlias = ("""int
| None""")
type P = ("""int
| None""")
q: TypeAlias = """(int
| None)"""
type Q = """(int
| None)"""
r: TypeAlias = """int | None"""
type R = """int | None"""

View file

@ -11,6 +11,7 @@ use crate::registry::Rule;
use crate::rules::flake8_type_checking::helpers::quote_type_expression;
use crate::{AlwaysFixableViolation, Edit, Fix, FixAvailability, Violation};
use ruff_python_ast::PythonVersion;
use ruff_python_ast::parenthesize::parenthesized_range;
/// ## What it does
/// Checks if [PEP 613] explicit type aliases contain references to
@ -87,11 +88,15 @@ impl Violation for UnquotedTypeAlias {
/// ## Example
/// Given:
/// ```python
/// from typing import TypeAlias
///
/// OptInt: TypeAlias = "int | None"
/// ```
///
/// Use instead:
/// ```python
/// from typing import TypeAlias
///
/// OptInt: TypeAlias = int | None
/// ```
///
@ -287,7 +292,30 @@ pub(crate) fn quoted_type_alias(
let range = annotation_expr.range();
let mut diagnostic = checker.report_diagnostic(QuotedTypeAlias, range);
let edit = Edit::range_replacement(annotation_expr.value.to_string(), range);
let fix_string = annotation_expr.value.to_string();
let fix_string = if (fix_string.contains('\n') || fix_string.contains('\r'))
&& parenthesized_range(
// Check for parenthesis outside string ("""...""")
annotation_expr.into(),
checker.semantic().current_statement().into(),
checker.comment_ranges(),
checker.locator().contents(),
)
.is_none()
&& parenthesized_range(
// Check for parenthesis inside string """(...)"""
expr.into(),
annotation_expr.into(),
checker.comment_ranges(),
checker.locator().contents(),
)
.is_none()
{
format!("({fix_string})")
} else {
fix_string
};
let edit = Edit::range_replacement(fix_string, range);
if checker.comment_ranges().intersects(range) {
diagnostic.set_fix(Fix::unsafe_edit(edit));
} else {

View file

@ -409,6 +409,8 @@ TC008.py:52:18: TC008 [*] Remove quotes from type alias
51 | a: TypeAlias = 'Baz' # OK
52 | type A = 'Baz' # TC008
| ^^^^^ TC008
53 |
54 | # O should have parenthesis added
|
= help: Remove quotes
@ -418,3 +420,187 @@ TC008.py:52:18: TC008 [*] Remove quotes from type alias
51 51 | a: TypeAlias = 'Baz' # OK
52 |- type A = 'Baz' # TC008
52 |+ type A = Baz # TC008
53 53 |
54 54 | # O should have parenthesis added
55 55 | o: TypeAlias = """int
TC008.py:55:16: TC008 [*] Remove quotes from type alias
|
54 | # O should have parenthesis added
55 | o: TypeAlias = """int
| ________________^
56 | | | None"""
| |_________^ TC008
57 | type O = """int
58 | | None"""
|
= help: Remove quotes
Safe fix
52 52 | type A = 'Baz' # TC008
53 53 |
54 54 | # O should have parenthesis added
55 |-o: TypeAlias = """int
56 |-| None"""
55 |+o: TypeAlias = (int
56 |+| None)
57 57 | type O = """int
58 58 | | None"""
59 59 |
TC008.py:57:10: TC008 [*] Remove quotes from type alias
|
55 | o: TypeAlias = """int
56 | | None"""
57 | type O = """int
| __________^
58 | | | None"""
| |_________^ TC008
59 |
60 | # P, Q, and R should not have parenthesis added
|
= help: Remove quotes
Safe fix
54 54 | # O should have parenthesis added
55 55 | o: TypeAlias = """int
56 56 | | None"""
57 |-type O = """int
58 |-| None"""
57 |+type O = (int
58 |+| None)
59 59 |
60 60 | # P, Q, and R should not have parenthesis added
61 61 | p: TypeAlias = ("""int
TC008.py:61:17: TC008 [*] Remove quotes from type alias
|
60 | # P, Q, and R should not have parenthesis added
61 | p: TypeAlias = ("""int
| _________________^
62 | | | None""")
| |_________^ TC008
63 | type P = ("""int
64 | | None""")
|
= help: Remove quotes
Safe fix
58 58 | | None"""
59 59 |
60 60 | # P, Q, and R should not have parenthesis added
61 |-p: TypeAlias = ("""int
62 |-| None""")
61 |+p: TypeAlias = (int
62 |+| None)
63 63 | type P = ("""int
64 64 | | None""")
65 65 |
TC008.py:63:11: TC008 [*] Remove quotes from type alias
|
61 | p: TypeAlias = ("""int
62 | | None""")
63 | type P = ("""int
| ___________^
64 | | | None""")
| |_________^ TC008
65 |
66 | q: TypeAlias = """(int
|
= help: Remove quotes
Safe fix
60 60 | # P, Q, and R should not have parenthesis added
61 61 | p: TypeAlias = ("""int
62 62 | | None""")
63 |-type P = ("""int
64 |-| None""")
63 |+type P = (int
64 |+| None)
65 65 |
66 66 | q: TypeAlias = """(int
67 67 | | None)"""
TC008.py:66:16: TC008 [*] Remove quotes from type alias
|
64 | | None""")
65 |
66 | q: TypeAlias = """(int
| ________________^
67 | | | None)"""
| |__________^ TC008
68 | type Q = """(int
69 | | None)"""
|
= help: Remove quotes
Safe fix
63 63 | type P = ("""int
64 64 | | None""")
65 65 |
66 |-q: TypeAlias = """(int
67 |-| None)"""
66 |+q: TypeAlias = (int
67 |+| None)
68 68 | type Q = """(int
69 69 | | None)"""
70 70 |
TC008.py:68:10: TC008 [*] Remove quotes from type alias
|
66 | q: TypeAlias = """(int
67 | | None)"""
68 | type Q = """(int
| __________^
69 | | | None)"""
| |__________^ TC008
70 |
71 | r: TypeAlias = """int | None"""
|
= help: Remove quotes
Safe fix
65 65 |
66 66 | q: TypeAlias = """(int
67 67 | | None)"""
68 |-type Q = """(int
69 |-| None)"""
68 |+type Q = (int
69 |+| None)
70 70 |
71 71 | r: TypeAlias = """int | None"""
72 72 | type R = """int | None"""
TC008.py:71:16: TC008 [*] Remove quotes from type alias
|
69 | | None)"""
70 |
71 | r: TypeAlias = """int | None"""
| ^^^^^^^^^^^^^^^^ TC008
72 | type R = """int | None"""
|
= help: Remove quotes
Safe fix
68 68 | type Q = """(int
69 69 | | None)"""
70 70 |
71 |-r: TypeAlias = """int | None"""
71 |+r: TypeAlias = int | None
72 72 | type R = """int | None"""
TC008.py:72:10: TC008 [*] Remove quotes from type alias
|
71 | r: TypeAlias = """int | None"""
72 | type R = """int | None"""
| ^^^^^^^^^^^^^^^^ TC008
|
= help: Remove quotes
Safe fix
69 69 | | None)"""
70 70 |
71 71 | r: TypeAlias = """int | None"""
72 |-type R = """int | None"""
72 |+type R = int | None