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