Skip to content
New issue

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

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

Already on GitHub? # to your account

[ruff] Also report problems for attrs dataclasses in preview mode (RUF008, RUF009) #14327

Merged
merged 5 commits into from
Nov 14, 2024
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
47 changes: 47 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF008_attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import typing
from typing import ClassVar, Sequence

import attr
from attr import s
from attrs import define, frozen

KNOWINGLY_MUTABLE_DEFAULT = []


@define
class A:
mutable_default: list[int] = []
immutable_annotation: typing.Sequence[int] = []
without_annotation = []
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: list[int] = field(default_factory=list)
class_variable: typing.ClassVar[list[int]] = []


@frozen
class B:
mutable_default: list[int] = []
immutable_annotation: Sequence[int] = []
without_annotation = []
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: list[int] = field(default_factory=list)
class_variable: ClassVar[list[int]] = []


@attr.s
class C:
mutable_default: list[int] = []
immutable_annotation: Sequence[int] = []
without_annotation = []
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: list[int] = field(default_factory=list)
class_variable: ClassVar[list[int]] = []

@s
class D:
mutable_default: list[int] = []
immutable_annotation: Sequence[int] = []
without_annotation = []
correct_code: list[int] = KNOWINGLY_MUTABLE_DEFAULT
perfectly_fine: list[int] = field(default_factory=list)
class_variable: ClassVar[list[int]] = []
75 changes: 75 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF009_attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import datetime
import re
import typing
from collections import OrderedDict
from fractions import Fraction
from pathlib import Path
from typing import ClassVar, NamedTuple

import attr
from attrs import Factory, define, field, frozen


def default_function() -> list[int]:
return []


class ImmutableType(NamedTuple):
something: int = 8


@attr.s
class A:
hidden_mutable_default: list[int] = default_function()
class_variable: typing.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".*")
fine_float: float = float("-inf")
fine_int: int = int(12)
fine_complex: complex = complex(1, 2)
fine_str: str = str("foo")
fine_bool: bool = bool("foo")
fine_fraction: Fraction = Fraction(1, 2)


DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES = ImmutableType(40)
DEFAULT_A_FOR_ALL_DATACLASSES = A([1, 2, 3])


@define
class B:
hidden_mutable_default: list[int] = default_function()
another_dataclass: A = A()
not_optimal: ImmutableType = ImmutableType(20)
good_variant: ImmutableType = DEFAULT_IMMUTABLETYPE_FOR_ALL_DATACLASSES
okay_variant: A = DEFAULT_A_FOR_ALL_DATACLASSES

fine_dataclass_function: list[int] = field(default_factory=list)
attrs_factory: dict[str, str] = Factory(OrderedDict)


class IntConversionDescriptor:
def __init__(self, *, default):
self._default = default

def __set_name__(self, owner, name):
self._name = "_" + name

def __get__(self, obj, type):
if obj is None:
return self._default

return getattr(obj, self._name, self._default)

def __set__(self, obj, value):
setattr(obj, self._name, int(value))


@frozen
class InventoryItem:
quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100)
5 changes: 5 additions & 0 deletions crates/ruff_linter/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,11 @@ mod tests {

#[test_case(Rule::ZipInsteadOfPairwise, Path::new("RUF007.py"))]
#[test_case(Rule::UnsafeMarkupUse, Path::new("RUF035.py"))]
#[test_case(
Rule::FunctionCallInDataclassDefaultArgument,
Path::new("RUF009_attrs.py")
)]
#[test_case(Rule::MutableDataclassDefault, Path::new("RUF008_attrs.py"))]
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"preview__{}_{}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::rules::ruff::rules::helpers::{
is_class_var_annotation, is_dataclass, is_dataclass_field, is_descriptor_class,
dataclass_kind, is_class_var_annotation, is_dataclass_field, is_descriptor_class,
};

