src/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Pydantic/PydanticVisitor.py

branch
eric7
changeset 11143
ef75c265ab47
child 11150
73d80859079c
equal deleted inserted replaced
11142:2f0fb22c1d63 11143:ef75c265ab47
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a node visitor to check for pydantic related issues.
8 """
9
10 #######################################################################
11 ## PydanticVisitor
12 ##
13 ## adapted from: flake8-pydantic v0.4.0
14 ##
15 ## Original: Copyright (c) 2023 Victorien
16 #######################################################################
17
18 import ast
19
20 from collections import deque
21
22 from .PydanticUtils import (
23 extractAnnotations,
24 isDataclass,
25 isFunction,
26 isName,
27 isPydanticModel,
28 )
29
30
31 class PydanticVisitor(ast.NodeVisitor):
32 """
33 Class implementing a node visitor to check for pydantic related issues.
34 """
35
36 def __init__(self, errorCallback):
37 """
38 Constructor
39
40 @param errorCallback callback function to register an error
41 @type func
42 """
43 super().__init__()
44
45 self.__error = errorCallback
46
47 self.__classStack = deque()
48
49 def __enterClass(self, node):
50 """
51 Private method to record class type when entering a class definition.
52
53 @param node reference to the node to be processed
54 @type ast.ClassDef
55 """
56 if isPydanticModel(node):
57 self.__classStack.append("pydantic_model")
58 elif isDataclass(node):
59 self.__classStack.append("dataclass")
60 else:
61 self.__classStack.append("other_class")
62
63 def __leaveClass(self):
64 """
65 Private method to remove the data recorded by the __enterClass method.
66 """
67 self.__classStack.pop()
68
69 @property
70 def __currentClass(self):
71 """
72 Private method returning the current class type as recorded by the __enterClass
73 method.
74
75 @return current class type (one of 'pydantic_model', 'dataclass' or
76 'other_class')
77 @rtype str
78 """
79 if not self.__classStack:
80 return None
81
82 return self.__classStack[-1]
83
84 def __checkForPyd001(self, node: ast.AnnAssign) -> None:
85 """
86 Private method to check positional argument for Field default argument.
87
88 @param node reference to the node to be processed
89 @type ast.AnnAssign
90 """
91 if (
92 self.__currentClass in {"pydantic_model", "dataclass"}
93 and isinstance(node.value, ast.Call)
94 and isFunction(node.value, "Field")
95 and len(node.value.args) >= 1
96 ):
97 self.__error(node.lineno - 1, node.col_offset, "PYD001")
98
99 def __checkForPyd002(self, node):
100 """
101 Private method to check non-annotated attribute inside Pydantic model.
102
103 @param node reference to the node to be processed
104 @type ast.ClassDef
105 """
106 if self.__currentClass == "pydantic_model":
107 invalidAssignments = [
108 assign
109 for assign in node.body
110 if isinstance(assign, ast.Assign)
111 if isinstance(assign.targets[0], ast.Name)
112 if not assign.targets[0].id.startswith("_")
113 if assign.targets[0].id != "model_config"
114 ]
115 for assignment in invalidAssignments:
116 self.__error(assignment.lineno - 1, assignment.col_offset, "PYD002")
117
118 def __checkForPyd003(self, node):
119 """
120 Private method to check unecessary Field call to specify a default value.
121
122 @param node reference to the node to be processed
123 @type ast.AnnAssign
124 """
125 if (
126 self.__currentClass in {"pydantic_model", "dataclass"}
127 and isinstance(node.value, ast.Call)
128 and isFunction(node.value, "Field")
129 and len(node.value.keywords) == 1
130 and node.value.keywords[0].arg == "default"
131 ):
132 self.__error(node.lineno - 1, node.col_offset, "PYD003")
133
134 def __checkForPyd004(self, node):
135 """
136 Private method to check for a default argument specified in annotated.
137
138 @param node reference to the node to be processed
139 @type ast.AnnAssign
140 """
141 if (
142 self.__currentClass in {"pydantic_model", "dataclass"}
143 and isinstance(node.annotation, ast.Subscript)
144 and isName(node.annotation.value, "Annotated")
145 and isinstance(node.annotation.slice, ast.Tuple)
146 ):
147 fieldCall = next(
148 (
149 elt
150 for elt in node.annotation.slice.elts
151 if isinstance(elt, ast.Call)
152 and isFunction(elt, "Field")
153 and any(k.arg == "default" for k in elt.keywords)
154 ),
155 None,
156 )
157 if fieldCall is not None:
158 self.__error(node.lineno - 1, node.col_offset, "PYD004")
159
160 def __checkForPyd005(self, node):
161 """
162 Private method to check for a field name overriding the annotation.
163
164 @param node reference to the node to be processed
165 @type ast.ClassDef
166 """
167 if self.__currentClass in {"pydantic_model", "dataclass"}:
168 previousTargets = set()
169
170 for stmt in node.body:
171 if isinstance(stmt, ast.AnnAssign) and isinstance(
172 stmt.target, ast.Name
173 ):
174 previousTargets.add(stmt.target.id)
175 if previousTargets & extractAnnotations(stmt.annotation):
176 self.__error(stmt.lineno - 1, stmt.col_offset, "PYD005")
177
178 def __checkForPyd006(self, node):
179 """
180 Private method to check for duplicate field names.
181
182 @param node reference to the node to be processed
183 @type ast.ClassDef
184 """
185 if self.__currentClass in {"pydantic_model", "dataclass"}:
186 previousTargets = set()
187
188 for stmt in node.body:
189 if isinstance(stmt, ast.AnnAssign) and isinstance(
190 stmt.target, ast.Name
191 ):
192 if stmt.target.id in previousTargets:
193 self.__error(stmt.lineno - 1, stmt.col_offset, "PYD006")
194
195 previousTargets.add(stmt.target.id)
196
197 def __checkForPyd010(self, node: ast.ClassDef) -> None:
198 """
199 Private method to check for the use of `__pydantic_config__`.
200
201 @param node reference to the node to be processed
202 @type ast.ClassDef
203 """
204 if self.__currentClass == "other_class":
205 for stmt in node.body:
206 if (
207 isinstance(stmt, ast.AnnAssign)
208 and isinstance(stmt.target, ast.Name)
209 and stmt.target.id == "__pydantic_config__"
210 ):
211 ##~ __pydantic_config__: ... = ...
212 self.__error(stmt.lineno - 1, stmt.col_offset, "PYD010")
213
214 if isinstance(stmt, ast.Assign) and any(
215 t.id == "__pydantic_config__"
216 for t in stmt.targets
217 if isinstance(t, ast.Name)
218 ):
219 ##~ __pydantic_config__ = ...
220 self.__error(stmt.lineno - 1, stmt.col_offset, "PYD010")
221
222 def visit_ClassDef(self, node: ast.ClassDef) -> None:
223 """
224 Public method to process class definitions.
225
226 @param node reference to the node to be processed.
227 @type ast.ClassDef
228 """
229 self.__enterClass(node)
230
231 # TODO: implement these methods
232 self.__checkForPyd002(node)
233 self.__checkForPyd005(node)
234 self.__checkForPyd006(node)
235 self.__checkForPyd010(node)
236
237 self.generic_visit(node)
238
239 self.__leaveClass()
240
241 def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
242 """
243 Public method to process annotated assignment.
244
245 @param node reference to the node to be processed.
246 @type ast.AnnAssign
247 """
248 self.__checkForPyd001(node)
249 self.__checkForPyd003(node)
250 self.__checkForPyd004(node)
251
252 self.generic_visit(node)

eric ide

mercurial