1# -*- coding: utf-8 -*- 2from __future__ import absolute_import 3 4import atexit 5import errno 6import fnmatch 7import itertools 8import operator 9import os 10import shutil 11import sys 12import uuid 13import warnings 14from functools import partial 15from functools import reduce 16from os.path import expanduser 17from os.path import expandvars 18from os.path import isabs 19from os.path import sep 20from posixpath import sep as posix_sep 21 22import six 23from six.moves import map 24 25from .compat import PY36 26from _pytest.warning_types import PytestWarning 27 28if PY36: 29 from pathlib import Path, PurePath 30else: 31 from pathlib2 import Path, PurePath 32 33__all__ = ["Path", "PurePath"] 34 35 36LOCK_TIMEOUT = 60 * 60 * 3 37 38get_lock_path = operator.methodcaller("joinpath", ".lock") 39 40 41def ensure_reset_dir(path): 42 """ 43 ensures the given path is an empty directory 44 """ 45 if path.exists(): 46 rm_rf(path) 47 path.mkdir() 48 49 50def on_rm_rf_error(func, path, exc, **kwargs): 51 """Handles known read-only errors during rmtree. 52 53 The returned value is used only by our own tests. 54 """ 55 start_path = kwargs["start_path"] 56 exctype, excvalue = exc[:2] 57 58 # another process removed the file in the middle of the "rm_rf" (xdist for example) 59 # more context: https://github.com/pytest-dev/pytest/issues/5974#issuecomment-543799018 60 if isinstance(excvalue, OSError) and excvalue.errno == errno.ENOENT: 61 return False 62 63 if not isinstance(excvalue, OSError) or excvalue.errno not in ( 64 errno.EACCES, 65 errno.EPERM, 66 ): 67 warnings.warn( 68 PytestWarning( 69 "(rm_rf) error removing {}\n{}: {}".format(path, exctype, excvalue) 70 ) 71 ) 72 return False 73 74 if func not in (os.rmdir, os.remove, os.unlink): 75 warnings.warn( 76 PytestWarning( 77 "(rm_rf) unknown function {} when removing {}:\n{}: {}".format( 78 path, func, exctype, excvalue 79 ) 80 ) 81 ) 82 return False 83 84 # Chmod + retry. 85 import stat 86 87 def chmod_rw(p): 88 mode = os.stat(p).st_mode 89 os.chmod(p, mode | stat.S_IRUSR | stat.S_IWUSR) 90 91 # For files, we need to recursively go upwards in the directories to 92 # ensure they all are also writable. 93 p = Path(path) 94 if p.is_file(): 95 for parent in p.parents: 96 chmod_rw(str(parent)) 97 # stop when we reach the original path passed to rm_rf 98 if parent == start_path: 99 break 100 chmod_rw(str(path)) 101 102 func(path) 103 return True 104 105 106def rm_rf(path): 107 """Remove the path contents recursively, even if some elements 108 are read-only. 109 """ 110 onerror = partial(on_rm_rf_error, start_path=path) 111 shutil.rmtree(str(path), onerror=onerror) 112 113 114def find_prefixed(root, prefix): 115 """finds all elements in root that begin with the prefix, case insensitive""" 116 l_prefix = prefix.lower() 117 for x in root.iterdir(): 118 if x.name.lower().startswith(l_prefix): 119 yield x 120 121 122def extract_suffixes(iter, prefix): 123 """ 124 :param iter: iterator over path names 125 :param prefix: expected prefix of the path names 126 :returns: the parts of the paths following the prefix 127 """ 128 p_len = len(prefix) 129 for p in iter: 130 yield p.name[p_len:] 131 132 133def find_suffixes(root, prefix): 134 """combines find_prefixes and extract_suffixes 135 """ 136 return extract_suffixes(find_prefixed(root, prefix), prefix) 137 138 139def parse_num(maybe_num): 140 """parses number path suffixes, returns -1 on error""" 141 try: 142 return int(maybe_num) 143 except ValueError: 144 return -1 145 146 147if six.PY2: 148 149 def _max(iterable, default): 150 """needed due to python2.7 lacking the default argument for max""" 151 return reduce(max, iterable, default) 152 153 154else: 155 _max = max 156 157 158def _force_symlink(root, target, link_to): 159 """helper to create the current symlink 160 161 it's full of race conditions that are reasonably ok to ignore 162 for the context of best effort linking to the latest testrun 163 164 the presumption being thatin case of much parallelism 165 the inaccuracy is going to be acceptable 166 """ 167 current_symlink = root.joinpath(target) 168 try: 169 current_symlink.unlink() 170 except OSError: 171 pass 172 try: 173 current_symlink.symlink_to(link_to) 174 except Exception: 175 pass 176 177 178def make_numbered_dir(root, prefix): 179 """create a directory with an increased number as suffix for the given prefix""" 180 for i in range(10): 181 # try up to 10 times to create the folder 182 max_existing = _max(map(parse_num, find_suffixes(root, prefix)), default=-1) 183 new_number = max_existing + 1 184 new_path = root.joinpath("{}{}".format(prefix, new_number)) 185 try: 186 new_path.mkdir() 187 except Exception: 188 pass 189 else: 190 _force_symlink(root, prefix + "current", new_path) 191 return new_path 192 else: 193 raise EnvironmentError( 194 "could not create numbered dir with prefix " 195 "{prefix} in {root} after 10 tries".format(prefix=prefix, root=root) 196 ) 197 198 199def create_cleanup_lock(p): 200 """crates a lock to prevent premature folder cleanup""" 201 lock_path = get_lock_path(p) 202 try: 203 fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) 204 except OSError as e: 205 if e.errno == errno.EEXIST: 206 six.raise_from( 207 EnvironmentError("cannot create lockfile in {path}".format(path=p)), e 208 ) 209 else: 210 raise 211 else: 212 pid = os.getpid() 213 spid = str(pid) 214 if not isinstance(spid, bytes): 215 spid = spid.encode("ascii") 216 os.write(fd, spid) 217 os.close(fd) 218 if not lock_path.is_file(): 219 raise EnvironmentError("lock path got renamed after successful creation") 220 return lock_path 221 222 223def register_cleanup_lock_removal(lock_path, register=atexit.register): 224 """registers a cleanup function for removing a lock, by default on atexit""" 225 pid = os.getpid() 226 227 def cleanup_on_exit(lock_path=lock_path, original_pid=pid): 228 current_pid = os.getpid() 229 if current_pid != original_pid: 230 # fork 231 return 232 try: 233 lock_path.unlink() 234 except (OSError, IOError): 235 pass 236 237 return register(cleanup_on_exit) 238 239 240def maybe_delete_a_numbered_dir(path): 241 """removes a numbered directory if its lock can be obtained and it does not seem to be in use""" 242 lock_path = None 243 try: 244 lock_path = create_cleanup_lock(path) 245 parent = path.parent 246 247 garbage = parent.joinpath("garbage-{}".format(uuid.uuid4())) 248 path.rename(garbage) 249 rm_rf(garbage) 250 except (OSError, EnvironmentError): 251 # known races: 252 # * other process did a cleanup at the same time 253 # * deletable folder was found 254 # * process cwd (Windows) 255 return 256 finally: 257 # if we created the lock, ensure we remove it even if we failed 258 # to properly remove the numbered dir 259 if lock_path is not None: 260 try: 261 lock_path.unlink() 262 except (OSError, IOError): 263 pass 264 265 266def ensure_deletable(path, consider_lock_dead_if_created_before): 267 """checks if a lock exists and breaks it if its considered dead""" 268 if path.is_symlink(): 269 return False 270 lock = get_lock_path(path) 271 if not lock.exists(): 272 return True 273 try: 274 lock_time = lock.stat().st_mtime 275 except Exception: 276 return False 277 else: 278 if lock_time < consider_lock_dead_if_created_before: 279 lock.unlink() 280 return True 281 else: 282 return False 283 284 285def try_cleanup(path, consider_lock_dead_if_created_before): 286 """tries to cleanup a folder if we can ensure it's deletable""" 287 if ensure_deletable(path, consider_lock_dead_if_created_before): 288 maybe_delete_a_numbered_dir(path) 289 290 291def cleanup_candidates(root, prefix, keep): 292 """lists candidates for numbered directories to be removed - follows py.path""" 293 max_existing = _max(map(parse_num, find_suffixes(root, prefix)), default=-1) 294 max_delete = max_existing - keep 295 paths = find_prefixed(root, prefix) 296 paths, paths2 = itertools.tee(paths) 297 numbers = map(parse_num, extract_suffixes(paths2, prefix)) 298 for path, number in zip(paths, numbers): 299 if number <= max_delete: 300 yield path 301 302 303def cleanup_numbered_dir(root, prefix, keep, consider_lock_dead_if_created_before): 304 """cleanup for lock driven numbered directories""" 305 for path in cleanup_candidates(root, prefix, keep): 306 try_cleanup(path, consider_lock_dead_if_created_before) 307 for path in root.glob("garbage-*"): 308 try_cleanup(path, consider_lock_dead_if_created_before) 309 310 311def make_numbered_dir_with_cleanup(root, prefix, keep, lock_timeout): 312 """creates a numbered dir with a cleanup lock and removes old ones""" 313 e = None 314 for i in range(10): 315 try: 316 p = make_numbered_dir(root, prefix) 317 lock_path = create_cleanup_lock(p) 318 register_cleanup_lock_removal(lock_path) 319 except Exception as exc: 320 e = exc 321 else: 322 consider_lock_dead_if_created_before = p.stat().st_mtime - lock_timeout 323 cleanup_numbered_dir( 324 root=root, 325 prefix=prefix, 326 keep=keep, 327 consider_lock_dead_if_created_before=consider_lock_dead_if_created_before, 328 ) 329 return p 330 assert e is not None 331 raise e 332 333 334def resolve_from_str(input, root): 335 assert not isinstance(input, Path), "would break on py2" 336 root = Path(root) 337 input = expanduser(input) 338 input = expandvars(input) 339 if isabs(input): 340 return Path(input) 341 else: 342 return root.joinpath(input) 343 344 345def fnmatch_ex(pattern, path): 346 """FNMatcher port from py.path.common which works with PurePath() instances. 347 348 The difference between this algorithm and PurePath.match() is that the latter matches "**" glob expressions 349 for each part of the path, while this algorithm uses the whole path instead. 350 351 For example: 352 "tests/foo/bar/doc/test_foo.py" matches pattern "tests/**/doc/test*.py" with this algorithm, but not with 353 PurePath.match(). 354 355 This algorithm was ported to keep backward-compatibility with existing settings which assume paths match according 356 this logic. 357 358 References: 359 * https://bugs.python.org/issue29249 360 * https://bugs.python.org/issue34731 361 """ 362 path = PurePath(path) 363 iswin32 = sys.platform.startswith("win") 364 365 if iswin32 and sep not in pattern and posix_sep in pattern: 366 # Running on Windows, the pattern has no Windows path separators, 367 # and the pattern has one or more Posix path separators. Replace 368 # the Posix path separators with the Windows path separator. 369 pattern = pattern.replace(posix_sep, sep) 370 371 if sep not in pattern: 372 name = path.name 373 else: 374 name = six.text_type(path) 375 return fnmatch.fnmatch(name, pattern) 376 377 378def parts(s): 379 parts = s.split(sep) 380 return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))} 381