Skip to content

Commit

Permalink
[ruff] Also report problems for attrs dataclasses in preview mode…
Browse files Browse the repository at this point in the history
… (`RUF008`, `RUF009`) (astral-sh#14327)

Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
  • Loading branch information
InSyncWithFoo and AlexWaygood authored Nov 14, 2024
1 parent 9a3001b commit d8b1afb
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 51 deletions.
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

0 comments on commit d8b1afb

Please sign in to comment.