From 919af9628d2b8b45a2cc19bd2c09737a6d1657a0 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Tue, 24 Jun 2025 21:27:21 +0000 Subject: [PATCH] [`pygrep_hooks`] Add `AsyncMock` methods to `invalid-mock-access` (`PGH005`) (#18547) ## Summary This PR expands PGH005 to also check for AsyncMock methods in the same vein. E.g., currently `assert mock.not_called` is linted. This PR adds the corresponding async assertions `assert mock.not_awaited()`. --------- Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> --- .../test/fixtures/pygrep_hooks/PGH005_0.py | 24 +++ crates/ruff_linter/src/preview.rs | 5 + .../ruff_linter/src/rules/pygrep_hooks/mod.rs | 19 ++ .../pygrep_hooks/rules/invalid_mock_access.rs | 35 +++- ...grep_hooks__tests__PGH005_PGH005_0.py.snap | 139 ++++++------- ...s__tests__preview__PGH005_PGH005_0.py.snap | 190 ++++++++++++++++++ 6 files changed, 339 insertions(+), 73 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/pygrep_hooks/PGH005_0.py b/crates/ruff_linter/resources/test/fixtures/pygrep_hooks/PGH005_0.py index cb80cc8c28..18e741d78b 100644 --- a/crates/ruff_linter/resources/test/fixtures/pygrep_hooks/PGH005_0.py +++ b/crates/ruff_linter/resources/test/fixtures/pygrep_hooks/PGH005_0.py @@ -1,3 +1,5 @@ +# Mock objects +# ============ # Errors assert my_mock.not_called() assert my_mock.called_once_with() @@ -17,3 +19,25 @@ my_mock.assert_called() my_mock.assert_called_once_with() """like :meth:`Mock.assert_called_once_with`""" """like :meth:`MagicMock.assert_called_once_with`""" + +# AsyncMock objects +# ================= +# Errors +assert my_mock.not_awaited() +assert my_mock.awaited_once_with() +assert my_mock.not_awaited +assert my_mock.awaited_once_with +my_mock.assert_not_awaited +my_mock.assert_awaited +my_mock.assert_awaited_once_with +my_mock.assert_awaited_once_with +MyMock.assert_awaited_once_with +assert my_mock.awaited + +# OK +assert my_mock.await_count == 1 +my_mock.assert_not_awaited() +my_mock.assert_awaited() +my_mock.assert_awaited_once_with() +"""like :meth:`Mock.assert_awaited_once_with`""" +"""like :meth:`MagicMock.assert_awaited_once_with`""" diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 89efe7db19..d33fb7d170 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -89,3 +89,8 @@ pub(crate) const fn is_ignore_init_files_in_useless_alias_enabled( ) -> bool { settings.preview.is_enabled() } + +// https://github.com/astral-sh/ruff/pull/18547 +pub(crate) const fn is_invalid_async_mock_access_check_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs b/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs index 525a3ff0be..1117bb516a 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/mod.rs @@ -10,6 +10,7 @@ mod tests { use crate::registry::Rule; + use crate::settings::types::PreviewMode; use crate::test::test_path; use crate::{assert_diagnostics, settings}; @@ -29,4 +30,22 @@ mod tests { assert_diagnostics!(snapshot, diagnostics); Ok(()) } + + #[test_case(Rule::InvalidMockAccess, Path::new("PGH005_0.py"))] + fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!( + "preview__{}_{}", + rule_code.noqa_code(), + path.to_string_lossy() + ); + let diagnostics = test_path( + Path::new("pygrep_hooks").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } } diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs index 766b6f4260..9ff519116d 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/invalid_mock_access.rs @@ -5,6 +5,7 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; +use crate::preview::is_invalid_async_mock_access_check_enabled; #[derive(Debug, PartialEq, Eq)] enum Reason { @@ -51,7 +52,7 @@ impl Violation for InvalidMockAccess { /// PGH005 pub(crate) fn uncalled_mock_method(checker: &Checker, expr: &Expr) { if let Expr::Attribute(ast::ExprAttribute { attr, .. }) = expr { - if matches!( + let is_uncalled_mock_method = matches!( attr.as_str(), "assert_any_call" | "assert_called" @@ -60,7 +61,20 @@ pub(crate) fn uncalled_mock_method(checker: &Checker, expr: &Expr) { | "assert_called_with" | "assert_has_calls" | "assert_not_called" - ) { + ); + let is_uncalled_async_mock_method = + is_invalid_async_mock_access_check_enabled(checker.settings()) + && matches!( + attr.as_str(), + "assert_awaited" + | "assert_awaited_once" + | "assert_awaited_with" + | "assert_awaited_once_with" + | "assert_any_await" + | "assert_has_awaits" + | "assert_not_awaited" + ); + if is_uncalled_mock_method || is_uncalled_async_mock_method { checker.report_diagnostic( InvalidMockAccess { reason: Reason::UncalledMethod(attr.to_string()), @@ -81,7 +95,7 @@ pub(crate) fn non_existent_mock_method(checker: &Checker, test: &Expr) { }, _ => return, }; - if matches!( + let is_missing_mock_method = matches!( attr.as_str(), "any_call" | "called_once" @@ -89,7 +103,20 @@ pub(crate) fn non_existent_mock_method(checker: &Checker, test: &Expr) { | "called_with" | "has_calls" | "not_called" - ) { + ); + let is_missing_async_mock_method = + is_invalid_async_mock_access_check_enabled(checker.settings()) + && matches!( + attr.as_str(), + "awaited" + | "awaited_once" + | "awaited_with" + | "awaited_once_with" + | "any_await" + | "has_awaits" + | "not_awaited" + ); + if is_missing_mock_method || is_missing_async_mock_method { checker.report_diagnostic( InvalidMockAccess { reason: Reason::NonExistentMethod(attr.to_string()), diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap index 1d8c3f41ff..f95f64df91 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap +++ b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__PGH005_PGH005_0.py.snap @@ -1,90 +1,91 @@ --- source: crates/ruff_linter/src/rules/pygrep_hooks/mod.rs --- -PGH005_0.py:2:8: PGH005 Non-existent mock method: `not_called` - | -1 | # Errors -2 | assert my_mock.not_called() - | ^^^^^^^^^^^^^^^^^^^^ PGH005 -3 | assert my_mock.called_once_with() -4 | assert my_mock.not_called - | - -PGH005_0.py:3:8: PGH005 Non-existent mock method: `called_once_with` - | -1 | # Errors -2 | assert my_mock.not_called() -3 | assert my_mock.called_once_with() - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 -4 | assert my_mock.not_called -5 | assert my_mock.called_once_with - | - PGH005_0.py:4:8: PGH005 Non-existent mock method: `not_called` | -2 | assert my_mock.not_called() -3 | assert my_mock.called_once_with() -4 | assert my_mock.not_called - | ^^^^^^^^^^^^^^^^^^ PGH005 -5 | assert my_mock.called_once_with -6 | my_mock.assert_not_called +2 | # ============ +3 | # Errors +4 | assert my_mock.not_called() + | ^^^^^^^^^^^^^^^^^^^^ PGH005 +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called | PGH005_0.py:5:8: PGH005 Non-existent mock method: `called_once_with` | -3 | assert my_mock.called_once_with() -4 | assert my_mock.not_called -5 | assert my_mock.called_once_with +3 | # Errors +4 | assert my_mock.not_called() +5 | assert my_mock.called_once_with() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +6 | assert my_mock.not_called +7 | assert my_mock.called_once_with + | + +PGH005_0.py:6:8: PGH005 Non-existent mock method: `not_called` + | +4 | assert my_mock.not_called() +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called + | ^^^^^^^^^^^^^^^^^^ PGH005 +7 | assert my_mock.called_once_with +8 | my_mock.assert_not_called + | + +PGH005_0.py:7:8: PGH005 Non-existent mock method: `called_once_with` + | +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called +7 | assert my_mock.called_once_with | ^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 -6 | my_mock.assert_not_called -7 | my_mock.assert_called +8 | my_mock.assert_not_called +9 | my_mock.assert_called | -PGH005_0.py:6:1: PGH005 Mock method should be called: `assert_not_called` - | -4 | assert my_mock.not_called -5 | assert my_mock.called_once_with -6 | my_mock.assert_not_called - | ^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 -7 | my_mock.assert_called -8 | my_mock.assert_called_once_with - | - -PGH005_0.py:7:1: PGH005 Mock method should be called: `assert_called` - | -5 | assert my_mock.called_once_with -6 | my_mock.assert_not_called -7 | my_mock.assert_called - | ^^^^^^^^^^^^^^^^^^^^^ PGH005 -8 | my_mock.assert_called_once_with -9 | my_mock.assert_called_once_with - | - -PGH005_0.py:8:1: PGH005 Mock method should be called: `assert_called_once_with` +PGH005_0.py:8:1: PGH005 Mock method should be called: `assert_not_called` | - 6 | my_mock.assert_not_called - 7 | my_mock.assert_called - 8 | my_mock.assert_called_once_with - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 - 9 | my_mock.assert_called_once_with -10 | MyMock.assert_called_once_with + 6 | assert my_mock.not_called + 7 | assert my_mock.called_once_with + 8 | my_mock.assert_not_called + | ^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with | -PGH005_0.py:9:1: PGH005 Mock method should be called: `assert_called_once_with` +PGH005_0.py:9:1: PGH005 Mock method should be called: `assert_called` | - 7 | my_mock.assert_called - 8 | my_mock.assert_called_once_with - 9 | my_mock.assert_called_once_with - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 -10 | MyMock.assert_called_once_with + 7 | assert my_mock.called_once_with + 8 | my_mock.assert_not_called + 9 | my_mock.assert_called + | ^^^^^^^^^^^^^^^^^^^^^ PGH005 +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with | PGH005_0.py:10:1: PGH005 Mock method should be called: `assert_called_once_with` | - 8 | my_mock.assert_called_once_with - 9 | my_mock.assert_called_once_with -10 | MyMock.assert_called_once_with - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 -11 | -12 | # OK + 8 | my_mock.assert_not_called + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +11 | my_mock.assert_called_once_with +12 | MyMock.assert_called_once_with + | + +PGH005_0.py:11:1: PGH005 Mock method should be called: `assert_called_once_with` + | + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +12 | MyMock.assert_called_once_with + | + +PGH005_0.py:12:1: PGH005 Mock method should be called: `assert_called_once_with` + | +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with +12 | MyMock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +13 | +14 | # OK | diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap new file mode 100644 index 0000000000..e61728110c --- /dev/null +++ b/crates/ruff_linter/src/rules/pygrep_hooks/snapshots/ruff_linter__rules__pygrep_hooks__tests__preview__PGH005_PGH005_0.py.snap @@ -0,0 +1,190 @@ +--- +source: crates/ruff_linter/src/rules/pygrep_hooks/mod.rs +--- +PGH005_0.py:4:8: PGH005 Non-existent mock method: `not_called` + | +2 | # ============ +3 | # Errors +4 | assert my_mock.not_called() + | ^^^^^^^^^^^^^^^^^^^^ PGH005 +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called + | + +PGH005_0.py:5:8: PGH005 Non-existent mock method: `called_once_with` + | +3 | # Errors +4 | assert my_mock.not_called() +5 | assert my_mock.called_once_with() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +6 | assert my_mock.not_called +7 | assert my_mock.called_once_with + | + +PGH005_0.py:6:8: PGH005 Non-existent mock method: `not_called` + | +4 | assert my_mock.not_called() +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called + | ^^^^^^^^^^^^^^^^^^ PGH005 +7 | assert my_mock.called_once_with +8 | my_mock.assert_not_called + | + +PGH005_0.py:7:8: PGH005 Non-existent mock method: `called_once_with` + | +5 | assert my_mock.called_once_with() +6 | assert my_mock.not_called +7 | assert my_mock.called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +8 | my_mock.assert_not_called +9 | my_mock.assert_called + | + +PGH005_0.py:8:1: PGH005 Mock method should be called: `assert_not_called` + | + 6 | assert my_mock.not_called + 7 | assert my_mock.called_once_with + 8 | my_mock.assert_not_called + | ^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with + | + +PGH005_0.py:9:1: PGH005 Mock method should be called: `assert_called` + | + 7 | assert my_mock.called_once_with + 8 | my_mock.assert_not_called + 9 | my_mock.assert_called + | ^^^^^^^^^^^^^^^^^^^^^ PGH005 +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with + | + +PGH005_0.py:10:1: PGH005 Mock method should be called: `assert_called_once_with` + | + 8 | my_mock.assert_not_called + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +11 | my_mock.assert_called_once_with +12 | MyMock.assert_called_once_with + | + +PGH005_0.py:11:1: PGH005 Mock method should be called: `assert_called_once_with` + | + 9 | my_mock.assert_called +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +12 | MyMock.assert_called_once_with + | + +PGH005_0.py:12:1: PGH005 Mock method should be called: `assert_called_once_with` + | +10 | my_mock.assert_called_once_with +11 | my_mock.assert_called_once_with +12 | MyMock.assert_called_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +13 | +14 | # OK + | + +PGH005_0.py:26:8: PGH005 Non-existent mock method: `not_awaited` + | +24 | # ================= +25 | # Errors +26 | assert my_mock.not_awaited() + | ^^^^^^^^^^^^^^^^^^^^^ PGH005 +27 | assert my_mock.awaited_once_with() +28 | assert my_mock.not_awaited + | + +PGH005_0.py:27:8: PGH005 Non-existent mock method: `awaited_once_with` + | +25 | # Errors +26 | assert my_mock.not_awaited() +27 | assert my_mock.awaited_once_with() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +28 | assert my_mock.not_awaited +29 | assert my_mock.awaited_once_with + | + +PGH005_0.py:28:8: PGH005 Non-existent mock method: `not_awaited` + | +26 | assert my_mock.not_awaited() +27 | assert my_mock.awaited_once_with() +28 | assert my_mock.not_awaited + | ^^^^^^^^^^^^^^^^^^^ PGH005 +29 | assert my_mock.awaited_once_with +30 | my_mock.assert_not_awaited + | + +PGH005_0.py:29:8: PGH005 Non-existent mock method: `awaited_once_with` + | +27 | assert my_mock.awaited_once_with() +28 | assert my_mock.not_awaited +29 | assert my_mock.awaited_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +30 | my_mock.assert_not_awaited +31 | my_mock.assert_awaited + | + +PGH005_0.py:30:1: PGH005 Mock method should be called: `assert_not_awaited` + | +28 | assert my_mock.not_awaited +29 | assert my_mock.awaited_once_with +30 | my_mock.assert_not_awaited + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +31 | my_mock.assert_awaited +32 | my_mock.assert_awaited_once_with + | + +PGH005_0.py:31:1: PGH005 Mock method should be called: `assert_awaited` + | +29 | assert my_mock.awaited_once_with +30 | my_mock.assert_not_awaited +31 | my_mock.assert_awaited + | ^^^^^^^^^^^^^^^^^^^^^^ PGH005 +32 | my_mock.assert_awaited_once_with +33 | my_mock.assert_awaited_once_with + | + +PGH005_0.py:32:1: PGH005 Mock method should be called: `assert_awaited_once_with` + | +30 | my_mock.assert_not_awaited +31 | my_mock.assert_awaited +32 | my_mock.assert_awaited_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +33 | my_mock.assert_awaited_once_with +34 | MyMock.assert_awaited_once_with + | + +PGH005_0.py:33:1: PGH005 Mock method should be called: `assert_awaited_once_with` + | +31 | my_mock.assert_awaited +32 | my_mock.assert_awaited_once_with +33 | my_mock.assert_awaited_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +34 | MyMock.assert_awaited_once_with +35 | assert my_mock.awaited + | + +PGH005_0.py:34:1: PGH005 Mock method should be called: `assert_awaited_once_with` + | +32 | my_mock.assert_awaited_once_with +33 | my_mock.assert_awaited_once_with +34 | MyMock.assert_awaited_once_with + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PGH005 +35 | assert my_mock.awaited + | + +PGH005_0.py:35:8: PGH005 Non-existent mock method: `awaited` + | +33 | my_mock.assert_awaited_once_with +34 | MyMock.assert_awaited_once_with +35 | assert my_mock.awaited + | ^^^^^^^^^^^^^^^ PGH005 +36 | +37 | # OK + |