From 42c57722ea1af68eb053334c65541e4c6e0d28a5 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 15:27:15 +0530 Subject: [PATCH 01/88] Implement B903 and B907 flake8-bugbear rules Fixes #3758 Implement B903 and B907 flake8-bugbear rules in Ruff. * Add `crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs` to implement B903 rule for data classes that only set attributes in an `__init__` method. * Add `crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs` to implement B907 rule to replace f"'{foo}'" with f"{foo!r}". * Update `crates/ruff_linter/src/rules/flake8_bugbear/mod.rs` to include B903 and B907 rules. * Add test cases for B903 and B907 rules in `crates/ruff_linter/src/rules/flake8_bugbear/tests.rs`. --- .../src/rules/flake8_bugbear/mod.rs | 3 +- .../src/rules/flake8_bugbear/rules/b903.rs | 98 +++++++++++++ .../src/rules/flake8_bugbear/rules/b907.rs | 59 ++++++++ .../src/rules/flake8_bugbear/tests.rs | 138 ++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/tests.rs diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index 1f122f15438b3..c40e7298b452c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -1,4 +1,3 @@ -//! Rules from [flake8-bugbear](https://pypi.org/project/flake8-bugbear/). pub(crate) mod helpers; pub(crate) mod rules; pub mod settings; @@ -65,6 +64,8 @@ mod tests { #[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))] #[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))] #[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))] + #[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))] + #[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs new file mode 100644 index 0000000000000..5a166e563e158 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs @@ -0,0 +1,98 @@ +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast, Arguments, Expr, Stmt}; +use ruff_python_semantic::SemanticModel; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::registry::Rule; + +/// ## What it does +/// Checks for data classes that only set attributes in an `__init__` method. +/// +/// ## Why is this bad? +/// Data classes that only set attributes in an `__init__` method can be +/// replaced with `dataclasses` for better readability and maintainability. +/// +/// ## Example +/// ```python +/// class Point: +/// def __init__(self, x, y): +/// self.x = x +/// self.y = y +/// ``` +/// +/// Use instead: +/// ```python +/// from dataclasses import dataclass +/// +/// @dataclass +/// class Point: +/// x: int +/// y: int +/// ``` +/// +/// ## References +/// - [Python documentation: `dataclasses`](https://docs.python.org/3/library/dataclasses.html) +#[violation] +pub struct UseDataclassesForDataClasses; + +impl Violation for UseDataclassesForDataClasses { + #[derive_message_formats] + fn message(&self) -> String { + format!("Use `dataclasses` for data classes that only set attributes in an `__init__` method") + } +} + +/// B903 +pub(crate) fn use_dataclasses_for_data_classes(checker: &mut Checker, stmt: &Stmt) { + let Stmt::ClassDef(ast::StmtClassDef { body, .. }) = stmt else { + return; + }; + + for stmt in body { + let Stmt::FunctionDef(ast::StmtFunctionDef { + name, + parameters, + body, + .. + }) = stmt + else { + continue; + }; + + if name.id != "__init__" { + continue; + } + + let mut has_only_attribute_assignments = true; + for stmt in body { + if let Stmt::Assign(ast::StmtAssign { targets, .. }) = stmt { + if targets.len() != 1 { + has_only_attribute_assignments = false; + break; + } + + let Expr::Attribute(ast::ExprAttribute { value, .. }) = &targets[0] else { + has_only_attribute_assignments = false; + break; + }; + + if !matches!(value.as_ref(), Expr::Name(_)) { + has_only_attribute_assignments = false; + break; + } + } else { + has_only_attribute_assignments = false; + break; + } + } + + if has_only_attribute_assignments { + checker.diagnostics.push(Diagnostic::new( + UseDataclassesForDataClasses, + stmt.range(), + )); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs new file mode 100644 index 0000000000000..5ec876cd75e3f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs @@ -0,0 +1,59 @@ +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::registry::Rule; + +/// ## What it does +/// Checks for f-strings that contain single quotes and suggests replacing them +/// with `!r` conversion. +/// +/// ## Why is this bad? +/// Using `!r` conversion in f-strings is both easier to read and will escape +/// quotes inside the string if they appear. +/// +/// ## Example +/// ```python +/// f"'{foo}'" +/// ``` +/// +/// Use instead: +/// ```python +/// f"{foo!r}" +/// ``` +/// +/// ## References +/// - [Python documentation: Formatted string literals](https://docs.python.org/3/reference/lexical_analysis.html#f-strings) +#[violation] +pub struct FStringSingleQuotes; + +impl Violation for FStringSingleQuotes { + #[derive_message_formats] + fn message(&self) -> String { + format!("Consider replacing f\"'{{foo}}'\" with f\"{{foo!r}}\" which is both easier to read and will escape quotes inside foo if that would appear") + } +} + +/// B907 +pub(crate) fn f_string_single_quotes(checker: &mut Checker, expr: &Expr) { + if let Expr::FString(ast::ExprFString { values, .. }) = expr { + for value in values { + if let Expr::FormattedValue(ast::ExprFormattedValue { value, .. }) = value { + if let Expr::Constant(ast::ExprConstant { + value: ast::Constant::Str(s), + .. + }) = value.as_ref() + { + if s.contains('\'') { + checker.diagnostics.push(Diagnostic::new( + FStringSingleQuotes, + expr.range(), + )); + } + } + } + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs b/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs new file mode 100644 index 0000000000000..861f27f106c79 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs @@ -0,0 +1,138 @@ +use std::path::Path; + +use anyhow::Result; +use test_case::test_case; + +use crate::assert_messages; +use crate::registry::Rule; + +use crate::settings::LinterSettings; +use crate::test::test_path; + +#[test_case(Rule::AbstractBaseClassWithoutAbstractMethod, Path::new("B024.py"))] +#[test_case(Rule::AssertFalse, Path::new("B011.py"))] +#[test_case(Rule::AssertRaisesException, Path::new("B017.py"))] +#[test_case(Rule::AssignmentToOsEnviron, Path::new("B003.py"))] +#[test_case(Rule::CachedInstanceMethod, Path::new("B019.py"))] +#[test_case(Rule::DuplicateHandlerException, Path::new("B014.py"))] +#[test_case(Rule::DuplicateTryBlockException, Path::new("B025.py"))] +#[test_case(Rule::DuplicateValue, Path::new("B033.py"))] +#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.py"))] +#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.pyi"))] +#[test_case(Rule::ExceptWithEmptyTuple, Path::new("B029.py"))] +#[test_case(Rule::ExceptWithNonExceptionClasses, Path::new("B030.py"))] +#[test_case(Rule::FStringDocstring, Path::new("B021.py"))] +#[test_case(Rule::FunctionCallInDefaultArgument, Path::new("B006_B008.py"))] +#[test_case(Rule::FunctionUsesLoopVariable, Path::new("B023.py"))] +#[test_case(Rule::GetAttrWithConstant, Path::new("B009_B010.py"))] +#[test_case(Rule::JumpStatementInFinally, Path::new("B012.py"))] +#[test_case(Rule::LoopVariableOverridesIterator, Path::new("B020.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_1.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_2.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_3.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_4.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_5.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_6.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_7.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_8.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_B008.py"))] +#[test_case(Rule::NoExplicitStacklevel, Path::new("B028.py"))] +#[test_case(Rule::RaiseLiteral, Path::new("B016.py"))] +#[test_case(Rule::RaiseWithoutFromInsideExcept, Path::new("B904.py"))] +#[test_case(Rule::ReSubPositionalArgs, Path::new("B034.py"))] +#[test_case(Rule::RedundantTupleInExceptionHandler, Path::new("B013.py"))] +#[test_case(Rule::ReuseOfGroupbyGenerator, Path::new("B031.py"))] +#[test_case(Rule::SetAttrWithConstant, Path::new("B009_B010.py"))] +#[test_case(Rule::StarArgUnpackingAfterKeywordArg, Path::new("B026.py"))] +#[test_case(Rule::StaticKeyDictComprehension, Path::new("B035.py"))] +#[test_case(Rule::StripWithMultiCharacters, Path::new("B005.py"))] +#[test_case(Rule::UnaryPrefixIncrementDecrement, Path::new("B002.py"))] +#[test_case(Rule::UnintentionalTypeAnnotation, Path::new("B032.py"))] +#[test_case(Rule::UnreliableCallableCheck, Path::new("B004.py"))] +#[test_case(Rule::UnusedLoopControlVariable, Path::new("B007.py"))] +#[test_case(Rule::UselessComparison, Path::new("B015.ipynb"))] +#[test_case(Rule::UselessComparison, Path::new("B015.py"))] +#[test_case(Rule::UselessContextlibSuppress, Path::new("B022.py"))] +#[test_case(Rule::UselessExpression, Path::new("B018.ipynb"))] +#[test_case(Rule::UselessExpression, Path::new("B018.py"))] +#[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))] +#[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))] +#[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))] +#[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))] +#[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))] +fn rules(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_bugbear").join(path).as_path(), + &LinterSettings::for_rule(rule_code), + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn zip_without_explicit_strict() -> Result<()> { + let snapshot = "B905.py"; + let diagnostics = test_path( + Path::new("flake8_bugbear").join(snapshot).as_path(), + &LinterSettings::for_rule(Rule::ZipWithoutExplicitStrict), + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_immutable_calls_arg_annotation() -> Result<()> { + let snapshot = "extend_immutable_calls_arg_annotation".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B006_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec![ + "custom.ImmutableTypeA".to_string(), + "custom.ImmutableTypeB".to_string(), + ], + }, + ..LinterSettings::for_rule(Rule::MutableArgumentDefault) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_immutable_calls_arg_default() -> Result<()> { + let snapshot = "extend_immutable_calls_arg_default".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B008_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec![ + "fastapi.Depends".to_string(), + "fastapi.Query".to_string(), + "custom.ImmutableTypeA".to_string(), + "B008_extended.Class".to_string(), + ], + }, + ..LinterSettings::for_rule(Rule::FunctionCallInDefaultArgument) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_mutable_contextvar_default() -> Result<()> { + let snapshot = "extend_mutable_contextvar_default".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B039_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec!["fastapi.Query".to_string()], + }, + ..LinterSettings::for_rule(Rule::MutableContextvarDefault) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} From cb89963c9e42df7c58f97615d59fd176a66facb3 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 15:40:41 +0530 Subject: [PATCH 02/88] Add new rules to `Rule` enum in `codes.rs` * **New Rules** - Add `UseDataclassesForDataClasses` variant to `Rule` enum - Add `FStringSingleQuotes` variant to `Rule` enum --- crates/ruff_linter/src/codes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 21486729a5770..54323b294f38b 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,6 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From 0adb25e2847574665fc8128ca13c5e902609489a Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 15:53:17 +0530 Subject: [PATCH 03/88] --- crates/ruff_linter/src/registry.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 1ee0cc5102add..6e901c88e2fd0 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,6 +1,3 @@ -//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs -//! with some helper symbols - use strum_macros::EnumIter; pub use codes::Rule; From a8acb23856e27afd965d6a4a2656810dd129e923 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:07:52 +0530 Subject: [PATCH 04/88] Update registry.rs --- crates/ruff_linter/src/registry.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 6e901c88e2fd0..e273d04b14ac1 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -265,6 +265,7 @@ impl Rule { | Rule::CommentedOutCode | Rule::EmptyComment | Rule::ExtraneousParentheses + | Rule::FStringSingleQuotes | Rule::InvalidCharacterBackspace | Rule::InvalidCharacterEsc | Rule::InvalidCharacterNul From 651eab4b5eac68eba5781f3f0127cfb5b8ad009b Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:08:16 +0530 Subject: [PATCH 05/88] Update registry.rs --- crates/ruff_linter/src/registry.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index e273d04b14ac1..32b9f7a14fafd 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,3 +1,6 @@ +//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs +//! with some helper symbols + use strum_macros::EnumIter; pub use codes::Rule; From 399e5185d2b6740420af1e31247f06577a54f5e8 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:28:12 +0530 Subject: [PATCH 06/88] Update `Rule` enum and imports for new rules B903 and B907 * **Update `Rule` enum** - Add `UseDataclassesForDataClasses` for B903 - Add `FStringSingleQuotes` for B907 * **Update imports in `mod.rs`** - Add `use_dataclasses_for_data_classes::*` - Add `f_string_single_quotes::*` --- crates/ruff_linter/src/codes.rs | 4 ++-- crates/ruff_linter/src/registry.rs | 4 ---- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 2 ++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 54323b294f38b..862687a285659 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, crate::registry::Rule::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, crate::registry::Rule::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 32b9f7a14fafd..6e901c88e2fd0 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,6 +1,3 @@ -//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs -//! with some helper symbols - use strum_macros::EnumIter; pub use codes::Rule; @@ -268,7 +265,6 @@ impl Rule { | Rule::CommentedOutCode | Rule::EmptyComment | Rule::ExtraneousParentheses - | Rule::FStringSingleQuotes | Rule::InvalidCharacterBackspace | Rule::InvalidCharacterEsc | Rule::InvalidCharacterNul diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 49b40b0b7900d..b2e69e7fbb80b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -31,6 +31,8 @@ pub(crate) use unused_async::*; pub(crate) use unused_noqa::*; pub(crate) use useless_if_else::*; pub(crate) use zip_instead_of_pairwise::*; +pub(crate) use use_dataclasses_for_data_classes::*; +pub(crate) use f_string_single_quotes::*; mod ambiguous_unicode_character; mod assert_with_print_message; From c8122720d24d9fc58a1c9f9c6e43dfb4c3bae01c Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:36:17 +0530 Subject: [PATCH 07/88] Rename b903.rs to use_dataclasses_for_data_classes.rs --- .../rules/{b903.rs => use_dataclasses_for_data_classes.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/ruff_linter/src/rules/flake8_bugbear/rules/{b903.rs => use_dataclasses_for_data_classes.rs} (100%) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/use_dataclasses_for_data_classes.rs similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs rename to crates/ruff_linter/src/rules/flake8_bugbear/rules/use_dataclasses_for_data_classes.rs From aa8191b65f117a5e4a55c2ec79d0f7fa2e593460 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:36:44 +0530 Subject: [PATCH 08/88] Rename b907.rs to f_string_single_quotes.rs --- .../flake8_bugbear/rules/{b907.rs => f_string_single_quotes.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/ruff_linter/src/rules/flake8_bugbear/rules/{b907.rs => f_string_single_quotes.rs} (100%) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_single_quotes.rs similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs rename to crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_single_quotes.rs From d0af490b833c7390d560481cc81aefce77a28384 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:53:03 +0530 Subject: [PATCH 09/88] Update mod.rs --- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index b2e69e7fbb80b..4f84ec55c7dc5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -6,6 +6,7 @@ pub(crate) use collection_literal_concatenation::*; pub(crate) use decimal_from_float_literal::*; pub(crate) use default_factory_kwarg::*; pub(crate) use explicit_f_string_type_conversion::*; +pub(crate) use f_string_single_quotes::*; pub(crate) use function_call_in_dataclass_default::*; pub(crate) use implicit_optional::*; pub(crate) use incorrectly_parenthesized_tuple_in_subscript::*; @@ -29,10 +30,10 @@ pub(crate) use unnecessary_iterable_allocation_for_first_element::*; pub(crate) use unnecessary_key_check::*; pub(crate) use unused_async::*; pub(crate) use unused_noqa::*; +pub(crate) use use_dataclasses_for_data_classes::*; pub(crate) use useless_if_else::*; pub(crate) use zip_instead_of_pairwise::*; -pub(crate) use use_dataclasses_for_data_classes::*; -pub(crate) use f_string_single_quotes::*; + mod ambiguous_unicode_character; mod assert_with_print_message; @@ -43,6 +44,7 @@ mod confusables; mod decimal_from_float_literal; mod default_factory_kwarg; mod explicit_f_string_type_conversion; +mod f_string_single_quotes; mod function_call_in_dataclass_default; mod helpers; mod implicit_optional; @@ -69,6 +71,7 @@ mod unnecessary_iterable_allocation_for_first_element; mod unnecessary_key_check; mod unused_async; mod unused_noqa; +mod use_dataclasses_for_data_classes mod useless_if_else; mod zip_instead_of_pairwise; From 37283a5c6163ed3b15bfbbea2c11c61bb35ccb98 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 16:55:50 +0530 Subject: [PATCH 10/88] add missing semicolon --- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 4f84ec55c7dc5..68134e0a8cbe5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -71,7 +71,7 @@ mod unnecessary_iterable_allocation_for_first_element; mod unnecessary_key_check; mod unused_async; mod unused_noqa; -mod use_dataclasses_for_data_classes +mod use_dataclasses_for_data_classes; mod useless_if_else; mod zip_instead_of_pairwise; From 86dd81896209304e85f04c4a5ab66605c24bb009 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 17:00:52 +0530 Subject: [PATCH 11/88] change to rule from crate --- crates/ruff_linter/src/codes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 862687a285659..0c5ab16613f5e 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, crate::registry::Rule::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, crate::registry::Rule::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::registry::Rule::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::registry::Rule::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From b544b41527fe339352515ea760946106958f50a4 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 17:06:16 +0530 Subject: [PATCH 12/88] Update codes.rs --- crates/ruff_linter/src/codes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 0c5ab16613f5e..54323b294f38b 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::registry::Rule::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::registry::Rule::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From 9edb7117a6980a28485f42811cc3c1309255a522 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 17:12:22 +0530 Subject: [PATCH 13/88] revert removed comments --- crates/ruff_linter/src/registry.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 6e901c88e2fd0..1ee0cc5102add 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,3 +1,6 @@ +//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs +//! with some helper symbols + use strum_macros::EnumIter; pub use codes::Rule; From 6d91f1e4d74aa2c581268e8617655d5c3ad64b41 Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 17:12:52 +0530 Subject: [PATCH 14/88] revert removed comment --- crates/ruff_linter/src/rules/flake8_bugbear/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index c40e7298b452c..5ceff4cdd4a78 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -1,3 +1,4 @@ +//! Rules from [flake8-bugbear](https://pypi.org/project/flake8-bugbear/). pub(crate) mod helpers; pub(crate) mod rules; pub mod settings; From 2aef2ef5f889f01dc404b699c10620d093ee418d Mon Sep 17 00:00:00 2001 From: Ayush Gupta Date: Wed, 2 Oct 2024 13:04:44 +0000 Subject: [PATCH 15/88] add B907.py and B903.py --- .../test/fixtures/flake8_bugbear/B903.py | 26 +++++++++++++++++++ .../test/fixtures/flake8_bugbear/B907.py | 8 ++++++ 2 files changed, 34 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py new file mode 100644 index 0000000000000..fd648e9a06dfb --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py @@ -0,0 +1,26 @@ +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + +class Rectangle: + def __init__(self, width, height): + self.width = width + self.height = height + + +class Circle: + def __init__(self, radius): + self.radius = radius + + +class Triangle: + def __init__(self, base, height): + self.base = base + self.height = height + + +class Square: + def __init__(self, side): + self.side = side diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py new file mode 100644 index 0000000000000..9e5b1acff712d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py @@ -0,0 +1,8 @@ +foo = "bar" +f"'{foo}'" + +baz = "qux" +f"'{baz}'" + +quux = "corge" +f"'{quux}'" From 7e3894f5b3573d77b0000bbddf0293fbbb5dc986 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Thu, 3 Oct 2024 15:35:05 +0530 Subject: [PATCH 16/88] Avoid short circuiting `B017` for multiple context managers (#13609) ## Summary fixes: #13603 --- .../resources/test/fixtures/flake8_bugbear/B017.py | 3 +++ .../flake8_bugbear/rules/assert_raises_exception.rs | 12 ++++++------ ...__rules__flake8_bugbear__tests__B017_B017.py.snap | 9 ++++++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B017.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B017.py index 917a848ba1884..99def7a555e13 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B017.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B017.py @@ -53,3 +53,6 @@ def test_pytest_raises(): with pytest.raises(Exception, match="hello"): raise ValueError("This is also fine") + + with contextlib.nullcontext(), pytest.raises(Exception): + raise ValueError("Multiple context managers") diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs index 18c84a84c19e2..a3da3f9dc69ad 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/assert_raises_exception.rs @@ -84,27 +84,27 @@ pub(crate) fn assert_raises_exception(checker: &mut Checker, items: &[WithItem]) range: _, }) = &item.context_expr else { - return; + continue; }; if item.optional_vars.is_some() { - return; + continue; } let [arg] = &*arguments.args else { - return; + continue; }; let semantic = checker.semantic(); let Some(builtin_symbol) = semantic.resolve_builtin_symbol(arg) else { - return; + continue; }; let exception = match builtin_symbol { "Exception" => ExceptionKind::Exception, "BaseException" => ExceptionKind::BaseException, - _ => return, + _ => continue, }; let assertion = if matches!(func.as_ref(), Expr::Attribute(ast::ExprAttribute { attr, .. }) if attr == "assertRaises") @@ -117,7 +117,7 @@ pub(crate) fn assert_raises_exception(checker: &mut Checker, items: &[WithItem]) { AssertionKind::PytestRaises } else { - return; + continue; }; checker.diagnostics.push(Diagnostic::new( diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017.py.snap index 59e2c1b5794fe..1d4ac20d75a6f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B017_B017.py.snap @@ -35,4 +35,11 @@ B017.py:48:10: B017 `pytest.raises(Exception)` should be considered evil 49 | raise ValueError("Hello") | - +B017.py:57:36: B017 `pytest.raises(Exception)` should be considered evil + | +55 | raise ValueError("This is also fine") +56 | +57 | with contextlib.nullcontext(), pytest.raises(Exception): + | ^^^^^^^^^^^^^^^^^^^^^^^^ B017 +58 | raise ValueError("Multiple context managers") + | From 3728d5b3a2ba1d97a1e6cbbe719b5960ca8ca53c Mon Sep 17 00:00:00 2001 From: cake-monotone Date: Thu, 3 Oct 2024 21:06:15 +0900 Subject: [PATCH 17/88] [`pyupgrade`] Fix UP043 to apply to `collections.abc.Generator` and `collections.abc.AsyncGenerator` (#13611) ## Summary fix #13602 Currently, `UP043` only applies to typing.Generator, but it should also support collections.abc.Generator. This update ensures `UP043` correctly handles both `collections.abc.Generator` and `collections.abc.AsyncGenerator` ### UP043 > `UP043` > Python 3.13 introduced the ability for type parameters to specify default values. As such, the default type arguments for some types in the standard library (e.g., Generator, AsyncGenerator) are now optional. > Omitting type parameters that match the default values can make the code more concise and easier to read. ```py Generator[int, None, None] -> Generator[int] ``` --- .../test/fixtures/pyupgrade/UP043.py | 13 ++++++- .../rules/unnecessary_default_type_args.rs | 20 ++++++++-- ...er__rules__pyupgrade__tests__UP043.py.snap | 38 ++++++++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP043.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP043.py index c4ebf662a67cd..3968a16a54a83 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP043.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP043.py @@ -1,4 +1,4 @@ -from typing import Generator, AsyncGenerator +from collections.abc import Generator, AsyncGenerator def func() -> Generator[int, None, None]: @@ -39,3 +39,14 @@ async def func() -> AsyncGenerator[int]: async def func() -> AsyncGenerator[int, int]: foo = yield 42 return foo + + +from typing import Generator, AsyncGenerator + + +def func() -> Generator[str, None, None]: + yield "hello" + + +async def func() -> AsyncGenerator[str, None]: + yield "hello" diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs index 8349eae78fce0..4ef77fbc30f71 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_default_type_args.rs @@ -19,7 +19,7 @@ use crate::checkers::ast::Checker; /// ## Examples /// /// ```python -/// from typing import Generator, AsyncGenerator +/// from collections.abc import Generator, AsyncGenerator /// /// /// def sync_gen() -> Generator[int, None, None]: @@ -33,7 +33,7 @@ use crate::checkers::ast::Checker; /// Use instead: /// /// ```python -/// from typing import Generator, AsyncGenerator +/// from collections.abc import Generator, AsyncGenerator /// /// /// def sync_gen() -> Generator[int]: @@ -47,6 +47,7 @@ use crate::checkers::ast::Checker; /// ## References /// /// - [PEP 696 – Type Defaults for Type Parameters](https://peps.python.org/pep-0696/) +/// - [Annotating generators and coroutines](https://docs.python.org/3.13/library/typing.html#annotating-generators-and-coroutines) /// - [typing.Generator](https://docs.python.org/3.13/library/typing.html#typing.Generator) /// - [typing.AsyncGenerator](https://docs.python.org/3.13/library/typing.html#typing.AsyncGenerator) #[violation] @@ -140,9 +141,20 @@ impl DefaultedTypeAnnotation { /// includes default type arguments. fn from_expr(expr: &Expr, semantic: &ruff_python_semantic::SemanticModel) -> Option { let qualified_name = semantic.resolve_qualified_name(expr)?; - if semantic.match_typing_qualified_name(&qualified_name, "Generator") { + + if semantic.match_typing_qualified_name(&qualified_name, "Generator") + || matches!( + qualified_name.segments(), + ["collections", "abc", "Generator"] + ) + { Some(Self::Generator) - } else if semantic.match_typing_qualified_name(&qualified_name, "AsyncGenerator") { + } else if semantic.match_typing_qualified_name(&qualified_name, "AsyncGenerator") + || matches!( + qualified_name.segments(), + ["collections", "abc", "AsyncGenerator"] + ) + { Some(Self::AsyncGenerator) } else { None diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP043.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP043.py.snap index 4198822ad3363..65243085cce44 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP043.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP043.py.snap @@ -10,7 +10,7 @@ UP043.py:4:15: UP043 [*] Unnecessary default type arguments = help: Remove default type arguments ℹ Safe fix -1 1 | from typing import Generator, AsyncGenerator +1 1 | from collections.abc import Generator, AsyncGenerator 2 2 | 3 3 | 4 |-def func() -> Generator[int, None, None]: @@ -72,4 +72,38 @@ UP043.py:31:21: UP043 [*] Unnecessary default type arguments 31 |+async def func() -> AsyncGenerator[int]: 32 32 | yield 42 33 33 | -34 34 | +34 34 | + +UP043.py:47:15: UP043 [*] Unnecessary default type arguments + | +47 | def func() -> Generator[str, None, None]: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ UP043 +48 | yield "hello" + | + = help: Remove default type arguments + +ℹ Safe fix +44 44 | from typing import Generator, AsyncGenerator +45 45 | +46 46 | +47 |-def func() -> Generator[str, None, None]: + 47 |+def func() -> Generator[str]: +48 48 | yield "hello" +49 49 | +50 50 | + +UP043.py:51:21: UP043 [*] Unnecessary default type arguments + | +51 | async def func() -> AsyncGenerator[str, None]: + | ^^^^^^^^^^^^^^^^^^^^^^^^^ UP043 +52 | yield "hello" + | + = help: Remove default type arguments + +ℹ Safe fix +48 48 | yield "hello" +49 49 | +50 50 | +51 |-async def func() -> AsyncGenerator[str, None]: + 51 |+async def func() -> AsyncGenerator[str]: +52 52 | yield "hello" From fdd0a22c03ddecea7e8b870b6d7a007210ee3d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 3 Oct 2024 07:35:27 -0700 Subject: [PATCH 18/88] Move to maintained mirror of prettier (#13592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/pre-commit/mirrors-prettier has been archived and is no longer maintained. Signed-off-by: Bernát Gábor --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee5940aae03eb..5cffbda35c829 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -68,8 +68,8 @@ repos: require_serial: true # Prettier - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.3.3 hooks: - id: prettier types: [yaml] From cc1f766622bd27c24e47362503f44f8545710c6f Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 3 Oct 2024 10:22:20 -0500 Subject: [PATCH 19/88] Preserve trivia (i.e. comments) in PLR5501 (#13573) Closes https://github.com/astral-sh/ruff/issues/13545 As described in the issue, we move comments before the inner `if` statement to before the newly constructed `elif` statement (previously `else`). --- .../fixtures/pylint/collapsible_else_if.py | 47 +++++ .../rules/pylint/rules/collapsible_else_if.rs | 31 +++- ...tests__PLR5501_collapsible_else_if.py.snap | 170 ++++++++++++++++-- 3 files changed, 225 insertions(+), 23 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pylint/collapsible_else_if.py b/crates/ruff_linter/resources/test/fixtures/pylint/collapsible_else_if.py index 7848b2c05725d..0b73d1fe5c943 100644 --- a/crates/ruff_linter/resources/test/fixtures/pylint/collapsible_else_if.py +++ b/crates/ruff_linter/resources/test/fixtures/pylint/collapsible_else_if.py @@ -97,3 +97,50 @@ def not_ok5(): if 2: pass else: pass + + +def not_ok1_with_multiline_comments(): + if 1: + pass + else: + # inner comment which happens + # to be longer than one line + if 2: + pass + else: + pass # final pass comment + + +def not_ok1_with_deep_indented_comments(): + if 1: + pass + else: + # inner comment which happens to be overly indented + if 2: + pass + else: + pass # final pass comment + + +def not_ok1_with_shallow_indented_comments(): + if 1: + pass + else: + # inner comment which happens to be under indented + if 2: + pass + else: + pass # final pass comment + + +def not_ok1_with_mixed_indented_comments(): + if 1: + pass + else: + # inner comment which has mixed + # indentation levels + # which is pretty weird + if 2: + pass + else: + pass # final pass comment diff --git a/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs b/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs index 5e491b6c53bb6..aae432959fa84 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/collapsible_else_if.rs @@ -108,7 +108,11 @@ fn convert_to_elif( let inner_if_line_start = locator.line_start(first.start()); let inner_if_line_end = locator.line_end(first.end()); - // Identify the indentation of the loop itself (e.g., the `while` or `for`). + // Capture the trivia between the `else` and the `if`. + let else_line_end = locator.full_line_end(else_clause.start()); + let trivia_range = TextRange::new(else_line_end, inner_if_line_start); + + // Identify the indentation of the outer clause let Some(indentation) = indentation(locator, else_clause) else { return Err(anyhow::anyhow!("`else` is expected to be on its own line")); }; @@ -122,15 +126,30 @@ fn convert_to_elif( stylist, )?; + // If there's trivia, restore it + let trivia = if trivia_range.is_empty() { + None + } else { + let indented_trivia = + adjust_indentation(trivia_range, indentation, locator, indexer, stylist)?; + Some(Edit::insertion( + indented_trivia, + locator.line_start(else_clause.start()), + )) + }; + // Strip the indent from the first line of the `if` statement, and add `el` to the start. let Some(unindented) = indented.strip_prefix(indentation) else { return Err(anyhow::anyhow!("indented block to start with indentation")); }; let indented = format!("{indentation}el{unindented}"); - Ok(Fix::safe_edit(Edit::replacement( - indented, - locator.line_start(else_clause.start()), - inner_if_line_end, - ))) + Ok(Fix::safe_edits( + Edit::replacement( + indented, + locator.line_start(else_clause.start()), + inner_if_line_end, + ), + trivia, + )) } diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap index 4d13e11126c9a..d5b95a89eaa68 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap +++ b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLR5501_collapsible_else_if.py.snap @@ -73,18 +73,19 @@ collapsible_else_if.py:55:5: PLR5501 [*] Use `elif` instead of `else` then `if`, 52 52 | def not_ok1_with_comments(): 53 53 | if 1: 54 54 | pass - 55 |+ elif 2: - 56 |+ pass -55 57 | else: + 55 |+ # inner comment + 56 |+ elif 2: + 57 |+ pass +55 58 | else: 56 |- # inner comment 57 |- if 2: 58 |- pass 59 |- else: 60 |- pass # final pass comment - 58 |+ pass # final pass comment -61 59 | -62 60 | -63 61 | # Regression test for https://github.com/apache/airflow/blob/f1e1cdcc3b2826e68ba133f350300b5065bbca33/airflow/models/dag.py#L1737 + 59 |+ pass # final pass comment +61 60 | +62 61 | +63 62 | # Regression test for https://github.com/apache/airflow/blob/f1e1cdcc3b2826e68ba133f350300b5065bbca33/airflow/models/dag.py#L1737 collapsible_else_if.py:69:5: PLR5501 [*] Use `elif` instead of `else` then `if`, to reduce indentation | @@ -181,15 +182,150 @@ collapsible_else_if.py:96:5: PLR5501 [*] Use `elif` instead of `else` then `if`, = help: Convert to `elif` ℹ Safe fix -93 93 | def not_ok5(): -94 94 | if 1: -95 95 | pass -96 |- else: -97 |- if 2: -98 |- pass -99 |- else: pass - 96 |+ elif 2: - 97 |+ pass - 98 |+ else: pass +93 93 | def not_ok5(): +94 94 | if 1: +95 95 | pass +96 |- else: +97 |- if 2: +98 |- pass +99 |- else: pass + 96 |+ elif 2: + 97 |+ pass + 98 |+ else: pass +100 99 | +101 100 | +102 101 | def not_ok1_with_multiline_comments(): +collapsible_else_if.py:105:5: PLR5501 [*] Use `elif` instead of `else` then `if`, to reduce indentation + | +103 | if 1: +104 | pass +105 | else: + | _____^ +106 | | # inner comment which happens +107 | | # to be longer than one line +108 | | if 2: + | |________^ PLR5501 +109 | pass +110 | else: + | + = help: Convert to `elif` +ℹ Safe fix +102 102 | def not_ok1_with_multiline_comments(): +103 103 | if 1: +104 104 | pass + 105 |+ # inner comment which happens + 106 |+ # to be longer than one line + 107 |+ elif 2: + 108 |+ pass +105 109 | else: +106 |- # inner comment which happens +107 |- # to be longer than one line +108 |- if 2: +109 |- pass +110 |- else: +111 |- pass # final pass comment + 110 |+ pass # final pass comment +112 111 | +113 112 | +114 113 | def not_ok1_with_deep_indented_comments(): + +collapsible_else_if.py:117:5: PLR5501 [*] Use `elif` instead of `else` then `if`, to reduce indentation + | +115 | if 1: +116 | pass +117 | else: + | _____^ +118 | | # inner comment which happens to be overly indented +119 | | if 2: + | |________^ PLR5501 +120 | pass +121 | else: + | + = help: Convert to `elif` + +ℹ Safe fix +114 114 | def not_ok1_with_deep_indented_comments(): +115 115 | if 1: +116 116 | pass + 117 |+ # inner comment which happens to be overly indented + 118 |+ elif 2: + 119 |+ pass +117 120 | else: +118 |- # inner comment which happens to be overly indented +119 |- if 2: +120 |- pass +121 |- else: +122 |- pass # final pass comment + 121 |+ pass # final pass comment +123 122 | +124 123 | +125 124 | def not_ok1_with_shallow_indented_comments(): + +collapsible_else_if.py:128:5: PLR5501 [*] Use `elif` instead of `else` then `if`, to reduce indentation + | +126 | if 1: +127 | pass +128 | else: + | _____^ +129 | | # inner comment which happens to be under indented +130 | | if 2: + | |________^ PLR5501 +131 | pass +132 | else: + | + = help: Convert to `elif` + +ℹ Safe fix +125 125 | def not_ok1_with_shallow_indented_comments(): +126 126 | if 1: +127 127 | pass +128 |- else: +129 128 | # inner comment which happens to be under indented +130 |- if 2: +131 |- pass +132 |- else: +133 |- pass # final pass comment + 129 |+ elif 2: + 130 |+ pass + 131 |+ else: + 132 |+ pass # final pass comment +134 133 | +135 134 | +136 135 | def not_ok1_with_mixed_indented_comments(): + +collapsible_else_if.py:139:5: PLR5501 [*] Use `elif` instead of `else` then `if`, to reduce indentation + | +137 | if 1: +138 | pass +139 | else: + | _____^ +140 | | # inner comment which has mixed +141 | | # indentation levels +142 | | # which is pretty weird +143 | | if 2: + | |________^ PLR5501 +144 | pass +145 | else: + | + = help: Convert to `elif` + +ℹ Safe fix +136 136 | def not_ok1_with_mixed_indented_comments(): +137 137 | if 1: +138 138 | pass + 139 |+ # inner comment which has mixed + 140 |+ # indentation levels + 141 |+ # which is pretty weird + 142 |+ elif 2: + 143 |+ pass +139 144 | else: +140 |- # inner comment which has mixed +141 |- # indentation levels +142 |- # which is pretty weird +143 |- if 2: +144 |- pass +145 |- else: +146 |- pass # final pass comment + 145 |+ pass # final pass comment From 4aefe523938f7176be0bcaa03b6f126c8ae783fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 3 Oct 2024 10:38:07 -0700 Subject: [PATCH 20/88] Support ruff discovery in pip build environments (#13591) Resolves https://github.com/astral-sh/ruff/issues/13321. Contents of overlay: ```bash /private/var/folders/v0/l8q3ghks2gs5ns2_p63tyqh40000gq/T/pip-build-env-e0ukpbvo/overlay/bin: total 26M -rwxr-xr-x 1 bgabor8 staff 26M Oct 1 08:22 ruff drwxr-xr-x 3 bgabor8 staff 96 Oct 1 08:22 . drwxr-xr-x 4 bgabor8 staff 128 Oct 1 08:22 .. ``` Python executable: ```bash '/Users/bgabor8/git/github/ruff-find-bin-during-build/.venv/bin/python' ``` PATH is: ```bash ['/private/var/folders/v0/l8q3ghks2gs5ns2_p63tyqh40000gq/T/pip-build-env-e0ukpbvo/overlay/bin', '/private/var/folders/v0/l8q3ghks2gs5ns2_p63tyqh40000gq/T/pip-build-env-e0ukpbvo/normal/bin', '/Library/Frameworks/Python.framework/Versions/3.11/bin', '/Library/Frameworks/Python.framework/Versions/3.12/bin', ``` Not sure where to add tests, there does not seem to be any existing one. Can someone help me with that? --- python/ruff/__main__.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/python/ruff/__main__.py b/python/ruff/__main__.py index 9db95031bc8df..d536867ad2a1b 100644 --- a/python/ruff/__main__.py +++ b/python/ruff/__main__.py @@ -33,6 +33,27 @@ def find_ruff_bin() -> str: if os.path.isfile(target_path): return target_path + # Search for pip-specific build environments. + # + # See: https://github.com/pypa/pip/blob/102d8187a1f5a4cd5de7a549fd8a9af34e89a54f/src/pip/_internal/build_env.py#L87 + paths = os.environ.get("PATH", "").split(os.pathsep) + if len(paths) >= 2: + first, second = os.path.split(paths[0]), os.path.split(paths[1]) + # Search for both an `overlay` and `normal` folder within a `pip-build-env-{random}` folder. (The final segment + # of the path is the `bin` directory.) + if ( + len(first) >= 3 + and len(second) >= 3 + and first[-3].startswith("pip-build-env-") + and first[-2] == "overlay" + and second[-3].startswith("pip-build-env-") + and second[-2] == "normal" + ): + # The overlay must contain the ruff binary. + candidate = os.path.join(first, ruff_exe) + if os.path.isfile(candidate): + return candidate + raise FileNotFoundError(scripts_path) From 7ad07c2c5d9208ad55b55eb0e6124c2d8a07c065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 3 Oct 2024 21:44:44 +0200 Subject: [PATCH 21/88] Add `allow-unused-imports` setting for `unused-import` rule (`F401`) (#13601) ## Summary Resolves https://github.com/astral-sh/ruff/issues/9962 by allowing a configuration setting `allowed-unused-imports` TODO: - [x] Figure out the correct name and place for the setting; currently, I have added it top level. - [x] The comparison is pretty naive. I tried using `glob::Pattern` but couldn't get it to work in the configuration. - [x] Add tests - [x] Update documentations ## Test Plan `cargo test` --- ...ow_settings__display_default_settings.snap | 1 + .../test/fixtures/pyflakes/F401_31.py | 12 +++++++ crates/ruff_linter/src/rules/pyflakes/mod.rs | 22 +++++++++++-- .../src/rules/pyflakes/rules/unused_import.rs | 15 +++++++++ .../src/rules/pyflakes/settings.rs | 4 ++- ...s__f401_allowed_unused_imports_option.snap | 16 ++++++++++ crates/ruff_workspace/src/configuration.rs | 9 ++++++ crates/ruff_workspace/src/options.rs | 26 ++++++++++++++++ ruff.schema.json | 31 +++++++++++++++++++ 9 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/pyflakes/F401_31.py create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_allowed_unused_imports_option.snap diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index 97edd400abd3d..223d1b253c219 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -359,6 +359,7 @@ linter.pycodestyle.max_line_length = 88 linter.pycodestyle.max_doc_length = none linter.pycodestyle.ignore_overlong_task_comments = false linter.pyflakes.extend_generics = [] +linter.pyflakes.allowed_unused_imports = [] linter.pylint.allow_magic_value_types = [ str, bytes, diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_31.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_31.py new file mode 100644 index 0000000000000..23787a3011016 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_31.py @@ -0,0 +1,12 @@ +""" +Test: allowed-unused-imports +""" + +# OK +import hvplot.pandas +import hvplot.pandas.plots +from hvplot.pandas import scatter_matrix +from hvplot.pandas.plots import scatter_matrix + +# Errors +from hvplot.pandas_alias import scatter_matrix diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 34d03f6805c97..5e42a7e0e2cb6 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -327,6 +327,21 @@ mod tests { assert_messages!(snapshot, diagnostics); Ok(()) } + #[test_case(Rule::UnusedImport, Path::new("F401_31.py"))] + fn f401_allowed_unused_imports_option(rule_code: Rule, path: &Path) -> Result<()> { + let diagnostics = test_path( + Path::new("pyflakes").join(path).as_path(), + &LinterSettings { + pyflakes: pyflakes::settings::Settings { + allowed_unused_imports: vec!["hvplot.pandas".to_string()], + ..pyflakes::settings::Settings::default() + }, + ..LinterSettings::for_rule(rule_code) + }, + )?; + assert_messages!(diagnostics); + Ok(()) + } #[test] fn f841_dummy_variable_rgx() -> Result<()> { @@ -427,7 +442,7 @@ mod tests { Path::new("pyflakes/project/foo/bar.py"), &LinterSettings { typing_modules: vec!["foo.typical".to_string()], - ..LinterSettings::for_rules(vec![Rule::UndefinedName]) + ..LinterSettings::for_rule(Rule::UndefinedName) }, )?; assert_messages!(diagnostics); @@ -440,7 +455,7 @@ mod tests { Path::new("pyflakes/project/foo/bop/baz.py"), &LinterSettings { typing_modules: vec!["foo.typical".to_string()], - ..LinterSettings::for_rules(vec![Rule::UndefinedName]) + ..LinterSettings::for_rule(Rule::UndefinedName) }, )?; assert_messages!(diagnostics); @@ -455,8 +470,9 @@ mod tests { &LinterSettings { pyflakes: pyflakes::settings::Settings { extend_generics: vec!["django.db.models.ForeignKey".to_string()], + ..pyflakes::settings::Settings::default() }, - ..LinterSettings::for_rules(vec![Rule::UnusedImport]) + ..LinterSettings::for_rule(Rule::UnusedImport) }, )?; assert_messages!(snapshot, diagnostics); diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 57ce105454168..ecba0975d00a7 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -6,6 +6,7 @@ use std::collections::BTreeMap; use ruff_diagnostics::{Applicability, Diagnostic, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::name::QualifiedName; use ruff_python_ast::{self as ast, Stmt}; use ruff_python_semantic::{ AnyImport, BindingKind, Exceptions, Imported, NodeId, Scope, SemanticModel, SubmoduleImport, @@ -308,6 +309,20 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope, diagnostics: &mut continue; } + // If an import was marked as allowed, avoid treating it as unused. + if checker + .settings + .pyflakes + .allowed_unused_imports + .iter() + .any(|allowed_unused_import| { + let allowed_unused_import = QualifiedName::from_dotted_name(allowed_unused_import); + import.qualified_name().starts_with(&allowed_unused_import) + }) + { + continue; + } + let import = ImportBinding { name, import, diff --git a/crates/ruff_linter/src/rules/pyflakes/settings.rs b/crates/ruff_linter/src/rules/pyflakes/settings.rs index 2aa404fbe4688..94343bc987c25 100644 --- a/crates/ruff_linter/src/rules/pyflakes/settings.rs +++ b/crates/ruff_linter/src/rules/pyflakes/settings.rs @@ -7,6 +7,7 @@ use std::fmt; #[derive(Debug, Clone, Default, CacheKey)] pub struct Settings { pub extend_generics: Vec, + pub allowed_unused_imports: Vec, } impl fmt::Display for Settings { @@ -15,7 +16,8 @@ impl fmt::Display for Settings { formatter = f, namespace = "linter.pyflakes", fields = [ - self.extend_generics | debug + self.extend_generics | debug, + self.allowed_unused_imports | debug ] } Ok(()) diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_allowed_unused_imports_option.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_allowed_unused_imports_option.snap new file mode 100644 index 0000000000000..be1af3465c0eb --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_allowed_unused_imports_option.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401_31.py:12:33: F401 [*] `hvplot.pandas_alias.scatter_matrix` imported but unused + | +11 | # Errors +12 | from hvplot.pandas_alias import scatter_matrix + | ^^^^^^^^^^^^^^ F401 + | + = help: Remove unused import: `hvplot.pandas_alias.scatter_matrix` + +ℹ Safe fix +9 9 | from hvplot.pandas.plots import scatter_matrix +10 10 | +11 11 | # Errors +12 |-from hvplot.pandas_alias import scatter_matrix diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 7628fb96e233c..c8d4f94f51fc3 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -626,6 +626,7 @@ pub struct LintConfiguration { pub logger_objects: Option>, pub task_tags: Option>, pub typing_modules: Option>, + pub allowed_unused_imports: Option>, // Plugins pub flake8_annotations: Option, @@ -738,6 +739,7 @@ impl LintConfiguration { task_tags: options.common.task_tags, logger_objects: options.common.logger_objects, typing_modules: options.common.typing_modules, + allowed_unused_imports: options.common.allowed_unused_imports, // Plugins flake8_annotations: options.common.flake8_annotations, flake8_bandit: options.common.flake8_bandit, @@ -1106,6 +1108,9 @@ impl LintConfiguration { .or(config.explicit_preview_rules), task_tags: self.task_tags.or(config.task_tags), typing_modules: self.typing_modules.or(config.typing_modules), + allowed_unused_imports: self + .allowed_unused_imports + .or(config.allowed_unused_imports), // Plugins flake8_annotations: self.flake8_annotations.combine(config.flake8_annotations), flake8_bandit: self.flake8_bandit.combine(config.flake8_bandit), @@ -1327,6 +1332,7 @@ fn warn_about_deprecated_top_level_lint_options( explicit_preview_rules, task_tags, typing_modules, + allowed_unused_imports, unfixable, flake8_annotations, flake8_bandit, @@ -1425,6 +1431,9 @@ fn warn_about_deprecated_top_level_lint_options( if typing_modules.is_some() { used_options.push("typing-modules"); } + if allowed_unused_imports.is_some() { + used_options.push("allowed-unused-imports"); + } if unfixable.is_some() { used_options.push("unfixable"); diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index b1399dbaaf626..d2ddb2c0937ce 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -796,6 +796,16 @@ pub struct LintCommonOptions { )] pub typing_modules: Option>, + /// A list of modules which is allowed even thought they are not used + /// in the code. + /// + /// This is useful when a module has a side effect when imported. + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = r#"allowed-unused-imports = ["hvplot.pandas"]"# + )] + pub allowed_unused_imports: Option>, /// A list of rule codes or prefixes to consider non-fixable. #[option( default = "[]", @@ -2812,12 +2822,28 @@ pub struct PyflakesOptions { example = "extend-generics = [\"django.db.models.ForeignKey\"]" )] pub extend_generics: Option>, + + /// A list of modules to ignore when considering unused imports. + /// + /// Used to prevent violations for specific modules that are known to have side effects on + /// import (e.g., `hvplot.pandas`). + /// + /// Modules in this list are expected to be fully-qualified names (e.g., `hvplot.pandas`). Any + /// submodule of a given module will also be ignored (e.g., given `hvplot`, `hvplot.pandas` + /// will also be ignored). + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = r#"allowed-unused-imports = ["hvplot.pandas"]"# + )] + pub allowed_unused_imports: Option>, } impl PyflakesOptions { pub fn into_settings(self) -> pyflakes::settings::Settings { pyflakes::settings::Settings { extend_generics: self.extend_generics.unwrap_or_default(), + allowed_unused_imports: self.allowed_unused_imports.unwrap_or_default(), } } } diff --git a/ruff.schema.json b/ruff.schema.json index 259dff791af34..a04e37a0f3e1f 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -16,6 +16,17 @@ "minLength": 1 } }, + "allowed-unused-imports": { + "description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", + "deprecated": true, + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "analyze": { "description": "Options to configure import map generation.", "anyOf": [ @@ -1887,6 +1898,16 @@ "minLength": 1 } }, + "allowed-unused-imports": { + "description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "dummy-variable-rgx": { "description": "A regular expression used to identify \"dummy\" variables, or those which should be ignored when enforcing (e.g.) unused-variable rules. The default expression matches `_`, `__`, and `_var`, but not `_var_`.", "type": [ @@ -2585,6 +2606,16 @@ "PyflakesOptions": { "type": "object", "properties": { + "allowed-unused-imports": { + "description": "A list of modules to ignore when considering unused imports.\n\nUsed to prevent violations for specific modules that are known to have side effects on import (e.g., `hvplot.pandas`).\n\nModules in this list are expected to be fully-qualified names (e.g., `hvplot.pandas`). Any submodule of a given module will also be ignored (e.g., given `hvplot`, `hvplot.pandas` will also be ignored).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "extend-generics": { "description": "Additional functions or classes to consider generic, such that any subscripts should be treated as type annotation (e.g., `ForeignKey` in `django.db.models.ForeignKey[\"User\"]`.\n\nExpects to receive a list of fully-qualified names (e.g., `django.db.models.ForeignKey`, rather than `ForeignKey`).", "type": [ From 99e4566fce9faf1c66ca3dafd6216be3976dc913 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 3 Oct 2024 16:39:22 -0500 Subject: [PATCH 22/88] Mark `FURB118` fix as unsafe (#13613) Closes https://github.com/astral-sh/ruff/issues/13421 --- .../refurb/rules/reimplemented_operator.rs | 20 ++++-- ...es__refurb__tests__FURB118_FURB118.py.snap | 72 +++++++++---------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs index 8cd14f625be14..ce634a8e40651 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/reimplemented_operator.rs @@ -17,14 +17,14 @@ use crate::checkers::ast::Checker; use crate::importer::{ImportRequest, Importer}; /// ## What it does -/// Checks for lambda expressions and function definitions that can be replaced -/// with a function from the `operator` module. +/// Checks for lambda expressions and function definitions that can be replaced with a function from +/// the `operator` module. /// /// ## Why is this bad? -/// The `operator` module provides functions that implement the same functionality -/// as the corresponding operators. For example, `operator.add` is equivalent to -/// `lambda x, y: x + y`. Using the functions from the `operator` module is more -/// concise and communicates the intent of the code more clearly. +/// The `operator` module provides functions that implement the same functionality as the +/// corresponding operators. For example, `operator.add` is equivalent to `lambda x, y: x + y`. +/// Using the functions from the `operator` module is more concise and communicates the intent of +/// the code more clearly. /// /// ## Example /// ```python @@ -42,6 +42,12 @@ use crate::importer::{ImportRequest, Importer}; /// nums = [1, 2, 3] /// total = functools.reduce(operator.add, nums) /// ``` +/// +/// ## Fix safety +/// This fix is usually safe, but if the lambda is called with keyword arguments, e.g., +/// `add = lambda x, y: x + y; add(x=1, y=2)`, replacing the lambda with an operator function, e.g., +/// `operator.add`, will cause the call to raise a `TypeError`, as functions in `operator` do not allow +/// keyword arguments. #[violation] pub struct ReimplementedOperator { operator: Operator, @@ -177,7 +183,7 @@ impl FunctionLike<'_> { } else { format!("{binding}({})", operator.args.join(", ")) }; - Ok(Some(Fix::safe_edits( + Ok(Some(Fix::unsafe_edits( Edit::range_replacement(content, self.range()), [edit], ))) diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap index 14a1577869908..97bf4e8b85d42 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB118_FURB118.py.snap @@ -11,7 +11,7 @@ FURB118.py:2:13: FURB118 [*] Use `operator.invert` instead of defining a lambda | = help: Replace with `operator.invert` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |-op_bitnot = lambda x: ~x 2 |+import operator @@ -31,7 +31,7 @@ FURB118.py:3:10: FURB118 [*] Use `operator.not_` instead of defining a lambda | = help: Replace with `operator.not_` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -51,7 +51,7 @@ FURB118.py:4:10: FURB118 [*] Use `operator.pos` instead of defining a lambda | = help: Replace with `operator.pos` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -73,7 +73,7 @@ FURB118.py:5:10: FURB118 [*] Use `operator.neg` instead of defining a lambda | = help: Replace with `operator.neg` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -96,7 +96,7 @@ FURB118.py:7:10: FURB118 [*] Use `operator.add` instead of defining a lambda | = help: Replace with `operator.add` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -120,7 +120,7 @@ FURB118.py:8:10: FURB118 [*] Use `operator.sub` instead of defining a lambda | = help: Replace with `operator.sub` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -146,7 +146,7 @@ FURB118.py:9:11: FURB118 [*] Use `operator.mul` instead of defining a lambda | = help: Replace with `operator.mul` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -173,7 +173,7 @@ FURB118.py:10:14: FURB118 [*] Use `operator.matmul` instead of defining a lambda | = help: Replace with `operator.matmul` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -200,7 +200,7 @@ FURB118.py:11:14: FURB118 [*] Use `operator.truediv` instead of defining a lambd | = help: Replace with `operator.truediv` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -227,7 +227,7 @@ FURB118.py:12:10: FURB118 [*] Use `operator.mod` instead of defining a lambda | = help: Replace with `operator.mod` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -254,7 +254,7 @@ FURB118.py:13:10: FURB118 [*] Use `operator.pow` instead of defining a lambda | = help: Replace with `operator.pow` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -281,7 +281,7 @@ FURB118.py:14:13: FURB118 [*] Use `operator.lshift` instead of defining a lambda | = help: Replace with `operator.lshift` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -308,7 +308,7 @@ FURB118.py:15:13: FURB118 [*] Use `operator.rshift` instead of defining a lambda | = help: Replace with `operator.rshift` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -335,7 +335,7 @@ FURB118.py:16:12: FURB118 [*] Use `operator.or_` instead of defining a lambda | = help: Replace with `operator.or_` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -362,7 +362,7 @@ FURB118.py:17:10: FURB118 [*] Use `operator.xor` instead of defining a lambda | = help: Replace with `operator.xor` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -388,7 +388,7 @@ FURB118.py:18:13: FURB118 [*] Use `operator.and_` instead of defining a lambda | = help: Replace with `operator.and_` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -415,7 +415,7 @@ FURB118.py:19:15: FURB118 [*] Use `operator.floordiv` instead of defining a lamb | = help: Replace with `operator.floordiv` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -442,7 +442,7 @@ FURB118.py:21:9: FURB118 [*] Use `operator.eq` instead of defining a lambda | = help: Replace with `operator.eq` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -468,7 +468,7 @@ FURB118.py:22:9: FURB118 [*] Use `operator.ne` instead of defining a lambda | = help: Replace with `operator.ne` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -495,7 +495,7 @@ FURB118.py:23:9: FURB118 [*] Use `operator.lt` instead of defining a lambda | = help: Replace with `operator.lt` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -522,7 +522,7 @@ FURB118.py:24:10: FURB118 [*] Use `operator.le` instead of defining a lambda | = help: Replace with `operator.le` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -549,7 +549,7 @@ FURB118.py:25:9: FURB118 [*] Use `operator.gt` instead of defining a lambda | = help: Replace with `operator.gt` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -576,7 +576,7 @@ FURB118.py:26:10: FURB118 [*] Use `operator.ge` instead of defining a lambda | = help: Replace with `operator.ge` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -603,7 +603,7 @@ FURB118.py:27:9: FURB118 [*] Use `operator.is_` instead of defining a lambda | = help: Replace with `operator.is_` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -630,7 +630,7 @@ FURB118.py:28:12: FURB118 [*] Use `operator.is_not` instead of defining a lambda | = help: Replace with `operator.is_not` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -657,7 +657,7 @@ FURB118.py:29:9: FURB118 [*] Use `operator.contains` instead of defining a lambd | = help: Replace with `operator.contains` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -684,7 +684,7 @@ FURB118.py:30:17: FURB118 [*] Use `operator.itemgetter(0)` instead of defining a | = help: Replace with `operator.itemgetter(0)` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -711,7 +711,7 @@ FURB118.py:31:17: FURB118 [*] Use `operator.itemgetter(0, 1, 2)` instead of defi | = help: Replace with `operator.itemgetter(0, 1, 2)` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -738,7 +738,7 @@ FURB118.py:32:17: FURB118 [*] Use `operator.itemgetter(slice(1, None), 2)` inste | = help: Replace with `operator.itemgetter(slice(1, None), 2)` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -765,7 +765,7 @@ FURB118.py:33:17: FURB118 [*] Use `operator.itemgetter(slice(None))` instead of | = help: Replace with `operator.itemgetter(slice(None))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -791,7 +791,7 @@ FURB118.py:34:17: FURB118 [*] Use `operator.itemgetter((0, 1))` instead of defin | = help: Replace with `operator.itemgetter((0, 1))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -816,7 +816,7 @@ FURB118.py:35:17: FURB118 [*] Use `operator.itemgetter((0, 1))` instead of defin | = help: Replace with `operator.itemgetter((0, 1))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -857,7 +857,7 @@ FURB118.py:88:17: FURB118 [*] Use `operator.itemgetter((slice(None), 1))` instea | = help: Replace with `operator.itemgetter((slice(None), 1))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -884,7 +884,7 @@ FURB118.py:89:17: FURB118 [*] Use `operator.itemgetter((1, slice(None)))` instea | = help: Replace with `operator.itemgetter((1, slice(None)))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -910,7 +910,7 @@ FURB118.py:92:17: FURB118 [*] Use `operator.itemgetter((1, slice(None)))` instea | = help: Replace with `operator.itemgetter((1, slice(None)))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x @@ -934,7 +934,7 @@ FURB118.py:95:17: FURB118 [*] Use `operator.itemgetter((1, 2))` instead | = help: Replace with `operator.itemgetter((1, 2))` -ℹ Safe fix +ℹ Unsafe fix 1 1 | # Errors. 2 |+import operator 2 3 | op_bitnot = lambda x: ~x From 975be9c1c6cc8f8f4e52bdad8123d151192c7123 Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala Date: Fri, 4 Oct 2024 18:51:13 +0530 Subject: [PATCH 23/88] Bump version to 0.6.9 (#13624) --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ Cargo.lock | 6 +++--- README.md | 6 +++--- crates/ruff/Cargo.toml | 2 +- crates/ruff_linter/Cargo.toml | 2 +- crates/ruff_wasm/Cargo.toml | 2 +- docs/integrations.md | 6 +++--- pyproject.toml | 2 +- scripts/benchmarks/pyproject.toml | 2 +- 9 files changed, 42 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d76074d5a02..4e7f621738038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.6.9 + +### Preview features + +- Fix codeblock dynamic line length calculation for indented docstring examples ([#13523](https://github.com/astral-sh/ruff/pull/13523)) +- \[`refurb`\] Mark `FURB118` fix as unsafe ([#13613](https://github.com/astral-sh/ruff/pull/13613)) + +### Rule changes + +- \[`pydocstyle`\] Don't raise `D208` when last line is non-empty ([#13372](https://github.com/astral-sh/ruff/pull/13372)) +- \[`pylint`\] Preserve trivia (i.e. comments) in `PLR5501` autofix ([#13573](https://github.com/astral-sh/ruff/pull/13573)) + +### Configuration + +- \[`pyflakes`\] Add `allow-unused-imports` setting for `unused-import` rule (`F401`) ([#13601](https://github.com/astral-sh/ruff/pull/13601)) + +### Bug fixes + +- Support ruff discovery in pip build environments ([#13591](https://github.com/astral-sh/ruff/pull/13591)) +- \[`flake8-bugbear`\] Avoid short circuiting `B017` for multiple context managers ([#13609](https://github.com/astral-sh/ruff/pull/13609)) +- \[`pylint`\] Do not offer an invalid fix for `PLR1716` when the comparisons contain parenthesis ([#13527](https://github.com/astral-sh/ruff/pull/13527)) +- \[`pyupgrade`\] Fix `UP043` to apply to `collections.abc.Generator` and `collections.abc.AsyncGenerator` ([#13611](https://github.com/astral-sh/ruff/pull/13611)) +- \[`refurb`\] Fix handling of slices in tuples for `FURB118`, e.g., `x[:, 1]` ([#13518](https://github.com/astral-sh/ruff/pull/13518)) + +### Documentation + +- Update GitHub Action link to `astral-sh/ruff-action` ([#13551](https://github.com/astral-sh/ruff/pull/13551)) + ## 0.6.8 ### Preview features diff --git a/Cargo.lock b/Cargo.lock index f4326c3ffa3f8..dadfc5b6b4723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,7 +2264,7 @@ dependencies = [ [[package]] name = "ruff" -version = "0.6.8" +version = "0.6.9" dependencies = [ "anyhow", "argfile", @@ -2484,7 +2484,7 @@ dependencies = [ [[package]] name = "ruff_linter" -version = "0.6.8" +version = "0.6.9" dependencies = [ "aho-corasick", "annotate-snippets 0.9.2", @@ -2804,7 +2804,7 @@ dependencies = [ [[package]] name = "ruff_wasm" -version = "0.6.8" +version = "0.6.9" dependencies = [ "console_error_panic_hook", "console_log", diff --git a/README.md b/README.md index 991d7d3289125..d5ee610ca391e 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh powershell -c "irm https://astral.sh/ruff/install.ps1 | iex" # For a specific version. -curl -LsSf https://astral.sh/ruff/0.6.8/install.sh | sh -powershell -c "irm https://astral.sh/ruff/0.6.8/install.ps1 | iex" +curl -LsSf https://astral.sh/ruff/0.6.9/install.sh | sh +powershell -c "irm https://astral.sh/ruff/0.6.9/install.ps1 | iex" ``` You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff), @@ -170,7 +170,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.8 + rev: v0.6.9 hooks: # Run the linter. - id: ruff diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index a3209d1abf141..d6889a3c13bfa 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff" -version = "0.6.8" +version = "0.6.9" publish = true authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index 90685690915e5..a795dd30d1ac1 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.6.8" +version = "0.6.9" publish = false authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index 780d937c6622a..fa94e2bb198c3 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.6.8" +version = "0.6.9" publish = false authors = { workspace = true } edition = { workspace = true } diff --git a/docs/integrations.md b/docs/integrations.md index 74691422cee6b..d39a3114eb721 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -78,7 +78,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.8 + rev: v0.6.9 hooks: # Run the linter. - id: ruff @@ -91,7 +91,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.8 + rev: v0.6.9 hooks: # Run the linter. - id: ruff @@ -105,7 +105,7 @@ To run the hooks over Jupyter Notebooks too, add `jupyter` to the list of allowe ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.8 + rev: v0.6.9 hooks: # Run the linter. - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 04d09d0ff1d16..3cb16e139de03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ruff" -version = "0.6.8" +version = "0.6.9" description = "An extremely fast Python linter and code formatter, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] readme = "README.md" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index 3fc59362d0fdc..4f392d074fdcc 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "0.6.8" +version = "0.6.9" description = "" authors = ["Charles Marsh "] From d726f09cf045d74d4de60ac498e175b75605a854 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 4 Oct 2024 08:48:47 -0500 Subject: [PATCH 24/88] Fix `PTH123` false positive when `open` is passed a file descriptor (#13616) Closes https://github.com/astral-sh/ruff/issues/12871 Includes some minor semantic type inference extensions changes to help with reliably detecting integers --- .../fixtures/flake8_use_pathlib/full_name.py | 9 ++++++ .../rules/replaceable_by_pathlib.rs | 31 ++++++++++++++++++- ...ake8_use_pathlib__tests__full_name.py.snap | 2 -- .../src/analyze/typing.rs | 27 +++++++++++----- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/full_name.py b/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/full_name.py index 5d2fca01873df..20197b9703863 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/full_name.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/full_name.py @@ -46,3 +46,12 @@ def opener(path, flags): open(p, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None) open(p, 'r', - 1, None, None, None, True, None) open(p, 'r', - 1, None, None, None, False, opener) + +# Cannot be upgraded `pathlib.Open` does not support fds +# See https://github.com/astral-sh/ruff/issues/12871 +open(1) +open(1, "w") +x = 2 +open(x) +def foo(y: int): + open(y) diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs index fabcdd40e3ad7..aa1db51279f61 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/replaceable_by_pathlib.rs @@ -1,5 +1,7 @@ use ruff_diagnostics::{Diagnostic, DiagnosticKind}; -use ruff_python_ast::{Expr, ExprBooleanLiteral, ExprCall}; +use ruff_python_ast::{self as ast, Expr, ExprBooleanLiteral, ExprCall}; +use ruff_python_semantic::analyze::typing; +use ruff_python_semantic::SemanticModel; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; @@ -124,6 +126,10 @@ pub(crate) fn replaceable_by_pathlib(checker: &mut Checker, call: &ExprCall) { .arguments .find_argument("opener", 7) .is_some_and(|expr| !expr.is_none_literal_expr()) + || call + .arguments + .find_positional(0) + .is_some_and(|expr| is_file_descriptor(expr, checker.semantic())) { return None; } @@ -159,3 +165,26 @@ pub(crate) fn replaceable_by_pathlib(checker: &mut Checker, call: &ExprCall) { } } } + +/// Returns `true` if the given expression looks like a file descriptor, i.e., if it is an integer. +fn is_file_descriptor(expr: &Expr, semantic: &SemanticModel) -> bool { + if matches!( + expr, + Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(_), + .. + }) + ) { + return true; + }; + + let Some(name) = expr.as_name_expr() else { + return false; + }; + + let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else { + return false; + }; + + typing::is_int(binding, semantic) +} diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap index 92dd4a47e4b2e..ddcff48231649 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__full_name.py.snap @@ -317,5 +317,3 @@ full_name.py:47:1: PTH123 `open()` should be replaced by `Path.open()` | ^^^^ PTH123 48 | open(p, 'r', - 1, None, None, None, False, opener) | - - diff --git a/crates/ruff_python_semantic/src/analyze/typing.rs b/crates/ruff_python_semantic/src/analyze/typing.rs index 2b1531dce88a6..073b6334a6515 100644 --- a/crates/ruff_python_semantic/src/analyze/typing.rs +++ b/crates/ruff_python_semantic/src/analyze/typing.rs @@ -11,7 +11,7 @@ use ruff_python_stdlib::typing::{ }; use ruff_text_size::Ranged; -use crate::analyze::type_inference::{PythonType, ResolvedPythonType}; +use crate::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType}; use crate::model::SemanticModel; use crate::{Binding, BindingKind, Modules}; @@ -576,7 +576,7 @@ trait BuiltinTypeChecker { /// Builtin type name. const BUILTIN_TYPE_NAME: &'static str; /// Type name as found in the `Typing` module. - const TYPING_NAME: &'static str; + const TYPING_NAME: Option<&'static str>; /// [`PythonType`] associated with the intended type. const EXPR_TYPE: PythonType; @@ -584,7 +584,7 @@ trait BuiltinTypeChecker { fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool { let value = map_subscript(annotation); semantic.match_builtin_expr(value, Self::BUILTIN_TYPE_NAME) - || semantic.match_typing_expr(value, Self::TYPING_NAME) + || Self::TYPING_NAME.is_some_and(|name| semantic.match_typing_expr(value, name)) } /// Check initializer expression to match the intended type. @@ -624,7 +624,7 @@ struct ListChecker; impl BuiltinTypeChecker for ListChecker { const BUILTIN_TYPE_NAME: &'static str = "list"; - const TYPING_NAME: &'static str = "List"; + const TYPING_NAME: Option<&'static str> = Some("List"); const EXPR_TYPE: PythonType = PythonType::List; } @@ -632,7 +632,7 @@ struct DictChecker; impl BuiltinTypeChecker for DictChecker { const BUILTIN_TYPE_NAME: &'static str = "dict"; - const TYPING_NAME: &'static str = "Dict"; + const TYPING_NAME: Option<&'static str> = Some("Dict"); const EXPR_TYPE: PythonType = PythonType::Dict; } @@ -640,7 +640,7 @@ struct SetChecker; impl BuiltinTypeChecker for SetChecker { const BUILTIN_TYPE_NAME: &'static str = "set"; - const TYPING_NAME: &'static str = "Set"; + const TYPING_NAME: Option<&'static str> = Some("Set"); const EXPR_TYPE: PythonType = PythonType::Set; } @@ -648,10 +648,18 @@ struct TupleChecker; impl BuiltinTypeChecker for TupleChecker { const BUILTIN_TYPE_NAME: &'static str = "tuple"; - const TYPING_NAME: &'static str = "Tuple"; + const TYPING_NAME: Option<&'static str> = Some("Tuple"); const EXPR_TYPE: PythonType = PythonType::Tuple; } +struct IntChecker; + +impl BuiltinTypeChecker for IntChecker { + const BUILTIN_TYPE_NAME: &'static str = "int"; + const TYPING_NAME: Option<&'static str> = None; + const EXPR_TYPE: PythonType = PythonType::Number(NumberLike::Integer); +} + pub struct IoBaseChecker; impl TypeChecker for IoBaseChecker { @@ -761,6 +769,11 @@ pub fn is_dict(binding: &Binding, semantic: &SemanticModel) -> bool { check_type::(binding, semantic) } +/// Test whether the given binding can be considered an integer. +pub fn is_int(binding: &Binding, semantic: &SemanticModel) -> bool { + check_type::(binding, semantic) +} + /// Test whether the given binding can be considered a set. /// /// For this, we check what value might be associated with it through it's initialization and From 888930b7d37c686ff9680440867c1fa3fa7e99dd Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 4 Oct 2024 19:40:59 +0200 Subject: [PATCH 25/88] [red-knot] feat: implement integer comparison (#13571) ## Summary Implements the comparison operator for `[Type::IntLiteral]` and `[Type::BooleanLiteral]` (as an artifact of special handling of `True` and `False` in python). Sets the framework to implement more comparison for types known at static time (e.g. `BooleanLiteral`, `StringLiteral`), allowing us to only implement cases of the triplet ` Type`, ` Type`, `CmpOp`. Contributes to #12701 (without checking off an item yet). ## Test Plan - Added a test for the comparison of literals that should include most cases of note. - Added a test for the comparison of int instances Please note that the cases do not cover 100% of the branches as there are many and the current testing strategy with variables make this fairly confusing once we have too many in one test. --------- Co-authored-by: Carl Meyer Co-authored-by: Alex Waygood --- Cargo.lock | 1 + crates/red_knot_python_semantic/Cargo.toml | 1 + .../src/types/infer.rs | 341 ++++++++++++++++-- 3 files changed, 320 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dadfc5b6b4723..1d6904d703e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2083,6 +2083,7 @@ dependencies = [ "countme", "hashbrown", "insta", + "itertools 0.13.0", "ordermap", "red_knot_vendored", "ruff_db", diff --git a/crates/red_knot_python_semantic/Cargo.toml b/crates/red_knot_python_semantic/Cargo.toml index 6aff354f5fb6d..f9aee056356ba 100644 --- a/crates/red_knot_python_semantic/Cargo.toml +++ b/crates/red_knot_python_semantic/Cargo.toml @@ -24,6 +24,7 @@ bitflags = { workspace = true } camino = { workspace = true } compact_str = { workspace = true } countme = { workspace = true } +itertools = { workspace = true} ordermap = { workspace = true } salsa = { workspace = true } thiserror = { workspace = true } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 119fb207b8be7..b46be62df9d3d 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -26,6 +26,7 @@ //! stringified annotations. We have a fourth Salsa query for inferring the deferred types //! associated with a particular definition. Scope-level inference infers deferred types for all //! definitions once the rest of the types in the scope have been inferred. +use itertools::Itertools; use std::num::NonZeroU32; use ruff_db::files::File; @@ -328,6 +329,14 @@ impl<'db> TypeInferenceBuilder<'db> { matches!(self.region, InferenceRegion::Deferred(_)) } + /// Get the already-inferred type of an expression node. + /// + /// PANIC if no type has been inferred for this node. + fn expression_ty(&self, expr: &ast::Expr) -> Type<'db> { + self.types + .expression_ty(expr.scoped_ast_id(self.db, self.scope)) + } + /// Infers types in the given [`InferenceRegion`]. fn infer_region(&mut self) { match self.region { @@ -984,9 +993,7 @@ impl<'db> TypeInferenceBuilder<'db> { // TODO(dhruvmanila): The correct type inference here is the return type of the __enter__ // method of the context manager. - let context_expr_ty = self - .types - .expression_ty(with_item.context_expr.scoped_ast_id(self.db, self.scope)); + let context_expr_ty = self.expression_ty(&with_item.context_expr); self.types .expressions @@ -1151,9 +1158,7 @@ impl<'db> TypeInferenceBuilder<'db> { let expression = self.index.expression(assignment.value.as_ref()); let result = infer_expression_types(self.db, expression); self.extend(result); - let value_ty = self - .types - .expression_ty(assignment.value.scoped_ast_id(self.db, self.scope)); + let value_ty = self.expression_ty(&assignment.value); self.add_binding(assignment.into(), definition, value_ty); self.types .expressions @@ -1349,9 +1354,7 @@ impl<'db> TypeInferenceBuilder<'db> { let expression = self.index.expression(iterable); let result = infer_expression_types(self.db, expression); self.extend(result); - let iterable_ty = self - .types - .expression_ty(iterable.scoped_ast_id(self.db, self.scope)); + let iterable_ty = self.expression_ty(iterable); let loop_var_value_ty = if is_async { // TODO(Alex): async iterables/iterators! @@ -2434,28 +2437,41 @@ impl<'db> TypeInferenceBuilder<'db> { op, values, } = bool_op; + Self::infer_chained_boolean_types( + self.db, + *op, + values.iter().map(|value| self.infer_expression(value)), + values.len(), + ) + } + + /// Computes the output of a chain of (one) boolean operation, consuming as input an iterator + /// of types. The iterator is consumed even if the boolean evaluation can be short-circuited, + /// in order to ensure the invariant that all expressions are evaluated when inferring types. + fn infer_chained_boolean_types( + db: &'db dyn Db, + op: ast::BoolOp, + values: impl IntoIterator>, + n_values: usize, + ) -> Type<'db> { let mut done = false; UnionType::from_elements( - self.db, - values.iter().enumerate().map(|(i, value)| { - // We need to infer the type of every expression (that's an invariant maintained by - // type inference), even if we can short-circuit boolean evaluation of some of - // those types. - let value_ty = self.infer_expression(value); + db, + values.into_iter().enumerate().map(|(i, ty)| { if done { Type::Never } else { - let is_last = i == values.len() - 1; - match (value_ty.bool(self.db), is_last, op) { - (Truthiness::Ambiguous, _, _) => value_ty, + let is_last = i == n_values - 1; + match (ty.bool(db), is_last, op) { + (Truthiness::Ambiguous, _, _) => ty, (Truthiness::AlwaysTrue, false, ast::BoolOp::And) => Type::Never, (Truthiness::AlwaysFalse, false, ast::BoolOp::Or) => Type::Never, (Truthiness::AlwaysFalse, _, ast::BoolOp::And) | (Truthiness::AlwaysTrue, _, ast::BoolOp::Or) => { done = true; - value_ty + ty } - (_, true, _) => value_ty, + (_, true, _) => ty, } } }), @@ -2466,16 +2482,138 @@ impl<'db> TypeInferenceBuilder<'db> { let ast::ExprCompare { range: _, left, - ops: _, + ops, comparators, } = compare; self.infer_expression(left); - // TODO actually handle ops and return correct type for right in comparators.as_ref() { self.infer_expression(right); } - Type::Todo + + // https://docs.python.org/3/reference/expressions.html#comparisons + // > Formally, if `a, b, c, …, y, z` are expressions and `op1, op2, …, opN` are comparison + // > operators, then `a op1 b op2 c ... y opN z` is equivalent to a `op1 b and b op2 c and + // ... > y opN z`, except that each expression is evaluated at most once. + // + // As some operators (==, !=, <, <=, >, >=) *can* return an arbitrary type, the logic below + // is shared with the one in `infer_binary_type_comparison`. + Self::infer_chained_boolean_types( + self.db, + ast::BoolOp::And, + std::iter::once(left.as_ref()) + .chain(comparators.as_ref().iter()) + .tuple_windows::<(_, _)>() + .zip(ops.iter()) + .map(|((left, right), op)| { + let left_ty = self.expression_ty(left); + let right_ty = self.expression_ty(right); + + self.infer_binary_type_comparison(left_ty, *op, right_ty) + .unwrap_or_else(|| { + // Handle unsupported operators (diagnostic, `bool`/`Unknown` outcome) + self.add_diagnostic( + AnyNodeRef::ExprCompare(compare), + "operator-unsupported", + format_args!( + "Operator `{}` is not supported for types `{}` and `{}`", + op, + left_ty.display(self.db), + right_ty.display(self.db) + ), + ); + match op { + // `in, not in, is, is not` always return bool instances + ast::CmpOp::In + | ast::CmpOp::NotIn + | ast::CmpOp::Is + | ast::CmpOp::IsNot => { + builtins_symbol_ty(self.db, "bool").to_instance(self.db) + } + // Other operators can return arbitrary types + _ => Type::Unknown, + } + }) + }), + ops.len(), + ) + } + + /// Infers the type of a binary comparison (e.g. 'left == right'). See + /// `infer_compare_expression` for the higher level logic dealing with multi-comparison + /// expressions. + /// + /// If the operation is not supported, return None (we need upstream context to emit a + /// diagnostic). + fn infer_binary_type_comparison( + &mut self, + left: Type<'db>, + op: ast::CmpOp, + right: Type<'db>, + ) -> Option> { + // Note: identity (is, is not) for equal builtin types is unreliable and not part of the + // language spec. + // - `[ast::CompOp::Is]`: return `false` if unequal, `bool` if equal + // - `[ast::CompOp::IsNot]`: return `true` if unequal, `bool` if equal + match (left, right) { + (Type::IntLiteral(n), Type::IntLiteral(m)) => match op { + ast::CmpOp::Eq => Some(Type::BooleanLiteral(n == m)), + ast::CmpOp::NotEq => Some(Type::BooleanLiteral(n != m)), + ast::CmpOp::Lt => Some(Type::BooleanLiteral(n < m)), + ast::CmpOp::LtE => Some(Type::BooleanLiteral(n <= m)), + ast::CmpOp::Gt => Some(Type::BooleanLiteral(n > m)), + ast::CmpOp::GtE => Some(Type::BooleanLiteral(n >= m)), + ast::CmpOp::Is => { + if n == m { + Some(builtins_symbol_ty(self.db, "bool").to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(false)) + } + } + ast::CmpOp::IsNot => { + if n == m { + Some(builtins_symbol_ty(self.db, "bool").to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(true)) + } + } + // Undefined for (int, int) + ast::CmpOp::In | ast::CmpOp::NotIn => None, + }, + (Type::IntLiteral(_), Type::Instance(_)) => { + self.infer_binary_type_comparison(Type::builtin_int_instance(self.db), op, right) + } + (Type::Instance(_), Type::IntLiteral(_)) => { + self.infer_binary_type_comparison(left, op, Type::builtin_int_instance(self.db)) + } + // Booleans are coded as integers (False = 0, True = 1) + (Type::IntLiteral(n), Type::BooleanLiteral(b)) => self.infer_binary_type_comparison( + Type::IntLiteral(n), + op, + Type::IntLiteral(i64::from(b)), + ), + (Type::BooleanLiteral(b), Type::IntLiteral(m)) => self.infer_binary_type_comparison( + Type::IntLiteral(i64::from(b)), + op, + Type::IntLiteral(m), + ), + (Type::BooleanLiteral(a), Type::BooleanLiteral(b)) => self + .infer_binary_type_comparison( + Type::IntLiteral(i64::from(a)), + op, + Type::IntLiteral(i64::from(b)), + ), + // Lookup the rich comparison `__dunder__` methods on instances + (Type::Instance(left_class_ty), Type::Instance(right_class_ty)) => match op { + ast::CmpOp::Lt => { + perform_rich_comparison(self.db, left_class_ty, right_class_ty, "__lt__") + } + // TODO: implement mapping from `ast::CmpOp` to rich comparison methods + _ => Some(Type::Todo), + }, + // TODO: handle more types + _ => Some(Type::Todo), + } } fn infer_subscript_expression(&mut self, subscript: &ast::ExprSubscript) -> Type<'db> { @@ -2995,6 +3133,36 @@ impl StringPartsCollector { } } +/// Rich comparison in Python are the operators `==`, `!=`, `<`, `<=`, `>`, and `>=`. Their +/// behaviour can be edited for classes by implementing corresponding dunder methods. +/// This function performs rich comparison between two instances and returns the resulting type. +/// see `` +fn perform_rich_comparison<'db>( + db: &'db dyn Db, + left: ClassType<'db>, + right: ClassType<'db>, + dunder_name: &str, +) -> Option> { + // The following resource has details about the rich comparison algorithm: + // https://snarky.ca/unravelling-rich-comparison-operators/ + // + // TODO: the reflected dunder actually has priority if the r.h.s. is a strict subclass of the + // l.h.s. + // TODO: `object.__ne__` will call `__eq__` if `__ne__` is not defined + + let dunder = left.class_member(db, dunder_name); + if !dunder.is_unbound() { + // TODO: this currently gives the return type even if the arg types are invalid + // (e.g. int.__lt__ with string instance should be None, currently bool) + return dunder + .call(db, &[Type::Instance(left), Type::Instance(right)]) + .return_ty(db); + } + + // TODO: reflected dunder -- (==, ==), (!=, !=), (<, >), (>, <), (<=, >=), (>=, <=) + None +} + #[cfg(test)] mod tests { @@ -3879,6 +4047,133 @@ mod tests { Ok(()) } + #[test] + fn comparison_integer_literals() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "src/a.py", + r#" + a = 1 == 1 == True + b = 1 == 1 == 2 == 4 + c = False < True <= 2 < 3 != 6 + d = 1 < 1 + e = 1 > 1 + f = 1 is 1 + g = 1 is not 1 + h = 1 is 2 + i = 1 is not 7 + j = 1 <= "" and 0 < 1 + "#, + )?; + + assert_public_ty(&db, "src/a.py", "a", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "b", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "c", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "d", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "e", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "f", "bool"); + assert_public_ty(&db, "src/a.py", "g", "bool"); + assert_public_ty(&db, "src/a.py", "h", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "i", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "j", "@Todo | Literal[True]"); + + Ok(()) + } + + #[test] + fn comparison_integer_instance() -> anyhow::Result<()> { + let mut db = setup_db(); + + db.write_dedented( + "src/a.py", + r#" + def int_instance() -> int: ... + a = 1 == int_instance() + b = 9 < int_instance() + c = int_instance() < int_instance() + "#, + )?; + + // TODO: implement lookup of `__eq__` on typeshed `int` stub + assert_public_ty(&db, "src/a.py", "a", "@Todo"); + assert_public_ty(&db, "src/a.py", "b", "bool"); + assert_public_ty(&db, "src/a.py", "c", "bool"); + + Ok(()) + } + + #[test] + fn comparison_unsupported_operators() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "src/a.py", + r#" + a = 1 in 7 + b = 0 not in 10 + c = object() < 5 + d = 5 < object() + "#, + )?; + + assert_file_diagnostics( + &db, + "src/a.py", + &[ + "Operator `in` is not supported for types `Literal[1]` and `Literal[7]`", + "Operator `not in` is not supported for types `Literal[0]` and `Literal[10]`", + "Operator `<` is not supported for types `object` and `Literal[5]`", + ], + ); + assert_public_ty(&db, "src/a.py", "a", "bool"); + assert_public_ty(&db, "src/a.py", "b", "bool"); + assert_public_ty(&db, "src/a.py", "c", "Unknown"); + // TODO: this should be `Unknown` but we don't check if __lt__ signature is valid for right + // operand type + assert_public_ty(&db, "src/a.py", "d", "bool"); + + Ok(()) + } + + #[test] + fn comparison_non_bool_returns() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "src/a.py", + r#" + from __future__ import annotations + class A: + def __lt__(self, other) -> A: ... + class B: + def __lt__(self, other) -> B: ... + class C: + def __lt__(self, other) -> C: ... + + a = A() < B() < C() + b = 0 < 1 < A() < 3 + c = 10 < 0 < A() < B() < C() + "#, + )?; + + // Walking through the example + // 1. A() < B() < C() + // 2. A() < B() and B() < C() - split in N comparison + // 3. A() and B() - evaluate outcome types + // 4. bool and bool - evaluate truthiness + // 5. A | B - union of "first true" types + assert_public_ty(&db, "src/a.py", "a", "A | B"); + // Walking through the example + // 1. 0 < 1 < A() < 3 + // 2. 0 < 1 and 1 < A() and A() < 3 - split in N comparison + // 3. True and bool and A - evaluate outcome types + // 4. True and bool and bool - evaluate truthiness + // 5. bool | A - union of "true" types + assert_public_ty(&db, "src/a.py", "b", "bool | A"); + // Short-cicuit to False + assert_public_ty(&db, "src/a.py", "c", "Literal[False]"); + + Ok(()) + } + #[test] fn bytes_type() -> anyhow::Result<()> { let mut db = setup_db(); From 020f4d4a54eec15b53a5fd94409600f1ad0a63aa Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 4 Oct 2024 14:09:43 -0500 Subject: [PATCH 26/88] Add test cases for `RUF006` with lambdas (#13628) As discussed in https://github.com/astral-sh/ruff/issues/13619 --- crates/ruff_linter/resources/test/fixtures/ruff/RUF006.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF006.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF006.py index 2db2e711d54a3..1944d7429de03 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF006.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF006.py @@ -185,3 +185,9 @@ def f(): global task loop = asyncio.get_event_loop() task = loop.create_task(main()) # Error + +# OK +# The task is returned by the lambda +f = lambda *args: asyncio.create_task(foo()) +f = lambda *args: lambda *args: asyncio.create_task(foo()) +f = lambda *args: [asyncio.create_task(foo()) for x in args] From 2a365bb278bb00e4bb38825cb4c7eeb09cc11516 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 4 Oct 2024 14:22:26 -0500 Subject: [PATCH 27/88] Mark `PLE1141` fix as unsafe (#13629) Closes https://github.com/astral-sh/ruff/issues/13343 --------- Co-authored-by: Alex Waygood --- .../pylint/rules/dict_iter_missing_items.rs | 18 +++++++++++++++++- ...ts__PLE1141_dict_iter_missing_items.py.snap | 8 +++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs b/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs index 2af5085d8991f..1fa2fe15d938b 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/dict_iter_missing_items.rs @@ -33,7 +33,22 @@ use crate::checkers::ast::Checker; /// /// for city, population in data.items(): /// print(f"{city} has population {population}.") +/// +/// ## Known problems +/// If the dictionary key is a tuple, e.g.: +/// +/// ```python +/// d = {(1, 2): 3, (3, 4): 5} +/// for x, y in d: +/// print(x, y) /// ``` +/// +/// The tuple key is unpacked into `x` and `y` instead of the key and values. This means that +/// the suggested fix of using `d.items()` would result in different runtime behavior. Ruff +/// cannot consistently infer the type of a dictionary's keys. +/// +/// ## Fix safety +/// Due to the known problem with tuple keys, this fix is unsafe. #[violation] pub struct DictIterMissingItems; @@ -48,6 +63,7 @@ impl AlwaysFixableViolation for DictIterMissingItems { } } +/// PLE1141 pub(crate) fn dict_iter_missing_items(checker: &mut Checker, target: &Expr, iter: &Expr) { let Expr::Tuple(tuple) = target else { return; @@ -78,7 +94,7 @@ pub(crate) fn dict_iter_missing_items(checker: &mut Checker, target: &Expr, iter } let mut diagnostic = Diagnostic::new(DictIterMissingItems, iter.range()); - diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( + diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement( format!("{}.items()", name.id), iter.range(), ))); diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap index 34684ce92dac2..db90e1a686e01 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap +++ b/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE1141_dict_iter_missing_items.py.snap @@ -10,7 +10,7 @@ dict_iter_missing_items.py:13:13: PLE1141 [*] Unpacking a dictionary in iteratio | = help: Add a call to `.items()` -ℹ Safe fix +ℹ Unsafe fix 10 10 | s2 = {1, 2, 3} 11 11 | 12 12 | # Errors @@ -30,7 +30,7 @@ dict_iter_missing_items.py:16:13: PLE1141 [*] Unpacking a dictionary in iteratio | = help: Add a call to `.items()` -ℹ Safe fix +ℹ Unsafe fix 13 13 | for k, v in d: 14 14 | pass 15 15 | @@ -38,6 +38,4 @@ dict_iter_missing_items.py:16:13: PLE1141 [*] Unpacking a dictionary in iteratio 16 |+for k, v in d_tuple_incorrect_tuple.items(): 17 17 | pass 18 18 | -19 19 | - - +19 19 | From 7c5a7d909cbd59479ffc06deb432e3932cbb6d14 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 5 Oct 2024 17:59:36 +0100 Subject: [PATCH 28/88] [red-knot] Improve tests relating to type inference for exception handlers (#13643) --- .../src/types/infer.rs | 87 ++++++++++++------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index b46be62df9d3d..690f73a696d87 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -5968,19 +5968,22 @@ mod tests { "src/a.py", " import re + from typing_extensions import reveal_type try: x except NameError as e: - pass + reveal_type(e) except re.error as f: - pass + reveal_type(f) ", )?; - assert_public_ty(&db, "src/a.py", "e", "NameError"); - assert_public_ty(&db, "src/a.py", "f", "error"); - assert_file_diagnostics(&db, "src/a.py", &[]); + assert_file_diagnostics( + &db, + "src/a.py", + &["Revealed type is `NameError`", "Revealed type is `error`"], + ); Ok(()) } @@ -5993,21 +5996,25 @@ mod tests { "src/a.py", " from nonexistent_module import foo + from typing_extensions import reveal_type try: x except foo as e: - pass + reveal_type(foo) + reveal_type(e) ", )?; assert_file_diagnostics( &db, "src/a.py", - &["Cannot resolve import `nonexistent_module`"], + &[ + "Cannot resolve import `nonexistent_module`", + "Revealed type is `Unknown`", + "Revealed type is `Unknown`", + ], ); - assert_public_ty(&db, "src/a.py", "foo", "Unknown"); - assert_public_ty(&db, "src/a.py", "e", "Unknown"); Ok(()) } @@ -6019,25 +6026,28 @@ mod tests { db.write_dedented( "src/a.py", " + from typing_extensions import reveal_type + EXCEPTIONS = (AttributeError, TypeError) try: x except (RuntimeError, OSError) as e: - pass + reveal_type(e) except EXCEPTIONS as f: - pass + reveal_type(f) ", )?; - assert_file_diagnostics(&db, "src/a.py", &[]); - // For these TODOs we need support for `tuple` types: + let expected_diagnostics = &[ + // TODO: Should be `RuntimeError | OSError` --Alex + "Revealed type is `@Todo`", + // TODO: Should be `AttributeError | TypeError` --Alex + "Revealed type is `@Todo`", + ]; - // TODO: Should be `RuntimeError | OSError` --Alex - assert_public_ty(&db, "src/a.py", "e", "@Todo"); - // TODO: Should be `AttributeError | TypeError` --Alex - assert_public_ty(&db, "src/a.py", "e", "@Todo"); + assert_file_diagnostics(&db, "src/a.py", expected_diagnostics); Ok(()) } @@ -6049,15 +6059,16 @@ mod tests { db.write_dedented( "src/a.py", " + from typing_extensions import reveal_type + try: x except as e: - pass + reveal_type(e) ", )?; - assert_file_diagnostics(&db, "src/a.py", &[]); - assert_public_ty(&db, "src/a.py", "e", "Unknown"); + assert_file_diagnostics(&db, "src/a.py", &["Revealed type is `Unknown`"]); Ok(()) } @@ -6069,19 +6080,23 @@ mod tests { db.write_dedented( "src/a.py", " + from typing_extensions import reveal_type + try: x except* BaseException as e: - pass + reveal_type(e) ", )?; - assert_file_diagnostics(&db, "src/a.py", &[]); - // TODO: once we support `sys.version_info` branches, // we can set `--target-version=py311` in this test // and the inferred type will just be `BaseExceptionGroup` --Alex - assert_public_ty(&db, "src/a.py", "e", "Unknown | BaseExceptionGroup"); + assert_file_diagnostics( + &db, + "src/a.py", + &["Revealed type is `Unknown | BaseExceptionGroup`"], + ); Ok(()) } @@ -6093,21 +6108,25 @@ mod tests { db.write_dedented( "src/a.py", " + from typing_extensions import reveal_type + try: x except* OSError as e: - pass + reveal_type(e) ", )?; - assert_file_diagnostics(&db, "src/a.py", &[]); - // TODO: once we support `sys.version_info` branches, // we can set `--target-version=py311` in this test // and the inferred type will just be `BaseExceptionGroup` --Alex // // TODO more precise would be `ExceptionGroup[OSError]` --Alex - assert_public_ty(&db, "src/a.py", "e", "Unknown | BaseExceptionGroup"); + assert_file_diagnostics( + &db, + "src/a.py", + &["Revealed type is `Unknown | BaseExceptionGroup`"], + ); Ok(()) } @@ -6119,21 +6138,25 @@ mod tests { db.write_dedented( "src/a.py", " + from typing_extensions import reveal_type + try: x except* (TypeError, AttributeError) as e: - pass + reveal_type(e) ", )?; - assert_file_diagnostics(&db, "src/a.py", &[]); - // TODO: once we support `sys.version_info` branches, // we can set `--target-version=py311` in this test // and the inferred type will just be `BaseExceptionGroup` --Alex // // TODO more precise would be `ExceptionGroup[TypeError | AttributeError]` --Alex - assert_public_ty(&db, "src/a.py", "e", "Unknown | BaseExceptionGroup"); + assert_file_diagnostics( + &db, + "src/a.py", + &["Revealed type is `Unknown | BaseExceptionGroup`"], + ); Ok(()) } From 1c2cafc1010ebfe59573c8dbaf57dc97db192f3c Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Oct 2024 19:03:46 +0200 Subject: [PATCH 29/88] [red-knot] more ergonomic and efficient handling of known builtin classes (#13615) --- crates/red_knot_python_semantic/src/types.rs | 216 ++++++++++++++---- .../src/types/builder.rs | 20 +- .../src/types/infer.rs | 46 ++-- 3 files changed, 208 insertions(+), 74 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index b415266d38886..ce84a55e6dee0 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -14,7 +14,7 @@ use crate::stdlib::{ builtins_symbol_ty, types_symbol_ty, typeshed_symbol_ty, typing_extensions_symbol_ty, }; use crate::types::narrow::narrowing_constraint; -use crate::{Db, FxOrderSet}; +use crate::{Db, FxOrderSet, Module}; pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder}; pub(crate) use self::diagnostic::TypeCheckDiagnostics; @@ -385,14 +385,6 @@ impl<'db> Type<'db> { } } - pub fn builtin_str_instance(db: &'db dyn Db) -> Self { - builtins_symbol_ty(db, "str").to_instance(db) - } - - pub fn builtin_int_instance(db: &'db dyn Db) -> Self { - builtins_symbol_ty(db, "int").to_instance(db) - } - pub fn is_stdlib_symbol(&self, db: &'db dyn Db, module_name: &str, name: &str) -> bool { match self { Type::Class(class) => class.is_stdlib_symbol(db, module_name, name), @@ -423,19 +415,17 @@ impl<'db> Type<'db> { (_, Type::Unknown | Type::Any | Type::Todo) => false, (Type::Never, _) => true, (_, Type::Never) => false, - (Type::IntLiteral(_), Type::Instance(class)) - if class.is_stdlib_symbol(db, "builtins", "int") => - { + (Type::IntLiteral(_), Type::Instance(class)) if class.is_known(db, KnownClass::Int) => { true } (Type::StringLiteral(_), Type::LiteralString) => true, (Type::StringLiteral(_) | Type::LiteralString, Type::Instance(class)) - if class.is_stdlib_symbol(db, "builtins", "str") => + if class.is_known(db, KnownClass::Str) => { true } (Type::BytesLiteral(_), Type::Instance(class)) - if class.is_stdlib_symbol(db, "builtins", "bytes") => + if class.is_known(db, KnownClass::Bytes) => { true } @@ -443,8 +433,8 @@ impl<'db> Type<'db> { .elements(db) .iter() .any(|&elem_ty| ty.is_subtype_of(db, elem_ty)), - (_, Type::Instance(class)) if class.is_stdlib_symbol(db, "builtins", "object") => true, - (Type::Instance(class), _) if class.is_stdlib_symbol(db, "builtins", "object") => false, + (_, Type::Instance(class)) if class.is_known(db, KnownClass::Object) => true, + (Type::Instance(class), _) if class.is_known(db, KnownClass::Object) => false, // TODO _ => false, } @@ -600,9 +590,9 @@ impl<'db> Type<'db> { fn call(self, db: &'db dyn Db, arg_types: &[Type<'db>]) -> CallOutcome<'db> { match self { // TODO validate typed call arguments vs callable signature - Type::Function(function_type) => match function_type.kind(db) { - FunctionKind::Ordinary => CallOutcome::callable(function_type.return_type(db)), - FunctionKind::RevealType => CallOutcome::revealed( + Type::Function(function_type) => match function_type.known(db) { + None => CallOutcome::callable(function_type.return_type(db)), + Some(KnownFunction::RevealType) => CallOutcome::revealed( function_type.return_type(db), *arg_types.first().unwrap_or(&Type::Unknown), ), @@ -610,16 +600,15 @@ impl<'db> Type<'db> { // TODO annotated return type on `__new__` or metaclass `__call__` Type::Class(class) => { - // If the class is the builtin-bool class (for example `bool(1)`), we try to return - // the specific truthiness value of the input arg, `Literal[True]` for the example above. - let is_bool = class.is_stdlib_symbol(db, "builtins", "bool"); - CallOutcome::callable(if is_bool { - arg_types + CallOutcome::callable(match class.known(db) { + // If the class is the builtin-bool class (for example `bool(1)`), we try to + // return the specific truthiness value of the input arg, `Literal[True]` for + // the example above. + Some(KnownClass::Bool) => arg_types .first() .map(|arg| arg.bool(db).into_type(db)) - .unwrap_or(Type::BooleanLiteral(false)) - } else { - Type::Instance(class) + .unwrap_or(Type::BooleanLiteral(false)), + _ => Type::Instance(class), }) } @@ -714,7 +703,7 @@ impl<'db> Type<'db> { let dunder_get_item_method = iterable_meta_type.member(db, "__getitem__"); dunder_get_item_method - .call(db, &[self, builtins_symbol_ty(db, "int").to_instance(db)]) + .call(db, &[self, KnownClass::Int.to_instance(db)]) .return_ty(db) .map(|element_ty| IterationOutcome::Iterable { element_ty }) .unwrap_or(IterationOutcome::NotIterable { @@ -758,17 +747,17 @@ impl<'db> Type<'db> { Type::Never => Type::Never, Type::Instance(class) => Type::Class(*class), Type::Union(union) => union.map(db, |ty| ty.to_meta_type(db)), - Type::BooleanLiteral(_) => builtins_symbol_ty(db, "bool"), - Type::BytesLiteral(_) => builtins_symbol_ty(db, "bytes"), - Type::IntLiteral(_) => builtins_symbol_ty(db, "int"), - Type::Function(_) => types_symbol_ty(db, "FunctionType"), - Type::Module(_) => types_symbol_ty(db, "ModuleType"), - Type::Tuple(_) => builtins_symbol_ty(db, "tuple"), - Type::None => typeshed_symbol_ty(db, "NoneType"), + Type::BooleanLiteral(_) => KnownClass::Bool.to_class(db), + Type::BytesLiteral(_) => KnownClass::Bytes.to_class(db), + Type::IntLiteral(_) => KnownClass::Int.to_class(db), + Type::Function(_) => KnownClass::FunctionType.to_class(db), + Type::Module(_) => KnownClass::ModuleType.to_class(db), + Type::Tuple(_) => KnownClass::Tuple.to_class(db), + Type::None => KnownClass::NoneType.to_class(db), // TODO not accurate if there's a custom metaclass... - Type::Class(_) => builtins_symbol_ty(db, "type"), + Type::Class(_) => KnownClass::Type.to_class(db), // TODO can we do better here? `type[LiteralString]`? - Type::StringLiteral(_) | Type::LiteralString => builtins_symbol_ty(db, "str"), + Type::StringLiteral(_) | Type::LiteralString => KnownClass::Str.to_class(db), // TODO: `type[Any]`? Type::Any => Type::Todo, // TODO: `type[Unknown]`? @@ -790,7 +779,7 @@ impl<'db> Type<'db> { Type::IntLiteral(_) | Type::BooleanLiteral(_) => self.repr(db), Type::StringLiteral(_) | Type::LiteralString => *self, // TODO: handle more complex types - _ => Type::builtin_str_instance(db), + _ => KnownClass::Str.to_instance(db), } } @@ -813,7 +802,7 @@ impl<'db> Type<'db> { })), Type::LiteralString => Type::LiteralString, // TODO: handle more complex types - _ => Type::builtin_str_instance(db), + _ => KnownClass::Str.to_instance(db), } } } @@ -824,6 +813,133 @@ impl<'db> From<&Type<'db>> for Type<'db> { } } +/// Non-exhaustive enumeration of known classes (e.g. `builtins.int`, `typing.Any`, ...) to allow +/// for easier syntax when interacting with very common classes. +/// +/// Feel free to expand this enum if you ever find yourself using the same class in multiple +/// places. +/// Note: good candidates are any classes in `[crate::stdlib::CoreStdlibModule]` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KnownClass { + // To figure out where an stdlib symbol is defined, you can go into `crates/red_knot_vendored` + // and grep for the symbol name in any `.pyi` file. + + // Builtins + Bool, + Object, + Bytes, + Type, + Int, + Float, + Str, + List, + Tuple, + Set, + Dict, + // Types + ModuleType, + FunctionType, + // Typeshed + NoneType, // Part of `types` for Python >= 3.10 +} + +impl<'db> KnownClass { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Bool => "bool", + Self::Object => "object", + Self::Bytes => "bytes", + Self::Tuple => "tuple", + Self::Int => "int", + Self::Float => "float", + Self::Str => "str", + Self::Set => "set", + Self::Dict => "dict", + Self::List => "list", + Self::Type => "type", + Self::ModuleType => "ModuleType", + Self::FunctionType => "FunctionType", + Self::NoneType => "NoneType", + } + } + + pub fn to_instance(&self, db: &'db dyn Db) -> Type<'db> { + self.to_class(db).to_instance(db) + } + + pub fn to_class(&self, db: &'db dyn Db) -> Type<'db> { + match self { + Self::Bool + | Self::Object + | Self::Bytes + | Self::Type + | Self::Int + | Self::Float + | Self::Str + | Self::List + | Self::Tuple + | Self::Set + | Self::Dict => builtins_symbol_ty(db, self.as_str()), + Self::ModuleType | Self::FunctionType => types_symbol_ty(db, self.as_str()), + Self::NoneType => typeshed_symbol_ty(db, self.as_str()), + } + } + + pub fn maybe_from_module(module: &Module, class_name: &str) -> Option { + let candidate = Self::from_name(class_name)?; + if candidate.check_module(module) { + Some(candidate) + } else { + None + } + } + + fn from_name(name: &str) -> Option { + // Note: if this becomes hard to maintain (as rust can't ensure at compile time that all + // variants of `Self` are covered), we might use a macro (in-house or dependency) + // See: https://stackoverflow.com/q/39070244 + match name { + "bool" => Some(Self::Bool), + "object" => Some(Self::Object), + "bytes" => Some(Self::Bytes), + "tuple" => Some(Self::Tuple), + "type" => Some(Self::Type), + "int" => Some(Self::Int), + "float" => Some(Self::Float), + "str" => Some(Self::Str), + "set" => Some(Self::Set), + "dict" => Some(Self::Dict), + "list" => Some(Self::List), + "NoneType" => Some(Self::NoneType), + "ModuleType" => Some(Self::ModuleType), + "FunctionType" => Some(Self::FunctionType), + _ => None, + } + } + + /// Private method checking if known class can be defined in the given module. + fn check_module(self, module: &Module) -> bool { + if !module.search_path().is_standard_library() { + return false; + } + match self { + Self::Bool + | Self::Object + | Self::Bytes + | Self::Type + | Self::Int + | Self::Float + | Self::Str + | Self::List + | Self::Tuple + | Self::Set + | Self::Dict => module.name() == "builtins", + Self::ModuleType | Self::FunctionType => module.name() == "types", + Self::NoneType => matches!(module.name().as_str(), "_typeshed" | "types"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum CallOutcome<'db> { Callable { @@ -1128,7 +1244,7 @@ impl Truthiness { match self { Self::AlwaysTrue => Type::BooleanLiteral(true), Self::AlwaysFalse => Type::BooleanLiteral(false), - Self::Ambiguous => builtins_symbol_ty(db, "bool").to_instance(db), + Self::Ambiguous => KnownClass::Bool.to_instance(db), } } } @@ -1150,7 +1266,7 @@ pub struct FunctionType<'db> { pub name: ast::name::Name, /// Is this a function that we special-case somehow? If so, which one? - kind: FunctionKind, + known: Option, definition: Definition<'db>, @@ -1202,11 +1318,10 @@ impl<'db> FunctionType<'db> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Hash)] -pub enum FunctionKind { - /// Just a normal function for which we have no particular special casing - #[default] - Ordinary, +/// Non-exhaustive enumeration of known functions (e.g. `builtins.reveal_type`, ...) that might +/// have special behavior. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum KnownFunction { /// `builtins.reveal_type`, `typing.reveal_type` or `typing_extensions.reveal_type` RevealType, } @@ -1220,9 +1335,18 @@ pub struct ClassType<'db> { definition: Definition<'db>, body_scope: ScopeId<'db>, + + known: Option, } impl<'db> ClassType<'db> { + pub fn is_known(self, db: &'db dyn Db, known_class: KnownClass) -> bool { + match self.known(db) { + Some(known) => known == known_class, + None => false, + } + } + /// Return true if this class is a standard library type with given module name and name. pub(crate) fn is_stdlib_symbol(self, db: &'db dyn Db, module_name: &str, name: &str) -> bool { name == self.name(db) diff --git a/crates/red_knot_python_semantic/src/types/builder.rs b/crates/red_knot_python_semantic/src/types/builder.rs index 1ba6bc72c4cb6..3164e9e3592d6 100644 --- a/crates/red_knot_python_semantic/src/types/builder.rs +++ b/crates/red_knot_python_semantic/src/types/builder.rs @@ -25,10 +25,12 @@ //! * No type in an intersection can be a supertype of any other type in the intersection (just //! eliminate the supertype from the intersection). //! * An intersection containing two non-overlapping types should simplify to [`Type::Never`]. -use crate::types::{builtins_symbol_ty, IntersectionType, Type, UnionType}; +use crate::types::{IntersectionType, Type, UnionType}; use crate::{Db, FxOrderSet}; use smallvec::SmallVec; +use super::KnownClass; + pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, @@ -64,7 +66,7 @@ impl<'db> UnionBuilder<'db> { let mut to_remove = SmallVec::<[usize; 2]>::new(); for (index, element) in self.elements.iter().enumerate() { if Some(*element) == bool_pair { - to_add = builtins_symbol_ty(self.db, "bool"); + to_add = KnownClass::Bool.to_class(self.db); to_remove.push(index); // The type we are adding is a BooleanLiteral, which doesn't have any // subtypes. And we just found that the union already contained our @@ -300,7 +302,7 @@ mod tests { use crate::db::tests::TestDb; use crate::program::{Program, SearchPathSettings}; use crate::python_version::PythonVersion; - use crate::types::{builtins_symbol_ty, UnionBuilder}; + use crate::types::{KnownClass, UnionBuilder}; use crate::ProgramSettings; use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; @@ -360,7 +362,7 @@ mod tests { #[test] fn build_union_bool() { let db = setup_db(); - let bool_ty = builtins_symbol_ty(&db, "bool"); + let bool_ty = KnownClass::Bool.to_class(&db); let t0 = Type::BooleanLiteral(true); let t1 = Type::BooleanLiteral(true); @@ -389,7 +391,7 @@ mod tests { #[test] fn build_union_simplify_subtype() { let db = setup_db(); - let t0 = Type::builtin_str_instance(&db); + let t0 = KnownClass::Str.to_instance(&db); let t1 = Type::LiteralString; let u0 = UnionType::from_elements(&db, [t0, t1]); let u1 = UnionType::from_elements(&db, [t1, t0]); @@ -401,7 +403,7 @@ mod tests { #[test] fn build_union_no_simplify_unknown() { let db = setup_db(); - let t0 = Type::builtin_str_instance(&db); + let t0 = KnownClass::Str.to_instance(&db); let t1 = Type::Unknown; let u0 = UnionType::from_elements(&db, [t0, t1]); let u1 = UnionType::from_elements(&db, [t1, t0]); @@ -413,9 +415,9 @@ mod tests { #[test] fn build_union_subsume_multiple() { let db = setup_db(); - let str_ty = Type::builtin_str_instance(&db); - let int_ty = Type::builtin_int_instance(&db); - let object_ty = builtins_symbol_ty(&db, "object").to_instance(&db); + let str_ty = KnownClass::Str.to_instance(&db); + let int_ty = KnownClass::Int.to_instance(&db); + let object_ty = KnownClass::Object.to_instance(&db); let unknown_ty = Type::Unknown; let u0 = UnionType::from_elements(&db, [str_ty, unknown_ty, int_ty, object_ty]); diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 690f73a696d87..eef0e9d16dcdd 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -51,11 +51,13 @@ use crate::stdlib::builtins_module_scope; use crate::types::diagnostic::{TypeCheckDiagnostic, TypeCheckDiagnostics}; use crate::types::{ bindings_ty, builtins_symbol_ty, declarations_ty, global_symbol_ty, symbol_ty, - typing_extensions_symbol_ty, BytesLiteralType, ClassType, FunctionKind, FunctionType, + typing_extensions_symbol_ty, BytesLiteralType, ClassType, FunctionType, KnownFunction, StringLiteralType, Truthiness, TupleType, Type, TypeArrayDisplay, UnionType, }; use crate::Db; +use super::KnownClass; + /// Infer all types for a [`ScopeId`], including all definitions and expressions in that scope. /// Use when checking a scope, or needing to provide a type for an arbitrary expression in the /// scope. @@ -518,8 +520,8 @@ impl<'db> TypeInferenceBuilder<'db> { match left { Type::IntLiteral(_) => {} Type::Instance(cls) - if cls.is_stdlib_symbol(self.db, "builtins", "float") - || cls.is_stdlib_symbol(self.db, "builtins", "int") => {} + if cls.is_known(self.db, KnownClass::Float) + || cls.is_known(self.db, KnownClass::Int) => {} _ => return, }; @@ -749,8 +751,10 @@ impl<'db> TypeInferenceBuilder<'db> { } let function_kind = match &**name { - "reveal_type" if definition.is_typing_definition(self.db) => FunctionKind::RevealType, - _ => FunctionKind::Ordinary, + "reveal_type" if definition.is_typing_definition(self.db) => { + Some(KnownFunction::RevealType) + } + _ => None, }; let function_ty = Type::Function(FunctionType::new( self.db, @@ -861,11 +865,15 @@ impl<'db> TypeInferenceBuilder<'db> { .node_scope(NodeWithScopeRef::Class(class)) .to_scope_id(self.db, self.file); + let maybe_known_class = file_to_module(self.db, body_scope.file(self.db)) + .as_ref() + .and_then(|module| KnownClass::maybe_from_module(module, name.as_str())); let class_ty = Type::Class(ClassType::new( self.db, name.id.clone(), definition, body_scope, + maybe_known_class, )); self.add_declaration_with_binding(class.into(), definition, class_ty, class_ty); @@ -1708,8 +1716,8 @@ impl<'db> TypeInferenceBuilder<'db> { ast::Number::Int(n) => n .as_i64() .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), - ast::Number::Float(_) => builtins_symbol_ty(self.db, "float").to_instance(self.db), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ast::Number::Float(_) => KnownClass::Float.to_instance(self.db), ast::Number::Complex { .. } => { builtins_symbol_ty(self.db, "complex").to_instance(self.db) } @@ -1826,7 +1834,7 @@ impl<'db> TypeInferenceBuilder<'db> { } // TODO generic - builtins_symbol_ty(self.db, "list").to_instance(self.db) + KnownClass::List.to_instance(self.db) } fn infer_set_expression(&mut self, set: &ast::ExprSet) -> Type<'db> { @@ -1837,7 +1845,7 @@ impl<'db> TypeInferenceBuilder<'db> { } // TODO generic - builtins_symbol_ty(self.db, "set").to_instance(self.db) + KnownClass::Set.to_instance(self.db) } fn infer_dict_expression(&mut self, dict: &ast::ExprDict) -> Type<'db> { @@ -1849,7 +1857,7 @@ impl<'db> TypeInferenceBuilder<'db> { } // TODO generic - builtins_symbol_ty(self.db, "dict").to_instance(self.db) + KnownClass::Dict.to_instance(self.db) } /// Infer the type of the `iter` expression of the first comprehension. @@ -2347,31 +2355,31 @@ impl<'db> TypeInferenceBuilder<'db> { (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Add) => n .checked_add(m) .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Sub) => n .checked_sub(m) .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mult) => n .checked_mul(m) .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), (Type::IntLiteral(_), Type::IntLiteral(_), ast::Operator::Div) => { - builtins_symbol_ty(self.db, "float").to_instance(self.db) + KnownClass::Float.to_instance(self.db) } (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::FloorDiv) => n .checked_div(m) .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mod) => n .checked_rem(m) .map(Type::IntLiteral) - .unwrap_or_else(|| Type::builtin_int_instance(self.db)), + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), (Type::BytesLiteral(lhs), Type::BytesLiteral(rhs), ast::Operator::Add) => { Type::BytesLiteral(BytesLiteralType::new( @@ -2581,10 +2589,10 @@ impl<'db> TypeInferenceBuilder<'db> { ast::CmpOp::In | ast::CmpOp::NotIn => None, }, (Type::IntLiteral(_), Type::Instance(_)) => { - self.infer_binary_type_comparison(Type::builtin_int_instance(self.db), op, right) + self.infer_binary_type_comparison(KnownClass::Int.to_instance(self.db), op, right) } (Type::Instance(_), Type::IntLiteral(_)) => { - self.infer_binary_type_comparison(left, op, Type::builtin_int_instance(self.db)) + self.infer_binary_type_comparison(left, op, KnownClass::Int.to_instance(self.db)) } // Booleans are coded as integers (False = 0, True = 1) (Type::IntLiteral(n), Type::BooleanLiteral(b)) => self.infer_binary_type_comparison( @@ -3124,7 +3132,7 @@ impl StringPartsCollector { fn ty(self, db: &dyn Db) -> Type { if self.expression { - Type::builtin_str_instance(db) + KnownClass::Str.to_instance(db) } else if let Some(concatenated) = self.concatenated { Type::StringLiteral(StringLiteralType::new(db, concatenated.into_boxed_str())) } else { From f1205177fd9a323c93be456ea3f193870780645b Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Oct 2024 20:01:10 +0200 Subject: [PATCH 30/88] [red-knot] fix: when simplifying union, True & False -> instance(bool) (#13644) --- .../src/types/builder.rs | 6 ++--- .../src/types/infer.rs | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types/builder.rs b/crates/red_knot_python_semantic/src/types/builder.rs index 3164e9e3592d6..4dbfa6326e8ff 100644 --- a/crates/red_knot_python_semantic/src/types/builder.rs +++ b/crates/red_knot_python_semantic/src/types/builder.rs @@ -66,7 +66,7 @@ impl<'db> UnionBuilder<'db> { let mut to_remove = SmallVec::<[usize; 2]>::new(); for (index, element) in self.elements.iter().enumerate() { if Some(*element) == bool_pair { - to_add = KnownClass::Bool.to_class(self.db); + to_add = KnownClass::Bool.to_instance(self.db); to_remove.push(index); // The type we are adding is a BooleanLiteral, which doesn't have any // subtypes. And we just found that the union already contained our @@ -362,7 +362,7 @@ mod tests { #[test] fn build_union_bool() { let db = setup_db(); - let bool_ty = KnownClass::Bool.to_class(&db); + let bool_instance_ty = KnownClass::Bool.to_instance(&db); let t0 = Type::BooleanLiteral(true); let t1 = Type::BooleanLiteral(true); @@ -373,7 +373,7 @@ mod tests { assert_eq!(union.elements(&db), &[t0, t3]); let union = UnionType::from_elements(&db, [t0, t1, t2, t3]).expect_union(); - assert_eq!(union.elements(&db), &[bool_ty, t3]); + assert_eq!(union.elements(&db), &[bool_instance_ty, t3]); } #[test] diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index eef0e9d16dcdd..9d0583c260c6c 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -4548,6 +4548,32 @@ mod tests { Ok(()) } + #[test] + fn simplify_true_and_false_to_bool() -> anyhow::Result<()> { + let mut db = setup_db(); + + db.write_dedented( + "src/a.py", + " + from typing_extensions import reveal_type + + def returns_bool() -> bool: + return True + + if returns_bool(): + x = True + else: + x = False + + reveal_type(x) + ", + )?; + + assert_file_diagnostics(&db, "src/a.py", &["Revealed type is `bool`"]); + + Ok(()) + } + #[test] fn literal_int_arithmetic() -> anyhow::Result<()> { let mut db = setup_db(); From 8108f8381056641543c0255ea5b54d650d1c5332 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Oct 2024 21:22:30 +0200 Subject: [PATCH 31/88] [red-knot] feat: add `StringLiteral` and `LiteralString` comparison (#13634) ## Summary Implements string literal comparisons and fallbacks to `str` instance for `LiteralString`. Completes an item in #13618 ## Test Plan - Adds a dedicated test with non exhaustive cases --------- Co-authored-by: Alex Waygood --- .../src/types/infer.rs | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 9d0583c260c6c..3de456dded065 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -2535,9 +2535,7 @@ impl<'db> TypeInferenceBuilder<'db> { ast::CmpOp::In | ast::CmpOp::NotIn | ast::CmpOp::Is - | ast::CmpOp::IsNot => { - builtins_symbol_ty(self.db, "bool").to_instance(self.db) - } + | ast::CmpOp::IsNot => KnownClass::Bool.to_instance(self.db), // Other operators can return arbitrary types _ => Type::Unknown, } @@ -2573,14 +2571,14 @@ impl<'db> TypeInferenceBuilder<'db> { ast::CmpOp::GtE => Some(Type::BooleanLiteral(n >= m)), ast::CmpOp::Is => { if n == m { - Some(builtins_symbol_ty(self.db, "bool").to_instance(self.db)) + Some(KnownClass::Bool.to_instance(self.db)) } else { Some(Type::BooleanLiteral(false)) } } ast::CmpOp::IsNot => { if n == m { - Some(builtins_symbol_ty(self.db, "bool").to_instance(self.db)) + Some(KnownClass::Bool.to_instance(self.db)) } else { Some(Type::BooleanLiteral(true)) } @@ -2594,6 +2592,7 @@ impl<'db> TypeInferenceBuilder<'db> { (Type::Instance(_), Type::IntLiteral(_)) => { self.infer_binary_type_comparison(left, op, KnownClass::Int.to_instance(self.db)) } + // Booleans are coded as integers (False = 0, True = 1) (Type::IntLiteral(n), Type::BooleanLiteral(b)) => self.infer_binary_type_comparison( Type::IntLiteral(n), @@ -2611,6 +2610,49 @@ impl<'db> TypeInferenceBuilder<'db> { op, Type::IntLiteral(i64::from(b)), ), + + (Type::StringLiteral(salsa_s1), Type::StringLiteral(salsa_s2)) => { + let s1 = salsa_s1.value(self.db); + let s2 = salsa_s2.value(self.db); + match op { + ast::CmpOp::Eq => Some(Type::BooleanLiteral(s1 == s2)), + ast::CmpOp::NotEq => Some(Type::BooleanLiteral(s1 != s2)), + ast::CmpOp::Lt => Some(Type::BooleanLiteral(s1 < s2)), + ast::CmpOp::LtE => Some(Type::BooleanLiteral(s1 <= s2)), + ast::CmpOp::Gt => Some(Type::BooleanLiteral(s1 > s2)), + ast::CmpOp::GtE => Some(Type::BooleanLiteral(s1 >= s2)), + ast::CmpOp::In => Some(Type::BooleanLiteral(s2.contains(s1.as_ref()))), + ast::CmpOp::NotIn => Some(Type::BooleanLiteral(!s2.contains(s1.as_ref()))), + ast::CmpOp::Is => { + if s1 == s2 { + Some(KnownClass::Bool.to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(false)) + } + } + ast::CmpOp::IsNot => { + if s1 == s2 { + Some(KnownClass::Bool.to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(true)) + } + } + } + } + (Type::StringLiteral(_), _) => { + self.infer_binary_type_comparison(KnownClass::Str.to_instance(self.db), op, right) + } + (_, Type::StringLiteral(_)) => { + self.infer_binary_type_comparison(left, op, KnownClass::Str.to_instance(self.db)) + } + + (Type::LiteralString, _) => { + self.infer_binary_type_comparison(KnownClass::Str.to_instance(self.db), op, right) + } + (_, Type::LiteralString) => { + self.infer_binary_type_comparison(left, op, KnownClass::Str.to_instance(self.db)) + } + // Lookup the rich comparison `__dunder__` methods on instances (Type::Instance(left_class_ty), Type::Instance(right_class_ty)) => match op { ast::CmpOp::Lt => { @@ -4110,6 +4152,42 @@ mod tests { Ok(()) } + #[test] + fn comparison_string_literals() -> anyhow::Result<()> { + let mut db = setup_db(); + db.write_dedented( + "src/a.py", + r#" + def str_instance() -> str: ... + a = "abc" == "abc" + b = "ab_cd" <= "ab_ce" + c = "abc" in "ab cd" + d = "" not in "hello" + e = "--" is "--" + f = "A" is "B" + g = "--" is not "--" + h = "A" is not "B" + i = str_instance() < "..." + j = "ab" < "ab_cd" + "#, + )?; + + assert_public_ty(&db, "src/a.py", "a", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "b", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "c", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "d", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "e", "bool"); + assert_public_ty(&db, "src/a.py", "f", "Literal[False]"); + assert_public_ty(&db, "src/a.py", "g", "bool"); + assert_public_ty(&db, "src/a.py", "h", "Literal[True]"); + assert_public_ty(&db, "src/a.py", "i", "bool"); + // Very cornercase test ensuring we're not comparing the interned salsa symbols, which + // compare by order of declaration + assert_public_ty(&db, "src/a.py", "j", "Literal[True]"); + + Ok(()) + } + #[test] fn comparison_unsupported_operators() -> anyhow::Result<()> { let mut db = setup_db(); From 383d9d9f6e24eee6d11f835ee543f6e9eea42e35 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:03:12 +0200 Subject: [PATCH 32/88] Update Rust crate once_cell to v1.20.2 (#13653) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d6904d703e5e..d0c971ce92a8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1626,9 +1626,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "oorandom" From 43330225be9b22dd853ce75a4b35c6bf23041148 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:04:04 +0200 Subject: [PATCH 33/88] Update Rust crate serde_with to v3.11.0 (#13655) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0c971ce92a8e..204cfcb4be706 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3116,9 +3116,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.9.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" dependencies = [ "serde", "serde_derive", @@ -3127,9 +3127,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.9.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" dependencies = [ "darling", "proc-macro2", From 03fa7f64ddfcb2109babb7adaf7eea469de1cbc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:05:40 +0200 Subject: [PATCH 34/88] Update Rust crate clap to v4.5.19 (#13647) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 204cfcb4be706..1d8f5ac8bd729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,9 +353,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" +checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" dependencies = [ "clap_builder", "clap_derive", @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" +checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" dependencies = [ "anstream", "anstyle", @@ -3282,12 +3282,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" dependencies = [ "rustix", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] From 2ab78dd6a5b42c7e8c22cefbe1d07ffcf1b91339 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:15:26 +0200 Subject: [PATCH 35/88] Update NPM Development dependencies (#13651) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- playground/api/package-lock.json | 14 ++-- playground/api/package.json | 2 +- playground/package-lock.json | 113 ++++++++++++++++--------------- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 0431cb0a0856f..8e4042a32e24b 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -16,7 +16,7 @@ "@cloudflare/workers-types": "^4.20230801.0", "miniflare": "^3.20230801.1", "typescript": "^5.1.6", - "wrangler": "3.78.12" + "wrangler": "3.80.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -132,9 +132,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240925.0.tgz", - "integrity": "sha512-KpqyRWvanEuXgBTKYFzRp4NsWOEcswxjsPRSre1zYQcODmc8PUrraVHQUmgvkJgv3FzB+vI9xm7J6oE4MmZHCA==", + "version": "4.20241004.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241004.0.tgz", + "integrity": "sha512-3LrPvtecs4umknOF1bTPNLHUG/ZjeSE6PYBQ/tbO7lwaVhjZTaTugiaCny2byrZupBlVNuubQVktcAgMfw0C1A==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -1585,9 +1585,9 @@ } }, "node_modules/wrangler": { - "version": "3.78.12", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.78.12.tgz", - "integrity": "sha512-a/xk/N04IvOGk9J+BLkiFg42GDyPS+0BiJimbrHsbX+CDr8Iqq3HNMEyQld+6zbmq01u/gmc8S7GKVR9vDx4+g==", + "version": "3.80.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.80.0.tgz", + "integrity": "sha512-c9aN7Buf7XgTPpQkw1d0VjNRI4qg3m35PTg70Tg4mOeHwHGjsd74PAsP1G8BjpdqDjfWtsua7tj1g4M3/5dYNQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { diff --git a/playground/api/package.json b/playground/api/package.json index 05ff1778f30be..0da4ca8f4d241 100644 --- a/playground/api/package.json +++ b/playground/api/package.json @@ -5,7 +5,7 @@ "@cloudflare/workers-types": "^4.20230801.0", "miniflare": "^3.20230801.1", "typescript": "^5.1.6", - "wrangler": "3.78.12" + "wrangler": "3.80.0" }, "private": true, "scripts": { diff --git a/playground/package-lock.json b/playground/package-lock.json index 0ca0e09d7bb59..cdc39990528d8 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -1129,9 +1129,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", - "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1149,17 +1149,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1183,16 +1183,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -1212,14 +1212,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1230,14 +1230,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/utils": "8.7.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1255,9 +1255,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "license": "MIT", "engines": { @@ -1269,14 +1269,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1324,16 +1324,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1347,13 +1347,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2398,9 +2398,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz", - "integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", "dev": true, "license": "MIT", "dependencies": { @@ -2425,9 +2425,9 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", - "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", "dependencies": { @@ -2439,7 +2439,7 @@ "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.9.0", + "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", @@ -2448,13 +2448,14 @@ "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/debug": { @@ -2491,9 +2492,9 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.0.tgz", - "integrity": "sha512-IHBePmfWH5lKhJnJ7WB1V+v/GolbB0rjS8XYVCSQCZKaQCAUhMoVoOEn1Ef8Z8Wf0a7l8KTJvuZg5/e4qrZ6nA==", + "version": "7.37.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz", + "integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==", "dev": true, "license": "MIT", "dependencies": { From 824def2194723656c93519473b6bd048f6c73302 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:15:58 +0200 Subject: [PATCH 36/88] Update dependency ruff to v0.6.9 (#13648) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/requirements-insiders.txt | 2 +- docs/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements-insiders.txt b/docs/requirements-insiders.txt index ac753ff769405..64a16d5d511cf 100644 --- a/docs/requirements-insiders.txt +++ b/docs/requirements-insiders.txt @@ -1,5 +1,5 @@ PyYAML==6.0.2 -ruff==0.6.8 +ruff==0.6.9 mkdocs==1.6.1 mkdocs-material @ git+ssh://git@github.com/astral-sh/mkdocs-material-insiders.git@38c0b8187325c3bab386b666daf3518ac036f2f4 mkdocs-redirects==1.2.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index 8a9f35b7def50..d2abb0f4175e4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ PyYAML==6.0.2 -ruff==0.6.8 +ruff==0.6.9 mkdocs==1.6.1 mkdocs-material==9.1.18 mkdocs-redirects==1.2.1 From 38d872ea4c4e2ad746a6be11400a88ce5eccb7cb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:50:59 +0200 Subject: [PATCH 37/88] Update Rust crate hashbrown to 0.15.0 (#13652) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Micha Reiser --- Cargo.lock | 25 ++++--- Cargo.toml | 67 +++++++++++-------- .../src/semantic_index.rs | 4 +- 3 files changed, 54 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d8f5ac8bd729..fcd522d147d4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,12 +36,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -714,7 +708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -728,7 +722,7 @@ checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -1026,16 +1020,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1127,7 +1126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc9da1a252bd44cd341657203722352efc9bc0c847d06ea6d2dc1cd1135e0a01" dependencies = [ "ahash", - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1147,7 +1146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.5", "serde", ] @@ -2081,7 +2080,7 @@ dependencies = [ "camino", "compact_str", "countme", - "hashbrown", + "hashbrown 0.15.0", "insta", "itertools 0.13.0", "ordermap", diff --git a/Cargo.toml b/Cargo.toml index d4323fe1c57af..428c329a0923d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,10 @@ filetime = { version = "0.2.23" } glob = { version = "0.3.1" } globset = { version = "0.4.14" } globwalk = { version = "0.9.1" } -hashbrown = "0.14.3" +hashbrown = { version = "0.15.0", default-features = false, features = [ + "raw-entry", + "inline-more", +] } ignore = { version = "0.4.22" } imara-diff = { version = "0.1.5" } imperative = { version = "1.0.4" } @@ -90,7 +93,7 @@ libcst = { version = "1.1.0", default-features = false } log = { version = "0.4.17" } lsp-server = { version = "0.7.6" } lsp-types = { git = "https://github.com/astral-sh/lsp-types.git", rev = "3512a9f", features = [ - "proposed", + "proposed", ] } matchit = { version = "0.8.1" } memchr = { version = "2.7.1" } @@ -120,7 +123,7 @@ serde-wasm-bindgen = { version = "0.6.4" } serde_json = { version = "1.0.113" } serde_test = { version = "1.0.152" } serde_with = { version = "3.6.0", default-features = false, features = [ - "macros", + "macros", ] } shellexpand = { version = "3.0.0" } similar = { version = "2.4.0", features = ["inline"] } @@ -137,7 +140,10 @@ toml = { version = "0.8.11" } tracing = { version = "0.1.40" } tracing-flame = { version = "0.2.0" } tracing-indicatif = { version = "0.3.6" } -tracing-subscriber = { version = "0.3.18", default-features = false, features = ["env-filter", "fmt"] } +tracing-subscriber = { version = "0.3.18", default-features = false, features = [ + "env-filter", + "fmt", +] } tracing-tree = { version = "0.4.0" } typed-arena = { version = "2.0.2" } unic-ucd-category = { version = "0.9" } @@ -148,10 +154,10 @@ unicode-normalization = { version = "0.1.23" } ureq = { version = "2.9.6" } url = { version = "2.5.0" } uuid = { version = "1.6.1", features = [ - "v4", - "fast-rng", - "macro-diagnostics", - "js", + "v4", + "fast-rng", + "macro-diagnostics", + "js", ] } walkdir = { version = "2.3.2" } wasm-bindgen = { version = "0.2.92" } @@ -162,7 +168,10 @@ zip = { version = "0.6.6", default-features = false } [workspace.lints.rust] unsafe_code = "warn" unreachable_pub = "warn" -unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)", "cfg(codspeed)"] } +unexpected_cfgs = { level = "warn", check-cfg = [ + "cfg(fuzzing)", + "cfg(codspeed)", +] } [workspace.lints.clippy] pedantic = { level = "warn", priority = -2 } @@ -245,23 +254,23 @@ windows-archive = ".zip" unix-archive = ".tar.gz" # Target platforms to build apps for (Rust target-triple syntax) targets = [ - "aarch64-apple-darwin", - "aarch64-pc-windows-msvc", - "aarch64-unknown-linux-gnu", - "aarch64-unknown-linux-musl", - "arm-unknown-linux-musleabihf", - "armv7-unknown-linux-gnueabihf", - "armv7-unknown-linux-musleabihf", - "i686-pc-windows-msvc", - "i686-unknown-linux-gnu", - "i686-unknown-linux-musl", - "powerpc64-unknown-linux-gnu", - "powerpc64le-unknown-linux-gnu", - "s390x-unknown-linux-gnu", - "x86_64-apple-darwin", - "x86_64-pc-windows-msvc", - "x86_64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", + "aarch64-apple-darwin", + "aarch64-pc-windows-msvc", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "arm-unknown-linux-musleabihf", + "armv7-unknown-linux-gnueabihf", + "armv7-unknown-linux-musleabihf", + "i686-pc-windows-msvc", + "i686-unknown-linux-gnu", + "i686-unknown-linux-musl", + "powerpc64-unknown-linux-gnu", + "powerpc64le-unknown-linux-gnu", + "s390x-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", ] # Whether to auto-include files like READMEs, LICENSEs, and CHANGELOGs (default true) auto-includes = false @@ -280,7 +289,11 @@ local-artifacts-jobs = ["./build-binaries", "./build-docker"] # Publish jobs to run in CI publish-jobs = ["./publish-pypi", "./publish-wasm"] # Post-announce jobs to run in CI -post-announce-jobs = ["./notify-dependents", "./publish-docs", "./publish-playground"] +post-announce-jobs = [ + "./notify-dependents", + "./publish-docs", + "./publish-playground", +] # Custom permissions for GitHub Jobs github-custom-job-permissions = { "build-docker" = { packages = "write", contents = "read" }, "publish-wasm" = { contents = "read", id-token = "write", packages = "write" } } # Whether to install an updater program diff --git a/crates/red_knot_python_semantic/src/semantic_index.rs b/crates/red_knot_python_semantic/src/semantic_index.rs index 5ec6d57ccb112..ac5463053d08c 100644 --- a/crates/red_knot_python_semantic/src/semantic_index.rs +++ b/crates/red_knot_python_semantic/src/semantic_index.rs @@ -1,7 +1,7 @@ use std::iter::FusedIterator; use std::sync::Arc; -use rustc_hash::FxHashMap; +use rustc_hash::{FxBuildHasher, FxHashMap}; use salsa::plumbing::AsId; use ruff_db::files::File; @@ -31,7 +31,7 @@ pub(crate) use self::use_def::{ BindingWithConstraints, BindingWithConstraintsIterator, DeclarationsIterator, }; -type SymbolMap = hashbrown::HashMap; +type SymbolMap = hashbrown::HashMap; /// Returns the semantic index for `file`. /// From 73aa6ea417ee52154cbe3a11691c4da5bf7415d8 Mon Sep 17 00:00:00 2001 From: Aleksei Latyshev Date: Mon, 7 Oct 2024 09:35:14 +0200 Subject: [PATCH 38/88] [`refurb`] implement `hardcoded-string-charset` (FURB156) (#13530) Co-authored-by: Micha Reiser --- .../resources/test/fixtures/refurb/FURB156.py | 53 ++ .../src/checkers/ast/analyze/expression.rs | 8 +- crates/ruff_linter/src/codes.rs | 1 + crates/ruff_linter/src/rules/refurb/mod.rs | 1 + .../refurb/rules/hardcoded_string_charset.rs | 171 ++++++ .../ruff_linter/src/rules/refurb/rules/mod.rs | 2 + ...es__refurb__tests__FURB156_FURB156.py.snap | 486 ++++++++++++++++++ ruff.schema.json | 1 + 8 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/refurb/FURB156.py create mode 100644 crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs create mode 100644 crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/refurb/FURB156.py b/crates/ruff_linter/resources/test/fixtures/refurb/FURB156.py new file mode 100644 index 0000000000000..2dff502bae56b --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/refurb/FURB156.py @@ -0,0 +1,53 @@ +# Errors + +_ = "0123456789" +_ = "01234567" +_ = "0123456789abcdefABCDEF" +_ = "abcdefghijklmnopqrstuvwxyz" +_ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +_ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +_ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +_ = " \t\n\r\v\f" + +_ = "" in "1234567890" +_ = "" in "12345670" +_ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +_ = ( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' + "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" +) +_ = id("0123" + "4567" + "89") +_ = "" in ("123" + "456" + "789" + "0") + +_ = "" in ( # comment + "123" + "456" + "789" + "0") + + +_ = "" in ( + "123" + "456" # inline comment + "789" + "0") + +_ = ( + "0123456789" +).capitalize() + +_ = ( + "0123456789" + # with comment +).capitalize() + +# Ok + +_ = "1234567890" +_ = "1234" +_ = "" in "1234" diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 64455275cca72..99deb3b994998 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1355,6 +1355,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { if checker.enabled(Rule::SingleItemMembershipTest) { refurb::rules::single_item_membership_test(checker, expr, left, ops, comparators); } + if checker.enabled(Rule::HardcodedStringCharset) { + refurb::rules::hardcoded_string_charset_comparison(checker, compare); + } } Expr::NumberLiteral(number_literal @ ast::ExprNumberLiteral { .. }) => { if checker.source_type.is_stub() && checker.enabled(Rule::NumericLiteralTooLong) { @@ -1364,7 +1367,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { refurb::rules::math_constant(checker, number_literal); } } - Expr::StringLiteral(ast::ExprStringLiteral { value, range: _ }) => { + Expr::StringLiteral(string_like @ ast::ExprStringLiteral { value, range: _ }) => { if checker.enabled(Rule::UnicodeKindPrefix) { for string_part in value { pyupgrade::rules::unicode_kind_prefix(checker, string_part); @@ -1375,6 +1378,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { ruff::rules::missing_fstring_syntax(checker, string_literal); } } + if checker.enabled(Rule::HardcodedStringCharset) { + refurb::rules::hardcoded_string_charset_literal(checker, string_like); + } } Expr::If( if_exp @ ast::ExprIf { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 21486729a5770..41e4482e42727 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1055,6 +1055,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Refurb, "148") => (RuleGroup::Preview, rules::refurb::rules::UnnecessaryEnumerate), (Refurb, "152") => (RuleGroup::Preview, rules::refurb::rules::MathConstant), (Refurb, "154") => (RuleGroup::Preview, rules::refurb::rules::RepeatedGlobal), + (Refurb, "156") => (RuleGroup::Preview, rules::refurb::rules::HardcodedStringCharset), (Refurb, "157") => (RuleGroup::Preview, rules::refurb::rules::VerboseDecimalConstructor), (Refurb, "161") => (RuleGroup::Stable, rules::refurb::rules::BitCount), (Refurb, "163") => (RuleGroup::Stable, rules::refurb::rules::RedundantLogBase), diff --git a/crates/ruff_linter/src/rules/refurb/mod.rs b/crates/ruff_linter/src/rules/refurb/mod.rs index 6f438ba632e6f..640773c3e72fb 100644 --- a/crates/ruff_linter/src/rules/refurb/mod.rs +++ b/crates/ruff_linter/src/rules/refurb/mod.rs @@ -29,6 +29,7 @@ mod tests { #[test_case(Rule::UnnecessaryEnumerate, Path::new("FURB148.py"))] #[test_case(Rule::MathConstant, Path::new("FURB152.py"))] #[test_case(Rule::RepeatedGlobal, Path::new("FURB154.py"))] + #[test_case(Rule::HardcodedStringCharset, Path::new("FURB156.py"))] #[test_case(Rule::VerboseDecimalConstructor, Path::new("FURB157.py"))] #[test_case(Rule::UnnecessaryFromFloat, Path::new("FURB164.py"))] #[test_case(Rule::PrintEmptyString, Path::new("FURB105.py"))] diff --git a/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs b/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs new file mode 100644 index 0000000000000..11db4cbccb129 --- /dev/null +++ b/crates/ruff_linter/src/rules/refurb/rules/hardcoded_string_charset.rs @@ -0,0 +1,171 @@ +use crate::checkers::ast::Checker; +use crate::importer::ImportRequest; +use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{CmpOp, Expr, ExprCompare, ExprStringLiteral}; +use ruff_text_size::TextRange; + +/// ## What it does +/// Checks for uses of hardcoded charsets, which are defined in Python string module. +/// +/// ## Why is this bad? +/// Usage of named charsets from the standard library is more readable and less error-prone. +/// +/// ## Example +/// ```python +/// x = "0123456789" +/// y in "abcdefghijklmnopqrstuvwxyz" +/// ``` +/// +/// Use instead +/// ```python +/// import string +/// +/// x = string.digits +/// y in string.ascii_lowercase +/// ``` +/// +/// ## References +/// - [Python documentation: String constants](https://docs.python.org/3/library/string.html#string-constants) +#[violation] +pub struct HardcodedStringCharset { + name: &'static str, +} + +impl AlwaysFixableViolation for HardcodedStringCharset { + #[derive_message_formats] + fn message(&self) -> String { + format!("Use of hardcoded string charset") + } + + fn fix_title(&self) -> String { + let HardcodedStringCharset { name } = self; + format!("Replace hardcoded charset with `string.{name}`") + } +} + +struct NamedCharset { + name: &'static str, + bytes: &'static [u8], + ascii_char_set: AsciiCharSet, +} + +/// Represents the set of ascii characters in form of a bitset. +#[derive(Copy, Clone, Eq, PartialEq)] +struct AsciiCharSet(u128); + +impl AsciiCharSet { + /// Creates the set of ascii characters from `bytes`. + /// Returns None if there is non-ascii byte. + const fn from_bytes(bytes: &[u8]) -> Option { + // TODO: simplify implementation, when const-traits are supported + // https://github.com/rust-lang/rust-project-goals/issues/106 + let mut bitset = 0; + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii() { + return None; + } + bitset |= 1 << bytes[i]; + i += 1; + } + Some(Self(bitset)) + } +} + +impl NamedCharset { + const fn new(name: &'static str, bytes: &'static [u8]) -> Self { + Self { + name, + bytes, + // SAFETY: The named charset is guaranteed to have only ascii bytes. + // TODO: replace with `.unwrap()`, when `Option::unwrap` will be stable in `const fn` + // https://github.com/rust-lang/rust/issues/67441 + ascii_char_set: match AsciiCharSet::from_bytes(bytes) { + Some(ascii_char_set) => ascii_char_set, + None => unreachable!(), + }, + } + } +} + +const KNOWN_NAMED_CHARSETS: [NamedCharset; 9] = [ + NamedCharset::new( + "ascii_letters", + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + ), + NamedCharset::new("ascii_lowercase", b"abcdefghijklmnopqrstuvwxyz"), + NamedCharset::new("ascii_uppercase", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + NamedCharset::new("digits", b"0123456789"), + NamedCharset::new("hexdigits", b"0123456789abcdefABCDEF"), + NamedCharset::new("octdigits", b"01234567"), + NamedCharset::new("punctuation", b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"), + NamedCharset::new( + "printable", + b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"\ + #$%&'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c", + ), + NamedCharset::new("whitespace", b" \t\n\r\x0b\x0c"), +]; + +fn check_charset_as_set(bytes: &[u8]) -> Option<&NamedCharset> { + let ascii_char_set = AsciiCharSet::from_bytes(bytes)?; + + KNOWN_NAMED_CHARSETS + .iter() + .find(|&charset| charset.ascii_char_set == ascii_char_set) +} + +fn check_charset_exact(bytes: &[u8]) -> Option<&NamedCharset> { + KNOWN_NAMED_CHARSETS + .iter() + .find(|&charset| charset.bytes == bytes) +} + +fn push_diagnostic(checker: &mut Checker, range: TextRange, charset: &NamedCharset) { + let name = charset.name; + let mut diagnostic = Diagnostic::new(HardcodedStringCharset { name }, range); + diagnostic.try_set_fix(|| { + let (edit, binding) = checker.importer().get_or_import_symbol( + &ImportRequest::import("string", name), + range.start(), + checker.semantic(), + )?; + Ok(Fix::safe_edits( + Edit::range_replacement(binding, range), + [edit], + )) + }); + checker.diagnostics.push(diagnostic); +} + +/// FURB156 +pub(crate) fn hardcoded_string_charset_comparison(checker: &mut Checker, compare: &ExprCompare) { + let ( + [CmpOp::In | CmpOp::NotIn], + [Expr::StringLiteral(string_literal @ ExprStringLiteral { value, .. })], + ) = (compare.ops.as_ref(), compare.comparators.as_ref()) + else { + return; + }; + + let bytes = value.to_str().as_bytes(); + + let Some(charset) = check_charset_as_set(bytes) else { + return; + }; + + // In this case the diagnostic will be emitted via string_literal check. + if charset.bytes == bytes { + return; + } + + push_diagnostic(checker, string_literal.range, charset); +} + +/// FURB156 +pub(crate) fn hardcoded_string_charset_literal(checker: &mut Checker, expr: &ExprStringLiteral) { + if let Some(charset) = check_charset_exact(expr.value.to_str().as_bytes()) { + push_diagnostic(checker, expr.range, charset); + } +} diff --git a/crates/ruff_linter/src/rules/refurb/rules/mod.rs b/crates/ruff_linter/src/rules/refurb/rules/mod.rs index aceaba6d2445c..e71ded4315d23 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/mod.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/mod.rs @@ -3,6 +3,7 @@ pub(crate) use check_and_remove_from_set::*; pub(crate) use delete_full_slice::*; pub(crate) use for_loop_set_mutations::*; pub(crate) use fstring_number_format::*; +pub(crate) use hardcoded_string_charset::*; pub(crate) use hashlib_digest_hex::*; pub(crate) use if_exp_instead_of_or_operator::*; pub(crate) use if_expr_min_max::*; @@ -36,6 +37,7 @@ mod check_and_remove_from_set; mod delete_full_slice; mod for_loop_set_mutations; mod fstring_number_format; +mod hardcoded_string_charset; mod hashlib_digest_hex; mod if_exp_instead_of_or_operator; mod if_expr_min_max; diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap new file mode 100644 index 0000000000000..9d94466532651 --- /dev/null +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap @@ -0,0 +1,486 @@ +--- +source: crates/ruff_linter/src/rules/refurb/mod.rs +--- +FURB156.py:3:5: FURB156 [*] Use of hardcoded string charset + | +1 | # Errors +2 | +3 | _ = "0123456789" + | ^^^^^^^^^^^^ FURB156 +4 | _ = "01234567" +5 | _ = "0123456789abcdefABCDEF" + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 |-_ = "0123456789" + 4 |+_ = string.digits +4 5 | _ = "01234567" +5 6 | _ = "0123456789abcdefABCDEF" +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" + +FURB156.py:4:5: FURB156 [*] Use of hardcoded string charset + | +3 | _ = "0123456789" +4 | _ = "01234567" + | ^^^^^^^^^^ FURB156 +5 | _ = "0123456789abcdefABCDEF" +6 | _ = "abcdefghijklmnopqrstuvwxyz" + | + = help: Replace hardcoded charset with `string.octdigits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 |-_ = "01234567" + 5 |+_ = string.octdigits +5 6 | _ = "0123456789abcdefABCDEF" +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +FURB156.py:5:5: FURB156 [*] Use of hardcoded string charset + | +3 | _ = "0123456789" +4 | _ = "01234567" +5 | _ = "0123456789abcdefABCDEF" + | ^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 +6 | _ = "abcdefghijklmnopqrstuvwxyz" +7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + | + = help: Replace hardcoded charset with `string.hexdigits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +5 |-_ = "0123456789abcdefABCDEF" + 6 |+_ = string.hexdigits +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +FURB156.py:6:5: FURB156 [*] Use of hardcoded string charset + | +4 | _ = "01234567" +5 | _ = "0123456789abcdefABCDEF" +6 | _ = "abcdefghijklmnopqrstuvwxyz" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 +7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + | + = help: Replace hardcoded charset with `string.ascii_lowercase` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +5 6 | _ = "0123456789abcdefABCDEF" +6 |-_ = "abcdefghijklmnopqrstuvwxyz" + 7 |+_ = string.ascii_lowercase +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" + +FURB156.py:7:5: FURB156 [*] Use of hardcoded string charset + | +5 | _ = "0123456789abcdefABCDEF" +6 | _ = "abcdefghijklmnopqrstuvwxyz" +7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 +8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" + | + = help: Replace hardcoded charset with `string.ascii_uppercase` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +5 6 | _ = "0123456789abcdefABCDEF" +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" +7 |-_ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 8 |+_ = string.ascii_uppercase +8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 11 | _ = " \t\n\r\v\f" + +FURB156.py:8:5: FURB156 [*] Use of hardcoded string charset + | + 6 | _ = "abcdefghijklmnopqrstuvwxyz" + 7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 + 9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 | _ = " \t\n\r\v\f" + | + = help: Replace hardcoded charset with `string.ascii_letters` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +5 6 | _ = "0123456789abcdefABCDEF" +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 |-_ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + 9 |+_ = string.ascii_letters +9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 11 | _ = " \t\n\r\v\f" +11 12 | + +FURB156.py:9:5: FURB156 [*] Use of hardcoded string charset + | + 7 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + 9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 +10 | _ = " \t\n\r\v\f" + | + = help: Replace hardcoded charset with `string.punctuation` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +6 7 | _ = "abcdefghijklmnopqrstuvwxyz" +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +9 |-_ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" + 10 |+_ = string.punctuation +10 11 | _ = " \t\n\r\v\f" +11 12 | +12 13 | _ = "" in "1234567890" + +FURB156.py:10:5: FURB156 [*] Use of hardcoded string charset + | + 8 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + 9 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 | _ = " \t\n\r\v\f" + | ^^^^^^^^^^^^^ FURB156 +11 | +12 | _ = "" in "1234567890" + | + = help: Replace hardcoded charset with `string.whitespace` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +7 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +8 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 |-_ = " \t\n\r\v\f" + 11 |+_ = string.whitespace +11 12 | +12 13 | _ = "" in "1234567890" +13 14 | _ = "" in "12345670" + +FURB156.py:12:11: FURB156 [*] Use of hardcoded string charset + | +10 | _ = " \t\n\r\v\f" +11 | +12 | _ = "" in "1234567890" + | ^^^^^^^^^^^^ FURB156 +13 | _ = "" in "12345670" +14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +9 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +10 11 | _ = " \t\n\r\v\f" +11 12 | +12 |-_ = "" in "1234567890" + 13 |+_ = "" in string.digits +13 14 | _ = "" in "12345670" +14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +15 16 | _ = ( + +FURB156.py:13:11: FURB156 [*] Use of hardcoded string charset + | +12 | _ = "" in "1234567890" +13 | _ = "" in "12345670" + | ^^^^^^^^^^ FURB156 +14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +15 | _ = ( + | + = help: Replace hardcoded charset with `string.octdigits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +10 11 | _ = " \t\n\r\v\f" +11 12 | +12 13 | _ = "" in "1234567890" +13 |-_ = "" in "12345670" + 14 |+_ = "" in string.octdigits +14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +15 16 | _ = ( +16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' + +FURB156.py:14:5: FURB156 [*] Use of hardcoded string charset + | +12 | _ = "" in "1234567890" +13 | _ = "" in "12345670" +14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB156 +15 | _ = ( +16 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' + | + = help: Replace hardcoded charset with `string.printable` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +11 12 | +12 13 | _ = "" in "1234567890" +13 14 | _ = "" in "12345670" +14 |-_ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' + 15 |+_ = string.printable +15 16 | _ = ( +16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' +17 18 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" + +FURB156.py:16:5: FURB156 [*] Use of hardcoded string charset + | +14 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +15 | _ = ( +16 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' + | _____^ +17 | | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" + | |________________________________________________^ FURB156 +18 | ) +19 | _ = id("0123" + | + = help: Replace hardcoded charset with `string.printable` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +13 14 | _ = "" in "12345670" +14 15 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' +15 16 | _ = ( +16 |- '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' +17 |- "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" + 17 |+ string.printable +18 18 | ) +19 19 | _ = id("0123" +20 20 | "4567" + +FURB156.py:19:8: FURB156 [*] Use of hardcoded string charset + | +17 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" +18 | ) +19 | _ = id("0123" + | ________^ +20 | | "4567" +21 | | "89") + | |___________^ FURB156 +22 | _ = "" in ("123" +23 | "456" + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +16 17 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' +17 18 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" +18 19 | ) +19 |-_ = id("0123" +20 |- "4567" +21 |- "89") + 20 |+_ = id(string.digits) +22 21 | _ = "" in ("123" +23 22 | "456" +24 23 | "789" + +FURB156.py:22:12: FURB156 [*] Use of hardcoded string charset + | +20 | "4567" +21 | "89") +22 | _ = "" in ("123" + | ____________^ +23 | | "456" +24 | | "789" +25 | | "0") + | |______________^ FURB156 +26 | +27 | _ = "" in ( # comment + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +19 20 | _ = id("0123" +20 21 | "4567" +21 22 | "89") +22 |-_ = "" in ("123" +23 |- "456" +24 |- "789" +25 |- "0") + 23 |+_ = "" in (string.digits) +26 24 | +27 25 | _ = "" in ( # comment +28 26 | "123" + +FURB156.py:28:5: FURB156 [*] Use of hardcoded string charset + | +27 | _ = "" in ( # comment +28 | "123" + | _____^ +29 | | "456" +30 | | "789" +31 | | "0") + | |_______^ FURB156 + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +25 26 | "0") +26 27 | +27 28 | _ = "" in ( # comment +28 |- "123" +29 |- "456" +30 |- "789" +31 |- "0") + 29 |+ string.digits) +32 30 | +33 31 | +34 32 | _ = "" in ( + +FURB156.py:35:5: FURB156 [*] Use of hardcoded string charset + | +34 | _ = "" in ( +35 | "123" + | _____^ +36 | | "456" # inline comment +37 | | "789" +38 | | "0") + | |_______^ FURB156 +39 | +40 | _ = ( + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +32 33 | +33 34 | +34 35 | _ = "" in ( +35 |- "123" +36 |- "456" # inline comment +37 |- "789" +38 |- "0") + 36 |+ string.digits) +39 37 | +40 38 | _ = ( +41 39 | "0123456789" + +FURB156.py:41:5: FURB156 [*] Use of hardcoded string charset + | +40 | _ = ( +41 | "0123456789" + | ^^^^^^^^^^^^ FURB156 +42 | ).capitalize() + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +38 39 | "0") +39 40 | +40 41 | _ = ( +41 |- "0123456789" + 42 |+ string.digits +42 43 | ).capitalize() +43 44 | +44 45 | _ = ( + +FURB156.py:45:5: FURB156 [*] Use of hardcoded string charset + | +44 | _ = ( +45 | "0123456789" + | ^^^^^^^^^^^^ FURB156 +46 | # with comment +47 | ).capitalize() + | + = help: Replace hardcoded charset with `string.digits` + +ℹ Safe fix +1 1 | # Errors + 2 |+import string +2 3 | +3 4 | _ = "0123456789" +4 5 | _ = "01234567" +-------------------------------------------------------------------------------- +42 43 | ).capitalize() +43 44 | +44 45 | _ = ( +45 |- "0123456789" + 46 |+ string.digits +46 47 | # with comment +47 48 | ).capitalize() +48 49 | diff --git a/ruff.schema.json b/ruff.schema.json index a04e37a0f3e1f..cf01ac039f4b1 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3279,6 +3279,7 @@ "FURB15", "FURB152", "FURB154", + "FURB156", "FURB157", "FURB16", "FURB161", From 98878c9bf273632e0a889fca560d47df10989efd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 10:16:43 +0100 Subject: [PATCH 39/88] Update dependency tomli to v2.0.2 (#13649) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- python/ruff-ecosystem/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ruff-ecosystem/pyproject.toml b/python/ruff-ecosystem/pyproject.toml index 6b395c1b7f31d..1ed27e7017cde 100644 --- a/python/ruff-ecosystem/pyproject.toml +++ b/python/ruff-ecosystem/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "ruff-ecosystem" version = "0.0.0" requires-python = ">=3.11" -dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.1"] +dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.2"] [project.scripts] ruff-ecosystem = "ruff_ecosystem.cli:entrypoint" From 7856e90a2cb677eac29f99b38e38caad15a0d810 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 10:16:58 +0100 Subject: [PATCH 40/88] Update pre-commit dependencies (#13650) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5cffbda35c829..097186a3b1b54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: )$ - repo: https://github.com/crate-ci/typos - rev: v1.24.6 + rev: v1.25.0 hooks: - id: typos @@ -59,7 +59,7 @@ repos: pass_filenames: false # This makes it a lot faster - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.6.9 hooks: - id: ruff-format - id: ruff From 58a11b33daa50783be8d497977c55209a3c3c918 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 7 Oct 2024 12:49:45 +0100 Subject: [PATCH 41/88] Fixup docs markup for `RUF027` (#13659) --- .../src/rules/ruff/rules/missing_fstring_syntax.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs index aea9a60208fbc..fbc652084d7d5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs @@ -35,7 +35,7 @@ use crate::rules::fastapi::rules::is_fastapi_route_call; /// 5. The string references variables that are not in scope, or it doesn't capture variables at all. /// 6. Any format specifiers in the potential f-string are invalid. /// 7. The string is part of a function call that is known to expect a template string rather than an -/// evaluated f-string: for example, a [`logging`] call, a [`gettext`] call, or a [`fastAPI` path]. +/// evaluated f-string: for example, a [`logging`] call, a [`gettext`] call, or a [fastAPI path]. /// /// ## Example /// @@ -52,9 +52,9 @@ use crate::rules::fastapi::rules::is_fastapi_route_call; /// print(f"Hello {name}! It is {day_of_week} today!") /// ``` /// -/// [`logging`]: https://docs.python.org/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application -/// [`gettext`]: https://docs.python.org/3/library/gettext.html -/// [`fastAPI` path]: https://fastapi.tiangolo.com/tutorial/path-params/ +/// [logging]: https://docs.python.org/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application +/// [gettext]: https://docs.python.org/3/library/gettext.html +/// [fastAPI path]: https://fastapi.tiangolo.com/tutorial/path-params/ #[violation] pub struct MissingFStringSyntax; From 646e4136d7f0febaa80335bfeb31e767fae6718d Mon Sep 17 00:00:00 2001 From: qdegraaf <34540841+qdegraaf@users.noreply.github.com> Date: Mon, 7 Oct 2024 13:56:17 +0200 Subject: [PATCH 42/88] [`flake8-bugbear`] Tweak `B905` message to not suggest setting parameter `strict=` to `False` (#13656) Co-authored-by: Alex Waygood --- .../rules/zip_without_explicit_strict.rs | 8 ++++---- ...rules__flake8_bugbear__tests__B905.py.snap | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs index 513c0c7e52f40..5c3bfb1e4129c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs @@ -16,9 +16,9 @@ use crate::fix::edits::add_argument; /// resulting iterator will be silently truncated to the length of the shortest /// iterable. This can lead to subtle bugs. /// -/// Use the `strict` parameter to raise a `ValueError` if the iterables are of -/// non-uniform length. If the iterables are intentionally different lengths, the -/// parameter should be explicitly set to `False`. +/// Pass `strict=True` to raise a `ValueError` if the iterables are of +/// non-uniform length. Alternatively, if the iterables are deliberately +/// different lengths, pass `strict=False` to make the intention explicit. /// /// ## Example /// ```python @@ -47,7 +47,7 @@ impl AlwaysFixableViolation for ZipWithoutExplicitStrict { } fn fix_title(&self) -> String { - "Add explicit `strict=False`".to_string() + "Add explicit value for parameter `strict=`".to_string() } } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B905.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B905.py.snap index 2e0c24059003d..66bde9f97f3ba 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B905.py.snap +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B905.py.snap @@ -9,7 +9,7 @@ B905.py:4:1: B905 [*] `zip()` without an explicit `strict=` parameter 5 | zip(range(3)) 6 | zip("a", "b") | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 1 1 | from itertools import count, cycle, repeat @@ -30,7 +30,7 @@ B905.py:5:1: B905 [*] `zip()` without an explicit `strict=` parameter 6 | zip("a", "b") 7 | zip("a", "b", *zip("c")) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 2 2 | @@ -51,7 +51,7 @@ B905.py:6:1: B905 [*] `zip()` without an explicit `strict=` parameter 7 | zip("a", "b", *zip("c")) 8 | zip(zip("a"), strict=False) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 3 3 | # Errors @@ -72,7 +72,7 @@ B905.py:7:1: B905 [*] `zip()` without an explicit `strict=` parameter 8 | zip(zip("a"), strict=False) 9 | zip(zip("a", strict=True)) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 4 4 | zip() @@ -93,7 +93,7 @@ B905.py:7:16: B905 [*] `zip()` without an explicit `strict=` parameter 8 | zip(zip("a"), strict=False) 9 | zip(zip("a", strict=True)) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 4 4 | zip() @@ -113,7 +113,7 @@ B905.py:8:5: B905 [*] `zip()` without an explicit `strict=` parameter | ^^^^^^^^ B905 9 | zip(zip("a", strict=True)) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 5 5 | zip(range(3)) @@ -134,7 +134,7 @@ B905.py:9:1: B905 [*] `zip()` without an explicit `strict=` parameter 10 | 11 | # OK | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 6 6 | zip("a", "b") @@ -153,7 +153,7 @@ B905.py:24:1: B905 [*] `zip()` without an explicit `strict=` parameter | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B905 25 | zip([1, 2, 3], repeat(1, times=4)) | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 21 21 | zip([1, 2, 3], repeat(1, times=None)) @@ -174,7 +174,7 @@ B905.py:25:1: B905 [*] `zip()` without an explicit `strict=` parameter 26 | 27 | import builtins | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 22 22 | @@ -193,7 +193,7 @@ B905.py:29:1: B905 [*] `zip()` without an explicit `strict=` parameter 29 | builtins.zip([1, 2, 3]) | ^^^^^^^^^^^^^^^^^^^^^^^ B905 | - = help: Add explicit `strict=False` + = help: Add explicit value for parameter `strict=` ℹ Safe fix 26 26 | From 31ca1c306448a9574830a827f87d09dbf81349f8 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 7 Oct 2024 14:25:54 +0200 Subject: [PATCH 43/88] [`flake8-async`] allow async generators (`ASYNC100`) (#13639) ## Summary Treat async generators as "await" in ASYNC100. Fixes #13637 ## Test Plan Updated snapshot --- .../test/fixtures/flake8_async/ASYNC100.py | 5 ++ ...e8_async__tests__ASYNC100_ASYNC100.py.snap | 54 +++++++++---------- crates/ruff_python_ast/src/helpers.rs | 8 +++ 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC100.py b/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC100.py index 8434073d22dac..353bd2b73afda 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC100.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC100.py @@ -36,6 +36,11 @@ async def func(): ... +async def main(): + async with asyncio.timeout(7): + print({i async for i in long_running_range()}) + + async def func(): with anyio.move_on_after(delay=0.2): ... diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap index 0eca205a5b468..5d92713d307be 100644 --- a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap +++ b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC100_ASYNC100.py.snap @@ -19,28 +19,19 @@ ASYNC100.py:18:5: ASYNC100 A `with trio.move_on_after(...):` context does not co | |___________^ ASYNC100 | -ASYNC100.py:40:5: ASYNC100 A `with anyio.move_on_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. - | -39 | async def func(): -40 | with anyio.move_on_after(delay=0.2): - | _____^ -41 | | ... - | |___________^ ASYNC100 - | - -ASYNC100.py:45:5: ASYNC100 A `with anyio.fail_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:45:5: ASYNC100 A `with anyio.move_on_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 44 | async def func(): -45 | with anyio.fail_after(): +45 | with anyio.move_on_after(delay=0.2): | _____^ 46 | | ... | |___________^ ASYNC100 | -ASYNC100.py:50:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:50:5: ASYNC100 A `with anyio.fail_after(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 49 | async def func(): -50 | with anyio.CancelScope(): +50 | with anyio.fail_after(): | _____^ 51 | | ... | |___________^ ASYNC100 @@ -49,7 +40,7 @@ ASYNC100.py:50:5: ASYNC100 A `with anyio.CancelScope(...):` context does not con ASYNC100.py:55:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 54 | async def func(): -55 | with anyio.CancelScope(), nullcontext(): +55 | with anyio.CancelScope(): | _____^ 56 | | ... | |___________^ ASYNC100 @@ -58,44 +49,53 @@ ASYNC100.py:55:5: ASYNC100 A `with anyio.CancelScope(...):` context does not con ASYNC100.py:60:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 59 | async def func(): -60 | with nullcontext(), anyio.CancelScope(): +60 | with anyio.CancelScope(), nullcontext(): | _____^ 61 | | ... | |___________^ ASYNC100 | -ASYNC100.py:65:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:65:5: ASYNC100 A `with anyio.CancelScope(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 64 | async def func(): -65 | async with asyncio.timeout(delay=0.2): +65 | with nullcontext(), anyio.CancelScope(): | _____^ 66 | | ... | |___________^ ASYNC100 | -ASYNC100.py:70:5: ASYNC100 A `with asyncio.timeout_at(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:70:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | 69 | async def func(): -70 | async with asyncio.timeout_at(when=0.2): +70 | async with asyncio.timeout(delay=0.2): | _____^ 71 | | ... | |___________^ ASYNC100 | -ASYNC100.py:80:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:75:5: ASYNC100 A `with asyncio.timeout_at(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. + | +74 | async def func(): +75 | async with asyncio.timeout_at(when=0.2): + | _____^ +76 | | ... + | |___________^ ASYNC100 + | + +ASYNC100.py:85:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | -79 | async def func(): -80 | async with asyncio.timeout(delay=0.2), asyncio.TaskGroup(), asyncio.timeout(delay=0.2): +84 | async def func(): +85 | async with asyncio.timeout(delay=0.2), asyncio.TaskGroup(), asyncio.timeout(delay=0.2): | _____^ -81 | | ... +86 | | ... | |___________^ ASYNC100 | -ASYNC100.py:90:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. +ASYNC100.py:95:5: ASYNC100 A `with asyncio.timeout(...):` context does not contain any `await` statements. This makes it pointless, as the timeout can only be triggered by a checkpoint. | -89 | async def func(): -90 | async with asyncio.timeout(delay=0.2), asyncio.timeout(delay=0.2): +94 | async def func(): +95 | async with asyncio.timeout(delay=0.2), asyncio.timeout(delay=0.2): | _____^ -91 | | ... +96 | | ... | |___________^ ASYNC100 | diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index 44b48c2b18257..565c67e585f5c 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -1004,6 +1004,14 @@ impl Visitor<'_> for AwaitVisitor { crate::visitor::walk_expr(self, expr); } } + + fn visit_comprehension(&mut self, comprehension: &'_ crate::Comprehension) { + if comprehension.is_async { + self.seen_await = true; + } else { + crate::visitor::walk_comprehension(self, comprehension); + } + } } /// Return `true` if a `Stmt` is a docstring. From 27ac34d6833fb3dc90c152d0e186e7bc5d59e93f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 7 Oct 2024 13:31:01 +0100 Subject: [PATCH 44/88] Rework `S606` (`start-process-with-no-shell`) docs to make clear the security motivations (#13658) Helps with #13614. This docs rewrite draws on the [documentation for the original bandit rule](https://bandit.readthedocs.io/en/latest/plugins/b606_start_process_with_no_shell.html). --- .../flake8_bandit/rules/shell_injection.rs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs index ff2330b623eba..eeb2f894c39a9 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/shell_injection.rs @@ -191,22 +191,28 @@ impl Violation for StartProcessWithAShell { /// Checks for functions that start a process without a shell. /// /// ## Why is this bad? -/// The `subprocess` module provides more powerful facilities for spawning new -/// processes and retrieving their results; using that module is preferable to -/// using these functions. +/// Invoking any kind of external executable via a function call can pose +/// security risks if arbitrary variables are passed to the executable, or if +/// the input is otherwise unsanitised or unvalidated. +/// +/// This rule specifically flags functions in the `os` module that spawn +/// subprocesses *without* the use of a shell. Note that these typically pose a +/// much smaller security risk than subprocesses that are started *with* a +/// shell, which are flagged by [`start-process-with-a-shell`] (`S605`). This +/// gives you the option of enabling one rule while disabling the other if you +/// decide that the security risk from these functions is acceptable for your +/// use case. /// /// ## Example /// ```python -/// os.spawnlp(os.P_NOWAIT, "/bin/mycmd", "mycmd", "myarg") -/// ``` +/// import os /// -/// Use instead: -/// ```python -/// subprocess.Popen(["/bin/mycmd", "myarg"]) +/// +/// def insecure_function(arbitrary_user_input: str): +/// os.spawnlp(os.P_NOWAIT, "/bin/mycmd", "mycmd", arbitrary_user_input) /// ``` /// -/// ## References -/// - [Python documentation: Replacing the `os.spawn` family](https://docs.python.org/3/library/subprocess.html#replacing-the-os-spawn-family) +/// [start-process-with-a-shell]: https://docs.astral.sh/ruff/rules/start-process-with-a-shell/#start-process-with-a-shell-s605 #[violation] pub struct StartProcessWithNoShell; @@ -480,7 +486,7 @@ impl From<&Expr> for Safety { /// /// ## Examples /// ```python -/// import subprocess +/// import os /// /// os.system("/bin/ls") /// os.system("./bin/ls") From 14ee5dbfde5c7bd07cd6d6fefee529cfd8291c73 Mon Sep 17 00:00:00 2001 From: Dylan <53534755+dylwil3@users.noreply.github.com> Date: Mon, 7 Oct 2024 09:13:28 -0500 Subject: [PATCH 45/88] [refurb] Count codepoints not bytes for `slice-to-remove-prefix-or-suffix (FURB188)` (#13631) --- .../resources/test/fixtures/refurb/FURB188.py | 30 +++++++- .../rules/slice_to_remove_prefix_or_suffix.rs | 11 +-- ...es__refurb__tests__FURB188_FURB188.py.snap | 72 ++++++++++++++++++- crates/ruff_python_ast/src/int.rs | 8 +++ 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/refurb/FURB188.py b/crates/ruff_linter/resources/test/fixtures/refurb/FURB188.py index 45a39257f3255..45935595183a3 100644 --- a/crates/ruff_linter/resources/test/fixtures/refurb/FURB188.py +++ b/crates/ruff_linter/resources/test/fixtures/refurb/FURB188.py @@ -169,4 +169,32 @@ def ignore_step(): text = "!x!y!z" if text.startswith("!"): text = text[1::2] - print(text) \ No newline at end of file + print(text) + +def handle_unicode(): + # should be skipped! + text = "řetězec" + if text.startswith("ř"): + text = text[2:] + + # should be linted + # with fix `text = text.removeprefix("ř")` + text = "řetězec" + if text.startswith("ř"): + text = text[1:] + + +def handle_surrogates(): + # should be linted + text = "\ud800\udc00heythere" + if text.startswith("\ud800\udc00"): + text = text[2:] + text = "\U00010000heythere" + if text.startswith("\U00010000"): + text = text[1:] + + # should not be linted + text = "\ud800\udc00heythere" + if text.startswith("\ud800\udc00"): + text = text[1:] + \ No newline at end of file diff --git a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs index e61cb1dc13696..44443032f242f 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/slice_to_remove_prefix_or_suffix.rs @@ -4,7 +4,7 @@ use ruff_macros::{derive_message_formats, violation}; use ruff_python_ast as ast; use ruff_python_semantic::SemanticModel; use ruff_source_file::Locator; -use ruff_text_size::{Ranged, TextLen}; +use ruff_text_size::Ranged; /// ## What it does /// Checks for the removal of a prefix or suffix from a string by assigning @@ -334,8 +334,9 @@ fn affix_matches_slice_bound(data: &RemoveAffixData, semantic: &SemanticModel) - }), ) => num .as_int() - .and_then(ast::Int::as_u32) // Only support prefix removal for size at most `u32::MAX` - .is_some_and(|x| x == string_val.to_str().text_len().to_u32()), + // Only support prefix removal for size at most `usize::MAX` + .and_then(ast::Int::as_usize) + .is_some_and(|x| x == string_val.chars().count()), ( AffixKind::StartsWith, ast::Expr::Call(ast::ExprCall { @@ -369,8 +370,8 @@ fn affix_matches_slice_bound(data: &RemoveAffixData, semantic: &SemanticModel) - // Only support prefix removal for size at most `u32::MAX` value .as_int() - .and_then(ast::Int::as_u32) - .is_some_and(|x| x == string_val.to_str().text_len().to_u32()) + .and_then(ast::Int::as_usize) + .is_some_and(|x| x == string_val.chars().count()) }, ), ( diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap index 89a0c17633e70..ddcc3676ac252 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB188_FURB188.py.snap @@ -250,4 +250,74 @@ FURB188.py:162:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing 162 |+ text = text.removeprefix("!") 164 163 | print(text) 165 164 | -166 165 | +166 165 | + +FURB188.py:183:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice. + | +181 | # with fix `text = text.removeprefix("ř")` +182 | text = "řetězec" +183 | if text.startswith("ř"): + | _____^ +184 | | text = text[1:] + | |_______________________^ FURB188 + | + = help: Use removeprefix instead of assignment conditional upon startswith. + +ℹ Safe fix +180 180 | # should be linted +181 181 | # with fix `text = text.removeprefix("ř")` +182 182 | text = "řetězec" +183 |- if text.startswith("ř"): +184 |- text = text[1:] + 183 |+ text = text.removeprefix("ř") +185 184 | +186 185 | +187 186 | def handle_surrogates(): + +FURB188.py:190:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice. + | +188 | # should be linted +189 | text = "\ud800\udc00heythere" +190 | if text.startswith("\ud800\udc00"): + | _____^ +191 | | text = text[2:] + | |_______________________^ FURB188 +192 | text = "\U00010000heythere" +193 | if text.startswith("\U00010000"): + | + = help: Use removeprefix instead of assignment conditional upon startswith. + +ℹ Safe fix +187 187 | def handle_surrogates(): +188 188 | # should be linted +189 189 | text = "\ud800\udc00heythere" +190 |- if text.startswith("\ud800\udc00"): +191 |- text = text[2:] + 190 |+ text = text.removeprefix("\ud800\udc00") +192 191 | text = "\U00010000heythere" +193 192 | if text.startswith("\U00010000"): +194 193 | text = text[1:] + +FURB188.py:193:5: FURB188 [*] Prefer `removeprefix` over conditionally replacing with slice. + | +191 | text = text[2:] +192 | text = "\U00010000heythere" +193 | if text.startswith("\U00010000"): + | _____^ +194 | | text = text[1:] + | |_______________________^ FURB188 +195 | +196 | # should not be linted + | + = help: Use removeprefix instead of assignment conditional upon startswith. + +ℹ Safe fix +190 190 | if text.startswith("\ud800\udc00"): +191 191 | text = text[2:] +192 192 | text = "\U00010000heythere" +193 |- if text.startswith("\U00010000"): +194 |- text = text[1:] + 193 |+ text = text.removeprefix("\U00010000") +195 194 | +196 195 | # should not be linted +197 196 | text = "\ud800\udc00heythere" diff --git a/crates/ruff_python_ast/src/int.rs b/crates/ruff_python_ast/src/int.rs index 08f3d39119545..bbcf1b0b2a349 100644 --- a/crates/ruff_python_ast/src/int.rs +++ b/crates/ruff_python_ast/src/int.rs @@ -96,6 +96,14 @@ impl Int { } } + /// Return the [`Int`] as an u64, if it can be represented as that data type. + pub fn as_usize(&self) -> Option { + match &self.0 { + Number::Small(small) => usize::try_from(*small).ok(), + Number::Big(_) => None, + } + } + /// Return the [`Int`] as an i8, if it can be represented as that data type. pub fn as_i8(&self) -> Option { match &self.0 { From d7484e6942e66f03430532de7442508e195a7c1f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 7 Oct 2024 16:13:06 +0100 Subject: [PATCH 46/88] [red-knot] Improve type inference for except handlers where a tuple of exception classes is caught (#13646) --- .../src/types/infer.rs | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 3de456dded065..349ef57651e46 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -1030,10 +1030,17 @@ impl<'db> TypeInferenceBuilder<'db> { } else { // TODO: anything that's a consistent subtype of // `type[BaseException] | tuple[type[BaseException], ...]` should be valid; - // anything else should be invalid --Alex + // anything else is invalid and should lead to a diagnostic being reported --Alex match node_ty { Type::Any | Type::Unknown => node_ty, Type::Class(class_ty) => Type::Instance(class_ty), + Type::Tuple(tuple) => UnionType::from_elements( + self.db, + tuple + .elements(self.db) + .iter() + .map(|ty| ty.into_class_type().map_or(Type::Todo, Type::Instance)), + ), _ => Type::Todo, } }; @@ -6151,11 +6158,54 @@ mod tests { ", )?; - // For these TODOs we need support for `tuple` types: let expected_diagnostics = &[ - // TODO: Should be `RuntimeError | OSError` --Alex + "Revealed type is `RuntimeError | OSError`", + "Revealed type is `AttributeError | TypeError`", + ]; + + assert_file_diagnostics(&db, "src/a.py", expected_diagnostics); + + Ok(()) + } + + #[test] + fn except_handler_dynamic_exceptions() -> anyhow::Result<()> { + let mut db = setup_db(); + + db.write_dedented( + "src/a.py", + " + from typing_extensions import reveal_type + + def foo( + x: type[AttributeError], + y: tuple[type[OSError], type[RuntimeError]], + z: tuple[type[BaseException], ...] + ): + try: + w + except x as e: + reveal_type(e) + except y as f: + reveal_type(f) + except z as g: + reveal_type(g) + ", + )?; + + let expected_diagnostics = &[ + // TODO: these `__class_getitem__` diagnostics are all false positives: + // (`builtins.type` is unique at runtime + // as it can be subscripted even though it has no `__class_getitem__` method) + "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", + "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", + "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", + "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", + // Should be `AttributeError`: + "Revealed type is `@Todo`", + // Should be `OSError | RuntimeError`: "Revealed type is `@Todo`", - // TODO: Should be `AttributeError | TypeError` --Alex + // Should be `BaseException`: "Revealed type is `@Todo`", ]; From fb90f5a13d271cc0ddf954b0e576b38694175a01 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 7 Oct 2024 11:20:45 -0500 Subject: [PATCH 47/88] Add known limitation to `C416` with dictionaries (#13627) Part of https://github.com/astral-sh/ruff/issues/13625 See also #13629 --- .../rules/unnecessary_comprehension.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs index dd8a2124f25d6..409e73c2f0396 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension.rs @@ -12,9 +12,8 @@ use crate::rules::flake8_comprehensions::fixes; /// Checks for unnecessary `dict`, `list`, and `set` comprehension. /// /// ## Why is this bad? -/// It's unnecessary to use a `dict`/`list`/`set` comprehension to build a -/// data structure if the elements are unchanged. Wrap the iterable with -/// `dict()`, `list()`, or `set()` instead. +/// It's unnecessary to use a `dict`/`list`/`set` comprehension to build a data structure if the +/// elements are unchanged. Wrap the iterable with `dict()`, `list()`, or `set()` instead. /// /// ## Examples /// ```python @@ -30,10 +29,33 @@ use crate::rules::flake8_comprehensions::fixes; /// set(iterable) /// ``` /// +/// ## Known problems +/// +/// This rule may produce false positives for dictionary comprehensions that iterate over a mapping. +/// The `dict` constructor behaves differently depending on if it receives a sequence (e.g., a +/// `list`) or a mapping (e.g., a `dict`). When a comprehension iterates over the keys of a mapping, +/// replacing it with a `dict` constructor call will give a different result. +/// +/// For example: +/// +/// ```pycon +/// >>> d1 = {(1, 2): 3, (4, 5): 6} +/// >>> {x: y for x, y in d1} # Iterates over the keys of a mapping +/// {1: 2, 4: 5} +/// >>> dict(d1) # Ruff's incorrect suggested fix +/// (1, 2): 3, (4, 5): 6} +/// >>> dict(d1.keys()) # Correct fix +/// {1: 2, 4: 5} +/// ``` +/// +/// When the comprehension iterates over a sequence, Ruff's suggested fix is correct. However, Ruff +/// cannot consistently infer if the iterable type is a sequence or a mapping and cannot suggest +/// the correct fix for mappings. +/// /// ## Fix safety -/// This rule's fix is marked as unsafe, as it may occasionally drop comments -/// when rewriting the comprehension. In most cases, though, comments will be -/// preserved. +/// Due to the known problem with dictionary comprehensions, this fix is marked as unsafe. +/// +/// Additionally, this fix may drop comments when rewriting the comprehension. #[violation] pub struct UnnecessaryComprehension { obj_type: String, From 71b52b83e4c6f4705fecfa556324ae2cfd8f2ec5 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 7 Oct 2024 19:43:47 +0100 Subject: [PATCH 48/88] [red-knot] Allow `type[]` to be subscripted (#13667) Fixed a TODO by adding another TODO. It's the red-knot way! ## Summary `builtins.type` can be subscripted at runtime on Python 3.9+, even though it has no `__class_getitem__` method and its metaclass (which is... itself) has no `__getitem__` method. The special case is [hardcoded directly into `PyObject_GetItem` in CPython](https://github.com/python/cpython/blob/744caa8ef42ab67c6aa20cd691e078721e72e22a/Objects/abstract.c#L181-L184). We just have to replicate the special case in our semantic model. This will fail at runtime on Python <3.9. However, there's a bunch of outstanding questions (detailed in the TODO comment I added) regarding how we deal with subscriptions of other generic types on lower Python versions. Since we want to avoid too many false positives for now, I haven't tried to address this; I've just made `type` subscriptable on all Python versions. ## Test Plan `cargo test -p red_knot_python_semantic --lib` --- crates/red_knot_python_semantic/src/types.rs | 9 +++++++-- .../src/types/infer.rs | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index ce84a55e6dee0..3e686b1636d3b 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -837,6 +837,7 @@ pub enum KnownClass { Set, Dict, // Types + GenericAlias, ModuleType, FunctionType, // Typeshed @@ -857,6 +858,7 @@ impl<'db> KnownClass { Self::Dict => "dict", Self::List => "list", Self::Type => "type", + Self::GenericAlias => "GenericAlias", Self::ModuleType => "ModuleType", Self::FunctionType => "FunctionType", Self::NoneType => "NoneType", @@ -880,7 +882,9 @@ impl<'db> KnownClass { | Self::Tuple | Self::Set | Self::Dict => builtins_symbol_ty(db, self.as_str()), - Self::ModuleType | Self::FunctionType => types_symbol_ty(db, self.as_str()), + Self::GenericAlias | Self::ModuleType | Self::FunctionType => { + types_symbol_ty(db, self.as_str()) + } Self::NoneType => typeshed_symbol_ty(db, self.as_str()), } } @@ -910,6 +914,7 @@ impl<'db> KnownClass { "set" => Some(Self::Set), "dict" => Some(Self::Dict), "list" => Some(Self::List), + "GenericAlias" => Some(Self::GenericAlias), "NoneType" => Some(Self::NoneType), "ModuleType" => Some(Self::ModuleType), "FunctionType" => Some(Self::FunctionType), @@ -934,7 +939,7 @@ impl<'db> KnownClass { | Self::Tuple | Self::Set | Self::Dict => module.name() == "builtins", - Self::ModuleType | Self::FunctionType => module.name() == "types", + Self::GenericAlias | Self::ModuleType | Self::FunctionType => module.name() == "types", Self::NoneType => matches!(module.name().as_str(), "_typeshed" | "types"), } } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 349ef57651e46..f34892ff5d00e 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -2828,6 +2828,13 @@ impl<'db> TypeInferenceBuilder<'db> { // Otherwise, if the value is itself a class and defines `__class_getitem__`, // return its return type. + // + // TODO: lots of classes are only subscriptable at runtime on Python 3.9+, + // *but* we should also allow them to be subscripted in stubs + // (and in annotations if `from __future__ import annotations` is enabled), + // even if the target version is Python 3.8 or lower, + // despite the fact that there will be no corresponding `__class_getitem__` + // method in these `sys.version_info` branches. if value_ty.is_class(self.db) { let dunder_class_getitem_method = value_ty.member(self.db, "__class_getitem__"); if !dunder_class_getitem_method.is_unbound() { @@ -2848,6 +2855,11 @@ impl<'db> TypeInferenceBuilder<'db> { }); } + if matches!(value_ty, Type::Class(class) if class.is_known(self.db, KnownClass::Type)) + { + return KnownClass::GenericAlias.to_instance(self.db); + } + self.non_subscriptable_diagnostic( (&**value).into(), value_ty, @@ -6194,13 +6206,6 @@ mod tests { )?; let expected_diagnostics = &[ - // TODO: these `__class_getitem__` diagnostics are all false positives: - // (`builtins.type` is unique at runtime - // as it can be subscripted even though it has no `__class_getitem__` method) - "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", - "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", - "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", - "Cannot subscript object of type `Literal[type]` with no `__class_getitem__` method", // Should be `AttributeError`: "Revealed type is `@Todo`", // Should be `OSError | RuntimeError`: From 42fcbef876a6d0554e04ca73d3ba706947f9ea0f Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 7 Oct 2024 14:08:36 -0500 Subject: [PATCH 49/88] Fix typo in `allow-unused-imports` documentation (#13669) --- crates/ruff_workspace/src/options.rs | 2 +- ruff.schema.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index d2ddb2c0937ce..76f1a4b9a095c 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -796,7 +796,7 @@ pub struct LintCommonOptions { )] pub typing_modules: Option>, - /// A list of modules which is allowed even thought they are not used + /// A list of modules which is allowed even though they are not used /// in the code. /// /// This is useful when a module has a side effect when imported. diff --git a/ruff.schema.json b/ruff.schema.json index cf01ac039f4b1..0010ec04f2cac 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -17,7 +17,7 @@ } }, "allowed-unused-imports": { - "description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", + "description": "A list of modules which is allowed even though they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", "deprecated": true, "type": [ "array", @@ -1899,7 +1899,7 @@ } }, "allowed-unused-imports": { - "description": "A list of modules which is allowed even thought they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", + "description": "A list of modules which is allowed even though they are not used in the code.\n\nThis is useful when a module has a side effect when imported.", "type": [ "array", "null" From fc661e193a45fe62a441ed2abe529fc16bd69583 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 8 Oct 2024 11:59:17 +0200 Subject: [PATCH 50/88] Normalize implicit concatenated f-string quotes per part (#13539) --- crates/ruff_python_formatter/generate.py | 7 +-- .../test/fixtures/ruff/expression/fstring.py | 8 ++++ .../src/expression/expr_string_literal.rs | 30 ++----------- crates/ruff_python_formatter/src/generated.rs | 36 ++++++++++++++++ .../src/other/f_string.rs | 38 +++++++++++++--- .../src/other/f_string_part.rs | 17 ++++---- .../src/other/string_literal.rs | 43 +++++++++++++------ crates/ruff_python_formatter/src/preview.rs | 7 +++ .../src/statement/suite.rs | 4 +- .../ruff_python_formatter/src/string/any.rs | 7 ++- .../format@expression__fstring.py.snap | 36 +++++++++++++++- 11 files changed, 171 insertions(+), 62 deletions(-) diff --git a/crates/ruff_python_formatter/generate.py b/crates/ruff_python_formatter/generate.py index bf89ac1a4b523..ff28a04331dc0 100755 --- a/crates/ruff_python_formatter/generate.py +++ b/crates/ruff_python_formatter/generate.py @@ -33,14 +33,15 @@ def rustfmt(code: str) -> str: nodes = [] for node_line in node_lines: node = node_line.split("(")[1].split(")")[0].split("::")[-1].split("<")[0] - # `FString` and `StringLiteral` has a custom implementation while the formatting for - # `FStringLiteralElement` and `FStringExpressionElement` are handled by the `FString` + # `FString` has a custom implementation while the formatting for + # `FStringLiteralElement`, `FStringFormatSpec` and `FStringExpressionElement` are handled by the `FString` # implementation. if node in ( "FString", - "StringLiteral", "FStringLiteralElement", "FStringExpressionElement", + "FStringFormatSpec", + "Identifier", ): continue nodes.append(node) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index 001a554163b32..69f65c20c573a 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -307,3 +307,11 @@ ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) diff --git a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs index 460c1519dd6da..23e8eab633eb5 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -4,37 +4,17 @@ use ruff_python_ast::{AnyNodeRef, ExprStringLiteral}; use crate::expression::parentheses::{ in_parentheses_only_group, NeedsParentheses, OptionalParentheses, }; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::{AnyString, FormatImplicitConcatenatedString}; #[derive(Default)] pub struct FormatExprStringLiteral { - kind: ExprStringLiteralKind, -} - -#[derive(Default, Copy, Clone, Debug)] -pub enum ExprStringLiteralKind { - #[default] - String, - Docstring, -} - -impl ExprStringLiteralKind { - const fn string_literal_kind(self) -> StringLiteralKind { - match self { - ExprStringLiteralKind::String => StringLiteralKind::String, - ExprStringLiteralKind::Docstring => StringLiteralKind::Docstring, - } - } - - const fn is_docstring(self) -> bool { - matches!(self, ExprStringLiteralKind::Docstring) - } + kind: StringLiteralKind, } impl FormatRuleWithOptions> for FormatExprStringLiteral { - type Options = ExprStringLiteralKind; + type Options = StringLiteralKind; fn with_options(mut self, options: Self::Options) -> Self { self.kind = options; @@ -47,9 +27,7 @@ impl FormatNodeRule for FormatExprStringLiteral { let ExprStringLiteral { value, .. } = item; match value.as_slice() { - [string_literal] => { - FormatStringLiteral::new(string_literal, self.kind.string_literal_kind()).fmt(f) - } + [string_literal] => string_literal.format().with_options(self.kind).fmt(f), _ => { // This is just a sanity check because [`DocstringStmt::try_from_statement`] // ensures that the docstring is a *single* string literal. diff --git a/crates/ruff_python_formatter/src/generated.rs b/crates/ruff_python_formatter/src/generated.rs index 63667ac5095dc..90ea7b00dd51e 100644 --- a/crates/ruff_python_formatter/src/generated.rs +++ b/crates/ruff_python_formatter/src/generated.rs @@ -2935,6 +2935,42 @@ impl<'ast> IntoFormat> for ast::TypeParamParamSpec { } } +impl FormatRule> + for crate::other::string_literal::FormatStringLiteral +{ + #[inline] + fn fmt(&self, node: &ast::StringLiteral, f: &mut PyFormatter) -> FormatResult<()> { + FormatNodeRule::::fmt(self, node, f) + } +} +impl<'ast> AsFormat> for ast::StringLiteral { + type Format<'a> = FormatRefWithRule< + 'a, + ast::StringLiteral, + crate::other::string_literal::FormatStringLiteral, + PyFormatContext<'ast>, + >; + fn format(&self) -> Self::Format<'_> { + FormatRefWithRule::new( + self, + crate::other::string_literal::FormatStringLiteral::default(), + ) + } +} +impl<'ast> IntoFormat> for ast::StringLiteral { + type Format = FormatOwnedWithRule< + ast::StringLiteral, + crate::other::string_literal::FormatStringLiteral, + PyFormatContext<'ast>, + >; + fn into_format(self) -> Self::Format { + FormatOwnedWithRule::new( + self, + crate::other::string_literal::FormatStringLiteral::default(), + ) + } +} + impl FormatRule> for crate::other::bytes_literal::FormatBytesLiteral { diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 2b2e1f0449c60..6f46176a27888 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -1,10 +1,12 @@ +use crate::prelude::*; +use crate::preview::{ + is_f_string_formatting_enabled, is_f_string_implicit_concatenated_string_literal_quotes_enabled, +}; +use crate::string::{Quoting, StringNormalizer, StringQuotes}; use ruff_formatter::write; use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; use ruff_source_file::Locator; - -use crate::prelude::*; -use crate::preview::is_f_string_formatting_enabled; -use crate::string::{Quoting, StringNormalizer, StringQuotes}; +use ruff_text_size::Ranged; use super::f_string_element::FormatFStringElement; @@ -29,8 +31,17 @@ impl Format> for FormatFString<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { let locator = f.context().locator(); + // If the preview style is enabled, make the decision on what quotes to use locally for each + // f-string instead of globally for the entire f-string expression. + let quoting = + if is_f_string_implicit_concatenated_string_literal_quotes_enabled(f.context()) { + f_string_quoting(self.value, &locator) + } else { + self.quoting + }; + let normalizer = StringNormalizer::from_context(f.context()) - .with_quoting(self.quoting) + .with_quoting(quoting) .with_preferred_quote_style(f.options().quote_style()); // If f-string formatting is disabled (not in preview), then we will @@ -140,3 +151,20 @@ impl FStringLayout { matches!(self, FStringLayout::Multiline) } } + +fn f_string_quoting(f_string: &FString, locator: &Locator) -> Quoting { + let triple_quoted = f_string.flags.is_triple_quoted(); + + if f_string.elements.expressions().any(|expression| { + let string_content = locator.slice(expression.range()); + if triple_quoted { + string_content.contains(r#"""""#) || string_content.contains("'''") + } else { + string_content.contains(['"', '\'']) + } + }) { + Quoting::Preserve + } else { + Quoting::CanChange + } +} diff --git a/crates/ruff_python_formatter/src/other/f_string_part.rs b/crates/ruff_python_formatter/src/other/f_string_part.rs index c471b5fc8cd4f..d33148aaccc44 100644 --- a/crates/ruff_python_formatter/src/other/f_string_part.rs +++ b/crates/ruff_python_formatter/src/other/f_string_part.rs @@ -1,7 +1,7 @@ use ruff_python_ast::FStringPart; use crate::other::f_string::FormatFString; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -25,14 +25,13 @@ impl<'a> FormatFStringPart<'a> { impl Format> for FormatFStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self.part { - FStringPart::Literal(string_literal) => FormatStringLiteral::new( - string_literal, - // If an f-string part is a string literal, the f-string is always - // implicitly concatenated e.g., `"foo" f"bar {x}"`. A standalone - // string literal would be a string expression, not an f-string. - StringLiteralKind::InImplicitlyConcatenatedFString(self.quoting), - ) - .fmt(f), + #[allow(deprecated)] + FStringPart::Literal(string_literal) => string_literal + .format() + .with_options(StringLiteralKind::InImplicitlyConcatenatedFString( + self.quoting, + )) + .fmt(f), FStringPart::FString(f_string) => FormatFString::new(f_string, self.quoting).fmt(f), } } diff --git a/crates/ruff_python_formatter/src/other/string_literal.rs b/crates/ruff_python_formatter/src/other/string_literal.rs index 2d3d752d434b8..7aee127d4b06e 100644 --- a/crates/ruff_python_formatter/src/other/string_literal.rs +++ b/crates/ruff_python_formatter/src/other/string_literal.rs @@ -1,23 +1,28 @@ +use ruff_formatter::FormatRuleWithOptions; use ruff_python_ast::StringLiteral; use crate::prelude::*; +use crate::preview::is_f_string_implicit_concatenated_string_literal_quotes_enabled; use crate::string::{docstring, Quoting, StringNormalizer}; use crate::QuoteStyle; -pub(crate) struct FormatStringLiteral<'a> { - value: &'a StringLiteral, +#[derive(Default)] +pub struct FormatStringLiteral { layout: StringLiteralKind, } -impl<'a> FormatStringLiteral<'a> { - pub(crate) fn new(value: &'a StringLiteral, layout: StringLiteralKind) -> Self { - Self { value, layout } +impl FormatRuleWithOptions> for FormatStringLiteral { + type Options = StringLiteralKind; + + fn with_options(mut self, layout: StringLiteralKind) -> Self { + self.layout = layout; + self } } /// The kind of a string literal. #[derive(Copy, Clone, Debug, Default)] -pub(crate) enum StringLiteralKind { +pub enum StringLiteralKind { /// A normal string literal e.g., `"foo"`. #[default] String, @@ -26,6 +31,8 @@ pub(crate) enum StringLiteralKind { /// A string literal that is implicitly concatenated with an f-string. This /// makes the overall expression an f-string whose quoting detection comes /// from the parent node (f-string expression). + #[deprecated] + #[allow(private_interfaces)] InImplicitlyConcatenatedFString(Quoting), } @@ -36,16 +43,28 @@ impl StringLiteralKind { } /// Returns the quoting to be used for this string literal. - fn quoting(self) -> Quoting { + fn quoting(self, context: &PyFormatContext) -> Quoting { match self { StringLiteralKind::String | StringLiteralKind::Docstring => Quoting::CanChange, - StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => quoting, + #[allow(deprecated)] + StringLiteralKind::InImplicitlyConcatenatedFString(quoting) => { + // Allow string literals to pick the "optimal" quote character + // even if any other fstring in the implicit concatenation uses an expression + // containing a quote character. + // TODO: Remove StringLiteralKind::InImplicitlyConcatenatedFString when promoting + // this style to stable and remove the layout from `AnyStringPart::String`. + if is_f_string_implicit_concatenated_string_literal_quotes_enabled(context) { + Quoting::CanChange + } else { + quoting + } + } } } } -impl Format> for FormatStringLiteral<'_> { - fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { +impl FormatNodeRule for FormatStringLiteral { + fn fmt_fields(&self, item: &StringLiteral, f: &mut PyFormatter) -> FormatResult<()> { let quote_style = f.options().quote_style(); let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() { // Per PEP 8 and PEP 257, always prefer double quotes for docstrings, @@ -56,9 +75,9 @@ impl Format> for FormatStringLiteral<'_> { }; let normalized = StringNormalizer::from_context(f.context()) - .with_quoting(self.layout.quoting()) + .with_quoting(self.layout.quoting(f.context())) .with_preferred_quote_style(quote_style) - .normalize(self.value.into()); + .normalize(item.into()); if self.layout.is_docstring() { docstring::format(&normalized, f) diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 30d7b858dfdef..92b86f3ccfd7b 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -19,6 +19,13 @@ pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool context.is_preview() } +/// See [#13539](https://github.com/astral-sh/ruff/pull/13539) +pub(crate) fn is_f_string_implicit_concatenated_string_literal_quotes_enabled( + context: &PyFormatContext, +) -> bool { + context.is_preview() +} + pub(crate) fn is_with_single_item_pre_39_enabled(context: &PyFormatContext) -> bool { context.is_preview() } diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index d0d89839ccf73..c483f917e2395 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -11,7 +11,7 @@ use crate::comments::{ leading_comments, trailing_comments, Comments, LeadingDanglingTrailingComments, }; use crate::context::{NodeLevel, TopLevelStatementPosition, WithIndentLevel, WithNodeLevel}; -use crate::expression::expr_string_literal::ExprStringLiteralKind; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::statement::stmt_expr::FormatStmtExpr; use crate::verbatim::{ @@ -850,7 +850,7 @@ impl Format> for DocstringStmt<'_> { .then_some(source_position(self.docstring.start())), string_literal .format() - .with_options(ExprStringLiteralKind::Docstring), + .with_options(StringLiteralKind::Docstring), f.options() .source_map_generation() .is_enabled() diff --git a/crates/ruff_python_formatter/src/string/any.rs b/crates/ruff_python_formatter/src/string/any.rs index b86b3b4fc03de..0341715ac09f6 100644 --- a/crates/ruff_python_formatter/src/string/any.rs +++ b/crates/ruff_python_formatter/src/string/any.rs @@ -11,7 +11,7 @@ use ruff_text_size::{Ranged, TextRange}; use crate::expression::expr_f_string::f_string_quoting; use crate::other::f_string::FormatFString; -use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind}; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::string::Quoting; @@ -160,6 +160,7 @@ impl<'a> Iterator for AnyStringPartsIter<'a> { match part { ast::FStringPart::Literal(string_literal) => AnyStringPart::String { part: string_literal, + #[allow(deprecated)] layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting), }, ast::FStringPart::FString(f_string) => AnyStringPart::FString { @@ -226,9 +227,7 @@ impl Ranged for AnyStringPart<'_> { impl Format> for AnyStringPart<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self { - AnyStringPart::String { part, layout } => { - FormatStringLiteral::new(part, *layout).fmt(f) - } + AnyStringPart::String { part, layout } => part.format().with_options(*layout).fmt(f), AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f), } diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index 189d25ce3f5b3..5faebb836e37d 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -313,6 +313,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) ``` ## Outputs @@ -649,6 +657,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + "This string should change its quotes to double quotes" + f'This string uses double quotes in an expression {"woah"}' + f"This f-string does not use any quotes." +) ``` @@ -973,6 +989,14 @@ hello { ] } -------- """ + + +# Implicit concatenated f-string containing quotes +_ = ( + 'This string should change its quotes to double quotes' + f'This string uses double quotes in an expression {"woah"}' + f'This f-string does not use any quotes.' +) ``` @@ -1279,7 +1303,7 @@ hello { # comment 27 # comment 28 } woah {x}" -@@ -287,19 +299,19 @@ +@@ -287,27 +299,27 @@ if indent2: foo = f"""hello world hello { @@ -1314,4 +1338,14 @@ hello { + ] + } -------- """ + + + # Implicit concatenated f-string containing quotes + _ = ( +- 'This string should change its quotes to double quotes' ++ "This string should change its quotes to double quotes" + f'This string uses double quotes in an expression {"woah"}' +- f'This f-string does not use any quotes.' ++ f"This f-string does not use any quotes." + ) ``` From 93eff7f1744cc428d615d5f8a3cb9f75b1768f42 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 8 Oct 2024 12:33:19 -0700 Subject: [PATCH 51/88] [red-knot] type inference/checking test framework (#13636) ## Summary Adds a markdown-based test framework for writing tests of type inference and type checking. Fixes #11664. Implements the basic required features. A markdown test file is a suite of tests, each test can contain one or more Python files, with optionally specified path/name. The test writes all files to an in-memory file system, runs red-knot, and matches the resulting diagnostics against `Type: ` and `Error: ` assertions embedded in the Python source as comments. We will want to add features like incremental tests, setting custom configuration for tests, writing non-Python files, testing syntax errors, capturing full diagnostic output, etc. There's also plenty of room for improved UX (colored output?). ## Test Plan Lots of tests! Sample of the current output when a test fails: ``` Running tests/inference.rs (target/debug/deps/inference-7c96590aa84de2a4) running 1 test test inference::path_1_resources_inference_numbers_md ... FAILED failures: ---- inference::path_1_resources_inference_numbers_md stdout ---- inference/numbers.md - Numbers - Floats /src/test.py line 2: unexpected error: [invalid-assignment] "Object of type `Literal["str"]` is not assignable to `int`" thread 'inference::path_1_resources_inference_numbers_md' panicked at crates/red_knot_test/src/lib.rs:60:5: Some tests failed. note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: inference::path_1_resources_inference_numbers_md test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s error: test failed, to rerun pass `-p red_knot_test --test inference` ``` --------- Co-authored-by: Micha Reiser Co-authored-by: Alex Waygood --- .github/workflows/ci.yaml | 2 +- Cargo.lock | 69 ++ Cargo.toml | 2 + crates/red_knot_python_semantic/Cargo.toml | 2 + .../resources/mdtest/numbers.md | 35 + crates/red_knot_python_semantic/src/types.rs | 2 +- .../src/types/infer.rs | 22 - .../red_knot_python_semantic/tests/mdtest.rs | 13 + crates/red_knot_test/Cargo.toml | 32 + crates/red_knot_test/src/assertion.rs | 621 ++++++++++++++ crates/red_knot_test/src/db.rs | 88 ++ crates/red_knot_test/src/diagnostic.rs | 173 ++++ crates/red_knot_test/src/lib.rs | 91 ++ crates/red_knot_test/src/matcher.rs | 789 ++++++++++++++++++ crates/red_knot_test/src/parser.rs | 576 +++++++++++++ .../ruff_python_trivia/src/comment_ranges.rs | 2 +- crates/ruff_text_size/src/traits.rs | 10 + 17 files changed, 2504 insertions(+), 25 deletions(-) create mode 100644 crates/red_knot_python_semantic/resources/mdtest/numbers.md create mode 100644 crates/red_knot_python_semantic/tests/mdtest.rs create mode 100644 crates/red_knot_test/Cargo.toml create mode 100644 crates/red_knot_test/src/assertion.rs create mode 100644 crates/red_knot_test/src/db.rs create mode 100644 crates/red_knot_test/src/diagnostic.rs create mode 100644 crates/red_knot_test/src/lib.rs create mode 100644 crates/red_knot_test/src/matcher.rs create mode 100644 crates/red_knot_test/src/parser.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 915c54d10dcc8..a9cbebdf6dc86 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -148,7 +148,7 @@ jobs: # sync, not just public items. Eventually we should do this for all # crates; for now add crates here as they are warning-clean to prevent # regression. - - run: cargo doc --no-deps -p red_knot_python_semantic -p red_knot -p ruff_db --document-private-items + - run: cargo doc --no-deps -p red_knot_python_semantic -p red_knot -p red_knot_test -p ruff_db --document-private-items env: # Setting RUSTDOCFLAGS because `cargo doc --check` isn't yet implemented (https://github.com/rust-lang/cargo/issues/10025). RUSTDOCFLAGS: "-D warnings" diff --git a/Cargo.lock b/Cargo.lock index fcd522d147d4b..864af0e879960 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2084,7 +2084,9 @@ dependencies = [ "insta", "itertools 0.13.0", "ordermap", + "red_knot_test", "red_knot_vendored", + "rstest", "ruff_db", "ruff_index", "ruff_python_ast", @@ -2127,6 +2129,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "red_knot_test" +version = "0.0.0" +dependencies = [ + "anyhow", + "once_cell", + "red_knot_python_semantic", + "red_knot_vendored", + "regex", + "ruff_db", + "ruff_index", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash 2.0.0", + "salsa", + "smallvec", +] + [[package]] name = "red_knot_vendored" version = "0.0.0" @@ -2247,6 +2268,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "ring" version = "0.17.8" @@ -2262,6 +2289,33 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +dependencies = [ + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "ruff" version = "0.6.9" @@ -2885,6 +2939,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.37" @@ -3030,6 +3093,12 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.210" diff --git a/Cargo.toml b/Cargo.toml index 428c329a0923d..234ac4dc7a538 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ ruff_workspace = { path = "crates/ruff_workspace" } red_knot_python_semantic = { path = "crates/red_knot_python_semantic" } red_knot_server = { path = "crates/red_knot_server" } +red_knot_test = { path = "crates/red_knot_test" } red_knot_workspace = { path = "crates/red_knot_workspace", default-features = false } aho-corasick = { version = "1.1.3" } @@ -114,6 +115,7 @@ quote = { version = "1.0.23" } rand = { version = "0.8.5" } rayon = { version = "1.10.0" } regex = { version = "1.10.2" } +rstest = { version = "0.22.0", default-features = false } rustc-hash = { version = "2.0.0" } salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "4a7c955255e707e64e43f3ce5eabb771ae067768" } schemars = { version = "0.8.16" } diff --git a/crates/red_knot_python_semantic/Cargo.toml b/crates/red_knot_python_semantic/Cargo.toml index f9aee056356ba..cc475b71e07e1 100644 --- a/crates/red_knot_python_semantic/Cargo.toml +++ b/crates/red_knot_python_semantic/Cargo.toml @@ -38,10 +38,12 @@ test-case = { workspace = true } [dev-dependencies] ruff_db = { workspace = true, features = ["os", "testing"] } ruff_python_parser = { workspace = true } +red_knot_test = { workspace = true } red_knot_vendored = { workspace = true } anyhow = { workspace = true } insta = { workspace = true } +rstest = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/red_knot_python_semantic/resources/mdtest/numbers.md b/crates/red_knot_python_semantic/resources/mdtest/numbers.md new file mode 100644 index 0000000000000..982cadc184ac5 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/numbers.md @@ -0,0 +1,35 @@ +# Numbers + +## Integers + +### Literals + +We can infer an integer literal type: + +```py +reveal_type(1) # revealed: Literal[1] +``` + +### Overflow + +We only track integer literals within the range of an i64: + +```py +reveal_type(9223372036854775808) # revealed: int +``` + +## Floats + +There aren't literal float types, but we infer the general float type: + +```py +reveal_type(1.0) # revealed: float +``` + +## Complex + +Same for complex: + +```py +reveal_type(2j) # revealed: complex +``` diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 3e686b1636d3b..f820331a21c35 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -17,7 +17,7 @@ use crate::types::narrow::narrowing_constraint; use crate::{Db, FxOrderSet, Module}; pub(crate) use self::builder::{IntersectionBuilder, UnionBuilder}; -pub(crate) use self::diagnostic::TypeCheckDiagnostics; +pub use self::diagnostic::{TypeCheckDiagnostic, TypeCheckDiagnostics}; pub(crate) use self::display::TypeArrayDisplay; pub(crate) use self::infer::{ infer_deferred_types, infer_definition_types, infer_expression_types, infer_scope_types, diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index f34892ff5d00e..f894d6dc24133 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -3683,28 +3683,6 @@ mod tests { Ok(()) } - #[test] - fn number_literal() -> anyhow::Result<()> { - let mut db = setup_db(); - - db.write_dedented( - "src/a.py", - " - a = 1 - b = 9223372036854775808 - c = 1.45 - d = 2j - ", - )?; - - assert_public_ty(&db, "src/a.py", "a", "Literal[1]"); - assert_public_ty(&db, "src/a.py", "b", "int"); - assert_public_ty(&db, "src/a.py", "c", "float"); - assert_public_ty(&db, "src/a.py", "d", "complex"); - - Ok(()) - } - #[test] fn negated_int_literal() -> anyhow::Result<()> { let mut db = setup_db(); diff --git a/crates/red_knot_python_semantic/tests/mdtest.rs b/crates/red_knot_python_semantic/tests/mdtest.rs new file mode 100644 index 0000000000000..6493ce290a37b --- /dev/null +++ b/crates/red_knot_python_semantic/tests/mdtest.rs @@ -0,0 +1,13 @@ +use red_knot_test::run; +use std::path::PathBuf; + +#[rstest::rstest] +fn mdtest(#[files("resources/mdtest/**/*.md")] path: PathBuf) { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("mdtest") + .canonicalize() + .unwrap(); + let title = path.strip_prefix(crate_dir).unwrap(); + run(&path, title.as_os_str().to_str().unwrap()); +} diff --git a/crates/red_knot_test/Cargo.toml b/crates/red_knot_test/Cargo.toml new file mode 100644 index 0000000000000..88059cb0e265a --- /dev/null +++ b/crates/red_knot_test/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "red_knot_test" +version = "0.0.0" +publish = false +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +red_knot_python_semantic = { workspace = true } +red_knot_vendored = { workspace = true } +ruff_db = { workspace = true } +ruff_index = { workspace = true } +ruff_python_trivia = { workspace = true } +ruff_source_file = { workspace = true } +ruff_text_size = { workspace = true } + +anyhow = { workspace = true } +once_cell = { workspace = true } +regex = { workspace = true } +rustc-hash = { workspace = true } +salsa = { workspace = true } +smallvec = { workspace = true } + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/red_knot_test/src/assertion.rs b/crates/red_knot_test/src/assertion.rs new file mode 100644 index 0000000000000..160214e36b652 --- /dev/null +++ b/crates/red_knot_test/src/assertion.rs @@ -0,0 +1,621 @@ +//! Parse type and type-error assertions in Python comment form. +//! +//! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`. +//! In the latter case, the `8` is a column number, and `"message text"` asserts that the full +//! diagnostic message contains the text `"message text"`; all three are optional (`# error:` will +//! match any error.) +//! +//! Assertion comments may be placed at end-of-line: +//! +//! ```py +//! x: int = "foo" # error: [invalid-assignment] +//! ``` +//! +//! Or as a full-line comment on the preceding line: +//! +//! ```py +//! # error: [invalid-assignment] +//! x: int = "foo" +//! ``` +//! +//! Multiple assertion comments may apply to the same line; in this case all (or all but the last) +//! must be full-line comments: +//! +//! ```py +//! # error: [unbound-name] +//! reveal_type(x) # revealed: Unbound +//! ``` +//! +//! or +//! +//! ```py +//! # error: [unbound-name] +//! # revealed: Unbound +//! reveal_type(x) +//! ``` + +use crate::db::Db; +use once_cell::sync::Lazy; +use regex::Regex; +use ruff_db::files::File; +use ruff_db::parsed::parsed_module; +use ruff_db::source::{line_index, source_text, SourceText}; +use ruff_python_trivia::CommentRanges; +use ruff_source_file::{LineIndex, Locator, OneIndexed}; +use ruff_text_size::{Ranged, TextRange}; +use smallvec::SmallVec; +use std::ops::Deref; + +/// Diagnostic assertion comments in a single embedded file. +#[derive(Debug)] +pub(crate) struct InlineFileAssertions { + comment_ranges: CommentRanges, + source: SourceText, + lines: LineIndex, +} + +impl InlineFileAssertions { + pub(crate) fn from_file(db: &Db, file: File) -> Self { + let source = source_text(db, file); + let lines = line_index(db, file); + let parsed = parsed_module(db, file); + let comment_ranges = CommentRanges::from(parsed.tokens()); + Self { + comment_ranges, + source, + lines, + } + } + + fn locator(&self) -> Locator { + Locator::with_index(&self.source, self.lines.clone()) + } + + fn line_number(&self, range: &impl Ranged) -> OneIndexed { + self.lines.line_index(range.start()) + } + + fn is_own_line_comment(&self, ranged_assertion: &AssertionWithRange) -> bool { + CommentRanges::is_own_line(ranged_assertion.start(), &self.locator()) + } +} + +impl<'a> IntoIterator for &'a InlineFileAssertions { + type Item = LineAssertions<'a>; + type IntoIter = LineAssertionsIterator<'a>; + + fn into_iter(self) -> Self::IntoIter { + Self::IntoIter { + file_assertions: self, + inner: AssertionWithRangeIterator { + file_assertions: self, + inner: self.comment_ranges.into_iter(), + } + .peekable(), + } + } +} + +/// An [`Assertion`] with the [`TextRange`] of its original inline comment. +#[derive(Debug)] +struct AssertionWithRange<'a>(Assertion<'a>, TextRange); + +impl<'a> Deref for AssertionWithRange<'a> { + type Target = Assertion<'a>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Ranged for AssertionWithRange<'_> { + fn range(&self) -> TextRange { + self.1 + } +} + +impl<'a> From> for Assertion<'a> { + fn from(value: AssertionWithRange<'a>) -> Self { + value.0 + } +} + +/// Iterator that yields all assertions within a single embedded Python file. +#[derive(Debug)] +struct AssertionWithRangeIterator<'a> { + file_assertions: &'a InlineFileAssertions, + inner: std::iter::Copied>, +} + +impl<'a> Iterator for AssertionWithRangeIterator<'a> { + type Item = AssertionWithRange<'a>; + + fn next(&mut self) -> Option { + let locator = self.file_assertions.locator(); + loop { + let inner_next = self.inner.next()?; + let comment = locator.slice(inner_next); + if let Some(assertion) = Assertion::from_comment(comment) { + return Some(AssertionWithRange(assertion, inner_next)); + }; + } + } +} + +impl std::iter::FusedIterator for AssertionWithRangeIterator<'_> {} + +/// A vector of [`Assertion`]s belonging to a single line. +/// +/// Most lines will have zero or one assertion, so we use a [`SmallVec`] optimized for a single +/// element to avoid most heap vector allocations. +type AssertionVec<'a> = SmallVec<[Assertion<'a>; 1]>; + +#[derive(Debug)] +pub(crate) struct LineAssertionsIterator<'a> { + file_assertions: &'a InlineFileAssertions, + inner: std::iter::Peekable>, +} + +impl<'a> Iterator for LineAssertionsIterator<'a> { + type Item = LineAssertions<'a>; + + fn next(&mut self) -> Option { + let file = self.file_assertions; + let ranged_assertion = self.inner.next()?; + let mut collector = AssertionVec::new(); + let mut line_number = file.line_number(&ranged_assertion); + // Collect all own-line comments on consecutive lines; these all apply to the same line of + // code. For example: + // + // ```py + // # error: [unbound-name] + // # revealed: Unbound + // reveal_type(x) + // ``` + // + if file.is_own_line_comment(&ranged_assertion) { + collector.push(ranged_assertion.into()); + let mut only_own_line = true; + while let Some(ranged_assertion) = self.inner.peek() { + let next_line_number = line_number.saturating_add(1); + if file.line_number(ranged_assertion) == next_line_number { + if !file.is_own_line_comment(ranged_assertion) { + only_own_line = false; + } + line_number = next_line_number; + collector.push(self.inner.next().unwrap().into()); + // If we see an end-of-line comment, it has to be the end of the stack, + // otherwise we'd botch this case, attributing all three errors to the `bar` + // line: + // + // ```py + // # error: + // foo # error: + // bar # error: + // ``` + // + if !only_own_line { + break; + } + } else { + break; + } + } + if only_own_line { + // The collected comments apply to the _next_ line in the code. + line_number = line_number.saturating_add(1); + } + } else { + // We have a line-trailing comment; it applies to its own line, and is not grouped. + collector.push(ranged_assertion.into()); + } + Some(LineAssertions { + line_number, + assertions: collector, + }) + } +} + +impl std::iter::FusedIterator for LineAssertionsIterator<'_> {} + +/// One or more assertions referring to the same line of code. +#[derive(Debug)] +pub(crate) struct LineAssertions<'a> { + /// The line these assertions refer to. + /// + /// Not necessarily the same line the assertion comment is located on; for an own-line comment, + /// it's the next non-assertion line. + pub(crate) line_number: OneIndexed, + + /// The assertions referring to this line. + pub(crate) assertions: AssertionVec<'a>, +} + +impl<'a> Deref for LineAssertions<'a> { + type Target = [Assertion<'a>]; + + fn deref(&self) -> &Self::Target { + &self.assertions + } +} + +static TYPE_RE: Lazy = + Lazy::new(|| Regex::new(r"^#\s*revealed:\s*(?.+?)\s*$").unwrap()); + +static ERROR_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"^#\s*error:(\s*(?\d+))?(\s*\[(?.+?)\])?(\s*"(?.+?)")?\s*$"#, + ) + .unwrap() +}); + +/// A single diagnostic assertion comment. +#[derive(Debug)] +pub(crate) enum Assertion<'a> { + /// A `revealed: ` assertion. + Revealed(&'a str), + + /// An `error: ` assertion. + Error(ErrorAssertion<'a>), +} + +impl<'a> Assertion<'a> { + fn from_comment(comment: &'a str) -> Option { + if let Some(caps) = TYPE_RE.captures(comment) { + Some(Self::Revealed(caps.name("ty_display").unwrap().as_str())) + } else { + ERROR_RE.captures(comment).map(|caps| { + Self::Error(ErrorAssertion { + rule: caps.name("rule").map(|m| m.as_str()), + column: caps.name("column").and_then(|m| m.as_str().parse().ok()), + message_contains: caps.name("message").map(|m| m.as_str()), + }) + }) + } + } +} + +impl std::fmt::Display for Assertion<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"), + Self::Error(assertion) => assertion.fmt(f), + } + } +} + +/// An `error: ` assertion comment. +#[derive(Debug)] +pub(crate) struct ErrorAssertion<'a> { + /// The diagnostic rule code we expect. + pub(crate) rule: Option<&'a str>, + + /// The column we expect the diagnostic range to start at. + pub(crate) column: Option, + + /// A string we expect to be contained in the diagnostic message. + pub(crate) message_contains: Option<&'a str>, +} + +impl std::fmt::Display for ErrorAssertion<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("error:")?; + if let Some(column) = self.column { + write!(f, " {column}")?; + } + if let Some(rule) = self.rule { + write!(f, " [{rule}]")?; + } + if let Some(message) = self.message_contains { + write!(f, r#" "{message}""#)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Assertion, InlineFileAssertions, LineAssertions}; + use ruff_db::files::system_path_to_file; + use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; + use ruff_python_trivia::textwrap::dedent; + use ruff_source_file::OneIndexed; + + fn get_assertions(source: &str) -> InlineFileAssertions { + let mut db = crate::db::Db::setup(SystemPathBuf::from("/src")); + db.write_file("/src/test.py", source).unwrap(); + let file = system_path_to_file(&db, "/src/test.py").unwrap(); + InlineFileAssertions::from_file(&db, file) + } + + fn as_vec(assertions: &InlineFileAssertions) -> Vec { + assertions.into_iter().collect() + } + + #[test] + fn ty_display() { + let assertions = get_assertions(&dedent( + " + reveal_type(1) # revealed: Literal[1] + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), "revealed: Literal[1]"); + } + + #[test] + fn error() { + let assertions = get_assertions(&dedent( + " + x # error: + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), "error:"); + } + + #[test] + fn prior_line() { + let assertions = get_assertions(&dedent( + " + # revealed: Literal[1] + reveal_type(1) + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), "revealed: Literal[1]"); + } + + #[test] + fn stacked_prior_line() { + let assertions = get_assertions(&dedent( + " + # revealed: Unbound + # error: [unbound-name] + reveal_type(x) + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(3)); + + let [assert1, assert2] = &line.assertions[..] else { + panic!("expected two assertions"); + }; + + assert_eq!(format!("{assert1}"), "revealed: Unbound"); + assert_eq!(format!("{assert2}"), "error: [unbound-name]"); + } + + #[test] + fn stacked_mixed() { + let assertions = get_assertions(&dedent( + " + # revealed: Unbound + reveal_type(x) # error: [unbound-name] + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + + let [assert1, assert2] = &line.assertions[..] else { + panic!("expected two assertions"); + }; + + assert_eq!(format!("{assert1}"), "revealed: Unbound"); + assert_eq!(format!("{assert2}"), "error: [unbound-name]"); + } + + #[test] + fn multiple_lines() { + let assertions = get_assertions(&dedent( + r#" + # error: [invalid-assignment] + x: int = "foo" + y # error: [unbound-name] + "#, + )); + + let [line1, line2] = &as_vec(&assertions)[..] else { + panic!("expected two lines"); + }; + + assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2)); + assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3)); + + let [Assertion::Error(error1)] = &line1.assertions[..] else { + panic!("expected one error assertion"); + }; + + assert_eq!(error1.rule, Some("invalid-assignment")); + + let [Assertion::Error(error2)] = &line2.assertions[..] else { + panic!("expected one error assertion"); + }; + + assert_eq!(error2.rule, Some("unbound-name")); + } + + #[test] + fn multiple_lines_mixed_stack() { + let assertions = get_assertions(&dedent( + r#" + # error: [invalid-assignment] + x: int = reveal_type("foo") # revealed: str + y # error: [unbound-name] + "#, + )); + + let [line1, line2] = &as_vec(&assertions)[..] else { + panic!("expected two lines"); + }; + + assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2)); + assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3)); + + let [Assertion::Error(error1), Assertion::Revealed(expected_ty)] = &line1.assertions[..] + else { + panic!("expected one error assertion and one Revealed assertion"); + }; + + assert_eq!(error1.rule, Some("invalid-assignment")); + assert_eq!(*expected_ty, "str"); + + let [Assertion::Error(error2)] = &line2.assertions[..] else { + panic!("expected one error assertion"); + }; + + assert_eq!(error2.rule, Some("unbound-name")); + } + + #[test] + fn error_with_rule() { + let assertions = get_assertions(&dedent( + " + x # error: [unbound-name] + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), "error: [unbound-name]"); + } + + #[test] + fn error_with_rule_and_column() { + let assertions = get_assertions(&dedent( + " + x # error: 1 [unbound-name] + ", + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), "error: 1 [unbound-name]"); + } + + #[test] + fn error_with_rule_and_message() { + let assertions = get_assertions(&dedent( + r#" + # error: [unbound-name] "`x` is unbound" + x + "#, + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!( + format!("{assert}"), + r#"error: [unbound-name] "`x` is unbound""# + ); + } + + #[test] + fn error_with_message_and_column() { + let assertions = get_assertions(&dedent( + r#" + # error: 1 "`x` is unbound" + x + "#, + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!(format!("{assert}"), r#"error: 1 "`x` is unbound""#); + } + + #[test] + fn error_with_rule_and_message_and_column() { + let assertions = get_assertions(&dedent( + r#" + # error: 1 [unbound-name] "`x` is unbound" + x + "#, + )); + + let [line] = &as_vec(&assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + + let [assert] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + + assert_eq!( + format!("{assert}"), + r#"error: 1 [unbound-name] "`x` is unbound""# + ); + } +} diff --git a/crates/red_knot_test/src/db.rs b/crates/red_knot_test/src/db.rs new file mode 100644 index 0000000000000..5787942d7f73e --- /dev/null +++ b/crates/red_knot_test/src/db.rs @@ -0,0 +1,88 @@ +use red_knot_python_semantic::{ + Db as SemanticDb, Program, ProgramSettings, PythonVersion, SearchPathSettings, +}; +use ruff_db::files::{File, Files}; +use ruff_db::system::SystemPathBuf; +use ruff_db::system::{DbWithTestSystem, System, TestSystem}; +use ruff_db::vendored::VendoredFileSystem; +use ruff_db::{Db as SourceDb, Upcast}; + +#[salsa::db] +pub(crate) struct Db { + storage: salsa::Storage, + files: Files, + system: TestSystem, + vendored: VendoredFileSystem, +} + +impl Db { + pub(crate) fn setup(workspace_root: SystemPathBuf) -> Self { + let db = Self { + storage: salsa::Storage::default(), + system: TestSystem::default(), + vendored: red_knot_vendored::file_system().clone(), + files: Files::default(), + }; + + db.memory_file_system() + .create_directory_all(&workspace_root) + .unwrap(); + + Program::from_settings( + &db, + &ProgramSettings { + target_version: PythonVersion::default(), + search_paths: SearchPathSettings::new(workspace_root), + }, + ) + .expect("Invalid search path settings"); + + db + } +} + +impl DbWithTestSystem for Db { + fn test_system(&self) -> &TestSystem { + &self.system + } + + fn test_system_mut(&mut self) -> &mut TestSystem { + &mut self.system + } +} + +#[salsa::db] +impl SourceDb for Db { + fn vendored(&self) -> &VendoredFileSystem { + &self.vendored + } + + fn system(&self) -> &dyn System { + &self.system + } + + fn files(&self) -> &Files { + &self.files + } +} + +impl Upcast for Db { + fn upcast(&self) -> &(dyn SourceDb + 'static) { + self + } + fn upcast_mut(&mut self) -> &mut (dyn SourceDb + 'static) { + self + } +} + +#[salsa::db] +impl SemanticDb for Db { + fn is_file_open(&self, file: File) -> bool { + !file.path(self).is_vendored_path() + } +} + +#[salsa::db] +impl salsa::Database for Db { + fn salsa_event(&self, _event: &dyn Fn() -> salsa::Event) {} +} diff --git a/crates/red_knot_test/src/diagnostic.rs b/crates/red_knot_test/src/diagnostic.rs new file mode 100644 index 0000000000000..56e4a87906c57 --- /dev/null +++ b/crates/red_knot_test/src/diagnostic.rs @@ -0,0 +1,173 @@ +//! Sort and group diagnostics by line number, so they can be correlated with assertions. +//! +//! We don't assume that we will get the diagnostics in source order. + +use ruff_source_file::{LineIndex, OneIndexed}; +use ruff_text_size::Ranged; +use std::ops::{Deref, Range}; + +/// All diagnostics for one embedded Python file, sorted and grouped by start line number. +/// +/// The diagnostics are kept in a flat vector, sorted by line number. A separate vector of +/// [`LineDiagnosticRange`] has one entry for each contiguous slice of the diagnostics vector +/// containing diagnostics which all start on the same line. +#[derive(Debug)] +pub(crate) struct SortedDiagnostics { + diagnostics: Vec, + line_ranges: Vec, +} + +impl SortedDiagnostics +where + T: Ranged + Clone, +{ + pub(crate) fn new(diagnostics: impl IntoIterator, line_index: &LineIndex) -> Self { + let mut diagnostics: Vec<_> = diagnostics + .into_iter() + .map(|diagnostic| DiagnosticWithLine { + line_number: line_index.line_index(diagnostic.start()), + diagnostic, + }) + .collect(); + diagnostics.sort_unstable_by_key(|diagnostic_with_line| diagnostic_with_line.line_number); + + let mut diags = Self { + diagnostics: Vec::with_capacity(diagnostics.len()), + line_ranges: vec![], + }; + + let mut current_line_number = None; + let mut start = 0; + for DiagnosticWithLine { + line_number, + diagnostic, + } in diagnostics + { + match current_line_number { + None => { + current_line_number = Some(line_number); + } + Some(current) => { + if line_number != current { + let end = diags.diagnostics.len(); + diags.line_ranges.push(LineDiagnosticRange { + line_number: current, + diagnostic_index_range: start..end, + }); + start = end; + current_line_number = Some(line_number); + } + } + } + diags.diagnostics.push(diagnostic); + } + if let Some(line_number) = current_line_number { + diags.line_ranges.push(LineDiagnosticRange { + line_number, + diagnostic_index_range: start..diags.diagnostics.len(), + }); + } + + diags + } + + pub(crate) fn iter_lines(&self) -> LineDiagnosticsIterator { + LineDiagnosticsIterator { + diagnostics: self.diagnostics.as_slice(), + inner: self.line_ranges.iter(), + } + } +} + +/// Range delineating diagnostics in [`SortedDiagnostics`] that begin on a single line. +#[derive(Debug)] +struct LineDiagnosticRange { + line_number: OneIndexed, + diagnostic_index_range: Range, +} + +/// Iterator to group sorted diagnostics by line. +pub(crate) struct LineDiagnosticsIterator<'a, T> { + diagnostics: &'a [T], + inner: std::slice::Iter<'a, LineDiagnosticRange>, +} + +impl<'a, T> Iterator for LineDiagnosticsIterator<'a, T> +where + T: Ranged + Clone, +{ + type Item = LineDiagnostics<'a, T>; + + fn next(&mut self) -> Option { + let LineDiagnosticRange { + line_number, + diagnostic_index_range, + } = self.inner.next()?; + Some(LineDiagnostics { + line_number: *line_number, + diagnostics: &self.diagnostics[diagnostic_index_range.clone()], + }) + } +} + +impl std::iter::FusedIterator for LineDiagnosticsIterator<'_, T> where T: Clone + Ranged {} + +/// All diagnostics that start on a single line of source code in one embedded Python file. +#[derive(Debug)] +pub(crate) struct LineDiagnostics<'a, T> { + /// Line number on which these diagnostics start. + pub(crate) line_number: OneIndexed, + + /// Diagnostics starting on this line. + pub(crate) diagnostics: &'a [T], +} + +impl Deref for LineDiagnostics<'_, T> { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.diagnostics + } +} + +#[derive(Debug)] +struct DiagnosticWithLine { + line_number: OneIndexed, + diagnostic: T, +} + +#[cfg(test)] +mod tests { + use crate::db::Db; + use ruff_db::files::system_path_to_file; + use ruff_db::source::line_index; + use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; + use ruff_source_file::OneIndexed; + use ruff_text_size::{TextRange, TextSize}; + + #[test] + fn sort_and_group() { + let mut db = Db::setup(SystemPathBuf::from("/src")); + db.write_file("/src/test.py", "one\ntwo\n").unwrap(); + let file = system_path_to_file(&db, "/src/test.py").unwrap(); + let lines = line_index(&db, file); + + let ranges = vec![ + TextRange::new(TextSize::new(0), TextSize::new(1)), + TextRange::new(TextSize::new(5), TextSize::new(10)), + TextRange::new(TextSize::new(1), TextSize::new(7)), + ]; + + let sorted = super::SortedDiagnostics::new(&ranges, &lines); + let grouped = sorted.iter_lines().collect::>(); + + let [line1, line2] = &grouped[..] else { + panic!("expected two lines"); + }; + + assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(0)); + assert_eq!(line1.diagnostics.len(), 2); + assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(1)); + assert_eq!(line2.diagnostics.len(), 1); + } +} diff --git a/crates/red_knot_test/src/lib.rs b/crates/red_knot_test/src/lib.rs new file mode 100644 index 0000000000000..2d05e6283ab01 --- /dev/null +++ b/crates/red_knot_test/src/lib.rs @@ -0,0 +1,91 @@ +use parser as test_parser; +use red_knot_python_semantic::types::check_types; +use ruff_db::files::system_path_to_file; +use ruff_db::parsed::parsed_module; +use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +type Failures = BTreeMap; + +mod assertion; +mod db; +mod diagnostic; +mod matcher; +mod parser; + +/// Run `path` as a markdown test suite with given `title`. +/// +/// Panic on test failure, and print failure details. +#[allow(clippy::print_stdout)] +pub fn run(path: &PathBuf, title: &str) { + let source = std::fs::read_to_string(path).unwrap(); + let suite = match test_parser::parse(title, &source) { + Ok(suite) => suite, + Err(err) => { + panic!("Error parsing `{}`: {err}", path.to_str().unwrap()) + } + }; + + let mut any_failures = false; + for test in suite.tests() { + if let Err(failures) = run_test(&test) { + any_failures = true; + println!("{}", test.name()); + + for (path, by_line) in failures { + println!(" {path}"); + for (line, failures) in by_line.iter() { + for failure in failures { + println!(" line {line}: {failure}"); + } + } + println!(); + } + } + } + + assert!(!any_failures, "Some tests failed."); +} + +fn run_test(test: &parser::MarkdownTest) -> Result<(), Failures> { + let workspace_root = SystemPathBuf::from("/src"); + let mut db = db::Db::setup(workspace_root.clone()); + + let mut system_paths = vec![]; + + for file in test.files() { + assert!( + matches!(file.lang, "py" | "pyi"), + "Non-Python files not supported yet." + ); + let full_path = workspace_root.join(file.path); + db.write_file(&full_path, file.code).unwrap(); + system_paths.push(full_path); + } + + let mut failures = BTreeMap::default(); + + for path in system_paths { + let file = system_path_to_file(&db, path.clone()).unwrap(); + let parsed = parsed_module(&db, file); + + // TODO allow testing against code with syntax errors + assert!( + parsed.errors().is_empty(), + "Python syntax errors in {}, {:?}: {:?}", + test.name(), + path, + parsed.errors() + ); + + matcher::match_file(&db, file, check_types(&db, file)).unwrap_or_else(|line_failures| { + failures.insert(path, line_failures); + }); + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } +} diff --git a/crates/red_knot_test/src/matcher.rs b/crates/red_knot_test/src/matcher.rs new file mode 100644 index 0000000000000..7cd9604db40ca --- /dev/null +++ b/crates/red_knot_test/src/matcher.rs @@ -0,0 +1,789 @@ +//! Match [`TypeCheckDiagnostic`]s against [`Assertion`]s and produce test failure messages for any +//! mismatches. +use crate::assertion::{Assertion, InlineFileAssertions}; +use crate::db::Db; +use crate::diagnostic::SortedDiagnostics; +use red_knot_python_semantic::types::TypeCheckDiagnostic; +use ruff_db::files::File; +use ruff_db::source::{line_index, source_text, SourceText}; +use ruff_source_file::{LineIndex, OneIndexed}; +use ruff_text_size::Ranged; +use std::cmp::Ordering; +use std::ops::Range; +use std::sync::Arc; + +#[derive(Debug, Default)] +pub(super) struct FailuresByLine { + failures: Vec, + lines: Vec, +} + +impl FailuresByLine { + pub(super) fn iter(&self) -> impl Iterator { + self.lines.iter().map(|line_failures| { + ( + line_failures.line_number, + &self.failures[line_failures.range.clone()], + ) + }) + } + + fn push(&mut self, line_number: OneIndexed, messages: Vec) { + let start = self.failures.len(); + self.failures.extend(messages); + self.lines.push(LineFailures { + line_number, + range: start..self.failures.len(), + }); + } + + fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +#[derive(Debug)] +struct LineFailures { + line_number: OneIndexed, + range: Range, +} + +pub(super) fn match_file( + db: &Db, + file: File, + diagnostics: impl IntoIterator, +) -> Result<(), FailuresByLine> +where + T: Diagnostic + Clone, +{ + // Parse assertions from comments in the file, and get diagnostics from the file; both + // ordered by line number. + let assertions = InlineFileAssertions::from_file(db, file); + let diagnostics = SortedDiagnostics::new(diagnostics, &line_index(db, file)); + + // Get iterators over assertions and diagnostics grouped by line, in ascending line order. + let mut line_assertions = assertions.into_iter(); + let mut line_diagnostics = diagnostics.iter_lines(); + + let mut current_assertions = line_assertions.next(); + let mut current_diagnostics = line_diagnostics.next(); + + let matcher = Matcher::from_file(db, file); + let mut failures = FailuresByLine::default(); + + loop { + match (¤t_assertions, ¤t_diagnostics) { + (Some(assertions), Some(diagnostics)) => { + match assertions.line_number.cmp(&diagnostics.line_number) { + Ordering::Equal => { + // We have assertions and diagnostics on the same line; check for + // matches and error on any that don't match, then advance both + // iterators. + matcher + .match_line(diagnostics, assertions) + .unwrap_or_else(|messages| { + failures.push(assertions.line_number, messages); + }); + current_assertions = line_assertions.next(); + current_diagnostics = line_diagnostics.next(); + } + Ordering::Less => { + // We have assertions on an earlier line than diagnostics; report these + // assertions as all unmatched, and advance the assertions iterator. + failures.push(assertions.line_number, unmatched(assertions)); + current_assertions = line_assertions.next(); + } + Ordering::Greater => { + // We have diagnostics on an earlier line than assertions; report these + // diagnostics as all unmatched, and advance the diagnostics iterator. + failures.push(diagnostics.line_number, unmatched(diagnostics)); + current_diagnostics = line_diagnostics.next(); + } + } + } + (Some(assertions), None) => { + // We've exhausted diagnostics but still have assertions; report these assertions + // as unmatched and advance the assertions iterator. + failures.push(assertions.line_number, unmatched(assertions)); + current_assertions = line_assertions.next(); + } + (None, Some(diagnostics)) => { + // We've exhausted assertions but still have diagnostics; report these + // diagnostics as unmatched and advance the diagnostics iterator. + failures.push(diagnostics.line_number, unmatched(diagnostics)); + current_diagnostics = line_diagnostics.next(); + } + // When we've exhausted both diagnostics and assertions, break. + (None, None) => break, + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } +} + +pub(super) trait Diagnostic: Ranged { + fn rule(&self) -> &str; + + fn message(&self) -> &str; +} + +impl Diagnostic for Arc { + fn rule(&self) -> &str { + self.as_ref().rule() + } + + fn message(&self) -> &str { + self.as_ref().message() + } +} + +trait Unmatched { + fn unmatched(&self) -> String; +} + +impl Unmatched for T +where + T: Diagnostic, +{ + fn unmatched(&self) -> String { + format!( + r#"unexpected error: [{}] "{}""#, + self.rule(), + self.message() + ) + } +} + +impl Unmatched for Assertion<'_> { + fn unmatched(&self) -> String { + format!("unmatched assertion: {self}") + } +} + +fn unmatched<'a, T: Unmatched + 'a>(unmatched: &'a [T]) -> Vec { + unmatched.iter().map(Unmatched::unmatched).collect() +} + +struct Matcher { + line_index: LineIndex, + source: SourceText, +} + +impl Matcher { + fn from_file(db: &Db, file: File) -> Self { + Self { + line_index: line_index(db, file), + source: source_text(db, file), + } + } + + /// Check a slice of [`Diagnostic`]s against a slice of [`Assertion`]s. + /// + /// Return vector of [`Unmatched`] for any unmatched diagnostics or assertions. + fn match_line<'a, 'b, T: Diagnostic + 'a>( + &self, + diagnostics: &'a [T], + assertions: &'a [Assertion<'b>], + ) -> Result<(), Vec> + where + 'b: 'a, + { + let mut failures = vec![]; + let mut unmatched: Vec<_> = diagnostics.iter().collect(); + for assertion in assertions { + if !self.matches(assertion, &mut unmatched) { + failures.push(assertion.unmatched()); + } + } + for diagnostic in unmatched { + failures.push(diagnostic.unmatched()); + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } + } + + fn column(&self, ranged: &T) -> OneIndexed { + self.line_index + .source_location(ranged.start(), &self.source) + .column + } + + /// Check if `assertion` matches any [`Diagnostic`]s in `unmatched`. + /// + /// If so, return `true` and remove the matched diagnostics from `unmatched`. Otherwise, return + /// `false`. + /// + /// An `Error` assertion can only match one diagnostic; even if it could match more than one, + /// we short-circuit after the first match. + /// + /// A `Revealed` assertion must match a revealed-type diagnostic, and may also match an + /// undefined-reveal diagnostic, if present. + fn matches(&self, assertion: &Assertion, unmatched: &mut Vec<&T>) -> bool { + match assertion { + Assertion::Error(error) => { + let position = unmatched.iter().position(|diagnostic| { + !error.rule.is_some_and(|rule| rule != diagnostic.rule()) + && !error + .column + .is_some_and(|col| col != self.column(*diagnostic)) + && !error + .message_contains + .is_some_and(|needle| !diagnostic.message().contains(needle)) + }); + if let Some(position) = position { + unmatched.swap_remove(position); + true + } else { + false + } + } + Assertion::Revealed(expected_type) => { + let mut matched_revealed_type = None; + let mut matched_undefined_reveal = None; + let expected_reveal_type_message = format!("Revealed type is `{expected_type}`"); + for (index, diagnostic) in unmatched.iter().enumerate() { + if matched_revealed_type.is_none() + && diagnostic.rule() == "revealed-type" + && diagnostic.message() == expected_reveal_type_message + { + matched_revealed_type = Some(index); + } else if matched_undefined_reveal.is_none() + && diagnostic.rule() == "undefined-reveal" + { + matched_undefined_reveal = Some(index); + } + if matched_revealed_type.is_some() && matched_undefined_reveal.is_some() { + break; + } + } + if matched_revealed_type.is_some() { + let mut idx = 0; + unmatched.retain(|_| { + let retain = Some(idx) != matched_revealed_type + && Some(idx) != matched_undefined_reveal; + idx += 1; + retain + }); + true + } else { + false + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::FailuresByLine; + use ruff_db::files::system_path_to_file; + use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; + use ruff_source_file::OneIndexed; + use ruff_text_size::{Ranged, TextRange}; + + #[derive(Clone, Debug)] + struct TestDiagnostic { + rule: &'static str, + message: &'static str, + range: TextRange, + } + + impl TestDiagnostic { + fn new(rule: &'static str, message: &'static str, offset: usize) -> Self { + let offset: u32 = offset.try_into().unwrap(); + Self { + rule, + message, + range: TextRange::new(offset.into(), (offset + 1).into()), + } + } + } + + impl super::Diagnostic for TestDiagnostic { + fn rule(&self) -> &str { + self.rule + } + + fn message(&self) -> &str { + self.message + } + } + + impl Ranged for TestDiagnostic { + fn range(&self) -> ruff_text_size::TextRange { + self.range + } + } + + fn get_result(source: &str, diagnostics: Vec) -> Result<(), FailuresByLine> { + let mut db = crate::db::Db::setup(SystemPathBuf::from("/src")); + db.write_file("/src/test.py", source).unwrap(); + let file = system_path_to_file(&db, "/src/test.py").unwrap(); + + super::match_file(&db, file, diagnostics) + } + + fn assert_fail(result: Result<(), FailuresByLine>, messages: &[(usize, &[&str])]) { + let Err(failures) = result else { + panic!("expected a failure"); + }; + + let expected: Vec<(OneIndexed, Vec)> = messages + .iter() + .map(|(idx, msgs)| { + ( + OneIndexed::from_zero_indexed(*idx), + msgs.iter().map(ToString::to_string).collect(), + ) + }) + .collect(); + let failures: Vec<(OneIndexed, Vec)> = failures + .iter() + .map(|(idx, msgs)| (idx, msgs.to_vec())) + .collect(); + + assert_eq!(failures, expected); + } + + fn assert_ok(result: &Result<(), FailuresByLine>) { + assert!(result.is_ok(), "{result:?}"); + } + + #[test] + fn type_match() { + let result = get_result( + "x # revealed: Foo", + vec![TestDiagnostic::new( + "revealed-type", + "Revealed type is `Foo`", + 0, + )], + ); + + assert_ok(&result); + } + + #[test] + fn type_wrong_rule() { + let result = get_result( + "x # revealed: Foo", + vec![TestDiagnostic::new( + "not-revealed-type", + "Revealed type is `Foo`", + 0, + )], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: revealed: Foo", + r#"unexpected error: [not-revealed-type] "Revealed type is `Foo`""#, + ], + )], + ); + } + + #[test] + fn type_wrong_message() { + let result = get_result( + "x # revealed: Foo", + vec![TestDiagnostic::new("revealed-type", "Something else", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: revealed: Foo", + r#"unexpected error: [revealed-type] "Something else""#, + ], + )], + ); + } + + #[test] + fn type_unmatched() { + let result = get_result("x # revealed: Foo", vec![]); + + assert_fail(result, &[(0, &["unmatched assertion: revealed: Foo"])]); + } + + #[test] + fn type_match_with_undefined() { + let result = get_result( + "x # revealed: Foo", + vec![ + TestDiagnostic::new("revealed-type", "Revealed type is `Foo`", 0), + TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0), + ], + ); + + assert_ok(&result); + } + + #[test] + fn type_match_with_only_undefined() { + let result = get_result( + "x # revealed: Foo", + vec![TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: revealed: Foo", + r#"unexpected error: [undefined-reveal] "Doesn't matter""#, + ], + )], + ); + } + + #[test] + fn error_match() { + let result = get_result( + "x # error:", + vec![TestDiagnostic::new("anything", "Any message", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_unmatched() { + let result = get_result("x # error:", vec![]); + + assert_fail(result, &[(0, &["unmatched assertion: error:"])]); + } + + #[test] + fn error_match_column() { + let result = get_result( + "x # error: 1", + vec![TestDiagnostic::new("anything", "Any message", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_wrong_column() { + let result = get_result( + "x # error: 2", + vec![TestDiagnostic::new("anything", "Any message", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: error: 2", + r#"unexpected error: [anything] "Any message""#, + ], + )], + ); + } + + #[test] + fn error_match_rule() { + let result = get_result( + "x # error: [some-rule]", + vec![TestDiagnostic::new("some-rule", "Any message", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_wrong_rule() { + let result = get_result( + "x # error: [some-rule]", + vec![TestDiagnostic::new("anything", "Any message", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: error: [some-rule]", + r#"unexpected error: [anything] "Any message""#, + ], + )], + ); + } + + #[test] + fn error_match_message() { + let result = get_result( + r#"x # error: "contains this""#, + vec![TestDiagnostic::new("anything", "message contains this", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_wrong_message() { + let result = get_result( + r#"x # error: "contains this""#, + vec![TestDiagnostic::new("anything", "Any message", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + r#"unmatched assertion: error: "contains this""#, + r#"unexpected error: [anything] "Any message""#, + ], + )], + ); + } + + #[test] + fn error_match_column_and_rule() { + let result = get_result( + "x # error: 1 [some-rule]", + vec![TestDiagnostic::new("some-rule", "Any message", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_match_column_and_message() { + let result = get_result( + r#"x # error: 1 "contains this""#, + vec![TestDiagnostic::new("anything", "message contains this", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_match_rule_and_message() { + let result = get_result( + r#"x # error: [a-rule] "contains this""#, + vec![TestDiagnostic::new("a-rule", "message contains this", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_match_all() { + let result = get_result( + r#"x # error: 1 [a-rule] "contains this""#, + vec![TestDiagnostic::new("a-rule", "message contains this", 0)], + ); + + assert_ok(&result); + } + + #[test] + fn error_match_all_wrong_column() { + let result = get_result( + r#"x # error: 2 [some-rule] "contains this""#, + vec![TestDiagnostic::new("some-rule", "message contains this", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + r#"unmatched assertion: error: 2 [some-rule] "contains this""#, + r#"unexpected error: [some-rule] "message contains this""#, + ], + )], + ); + } + + #[test] + fn error_match_all_wrong_rule() { + let result = get_result( + r#"x # error: 1 [some-rule] "contains this""#, + vec![TestDiagnostic::new( + "other-rule", + "message contains this", + 0, + )], + ); + + assert_fail( + result, + &[( + 0, + &[ + r#"unmatched assertion: error: 1 [some-rule] "contains this""#, + r#"unexpected error: [other-rule] "message contains this""#, + ], + )], + ); + } + + #[test] + fn error_match_all_wrong_message() { + let result = get_result( + r#"x # error: 1 [some-rule] "contains this""#, + vec![TestDiagnostic::new("some-rule", "Any message", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + r#"unmatched assertion: error: 1 [some-rule] "contains this""#, + r#"unexpected error: [some-rule] "Any message""#, + ], + )], + ); + } + + #[test] + fn interspersed_matches_and_mismatches() { + let source = r#" + 1 # error: [line-one] + 2 + 3 # error: [line-three] + 4 # error: [line-four] + 5 + 6: # error: [line-six] + "#; + let two = source.find('2').unwrap(); + let three = source.find('3').unwrap(); + let five = source.find('5').unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("line-two", "msg", two), + TestDiagnostic::new("line-three", "msg", three), + TestDiagnostic::new("line-five", "msg", five), + ], + ); + + assert_fail( + result, + &[ + (1, &["unmatched assertion: error: [line-one]"]), + (2, &[r#"unexpected error: [line-two] "msg""#]), + (4, &["unmatched assertion: error: [line-four]"]), + (5, &[r#"unexpected error: [line-five] "msg""#]), + (6, &["unmatched assertion: error: [line-six]"]), + ], + ); + } + + #[test] + fn more_diagnostics_than_assertions() { + let source = r#" + 1 # error: [line-one] + 2 + "#; + let one = source.find('1').unwrap(); + let two = source.find('2').unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("line-one", "msg", one), + TestDiagnostic::new("line-two", "msg", two), + ], + ); + + assert_fail(result, &[(2, &[r#"unexpected error: [line-two] "msg""#])]); + } + + #[test] + fn multiple_assertions_and_diagnostics_same_line() { + let source = " + # error: [one-rule] + # error: [other-rule] + x + "; + let x = source.find('x').unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("one-rule", "msg", x), + TestDiagnostic::new("other-rule", "msg", x), + ], + ); + + assert_ok(&result); + } + + #[test] + fn multiple_assertions_and_diagnostics_same_line_all_same() { + let source = " + # error: [one-rule] + # error: [one-rule] + x + "; + let x = source.find('x').unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("one-rule", "msg", x), + TestDiagnostic::new("one-rule", "msg", x), + ], + ); + + assert_ok(&result); + } + + #[test] + fn multiple_assertions_and_diagnostics_same_line_mismatch() { + let source = " + # error: [one-rule] + # error: [other-rule] + x + "; + let x = source.find('x').unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("one-rule", "msg", x), + TestDiagnostic::new("other-rule", "msg", x), + TestDiagnostic::new("third-rule", "msg", x), + ], + ); + + assert_fail(result, &[(3, &[r#"unexpected error: [third-rule] "msg""#])]); + } + + #[test] + fn parenthesized_expression() { + let source = " + a = b + ( + error: [undefined-reveal] + reveal_type(5) # revealed: Literal[5] + ) + "; + let reveal = source.find("reveal_type").unwrap(); + let result = get_result( + source, + vec![ + TestDiagnostic::new("undefined-reveal", "msg", reveal), + TestDiagnostic::new("revealed-type", "Revealed type is `Literal[5]`", reveal), + ], + ); + + assert_ok(&result); + } +} diff --git a/crates/red_knot_test/src/parser.rs b/crates/red_knot_test/src/parser.rs new file mode 100644 index 0000000000000..17a24d9beaf48 --- /dev/null +++ b/crates/red_knot_test/src/parser.rs @@ -0,0 +1,576 @@ +use once_cell::sync::Lazy; +use regex::{Captures, Regex}; +use ruff_index::{newtype_index, IndexVec}; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Parse the Markdown `source` as a test suite with given `title`. +pub(crate) fn parse<'s>(title: &'s str, source: &'s str) -> anyhow::Result> { + let parser = Parser::new(title, source); + parser.parse() +} + +/// A parsed markdown file containing tests. +/// +/// Borrows from the source string and filepath it was created from. +#[derive(Debug)] +pub(crate) struct MarkdownTestSuite<'s> { + /// Header sections. + sections: IndexVec>, + + /// Test files embedded within the Markdown file. + files: IndexVec>, +} + +impl<'s> MarkdownTestSuite<'s> { + pub(crate) fn tests(&self) -> MarkdownTestIterator<'_, 's> { + MarkdownTestIterator { + suite: self, + current_file_index: 0, + } + } +} + +/// A single test inside a [`MarkdownTestSuite`]. +/// +/// A test is a single header section (or the implicit root section, if there are no Markdown +/// headers in the file), containing one or more embedded Python files as fenced code blocks, and +/// containing no nested header subsections. +#[derive(Debug)] +pub(crate) struct MarkdownTest<'m, 's> { + suite: &'m MarkdownTestSuite<'s>, + section: &'m Section<'s>, + files: &'m [EmbeddedFile<'s>], +} + +impl<'m, 's> MarkdownTest<'m, 's> { + pub(crate) fn name(&self) -> String { + let mut name = String::new(); + let mut parent_id = self.section.parent_id; + while let Some(next_id) = parent_id { + let parent = &self.suite.sections[next_id]; + parent_id = parent.parent_id; + if !name.is_empty() { + name.insert_str(0, " - "); + } + name.insert_str(0, parent.title); + } + if !name.is_empty() { + name.push_str(" - "); + } + name.push_str(self.section.title); + name + } + + pub(crate) fn files(&self) -> impl Iterator> { + self.files.iter() + } +} + +/// Iterator yielding all [`MarkdownTest`]s in a [`MarkdownTestSuite`]. +#[derive(Debug)] +pub(crate) struct MarkdownTestIterator<'m, 's> { + suite: &'m MarkdownTestSuite<'s>, + current_file_index: usize, +} + +impl<'m, 's> Iterator for MarkdownTestIterator<'m, 's> { + type Item = MarkdownTest<'m, 's>; + + fn next(&mut self) -> Option { + let mut current_file_index = self.current_file_index; + let mut file = self.suite.files.get(current_file_index.into()); + let section_id = file?.section; + while file.is_some_and(|file| file.section == section_id) { + current_file_index += 1; + file = self.suite.files.get(current_file_index.into()); + } + let files = &self.suite.files[EmbeddedFileId::from_usize(self.current_file_index) + ..EmbeddedFileId::from_usize(current_file_index)]; + self.current_file_index = current_file_index; + Some(MarkdownTest { + suite: self.suite, + section: &self.suite.sections[section_id], + files, + }) + } +} + +#[newtype_index] +struct SectionId; + +/// A single header section of a [`MarkdownTestSuite`], or the implicit root "section". +/// +/// A header section is the part of a Markdown file beginning with a `#`-prefixed header line, and +/// extending until the next header line at the same or higher outline level (that is, with the +/// same number or fewer `#` characters). +/// +/// A header section may either contain one or more embedded Python files (making it a +/// [`MarkdownTest`]), or it may contain nested sections (headers with more `#` characters), but +/// not both. +#[derive(Debug)] +struct Section<'s> { + title: &'s str, + level: u8, + parent_id: Option, +} + +#[newtype_index] +struct EmbeddedFileId; + +/// A single file embedded in a [`Section`] as a fenced code block. +/// +/// Currently must be a Python file (`py` language) or type stub (`pyi`). In the future we plan +/// support other kinds of files as well (TOML configuration, typeshed VERSIONS, `pth` files...). +/// +/// A Python embedded file makes its containing [`Section`] into a [`MarkdownTest`], and will be +/// type-checked and searched for inline-comment assertions to match against the diagnostics from +/// type checking. +#[derive(Debug)] +pub(crate) struct EmbeddedFile<'s> { + section: SectionId, + pub(crate) path: &'s str, + pub(crate) lang: &'s str, + pub(crate) code: &'s str, +} + +/// Matches an arbitrary amount of whitespace (including newlines), followed by a sequence of `#` +/// characters, followed by a title heading, followed by a newline. +static HEADER_RE: Lazy = + Lazy::new(|| Regex::new(r"^(\s*\n)*(?#+)\s+(?.+)\s*\n").unwrap()); + +/// Matches a code block fenced by triple backticks, possibly with language and `key=val` +/// configuration items following the opening backticks (in the "tag string" of the code block). +static CODE_RE: Lazy<Regex> = Lazy::new(|| { + Regex::new(r"^```(?<lang>\w+)(?<config>( +\S+)*)\s*\n(?<code>(.|\n)*?)\n```\s*\n").unwrap() +}); + +#[derive(Debug)] +struct SectionStack(Vec<SectionId>); + +impl SectionStack { + fn new(root_section_id: SectionId) -> Self { + Self(vec![root_section_id]) + } + + fn push(&mut self, section_id: SectionId) { + self.0.push(section_id); + } + + fn pop(&mut self) -> Option<SectionId> { + let popped = self.0.pop(); + debug_assert_ne!(popped, None, "Should never pop the implicit root section"); + debug_assert!( + !self.0.is_empty(), + "Should never pop the implicit root section" + ); + popped + } + + fn parent(&mut self) -> SectionId { + *self + .0 + .last() + .expect("Should never pop the implicit root section") + } +} + +/// Parse the source of a Markdown file into a [`MarkdownTestSuite`]. +#[derive(Debug)] +struct Parser<'s> { + /// [`Section`]s of the final [`MarkdownTestSuite`]. + sections: IndexVec<SectionId, Section<'s>>, + + /// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`]. + files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>, + + /// The unparsed remainder of the Markdown source. + unparsed: &'s str, + + /// Stack of ancestor sections. + stack: SectionStack, + + /// Names of embedded files in current active section. + current_section_files: Option<FxHashSet<&'s str>>, +} + +impl<'s> Parser<'s> { + fn new(title: &'s str, source: &'s str) -> Self { + let mut sections = IndexVec::default(); + let root_section_id = sections.push(Section { + title, + level: 0, + parent_id: None, + }); + Self { + sections, + files: IndexVec::default(), + unparsed: source, + stack: SectionStack::new(root_section_id), + current_section_files: None, + } + } + + fn parse(mut self) -> anyhow::Result<MarkdownTestSuite<'s>> { + self.parse_impl()?; + Ok(self.finish()) + } + + fn finish(mut self) -> MarkdownTestSuite<'s> { + self.sections.shrink_to_fit(); + self.files.shrink_to_fit(); + + MarkdownTestSuite { + sections: self.sections, + files: self.files, + } + } + + fn parse_impl(&mut self) -> anyhow::Result<()> { + while !self.unparsed.is_empty() { + if let Some(captures) = self.scan(&HEADER_RE) { + self.parse_header(&captures)?; + } else if let Some(captures) = self.scan(&CODE_RE) { + self.parse_code_block(&captures)?; + } else { + // ignore other Markdown syntax (paragraphs, etc) used as comments in the test + if let Some(next_newline) = self.unparsed.find('\n') { + (_, self.unparsed) = self.unparsed.split_at(next_newline + 1); + } else { + break; + } + } + } + + Ok(()) + } + + fn parse_header(&mut self, captures: &Captures<'s>) -> anyhow::Result<()> { + let header_level = captures["level"].len(); + self.pop_sections_to_level(header_level); + + let parent = self.stack.parent(); + + let section = Section { + // HEADER_RE can't match without a match for group 'title'. + title: captures.name("title").unwrap().into(), + level: header_level.try_into()?, + parent_id: Some(parent), + }; + + if self.current_section_files.is_some() { + return Err(anyhow::anyhow!( + "Header '{}' not valid inside a test case; parent '{}' has code files.", + section.title, + self.sections[parent].title, + )); + } + + let section_id = self.sections.push(section); + self.stack.push(section_id); + + self.current_section_files = None; + + Ok(()) + } + + fn parse_code_block(&mut self, captures: &Captures<'s>) -> anyhow::Result<()> { + // We never pop the implicit root section. + let parent = self.stack.parent(); + + let mut config: FxHashMap<&'s str, &'s str> = FxHashMap::default(); + + if let Some(config_match) = captures.name("config") { + for item in config_match.as_str().split_whitespace() { + let mut parts = item.split('='); + let key = parts.next().unwrap(); + let Some(val) = parts.next() else { + return Err(anyhow::anyhow!("Invalid config item `{}`.", item)); + }; + if parts.next().is_some() { + return Err(anyhow::anyhow!("Invalid config item `{}`.", item)); + } + if config.insert(key, val).is_some() { + return Err(anyhow::anyhow!("Duplicate config item `{}`.", item)); + } + } + } + + let path = config.get("path").copied().unwrap_or("test.py"); + + self.files.push(EmbeddedFile { + path, + section: parent, + // CODE_RE can't match without matches for 'lang' and 'code'. + lang: captures.name("lang").unwrap().into(), + code: captures.name("code").unwrap().into(), + }); + + if let Some(current_files) = &mut self.current_section_files { + if !current_files.insert(path) { + if path == "test.py" { + return Err(anyhow::anyhow!( + "Test `{}` has duplicate files named `{path}`. \ + (This is the default filename; \ + consider giving some files an explicit name with `path=...`.)", + self.sections[parent].title + )); + } + return Err(anyhow::anyhow!( + "Test `{}` has duplicate files named `{path}`.", + self.sections[parent].title + )); + }; + } else { + self.current_section_files = Some(FxHashSet::from_iter([path])); + } + + Ok(()) + } + + fn pop_sections_to_level(&mut self, level: usize) { + while level <= self.sections[self.stack.parent()].level.into() { + self.stack.pop(); + // We would have errored before pushing a child section if there were files, so we know + // no parent section can have files. + self.current_section_files = None; + } + } + + /// Get capture groups and advance cursor past match if unparsed text matches `pattern`. + fn scan(&mut self, pattern: &Regex) -> Option<Captures<'s>> { + if let Some(captures) = pattern.captures(self.unparsed) { + let (_, unparsed) = self.unparsed.split_at(captures.get(0).unwrap().end()); + self.unparsed = unparsed; + Some(captures) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use ruff_python_trivia::textwrap::dedent; + + #[test] + fn empty() { + let mf = super::parse("file.md", "").unwrap(); + + assert!(mf.tests().next().is_none()); + } + + #[test] + fn single_file_test() { + let source = dedent( + " + ```py + x = 1 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected one test"); + }; + + assert_eq!(test.name(), "file.md"); + + let [file] = test.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, "test.py"); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "x = 1"); + } + + #[test] + fn multiple_tests() { + let source = dedent( + " + # One + + ```py + x = 1 + ``` + + # Two + + ```py + y = 2 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test1, test2] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected two tests"); + }; + + assert_eq!(test1.name(), "file.md - One"); + assert_eq!(test2.name(), "file.md - Two"); + + let [file] = test1.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, "test.py"); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "x = 1"); + + let [file] = test2.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, "test.py"); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "y = 2"); + } + + #[test] + fn custom_file_path() { + let source = dedent( + " + ```py path=foo.py + x = 1 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected one test"); + }; + let [file] = test.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, "foo.py"); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "x = 1"); + } + + #[test] + fn multi_line_file() { + let source = dedent( + " + ```py + x = 1 + y = 2 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected one test"); + }; + let [file] = test.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.code, "x = 1\ny = 2"); + } + + #[test] + fn no_header_inside_test() { + let source = dedent( + " + # One + + ```py + x = 1 + ``` + + ## Two + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Header 'Two' not valid inside a test case; parent 'One' has code files." + ); + } + + #[test] + fn invalid_config_item_no_equals() { + let source = dedent( + " + ```py foo + x = 1 + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!(err.to_string(), "Invalid config item `foo`."); + } + + #[test] + fn invalid_config_item_too_many_equals() { + let source = dedent( + " + ```py foo=bar=baz + x = 1 + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!(err.to_string(), "Invalid config item `foo=bar=baz`."); + } + + #[test] + fn invalid_config_item_duplicate() { + let source = dedent( + " + ```py foo=bar foo=baz + x = 1 + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!(err.to_string(), "Duplicate config item `foo=baz`."); + } + + #[test] + fn no_duplicate_name_files_in_test() { + let source = dedent( + " + ```py + x = 1 + ``` + + ```py + y = 2 + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Test `file.md` has duplicate files named `test.py`. \ + (This is the default filename; consider giving some files an explicit name \ + with `path=...`.)" + ); + } + + #[test] + fn no_duplicate_name_files_in_test_non_default() { + let source = dedent( + " + ```py path=foo.py + x = 1 + ``` + + ```py path=foo.py + y = 2 + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Test `file.md` has duplicate files named `foo.py`." + ); + } +} diff --git a/crates/ruff_python_trivia/src/comment_ranges.rs b/crates/ruff_python_trivia/src/comment_ranges.rs index e54ea44016443..673a4aefd6d36 100644 --- a/crates/ruff_python_trivia/src/comment_ranges.rs +++ b/crates/ruff_python_trivia/src/comment_ranges.rs @@ -194,7 +194,7 @@ impl CommentRanges { } /// Returns `true` if a comment is an own-line comment (as opposed to an end-of-line comment). - fn is_own_line(offset: TextSize, locator: &Locator) -> bool { + pub fn is_own_line(offset: TextSize, locator: &Locator) -> bool { let range = TextRange::new(locator.line_start(offset), offset); locator.slice(range).chars().all(is_python_whitespace) } diff --git a/crates/ruff_text_size/src/traits.rs b/crates/ruff_text_size/src/traits.rs index cec7d72190dab..a17bb07112398 100644 --- a/crates/ruff_text_size/src/traits.rs +++ b/crates/ruff_text_size/src/traits.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use {crate::TextRange, crate::TextSize, std::convert::TryInto}; use priv_in_pub::Sealed; @@ -66,3 +67,12 @@ where T::range(self) } } + +impl<T> Ranged for Arc<T> +where + T: Ranged, +{ + fn range(&self) -> TextRange { + T::range(self) + } +} From b9827a41221fdd86d026f07b612c45b0971923c1 Mon Sep 17 00:00:00 2001 From: Micha Reiser <micha@reiser.io> Date: Wed, 9 Oct 2024 08:25:40 +0200 Subject: [PATCH 52/88] Remove layout values from `AnyStringPart` (#13681) --- .../src/other/bytes_literal.rs | 1 - .../src/other/f_string.rs | 4 +- .../ruff_python_formatter/src/string/any.rs | 77 ++++++------------- .../ruff_python_formatter/src/string/mod.rs | 23 +++++- .../src/string/normalize.rs | 2 +- 5 files changed, 45 insertions(+), 62 deletions(-) diff --git a/crates/ruff_python_formatter/src/other/bytes_literal.rs b/crates/ruff_python_formatter/src/other/bytes_literal.rs index bb14111a033bb..7818ced154375 100644 --- a/crates/ruff_python_formatter/src/other/bytes_literal.rs +++ b/crates/ruff_python_formatter/src/other/bytes_literal.rs @@ -9,7 +9,6 @@ pub struct FormatBytesLiteral; impl FormatNodeRule<BytesLiteral> for FormatBytesLiteral { fn fmt_fields(&self, item: &BytesLiteral, f: &mut PyFormatter) -> FormatResult<()> { StringNormalizer::from_context(f.context()) - .with_preferred_quote_style(f.options().quote_style()) .normalize(item.into()) .fmt(f) } diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 6f46176a27888..9202ea94aab20 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -40,9 +40,7 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> { self.quoting }; - let normalizer = StringNormalizer::from_context(f.context()) - .with_quoting(quoting) - .with_preferred_quote_style(f.options().quote_style()); + let normalizer = StringNormalizer::from_context(f.context()).with_quoting(quoting); // If f-string formatting is disabled (not in preview), then we will // fall back to the previous behavior of normalizing the f-string. diff --git a/crates/ruff_python_formatter/src/string/any.rs b/crates/ruff_python_formatter/src/string/any.rs index 0341715ac09f6..50bab2ce04594 100644 --- a/crates/ruff_python_formatter/src/string/any.rs +++ b/crates/ruff_python_formatter/src/string/any.rs @@ -10,9 +10,6 @@ use ruff_source_file::Locator; use ruff_text_size::{Ranged, TextRange}; use crate::expression::expr_f_string::f_string_quoting; -use crate::other::f_string::FormatFString; -use crate::other::string_literal::StringLiteralKind; -use crate::prelude::*; use crate::string::Quoting; /// Represents any kind of string expression. This could be either a string, @@ -46,6 +43,10 @@ impl<'a> AnyString<'a> { } } + pub(crate) const fn is_fstring(self) -> bool { + matches!(self, Self::FString(_)) + } + /// Returns the quoting to be used for this string. pub(super) fn quoting(self, locator: &Locator<'_>) -> Quoting { match self { @@ -54,23 +55,21 @@ impl<'a> AnyString<'a> { } } - /// Returns a vector of all the [`AnyStringPart`] of this string. - pub(super) fn parts(self, quoting: Quoting) -> AnyStringPartsIter<'a> { + /// Returns an iterator over the [`AnyStringPart`]s of this string. + pub(super) fn parts(self) -> AnyStringPartsIter<'a> { match self { Self::String(ExprStringLiteral { value, .. }) => { AnyStringPartsIter::String(value.iter()) } Self::Bytes(ExprBytesLiteral { value, .. }) => AnyStringPartsIter::Bytes(value.iter()), - Self::FString(ExprFString { value, .. }) => { - AnyStringPartsIter::FString(value.iter(), quoting) - } + Self::FString(ExprFString { value, .. }) => AnyStringPartsIter::FString(value.iter()), } } pub(crate) fn is_multiline(self, source: &str) -> bool { match self { AnyString::String(_) | AnyString::Bytes(_) => { - self.parts(Quoting::default()) + self.parts() .next() .is_some_and(|part| part.flags().is_triple_quoted()) && memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some() @@ -139,7 +138,7 @@ impl<'a> From<&'a ExprFString> for AnyString<'a> { pub(super) enum AnyStringPartsIter<'a> { String(std::slice::Iter<'a, StringLiteral>), Bytes(std::slice::Iter<'a, ast::BytesLiteral>), - FString(std::slice::Iter<'a, ast::FStringPart>, Quoting), + FString(std::slice::Iter<'a, ast::FStringPart>), } impl<'a> Iterator for AnyStringPartsIter<'a> { @@ -147,28 +146,12 @@ impl<'a> Iterator for AnyStringPartsIter<'a> { fn next(&mut self) -> Option<Self::Item> { let part = match self { - Self::String(inner) => { - let part = inner.next()?; - AnyStringPart::String { - part, - layout: StringLiteralKind::String, - } - } + Self::String(inner) => AnyStringPart::String(inner.next()?), Self::Bytes(inner) => AnyStringPart::Bytes(inner.next()?), - Self::FString(inner, quoting) => { - let part = inner.next()?; - match part { - ast::FStringPart::Literal(string_literal) => AnyStringPart::String { - part: string_literal, - #[allow(deprecated)] - layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting), - }, - ast::FStringPart::FString(f_string) => AnyStringPart::FString { - part: f_string, - quoting: *quoting, - }, - } - } + Self::FString(inner) => match inner.next()? { + ast::FStringPart::Literal(string_literal) => AnyStringPart::String(string_literal), + ast::FStringPart::FString(f_string) => AnyStringPart::FString(f_string), + }, }; Some(part) @@ -183,23 +166,17 @@ impl FusedIterator for AnyStringPartsIter<'_> {} /// This is constructed from the [`AnyString::parts`] method on [`AnyString`]. #[derive(Clone, Debug)] pub(super) enum AnyStringPart<'a> { - String { - part: &'a ast::StringLiteral, - layout: StringLiteralKind, - }, + String(&'a ast::StringLiteral), Bytes(&'a ast::BytesLiteral), - FString { - part: &'a ast::FString, - quoting: Quoting, - }, + FString(&'a ast::FString), } impl AnyStringPart<'_> { fn flags(&self) -> AnyStringFlags { match self { - Self::String { part, .. } => part.flags.into(), + Self::String(part) => part.flags.into(), Self::Bytes(bytes_literal) => bytes_literal.flags.into(), - Self::FString { part, .. } => part.flags.into(), + Self::FString(part) => part.flags.into(), } } } @@ -207,9 +184,9 @@ impl AnyStringPart<'_> { impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> { fn from(value: &AnyStringPart<'a>) -> Self { match value { - AnyStringPart::String { part, .. } => AnyNodeRef::StringLiteral(part), + AnyStringPart::String(part) => AnyNodeRef::StringLiteral(part), AnyStringPart::Bytes(part) => AnyNodeRef::BytesLiteral(part), - AnyStringPart::FString { part, .. } => AnyNodeRef::FString(part), + AnyStringPart::FString(part) => AnyNodeRef::FString(part), } } } @@ -217,19 +194,9 @@ impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> { impl Ranged for AnyStringPart<'_> { fn range(&self) -> TextRange { match self { - Self::String { part, .. } => part.range(), + Self::String(part) => part.range(), Self::Bytes(part) => part.range(), - Self::FString { part, .. } => part.range(), - } - } -} - -impl Format<PyFormatContext<'_>> for AnyStringPart<'_> { - fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { - match self { - AnyStringPart::String { part, layout } => part.format().with_options(*layout).fmt(f), - AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), - AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f), + Self::FString(part) => part.range(), } } } diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index bdaba1a7d7778..40a218d018ca5 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -11,7 +11,10 @@ use ruff_text_size::{Ranged, TextRange}; use crate::comments::{leading_comments, trailing_comments}; use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space; +use crate::other::f_string::FormatFString; +use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; +use crate::string::any::AnyStringPart; use crate::QuoteStyle; mod any; @@ -46,12 +49,28 @@ impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> { let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space()); - for part in self.string.parts(quoting) { + for part in self.string.parts() { let part_comments = comments.leading_dangling_trailing(&part); + + let format_part = format_with(|f: &mut PyFormatter| match part { + AnyStringPart::String(part) => { + let kind = if self.string.is_fstring() { + #[allow(deprecated)] + StringLiteralKind::InImplicitlyConcatenatedFString(quoting) + } else { + StringLiteralKind::String + }; + + part.format().with_options(kind).fmt(f) + } + AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), + AnyStringPart::FString(part) => FormatFString::new(part, quoting).fmt(f), + }); + joiner.entry(&format_args![ line_suffix_boundary(), leading_comments(part_comments.leading), - part, + format_part, trailing_comments(part_comments.trailing) ]); } diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index 70d4fe2e72449..2e95575db241e 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -22,7 +22,7 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { pub(crate) fn from_context(context: &'a PyFormatContext<'src>) -> Self { Self { quoting: Quoting::default(), - preferred_quote_style: QuoteStyle::default(), + preferred_quote_style: context.options().quote_style(), context, } } From 5b4afd30caebd6e2c5c27bbf5debc1663cbb28a2 Mon Sep 17 00:00:00 2001 From: Alex Waygood <Alex.Waygood@Gmail.com> Date: Wed, 9 Oct 2024 14:18:52 +0100 Subject: [PATCH 53/88] Harmonise methods for distinguishing different Python source types (#13682) --- Cargo.lock | 1 - crates/ruff_dev/Cargo.toml | 1 - crates/ruff_dev/src/round_trip.rs | 4 +- .../rules/builtin_module_shadowing.rs | 6 +-- .../rules/implicit_namespace_package.rs | 3 +- .../pep8_naming/rules/invalid_module_name.rs | 6 +-- crates/ruff_python_ast/src/lib.rs | 30 ++++++++++++--- crates/ruff_python_semantic/src/model.rs | 5 +-- crates/ruff_python_stdlib/src/path.rs | 37 +++---------------- 9 files changed, 40 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 864af0e879960..df14e77427054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2466,7 +2466,6 @@ dependencies = [ "ruff_python_codegen", "ruff_python_formatter", "ruff_python_parser", - "ruff_python_stdlib", "ruff_python_trivia", "ruff_workspace", "schemars", diff --git a/crates/ruff_dev/Cargo.toml b/crates/ruff_dev/Cargo.toml index 632c12f473786..7443d06361f50 100644 --- a/crates/ruff_dev/Cargo.toml +++ b/crates/ruff_dev/Cargo.toml @@ -20,7 +20,6 @@ ruff_python_ast = { workspace = true } ruff_python_codegen = { workspace = true } ruff_python_formatter = { workspace = true } ruff_python_parser = { workspace = true } -ruff_python_stdlib = { workspace = true } ruff_python_trivia = { workspace = true } ruff_workspace = { workspace = true, features = ["schemars"] } diff --git a/crates/ruff_dev/src/round_trip.rs b/crates/ruff_dev/src/round_trip.rs index a910f364284f7..6a070c65c1836 100644 --- a/crates/ruff_dev/src/round_trip.rs +++ b/crates/ruff_dev/src/round_trip.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use anyhow::Result; +use ruff_python_ast::PySourceType; use ruff_python_codegen::round_trip; -use ruff_python_stdlib::path::is_jupyter_notebook; #[derive(clap::Args)] pub(crate) struct Args { @@ -18,7 +18,7 @@ pub(crate) struct Args { pub(crate) fn main(args: &Args) -> Result<()> { let path = args.file.as_path(); - if is_jupyter_notebook(path) { + if PySourceType::from(path).is_ipynb() { println!("{}", ruff_notebook::round_trip(path)?); } else { let contents = fs::read_to_string(&args.file)?; diff --git a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_module_shadowing.rs b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_module_shadowing.rs index d38665274d328..14d42fb7e9e4e 100644 --- a/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_module_shadowing.rs +++ b/crates/ruff_linter/src/rules/flake8_builtins/rules/builtin_module_shadowing.rs @@ -2,6 +2,7 @@ use std::path::Path; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::PySourceType; use ruff_python_stdlib::path::is_module_file; use ruff_python_stdlib::sys::is_known_standard_library; use ruff_text_size::TextRange; @@ -42,10 +43,7 @@ pub(crate) fn builtin_module_shadowing( allowed_modules: &[String], target_version: PythonVersion, ) -> Option<Diagnostic> { - if !path - .extension() - .is_some_and(|ext| ext == "py" || ext == "pyi") - { + if !PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file_or_stub) { return None; } diff --git a/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs b/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs index 10c3a591dd0d6..33616de138667 100644 --- a/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs +++ b/crates/ruff_linter/src/rules/flake8_no_pep420/rules/implicit_namespace_package.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::PySourceType; use ruff_python_trivia::CommentRanges; use ruff_source_file::Locator; use ruff_text_size::{TextRange, TextSize}; @@ -51,7 +52,7 @@ pub(crate) fn implicit_namespace_package( ) -> Option<Diagnostic> { if package.is_none() // Ignore non-`.py` files, which don't require an `__init__.py`. - && path.extension().is_some_and( |ext| ext == "py") + && PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file) // Ignore any files that are direct children of the project root. && !path .parent() diff --git a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs index 6be2eb83aa7b4..d525aa1d629cb 100644 --- a/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs +++ b/crates/ruff_linter/src/rules/pep8_naming/rules/invalid_module_name.rs @@ -3,6 +3,7 @@ use std::path::Path; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::PySourceType; use ruff_python_stdlib::identifiers::{is_migration_name, is_module_name}; use ruff_python_stdlib::path::is_module_file; use ruff_text_size::TextRange; @@ -53,10 +54,7 @@ pub(crate) fn invalid_module_name( package: Option<&Path>, ignore_names: &IgnoreNames, ) -> Option<Diagnostic> { - if !path - .extension() - .is_some_and(|ext| ext == "py" || ext == "pyi") - { + if !PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file_or_stub) { return None; } diff --git a/crates/ruff_python_ast/src/lib.rs b/crates/ruff_python_ast/src/lib.rs index 48a9afeb5730f..346fae9d8a389 100644 --- a/crates/ruff_python_ast/src/lib.rs +++ b/crates/ruff_python_ast/src/lib.rs @@ -68,7 +68,7 @@ pub enum TomlSourceType { Unrecognized, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, is_macro::Is)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PySourceType { /// The source is a Python file (`.py`). @@ -99,13 +99,33 @@ impl PySourceType { Some(ty) } -} -impl<P: AsRef<Path>> From<P> for PySourceType { - fn from(path: P) -> Self { + pub fn try_from_path(path: impl AsRef<Path>) -> Option<Self> { path.as_ref() .extension() .and_then(OsStr::to_str) - .map_or(Self::Python, Self::from_extension) + .and_then(Self::try_from_extension) + } + + pub const fn is_py_file(self) -> bool { + matches!(self, Self::Python) + } + + pub const fn is_stub(self) -> bool { + matches!(self, Self::Stub) + } + + pub const fn is_py_file_or_stub(self) -> bool { + matches!(self, Self::Python | Self::Stub) + } + + pub const fn is_ipynb(self) -> bool { + matches!(self, Self::Ipynb) + } +} + +impl<P: AsRef<Path>> From<P> for PySourceType { + fn from(path: P) -> Self { + Self::try_from_path(path).unwrap_or_default() } } diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 303cd12cac583..959ab5226d3c9 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -5,8 +5,7 @@ use rustc_hash::FxHashMap; use ruff_python_ast::helpers::from_relative_import; use ruff_python_ast::name::{QualifiedName, UnqualifiedName}; -use ruff_python_ast::{self as ast, Expr, ExprContext, Operator, Stmt}; -use ruff_python_stdlib::path::is_python_stub_file; +use ruff_python_ast::{self as ast, Expr, ExprContext, Operator, PySourceType, Stmt}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::binding::{ @@ -2246,7 +2245,7 @@ bitflags! { impl SemanticModelFlags { pub fn new(path: &Path) -> Self { - if is_python_stub_file(path) { + if PySourceType::from(path).is_stub() { Self::STUB_FILE } else { Self::default() diff --git a/crates/ruff_python_stdlib/src/path.rs b/crates/ruff_python_stdlib/src/path.rs index f9998efec3d89..6bc14c78bf723 100644 --- a/crates/ruff_python_stdlib/src/path.rs +++ b/crates/ruff_python_stdlib/src/path.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::path::Path; /// Return `true` if the [`Path`] is named `pyproject.toml`. @@ -6,38 +7,10 @@ pub fn is_pyproject_toml(path: &Path) -> bool { .is_some_and(|name| name == "pyproject.toml") } -/// Return `true` if the [`Path`] appears to be that of a Python interface definition file (`.pyi`). -pub fn is_python_stub_file(path: &Path) -> bool { - path.extension().is_some_and(|ext| ext == "pyi") -} - -/// Return `true` if the [`Path`] appears to be that of a Jupyter notebook (`.ipynb`). -pub fn is_jupyter_notebook(path: &Path) -> bool { - path.extension().is_some_and(|ext| ext == "ipynb") -} - /// Return `true` if a [`Path`] should use the name of its parent directory as its module name. pub fn is_module_file(path: &Path) -> bool { - path.file_name().is_some_and(|file_name| { - file_name == "__init__.py" - || file_name == "__init__.pyi" - || file_name == "__main__.py" - || file_name == "__main__.pyi" - }) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use crate::path::is_jupyter_notebook; - - #[test] - fn test_is_jupyter_notebook() { - let path = Path::new("foo/bar/baz.ipynb"); - assert!(is_jupyter_notebook(path)); - - let path = Path::new("foo/bar/baz.py"); - assert!(!is_jupyter_notebook(path)); - } + matches!( + path.file_name().and_then(OsStr::to_str), + Some("__init__.py" | "__init__.pyi" | "__main__.py" | "__main__.pyi") + ) } From d6b24b690a63a8d35a39c8fe749de5271526a8bf Mon Sep 17 00:00:00 2001 From: Alex Waygood <Alex.Waygood@Gmail.com> Date: Thu, 10 Oct 2024 17:24:17 +0100 Subject: [PATCH 54/88] [`pycodestyle`] Fix whitespace-related false positives and false negatives inside type-parameter lists (#13704) --- .../test/fixtures/pycodestyle/E23.py | 38 ++ .../test/fixtures/pycodestyle/E25.py | 16 + .../rules/logical_lines/missing_whitespace.rs | 31 +- .../pycodestyle/rules/logical_lines/mod.rs | 106 +++++ ...hitespace_around_named_parameter_equals.rs | 32 +- ...ules__pycodestyle__tests__E231_E23.py.snap | 429 ++++++++++++++++++ ...ules__pycodestyle__tests__E252_E25.py.snap | 388 ++++++++++++++++ 7 files changed, 996 insertions(+), 44 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E23.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E23.py index 37d8a282a3282..b4eddee8b4d93 100644 --- a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E23.py +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E23.py @@ -104,3 +104,41 @@ def main() -> None: ] } ] + +# Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + x:A = "foo"[::-1], + y:B = [[["foo", "bar"]]], + z:object = "fooo", +): + pass + +class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + self, + x:A = "foo"[::-1], + y:B = [[["foo", "bar"]]], + z:object = "fooo", + ): + pass + +class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + pass + +# Should be no E231 errors on any of these: +def pep_696_good[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes]( + x: A = "foo"[::-1], + y: B = [[["foo", "bar"]]], + z: object = "fooo", +): + pass + +class PEP696Good[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes]: + pass + +class PEP696GoodWithEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes](): + pass + +class PEP696GoodWithNonEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes](object, something_dynamic[x::-1]): + pass diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E25.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E25.py index 9d8bddac4234c..90f112cfc2a1a 100644 --- a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E25.py +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E25.py @@ -59,3 +59,19 @@ def add(a: int = _default(name='f')): print(f"{foo = }") # ...but then it creates false negatives for now print(f"{foo(a = 1)}") + +# There should be at least one E251 diagnostic for each type parameter here: +def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + pass + +class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + pass + +# The last of these should cause us to emit E231, +# but E231 isn't tested by this fixture: +def pep_696_good[A = int, B: object = str, C:object = memoryview](): + pass + +class PEP696Good[A = int, B: object = str, C:object = memoryview]: + def pep_696_good_method[A = int, B: object = str, C:object = memoryview](self): + pass diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs index b9a7eb8aab6e4..0d714b5e88317 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_whitespace.rs @@ -6,7 +6,7 @@ use ruff_text_size::Ranged; use crate::checkers::logical_lines::LogicalLinesContext; -use super::LogicalLine; +use super::{DefinitionState, LogicalLine}; /// ## What it does /// Checks for missing whitespace after `,`, `;`, and `:`. @@ -28,22 +28,10 @@ pub struct MissingWhitespace { token: TokenKind, } -impl MissingWhitespace { - fn token_text(&self) -> char { - match self.token { - TokenKind::Colon => ':', - TokenKind::Semi => ';', - TokenKind::Comma => ',', - _ => unreachable!(), - } - } -} - impl AlwaysFixableViolation for MissingWhitespace { #[derive_message_formats] fn message(&self) -> String { - let token = self.token_text(); - format!("Missing whitespace after '{token}'") + format!("Missing whitespace after {}", self.token) } fn fix_title(&self) -> String { @@ -54,11 +42,13 @@ impl AlwaysFixableViolation for MissingWhitespace { /// E231 pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesContext) { let mut fstrings = 0u32; + let mut definition_state = DefinitionState::from_tokens(line.tokens()); let mut brackets = Vec::new(); let mut iter = line.tokens().iter().peekable(); while let Some(token) = iter.next() { let kind = token.kind(); + definition_state.visit_token_kind(kind); match kind { TokenKind::FStringStart => fstrings += 1, TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1), @@ -97,7 +87,9 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesC if let Some(next_token) = iter.peek() { match (kind, next_token.kind()) { (TokenKind::Colon, _) - if matches!(brackets.last(), Some(TokenKind::Lsqb)) => + if matches!(brackets.last(), Some(TokenKind::Lsqb)) + && !(definition_state.in_type_params() + && brackets.len() == 1) => { continue; // Slice syntax, no space required } @@ -111,13 +103,10 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, context: &mut LogicalLinesC } } - let mut diagnostic = + let diagnostic = Diagnostic::new(MissingWhitespace { token: kind }, token.range()); - diagnostic.set_fix(Fix::safe_edit(Edit::insertion( - " ".to_string(), - token.end(), - ))); - context.push_diagnostic(diagnostic); + let fix = Fix::safe_edit(Edit::insertion(" ".to_string(), token.end())); + context.push_diagnostic(diagnostic.with_fix(fix)); } } _ => {} diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs index 69fa5d96dfcab..51139a830bda3 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs @@ -470,6 +470,112 @@ struct Line { tokens_end: u32, } +/// Keeps track of whether we are currently visiting a class or function definition in a +/// [`LogicalLine`]. If we are visiting a class or function, the enum also keeps track +/// of the [type parameters] of the class/function. +/// +/// Call [`DefinitionState::visit_token_kind`] on the [`TokenKind`] of each +/// successive [`LogicalLineToken`] to ensure the state remains up to date. +/// +/// [type parameters]: https://docs.python.org/3/reference/compound_stmts.html#type-params +#[derive(Debug, Clone, Copy)] +enum DefinitionState { + InClass(TypeParamsState), + InFunction(TypeParamsState), + NotInClassOrFunction, +} + +impl DefinitionState { + fn from_tokens<'a>(tokens: impl IntoIterator<Item = &'a LogicalLineToken>) -> Self { + let mut token_kinds = tokens.into_iter().map(LogicalLineToken::kind); + while let Some(token_kind) = token_kinds.next() { + let state = match token_kind { + TokenKind::Indent | TokenKind::Dedent => continue, + TokenKind::Class => Self::InClass(TypeParamsState::default()), + TokenKind::Def => Self::InFunction(TypeParamsState::default()), + TokenKind::Async if matches!(token_kinds.next(), Some(TokenKind::Def)) => { + Self::InFunction(TypeParamsState::default()) + } + _ => Self::NotInClassOrFunction, + }; + return state; + } + Self::NotInClassOrFunction + } + + const fn in_function_definition(self) -> bool { + matches!(self, Self::InFunction(_)) + } + + const fn type_params_state(self) -> Option<TypeParamsState> { + match self { + Self::InClass(state) | Self::InFunction(state) => Some(state), + Self::NotInClassOrFunction => None, + } + } + + fn in_type_params(self) -> bool { + matches!( + self.type_params_state(), + Some(TypeParamsState::InTypeParams { .. }) + ) + } + + fn visit_token_kind(&mut self, token_kind: TokenKind) { + let type_params_state_mut = match self { + Self::InClass(type_params_state) | Self::InFunction(type_params_state) => { + type_params_state + } + Self::NotInClassOrFunction => return, + }; + match token_kind { + TokenKind::Lpar if type_params_state_mut.before_type_params() => { + *type_params_state_mut = TypeParamsState::TypeParamsEnded; + } + TokenKind::Lsqb => match type_params_state_mut { + TypeParamsState::TypeParamsEnded => {} + TypeParamsState::BeforeTypeParams => { + *type_params_state_mut = TypeParamsState::InTypeParams { + inner_square_brackets: 0, + }; + } + TypeParamsState::InTypeParams { + inner_square_brackets, + } => *inner_square_brackets += 1, + }, + TokenKind::Rsqb => { + if let TypeParamsState::InTypeParams { + inner_square_brackets, + } = type_params_state_mut + { + if *inner_square_brackets == 0 { + *type_params_state_mut = TypeParamsState::TypeParamsEnded; + } else { + *inner_square_brackets -= 1; + } + } + } + _ => {} + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +enum TypeParamsState { + #[default] + BeforeTypeParams, + InTypeParams { + inner_square_brackets: u32, + }, + TypeParamsEnded, +} + +impl TypeParamsState { + const fn before_type_params(self) -> bool { + matches!(self, Self::BeforeTypeParams) + } +} + #[cfg(test)] mod tests { use ruff_python_parser::parse_module; diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs index a0c4f49bf2250..c9fd76e15bbfe 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/whitespace_around_named_parameter_equals.rs @@ -4,7 +4,7 @@ use ruff_python_parser::TokenKind; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::logical_lines::LogicalLinesContext; -use crate::rules::pycodestyle::rules::logical_lines::{LogicalLine, LogicalLineToken}; +use crate::rules::pycodestyle::rules::logical_lines::{DefinitionState, LogicalLine}; /// ## What it does /// Checks for missing whitespace around the equals sign in an unannotated @@ -84,18 +84,6 @@ impl AlwaysFixableViolation for MissingWhitespaceAroundParameterEquals { } } -fn is_in_def(tokens: &[LogicalLineToken]) -> bool { - for token in tokens { - match token.kind() { - TokenKind::Async | TokenKind::Indent | TokenKind::Dedent => continue, - TokenKind::Def => return true, - _ => return false, - } - } - - false -} - /// E251, E252 pub(crate) fn whitespace_around_named_parameter_equals( line: &LogicalLine, @@ -106,17 +94,14 @@ pub(crate) fn whitespace_around_named_parameter_equals( let mut annotated_func_arg = false; let mut prev_end = TextSize::default(); - let in_def = is_in_def(line.tokens()); + let mut definition_state = DefinitionState::from_tokens(line.tokens()); let mut iter = line.tokens().iter().peekable(); while let Some(token) = iter.next() { let kind = token.kind(); - - if kind == TokenKind::NonLogicalNewline { - continue; - } - + definition_state.visit_token_kind(kind); match kind { + TokenKind::NonLogicalNewline => continue, TokenKind::FStringStart => fstrings += 1, TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1), TokenKind::Lpar | TokenKind::Lsqb => { @@ -128,15 +113,16 @@ pub(crate) fn whitespace_around_named_parameter_equals( annotated_func_arg = false; } } - - TokenKind::Colon if parens == 1 && in_def => { + TokenKind::Colon if parens == 1 && definition_state.in_function_definition() => { annotated_func_arg = true; } TokenKind::Comma if parens == 1 => { annotated_func_arg = false; } - TokenKind::Equal if parens > 0 && fstrings == 0 => { - if annotated_func_arg && parens == 1 { + TokenKind::Equal + if definition_state.in_type_params() || (parens > 0 && fstrings == 0) => + { + if definition_state.in_type_params() || (annotated_func_arg && parens == 1) { let start = token.start(); if start == prev_end && prev_end != TextSize::new(0) { let mut diagnostic = diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap index 62abb9df58ccb..5a05a709d79f7 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E231_E23.py.snap @@ -476,3 +476,432 @@ E23.py:102:24: E231 [*] Missing whitespace after ',' 103 103 | }, 104 104 | ] 105 105 | } + +E23.py:109:18: E231 [*] Missing whitespace after ':' + | +108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +110 | x:A = "foo"[::-1], +111 | y:B = [[["foo", "bar"]]], + | + = help: Add missing whitespace + +ℹ Safe fix +106 106 | ] +107 107 | +108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 109 |+def pep_696_bad[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 110 | x:A = "foo"[::-1], +111 111 | y:B = [[["foo", "bar"]]], +112 112 | z:object = "fooo", + +E23.py:109:40: E231 [*] Missing whitespace after ':' + | +108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +110 | x:A = "foo"[::-1], +111 | y:B = [[["foo", "bar"]]], + | + = help: Add missing whitespace + +ℹ Safe fix +106 106 | ] +107 107 | +108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 109 |+def pep_696_bad[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes]( +110 110 | x:A = "foo"[::-1], +111 111 | y:B = [[["foo", "bar"]]], +112 112 | z:object = "fooo", + +E23.py:109:70: E231 [*] Missing whitespace after ':' + | +108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +110 | x:A = "foo"[::-1], +111 | y:B = [[["foo", "bar"]]], + | + = help: Add missing whitespace + +ℹ Safe fix +106 106 | ] +107 107 | +108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 |-def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 109 |+def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes]( +110 110 | x:A = "foo"[::-1], +111 111 | y:B = [[["foo", "bar"]]], +112 112 | z:object = "fooo", + +E23.py:110:6: E231 [*] Missing whitespace after ':' + | +108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 | x:A = "foo"[::-1], + | ^ E231 +111 | y:B = [[["foo", "bar"]]], +112 | z:object = "fooo", + | + = help: Add missing whitespace + +ℹ Safe fix +107 107 | +108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 |- x:A = "foo"[::-1], + 110 |+ x: A = "foo"[::-1], +111 111 | y:B = [[["foo", "bar"]]], +112 112 | z:object = "fooo", +113 113 | ): + +E23.py:111:6: E231 [*] Missing whitespace after ':' + | +109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 | x:A = "foo"[::-1], +111 | y:B = [[["foo", "bar"]]], + | ^ E231 +112 | z:object = "fooo", +113 | ): + | + = help: Add missing whitespace + +ℹ Safe fix +108 108 | # Should be E231 errors on all of these type parameters and function parameters, but not on their (strange) defaults +109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 110 | x:A = "foo"[::-1], +111 |- y:B = [[["foo", "bar"]]], + 111 |+ y: B = [[["foo", "bar"]]], +112 112 | z:object = "fooo", +113 113 | ): +114 114 | pass + +E23.py:112:6: E231 [*] Missing whitespace after ':' + | +110 | x:A = "foo"[::-1], +111 | y:B = [[["foo", "bar"]]], +112 | z:object = "fooo", + | ^ E231 +113 | ): +114 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +109 109 | def pep_696_bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +110 110 | x:A = "foo"[::-1], +111 111 | y:B = [[["foo", "bar"]]], +112 |- z:object = "fooo", + 112 |+ z: object = "fooo", +113 113 | ): +114 114 | pass +115 115 | + +E23.py:116:18: E231 [*] Missing whitespace after ':' + | +114 | pass +115 | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + | ^ E231 +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 | self, + | + = help: Add missing whitespace + +ℹ Safe fix +113 113 | ): +114 114 | pass +115 115 | +116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + 116 |+class PEP696Bad[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], + +E23.py:116:40: E231 [*] Missing whitespace after ':' + | +114 | pass +115 | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + | ^ E231 +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 | self, + | + = help: Add missing whitespace + +ℹ Safe fix +113 113 | ): +114 114 | pass +115 115 | +116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + 116 |+class PEP696Bad[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes]: +117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], + +E23.py:116:70: E231 [*] Missing whitespace after ':' + | +114 | pass +115 | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + | ^ E231 +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 | self, + | + = help: Add missing whitespace + +ℹ Safe fix +113 113 | ): +114 114 | pass +115 115 | +116 |-class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: + 116 |+class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes]: +117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], + +E23.py:117:29: E231 [*] Missing whitespace after ':' + | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +118 | self, +119 | x:A = "foo"[::-1], + | + = help: Add missing whitespace + +ℹ Safe fix +114 114 | pass +115 115 | +116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 117 |+ def pep_696_bad_method[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], +120 120 | y:B = [[["foo", "bar"]]], + +E23.py:117:51: E231 [*] Missing whitespace after ':' + | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +118 | self, +119 | x:A = "foo"[::-1], + | + = help: Add missing whitespace + +ℹ Safe fix +114 114 | pass +115 115 | +116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 117 |+ def pep_696_bad_method[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], +120 120 | y:B = [[["foo", "bar"]]], + +E23.py:117:81: E231 [*] Missing whitespace after ':' + | +116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + | ^ E231 +118 | self, +119 | x:A = "foo"[::-1], + | + = help: Add missing whitespace + +ℹ Safe fix +114 114 | pass +115 115 | +116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 |- def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( + 117 |+ def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], +120 120 | y:B = [[["foo", "bar"]]], + +E23.py:119:10: E231 [*] Missing whitespace after ':' + | +117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 | self, +119 | x:A = "foo"[::-1], + | ^ E231 +120 | y:B = [[["foo", "bar"]]], +121 | z:object = "fooo", + | + = help: Add missing whitespace + +ℹ Safe fix +116 116 | class PEP696Bad[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]: +117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 |- x:A = "foo"[::-1], + 119 |+ x: A = "foo"[::-1], +120 120 | y:B = [[["foo", "bar"]]], +121 121 | z:object = "fooo", +122 122 | ): + +E23.py:120:10: E231 [*] Missing whitespace after ':' + | +118 | self, +119 | x:A = "foo"[::-1], +120 | y:B = [[["foo", "bar"]]], + | ^ E231 +121 | z:object = "fooo", +122 | ): + | + = help: Add missing whitespace + +ℹ Safe fix +117 117 | def pep_696_bad_method[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes]( +118 118 | self, +119 119 | x:A = "foo"[::-1], +120 |- y:B = [[["foo", "bar"]]], + 120 |+ y: B = [[["foo", "bar"]]], +121 121 | z:object = "fooo", +122 122 | ): +123 123 | pass + +E23.py:121:10: E231 [*] Missing whitespace after ':' + | +119 | x:A = "foo"[::-1], +120 | y:B = [[["foo", "bar"]]], +121 | z:object = "fooo", + | ^ E231 +122 | ): +123 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +118 118 | self, +119 119 | x:A = "foo"[::-1], +120 120 | y:B = [[["foo", "bar"]]], +121 |- z:object = "fooo", + 121 |+ z: object = "fooo", +122 122 | ): +123 123 | pass +124 124 | + +E23.py:125:32: E231 [*] Missing whitespace after ':' + | +123 | pass +124 | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + | ^ E231 +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +122 122 | ): +123 123 | pass +124 124 | +125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + 125 |+class PEP696BadWithEmptyBases[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | + +E23.py:125:54: E231 [*] Missing whitespace after ':' + | +123 | pass +124 | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + | ^ E231 +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +122 122 | ): +123 123 | pass +124 124 | +125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + 125 |+class PEP696BadWithEmptyBases[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes](): +126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | + +E23.py:125:84: E231 [*] Missing whitespace after ':' + | +123 | pass +124 | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + | ^ E231 +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +122 122 | ): +123 123 | pass +124 124 | +125 |-class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): + 125 |+class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes](): +126 126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | + +E23.py:126:47: E231 [*] Missing whitespace after ':' + | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + | ^ E231 +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +123 123 | pass +124 124 | +125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + 126 |+ class IndentedPEP696BadWithNonEmptyBases[A: object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | +129 129 | # Should be no E231 errors on any of these: + +E23.py:126:69: E231 [*] Missing whitespace after ':' + | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + | ^ E231 +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +123 123 | pass +124 124 | +125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + 126 |+ class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B: object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | +129 129 | # Should be no E231 errors on any of these: + +E23.py:126:99: E231 [*] Missing whitespace after ':' + | +125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 | class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + | ^ E231 +127 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +123 123 | pass +124 124 | +125 125 | class PEP696BadWithEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](): +126 |- class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C:object= bytes](object, something_dynamic[x::-1]): + 126 |+ class IndentedPEP696BadWithNonEmptyBases[A:object="foo"[::-1], B:object =[[["foo", "bar"]]], C: object= bytes](object, something_dynamic[x::-1]): +127 127 | pass +128 128 | +129 129 | # Should be no E231 errors on any of these: diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap index 54c129811470f..2fbc8575d72d6 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E252_E25.py.snap @@ -85,4 +85,392 @@ E25.py:46:36: E252 [*] Missing whitespace around parameter equals 48 48 | #: Okay 49 49 | def add(a: int = _default(name='f')): +E25.py:64:18: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A =int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:18: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A= int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:26: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B = str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:33: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C = bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:49: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object =int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:49: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object= int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:64: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object =str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:64: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object= str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:80: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object = bool, G: object= bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + +E25.py:64:96: E252 [*] Missing whitespace around parameter equals + | +63 | # There should be at least one E251 diagnostic for each type parameter here: +64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + | ^ E252 +65 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +61 61 | print(f"{foo(a = 1)}") +62 62 | +63 63 | # There should be at least one E251 diagnostic for each type parameter here: +64 |-def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): + 64 |+def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object = bytes](): +65 65 | pass +66 66 | +67 67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: +E25.py:67:18: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A =int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:18: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A= int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:26: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B = str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:33: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C = bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:49: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object =int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:49: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object= int, E: object=str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:64: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object =str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:64: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object= str, F: object =bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:80: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object = bool, G: object= bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, + +E25.py:67:96: E252 [*] Missing whitespace around parameter equals + | +65 | pass +66 | +67 | class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + | ^ E252 +68 | pass + | + = help: Add missing whitespace + +ℹ Safe fix +64 64 | def pep_696_bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes](): +65 65 | pass +66 66 | +67 |-class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object= bytes]: + 67 |+class PEP696Bad[A=int, B =str, C= bool, D:object=int, E: object=str, F: object =bool, G: object = bytes]: +68 68 | pass +69 69 | +70 70 | # The last of these should cause us to emit E231, From a3dc5c0529e779910ce928dda91c21a5e3381738 Mon Sep 17 00:00:00 2001 From: Carl Meyer <carl@astral.sh> Date: Thu, 10 Oct 2024 12:02:01 -0700 Subject: [PATCH 55/88] [red-knot] document test framework (#13695) This adds documentation for the new test framework. I also added documentation for the planned design of features we haven't built yet (clearly marked as such), so that this doc can become the sole source of truth for the test framework design (we don't need to refer back to the original internal design document.) Also fixes a few issues in the test framework implementation that were discovered in writing up the docs. --------- Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com> Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com> Co-authored-by: Dhruv Manilawala <dhruvmanila@gmail.com> --- .pre-commit-config.yaml | 1 + .../resources/README.md | 4 + .../red_knot_python_semantic/tests/mdtest.rs | 1 + crates/red_knot_test/README.md | 449 ++++++++++++++++++ crates/red_knot_test/src/matcher.rs | 220 ++++++--- 5 files changed, 598 insertions(+), 77 deletions(-) create mode 100644 crates/red_knot_python_semantic/resources/README.md create mode 100644 crates/red_knot_test/README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 097186a3b1b54..710e128b25eb7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,7 @@ repos: additional_dependencies: - mdformat-mkdocs - mdformat-admon + - mdformat-footnote exclude: | (?x)^( docs/formatter/black\.md diff --git a/crates/red_knot_python_semantic/resources/README.md b/crates/red_knot_python_semantic/resources/README.md new file mode 100644 index 0000000000000..0a04772b9af87 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/README.md @@ -0,0 +1,4 @@ +Markdown files within the `mdtest/` subdirectory are tests of type inference and type checking; +executed by the `tests/mdtest.rs` integration test. + +See `crates/red_knot_test/README.md` for documentation of this test format. diff --git a/crates/red_knot_python_semantic/tests/mdtest.rs b/crates/red_knot_python_semantic/tests/mdtest.rs index 6493ce290a37b..1215995700564 100644 --- a/crates/red_knot_python_semantic/tests/mdtest.rs +++ b/crates/red_knot_python_semantic/tests/mdtest.rs @@ -1,6 +1,7 @@ use red_knot_test::run; use std::path::PathBuf; +/// See `crates/red_knot_test/README.md` for documentation on these tests. #[rstest::rstest] fn mdtest(#[files("resources/mdtest/**/*.md")] path: PathBuf) { let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/red_knot_test/README.md b/crates/red_knot_test/README.md new file mode 100644 index 0000000000000..c5ea01bc8a9c9 --- /dev/null +++ b/crates/red_knot_test/README.md @@ -0,0 +1,449 @@ +# Writing type-checking / type-inference tests + +Any Markdown file can be a test suite. + +In order for it to be run as one, `red_knot_test::run` must be called with its path; see +`crates/red_knot_python_semantic/tests/mdtest.rs` for an example that treats all Markdown files +under a certain directory as test suites. + +A Markdown test suite can contain any number of tests. A test consists of one or more embedded +"files", each defined by a triple-backticks fenced code block. The code block must have a tag string +specifying its language; currently only `py` (Python files) and `pyi` (type stub files) are +supported. + +The simplest possible test suite consists of just a single test, with a single embedded file: + +````markdown +```py +reveal_type(1) # revealed: Literal[1] +``` +```` + +When running this test, the mdtest framework will write a file with these contents to the default +file path (`/src/test.py`) in its in-memory file system, run a type check on that file, and then +match the resulting diagnostics with the assertions in the test. Assertions are in the form of +Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise, it +fails. + +## Assertions + +Two kinds of assertions are supported: `# revealed:` (shown above) and `# error:`. + +### Assertion kinds + +#### revealed + +A `# revealed:` assertion should always be paired with a call to the `reveal_type` utility, which +reveals (via a diagnostic) the inferred type of its argument (which can be any expression). The text +after `# revealed:` must match exactly with the displayed form of the revealed type of that +expression. + +The `reveal_type` function can be imported from the `typing` standard library module (or, for older +Python versions, from the `typing_extensions` pseudo-standard-library module[^extensions]): + +```py +from typing import reveal_type + +reveal_type("foo") # revealed: Literal["foo"] +``` + +For convenience, type checkers also pretend that `reveal_type` is a built-in, so that this import is +not required. Using `reveal_type` without importing it issues a diagnostic warning that it was used +without importing it, in addition to the diagnostic revealing the type of the expression. + +The `# revealed:` assertion must always match a revealed-type diagnostic, and will also match the +undefined-reveal diagnostic, if present, so it's safe to use `reveal_type` in tests either with or +without importing it. (Style preference is to not import it in tests, unless specifically testing +something about the behavior of importing it.) + +#### error + +A comment beginning with `# error:` is an assertion that a type checker diagnostic will +be emitted, with text span starting on that line. If the comment is simply `# error:`, this will +match any diagnostic. The matching can be narrowed in three ways: + +- `# error: [invalid-assignment]` requires that the matched diagnostic have the rule code + `invalid-assignment`. (The square brackets are required.) +- `# error: "Some text"` requires that the diagnostic's full message contain the text `Some text`. + (The double quotes are required in the assertion comment; they are not part of the matched text.) +- `# error: 8 [rule-code]` or `# error: 8 "Some text"` additionally requires that the matched + diagnostic's text span begins on column 8 (one-indexed) of this line. + +Assertions must contain either a rule code or a contains-text, or both, and may optionally also +include a column number. They must come in order: first column, if present; then rule code, if +present; then contains-text, if present. For example, an assertion using all three would look like +`# error: 8 [invalid-assignment] "Some text"`. + +Error assertions in tests intended to test type checker semantics should primarily use rule-code +assertions, with occasional contains-text assertions where needed to disambiguate. + +### Assertion locations + +An assertion comment may be a line-trailing comment, in which case it applies to the line it is on: + +```py +x: str = 1 # error: [invalid-assignment] +``` + +Or it may be a comment on its own line, in which case it applies to the next line that does not +contain an assertion comment: + +```py +# error: [invalid-assignment] +x: str = 1 +``` + +Multiple assertions applying to the same line may be stacked: + +```py +# error: [invalid-assignment] +# revealed: Literal[1] +x: str = reveal_type(1) +``` + +Intervening empty lines or non-assertion comments are not allowed; an assertion stack must be one +assertion per line, immediately following each other, with the line immediately following the last +assertion as the line of source code on which the matched diagnostics are emitted. + +## Multi-file tests + +Some tests require multiple files, with imports from one file into another. Multiple fenced code +blocks represent multiple embedded files. Since files must have unique names, at most one file can +use the default name of `/src/test.py`. Other files must explicitly specify their file name: + +````markdown +```py +from b import C +reveal_type(C) # revealed: Literal[C] +``` + +```py path=b.py +class C: pass +``` +```` + +Relative file names are always relative to the "workspace root", which is also an import root (that +is, the equivalent of a runtime entry on `sys.path`). + +The default workspace root is `/src/`. Currently it is not possible to customize this in a test, but +this is a feature we will want to add in the future. + +So the above test creates two files, `/src/test.py` and `/src/b.py`, and sets the workspace root to +`/src/`, allowing `test.py` to import from `b.py` using the module name `b`. + +## Multi-test suites + +A single test suite (Markdown file) can contain multiple tests, by demarcating them using Markdown +header lines: + +````markdown +# Same-file invalid assignment + +```py +x: int = "foo" # error: [invalid-assignment] +``` + +# Cross-file invalid assignment + +```py +from b import y +x: int = y # error: [invalid-assignment] +``` + +```py path=b.py +y = "foo" +``` +```` + +This test suite contains two tests, one named "Same-file invalid assignment" and the other named +"Cross-file invalid assignment". The first test involves only a single embedded file, and the second +test involves two embedded files. + +The tests are run independently, in independent in-memory file systems and with new red-knot +[Salsa](https://github.com/salsa-rs/salsa) databases. This means that each is a from-scratch run of +the type checker, with no data persisting from any previous test. + +Due to `cargo test` limitations, an entire test suite (Markdown file) is run as a single Rust test, +so it's not possible to select individual tests within it to run. + +## Structured test suites + +Markdown headers can also be used to group related tests within a suite: + +````markdown +# Literals + +## Numbers + +### Integer + +```py +reveal_type(1) # revealed: Literal[1] +``` + +### Float + +```py +reveal_type(1.0) # revealed: float +``` + +## Strings + +```py +reveal_type("foo") # revealed: Literal["foo"] +``` +```` + +This test suite contains three tests, named "Literals - Numbers - Integer", "Literals - Numbers - +Float", and "Literals - Strings". + +A header-demarcated section must either be a test or a grouping header; it cannot be both. That is, +a header section can either contain embedded files (making it a test), or it can contain more +deeply-nested headers (headers with more `#`), but it cannot contain both. + +## Documentation of tests + +Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by +the test framework) between fenced code blocks. This permits natural documentation of +why a test exists, and what it intends to assert: + +````markdown +Assigning a string to a variable annotated as `int` is not permitted: + +```py +x: int = "foo" # error: [invalid-assignment] +``` +```` + +## Planned features + +There are some designed features that we intend for the test framework to have, but have not yet +implemented: + +### Multi-line diagnostic assertions + +We may want to be able to assert that a diagnostic spans multiple lines, and to assert the columns it +begins and/or ends on. The planned syntax for this will use `<<<` and `>>>` to mark the start and end lines for +an assertion: + +```py +(3 # error: 2 [unsupported-operands] <<< + + + "foo") # error: 6 >>> +``` + +The column assertion `6` on the ending line should be optional. + +In cases of overlapping such assertions, resolve ambiguity using more angle brackets: `<<<<` begins +an assertion ended by `>>>>`, etc. + +### Non-Python files + +Some tests may need to specify non-Python embedded files: typeshed `stdlib/VERSIONS`, `pth` files, +`py.typed` files, `pyvenv.cfg` files... + +We will allow specifying any of these using the `text` language in the code block tag string: + +````markdown +```text path=/third-party/foo/py.typed +partial +``` +```` + +We may want to also support testing Jupyter notebooks as embedded files; exact syntax for this is +yet to be determined. + +Of course, red-knot is only run directly on `py` and `pyi` files, and assertion comments are only +possible in these files. + +A fenced code block with no language will always be an error. + +### Configuration + +We will add the ability to specify non-default red-knot configurations to use in tests, by including +a TOML code block: + +````markdown +```toml +[tool.knot] +warn-on-any = true +``` + +```py +from typing import Any + +def f(x: Any): # error: [use-of-any] + pass +``` +```` + +It should be possible to include a TOML code block in a single test (as shown), or in a grouping +section, in which case it applies to all nested tests within that grouping section. Configurations +at multiple level are allowed and merged, with the most-nested (closest to the test) taking +precedence. + +### Running just a single test from a suite + +Having each test in a suite always run as a distinct Rust test would require writing our own test +runner or code-generating tests in a build script; neither of these is planned. + +We could still allow running just a single test from a suite, for debugging purposes, either via +some "focus" syntax that could be easily temporarily added to a test, or via an environment +variable. + +### Configuring search paths and kinds + +The red-knot TOML configuration format hasn't been designed yet, and we may want to implement +support in the test framework for configuring search paths before it is designed. If so, we can +define some configuration options for now under the `[tool.knot.tests]` namespace. In the future, +perhaps some of these can be replaced by real red-knot configuration options; some or all may also +be kept long-term as test-specific options. + +Some configuration options we will want to provide: + +- We should be able to configure the default workspace root to something other than `/src/` using a + `workspace-root` configuration option. + +- We should be able to add a third-party root using the `third-party-root` configuration option. + +- We may want to add additional configuration options for setting additional search path kinds. + +Paths for `workspace-root` and `third-party-root` must be absolute. + +Relative embedded-file paths are relative to the workspace root, even if it is explicitly set to a +non-default value using the `workspace-root` config. + +### Specifying a custom typeshed + +Some tests will need to override the default typeshed with custom files. The `[tool.knot.tests]` +configuration option `typeshed-root` should be usable for this: + +````markdown +```toml +[tool.knot.tests] +typeshed-root = "/typeshed" +``` + +This file is importable as part of our custom typeshed, because it is within `/typeshed`, which we +configured above as our custom typeshed root: + +```py path=/typeshed/stdlib/builtins.pyi +I_AM_THE_ONLY_BUILTIN = 1 +``` + +This file is written to `/src/test.py`, because the default workspace root is `/src/ and the default +file path is `test.py`: + +```py +reveal_type(I_AM_THE_ONLY_BUILTIN) # revealed: Literal[1] +``` + +```` + +A fenced code block with language `text` can be used to provide a `stdlib/VERSIONS` file in the +custom typeshed root. If no such file is created explicitly, one should be created implicitly +including entries enabling all specified `<typeshed-root>/stdlib` files for all supported Python +versions. + +### I/O errors + +We could use an `error=` configuration option in the tag string to make an embedded file cause an +I/O error on read. + +### Asserting on full diagnostic output + +The inline comment diagnostic assertions are useful for making quick, readable assertions about +diagnostics in a particular location. But sometimes we will want to assert on the full diagnostic +output of checking an embedded Python file. Or sometimes (see “incremental tests” below) we will +want to assert on diagnostics in a file, without impacting the contents of that file by changing a +comment in it. In these cases, a Python code block in a test could be followed by a fenced code +block with language `output`; this would contain the full diagnostic output for the preceding test +file: + +````markdown +# full output + +```py +x = 1 +reveal_type(x) +``` + +This is just an example, not a proposal that red-knot would ever actually output diagnostics in +precisely this format: + +```output +test.py, line 1, col 1: revealed type is 'Literal[1]' +``` +```` + +We will want to build tooling to automatically capture and update these “full diagnostic output” +blocks, when tests are run in an update-output mode (probably specified by an environment variable.) + +By default, an `output` block will specify diagnostic output for the file `<workspace-root>/test.py`. +An `output` block can have a `path=` option, to explicitly specify the Python file for which it +asserts diagnostic output, and a `stage=` option, to specify which stage of an incremental test it +specifies diagnostic output at. (See “incremental tests” below.) + +It is an error for an `output` block to exist, if there is no `py` or `python` block in the same +test for the same file path. + +### Incremental tests + +Some tests should validate incremental checking, by initially creating some files, checking them, +and then modifying/adding/deleting files and checking again. + +We should add the capability to create an incremental test by using the `stage=` option on some +fenced code blocks in the test: + +````markdown +# Incremental + +## modify a file + +Initial version of `test.py` and `b.py`: + +```py +from b import x +reveal_type(x) +``` + +```py path=b.py +x = 1 +``` + +Initial expected output for `test.py`: + +```output +/src/test.py, line 1, col 1: revealed type is 'Literal[1]' +``` + +Now in our first incremental stage, modify the contents of `b.py`: + +```py path=b.py stage=1 +# b.py +x = 2 +``` + +And this is our updated expected output for `test.py` at stage 1: + +```output stage=1 +/src/test.py, line 1, col 1: revealed type is 'Literal[2]' +``` + +(One reason to use full-diagnostic-output blocks in this test is that updating +inline-comment diagnostic assertions for `test.py` would require specifying new +contents for `test.py` in stage 1, which we don't want to do in this test.) +```` + +It will be possible to provide any number of stages in an incremental test. If a stage re-specifies +a filename that was specified in a previous stage (or the initial stage), that file is modified. A +new filename appearing for the first time in a new stage will create a new file. To delete a +previously created file, specify that file with the tag `delete` in its tag string (in this case, it +is an error to provide non-empty contents). Any previously-created files that are not re-specified +in a later stage continue to exist with their previously-specified contents, and are not "touched". + +All stages should be run in order, incrementally, and then the final state should also be re-checked +cold, to validate equivalence of cold and incremental check results. + +[^extensions]: `typing-extensions` is a third-party module, but typeshed, and thus type checkers + also, treat it as part of the standard library. diff --git a/crates/red_knot_test/src/matcher.rs b/crates/red_knot_test/src/matcher.rs index 7cd9604db40ca..0cddea17217a2 100644 --- a/crates/red_knot_test/src/matcher.rs +++ b/crates/red_knot_test/src/matcher.rs @@ -1,6 +1,6 @@ //! Match [`TypeCheckDiagnostic`]s against [`Assertion`]s and produce test failure messages for any //! mismatches. -use crate::assertion::{Assertion, InlineFileAssertions}; +use crate::assertion::{Assertion, ErrorAssertion, InlineFileAssertions}; use crate::db::Db; use crate::diagnostic::SortedDiagnostics; use red_knot_python_semantic::types::TypeCheckDiagnostic; @@ -145,6 +145,20 @@ trait Unmatched { fn unmatched(&self) -> String; } +fn unmatched<'a, T: Unmatched + 'a>(unmatched: &'a [T]) -> Vec<String> { + unmatched.iter().map(Unmatched::unmatched).collect() +} + +trait UnmatchedWithColumn { + fn unmatched_with_column(&self, column: OneIndexed) -> String; +} + +impl Unmatched for Assertion<'_> { + fn unmatched(&self) -> String { + format!("unmatched assertion: {self}") + } +} + impl<T> Unmatched for T where T: Diagnostic, @@ -158,16 +172,19 @@ where } } -impl Unmatched for Assertion<'_> { - fn unmatched(&self) -> String { - format!("unmatched assertion: {self}") +impl<T> UnmatchedWithColumn for T +where + T: Diagnostic, +{ + fn unmatched_with_column(&self, column: OneIndexed) -> String { + format!( + r#"unexpected error: {column} [{}] "{}""#, + self.rule(), + self.message() + ) } } -fn unmatched<'a, T: Unmatched + 'a>(unmatched: &'a [T]) -> Vec<String> { - unmatched.iter().map(Unmatched::unmatched).collect() -} - struct Matcher { line_index: LineIndex, source: SourceText, @@ -195,12 +212,23 @@ impl Matcher { let mut failures = vec![]; let mut unmatched: Vec<_> = diagnostics.iter().collect(); for assertion in assertions { + if matches!( + assertion, + Assertion::Error(ErrorAssertion { + rule: None, + message_contains: None, + .. + }) + ) { + failures.push("invalid assertion: no rule or message text".to_string()); + continue; + } if !self.matches(assertion, &mut unmatched) { failures.push(assertion.unmatched()); } } for diagnostic in unmatched { - failures.push(diagnostic.unmatched()); + failures.push(diagnostic.unmatched_with_column(self.column(diagnostic))); } if failures.is_empty() { Ok(()) @@ -285,6 +313,7 @@ mod tests { use super::FailuresByLine; use ruff_db::files::system_path_to_file; use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; + use ruff_python_trivia::textwrap::dedent; use ruff_source_file::OneIndexed; use ruff_text_size::{Ranged, TextRange}; @@ -387,7 +416,7 @@ mod tests { 0, &[ "unmatched assertion: revealed: Foo", - r#"unexpected error: [not-revealed-type] "Revealed type is `Foo`""#, + r#"unexpected error: 1 [not-revealed-type] "Revealed type is `Foo`""#, ], )], ); @@ -406,7 +435,7 @@ mod tests { 0, &[ "unmatched assertion: revealed: Foo", - r#"unexpected error: [revealed-type] "Something else""#, + r#"unexpected error: 1 [revealed-type] "Something else""#, ], )], ); @@ -445,56 +474,17 @@ mod tests { 0, &[ "unmatched assertion: revealed: Foo", - r#"unexpected error: [undefined-reveal] "Doesn't matter""#, + r#"unexpected error: 1 [undefined-reveal] "Doesn't matter""#, ], )], ); } - #[test] - fn error_match() { - let result = get_result( - "x # error:", - vec![TestDiagnostic::new("anything", "Any message", 0)], - ); - - assert_ok(&result); - } - #[test] fn error_unmatched() { - let result = get_result("x # error:", vec![]); - - assert_fail(result, &[(0, &["unmatched assertion: error:"])]); - } - - #[test] - fn error_match_column() { - let result = get_result( - "x # error: 1", - vec![TestDiagnostic::new("anything", "Any message", 0)], - ); - - assert_ok(&result); - } - - #[test] - fn error_wrong_column() { - let result = get_result( - "x # error: 2", - vec![TestDiagnostic::new("anything", "Any message", 0)], - ); + let result = get_result("x # error: [rule]", vec![]); - assert_fail( - result, - &[( - 0, - &[ - "unmatched assertion: error: 2", - r#"unexpected error: [anything] "Any message""#, - ], - )], - ); + assert_fail(result, &[(0, &["unmatched assertion: error: [rule]"])]); } #[test] @@ -520,7 +510,7 @@ mod tests { 0, &[ "unmatched assertion: error: [some-rule]", - r#"unexpected error: [anything] "Any message""#, + r#"unexpected error: 1 [anything] "Any message""#, ], )], ); @@ -549,7 +539,7 @@ mod tests { 0, &[ r#"unmatched assertion: error: "contains this""#, - r#"unexpected error: [anything] "Any message""#, + r#"unexpected error: 1 [anything] "Any message""#, ], )], ); @@ -565,6 +555,25 @@ mod tests { assert_ok(&result); } + #[test] + fn error_wrong_column() { + let result = get_result( + "x # error: 2 [rule]", + vec![TestDiagnostic::new("rule", "Any message", 0)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: error: 2 [rule]", + r#"unexpected error: 1 [rule] "Any message""#, + ], + )], + ); + } + #[test] fn error_match_column_and_message() { let result = get_result( @@ -608,7 +617,7 @@ mod tests { 0, &[ r#"unmatched assertion: error: 2 [some-rule] "contains this""#, - r#"unexpected error: [some-rule] "message contains this""#, + r#"unexpected error: 1 [some-rule] "message contains this""#, ], )], ); @@ -631,7 +640,7 @@ mod tests { 0, &[ r#"unmatched assertion: error: 1 [some-rule] "contains this""#, - r#"unexpected error: [other-rule] "message contains this""#, + r#"unexpected error: 1 [other-rule] "message contains this""#, ], )], ); @@ -650,7 +659,7 @@ mod tests { 0, &[ r#"unmatched assertion: error: 1 [some-rule] "contains this""#, - r#"unexpected error: [some-rule] "Any message""#, + r#"unexpected error: 1 [some-rule] "Any message""#, ], )], ); @@ -658,19 +667,21 @@ mod tests { #[test] fn interspersed_matches_and_mismatches() { - let source = r#" + let source = dedent( + r#" 1 # error: [line-one] 2 3 # error: [line-three] 4 # error: [line-four] 5 6: # error: [line-six] - "#; + "#, + ); let two = source.find('2').unwrap(); let three = source.find('3').unwrap(); let five = source.find('5').unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("line-two", "msg", two), TestDiagnostic::new("line-three", "msg", three), @@ -692,14 +703,16 @@ mod tests { #[test] fn more_diagnostics_than_assertions() { - let source = r#" + let source = dedent( + r#" 1 # error: [line-one] 2 - "#; + "#, + ); let one = source.find('1').unwrap(); let two = source.find('2').unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("line-one", "msg", one), TestDiagnostic::new("line-two", "msg", two), @@ -711,14 +724,16 @@ mod tests { #[test] fn multiple_assertions_and_diagnostics_same_line() { - let source = " + let source = dedent( + " # error: [one-rule] # error: [other-rule] x - "; + ", + ); let x = source.find('x').unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("one-rule", "msg", x), TestDiagnostic::new("other-rule", "msg", x), @@ -730,14 +745,16 @@ mod tests { #[test] fn multiple_assertions_and_diagnostics_same_line_all_same() { - let source = " + let source = dedent( + " # error: [one-rule] # error: [one-rule] x - "; + ", + ); let x = source.find('x').unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("one-rule", "msg", x), TestDiagnostic::new("one-rule", "msg", x), @@ -749,14 +766,16 @@ mod tests { #[test] fn multiple_assertions_and_diagnostics_same_line_mismatch() { - let source = " + let source = dedent( + " # error: [one-rule] # error: [other-rule] x - "; + ", + ); let x = source.find('x').unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("one-rule", "msg", x), TestDiagnostic::new("other-rule", "msg", x), @@ -764,20 +783,25 @@ mod tests { ], ); - assert_fail(result, &[(3, &[r#"unexpected error: [third-rule] "msg""#])]); + assert_fail( + result, + &[(3, &[r#"unexpected error: 1 [third-rule] "msg""#])], + ); } #[test] fn parenthesized_expression() { - let source = " + let source = dedent( + " a = b + ( error: [undefined-reveal] reveal_type(5) # revealed: Literal[5] ) - "; + ", + ); let reveal = source.find("reveal_type").unwrap(); let result = get_result( - source, + &source, vec![ TestDiagnostic::new("undefined-reveal", "msg", reveal), TestDiagnostic::new("revealed-type", "Revealed type is `Literal[5]`", reveal), @@ -786,4 +810,46 @@ mod tests { assert_ok(&result); } + + #[test] + fn bare_error_assertion_not_allowed() { + let source = "x # error:"; + let x = source.find('x').unwrap(); + let result = get_result( + source, + vec![TestDiagnostic::new("some-rule", "some message", x)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "invalid assertion: no rule or message text", + r#"unexpected error: 1 [some-rule] "some message""#, + ], + )], + ); + } + + #[test] + fn column_only_error_assertion_not_allowed() { + let source = "x # error: 1"; + let x = source.find('x').unwrap(); + let result = get_result( + source, + vec![TestDiagnostic::new("some-rule", "some message", x)], + ); + + assert_fail( + result, + &[( + 0, + &[ + "invalid assertion: no rule or message text", + r#"unexpected error: 1 [some-rule] "some message""#, + ], + )], + ); + } } From 6ae833e0c7196a4cd6e5ac9088cb1e99dd1d422b Mon Sep 17 00:00:00 2001 From: Carl Meyer <carl@astral.sh> Date: Thu, 10 Oct 2024 17:33:53 -0700 Subject: [PATCH 56/88] [red-knot] mdtest usability improvements for reveal_type (#13709) ## Summary Fixes #13708. Silence `undefined-reveal` diagnostic on any line including a `# revealed:` assertion. Add more context to un-silenced `undefined-reveal` diagnostics in mdtest test failures. This doesn't make the failure output less verbose, but it hopefully clarifies the right fix for an `undefined-reveal` in mdtest, while still making it clear what red-knot's normal diagnostic for this looks like. ## Test Plan Added and updated tests. --- crates/red_knot_test/src/matcher.rs | 122 ++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 27 deletions(-) diff --git a/crates/red_knot_test/src/matcher.rs b/crates/red_knot_test/src/matcher.rs index 0cddea17217a2..24defb94be0a7 100644 --- a/crates/red_knot_test/src/matcher.rs +++ b/crates/red_knot_test/src/matcher.rs @@ -159,15 +159,28 @@ impl Unmatched for Assertion<'_> { } } +fn maybe_add_undefined_reveal_clarification<T: Diagnostic>( + diagnostic: &T, + original: std::fmt::Arguments, +) -> String { + if diagnostic.rule() == "undefined-reveal" { + format!( + "used built-in `reveal_type`: add a `# revealed` assertion on this line \ + (original diagnostic: {original})" + ) + } else { + format!("unexpected error: {original}") + } +} + impl<T> Unmatched for T where T: Diagnostic, { fn unmatched(&self) -> String { - format!( - r#"unexpected error: [{}] "{}""#, - self.rule(), - self.message() + maybe_add_undefined_reveal_clarification( + self, + format_args!(r#"[{}] "{}""#, self.rule(), self.message()), ) } } @@ -177,10 +190,9 @@ where T: Diagnostic, { fn unmatched_with_column(&self, column: OneIndexed) -> String { - format!( - r#"unexpected error: {column} [{}] "{}""#, - self.rule(), - self.message() + maybe_add_undefined_reveal_clarification( + self, + format_args!(r#"{column} [{}] "{}""#, self.rule(), self.message()), ) } } @@ -291,18 +303,14 @@ impl Matcher { break; } } - if matched_revealed_type.is_some() { - let mut idx = 0; - unmatched.retain(|_| { - let retain = Some(idx) != matched_revealed_type - && Some(idx) != matched_undefined_reveal; - idx += 1; - retain - }); - true - } else { - false - } + let mut idx = 0; + unmatched.retain(|_| { + let retain = + Some(idx) != matched_revealed_type && Some(idx) != matched_undefined_reveal; + idx += 1; + retain + }); + matched_revealed_type.is_some() } } } @@ -386,7 +394,7 @@ mod tests { } #[test] - fn type_match() { + fn revealed_match() { let result = get_result( "x # revealed: Foo", vec![TestDiagnostic::new( @@ -400,7 +408,7 @@ mod tests { } #[test] - fn type_wrong_rule() { + fn revealed_wrong_rule() { let result = get_result( "x # revealed: Foo", vec![TestDiagnostic::new( @@ -423,7 +431,7 @@ mod tests { } #[test] - fn type_wrong_message() { + fn revealed_wrong_message() { let result = get_result( "x # revealed: Foo", vec![TestDiagnostic::new("revealed-type", "Something else", 0)], @@ -442,14 +450,14 @@ mod tests { } #[test] - fn type_unmatched() { + fn revealed_unmatched() { let result = get_result("x # revealed: Foo", vec![]); assert_fail(result, &[(0, &["unmatched assertion: revealed: Foo"])]); } #[test] - fn type_match_with_undefined() { + fn revealed_match_with_undefined() { let result = get_result( "x # revealed: Foo", vec![ @@ -462,19 +470,79 @@ mod tests { } #[test] - fn type_match_with_only_undefined() { + fn revealed_match_with_only_undefined() { let result = get_result( "x # revealed: Foo", vec![TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0)], ); + assert_fail(result, &[(0, &["unmatched assertion: revealed: Foo"])]); + } + + #[test] + fn revealed_mismatch_with_undefined() { + let result = get_result( + "x # revealed: Foo", + vec![ + TestDiagnostic::new("revealed-type", "Revealed type is `Bar`", 0), + TestDiagnostic::new("undefined-reveal", "Doesn't matter", 0), + ], + ); + assert_fail( result, &[( 0, &[ "unmatched assertion: revealed: Foo", - r#"unexpected error: 1 [undefined-reveal] "Doesn't matter""#, + r#"unexpected error: 1 [revealed-type] "Revealed type is `Bar`""#, + ], + )], + ); + } + + #[test] + fn undefined_reveal_type_unmatched() { + let result = get_result( + "reveal_type(1)", + vec![ + TestDiagnostic::new("undefined-reveal", "undefined reveal message", 0), + TestDiagnostic::new("revealed-type", "Revealed type is `Literal[1]`", 12), + ], + ); + + assert_fail( + result, + &[( + 0, + &[ + "used built-in `reveal_type`: add a `# revealed` assertion on this line (\ + original diagnostic: [undefined-reveal] \"undefined reveal message\")", + r#"unexpected error: [revealed-type] "Revealed type is `Literal[1]`""#, + ], + )], + ); + } + + #[test] + fn undefined_reveal_type_mismatched() { + let result = get_result( + "reveal_type(1) # error: [something-else]", + vec![ + TestDiagnostic::new("undefined-reveal", "undefined reveal message", 0), + TestDiagnostic::new("revealed-type", "Revealed type is `Literal[1]`", 12), + ], + ); + + assert_fail( + result, + &[( + 0, + &[ + "unmatched assertion: error: [something-else]", + "used built-in `reveal_type`: add a `# revealed` assertion on this line (\ + original diagnostic: 1 [undefined-reveal] \"undefined reveal message\")", + r#"unexpected error: 13 [revealed-type] "Revealed type is `Literal[1]`""#, ], )], ); From 3209953276731f68bc1646864217af40eabe79d5 Mon Sep 17 00:00:00 2001 From: Carl Meyer <carl@astral.sh> Date: Fri, 11 Oct 2024 12:36:48 -0700 Subject: [PATCH 57/88] [red-knot] clarify mdtest README (#13720) Address a potential point of confusion that bit a contributor in https://github.com/astral-sh/ruff/pull/13719 Also remove a no-longer-accurate line about bare `error: ` assertions (which are no longer allowed) and clarify another point about which kinds of error assertions to use. --- crates/red_knot_test/README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/red_knot_test/README.md b/crates/red_knot_test/README.md index c5ea01bc8a9c9..9a5964db5c37a 100644 --- a/crates/red_knot_test/README.md +++ b/crates/red_knot_test/README.md @@ -25,6 +25,15 @@ match the resulting diagnostics with the assertions in the test. Assertions are Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise, it fails. +<!--- +(If you are reading this document in raw Markdown source rather than rendered Markdown, note that +the quadruple-backtick-fenced "markdown" language code block above is NOT itself part of the mdtest +syntax, it's just how this README embeds an example mdtest Markdown document.) +---> + +See actual example mdtest suites in +[`crates/red_knot_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/resources/mdtest). + ## Assertions Two kinds of assertions are supported: `# revealed:` (shown above) and `# error:`. @@ -58,9 +67,8 @@ something about the behavior of importing it.) #### error -A comment beginning with `# error:` is an assertion that a type checker diagnostic will -be emitted, with text span starting on that line. If the comment is simply `# error:`, this will -match any diagnostic. The matching can be narrowed in three ways: +A comment beginning with `# error:` is an assertion that a type checker diagnostic will be emitted, +with text span starting on that line. The matching can be narrowed in three ways: - `# error: [invalid-assignment]` requires that the matched diagnostic have the rule code `invalid-assignment`. (The square brackets are required.) @@ -75,7 +83,8 @@ present; then contains-text, if present. For example, an assertion using all thr `# error: 8 [invalid-assignment] "Some text"`. Error assertions in tests intended to test type checker semantics should primarily use rule-code -assertions, with occasional contains-text assertions where needed to disambiguate. +assertions, with occasional contains-text assertions where needed to disambiguate or validate some +details of the diagnostic message. ### Assertion locations From 46bc69d1d499d50443451a7fff4aa6b80fd9cf8b Mon Sep 17 00:00:00 2001 From: Steve C <diceroll123@gmail.com> Date: Sun, 13 Oct 2024 06:33:03 -0400 Subject: [PATCH 58/88] [`flake8-pyi`] - fix dropped exprs in `PYI030` autofix (#13727) --- .../test/fixtures/flake8_pyi/PYI030.py | 4 ++++ .../rules/unnecessary_literal_union.rs | 2 ++ ...__flake8_pyi__tests__PYI030_PYI030.py.snap | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI030.py b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI030.py index c6cd2b453707a..59edf252dde17 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI030.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI030.py @@ -87,3 +87,7 @@ def func2() -> Literal[1] | Literal[2]: # Error # Should use the first literal subscript attribute when fixing field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error + +from typing import IO, Literal + +InlineOption = Literal["a"] | Literal["b"] | IO[str] diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs index ed062c8e266ef..0ea87fa88d71e 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/unnecessary_literal_union.rs @@ -80,6 +80,8 @@ pub(crate) fn unnecessary_literal_union<'a>(checker: &mut Checker, expr: &'a Exp } else { literal_exprs.push(slice); } + } else { + other_exprs.push(expr); } } else { other_exprs.push(expr); diff --git a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap index 47a803b8da44a..82093c63aaa00 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap +++ b/crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI030_PYI030.py.snap @@ -517,6 +517,8 @@ PYI030.py:89:10: PYI030 [*] Multiple literal members in a union. Use a single li 88 | # Should use the first literal subscript attribute when fixing 89 | field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI030 +90 | +91 | from typing import IO, Literal | = help: Replace with a single `Literal` @@ -526,5 +528,22 @@ PYI030.py:89:10: PYI030 [*] Multiple literal members in a union. Use a single li 88 88 | # Should use the first literal subscript attribute when fixing 89 |-field25: typing.Union[typing_extensions.Literal[1], typing.Union[Literal[2], typing.Union[Literal[3], Literal[4]]], str] # Error 89 |+field25: typing.Union[typing_extensions.Literal[1, 2, 3, 4], str] # Error +90 90 | +91 91 | from typing import IO, Literal +92 92 | +PYI030.py:93:16: PYI030 [*] Multiple literal members in a union. Use a single literal, e.g. `Literal["a", "b"]` + | +91 | from typing import IO, Literal +92 | +93 | InlineOption = Literal["a"] | Literal["b"] | IO[str] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI030 + | + = help: Replace with a single `Literal` +ℹ Safe fix +90 90 | +91 91 | from typing import IO, Literal +92 92 | +93 |-InlineOption = Literal["a"] | Literal["b"] | IO[str] + 93 |+InlineOption = Literal["a", "b"] | IO[str] From defdc4dd8e88b881f113a9d7fb5cc4e8ef22ac35 Mon Sep 17 00:00:00 2001 From: Alex Waygood <Alex.Waygood@Gmail.com> Date: Sun, 13 Oct 2024 14:20:35 +0100 Subject: [PATCH 59/88] [red-knot] Use colors to improve readability of `mdtest` output (#13725) --- Cargo.lock | 1 + crates/red_knot_test/Cargo.toml | 1 + crates/red_knot_test/src/lib.rs | 12 ++++++++---- crates/red_knot_test/src/matcher.rs | 16 +++++++++++----- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df14e77427054..2e586d7220f80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2134,6 +2134,7 @@ name = "red_knot_test" version = "0.0.0" dependencies = [ "anyhow", + "colored", "once_cell", "red_knot_python_semantic", "red_knot_vendored", diff --git a/crates/red_knot_test/Cargo.toml b/crates/red_knot_test/Cargo.toml index 88059cb0e265a..5fcc0b9143edb 100644 --- a/crates/red_knot_test/Cargo.toml +++ b/crates/red_knot_test/Cargo.toml @@ -20,6 +20,7 @@ ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } anyhow = { workspace = true } +colored = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/red_knot_test/src/lib.rs b/crates/red_knot_test/src/lib.rs index 2d05e6283ab01..688b906ac09d0 100644 --- a/crates/red_knot_test/src/lib.rs +++ b/crates/red_knot_test/src/lib.rs @@ -1,3 +1,4 @@ +use colored::Colorize; use parser as test_parser; use red_knot_python_semantic::types::check_types; use ruff_db::files::system_path_to_file; @@ -31,13 +32,14 @@ pub fn run(path: &PathBuf, title: &str) { for test in suite.tests() { if let Err(failures) = run_test(&test) { any_failures = true; - println!("{}", test.name()); + println!("\n{}\n", test.name().bold().underline()); for (path, by_line) in failures { - println!(" {path}"); - for (line, failures) in by_line.iter() { + println!("{}", path.as_str().bold()); + for (line_number, failures) in by_line.iter() { for failure in failures { - println!(" line {line}: {failure}"); + let line_info = format!("line {line_number}:").cyan(); + println!(" {line_info} {failure}"); } } println!(); @@ -45,6 +47,8 @@ pub fn run(path: &PathBuf, title: &str) { } } + println!("{}\n", "-".repeat(50)); + assert!(!any_failures, "Some tests failed."); } diff --git a/crates/red_knot_test/src/matcher.rs b/crates/red_knot_test/src/matcher.rs index 24defb94be0a7..fbc3d9ba7739d 100644 --- a/crates/red_knot_test/src/matcher.rs +++ b/crates/red_knot_test/src/matcher.rs @@ -3,6 +3,7 @@ use crate::assertion::{Assertion, ErrorAssertion, InlineFileAssertions}; use crate::db::Db; use crate::diagnostic::SortedDiagnostics; +use colored::Colorize; use red_knot_python_semantic::types::TypeCheckDiagnostic; use ruff_db::files::File; use ruff_db::source::{line_index, source_text, SourceText}; @@ -155,7 +156,7 @@ trait UnmatchedWithColumn { impl Unmatched for Assertion<'_> { fn unmatched(&self) -> String { - format!("unmatched assertion: {self}") + format!("{} {self}", "unmatched assertion:".red()) } } @@ -165,11 +166,11 @@ fn maybe_add_undefined_reveal_clarification<T: Diagnostic>( ) -> String { if diagnostic.rule() == "undefined-reveal" { format!( - "used built-in `reveal_type`: add a `# revealed` assertion on this line \ - (original diagnostic: {original})" + "{} add a `# revealed` assertion on this line (original diagnostic: {original})", + "used built-in `reveal_type`:".yellow() ) } else { - format!("unexpected error: {original}") + format!("{} {original}", "unexpected error:".red()) } } @@ -232,7 +233,10 @@ impl Matcher { .. }) ) { - failures.push("invalid assertion: no rule or message text".to_string()); + failures.push(format!( + "{} no rule or message text", + "invalid assertion:".red() + )); continue; } if !self.matches(assertion, &mut unmatched) { @@ -360,6 +364,8 @@ mod tests { } fn get_result(source: &str, diagnostics: Vec<TestDiagnostic>) -> Result<(), FailuresByLine> { + colored::control::set_override(false); + let mut db = crate::db::Db::setup(SystemPathBuf::from("/src")); db.write_file("/src/test.py", source).unwrap(); let file = system_path_to_file(&db, "/src/test.py").unwrap(); From 8445e4725cb716890797c708c17258c347263da4 Mon Sep 17 00:00:00 2001 From: Micha Reiser <micha@reiser.io> Date: Mon, 14 Oct 2024 09:17:38 +0200 Subject: [PATCH 60/88] Downgrade benchmarks CI job to ubuntu 22 (#13743) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a9cbebdf6dc86..aebffa251ca1e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -608,7 +608,7 @@ jobs: just test benchmarks: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: determine_changes if: ${{ github.repository == 'astral-sh/ruff' && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }} timeout-minutes: 20 From 3111dce5b4de1c47e6092bdee41882d40f6a83dc Mon Sep 17 00:00:00 2001 From: Micha Reiser <micha@reiser.io> Date: Mon, 14 Oct 2024 09:31:35 +0200 Subject: [PATCH 61/88] Fix `mkdocs` CI job (#13744) --- .github/workflows/ci.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aebffa251ca1e..1b44b470a6545 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -518,6 +518,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: "Add SSH key" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} uses: webfactory/ssh-agent@v0.9.0 @@ -525,13 +527,15 @@ jobs: ssh-private-key: ${{ secrets.MKDOCS_INSIDERS_SSH_KEY }} - name: "Install Rust toolchain" run: rustup show + - name: Install uv + uses: astral-sh/setup-uv@v3 - uses: Swatinem/rust-cache@v2 - name: "Install Insiders dependencies" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} - run: pip install -r docs/requirements-insiders.txt + run: uv pip install -r docs/requirements-insiders.txt --system - name: "Install dependencies" if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS != 'true' }} - run: pip install -r docs/requirements.txt + run: uv pip install -r docs/requirements.txt --system - name: "Update README File" run: python scripts/transform_readme.py --target mkdocs - name: "Generate docs" From e4c0dd6f962c8a9dcee56f53875ec6a997877064 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:38:45 +0000 Subject: [PATCH 62/88] Update rust-wasm-bindgen monorepo (#13738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e586d7220f80..b6afe2480d9c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,9 +1311,9 @@ checksum = "8b23360e99b8717f20aaa4598f5a6541efbe30630039fbc7706cf954a87947ae" [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -3857,9 +3857,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -3868,9 +3868,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -3883,9 +3883,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", @@ -3895,9 +3895,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3905,9 +3905,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -3918,15 +3918,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-bindgen-test" -version = "0.3.43" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9" +checksum = "d381749acb0943d357dcbd8f0b100640679883fcdeeef04def49daf8d33a5426" dependencies = [ "console_error_panic_hook", "js-sys", @@ -3939,9 +3939,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.43" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" +checksum = "c97b2ef2c8d627381e51c071c2ab328eac606d3f69dd82bcbca20a9e389d95f0" dependencies = [ "proc-macro2", "quote", From 63df94b521c68a5ee5bcb84070bc93675e626272 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:39:46 +0000 Subject: [PATCH 63/88] Update Rust crate proc-macro2 to v1.0.87 (#13735) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b6afe2480d9c5..256ff7618d534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,9 +1943,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] From dd5018ac5518c94cc67d631239dd9eb9a8638219 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:40:49 +0000 Subject: [PATCH 64/88] Update dependency @miniflare/kv to v2.14.4 (#13736) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- playground/api/package-lock.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 8e4042a32e24b..8017c2f852dc1 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -559,20 +559,22 @@ } }, "node_modules/@miniflare/kv": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/@miniflare/kv/-/kv-2.14.2.tgz", - "integrity": "sha512-3rs4cJOGACT/U7NH7j4KD29ugXRYUiM0aGkvOEdFQtChXLsYClJljXpezTfJJxBwZjdS4F2UFTixtFcHp74UfA==", + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/@miniflare/kv/-/kv-2.14.4.tgz", + "integrity": "sha512-QlERH0Z+klwLg0xw+/gm2yC34Nnr/I0GcQ+ASYqXeIXBwjqOtMBa3YVQnocaD+BPy/6TUtSpOAShHsEj76R2uw==", + "license": "MIT", "dependencies": { - "@miniflare/shared": "2.14.2" + "@miniflare/shared": "2.14.4" }, "engines": { "node": ">=16.13" } }, - "node_modules/@miniflare/kv/node_modules/@miniflare/shared": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.2.tgz", - "integrity": "sha512-dDnYIztz10zDQjaFJ8Gy9UaaBWZkw3NyhFdpX6tAeyPA/2lGvkftc42MYmNi8s5ljqkZAtKgWAJnSf2K75NCJw==", + "node_modules/@miniflare/shared": { + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.4.tgz", + "integrity": "sha512-upl4RSB3hyCnITOFmRZjJj4A72GmkVrtfZTilkdq5Qe5TTlzsjVeDJp7AuNUM9bM8vswRo+N5jOiot6O4PVwwQ==", + "license": "MIT", "dependencies": { "@types/better-sqlite3": "^7.6.0", "kleur": "^4.1.4", From 58bc9816773c4006d9b87e3f454c80a4338f9b6a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:41:33 +0200 Subject: [PATCH 65/88] Update Rust crate pathdiff to v0.2.2 (#13734) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 256ff7618d534..80fc2b846939c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1720,9 +1720,9 @@ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" [[package]] name = "peg" From 4ef422d3b440bf0075198d49f07746996c1b71f9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:44:55 +0200 Subject: [PATCH 66/88] Update Rust crate clap to v4.5.20 (#13733) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80fc2b846939c..8b17aefc3305f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,9 +347,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.19" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -357,9 +357,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.19" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", From c3a3622e30409d9dad637ee90d1b16180968b140 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:51:13 +0200 Subject: [PATCH 67/88] Update Rust crate libcst to v1.5.0 (#13739) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b17aefc3305f..7da7c035dbb2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1352,9 +1352,9 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libcst" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10293a04a48e8b0cb2cc825a93b83090e527bffd3c897a0255ad7bc96079e920" +checksum = "1586dd7a857d8a61a577afde1a24cc9573ff549eff092d5ce968b1ec93cc61b6" dependencies = [ "chic", "libcst_derive", @@ -1690,9 +1690,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "path-absolutize" @@ -1726,9 +1726,9 @@ checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" [[package]] name = "peg" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "400bcab7d219c38abf8bd7cc2054eb9bbbd4312d66f6a5557d572a203f646f61" +checksum = "295283b02df346d1ef66052a757869b2876ac29a6bb0ac3f5f7cd44aebe40e8f" dependencies = [ "peg-macros", "peg-runtime", @@ -1736,9 +1736,9 @@ dependencies = [ [[package]] name = "peg-macros" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e61cce859b76d19090f62da50a9fe92bab7c2a5f09e183763559a2ac392c90" +checksum = "bdad6a1d9cf116a059582ce415d5f5566aabcd4008646779dab7fdc2a9a9d426" dependencies = [ "peg-runtime", "proc-macro2", @@ -1747,9 +1747,9 @@ dependencies = [ [[package]] name = "peg-runtime" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36bae92c60fa2398ce4678b98b2c4b5a7c61099961ca1fa305aec04a9ad28922" +checksum = "e3aeb8f54c078314c2065ee649a7241f46b9d8e418e1a9581ba0546657d7aa3a" [[package]] name = "pep440_rs" From 814ab475828b9cd474236bd728d885ec3e8bf76d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:52:28 +0000 Subject: [PATCH 68/88] Update dependency @miniflare/storage-memory to v2.14.4 (#13737) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- playground/api/package-lock.json | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 8017c2f852dc1..5c05c407e426c 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -586,25 +586,12 @@ } }, "node_modules/@miniflare/storage-memory": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/@miniflare/storage-memory/-/storage-memory-2.14.2.tgz", - "integrity": "sha512-9Wtz9mayHIY0LDsfpMGx+/sfKCq3eAQJzYY+ju1tTEaKR0sVAuO51wu0wbyldsjj9OcBcd2X32iPbIa7KcSZQQ==", - "dependencies": { - "@miniflare/shared": "2.14.2" - }, - "engines": { - "node": ">=16.13" - } - }, - "node_modules/@miniflare/storage-memory/node_modules/@miniflare/shared": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/@miniflare/shared/-/shared-2.14.2.tgz", - "integrity": "sha512-dDnYIztz10zDQjaFJ8Gy9UaaBWZkw3NyhFdpX6tAeyPA/2lGvkftc42MYmNi8s5ljqkZAtKgWAJnSf2K75NCJw==", + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/@miniflare/storage-memory/-/storage-memory-2.14.4.tgz", + "integrity": "sha512-9jB5BqNkMZ3SFjbPFeiVkLi1BuSahMhc/W1Y9H0W89qFDrrD+z7EgRgDtHTG1ZRyi9gIlNtt9qhkO1B6W2qb2A==", + "license": "MIT", "dependencies": { - "@types/better-sqlite3": "^7.6.0", - "kleur": "^4.1.4", - "npx-import": "^1.1.4", - "picomatch": "^2.3.1" + "@miniflare/shared": "2.14.4" }, "engines": { "node": ">=16.13" From 5caabe54b63bf2688d03d8c770bef6c3c0ce783e Mon Sep 17 00:00:00 2001 From: Dhruv Manilawala <dhruvmanila@gmail.com> Date: Mon, 14 Oct 2024 15:48:33 +0530 Subject: [PATCH 69/88] Allow `ipytest` cell magic (#13745) ## Summary fixes: #13718 ## Test Plan Using the notebook as mentioned in https://github.com/astral-sh/ruff/issues/13718#issuecomment-2410631674, this PR does not give the "F821 Undefined name `test_sorted`" diagnostic. --- crates/ruff_notebook/src/cell.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ruff_notebook/src/cell.rs b/crates/ruff_notebook/src/cell.rs index 1d7985e4a37aa..c917dd16274b8 100644 --- a/crates/ruff_notebook/src/cell.rs +++ b/crates/ruff_notebook/src/cell.rs @@ -238,9 +238,19 @@ impl Cell { // // This is to avoid false positives when these variables are referenced // elsewhere in the notebook. + // + // Refer https://github.com/astral-sh/ruff/issues/13718 for `ipytest`. !matches!( command, - "capture" | "debug" | "prun" | "pypy" | "python" | "python3" | "time" | "timeit" + "capture" + | "debug" + | "ipytest" + | "prun" + | "pypy" + | "python" + | "python3" + | "time" + | "timeit" ) }) } From 9bb4722ebf554aab472cae0c8bf29e8908902df7 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:21:45 +0200 Subject: [PATCH 70/88] [`flake8-todos`] Allow words starting with todo (#13640) Co-authored-by: Micha Reiser <micha@reiser.io> --- .../test/fixtures/flake8_todos/TD006.py | 1 + crates/ruff_linter/src/directives.rs | 45 +++++-------- ..._invalid-todo-capitalization_TD006.py.snap | 66 +++++++++---------- 3 files changed, 49 insertions(+), 63 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_todos/TD006.py b/crates/ruff_linter/resources/test/fixtures/flake8_todos/TD006.py index 90fbbe387b078..b811ee3a514a9 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_todos/TD006.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_todos/TD006.py @@ -1,5 +1,6 @@ # TDO006 - accepted # TODO (evanrittenhouse): this is a valid TODO +# Todoism is a word which starts with todo, but is not a todo # TDO006 - error # ToDo (evanrittenhouse): invalid capitalization # todo (evanrittenhouse): another invalid capitalization diff --git a/crates/ruff_linter/src/directives.rs b/crates/ruff_linter/src/directives.rs index 2972a3fe0e659..43c28df17150e 100644 --- a/crates/ruff_linter/src/directives.rs +++ b/crates/ruff_linter/src/directives.rs @@ -287,19 +287,23 @@ impl<'a> TodoDirective<'a> { pub(crate) fn from_comment(comment: &'a str, comment_range: TextRange) -> Option<Self> { // The directive's offset from the start of the comment. let mut relative_offset = TextSize::new(0); - let mut subset_opt = Some(comment); + let mut subset = comment; // Loop over `#`-delimited sections of the comment to check for directives. This will // correctly handle cases like `# foo # TODO`. - while let Some(subset) = subset_opt { + loop { let trimmed = subset.trim_start_matches('#').trim_start(); let offset = subset.text_len() - trimmed.text_len(); relative_offset += offset; + // Find the first word. Don't use split by whitespace because that would include the `:` character + // in `TODO:` + let first_word = trimmed.split(|c: char| !c.is_alphanumeric()).next()?; + // If we detect a TodoDirectiveKind variant substring in the comment, construct and // return the appropriate TodoDirective - if let Ok(directive_kind) = trimmed.parse::<TodoDirectiveKind>() { + if let Ok(directive_kind) = first_word.parse::<TodoDirectiveKind>() { let len = directive_kind.len(); return Some(Self { @@ -310,11 +314,11 @@ impl<'a> TodoDirective<'a> { } // Shrink the subset to check for the next phrase starting with "#". - subset_opt = if let Some(new_offset) = trimmed.find('#') { + if let Some(new_offset) = trimmed.find('#') { relative_offset += TextSize::try_from(new_offset).unwrap(); - subset.get(relative_offset.to_usize()..) + subset = &subset[relative_offset.to_usize()..]; } else { - None + break; }; } @@ -334,30 +338,13 @@ impl FromStr for TodoDirectiveKind { type Err = (); fn from_str(s: &str) -> Result<Self, Self::Err> { - // The lengths of the respective variant strings: TODO, FIXME, HACK, XXX - for length in [3, 4, 5] { - let Some(substr) = s.get(..length) else { - break; - }; - - match substr.to_lowercase().as_str() { - "fixme" => { - return Ok(TodoDirectiveKind::Fixme); - } - "hack" => { - return Ok(TodoDirectiveKind::Hack); - } - "todo" => { - return Ok(TodoDirectiveKind::Todo); - } - "xxx" => { - return Ok(TodoDirectiveKind::Xxx); - } - _ => continue, - } + match s.to_lowercase().as_str() { + "fixme" => Ok(TodoDirectiveKind::Fixme), + "hack" => Ok(TodoDirectiveKind::Hack), + "todo" => Ok(TodoDirectiveKind::Todo), + "xxx" => Ok(TodoDirectiveKind::Xxx), + _ => Err(()), } - - Err(()) } } diff --git a/crates/ruff_linter/src/rules/flake8_todos/snapshots/ruff_linter__rules__flake8_todos__tests__invalid-todo-capitalization_TD006.py.snap b/crates/ruff_linter/src/rules/flake8_todos/snapshots/ruff_linter__rules__flake8_todos__tests__invalid-todo-capitalization_TD006.py.snap index 32cbcb436d479..7e5c3c1482cf9 100644 --- a/crates/ruff_linter/src/rules/flake8_todos/snapshots/ruff_linter__rules__flake8_todos__tests__invalid-todo-capitalization_TD006.py.snap +++ b/crates/ruff_linter/src/rules/flake8_todos/snapshots/ruff_linter__rules__flake8_todos__tests__invalid-todo-capitalization_TD006.py.snap @@ -1,58 +1,56 @@ --- source: crates/ruff_linter/src/rules/flake8_todos/mod.rs --- -TD006.py:4:3: TD006 [*] Invalid TODO capitalization: `ToDo` should be `TODO` +TD006.py:5:3: TD006 [*] Invalid TODO capitalization: `ToDo` should be `TODO` | -2 | # TODO (evanrittenhouse): this is a valid TODO -3 | # TDO006 - error -4 | # ToDo (evanrittenhouse): invalid capitalization +3 | # Todoism is a word which starts with todo, but is not a todo +4 | # TDO006 - error +5 | # ToDo (evanrittenhouse): invalid capitalization | ^^^^ TD006 -5 | # todo (evanrittenhouse): another invalid capitalization -6 | # foo # todo: invalid capitalization +6 | # todo (evanrittenhouse): another invalid capitalization +7 | # foo # todo: invalid capitalization | = help: Replace `ToDo` with `TODO` ℹ Safe fix -1 1 | # TDO006 - accepted 2 2 | # TODO (evanrittenhouse): this is a valid TODO -3 3 | # TDO006 - error -4 |-# ToDo (evanrittenhouse): invalid capitalization - 4 |+# TODO (evanrittenhouse): invalid capitalization -5 5 | # todo (evanrittenhouse): another invalid capitalization -6 6 | # foo # todo: invalid capitalization +3 3 | # Todoism is a word which starts with todo, but is not a todo +4 4 | # TDO006 - error +5 |-# ToDo (evanrittenhouse): invalid capitalization + 5 |+# TODO (evanrittenhouse): invalid capitalization +6 6 | # todo (evanrittenhouse): another invalid capitalization +7 7 | # foo # todo: invalid capitalization -TD006.py:5:3: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO` +TD006.py:6:3: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO` | -3 | # TDO006 - error -4 | # ToDo (evanrittenhouse): invalid capitalization -5 | # todo (evanrittenhouse): another invalid capitalization +4 | # TDO006 - error +5 | # ToDo (evanrittenhouse): invalid capitalization +6 | # todo (evanrittenhouse): another invalid capitalization | ^^^^ TD006 -6 | # foo # todo: invalid capitalization +7 | # foo # todo: invalid capitalization | = help: Replace `todo` with `TODO` ℹ Safe fix -2 2 | # TODO (evanrittenhouse): this is a valid TODO -3 3 | # TDO006 - error -4 4 | # ToDo (evanrittenhouse): invalid capitalization -5 |-# todo (evanrittenhouse): another invalid capitalization - 5 |+# TODO (evanrittenhouse): another invalid capitalization -6 6 | # foo # todo: invalid capitalization +3 3 | # Todoism is a word which starts with todo, but is not a todo +4 4 | # TDO006 - error +5 5 | # ToDo (evanrittenhouse): invalid capitalization +6 |-# todo (evanrittenhouse): another invalid capitalization + 6 |+# TODO (evanrittenhouse): another invalid capitalization +7 7 | # foo # todo: invalid capitalization -TD006.py:6:9: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO` +TD006.py:7:9: TD006 [*] Invalid TODO capitalization: `todo` should be `TODO` | -4 | # ToDo (evanrittenhouse): invalid capitalization -5 | # todo (evanrittenhouse): another invalid capitalization -6 | # foo # todo: invalid capitalization +5 | # ToDo (evanrittenhouse): invalid capitalization +6 | # todo (evanrittenhouse): another invalid capitalization +7 | # foo # todo: invalid capitalization | ^^^^ TD006 | = help: Replace `todo` with `TODO` ℹ Safe fix -3 3 | # TDO006 - error -4 4 | # ToDo (evanrittenhouse): invalid capitalization -5 5 | # todo (evanrittenhouse): another invalid capitalization -6 |-# foo # todo: invalid capitalization - 6 |+# foo # TODO: invalid capitalization - - +4 4 | # TDO006 - error +5 5 | # ToDo (evanrittenhouse): invalid capitalization +6 6 | # todo (evanrittenhouse): another invalid capitalization +7 |-# foo # todo: invalid capitalization + 7 |+# foo # TODO: invalid capitalization From 93097f1c530ace03946675c582c084e523b1e614 Mon Sep 17 00:00:00 2001 From: David Peter <sharkdp@users.noreply.github.com> Date: Mon, 14 Oct 2024 14:01:23 +0200 Subject: [PATCH 71/88] [red-knot] feat: Inference for `BytesLiteral` comparisons (#13746) Implements inference for `BytesLiteral` comparisons along the lines of https://github.com/astral-sh/ruff/pull/13634. closes #13687 Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com> --- .../mdtest/comparison/byte_literals.md | 43 ++++++++++++++++++ .../src/types/infer.rs | 45 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 crates/red_knot_python_semantic/resources/mdtest/comparison/byte_literals.md diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/byte_literals.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/byte_literals.md new file mode 100644 index 0000000000000..30487f00d6a3e --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/byte_literals.md @@ -0,0 +1,43 @@ +### Comparison: Byte literals + +These tests assert that we infer precise `Literal` types for comparisons between objects +inferred as having `Literal` bytes types: + +```py +reveal_type(b"abc" == b"abc") # revealed: Literal[True] +reveal_type(b"abc" == b"ab") # revealed: Literal[False] + +reveal_type(b"abc" != b"abc") # revealed: Literal[False] +reveal_type(b"abc" != b"ab") # revealed: Literal[True] + +reveal_type(b"abc" < b"abd") # revealed: Literal[True] +reveal_type(b"abc" < b"abb") # revealed: Literal[False] + +reveal_type(b"abc" <= b"abc") # revealed: Literal[True] +reveal_type(b"abc" <= b"abb") # revealed: Literal[False] + +reveal_type(b"abc" > b"abd") # revealed: Literal[False] +reveal_type(b"abc" > b"abb") # revealed: Literal[True] + +reveal_type(b"abc" >= b"abc") # revealed: Literal[True] +reveal_type(b"abc" >= b"abd") # revealed: Literal[False] + +reveal_type(b"" in b"") # revealed: Literal[True] +reveal_type(b"" in b"abc") # revealed: Literal[True] +reveal_type(b"abc" in b"") # revealed: Literal[False] +reveal_type(b"ab" in b"abc") # revealed: Literal[True] +reveal_type(b"abc" in b"abc") # revealed: Literal[True] +reveal_type(b"d" in b"abc") # revealed: Literal[False] +reveal_type(b"ac" in b"abc") # revealed: Literal[False] +reveal_type(b"\x81\x82" in b"\x80\x81\x82") # revealed: Literal[True] +reveal_type(b"\x82\x83" in b"\x80\x81\x82") # revealed: Literal[False] + +reveal_type(b"ab" not in b"abc") # revealed: Literal[False] +reveal_type(b"ac" not in b"abc") # revealed: Literal[True] + +reveal_type(b"abc" is b"abc") # revealed: bool +reveal_type(b"abc" is b"ab") # revealed: Literal[False] + +reveal_type(b"abc" is not b"abc") # revealed: bool +reveal_type(b"abc" is not b"ab") # revealed: Literal[True] +``` diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index f894d6dc24133..e5c173cbc69ea 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -2660,6 +2660,51 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_binary_type_comparison(left, op, KnownClass::Str.to_instance(self.db)) } + (Type::BytesLiteral(salsa_b1), Type::BytesLiteral(salsa_b2)) => { + let contains_subsequence = |needle: &[u8], haystack: &[u8]| { + if needle.is_empty() { + true + } else { + haystack + .windows(needle.len()) + .any(|window| window == needle) + } + }; + + let b1 = salsa_b1.value(self.db).as_ref(); + let b2 = salsa_b2.value(self.db).as_ref(); + match op { + ast::CmpOp::Eq => Some(Type::BooleanLiteral(b1 == b2)), + ast::CmpOp::NotEq => Some(Type::BooleanLiteral(b1 != b2)), + ast::CmpOp::Lt => Some(Type::BooleanLiteral(b1 < b2)), + ast::CmpOp::LtE => Some(Type::BooleanLiteral(b1 <= b2)), + ast::CmpOp::Gt => Some(Type::BooleanLiteral(b1 > b2)), + ast::CmpOp::GtE => Some(Type::BooleanLiteral(b1 >= b2)), + ast::CmpOp::In => Some(Type::BooleanLiteral(contains_subsequence(b1, b2))), + ast::CmpOp::NotIn => Some(Type::BooleanLiteral(!contains_subsequence(b1, b2))), + ast::CmpOp::Is => { + if b1 == b2 { + Some(KnownClass::Bool.to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(false)) + } + } + ast::CmpOp::IsNot => { + if b1 == b2 { + Some(KnownClass::Bool.to_instance(self.db)) + } else { + Some(Type::BooleanLiteral(true)) + } + } + } + } + (Type::BytesLiteral(_), _) => { + self.infer_binary_type_comparison(KnownClass::Bytes.to_instance(self.db), op, right) + } + (_, Type::BytesLiteral(_)) => { + self.infer_binary_type_comparison(left, op, KnownClass::Bytes.to_instance(self.db)) + } + // Lookup the rich comparison `__dunder__` methods on instances (Type::Instance(left_class_ty), Type::Instance(right_class_ty)) => match op { ast::CmpOp::Lt => { From 6048f331d92eb5b95de4111884a526c2d7cc8345 Mon Sep 17 00:00:00 2001 From: Alex Waygood <Alex.Waygood@Gmail.com> Date: Mon, 14 Oct 2024 13:02:03 +0100 Subject: [PATCH 72/88] [red-knot] Add a build.rs file to `red_knot_python_semantic`, and document pitfalls of using `rstest` in combination with `mdtest` (#13747) --- crates/red_knot_python_semantic/build.rs | 4 ++++ crates/red_knot_test/README.md | 12 ++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 crates/red_knot_python_semantic/build.rs diff --git a/crates/red_knot_python_semantic/build.rs b/crates/red_knot_python_semantic/build.rs new file mode 100644 index 0000000000000..10815f6a89199 --- /dev/null +++ b/crates/red_knot_python_semantic/build.rs @@ -0,0 +1,4 @@ +/// Rebuild the crate if a test file is added or removed from +pub fn main() { + println!("cargo:rerun-if-changed=resources/mdtest"); +} diff --git a/crates/red_knot_test/README.md b/crates/red_knot_test/README.md index 9a5964db5c37a..3c35862b4d488 100644 --- a/crates/red_knot_test/README.md +++ b/crates/red_knot_test/README.md @@ -34,6 +34,18 @@ syntax, it's just how this README embeds an example mdtest Markdown document.) See actual example mdtest suites in [`crates/red_knot_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/resources/mdtest). +> ℹ️ Note: If you use `rstest` to generate a separate test for all Markdown files in a certain directory, +> as with the example in `crates/red_knot_python_semantic/tests/mdtest.rs`, +> you will likely want to also make sure that the crate the tests are in is rebuilt every time a +> Markdown file is added or removed from the directory. See +> [`crates/red_knot_python_semantic/build.rs`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/build.rs) +> for an example of how to do this. +> +> This is because `rstest` generates its tests at build time rather than at runtime. +> Without the `build.rs` file to force a rebuild when a Markdown file is added or removed, +> a new Markdown test suite might not be run unless some other change in the crate caused a rebuild +> following the addition of the new test file. + ## Assertions Two kinds of assertions are supported: `# revealed:` (shown above) and `# error:`. From 04b636cba2c2eff5539c846fe4e654583b7ee472 Mon Sep 17 00:00:00 2001 From: David Peter <sharkdp@users.noreply.github.com> Date: Mon, 14 Oct 2024 15:17:19 +0200 Subject: [PATCH 73/88] [red knot] Use memmem::find instead of custom version (#13750) This is a follow-up on #13746: - Use `memmem::find` instead of rolling our own inferior version. - Avoid `x.as_ref()` calls using `&**x` --- Cargo.lock | 1 + crates/red_knot_python_semantic/Cargo.toml | 1 + .../src/types/infer.rs | 22 +++++++------------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7da7c035dbb2e..965a28362d55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2083,6 +2083,7 @@ dependencies = [ "hashbrown 0.15.0", "insta", "itertools 0.13.0", + "memchr", "ordermap", "red_knot_test", "red_knot_vendored", diff --git a/crates/red_knot_python_semantic/Cargo.toml b/crates/red_knot_python_semantic/Cargo.toml index cc475b71e07e1..7a74f5c307043 100644 --- a/crates/red_knot_python_semantic/Cargo.toml +++ b/crates/red_knot_python_semantic/Cargo.toml @@ -34,6 +34,7 @@ hashbrown = { workspace = true } smallvec = { workspace = true } static_assertions = { workspace = true } test-case = { workspace = true } +memchr = { workspace = true } [dev-dependencies] ruff_db = { workspace = true, features = ["os", "testing"] } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index e5c173cbc69ea..4b1c420bb4741 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -2661,18 +2661,8 @@ impl<'db> TypeInferenceBuilder<'db> { } (Type::BytesLiteral(salsa_b1), Type::BytesLiteral(salsa_b2)) => { - let contains_subsequence = |needle: &[u8], haystack: &[u8]| { - if needle.is_empty() { - true - } else { - haystack - .windows(needle.len()) - .any(|window| window == needle) - } - }; - - let b1 = salsa_b1.value(self.db).as_ref(); - let b2 = salsa_b2.value(self.db).as_ref(); + let b1 = &**salsa_b1.value(self.db); + let b2 = &**salsa_b2.value(self.db); match op { ast::CmpOp::Eq => Some(Type::BooleanLiteral(b1 == b2)), ast::CmpOp::NotEq => Some(Type::BooleanLiteral(b1 != b2)), @@ -2680,8 +2670,12 @@ impl<'db> TypeInferenceBuilder<'db> { ast::CmpOp::LtE => Some(Type::BooleanLiteral(b1 <= b2)), ast::CmpOp::Gt => Some(Type::BooleanLiteral(b1 > b2)), ast::CmpOp::GtE => Some(Type::BooleanLiteral(b1 >= b2)), - ast::CmpOp::In => Some(Type::BooleanLiteral(contains_subsequence(b1, b2))), - ast::CmpOp::NotIn => Some(Type::BooleanLiteral(!contains_subsequence(b1, b2))), + ast::CmpOp::In => { + Some(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_some())) + } + ast::CmpOp::NotIn => { + Some(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_none())) + } ast::CmpOp::Is => { if b1 == b2 { Some(KnownClass::Bool.to_instance(self.db)) From 2e8177bbfd6ba69ec7fe3564e67a7632db6cb067 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 15:27:15 +0530 Subject: [PATCH 74/88] Implement B903 and B907 flake8-bugbear rules Fixes #3758 Implement B903 and B907 flake8-bugbear rules in Ruff. * Add `crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs` to implement B903 rule for data classes that only set attributes in an `__init__` method. * Add `crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs` to implement B907 rule to replace f"'{foo}'" with f"{foo!r}". * Update `crates/ruff_linter/src/rules/flake8_bugbear/mod.rs` to include B903 and B907 rules. * Add test cases for B903 and B907 rules in `crates/ruff_linter/src/rules/flake8_bugbear/tests.rs`. --- .../src/rules/flake8_bugbear/mod.rs | 3 +- .../src/rules/flake8_bugbear/rules/b903.rs | 98 +++++++++++++ .../src/rules/flake8_bugbear/rules/b907.rs | 59 ++++++++ .../src/rules/flake8_bugbear/tests.rs | 138 ++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/tests.rs diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index 1f122f15438b3..c40e7298b452c 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -1,4 +1,3 @@ -//! Rules from [flake8-bugbear](https://pypi.org/project/flake8-bugbear/). pub(crate) mod helpers; pub(crate) mod rules; pub mod settings; @@ -65,6 +64,8 @@ mod tests { #[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))] #[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))] #[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))] + #[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))] + #[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs new file mode 100644 index 0000000000000..5a166e563e158 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs @@ -0,0 +1,98 @@ +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast, Arguments, Expr, Stmt}; +use ruff_python_semantic::SemanticModel; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::registry::Rule; + +/// ## What it does +/// Checks for data classes that only set attributes in an `__init__` method. +/// +/// ## Why is this bad? +/// Data classes that only set attributes in an `__init__` method can be +/// replaced with `dataclasses` for better readability and maintainability. +/// +/// ## Example +/// ```python +/// class Point: +/// def __init__(self, x, y): +/// self.x = x +/// self.y = y +/// ``` +/// +/// Use instead: +/// ```python +/// from dataclasses import dataclass +/// +/// @dataclass +/// class Point: +/// x: int +/// y: int +/// ``` +/// +/// ## References +/// - [Python documentation: `dataclasses`](https://docs.python.org/3/library/dataclasses.html) +#[violation] +pub struct UseDataclassesForDataClasses; + +impl Violation for UseDataclassesForDataClasses { + #[derive_message_formats] + fn message(&self) -> String { + format!("Use `dataclasses` for data classes that only set attributes in an `__init__` method") + } +} + +/// B903 +pub(crate) fn use_dataclasses_for_data_classes(checker: &mut Checker, stmt: &Stmt) { + let Stmt::ClassDef(ast::StmtClassDef { body, .. }) = stmt else { + return; + }; + + for stmt in body { + let Stmt::FunctionDef(ast::StmtFunctionDef { + name, + parameters, + body, + .. + }) = stmt + else { + continue; + }; + + if name.id != "__init__" { + continue; + } + + let mut has_only_attribute_assignments = true; + for stmt in body { + if let Stmt::Assign(ast::StmtAssign { targets, .. }) = stmt { + if targets.len() != 1 { + has_only_attribute_assignments = false; + break; + } + + let Expr::Attribute(ast::ExprAttribute { value, .. }) = &targets[0] else { + has_only_attribute_assignments = false; + break; + }; + + if !matches!(value.as_ref(), Expr::Name(_)) { + has_only_attribute_assignments = false; + break; + } + } else { + has_only_attribute_assignments = false; + break; + } + } + + if has_only_attribute_assignments { + checker.diagnostics.push(Diagnostic::new( + UseDataclassesForDataClasses, + stmt.range(), + )); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs new file mode 100644 index 0000000000000..5ec876cd75e3f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs @@ -0,0 +1,59 @@ +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; +use crate::registry::Rule; + +/// ## What it does +/// Checks for f-strings that contain single quotes and suggests replacing them +/// with `!r` conversion. +/// +/// ## Why is this bad? +/// Using `!r` conversion in f-strings is both easier to read and will escape +/// quotes inside the string if they appear. +/// +/// ## Example +/// ```python +/// f"'{foo}'" +/// ``` +/// +/// Use instead: +/// ```python +/// f"{foo!r}" +/// ``` +/// +/// ## References +/// - [Python documentation: Formatted string literals](https://docs.python.org/3/reference/lexical_analysis.html#f-strings) +#[violation] +pub struct FStringSingleQuotes; + +impl Violation for FStringSingleQuotes { + #[derive_message_formats] + fn message(&self) -> String { + format!("Consider replacing f\"'{{foo}}'\" with f\"{{foo!r}}\" which is both easier to read and will escape quotes inside foo if that would appear") + } +} + +/// B907 +pub(crate) fn f_string_single_quotes(checker: &mut Checker, expr: &Expr) { + if let Expr::FString(ast::ExprFString { values, .. }) = expr { + for value in values { + if let Expr::FormattedValue(ast::ExprFormattedValue { value, .. }) = value { + if let Expr::Constant(ast::ExprConstant { + value: ast::Constant::Str(s), + .. + }) = value.as_ref() + { + if s.contains('\'') { + checker.diagnostics.push(Diagnostic::new( + FStringSingleQuotes, + expr.range(), + )); + } + } + } + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs b/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs new file mode 100644 index 0000000000000..861f27f106c79 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/tests.rs @@ -0,0 +1,138 @@ +use std::path::Path; + +use anyhow::Result; +use test_case::test_case; + +use crate::assert_messages; +use crate::registry::Rule; + +use crate::settings::LinterSettings; +use crate::test::test_path; + +#[test_case(Rule::AbstractBaseClassWithoutAbstractMethod, Path::new("B024.py"))] +#[test_case(Rule::AssertFalse, Path::new("B011.py"))] +#[test_case(Rule::AssertRaisesException, Path::new("B017.py"))] +#[test_case(Rule::AssignmentToOsEnviron, Path::new("B003.py"))] +#[test_case(Rule::CachedInstanceMethod, Path::new("B019.py"))] +#[test_case(Rule::DuplicateHandlerException, Path::new("B014.py"))] +#[test_case(Rule::DuplicateTryBlockException, Path::new("B025.py"))] +#[test_case(Rule::DuplicateValue, Path::new("B033.py"))] +#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.py"))] +#[test_case(Rule::EmptyMethodWithoutAbstractDecorator, Path::new("B027.pyi"))] +#[test_case(Rule::ExceptWithEmptyTuple, Path::new("B029.py"))] +#[test_case(Rule::ExceptWithNonExceptionClasses, Path::new("B030.py"))] +#[test_case(Rule::FStringDocstring, Path::new("B021.py"))] +#[test_case(Rule::FunctionCallInDefaultArgument, Path::new("B006_B008.py"))] +#[test_case(Rule::FunctionUsesLoopVariable, Path::new("B023.py"))] +#[test_case(Rule::GetAttrWithConstant, Path::new("B009_B010.py"))] +#[test_case(Rule::JumpStatementInFinally, Path::new("B012.py"))] +#[test_case(Rule::LoopVariableOverridesIterator, Path::new("B020.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_1.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_2.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_3.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_4.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_5.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_6.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_7.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_8.py"))] +#[test_case(Rule::MutableArgumentDefault, Path::new("B006_B008.py"))] +#[test_case(Rule::NoExplicitStacklevel, Path::new("B028.py"))] +#[test_case(Rule::RaiseLiteral, Path::new("B016.py"))] +#[test_case(Rule::RaiseWithoutFromInsideExcept, Path::new("B904.py"))] +#[test_case(Rule::ReSubPositionalArgs, Path::new("B034.py"))] +#[test_case(Rule::RedundantTupleInExceptionHandler, Path::new("B013.py"))] +#[test_case(Rule::ReuseOfGroupbyGenerator, Path::new("B031.py"))] +#[test_case(Rule::SetAttrWithConstant, Path::new("B009_B010.py"))] +#[test_case(Rule::StarArgUnpackingAfterKeywordArg, Path::new("B026.py"))] +#[test_case(Rule::StaticKeyDictComprehension, Path::new("B035.py"))] +#[test_case(Rule::StripWithMultiCharacters, Path::new("B005.py"))] +#[test_case(Rule::UnaryPrefixIncrementDecrement, Path::new("B002.py"))] +#[test_case(Rule::UnintentionalTypeAnnotation, Path::new("B032.py"))] +#[test_case(Rule::UnreliableCallableCheck, Path::new("B004.py"))] +#[test_case(Rule::UnusedLoopControlVariable, Path::new("B007.py"))] +#[test_case(Rule::UselessComparison, Path::new("B015.ipynb"))] +#[test_case(Rule::UselessComparison, Path::new("B015.py"))] +#[test_case(Rule::UselessContextlibSuppress, Path::new("B022.py"))] +#[test_case(Rule::UselessExpression, Path::new("B018.ipynb"))] +#[test_case(Rule::UselessExpression, Path::new("B018.py"))] +#[test_case(Rule::ReturnInGenerator, Path::new("B901.py"))] +#[test_case(Rule::LoopIteratorMutation, Path::new("B909.py"))] +#[test_case(Rule::MutableContextvarDefault, Path::new("B039.py"))] +#[test_case(Rule::UseDataclassesForDataClasses, Path::new("B903.py"))] +#[test_case(Rule::FStringSingleQuotes, Path::new("B907.py"))] +fn rules(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_bugbear").join(path).as_path(), + &LinterSettings::for_rule(rule_code), + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn zip_without_explicit_strict() -> Result<()> { + let snapshot = "B905.py"; + let diagnostics = test_path( + Path::new("flake8_bugbear").join(snapshot).as_path(), + &LinterSettings::for_rule(Rule::ZipWithoutExplicitStrict), + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_immutable_calls_arg_annotation() -> Result<()> { + let snapshot = "extend_immutable_calls_arg_annotation".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B006_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec![ + "custom.ImmutableTypeA".to_string(), + "custom.ImmutableTypeB".to_string(), + ], + }, + ..LinterSettings::for_rule(Rule::MutableArgumentDefault) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_immutable_calls_arg_default() -> Result<()> { + let snapshot = "extend_immutable_calls_arg_default".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B008_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec![ + "fastapi.Depends".to_string(), + "fastapi.Query".to_string(), + "custom.ImmutableTypeA".to_string(), + "B008_extended.Class".to_string(), + ], + }, + ..LinterSettings::for_rule(Rule::FunctionCallInDefaultArgument) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} + +#[test] +fn extend_mutable_contextvar_default() -> Result<()> { + let snapshot = "extend_mutable_contextvar_default".to_string(); + let diagnostics = test_path( + Path::new("flake8_bugbear/B039_extended.py"), + &LinterSettings { + flake8_bugbear: super::settings::Settings { + extend_immutable_calls: vec!["fastapi.Query".to_string()], + }, + ..LinterSettings::for_rule(Rule::MutableContextvarDefault) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) +} From f0f243a491279c7e450b9d7ab74fe036acb2002d Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 15:40:41 +0530 Subject: [PATCH 75/88] Add new rules to `Rule` enum in `codes.rs` * **New Rules** - Add `UseDataclassesForDataClasses` variant to `Rule` enum - Add `FStringSingleQuotes` variant to `Rule` enum --- crates/ruff_linter/src/codes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 41e4482e42727..532e350aaf48c 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,6 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From a10e61e1033bf14b96a23f0fc86f8bb127dad76d Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 15:53:17 +0530 Subject: [PATCH 76/88] --- crates/ruff_linter/src/registry.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 1ee0cc5102add..6e901c88e2fd0 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,6 +1,3 @@ -//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs -//! with some helper symbols - use strum_macros::EnumIter; pub use codes::Rule; From a9d86c80b553e193684bfd6ae949fc2dc2e9f5ef Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:07:52 +0530 Subject: [PATCH 77/88] Update registry.rs --- crates/ruff_linter/src/registry.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 6e901c88e2fd0..e273d04b14ac1 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -265,6 +265,7 @@ impl Rule { | Rule::CommentedOutCode | Rule::EmptyComment | Rule::ExtraneousParentheses + | Rule::FStringSingleQuotes | Rule::InvalidCharacterBackspace | Rule::InvalidCharacterEsc | Rule::InvalidCharacterNul From 081b514bd39bebf33fdde95632e0e9cba61ab33f Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:08:16 +0530 Subject: [PATCH 78/88] Update registry.rs --- crates/ruff_linter/src/registry.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index e273d04b14ac1..32b9f7a14fafd 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,3 +1,6 @@ +//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs +//! with some helper symbols + use strum_macros::EnumIter; pub use codes::Rule; From 1599b2535f4fad8c79b1942e50469615251a7e68 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:28:12 +0530 Subject: [PATCH 79/88] Update `Rule` enum and imports for new rules B903 and B907 * **Update `Rule` enum** - Add `UseDataclassesForDataClasses` for B903 - Add `FStringSingleQuotes` for B907 * **Update imports in `mod.rs`** - Add `use_dataclasses_for_data_classes::*` - Add `f_string_single_quotes::*` --- crates/ruff_linter/src/codes.rs | 4 ++-- crates/ruff_linter/src/registry.rs | 4 ---- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 2 ++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 532e350aaf48c..674735d2a14f2 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, crate::registry::Rule::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, crate::registry::Rule::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 32b9f7a14fafd..6e901c88e2fd0 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,6 +1,3 @@ -//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs -//! with some helper symbols - use strum_macros::EnumIter; pub use codes::Rule; @@ -268,7 +265,6 @@ impl Rule { | Rule::CommentedOutCode | Rule::EmptyComment | Rule::ExtraneousParentheses - | Rule::FStringSingleQuotes | Rule::InvalidCharacterBackspace | Rule::InvalidCharacterEsc | Rule::InvalidCharacterNul diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 49b40b0b7900d..b2e69e7fbb80b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -31,6 +31,8 @@ pub(crate) use unused_async::*; pub(crate) use unused_noqa::*; pub(crate) use useless_if_else::*; pub(crate) use zip_instead_of_pairwise::*; +pub(crate) use use_dataclasses_for_data_classes::*; +pub(crate) use f_string_single_quotes::*; mod ambiguous_unicode_character; mod assert_with_print_message; From cbcba8f45a1df88a0e36380ce8ffe150ade7b9ff Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:36:17 +0530 Subject: [PATCH 80/88] Rename b903.rs to use_dataclasses_for_data_classes.rs --- .../rules/{b903.rs => use_dataclasses_for_data_classes.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/ruff_linter/src/rules/flake8_bugbear/rules/{b903.rs => use_dataclasses_for_data_classes.rs} (100%) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/use_dataclasses_for_data_classes.rs similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/rules/b903.rs rename to crates/ruff_linter/src/rules/flake8_bugbear/rules/use_dataclasses_for_data_classes.rs From 6c1b1a3904aebf493d8e9f3cbabd633b1e3b7f26 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:36:44 +0530 Subject: [PATCH 81/88] Rename b907.rs to f_string_single_quotes.rs --- .../flake8_bugbear/rules/{b907.rs => f_string_single_quotes.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/ruff_linter/src/rules/flake8_bugbear/rules/{b907.rs => f_string_single_quotes.rs} (100%) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_single_quotes.rs similarity index 100% rename from crates/ruff_linter/src/rules/flake8_bugbear/rules/b907.rs rename to crates/ruff_linter/src/rules/flake8_bugbear/rules/f_string_single_quotes.rs From 549adc927f2a3d00eea94a9d579afd9490efaaa5 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:53:03 +0530 Subject: [PATCH 82/88] Update mod.rs --- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index b2e69e7fbb80b..4f84ec55c7dc5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -6,6 +6,7 @@ pub(crate) use collection_literal_concatenation::*; pub(crate) use decimal_from_float_literal::*; pub(crate) use default_factory_kwarg::*; pub(crate) use explicit_f_string_type_conversion::*; +pub(crate) use f_string_single_quotes::*; pub(crate) use function_call_in_dataclass_default::*; pub(crate) use implicit_optional::*; pub(crate) use incorrectly_parenthesized_tuple_in_subscript::*; @@ -29,10 +30,10 @@ pub(crate) use unnecessary_iterable_allocation_for_first_element::*; pub(crate) use unnecessary_key_check::*; pub(crate) use unused_async::*; pub(crate) use unused_noqa::*; +pub(crate) use use_dataclasses_for_data_classes::*; pub(crate) use useless_if_else::*; pub(crate) use zip_instead_of_pairwise::*; -pub(crate) use use_dataclasses_for_data_classes::*; -pub(crate) use f_string_single_quotes::*; + mod ambiguous_unicode_character; mod assert_with_print_message; @@ -43,6 +44,7 @@ mod confusables; mod decimal_from_float_literal; mod default_factory_kwarg; mod explicit_f_string_type_conversion; +mod f_string_single_quotes; mod function_call_in_dataclass_default; mod helpers; mod implicit_optional; @@ -69,6 +71,7 @@ mod unnecessary_iterable_allocation_for_first_element; mod unnecessary_key_check; mod unused_async; mod unused_noqa; +mod use_dataclasses_for_data_classes mod useless_if_else; mod zip_instead_of_pairwise; From 8efe87b0fbe66a5028d9f9d4579faab9dfbe80f6 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 16:55:50 +0530 Subject: [PATCH 83/88] add missing semicolon --- crates/ruff_linter/src/rules/ruff/rules/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 4f84ec55c7dc5..68134e0a8cbe5 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -71,7 +71,7 @@ mod unnecessary_iterable_allocation_for_first_element; mod unnecessary_key_check; mod unused_async; mod unused_noqa; -mod use_dataclasses_for_data_classes +mod use_dataclasses_for_data_classes; mod useless_if_else; mod zip_instead_of_pairwise; From 4d874bd98c87d266dc409397f96f23348c534ab8 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 17:00:52 +0530 Subject: [PATCH 84/88] change to rule from crate --- crates/ruff_linter/src/codes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 674735d2a14f2..eb700a38bf2cb 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, crate::registry::Rule::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, crate::registry::Rule::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::registry::Rule::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::registry::Rule::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From 2b48f4c18860474e47deb9816e15ef8fd8e1dd9d Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 17:06:16 +0530 Subject: [PATCH 85/88] Update codes.rs --- crates/ruff_linter/src/codes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index eb700a38bf2cb..532e350aaf48c 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -356,8 +356,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Bugbear, "904") => (RuleGroup::Stable, rules::flake8_bugbear::rules::RaiseWithoutFromInsideExcept), (Flake8Bugbear, "905") => (RuleGroup::Stable, rules::flake8_bugbear::rules::ZipWithoutExplicitStrict), (Flake8Bugbear, "909") => (RuleGroup::Preview, rules::flake8_bugbear::rules::LoopIteratorMutation), - (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::registry::Rule::UseDataclassesForDataClasses), - (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::registry::Rule::FStringSingleQuotes), + (Flake8Bugbear, "903") => (RuleGroup::Preview, rules::flake8_bugbear::rules::UseDataclassesForDataClasses), + (Flake8Bugbear, "907") => (RuleGroup::Preview, rules::flake8_bugbear::rules::FStringSingleQuotes), // flake8-blind-except (Flake8BlindExcept, "001") => (RuleGroup::Stable, rules::flake8_blind_except::rules::BlindExcept), From 141fc5f5a28ef4f337d5994bb1e6f8a8df480486 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 17:12:22 +0530 Subject: [PATCH 86/88] revert removed comments --- crates/ruff_linter/src/registry.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 6e901c88e2fd0..1ee0cc5102add 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -1,3 +1,6 @@ +//! Remnant of the registry of all [`Rule`] implementations, now it's reexporting from codes.rs +//! with some helper symbols + use strum_macros::EnumIter; pub use codes::Rule; From 9241b87fd9732806afdf8a9bb03f708ef6b4bf8b Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 17:12:52 +0530 Subject: [PATCH 87/88] revert removed comment --- crates/ruff_linter/src/rules/flake8_bugbear/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index c40e7298b452c..5ceff4cdd4a78 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -1,3 +1,4 @@ +//! Rules from [flake8-bugbear](https://pypi.org/project/flake8-bugbear/). pub(crate) mod helpers; pub(crate) mod rules; pub mod settings; From ae5a829d9153ad605d3b084e5cf140e5141ad8e7 Mon Sep 17 00:00:00 2001 From: Ayush Gupta <ayush.gpt8@gmail.com> Date: Wed, 2 Oct 2024 13:04:44 +0000 Subject: [PATCH 88/88] add B907.py and B903.py --- .../test/fixtures/flake8_bugbear/B903.py | 26 +++++++++++++++++++ .../test/fixtures/flake8_bugbear/B907.py | 8 ++++++ 2 files changed, 34 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py new file mode 100644 index 0000000000000..fd648e9a06dfb --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B903.py @@ -0,0 +1,26 @@ +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + +class Rectangle: + def __init__(self, width, height): + self.width = width + self.height = height + + +class Circle: + def __init__(self, radius): + self.radius = radius + + +class Triangle: + def __init__(self, base, height): + self.base = base + self.height = height + + +class Square: + def __init__(self, side): + self.side = side diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py new file mode 100644 index 0000000000000..9e5b1acff712d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B907.py @@ -0,0 +1,8 @@ +foo = "bar" +f"'{foo}'" + +baz = "qux" +f"'{baz}'" + +quux = "corge" +f"'{quux}'"