This commit is contained in:
Waylon Walker 2022-03-31 20:20:07 -05:00
commit 38355d2442
No known key found for this signature in database
GPG key ID: 66E2BF2B4190EFE4
9083 changed files with 1225834 additions and 0 deletions

View file

@ -0,0 +1,43 @@
# encoding: utf-8
"""
The *pathspec* package provides pattern matching for file paths. So far
this only includes Git's wildmatch pattern matching (the style used for
".gitignore" files).
The following classes are imported and made available from the root of
the `pathspec` package:
- :class:`pathspec.pathspec.PathSpec`
- :class:`pathspec.pattern.Pattern`
- :class:`pathspec.pattern.RegexPattern`
- :class:`pathspec.util.RecursionError`
The following functions are also imported:
- :func:`pathspec.util.iter_tree`
- :func:`pathspec.util.lookup_pattern`
- :func:`pathspec.util.match_files`
"""
from __future__ import unicode_literals
from .pathspec import PathSpec
from .pattern import Pattern, RegexPattern
from .util import iter_tree, lookup_pattern, match_files, RecursionError
from ._meta import (
__author__,
__copyright__,
__credits__,
__license__,
__version__,
)
# Load pattern implementations.
from . import patterns
# Expose `GitIgnorePattern` class in the root module for backward
# compatibility with v0.4.
from .patterns.gitwildmatch import GitIgnorePattern

View file

@ -0,0 +1,43 @@
# encoding: utf-8
"""
This module contains the project meta-data.
"""
__author__ = "Caleb P. Burns"
__copyright__ = "Copyright © 2013-2021 Caleb P. Burns"
__credits__ = [
"dahlia <https://github.com/dahlia>",
"highb <https://github.com/highb>",
"029xue <https://github.com/029xue>",
"mikexstudios <https://github.com/mikexstudios>",
"nhumrich <https://github.com/nhumrich>",
"davidfraser <https://github.com/davidfraser>",
"demurgos <https://github.com/demurgos>",
"ghickman <https://github.com/ghickman>",
"nvie <https://github.com/nvie>",
"adrienverge <https://github.com/adrienverge>",
"AndersBlomdell <https://github.com/AndersBlomdell>",
"highb <https://github.com/highb>",
"thmxv <https://github.com/thmxv>",
"wimglenn <https://github.com/wimglenn>",
"hugovk <https://github.com/hugovk>",
"dcecile <https://github.com/dcecile>",
"mroutis <https://github.com/mroutis>",
"jdufresne <https://github.com/jdufresne>",
"groodt <https://github.com/groodt>",
"ftrofin <https://github.com/ftrofin>",
"pykong <https://github.com/pykong>",
"nhhollander <https://github.com/nhhollander>",
"KOLANICH <https://github.com/KOLANICH>",
"JonjonHays <https://github.com/JonjonHays>",
"Isaac0616 <https://github.com/Isaac0616>",
"SebastiaanZ <https://github.com/SebastiaanZ>",
"RoelAdriaans <https://github.com/RoelAdriaans>",
"raviselker <https://github.com/raviselker>",
"johanvergeer <https://github.com/johanvergeer>",
"danjer <https://github.com/danjer>",
"jhbuhrman <https://github.com/jhbuhrman>",
"WPDOrdina <https://github.com/WPDOrdina>",
]
__license__ = "MPL 2.0"
__version__ = "0.9.0"

View file

@ -0,0 +1,41 @@
# encoding: utf-8
"""
This module provides compatibility between Python 2 and 3. Hardly
anything is used by this project to constitute including `six`_.
.. _`six`: http://pythonhosted.org/six
"""
import sys
if sys.version_info[0] < 3:
# Python 2.
unicode = unicode
string_types = (basestring,)
from collections import Iterable
from itertools import izip_longest
def iterkeys(mapping):
return mapping.iterkeys()
else:
# Python 3.
unicode = str
string_types = (unicode,)
from collections.abc import Iterable
from itertools import zip_longest as izip_longest
def iterkeys(mapping):
return mapping.keys()
try:
# Python 3.6+.
from collections.abc import Collection
except ImportError:
# Python 2.7 - 3.5.
from collections import Container as Collection
CollectionType = Collection
IterableType = Iterable

View file

@ -0,0 +1,243 @@
# encoding: utf-8
"""
This module provides an object oriented interface for pattern matching
of files.
"""
try:
from typing import (
Any,
AnyStr,
Callable,
Iterable,
Iterator,
Optional,
Text,
Union)
except ImportError:
pass
try:
# Python 3.6+ type hints.
from os import PathLike
from typing import Collection
except ImportError:
pass
from . import util
from .compat import (
CollectionType,
iterkeys,
izip_longest,
string_types)
from .pattern import Pattern
from .util import TreeEntry
class PathSpec(object):
"""
The :class:`PathSpec` class is a wrapper around a list of compiled
:class:`.Pattern` instances.
"""
def __init__(self, patterns):
# type: (Iterable[Pattern]) -> None
"""
Initializes the :class:`PathSpec` instance.
*patterns* (:class:`~collections.abc.Collection` or :class:`~collections.abc.Iterable`)
yields each compiled pattern (:class:`.Pattern`).
"""
self.patterns = patterns if isinstance(patterns, CollectionType) else list(patterns)
"""
*patterns* (:class:`~collections.abc.Collection` of :class:`.Pattern`)
contains the compiled patterns.
"""
def __eq__(self, other):
# type: (PathSpec) -> bool
"""
Tests the equality of this path-spec with *other* (:class:`PathSpec`)
by comparing their :attr:`~PathSpec.patterns` attributes.
"""
if isinstance(other, PathSpec):
paired_patterns = izip_longest(self.patterns, other.patterns)
return all(a == b for a, b in paired_patterns)
else:
return NotImplemented
def __len__(self):
"""
Returns the number of compiled patterns this path-spec contains
(:class:`int`).
"""
return len(self.patterns)
def __add__(self, other):
# type: (PathSpec) -> PathSpec
"""
Combines the :attr:`Pathspec.patterns` patterns from two
:class:`PathSpec` instances.
"""
if isinstance(other, PathSpec):
return PathSpec(self.patterns + other.patterns)
else:
return NotImplemented
def __iadd__(self, other):
# type: (PathSpec) -> PathSpec
"""
Adds the :attr:`Pathspec.patterns` patterns from one :class:`PathSpec`
instance to this instance.
"""
if isinstance(other, PathSpec):
self.patterns += other.patterns
return self
else:
return NotImplemented
@classmethod
def from_lines(cls, pattern_factory, lines):
# type: (Union[Text, Callable[[AnyStr], Pattern]], Iterable[AnyStr]) -> PathSpec
"""
Compiles the pattern lines.
*pattern_factory* can be either the name of a registered pattern
factory (:class:`str`), or a :class:`~collections.abc.Callable` used
to compile patterns. It must accept an uncompiled pattern (:class:`str`)
and return the compiled pattern (:class:`.Pattern`).
*lines* (:class:`~collections.abc.Iterable`) yields each uncompiled
pattern (:class:`str`). This simply has to yield each line so it can
be a :class:`file` (e.g., from :func:`open` or :class:`io.StringIO`)
or the result from :meth:`str.splitlines`.
Returns the :class:`PathSpec` instance.
"""
if isinstance(pattern_factory, string_types):
pattern_factory = util.lookup_pattern(pattern_factory)
if not callable(pattern_factory):
raise TypeError("pattern_factory:{!r} is not callable.".format(pattern_factory))
if not util._is_iterable(lines):
raise TypeError("lines:{!r} is not an iterable.".format(lines))
patterns = [pattern_factory(line) for line in lines if line]
return cls(patterns)
def match_file(self, file, separators=None):
# type: (Union[Text, PathLike], Optional[Collection[Text]]) -> bool
"""
Matches the file to this path-spec.
*file* (:class:`str` or :class:`~pathlib.PurePath`) is the file path
to be matched against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`)
optionally contains the path separators to normalize. See
:func:`~pathspec.util.normalize_file` for more information.
Returns :data:`True` if *file* matched; otherwise, :data:`False`.
"""
norm_file = util.normalize_file(file, separators=separators)
return util.match_file(self.patterns, norm_file)
def match_entries(self, entries, separators=None):
# type: (Iterable[TreeEntry], Optional[Collection[Text]]) -> Iterator[TreeEntry]
"""
Matches the entries to this path-spec.
*entries* (:class:`~collections.abc.Iterable` of :class:`~util.TreeEntry`)
contains the entries to be matched against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`;
or :data:`None`) optionally contains the path separators to
normalize. See :func:`~pathspec.util.normalize_file` for more
information.
Returns the matched entries (:class:`~collections.abc.Iterator` of
:class:`~util.TreeEntry`).
"""
if not util._is_iterable(entries):
raise TypeError("entries:{!r} is not an iterable.".format(entries))
entry_map = util._normalize_entries(entries, separators=separators)
match_paths = util.match_files(self.patterns, iterkeys(entry_map))
for path in match_paths:
yield entry_map[path]
def match_files(self, files, separators=None):
# type: (Iterable[Union[Text, PathLike]], Optional[Collection[Text]]) -> Iterator[Union[Text, PathLike]]
"""
Matches the files to this path-spec.
*files* (:class:`~collections.abc.Iterable` of :class:`str; or
:class:`pathlib.PurePath`) contains the file paths to be matched
against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`;
or :data:`None`) optionally contains the path separators to
normalize. See :func:`~pathspec.util.normalize_file` for more
information.
Returns the matched files (:class:`~collections.abc.Iterator` of
:class:`str` or :class:`pathlib.PurePath`).
"""
if not util._is_iterable(files):
raise TypeError("files:{!r} is not an iterable.".format(files))
file_map = util.normalize_files(files, separators=separators)
matched_files = util.match_files(self.patterns, iterkeys(file_map))
for norm_file in matched_files:
for orig_file in file_map[norm_file]:
yield orig_file
def match_tree_entries(self, root, on_error=None, follow_links=None):
# type: (Text, Optional[Callable], Optional[bool]) -> Iterator[TreeEntry]
"""
Walks the specified root path for all files and matches them to this
path-spec.
*root* (:class:`str`; or :class:`pathlib.PurePath`) is the root
directory to search.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. See
:func:`~pathspec.util.iter_tree_entries` for more information.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. See
:func:`~pathspec.util.iter_tree_files` for more information.
Returns the matched files (:class:`~collections.abc.Iterator` of
:class:`.TreeEntry`).
"""
entries = util.iter_tree_entries(root, on_error=on_error, follow_links=follow_links)
return self.match_entries(entries)
def match_tree_files(self, root, on_error=None, follow_links=None):
# type: (Text, Optional[Callable], Optional[bool]) -> Iterator[Text]
"""
Walks the specified root path for all files and matches them to this
path-spec.
*root* (:class:`str`; or :class:`pathlib.PurePath`) is the root
directory to search for files.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. See
:func:`~pathspec.util.iter_tree_files` for more information.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. See
:func:`~pathspec.util.iter_tree_files` for more information.
Returns the matched files (:class:`~collections.abc.Iterable` of
:class:`str`).
"""
files = util.iter_tree_files(root, on_error=on_error, follow_links=follow_links)
return self.match_files(files)
# Alias `match_tree_files()` as `match_tree()`.
match_tree = match_tree_files

