From fb2246c0b50b95803b8abee2b2bfbdca39850918 Mon Sep 17 00:00:00 2001 From: cpburnz <2126043+cpburnz@users.noreply.github.com> Date: Fri, 28 Jul 2023 21:02:27 -0400 Subject: [PATCH] Implement issue 80 --- CHANGES.rst | 5 ++ pathspec/_meta.py | 1 + pathspec/pathspec.py | 116 ++++++++++++++++++++++++++--------------- tests/test_pathspec.py | 50 ++++++++++++++++++ tox.ini | 2 +- 5 files changed, 131 insertions(+), 43 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f8ff9c5..9db9535 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,10 @@ Change History 0.11.2 (TBD) ------------ +New features: + +- `Issue #80`_: match_files with negated path spec. `pathspec.PathSpec.match_*()` now have a `negate` parameter to make using *.gitignore* logic easier and more efficient. + Bug fixes: - `Pull #76`_: Add edge case: patterns that end with an escaped space @@ -15,6 +19,7 @@ Bug fixes: .. _`Pull #76`: https://github.com/cpburnz/python-pathspec/pull/76 .. _`Issue #77`: https://github.com/cpburnz/python-pathspec/issues/77 .. _`Pull #78`: https://github.com/cpburnz/python-pathspec/pull/78/ +.. _`Issue #80`: https://github.com/cpburnz/python-pathspec/issues/80 0.11.1 (2023-03-14) diff --git a/pathspec/_meta.py b/pathspec/_meta.py index 3f417db..4e45b5a 100644 --- a/pathspec/_meta.py +++ b/pathspec/_meta.py @@ -51,6 +51,7 @@ "yschroeder ", "axesider ", "tomruk ", + "oprypin ", ] __license__ = "MPL 2.0" __version__ = "0.11.2.dev1" diff --git a/pathspec/pathspec.py b/pathspec/pathspec.py index e66331b..93f2f60 100644 --- a/pathspec/pathspec.py +++ b/pathspec/pathspec.py @@ -1,15 +1,11 @@ """ -This module provides an object oriented interface for pattern matching -of files. +This module provides an object oriented interface for pattern matching of files. """ -import sys from collections.abc import ( Collection as CollectionType) from itertools import ( zip_longest) -from os import ( - PathLike) from typing import ( AnyStr, Callable, @@ -107,15 +103,15 @@ def from_lines( """ 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`). + *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:`io.TextIOBase` (e.g., from :func:`open` or - :class:`io.StringIO`) or the result from :meth:`str.splitlines`. + *lines* (:class:`~collections.abc.Iterable`) yields each uncompiled pattern + (:class:`str`). This simply has to yield each line so that it can be a + :class:`io.TextIOBase` (e.g., from :func:`open` or :class:`io.StringIO`) or + the result from :meth:`str.splitlines`. Returns the :class:`PathSpec` instance. """ @@ -135,6 +131,8 @@ def match_entries( self, entries: Iterable[TreeEntry], separators: Optional[Collection[str]] = None, + *, + negate: Optional[bool] = None, ) -> Iterator[TreeEntry]: """ Matches the entries to this path-spec. @@ -142,10 +140,14 @@ def match_entries( *entries* (:class:`~collections.abc.Iterable` of :class:`~util.TreeEntry`) contains the entries to be matched against :attr:`self.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. + *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. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. Returns the matched entries (:class:`~collections.abc.Iterator` of :class:`~util.TreeEntry`). @@ -156,12 +158,17 @@ def match_entries( use_patterns = _filter_patterns(self.patterns) for entry in entries: norm_file = normalize_file(entry.path, separators) - if self._match_file(use_patterns, norm_file): + is_match = self._match_file(use_patterns, norm_file) + + if negate: + is_match = not is_match + + if is_match: yield entry - # Match files using the `match_file()` utility function. Subclasses - # may override this method as an instance method. It does not have to - # be a static method. + # Match files using the `match_file()` utility function. Subclasses may + # override this method as an instance method. It does not have to be a static + # method. _match_file = staticmethod(match_file) def match_file( @@ -188,6 +195,8 @@ def match_files( self, files: Iterable[StrPath], separators: Optional[Collection[str]] = None, + *, + negate: Optional[bool] = None, ) -> Iterator[StrPath]: """ Matches the files to this path-spec. @@ -196,10 +205,14 @@ def match_files( :class:`os.PathLike[str]`) contains the file paths to be matched against :attr:`self.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. + *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. + + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. Returns the matched files (:class:`~collections.abc.Iterator` of :class:`str` or :class:`os.PathLike[str]`). @@ -210,7 +223,12 @@ def match_files( use_patterns = _filter_patterns(self.patterns) for orig_file in files: norm_file = normalize_file(orig_file, separators) - if self._match_file(use_patterns, norm_file): + is_match = self._match_file(use_patterns, norm_file) + + if negate: + is_match = not is_match + + if is_match: yield orig_file def match_tree_entries( @@ -218,55 +236,69 @@ def match_tree_entries( root: StrPath, on_error: Optional[Callable] = None, follow_links: Optional[bool] = None, + *, + negate: Optional[bool] = None, ) -> Iterator[TreeEntry]: """ Walks the specified root path for all files and matches them to this path-spec. - *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory - to search. + *root* (:class:`str` or :class:`os.PathLike[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. See + *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 + *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. + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + 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) - yield from self.match_entries(entries) + yield from self.match_entries(entries, negate=negate) def match_tree_files( self, root: StrPath, on_error: Optional[Callable] = None, follow_links: Optional[bool] = None, + *, + negate: Optional[bool] = None, ) -> Iterator[str]: """ Walks the specified root path for all files and matches them to this path-spec. - *root* (:class:`str` or :class:`os.PathLike[str]`) is the root directory - to search for files. + *root* (:class:`str` or :class:`os.PathLike[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. See + *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 + *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. + *negate* (:class:`bool` or :data:`None`) is whether to negate the match + results of the patterns. If :data:`True`, a pattern matching a file will + exclude the file rather than include it. Default is :data:`None` for + :data:`False`. + 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) - yield from self.match_files(files) + yield from self.match_files(files, negate=negate) - # Alias `match_tree_files()` as `match_tree()` for backward - # compatibility before v0.3.2. + # Alias `match_tree_files()` as `match_tree()` for backward compatibility + # before v0.3.2. match_tree = match_tree_files diff --git a/tests/test_pathspec.py b/tests/test_pathspec.py index 20cbc6b..2e7c8f0 100644 --- a/tests/test_pathspec.py +++ b/tests/test_pathspec.py @@ -579,3 +579,53 @@ def test_08_issue_39(self): 'important/d.log', 'important/e.txt', }) + + def test_09_issue_80_a(self): + """ + Test negating patterns. + """ + spec = PathSpec.from_lines('gitwildmatch', [ + 'build', + '*.log', + '.*', + '!.gitignore', + ]) + files = { + '.c-tmp', + '.gitignore', + 'a.log', + 'b.txt', + 'build/d.log', + 'build/trace.bin', + 'trace.c', + } + keeps = set(spec.match_files(files, negate=True)) + self.assertEqual(keeps, { + '.gitignore', + 'b.txt', + 'trace.c', + }) + + def test_09_issue_80_b(self): + """ + Test negating patterns. + """ + spec = PathSpec.from_lines('gitwildmatch', [ + 'build', + '*.log', + '.*', + '!.gitignore', + ]) + files = { + '.c-tmp', + '.gitignore', + 'a.log', + 'b.txt', + 'build/d.log', + 'build/trace.bin', + 'trace.c', + } + keeps = set(spec.match_files(files, negate=True)) + ignores = set(spec.match_files(files)) + self.assertEqual(files - ignores, keeps) + self.assertEqual(files - keeps, ignores) diff --git a/tox.ini b/tox.ini index 9b820e3..5d039ae 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py38, py39, py310, py311, pypy3 +envlist = py37, py38, py39, py310, py311, py312, pypy3 isolated_build = True [testenv]