Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI059.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,15 @@ def __init__(self) -> None:

class SomeGeneric(Generic[T]): # Only one generic
pass


# syntax errors with starred and keyword arguments from
# https://github.com/astral-sh/ruff/issues/18602
class C1(Generic[T], str, **{"metaclass": type}): # PYI059
...

class C2(Generic[T], str, metaclass=type): # PYI059
...

class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix
...
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use ruff_diagnostics::Edit;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::parenthesize::parenthesized_range;
use ruff_python_ast::{self as ast, helpers::map_subscript};
use ruff_text_size::Ranged;

Expand Down Expand Up @@ -43,6 +45,11 @@ use crate::{Fix, FixAvailability, Violation};
/// ):
/// ...
/// ```
///
/// ## Fix availability
///
/// This rule's fix is only available when there are no `*args` present in the base class list.
///
/// ## References
/// - [`typing.Generic` documentation](https://docs.python.org/3/library/typing.html#typing.Generic)
///
Expand Down Expand Up @@ -94,6 +101,22 @@ pub(crate) fn generic_not_last_base_class(checker: &Checker, class_def: &ast::St

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

// Avoid suggesting a fix if any of the arguments is starred. This avoids tricky syntax errors
// in cases like
//
// ```python
// class C3(Generic[T], metaclass=type, *[str]): ...
// ```
//
// where we would naively try to put `Generic[T]` after `*[str]`, which is also after a keyword
// argument, causing the error.
if bases
.arguments_source_order()
.any(|arg| arg.value().is_starred_expr())
{
return;
}

// No fix if multiple `Generic[]`s are seen in the class bases.
if generic_base_iter.next().is_none() {
diagnostic.try_set_fix(|| generate_fix(generic_base, bases, checker));
Expand All @@ -109,12 +132,19 @@ fn generate_fix(
let source = locator.contents();

let deletion = remove_argument(generic_base, arguments, Parentheses::Preserve, source)?;
let insertion = add_argument(
locator.slice(generic_base),
arguments,
checker.comment_ranges(),
source,
);

let argument = locator.slice(generic_base);
let comment_ranges = checker.comment_ranges();

// adapted from `add_argument`, which doesn't automatically handle inserting before the first
// keyword argument.
let insertion = if let Some(ast::Keyword { range, value, .. }) = arguments.keywords.first() {
let keyword = parenthesized_range(value.into(), arguments.into(), comment_ranges, source)
.unwrap_or(*range);
Edit::insertion(format!("{argument}, "), keyword.start())
} else {
add_argument(argument, arguments, comment_ranges, source)
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we be adding this logic to add_argument, so that it's also fixed for other rules that use that utility? If we don't want to do that in this PR, is it worth opening a followup issue for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I actually checked the other callers of add_argument, and they were all adding keyword arguments! So I think it makes sense to fix this or add a note to the docs at the very least.

I don't mind doing it here, but I guess it might churn some snapshots to move the existing calls to the front of the kwarg list.

Copy link
Member

@AlexWaygood AlexWaygood Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with doing it here or postponing to a followup -- either is fine by me!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went ahead and did it here. It might have even shortened the net diff!

You're welcome to review again of course, but otherwise I'll just merge this when CI finishes. Thanks for the help!


Ok(Fix::safe_edits(deletion, [insertion]))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... it's unrelated to your PR, but I'm not sure that this should be a safe fix :-(

Moving the position of Generic[] in the bases tuple could change the behaviour of the code by changing the class's MRO, and it's possible that the code was actually working before the fix. (Having Generic[] as not-the-last base class can sometimes work, it's just... never advisable.)

Even if we're okay with the fix changing the behaviour of the code, is there a possibility it could delete comments in some situations? We discussed this in the original PR so I think we have good test coverage for it, but at that point in time we didn't have as rigorous a policy for whether a fix should be deemed to be unsafe if it removed comments: #11233 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering about that too, thanks for bringing it up.

It definitely removes comments (playground) trailing the Generic argument.

I'd be fine marking this always unsafe and adding a ## Fix safety section saying that it can change the MRO and also delete comments, if that sounds good to you.

This seems like a lot of changes for a rule about to be stabilized. How are you feeling about that? I'm definitely fine leaving this in preview for another cycle.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be fine marking this always unsafe and adding a ## Fix safety section saying that it can change the MRO and also delete comments, if that sounds good to you.

That sounds good to me, yeah!

This seems like a lot of changes for a rule about to be stabilized. How are you feeling about that? I'm definitely fine leaving this in preview for another cycle.

Agreed -- let's make the changes proposed in this PR and the docs updates proposed in #18601, but leave it in preview for now?

}
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,53 @@ PYI059.py:45:8: PYI059 `Generic[]` should always be the last base class
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
|
= help: Move `Generic[]` to the end

PYI059.py:59:9: PYI059 [*] `Generic[]` should always be the last base class
|
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
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
60 | ...
|
= help: Move `Generic[]` to the end

ℹ Safe fix
56 56 |
57 57 | # syntax errors with starred and keyword arguments from
58 58 | # https://github.com/astral-sh/ruff/issues/18602
59 |-class C1(Generic[T], str, **{"metaclass": type}): # PYI059
59 |+class C1(str, Generic[T], **{"metaclass": type}): # PYI059
60 60 | ...
61 61 |
62 62 | class C2(Generic[T], str, metaclass=type): # PYI059

PYI059.py:62:9: PYI059 [*] `Generic[]` should always be the last base class
|
60 | ...
61 |
62 | class C2(Generic[T], str, metaclass=type): # PYI059
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
63 | ...
|
= help: Move `Generic[]` to the end

ℹ Safe fix
59 59 | class C1(Generic[T], str, **{"metaclass": type}): # PYI059
60 60 | ...
61 61 |
62 |-class C2(Generic[T], str, metaclass=type): # PYI059
62 |+class C2(str, Generic[T], metaclass=type): # PYI059
63 63 | ...
64 64 |
65 65 | class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix

PYI059.py:65:9: PYI059 `Generic[]` should always be the last base class
|
63 | ...
64 |
65 | class C3(Generic[T], metaclass=type, *[str]): # PYI059 but no fix
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI059
66 | ...
|
= help: Move `Generic[]` to the end
Loading