diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT012.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT012.py index fefd2942dcea7..239fcb33547af 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT012.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT012.py @@ -73,3 +73,44 @@ def test_error_try(): [].size except: raise + + +# https://github.com/astral-sh/ruff/issues/9730 +def test_for_loops(): + + ## Errors + + with pytest.raises(RuntimeError): + for a in b: + print() + + with pytest.raises(RuntimeError): + for a in b: + assert foo + + with pytest.raises(RuntimeError): + async for a in b: + print() + + with pytest.raises(RuntimeError): + async for a in b: + assert foo + + + ## No errors in preview + + with pytest.raises(RuntimeError): + for a in b: + pass + + with pytest.raises(RuntimeError): + for a in b: + ... + + with pytest.raises(RuntimeError): + async for a in b: + pass + + with pytest.raises(RuntimeError): + async for a in b: + ... diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT031.py b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT031.py index c9bd096d0488f..0bf3b950ab868 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT031.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_pytest_style/PT031.py @@ -73,3 +73,44 @@ def test_error_try(): foo() except: raise + + +# https://github.com/astral-sh/ruff/issues/9730 +def test_for_loops(): + + ## Errors + + with pytest.warns(RuntimeError): + for a in b: + print() + + with pytest.warns(RuntimeError): + for a in b: + assert foo + + with pytest.warns(RuntimeError): + async for a in b: + print() + + with pytest.warns(RuntimeError): + async for a in b: + assert foo + + + ## No errors in preview + + with pytest.warns(RuntimeError): + for a in b: + pass + + with pytest.warns(RuntimeError): + for a in b: + ... + + with pytest.warns(RuntimeError): + async for a in b: + pass + + with pytest.warns(RuntimeError): + async for a in b: + ... diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs index 7cb3486d0141d..dca6e3ced4486 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs @@ -11,7 +11,7 @@ mod tests { use test_case::test_case; use crate::registry::Rule; - use crate::settings::types::IdentifierPattern; + use crate::settings::types::{IdentifierPattern, PreviewMode}; use crate::test::test_path; use crate::{assert_messages, settings}; @@ -358,6 +358,36 @@ mod tests { Ok(()) } + #[test_case( + Rule::PytestRaisesWithMultipleStatements, + Path::new("PT012.py"), + Settings::default(), + "PT012_preview" + )] + #[test_case( + Rule::PytestWarnsWithMultipleStatements, + Path::new("PT031.py"), + Settings::default(), + "PT031_preview" + )] + fn test_pytest_style_preview( + rule_code: Rule, + path: &Path, + plugin_settings: Settings, + name: &str, + ) -> Result<()> { + let diagnostics = test_path( + Path::new("flake8_pytest_style").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + flake8_pytest_style: plugin_settings, + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_messages!(name, diagnostics); + Ok(()) + } + /// This test ensure that PT006 and PT007 don't conflict when both of them suggest a fix that /// edits `argvalues` for `pytest.mark.parametrize`. #[test] diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs index 313c3f4424a90..15f41487406d9 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/raises.rs @@ -20,6 +20,10 @@ use super::helpers::is_empty_or_null_string; /// A `pytest.raises` context manager should only contain a single simple /// statement that raises the expected exception. /// +/// In [preview], this rule allows `pytest.raises` bodies to contain `for` +/// loops with empty bodies (e.g., `pass` or `...` statements), to test +/// iterator behavior. +/// /// ## Example /// ```python /// import pytest @@ -46,6 +50,8 @@ use super::helpers::is_empty_or_null_string; /// /// ## References /// - [`pytest` documentation: `pytest.raises`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) +/// +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] pub(crate) struct PytestRaisesWithMultipleStatements; @@ -205,10 +211,18 @@ pub(crate) fn complex_raises( // Check body for `pytest.raises` context manager if raises_called { let is_too_complex = if let [stmt] = body { + let in_preview = checker.settings.preview.is_enabled(); + match stmt { Stmt::With(ast::StmtWith { body, .. }) => is_non_trivial_with_body(body), - // Allow function and class definitions to test decorators + // Allow function and class definitions to test decorators. Stmt::ClassDef(_) | Stmt::FunctionDef(_) => false, + // Allow empty `for` loops to test iterators. + Stmt::For(ast::StmtFor { body, .. }) if in_preview => match &body[..] { + [Stmt::Pass(_)] => false, + [Stmt::Expr(ast::StmtExpr { value, .. })] => !value.is_ellipsis_literal_expr(), + _ => true, + }, stmt => is_compound_statement(stmt), } } else { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs index f89cfd4219c01..086b46212451b 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/warns.rs @@ -20,6 +20,10 @@ use super::helpers::is_empty_or_null_string; /// A `pytest.warns` context manager should only contain a single /// simple statement that triggers the expected warning. /// +/// In [preview], this rule allows `pytest.warns` bodies to contain `for` +/// loops with empty bodies (e.g., `pass` or `...` statements), to test +/// iterator behavior. +/// /// ## Example /// ```python /// import pytest @@ -38,12 +42,14 @@ use super::helpers::is_empty_or_null_string; /// /// def test_foo_warns(): /// setup() -/// with pytest.warning(Warning): +/// with pytest.warns(Warning): /// foo() /// ``` /// /// ## References /// - [`pytest` documentation: `pytest.warns`](https://docs.pytest.org/en/latest/reference/reference.html#pytest-warns) +/// +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] pub(crate) struct PytestWarnsWithMultipleStatements; @@ -200,10 +206,18 @@ pub(crate) fn complex_warns(checker: &mut Checker, stmt: &Stmt, items: &[WithIte // Check body for `pytest.warns` context manager if warns_called { let is_too_complex = if let [stmt] = body { + let in_preview = checker.settings.preview.is_enabled(); + match stmt { Stmt::With(ast::StmtWith { body, .. }) => is_non_trivial_with_body(body), - // Allow function and class definitions to test decorators + // Allow function and class definitions to test decorators. Stmt::ClassDef(_) | Stmt::FunctionDef(_) => false, + // Allow empty `for` loops to test iterators. + Stmt::For(ast::StmtFor { body, .. }) if in_preview => match &body[..] { + [Stmt::Pass(_)] => false, + [Stmt::Expr(ast::StmtExpr { value, .. })] => !value.is_ellipsis_literal_expr(), + _ => true, + }, stmt => is_compound_statement(stmt), } } else { diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012.snap index 0bfc3ee2ce5c5..3bbaf9924fd52 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012.snap +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012.snap @@ -78,3 +78,95 @@ PT012.py:71:5: PT012 `pytest.raises()` block should contain a single simple stat 75 | | raise | |_________________^ PT012 | + +PT012.py:83:5: PT012 `pytest.raises()` block should contain a single simple statement + | +81 | ## Errors +82 | +83 | / with pytest.raises(RuntimeError): +84 | | for a in b: +85 | | print() + | |___________________^ PT012 +86 | +87 | with pytest.raises(RuntimeError): + | + +PT012.py:87:5: PT012 `pytest.raises()` block should contain a single simple statement + | +85 | print() +86 | +87 | / with pytest.raises(RuntimeError): +88 | | for a in b: +89 | | assert foo + | |______________________^ PT012 +90 | +91 | with pytest.raises(RuntimeError): + | + +PT012.py:91:5: PT012 `pytest.raises()` block should contain a single simple statement + | +89 | assert foo +90 | +91 | / with pytest.raises(RuntimeError): +92 | | async for a in b: +93 | | print() + | |___________________^ PT012 +94 | +95 | with pytest.raises(RuntimeError): + | + +PT012.py:95:5: PT012 `pytest.raises()` block should contain a single simple statement + | +93 | print() +94 | +95 | / with pytest.raises(RuntimeError): +96 | | async for a in b: +97 | | assert foo + | |______________________^ PT012 + | + +PT012.py:102:5: PT012 `pytest.raises()` block should contain a single simple statement + | +100 | ## No errors in preview +101 | +102 | / with pytest.raises(RuntimeError): +103 | | for a in b: +104 | | pass + | |________________^ PT012 +105 | +106 | with pytest.raises(RuntimeError): + | + +PT012.py:106:5: PT012 `pytest.raises()` block should contain a single simple statement + | +104 | pass +105 | +106 | / with pytest.raises(RuntimeError): +107 | | for a in b: +108 | | ... + | |_______________^ PT012 +109 | +110 | with pytest.raises(RuntimeError): + | + +PT012.py:110:5: PT012 `pytest.raises()` block should contain a single simple statement + | +108 | ... +109 | +110 | / with pytest.raises(RuntimeError): +111 | | async for a in b: +112 | | pass + | |________________^ PT012 +113 | +114 | with pytest.raises(RuntimeError): + | + +PT012.py:114:5: PT012 `pytest.raises()` block should contain a single simple statement + | +112 | pass +113 | +114 | / with pytest.raises(RuntimeError): +115 | | async for a in b: +116 | | ... + | |_______________^ PT012 + | diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012_preview.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012_preview.snap new file mode 100644 index 0000000000000..43cf518d00187 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT012_preview.snap @@ -0,0 +1,126 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +PT012.py:42:5: PT012 `pytest.raises()` block should contain a single simple statement + | +41 | def test_error_multiple_statements(): +42 | / with pytest.raises(AttributeError): +43 | | len([]) +44 | | [].size + | |_______________^ PT012 + | + +PT012.py:48:5: PT012 `pytest.raises()` block should contain a single simple statement + | +47 | async def test_error_complex_statement(): +48 | / with pytest.raises(AttributeError): +49 | | if True: +50 | | [].size + | |___________________^ PT012 +51 | +52 | with pytest.raises(AttributeError): + | + +PT012.py:52:5: PT012 `pytest.raises()` block should contain a single simple statement + | +50 | [].size +51 | +52 | / with pytest.raises(AttributeError): +53 | | for i in []: +54 | | [].size + | |___________________^ PT012 +55 | +56 | with pytest.raises(AttributeError): + | + +PT012.py:56:5: PT012 `pytest.raises()` block should contain a single simple statement + | +54 | [].size +55 | +56 | / with pytest.raises(AttributeError): +57 | | async for i in []: +58 | | [].size + | |___________________^ PT012 +59 | +60 | with pytest.raises(AttributeError): + | + +PT012.py:60:5: PT012 `pytest.raises()` block should contain a single simple statement + | +58 | [].size +59 | +60 | / with pytest.raises(AttributeError): +61 | | while True: +62 | | [].size + | |___________________^ PT012 +63 | +64 | with pytest.raises(AttributeError): + | + +PT012.py:64:5: PT012 `pytest.raises()` block should contain a single simple statement + | +62 | [].size +63 | +64 | / with pytest.raises(AttributeError): +65 | | async with context_manager_under_test(): +66 | | if True: +67 | | raise Exception + | |_______________________________^ PT012 + | + +PT012.py:71:5: PT012 `pytest.raises()` block should contain a single simple statement + | +70 | def test_error_try(): +71 | / with pytest.raises(AttributeError): +72 | | try: +73 | | [].size +74 | | except: +75 | | raise + | |_________________^ PT012 + | + +PT012.py:83:5: PT012 `pytest.raises()` block should contain a single simple statement + | +81 | ## Errors +82 | +83 | / with pytest.raises(RuntimeError): +84 | | for a in b: +85 | | print() + | |___________________^ PT012 +86 | +87 | with pytest.raises(RuntimeError): + | + +PT012.py:87:5: PT012 `pytest.raises()` block should contain a single simple statement + | +85 | print() +86 | +87 | / with pytest.raises(RuntimeError): +88 | | for a in b: +89 | | assert foo + | |______________________^ PT012 +90 | +91 | with pytest.raises(RuntimeError): + | + +PT012.py:91:5: PT012 `pytest.raises()` block should contain a single simple statement + | +89 | assert foo +90 | +91 | / with pytest.raises(RuntimeError): +92 | | async for a in b: +93 | | print() + | |___________________^ PT012 +94 | +95 | with pytest.raises(RuntimeError): + | + +PT012.py:95:5: PT012 `pytest.raises()` block should contain a single simple statement + | +93 | print() +94 | +95 | / with pytest.raises(RuntimeError): +96 | | async for a in b: +97 | | assert foo + | |______________________^ PT012 + | diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031.snap index 8d4dd730c7f1f..1df272526e43f 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031.snap +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031.snap @@ -78,3 +78,95 @@ PT031.py:71:5: PT031 `pytest.warns()` block should contain a single simple state 75 | | raise | |_________________^ PT031 | + +PT031.py:83:5: PT031 `pytest.warns()` block should contain a single simple statement + | +81 | ## Errors +82 | +83 | / with pytest.warns(RuntimeError): +84 | | for a in b: +85 | | print() + | |___________________^ PT031 +86 | +87 | with pytest.warns(RuntimeError): + | + +PT031.py:87:5: PT031 `pytest.warns()` block should contain a single simple statement + | +85 | print() +86 | +87 | / with pytest.warns(RuntimeError): +88 | | for a in b: +89 | | assert foo + | |______________________^ PT031 +90 | +91 | with pytest.warns(RuntimeError): + | + +PT031.py:91:5: PT031 `pytest.warns()` block should contain a single simple statement + | +89 | assert foo +90 | +91 | / with pytest.warns(RuntimeError): +92 | | async for a in b: +93 | | print() + | |___________________^ PT031 +94 | +95 | with pytest.warns(RuntimeError): + | + +PT031.py:95:5: PT031 `pytest.warns()` block should contain a single simple statement + | +93 | print() +94 | +95 | / with pytest.warns(RuntimeError): +96 | | async for a in b: +97 | | assert foo + | |______________________^ PT031 + | + +PT031.py:102:5: PT031 `pytest.warns()` block should contain a single simple statement + | +100 | ## No errors in preview +101 | +102 | / with pytest.warns(RuntimeError): +103 | | for a in b: +104 | | pass + | |________________^ PT031 +105 | +106 | with pytest.warns(RuntimeError): + | + +PT031.py:106:5: PT031 `pytest.warns()` block should contain a single simple statement + | +104 | pass +105 | +106 | / with pytest.warns(RuntimeError): +107 | | for a in b: +108 | | ... + | |_______________^ PT031 +109 | +110 | with pytest.warns(RuntimeError): + | + +PT031.py:110:5: PT031 `pytest.warns()` block should contain a single simple statement + | +108 | ... +109 | +110 | / with pytest.warns(RuntimeError): +111 | | async for a in b: +112 | | pass + | |________________^ PT031 +113 | +114 | with pytest.warns(RuntimeError): + | + +PT031.py:114:5: PT031 `pytest.warns()` block should contain a single simple statement + | +112 | pass +113 | +114 | / with pytest.warns(RuntimeError): +115 | | async for a in b: +116 | | ... + | |_______________^ PT031 + | diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031_preview.snap b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031_preview.snap new file mode 100644 index 0000000000000..ebf9e8b2fccd2 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/snapshots/ruff_linter__rules__flake8_pytest_style__tests__PT031_preview.snap @@ -0,0 +1,126 @@ +--- +source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs +--- +PT031.py:42:5: PT031 `pytest.warns()` block should contain a single simple statement + | +41 | def test_error_multiple_statements(): +42 | / with pytest.warns(UserWarning): +43 | | foo() +44 | | bar() + | |_____________^ PT031 + | + +PT031.py:48:5: PT031 `pytest.warns()` block should contain a single simple statement + | +47 | async def test_error_complex_statement(): +48 | / with pytest.warns(UserWarning): +49 | | if True: +50 | | foo() + | |_________________^ PT031 +51 | +52 | with pytest.warns(UserWarning): + | + +PT031.py:52:5: PT031 `pytest.warns()` block should contain a single simple statement + | +50 | foo() +51 | +52 | / with pytest.warns(UserWarning): +53 | | for i in []: +54 | | foo() + | |_________________^ PT031 +55 | +56 | with pytest.warns(UserWarning): + | + +PT031.py:56:5: PT031 `pytest.warns()` block should contain a single simple statement + | +54 | foo() +55 | +56 | / with pytest.warns(UserWarning): +57 | | async for i in []: +58 | | foo() + | |_________________^ PT031 +59 | +60 | with pytest.warns(UserWarning): + | + +PT031.py:60:5: PT031 `pytest.warns()` block should contain a single simple statement + | +58 | foo() +59 | +60 | / with pytest.warns(UserWarning): +61 | | while True: +62 | | foo() + | |_________________^ PT031 +63 | +64 | with pytest.warns(UserWarning): + | + +PT031.py:64:5: PT031 `pytest.warns()` block should contain a single simple statement + | +62 | foo() +63 | +64 | / with pytest.warns(UserWarning): +65 | | async with context_manager_under_test(): +66 | | if True: +67 | | foo() + | |_____________________^ PT031 + | + +PT031.py:71:5: PT031 `pytest.warns()` block should contain a single simple statement + | +70 | def test_error_try(): +71 | / with pytest.warns(UserWarning): +72 | | try: +73 | | foo() +74 | | except: +75 | | raise + | |_________________^ PT031 + | + +PT031.py:83:5: PT031 `pytest.warns()` block should contain a single simple statement + | +81 | ## Errors +82 | +83 | / with pytest.warns(RuntimeError): +84 | | for a in b: +85 | | print() + | |___________________^ PT031 +86 | +87 | with pytest.warns(RuntimeError): + | + +PT031.py:87:5: PT031 `pytest.warns()` block should contain a single simple statement + | +85 | print() +86 | +87 | / with pytest.warns(RuntimeError): +88 | | for a in b: +89 | | assert foo + | |______________________^ PT031 +90 | +91 | with pytest.warns(RuntimeError): + | + +PT031.py:91:5: PT031 `pytest.warns()` block should contain a single simple statement + | +89 | assert foo +90 | +91 | / with pytest.warns(RuntimeError): +92 | | async for a in b: +93 | | print() + | |___________________^ PT031 +94 | +95 | with pytest.warns(RuntimeError): + | + +PT031.py:95:5: PT031 `pytest.warns()` block should contain a single simple statement + | +93 | print() +94 | +95 | / with pytest.warns(RuntimeError): +96 | | async for a in b: +97 | | assert foo + | |______________________^ PT031 + |