Add rule removal infrastructure (#9691)

Similar to https://github.com/astral-sh/ruff/pull/9689 — retains removed
rules for better error messages and documentation but removed rules
_cannot_ be used in any context.

Removes PLR1706 as a useful test case and something we want to
accomplish in #9680 anyway. The rule was in preview so we do not need to
deprecate it first.

Closes https://github.com/astral-sh/ruff/issues/9007

## Test plan

<img width="1110" alt="Rules table"
src="ac9fa682-623c-44aa-8e51-d8ab0d308355">

<img width="1110" alt="Rule page"
src="05850b2d-7ca5-49bb-8df8-bb931bab25cd">
This commit is contained in:
Zanie Blue 2024-01-30 11:45:49 -06:00
parent a0ef087e73
commit e0bc08a758
13 changed files with 127 additions and 411 deletions

View file

@ -1091,6 +1091,39 @@ fn preview_enabled_group_ignore() {
"###);
}
#[test]
fn removed_direct() {
// Selection of a removed rule should fail
let mut cmd = RuffCheck::default().args(["--select", "PLR1706"]).build();
assert_cmd_snapshot!(cmd, @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
ruff failed
Cause: Rule `PLR1706` was removed and cannot be selected.
"###);
}
#[test]
fn removed_indirect() {
// Selection _including_ a removed rule without matching should not fail
// nor should the rule be used
let mut cmd = RuffCheck::default().args(["--select", "PLR"]).build();
assert_cmd_snapshot!(cmd.pass_stdin(r###"
# This would have been a PLR1706 violation
x, y = 1, 2
maximum = x >= y and x or y
"""###), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"###);
}
#[test]
fn deprecated_direct() {
// Selection of a deprecated rule without preview enabled should still work

View file

@ -45,6 +45,14 @@ pub(crate) fn main(args: &Args) -> Result<()> {
output.push('\n');
}
if rule.is_removed() {
output.push_str(
r"**Warning: This rule has been removed and its documentation is only available for historical reasons.**",
);
output.push('\n');
output.push('\n');
}
let fix_availability = rule.fixable();
if matches!(
fix_availability,

View file

@ -15,6 +15,7 @@ use ruff_workspace::options_base::OptionsMetadata;
const FIX_SYMBOL: &str = "🛠️";
const PREVIEW_SYMBOL: &str = "🧪";
const REMOVED_SYMBOL: &str = "";
const WARNING_SYMBOL: &str = "⚠️";
const STABLE_SYMBOL: &str = "✔️";
const SPACER: &str = "&nbsp;&nbsp;&nbsp;&nbsp;";
@ -26,6 +27,9 @@ fn generate_table(table_out: &mut String, rules: impl IntoIterator<Item = Rule>,
table_out.push('\n');
for rule in rules {
let status_token = match rule.group() {
RuleGroup::Removed => {
format!("<span title='Rule has been removed'>{REMOVED_SYMBOL}</span>")
}
RuleGroup::Deprecated => {
format!("<span title='Rule has been deprecated'>{WARNING_SYMBOL}</span>")
}
@ -62,9 +66,20 @@ fn generate_table(table_out: &mut String, rules: impl IntoIterator<Item = Rule>,
Cow::Borrowed(message)
};
// Start and end of style spans
let mut ss = "";
let mut se = "";
if rule.is_removed() {
ss = "<span style='opacity: 0.5', title='This rule has been removed'>";
se = "</span>";
} else if rule.is_deprecated() {
ss = "<span style='opacity: 0.8', title='This rule has been deprecated'>";
se = "</span>";
}
#[allow(clippy::or_fun_call)]
table_out.push_str(&format!(
"| {0}{1} {{ #{0}{1} }} | {2} | {3} | {4} |",
"| {ss}{0}{1}{se} {{ #{0}{1} }} | {ss}{2}{se} | {ss}{3}{se} | {ss}{4}{se} |",
linter.common_prefix(),
linter.code_for_rule(rule).unwrap(),
rule.explanation()
@ -101,6 +116,11 @@ pub(crate) fn generate() -> String {
));
table_out.push_str("<br />");
table_out.push_str(&format!(
"{SPACER}{REMOVED_SYMBOL}{SPACER} The rule has been removed only the documentation is available."
));
table_out.push_str("<br />");
table_out.push_str(&format!(
"{SPACER}{FIX_SYMBOL}{SPACER} The rule is automatically fixable by the `--fix` command-line option."
));

View file

@ -1,73 +0,0 @@
# OK
1<2 and 'b' and 'c'
1<2 or 'a' and 'b'
1<2 and 'a'
1<2 or 'a'
2>1
1<2 and 'a' or 'b' and 'c'
1<2 and 'a' or 'b' or 'c'
1<2 and 'a' or 'b' or 'c' or (lambda x: x+1)
1<2 and 'a' or 'b' or (lambda x: x+1) or 'c'
default = 'default'
if (not isinstance(default, bool) and isinstance(default, int)) \
or (isinstance(default, str) and default):
pass
docid, token = None, None
(docid is None and token is None) or (docid is not None and token is not None)
vendor, os_version = 'darwin', '14'
vendor == "debian" and os_version in ["12"] or vendor == "ubuntu" and os_version in []
# Don't emit if the parent is an `if` statement.
if (task_id in task_dict and task_dict[task_id] is not task) \
or task_id in used_group_ids:
pass
no_target, is_x64, target = True, False, 'target'
if (no_target and not is_x64) or target == 'ARM_APPL_RUST_TARGET':
pass
# Don't emit if the parent is a `bool_op` expression.
isinstance(val, str) and ((len(val) == 7 and val[0] == "#") or val in enums.NamedColor)
# Errors
1<2 and 'a' or 'b'
(lambda x: x+1) and 'a' or 'b'
'a' and (lambda x: x+1) or 'orange'
val = '#0000FF'
(len(val) == 7 and val[0] == "#") or val in {'green'}
marker = 'marker'
isinstance(marker, dict) and 'field' in marker or marker in {}
def has_oranges(oranges, apples=None) -> bool:
return apples and False or oranges
[x for x in l if a and b or c]
{x: y for x in l if a and b or c}
{x for x in l if a and b or c}
new_list = [
x
for sublist in all_lists
if a and b or c
for x in sublist
if (isinstance(operator, list) and x in operator) or x != operator
]

View file

@ -1508,9 +1508,6 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
if checker.enabled(Rule::RepeatedEqualityComparison) {
pylint::rules::repeated_equality_comparison(checker, bool_op);
}
if checker.enabled(Rule::AndOrTernary) {
pylint::rules::and_or_ternary(checker, bool_op);
}
if checker.enabled(Rule::UnnecessaryKeyCheck) {
ruff::rules::unnecessary_key_check(checker, expr);
}

View file

@ -55,6 +55,8 @@ pub enum RuleGroup {
/// The rule has been deprecated, warnings will be displayed during selection in stable
/// and errors will be raised if used with preview mode enabled.
Deprecated,
/// The rule has been removed, errors will be displayed on use.
Removed,
/// Legacy category for unstable rules, supports backwards compatible selection.
#[deprecated(note = "Use `RuleGroup::Preview` for new rules instead")]
Nursery,
@ -268,7 +270,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Pylint, "R1704") => (RuleGroup::Preview, rules::pylint::rules::RedefinedArgumentFromLocal),
(Pylint, "R1711") => (RuleGroup::Stable, rules::pylint::rules::UselessReturn),
(Pylint, "R1714") => (RuleGroup::Stable, rules::pylint::rules::RepeatedEqualityComparison),
(Pylint, "R1706") => (RuleGroup::Preview, rules::pylint::rules::AndOrTernary),
(Pylint, "R1706") => (RuleGroup::Removed, rules::pylint::rules::AndOrTernary),
(Pylint, "R1722") => (RuleGroup::Stable, rules::pylint::rules::SysExitAlias),
(Pylint, "R1733") => (RuleGroup::Preview, rules::pylint::rules::UnnecessaryDictIndexLookup),
(Pylint, "R1736") => (RuleGroup::Preview, rules::pylint::rules::UnnecessaryListIndexLookup),

View file

@ -213,6 +213,8 @@ impl RuleSelector {
|| (preview_enabled && (matches!(self, RuleSelector::Rule { .. }) || !preview_require_explicit))
// Deprecated rules are excluded in preview mode unless explicitly selected
|| (rule.is_deprecated() && (!preview_enabled || matches!(self, RuleSelector::Rule { .. })))
// Removed rules are included if explicitly selected but will error downstream
|| (rule.is_removed() && matches!(self, RuleSelector::Rule { .. }))
})
}
}
@ -247,6 +249,8 @@ pub struct PreviewOptions {
#[cfg(feature = "schemars")]
mod schema {
use std::str::FromStr;
use itertools::Itertools;
use schemars::JsonSchema;
use schemars::_serde_json::Value;
@ -290,6 +294,16 @@ mod schema {
(!prefix.is_empty()).then(|| prefix.to_string())
})),
)
.filter(|p| {
// Exclude any prefixes where all of the rules are removed
if let Ok(Self::Rule { prefix, .. } | Self::Prefix { prefix, .. }) =
RuleSelector::from_str(p)
{
!prefix.rules().all(|rule| rule.is_removed())
} else {
true
}
})
.sorted()
.map(Value::String)
.collect(),

View file

@ -20,7 +20,6 @@ mod tests {
use crate::settings::LinterSettings;
use crate::test::test_path;
#[test_case(Rule::AndOrTernary, Path::new("and_or_ternary.py"))]
#[test_case(Rule::AssertOnStringLiteral, Path::new("assert_on_string_literal.py"))]
#[test_case(Rule::AwaitOutsideAsync, Path::new("await_outside_async.py"))]
#[test_case(Rule::BadOpenMode, Path::new("bad_open_mode.py"))]

View file

@ -1,13 +1,10 @@
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{
BoolOp, Expr, ExprBoolOp, ExprDictComp, ExprIfExp, ExprListComp, ExprSetComp,
};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
use crate::fix::snippet::SourceCodeSnippet;
use ruff_diagnostics::Violation;
use ruff_macros::violation;
/// ## Removal
/// This rule was removed from Ruff because it was common for it to introduce behavioral changes.
/// See [#9007](https://github.com/astral-sh/ruff/issues/9007) for more information.
///
/// ## What it does
/// Checks for uses of the known pre-Python 2.5 ternary syntax.
///
@ -31,100 +28,15 @@ use crate::fix::snippet::SourceCodeSnippet;
/// maximum = x if x >= y else y
/// ```
#[violation]
pub struct AndOrTernary {
ternary: SourceCodeSnippet,
}
impl Violation for AndOrTernary {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
if let Some(ternary) = self.ternary.full_display() {
format!("Consider using if-else expression (`{ternary}`)")
} else {
format!("Consider using if-else expression")
}
}
fn fix_title(&self) -> Option<String> {
Some(format!("Convert to if-else expression"))
}
}
/// Returns `Some((condition, true_value, false_value))`, if `bool_op` is of the form `condition and true_value or false_value`.
fn parse_and_or_ternary(bool_op: &ExprBoolOp) -> Option<(&Expr, &Expr, &Expr)> {
if bool_op.op != BoolOp::Or {
return None;
}
let [expr, false_value] = bool_op.values.as_slice() else {
return None;
};
let Some(and_op) = expr.as_bool_op_expr() else {
return None;
};
if and_op.op != BoolOp::And {
return None;
}
let [condition, true_value] = and_op.values.as_slice() else {
return None;
};
if false_value.is_bool_op_expr() || true_value.is_bool_op_expr() {
return None;
}
Some((condition, true_value, false_value))
}
/// Returns `true` if the expression is used within a comprehension.
fn is_comprehension_if(parent: Option<&Expr>, expr: &ExprBoolOp) -> bool {
let comprehensions = match parent {
Some(Expr::ListComp(ExprListComp { generators, .. })) => generators,
Some(Expr::SetComp(ExprSetComp { generators, .. })) => generators,
Some(Expr::DictComp(ExprDictComp { generators, .. })) => generators,
_ => {
return false;
}
};
comprehensions
.iter()
.any(|comp| comp.ifs.iter().any(|ifs| ifs.range() == expr.range()))
}
pub struct AndOrTernary;
/// PLR1706
pub(crate) fn and_or_ternary(checker: &mut Checker, bool_op: &ExprBoolOp) {
if checker.semantic().current_statement().is_if_stmt() {
return;
impl Violation for AndOrTernary {
fn message(&self) -> String {
unreachable!("PLR1706 has been removed");
}
let parent_expr = checker.semantic().current_expression_parent();
if parent_expr.is_some_and(Expr::is_bool_op_expr) {
return;
fn message_formats() -> &'static [&'static str] {
&["Consider using if-else expression"]
}
let Some((condition, true_value, false_value)) = parse_and_or_ternary(bool_op) else {
return;
};
let if_expr = Expr::IfExp(ExprIfExp {
test: Box::new(condition.clone()),
body: Box::new(true_value.clone()),
orelse: Box::new(false_value.clone()),
range: TextRange::default(),
});
let ternary = if is_comprehension_if(parent_expr, bool_op) {
format!("({})", checker.generator().expr(&if_expr))
} else {
checker.generator().expr(&if_expr)
};
let mut diagnostic = Diagnostic::new(
AndOrTernary {
ternary: SourceCodeSnippet::new(ternary.clone()),
},
bool_op.range,
);
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
ternary,
bool_op.range,
)));
checker.diagnostics.push(diagnostic);
}

View file

@ -1,229 +0,0 @@
---
source: crates/ruff_linter/src/rules/pylint/mod.rs
---
and_or_ternary.py:46:1: PLR1706 [*] Consider using if-else expression (`'a' if 1 < 2 else 'b'`)
|
44 | # Errors
45 |
46 | 1<2 and 'a' or 'b'
| ^^^^^^^^^^^^^^^^^^ PLR1706
47 |
48 | (lambda x: x+1) and 'a' or 'b'
|
= help: Convert to if-else expression
Unsafe fix
43 43 |
44 44 | # Errors
45 45 |
46 |-1<2 and 'a' or 'b'
46 |+'a' if 1 < 2 else 'b'
47 47 |
48 48 | (lambda x: x+1) and 'a' or 'b'
49 49 |
and_or_ternary.py:48:1: PLR1706 [*] Consider using if-else expression (`'a' if (lambda x: x + 1) else 'b'`)
|
46 | 1<2 and 'a' or 'b'
47 |
48 | (lambda x: x+1) and 'a' or 'b'
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
49 |
50 | 'a' and (lambda x: x+1) or 'orange'
|
= help: Convert to if-else expression
Unsafe fix
45 45 |
46 46 | 1<2 and 'a' or 'b'
47 47 |
48 |-(lambda x: x+1) and 'a' or 'b'
48 |+'a' if (lambda x: x + 1) else 'b'
49 49 |
50 50 | 'a' and (lambda x: x+1) or 'orange'
51 51 |
and_or_ternary.py:50:1: PLR1706 [*] Consider using if-else expression (`(lambda x: x + 1) if 'a' else 'orange'`)
|
48 | (lambda x: x+1) and 'a' or 'b'
49 |
50 | 'a' and (lambda x: x+1) or 'orange'
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
51 |
52 | val = '#0000FF'
|
= help: Convert to if-else expression
Unsafe fix
47 47 |
48 48 | (lambda x: x+1) and 'a' or 'b'
49 49 |
50 |-'a' and (lambda x: x+1) or 'orange'
50 |+(lambda x: x + 1) if 'a' else 'orange'
51 51 |
52 52 | val = '#0000FF'
53 53 | (len(val) == 7 and val[0] == "#") or val in {'green'}
and_or_ternary.py:53:1: PLR1706 [*] Consider using if-else expression
|
52 | val = '#0000FF'
53 | (len(val) == 7 and val[0] == "#") or val in {'green'}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
54 |
55 | marker = 'marker'
|
= help: Convert to if-else expression
Unsafe fix
50 50 | 'a' and (lambda x: x+1) or 'orange'
51 51 |
52 52 | val = '#0000FF'
53 |-(len(val) == 7 and val[0] == "#") or val in {'green'}
53 |+val[0] == '#' if len(val) == 7 else val in {'green'}
54 54 |
55 55 | marker = 'marker'
56 56 | isinstance(marker, dict) and 'field' in marker or marker in {}
and_or_ternary.py:56:1: PLR1706 [*] Consider using if-else expression
|
55 | marker = 'marker'
56 | isinstance(marker, dict) and 'field' in marker or marker in {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
57 |
58 | def has_oranges(oranges, apples=None) -> bool:
|
= help: Convert to if-else expression
Unsafe fix
53 53 | (len(val) == 7 and val[0] == "#") or val in {'green'}
54 54 |
55 55 | marker = 'marker'
56 |-isinstance(marker, dict) and 'field' in marker or marker in {}
56 |+'field' in marker if isinstance(marker, dict) else marker in {}
57 57 |
58 58 | def has_oranges(oranges, apples=None) -> bool:
59 59 | return apples and False or oranges
and_or_ternary.py:59:12: PLR1706 [*] Consider using if-else expression (`False if apples else oranges`)
|
58 | def has_oranges(oranges, apples=None) -> bool:
59 | return apples and False or oranges
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
60 |
61 | [x for x in l if a and b or c]
|
= help: Convert to if-else expression
Unsafe fix
56 56 | isinstance(marker, dict) and 'field' in marker or marker in {}
57 57 |
58 58 | def has_oranges(oranges, apples=None) -> bool:
59 |- return apples and False or oranges
59 |+ return False if apples else oranges
60 60 |
61 61 | [x for x in l if a and b or c]
62 62 |
and_or_ternary.py:61:18: PLR1706 [*] Consider using if-else expression (`(b if a else c)`)
|
59 | return apples and False or oranges
60 |
61 | [x for x in l if a and b or c]
| ^^^^^^^^^^^^ PLR1706
62 |
63 | {x: y for x in l if a and b or c}
|
= help: Convert to if-else expression
Unsafe fix
58 58 | def has_oranges(oranges, apples=None) -> bool:
59 59 | return apples and False or oranges
60 60 |
61 |-[x for x in l if a and b or c]
61 |+[x for x in l if (b if a else c)]
62 62 |
63 63 | {x: y for x in l if a and b or c}
64 64 |
and_or_ternary.py:63:21: PLR1706 [*] Consider using if-else expression (`(b if a else c)`)
|
61 | [x for x in l if a and b or c]
62 |
63 | {x: y for x in l if a and b or c}
| ^^^^^^^^^^^^ PLR1706
64 |
65 | {x for x in l if a and b or c}
|
= help: Convert to if-else expression
Unsafe fix
60 60 |
61 61 | [x for x in l if a and b or c]
62 62 |
63 |-{x: y for x in l if a and b or c}
63 |+{x: y for x in l if (b if a else c)}
64 64 |
65 65 | {x for x in l if a and b or c}
66 66 |
and_or_ternary.py:65:18: PLR1706 [*] Consider using if-else expression (`(b if a else c)`)
|
63 | {x: y for x in l if a and b or c}
64 |
65 | {x for x in l if a and b or c}
| ^^^^^^^^^^^^ PLR1706
66 |
67 | new_list = [
|
= help: Convert to if-else expression
Unsafe fix
62 62 |
63 63 | {x: y for x in l if a and b or c}
64 64 |
65 |-{x for x in l if a and b or c}
65 |+{x for x in l if (b if a else c)}
66 66 |
67 67 | new_list = [
68 68 | x
and_or_ternary.py:70:8: PLR1706 [*] Consider using if-else expression (`(b if a else c)`)
|
68 | x
69 | for sublist in all_lists
70 | if a and b or c
| ^^^^^^^^^^^^ PLR1706
71 | for x in sublist
72 | if (isinstance(operator, list) and x in operator) or x != operator
|
= help: Convert to if-else expression
Unsafe fix
67 67 | new_list = [
68 68 | x
69 69 | for sublist in all_lists
70 |- if a and b or c
70 |+ if (b if a else c)
71 71 | for x in sublist
72 72 | if (isinstance(operator, list) and x in operator) or x != operator
73 73 | ]
and_or_ternary.py:72:8: PLR1706 [*] Consider using if-else expression
|
70 | if a and b or c
71 | for x in sublist
72 | if (isinstance(operator, list) and x in operator) or x != operator
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLR1706
73 | ]
|
= help: Convert to if-else expression
Unsafe fix
69 69 | for sublist in all_lists
70 70 | if a and b or c
71 71 | for x in sublist
72 |- if (isinstance(operator, list) and x in operator) or x != operator
72 |+ if (x in operator if isinstance(operator, list) else x != operator)
73 73 | ]

View file

@ -329,6 +329,10 @@ See also https://github.com/astral-sh/ruff/issues/2186.
pub fn is_deprecated(&self) -> bool {
matches!(self.group(), RuleGroup::Deprecated)
}
pub fn is_removed(&self) -> bool {
matches!(self.group(), RuleGroup::Removed)
}
}
impl Linter {

View file

@ -756,6 +756,7 @@ impl LintConfiguration {
let mut redirects = FxHashMap::default();
let mut deprecated_nursery_selectors = FxHashSet::default();
let mut deprecated_selectors = FxHashSet::default();
let mut removed_selectors = FxHashSet::default();
let mut ignored_preview_selectors = FxHashSet::default();
// Track which docstring rules are specifically enabled
@ -922,6 +923,13 @@ impl LintConfiguration {
}
}
// Removed rules
if let RuleSelector::Rule { prefix, .. } = selector {
if prefix.rules().any(|rule| rule.is_removed()) {
removed_selectors.insert(selector);
}
}
// Redirected rules
if let RuleSelector::Prefix {
prefix,
@ -933,6 +941,29 @@ impl LintConfiguration {
}
}
let removed_selectors = removed_selectors.iter().collect::<Vec<_>>();
match removed_selectors.as_slice() {
[] => (),
[selection] => {
let (prefix, code) = selection.prefix_and_code();
return Err(anyhow!(
"Rule `{prefix}{code}` was removed and cannot be selected."
));
}
[..] => {
let mut message =
"The following rules have been removed and cannot be selected:".to_string();
for selection in removed_selectors {
let (prefix, code) = selection.prefix_and_code();
message.push_str("\n - ");
message.push_str(prefix);
message.push_str(code);
}
message.push('\n');
return Err(anyhow!(message));
}
}
for (from, target) in redirects {
// TODO(martin): This belongs into the ruff crate.
warn_user_once_by_id!(
@ -1423,7 +1454,6 @@ mod tests {
use std::str::FromStr;
const PREVIEW_RULES: &[Rule] = &[
Rule::AndOrTernary,
Rule::AssignmentInAssert,
Rule::DirectLoggerInstantiation,
Rule::InvalidGetLoggerArgument,

1
ruff.schema.json generated
View file

@ -3267,7 +3267,6 @@
"PLR1701",
"PLR1702",
"PLR1704",
"PLR1706",
"PLR171",
"PLR1711",
"PLR1714",