/// ## What it does
Expand Down Expand Up @@ -74,7 +74,13 @@ pub(crate) fn function_call_in_dataclass_default(
checker: &mut Checker,
class_def: &ast::StmtClassDef,
) {
if !is_dataclass(class_def, checker.semantic()) {
let semantic = checker.semantic();

let Some(dataclass_kind) = dataclass_kind(class_def, semantic) else {
return;
};

if dataclass_kind.is_attrs() && checker.settings.preview.is_disabled() {
return;
}

Expand All @@ -87,26 +93,31 @@ pub(crate) fn function_call_in_dataclass_default(
.collect();

for statement in &class_def.body {
if let Stmt::AnnAssign(ast::StmtAnnAssign {
let Stmt::AnnAssign(ast::StmtAnnAssign {
annotation,
value: Some(expr),
..
}) = statement
else {
continue;
};
let Expr::Call(ast::ExprCall { func, .. }) = &**expr else {
continue;
};

if is_class_var_annotation(annotation, checker.semantic())
|| is_immutable_func(func, checker.semantic(), &extend_immutable_calls)
|| is_dataclass_field(func, checker.semantic(), dataclass_kind)
|| is_descriptor_class(func, checker.semantic())
{
if let Expr::Call(ast::ExprCall { func, .. }) = expr.as_ref() {
if !is_class_var_annotation(annotation, checker.semantic())
&& !is_immutable_func(func, checker.semantic(), &extend_immutable_calls)
&& !is_dataclass_field(func, checker.semantic())
&& !is_descriptor_class(func, checker.semantic())
{
checker.diagnostics.push(Diagnostic::new(
FunctionCallInDataclassDefaultArgument {
name: UnqualifiedName::from_expr(func).map(|name| name.to_string()),
},
expr.range(),
));
}
}
continue;
}

let kind = FunctionCallInDataclassDefaultArgument {
name: UnqualifiedName::from_expr(func).map(|name| name.to_string()),
};
let diagnostic = Diagnostic::new(kind, expr.range());

checker.diagnostics.push(diagnostic);
}
}
89 changes: 72 additions & 17 deletions crates/ruff_linter/src/rules/ruff/rules/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,42 @@ pub(super) fn is_special_attribute(value: &Expr) -> bool {
}
}

/// Returns `true` if the given [`Expr`] is a `dataclasses.field` call.
pub(super) fn is_dataclass_field(func: &Expr, semantic: &SemanticModel) -> bool {
if !semantic.seen_module(Modules::DATACLASSES) {
return false;
}

/// Returns `true` if the given [`Expr`] is a stdlib `dataclasses.field` call.
fn is_stdlib_dataclass_field(func: &Expr, semantic: &SemanticModel) -> bool {
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["dataclasses", "field"]))
}

/// Returns `true` if the given [`Expr`] is a call to `attr.ib()` or `attrs.field()`.
fn is_attrs_field(func: &Expr, semantic: &SemanticModel) -> bool {
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| {
matches!(
qualified_name.segments(),
["attrs", "field" | "Factory"] | ["attr", "ib"]
)
})
}

/// Return `true` if `func` represents a `field()` call corresponding to the `dataclass_kind` variant passed in.
///
/// I.e., if `DataclassKind::Attrs` is passed in,
/// return `true` if `func` represents a call to `attr.ib()` or `attrs.field()`;
/// if `DataclassKind::Stdlib` is passed in,
/// return `true` if `func` represents a call to `dataclasse.field()`.
pub(super) fn is_dataclass_field(
func: &Expr,
semantic: &SemanticModel,
dataclass_kind: DataclassKind,
) -> bool {
match dataclass_kind {
DataclassKind::Attrs => is_attrs_field(func, semantic),
DataclassKind::Stdlib => is_stdlib_dataclass_field(func, semantic),
}
}

/// Returns `true` if the given [`Expr`] is a `typing.ClassVar` annotation.
pub(super) fn is_class_var_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
if !semantic.seen_typing() {
Expand All @@ -51,19 +76,49 @@ pub(super) fn is_final_annotation(annotation: &Expr, semantic: &SemanticModel) -
semantic.match_typing_expr(map_subscript(annotation), "Final")
}

/// Returns `true` if the given class is a dataclass.
pub(super) fn is_dataclass(class_def: &ast::StmtClassDef, semantic: &SemanticModel) -> bool {
if !semantic.seen_module(Modules::DATACLASSES) {
return false;
/// Enumeration of various kinds of dataclasses recognised by Ruff
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(super) enum DataclassKind {
/// dataclasses created by the stdlib `dataclasses` module
Stdlib,
/// dataclasses created by the third-party `attrs` library
Attrs,
}

impl DataclassKind {
pub(super) const fn is_stdlib(self) -> bool {
matches!(self, DataclassKind::Stdlib)
}

class_def.decorator_list.iter().any(|decorator| {
semantic
.resolve_qualified_name(map_callable(&decorator.expression))
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["dataclasses", "dataclass"])
})
})
pub(super) const fn is_attrs(self) -> bool {
matches!(self, DataclassKind::Attrs)
}
}

