mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-09 05:08:21 +00:00
Add bugbear immutable functions as allowed in dataclasses (#4122)
This commit is contained in:
parent
089b64e9c1
commit
ee6d8f7467
7 changed files with 132 additions and 63 deletions
|
@ -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)
|
||||||
|
|
|
@ -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((
|
||||||
|
|
|
@ -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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
2
ruff.schema.json
generated
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue