Skip to content

Commit 6051a11

Browse files
ntBreAlexWaygood
andauthored
[flake8-pyi] Avoid syntax error in the case of starred and keyword arguments (PYI059) (#18611)
## Summary Fixes #18602 by: 1. Avoiding a fix when `*args` are present 2. Inserting the `Generic` base class right before the first keyword argument, if one is present In an intermediate commit, I also had special handling to avoid a fix in the `**kwargs` case, but this is treated (roughly) as a normal keyword, and I believe handling it properly falls out of the other keyword fix. I also updated the `add_argument` utility function to insert new arguments right before the keyword argument list instead of at the very end of the argument list. This changed a couple of snapshots unrelated to `PYI059`, but there shouldn't be any functional changes to other rules because all other calls to `add_argument` were adding a keyword argument anyway. ## Test Plan Existing PYI059 cases, plus new tests based on the issue --------- Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
1 parent 161446a commit 6051a11

File tree

7 files changed

+122
-25
lines changed

7 files changed

+122
-25
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI059.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,15 @@ def __init__(self) -> None:
5252

5353
class SomeGeneric(Generic[T]): # Only one generic
5454
pass
55+
56+
57+
# syntax errors with starred and keyword arguments from
58+
# https://github.com/astral-sh/ruff/issues/18602
59+
class C1(Generic[T], str, **{"metaclass": type}): # PYI059
60+
...
61+
62+
class C2(Generic[T], str, metaclass=type): # PYI059
63+
...
64+
65+
class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix
66+
...

crates/ruff_linter/src/fix/edits.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
33
use anyhow::{Context, Result};
44

5+
use ruff_python_ast::AnyNodeRef;
56
use ruff_python_ast::parenthesize::parenthesized_range;
67
use ruff_python_ast::{self as ast, Arguments, ExceptHandler, Expr, ExprList, Parameters, Stmt};
7-
use ruff_python_ast::{AnyNodeRef, ArgOrKeyword};
88
use ruff_python_codegen::Stylist;
99
use ruff_python_index::Indexer;
1010
use ruff_python_trivia::textwrap::dedent_to;
@@ -257,19 +257,23 @@ pub(crate) fn remove_argument<T: Ranged>(
257257
}
258258

259259
/// Generic function to add arguments or keyword arguments to function calls.
260+
///
261+
/// The new argument will be inserted before the first existing keyword argument in `arguments`, if
262+
/// there are any present. Otherwise, the new argument is added to the end of the argument list.
260263
pub(crate) fn add_argument(
261264
argument: &str,
262265
arguments: &Arguments,
263266
comment_ranges: &CommentRanges,
264267
source: &str,
265268
) -> Edit {
266-
if let Some(last) = arguments.arguments_source_order().last() {
269+
if let Some(ast::Keyword { range, value, .. }) = arguments.keywords.first() {
270+
let keyword = parenthesized_range(value.into(), arguments.into(), comment_ranges, source)
271+
.unwrap_or(*range);
272+
Edit::insertion(format!("{argument}, "), keyword.start())
273+
} else if let Some(last) = arguments.arguments_source_order().last() {
267274
// Case 1: existing arguments, so append after the last argument.
268275
let last = parenthesized_range(
269-
match last {
270-
ArgOrKeyword::Arg(arg) => arg.into(),
271-
ArgOrKeyword::Keyword(keyword) => (&keyword.value).into(),
272-
},
276+
last.value().into(),
273277
arguments.into(),
274278
comment_ranges,
275279
source,

crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B028_B028.py.snap

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,10 @@ B028.py:9:1: B028 [*] No explicit `stacklevel` keyword argument found
3737
7 7 |
3838
8 8 | warnings.warn("test", DeprecationWarning)
3939
9 |-warnings.warn("test", DeprecationWarning, source=None)
40-
10 9 | warnings.warn("test", DeprecationWarning, source=None, stacklevel=2)
41-
10 |+warnings.warn("test", DeprecationWarning, source=None, stacklevel=2)
40+
9 |+warnings.warn("test", DeprecationWarning, stacklevel=2, source=None)
41+
10 10 | warnings.warn("test", DeprecationWarning, source=None, stacklevel=2)
4242
11 11 | warnings.warn("test", DeprecationWarning, stacklevel=1)
4343
12 12 | warnings.warn("test", DeprecationWarning, 1)
44-
13 13 | warnings.warn("test", category=DeprecationWarning, stacklevel=1)
4544

4645
B028.py:22:1: B028 [*] No explicit `stacklevel` keyword argument found
4746
|
@@ -59,7 +58,7 @@ B028.py:22:1: B028 [*] No explicit `stacklevel` keyword argument found
5958
24 24 | DeprecationWarning,
6059
25 25 | # some comments here
6160
26 |- source = None # no trailing comma
62-
26 |+ source = None, stacklevel=2 # no trailing comma
61+
26 |+ stacklevel=2, source = None # no trailing comma
6362
27 27 | )
6463
28 28 |
6564
29 29 | # https://github.com/astral-sh/ruff/issues/18011
@@ -80,7 +79,7 @@ B028.py:32:1: B028 [*] No explicit `stacklevel` keyword argument found
8079
30 30 | warnings.warn("test", skip_file_prefixes=(os.path.dirname(__file__),))
8180
31 31 | # trigger diagnostic if `skip_file_prefixes` is present and set to the default value
8281
32 |-warnings.warn("test", skip_file_prefixes=())
83-
32 |+warnings.warn("test", skip_file_prefixes=(), stacklevel=2)
82+
32 |+warnings.warn("test", stacklevel=2, skip_file_prefixes=())
8483
33 33 |
8584
34 34 | _my_prefixes = ("this","that")
8685
35 35 | warnings.warn("test", skip_file_prefixes = _my_prefixes)

crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ use crate::{Fix, FixAvailability, Violation};
1313
/// ## Why is this bad?
1414
/// If `Generic[]` is not the final class in the bases tuple, unexpected
1515
/// behaviour can occur at runtime (See [this CPython issue][1] for an example).
16-
/// The rule is also applied to stub files, but, unlike at runtime,
17-
/// in stubs it is purely enforced for stylistic consistency.
16+
///
17+
/// The rule is also applied to stub files, where it won't cause issues at
18+
/// runtime. This is because type checkers may not be able to infer an
19+
/// accurate [MRO] for the class, which could lead to unexpected or
20+
/// inaccurate results when they analyze your code.
1821
///
1922
/// For example:
2023
/// ```python
@@ -43,10 +46,23 @@ use crate::{Fix, FixAvailability, Violation};
4346
/// ):
4447
/// ...
4548
/// ```
49+
///
50+
/// ## Fix safety
51+
///
52+
/// This rule's fix is always unsafe because reordering base classes can change
53+
/// the behavior of the code by modifying the class's MRO. The fix will also
54+
/// delete trailing comments after the `Generic` base class in multi-line base
55+
/// class lists, if any are present.
56+
///
57+
/// ## Fix availability
58+
///
59+
/// This rule's fix is only available when there are no `*args` present in the base class list.
60+
///
4661
/// ## References
4762
/// - [`typing.Generic` documentation](https://docs.python.org/3/library/typing.html#typing.Generic)
4863
///
4964
/// [1]: https://github.com/python/cpython/issues/106102
65+
/// [MRO]: https://docs.python.org/3/glossary.html#term-method-resolution-order
5066
#[derive(ViolationMetadata)]
5167
pub(crate) struct GenericNotLastBaseClass;
5268

@@ -94,6 +110,22 @@ pub(crate) fn generic_not_last_base_class(checker: &Checker, class_def: &ast::St
94110

95111
let mut diagnostic = checker.report_diagnostic(GenericNotLastBaseClass, bases.range());
96112

113+
// Avoid suggesting a fix if any of the arguments is starred. This avoids tricky syntax errors
114+
// in cases like
115+
//
116+
// ```python
117+
// class C3(Generic[T], metaclass=type, *[str]): ...
118+
// ```
119+
//
120+
// where we would naively try to put `Generic[T]` after `*[str]`, which is also after a keyword
121+
// argument, causing the error.
122+
if bases
123+
.arguments_source_order()
124+
.any(|arg| arg.value().is_starred_expr())
125+
{
126+
return;
127+
}
128+
97129
// No fix if multiple `Generic[]`s are seen in the class bases.
98130
if generic_base_iter.next().is_none() {
99131
diagnostic.try_set_fix(|| generate_fix(generic_base, bases, checker));
@@ -116,5 +148,5 @@ fn generate_fix(
116148
source,
117149
);
118150

119-
Ok(Fix::safe_edits(deletion, [insertion]))
151+
Ok(Fix::unsafe_edits(deletion, [insertion]))
120152
}

crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.py.snap

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PYI059.py:8:17: PYI059 [*] `Generic[]` should always be the last base class
1212
|
1313
= help: Move `Generic[]` to the end
1414

15-
Safe fix
15+
Unsafe fix
1616
5 5 | K = TypeVar('K')
1717
6 6 | V = TypeVar('V')
1818
7 7 |
@@ -37,7 +37,7 @@ PYI059.py:15:16: PYI059 [*] `Generic[]` should always be the last base class
3737
|
3838
= help: Move `Generic[]` to the end
3939

40-
Safe fix
40+
Unsafe fix
4141
13 13 | self._items.append(item)
4242
14 14 |
4343
15 15 | class MyMapping( # PYI059
@@ -59,7 +59,7 @@ PYI059.py:26:10: PYI059 [*] `Generic[]` should always be the last base class
5959
|
6060
= help: Move `Generic[]` to the end
6161

62-
Safe fix
62+
Unsafe fix
6363
23 23 | # Inheriting from just `Generic` is a TypeError, but it's probably fine
6464
24 24 | # to flag this issue in this case as well, since after fixing the error
6565
25 25 | # the Generic's position issue persists.
@@ -88,7 +88,7 @@ PYI059.py:30:10: PYI059 [*] `Generic[]` should always be the last base class
8888
|
8989
= help: Move `Generic[]` to the end
9090

91-
Safe fix
91+
Unsafe fix
9292
30 30 | class Foo( # comment about the bracket
9393
31 31 | # Part 1 of multiline comment 1
9494
32 32 | # Part 2 of multiline comment 1
@@ -111,3 +111,53 @@ PYI059.py:45:8: PYI059 `Generic[]` should always be the last base class
111111
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
112112
|
113113
= help: Move `Generic[]` to the end
114+
115+
PYI059.py:59:9: PYI059 [*] `Generic[]` should always be the last base class
116+
|
117+
57 | # syntax errors with starred and keyword arguments from
118+
58 | # https://github.com/astral-sh/ruff/issues/18602
119+
59 | class C1(Generic[T], str, **{"metaclass": type}): # PYI059
120+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
121+
60 | ...
122+
|
123+
= help: Move `Generic[]` to the end
124+
125+
Unsafe fix
126+
56 56 |
127+
57 57 | # syntax errors with starred and keyword arguments from
128+
58 58 | # https://github.com/astral-sh/ruff/issues/18602
129+
59 |-class C1(Generic[T], str, **{"metaclass": type}): # PYI059
130+
59 |+class C1(str, Generic[T], **{"metaclass": type}): # PYI059
131+
60 60 | ...
132+
61 61 |
133+
62 62 | class C2(Generic[T], str, metaclass=type): # PYI059
134+
135+
PYI059.py:62:9: PYI059 [*] `Generic[]` should always be the last base class
136+
|
137+
60 | ...
138+
61 |
139+
62 | class C2(Generic[T], str, metaclass=type): # PYI059
140+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
141+
63 | ...
142+
|
143+
= help: Move `Generic[]` to the end
144+
145+
Unsafe fix
146+
59 59 | class C1(Generic[T], str, **{"metaclass": type}): # PYI059
147+
60 60 | ...
148+
61 61 |
149+
62 |-class C2(Generic[T], str, metaclass=type): # PYI059
150+
62 |+class C2(str, Generic[T], metaclass=type): # PYI059
151+
63 63 | ...
152+
64 64 |
153+
65 65 | class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix
154+
155+
PYI059.py:65:9: PYI059 `Generic[]` should always be the last base class
156+
|
157+
63 | ...
158+
64 |
159+
65 | class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix
160+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
161+
66 | ...
162+
|
163+
= help: Move `Generic[]` to the end

crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.pyi.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PYI059.pyi:8:17: PYI059 [*] `Generic[]` should always be the last base class
1212
|
1313
= help: Move `Generic[]` to the end
1414

15-
Safe fix
15+
Unsafe fix
1616
5 5 | K = TypeVar('K')
1717
6 6 | V = TypeVar('V')
1818
7 7 |
@@ -37,7 +37,7 @@ PYI059.pyi:12:16: PYI059 [*] `Generic[]` should always be the last base class
3737
|
3838
= help: Move `Generic[]` to the end
3939

40-
Safe fix
40+
Unsafe fix
4141
10 10 | def push(self, item: T) -> None: ...
4242
11 11 |
4343
12 12 | class MyMapping( # PYI059
@@ -58,7 +58,7 @@ PYI059.pyi:22:10: PYI059 [*] `Generic[]` should always be the last base class
5858
|
5959
= help: Move `Generic[]` to the end
6060

61-
Safe fix
61+
Unsafe fix
6262
19 19 | # Inheriting from just `Generic` is a TypeError, but it's probably fine
6363
20 20 | # to flag this issue in this case as well, since after fixing the error
6464
21 21 | # the Generic's position issue persists.
@@ -87,7 +87,7 @@ PYI059.pyi:25:10: PYI059 [*] `Generic[]` should always be the last base class
8787
|
8888
= help: Move `Generic[]` to the end
8989

90-
Safe fix
90+
Unsafe fix
9191
25 25 | class Foo( # comment about the bracket
9292
26 26 | # Part 1 of multiline comment 1
9393
27 27 | # Part 2 of multiline comment 1

crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLW1510_subprocess_run_without_check.py.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ subprocess_run_without_check.py:5:1: PLW1510 [*] `subprocess.run` without explic
3737
3 3 | # Errors.
3838
4 4 | subprocess.run("ls")
3939
5 |-subprocess.run("ls", shell=True)
40-
5 |+subprocess.run("ls", shell=True, check=False)
40+
5 |+subprocess.run("ls", check=False, shell=True)
4141
6 6 | subprocess.run(
4242
7 7 | ["ls"],
4343
8 8 | shell=False,
@@ -58,7 +58,7 @@ subprocess_run_without_check.py:6:1: PLW1510 [*] `subprocess.run` without explic
5858
6 6 | subprocess.run(
5959
7 7 | ["ls"],
6060
8 |- shell=False,
61-
8 |+ shell=False, check=False,
61+
8 |+ check=False, shell=False,
6262
9 9 | )
6363
10 10 | subprocess.run(["ls"], **kwargs)
6464
11 11 |
@@ -79,7 +79,7 @@ subprocess_run_without_check.py:10:1: PLW1510 [*] `subprocess.run` without expli
7979
8 8 | shell=False,
8080
9 9 | )
8181
10 |-subprocess.run(["ls"], **kwargs)
82-
10 |+subprocess.run(["ls"], **kwargs, check=False)
82+
10 |+subprocess.run(["ls"], check=False, **kwargs)
8383
11 11 |
8484
12 12 | # Non-errors.
8585
13 13 | subprocess.run("ls", check=True)

0 commit comments

Comments
 (0)