|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the checker for functions that can be replaced by use of |
|
8 the pathlib module. |
|
9 """ |
|
10 |
|
11 import ast |
|
12 import sys |
|
13 |
|
14 |
|
15 class PathlibChecker(object): |
|
16 """ |
|
17 Class implementing a checker for functions that can be replaced by use of |
|
18 the pathlib module. |
|
19 """ |
|
20 Codes = [ |
|
21 ## Replacements for the os module functions |
|
22 "P101", "P102", "P103", "P104", "P105", "P106", "P107", |
|
23 "P108", "P109", "P110", "P111", |
|
24 |
|
25 ## Replacements for the os.path module functions |
|
26 "P201", "P202", "P203", "P204", "P205", "P206", "P207", |
|
27 "P208", "P209", "P210", "P211", "P212", |
|
28 |
|
29 ## Replacements for some Python standrd library functions |
|
30 "P301", |
|
31 |
|
32 ## Replacements for py.path.local |
|
33 "P401", |
|
34 ] |
|
35 |
|
36 # map functions to be replaced to error codes |
|
37 Function2Code = { |
|
38 "os.chmod": "P101", |
|
39 "os.mkdir": "P102", |
|
40 "os.makedirs": "P103", |
|
41 "os.rename": "P104", |
|
42 "os.replace": "P105", |
|
43 "os.rmdir": "P106", |
|
44 "os.remove": "P107", |
|
45 "os.unlink": "P108", |
|
46 "os.getcwd": "P109", |
|
47 "os.readlink": "P110", |
|
48 "os.stat": "P111", |
|
49 |
|
50 "os.path.abspath": "P201", |
|
51 "os.path.exists": "P202", |
|
52 "os.path.expanduser": "P203", |
|
53 "os.path.isdir": "P204", |
|
54 "os.path.isfile": "P205", |
|
55 "os.path.islink": "P206", |
|
56 "os.path.isabs": "P207", |
|
57 "os.path.join": "P208", |
|
58 "os.path.basename": "P209", |
|
59 "os.path.dirname": "P210", |
|
60 "os.path.samefile": "P211", |
|
61 "os.path.splitext": "P212", |
|
62 |
|
63 "open": "P301", |
|
64 |
|
65 "py.path.local": "P401", |
|
66 } |
|
67 |
|
68 def __init__(self, source, filename, selected, ignored, expected, repeat): |
|
69 """ |
|
70 Constructor |
|
71 |
|
72 @param source source code to be checked |
|
73 @type list of str |
|
74 @param filename name of the source file |
|
75 @type str |
|
76 @param selected list of selected codes |
|
77 @type list of str |
|
78 @param ignored list of codes to be ignored |
|
79 @type list of str |
|
80 @param expected list of expected codes |
|
81 @type list of str |
|
82 @param repeat flag indicating to report each occurrence of a code |
|
83 @type bool |
|
84 """ |
|
85 self.__select = tuple(selected) |
|
86 self.__ignore = ('',) if selected else tuple(ignored) |
|
87 self.__expected = expected[:] |
|
88 self.__repeat = repeat |
|
89 self.__filename = filename |
|
90 self.__source = source[:] |
|
91 |
|
92 # statistics counters |
|
93 self.counters = {} |
|
94 |
|
95 # collection of detected errors |
|
96 self.errors = [] |
|
97 |
|
98 self.__checkCodes = (code for code in self.Codes |
|
99 if not self.__ignoreCode(code)) |
|
100 |
|
101 def __ignoreCode(self, code): |
|
102 """ |
|
103 Private method to check if the message code should be ignored. |
|
104 |
|
105 @param code message code to check for |
|
106 @type str |
|
107 @return flag indicating to ignore the given code |
|
108 @rtype bool |
|
109 """ |
|
110 return (code.startswith(self.__ignore) and |
|
111 not code.startswith(self.__select)) |
|
112 |
|
113 def __error(self, lineNumber, offset, code, *args): |
|
114 """ |
|
115 Private method to record an issue. |
|
116 |
|
117 @param lineNumber line number of the issue |
|
118 @type int |
|
119 @param offset position within line of the issue |
|
120 @type int |
|
121 @param code message code |
|
122 @type str |
|
123 @param args arguments for the message |
|
124 @type list |
|
125 """ |
|
126 if self.__ignoreCode(code): |
|
127 return |
|
128 |
|
129 if code in self.counters: |
|
130 self.counters[code] += 1 |
|
131 else: |
|
132 self.counters[code] = 1 |
|
133 |
|
134 # Don't care about expected codes |
|
135 if code in self.__expected: |
|
136 return |
|
137 |
|
138 if code and (self.counters[code] == 1 or self.__repeat): |
|
139 # record the issue with one based line number |
|
140 self.errors.append( |
|
141 { |
|
142 "file": self.__filename, |
|
143 "line": lineNumber + 1, |
|
144 "offset": offset, |
|
145 "code": code, |
|
146 "args": args, |
|
147 } |
|
148 ) |
|
149 |
|
150 def __reportInvalidSyntax(self): |
|
151 """ |
|
152 Private method to report a syntax error. |
|
153 """ |
|
154 exc_type, exc = sys.exc_info()[:2] |
|
155 if len(exc.args) > 1: |
|
156 offset = exc.args[1] |
|
157 if len(offset) > 2: |
|
158 offset = offset[1:3] |
|
159 else: |
|
160 offset = (1, 0) |
|
161 self.__error(offset[0] - 1, offset[1] or 0, |
|
162 'M901', exc_type.__name__, exc.args[0]) |
|
163 |
|
164 def __generateTree(self): |
|
165 """ |
|
166 Private method to generate an AST for our source. |
|
167 |
|
168 @return generated AST |
|
169 @rtype ast.AST |
|
170 """ |
|
171 return ast.parse("".join(self.__source), self.__filename) |
|
172 |
|
173 def run(self): |
|
174 """ |
|
175 Public method to check the given source against functions |
|
176 to be replaced by 'pathlib' equivalents. |
|
177 """ |
|
178 if not self.__filename: |
|
179 # don't do anything, if essential data is missing |
|
180 return |
|
181 |
|
182 if not self.__checkCodes: |
|
183 # don't do anything, if no codes were selected |
|
184 return |
|
185 |
|
186 try: |
|
187 self.__tree = self.__generateTree() |
|
188 except (SyntaxError, TypeError): |
|
189 self.__reportInvalidSyntax() |
|
190 return |
|
191 |
|
192 visitor = PathlibVisitor(self.__checkForReplacement) |
|
193 visitor.visit(self.__tree) |
|
194 |
|
195 def __checkForReplacement(self, node, name): |
|
196 """ |
|
197 Private method to check the given node for the need for a |
|
198 replacement. |
|
199 |
|
200 @param node reference to the AST node to check |
|
201 @type ast.AST |
|
202 @param name resolved name of the node |
|
203 @type str |
|
204 """ |
|
205 try: |
|
206 errorCode = self.Function2Code[name] |
|
207 self.__error(node.lineno - 1, node.col_offset, errorCode) |
|
208 except KeyError: |
|
209 # name is not in our list of replacements |
|
210 pass |
|
211 |
|
212 |
|
213 class PathlibVisitor(ast.NodeVisitor): |
|
214 """ |
|
215 Class to traverse the AST node tree and check for potential issues. |
|
216 """ |
|
217 def __init__(self, checkCallback): |
|
218 """ |
|
219 Constructor |
|
220 |
|
221 @param checkCallback callback function taking a reference to the |
|
222 AST node and the resolved name |
|
223 @type func |
|
224 """ |
|
225 super(PathlibVisitor, self).__init__() |
|
226 |
|
227 self.__checkCallback = checkCallback |
|
228 self.__importAlias = {} |
|
229 |
|
230 def visit_ImportFrom(self, node): |
|
231 """ |
|
232 Public method handle the ImportFrom AST node. |
|
233 |
|
234 @param node reference to the ImportFrom AST node |
|
235 @type ast.ImportFrom |
|
236 """ |
|
237 for imp in node.names: |
|
238 if imp.asname: |
|
239 self.__importAlias[imp.asname] = f"{node.module}.{imp.name}" |
|
240 else: |
|
241 self.__importAlias[imp.name] = f"{node.module}.{imp.name}" |
|
242 |
|
243 def visit_Import(self, node): |
|
244 """ |
|
245 Public method to handle the Import AST node. |
|
246 |
|
247 @param node reference to the Import AST node |
|
248 @type ast.Import |
|
249 """ |
|
250 for imp in node.names: |
|
251 if imp.asname: |
|
252 self.__importAlias[imp.asname] = imp.name |
|
253 |
|
254 def visit_Call(self, node): |
|
255 """ |
|
256 Public method to handle the Call AST node. |
|
257 |
|
258 @param node reference to the Call AST node |
|
259 @type ast.Call |
|
260 """ |
|
261 nameResolver = NameResolver(self.__importAlias) |
|
262 nameResolver.visit(node.func) |
|
263 |
|
264 self.__checkCallback(node, nameResolver.name()) |
|
265 |
|
266 |
|
267 class NameResolver(ast.NodeVisitor): |
|
268 """ |
|
269 Class to resolve a Name or Attribute node. |
|
270 """ |
|
271 def __init__(self, importAlias): |
|
272 """ |
|
273 Constructor |
|
274 |
|
275 @param importAlias reference to the import aliases dictionary |
|
276 @type dict |
|
277 """ |
|
278 self.__importAlias = importAlias |
|
279 self.__names = [] |
|
280 |
|
281 def name(self): |
|
282 """ |
|
283 Public method to resolve the name. |
|
284 |
|
285 @return resolved name |
|
286 @rtype str |
|
287 """ |
|
288 try: |
|
289 attr = self.__importAlias[self.__names[-1]] |
|
290 self.__names[-1] = attr |
|
291 except (KeyError, IndexError): |
|
292 # do nothing if there is no such name or the names list is empty |
|
293 pass |
|
294 |
|
295 return ".".join(reversed(self.__names)) |
|
296 |
|
297 def visit_Name(self, node): |
|
298 """ |
|
299 Public method to handle the Name AST node. |
|
300 |
|
301 @param node reference to the Name AST node |
|
302 @type ast.Name |
|
303 """ |
|
304 self.__names.append(node.id) |
|
305 |
|
306 def visit_Attribute(self, node): |
|
307 """ |
|
308 Public method to handle the Attribute AST node. |
|
309 |
|
310 @param node reference to the Attribute AST node |
|
311 @type ast.Attribute |
|
312 """ |
|
313 try: |
|
314 self.__names.append(node.attr) |
|
315 self.__names.append(node.value.id) |
|
316 except AttributeError: |
|
317 self.generic_visit(node) |