Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autofix for none-not-at-end-of-union (RUF036) #15139

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

harupy
Copy link
Contributor

@harupy harupy commented Dec 25, 2024

Summary

Close #15136. Implement autofix for none-not-at-end-of-union (RUF036)

Test Plan

Existing and new tests

Copy link
Contributor

github-actions bot commented Dec 25, 2024

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

ℹ️ ecosystem check detected linter changes. (+0 -82 violations, +390 -0 fixes in 18 projects; 37 projects unchanged)

DisnakeDev/disnake (+0 -0 violations, +2 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ tests/test_utils.py:782:25: RUF036 [*] `None` not at the end of the type annotation.
- tests/test_utils.py:782:25: RUF036 `None` not at the end of the type annotation.

RasaHQ/rasa (+0 -0 violations, +6 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ rasa/shared/core/events.py:597:48: RUF036 [*] `None` not at the end of the type annotation.
- rasa/shared/core/events.py:597:48: RUF036 `None` not at the end of the type annotation.
+ rasa/shared/core/events.py:624:31: RUF036 [*] `None` not at the end of the type annotation.
- rasa/shared/core/events.py:624:31: RUF036 `None` not at the end of the type annotation.
+ rasa/utils/tensorflow/models.py:247:35: RUF036 [*] `None` not at the end of the type annotation.
- rasa/utils/tensorflow/models.py:247:35: RUF036 `None` not at the end of the type annotation.

apache/airflow (+0 -0 violations, +242 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview --select ALL

+ airflow/api_fastapi/common/parameters.py:641:22: RUF036 [*] `None` not at the end of the type annotation.
- airflow/api_fastapi/common/parameters.py:641:22: RUF036 `None` not at the end of the type annotation.
+ airflow/auth/managers/base_auth_manager.py:448:36: RUF036 [*] `None` not at the end of the type annotation.
- airflow/auth/managers/base_auth_manager.py:448:36: RUF036 `None` not at the end of the type annotation.
+ airflow/cli/commands/remote_commands/task_command.py:250:71: RUF036 [*] `None` not at the end of the type annotation.
- airflow/cli/commands/remote_commands/task_command.py:250:71: RUF036 `None` not at the end of the type annotation.
+ airflow/cli/commands/remote_commands/task_command.py:336:46: RUF036 [*] `None` not at the end of the type annotation.
- airflow/cli/commands/remote_commands/task_command.py:336:46: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:117:23: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:117:23: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:118:25: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:118:25: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:124:21: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:124:21: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:125:26: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:125:26: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:245:23: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:245:23: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:246:25: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:246:25: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:252:21: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:252:21: RUF036 `None` not at the end of the type annotation.
+ airflow/decorators/__init__.pyi:253:26: RUF036 [*] `None` not at the end of the type annotation.
- airflow/decorators/__init__.pyi:253:26: RUF036 `None` not at the end of the type annotation.
+ airflow/example_dags/plugins/event_listener.py:93:76: RUF036 [*] `None` not at the end of the type annotation.
... 217 additional changes omitted for project

apache/superset (+0 -0 violations, +10 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview --select ALL

+ superset/config.py:1126:5: RUF036 [*] `None` not at the end of the type annotation.
- superset/config.py:1126:5: RUF036 `None` not at the end of the type annotation.
+ superset/config.py:1728:43: RUF036 [*] `None` not at the end of the type annotation.
- superset/config.py:1728:43: RUF036 `None` not at the end of the type annotation.
+ superset/config.py:713:5: RUF036 [*] `None` not at the end of the type annotation.
- superset/config.py:713:5: RUF036 `None` not at the end of the type annotation.
+ superset/db_engine_specs/gsheets.py:166:26: RUF036 [*] `None` not at the end of the type annotation.
- superset/db_engine_specs/gsheets.py:166:26: RUF036 `None` not at the end of the type annotation.
+ superset/jinja_context.py:84:16: RUF036 [*] `None` not at the end of the type annotation.
- superset/jinja_context.py:84:16: RUF036 `None` not at the end of the type annotation.

bokeh/bokeh (+0 -0 violations, +6 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview --select ALL

+ src/bokeh/client/websocket.py:73:85: RUF036 [*] `None` not at the end of the type annotation.
- src/bokeh/client/websocket.py:73:85: RUF036 `None` not at the end of the type annotation.
+ src/bokeh/embed/standalone.py:84:30: RUF036 [*] `None` not at the end of the type annotation.
- src/bokeh/embed/standalone.py:84:30: RUF036 `None` not at the end of the type annotation.
+ src/bokeh/util/tornado.py:231:26: RUF036 [*] `None` not at the end of the type annotation.
- src/bokeh/util/tornado.py:231:26: RUF036 `None` not at the end of the type annotation.

latchbio/latch (+0 -0 violations, +14 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ src/latch/registry/types.py:45:5: RUF036 [*] `None` not at the end of the type annotation.
- src/latch/registry/types.py:45:5: RUF036 `None` not at the end of the type annotation.
+ src/latch/types/metadata.py:419:5: RUF036 [*] `None` not at the end of the type annotation.
- src/latch/types/metadata.py:419:5: RUF036 `None` not at the end of the type annotation.
+ src/latch_cli/services/get.py:38:32: RUF036 [*] `None` not at the end of the type annotation.
- src/latch_cli/services/get.py:38:32: RUF036 `None` not at the end of the type annotation.
+ src/latch_cli/services/launch.py:135:46: RUF036 [*] `None` not at the end of the type annotation.
- src/latch_cli/services/launch.py:135:46: RUF036 `None` not at the end of the type annotation.
+ src/latch_cli/snakemake/config/utils.py:12:53: RUF036 [*] `None` not at the end of the type annotation.
- src/latch_cli/snakemake/config/utils.py:12:53: RUF036 `None` not at the end of the type annotation.
... 4 additional changes omitted for project

lnbits/lnbits (+0 -0 violations, +2 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ lnbits/core/views/payment_api.py:172:27: RUF036 [*] `None` not at the end of the type annotation.
- lnbits/core/views/payment_api.py:172:27: RUF036 `None` not at the end of the type annotation.

milvus-io/pymilvus (+0 -0 violations, +2 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ pymilvus/client/abstract.py:806:40: RUF036 [*] `None` not at the end of the type annotation.
- pymilvus/client/abstract.py:806:40: RUF036 `None` not at the end of the type annotation.

pandas-dev/pandas (+0 -31 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

- pandas/core/dtypes/cast.py:182:38: RUF036 `None` not at the end of the type annotation.
- pandas/core/dtypes/cast.py:182:65: RUF036 `None` not at the end of the type annotation.
- pandas/core/dtypes/dtypes.py:1269:15: RUF036 `None` not at the end of the type annotation.
- pandas/core/groupby/groupby.py:2784:24: RUF036 `None` not at the end of the type annotation.
- pandas/core/groupby/groupby.py:4248:22: RUF036 `None` not at the end of the type annotation.
- pandas/core/reshape/encoding.py:367:10: RUF036 `None` not at the end of the type annotation.
- pandas/core/reshape/encoding.py:368:23: RUF036 `None` not at the end of the type annotation.
- pandas/io/_util.py:69:41: RUF036 `None` not at the end of the type annotation.
- pandas/io/excel/_util.py:181:6: RUF036 `None` not at the end of the type annotation.
- pandas/io/parsers/readers.py:101:20: RUF036 `None` not at the end of the type annotation.
... 21 additional changes omitted for project

pypa/cibuildwheel (+0 -0 violations, +2 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview

+ cibuildwheel/options.py:753:41: RUF036 [*] `None` not at the end of the type annotation.
- cibuildwheel/options.py:753:41: RUF036 `None` not at the end of the type annotation.

python/typeshed (+0 -51 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --ignore RUF9 --output-format concise --preview --select E,F,FA,I,PYI,RUF,UP,W

- stdlib/ast.pyi:1726:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1736:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1746:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1756:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1765:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1774:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1783:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1793:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1805:26: RUF036 `None` not at the end of the type annotation.
- stdlib/ast.pyi:1814:26: RUF036 `None` not at the end of the type annotation.
... 41 additional changes omitted for project

... Truncated remaining completed project reports due to GitHub comment length restrictions

Changes by rule (1 rules affected)

code total + violation - violation + fix - fix
RUF036 472 0 82 390 0

applicability,
)
};
diagnostic.set_fix(fix);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we set the fix to all the diagnostics?

Copy link
Member

Choose a reason for hiding this comment

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

Can you tell me a bit more why you think we should (or shouldn't) set the fix for all violations?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@MichaReiser For example, this part looks like the same fix is set to all the diagnostics:

if let Some(fix) = fix {
for diagnostic in &mut diagnostics {
diagnostic.set_fix(fix.clone());
}

Copy link
Member

Choose a reason for hiding this comment

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

I'm actually leaning towards only emitting a single diagnostic rather than emitting multiple diagnostics. This should also simplify the code a bit because we can change none_exprs to an Option only tracking the last_none rather than all None values

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A single diagnostic makes sense.

@AlexWaygood AlexWaygood added the fixes Related to suggested fixes for violations label Dec 25, 2024
@dylwil3
Copy link
Collaborator

dylwil3 commented Dec 25, 2024

Thanks for working on this! Can you add some tests with nested and mixed uses of unions? Like these:

def f(x: None | Union[None ,int]): ...

def g(x: int | (str | None) | list): ...

def h(x: Union[int, Union[None, list | set]]: ...

also the examples from poetry in the ecosystem check like:

    def _detect_active_python(io: None | IO = None) -> Path | None: ...

@dylwil3
Copy link
Collaborator

dylwil3 commented Dec 25, 2024

It looks like in the ecosystem check a bunch of violations were removed with no fix, so I'm wondering if a syntax error was introduced by the fix. Maybe when there is a default argument something goes wrong?

@Daverball
Copy link
Contributor

Daverball commented Dec 25, 2024

It looks like in the ecosystem check a bunch of violations were removed with no fix, so I'm wondering if a syntax error was introduced by the fix. Maybe when there is a default argument something goes wrong?

I think this happens for projects where the ruff.toml or pyproject.toml contains fix = true. I also got tripped up by this behavior in some of my pull requests. I think it would be a good idea to change the ecosystem script to pass --no-fix, so this doesn't happen, since the output will always be interpreted incorrectly when fixes have been applied (this can also lead to other confusing things like new E501 flags, because the fix caused a line to get too long, or an unrelated unfixable violation getting flagged, because the fix caused the violation to be slightly offset, so you see a +1 -1 for that violation).

@harupy harupy requested a review from MichaReiser December 27, 2024 12:34
Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

Thanks for working on this.

The fix now seems to remove duplicate None elements and I think it also isn't handling nesting correctly - see the inline comments in the tests.

I tried to implement a fix that does more narrow edits than regenerating the entire union / subscript but the whitespace handling isn't perfect yet and I haven't looked into some edge cases like quoted annotations or parentheses. But maybe it's useful for you

Index: crates/ruff_python_parser/src/lib.rs
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/crates/ruff_python_parser/src/lib.rs b/crates/ruff_python_parser/src/lib.rs
--- a/crates/ruff_python_parser/src/lib.rs	(revision aa1a5cb2c7ef0dc3b38595a92285066721e626c0)
+++ b/crates/ruff_python_parser/src/lib.rs	(date 1735565508471)
@@ -478,6 +478,35 @@
             }
         }
     }
+
+    /// Returns a slice of tokens before the given [`TextSize`] offset.
+    ///
+    /// If the given offset is between two tokens, the returned slice will start from the following
+    /// token. In other words, if the offset is between the end of previous token and start of next
+    /// token, the returned slice will start from the next token.
+    ///
+    /// # Panics
+    ///
+    /// If the given offset is inside a token range.
+    pub fn before(&self, offset: TextSize) -> &[Token] {
+        match self.binary_search_by(|token| token.start().cmp(&offset)) {
+            Ok(idx) => &self[..idx],
+            Err(idx) => {
+                if let Some(prev) = self.get(idx.saturating_sub(1)) {
+                    // If it's equal to the end offset, then it's at a token boundary which is
+                    // valid. If it's greater than the end offset, then it's in the gap between
+                    // the tokens which is valid as well.
+                    assert!(
+                        offset >= prev.end(),
+                        "Offset {:?} is inside a token range {:?}",
+                        offset,
+                        prev.range()
+                    );
+                }
+                &self[..idx]
+            }
+        }
+    }
 }
 
 impl<'a> IntoIterator for &'a Tokens {
Index: crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs b/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs
--- a/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs	(revision aa1a5cb2c7ef0dc3b38595a92285066721e626c0)
+++ b/crates/ruff_linter/src/rules/ruff/rules/none_not_at_end_of_union.rs	(date 1735567068721)
@@ -1,13 +1,14 @@
+use crate::checkers::ast::Checker;
 use itertools::{Itertools, Position};
 use ruff_diagnostics::{Applicability, Diagnostic, Edit, Fix, FixAvailability, Violation};
 use ruff_macros::{derive_message_formats, ViolationMetadata};
 use ruff_python_ast::{self as ast, Expr};
+use ruff_python_parser::TokenKind;
 use ruff_python_semantic::analyze::typing::traverse_union;
-use ruff_text_size::Ranged;
+use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
+use ruff_text_size::{Ranged, TextRange, TextSlice};
 use smallvec::SmallVec;
 
-use crate::checkers::ast::Checker;
-
 /// ## What it does
 /// Checks for type annotations where `None` is not at the end of an union.
 ///
@@ -49,15 +50,13 @@
 /// RUF036
 pub(crate) fn none_not_at_end_of_union<'a>(checker: &mut Checker, union: &'a Expr) {
     let semantic = checker.semantic();
-    let mut none_exprs: SmallVec<[&Expr; 1]> = SmallVec::new();
-    let mut non_none_exprs: SmallVec<[&Expr; 1]> = SmallVec::new();
-    let mut last_expr: Option<&Expr> = None;
+    let mut last_none = None;
+    let mut last_expr: Option<&'a Expr> = None;
     let mut find_none = |expr: &'a Expr, _parent: &Expr| {
-        if matches!(expr, Expr::NoneLiteral(_)) {
-            none_exprs.push(expr);
-        } else {
-            non_none_exprs.push(expr);
+        if expr.is_none_literal_expr() {
+            last_none = Some(expr);
         }
+
         last_expr = Some(expr);
     };
 
@@ -69,40 +68,72 @@
     };
 
     // There must be at least one `None` expression.
-    let Some(last_none) = none_exprs.last() else {
+    let Some(last_none) = last_none else {
         return;
     };
 
     // If any of the `None` literals is last we do not emit.
-    if *last_none == last_expr {
+    if last_none == last_expr {
         return;
     }
 
-    for (pos, none_expr) in none_exprs.iter().with_position() {
-        let mut diagnostic = Diagnostic::new(NoneNotAtEndOfUnion, none_expr.range());
-        if matches!(pos, Position::Last | Position::Only) {
-            let mut elements = non_none_exprs
-                .iter()
-                .map(|expr| checker.locator().slice(expr.range()).to_string())
-                .chain(std::iter::once("None".to_string()));
-            let (range, separator) =
-                if let Expr::Subscript(ast::ExprSubscript { slice, .. }) = union {
-                    (slice.range(), ", ")
-                } else {
-                    (union.range(), " | ")
-                };
-            let applicability = if checker.comment_ranges().intersects(range) {
-                Applicability::Unsafe
-            } else {
-                Applicability::Safe
-            };
-            let fix = Fix::applicable_edit(
-                Edit::range_replacement(elements.join(separator), range),
-                applicability,
-            );
-            diagnostic.set_fix(fix);
-        }
+    let range = if let Expr::Subscript(ast::ExprSubscript { slice, .. }) = union {
+        slice.range()
+    } else {
+        union.range()
+    };
+
+    let preceding_separator = checker.tokens().before(last_none.start());
+
+    let applicability = if checker.comment_ranges().intersects(range) {
+        Applicability::Unsafe
+    } else {
+        Applicability::Safe
+    };
+
+    // ```python
+    // int | None | str
+    // ```
+    let (insertion, range) = if let Some(last) = preceding_separator
+        .last()
+        .filter(|last| matches!(last.kind(), TokenKind::Comma | TokenKind::Vbar))
+    {
+        let range = TextRange::new(last.start(), last_none.end());
+        (
+            Edit::insertion(checker.source().slice(range).to_string(), last_expr.end()),
+            range,
+        )
+    } else {
+        // ```python
+        // None | int
+        // int | (None | str) | Literal[4]
+        // ```
+        let separator_after = checker
+            .tokens()
+            .after(last_none.end())
+            .first()
+            .filter(|token| matches!(token.kind(), TokenKind::Comma | TokenKind::Vbar))
+            .unwrap();
+        (
+            Edit::insertion(
+                format!(
+                    "{separator} None",
+                    separator = if separator_after.kind() == TokenKind::Comma {
+                        ","
+                    } else {
+                        "|"
+                    }
+                ),
+                last_expr.end(),
+            ),
+            TextRange::new(last_none.start(), separator_after.end()),
+        )
+    };
+
+    let fix = Fix::applicable_edits(insertion, [Edit::range_deletion(range)], applicability);
+
+    let mut diagnostic = Diagnostic::new(NoneNotAtEndOfUnion, last_none.range());
+    diagnostic.set_fix(fix);
 
-        checker.diagnostics.push(diagnostic);
-    }
+    checker.diagnostics.push(diagnostic);
 }

applicability,
)
};
diagnostic.set_fix(fix);
Copy link
Member

Choose a reason for hiding this comment

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

I'm actually leaning towards only emitting a single diagnostic rather than emitting multiple diagnostics. This should also simplify the code a bit because we can change none_exprs to an Option only tracking the last_none rather than all None values

10 10 |
11 11 |
12 |-def func3(arg: None | None | int):
12 |+def func3(arg: int | None):
Copy link
Member

Choose a reason for hiding this comment

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

We should avoid removing duplicate None values. While the second None is useless, it's not what the rule is about

Comment on lines +207 to +208
44 |-def func10(x: U[int, U[None, list | set]]):
44 |+def func10(x: U[int, list, set, None]):
Copy link
Member

Choose a reason for hiding this comment

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

The fix here looks incorrect. It flattens the nested U types.

Copy link
Contributor Author

@harupy harupy Jan 6, 2025

Choose a reason for hiding this comment

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

@MichaReiser Do we need to fix this case? Can we only generate a fix when the type annotation has a single None and no nested unions?

Copy link
Member

Choose a reason for hiding this comment

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

I don't think we necessarily have to but we at least shouldn't provide a fix for those cases.

@dhruvmanila dhruvmanila added rule Implementing or modifying a lint rule preview Related to preview mode features labels Jan 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
fixes Related to suggested fixes for violations preview Related to preview mode features rule Implementing or modifying a lint rule
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature request: Autofix for none-not-at-end-of-union (RUF036)
6 participants