1# -*- coding: utf-8 -*-
2from __future__ import absolute_import
3
4import itertools
5import operator
6import os
7import warnings
8from abc import abstractmethod, abstractproperty
9from functools import reduce
10
11from plumbum.lib import six
12
13
14class FSUser(int):
15    """A special object that represents a file-system user. It derives from ``int``, so it behaves
16    just like a number (``uid``/``gid``), but also have a ``.name`` attribute that holds the
17    string-name of the user, if given (otherwise ``None``)
18    """
19
20    def __new__(cls, val, name=None):
21        self = int.__new__(cls, val)
22        self.name = name
23        return self
24
25
26class Path(str, six.ABC):
27    """An abstraction over file system paths. This class is abstract, and the two implementations
28    are :class:`LocalPath <plumbum.machines.local.LocalPath>` and
29    :class:`RemotePath <plumbum.path.remote.RemotePath>`.
30    """
31
32    CASE_SENSITIVE = True
33
34    def __repr__(self):
35        return "<{} {}>".format(self.__class__.__name__, str(self))
36
37    def __div__(self, other):
38        """Joins two paths"""
39        return self.join(other)
40
41    __truediv__ = __div__
42
43    def __getitem__(self, key):
44        if type(key) == str or isinstance(key, Path):
45            return self / key
46        return str(self)[key]
47
48    def __floordiv__(self, expr):
49        """Returns a (possibly empty) list of paths that matched the glob-pattern under this path"""
50        return self.glob(expr)
51
52    def __iter__(self):
53        """Iterate over the files in this directory"""
54        return iter(self.list())
55
56    def __eq__(self, other):
57        if isinstance(other, Path):
58            return self._get_info() == other._get_info()
59        elif isinstance(other, str):
60            if self.CASE_SENSITIVE:
61                return str(self) == other
62            else:
63                return str(self).lower() == other.lower()
64        else:
65            return NotImplemented
66
67    def __ne__(self, other):
68        return not (self == other)
69
70    def __gt__(self, other):
71        return str(self) > str(other)
72
73    def __ge__(self, other):
74        return str(self) >= str(other)
75
76    def __lt__(self, other):
77        return str(self) < str(other)
78
79    def __le__(self, other):
80        return str(self) <= str(other)
81
82    def __hash__(self):
83        if self.CASE_SENSITIVE:
84            return hash(str(self))
85        else:
86            return hash(str(self).lower())
87
88    def __nonzero__(self):
89        return bool(str(self))
90
91    __bool__ = __nonzero__
92
93    def __fspath__(self):
94        """Added for Python 3.6 support"""
95        return str(self)
96
97    def __contains__(self, item):
98        """Paths should support checking to see if an file or folder is in them."""
99        try:
100            return (self / item.name).exists()
101        except AttributeError:
102            return (self / item).exists()
103
104    @abstractmethod
105    def _form(self, *parts):
106        pass
107
108    def up(self, count=1):
109        """Go up in ``count`` directories (the default is 1)"""
110        return self.join("../" * count)
111
112    def walk(
113        self, filter=lambda p: True, dir_filter=lambda p: True
114    ):  # @ReservedAssignment
115        """traverse all (recursive) sub-elements under this directory, that match the given filter.
116        By default, the filter accepts everything; you can provide a custom filter function that
117        takes a path as an argument and returns a boolean
118
119        :param filter: the filter (predicate function) for matching results. Only paths matching
120                       this predicate are returned. Defaults to everything.
121        :param dir_filter: the filter (predicate function) for matching directories. Only directories
122                           matching this predicate are recursed into. Defaults to everything.
123        """
124        for p in self.list():
125            if filter(p):
126                yield p
127            if p.is_dir() and dir_filter(p):
128                for p2 in p.walk(filter, dir_filter):
129                    yield p2
130
131    @abstractproperty
132    def name(self):
133        """The basename component of this path"""
134
135    @property
136    def basename(self):
137        """Included for compatibility with older Plumbum code"""
138        warnings.warn("Use .name instead", DeprecationWarning)
139        return self.name
140
141    @abstractproperty
142    def stem(self):
143        """The name without an extension, or the last component of the path"""
144
145    @abstractproperty
146    def dirname(self):
147        """The dirname component of this path"""
148
149    @abstractproperty
150    def root(self):
151        """The root of the file tree (`/` on Unix)"""
152
153    @abstractproperty
154    def drive(self):
155        """The drive letter (on Windows)"""
156
157    @abstractproperty
158    def suffix(self):
159        """The suffix of this file"""
160
161    @abstractproperty
162    def suffixes(self):
163        """This is a list of all suffixes"""
164
165    @abstractproperty
166    def uid(self):
167        """The user that owns this path. The returned value is a :class:`FSUser <plumbum.path.FSUser>`
168        object which behaves like an ``int`` (as expected from ``uid``), but it also has a ``.name``
169        attribute that holds the string-name of the user"""
170
171    @abstractproperty
172    def gid(self):
173        """The group that owns this path. The returned value is a :class:`FSUser <plumbum.path.FSUser>`
174        object which behaves like an ``int`` (as expected from ``gid``), but it also has a ``.name``
175        attribute that holds the string-name of the group"""
176
177    @abstractmethod
178    def as_uri(self, scheme=None):
179        """Returns a universal resource identifier. Use ``scheme`` to force a scheme."""
180
181    @abstractmethod
182    def _get_info(self):
183        pass
184
185    @abstractmethod
186    def join(self, *parts):
187        """Joins this path with any number of paths"""
188
189    @abstractmethod
190    def list(self):
191        """Returns the files in this directory"""
192
193    @abstractmethod
194    def iterdir(self):
195        """Returns an iterator over the directory. Might be slightly faster on Python 3.5 than .list()"""
196
197    @abstractmethod
198    def is_dir(self):
199        """Returns ``True`` if this path is a directory, ``False`` otherwise"""
200
201    def isdir(self):
202        """Included for compatibility with older Plumbum code"""
203        warnings.warn("Use .is_dir() instead", DeprecationWarning)
204        return self.is_dir()
205
206    @abstractmethod
207    def is_file(self):
208        """Returns ``True`` if this path is a regular file, ``False`` otherwise"""
209
210    def isfile(self):
211        """Included for compatibility with older Plumbum code"""
212        warnings.warn("Use .is_file() instead", DeprecationWarning)
213        return self.is_file()
214
215    def islink(self):
216        """Included for compatibility with older Plumbum code"""
217        warnings.warn("Use is_symlink instead", DeprecationWarning)
218        return self.is_symlink()
219
220    @abstractmethod
221    def is_symlink(self):
222        """Returns ``True`` if this path is a symbolic link, ``False`` otherwise"""
223
224    @abstractmethod
225    def exists(self):
226        """Returns ``True`` if this path exists, ``False`` otherwise"""
227
228    @abstractmethod
229    def stat(self):
230        """Returns the os.stats for a file"""
231        pass
232
233    @abstractmethod
234    def with_name(self, name):
235        """Returns a path with the name replaced"""
236
237    @abstractmethod
238    def with_suffix(self, suffix, depth=1):
239        """Returns a path with the suffix replaced. Up to last ``depth`` suffixes will be
240        replaced. None will replace all suffixes. If there are less than ``depth`` suffixes,
241        this will replace all suffixes. ``.tar.gz`` is an example where ``depth=2`` or
242        ``depth=None`` is useful"""
243
244    def preferred_suffix(self, suffix):
245        """Adds a suffix if one does not currently exist (otherwise, no change). Useful
246        for loading files with a default suffix"""
247        if len(self.suffixes) > 0:
248            return self
249        else:
250            return self.with_suffix(suffix)
251
252    @abstractmethod
253    def glob(self, pattern):
254        """Returns a (possibly empty) list of paths that matched the glob-pattern under this path"""
255
256    @abstractmethod
257    def delete(self):
258        """Deletes this path (recursively, if a directory)"""
259
260    @abstractmethod
261    def move(self, dst):
262        """Moves this path to a different location"""
263
264    def rename(self, newname):
265        """Renames this path to the ``new name`` (only the basename is changed)"""
266        return self.move(self.up() / newname)
267
268    @abstractmethod
269    def copy(self, dst, override=None):
270        """Copies this path (recursively, if a directory) to the destination path "dst".
271        Raises TypeError if dst exists and override is False.
272        Will overwrite if override is True.
273        Will silently fail to copy if override is None (the default)."""
274
275    @abstractmethod
276    def mkdir(self, mode=0o777, parents=True, exist_ok=True):
277        """
278        Creates a directory at this path.
279
280        :param mode: **Currently only implemented for local paths!** Numeric mode to use for directory
281                     creation, which may be ignored on some systems. The current implementation
282                     reproduces the behavior of ``os.mkdir`` (i.e., the current umask is first masked
283                     out), but this may change for remote paths. As with ``os.mkdir``, it is recommended
284                     to call :func:`chmod` explicitly if you need to be sure.
285        :param parents: If this is true (the default), the directory's parents will also be created if
286                        necessary.
287        :param exist_ok: If this is true (the default), no exception will be raised if the directory
288                         already exists (otherwise ``OSError``).
289
290        Note that the defaults for ``parents`` and ``exist_ok`` are the opposite of what they are in
291        Python's own ``pathlib`` - this is to maintain backwards-compatibility with Plumbum's behaviour
292        from before they were implemented.
293        """
294
295    @abstractmethod
296    def open(self, mode="r"):
297        """opens this path as a file"""
298
299    @abstractmethod
300    def read(self, encoding=None):
301        """returns the contents of this file as a ``str``. By default the data is read
302        as text, but you can specify the encoding, e.g., ``'latin1'`` or ``'utf8'``"""
303
304    @abstractmethod
305    def write(self, data, encoding=None):
306        """writes the given data to this file. By default the data is written as-is
307        (either text or binary), but you can specify the encoding, e.g., ``'latin1'``
308        or ``'utf8'``"""
309
310    @abstractmethod
311    def touch(self):
312        """Update the access time. Creates an empty file if none exists."""
313
314    @abstractmethod
315    def chown(self, owner=None, group=None, recursive=None):
316        """Change ownership of this path.
317
318        :param owner: The owner to set (either ``uid`` or ``username``), optional
319        :param group: The group to set (either ``gid`` or ``groupname``), optional
320        :param recursive: whether to change ownership of all contained files and subdirectories.
321                          Only meaningful when ``self`` is a directory. If ``None``, the value
322                          will default to ``True`` if ``self`` is a directory, ``False`` otherwise.
323        """
324
325    @abstractmethod
326    def chmod(self, mode):
327        """Change the mode of path to the numeric mode.
328
329        :param mode: file mode as for os.chmod
330        """
331
332    @staticmethod
333    def _access_mode_to_flags(
334        mode, flags={"f": os.F_OK, "w": os.W_OK, "r": os.R_OK, "x": os.X_OK}
335    ):
336        if isinstance(mode, str):
337            mode = reduce(operator.or_, [flags[m] for m in mode.lower()], 0)
338        return mode
339
340    @abstractmethod
341    def access(self, mode=0):
342        """Test file existence or permission bits
343
344        :param mode: a bitwise-or of access bits, or a string-representation thereof:
345                     ``'f'``, ``'x'``, ``'r'``, ``'w'`` for ``os.F_OK``, ``os.X_OK``,
346                     ``os.R_OK``, ``os.W_OK``
347        """
348
349    @abstractmethod
350    def link(self, dst):
351        """Creates a hard link from ``self`` to ``dst``
352
353        :param dst: the destination path
354        """
355
356    @abstractmethod
357    def symlink(self, dst):
358        """Creates a symbolic link from ``self`` to ``dst``
359
360        :param dst: the destination path
361        """
362
363    @abstractmethod
364    def unlink(self):
365        """Deletes a symbolic link"""
366
367    def split(self, *dummy_args, **dummy_kargs):
368        """Splits the path on directory separators, yielding a list of directories, e.g,
369        ``"/var/log/messages"`` will yield ``['var', 'log', 'messages']``.
370        """
371        parts = []
372        path = self
373        while path != path.dirname:
374            parts.append(path.name)
375            path = path.dirname
376        return parts[::-1]
377
378    @property
379    def parts(self):
380        """Splits the directory into parts, including the base directroy, returns a tuple"""
381        return tuple([self.drive + self.root] + self.split())
382
383    def relative_to(self, source):
384        """Computes the "relative path" require to get from ``source`` to ``self``. They satisfy the invariant
385        ``source_path + (target_path - source_path) == target_path``. For example::
386
387            /var/log/messages - /var/log/messages = []
388            /var/log/messages - /var              = [log, messages]
389            /var/log/messages - /                 = [var, log, messages]
390            /var/log/messages - /var/tmp          = [.., log, messages]
391            /var/log/messages - /opt              = [.., var, log, messages]
392            /var/log/messages - /opt/lib          = [.., .., var, log, messages]
393        """
394        if isinstance(source, str):
395            source = self._form(source)
396        parts = self.split()
397        baseparts = source.split()
398        ancestors = len(
399            list(itertools.takewhile(lambda p: p[0] == p[1], zip(parts, baseparts)))
400        )
401        return RelativePath([".."] * (len(baseparts) - ancestors) + parts[ancestors:])
402
403    def __sub__(self, other):
404        """Same as ``self.relative_to(other)``"""
405        return self.relative_to(other)
406
407    def _glob(self, pattern, fn):
408        """Applies a glob string or list/tuple/iterable to the current path, using ``fn``"""
409        if isinstance(pattern, str):
410            return fn(pattern)
411        else:
412            results = []
413            for single_pattern in pattern:
414                results.extend(fn(single_pattern))
415            return sorted(list(set(results)))
416
417    def resolve(self, strict=False):
418        """Added to allow pathlib like syntax. Does nothing since
419        Plumbum paths are always absolute. Does not (currently) resolve
420        symlinks."""
421        # TODO: Resolve symlinks here
422        return self
423
424    @property
425    def parents(self):
426        """Pathlib like sequence of ancestors"""
427        join = lambda x, y: self._form(x) / y
428        as_list = (
429            reduce(join, self.parts[:i], self.parts[0])
430            for i in range(len(self.parts) - 1, 0, -1)
431        )
432        return tuple(as_list)
433
434    @property
435    def parent(self):
436        """Pathlib like parent of the path."""
437        return self.parents[0]
438
439
440class RelativePath(object):
441    """
442    Relative paths are the "delta" required to get from one path to another.
443    Note that relative path do not point at anything, and thus are not paths.
444    Therefore they are system agnostic (but closed under addition)
445    Paths are always absolute and point at "something", whether existent or not.
446
447    Relative paths are created by subtracting paths (``Path.relative_to``)
448    """
449
450    def __init__(self, parts):
451        self.parts = parts
452
453    def __str__(self):
454        return "/".join(self.parts)
455
456    def __iter__(self):
457        return iter(self.parts)
458
459    def __len__(self):
460        return len(self.parts)
461
462    def __getitem__(self, index):
463        return self.parts[index]
464
465    def __repr__(self):
466        return "RelativePath({!r})".format(self.parts)
467
468    def __eq__(self, other):
469        return str(self) == str(other)
470
471    def __ne__(self, other):
472        return not (self == other)
473
474    def __gt__(self, other):
475        return str(self) > str(other)
476
477    def __ge__(self, other):
478        return str(self) >= str(other)
479
480    def __lt__(self, other):
481        return str(self) < str(other)
482
483    def __le__(self, other):
484        return str(self) <= str(other)
485
486    def __hash__(self):
487        return hash(str(self))
488
489    def __nonzero__(self):
490        return bool(str(self))
491
492    __bool__ = __nonzero__
493
494    def up(self, count=1):
495        return RelativePath(self.parts[:-count])
496
497    def __radd__(self, path):
498        return path.join(*self.parts)
499