Compare commits

...

7 Commits

Author SHA1 Message Date
Zanie
a19e98a2c7 Fix false positive for S608 where SQL-like expression follows other text 2023-11-16 09:49:13 -06:00
Charlie Marsh
2424188bb2 Trim trailing empty strings when converting to f-strings (#8712)
## Summary

When converting from a `.format` call to an f-string, we can trim any
trailing empty tokens.

Closes https://github.com/astral-sh/ruff/issues/8683.
2023-11-15 23:14:49 -05:00
Charlie Marsh
a59172528c Add fix for future-required-type-annotation (#8711)
## Summary

We already support inserting imports for `I002` -- this PR just adds the
same fix for `FA102`, which is explicitly about `from __future__ import
annotations`.

Closes https://github.com/astral-sh/ruff/issues/8682.
2023-11-16 03:08:02 +00:00
Charlie Marsh
cd29761b9c Run unicode prefix rule over tokens (#8709)
## Summary

It seems like the range of an `ExprStringLiteral` can be somewhat
unreliable when the string is part of an implicit concatenation with an
f-string. Using the tokens themselves is more reliable.

Closes #8680.
Closes https://github.com/astral-sh/ruff/issues/7784.
2023-11-16 02:30:42 +00:00
Charlie Marsh
4ac78d5725 Treat display as a builtin in IPython (#8707)
## Summary

`display` is a special-cased builtin in IPython. This PR adds it to the
builtin namespace when analyzing IPython notebooks.

Closes https://github.com/astral-sh/ruff/issues/8702.
2023-11-16 01:58:44 +00:00
Alan Du
2083352ae3 Add autofix for PIE800 (#8668)
## Summary

This adds an autofix for PIE800 (unnecessary spread) -- whenever we see
a `**{...}` inside another dictionary literal, just delete the `**{` and
`}` to inline the key-value pairs. So `{"a": "b", **{"c": "d"}}` becomes
just `{"a": "b", "c": "d"}`.

I have enabled this just for preview mode.

## Test Plan

Updated the preview snapshot test.
2023-11-15 18:11:04 +00:00
Tuomas Siipola
0e2ece5217 Implement FURB136 (#8664)
## Summary

Implements
[FURB136](https://github.com/dosisod/refurb/blob/master/docs/checks.md#furb136-use-min-max)
that checks for `if` expressions that can be replaced with `min()` or
`max()` calls. See issue #1348 for more information.

This implementation diverges from Refurb's original implementation by
retaining the order of equal values. For example, Refurb suggest that
the following expressions:

```python
highest_score1 = score1 if score1 > score2 else score2
highest_score2 = score1 if score1 >= score2 else score2
```

should be to rewritten as:

```python
highest_score1 = max(score1, score2)
highest_score2 = max(score1, score2)
```

whereas this implementation provides more correct alternatives:

```python
highest_score1 = max(score2, score1)
highest_score2 = max(score1, score2)
```

## Test Plan

Unit test checks all eight possibilities.
2023-11-15 18:10:13 +00:00
45 changed files with 1168 additions and 109 deletions

View File

@@ -101,4 +101,5 @@ query = "INSERT table VALUES (%s)" % (var,)
query = "REPLACE INTO table VALUES (%s)" % (var,)
query = "REPLACE table VALUES (%s)" % (var,)
query = "Deselect something that is not SQL even though it has a ' from ' somewhere in %s." % "there"
not_a_query = "Deselect something that is not SQL even though it has a ' from ' somewhere in %s." % "there"
not_a_query = f"Please select a value from the list of possible variants: {variants}."

View File

@@ -1,9 +1,21 @@
{"foo": 1, **{"bar": 1}} # PIE800
{**{"bar": 10}, "a": "b"} # PIE800
foo({**foo, **{"bar": True}}) # PIE800
{**foo, **{"bar": 10}} # PIE800
{ # PIE800
"a": "b",
# Preserve
**{
# all
"bar": 10, # the
# comments
},
}
{**foo, **buzz, **{bar: 10}} # PIE800
{**foo, "bar": True } # OK

View File

@@ -0,0 +1,4 @@
"""Test for IPython-only builtins."""
x = 1
display(x)

View File

@@ -0,0 +1,74 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "af8fee97-f9aa-47c2-b34c-b109a5f083d6",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"1"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"1"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "acd5eb1e-6991-42b8-806f-20d17d7e571f",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"1"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"display(1)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f8f3f599-030e-48b3-bcd3-31d97f725c68",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -25,3 +25,6 @@ u = u
def hello():
return"Hello"
f"foo"u"bar"
f"foo" u"bar"

View File

@@ -207,3 +207,22 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# The fixed string will exceed the line length, but it's still smaller than the
# existing line length, so it's fine.
"<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
# When fixing, trim the trailing empty string.
raise ValueError("Conflicting configuration dicts: {!r} {!r}"
"".format(new_dict, d))
# When fixing, trim the trailing empty string.
raise ValueError("Conflicting configuration dicts: {!r} {!r}"
.format(new_dict, d))
raise ValueError(
"Conflicting configuration dicts: {!r} {!r}"
"".format(new_dict, d)
)
raise ValueError(
"Conflicting configuration dicts: {!r} {!r}"
"".format(new_dict, d)
)

View File

@@ -0,0 +1,25 @@
x = 1
y = 2
x if x > y else y # FURB136
x if x >= y else y # FURB136
x if x < y else y # FURB136
x if x <= y else y # FURB136
y if x > y else x # FURB136
y if x >= y else x # FURB136
y if x < y else x # FURB136
y if x <= y else x # FURB136
x + y if x > y else y # OK
x if (
x
> y
) else y # FURB136

View File

@@ -936,13 +936,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
flake8_trio::rules::zero_sleep_call(checker, call);
}
}
Expr::Dict(
dict @ ast::ExprDict {
keys,
values,
range: _,
},
) => {
Expr::Dict(dict) => {
if checker.any_enabled(&[
Rule::MultiValueRepeatedKeyLiteral,
Rule::MultiValueRepeatedKeyVariable,
@@ -950,7 +944,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
pyflakes::rules::repeated_keys(checker, dict);
}
if checker.enabled(Rule::UnnecessarySpread) {
flake8_pie::rules::unnecessary_spread(checker, keys, values);
flake8_pie::rules::unnecessary_spread(checker, dict);
}
}
Expr::Set(ast::ExprSet { elts, range: _ }) => {
@@ -1272,21 +1266,20 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.enabled(Rule::HardcodedTempFile) {
flake8_bandit::rules::hardcoded_tmp_directory(checker, string);
}
if checker.enabled(Rule::UnicodeKindPrefix) {
pyupgrade::rules::unicode_kind_prefix(checker, string);
}
if checker.source_type.is_stub() {
if checker.enabled(Rule::StringOrBytesTooLong) {
flake8_pyi::rules::string_or_bytes_too_long(checker, expr);
}
}
}
Expr::IfExp(ast::ExprIfExp {
test,
body,
orelse,
range: _,
}) => {
Expr::IfExp(
if_exp @ ast::ExprIfExp {
test,
body,
orelse,
range: _,
},
) => {
if checker.enabled(Rule::IfElseBlockInsteadOfDictGet) {
flake8_simplify::rules::if_exp_instead_of_dict_get(
checker, expr, test, body, orelse,
@@ -1301,6 +1294,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.enabled(Rule::IfExprWithTwistedArms) {
flake8_simplify::rules::twisted_arms_in_ifexpr(checker, expr, test, body, orelse);
}
if checker.enabled(Rule::IfExprMinMax) {
refurb::rules::if_expr_min_max(checker, if_exp);
}
}
Expr::ListComp(
comp @ ast::ExprListComp {

View File

@@ -54,7 +54,7 @@ use ruff_python_semantic::{
ModuleKind, NodeId, ScopeId, ScopeKind, SemanticModel, SemanticModelFlags, Snapshot,
StarImport, SubmoduleImport,
};
use ruff_python_stdlib::builtins::{BUILTINS, MAGIC_GLOBALS};
use ruff_python_stdlib::builtins::{IPYTHON_BUILTINS, MAGIC_GLOBALS, PYTHON_BUILTINS};
use ruff_source_file::Locator;
use crate::checkers::ast::deferred::Deferred;
@@ -1592,9 +1592,16 @@ impl<'a> Checker<'a> {
}
fn bind_builtins(&mut self) {
for builtin in BUILTINS
for builtin in PYTHON_BUILTINS
.iter()
.chain(MAGIC_GLOBALS.iter())
.chain(
self.source_type
.is_ipynb()
.then_some(IPYTHON_BUILTINS)
.into_iter()
.flatten(),
)
.copied()
.chain(self.settings.builtins.iter().map(String::as_str))
{

View File

@@ -91,6 +91,10 @@ pub(crate) fn check_tokens(
pycodestyle::rules::tab_indentation(&mut diagnostics, tokens, locator, indexer);
}
if settings.rules.enabled(Rule::UnicodeKindPrefix) {
pyupgrade::rules::unicode_kind_prefix(&mut diagnostics, tokens);
}
if settings.rules.any_enabled(&[
Rule::InvalidCharacterBackspace,
Rule::InvalidCharacterSub,

View File

@@ -947,6 +947,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Refurb, "131") => (RuleGroup::Nursery, rules::refurb::rules::DeleteFullSlice),
#[allow(deprecated)]
(Refurb, "132") => (RuleGroup::Nursery, rules::refurb::rules::CheckAndRemoveFromSet),
(Refurb, "136") => (RuleGroup::Preview, rules::refurb::rules::IfExprMinMax),
(Refurb, "140") => (RuleGroup::Preview, rules::refurb::rules::ReimplementedStarmap),
(Refurb, "145") => (RuleGroup::Preview, rules::refurb::rules::SliceCopy),
(Refurb, "148") => (RuleGroup::Preview, rules::refurb::rules::UnnecessaryEnumerate),

View File

@@ -7,7 +7,7 @@ use std::error::Error;
use anyhow::Result;
use libcst_native::{ImportAlias, Name, NameOrAttribute};
use ruff_python_ast::{self as ast, PySourceType, Stmt, Suite};
use ruff_python_ast::{self as ast, PySourceType, Stmt};
use ruff_text_size::{Ranged, TextSize};
use ruff_diagnostics::Edit;
@@ -26,7 +26,7 @@ mod insertion;
pub(crate) struct Importer<'a> {
/// The Python AST to which we are adding imports.
python_ast: &'a Suite,
python_ast: &'a [Stmt],
/// The [`Locator`] for the Python AST.
locator: &'a Locator<'a>,
/// The [`Stylist`] for the Python AST.
@@ -39,7 +39,7 @@ pub(crate) struct Importer<'a> {
impl<'a> Importer<'a> {
pub(crate) fn new(
python_ast: &'a Suite,
python_ast: &'a [Stmt],
locator: &'a Locator<'a>,
stylist: &'a Stylist<'a>,
) -> Self {

View File

@@ -639,7 +639,7 @@ mod tests {
use crate::registry::Rule;
use crate::source_kind::SourceKind;
use crate::test::{test_contents, test_notebook_path, TestedNotebook};
use crate::test::{assert_notebook_path, test_contents, TestedNotebook};
use crate::{assert_messages, settings};
/// Construct a path to a Jupyter notebook in the `resources/test/fixtures/jupyter` directory.
@@ -655,7 +655,7 @@ mod tests {
messages,
source_notebook,
..
} = test_notebook_path(
} = assert_notebook_path(
&actual,
expected,
&settings::LinterSettings::for_rule(Rule::UnsortedImports),
@@ -672,7 +672,7 @@ mod tests {
messages,
source_notebook,
..
} = test_notebook_path(
} = assert_notebook_path(
&actual,
expected,
&settings::LinterSettings::for_rule(Rule::UnusedImport),
@@ -689,7 +689,7 @@ mod tests {
messages,
source_notebook,
..
} = test_notebook_path(
} = assert_notebook_path(
&actual,
expected,
&settings::LinterSettings::for_rule(Rule::UnusedVariable),
@@ -706,7 +706,7 @@ mod tests {
let TestedNotebook {
linted_notebook: fixed_notebook,
..
} = test_notebook_path(
} = assert_notebook_path(
actual_path,
&expected_path,
&settings::LinterSettings::for_rule(Rule::UnusedImport),

View File

@@ -297,6 +297,7 @@ impl Rule {
| Rule::TabIndentation
| Rule::TrailingCommaOnBareTuple
| Rule::TypeCommentInStub
| Rule::UnicodeKindPrefix
| Rule::UselessSemicolon
| Rule::UTF8EncodingDeclaration => LintSource::Tokens,
Rule::IOError => LintSource::Io,

View File

@@ -13,7 +13,15 @@ use crate::checkers::ast::Checker;
use super::super::helpers::string_literal;
static SQL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)\b(select\s.+\sfrom\s|delete\s+from\s|(insert|replace)\s.+\svalues\s|update\s.+\sset\s)")
// We pass this generated expression strings like:
// "SELECT " + val + " FROM " + table
// f'delete from table where var = {var}'
// "\n SELECT *\n FROM table\n WHERE var = {}\n ".format(var)
//
// To avoid false positives, we:
// - Require the SQL to be at the start of the expression, allowing for tokens that are not a part of the string
// - Require whole-word matches for SQL keywords
Regex::new(r#"(?i)\A(\"|f\"|\'|f\'|\\|\\n|\s)*\b(select\s.+\sfrom\s|delete\s+from\s|(insert|replace)\s.+\svalues\s|update\s.+\sset\s)"#)
.unwrap()
});
@@ -51,7 +59,7 @@ fn has_string_literal(expr: &Expr) -> bool {
}
fn matches_sql_statement(string: &str) -> bool {
SQL_REGEX.is_match(string)
SQL_REGEX.is_match(string.trim_start())
}
fn matches_string_format_expression(expr: &Expr, semantic: &SemanticModel) -> bool {

View File

@@ -476,7 +476,7 @@ S608.py:102:9: S608 Possible SQL injection vector through string-based query con
102 | query = "REPLACE table VALUES (%s)" % (var,)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ S608
103 |
104 | query = "Deselect something that is not SQL even though it has a ' from ' somewhere in %s." % "there"
104 | not_a_query = "Deselect something that is not SQL even though it has a ' from ' somewhere in %s." % "there"
|

View File

@@ -1,5 +1,14 @@
use ruff_python_stdlib::builtins::is_builtin;
use ruff_python_ast::PySourceType;
use ruff_python_stdlib::builtins::{is_ipython_builtin, is_python_builtin};
pub(super) fn shadows_builtin(name: &str, ignorelist: &[String]) -> bool {
is_builtin(name) && ignorelist.iter().all(|ignore| ignore != name)
pub(super) fn shadows_builtin(
name: &str,
ignorelist: &[String],
source_type: PySourceType,
) -> bool {
if is_python_builtin(name) || source_type.is_ipynb() && is_ipython_builtin(name) {
ignorelist.iter().all(|ignore| ignore != name)
} else {
false
}
}

View File

@@ -67,6 +67,7 @@ pub(crate) fn builtin_argument_shadowing(checker: &mut Checker, parameter: &Para
if shadows_builtin(
parameter.name.as_str(),
&checker.settings.flake8_builtins.builtins_ignorelist,
checker.source_type,
) {
checker.diagnostics.push(Diagnostic::new(
BuiltinArgumentShadowing {

View File

@@ -74,7 +74,11 @@ pub(crate) fn builtin_attribute_shadowing(
name: &str,
range: TextRange,
) {
if shadows_builtin(name, &checker.settings.flake8_builtins.builtins_ignorelist) {
if shadows_builtin(
name,
&checker.settings.flake8_builtins.builtins_ignorelist,
checker.source_type,
) {
// Ignore shadowing within `TypedDict` definitions, since these are only accessible through
// subscripting and not through attribute access.
if class_def
@@ -102,7 +106,11 @@ pub(crate) fn builtin_method_shadowing(
decorator_list: &[Decorator],
range: TextRange,
) {
if shadows_builtin(name, &checker.settings.flake8_builtins.builtins_ignorelist) {
if shadows_builtin(
name,
&checker.settings.flake8_builtins.builtins_ignorelist,
checker.source_type,
) {
// Ignore some standard-library methods. Ideally, we'd ignore all overridden methods, since
// those should be flagged on the superclass, but that's more difficult.
if is_standard_library_override(name, class_def, checker.semantic()) {

View File

@@ -60,7 +60,11 @@ impl Violation for BuiltinVariableShadowing {
/// A001
pub(crate) fn builtin_variable_shadowing(checker: &mut Checker, name: &str, range: TextRange) {
if shadows_builtin(name, &checker.settings.flake8_builtins.builtins_ignorelist) {
if shadows_builtin(
name,
&checker.settings.flake8_builtins.builtins_ignorelist,
checker.source_type,
) {
checker.diagnostics.push(Diagnostic::new(
BuiltinVariableShadowing {
name: name.to_string(),

View File

@@ -1,11 +1,13 @@
use ruff_python_ast::Expr;
use std::fmt;
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_text_size::Ranged;
use ruff_python_ast::imports::{AnyImport, ImportFrom};
use ruff_python_ast::Expr;
use ruff_text_size::{Ranged, TextSize};
use crate::checkers::ast::Checker;
use crate::importer::Importer;
/// ## What it does
/// Checks for uses of PEP 585- and PEP 604-style type annotations in Python
@@ -42,6 +44,10 @@ use crate::checkers::ast::Checker;
/// ...
/// ```
///
/// ## Fix safety
/// This rule's fix is marked as unsafe, as adding `from __future__ import annotations`
/// may change the semantics of the program.
///
/// ## Options
/// - `target-version`
#[violation]
@@ -66,18 +72,28 @@ impl fmt::Display for Reason {
}
}
impl Violation for FutureRequiredTypeAnnotation {
impl AlwaysFixableViolation for FutureRequiredTypeAnnotation {
#[derive_message_formats]
fn message(&self) -> String {
let FutureRequiredTypeAnnotation { reason } = self;
format!("Missing `from __future__ import annotations`, but uses {reason}")
}
fn fix_title(&self) -> String {
format!("Add `from __future__ import annotations`")
}
}
/// FA102
pub(crate) fn future_required_type_annotation(checker: &mut Checker, expr: &Expr, reason: Reason) {
checker.diagnostics.push(Diagnostic::new(
FutureRequiredTypeAnnotation { reason },
expr.range(),
));
let mut diagnostic = Diagnostic::new(FutureRequiredTypeAnnotation { reason }, expr.range());
if let Some(python_ast) = checker.semantic().definitions.python_ast() {
let required_import =
AnyImport::ImportFrom(ImportFrom::member("__future__", "annotations"));
diagnostic.set_fix(Fix::unsafe_edit(
Importer::new(python_ast, checker.locator(), checker.stylist())
.add_import(&required_import, TextSize::default()),
));
}
checker.diagnostics.push(diagnostic);
}

View File

@@ -1,19 +1,33 @@
---
source: crates/ruff_linter/src/rules/flake8_future_annotations/mod.rs
---
no_future_import_uses_lowercase.py:2:13: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
no_future_import_uses_lowercase.py:2:13: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
1 | def main() -> None:
2 | a_list: list[str] = []
| ^^^^^^^^^ FA102
3 | a_list.append("hello")
|
= help: Add `from __future__ import annotations`
no_future_import_uses_lowercase.py:6:14: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] = []
3 4 | a_list.append("hello")
no_future_import_uses_lowercase.py:6:14: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
6 | def hello(y: dict[str, int]) -> None:
| ^^^^^^^^^^^^^^ FA102
7 | del y
|
= help: Add `from __future__ import annotations`
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] = []
3 4 | a_list.append("hello")

View File

@@ -1,34 +1,62 @@
---
source: crates/ruff_linter/src/rules/flake8_future_annotations/mod.rs
---
no_future_import_uses_union.py:2:13: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
no_future_import_uses_union.py:2:13: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
1 | def main() -> None:
2 | a_list: list[str] | None = []
| ^^^^^^^^^ FA102
3 | a_list.append("hello")
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union.py:2:13: FA102 Missing `from __future__ import annotations`, but uses PEP 604 union
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] | None = []
3 4 | a_list.append("hello")
no_future_import_uses_union.py:2:13: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 604 union
|
1 | def main() -> None:
2 | a_list: list[str] | None = []
| ^^^^^^^^^^^^^^^^ FA102
3 | a_list.append("hello")
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union.py:6:14: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] | None = []
3 4 | a_list.append("hello")
no_future_import_uses_union.py:6:14: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
6 | def hello(y: dict[str, int] | None) -> None:
| ^^^^^^^^^^^^^^ FA102
7 | del y
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union.py:6:14: FA102 Missing `from __future__ import annotations`, but uses PEP 604 union
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] | None = []
3 4 | a_list.append("hello")
no_future_import_uses_union.py:6:14: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 604 union
|
6 | def hello(y: dict[str, int] | None) -> None:
| ^^^^^^^^^^^^^^^^^^^^^ FA102
7 | del y
|
= help: Add `from __future__ import annotations`
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str] | None = []
3 4 | a_list.append("hello")

View File

@@ -1,52 +1,94 @@
---
source: crates/ruff_linter/src/rules/flake8_future_annotations/mod.rs
---
no_future_import_uses_union_inner.py:2:13: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
no_future_import_uses_union_inner.py:2:13: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
1 | def main() -> None:
2 | a_list: list[str | None] = []
| ^^^^^^^^^^^^^^^^ FA102
3 | a_list.append("hello")
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union_inner.py:2:18: FA102 Missing `from __future__ import annotations`, but uses PEP 604 union
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")
no_future_import_uses_union_inner.py:2:18: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 604 union
|
1 | def main() -> None:
2 | a_list: list[str | None] = []
| ^^^^^^^^^^ FA102
3 | a_list.append("hello")
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union_inner.py:6:14: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")
no_future_import_uses_union_inner.py:6:14: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
6 | def hello(y: dict[str | None, int]) -> None:
| ^^^^^^^^^^^^^^^^^^^^^ FA102
7 | z: tuple[str, str | None, str] = tuple(y)
8 | del z
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union_inner.py:6:19: FA102 Missing `from __future__ import annotations`, but uses PEP 604 union
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")
no_future_import_uses_union_inner.py:6:19: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 604 union
|
6 | def hello(y: dict[str | None, int]) -> None:
| ^^^^^^^^^^ FA102
7 | z: tuple[str, str | None, str] = tuple(y)
8 | del z
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union_inner.py:7:8: FA102 Missing `from __future__ import annotations`, but uses PEP 585 collection
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")
no_future_import_uses_union_inner.py:7:8: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 585 collection
|
6 | def hello(y: dict[str | None, int]) -> None:
7 | z: tuple[str, str | None, str] = tuple(y)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ FA102
8 | del z
|
= help: Add `from __future__ import annotations`
no_future_import_uses_union_inner.py:7:19: FA102 Missing `from __future__ import annotations`, but uses PEP 604 union
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")
no_future_import_uses_union_inner.py:7:19: FA102 [*] Missing `from __future__ import annotations`, but uses PEP 604 union
|
6 | def hello(y: dict[str | None, int]) -> None:
7 | z: tuple[str, str | None, str] = tuple(y)
| ^^^^^^^^^^ FA102
8 | del z
|
= help: Add `from __future__ import annotations`
Unsafe fix
1 |+from __future__ import annotations
1 2 | def main() -> None:
2 3 | a_list: list[str | None] = []
3 4 | a_list.append("hello")

View File

@@ -32,6 +32,7 @@ mod tests {
}
#[test_case(Rule::UnnecessaryPlaceholder, Path::new("PIE790.py"))]
#[test_case(Rule::UnnecessarySpread, Path::new("PIE800.py"))]
#[test_case(Rule::ReimplementedContainerBuiltin, Path::new("PIE807.py"))]
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(

View File

@@ -1,9 +1,10 @@
use ruff_python_ast::Expr;
use ruff_python_ast::{self as ast, Expr};
use ruff_diagnostics::Diagnostic;
use ruff_diagnostics::Violation;
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_text_size::Ranged;
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextSize};
use crate::checkers::ast::Checker;
@@ -32,22 +33,76 @@ use crate::checkers::ast::Checker;
pub struct UnnecessarySpread;
impl Violation for UnnecessarySpread {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
format!("Unnecessary spread `**`")
}
fn fix_title(&self) -> Option<String> {
Some(format!("Remove unnecessary dict"))
}
}
/// PIE800
pub(crate) fn unnecessary_spread(checker: &mut Checker, keys: &[Option<Expr>], values: &[Expr]) {
for item in keys.iter().zip(values.iter()) {
pub(crate) fn unnecessary_spread(checker: &mut Checker, dict: &ast::ExprDict) {
// The first "end" is the start of the dictionary, immediately following the open bracket.
let mut prev_end = dict.start() + TextSize::from(1);
for item in dict.keys.iter().zip(dict.values.iter()) {
if let (None, value) = item {
// We only care about when the key is None which indicates a spread `**`
// inside a dict.
if let Expr::Dict(_) = value {
let diagnostic = Diagnostic::new(UnnecessarySpread, value.range());
if let Expr::Dict(inner) = value {
let mut diagnostic = Diagnostic::new(UnnecessarySpread, value.range());
if checker.settings.preview.is_enabled() {
if let Some(fix) = unnecessary_spread_fix(inner, prev_end, checker.locator()) {
diagnostic.set_fix(fix);
}
}
checker.diagnostics.push(diagnostic);
}
}
prev_end = item.1.end();
}
}
/// Generate a [`Fix`] to remove an unnecessary dictionary spread.
fn unnecessary_spread_fix(
dict: &ast::ExprDict,
prev_end: TextSize,
locator: &Locator,
) -> Option<Fix> {
// Find the `**` token preceding the spread.
let doublestar = SimpleTokenizer::starts_at(prev_end, locator.contents())
.find(|tok| matches!(tok.kind(), SimpleTokenKind::DoubleStar))?;
if let Some(last) = dict.values.last() {
// Ex) `**{a: 1, b: 2}`
let mut edits = vec![];
for tok in SimpleTokenizer::starts_at(last.end(), locator.contents()).skip_trivia() {
match tok.kind() {
SimpleTokenKind::Comma => {
edits.push(Edit::range_deletion(tok.range()));
}
SimpleTokenKind::RBrace => {
edits.push(Edit::range_deletion(tok.range()));
break;
}
_ => {}
}
}
Some(Fix::safe_edits(
// Delete the first `**{`
Edit::deletion(doublestar.start(), dict.start() + TextSize::from(1)),
// Delete the trailing `}`
edits,
))
} else {
// Ex) `**{}`
Some(Fix::safe_edit(Edit::deletion(
doublestar.start(),
dict.end(),
)))
}
}

View File

@@ -6,37 +6,67 @@ PIE800.py:1:14: PIE800 Unnecessary spread `**`
1 | {"foo": 1, **{"bar": 1}} # PIE800
| ^^^^^^^^^^ PIE800
2 |
3 | foo({**foo, **{"bar": True}}) # PIE800
3 | {**{"bar": 10}, "a": "b"} # PIE800
|
= help: Remove unnecessary dict
PIE800.py:3:15: PIE800 Unnecessary spread `**`
PIE800.py:3:4: PIE800 Unnecessary spread `**`
|
1 | {"foo": 1, **{"bar": 1}} # PIE800
2 |
3 | foo({**foo, **{"bar": True}}) # PIE800
3 | {**{"bar": 10}, "a": "b"} # PIE800
| ^^^^^^^^^^^ PIE800
4 |
5 | foo({**foo, **{"bar": True}}) # PIE800
|
= help: Remove unnecessary dict
PIE800.py:5:15: PIE800 Unnecessary spread `**`
|
3 | {**{"bar": 10}, "a": "b"} # PIE800
4 |
5 | foo({**foo, **{"bar": True}}) # PIE800
| ^^^^^^^^^^^^^ PIE800
4 |
5 | {**foo, **{"bar": 10}} # PIE800
6 |
7 | {**foo, **{"bar": 10}} # PIE800
|
= help: Remove unnecessary dict
PIE800.py:5:11: PIE800 Unnecessary spread `**`
PIE800.py:7:11: PIE800 Unnecessary spread `**`
|
3 | foo({**foo, **{"bar": True}}) # PIE800
4 |
5 | {**foo, **{"bar": 10}} # PIE800
5 | foo({**foo, **{"bar": True}}) # PIE800
6 |
7 | {**foo, **{"bar": 10}} # PIE800
| ^^^^^^^^^^^ PIE800
6 |
7 | {**foo, **buzz, **{bar: 10}} # PIE800
|
PIE800.py:7:19: PIE800 Unnecessary spread `**`
|
5 | {**foo, **{"bar": 10}} # PIE800
6 |
7 | {**foo, **buzz, **{bar: 10}} # PIE800
| ^^^^^^^^^ PIE800
8 |
9 | {**foo, "bar": True } # OK
9 | { # PIE800
|
= help: Remove unnecessary dict
PIE800.py:12:7: PIE800 Unnecessary spread `**`
|
10 | "a": "b",
11 | # Preserve
12 | **{
| _______^
13 | | # all
14 | | "bar": 10, # the
15 | | # comments
16 | | },
| |_____^ PIE800
17 | }
|
= help: Remove unnecessary dict
PIE800.py:19:19: PIE800 Unnecessary spread `**`
|
17 | }
18 |
19 | {**foo, **buzz, **{bar: 10}} # PIE800
| ^^^^^^^^^ PIE800
20 |
21 | {**foo, "bar": True } # OK
|
= help: Remove unnecessary dict

View File

@@ -0,0 +1,134 @@
---
source: crates/ruff_linter/src/rules/flake8_pie/mod.rs
---
PIE800.py:1:14: PIE800 [*] Unnecessary spread `**`
|
1 | {"foo": 1, **{"bar": 1}} # PIE800
| ^^^^^^^^^^ PIE800
2 |
3 | {**{"bar": 10}, "a": "b"} # PIE800
|
= help: Remove unnecessary dict
Safe fix
1 |-{"foo": 1, **{"bar": 1}} # PIE800
1 |+{"foo": 1, "bar": 1} # PIE800
2 2 |
3 3 | {**{"bar": 10}, "a": "b"} # PIE800
4 4 |
PIE800.py:3:4: PIE800 [*] Unnecessary spread `**`
|
1 | {"foo": 1, **{"bar": 1}} # PIE800
2 |
3 | {**{"bar": 10}, "a": "b"} # PIE800
| ^^^^^^^^^^^ PIE800
4 |
5 | foo({**foo, **{"bar": True}}) # PIE800
|
= help: Remove unnecessary dict
Safe fix
1 1 | {"foo": 1, **{"bar": 1}} # PIE800
2 2 |
3 |-{**{"bar": 10}, "a": "b"} # PIE800
3 |+{"bar": 10, "a": "b"} # PIE800
4 4 |
5 5 | foo({**foo, **{"bar": True}}) # PIE800
6 6 |
PIE800.py:5:15: PIE800 [*] Unnecessary spread `**`
|
3 | {**{"bar": 10}, "a": "b"} # PIE800
4 |
5 | foo({**foo, **{"bar": True}}) # PIE800
| ^^^^^^^^^^^^^ PIE800
6 |
7 | {**foo, **{"bar": 10}} # PIE800
|
= help: Remove unnecessary dict
Safe fix
2 2 |
3 3 | {**{"bar": 10}, "a": "b"} # PIE800
4 4 |
5 |-foo({**foo, **{"bar": True}}) # PIE800
5 |+foo({**foo, "bar": True}) # PIE800
6 6 |
7 7 | {**foo, **{"bar": 10}} # PIE800
8 8 |
PIE800.py:7:11: PIE800 [*] Unnecessary spread `**`
|
5 | foo({**foo, **{"bar": True}}) # PIE800
6 |
7 | {**foo, **{"bar": 10}} # PIE800
| ^^^^^^^^^^^ PIE800
8 |
9 | { # PIE800
|
= help: Remove unnecessary dict
Safe fix
4 4 |
5 5 | foo({**foo, **{"bar": True}}) # PIE800
6 6 |
7 |-{**foo, **{"bar": 10}} # PIE800
7 |+{**foo, "bar": 10} # PIE800
8 8 |
9 9 | { # PIE800
10 10 | "a": "b",
PIE800.py:12:7: PIE800 [*] Unnecessary spread `**`
|
10 | "a": "b",
11 | # Preserve
12 | **{
| _______^
13 | | # all
14 | | "bar": 10, # the
15 | | # comments
16 | | },
| |_____^ PIE800
17 | }
|
= help: Remove unnecessary dict
Safe fix
9 9 | { # PIE800
10 10 | "a": "b",
11 11 | # Preserve
12 |- **{
12 |+
13 13 | # all
14 |- "bar": 10, # the
14 |+ "bar": 10 # the
15 15 | # comments
16 |- },
16 |+ ,
17 17 | }
18 18 |
19 19 | {**foo, **buzz, **{bar: 10}} # PIE800
PIE800.py:19:19: PIE800 [*] Unnecessary spread `**`
|
17 | }
18 |
19 | {**foo, **buzz, **{bar: 10}} # PIE800
| ^^^^^^^^^ PIE800
20 |
21 | {**foo, "bar": True } # OK
|
= help: Remove unnecessary dict
Safe fix
16 16 | },
17 17 | }
18 18 |
19 |-{**foo, **buzz, **{bar: 10}} # PIE800
19 |+{**foo, **buzz, bar: 10} # PIE800
20 20 |
21 21 | {**foo, "bar": True } # OK
22 22 |

View File

@@ -138,6 +138,8 @@ mod tests {
#[test_case(Rule::UndefinedName, Path::new("F821_18.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_19.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_20.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_21.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_22.ipynb"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_0.py"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_2.py"))]

View File

@@ -0,0 +1,11 @@
---
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
---
F821_21.py:4:1: F821 Undefined name `display`
|
3 | x = 1
4 | display(x)
| ^^^^^^^ F821
|

View File

@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
---

View File

@@ -384,7 +384,21 @@ pub(crate) fn f_strings(
contents.push_str(&fstring);
prev_end = range.end();
}
contents.push_str(checker.locator().slice(TextRange::new(prev_end, end)));
// If the remainder is non-empty, add it to the contents.
let rest = checker.locator().slice(TextRange::new(prev_end, end));
if !lexer::lex_starts_at(rest, Mode::Expression, prev_end)
.flatten()
.all(|(token, _)| match token {
Tok::Comment(_) | Tok::Newline | Tok::NonLogicalNewline | Tok::Indent | Tok::Dedent => {
true
}
Tok::String { value, .. } => value.is_empty(),
_ => false,
})
{
contents.push_str(rest);
}
// If necessary, add a space between any leading keyword (`return`, `yield`, `assert`, etc.)
// and the string. For example, `return"foo"` is valid, but `returnf"foo"` is not.

View File

@@ -1,9 +1,10 @@
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::ExprStringLiteral;
use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::checkers::ast::Checker;
use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::{StringKind, Tok};
use ruff_text_size::{Ranged, TextRange, TextSize};
/// ## What it does
/// Checks for uses of the Unicode kind prefix (`u`) in strings.
@@ -39,13 +40,19 @@ impl AlwaysFixableViolation for UnicodeKindPrefix {
}
/// UP025
pub(crate) fn unicode_kind_prefix(checker: &mut Checker, string: &ExprStringLiteral) {
if string.unicode {
let mut diagnostic = Diagnostic::new(UnicodeKindPrefix, string.range);
diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(TextRange::at(
string.start(),
TextSize::from(1),
))));
checker.diagnostics.push(diagnostic);
pub(crate) fn unicode_kind_prefix(diagnostics: &mut Vec<Diagnostic>, tokens: &[LexResult]) {
for (token, range) in tokens.iter().flatten() {
if let Tok::String {
kind: StringKind::Unicode,
..
} = token
{
let mut diagnostic = Diagnostic::new(UnicodeKindPrefix, *range);
diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(TextRange::at(
range.start(),
TextSize::from(1),
))));
diagnostics.push(diagnostic);
}
}
}

View File

@@ -248,4 +248,37 @@ UP025.py:19:5: UP025 [*] Remove unicode literals from strings
21 21 | # These should not change
22 22 | u = "Hello"
UP025.py:29:7: UP025 [*] Remove unicode literals from strings
|
27 | return"Hello"
28 |
29 | f"foo"u"bar"
| ^^^^^^ UP025
30 | f"foo" u"bar"
|
= help: Remove unicode prefix
Safe fix
26 26 | def hello():
27 27 | return"Hello"
28 28 |
29 |-f"foo"u"bar"
29 |+f"foo""bar"
30 30 | f"foo" u"bar"
UP025.py:30:8: UP025 [*] Remove unicode literals from strings
|
29 | f"foo"u"bar"
30 | f"foo" u"bar"
| ^^^^^^ UP025
|
= help: Remove unicode prefix
Safe fix
27 27 | return"Hello"
28 28 |
29 29 | f"foo"u"bar"
30 |-f"foo" u"bar"
30 |+f"foo" "bar"

View File

@@ -962,6 +962,8 @@ UP032_0.py:209:1: UP032 [*] Use f-string instead of `format` call
208 | # existing line length, so it's fine.
209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ UP032
210 |
211 | # When fixing, trim the trailing empty string.
|
= help: Convert to f-string
@@ -971,5 +973,98 @@ UP032_0.py:209:1: UP032 [*] Use f-string instead of `format` call
208 208 | # existing line length, so it's fine.
209 |-"<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
209 |+f"<Customer: {self.internal_ids}, {self.external_ids}, {self.properties}, {self.tags}, {self.others}>"
210 210 |
211 211 | # When fixing, trim the trailing empty string.
212 212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032_0.py:212:18: UP032 [*] Use f-string instead of `format` call
|
211 | # When fixing, trim the trailing empty string.
212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
213 | | "".format(new_dict, d))
| |_______________________________________^ UP032
214 |
215 | # When fixing, trim the trailing empty string.
|
= help: Convert to f-string
Safe fix
209 209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
210 210 |
211 211 | # When fixing, trim the trailing empty string.
212 |-raise ValueError("Conflicting configuration dicts: {!r} {!r}"
213 |- "".format(new_dict, d))
212 |+raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}")
214 213 |
215 214 | # When fixing, trim the trailing empty string.
216 215 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032_0.py:216:18: UP032 [*] Use f-string instead of `format` call
|
215 | # When fixing, trim the trailing empty string.
216 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
217 | | .format(new_dict, d))
| |_____________________________________^ UP032
218 |
219 | raise ValueError(
|
= help: Convert to f-string
Safe fix
213 213 | "".format(new_dict, d))
214 214 |
215 215 | # When fixing, trim the trailing empty string.
216 |-raise ValueError("Conflicting configuration dicts: {!r} {!r}"
217 |- .format(new_dict, d))
216 |+raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}")
218 217 |
219 218 | raise ValueError(
220 219 | "Conflicting configuration dicts: {!r} {!r}"
UP032_0.py:220:5: UP032 [*] Use f-string instead of `format` call
|
219 | raise ValueError(
220 | "Conflicting configuration dicts: {!r} {!r}"
| _____^
221 | | "".format(new_dict, d)
| |__________________________^ UP032
222 | )
|
= help: Convert to f-string
Safe fix
217 217 | .format(new_dict, d))
218 218 |
219 219 | raise ValueError(
220 |- "Conflicting configuration dicts: {!r} {!r}"
221 |- "".format(new_dict, d)
220 |+ f"Conflicting configuration dicts: {new_dict!r} {d!r}"
222 221 | )
223 222 |
224 223 | raise ValueError(
UP032_0.py:225:5: UP032 [*] Use f-string instead of `format` call
|
224 | raise ValueError(
225 | "Conflicting configuration dicts: {!r} {!r}"
| _____^
226 | | "".format(new_dict, d)
| |__________________________^ UP032
227 |
228 | )
|
= help: Convert to f-string
Safe fix
222 222 | )
223 223 |
224 224 | raise ValueError(
225 |- "Conflicting configuration dicts: {!r} {!r}"
226 |- "".format(new_dict, d)
225 |+ f"Conflicting configuration dicts: {new_dict!r} {d!r}"
227 226 |
228 227 | )

View File

@@ -18,6 +18,7 @@ mod tests {
#[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))]
#[test_case(Rule::DeleteFullSlice, Path::new("FURB131.py"))]
#[test_case(Rule::CheckAndRemoveFromSet, Path::new("FURB132.py"))]
#[test_case(Rule::IfExprMinMax, Path::new("FURB136.py"))]
#[test_case(Rule::ReimplementedStarmap, Path::new("FURB140.py"))]
#[test_case(Rule::SliceCopy, Path::new("FURB145.py"))]
#[test_case(Rule::UnnecessaryEnumerate, Path::new("FURB148.py"))]

View File

@@ -0,0 +1,178 @@
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::comparable::ComparableExpr;
use ruff_python_ast::{self as ast, CmpOp, Expr};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::fix::snippet::SourceCodeSnippet;
/// ## What it does
/// Checks for `if` expressions that can be replaced with `min()` or `max()`
/// calls.
///
/// ## Why is this bad?
/// An `if` expression that selects the lesser or greater of two
/// sub-expressions can be replaced with a `min()` or `max()` call
/// respectively. When possible, prefer `min()` and `max()`, as they're more
/// concise and readable than the equivalent `if` expression.
///
/// ## Example
/// ```python
/// highest_score = score1 if score1 > score2 else score2
/// ```
///
/// Use instead:
/// ```python
/// highest_score = max(score2, score1)
/// ```
///
/// ## References
/// - [Python documentation: `min`](https://docs.python.org/3.11/library/functions.html#min)
/// - [Python documentation: `max`](https://docs.python.org/3.11/library/functions.html#max)
#[violation]
pub struct IfExprMinMax {
min_max: MinMax,
expression: SourceCodeSnippet,
replacement: SourceCodeSnippet,
}
impl Violation for IfExprMinMax {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
let Self {
min_max,
expression,
replacement,
} = self;
match (expression.full_display(), replacement.full_display()) {
(_, None) => {
format!("Replace `if` expression with `{min_max}` call")
}
(None, Some(replacement)) => {
format!("Replace `if` expression with `{replacement}`")
}
(Some(expression), Some(replacement)) => {
format!("Replace `{expression}` with `{replacement}`")
}
}
}
fn fix_title(&self) -> Option<String> {
let Self {
replacement,
min_max,
..
} = self;
if let Some(replacement) = replacement.full_display() {
Some(format!("Replace with `{replacement}`"))
} else {
Some(format!("Replace with `{min_max}` call"))
}
}
}
/// FURB136
pub(crate) fn if_expr_min_max(checker: &mut Checker, if_exp: &ast::ExprIfExp) {
let Expr::Compare(ast::ExprCompare {
left,
ops,
comparators,
..
}) = if_exp.test.as_ref()
else {
return;
};
// Ignore, e.g., `foo < bar < baz`.
let [op] = ops.as_slice() else {
return;
};
// Determine whether to use `min()` or `max()`, and whether to flip the
// order of the arguments, which is relevant for breaking ties.
let (mut min_max, mut flip_args) = match op {
CmpOp::Gt => (MinMax::Max, true),
CmpOp::GtE => (MinMax::Max, false),
CmpOp::Lt => (MinMax::Min, true),
CmpOp::LtE => (MinMax::Min, false),
_ => return,
};
let [right] = comparators.as_slice() else {
return;
};
let body_cmp = ComparableExpr::from(if_exp.body.as_ref());
let orelse_cmp = ComparableExpr::from(if_exp.orelse.as_ref());
let left_cmp = ComparableExpr::from(left);
let right_cmp = ComparableExpr::from(right);
if body_cmp == right_cmp && orelse_cmp == left_cmp {
min_max = min_max.reverse();
flip_args = !flip_args;
} else if body_cmp != left_cmp || orelse_cmp != right_cmp {
return;
}
let (arg1, arg2) = if flip_args {
(right, left.as_ref())
} else {
(left.as_ref(), right)
};
let replacement = format!(
"{min_max}({}, {})",
checker.generator().expr(arg1),
checker.generator().expr(arg2),
);
let mut diagnostic = Diagnostic::new(
IfExprMinMax {
min_max,
expression: SourceCodeSnippet::from_str(checker.locator().slice(if_exp)),
replacement: SourceCodeSnippet::from_str(replacement.as_str()),
},
if_exp.range(),
);
if checker.semantic().is_builtin(min_max.as_str()) {
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
replacement,
if_exp.range(),
)));
}
checker.diagnostics.push(diagnostic);
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum MinMax {
Min,
Max,
}
impl MinMax {
fn reverse(self) -> Self {
match self {
Self::Min => Self::Max,
Self::Max => Self::Min,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Min => "min",
Self::Max => "max",
}
}
}
impl std::fmt::Display for MinMax {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}

View File

@@ -1,5 +1,6 @@
pub(crate) use check_and_remove_from_set::*;
pub(crate) use delete_full_slice::*;
pub(crate) use if_expr_min_max::*;
pub(crate) use implicit_cwd::*;
pub(crate) use isinstance_type_none::*;
pub(crate) use print_empty_string::*;
@@ -13,6 +14,7 @@ pub(crate) use unnecessary_enumerate::*;
mod check_and_remove_from_set;
mod delete_full_slice;
mod if_expr_min_max;
mod implicit_cwd;
mod isinstance_type_none;
mod print_empty_string;

View File

@@ -6,7 +6,7 @@ use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_python_codegen::Generator;
use ruff_python_semantic::analyze::typing::is_list;
use ruff_python_semantic::{Binding, BindingId, DefinitionId, SemanticModel};
use ruff_python_semantic::{Binding, BindingId, SemanticModel};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
@@ -183,8 +183,7 @@ fn match_consecutive_appends<'a>(
let siblings: &[Stmt] = if semantic.at_top_level() {
// If the statement is at the top level, we should go to the parent module.
// Module is available in the definitions list.
let module = semantic.definitions[DefinitionId::module()].as_module()?;
module.python_ast
semantic.definitions.python_ast()?
} else {
// Otherwise, go to the parent, and take its body as a sequence of siblings.
semantic

View File

@@ -0,0 +1,194 @@
---
source: crates/ruff_linter/src/rules/refurb/mod.rs
---
FURB136.py:4:1: FURB136 [*] Replace `x if x > y else y` with `max(y, x)`
|
2 | y = 2
3 |
4 | x if x > y else y # FURB136
| ^^^^^^^^^^^^^^^^^ FURB136
5 |
6 | x if x >= y else y # FURB136
|
= help: Replace with `max(y, x)`
Safe fix
1 1 | x = 1
2 2 | y = 2
3 3 |
4 |-x if x > y else y # FURB136
4 |+max(y, x) # FURB136
5 5 |
6 6 | x if x >= y else y # FURB136
7 7 |
FURB136.py:6:1: FURB136 [*] Replace `x if x >= y else y` with `max(x, y)`
|
4 | x if x > y else y # FURB136
5 |
6 | x if x >= y else y # FURB136
| ^^^^^^^^^^^^^^^^^^ FURB136
7 |
8 | x if x < y else y # FURB136
|
= help: Replace with `max(x, y)`
Safe fix
3 3 |
4 4 | x if x > y else y # FURB136
5 5 |
6 |-x if x >= y else y # FURB136
6 |+max(x, y) # FURB136
7 7 |
8 8 | x if x < y else y # FURB136
9 9 |
FURB136.py:8:1: FURB136 [*] Replace `x if x < y else y` with `min(y, x)`
|
6 | x if x >= y else y # FURB136
7 |
8 | x if x < y else y # FURB136
| ^^^^^^^^^^^^^^^^^ FURB136
9 |
10 | x if x <= y else y # FURB136
|
= help: Replace with `min(y, x)`
Safe fix
5 5 |
6 6 | x if x >= y else y # FURB136
7 7 |
8 |-x if x < y else y # FURB136
8 |+min(y, x) # FURB136
9 9 |
10 10 | x if x <= y else y # FURB136
11 11 |
FURB136.py:10:1: FURB136 [*] Replace `x if x <= y else y` with `min(x, y)`
|
8 | x if x < y else y # FURB136
9 |
10 | x if x <= y else y # FURB136
| ^^^^^^^^^^^^^^^^^^ FURB136
11 |
12 | y if x > y else x # FURB136
|
= help: Replace with `min(x, y)`
Safe fix
7 7 |
8 8 | x if x < y else y # FURB136
9 9 |
10 |-x if x <= y else y # FURB136
10 |+min(x, y) # FURB136
11 11 |
12 12 | y if x > y else x # FURB136
13 13 |
FURB136.py:12:1: FURB136 [*] Replace `y if x > y else x` with `min(x, y)`
|
10 | x if x <= y else y # FURB136
11 |
12 | y if x > y else x # FURB136
| ^^^^^^^^^^^^^^^^^ FURB136
13 |
14 | y if x >= y else x # FURB136
|
= help: Replace with `min(x, y)`
Safe fix
9 9 |
10 10 | x if x <= y else y # FURB136
11 11 |
12 |-y if x > y else x # FURB136
12 |+min(x, y) # FURB136
13 13 |
14 14 | y if x >= y else x # FURB136
15 15 |
FURB136.py:14:1: FURB136 [*] Replace `y if x >= y else x` with `min(y, x)`
|
12 | y if x > y else x # FURB136
13 |
14 | y if x >= y else x # FURB136
| ^^^^^^^^^^^^^^^^^^ FURB136
15 |
16 | y if x < y else x # FURB136
|
= help: Replace with `min(y, x)`
Safe fix
11 11 |
12 12 | y if x > y else x # FURB136
13 13 |
14 |-y if x >= y else x # FURB136
14 |+min(y, x) # FURB136
15 15 |
16 16 | y if x < y else x # FURB136
17 17 |
FURB136.py:16:1: FURB136 [*] Replace `y if x < y else x` with `max(x, y)`
|
14 | y if x >= y else x # FURB136
15 |
16 | y if x < y else x # FURB136
| ^^^^^^^^^^^^^^^^^ FURB136
17 |
18 | y if x <= y else x # FURB136
|
= help: Replace with `max(x, y)`
Safe fix
13 13 |
14 14 | y if x >= y else x # FURB136
15 15 |
16 |-y if x < y else x # FURB136
16 |+max(x, y) # FURB136
17 17 |
18 18 | y if x <= y else x # FURB136
19 19 |
FURB136.py:18:1: FURB136 [*] Replace `y if x <= y else x` with `max(y, x)`
|
16 | y if x < y else x # FURB136
17 |
18 | y if x <= y else x # FURB136
| ^^^^^^^^^^^^^^^^^^ FURB136
19 |
20 | x + y if x > y else y # OK
|
= help: Replace with `max(y, x)`
Safe fix
15 15 |
16 16 | y if x < y else x # FURB136
17 17 |
18 |-y if x <= y else x # FURB136
18 |+max(y, x) # FURB136
19 19 |
20 20 | x + y if x > y else y # OK
21 21 |
FURB136.py:22:1: FURB136 [*] Replace `if` expression with `max(y, x)`
|
20 | x + y if x > y else y # OK
21 |
22 | / x if (
23 | | x
24 | | > y
25 | | ) else y # FURB136
| |________^ FURB136
|
= help: Replace with `max(y, x)`
Safe fix
19 19 |
20 20 | x + y if x > y else y # OK
21 21 |
22 |-x if (
23 |- x
24 |- > y
25 |-) else y # FURB136
22 |+max(y, x) # FURB136

View File

@@ -38,12 +38,13 @@ pub(crate) fn test_resource_path(path: impl AsRef<Path>) -> std::path::PathBuf {
Path::new("./resources/test/").join(path)
}
/// Run [`check_path`] on a file in the `resources/test/fixtures` directory.
/// Run [`check_path`] on a Python file in the `resources/test/fixtures` directory.
#[cfg(not(fuzzing))]
pub(crate) fn test_path(path: impl AsRef<Path>, settings: &LinterSettings) -> Result<Vec<Message>> {
let path = test_resource_path("fixtures").join(path);
let contents = std::fs::read_to_string(&path)?;
Ok(test_contents(&SourceKind::Python(contents), &path, settings).0)
let source_type = PySourceType::from(&path);
let source_kind = SourceKind::from_path(path.as_ref(), source_type)?.expect("valid source");
Ok(test_contents(&source_kind, &path, settings).0)
}
#[cfg(not(fuzzing))]
@@ -54,7 +55,7 @@ pub(crate) struct TestedNotebook {
}
#[cfg(not(fuzzing))]
pub(crate) fn test_notebook_path(
pub(crate) fn assert_notebook_path(
path: impl AsRef<Path>,
expected: impl AsRef<Path>,
settings: &LinterSettings,

View File

@@ -248,6 +248,12 @@ impl<'a> Definitions<'a> {
ContextualizedDefinitions(definitions.raw)
}
/// Returns a reference to the Python AST.
pub fn python_ast(&self) -> Option<&'a [Stmt]> {
let module = self[DefinitionId::module()].as_module()?;
Some(module.python_ast)
}
}
impl<'a> Deref for Definitions<'a> {

View File

@@ -1,7 +1,7 @@
/// A list of all Python builtins.
///
/// Intended to be kept in sync with [`is_builtin`].
pub const BUILTINS: &[&str] = &[
/// Intended to be kept in sync with [`is_python_builtin`].
pub const PYTHON_BUILTINS: &[&str] = &[
"ArithmeticError",
"AssertionError",
"AttributeError",
@@ -161,6 +161,11 @@ pub const BUILTINS: &[&str] = &[
"zip",
];
/// A list of all builtins that are available in IPython.
///
/// Intended to be kept in sync with [`is_ipython_builtin`].
pub const IPYTHON_BUILTINS: &[&str] = &["display"];
/// Globally defined names which are not attributes of the builtins module, or
/// are only present on some platforms.
pub const MAGIC_GLOBALS: &[&str] = &[
@@ -173,9 +178,9 @@ pub const MAGIC_GLOBALS: &[&str] = &[
/// Returns `true` if the given name is that of a Python builtin.
///
/// Intended to be kept in sync with [`BUILTINS`].
pub fn is_builtin(name: &str) -> bool {
// Constructed by converting the `BUILTINS` slice to a `match` expression.
/// Intended to be kept in sync with [`PYTHON_BUILTINS`].
pub fn is_python_builtin(name: &str) -> bool {
// Constructed by converting the `PYTHON_BUILTINS` slice to a `match` expression.
matches!(
name,
"ArithmeticError"
@@ -345,3 +350,11 @@ pub fn is_iterator(name: &str) -> bool {
"enumerate" | "filter" | "map" | "reversed" | "zip" | "iter"
)
}
/// Returns `true` if the given name is that of an IPython builtin.
///
/// Intended to be kept in sync with [`IPYTHON_BUILTINS`].
pub fn is_ipython_builtin(name: &str) -> bool {
// Constructed by converting the `IPYTHON_BUILTINS` slice to a `match` expression.
matches!(name, "display")
}

View File

@@ -1151,6 +1151,7 @@ mod tests {
Rule::DirectLoggerInstantiation,
Rule::InvalidGetLoggerArgument,
Rule::IsinstanceTypeNone,
Rule::IfExprMinMax,
Rule::ManualDictComprehension,
Rule::ReimplementedStarmap,
Rule::SliceCopy,

1
ruff.schema.json generated
View File

@@ -2823,6 +2823,7 @@
"FURB13",
"FURB131",
"FURB132",
"FURB136",
"FURB14",
"FURB140",
"FURB145",