[flake8-pyi] Implement PYI059 (generic-not-last-base-class)#11233
[flake8-pyi] Implement PYI059 (generic-not-last-base-class)#11233AlexWaygood merged 18 commits intoastral-sh:mainfrom
flake8-pyi] Implement PYI059 (generic-not-last-base-class)#11233Conversation
|
Is it okay to add the autofix in the same PR? |
|
Just wondering, what happens if a class has two |
Yep! |
It's a runtime error: >>> import typing
>>> T = typing.TypeVar('T')
>>> U = typing.TypeVar('U')
>>> class C(typing.Generic[T], typing.Generic[U]):
... ...
...
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/opt/homebrew/Cellar/[email protected]/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/typing.py", line 1102, in _generic_init_subclass
raise TypeError(
TypeError: Cannot inherit from Generic[...] multiple times. |
|
Good to know. We should make sure the rule doesn't behave poorly on that case regardless, i.e. it could cause an infinite fix loop. |
|
| code | total | + violation | - violation | + fix | - fix |
|---|---|---|---|---|---|
| PYI059 | 2 | 2 | 0 | 0 | 0 |
Should multiple |
AlexWaygood
left a comment
There was a problem hiding this comment.
Nice, thank you! Overall this looks good. Left some inline comments below :-)
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
Co-authored-by: Alex Waygood <[email protected]>
I'd say probably not. The behaviour we implemented at flake8-pyi is to ignore this rule if (We shouldn't flag multiple |
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
.../src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI059_PYI059.py.snap
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
| fn generate_fix(bases: &Arguments, generic_base_index: usize, locator: &Locator) -> Fix { | ||
| let last_base = bases.args.last().expect("Last base should always exist"); | ||
| let generic_base = bases | ||
| .args | ||
| .get(generic_base_index) | ||
| .expect("Generic base should always exist"); | ||
| let next_base = bases | ||
| .args | ||
| .get(generic_base_index + 1) | ||
| .expect("Generic base should never be the last base during auto-fix"); | ||
|
|
||
| let deletion = Edit::deletion(generic_base.start(), next_base.start()); | ||
| let insertion = Edit::insertion( | ||
| format!(", {}", locator.slice(generic_base.range())), | ||
| last_base.end(), | ||
| ); | ||
| Fix::safe_edits(insertion, [deletion]) | ||
| } |
There was a problem hiding this comment.
what happens for pathological cases like
class Foo( # comment about the bracket
# Part 1 of multiline comment 1
# Part 2 of multiline comment 1
Generic[T] # comment about Generic[T]
# another comment?
, # comment about the comma?
# part 1 of multiline comment 2
# part 2 of multiline comment 2
int, # comment about int
# yet another comment?
): # and another one for good measure
passThere was a problem hiding this comment.
It will technically work, but comments before int and after Generic[T] get eaten up. That is fixable, but the fix is subjective.
What's ruff's general stance in such cases? I know libcst has capabilities to try and identify which comment groups belong to which expression. But the easier thing would be to avoid moving comments at all, and just preserve comments.
There was a problem hiding this comment.
Different rules take different stances; we don't have a unified policy yet. Several of our autofixes will currently happily delete your comments (e.g. #9779); I think we all agree that this is bad, but we haven't yet decided how bad it is (see #9790). Other rules approach comments with a perhaps-excessive level of caution, either using the raw token stream for their autofix (RUF022, RUF023), or using libcst. Both are much slower than simple text replacements, I think libcst especially.
the fix is subjective.
absolutely! But I think that's always the case for something like this. I don't mind that much if some comments end up in the "wrong place", but I do think we should try to avoid deleting any comments if it's not too hard or costly to do.
There was a problem hiding this comment.
Trying to figure out how to get the start and end ranges of the comma after the Generic base. Tried using libcst to obtain the Comma itself, but does it have position information?
There was a problem hiding this comment.
I would probably just read one character at a time after the end of the generic base until you see a comma — does that make sense here? I'd avoid LibCST unless it's absolutely necessary (it's slow).
There was a problem hiding this comment.
I would probably just read one character at a time after the end of the generic base until you see a comma — does that make sense here? I'd avoid LibCST unless it's absolutely necessary (it's slow).
Agree -- memchr is probably the best tool for that (you can grep in the crates/ruff_linter directory for existing uses). Other possibilities are using libCST (but as Zanie says, it's really slow), or using the raw tokens (also can be slow, and probably more complicated here than using memchr).
If you look at https://play.ruff.rs/b2b834e8-0247-47fe-995a-c0ade0e6b240, with the following snippet:
class Foo(int, str): passOn the AST tab on the right, we can see:
- The
ExprNamenode for theintbase has range10..13 - The
ExprNamenode for thestrbase has range15..18
On the tokens tab on the right, we can see that:
- The comma in between the two nodes has range
13..14
There was a problem hiding this comment.
First try at this, I've used .find() (which I suppose can be swapped with memchr) and .position() (which I'm not sure if it can be replaced)
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_pyi/rules/generic_not_last_base_class.rs
Outdated
Show resolved
Hide resolved
…_base_class.rs Co-authored-by: Alex Waygood <[email protected]>
| let last_whitespace = (comma_after_generic_base + 1) | ||
| + locator.contents()[comma_after_generic_base + 1..] | ||
| .bytes() | ||
| .position(|b| !b.is_ascii_whitespace()) | ||
| .expect("Non whitespace character must always exist after Generic[]"); |
There was a problem hiding this comment.
I don't really understand what's going on here, I'm afraid :(
Is this the best name for this variable? Would you mind adding some comments to this function to explain how it works (preferably with some examples)?
There was a problem hiding this comment.
I'm trying to find the first index in the source code after Generic[...],, which doesn't contain a whitespace.
Basically, deleting just Generic[T] and the comma after it would leave a trailing whitespace around (usually a space).
Without the whitespace detection, this code:
class Foo(Generic[T], Bar):would be autofixed into:
class Foo( Bar, Generic[T]):i.e. The space before Bar was not deleted.
There was a problem hiding this comment.
I'm honestly not sure if this is the best way to write this, please feel free to refactor or comment on this.
There was a problem hiding this comment.
I think deleting a positional argument from a function call during autofix is common enough that once we finalize on the way it should be done this can be lifted to make a helper instead.
There was a problem hiding this comment.
Ah... in fact...
ruff/crates/ruff_linter/src/fix/edits.rs
Lines 155 to 237 in 12b5c3a
There was a problem hiding this comment.
I'm so sorry, I completely forgot that we had those helpers!
There was a problem hiding this comment.
I also didn't find them, so partly on me :P it uses the Tokenizer which is pretty clever
There was a problem hiding this comment.
I'll go ahead and refactor it.
|
(Everything looks great to me now except the autofix code, which I'm still not quite sure about -- both in terms of code readability and in terms of the number of |
|
@tusharsadhwani, I've just pushed 6fce0fd, which gets rid of the remaining |
|
Okay, this is much cleaner. |
flake8-pyi] Implement PYI059 (generic-not-last-base-class)
Summary
Implements
Y059fromflake8-pyi:typing.Generic[]should be the last base in a class' bases list.If Multiple
Generic[]classes are found, we don't generate a fix for it.Test Plan
cargo testandcargo insta review.