diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 6b2a317bae..4449f06671 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1184,6 +1184,16 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Logging, "014") => (RuleGroup::Stable, rules::flake8_logging::rules::ExcInfoOutsideExceptHandler), (Flake8Logging, "015") => (RuleGroup::Stable, rules::flake8_logging::rules::RootLoggerCall), + // flake8-mock-spec + (Flake8MockSpec, "010") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::Mock), + (Flake8MockSpec, "011") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::MagicMock), + (Flake8MockSpec, "012") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::NonCallableMock), + (Flake8MockSpec, "013") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::AsyncMock), + (Flake8MockSpec, "014") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::ThreadingMock), + (Flake8MockSpec, "020") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::Patch), + (Flake8MockSpec, "021") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::PatchObject), + (Flake8MockSpec, "022") => (RuleGroup::Preview, rules::flake8_mock_spec::rules::PatchMultiple), + _ => return None, }) } diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 9351cb5de2..7566895f08 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -112,6 +112,9 @@ pub enum Linter { /// [flake8-logging-format](https://pypi.org/project/flake8-logging-format/) #[prefix = "G"] Flake8LoggingFormat, + /// [flake8-mock-spec](https://pypi.org/project/flake8-mock-spec/) + #[prefix = "TMS"] + Flake8MockSpec, /// [flake8-no-pep420](https://pypi.org/project/flake8-no-pep420/) #[prefix = "INP"] Flake8NoPep420, diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/async_mock.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/async_mock.rs new file mode 100644 index 0000000000..6f1814a49f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/async_mock.rs @@ -0,0 +1,33 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct AsyncMock; + +impl Violation for AsyncMock { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.AsyncMock` without `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "AsyncMock"]) + }) + { + if call.arguments.find_argument("spec", 0).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(AsyncMock, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/magic_mock.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/magic_mock.rs new file mode 100644 index 0000000000..efe788d2eb --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/magic_mock.rs @@ -0,0 +1,33 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct MagicMock; + +impl Violation for MagicMock { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.MagicMock` without `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "MagicMock"]) + }) + { + if call.arguments.find_argument("spec", 0).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(MagicMock, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/mock.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/mock.rs new file mode 100644 index 0000000000..a1d1bc95cc --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/mock.rs @@ -0,0 +1,33 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct Mock; + +impl Violation for Mock { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.Mock` without `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "Mock"]) + }) + { + if call.arguments.find_argument("spec", 0).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(Mock, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/non_callable_mock.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/non_callable_mock.rs new file mode 100644 index 0000000000..7676b7f412 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/non_callable_mock.rs @@ -0,0 +1,33 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct NonCallableMock; + +impl Violation for NonCallableMock { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.NonCallableMock` without `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "NonCallableMock"]) + }) + { + if call.arguments.find_argument("spec", 0).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(NonCallableMock, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch.rs new file mode 100644 index 0000000000..493ce48f2a --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch.rs @@ -0,0 +1,36 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct Patch; + +impl Violation for Patch { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.patch` without one any `autospec`, `new`, `new_callable`, `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "patch"]) + }) + { + if call.arguments.find_keyword("autospec").is_none() + && call.arguments.find_argument("new", 1).is_none() + && call.arguments.find_keyword("new_callable").is_none() + && call.arguments.find_keyword("spec").is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(Patch, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_multiple.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_multiple.rs new file mode 100644 index 0000000000..02f3860284 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_multiple.rs @@ -0,0 +1,35 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct PatchMultiple; + +impl Violation for PatchMultiple { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.patch.multiple` without one any `autospec`, `new`, `new_callable`, `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "patch", "multiple"]) + }) + { + if call.arguments.find_keyword("autospec").is_none() + && call.arguments.find_keyword("new_callable").is_none() + && call.arguments.find_argument("spec", 1).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(PatchMultiple, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_object.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_object.rs new file mode 100644 index 0000000000..98ee8eaa29 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/patch_object.rs @@ -0,0 +1,36 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct PatchObject; + +impl Violation for PatchObject { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.patch.object` without one any `autospec`, `new`, `new_callable`, `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "patch", "object"]) + }) + { + if call.arguments.find_keyword("autospec").is_none() + && call.arguments.find_argument("new", 2).is_none() + && call.arguments.find_keyword("new_callable").is_none() + && call.arguments.find_keyword("spec").is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(PatchObject, call.func.range()); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_mock_spec/rules/threading_mock.rs b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/threading_mock.rs new file mode 100644 index 0000000000..f49bb8e734 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_mock_spec/rules/threading_mock.rs @@ -0,0 +1,33 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; + +use crate::Violation; + +#[derive(ViolationMetadata)] +pub(crate) struct ThreadingMock; + +impl Violation for ThreadingMock { + #[derive_message_formats] + fn message(&self) -> String { + "`unittest.mock.ThreadingMock` without `spec` or `spec_set` argument".to_string() + } +} + +pub(crate) fn mock(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::UNITTEST) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["unittest", "mock", "ThreadingMock"]) + }) + { + if call.arguments.find_argument("spec", 0).is_none() + && call.arguments.find_keyword("spec_set").is_none() + { + let mut diagnostic = checker.report_diagnostic(ThreadingMock, call.func.range()); + } + } +} diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 4f2dc47162..33ea4798c5 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -1485,6 +1485,7 @@ impl<'a> SemanticModel<'a> { "airflow" => self.seen.insert(Modules::AIRFLOW), "hashlib" => self.seen.insert(Modules::HASHLIB), "crypt" => self.seen.insert(Modules::CRYPT), + "unittest" => self.seen.insert(Modules::UNITTEST), _ => {} } } @@ -2196,6 +2197,7 @@ bitflags! { const AIRFLOW = 1 << 27; const HASHLIB = 1 << 28; const CRYPT = 1 << 29; + const UNITTEST = 1 << 30; } }