|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a node visitor to check logging formatting issues. |
|
8 """ |
|
9 |
|
10 import ast |
|
11 import contextlib |
|
12 |
|
13 |
|
14 _LoggingLevels = { |
|
15 "debug", |
|
16 "critical", |
|
17 "error", |
|
18 "exception", |
|
19 "info", |
|
20 "warn", |
|
21 "warning", |
|
22 } |
|
23 |
|
24 |
|
25 # default LogRecord attributes that shouldn't be overwritten by extra dict |
|
26 _ReservedAttrs = { |
|
27 "args", "asctime", "created", "exc_info", "exc_text", "filename", |
|
28 "funcName", "levelname", "levelno", "lineno", "module", |
|
29 "msecs", "message", "msg", "name", "pathname", "process", |
|
30 "processName", "relativeCreated", "stack_info", "thread", "threadName"} |
|
31 |
|
32 ####################################################################### |
|
33 ## LoggingFormatVisitor |
|
34 ## |
|
35 ## adapted from: flake8-logging-format v0.9.0 |
|
36 ## |
|
37 ## Original: Copyright (c) 2017 Globality Engineering |
|
38 ####################################################################### |
|
39 |
|
40 class LoggingFormatVisitor(ast.NodeVisitor): |
|
41 """ |
|
42 Class implementing a node visitor to check logging formatting issues. |
|
43 """ |
|
44 |
|
45 def __init__(self): |
|
46 """ |
|
47 Constructor |
|
48 """ |
|
49 super().__init__() |
|
50 |
|
51 self.__currentLoggingCall = None |
|
52 self.__currentLoggingArgument = None |
|
53 self.__currentLoggingLevel = None |
|
54 self.__currentExtraKeyword = None |
|
55 self.__currentExceptNames = [] |
|
56 self.violations = [] |
|
57 |
|
58 def __withinLoggingStatement(self): |
|
59 """ |
|
60 Private method to check, if we are inside a logging statement. |
|
61 |
|
62 @return flag indicating we are inside a logging statement |
|
63 @rtype bool |
|
64 """ |
|
65 return self.__currentLoggingCall is not None |
|
66 |
|
67 def __withinLoggingArgument(self): |
|
68 """ |
|
69 Private method to check, if we are inside a logging argument. |
|
70 |
|
71 @return flag indicating we are inside a logging argument |
|
72 @rtype bool |
|
73 """ |
|
74 return self.__currentLoggingArgument is not None |
|
75 |
|
76 def __withinExtraKeyword(self, node): |
|
77 """ |
|
78 Private method to check, if we are inside the extra keyword. |
|
79 |
|
80 @param node reference to the node to be checked |
|
81 @type ast.keyword |
|
82 @return flag indicating we are inside the extra keyword |
|
83 @rtype bool |
|
84 """ |
|
85 return ( |
|
86 self.__currentExtraKeyword is not None |
|
87 and self.__currentExtraKeyword != node |
|
88 ) |
|
89 |
|
90 def __getExceptHandlerName(self, node): |
|
91 """ |
|
92 Private method to get the exception name from an ExceptHandler node. |
|
93 |
|
94 @param node reference to the node to be checked |
|
95 @type ast.ExceptHandler |
|
96 @return exception name |
|
97 @rtype str |
|
98 """ |
|
99 name = node.name |
|
100 if not name: |
|
101 return None |
|
102 |
|
103 return name |
|
104 |
|
105 def __getIdAttr(self, value): |
|
106 """ |
|
107 Private method to check if value has id attribute and return it. |
|
108 |
|
109 @param value value to get id from |
|
110 @type ast.Name |
|
111 @return ID of value |
|
112 @rtype str |
|
113 """ |
|
114 """Check if value has id attribute and return it. |
|
115 |
|
116 :param value: The value to get id from. |
|
117 :return: The value.id. |
|
118 """ |
|
119 if not hasattr(value, "id") and hasattr(value, "value"): |
|
120 value = value.value |
|
121 |
|
122 return value.id |
|
123 |
|
124 def __detectLoggingLevel(self, node): |
|
125 """ |
|
126 Private method to decide whether an AST Call is a logging call. |
|
127 |
|
128 @param node reference to the node to be processed |
|
129 @type ast.Call |
|
130 @return logging level |
|
131 @rtype str or None |
|
132 """ |
|
133 with contextlib.suppress(AttributeError): |
|
134 if self.__getIdAttr(node.func.value) in ("parser", "warnings"): |
|
135 return None |
|
136 |
|
137 if node.func.attr in _LoggingLevels: |
|
138 return node.func.attr |
|
139 |
|
140 return None |
|
141 |
|
142 def __isFormatCall(self, node): |
|
143 """ |
|
144 Private method to check if a function call uses format. |
|
145 |
|
146 @param node reference to the node to be processed |
|
147 @type ast.Call |
|
148 @return flag indicating the function call uses format |
|
149 @rtype bool |
|
150 """ |
|
151 try: |
|
152 return node.func.attr == "format" |
|
153 except AttributeError: |
|
154 return False |
|
155 |
|
156 |
|
157 def __shouldCheckExtraFieldClash(self, node): |
|
158 """ |
|
159 Private method to check, if the extra field clash check should be done. |
|
160 |
|
161 @param node reference to the node to be processed |
|
162 @type ast.Dict |
|
163 @return flag indicating to perform the check |
|
164 @rtype bool |
|
165 """ |
|
166 return all( |
|
167 ( |
|
168 self.__withinLoggingStatement(), |
|
169 self.__withinExtraKeyword(node), |
|
170 ) |
|
171 ) |
|
172 |
|
173 def __shouldCheckExtraException(self, node): |
|
174 """ |
|
175 Private method to check, if the check for extra exceptions should be done. |
|
176 |
|
177 c @type ast.Dict |
|
178 @return flag indicating to perform the check |
|
179 @rtype bool |
|
180 """ |
|
181 return all( |
|
182 ( |
|
183 self.__withinLoggingStatement(), |
|
184 self.__withinExtraKeyword(node), |
|
185 len(self.__currentExceptNames) > 0, |
|
186 ) |
|
187 ) |
|
188 |
|
189 def __isBareException(self, node): |
|
190 """ |
|
191 Private method to check, if the node is a bare exception name from an except |
|
192 block. |
|
193 |
|
194 @param node reference to the node to be processed |
|
195 @type ast.AST |
|
196 @return flag indicating a bare exception |
|
197 @rtype TYPE |
|
198 """ |
|
199 return isinstance(node, ast.Name) and node.id in self.__currentExceptNames |
|
200 |
|
201 def __isStrException(self, node): |
|
202 """ |
|
203 Private method to check if the node is the expression str(e) or unicode(e), |
|
204 where e is an exception name from an except block. |
|
205 |
|
206 @param node reference to the node to be processed |
|
207 @type ast.AST |
|
208 @return flag indicating a string exception |
|
209 @rtype TYPE |
|
210 """ |
|
211 return ( |
|
212 isinstance(node, ast.Call) |
|
213 and isinstance(node.func, ast.Name) |
|
214 and node.func.id in ('str', 'unicode') |
|
215 and node.args |
|
216 and self.__isBareException(node.args[0]) |
|
217 ) |
|
218 |
|
219 def __checkExceptionArg(self, node): |
|
220 """ |
|
221 Private method to check an exception argument. |
|
222 |
|
223 @param node reference to the node to be processed |
|
224 @type ast.AST |
|
225 """ |
|
226 if self.__isBareException(node) or self.__isStrException(node): |
|
227 self.violations.append((node, "L130")) |
|
228 |
|
229 def __checkExcInfo(self, node): |
|
230 """ |
|
231 Private method to check, if the exc_info keyword is used with logging.error or |
|
232 logging.exception. |
|
233 |
|
234 @param node reference to the node to be processed |
|
235 @type ast.AST |
|
236 """ |
|
237 if self.__currentLoggingLevel not in ('error', 'exception'): |
|
238 return |
|
239 |
|
240 for kw in node.keywords: |
|
241 if kw.arg == 'exc_info': |
|
242 if self.__currentLoggingLevel == 'error': |
|
243 violation = "L131" |
|
244 else: |
|
245 violation = "L132" |
|
246 self.violations.append((node, violation)) |
|
247 |
|
248 def visit_Call(self, node): |
|
249 """ |
|
250 Public method to handle a function call. |
|
251 |
|
252 Every logging statement and string format is expected to be a function |
|
253 call. |
|
254 |
|
255 @param node reference to the node to be processed |
|
256 @type ast.Call |
|
257 """ |
|
258 # we are in a logging statement |
|
259 if ( |
|
260 self.__withinLoggingStatement() |
|
261 and self.__withinLoggingArgument() |
|
262 and self.__isFormatCall(node) |
|
263 ): |
|
264 self.violations.append((node, "L101")) |
|
265 super().generic_visit(node) |
|
266 return |
|
267 |
|
268 loggingLevel = self.__detectLoggingLevel(node) |
|
269 |
|
270 if loggingLevel and self.__currentLoggingLevel is None: |
|
271 self.__currentLoggingLevel = loggingLevel |
|
272 |
|
273 # we are in some other statement |
|
274 if loggingLevel is None: |
|
275 super().generic_visit(node) |
|
276 return |
|
277 |
|
278 # we are entering a new logging statement |
|
279 self.__currentLoggingCall = node |
|
280 |
|
281 if loggingLevel == "warn": |
|
282 self.violations.append((node, "L110")) |
|
283 |
|
284 self.__checkExcInfo(node) |
|
285 |
|
286 for index, child in enumerate(ast.iter_child_nodes(node)): |
|
287 if index == 1: |
|
288 self.__currentLoggingArgument = child |
|
289 if index >= 1: |
|
290 self.__checkExceptionArg(child) |
|
291 if index > 1 and isinstance(child, ast.keyword) and child.arg == "extra": |
|
292 self.__currentExtraKeyword = child |
|
293 |
|
294 super().visit(child) |
|
295 |
|
296 self.__currentLoggingArgument = None |
|
297 self.__currentExtraKeyword = None |
|
298 |
|
299 self.__currentLoggingCall = None |
|
300 self.__currentLoggingLevel = None |
|
301 |
|
302 def visit_BinOp(self, node): |
|
303 """ |
|
304 Public method to handle binary operations while processing the first |
|
305 logging argument. |
|
306 |
|
307 @param node reference to the node to be processed |
|
308 @type ast.BinOp |
|
309 """ |
|
310 if self.__withinLoggingStatement() and self.__withinLoggingArgument(): |
|
311 # handle percent format |
|
312 if isinstance(node.op, ast.Mod): |
|
313 self.violations.append((node, "L102")) |
|
314 |
|
315 # handle string concat |
|
316 if isinstance(node.op, ast.Add): |
|
317 self.violations.append((node, "L103")) |
|
318 |
|
319 super().generic_visit(node) |
|
320 |
|
321 def visit_Dict(self, node): |
|
322 """ |
|
323 Public method to handle dict arguments. |
|
324 |
|
325 @param node reference to the node to be processed |
|
326 @type ast.Dict |
|
327 """ |
|
328 if self.__shouldCheckExtraFieldClash(node): |
|
329 for key in node.keys: |
|
330 # key can be None if the dict uses double star syntax |
|
331 if key is not None and key.s in _ReservedAttrs: |
|
332 self.violations.append((node, "L121", key.s)) |
|
333 |
|
334 if self.__shouldCheckExtraException(node): |
|
335 for value in node.values: |
|
336 self.__checkExceptionArg(value) |
|
337 |
|
338 super().generic_visit(node) |
|
339 |
|
340 def visit_JoinedStr(self, node): |
|
341 """ |
|
342 Public method to handle f-string arguments. |
|
343 |
|
344 @param node reference to the node to be processed |
|
345 @type ast.JoinedStr |
|
346 """ |
|
347 if ( |
|
348 self.__withinLoggingStatement() |
|
349 and any(isinstance(i, ast.FormattedValue) for i in node.values) |
|
350 and self.__withinLoggingArgument() |
|
351 ): |
|
352 self.violations.append((node, "L104")) |
|
353 |
|
354 super().generic_visit(node) |
|
355 |
|
356 |
|
357 def visit_ExceptHandler(self, node): |
|
358 """ |
|
359 Public method to handle an exception handler. |
|
360 |
|
361 @param node reference to the node to be processed |
|
362 @type ast.ExceptHandler |
|
363 """ |
|
364 name = self.__getExceptHandlerName(node) |
|
365 if not name: |
|
366 super().generic_visit(node) |
|
367 return |
|
368 |
|
369 self.__currentExceptNames.append(name) |
|
370 |
|
371 super().generic_visit(node) |
|
372 |
|
373 self.__currentExceptNames.pop() |