Skip to content

Commit cf1404a

Browse files
yushao2DanielNoordjacobtylerwallsPierre-Sassoulas
authored
feat(5159): Add new checker invalid-field-call (#8126)
Co-authored-by: Daniël van Noord <[email protected]> Co-authored-by: Jacob Walls <[email protected]> Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent 503d360 commit cf1404a

File tree

13 files changed

+268
-3
lines changed

13 files changed

+268
-3
lines changed

.pyenchant_pylint_custom_dict.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ async
2121
asynccontextmanager
2222
attr
2323
attrib
24+
attrname
2425
backport
2526
BaseChecker
2627
basename
@@ -72,6 +73,7 @@ CVE
7273
cwd
7374
cyclomatic
7475
dataclass
76+
dataclasses
7577
datetime
7678
debian
7779
deduplication
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dataclasses import dataclass, field
2+
3+
4+
@dataclass
5+
class C:
6+
a: float
7+
b: float
8+
c: float
9+
10+
field(init=False) # [invalid-field-call]
11+
12+
def __post_init__(self):
13+
self.c = self.a + self.b
14+
15+
16+
print(field(init=False)) # [invalid-field-call]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from dataclasses import dataclass, field, make_dataclass
2+
3+
C = make_dataclass(
4+
"C",
5+
[("x", int), "y", ("z", int, field(default=5))],
6+
namespace={"add_one": lambda self: self.x + 1},
7+
)
8+
9+
10+
@dataclass
11+
class C:
12+
a: float
13+
b: float
14+
c: float = field(init=False)
15+
16+
def __post_init__(self):
17+
self.c = self.a + self.b

doc/user_guide/checkers/features.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,19 @@ Classes checker Messages
389389
an unexpected reason. Please report this kind if you don't make sense of it.
390390

391391

392+
Dataclass checker
393+
~~~~~~~~~~~~~~~~~
394+
395+
Verbatim name of the checker is ``dataclass``.
396+
397+
Dataclass checker Messages
398+
^^^^^^^^^^^^^^^^^^^^^^^^^^
399+
:invalid-field-call (E3701): *Invalid usage of field(), %s*
400+
The dataclasses.field() specifier should only be used as the value of an
401+
assignment within a dataclass, or within the make_dataclass() function. This
402+
message can't be emitted when using Python < 3.7.
403+
404+
392405
Design checker
393406
~~~~~~~~~~~~~~
394407

doc/user_guide/messages/messages_overview.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ All messages in the error category:
9191
error/invalid-class-object
9292
error/invalid-enum-extension
9393
error/invalid-envvar-value
94+
error/invalid-field-call
9495
error/invalid-format-returned
9596
error/invalid-getnewargs-ex-returned
9697
error/invalid-getnewargs-returned

doc/whatsnew/fragments/5159.new_check

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added ``DataclassChecker`` module and ``invalid-field-call`` checker to check for invalid dataclasses.field() usage.
2+
3+
Refs #5159

pylint/checkers/dataclass_checker.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
2+
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
3+
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
4+
5+
"""Dataclass checkers for Python code."""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING
10+
11+
from astroid import nodes
12+
from astroid.brain.brain_dataclasses import DATACLASS_MODULES
13+
14+
from pylint.checkers import BaseChecker, utils
15+
from pylint.interfaces import INFERENCE
16+
17+
if TYPE_CHECKING:
18+
from pylint.lint import PyLinter
19+
20+
21+
def _is_dataclasses_module(node: nodes.Module) -> bool:
22+
"""Utility function to check if node is from dataclasses_module."""
23+
return node.name in DATACLASS_MODULES
24+
25+
26+
def _check_name_or_attrname_eq_to(
27+
node: nodes.Name | nodes.Attribute, check_with: str
28+
) -> bool:
29+
"""Utility function to check either a Name/Attribute node's name/attrname with a
30+
given string.
31+
"""
32+
if isinstance(node, nodes.Name):
33+
return str(node.name) == check_with
34+
return str(node.attrname) == check_with
35+
36+
37+
class DataclassChecker(BaseChecker):
38+
"""Checker that detects invalid or problematic usage in dataclasses.
39+
40+
Checks for
41+
* invalid-field-call
42+
"""
43+
44+
name = "dataclass"
45+
msgs = {
46+
"E3701": (
47+
"Invalid usage of field(), %s",
48+
"invalid-field-call",
49+
"The dataclasses.field() specifier should only be used as the value of "
50+
"an assignment within a dataclass, or within the make_dataclass() function.",
51+
),
52+
}
53+
54+
@utils.only_required_for_messages("invalid-field-call")
55+
def visit_call(self, node: nodes.Call) -> None:
56+
self._check_invalid_field_call(node)
57+
58+
def _check_invalid_field_call(self, node: nodes.Call) -> None:
59+
"""Checks for correct usage of the dataclasses.field() specifier in
60+
dataclasses or within the make_dataclass() function.
61+
62+
Emits message
63+
when field() is detected to be used outside a class decorated with
64+
@dataclass decorator and outside make_dataclass() function, or when it
65+
is used improperly within a dataclass.
66+
"""
67+
if not isinstance(node.func, (nodes.Name, nodes.Attribute)):
68+
return
69+
if not _check_name_or_attrname_eq_to(node.func, "field"):
70+
return
71+
inferred_func = utils.safe_infer(node.func)
72+
if not (
73+
isinstance(inferred_func, nodes.FunctionDef)
74+
and _is_dataclasses_module(inferred_func.root())
75+
):
76+
return
77+
scope_node = node.parent
78+
while scope_node and not isinstance(scope_node, (nodes.ClassDef, nodes.Call)):
79+
scope_node = scope_node.parent
80+
81+
if isinstance(scope_node, nodes.Call):
82+
self._check_invalid_field_call_within_call(node, scope_node)
83+
return
84+
85+
if not scope_node or not scope_node.is_dataclass:
86+
self.add_message(
87+
"invalid-field-call",
88+
node=node,
89+
args=(
90+
"it should be used within a dataclass or the make_dataclass() function.",
91+
),
92+
confidence=INFERENCE,
93+
)
94+
return
95+
96+
if not (isinstance(node.parent, nodes.AnnAssign) and node == node.parent.value):
97+
self.add_message(
98+
"invalid-field-call",
99+
node=node,
100+
args=("it should be the value of an assignment within a dataclass.",),
101+
confidence=INFERENCE,
102+
)
103+
104+
def _check_invalid_field_call_within_call(
105+
self, node: nodes.Call, scope_node: nodes.Call
106+
) -> None:
107+
"""Checks for special case where calling field is valid as an argument of the
108+
make_dataclass() function.
109+
"""
110+
inferred_func = utils.safe_infer(scope_node.func)
111+
if (
112+
isinstance(scope_node.func, (nodes.Name, nodes.AssignName))
113+
and scope_node.func.name == "make_dataclass"
114+
and isinstance(inferred_func, nodes.FunctionDef)
115+
and _is_dataclasses_module(inferred_func.root())
116+
):
117+
return
118+
self.add_message(
119+
"invalid-field-call",
120+
node=node,
121+
args=(
122+
"it should be used within a dataclass or the make_dataclass() function.",
123+
),
124+
confidence=INFERENCE,
125+
)
126+
127+
128+
def register(linter: PyLinter) -> None:
129+
linter.register_checker(DataclassChecker(linter))

tests/functional/d/dataclass/dataclass_with_default_factory.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# pylint: disable=invalid-field-call
12
"""Various regression tests for dataclasses."""
23
# See issues:
34
# - https://github.com/pylint-dev/pylint/issues/2605
@@ -48,3 +49,9 @@ class Test2:
4849
class TEST3:
4950
"""Test dataclass that puts call to field() in another function call"""
5051
attribute: int = cast(int, field(default_factory=dict))
52+
53+
54+
@dc.dataclass
55+
class TEST4:
56+
"""Absurd example to test a potential crash found during development."""
57+
attribute: int = lambda this: cast(int, this)(field(default_factory=dict))
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
not-an-iterable:40:9:40:23::Non-iterable value Test2.int_prop is used in an iterating context:UNDEFINED
2-
unsupported-assignment-operation:44:0:44:14::'Test2.int_prop' does not support item assignment:UNDEFINED
1+
not-an-iterable:41:9:41:23::Non-iterable value Test2.int_prop is used in an iterating context:UNDEFINED
2+
unsupported-assignment-operation:45:0:45:14::'Test2.int_prop' does not support item assignment:UNDEFINED
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tests for the invalid-field-call message."""
2+
3+
# pylint: disable=invalid-name, missing-class-docstring, missing-function-docstring, too-few-public-methods
4+
5+
from dataclasses import dataclass, field, make_dataclass
6+
import dataclasses as dc
7+
8+
class MyClass:
9+
def field(self):
10+
return "Not an actual dataclasses.field function"
11+
12+
mc = MyClass()
13+
14+
C = make_dataclass('C',
15+
[('x', int),
16+
'y',
17+
('z', int, field(default=5))],
18+
namespace={'add_one': lambda self: self.x + 1})
19+
20+
bad = print(field(init=False)) # [invalid-field-call]
21+
22+
a: float = field() # [invalid-field-call]
23+
24+
class NotADataClass:
25+
field() # [invalid-field-call]
26+
a: float = field(init=False) # [invalid-field-call]
27+
dc.field() # [invalid-field-call]
28+
b: float = dc.field(init=False) # [invalid-field-call]
29+
30+
@dataclass
31+
class DC:
32+
field() # [invalid-field-call]
33+
dc.field() # [invalid-field-call]
34+
mc.field()
35+
a: float = field(init=False)
36+
b: float = dc.field(init=False)
37+
# TODO(remove py3.9 min) pylint: disable-next=unsubscriptable-object
38+
c: list[float] = [field(), field()] # [invalid-field-call, invalid-field-call]
39+
40+
@dc.dataclass
41+
class IsAlsoDC:
42+
field() # [invalid-field-call]
43+
a: float = field(init=False)
44+
b: float = dc.field(init=False)
45+
# TODO(remove py3.9 min) pylint: disable-next=unsubscriptable-object
46+
c: list[float] = [field(), field()] # [invalid-field-call, invalid-field-call]
47+
48+
@dc.dataclass(frozen=True)
49+
class FrozenDC:
50+
a: float = field(init=False)
51+
b: float = dc.field(init=False)
52+
53+
def my_decorator(func):
54+
def wrapper():
55+
func()
56+
57+
return wrapper
58+
59+
@my_decorator
60+
class AlsoNotADataClass:
61+
a: float = field(init=False) # [invalid-field-call]

0 commit comments

Comments
 (0)