Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[flake8-pytest-style] Test function parameters with default arguments (PT028) #15449

Merged
merged 5 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Errors

def test_foo(a=1): ...
def test_foo(a = 1): ...
def test_foo(a = (1)): ...
def test_foo(a: int=1): ...
def test_foo(a: int = 1): ...
def test_foo(a: (int) = 1): ...
def test_foo(a: int = (1)): ...
def test_foo(a: (int) = (1)): ...
def test_foo(a=1, /, b=2, *, c=3): ...


# No errors

def test_foo(a): ...
def test_foo(a: int): ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Errors

def test_this_is_a_test(a=1): ...
def testThisIsAlsoATest(a=1): ...

class TestClass:
def test_this_too_is_a_test(self, a=1): ...
def testAndOfCourseThis(self, a=1): ...


# No errors

def this_is_not_a_test(a=1): ...

class TestClassLookalike:
def __init__(self): ...
def test_this_is_not_a_test(self, a=1): ...
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
if checker.enabled(Rule::PostInitDefault) {
ruff::rules::post_init_default(checker, function_def);
}
if checker.enabled(Rule::PytestParameterWithDefaultArgument) {
flake8_pytest_style::rules::parameter_with_default_argument(checker, function_def);
}
}
Stmt::Return(_) => {
if checker.enabled(Rule::ReturnOutsideFunction) {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8PytestStyle, "025") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestErroneousUseFixturesOnFixture),
(Flake8PytestStyle, "026") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestUseFixturesWithoutParameters),
(Flake8PytestStyle, "027") => (RuleGroup::Stable, rules::flake8_pytest_style::rules::PytestUnittestRaisesAssertion),
(Flake8PytestStyle, "028") => (RuleGroup::Preview, rules::flake8_pytest_style::rules::PytestParameterWithDefaultArgument),
(Flake8PytestStyle, "029") => (RuleGroup::Preview, rules::flake8_pytest_style::rules::PytestWarnsWithoutWarning),
(Flake8PytestStyle, "030") => (RuleGroup::Preview, rules::flake8_pytest_style::rules::PytestWarnsTooBroad),
(Flake8PytestStyle, "031") => (RuleGroup::Preview, rules::flake8_pytest_style::rules::PytestWarnsWithMultipleStatements),
Expand Down
12 changes: 12 additions & 0 deletions crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ mod tests {
use super::settings::Settings;
use super::types;

#[test_case(
Rule::PytestParameterWithDefaultArgument,
Path::new("is_pytest_test.py"),
Settings::default(),
"is_pytest_test"
)]
#[test_case(
Rule::PytestFixtureIncorrectParenthesesStyle,
Path::new("PT001.py"),
Expand Down Expand Up @@ -275,6 +281,12 @@ mod tests {
Settings::default(),
"PT027_1"
)]
#[test_case(
Rule::PytestParameterWithDefaultArgument,
Path::new("PT028.py"),
Settings::default(),
"PT028"
)]
#[test_case(
Rule::PytestWarnsWithoutWarning,
Path::new("PT029.py"),
Expand Down
50 changes: 46 additions & 4 deletions crates/ruff_linter/src/rules/flake8_pytest_style/rules/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::fmt;

use crate::checkers::ast::Checker;
use ruff_python_ast::helpers::map_callable;
use ruff_python_ast::name::UnqualifiedName;
use ruff_python_ast::{self as ast, Decorator, Expr, ExprCall, Keyword};
use ruff_python_semantic::SemanticModel;
use ruff_python_ast::{self as ast, Decorator, Expr, ExprCall, Keyword, Stmt, StmtFunctionDef};
use ruff_python_semantic::analyze::visibility;
use ruff_python_semantic::{ScopeKind, SemanticModel};
use ruff_python_trivia::PythonWhitespace;
use std::fmt;

pub(super) fn get_mark_decorators(
decorators: &[Decorator],
Expand Down Expand Up @@ -46,6 +47,47 @@ pub(super) fn is_pytest_parametrize(call: &ExprCall, semantic: &SemanticModel) -
})
}

