From 0ce5ce4de11c7e3dc3aa131287c671618e61c014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=C3=ADque=20Porfirio?= <56317416+caiquejjx@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:49:59 -0300 Subject: [PATCH] [`ruff`] Add exception for `ctypes.Structure._fields_` (`RUF012`) (#22559) Closes #22166 ## Summary Adds an exception for `ctypes.Structure._fields_` to the rule RUF012 as it has it's own way of enforcing immutability: > The fields class variable can only be set once. Later assignments will raise an [AttributeError](https://docs.python.org/3/library/exceptions.html#AttributeError). --------- Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> --- .../resources/test/fixtures/ruff/RUF012.py | 12 +++++++++++ crates/ruff_linter/src/rules/ruff/helpers.rs | 20 +++++++++++++++++++ .../rules/ruff/rules/mutable_class_default.rs | 12 +++++++++-- ..._rules__ruff__tests__RUF012_RUF012.py.snap | 11 ++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py index c6a27523da..e5ec4d610b 100644 --- a/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF012.py @@ -138,3 +138,15 @@ class AWithQuotes: class P: class_variable: ClassVar[list] = [10, 20, 30, 40, 50] class_variable = [*class_variable[0::1], *class_variable[2::3]] + + +import ctypes +# Lint should trigger RUF012 only for the `test` field and not the `_fields_` +class S(ctypes.Structure): + test = [""] + _fields_ = [ + ("attr_set", ctypes.c_uint64), + ("attr_clr", ctypes.c_uint64), + ("propagation", ctypes.c_uint64), + ("userns_fd", ctypes.c_uint64), + ] diff --git a/crates/ruff_linter/src/rules/ruff/helpers.rs b/crates/ruff_linter/src/rules/ruff/helpers.rs index 535492816b..7ddf4a9f42 100644 --- a/crates/ruff_linter/src/rules/ruff/helpers.rs +++ b/crates/ruff_linter/src/rules/ruff/helpers.rs @@ -242,3 +242,23 @@ pub(super) fn is_descriptor_class(func: &Expr, semantic: &SemanticModel) -> bool }) }) } + +/// Returns `true` if the class has `ctypes.Structure` as a base +/// and the first target is the `_fields_` attribute. +pub(super) fn is_ctypes_structure_fields( + class_def: &ast::StmtClassDef, + semantic: &SemanticModel, + targets: &[Expr], +) -> bool { + let is_ctypes_structure = + analyze::class::any_qualified_base_class(class_def, semantic, &|qualified_name| { + matches!(qualified_name.segments(), ["ctypes", "Structure"]) + }); + + let is_fields = matches!( + targets.first(), + Some(Expr::Name(ast::ExprName { id, .. })) if id == "_fields_" + ); + + is_ctypes_structure && is_fields +} diff --git a/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs b/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs index 991008f662..94145df204 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mutable_class_default.rs @@ -8,8 +8,8 @@ use ruff_text_size::Ranged; use crate::Violation; use crate::checkers::ast::Checker; use crate::rules::ruff::helpers::{ - dataclass_kind, has_default_copy_semantics, is_class_var_annotation, is_final_annotation, - is_special_attribute, + dataclass_kind, has_default_copy_semantics, is_class_var_annotation, + is_ctypes_structure_fields, is_final_annotation, is_special_attribute, }; /// ## What it does @@ -140,6 +140,14 @@ pub(crate) fn mutable_class_default(checker: &Checker, class_def: &ast::StmtClas .is_some_and(|name| class_var_targets.contains(&name.id)) }) && is_mutable_expr(value, checker.semantic()) { + // The `_fields_` property of a `ctypes.Structure` base class has its + // immutability enforced by the base class itself which will throw an error if + // it's set a second time + // See: https://docs.python.org/3/library/ctypes.html#ctypes.Structure._fields_ + if is_ctypes_structure_fields(class_def, checker.semantic(), targets) { + return; + } + // 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; diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap index 7adb158ab3..b61f31ab24 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF012_RUF012.py.snap @@ -117,3 +117,14 @@ RUF012 Mutable class attributes should be annotated with `typing.ClassVar` 134 | final_variable_without_subscript: 'Final' = [] | ^^ | + +RUF012 Mutable class attributes should be annotated with `typing.ClassVar` + --> RUF012.py:146:12 + | +144 | # Lint should trigger RUF012 only for the `test` field and not the `_fields_` +145 | class S(ctypes.Structure): +146 | test = [""] + | ^^^^ +147 | _fields_ = [ +148 | ("attr_set", ctypes.c_uint64), + |