diff --git a/CHANGES.rst b/CHANGES.rst index d9e62c6..d517fe2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,7 @@ Change History - `Issue #45`_: Fix for duplicate leading double-asterisk, and edge cases. - `Issue #46`_: Fix matching absolute paths. - API change: `util.normalize_files()` now returns a `Dict[str, List[pathlike]]` instead of a `Dict[str, pathlike]`. +- Added type hinting. .. _`Issue #45`: https://github.com/cpburnz/python-path-specification/pull/45 .. _`Issue #46`: https://github.com/cpburnz/python-path-specification/issues/46 diff --git a/pathspec/compat.py b/pathspec/compat.py index 37c6480..f5d17bf 100644 --- a/pathspec/compat.py +++ b/pathspec/compat.py @@ -36,3 +36,6 @@ def iterkeys(mapping): except ImportError: # Python 2.7 - 3.5. from collections import Container as Collection + +CollectionType = Collection +IterableType = Iterable diff --git a/pathspec/pathspec.py b/pathspec/pathspec.py index 4585c37..ff40089 100644 --- a/pathspec/pathspec.py +++ b/pathspec/pathspec.py @@ -4,8 +4,34 @@ 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 Collection, iterkeys, izip_longest, string_types, unicode +from .compat import ( + CollectionType, + iterkeys, + izip_longest, + string_types) +from .pattern import Pattern +from .util import TreeEntry class PathSpec(object): @@ -15,6 +41,7 @@ class PathSpec(object): """ def __init__(self, patterns): + # type: (Iterable[Pattern]) -> None """ Initializes the :class:`PathSpec` instance. @@ -22,13 +49,14 @@ def __init__(self, patterns): yields each compiled pattern (:class:`.Pattern`). """ - self.patterns = patterns if isinstance(patterns, Collection) else list(patterns) + 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. @@ -47,6 +75,7 @@ def __len__(self): return len(self.patterns) def __add__(self, other): + # type: (PathSpec) -> PathSpec """ Combines the :attr:`Pathspec.patterns` patterns from two :class:`PathSpec` instances. @@ -57,6 +86,7 @@ def __add__(self, other): return NotImplemented def __iadd__(self, other): + # type: (PathSpec) -> PathSpec """ Adds the :attr:`Pathspec.patterns` patterns from one :class:`PathSpec` instance to this instance. @@ -69,6 +99,7 @@ def __iadd__(self, other): @classmethod def from_lines(cls, pattern_factory, lines): + # type: (Union[Text, Callable[[AnyStr], Pattern]], Iterable[AnyStr]) -> PathSpec """ Compiles the pattern lines. @@ -92,10 +123,11 @@ def from_lines(cls, pattern_factory, lines): if not util._is_iterable(lines): raise TypeError("lines:{!r} is not an iterable.".format(lines)) - lines = [pattern_factory(line) for line in lines if line] - return cls(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. @@ -112,6 +144,7 @@ def match_file(self, file, separators=None): 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. @@ -123,7 +156,7 @@ def match_entries(self, entries, separators=None): normalize. See :func:`~pathspec.util.normalize_file` for more information. - Returns the matched entries (:class:`~collections.abc.Iterable` of + Returns the matched entries (:class:`~collections.abc.Iterator` of :class:`~util.TreeEntry`). """ if not util._is_iterable(entries): @@ -135,6 +168,7 @@ def match_entries(self, entries, separators=None): 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. @@ -147,8 +181,8 @@ def match_files(self, files, separators=None): normalize. See :func:`~pathspec.util.normalize_file` for more information. - Returns the matched files (:class:`~collections.abc.Iterable` of - :class:`str`). + 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)) @@ -160,6 +194,7 @@ def match_files(self, files, separators=None): 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. @@ -175,13 +210,14 @@ def match_tree_entries(self, root, on_error=None, follow_links=None): 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`). + 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. diff --git a/pathspec/pattern.py b/pathspec/pattern.py index 4ba4edf..c354c26 100644 --- a/pathspec/pattern.py +++ b/pathspec/pattern.py @@ -4,6 +4,18 @@ """ import re +try: + from typing import ( + AnyStr, + Iterable, + Iterator, + Optional, + Pattern as RegexHint, + Text, + Tuple, + Union) +except ImportError: + pass from .compat import unicode @@ -17,6 +29,7 @@ class Pattern(object): __slots__ = ('include',) def __init__(self, include): + # type: (Optional[bool]) -> None """ Initializes the :class:`Pattern` instance. @@ -33,6 +46,7 @@ def __init__(self, include): """ def match(self, files): + # type: (Iterable[Text]) -> Iterator[Text] """ Matches this pattern against the specified files. @@ -55,6 +69,7 @@ class RegexPattern(Pattern): __slots__ = ('regex',) def __init__(self, pattern, include=None): + # type: (Union[AnyStr, RegexHint], Optional[bool]) -> None """ Initializes the :class:`RegexPattern` instance. @@ -103,6 +118,7 @@ def __init__(self, pattern, include=None): 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` @@ -114,6 +130,7 @@ def __eq__(self, other): return NotImplemented def match(self, files): + # type: (Iterable[Text]) -> Iterable[Text] """ Matches this pattern against the specified files. @@ -130,6 +147,7 @@ def match(self, files): @classmethod def pattern_to_regex(cls, pattern): + # type: (Text) -> Tuple[Text, bool] """ Convert the pattern into an uncompiled regular expression. diff --git a/pathspec/patterns/gitwildmatch.py b/pathspec/patterns/gitwildmatch.py index a40950d..4b4831b 100644 --- a/pathspec/patterns/gitwildmatch.py +++ b/pathspec/patterns/gitwildmatch.py @@ -8,6 +8,14 @@ import re import warnings +try: + from typing import ( + AnyStr, + Optional, + Text, + Tuple) +except ImportError: + pass from .. import util from ..compat import unicode @@ -28,6 +36,7 @@ class GitWildMatchPattern(RegexPattern): @classmethod def pattern_to_regex(cls, pattern): + # type: (AnyStr) -> Tuple[Optional[AnyStr], Optional[bool]] """ Convert the pattern into a regular expression. @@ -205,6 +214,7 @@ def pattern_to_regex(cls, pattern): @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 @@ -245,28 +255,28 @@ def _translate_segment_glob(pattern): regex += '[^/]' elif char == '[': - # Braket expression wildcard. Except for the beginning - # exclamation mark, the whole braket expression can be used + # 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. - # - "[][!]" matchs ']', '[' and '!'. - # - "[]-]" matchs ']' and '-'. - # - "[!]a-]" matchs any character except ']', 'a' and '-'. + # - "[][!]" 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 braket if it is at the beginning of the + # Pass first closing bracket if it is at the beginning of the # expression. if j < end and pattern[j] == ']': j += 1 - # Find closing braket. Stop once we reach the end or find it. + # 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 braket expression. Increment j to be one past - # the closing braket: + # Found end of bracket expression. Increment j to be one past + # the closing bracket: # # [...] # ^ ^ @@ -280,7 +290,7 @@ def _translate_segment_glob(pattern): expr += '^' i += 1 elif pattern[i] == '^': - # POSIX declares that the regex braket expression negation + # 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, @@ -288,19 +298,19 @@ def _translate_segment_glob(pattern): expr += '\\^' i += 1 - # Build regex braket expression. Escape slashes so they are + # 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 braket expression to regex result. + # Add regex bracket expression to regex result. regex += expr - # Set i to one past the closing braket. + # Set i to one past the closing bracket. i = j else: - # Failed to find closing braket, treat opening braket as a - # braket literal instead of as an expression. + # Failed to find closing bracket, treat opening bracket as a + # bracket literal instead of as an expression. regex += '\\[' else: @@ -311,18 +321,33 @@ def _translate_segment_glob(pattern): @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`, :class:`bytes`) + 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"[]!*#?" - return "".join("\\" + x if x in meta_characters else x for x in s) + 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) @@ -338,7 +363,7 @@ def __init__(self, *args, **kw): Warn about deprecation. """ self._deprecated() - return super(GitIgnorePattern, self).__init__(*args, **kw) + super(GitIgnorePattern, self).__init__(*args, **kw) @staticmethod def _deprecated(): diff --git a/pathspec/util.py b/pathspec/util.py index e310778..64a5dea 100644 --- a/pathspec/util.py +++ b/pathspec/util.py @@ -7,8 +7,35 @@ import os.path import posixpath import stat - -from .compat import Collection, Iterable, string_types, unicode +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] """ @@ -26,6 +53,7 @@ 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. @@ -43,7 +71,7 @@ def detailed_match_files(patterns, files, all_matches=None): 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, Collection) else list(files) + all_files = files if isinstance(files, CollectionType) else list(files) return_files = {} for pattern in patterns: if pattern.include is not None: @@ -68,6 +96,7 @@ def detailed_match_files(patterns, files, all_matches=None): def _is_iterable(value): + # type: (Any) -> bool """ Check whether the value is an iterable (excludes strings). @@ -75,10 +104,11 @@ def _is_iterable(value): Returns whether *value* is a iterable (:class:`bool`). """ - return isinstance(value, Iterable) and not isinstance(value, (unicode, bytes)) + 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. @@ -96,7 +126,7 @@ def iter_tree_entries(root, on_error=None, follow_links=None): Raises :exc:`RecursionError` if recursion is detected. - Returns an :class:`~collections.abc.Iterable` yielding each file or + 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): @@ -110,6 +140,7 @@ def iter_tree_entries(root, on_error=None, follow_links=None): 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. @@ -127,7 +158,7 @@ def iter_tree_files(root, on_error=None, follow_links=None): Raises :exc:`RecursionError` if recursion is detected. - Returns an :class:`~collections.abc.Iterable` yielding the path to + 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): @@ -146,6 +177,7 @@ def iter_tree_files(root, on_error=None, follow_links=None): 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. @@ -223,6 +255,7 @@ def _iter_tree_entries_next(root_full, dir_rel, memo, on_error, follow_links): def lookup_pattern(name): + # type: (Text) -> Callable[[AnyStr], Pattern] """ Lookups a registered pattern factory by name. @@ -235,6 +268,7 @@ def lookup_pattern(name): def match_file(patterns, file): + # type: (Iterable[Pattern], Text) -> bool """ Matches the file to the patterns. @@ -255,6 +289,7 @@ def match_file(patterns, file): def match_files(patterns, files): + # type: (Iterable[Pattern], Iterable[Text]) -> Set[Text] """ Matches the files to the patterns. @@ -266,7 +301,7 @@ def match_files(patterns, files): Returns the matched files (:class:`set` of :class:`str`). """ - all_files = files if isinstance(files, Collection) else list(files) + all_files = files if isinstance(files, CollectionType) else list(files) return_files = set() for pattern in patterns: if pattern.include is not None: @@ -279,6 +314,7 @@ def match_files(patterns, 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. @@ -299,6 +335,7 @@ def _normalize_entries(entries, separators=None): 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 ``'/'``). @@ -336,6 +373,7 @@ def normalize_file(file, separators=None): 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. @@ -348,7 +386,7 @@ def normalize_files(files, separators=None): Returns a :class:`dict` mapping the each normalized file path (:class:`str`) to the original file paths (:class:`list` of - :class:`str`). + :class:`str` or :class:`pathlib.PurePath`). """ norm_files = {} for path in files: @@ -362,6 +400,7 @@ def normalize_files(files, separators=None): def register_pattern(name, pattern_factory, override=None): + # type: (Text, Callable[[AnyStr], Pattern], Optional[bool]) -> None """ Registers the specified pattern factory. @@ -393,6 +432,7 @@ class AlreadyRegisteredError(Exception): """ def __init__(self, name, pattern_factory): + # type: (Text, Callable[[AnyStr], Pattern]) -> None """ Initializes the :exc:`AlreadyRegisteredError` instance. @@ -405,6 +445,7 @@ def __init__(self, name, pattern_factory): @property def message(self): + # type: () -> Text """ *message* (:class:`str`) is the error message. """ @@ -415,6 +456,7 @@ def message(self): @property def name(self): + # type: () -> Text """ *name* (:class:`str`) is the name of the registered pattern. """ @@ -422,6 +464,7 @@ def name(self): @property def pattern_factory(self): + # type: () -> Callable[[AnyStr], Pattern] """ *pattern_factory* (:class:`~collections.abc.Callable`) is the registered pattern factory. @@ -436,6 +479,7 @@ class RecursionError(Exception): """ def __init__(self, real_path, first_path, second_path): + # type: (Text, Text, Text) -> None """ Initializes the :exc:`RecursionError` instance. @@ -452,6 +496,7 @@ def __init__(self, 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 `. @@ -460,6 +505,7 @@ def first_path(self): @property def message(self): + # type: () -> Text """ *message* (:class:`str`) is the error message. """ @@ -471,6 +517,7 @@ def message(self): @property def real_path(self): + # type: () -> Text """ *real_path* (:class:`str`) is the real path that recursion was encountered on. @@ -479,6 +526,7 @@ def real_path(self): @property def second_path(self): + # type: () -> Text """ *second_path* (:class:`str`) is the second path encountered for :attr:`self.real_path `. @@ -495,6 +543,7 @@ class MatchDetail(object): __slots__ = ('patterns',) def __init__(self, patterns): + # type: (Sequence[Pattern]) -> None """ Initialize the :class:`.MatchDetail` instance. @@ -521,6 +570,7 @@ class TreeEntry(object): __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. @@ -558,6 +608,7 @@ def __init__(self, name, path, lstat, stat): """ def is_dir(self, follow_links=None): + # type: (Optional[bool]) -> bool """ Get whether the entry is a directory. @@ -574,6 +625,7 @@ def is_dir(self, follow_links=None): 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. @@ -590,12 +642,14 @@ def is_file(self, follow_links=None): 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.