/// Return the kind of dataclass this class definition is (stdlib or `attrs`), or `None` if the class is not a dataclass.
pub(super) fn dataclass_kind(
class_def: &ast::StmtClassDef,
semantic: &SemanticModel,
) -> Option<DataclassKind> {
if !(semantic.seen_module(Modules::DATACLASSES) || semantic.seen_module(Modules::ATTRS)) {
return None;
}

for decorator in &class_def.decorator_list {
let Some(qualified_name) =
semantic.resolve_qualified_name(map_callable(&decorator.expression))
else {
continue;
};

match qualified_name.segments() {
["attrs", "define" | "frozen"] | ["attr", "s"] => return Some(DataclassKind::Attrs),
["dataclasses", "dataclass"] => return Some(DataclassKind::Stdlib),
_ => continue,
}
}

None
}

/// Returns `true` if the given class has "default copy" semantics.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::rules::ruff::rules::helpers::{
has_default_copy_semantics, is_class_var_annotation, is_dataclass, is_final_annotation,
dataclass_kind, has_default_copy_semantics, is_class_var_annotation, is_final_annotation,
is_special_attribute,
};

Expand Down Expand Up @@ -65,8 +65,13 @@ pub(crate) fn mutable_class_default(checker: &mut Checker, class_def: &ast::Stmt
&& !is_class_var_annotation(annotation, checker.semantic())
&& !is_final_annotation(annotation, checker.semantic())
&& !is_immutable_annotation(annotation, checker.semantic(), &[])
&& !is_dataclass(class_def, checker.semantic())
{
if let Some(dataclass_kind) = dataclass_kind(class_def, checker.semantic()) {
if dataclass_kind.is_stdlib() || checker.settings.preview.is_enabled() {
continue;
}
}

// Avoid, e.g., Pydantic and msgspec models, which end up copying defaults on instance creation.
if has_default_copy_semantics(class_def, checker.semantic()) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use ruff_python_semantic::analyze::typing::{is_immutable_annotation, is_mutable_
use ruff_text_size::Ranged;

use crate::checkers::ast::Checker;
use crate::rules::ruff::rules::helpers::{is_class_var_annotation, is_dataclass};
use crate::rules::ruff::rules::helpers::{dataclass_kind, is_class_var_annotation};

/// ## What it does
/// Checks for mutable default values in dataclass attributes.
Expand Down Expand Up @@ -66,25 +66,33 @@ impl Violation for MutableDataclassDefault {

/// RUF008
pub(crate) fn mutable_dataclass_default(checker: &mut Checker, class_def: &ast::StmtClassDef) {
if !is_dataclass(class_def, checker.semantic()) {
let semantic = checker.semantic();

let Some(dataclass_kind) = dataclass_kind(class_def, semantic) else {
return;
};

if dataclass_kind.is_attrs() && checker.settings.preview.is_disabled() {
return;
}

for statement in &class_def.body {
if let Stmt::AnnAssign(ast::StmtAnnAssign {
let Stmt::AnnAssign(ast::StmtAnnAssign {
annotation,
value: Some(value),
..
}) = statement
else {
continue;
};

if is_mutable_expr(value, checker.semantic())
&& !is_class_var_annotation(annotation, checker.semantic())
&& !is_immutable_annotation(annotation, checker.semantic(), &[])
{
if is_mutable_expr(value, checker.semantic())
&& !is_class_var_annotation(annotation, checker.semantic())
&& !is_immutable_annotation(annotation, checker.semantic(), &[])
{
checker
.diagnostics
.push(Diagnostic::new(MutableDataclassDefault, value.range()));
}
let diagnostic = Diagnostic::new(MutableDataclassDefault, value.range());

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