View file

@ -0,0 +1,164 @@
# encoding: utf-8
"""
This module provides the base definition for patterns.
"""
import re
try:
from typing import (
AnyStr,
Iterable,
Iterator,
Optional,
Pattern as RegexHint,
Text,
Tuple,
Union)
except ImportError:
pass
from .compat import unicode
class Pattern(object):
"""
The :class:`Pattern` class is the abstract definition of a pattern.
"""
# Make the class dict-less.
__slots__ = ('include',)
def __init__(self, include):
# type: (Optional[bool]) -> None
"""
Initializes the :class:`Pattern` instance.
*include* (:class:`bool` or :data:`None`) is whether the matched
files should be included (:data:`True`), excluded (:data:`False`),
or is a null-operation (:data:`None`).
"""
self.include = include
"""
*include* (:class:`bool` or :data:`None`) is whether the matched
files should be included (:data:`True`), excluded (:data:`False`),
or is a null-operation (:data:`None`).
"""
def match(self, files):
# type: (Iterable[Text]) -> Iterator[Text]
"""
Matches this pattern against the specified files.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
each file relative to the root directory (e.g., ``"relative/path/to/file"``).
Returns an :class:`~collections.abc.Iterable` yielding each matched
file path (:class:`str`).
"""
raise NotImplementedError("{}.{} must override match().".format(self.__class__.__module__, self.__class__.__name__))
class RegexPattern(Pattern):
"""
The :class:`RegexPattern` class is an implementation of a pattern
using regular expressions.
"""
# Make the class dict-less.
__slots__ = ('regex',)
def __init__(self, pattern, include=None):
# type: (Union[AnyStr, RegexHint], Optional[bool]) -> None
"""
Initializes the :class:`RegexPattern` instance.
*pattern* (:class:`unicode`, :class:`bytes`, :class:`re.RegexObject`,
or :data:`None`) is the pattern to compile into a regular
expression.
*include* (:class:`bool` or :data:`None`) must be :data:`None`
unless *pattern* is a precompiled regular expression (:class:`re.RegexObject`)
in which case it is whether matched files should be included
(:data:`True`), excluded (:data:`False`), or is a null operation
(:data:`None`).
.. NOTE:: Subclasses do not need to support the *include*
parameter.
"""
self.regex = None
"""
*regex* (:class:`re.RegexObject`) is the regular expression for the
pattern.
"""
if isinstance(pattern, (unicode, bytes)):
assert include is None, "include:{!r} must be null when pattern:{!r} is a string.".format(include, pattern)
regex, include = self.pattern_to_regex(pattern)
# NOTE: Make sure to allow a null regular expression to be
# returned for a null-operation.
if include is not None:
regex = re.compile(regex)
elif pattern is not None and hasattr(pattern, 'match'):
# Assume pattern is a precompiled regular expression.
# - NOTE: Used specified *include*.
regex = pattern
elif pattern is None:
# NOTE: Make sure to allow a null pattern to be passed for a
# null-operation.
assert include is None, "include:{!r} must be null when pattern:{!r} is null.".format(include, pattern)
else:
raise TypeError("pattern:{!r} is not a string, RegexObject, or None.".format(pattern))
super(RegexPattern, self).__init__(include)
self.regex = regex
def __eq__(self, other):
# type: (RegexPattern) -> bool
"""
Tests the equality of this regex pattern with *other* (:class:`RegexPattern`)
by comparing their :attr:`~Pattern.include` and :attr:`~RegexPattern.regex`
attributes.
"""
if isinstance(other, RegexPattern):
return self.include == other.include and self.regex == other.regex
else:
return NotImplemented
def match(self, files):
# type: (Iterable[Text]) -> Iterable[Text]
"""
Matches this pattern against the specified files.
*files* (:class:`~collections.abc.Iterable` of :class:`str`)
contains each file relative to the root directory (e.g., "relative/path/to/file").
Returns an :class:`~collections.abc.Iterable` yielding each matched
file path (:class:`str`).
"""
if self.include is not None:
for path in files:
if self.regex.match(path) is not None:
yield path
@classmethod
def pattern_to_regex(cls, pattern):
# type: (Text) -> Tuple[Text, bool]
"""
Convert the pattern into an uncompiled regular expression.
*pattern* (:class:`str`) is the pattern to convert into a regular
expression.
Returns the uncompiled regular expression (:class:`str` or :data:`None`),
and whether matched files should be included (:data:`True`),
excluded (:data:`False`), or is a null-operation (:data:`None`).
.. NOTE:: The default implementation simply returns *pattern* and
:data:`True`.
"""
return pattern, True

View file

@ -0,0 +1,8 @@
# encoding: utf-8
"""
The *pathspec.patterns* package contains the pattern matching
implementations.
"""
# Load pattern implementations.
from .gitwildmatch import GitWildMatchPattern

View file

@ -0,0 +1,400 @@
# encoding: utf-8
"""
This module implements Git's wildmatch pattern matching which itself is
derived from Rsync's wildmatch. Git uses wildmatch for its ".gitignore"
files.
"""
from __future__ import unicode_literals
import re
import warnings
try:
from typing import (
AnyStr,
Optional,
Text,
Tuple)
except ImportError:
pass
from .. import util
from ..compat import unicode
from ..pattern import RegexPattern
#: The encoding to use when parsing a byte string pattern.
_BYTES_ENCODING = 'latin1'
class GitWildMatchPatternError(ValueError):
"""
The :class:`GitWildMatchPatternError` indicates an invalid git wild match
pattern.
"""
pass
class GitWildMatchPattern(RegexPattern):
"""
The :class:`GitWildMatchPattern` class represents a compiled Git
wildmatch pattern.
"""
# Keep the dict-less class hierarchy.
__slots__ = ()
@classmethod
def pattern_to_regex(cls, pattern):
# type: (AnyStr) -> Tuple[Optional[AnyStr], Optional[bool]]
"""
Convert the pattern into a regular expression.
*pattern* (:class:`unicode` or :class:`bytes`) is the pattern to
convert into a regular expression.
Returns the uncompiled regular expression (:class:`unicode`, :class:`bytes`,
or :data:`None`), and whether matched files should be included
(:data:`True`), excluded (:data:`False`), or if it is a
null-operation (:data:`None`).
"""
if isinstance(pattern, unicode):
return_type = unicode
elif isinstance(pattern, bytes):
return_type = bytes
pattern = pattern.decode(_BYTES_ENCODING)
else:
raise TypeError("pattern:{!r} is not a unicode or byte string.".format(pattern))
original_pattern = pattern
pattern = pattern.strip()
if pattern.startswith('#'):
# A pattern starting with a hash ('#') serves as a comment
# (neither includes nor excludes files). Escape the hash with a
# back-slash to match a literal hash (i.e., '\#').
regex = None
include = None
elif pattern == '/':
# EDGE CASE: According to `git check-ignore` (v2.4.1), a single
# '/' does not match any file.
regex = None
include = None
elif pattern:
if pattern.startswith('!'):
# A pattern starting with an exclamation mark ('!') negates the
# pattern (exclude instead of include). Escape the exclamation
# mark with a back-slash to match a literal exclamation mark
# (i.e., '\!').
include = False
# Remove leading exclamation mark.
pattern = pattern[1:]
else:
include = True
if pattern.startswith('\\'):
# Remove leading back-slash escape for escaped hash ('#') or
# exclamation mark ('!').
pattern = pattern[1:]
# Allow a regex override for edge cases that cannot be handled
# through normalization.
override_regex = None
# Split pattern into segments.
pattern_segs = pattern.split('/')
# Normalize pattern to make processing easier.
# EDGE CASE: Deal with duplicate double-asterisk sequences.
# Collapse each sequence down to one double-asterisk. Iterate over
# the segments in reverse and remove the duplicate double
# asterisks as we go.
for i in range(len(pattern_segs) - 1, 0, -1):
prev = pattern_segs[i-1]
seg = pattern_segs[i]
if prev == '**' and seg == '**':
del pattern_segs[i]
if len(pattern_segs) == 2 and pattern_segs[0] == '**' and not pattern_segs[1]:
# EDGE CASE: The '**/' pattern should match everything except
# individual files in the root directory. This case cannot be
# adequately handled through normalization. Use the override.
override_regex = '^.+/.*$'
if not pattern_segs[0]:
# A pattern beginning with a slash ('/') will only match paths
# directly on the root directory instead of any descendant
# paths. So, remove empty first segment to make pattern relative
# to root.
del pattern_segs[0]
elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]):
# A single pattern without a beginning slash ('/') will match
# any descendant path. This is equivalent to "**/{pattern}". So,
# prepend with double-asterisks to make pattern relative to
# root.
# EDGE CASE: This also holds for a single pattern with a
# trailing slash (e.g. dir/).
if pattern_segs[0] != '**':
pattern_segs.insert(0, '**')
else:
# EDGE CASE: A pattern without a beginning slash ('/') but
# contains at least one prepended directory (e.g.
# "dir/{pattern}") should not match "**/dir/{pattern}",
# according to `git check-ignore` (v2.4.1).
pass
if not pattern_segs:
# After resolving the edge cases, we end up with no
# pattern at all. This must be because the pattern is
# invalid.
raise GitWildMatchPatternError("Invalid git pattern: %r" % (original_pattern,))
if not pattern_segs[-1] and len(pattern_segs) > 1:
# A pattern ending with a slash ('/') will match all
# descendant paths if it is a directory but not if it is a
# regular file. This is equivalent to "{pattern}/**". So, set
# last segment to a double-asterisk to include all
# descendants.
pattern_segs[-1] = '**'
if override_regex is None:
# Build regular expression from pattern.
output = ['^']
need_slash = False
end = len(pattern_segs) - 1
for i, seg in enumerate(pattern_segs):
if seg == '**':
if i == 0 and i == end:
# A pattern consisting solely of double-asterisks ('**')
# will match every path.
output.append('.+')
elif i == 0:
# A normalized pattern beginning with double-asterisks
# ('**') will match any leading path segments.
output.append('(?:.+/)?')
need_slash = False
elif i == end:
# A normalized pattern ending with double-asterisks ('**')
# will match any trailing path segments.
output.append('/.*')
else:
# A pattern with inner double-asterisks ('**') will match
# multiple (or zero) inner path segments.
output.append('(?:/.+)?')
need_slash = True
elif seg == '*':
# Match single path segment.
if need_slash:
output.append('/')
output.append('[^/]+')
need_slash = True
else:
# Match segment glob pattern.
if need_slash:
output.append('/')
output.append(cls._translate_segment_glob(seg))
if i == end and include is True:
# A pattern ending without a slash ('/') will match a file
# or a directory (with paths underneath it). E.g., "foo"
# matches "foo", "foo/bar", "foo/bar/baz", etc.
# EDGE CASE: However, this does not hold for exclusion cases
# according to `git check-ignore` (v2.4.1).
output.append('(?:/.*)?')
need_slash = True
output.append('$')
regex = ''.join(output)
else:
# Use regex override.
regex = override_regex
else:
# A blank pattern is a null-operation (neither includes nor
# excludes files).
regex = None
include = None
if regex is not None and return_type is bytes:
regex = regex.encode(_BYTES_ENCODING)
return regex, include
@staticmethod
def _translate_segment_glob(pattern):
# type: (Text) -> Text
"""
Translates the glob pattern to a regular expression. This is used in
the constructor to translate a path segment glob pattern to its
corresponding regular expression.
*pattern* (:class:`str`) is the glob pattern.
Returns the regular expression (:class:`str`).
"""
# NOTE: This is derived from `fnmatch.translate()` and is similar to
# the POSIX function `fnmatch()` with the `FNM_PATHNAME` flag set.
escape = False
regex = ''
i, end = 0, len(pattern)
while i < end:
# Get next character.
char = pattern[i]
i += 1
if escape:
# Escape the character.
escape = False
regex += re.escape(char)
elif char == '\\':
# Escape character, escape next character.
escape = True
elif char == '*':
# Multi-character wildcard. Match any string (except slashes),
# including an empty string.
regex += '[^/]*'
elif char == '?':
# Single-character wildcard. Match any single character (except
# a slash).
regex += '[^/]'
elif char == '[':
# Bracket expression wildcard. Except for the beginning
# exclamation mark, the whole bracket expression can be used
# directly as regex but we have to find where the expression
# ends.
# - "[][!]" matches ']', '[' and '!'.
# - "[]-]" matches ']' and '-'.
# - "[!]a-]" matches any character except ']', 'a' and '-'.
j = i
# Pass brack expression negation.
if j < end and pattern[j] == '!':
j += 1
# Pass first closing bracket if it is at the beginning of the
# expression.
if j < end and pattern[j] == ']':
j += 1
# Find closing bracket. Stop once we reach the end or find it.
while j < end and pattern[j] != ']':
j += 1
if j < end:
# Found end of bracket expression. Increment j to be one past
# the closing bracket:
#
# [...]
# ^ ^
# i j
#
j += 1
expr = '['
if pattern[i] == '!':
# Braket expression needs to be negated.
expr += '^'
i += 1
elif pattern[i] == '^':
# POSIX declares that the regex bracket expression negation
# "[^...]" is undefined in a glob pattern. Python's
# `fnmatch.translate()` escapes the caret ('^') as a
# literal. To maintain consistency with undefined behavior,
# I am escaping the '^' as well.
expr += '\\^'
i += 1
# Build regex bracket expression. Escape slashes so they are
# treated as literal slashes by regex as defined by POSIX.
expr += pattern[i:j].replace('\\', '\\\\')
# Add regex bracket expression to regex result.
regex += expr
# Set i to one past the closing bracket.
i = j
else:
# Failed to find closing bracket, treat opening bracket as a
# bracket literal instead of as an expression.
regex += '\\['
else:
# Regular character, escape it for regex.
regex += re.escape(char)
return regex
@staticmethod
def escape(s):
# type: (AnyStr) -> AnyStr
"""
Escape special characters in the given string.
*s* (:class:`unicode` or :class:`bytes`) a filename or a string
that you want to escape, usually before adding it to a `.gitignore`
Returns the escaped string (:class:`unicode` or :class:`bytes`)
"""
if isinstance(s, unicode):
return_type = unicode
string = s
elif isinstance(s, bytes):
return_type = bytes
string = s.decode(_BYTES_ENCODING)
else:
raise TypeError("s:{!r} is not a unicode or byte string.".format(s))
# Reference: https://git-scm.com/docs/gitignore#_pattern_format
meta_characters = r"[]!*#?"
out_string = "".join("\\" + x if x in meta_characters else x for x in string)
if return_type is bytes:
return out_string.encode(_BYTES_ENCODING)
else:
return out_string
util.register_pattern('gitwildmatch', GitWildMatchPattern)
class GitIgnorePattern(GitWildMatchPattern):
"""
The :class:`GitIgnorePattern` class is deprecated by :class:`GitWildMatchPattern`.
This class only exists to maintain compatibility with v0.4.
"""
def __init__(self, *args, **kw):
"""
Warn about deprecation.
"""
self._deprecated()
super(GitIgnorePattern, self).__init__(*args, **kw)
@staticmethod
def _deprecated():
"""
Warn about deprecation.
"""
warnings.warn("GitIgnorePattern ('gitignore') is deprecated. Use GitWildMatchPattern ('gitwildmatch') instead.", DeprecationWarning, stacklevel=3)
@classmethod
def pattern_to_regex(cls, *args, **kw):
"""
Warn about deprecation.
"""
cls._deprecated()
return super(GitIgnorePattern, cls).pattern_to_regex(*args, **kw)
# Register `GitIgnorePattern` as "gitignore" for backward compatibility
# with v0.4.
util.register_pattern('gitignore', GitIgnorePattern)

