Skip to content

Commit 956b3c6

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-4138 Avoid raising S8514 when the class variable is likely intentional (#1096)
GitOrigin-RevId: 25997a4ff0b42970deaafdad60f7f69a1f1a4f2f
1 parent 9d83f3d commit 956b3c6

3 files changed

Lines changed: 75 additions & 4 deletions

File tree

python-checks/src/main/java/org/sonar/python/checks/DataclassFieldDefinitionCheck.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
package org.sonar.python.checks;
1818

19+
import java.util.List;
1920
import org.sonar.check.Rule;
2021
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2122
import org.sonar.plugins.python.api.SubscriptionContext;
@@ -24,6 +25,8 @@
2425
import org.sonar.plugins.python.api.tree.ClassDef;
2526
import org.sonar.plugins.python.api.tree.Decorator;
2627
import org.sonar.plugins.python.api.tree.Expression;
28+
import org.sonar.plugins.python.api.tree.ExpressionList;
29+
import org.sonar.plugins.python.api.tree.Name;
2730
import org.sonar.plugins.python.api.tree.Tree;
2831
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
2932
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
@@ -63,8 +66,37 @@ private static boolean isDataclassDecorator(Decorator decorator, SubscriptionCon
6366
}
6467

6568
private static void checkStatement(SubscriptionContext ctx, Tree statement) {
66-
if (statement instanceof AssignmentStatement assignmentStatement) {
69+
if (statement instanceof AssignmentStatement assignmentStatement && !isLikelyIntentionalClassVar(assignmentStatement)) {
6770
ctx.addIssue(assignmentStatement, MESSAGE_MISSING_ANNOTATION);
6871
}
6972
}
73+
74+
private static boolean isLikelyIntentionalClassVar(AssignmentStatement assignment) {
75+
List<ExpressionList> lhsList = assignment.lhsExpressions();
76+
if (lhsList.size() != 1) {
77+
return false;
78+
}
79+
List<Expression> expressions = lhsList.get(0).expressions();
80+
if (expressions.size() != 1 || !expressions.get(0).is(Tree.Kind.NAME)) {
81+
return false;
82+
}
83+
String name = ((Name) expressions.get(0)).name();
84+
return name.startsWith("_") || isAllCaps(name);
85+
}
86+
87+
// ALL_CAPS names follow the Python constant convention — they are intentional class-level attributes.
88+
private static boolean isAllCaps(String name) {
89+
if (name.isEmpty()) {
90+
return false;
91+
}
92+
boolean hasUpperCase = false;
93+
for (char c : name.toCharArray()) {
94+
if (Character.isUpperCase(c)) {
95+
hasUpperCase = true;
96+
} else if (!Character.isDigit(c) && c != '_') {
97+
return false;
98+
}
99+
}
100+
return hasUpperCase;
101+
}
70102
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8514.html

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
<p>This rule raises an issue when a dataclass contains attributes without type annotations.</p>
1+
<p>This rule raises an issue when a dataclass attribute is assigned a value without a type annotation, suggesting the developer likely intended an
2+
instance field but silently created a class variable instead.</p>
23
<h2>Why is this an issue?</h2>
34
<p>The <code>@dataclass</code> decorator only processes attributes that have type annotations. Attributes without annotations are silently excluded
45
from dataclass processing and become regular class variables instead of instance attributes. This means:</p>
@@ -12,8 +13,10 @@ <h3>What is the potential impact?</h3>
1213
dataclass contract. These bugs are difficult to trace because the class appears to work normally, but the affected attributes do not participate in
1314
any of the generated methods.</p>
1415
<h2>How to fix it</h2>
15-
<p>Add type annotations to all attributes that should be dataclass fields. If you intend an attribute to be a class variable shared across instances,
16-
move it outside the dataclass or document this intention clearly.</p>
16+
<p>Add type annotations to all attributes that should be dataclass fields.</p>
17+
<p>If you intentionally want a class variable shared across all instances, annotate it with <code>typing.ClassVar[T]</code>. This is the idiomatic
18+
Python way to declare class variables inside a dataclass — it makes the intent explicit and ensures the attribute is excluded from all generated
19+
dataclass methods.</p>
1720
<h3>Code examples</h3>
1821
<h4>Noncompliant code example</h4>
1922
<pre data-diff-id="1" data-diff-type="noncompliant">
@@ -33,10 +36,28 @@ <h4>Compliant solution</h4>
3336
timeout: int = 30
3437
retries: int = 3
3538
</pre>
39+
<h4>Noncompliant code example</h4>
40+
<pre data-diff-id="2" data-diff-type="noncompliant">
41+
from dataclasses import dataclass
42+
43+
@dataclass
44+
class Registry:
45+
instance_count = 0 # Noncompliant: intended as a class variable, but not explicit
46+
</pre>
47+
<h4>Compliant solution</h4>
48+
<pre data-diff-id="2" data-diff-type="compliant">
49+
from dataclasses import dataclass
50+
from typing import ClassVar
51+
52+
@dataclass
53+
class Registry:
54+
instance_count: ClassVar[int] = 0 # Explicit class variable, excluded from __init__
55+
</pre>
3656
<h2>Resources</h2>
3757
<h3>Documentation</h3>
3858
<ul>
3959
<li>Python Documentation - <a href="https://docs.python.org/3/library/dataclasses.html">dataclasses — Data Classes</a></li>
4060
<li>PEP 557 - <a href="https://peps.python.org/pep-0557/">Data Classes</a></li>
61+
<li>Python Documentation - <a href="https://docs.python.org/3/library/typing.html#typing.ClassVar">typing.ClassVar</a></li>
4162
</ul>
4263

python-checks/src/test/resources/checks/dataclassFieldDefinition.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,21 @@ class ParentDataclass:
193193

194194
class NonDataclassChild(ParentDataclass):
195195
unannotated = 99
196+
197+
198+
# ALL_CAPS names follow the Python constant convention and are intentional class-level attributes
199+
@dataclass
200+
class AllCapsConstants:
201+
MAX_RETRIES = 5
202+
DEFAULT_TIMEOUT = 30
203+
BASE_URL = "https://example.com"
204+
205+
206+
# Names starting with _ are considered intentional private/special attributes
207+
@dataclass
208+
class UnderscorePrefixedAttributes:
209+
_private = "value"
210+
__slots__ = ['x', 'y']
211+
__tablename__ = "users"
212+
__abstract__ = True
213+
_PRIVATE_CONST = 42

0 commit comments

Comments
 (0)