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