View file

@ -0,0 +1,548 @@
# encoding: utf-8
"""
This script tests ``GitWildMatchPattern``.
"""
from __future__ import unicode_literals
import re
import sys
import unittest
import pathspec.patterns.gitwildmatch
import pathspec.util
from pathspec.patterns.gitwildmatch import GitWildMatchPattern, GitWildMatchPatternError
if sys.version_info[0] >= 3:
unichr = chr
class GitWildMatchTest(unittest.TestCase):
"""
The ``GitWildMatchTest`` class tests the ``GitWildMatchPattern``
implementation.
"""
def test_00_empty(self):
"""
Tests an empty pattern.
"""
regex, include = GitWildMatchPattern.pattern_to_regex('')
self.assertIsNone(include)
self.assertIsNone(regex)
def test_01_absolute(self):
"""
Tests an absolute path pattern.
This should match:
an/absolute/file/path
an/absolute/file/path/foo
This should NOT match:
foo/an/absolute/file/path
"""
regex, include = GitWildMatchPattern.pattern_to_regex('/an/absolute/file/path')
self.assertTrue(include)
self.assertEqual(regex, '^an/absolute/file/path(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'an/absolute/file/path',
'an/absolute/file/path/foo',
'foo/an/absolute/file/path',
]))
self.assertEqual(results, {
'an/absolute/file/path',
'an/absolute/file/path/foo',
})
def test_01_absolute_root(self):
"""
Tests a single root absolute path pattern.
This should NOT match any file (according to git check-ignore
(v2.4.1)).
"""
regex, include = GitWildMatchPattern.pattern_to_regex('/')
self.assertIsNone(include)
self.assertIsNone(regex)
def test_01_relative(self):
"""
Tests a relative path pattern.
This should match:
spam
spam/
foo/spam
spam/foo
foo/spam/bar
"""
regex, include = GitWildMatchPattern.pattern_to_regex('spam')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?spam(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'spam',
'spam/',
'foo/spam',
'spam/foo',
'foo/spam/bar',
]))
self.assertEqual(results, {
'spam',
'spam/',
'foo/spam',
'spam/foo',
'foo/spam/bar',
})
def test_01_relative_nested(self):
"""
Tests a relative nested path pattern.
This should match:
foo/spam
foo/spam/bar
This should **not** match (according to git check-ignore (v2.4.1)):
bar/foo/spam
"""
regex, include = GitWildMatchPattern.pattern_to_regex('foo/spam')
self.assertTrue(include)
self.assertEqual(regex, '^foo/spam(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'foo/spam',
'foo/spam/bar',
'bar/foo/spam',
]))
self.assertEqual(results, {
'foo/spam',
'foo/spam/bar',
})
def test_02_comment(self):
"""
Tests a comment pattern.
"""
regex, include = GitWildMatchPattern.pattern_to_regex('# Cork soakers.')
self.assertIsNone(include)
self.assertIsNone(regex)
def test_02_ignore(self):
"""
Tests an exclude pattern.
This should NOT match (according to git check-ignore (v2.4.1)):
temp/foo
"""
regex, include = GitWildMatchPattern.pattern_to_regex('!temp')
self.assertIsNotNone(include)
self.assertFalse(include)
self.assertEqual(regex, '^(?:.+/)?temp$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match(['temp/foo']))
self.assertEqual(results, set())
def test_03_child_double_asterisk(self):
"""
Tests a directory name with a double-asterisk child
directory.
This should match:
spam/bar
This should **not** match (according to git check-ignore (v2.4.1)):
foo/spam/bar
"""
regex, include = GitWildMatchPattern.pattern_to_regex('spam/**')
self.assertTrue(include)
self.assertEqual(regex, '^spam/.*$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'spam/bar',
'foo/spam/bar',
]))
self.assertEqual(results, {'spam/bar'})
def test_03_inner_double_asterisk(self):
"""
Tests a path with an inner double-asterisk directory.
This should match:
left/right
left/bar/right
left/foo/bar/right
left/bar/right/foo
This should **not** match (according to git check-ignore (v2.4.1)):
foo/left/bar/right
"""
regex, include = GitWildMatchPattern.pattern_to_regex('left/**/right')
self.assertTrue(include)
self.assertEqual(regex, '^left(?:/.+)?/right(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'left/right',
'left/bar/right',
'left/foo/bar/right',
'left/bar/right/foo',
'foo/left/bar/right',
]))
self.assertEqual(results, {
'left/right',
'left/bar/right',
'left/foo/bar/right',
'left/bar/right/foo',
})
def test_03_only_double_asterisk(self):
"""
Tests a double-asterisk pattern which matches everything.
"""
regex, include = GitWildMatchPattern.pattern_to_regex('**')
self.assertTrue(include)
self.assertEqual(regex, '^.+$')
def test_03_parent_double_asterisk(self):
"""
Tests a file name with a double-asterisk parent directory.
This should match:
spam
foo/spam
foo/spam/bar
"""
regex, include = GitWildMatchPattern.pattern_to_regex('**/spam')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?spam(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'spam',
'foo/spam',
'foo/spam/bar',
]))
self.assertEqual(results, {
'spam',
'foo/spam',
'foo/spam/bar',
})
def test_03_duplicate_leading_double_asterisk_edge_case(self):
"""
Regression test for duplicate leading **/ bug.
"""
regex, include = GitWildMatchPattern.pattern_to_regex('**')
self.assertTrue(include)
self.assertEqual(regex, '^.+$')
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/**')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/**/**')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
regex, include = GitWildMatchPattern.pattern_to_regex('**/api')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?api(?:/.*)?$')
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/**/api')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
regex, include = GitWildMatchPattern.pattern_to_regex('**/api/')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?api/.*$')
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/api/**')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/**/api/**/**')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
def test_03_double_asterisk_trailing_slash_edge_case(self):
"""
Tests the edge-case **/ pattern.
This should match everything except individual files in the root directory.
"""
regex, include = GitWildMatchPattern.pattern_to_regex('**/')
self.assertTrue(include)
self.assertEqual(regex, '^.+/.*$')
equivalent_regex, include = GitWildMatchPattern.pattern_to_regex('**/**/')
self.assertTrue(include)
self.assertEqual(equivalent_regex, regex)
def test_04_infix_wildcard(self):
"""
Tests a pattern with an infix wildcard.
This should match:
foo--bar
foo-hello-bar
a/foo-hello-bar
foo-hello-bar/b
a/foo-hello-bar/b
"""
regex, include = GitWildMatchPattern.pattern_to_regex('foo-*-bar')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?foo\\-[^/]*\\-bar(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'foo--bar',
'foo-hello-bar',
'a/foo-hello-bar',
'foo-hello-bar/b',
'a/foo-hello-bar/b',
]))
self.assertEqual(results, {
'foo--bar',
'foo-hello-bar',
'a/foo-hello-bar',
'foo-hello-bar/b',
'a/foo-hello-bar/b',
})
def test_04_postfix_wildcard(self):
"""
Tests a pattern with a postfix wildcard.
This should match:
~temp-
~temp-foo
~temp-foo/bar
foo/~temp-bar
foo/~temp-bar/baz
"""
regex, include = GitWildMatchPattern.pattern_to_regex('~temp-*')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?\\~temp\\-[^/]*(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'~temp-',
'~temp-foo',
'~temp-foo/bar',
'foo/~temp-bar',
'foo/~temp-bar/baz',
]))
self.assertEqual(results, {
'~temp-',
'~temp-foo',
'~temp-foo/bar',
'foo/~temp-bar',
'foo/~temp-bar/baz',
})
def test_04_prefix_wildcard(self):
"""
Tests a pattern with a prefix wildcard.
This should match:
bar.py
bar.py/
foo/bar.py
foo/bar.py/baz
"""
regex, include = GitWildMatchPattern.pattern_to_regex('*.py')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?[^/]*\\.py(?:/.*)?$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'bar.py',
'bar.py/',
'foo/bar.py',
'foo/bar.py/baz',
]))
self.assertEqual(results, {
'bar.py',
'bar.py/',
'foo/bar.py',
'foo/bar.py/baz',
})
def test_05_directory(self):
"""
Tests a directory pattern.
This should match:
dir/
foo/dir/
foo/dir/bar
This should **not** match:
dir
"""
regex, include = GitWildMatchPattern.pattern_to_regex('dir/')
self.assertTrue(include)
self.assertEqual(regex, '^(?:.+/)?dir/.*$')
pattern = GitWildMatchPattern(re.compile(regex), include)
results = set(pattern.match([
'dir/',
'foo/dir/',
'foo/dir/bar',
'dir',
]))
self.assertEqual(results, {
'dir/',
'foo/dir/',
'foo/dir/bar',
})
def test_06_registered(self):
"""
Tests that the pattern is registered.
"""
self.assertIs(pathspec.util.lookup_pattern('gitwildmatch'), GitWildMatchPattern)
def test_06_access_deprecated(self):
"""
Tests that the pattern is accessible from the root module using the
deprecated alias.
"""
self.assertTrue(hasattr(pathspec, 'GitIgnorePattern'))
self.assertTrue(issubclass(pathspec.GitIgnorePattern, GitWildMatchPattern))
def test_06_registered_deprecated(self):
"""
Tests that the pattern is registered under the deprecated alias.
"""
self.assertIs(pathspec.util.lookup_pattern('gitignore'), pathspec.GitIgnorePattern)
def test_07_encode_bytes(self):
"""
Test encoding bytes.
"""
encoded = "".join(map(unichr, range(0,256))).encode(pathspec.patterns.gitwildmatch._BYTES_ENCODING)
expected = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
self.assertEqual(encoded, expected)
def test_07_decode_bytes(self):
"""
Test decoding bytes.
"""
decoded = bytes(bytearray(range(0,256))).decode(pathspec.patterns.gitwildmatch._BYTES_ENCODING)
expected = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
self.assertEqual(decoded, expected)
def test_07_match_bytes_and_bytes(self):
"""
Test byte string patterns matching byte string paths.
"""
pattern = GitWildMatchPattern(b'*.py')
results = set(pattern.match([b'a.py']))
self.assertEqual(results, {b'a.py'})
def test_07_match_bytes_and_bytes_complete(self):
"""
Test byte string patterns matching byte string paths.
"""
encoded = bytes(bytearray(range(0,256)))
escaped = b"".join(b"\\" + encoded[i:i+1] for i in range(len(encoded)))
pattern = GitWildMatchPattern(escaped)
results = set(pattern.match([encoded]))
self.assertEqual(results, {encoded})
@unittest.skipIf(sys.version_info[0] >= 3, "Python 3 is strict")
def test_07_match_bytes_and_unicode(self):
"""
Test byte string patterns matching byte string paths.
"""
pattern = GitWildMatchPattern(b'*.py')
results = set(pattern.match(['a.py']))
self.assertEqual(results, {'a.py'})
@unittest.skipIf(sys.version_info[0] == 2, "Python 2 is lenient")
def test_07_match_bytes_and_unicode_fail(self):
"""
Test byte string patterns matching byte string paths.
"""
pattern = GitWildMatchPattern(b'*.py')
with self.assertRaises(TypeError):
for _ in pattern.match(['a.py']):
pass
@unittest.skipIf(sys.version_info[0] >= 3, "Python 3 is strict")
def test_07_match_unicode_and_bytes(self):
"""
Test unicode patterns with byte paths.
"""
pattern = GitWildMatchPattern('*.py')
results = set(pattern.match([b'a.py']))
self.assertEqual(results, {b'a.py'})
@unittest.skipIf(sys.version_info[0] == 2, "Python 2 is lenient")
def test_07_match_unicode_and_bytes_fail(self):
"""
Test unicode patterns with byte paths.
"""
pattern = GitWildMatchPattern('*.py')
with self.assertRaises(TypeError):
for _ in pattern.match([b'a.py']):
pass
def test_07_match_unicode_and_unicode(self):
"""
Test unicode patterns with unicode paths.
"""
pattern = GitWildMatchPattern('*.py')
results = set(pattern.match(['a.py']))
self.assertEqual(results, {'a.py'})
def test_08_escape(self):
"""
Test escaping a string with meta-characters
"""
fname = "file!with*weird#naming_[1].t?t"
escaped = r"file\!with\*weird\#naming_\[1\].t\?t"
result = GitWildMatchPattern.escape(fname)
self.assertEqual(result, escaped)
def test_09_single_escape_fail(self):
"""
Test an escape on a line by itself.
"""
self._check_invalid_pattern("\\")
def test_09_single_exclamation_mark_fail(self):
"""
Test an escape on a line by itself.
"""
self._check_invalid_pattern("!")
def _check_invalid_pattern(self, git_ignore_pattern):
expected_message_pattern = re.escape(repr(git_ignore_pattern))
with self.assertRaisesRegexp(GitWildMatchPatternError, expected_message_pattern):
GitWildMatchPattern(git_ignore_pattern)

