Add bugbear immutable functions as allowed in dataclasses (#4122)

This commit is contained in:
Moritz Sauter 2023-04-28 03:23:06 +02:00 committed by GitHub
parent 089b64e9c1
commit ee6d8f7467
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 132 additions and 63 deletions

View file

@ -1,5 +1,8 @@
import datetime
import re
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar, NamedTuple from typing import ClassVar, NamedTuple
@ -17,6 +20,12 @@ class A:
class_variable: typing.ClassVar[list[int]] = default_function() class_variable: typing.ClassVar[list[int]] = default_function()
another_class_var: ClassVar[list[int]] = default_function() another_class_var: ClassVar[list[int]] = default_function()
fine_path: Path = Path()
fine_date: datetime.date = datetime.date(2042, 1, 1)
fine_timedelta: datetime.timedelta = datetime.timedelta(hours=7)
fine_tuple: tuple[int] = tuple([1])
fine_regex: re.Pattern = re.compile(r".*")
DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES = ImmutableType(40) DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES = ImmutableType(40)
DEFAULT_A_FOR_ALL_DATACLASSES = A([1, 2, 3]) DEFAULT_A_FOR_ALL_DATACLASSES = A([1, 2, 3])
@ -29,3 +38,5 @@ class B:
not_optimal: ImmutableType = ImmutableType(20) not_optimal: ImmutableType = ImmutableType(20)
good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
fine_dataclass_function: list[int] = field(default_factory=list)

View file

@ -1,3 +1,4 @@
use ruff_python_semantic::analyze::typing::is_immutable_func;
use ruff_text_size::TextRange; use ruff_text_size::TextRange;
use rustpython_parser::ast::{Arguments, Constant, Expr, ExprKind}; use rustpython_parser::ast::{Arguments, Constant, Expr, ExprKind};
@ -13,6 +14,45 @@ use crate::checkers::ast::Checker;
use super::mutable_argument_default::is_mutable_func; use super::mutable_argument_default::is_mutable_func;
/// ## What it does
/// Checks for function calls in function defaults.
///
/// ## Why is it bad?
/// The function calls in the defaults are only performed once, at definition
/// time. The returned value is then reused by all calls to the function.
///
/// ## Options
/// - `flake8-bugbear.extend-immutable-calls`
///
/// ## Examples:
/// ```python
/// def create_list() -> list[int]:
/// return [1, 2, 3]
///
/// def mutable_default(arg: list[int] = create_list()) -> list[int]:
/// arg.append(4)
/// return arg
/// ```
///
/// Use instead:
/// ```python
/// def better(arg: list[int] | None = None) -> list[int]:
/// if arg is None:
/// arg = create_list()
///
/// arg.append(4)
/// return arg
/// ```
///
/// Alternatively, if you _want_ the shared behaviour, make it more obvious
/// by assigning it to a module-level variable:
/// ```python
/// I_KNOW_THIS_IS_SHARED_STATE = create_list()
///
/// def mutable_default(arg: list[int] = I_KNOW_THIS_IS_SHARED_STATE) -> list[int]:
/// arg.append(4)
/// return arg
/// ```
#[violation] #[violation]
pub struct FunctionCallInDefaultArgument { pub struct FunctionCallInDefaultArgument {
pub name: Option<String>, pub name: Option<String>,
@ -30,35 +70,6 @@ impl Violation for FunctionCallInDefaultArgument {
} }
} }
const IMMUTABLE_FUNCS: &[&[&str]] = &[
&["", "tuple"],
&["", "frozenset"],
&["datetime", "date"],
&["datetime", "datetime"],
&["datetime", "timedelta"],
&["decimal", "Decimal"],
&["operator", "attrgetter"],
&["operator", "itemgetter"],
&["operator", "methodcaller"],
&["pathlib", "Path"],
&["types", "MappingProxyType"],
&["re", "compile"],
];
fn is_immutable_func(checker: &Checker, func: &Expr, extend_immutable_calls: &[CallPath]) -> bool {
checker
.ctx
.resolve_call_path(func)
.map_or(false, |call_path| {
IMMUTABLE_FUNCS
.iter()
.any(|target| call_path.as_slice() == *target)
|| extend_immutable_calls
.iter()
.any(|target| call_path == *target)
})
}
struct ArgumentDefaultVisitor<'a> { struct ArgumentDefaultVisitor<'a> {
checker: &'a Checker<'a>, checker: &'a Checker<'a>,
diagnostics: Vec<(DiagnosticKind, TextRange)>, diagnostics: Vec<(DiagnosticKind, TextRange)>,
@ -73,7 +84,7 @@ where
match &expr.node { match &expr.node {
ExprKind::Call { func, args, .. } => { ExprKind::Call { func, args, .. } => {
if !is_mutable_func(self.checker, func) if !is_mutable_func(self.checker, func)
&& !is_immutable_func(self.checker, func, &self.extend_immutable_calls) && !is_immutable_func(&self.checker.ctx, func, &self.extend_immutable_calls)
&& !is_nan_or_infinity(func, args) && !is_nan_or_infinity(func, args)
{ {
self.diagnostics.push(( self.diagnostics.push((

View file

@ -23,7 +23,8 @@ pub struct Options {
"# "#
)] )]
/// Additional callable functions to consider "immutable" when evaluating, /// Additional callable functions to consider "immutable" when evaluating,
/// e.g., the `no-mutable-default-argument` rule (`B006`). /// e.g., the `no-mutable-default-argument` rule (`B006`) or
/// `no-function-call-in-dataclass-defaults` rule (`RUF009`).
pub extend_immutable_calls: Option<Vec<String>>, pub extend_immutable_calls: Option<Vec<String>>,
} }

View file

@ -1,10 +1,13 @@
use ruff_python_ast::call_path::{from_qualified_name, CallPath};
use rustpython_parser::ast::{Expr, ExprKind, Stmt, StmtKind}; use rustpython_parser::ast::{Expr, ExprKind, Stmt, StmtKind};
use ruff_diagnostics::{Diagnostic, Violation}; use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{call_path::compose_call_path, helpers::map_callable}; use ruff_python_ast::{call_path::compose_call_path, helpers::map_callable};
use ruff_python_semantic::analyze::typing::is_immutable_annotation; use ruff_python_semantic::{
use ruff_python_semantic::context::Context; analyze::typing::{is_immutable_annotation, is_immutable_func},
context::Context,
};
use crate::checkers::ast::Checker; use crate::checkers::ast::Checker;
@ -64,6 +67,9 @@ impl Violation for MutableDataclassDefault {
/// Function calls are only performed once, at definition time. The returned /// Function calls are only performed once, at definition time. The returned
/// value is then reused by all instances of the dataclass. /// value is then reused by all instances of the dataclass.
/// ///
/// ## Options
/// - `flake8-bugbear.extend-immutable-calls`
///
/// ## Examples: /// ## Examples:
/// ```python /// ```python
/// from dataclasses import dataclass /// from dataclasses import dataclass
@ -141,11 +147,11 @@ fn is_mutable_expr(expr: &Expr) -> bool {
) )
} }
const ALLOWED_FUNCS: &[&[&str]] = &[&["dataclasses", "field"]]; const ALLOWED_DATACLASS_SPECIFIC_FUNCTIONS: &[&[&str]] = &[&["dataclasses", "field"]];
fn is_allowed_func(context: &Context, func: &Expr) -> bool { fn is_allowed_dataclass_function(context: &Context, func: &Expr) -> bool {
context.resolve_call_path(func).map_or(false, |call_path| { context.resolve_call_path(func).map_or(false, |call_path| {
ALLOWED_FUNCS ALLOWED_DATACLASS_SPECIFIC_FUNCTIONS
.iter() .iter()
.any(|target| call_path.as_slice() == *target) .any(|target| call_path.as_slice() == *target)
}) })
@ -161,6 +167,14 @@ fn is_class_var_annotation(context: &Context, annotation: &Expr) -> bool {
/// RUF009 /// RUF009
pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt]) { pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt]) {
let extend_immutable_calls: Vec<CallPath> = checker
.settings
.flake8_bugbear
.extend_immutable_calls
.iter()
.map(|target| from_qualified_name(target))
.collect();
for statement in body { for statement in body {
if let StmtKind::AnnAssign { if let StmtKind::AnnAssign {
annotation, annotation,
@ -172,7 +186,9 @@ pub fn function_call_in_dataclass_defaults(checker: &mut Checker, body: &[Stmt])
continue; continue;
} }
if let ExprKind::Call { func, .. } = &expr.node { if let ExprKind::Call { func, .. } = &expr.node {
if !is_allowed_func(&checker.ctx, func) { if !is_immutable_func(&checker.ctx, func, &extend_immutable_calls)
&& !is_allowed_dataclass_function(&checker.ctx, func)
{
checker.diagnostics.push(Diagnostic::new( checker.diagnostics.push(Diagnostic::new(
FunctionCallInDataclassDefaultArgument { FunctionCallInDataclassDefaultArgument {
name: compose_call_path(func), name: compose_call_path(func),

View file

@ -1,44 +1,44 @@
--- ---
source: crates/ruff/src/rules/ruff/mod.rs source: crates/ruff/src/rules/ruff/mod.rs
--- ---
RUF009.py:16:41: RUF009 Do not perform function call `default_function` in dataclass defaults RUF009.py:19:41: RUF009 Do not perform function call `default_function` in dataclass defaults
| |
16 | @dataclass() 19 | @dataclass()
17 | class A: 20 | class A:
18 | hidden_mutable_default: list[int] = default_function() 21 | hidden_mutable_default: list[int] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF009 | ^^^^^^^^^^^^^^^^^^ RUF009
19 | class_variable: typing.ClassVar[list[int]] = default_function() 22 | class_variable: typing.ClassVar[list[int]] = default_function()
20 | another_class_var: ClassVar[list[int]] = default_function() 23 | another_class_var: ClassVar[list[int]] = default_function()
| |
RUF009.py:27:41: RUF009 Do not perform function call `default_function` in dataclass defaults RUF009.py:36:41: RUF009 Do not perform function call `default_function` in dataclass defaults
| |
27 | @dataclass 36 | @dataclass
28 | class B: 37 | class B:
29 | hidden_mutable_default: list[int] = default_function() 38 | hidden_mutable_default: list[int] = default_function()
| ^^^^^^^^^^^^^^^^^^ RUF009 | ^^^^^^^^^^^^^^^^^^ RUF009
30 | another_dataclass: A = A() 39 | another_dataclass: A = A()
31 | not_optimal: ImmutableType = ImmutableType(20) 40 | not_optimal: ImmutableType = ImmutableType(20)
| |
RUF009.py:28:28: RUF009 Do not perform function call `A` in dataclass defaults RUF009.py:37:28: RUF009 Do not perform function call `A` in dataclass defaults
| |
28 | class B: 37 | class B:
29 | hidden_mutable_default: list[int] = default_function() 38 | hidden_mutable_default: list[int] = default_function()
30 | another_dataclass: A = A() 39 | another_dataclass: A = A()
| ^^^ RUF009 | ^^^ RUF009
31 | not_optimal: ImmutableType = ImmutableType(20) 40 | not_optimal: ImmutableType = ImmutableType(20)
32 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES 41 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
| |
RUF009.py:29:34: RUF009 Do not perform function call `ImmutableType` in dataclass defaults RUF009.py:38:34: RUF009 Do not perform function call `ImmutableType` in dataclass defaults
| |
29 | hidden_mutable_default: list[int] = default_function() 38 | hidden_mutable_default: list[int] = default_function()
30 | another_dataclass: A = A() 39 | another_dataclass: A = A()
31 | not_optimal: ImmutableType = ImmutableType(20) 40 | not_optimal: ImmutableType = ImmutableType(20)
| ^^^^^^^^^^^^^^^^^ RUF009 | ^^^^^^^^^^^^^^^^^ RUF009
32 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES 41 | good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
33 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES 42 | okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES
| |

View file

@ -122,3 +122,33 @@ pub fn is_immutable_annotation(context: &Context, expr: &Expr) -> bool {
_ => false, _ => false,
} }
} }
const IMMUTABLE_FUNCS: &[&[&str]] = &[
&["", "tuple"],
&["", "frozenset"],
&["datetime", "date"],
&["datetime", "datetime"],
&["datetime", "timedelta"],
&["decimal", "Decimal"],
&["operator", "attrgetter"],
&["operator", "itemgetter"],
&["operator", "methodcaller"],
&["pathlib", "Path"],
&["types", "MappingProxyType"],
&["re", "compile"],
];
pub fn is_immutable_func(
context: &Context,
func: &Expr,
extend_immutable_calls: &[CallPath],
) -> bool {
context.resolve_call_path(func).map_or(false, |call_path| {
IMMUTABLE_FUNCS
.iter()
.any(|target| call_path.as_slice() == *target)
|| extend_immutable_calls
.iter()
.any(|target| call_path == *target)
})
}

2
ruff.schema.json generated
View file

@ -669,7 +669,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"extend-immutable-calls": { "extend-immutable-calls": {
"description": "Additional callable functions to consider \"immutable\" when evaluating, e.g., the `no-mutable-default-argument` rule (`B006`).", "description": "Additional callable functions to consider \"immutable\" when evaluating, e.g., the `no-mutable-default-argument` rule (`B006`) or `no-function-call-in-dataclass-defaults` rule (`RUF009`).",
"type": [ "type": [
"array", "array",
"null" "null"