Skip to content

Commit

Permalink
[flake8-pytest-style] Test function parameters with default argumen…
Browse files Browse the repository at this point in the history
…ts (`PT028`) (astral-sh#15449)
  • Loading branch information
InSyncWithFoo authored Jan 13, 2025
1 parent 56b1445 commit 47d0a8b
Show file tree
Hide file tree
Showing 11 changed files with 484 additions and 4 deletions.
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();

if !func.name.starts_with("test") {
return false;
}

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()
{
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

0 comments on commit 47d0a8b

Please sign in to comment.