View file

@ -0,0 +1,226 @@
# encoding: utf-8
"""
This script tests ``PathSpec``.
"""
import unittest
import pathspec
class PathSpecTest(unittest.TestCase):
"""
The ``PathSpecTest`` class tests the ``PathSpec`` class.
"""
def test_01_absolute_dir_paths_1(self):
"""
Tests that absolute paths will be properly normalized and matched.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'foo',
])
results = set(spec.match_files([
'/a.py',
'/foo/a.py',
'/x/a.py',
'/x/foo/a.py',
'a.py',
'foo/a.py',
'x/a.py',
'x/foo/a.py',
]))
self.assertEqual(results, {
'/foo/a.py',
'/x/foo/a.py',
'foo/a.py',
'x/foo/a.py',
})
def test_01_absolute_dir_paths_2(self):
"""
Tests that absolute paths will be properly normalized and matched.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'/foo',
])
results = set(spec.match_files([
'/a.py',
'/foo/a.py',
'/x/a.py',
'/x/foo/a.py',
'a.py',
'foo/a.py',
'x/a.py',
'x/foo/a.py',
]))
self.assertEqual(results, {
'/foo/a.py',
'foo/a.py',
})
def test_01_current_dir_paths(self):
"""
Tests that paths referencing the current directory will be properly
normalized and matched.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
results = set(spec.match_files([
'./src/test1/a.txt',
'./src/test1/b.txt',
'./src/test1/c/c.txt',
'./src/test2/a.txt',
'./src/test2/b.txt',
'./src/test2/c/c.txt',
]))
self.assertEqual(results, {
'./src/test2/a.txt',
'./src/test2/b.txt',
'./src/test2/c/c.txt',
})
def test_01_match_files(self):
"""
Tests that matching files one at a time yields the same results as
matching multiples files at once.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
test_files = [
'src/test1/a.txt',
'src/test1/b.txt',
'src/test1/c/c.txt',
'src/test2/a.txt',
'src/test2/b.txt',
'src/test2/c/c.txt',
]
single_results = set(filter(spec.match_file, test_files))
multi_results = set(spec.match_files(test_files))
self.assertEqual(single_results, multi_results)
def test_01_windows_current_dir_paths(self):
"""
Tests that paths referencing the current directory will be properly
normalized and matched.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
results = set(spec.match_files([
'.\\src\\test1\\a.txt',
'.\\src\\test1\\b.txt',
'.\\src\\test1\\c\\c.txt',
'.\\src\\test2\\a.txt',
'.\\src\\test2\\b.txt',
'.\\src\\test2\\c\\c.txt',
], separators=('\\',)))
self.assertEqual(results, {
'.\\src\\test2\\a.txt',
'.\\src\\test2\\b.txt',
'.\\src\\test2\\c\\c.txt',
})
def test_01_windows_paths(self):
"""
Tests that Windows paths will be properly normalized and matched.
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
results = set(spec.match_files([
'src\\test1\\a.txt',
'src\\test1\\b.txt',
'src\\test1\\c\\c.txt',
'src\\test2\\a.txt',
'src\\test2\\b.txt',
'src\\test2\\c\\c.txt',
], separators=('\\',)))
self.assertEqual(results, {
'src\\test2\\a.txt',
'src\\test2\\b.txt',
'src\\test2\\c\\c.txt',
})
def test_02_eq(self):
"""
Tests equality.
"""
first_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
second_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
'!test1/',
])
self.assertEqual(first_spec, second_spec)
def test_02_ne(self):
"""
Tests equality.
"""
first_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'*.txt',
])
second_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'!*.txt',
])
self.assertNotEqual(first_spec, second_spec)
def test_01_addition(self):
"""
Test pattern addition using + operator
"""
first_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'test.txt',
'test.png'
])
second_spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'test.html',
'test.jpg'
])
combined_spec = first_spec + second_spec
results = set(combined_spec.match_files([
'test.txt',
'test.png',
'test.html',
'test.jpg'
], separators=('\\',)))
self.assertEqual(results, {
'test.txt',
'test.png',
'test.html',
'test.jpg'
})
def test_02_addition(self):
"""
Test pattern addition using += operator
"""
spec = pathspec.PathSpec.from_lines('gitwildmatch', [
'test.txt',
'test.png'
])
spec += pathspec.PathSpec.from_lines('gitwildmatch', [
'test.html',
'test.jpg'
])
results = set(spec.match_files([
'test.txt',
'test.png',
'test.html',
'test.jpg'
], separators=('\\',)))
self.assertEqual(results, {
'test.txt',
'test.png',
'test.html',
'test.jpg'
})