/// Whether the currently checked `func` is likely to be a Pytest test.
///
/// A normal Pytest test function is one whose name starts with `test` and is either:
///
/// * Placed at module-level, or
/// * Placed within a class whose name starts with `Test` and does not have an `__init__` method.
///
/// During test discovery, Pytest respects a few settings which we do not have access to.
/// This function is thus prone to both false positives and false negatives.
///
/// References:
/// - [`pytest` documentation: Conventions for Python test discovery](https://docs.pytest.org/en/stable/explanation/goodpractices.html#conventions-for-python-test-discovery)
/// - [`pytest` documentation: Changing naming conventions](https://docs.pytest.org/en/stable/example/pythoncollection.html#changing-naming-conventions)
pub(crate) fn is_likely_pytest_test(func: &StmtFunctionDef, checker: &Checker) -> bool {
let semantic = checker.semantic();
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved

if !func.name.starts_with("test") {
return false;
MichaReiser marked this conversation as resolved.
Show resolved Hide resolved
}

if semantic.scope_id.is_global() {
return true;
}

let ScopeKind::Class(class) = semantic.current_scope().kind else {
return false;
};

if !class.name.starts_with("Test") {
return false;
}

class.body.iter().all(|stmt| {
let Stmt::FunctionDef(function) = stmt else {
return true;
};

!visibility::is_init(&function.name)
})
}

pub(super) fn keyword_is_literal(keyword: &Keyword, literal: &str) -> bool {
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = &keyword.value {
value == literal
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/flake8_pytest_style/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(crate) use marks::*;
pub(crate) use parametrize::*;
pub(crate) use patch::*;
pub(crate) use raises::*;
pub(crate) use test_functions::*;
pub(crate) use warns::*;

mod assertion;
Expand All @@ -17,5 +18,6 @@ mod marks;
mod parametrize;
mod patch;
mod raises;
mod test_functions;
mod unittest_assert;
mod warns;
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use crate::checkers::ast::Checker;
use crate::rules::flake8_pytest_style::rules::helpers::is_likely_pytest_test;
use ruff_diagnostics::{Diagnostic, Edit, Fix, Violation};
use ruff_macros::{derive_message_formats, ViolationMetadata};
use ruff_python_ast::{ParameterWithDefault, StmtFunctionDef};
use ruff_text_size::Ranged;

/// ## What it does
/// Checks for parameters of test functions with default arguments.
///
/// ## Why is this bad?
/// Such a parameter will always have the default value during the test
/// regardless of whether a fixture with the same name is defined.
///
/// ## Example
///
/// ```python
/// def test_foo(a=1): ...
/// ```
///
/// Use instead:
///
/// ```python
/// def test_foo(a): ...
/// ```
///
/// ## Fix safety
/// This rule's fix is marked as unsafe, as modifying a function signature can
/// change the behavior of the code.
///
/// ## References
/// - [Original Pytest issue](https://github.com/pytest-dev/pytest/issues/12693)
#[derive(ViolationMetadata)]
pub(crate) struct PytestParameterWithDefaultArgument {
parameter_name: String,
}

impl Violation for PytestParameterWithDefaultArgument {
#[derive_message_formats]
fn message(&self) -> String {
format!(
"Test function parameter `{}` has default argument",
self.parameter_name
)
}

fn fix_title(&self) -> Option<String> {
Some("Remove default argument".to_string())
}
}

/// PT028
pub(crate) fn parameter_with_default_argument(
checker: &mut Checker,
function_def: &StmtFunctionDef,
) {
if !is_likely_pytest_test(function_def, checker) {
return;
}

let parameters = function_def.parameters.as_ref();

for ParameterWithDefault {
parameter,
default,
range: pwd_range,
} in parameters.iter_non_variadic_params()
InSyncWithFoo marked this conversation as resolved.
Show resolved Hide resolved
{
let Some(default) = default else {
continue;
};

let parameter_name = parameter.name.to_string();
let kind = PytestParameterWithDefaultArgument { parameter_name };
let diagnostic = Diagnostic::new(kind, default.range());

let edit = Edit::deletion(parameter.end(), pwd_range.end());
let fix = Fix::display_only_edit(edit);

checker.diagnostics.push(diagnostic.with_fix(fix));
}
}
Loading
Loading