[`ruff`] Validate arguments before offering a fix (`RUF056`) (#18631)

## Summary

Fixes https://github.com/astral-sh/ruff/issues/18628 by avoiding a fix
if there are "unknown" arguments, including any keyword arguments and
more than the expected 2 positional arguments.

I'm a bit on the fence here because it also seems reasonable to avoid a
diagnostic at all. Especially in the final test case I added (`not
my_dict.get(default=False)`), the hint suggesting to remove
`default=False` seems pretty misleading. At the same time, I guess the
diagnostic at least calls attention to the call site, which could help
to fix the missing argument bug too.

As I commented on the issue, I double-checked that keyword arguments are
invalid as far back as Python 3.8, even though the positional-only
marker was only added to the
[docs](https://docs.python.org/3.11/library/stdtypes.html#dict.get) in
3.12 (link is to 3.11, showing its absence).

## Test Plan

New tests derived from the bug report

## Stabilization

This was planned to be stabilized in 0.12, and the bug is less severe
than some others, but if there's nobody opposed, I will plan **not to
stabilize** this one for now.
This commit is contained in:
Brent Westbrook 2025-06-13 19:07:02 -04:00 committed by GitHub
parent 6d56ee803e
commit e4423044f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 156 additions and 128 deletions

View File

@ -149,23 +149,39 @@ value = not my_dict.get("key", 0) # [RUF056]
value = not my_dict.get("key", 0.0) # [RUF056] value = not my_dict.get("key", 0.0) # [RUF056]
value = not my_dict.get("key", "") # [RUF056] value = not my_dict.get("key", "") # [RUF056]
# testing dict.get call using kwargs
value = not my_dict.get(key="key", default=False) # [RUF056]
value = not my_dict.get(default=[], key="key") # [RUF056]
# testing invalid dict.get call with inline comment # testing invalid dict.get call with inline comment
value = not my_dict.get("key", # comment1 value = not my_dict.get("key", # comment1
[] # comment2 [] # comment2
) # [RUF056] ) # [RUF056]
# testing invalid dict.get call with kwargs and inline comment # regression tests for https://github.com/astral-sh/ruff/issues/18628
value = not my_dict.get(key="key", # comment1 # we should avoid fixes when there are "unknown" arguments present, including
default=False # comment2 # extra positional arguments, either of the positional-only arguments passed as
) # [RUF056] # a keyword, or completely unknown keywords.
value = not my_dict.get(default=[], # comment1
key="key" # comment2
) # [RUF056]
# testing invalid dict.get calls # extra positional
value = not my_dict.get(key="key", other="something", default=False) not my_dict.get("key", False, "?!")
value = not my_dict.get(default=False, other="something", key="test")
# `default` is positional-only, so these are invalid
not my_dict.get("key", default=False)
not my_dict.get(key="key", default=False)
not my_dict.get(default=[], key="key")
not my_dict.get(default=False)
not my_dict.get(key="key", other="something", default=False)
not my_dict.get(default=False, other="something", key="test")
# comments don't really matter here because of the kwargs but include them for
# completeness
not my_dict.get(
key="key", # comment1
default=False, # comment2
) # comment 3
not my_dict.get(
default=[], # comment1
key="key", # comment2
) # comment 3
# the fix is arguably okay here because the same `takes no keyword arguments`
# TypeError is raised at runtime before and after the fix, but we still bail
# out for having an unrecognized number of arguments
not my_dict.get("key", False, foo=...)

View File

@ -5,7 +5,7 @@ use ruff_text_size::Ranged;
use crate::checkers::ast::Checker; use crate::checkers::ast::Checker;
use crate::fix::edits::{Parentheses, remove_argument}; use crate::fix::edits::{Parentheses, remove_argument};
use crate::{AlwaysFixableViolation, Applicability, Fix}; use crate::{Applicability, Fix, FixAvailability, Violation};
/// ## What it does /// ## What it does
/// Checks for `dict.get(key, falsy_value)` calls in boolean test positions. /// Checks for `dict.get(key, falsy_value)` calls in boolean test positions.
@ -28,21 +28,34 @@ use crate::{AlwaysFixableViolation, Applicability, Fix};
/// ``` /// ```
/// ///
/// ## Fix safety /// ## Fix safety
/// This rule's fix is marked as safe, unless the `dict.get()` call contains comments between arguments. ///
/// This rule's fix is marked as safe, unless the `dict.get()` call contains comments between
/// arguments that will be deleted.
///
/// ## Fix availability
///
/// This rule's fix is unavailable in cases where invalid arguments are provided to `dict.get`. As
/// shown in the [documentation], `dict.get` takes two positional-only arguments, so invalid cases
/// are identified by the presence of more than two arguments or any keyword arguments.
///
/// [documentation]: https://docs.python.org/3.13/library/stdtypes.html#dict.get
#[derive(ViolationMetadata)] #[derive(ViolationMetadata)]
pub(crate) struct FalsyDictGetFallback; pub(crate) struct FalsyDictGetFallback;
impl AlwaysFixableViolation for FalsyDictGetFallback { impl Violation for FalsyDictGetFallback {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
"Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.".to_string() "Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.".to_string()
} }
fn fix_title(&self) -> String { fn fix_title(&self) -> Option<String> {
"Remove falsy fallback from `dict.get()`".to_string() Some("Remove falsy fallback from `dict.get()`".to_string())
} }
} }
/// RUF056
pub(crate) fn falsy_dict_get_fallback(checker: &Checker, expr: &Expr) { pub(crate) fn falsy_dict_get_fallback(checker: &Checker, expr: &Expr) {
let semantic = checker.semantic(); let semantic = checker.semantic();
@ -89,6 +102,16 @@ pub(crate) fn falsy_dict_get_fallback(checker: &Checker, expr: &Expr) {
let mut diagnostic = checker.report_diagnostic(FalsyDictGetFallback, fallback_arg.range()); let mut diagnostic = checker.report_diagnostic(FalsyDictGetFallback, fallback_arg.range());
// All arguments to `dict.get` are positional-only.
if !call.arguments.keywords.is_empty() {
return;
}
// And there are only two of them, at most.
if call.arguments.args.len() > 2 {
return;
}
let comment_ranges = checker.comment_ranges(); let comment_ranges = checker.comment_ranges();
// Determine applicability based on the presence of comments // Determine applicability based on the presence of comments

View File

@ -323,7 +323,7 @@ RUF056.py:149:32: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in
149 |+value = not my_dict.get("key") # [RUF056] 149 |+value = not my_dict.get("key") # [RUF056]
150 150 | value = not my_dict.get("key", "") # [RUF056] 150 150 | value = not my_dict.get("key", "") # [RUF056]
151 151 | 151 151 |
152 152 | # testing dict.get call using kwargs 152 152 | # testing invalid dict.get call with inline comment
RUF056.py:150:32: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy. RUF056.py:150:32: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
@ -332,7 +332,7 @@ RUF056.py:150:32: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in
150 | value = not my_dict.get("key", "") # [RUF056] 150 | value = not my_dict.get("key", "") # [RUF056]
| ^^ RUF056 | ^^ RUF056
151 | 151 |
152 | # testing dict.get call using kwargs 152 | # testing invalid dict.get call with inline comment
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
@ -343,142 +343,131 @@ RUF056.py:150:32: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in
150 |-value = not my_dict.get("key", "") # [RUF056] 150 |-value = not my_dict.get("key", "") # [RUF056]
150 |+value = not my_dict.get("key") # [RUF056] 150 |+value = not my_dict.get("key") # [RUF056]
151 151 | 151 151 |
152 152 | # testing dict.get call using kwargs 152 152 | # testing invalid dict.get call with inline comment
153 153 | value = not my_dict.get(key="key", default=False) # [RUF056] 153 153 | value = not my_dict.get("key", # comment1
RUF056.py:153:36: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy. RUF056.py:154:22: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
152 | # testing dict.get call using kwargs 152 | # testing invalid dict.get call with inline comment
153 | value = not my_dict.get(key="key", default=False) # [RUF056] 153 | value = not my_dict.get("key", # comment1
| ^^^^^^^^^^^^^ RUF056 154 | [] # comment2
154 | value = not my_dict.get(default=[], key="key") # [RUF056] | ^^ RUF056
155 | ) # [RUF056]
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
Safe fix Unsafe fix
150 150 | value = not my_dict.get("key", "") # [RUF056] 150 150 | value = not my_dict.get("key", "") # [RUF056]
151 151 | 151 151 |
152 152 | # testing dict.get call using kwargs 152 152 | # testing invalid dict.get call with inline comment
153 |-value = not my_dict.get(key="key", default=False) # [RUF056] 153 |-value = not my_dict.get("key", # comment1
153 |+value = not my_dict.get(key="key") # [RUF056] 154 |- [] # comment2
154 154 | value = not my_dict.get(default=[], key="key") # [RUF056] 153 |+value = not my_dict.get("key" # comment2
155 155 | 155 154 | ) # [RUF056]
156 156 | # testing invalid dict.get call with inline comment 156 155 |
157 156 | # regression tests for https://github.com/astral-sh/ruff/issues/18628
RUF056.py:154:25: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy. RUF056.py:163:24: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
152 | # testing dict.get call using kwargs 162 | # extra positional
153 | value = not my_dict.get(key="key", default=False) # [RUF056] 163 | not my_dict.get("key", False, "?!")
154 | value = not my_dict.get(default=[], key="key") # [RUF056] | ^^^^^ RUF056
164 |
165 | # `default` is positional-only, so these are invalid
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:166:24: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
165 | # `default` is positional-only, so these are invalid
166 | not my_dict.get("key", default=False)
| ^^^^^^^^^^^^^ RUF056
167 | not my_dict.get(key="key", default=False)
168 | not my_dict.get(default=[], key="key")
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:167:28: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
165 | # `default` is positional-only, so these are invalid
166 | not my_dict.get("key", default=False)
167 | not my_dict.get(key="key", default=False)
| ^^^^^^^^^^^^^ RUF056
168 | not my_dict.get(default=[], key="key")
169 | not my_dict.get(default=False)
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:168:17: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
166 | not my_dict.get("key", default=False)
167 | not my_dict.get(key="key", default=False)
168 | not my_dict.get(default=[], key="key")
| ^^^^^^^^^^ RUF056 | ^^^^^^^^^^ RUF056
155 | 169 | not my_dict.get(default=False)
156 | # testing invalid dict.get call with inline comment 170 | not my_dict.get(key="key", other="something", default=False)
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
Safe fix RUF056.py:169:17: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
151 151 |
152 152 | # testing dict.get call using kwargs
153 153 | value = not my_dict.get(key="key", default=False) # [RUF056]
154 |-value = not my_dict.get(default=[], key="key") # [RUF056]
154 |+value = not my_dict.get(key="key") # [RUF056]
155 155 |
156 156 | # testing invalid dict.get call with inline comment
157 157 | value = not my_dict.get("key", # comment1
RUF056.py:158:22: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
156 | # testing invalid dict.get call with inline comment 167 | not my_dict.get(key="key", default=False)
157 | value = not my_dict.get("key", # comment1 168 | not my_dict.get(default=[], key="key")
158 | [] # comment2 169 | not my_dict.get(default=False)
| ^^ RUF056
159 | ) # [RUF056]
|
= help: Remove falsy fallback from `dict.get()`
Unsafe fix
154 154 | value = not my_dict.get(default=[], key="key") # [RUF056]
155 155 |
156 156 | # testing invalid dict.get call with inline comment
157 |-value = not my_dict.get("key", # comment1
158 |- [] # comment2
157 |+value = not my_dict.get("key" # comment2
159 158 | ) # [RUF056]
160 159 |
161 160 | # testing invalid dict.get call with kwargs and inline comment
RUF056.py:163:25: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
161 | # testing invalid dict.get call with kwargs and inline comment
162 | value = not my_dict.get(key="key", # comment1
163 | default=False # comment2
| ^^^^^^^^^^^^^ RUF056 | ^^^^^^^^^^^^^ RUF056
164 | ) # [RUF056] 170 | not my_dict.get(key="key", other="something", default=False)
165 | value = not my_dict.get(default=[], # comment1 171 | not my_dict.get(default=False, other="something", key="test")
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
Unsafe fix RUF056.py:170:47: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
159 159 | ) # [RUF056]
160 160 |
161 161 | # testing invalid dict.get call with kwargs and inline comment
162 |-value = not my_dict.get(key="key", # comment1
163 |- default=False # comment2
162 |+value = not my_dict.get(key="key" # comment2
164 163 | ) # [RUF056]
165 164 | value = not my_dict.get(default=[], # comment1
166 165 | key="key" # comment2
RUF056.py:165:25: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
163 | default=False # comment2 168 | not my_dict.get(default=[], key="key")
164 | ) # [RUF056] 169 | not my_dict.get(default=False)
165 | value = not my_dict.get(default=[], # comment1 170 | not my_dict.get(key="key", other="something", default=False)
| ^^^^^^^^^^^^^ RUF056
171 | not my_dict.get(default=False, other="something", key="test")
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:171:17: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
169 | not my_dict.get(default=False)
170 | not my_dict.get(key="key", other="something", default=False)
171 | not my_dict.get(default=False, other="something", key="test")
| ^^^^^^^^^^^^^ RUF056
172 |
173 | # comments don't really matter here because of the kwargs but include them for
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:177:5: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
175 | not my_dict.get(
176 | key="key", # comment1
177 | default=False, # comment2
| ^^^^^^^^^^^^^ RUF056
178 | ) # comment 3
179 | not my_dict.get(
|
= help: Remove falsy fallback from `dict.get()`
RUF056.py:180:5: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
178 | ) # comment 3
179 | not my_dict.get(
180 | default=[], # comment1
| ^^^^^^^^^^ RUF056 | ^^^^^^^^^^ RUF056
166 | key="key" # comment2 181 | key="key", # comment2
167 | ) # [RUF056] 182 | ) # comment 3
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
Unsafe fix RUF056.py:187:24: RUF056 Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
162 162 | value = not my_dict.get(key="key", # comment1
163 163 | default=False # comment2
164 164 | ) # [RUF056]
165 |-value = not my_dict.get(default=[], # comment1
165 |+value = not my_dict.get(# comment1
166 166 | key="key" # comment2
167 167 | ) # [RUF056]
168 168 |
RUF056.py:170:55: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
| |
169 | # testing invalid dict.get calls 185 | # TypeError is raised at runtime before and after the fix, but we still bail
170 | value = not my_dict.get(key="key", other="something", default=False) 186 | # out for having an unrecognized number of arguments
| ^^^^^^^^^^^^^ RUF056 187 | not my_dict.get("key", False, foo=...)
171 | value = not my_dict.get(default=False, other="something", key="test") | ^^^^^ RUF056
| |
= help: Remove falsy fallback from `dict.get()` = help: Remove falsy fallback from `dict.get()`
Safe fix
167 167 | ) # [RUF056]
168 168 |
169 169 | # testing invalid dict.get calls
170 |-value = not my_dict.get(key="key", other="something", default=False)
170 |+value = not my_dict.get(key="key", other="something")
171 171 | value = not my_dict.get(default=False, other="something", key="test")
RUF056.py:171:25: RUF056 [*] Avoid providing a falsy fallback to `dict.get()` in boolean test positions. The default fallback `None` is already falsy.
|
169 | # testing invalid dict.get calls
170 | value = not my_dict.get(key="key", other="something", default=False)
171 | value = not my_dict.get(default=False, other="something", key="test")
| ^^^^^^^^^^^^^ RUF056
|
= help: Remove falsy fallback from `dict.get()`
Safe fix
168 168 |
169 169 | # testing invalid dict.get calls
170 170 | value = not my_dict.get(key="key", other="something", default=False)
171 |-value = not my_dict.get(default=False, other="something", key="test")
171 |+value = not my_dict.get(other="something", key="test")