Coverage for pyquickhelper/pycode/utils_tests_helper.py: 92%
184 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
1"""
2@file
3@brief This extension contains various functionalities to help unittesting.
4"""
5import os
6import stat
7import sys
8import re
9import warnings
10import time
11import importlib
12from contextlib import redirect_stdout, redirect_stderr
13from io import StringIO
16def _get_PyLinterRunV():
17 # Separate function to speed up import.
18 from pylint.lint import Run as PyLinterRun
19 from pylint import __version__ as pylint_version
20 if pylint_version >= '2.0.0':
21 PyLinterRunV = PyLinterRun
22 else:
23 PyLinterRunV = lambda *args, do_exit=False: PyLinterRun( # pylint: disable=E1120, E1123
24 *args, exit=do_exit) # pylint: disable=E1120, E1123
25 return PyLinterRunV
28def get_temp_folder(thisfile, name=None, clean=True, create=True,
29 persistent=False, path_name="tpath"):
30 """
31 Creates and returns a local temporary folder to store files
32 when unit testing.
34 @param thisfile use ``__file__`` or the function which runs the test
35 @param name name of the temporary folder
36 @param clean if True, clean the folder first, it can also a function
37 called to determine whether or not the folder should be
38 cleaned
39 @param create if True, creates it (empty if clean is True)
40 @param persistent if True, create a folder at root level to reduce path length,
41 the function checks the ``MAX_PATH`` variable and
42 shorten the test folder is *max_path* is True on :epkg:`Windows`,
43 on :epkg:`Linux`, it creates a folder three level ahead
44 @param path_name test path used when *max_path* is True
45 @return temporary folder
47 The function extracts the file which runs this test and will name
48 the temporary folder base on the name of the method. *name* must be None.
50 Parameter *clean* can be a function.
51 Signature is ``def clean(folder)``.
52 """
53 if name is None:
54 name = thisfile.__name__
55 if name.startswith("test_"):
56 name = "temp_" + name[5:]
57 elif not name.startswith("temp_"):
58 name = "temp_" + name
59 thisfile = os.path.abspath(thisfile.__func__.__code__.co_filename)
60 final = os.path.split(name)[-1]
62 if not final.startswith("temp_") and not final.startswith("temp2_"):
63 raise NameError( # pragma: no cover
64 f"the folder '{name}' must begin with temp_")
66 local = os.path.join(
67 os.path.normpath(os.path.abspath(os.path.dirname(thisfile))), name)
69 if persistent:
70 if sys.platform.startswith("win"): # pragma: no cover
71 from ctypes.wintypes import MAX_PATH
72 if MAX_PATH <= 300:
73 local = os.path.join(os.path.abspath("\\" + path_name), name)
74 else:
75 local = os.path.join(
76 local, "..", "..", "..", "..", path_name, name)
77 else:
78 local = os.path.join(local, "..", "..", "..",
79 "..", path_name, name)
80 local = os.path.normpath(local)
82 if name == local:
83 raise NameError( # pragma: no cover
84 f"The folder '{name}' must be relative, not absolute")
86 if not os.path.exists(local):
87 if create:
88 os.makedirs(local)
89 mode = os.stat(local).st_mode
90 nmode = mode | stat.S_IWRITE
91 if nmode != mode:
92 os.chmod(local, nmode) # pragma: no cover
93 else:
94 if (callable(clean) and clean(local)) or (not callable(clean) and clean):
95 # delayed import to speed up import time of pycode
96 from ..filehelper.synchelper import remove_folder
97 remove_folder(local)
98 time.sleep(0.1)
99 if create and not os.path.exists(local):
100 os.makedirs(local)
101 mode = os.stat(local).st_mode
102 nmode = mode | stat.S_IWRITE
103 if nmode != mode:
104 os.chmod(local, nmode) # pragma: no cover
106 return local
109def _extended_refactoring(filename, line): # pragma: no cover
110 """
111 Private function which does extra checkings
112 when refactoring :epkg:`pyquickhelper`.
114 @param filename filename
115 @param line line
116 @return None or error message
117 """
118 if "from pyquickhelper import fLOG" in line:
119 if "test_code_style" not in filename:
120 return "issue with fLOG"
121 if "from pyquickhelper import noLOG" in line:
122 if "test_code_style" not in filename:
123 return "issue with noLOG"
124 if "from pyquickhelper import run_cmd" in line:
125 if "test_code_style" not in filename:
126 return "issue with run_cmd"
127 if "from pyquickhelper import get_temp_folder" in line:
128 if "test_code_style" not in filename:
129 return "issue with get_temp_folder"
130 return None
133class PEP8Exception(Exception):
134 """
135 Code or style issues.
136 """
137 pass
140def check_pep8(folder, ignore=('E265', 'W504'), skip=None,
141 complexity=-1, stop_after=100, fLOG=None,
142 pylint_ignore=('C0103', 'C1801',
143 'R1705',
144 'W0108', 'W0613',
145 'W0107', 'C0415',
146 'C0209'),
147 recursive=True, neg_pattern=None, extended=None,
148 max_line_length=143, pattern=".*[.]py$",
149 run_lint=True, verbose=False, run_cmd_filter=None):
150 """
151 Checks if :epkg:`PEP8`,
152 the function calls command :epkg:`pycodestyle`
153 on a specific folder.
155 @param folder folder to look into
156 @param ignore list of warnings to skip when raising an exception if
157 :epkg:`PEP8` is not verified, see also
158 `Error Codes <http://pep8.readthedocs.org/en/latest/intro.html#error-codes>`_
159 @param pylint_ignore ignore :epkg:`pylint` issues, see
160 :epkg:`pylint error codes`
161 @param complexity see `check_file <https://pycodestyle.pycqa.org/en/latest/api.html>`_
162 @param stop_after stop after *stop_after* issues
163 @param skip skip a warning if a substring in this list is found
164 @param neg_pattern skip files verifying this regular expressions
165 @param extended list of tuple (name, function), see below
166 @param max_line_length maximum allowed length of a line of code
167 @param recursive look into subfolder
168 @param pattern only file matching this pattern will be checked
169 @param run_lint run :epkg:`pylint`
170 @param verbose :epkg:`pylint` is slow, tells which file is
171 investigated (but it is even slower)
172 @param run_cmd_filter some files makes :epkg:`pylint` crashes (``import yaml``),
173 the test for this is run in a separate process
174 if the function *run_cmd_filter* returns True of the filename,
175 *verbose* is set to True in that case
176 @param fLOG logging function
177 @return output
179 Functions mentioned in *extended* takes two parameters (file name and line)
180 and they returned None or an error message or a tuple (position in the line, error message).
181 When the return is not empty, a warning will be added to the ones
182 printed by :epkg:`pycodestyle`.
183 A few codes to ignore:
185 * *E501*: line too long (?? characters)
186 * *E265*: block comments should have a space after #
187 * *W504*: line break after binary operator, this one is raised
188 after the code is modified by @see fn remove_extra_spaces_and_pep8.
190 The full list is available at :epkg:`PEP8 codes`. In addition,
191 the function adds its own codes:
193 * *ECL1*: line too long for a specific reason.
195 Some errors to disable with :epkg:`pylint`:
197 * *C0103*: variable name is not conform
198 * *C0111*: missing function docstring
199 * *C1801*: do not use `len(SEQUENCE)` to determine if a sequence is empty
200 * *R0205*: Class '?' inherits from object, can be safely removed from bases in python3 (pylint)
201 * *R0901*: too many ancestors
202 * *R0902*: too many instance attributes
203 * *R0911*: too many return statements
204 * *R0912*: too many branches
205 * *R0913*: too many arguments
206 * *R0914*: too many local variables
207 * *R0915*: too many statements
208 * *R1702*: too many nested blocks
209 * *R1705*: unnecessary "else" after "return"
210 * *W0107*: unnecessary pass statements
211 * *W0108*: Lambda may not be necessary
212 * *W0613*: unused argument
214 The full list is available at :epkg:`pylint error codes`.
215 :epkg:`pylint` was added used to check the code.
216 It produces the following list of errors
217 :epkg:`pylint error codes`.
219 If *neg_pattern* is empty, it populates with a default value
220 which skips unnecessary folders:
221 ``".*[/\\\\\\\\]((_venv)|([.]git)|(__pycache__)|(temp_)).*"``.
222 """
223 # delayed import to speed up import time of pycode
224 import pycodestyle
225 from ..filehelper.synchelper import explore_folder_iterfile
226 if fLOG is None:
227 from ..loghelper.flog import noLOG # pragma: no cover
228 fLOG = noLOG # pragma: no cover
230 def extended_checkings(fname, content, buf, extended):
231 for i, line in enumerate(content):
232 for name, fu in extended:
233 r = fu(fname, line)
234 if isinstance(r, tuple):
235 c, r = r
236 else:
237 c = 1
238 if r is not None:
239 buf.write(f"{fname}:{i + 1}:{c} F{name} {r}\n")
241 def fkeep(s):
242 if len(s) == 0:
243 return False
244 if skip is not None:
245 for kip in skip:
246 if kip in s:
247 return False
248 return True
250 if max_line_length is not None:
251 if extended is None:
252 extended = []
253 else:
254 extended = extended.copy()
256 def check_lenght_line(fname, line):
257 if len(line) > max_line_length and not line.lstrip().startswith('#'):
258 if ">`_" in line:
259 return f"line too long (link) {len(line)} > {max_line_length}"
260 if ":math:`" in line:
261 return "line too long (:math:) {0} > {1}".format( # pragma: no cover
262 len(line), max_line_length)
263 if "ERROR: " in line:
264 return "line too long (ERROR:) {0} > {1}".format( # pragma: no cover
265 len(line), max_line_length)
266 return None
268 extended.append(("[ECL1]", check_lenght_line))
270 if ignore is None:
271 ignore = tuple()
272 elif isinstance(ignore, list):
273 ignore = tuple(ignore)
275 if neg_pattern is None:
276 neg_pattern = ".*[/\\\\]((_venv)|([.]git)|(__pycache__)|(temp_)|([.]egg)|(bin)).*"
278 try:
279 regneg_filter = None if neg_pattern is None else re.compile(
280 neg_pattern)
281 except re.error as e: # pragma: no cover
282 raise ValueError(f"Unable to compile '{neg_pattern}'") from e
284 # pycodestyle
285 fLOG(f"[check_pep8] code style on '{folder}'")
286 files_to_check = []
287 skipped = []
288 buf = StringIO()
289 with redirect_stdout(buf):
290 for file in explore_folder_iterfile(folder, pattern=pattern,
291 recursive=recursive):
292 if regneg_filter is not None:
293 if regneg_filter.search(file):
294 skipped.append(file)
295 continue
296 if file.endswith("__init__.py"):
297 ig = ignore + ('F401',)
298 else:
299 ig = ignore
300 if file is None:
301 raise RuntimeError( # pragma: no cover
302 "file cannot be None")
303 if len(file) == 0:
304 raise RuntimeError( # pragma: no cover
305 "file cannot be empty")
307 # code style
308 files_to_check.append(file)
309 try:
310 style = pycodestyle.StyleGuide(
311 ignore=ig, complexity=complexity, format='pylint',
312 max_line_length=max_line_length)
313 res = style.check_files([file])
314 except TypeError as e: # pragma: no cover
315 ext = "This is often due to an instruction from . import... The imported module has no name."
316 raise TypeError("Issue with pycodesyle for module '{0}' ig={1} complexity={2}\n{3}".format(
317 file, ig, complexity, ext)) from e
319 if extended is not None:
320 with open(file, "r", errors="ignore") as f:
321 content = f.readlines()
322 extended_checkings(file, content, buf, extended)
324 if res.total_errors + res.file_errors > 0:
325 res.print_filename = True
326 lines = [_ for _ in buf.getvalue().split("\n") if fkeep(_)]
327 if len(lines) > stop_after:
328 raise PEP8Exception( # pragma: no cover
329 "{0} lines\n{1}".format(len(lines), "\n".join(lines)))
331 lines = [_ for _ in buf.getvalue().split("\n") if fkeep(_)]
332 if len(lines) > 10:
333 raise PEP8Exception( # pragma: no cover
334 "{0} lines\n{1}".format(len(lines), "\n".join(lines)))
336 if len(files_to_check) == 0:
337 mes = skipped[0] if skipped else "-no skipped file-"
338 raise FileNotFoundError( # pragma: no cover
339 f"No file found in '{folder}'\n pattern='{pattern}'\nskipped='{mes}'")
341 # pylint
342 if not run_lint:
343 return "\n".join(lines)
344 fLOG(f"[check_pep8] pylint with {len(files_to_check)} files")
345 memout = sys.stdout
347 try:
348 fLOG('', OutputStream=memout)
349 regular_print = False
350 except TypeError: # pragma: no cover
351 regular_print = True
353 def myprint(s):
354 "local print, chooses the right function"
355 if regular_print: # pragma: no cover
356 memout.write(s + "\n")
357 else: # pragma: no cover
358 fLOG(s, OutputStream=memout)
360 neg_pat = ".*temp[0-9]?_.*,doc_.*"
361 if neg_pattern is not None:
362 neg_pat += ',' + neg_pattern
364 if run_cmd_filter is not None:
365 verbose = True # pragma: no cover
367 PyLinterRunV = _get_PyLinterRunV()
368 sout = StringIO()
369 serr = StringIO()
370 with redirect_stdout(sout):
371 with redirect_stderr(serr):
372 with warnings.catch_warnings():
373 warnings.simplefilter("ignore", DeprecationWarning)
374 opt = ["--ignore-patterns=" + neg_pat, "--persistent=n",
375 '--jobs=1', '--suggestion-mode=n', "--score=n",
376 '--max-args=30', '--max-locals=50', '--max-returns=30',
377 '--max-branches=50', '--max-parents=25',
378 '--max-attributes=50', '--min-public-methods=0',
379 '--max-public-methods=100', '--max-bool-expr=10',
380 '--max-statements=200',
381 '--msg-template={abspath}:{line}: {msg_id}: {msg} (pylint)']
382 if pylint_ignore:
383 opt.append('--disable=' + ','.join(pylint_ignore))
384 if max_line_length:
385 opt.append("--max-line-length=%d" % max_line_length)
386 if verbose: # pragma: no cover
387 for i, name in enumerate(files_to_check):
388 cop = list(opt)
389 cop.append(name)
390 if run_cmd_filter is None or not run_cmd_filter(name):
391 myprint(
392 f"[check_pep8] lint file {i + 1}/{len(files_to_check)} - '{name}'\n")
393 PyLinterRunV(cop, do_exit=False)
394 else:
395 # delayed import to speed up import time of pycode
396 from ..loghelper import run_cmd
397 # runs from command line
398 myprint(
399 f"[check_pep8] cmd-lint file {i + 1}/{len(files_to_check)} - '{name}'\n")
400 cmd = "{0} -m pylint {1}".format(
401 sys.executable, " ".join('"{0}"'.format(_) for _ in cop))
402 out = run_cmd(cmd, wait=True)[0]
403 lines.extend(_ for _ in out.split(
404 '\n') if _.strip('\r '))
405 else:
406 opt.extend(files_to_check)
407 PyLinterRunV(opt, do_exit=False)
409 pylint_lines = sout.getvalue().split('\n')
410 pylint_lines = [
411 _ for _ in pylint_lines if (
412 '(pylint)' in _ and fkeep(_) and _[0] != ' ' and len(_.split(':')) > 2)]
413 pylint_lines = [_ for _ in pylint_lines if not _.startswith(
414 "except ") and not _.startswith("else:") and not _.startswith(
415 "try:") and "# noqa" not in _]
416 lines.extend(pylint_lines)
417 if len(lines) > 0:
418 raise PEP8Exception(
419 "{0} lines\n{1}".format(len(lines), "\n".join(lines)))
421 return "\n".join(lines)
424def add_missing_development_version(names, root, hide=False):
425 """
426 Looks for development version of a given module and add paths to
427 ``sys.path`` after having checked they are working.
429 @param names name or names of the module to import
430 @param root folder where to look (assuming all modules location
431 at the same place in a flat hierarchy)
432 @param hide hide warnings when importing a module (might be a lot)
433 @return added paths
434 """
435 # delayed import to speed up import time
436 from ..loghelper import sys_path_append
438 if not isinstance(names, list):
439 names = [names]
440 root = os.path.abspath(root)
441 if os.path.isfile(root):
442 root = os.path.dirname(root)
443 if not os.path.exists(root):
444 raise FileNotFoundError(root) # pragma: no cover
446 spl = os.path.split(root)
447 py27 = False
448 if spl[-1].startswith("ut_"):
449 if "dist_module27" in root:
450 # python 27
451 py27 = True
452 newroot = os.path.join(root, "..", "..", "..", "..")
453 else:
454 newroot = os.path.join(root, "..", "..", "..")
455 else:
456 newroot = root
458 newroot = os.path.normpath(os.path.abspath(newroot))
459 found = os.listdir(newroot)
460 dirs = [os.path.join(newroot, _) for _ in found]
462 paths = []
463 for name in names:
464 exc = None
465 try:
466 if hide:
467 with warnings.catch_warnings(record=True):
468 importlib.import_module(name)
469 else:
470 importlib.import_module(name)
471 continue
472 except ImportError as e: # pragma: no cover
473 # it requires a path
474 exc = e
476 if name not in found:
477 raise FileNotFoundError( # pragma: no cover
478 "Unable to find a subfolder '{0}' in '{1}' (py27={3})\nFOUND:\n{2}\nexc={4}".format(
479 name, newroot, "\n".join(dirs), py27, exc))
481 if py27: # pragma: no cover
482 this = os.path.join(newroot, name, "dist_module27", "src")
483 if not os.path.exists(this):
484 this = os.path.join(newroot, name, "dist_module27")
485 else: # pragma: no cover
486 this = os.path.join(newroot, name, "src")
487 if not os.path.exists(this):
488 this = os.path.join(newroot, name)
490 if not os.path.exists(this): # pragma: no cover
491 raise FileNotFoundError(
492 "unable to find a subfolder '{0}' in '{1}' (*py27={3})\nFOUND:\n{2}".format(
493 this, newroot, "\n".join(dirs), py27))
494 with sys_path_append(this): # pragma: no cover
495 if hide:
496 with warnings.catch_warnings(record=True):
497 importlib.import_module(name)
498 else:
499 importlib.import_module(name)
500 paths.append(this) # pragma: no cover
501 return paths