View file

@ -0,0 +1,380 @@
# encoding: utf-8
"""
This script tests utility functions.
"""
import errno
import os
import os.path
import shutil
import sys
import tempfile
import unittest
from pathspec.util import iter_tree_entries, iter_tree_files, RecursionError, normalize_file
class IterTreeTest(unittest.TestCase):
"""
The ``IterTreeTest`` class tests `pathspec.util.iter_tree_files()`.
"""
def make_dirs(self, dirs):
"""
Create the specified directories.
"""
for dir in dirs:
os.mkdir(os.path.join(self.temp_dir, self.ospath(dir)))
def make_files(self, files):
"""
Create the specified files.
"""
for file in files:
self.mkfile(os.path.join(self.temp_dir, self.ospath(file)))
def make_links(self, links):
"""
Create the specified links.
"""
for link, node in links:
os.symlink(os.path.join(self.temp_dir, self.ospath(node)), os.path.join(self.temp_dir, self.ospath(link)))
@staticmethod
def mkfile(file):
"""
Creates an empty file.
"""
with open(file, 'wb'):
pass
@staticmethod
def ospath(path):
"""
Convert the POSIX path to a native OS path.
"""
return os.path.join(*path.split('/'))
def require_realpath(self):
"""
Skips the test if `os.path.realpath` does not properly support
symlinks.
"""
if self.broken_realpath:
raise unittest.SkipTest("`os.path.realpath` is broken.")
def require_symlink(self):
"""
Skips the test if `os.symlink` is not supported.
"""
if self.no_symlink:
raise unittest.SkipTest("`os.symlink` is not supported.")
def setUp(self):
"""
Called before each test.
"""
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""
Called after each test.
"""
shutil.rmtree(self.temp_dir)
def test_1_files(self):
"""
Tests to make sure all files are found.
"""
self.make_dirs([
'Empty',
'Dir',
'Dir/Inner',
])
self.make_files([
'a',
'b',
'Dir/c',
'Dir/d',
'Dir/Inner/e',
'Dir/Inner/f',
])
results = set(iter_tree_files(self.temp_dir))
self.assertEqual(results, set(map(self.ospath, [
'a',
'b',
'Dir/c',
'Dir/d',
'Dir/Inner/e',
'Dir/Inner/f',
])))
def test_2_0_check_symlink(self):
"""
Tests whether links can be created.
"""
# NOTE: Windows does not support `os.symlink` for Python 2. Windows
# Vista and greater supports `os.symlink` for Python 3.2+.
no_symlink = None
try:
file = os.path.join(self.temp_dir, 'file')
link = os.path.join(self.temp_dir, 'link')
self.mkfile(file)
try:
os.symlink(file, link)
except (AttributeError, NotImplementedError):
no_symlink = True
raise
no_symlink = False
finally:
self.__class__.no_symlink = no_symlink
def test_2_1_check_realpath(self):
"""
Tests whether `os.path.realpath` works properly with symlinks.
"""
# NOTE: Windows does not follow symlinks with `os.path.realpath`
# which is what we use to detect recursion. See <https://bugs.python.org/issue9949>
# for details.
broken_realpath = None
try:
self.require_symlink()
file = os.path.join(self.temp_dir, 'file')
link = os.path.join(self.temp_dir, 'link')
self.mkfile(file)
os.symlink(file, link)
try:
self.assertEqual(os.path.realpath(file), os.path.realpath(link))
except AssertionError:
broken_realpath = True
raise
broken_realpath = False
finally:
self.__class__.broken_realpath = broken_realpath
def test_2_2_links(self):
"""
Tests to make sure links to directories and files work.
"""
self.require_symlink()
self.make_dirs([
'Dir',
])
self.make_files([
'a',
'b',
'Dir/c',
'Dir/d',
])
self.make_links([
('ax', 'a'),
('bx', 'b'),
('Dir/cx', 'Dir/c'),
('Dir/dx', 'Dir/d'),
('DirX', 'Dir'),
])
results = set(iter_tree_files(self.temp_dir))
self.assertEqual(results, set(map(self.ospath, [
'a',
'ax',
'b',
'bx',
'Dir/c',
'Dir/cx',
'Dir/d',
'Dir/dx',
'DirX/c',
'DirX/cx',
'DirX/d',
'DirX/dx',
])))
def test_2_3_sideways_links(self):
"""
Tests to make sure the same directory can be encountered multiple
times via links.
"""
self.require_symlink()
self.make_dirs([
'Dir',
'Dir/Target',
])
self.make_files([
'Dir/Target/file',
])
self.make_links([
('Ax', 'Dir'),
('Bx', 'Dir'),
('Cx', 'Dir/Target'),
('Dx', 'Dir/Target'),
('Dir/Ex', 'Dir/Target'),
('Dir/Fx', 'Dir/Target'),
])
results = set(iter_tree_files(self.temp_dir))
self.assertEqual(results, set(map(self.ospath, [
'Ax/Ex/file',
'Ax/Fx/file',
'Ax/Target/file',
'Bx/Ex/file',
'Bx/Fx/file',
'Bx/Target/file',
'Cx/file',
'Dx/file',
'Dir/Ex/file',
'Dir/Fx/file',
'Dir/Target/file',
])))
def test_2_4_recursive_links(self):
"""
Tests detection of recursive links.
"""
self.require_symlink()
self.require_realpath()
self.make_dirs([
'Dir',
])
self.make_files([
'Dir/file',
])
self.make_links([
('Dir/Self', 'Dir'),
])
with self.assertRaises(RecursionError) as context:
set(iter_tree_files(self.temp_dir))
self.assertEqual(context.exception.first_path, 'Dir')
self.assertEqual(context.exception.second_path, self.ospath('Dir/Self'))
def test_2_5_recursive_circular_links(self):
"""
Tests detection of recursion through circular links.
"""
self.require_symlink()
self.require_realpath()
self.make_dirs([
'A',
'B',
'C',
])
self.make_files([
'A/d',
'B/e',
'C/f',
])
self.make_links([
('A/Bx', 'B'),
('B/Cx', 'C'),
('C/Ax', 'A'),
])
with self.assertRaises(RecursionError) as context:
set(iter_tree_files(self.temp_dir))
self.assertIn(context.exception.first_path, ('A', 'B', 'C'))
self.assertEqual(context.exception.second_path, {
'A': self.ospath('A/Bx/Cx/Ax'),
'B': self.ospath('B/Cx/Ax/Bx'),
'C': self.ospath('C/Ax/Bx/Cx'),
}[context.exception.first_path])
def test_2_6_detect_broken_links(self):
"""
Tests that broken links are detected.
"""
def reraise(e):
raise e
self.require_symlink()
self.make_links([
('A', 'DOES_NOT_EXIST'),
])
with self.assertRaises(OSError) as context:
set(iter_tree_files(self.temp_dir, on_error=reraise))
self.assertEqual(context.exception.errno, errno.ENOENT)
def test_2_7_ignore_broken_links(self):
"""
Tests that broken links are ignored.
"""
self.require_symlink()
self.make_links([
('A', 'DOES_NOT_EXIST'),
])
results = set(iter_tree_files(self.temp_dir))
self.assertEqual(results, set())
def test_2_8_no_follow_links(self):
"""
Tests to make sure directory links can be ignored.
"""
self.require_symlink()
self.make_dirs([
'Dir',
])
self.make_files([
'A',
'B',
'Dir/C',
'Dir/D',
])
self.make_links([
('Ax', 'A'),
('Bx', 'B'),
('Dir/Cx', 'Dir/C'),
('Dir/Dx', 'Dir/D'),
('DirX', 'Dir'),
])
results = set(iter_tree_files(self.temp_dir, follow_links=False))
self.assertEqual(results, set(map(self.ospath, [
'A',
'Ax',
'B',
'Bx',
'Dir/C',
'Dir/Cx',
'Dir/D',
'Dir/Dx',
'DirX',
])))
def test_3_entries(self):
"""
Tests to make sure all files are found.
"""
self.make_dirs([
'Empty',
'Dir',
'Dir/Inner',
])
self.make_files([
'a',
'b',
'Dir/c',
'Dir/d',
'Dir/Inner/e',
'Dir/Inner/f',
])
results = {entry.path for entry in iter_tree_entries(self.temp_dir)}
self.assertEqual(results, set(map(self.ospath, [
'a',
'b',
'Dir',
'Dir/c',
'Dir/d',
'Dir/Inner',
'Dir/Inner/e',
'Dir/Inner/f',
'Empty',
])))
@unittest.skipIf(sys.version_info < (3, 4), "pathlib entered stdlib in Python 3.4")
def test_4_normalizing_pathlib_path(self):
"""
Tests passing pathlib.Path as argument.
"""
from pathlib import Path
first_spec = normalize_file(Path('a.txt'))
second_spec = normalize_file('a.txt')
self.assertEqual(first_spec, second_spec)

View file

@ -0,0 +1,665 @@
# encoding: utf-8
"""
This module provides utility methods for dealing with path-specs.
"""
import os
import os.path
import posixpath
import stat
try:
from typing import (
Any,
AnyStr,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Sequence,
Set,
Text,
Union)
except ImportError:
pass
try:
# Python 3.6+ type hints.
from os import PathLike
from typing import Collection
except ImportError:
pass
from .compat import (
CollectionType,
IterableType,
string_types,
unicode)
from .pattern import Pattern
NORMALIZE_PATH_SEPS = [sep for sep in [os.sep, os.altsep] if sep and sep != posixpath.sep]
"""
*NORMALIZE_PATH_SEPS* (:class:`list` of :class:`str`) contains the path
separators that need to be normalized to the POSIX separator for the
current operating system. The separators are determined by examining
:data:`os.sep` and :data:`os.altsep`.
"""
_registered_patterns = {}
"""
*_registered_patterns* (:class:`dict`) maps a name (:class:`str`) to the
registered pattern factory (:class:`~collections.abc.Callable`).
"""
def detailed_match_files(patterns, files, all_matches=None):
# type: (Iterable[Pattern], Iterable[Text], Optional[bool]) -> Dict[Text, 'MatchDetail']
"""
Matches the files to the patterns, and returns which patterns matched
the files.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
the normalized file paths to be matched against *patterns*.
*all_matches* (:class:`boot` or :data:`None`) is whether to return all
matches patterns (:data:`True`), or only the last matched pattern
(:data:`False`). Default is :data:`None` for :data:`False`.
Returns the matched files (:class:`dict`) which maps each matched file
(:class:`str`) to the patterns that matched in order (:class:`.MatchDetail`).
"""
all_files = files if isinstance(files, CollectionType) else list(files)
return_files = {}
for pattern in patterns:
if pattern.include is not None:
result_files = pattern.match(all_files)
if pattern.include:
# Add files and record pattern.
for result_file in result_files:
if result_file in return_files:
if all_matches:
return_files[result_file].patterns.append(pattern)
else:
return_files[result_file].patterns[0] = pattern
else:
return_files[result_file] = MatchDetail([pattern])
else:
# Remove files.
for file in result_files:
del return_files[file]
return return_files
def _is_iterable(value):
# type: (Any) -> bool
"""
Check whether the value is an iterable (excludes strings).
*value* is the value to check,
Returns whether *value* is a iterable (:class:`bool`).
"""
return isinstance(value, IterableType) and not isinstance(value, (unicode, bytes))
def iter_tree_entries(root, on_error=None, follow_links=None):
# type: (Text, Optional[Callable], Optional[bool]) -> Iterator['TreeEntry']
"""
Walks the specified directory for all files and directories.
*root* (:class:`str`) is the root directory to search.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. It will be
called with the exception (:exc:`OSError`). Reraise the exception to
abort the walk. Default is :data:`None` to ignore file-system
exceptions.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. Default is
:data:`None` for :data:`True`.
Raises :exc:`RecursionError` if recursion is detected.
Returns an :class:`~collections.abc.Iterator` yielding each file or
directory entry (:class:`.TreeEntry`) relative to *root*.
"""
if on_error is not None and not callable(on_error):
raise TypeError("on_error:{!r} is not callable.".format(on_error))
if follow_links is None:
follow_links = True
for entry in _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links):
yield entry
def iter_tree_files(root, on_error=None, follow_links=None):
# type: (Text, Optional[Callable], Optional[bool]) -> Iterator[Text]
"""
Walks the specified directory for all files.
*root* (:class:`str`) is the root directory to search for files.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. It will be
called with the exception (:exc:`OSError`). Reraise the exception to
abort the walk. Default is :data:`None` to ignore file-system
exceptions.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. Default is
:data:`None` for :data:`True`.
Raises :exc:`RecursionError` if recursion is detected.
Returns an :class:`~collections.abc.Iterator` yielding the path to
each file (:class:`str`) relative to *root*.
"""
if on_error is not None and not callable(on_error):
raise TypeError("on_error:{!r} is not callable.".format(on_error))
if follow_links is None:
follow_links = True
for entry in _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links):
if not entry.is_dir(follow_links):
yield entry.path
# Alias `iter_tree_files()` as `iter_tree()`.
iter_tree = iter_tree_files
def _iter_tree_entries_next(root_full, dir_rel, memo, on_error, follow_links):
# type: (Text, Text, Dict[Text, Text], Callable, bool) -> Iterator['TreeEntry']
"""
Scan the directory for all descendant files.
*root_full* (:class:`str`) the absolute path to the root directory.
*dir_rel* (:class:`str`) the path to the directory to scan relative to
*root_full*.
*memo* (:class:`dict`) keeps track of ancestor directories
encountered. Maps each ancestor real path (:class:`str`) to relative
path (:class:`str`).
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions.
*follow_links* (:class:`bool`) is whether to walk symbolic links that
resolve to directories.
Yields each entry (:class:`.TreeEntry`).
"""
dir_full = os.path.join(root_full, dir_rel)
dir_real = os.path.realpath(dir_full)
# Remember each encountered ancestor directory and its canonical
# (real) path. If a canonical path is encountered more than once,
# recursion has occurred.
if dir_real not in memo:
memo[dir_real] = dir_rel
else:
raise RecursionError(real_path=dir_real, first_path=memo[dir_real], second_path=dir_rel)
for node_name in os.listdir(dir_full):
node_rel = os.path.join(dir_rel, node_name)
node_full = os.path.join(root_full, node_rel)
# Inspect child node.
try:
node_lstat = os.lstat(node_full)
except OSError as e:
if on_error is not None:
on_error(e)
continue
if stat.S_ISLNK(node_lstat.st_mode):
# Child node is a link, inspect the target node.
is_link = True
try:
node_stat = os.stat(node_full)
except OSError as e:
if on_error is not None:
on_error(e)
continue
else:
is_link = False
node_stat = node_lstat
if stat.S_ISDIR(node_stat.st_mode) and (follow_links or not is_link):
# Child node is a directory, recurse into it and yield its
# descendant files.
yield TreeEntry(node_name, node_rel, node_lstat, node_stat)
for entry in _iter_tree_entries_next(root_full, node_rel, memo, on_error, follow_links):
yield entry
elif stat.S_ISREG(node_stat.st_mode) or is_link:
# Child node is either a file or an unfollowed link, yield it.
yield TreeEntry(node_name, node_rel, node_lstat, node_stat)
# NOTE: Make sure to remove the canonical (real) path of the directory
# from the ancestors memo once we are done with it. This allows the
# same directory to appear multiple times. If this is not done, the
# second occurrence of the directory will be incorrectly interpreted
# as a recursion. See <https://github.com/cpburnz/python-path-specification/pull/7>.
del memo[dir_real]
def lookup_pattern(name):
# type: (Text) -> Callable[[AnyStr], Pattern]
"""
Lookups a registered pattern factory by name.
*name* (:class:`str`) is the name of the pattern factory.
Returns the registered pattern factory (:class:`~collections.abc.Callable`).
If no pattern factory is registered, raises :exc:`KeyError`.
"""
return _registered_patterns[name]
def match_file(patterns, file):
# type: (Iterable[Pattern], Text) -> bool
"""
Matches the file to the patterns.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*file* (:class:`str`) is the normalized file path to be matched
against *patterns*.
Returns :data:`True` if *file* matched; otherwise, :data:`False`.
"""
matched = False
for pattern in patterns:
if pattern.include is not None:
if file in pattern.match((file,)):
matched = pattern.include
return matched
def match_files(patterns, files):
# type: (Iterable[Pattern], Iterable[Text]) -> Set[Text]
"""
Matches the files to the patterns.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
the normalized file paths to be matched against *patterns*.
Returns the matched files (:class:`set` of :class:`str`).
"""
all_files = files if isinstance(files, CollectionType) else list(files)
return_files = set()
for pattern in patterns:
if pattern.include is not None:
result_files = pattern.match(all_files)
if pattern.include:
return_files.update(result_files)
else:
return_files.difference_update(result_files)
return return_files
def _normalize_entries(entries, separators=None):
# type: (Iterable['TreeEntry'], Optional[Collection[Text]]) -> Dict[Text, 'TreeEntry']
"""
Normalizes the entry paths to use the POSIX path separator.
*entries* (:class:`~collections.abc.Iterable` of :class:`.TreeEntry`)
contains the entries to be normalized.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
See :func:`normalize_file` for more information.
Returns a :class:`dict` mapping the each normalized file path (:class:`str`)
to the entry (:class:`.TreeEntry`)
"""
norm_files = {}
for entry in entries:
norm_files[normalize_file(entry.path, separators=separators)] = entry
return norm_files
def normalize_file(file, separators=None):
# type: (Union[Text, PathLike], Optional[Collection[Text]]) -> Text
"""
Normalizes the file path to use the POSIX path separator (i.e.,
``'/'``), and make the paths relative (remove leading ``'/'``).
*file* (:class:`str` or :class:`pathlib.PurePath`) is the file path.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
This does not need to include the POSIX path separator (``'/'``), but
including it will not affect the results. Default is :data:`None` for
:data:`NORMALIZE_PATH_SEPS`. To prevent normalization, pass an empty
container (e.g., an empty tuple ``()``).
Returns the normalized file path (:class:`str`).
"""
# Normalize path separators.
if separators is None:
separators = NORMALIZE_PATH_SEPS
# Convert path object to string.
norm_file = str(file)
for sep in separators:
norm_file = norm_file.replace(sep, posixpath.sep)
if norm_file.startswith('/'):
# Make path relative.
norm_file = norm_file[1:]
elif norm_file.startswith('./'):
# Remove current directory prefix.
norm_file = norm_file[2:]
return norm_file
def normalize_files(files, separators=None):
# type: (Iterable[Union[str, PathLike]], Optional[Collection[Text]]) -> Dict[Text, List[Union[str, PathLike]]]
"""
Normalizes the file paths to use the POSIX path separator.
*files* (:class:`~collections.abc.Iterable` of :class:`str` or
:class:`pathlib.PurePath`) contains the file paths to be normalized.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
See :func:`normalize_file` for more information.
Returns a :class:`dict` mapping the each normalized file path
(:class:`str`) to the original file paths (:class:`list` of
:class:`str` or :class:`pathlib.PurePath`).
"""
norm_files = {}
for path in files:
norm_file = normalize_file(path, separators=separators)
if norm_file in norm_files:
norm_files[norm_file].append(path)
else:
norm_files[norm_file] = [path]
return norm_files
def register_pattern(name, pattern_factory, override=None):
# type: (Text, Callable[[AnyStr], Pattern], Optional[bool]) -> None
"""
Registers the specified pattern factory.
*name* (:class:`str`) is the name to register the pattern factory
under.
*pattern_factory* (:class:`~collections.abc.Callable`) is used to
compile patterns. It must accept an uncompiled pattern (:class:`str`)
and return the compiled pattern (:class:`.Pattern`).
*override* (:class:`bool` or :data:`None`) optionally is whether to
allow overriding an already registered pattern under the same name
(:data:`True`), instead of raising an :exc:`AlreadyRegisteredError`
(:data:`False`). Default is :data:`None` for :data:`False`.
"""
if not isinstance(name, string_types):
raise TypeError("name:{!r} is not a string.".format(name))
if not callable(pattern_factory):
raise TypeError("pattern_factory:{!r} is not callable.".format(pattern_factory))
if name in _registered_patterns and not override:
raise AlreadyRegisteredError(name, _registered_patterns[name])
_registered_patterns[name] = pattern_factory
class AlreadyRegisteredError(Exception):
"""
The :exc:`AlreadyRegisteredError` exception is raised when a pattern
factory is registered under a name already in use.
"""
def __init__(self, name, pattern_factory):
# type: (Text, Callable[[AnyStr], Pattern]) -> None
"""
Initializes the :exc:`AlreadyRegisteredError` instance.
*name* (:class:`str`) is the name of the registered pattern.
*pattern_factory* (:class:`~collections.abc.Callable`) is the
registered pattern factory.
"""
super(AlreadyRegisteredError, self).__init__(name, pattern_factory)
@property
def message(self):
# type: () -> Text
"""
*message* (:class:`str`) is the error message.
"""
return "{name!r} is already registered for pattern factory:{pattern_factory!r}.".format(
name=self.name,
pattern_factory=self.pattern_factory,
)
@property
def name(self):
# type: () -> Text
"""
*name* (:class:`str`) is the name of the registered pattern.
"""
return self.args[0]
@property
def pattern_factory(self):
# type: () -> Callable[[AnyStr], Pattern]
"""
*pattern_factory* (:class:`~collections.abc.Callable`) is the
registered pattern factory.
"""
return self.args[1]
class RecursionError(Exception):
"""
The :exc:`RecursionError` exception is raised when recursion is
detected.
"""
def __init__(self, real_path, first_path, second_path):
# type: (Text, Text, Text) -> None
"""
Initializes the :exc:`RecursionError` instance.
*real_path* (:class:`str`) is the real path that recursion was
encountered on.
*first_path* (:class:`str`) is the first path encountered for
*real_path*.
*second_path* (:class:`str`) is the second path encountered for
*real_path*.
"""
super(RecursionError, self).__init__(real_path, first_path, second_path)
@property
def first_path(self):
# type: () -> Text
"""
*first_path* (:class:`str`) is the first path encountered for
:attr:`self.real_path <RecursionError.real_path>`.
"""
return self.args[1]
@property
def message(self):
# type: () -> Text
"""
*message* (:class:`str`) is the error message.
"""
return "Real path {real!r} was encountered at {first!r} and then {second!r}.".format(
real=self.real_path,
first=self.first_path,
second=self.second_path,
)
@property
def real_path(self):
# type: () -> Text
"""
*real_path* (:class:`str`) is the real path that recursion was
encountered on.
"""
return self.args[0]
@property
def second_path(self):
# type: () -> Text
"""
*second_path* (:class:`str`) is the second path encountered for
:attr:`self.real_path <RecursionError.real_path>`.
"""
return self.args[2]
class MatchDetail(object):
"""
The :class:`.MatchDetail` class contains information about
"""
#: Make the class dict-less.
__slots__ = ('patterns',)
def __init__(self, patterns):
# type: (Sequence[Pattern]) -> None
"""
Initialize the :class:`.MatchDetail` instance.
*patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`)
contains the patterns that matched the file in the order they were
encountered.
"""
self.patterns = patterns
"""
*patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`)
contains the patterns that matched the file in the order they were
encountered.
"""
class TreeEntry(object):
"""
The :class:`.TreeEntry` class contains information about a file-system
entry.
"""
#: Make the class dict-less.
__slots__ = ('_lstat', 'name', 'path', '_stat')
def __init__(self, name, path, lstat, stat):
# type: (Text, Text, os.stat_result, os.stat_result) -> None
"""
Initialize the :class:`.TreeEntry` instance.
*name* (:class:`str`) is the base name of the entry.
*path* (:class:`str`) is the relative path of the entry.
*lstat* (:class:`~os.stat_result`) is the stat result of the direct
entry.
*stat* (:class:`~os.stat_result`) is the stat result of the entry,
potentially linked.
"""
self._lstat = lstat
"""
*_lstat* (:class:`~os.stat_result`) is the stat result of the direct
entry.
"""
self.name = name
"""
*name* (:class:`str`) is the base name of the entry.
"""
self.path = path
"""
*path* (:class:`str`) is the path of the entry.
"""
self._stat = stat
"""
*_stat* (:class:`~os.stat_result`) is the stat result of the linked
entry.
"""
def is_dir(self, follow_links=None):
# type: (Optional[bool]) -> bool
"""
Get whether the entry is a directory.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, a symlink to a directory
will result in :data:`True`. Default is :data:`None` for :data:`True`.
Returns whether the entry is a directory (:class:`bool`).
"""
if follow_links is None:
follow_links = True
node_stat = self._stat if follow_links else self._lstat
return stat.S_ISDIR(node_stat.st_mode)
def is_file(self, follow_links=None):
# type: (Optional[bool]) -> bool
"""
Get whether the entry is a regular file.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, a symlink to a regular file
will result in :data:`True`. Default is :data:`None` for :data:`True`.
Returns whether the entry is a regular file (:class:`bool`).
"""
if follow_links is None:
follow_links = True
node_stat = self._stat if follow_links else self._lstat
return stat.S_ISREG(node_stat.st_mode)
def is_symlink(self):
# type: () -> bool
"""
Returns whether the entry is a symbolic link (:class:`bool`).
"""
return stat.S_ISLNK(self._lstat.st_mode)
def stat(self, follow_links=None):
# type: (Optional[bool]) -> os.stat_result
"""
Get the cached stat result for the entry.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, the stat result of the
linked file will be returned. Default is :data:`None` for :data:`True`.
Returns that stat result (:class:`~os.stat_result`).
"""
if follow_links is None:
follow_links = True
return self._stat if follow_links else self._lstat