Imported Upstream version 0.12.1 upstream/0.12.1
authorTizenOpenSource <tizenopensrc@samsung.com>
Tue, 6 Feb 2024 03:52:27 +0000 (12:52 +0900)
committerTizenOpenSource <tizenopensrc@samsung.com>
Tue, 6 Feb 2024 03:52:27 +0000 (12:52 +0900)
35 files changed:
CHANGES.rst [new file with mode: 0644]
LICENSE [new file with mode: 0644]
MANIFEST.in [new file with mode: 0644]
PKG-INFO [new file with mode: 0644]
README-dist.rst [new file with mode: 0644]
README.rst [new file with mode: 0644]
doc/Makefile [new file with mode: 0644]
doc/requirements.txt [new file with mode: 0644]
doc/source/api.rst [new file with mode: 0644]
doc/source/changes.rst [new file with mode: 0644]
doc/source/conf.py [new file with mode: 0644]
doc/source/index.rst [new file with mode: 0644]
doc/source/readme.rst [new file with mode: 0644]
packaging/python3-build.manifest [new file with mode: 0644]
packaging/python3-build.spec [new file with mode: 0644]
pathspec/__init__.py [new file with mode: 0644]
pathspec/_meta.py [new file with mode: 0644]
pathspec/gitignore.py [new file with mode: 0644]
pathspec/pathspec.py [new file with mode: 0644]
pathspec/pattern.py [new file with mode: 0644]
pathspec/patterns/__init__.py [new file with mode: 0644]
pathspec/patterns/gitwildmatch.py [new file with mode: 0644]
pathspec/py.typed [new file with mode: 0644]
pathspec/util.py [new file with mode: 0644]
prebuild.py [new file with mode: 0644]
pyproject.toml [new file with mode: 0644]
setup.cfg [new file with mode: 0644]
setup.py [new file with mode: 0644]
tests/__init__.py [new file with mode: 0644]
tests/test_01_util.py [new file with mode: 0644]
tests/test_02_gitwildmatch.py [new file with mode: 0644]
tests/test_03_pathspec.py [new file with mode: 0644]
tests/test_04_gitignore.py [new file with mode: 0644]
tests/util.py [new file with mode: 0644]
tox.ini [new file with mode: 0644]

diff --git a/CHANGES.rst b/CHANGES.rst
new file mode 100644 (file)
index 0000000..eb9e11c
--- /dev/null
@@ -0,0 +1,453 @@
+
+Change History
+==============
+
+
+0.12.1 (2023-12-10)
+-------------------
+
+Bug fixes:
+
+- `Issue #84`_: PathSpec.match_file() returns None since 0.12.0.
+
+
+.. _`Issue #84`: https://github.com/cpburnz/python-pathspec/issues/84
+
+
+0.12.0 (2023-12-09)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 3.7. See `Pull #82`_.
+
+
+API changes:
+
+- Signature of protected method `pathspec.pathspec.PathSpec._match_file()` (with a leading underscore) has been changed from `def _match_file(patterns: Iterable[Pattern], file: str) -> bool` to `def _match_file(patterns: Iterable[Tuple[int, Pattern]], file: str) -> Tuple[Optional[bool], Optional[int]]`.
+
+New features:
+
+- Added `pathspec.pathspec.PathSpec.check_*()` methods. These methods behave similarly to `.match_*()` but return additional information in the `pathspec.util.CheckResult` objects (e.g., `CheckResult.index` indicates the index of the last pattern that matched the file).
+- Added `pathspec.pattern.RegexPattern.pattern` attribute which stores the original, uncompiled pattern.
+
+Bug fixes:
+
+- `Issue #81`_: GitIgnoreSpec behaviors differ from git.
+- `Pull #83`_: Fix ReadTheDocs builds.
+
+Improvements:
+
+- Mark Python 3.12 as supported. See `Pull #82`_.
+- Improve test debugging.
+- Improve type hint on *on_error* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Improve type hint on *on_error* parameter on `pathspec.util.iter_tree_entries()`.
+
+
+.. _`Issue #81`: https://github.com/cpburnz/python-pathspec/issues/81
+.. _`Pull #82`: https://github.com/cpburnz/python-pathspec/pull/82
+.. _`Pull #83`: https://github.com/cpburnz/python-pathspec/pull/83
+
+
+0.11.2 (2023-07-28)
+-------------------
+
+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
+- `Issue #77`_/`Pull #78`_: Negate with caret symbol as with the exclamation mark.
+
+
+.. _`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)
+-------------------
+
+Bug fixes:
+
+- `Issue #74`_: Include directory should override exclude file.
+
+Improvements:
+
+- `Pull #75`_: Fix partially unknown PathLike type.
+- Convert `os.PathLike` to a string properly using `os.fspath`.
+
+
+.. _`Issue #74`: https://github.com/cpburnz/python-pathspec/issues/74
+.. _`Pull #75`: https://github.com/cpburnz/python-pathspec/pull/75
+
+
+0.11.0 (2023-01-24)
+-------------------
+
+Major changes:
+
+- Changed build backend to `flit_core.buildapi`_ from `setuptools.build_meta`_. Building with `setuptools` through `setup.py` is still supported for distributions that need it. See `Issue #72`_.
+
+Improvements:
+
+- `Issue #72`_/`Pull #73`_: Please consider switching the build-system to flit_core to ease setuptools bootstrap.
+
+
+.. _`flit_core.buildapi`: https://flit.pypa.io/en/latest/index.html
+.. _`Issue #72`: https://github.com/cpburnz/python-pathspec/issues/72
+.. _`Pull #73`: https://github.com/cpburnz/python-pathspec/pull/73
+
+
+0.10.3 (2022-12-09)
+-------------------
+
+New features:
+
+- Added utility function `pathspec.util.append_dir_sep()` to aid in distinguishing between directories and files on the file-system. See `Issue #65`_.
+
+Bug fixes:
+
+- `Issue #66`_/`Pull #67`_: Package not marked as py.typed.
+- `Issue #68`_: Exports are considered private.
+- `Issue #70`_/`Pull #71`_: 'Self' string literal type is Unknown in pyright.
+
+Improvements:
+
+- `Issue #65`_: Checking directories via match_file() does not work on Path objects.
+
+
+.. _`Issue #65`: https://github.com/cpburnz/python-pathspec/issues/65
+.. _`Issue #66`: https://github.com/cpburnz/python-pathspec/issues/66
+.. _`Pull #67`: https://github.com/cpburnz/python-pathspec/pull/67
+.. _`Issue #68`: https://github.com/cpburnz/python-pathspec/issues/68
+.. _`Issue #70`: https://github.com/cpburnz/python-pathspec/issues/70
+.. _`Pull #71`: https://github.com/cpburnz/python-pathspec/pull/71
+
+
+0.10.2 (2022-11-12)
+-------------------
+
+Bug fixes:
+
+- Fix failing tests on Windows.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_files()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_files()`.
+- `Issue #64`_: IndexError with my .gitignore file when trying to build a Python package.
+
+Improvements:
+
+- `Pull #58`_: CI: add GitHub Actions test workflow.
+
+
+.. _`Pull #58`: https://github.com/cpburnz/python-pathspec/pull/58
+.. _`Issue #64`: https://github.com/cpburnz/python-pathspec/issues/64
+
+
+0.10.1 (2022-09-02)
+-------------------
+
+Bug fixes:
+
+- Fix documentation on `pathspec.pattern.RegexPattern.match_file()`.
+- `Pull #60`_: Remove redundant wheel dep from pyproject.toml.
+- `Issue #61`_: Dist failure for Fedora, CentOS, EPEL.
+- `Issue #62`_: Since version 0.10.0 pure wildcard does not work in some cases.
+
+Improvements:
+
+- Restore support for legacy installations using `setup.py`. See `Issue #61`_.
+
+
+.. _`Pull #60`: https://github.com/cpburnz/python-pathspec/pull/60
+.. _`Issue #61`: https://github.com/cpburnz/python-pathspec/issues/61
+.. _`Issue #62`: https://github.com/cpburnz/python-pathspec/issues/62
+
+
+0.10.0 (2022-08-30)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 2.7, 3.5, 3.6. See `Issue #47`_.
+- The *gitwildmatch* pattern `dir/*` is now handled the same as `dir/`. This means `dir/*` will now match all descendants rather than only direct children. See `Issue #19`_.
+- Added `pathspec.GitIgnoreSpec` class (see new features).
+- Changed build system to `pyproject.toml`_ and build backend to `setuptools.build_meta`_ which may have unforeseen consequences.
+- Renamed GitHub project from `python-path-specification`_ to `python-pathspec`_. See `Issue #35`_.
+
+API changes:
+
+- Deprecated: `pathspec.util.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.util.normalize_files()` is no longer used.
+- Deprecated: `pathspec.util.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+- Deprecated: `pathspec.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+-      Deprecated: `pathspec.pattern.Pattern.match()` is no longer used. Use or implement
+       `pathspec.pattern.Pattern.match_file()`.
+
+New features:
+
+- Added class `pathspec.gitignore.GitIgnoreSpec` (with alias `pathspec.GitIgnoreSpec`) to implement *gitignore* behavior not possible with standard `PathSpec` class. The particular *gitignore* behavior implemented is prioritizing patterns matching the file directly over matching an ancestor directory.
+
+Bug fixes:
+
+- `Issue #19`_: Files inside an ignored sub-directory are not matched.
+- `Issue #41`_: Incorrectly (?) matches files inside directories that do match.
+- `Pull #51`_: Refactor deprecated unittest aliases for Python 3.11 compatibility.
+- `Issue #53`_: Symlink pathspec_meta.py breaks Windows.
+- `Issue #54`_: test_util.py uses os.symlink which can fail on Windows.
+- `Issue #55`_: Backslashes at start of pattern not handled correctly.
+- `Pull #56`_: pyproject.toml: include subpackages in setuptools config
+- `Issue #57`_: `!` doesn't exclude files in directories if the pattern doesn't have a trailing slash.
+
+Improvements:
+
+- Support Python 3.10, 3.11.
+- Modernize code to Python 3.7.
+- `Issue #52`_: match_files() is not a pure generator function, and it impacts tree_*() gravely.
+
+
+.. _`python-path-specification`: https://github.com/cpburnz/python-path-specification
+.. _`python-pathspec`: https://github.com/cpburnz/python-pathspec
+.. _`pyproject.toml`: https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/
+.. _`setuptools.build_meta`: https://setuptools.pypa.io/en/latest/build_meta.html
+.. _`Issue #19`: https://github.com/cpburnz/python-pathspec/issues/19
+.. _`Issue #35`: https://github.com/cpburnz/python-pathspec/issues/35
+.. _`Issue #41`: https://github.com/cpburnz/python-pathspec/issues/41
+.. _`Issue #47`: https://github.com/cpburnz/python-pathspec/issues/47
+.. _`Pull #51`: https://github.com/cpburnz/python-pathspec/pull/51
+.. _`Issue #52`: https://github.com/cpburnz/python-pathspec/issues/52
+.. _`Issue #53`: https://github.com/cpburnz/python-pathspec/issues/53
+.. _`Issue #54`: https://github.com/cpburnz/python-pathspec/issues/54
+.. _`Issue #55`: https://github.com/cpburnz/python-pathspec/issues/55
+.. _`Pull #56`: https://github.com/cpburnz/python-pathspec/pull/56
+.. _`Issue #57`: https://github.com/cpburnz/python-pathspec/issues/57
+
+
+0.9.0 (2021-07-17)
+------------------
+
+- `Issue #44`_/`Pull #50`_: Raise `GitWildMatchPatternError` for invalid git patterns.
+- `Pull #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 #44`: https://github.com/cpburnz/python-pathspec/issues/44
+.. _`Pull #45`: https://github.com/cpburnz/python-pathspec/pull/45
+.. _`Issue #46`: https://github.com/cpburnz/python-pathspec/issues/46
+.. _`Pull #50`: https://github.com/cpburnz/python-pathspec/pull/50
+
+
+0.8.1 (2020-11-07)
+------------------
+
+- `Pull #43`_: Add support for addition operator.
+
+.. _`Pull #43`: https://github.com/cpburnz/python-pathspec/pull/43
+
+
+0.8.0 (2020-04-09)
+------------------
+
+- `Issue #30`_: Expose what patterns matched paths. Added `util.detailed_match_files()`.
+- `Issue #31`_: `match_tree()` doesn't return symlinks.
+- `Issue #34`_: Support `pathlib.Path`\ s.
+- Add `PathSpec.match_tree_entries` and `util.iter_tree_entries()` to support directories and symlinks.
+- API change: `match_tree()` has been renamed to `match_tree_files()`. The old name `match_tree()` is still available as an alias.
+- API change: `match_tree_files()` now returns symlinks. This is a bug fix but it will change the returned results.
+
+.. _`Issue #30`: https://github.com/cpburnz/python-pathspec/issues/30
+.. _`Issue #31`: https://github.com/cpburnz/python-pathspec/issues/31
+.. _`Issue #34`: https://github.com/cpburnz/python-pathspec/issues/34
+
+
+0.7.0 (2019-12-27)
+------------------
+
+- `Pull #28`_: Add support for Python 3.8, and drop Python 3.4.
+- `Pull #29`_: Publish bdist wheel.
+
+.. _`Pull #28`: https://github.com/cpburnz/python-pathspec/pull/28
+.. _`Pull #29`: https://github.com/cpburnz/python-pathspec/pull/29
+
+
+0.6.0 (2019-10-03)
+------------------
+
+- `Pull #24`_: Drop support for Python 2.6, 3.2, and 3.3.
+- `Pull #25`_: Update README.rst.
+- `Pull #26`_: Method to escape gitwildmatch.
+
+.. _`Pull #24`: https://github.com/cpburnz/python-pathspec/pull/24
+.. _`Pull #25`: https://github.com/cpburnz/python-pathspec/pull/25
+.. _`Pull #26`: https://github.com/cpburnz/python-pathspec/pull/26
+
+
+0.5.9 (2018-09-15)
+------------------
+
+- Fixed file system error handling.
+
+
+0.5.8 (2018-09-15)
+------------------
+
+- Improved type checking.
+- Created scripts to test Python 2.6 because Tox removed support for it.
+- Improved byte string handling in Python 3.
+- `Issue #22`_: Handle dangling symlinks.
+
+.. _`Issue #22`: https://github.com/cpburnz/python-pathspec/issues/22
+
+
+0.5.7 (2018-08-14)
+------------------
+
+- `Issue #21`_: Fix collections deprecation warning.
+
+.. _`Issue #21`: https://github.com/cpburnz/python-pathspec/issues/21
+
+
+0.5.6 (2018-04-06)
+------------------
+
+- Improved unit tests.
+- Improved type checking.
+- `Issue #20`_: Support current directory prefix.
+
+.. _`Issue #20`: https://github.com/cpburnz/python-pathspec/issues/20
+
+
+0.5.5 (2017-09-09)
+------------------
+
+- Add documentation link to README.
+
+
+0.5.4 (2017-09-09)
+------------------
+
+- `Pull #17`_: Add link to Ruby implementation of *pathspec*.
+- Add sphinx documentation.
+
+.. _`Pull #17`: https://github.com/cpburnz/python-pathspec/pull/17
+
+
+0.5.3 (2017-07-01)
+------------------
+
+- `Issue #14`_: Fix byte strings for Python 3.
+- `Pull #15`_: Include "LICENSE" in source package.
+- `Issue #16`_: Support Python 2.6.
+
+.. _`Issue #14`: https://github.com/cpburnz/python-pathspec/issues/14
+.. _`Pull #15`: https://github.com/cpburnz/python-pathspec/pull/15
+.. _`Issue #16`: https://github.com/cpburnz/python-pathspec/issues/16
+
+
+0.5.2 (2017-04-04)
+------------------
+
+- Fixed change log.
+
+
+0.5.1 (2017-04-04)
+------------------
+
+- `Pull #13`_: Add equality methods to `PathSpec` and `RegexPattern`.
+
+.. _`Pull #13`: https://github.com/cpburnz/python-pathspec/pull/13
+
+
+0.5.0 (2016-08-22)
+------------------
+
+- `Issue #12`_: Add `PathSpec.match_file()`.
+- Renamed `gitignore.GitIgnorePattern` to `patterns.gitwildmatch.GitWildMatchPattern`.
+- Deprecated `gitignore.GitIgnorePattern`.
+
+.. _`Issue #12`: https://github.com/cpburnz/python-pathspec/issues/12
+
+
+0.4.0 (2016-07-15)
+------------------
+
+- `Issue #11`_: Support converting patterns into regular expressions without compiling them.
+- API change: Subclasses of `RegexPattern` should implement `pattern_to_regex()`.
+
+.. _`Issue #11`: https://github.com/cpburnz/python-pathspec/issues/11
+
+
+0.3.4 (2015-08-24)
+------------------
+
+- `Pull #7`_: Fixed non-recursive links.
+- `Pull #8`_: Fixed edge cases in gitignore patterns.
+- `Pull #9`_: Fixed minor usage documentation.
+- Fixed recursion detection.
+- Fixed trivial incompatibility with Python 3.2.
+
+.. _`Pull #7`: https://github.com/cpburnz/python-pathspec/pull/7
+.. _`Pull #8`: https://github.com/cpburnz/python-pathspec/pull/8
+.. _`Pull #9`: https://github.com/cpburnz/python-pathspec/pull/9
+
+
+0.3.3 (2014-11-21)
+------------------
+
+- Improved documentation.
+
+
+0.3.2 (2014-11-08)
+------------------
+
+- `Pull #5`_: Use tox for testing.
+- `Issue #6`_: Fixed matching Windows paths.
+- Improved documentation.
+- API change: `spec.match_tree()` and `spec.match_files()` now return iterators instead of sets.
+
+.. _`Pull #5`: https://github.com/cpburnz/python-pathspec/pull/5
+.. _`Issue #6`: https://github.com/cpburnz/python-pathspec/issues/6
+
+
+0.3.1 (2014-09-17)
+------------------
+
+- Updated README.
+
+
+0.3.0 (2014-09-17)
+------------------
+
+- `Pull #3`_: Fixed trailing slash in gitignore patterns.
+- `Pull #4`_: Fixed test for trailing slash in gitignore patterns.
+- Added registered patterns.
+
+.. _`Pull #3`: https://github.com/cpburnz/python-pathspec/pull/3
+.. _`Pull #4`: https://github.com/cpburnz/python-pathspec/pull/4
+
+
+0.2.2 (2013-12-17)
+------------------
+
+- Fixed setup.py.
+
+
+0.2.1 (2013-12-17)
+------------------
+
+- Added tests.
+- Fixed comment gitignore patterns.
+- Fixed relative path gitignore patterns.
+
+
+0.2.0 (2013-12-07)
+------------------
+
+- Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..14e2f77
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644 (file)
index 0000000..5c0ebdf
--- /dev/null
@@ -0,0 +1,11 @@
+include *.in
+include *.ini
+include *.py
+include *.rst
+include pathspec/py.typed
+include LICENSE
+recursive-include doc *
+recursive-include tests *
+prune dev
+prune doc/build
+global-exclude *.pyc
diff --git a/PKG-INFO b/PKG-INFO
new file mode 100644 (file)
index 0000000..9cef724
--- /dev/null
+++ b/PKG-INFO
@@ -0,0 +1,647 @@
+Metadata-Version: 2.1
+Name: pathspec
+Version: 0.12.1
+Summary: Utility library for gitignore style pattern matching of file paths.
+Author-email: "Caleb P. Burns" <cpburnz@gmail.com>
+Requires-Python: >=3.8
+Description-Content-Type: text/x-rst
+Classifier: Development Status :: 4 - Beta
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Topic :: Utilities
+Project-URL: Documentation, https://python-path-specification.readthedocs.io/en/latest/index.html
+Project-URL: Issue Tracker, https://github.com/cpburnz/python-pathspec/issues
+Project-URL: Source Code, https://github.com/cpburnz/python-pathspec
+
+
+PathSpec
+========
+
+*pathspec* is a utility library for pattern matching of file paths. So
+far this only includes Git's wildmatch pattern matching which itself is
+derived from Rsync's wildmatch. Git uses wildmatch for its `gitignore`_
+files.
+
+.. _`gitignore`: http://git-scm.com/docs/gitignore
+
+
+Tutorial
+--------
+
+Say you have a "Projects" directory and you want to back it up, but only
+certain files, and ignore others depending on certain conditions::
+
+       >>> import pathspec
+       >>> # The gitignore-style patterns for files to select, but we're including
+       >>> # instead of ignoring.
+       >>> spec_text = """
+       ...
+       ... # This is a comment because the line begins with a hash: "#"
+       ...
+       ... # Include several project directories (and all descendants) relative to
+       ... # the current directory. To reference a directory you must end with a
+       ... # slash: "/"
+       ... /project-a/
+       ... /project-b/
+       ... /project-c/
+       ...
+       ... # Patterns can be negated by prefixing with exclamation mark: "!"
+       ...
+       ... # Ignore temporary files beginning or ending with "~" and ending with
+       ... # ".swp".
+       ... !~*
+       ... !*~
+       ... !*.swp
+       ...
+       ... # These are python projects so ignore compiled python files from
+       ... # testing.
+       ... !*.pyc
+       ...
+       ... # Ignore the build directories but only directly under the project
+       ... # directories.
+       ... !/*/build/
+       ...
+       ... """
+
+We want to use the ``GitWildMatchPattern`` class to compile our patterns. The
+``PathSpec`` class provides an interface around pattern implementations::
+
+       >>> spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+
+That may be a mouthful but it allows for additional patterns to be implemented
+in the future without them having to deal with anything but matching the paths
+sent to them. ``GitWildMatchPattern`` is the implementation of the actual
+pattern which internally gets converted into a regular expression. ``PathSpec``
+is a simple wrapper around a list of compiled patterns.
+
+To make things simpler, we can use the registered name for a pattern class
+instead of always having to provide a reference to the class itself. The
+``GitWildMatchPattern`` class is registered as **gitwildmatch**::
+
+       >>> spec = pathspec.PathSpec.from_lines('gitwildmatch', spec_text.splitlines())
+
+If we wanted to manually compile the patterns we can just do the following::
+
+       >>> patterns = map(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+       >>> spec = PathSpec(patterns)
+
+``PathSpec.from_lines()`` is simply a class method which does just that.
+
+If you want to load the patterns from file, you can pass the file instance
+directly as well::
+
+       >>> with open('patterns.list', 'r') as fh:
+       >>>     spec = pathspec.PathSpec.from_lines('gitwildmatch', fh)
+
+You can perform matching on a whole directory tree with::
+
+       >>> matches = spec.match_tree('path/to/directory')
+
+Or you can perform matching on a specific set of file paths with::
+
+       >>> matches = spec.match_files(file_paths)
+
+Or check to see if an individual file matches::
+
+       >>> is_matched = spec.match_file(file_path)
+
+There is a specialized class, ``pathspec.GitIgnoreSpec``, which more closely
+implements the behavior of **gitignore**. This uses ``GitWildMatchPattern``
+pattern by default and handles some edge cases differently from the generic
+``PathSpec`` class. ``GitIgnoreSpec`` can be used without specifying the pattern
+factory::
+
+       >>> spec = pathspec.GitIgnoreSpec.from_lines(spec_text.splitlines())
+
+
+License
+-------
+
+*pathspec* is licensed under the `Mozilla Public License Version 2.0`_. See
+`LICENSE`_ or the `FAQ`_ for more information.
+
+In summary, you may use *pathspec* with any closed or open source project
+without affecting the license of the larger work so long as you:
+
+- give credit where credit is due,
+
+- and release any custom changes made to *pathspec*.
+
+.. _`Mozilla Public License Version 2.0`: http://www.mozilla.org/MPL/2.0
+.. _`LICENSE`: LICENSE
+.. _`FAQ`: http://www.mozilla.org/MPL/2.0/FAQ.html
+
+
+Source
+------
+
+The source code for *pathspec* is available from the GitHub repo
+`cpburnz/python-pathspec`_.
+
+.. _`cpburnz/python-pathspec`: https://github.com/cpburnz/python-pathspec
+
+
+Installation
+------------
+
+*pathspec* is available for install through `PyPI`_::
+
+       pip install pathspec
+
+*pathspec* can also be built from source. The following packages will be
+required:
+
+- `build`_ (>=0.6.0)
+
+*pathspec* can then be built and installed with::
+
+       python -m build
+       pip install dist/pathspec-*-py3-none-any.whl
+
+.. _`PyPI`: http://pypi.python.org/pypi/pathspec
+.. _`build`: https://pypi.org/project/build/
+
+
+Documentation
+-------------
+
+Documentation for *pathspec* is available on `Read the Docs`_.
+
+.. _`Read the Docs`: https://python-path-specification.readthedocs.io
+
+
+Other Languages
+---------------
+
+The related project `pathspec-ruby`_ (by *highb*) provides a similar library as
+a `Ruby gem`_.
+
+.. _`pathspec-ruby`: https://github.com/highb/pathspec-ruby
+.. _`Ruby gem`: https://rubygems.org/gems/pathspec
+
+
+
+Change History
+==============
+
+
+0.12.1 (2023-12-10)
+-------------------
+
+Bug fixes:
+
+- `Issue #84`_: PathSpec.match_file() returns None since 0.12.0.
+
+
+.. _`Issue #84`: https://github.com/cpburnz/python-pathspec/issues/84
+
+
+0.12.0 (2023-12-09)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 3.7. See `Pull #82`_.
+
+
+API changes:
+
+- Signature of protected method `pathspec.pathspec.PathSpec._match_file()` (with a leading underscore) has been changed from `def _match_file(patterns: Iterable[Pattern], file: str) -> bool` to `def _match_file(patterns: Iterable[Tuple[int, Pattern]], file: str) -> Tuple[Optional[bool], Optional[int]]`.
+
+New features:
+
+- Added `pathspec.pathspec.PathSpec.check_*()` methods. These methods behave similarly to `.match_*()` but return additional information in the `pathspec.util.CheckResult` objects (e.g., `CheckResult.index` indicates the index of the last pattern that matched the file).
+- Added `pathspec.pattern.RegexPattern.pattern` attribute which stores the original, uncompiled pattern.
+
+Bug fixes:
+
+- `Issue #81`_: GitIgnoreSpec behaviors differ from git.
+- `Pull #83`_: Fix ReadTheDocs builds.
+
+Improvements:
+
+- Mark Python 3.12 as supported. See `Pull #82`_.
+- Improve test debugging.
+- Improve type hint on *on_error* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Improve type hint on *on_error* parameter on `pathspec.util.iter_tree_entries()`.
+
+
+.. _`Issue #81`: https://github.com/cpburnz/python-pathspec/issues/81
+.. _`Pull #82`: https://github.com/cpburnz/python-pathspec/pull/82
+.. _`Pull #83`: https://github.com/cpburnz/python-pathspec/pull/83
+
+
+0.11.2 (2023-07-28)
+-------------------
+
+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
+- `Issue #77`_/`Pull #78`_: Negate with caret symbol as with the exclamation mark.
+
+
+.. _`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)
+-------------------
+
+Bug fixes:
+
+- `Issue #74`_: Include directory should override exclude file.
+
+Improvements:
+
+- `Pull #75`_: Fix partially unknown PathLike type.
+- Convert `os.PathLike` to a string properly using `os.fspath`.
+
+
+.. _`Issue #74`: https://github.com/cpburnz/python-pathspec/issues/74
+.. _`Pull #75`: https://github.com/cpburnz/python-pathspec/pull/75
+
+
+0.11.0 (2023-01-24)
+-------------------
+
+Major changes:
+
+- Changed build backend to `flit_core.buildapi`_ from `setuptools.build_meta`_. Building with `setuptools` through `setup.py` is still supported for distributions that need it. See `Issue #72`_.
+
+Improvements:
+
+- `Issue #72`_/`Pull #73`_: Please consider switching the build-system to flit_core to ease setuptools bootstrap.
+
+
+.. _`flit_core.buildapi`: https://flit.pypa.io/en/latest/index.html
+.. _`Issue #72`: https://github.com/cpburnz/python-pathspec/issues/72
+.. _`Pull #73`: https://github.com/cpburnz/python-pathspec/pull/73
+
+
+0.10.3 (2022-12-09)
+-------------------
+
+New features:
+
+- Added utility function `pathspec.util.append_dir_sep()` to aid in distinguishing between directories and files on the file-system. See `Issue #65`_.
+
+Bug fixes:
+
+- `Issue #66`_/`Pull #67`_: Package not marked as py.typed.
+- `Issue #68`_: Exports are considered private.
+- `Issue #70`_/`Pull #71`_: 'Self' string literal type is Unknown in pyright.
+
+Improvements:
+
+- `Issue #65`_: Checking directories via match_file() does not work on Path objects.
+
+
+.. _`Issue #65`: https://github.com/cpburnz/python-pathspec/issues/65
+.. _`Issue #66`: https://github.com/cpburnz/python-pathspec/issues/66
+.. _`Pull #67`: https://github.com/cpburnz/python-pathspec/pull/67
+.. _`Issue #68`: https://github.com/cpburnz/python-pathspec/issues/68
+.. _`Issue #70`: https://github.com/cpburnz/python-pathspec/issues/70
+.. _`Pull #71`: https://github.com/cpburnz/python-pathspec/pull/71
+
+
+0.10.2 (2022-11-12)
+-------------------
+
+Bug fixes:
+
+- Fix failing tests on Windows.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_files()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_files()`.
+- `Issue #64`_: IndexError with my .gitignore file when trying to build a Python package.
+
+Improvements:
+
+- `Pull #58`_: CI: add GitHub Actions test workflow.
+
+
+.. _`Pull #58`: https://github.com/cpburnz/python-pathspec/pull/58
+.. _`Issue #64`: https://github.com/cpburnz/python-pathspec/issues/64
+
+
+0.10.1 (2022-09-02)
+-------------------
+
+Bug fixes:
+
+- Fix documentation on `pathspec.pattern.RegexPattern.match_file()`.
+- `Pull #60`_: Remove redundant wheel dep from pyproject.toml.
+- `Issue #61`_: Dist failure for Fedora, CentOS, EPEL.
+- `Issue #62`_: Since version 0.10.0 pure wildcard does not work in some cases.
+
+Improvements:
+
+- Restore support for legacy installations using `setup.py`. See `Issue #61`_.
+
+
+.. _`Pull #60`: https://github.com/cpburnz/python-pathspec/pull/60
+.. _`Issue #61`: https://github.com/cpburnz/python-pathspec/issues/61
+.. _`Issue #62`: https://github.com/cpburnz/python-pathspec/issues/62
+
+
+0.10.0 (2022-08-30)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 2.7, 3.5, 3.6. See `Issue #47`_.
+- The *gitwildmatch* pattern `dir/*` is now handled the same as `dir/`. This means `dir/*` will now match all descendants rather than only direct children. See `Issue #19`_.
+- Added `pathspec.GitIgnoreSpec` class (see new features).
+- Changed build system to `pyproject.toml`_ and build backend to `setuptools.build_meta`_ which may have unforeseen consequences.
+- Renamed GitHub project from `python-path-specification`_ to `python-pathspec`_. See `Issue #35`_.
+
+API changes:
+
+- Deprecated: `pathspec.util.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.util.normalize_files()` is no longer used.
+- Deprecated: `pathspec.util.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+- Deprecated: `pathspec.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+-      Deprecated: `pathspec.pattern.Pattern.match()` is no longer used. Use or implement
+       `pathspec.pattern.Pattern.match_file()`.
+
+New features:
+
+- Added class `pathspec.gitignore.GitIgnoreSpec` (with alias `pathspec.GitIgnoreSpec`) to implement *gitignore* behavior not possible with standard `PathSpec` class. The particular *gitignore* behavior implemented is prioritizing patterns matching the file directly over matching an ancestor directory.
+
+Bug fixes:
+
+- `Issue #19`_: Files inside an ignored sub-directory are not matched.
+- `Issue #41`_: Incorrectly (?) matches files inside directories that do match.
+- `Pull #51`_: Refactor deprecated unittest aliases for Python 3.11 compatibility.
+- `Issue #53`_: Symlink pathspec_meta.py breaks Windows.
+- `Issue #54`_: test_util.py uses os.symlink which can fail on Windows.
+- `Issue #55`_: Backslashes at start of pattern not handled correctly.
+- `Pull #56`_: pyproject.toml: include subpackages in setuptools config
+- `Issue #57`_: `!` doesn't exclude files in directories if the pattern doesn't have a trailing slash.
+
+Improvements:
+
+- Support Python 3.10, 3.11.
+- Modernize code to Python 3.7.
+- `Issue #52`_: match_files() is not a pure generator function, and it impacts tree_*() gravely.
+
+
+.. _`python-path-specification`: https://github.com/cpburnz/python-path-specification
+.. _`python-pathspec`: https://github.com/cpburnz/python-pathspec
+.. _`pyproject.toml`: https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/
+.. _`setuptools.build_meta`: https://setuptools.pypa.io/en/latest/build_meta.html
+.. _`Issue #19`: https://github.com/cpburnz/python-pathspec/issues/19
+.. _`Issue #35`: https://github.com/cpburnz/python-pathspec/issues/35
+.. _`Issue #41`: https://github.com/cpburnz/python-pathspec/issues/41
+.. _`Issue #47`: https://github.com/cpburnz/python-pathspec/issues/47
+.. _`Pull #51`: https://github.com/cpburnz/python-pathspec/pull/51
+.. _`Issue #52`: https://github.com/cpburnz/python-pathspec/issues/52
+.. _`Issue #53`: https://github.com/cpburnz/python-pathspec/issues/53
+.. _`Issue #54`: https://github.com/cpburnz/python-pathspec/issues/54
+.. _`Issue #55`: https://github.com/cpburnz/python-pathspec/issues/55
+.. _`Pull #56`: https://github.com/cpburnz/python-pathspec/pull/56
+.. _`Issue #57`: https://github.com/cpburnz/python-pathspec/issues/57
+
+
+0.9.0 (2021-07-17)
+------------------
+
+- `Issue #44`_/`Pull #50`_: Raise `GitWildMatchPatternError` for invalid git patterns.
+- `Pull #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 #44`: https://github.com/cpburnz/python-pathspec/issues/44
+.. _`Pull #45`: https://github.com/cpburnz/python-pathspec/pull/45
+.. _`Issue #46`: https://github.com/cpburnz/python-pathspec/issues/46
+.. _`Pull #50`: https://github.com/cpburnz/python-pathspec/pull/50
+
+
+0.8.1 (2020-11-07)
+------------------
+
+- `Pull #43`_: Add support for addition operator.
+
+.. _`Pull #43`: https://github.com/cpburnz/python-pathspec/pull/43
+
+
+0.8.0 (2020-04-09)
+------------------
+
+- `Issue #30`_: Expose what patterns matched paths. Added `util.detailed_match_files()`.
+- `Issue #31`_: `match_tree()` doesn't return symlinks.
+- `Issue #34`_: Support `pathlib.Path`\ s.
+- Add `PathSpec.match_tree_entries` and `util.iter_tree_entries()` to support directories and symlinks.
+- API change: `match_tree()` has been renamed to `match_tree_files()`. The old name `match_tree()` is still available as an alias.
+- API change: `match_tree_files()` now returns symlinks. This is a bug fix but it will change the returned results.
+
+.. _`Issue #30`: https://github.com/cpburnz/python-pathspec/issues/30
+.. _`Issue #31`: https://github.com/cpburnz/python-pathspec/issues/31
+.. _`Issue #34`: https://github.com/cpburnz/python-pathspec/issues/34
+
+
+0.7.0 (2019-12-27)
+------------------
+
+- `Pull #28`_: Add support for Python 3.8, and drop Python 3.4.
+- `Pull #29`_: Publish bdist wheel.
+
+.. _`Pull #28`: https://github.com/cpburnz/python-pathspec/pull/28
+.. _`Pull #29`: https://github.com/cpburnz/python-pathspec/pull/29
+
+
+0.6.0 (2019-10-03)
+------------------
+
+- `Pull #24`_: Drop support for Python 2.6, 3.2, and 3.3.
+- `Pull #25`_: Update README.rst.
+- `Pull #26`_: Method to escape gitwildmatch.
+
+.. _`Pull #24`: https://github.com/cpburnz/python-pathspec/pull/24
+.. _`Pull #25`: https://github.com/cpburnz/python-pathspec/pull/25
+.. _`Pull #26`: https://github.com/cpburnz/python-pathspec/pull/26
+
+
+0.5.9 (2018-09-15)
+------------------
+
+- Fixed file system error handling.
+
+
+0.5.8 (2018-09-15)
+------------------
+
+- Improved type checking.
+- Created scripts to test Python 2.6 because Tox removed support for it.
+- Improved byte string handling in Python 3.
+- `Issue #22`_: Handle dangling symlinks.
+
+.. _`Issue #22`: https://github.com/cpburnz/python-pathspec/issues/22
+
+
+0.5.7 (2018-08-14)
+------------------
+
+- `Issue #21`_: Fix collections deprecation warning.
+
+.. _`Issue #21`: https://github.com/cpburnz/python-pathspec/issues/21
+
+
+0.5.6 (2018-04-06)
+------------------
+
+- Improved unit tests.
+- Improved type checking.
+- `Issue #20`_: Support current directory prefix.
+
+.. _`Issue #20`: https://github.com/cpburnz/python-pathspec/issues/20
+
+
+0.5.5 (2017-09-09)
+------------------
+
+- Add documentation link to README.
+
+
+0.5.4 (2017-09-09)
+------------------
+
+- `Pull #17`_: Add link to Ruby implementation of *pathspec*.
+- Add sphinx documentation.
+
+.. _`Pull #17`: https://github.com/cpburnz/python-pathspec/pull/17
+
+
+0.5.3 (2017-07-01)
+------------------
+
+- `Issue #14`_: Fix byte strings for Python 3.
+- `Pull #15`_: Include "LICENSE" in source package.
+- `Issue #16`_: Support Python 2.6.
+
+.. _`Issue #14`: https://github.com/cpburnz/python-pathspec/issues/14
+.. _`Pull #15`: https://github.com/cpburnz/python-pathspec/pull/15
+.. _`Issue #16`: https://github.com/cpburnz/python-pathspec/issues/16
+
+
+0.5.2 (2017-04-04)
+------------------
+
+- Fixed change log.
+
+
+0.5.1 (2017-04-04)
+------------------
+
+- `Pull #13`_: Add equality methods to `PathSpec` and `RegexPattern`.
+
+.. _`Pull #13`: https://github.com/cpburnz/python-pathspec/pull/13
+
+
+0.5.0 (2016-08-22)
+------------------
+
+- `Issue #12`_: Add `PathSpec.match_file()`.
+- Renamed `gitignore.GitIgnorePattern` to `patterns.gitwildmatch.GitWildMatchPattern`.
+- Deprecated `gitignore.GitIgnorePattern`.
+
+.. _`Issue #12`: https://github.com/cpburnz/python-pathspec/issues/12
+
+
+0.4.0 (2016-07-15)
+------------------
+
+- `Issue #11`_: Support converting patterns into regular expressions without compiling them.
+- API change: Subclasses of `RegexPattern` should implement `pattern_to_regex()`.
+
+.. _`Issue #11`: https://github.com/cpburnz/python-pathspec/issues/11
+
+
+0.3.4 (2015-08-24)
+------------------
+
+- `Pull #7`_: Fixed non-recursive links.
+- `Pull #8`_: Fixed edge cases in gitignore patterns.
+- `Pull #9`_: Fixed minor usage documentation.
+- Fixed recursion detection.
+- Fixed trivial incompatibility with Python 3.2.
+
+.. _`Pull #7`: https://github.com/cpburnz/python-pathspec/pull/7
+.. _`Pull #8`: https://github.com/cpburnz/python-pathspec/pull/8
+.. _`Pull #9`: https://github.com/cpburnz/python-pathspec/pull/9
+
+
+0.3.3 (2014-11-21)
+------------------
+
+- Improved documentation.
+
+
+0.3.2 (2014-11-08)
+------------------
+
+- `Pull #5`_: Use tox for testing.
+- `Issue #6`_: Fixed matching Windows paths.
+- Improved documentation.
+- API change: `spec.match_tree()` and `spec.match_files()` now return iterators instead of sets.
+
+.. _`Pull #5`: https://github.com/cpburnz/python-pathspec/pull/5
+.. _`Issue #6`: https://github.com/cpburnz/python-pathspec/issues/6
+
+
+0.3.1 (2014-09-17)
+------------------
+
+- Updated README.
+
+
+0.3.0 (2014-09-17)
+------------------
+
+- `Pull #3`_: Fixed trailing slash in gitignore patterns.
+- `Pull #4`_: Fixed test for trailing slash in gitignore patterns.
+- Added registered patterns.
+
+.. _`Pull #3`: https://github.com/cpburnz/python-pathspec/pull/3
+.. _`Pull #4`: https://github.com/cpburnz/python-pathspec/pull/4
+
+
+0.2.2 (2013-12-17)
+------------------
+
+- Fixed setup.py.
+
+
+0.2.1 (2013-12-17)
+------------------
+
+- Added tests.
+- Fixed comment gitignore patterns.
+- Fixed relative path gitignore patterns.
+
+
+0.2.0 (2013-12-07)
+------------------
+
+- Initial release.
+
diff --git a/README-dist.rst b/README-dist.rst
new file mode 100644 (file)
index 0000000..e7752fc
--- /dev/null
@@ -0,0 +1,620 @@
+
+PathSpec
+========
+
+*pathspec* is a utility library for pattern matching of file paths. So
+far this only includes Git's wildmatch pattern matching which itself is
+derived from Rsync's wildmatch. Git uses wildmatch for its `gitignore`_
+files.
+
+.. _`gitignore`: http://git-scm.com/docs/gitignore
+
+
+Tutorial
+--------
+
+Say you have a "Projects" directory and you want to back it up, but only
+certain files, and ignore others depending on certain conditions::
+
+       >>> import pathspec
+       >>> # The gitignore-style patterns for files to select, but we're including
+       >>> # instead of ignoring.
+       >>> spec_text = """
+       ...
+       ... # This is a comment because the line begins with a hash: "#"
+       ...
+       ... # Include several project directories (and all descendants) relative to
+       ... # the current directory. To reference a directory you must end with a
+       ... # slash: "/"
+       ... /project-a/
+       ... /project-b/
+       ... /project-c/
+       ...
+       ... # Patterns can be negated by prefixing with exclamation mark: "!"
+       ...
+       ... # Ignore temporary files beginning or ending with "~" and ending with
+       ... # ".swp".
+       ... !~*
+       ... !*~
+       ... !*.swp
+       ...
+       ... # These are python projects so ignore compiled python files from
+       ... # testing.
+       ... !*.pyc
+       ...
+       ... # Ignore the build directories but only directly under the project
+       ... # directories.
+       ... !/*/build/
+       ...
+       ... """
+
+We want to use the ``GitWildMatchPattern`` class to compile our patterns. The
+``PathSpec`` class provides an interface around pattern implementations::
+
+       >>> spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+
+That may be a mouthful but it allows for additional patterns to be implemented
+in the future without them having to deal with anything but matching the paths
+sent to them. ``GitWildMatchPattern`` is the implementation of the actual
+pattern which internally gets converted into a regular expression. ``PathSpec``
+is a simple wrapper around a list of compiled patterns.
+
+To make things simpler, we can use the registered name for a pattern class
+instead of always having to provide a reference to the class itself. The
+``GitWildMatchPattern`` class is registered as **gitwildmatch**::
+
+       >>> spec = pathspec.PathSpec.from_lines('gitwildmatch', spec_text.splitlines())
+
+If we wanted to manually compile the patterns we can just do the following::
+
+       >>> patterns = map(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+       >>> spec = PathSpec(patterns)
+
+``PathSpec.from_lines()`` is simply a class method which does just that.
+
+If you want to load the patterns from file, you can pass the file instance
+directly as well::
+
+       >>> with open('patterns.list', 'r') as fh:
+       >>>     spec = pathspec.PathSpec.from_lines('gitwildmatch', fh)
+
+You can perform matching on a whole directory tree with::
+
+       >>> matches = spec.match_tree('path/to/directory')
+
+Or you can perform matching on a specific set of file paths with::
+
+       >>> matches = spec.match_files(file_paths)
+
+Or check to see if an individual file matches::
+
+       >>> is_matched = spec.match_file(file_path)
+
+There is a specialized class, ``pathspec.GitIgnoreSpec``, which more closely
+implements the behavior of **gitignore**. This uses ``GitWildMatchPattern``
+pattern by default and handles some edge cases differently from the generic
+``PathSpec`` class. ``GitIgnoreSpec`` can be used without specifying the pattern
+factory::
+
+       >>> spec = pathspec.GitIgnoreSpec.from_lines(spec_text.splitlines())
+
+
+License
+-------
+
+*pathspec* is licensed under the `Mozilla Public License Version 2.0`_. See
+`LICENSE`_ or the `FAQ`_ for more information.
+
+In summary, you may use *pathspec* with any closed or open source project
+without affecting the license of the larger work so long as you:
+
+- give credit where credit is due,
+
+- and release any custom changes made to *pathspec*.
+
+.. _`Mozilla Public License Version 2.0`: http://www.mozilla.org/MPL/2.0
+.. _`LICENSE`: LICENSE
+.. _`FAQ`: http://www.mozilla.org/MPL/2.0/FAQ.html
+
+
+Source
+------
+
+The source code for *pathspec* is available from the GitHub repo
+`cpburnz/python-pathspec`_.
+
+.. _`cpburnz/python-pathspec`: https://github.com/cpburnz/python-pathspec
+
+
+Installation
+------------
+
+*pathspec* is available for install through `PyPI`_::
+
+       pip install pathspec
+
+*pathspec* can also be built from source. The following packages will be
+required:
+
+- `build`_ (>=0.6.0)
+
+*pathspec* can then be built and installed with::
+
+       python -m build
+       pip install dist/pathspec-*-py3-none-any.whl
+
+.. _`PyPI`: http://pypi.python.org/pypi/pathspec
+.. _`build`: https://pypi.org/project/build/
+
+
+Documentation
+-------------
+
+Documentation for *pathspec* is available on `Read the Docs`_.
+
+.. _`Read the Docs`: https://python-path-specification.readthedocs.io
+
+
+Other Languages
+---------------
+
+The related project `pathspec-ruby`_ (by *highb*) provides a similar library as
+a `Ruby gem`_.
+
+.. _`pathspec-ruby`: https://github.com/highb/pathspec-ruby
+.. _`Ruby gem`: https://rubygems.org/gems/pathspec
+
+
+
+Change History
+==============
+
+
+0.12.1 (2023-12-10)
+-------------------
+
+Bug fixes:
+
+- `Issue #84`_: PathSpec.match_file() returns None since 0.12.0.
+
+
+.. _`Issue #84`: https://github.com/cpburnz/python-pathspec/issues/84
+
+
+0.12.0 (2023-12-09)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 3.7. See `Pull #82`_.
+
+
+API changes:
+
+- Signature of protected method `pathspec.pathspec.PathSpec._match_file()` (with a leading underscore) has been changed from `def _match_file(patterns: Iterable[Pattern], file: str) -> bool` to `def _match_file(patterns: Iterable[Tuple[int, Pattern]], file: str) -> Tuple[Optional[bool], Optional[int]]`.
+
+New features:
+
+- Added `pathspec.pathspec.PathSpec.check_*()` methods. These methods behave similarly to `.match_*()` but return additional information in the `pathspec.util.CheckResult` objects (e.g., `CheckResult.index` indicates the index of the last pattern that matched the file).
+- Added `pathspec.pattern.RegexPattern.pattern` attribute which stores the original, uncompiled pattern.
+
+Bug fixes:
+
+- `Issue #81`_: GitIgnoreSpec behaviors differ from git.
+- `Pull #83`_: Fix ReadTheDocs builds.
+
+Improvements:
+
+- Mark Python 3.12 as supported. See `Pull #82`_.
+- Improve test debugging.
+- Improve type hint on *on_error* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Improve type hint on *on_error* parameter on `pathspec.util.iter_tree_entries()`.
+
+
+.. _`Issue #81`: https://github.com/cpburnz/python-pathspec/issues/81
+.. _`Pull #82`: https://github.com/cpburnz/python-pathspec/pull/82
+.. _`Pull #83`: https://github.com/cpburnz/python-pathspec/pull/83
+
+
+0.11.2 (2023-07-28)
+-------------------
+
+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
+- `Issue #77`_/`Pull #78`_: Negate with caret symbol as with the exclamation mark.
+
+
+.. _`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)
+-------------------
+
+Bug fixes:
+
+- `Issue #74`_: Include directory should override exclude file.
+
+Improvements:
+
+- `Pull #75`_: Fix partially unknown PathLike type.
+- Convert `os.PathLike` to a string properly using `os.fspath`.
+
+
+.. _`Issue #74`: https://github.com/cpburnz/python-pathspec/issues/74
+.. _`Pull #75`: https://github.com/cpburnz/python-pathspec/pull/75
+
+
+0.11.0 (2023-01-24)
+-------------------
+
+Major changes:
+
+- Changed build backend to `flit_core.buildapi`_ from `setuptools.build_meta`_. Building with `setuptools` through `setup.py` is still supported for distributions that need it. See `Issue #72`_.
+
+Improvements:
+
+- `Issue #72`_/`Pull #73`_: Please consider switching the build-system to flit_core to ease setuptools bootstrap.
+
+
+.. _`flit_core.buildapi`: https://flit.pypa.io/en/latest/index.html
+.. _`Issue #72`: https://github.com/cpburnz/python-pathspec/issues/72
+.. _`Pull #73`: https://github.com/cpburnz/python-pathspec/pull/73
+
+
+0.10.3 (2022-12-09)
+-------------------
+
+New features:
+
+- Added utility function `pathspec.util.append_dir_sep()` to aid in distinguishing between directories and files on the file-system. See `Issue #65`_.
+
+Bug fixes:
+
+- `Issue #66`_/`Pull #67`_: Package not marked as py.typed.
+- `Issue #68`_: Exports are considered private.
+- `Issue #70`_/`Pull #71`_: 'Self' string literal type is Unknown in pyright.
+
+Improvements:
+
+- `Issue #65`_: Checking directories via match_file() does not work on Path objects.
+
+
+.. _`Issue #65`: https://github.com/cpburnz/python-pathspec/issues/65
+.. _`Issue #66`: https://github.com/cpburnz/python-pathspec/issues/66
+.. _`Pull #67`: https://github.com/cpburnz/python-pathspec/pull/67
+.. _`Issue #68`: https://github.com/cpburnz/python-pathspec/issues/68
+.. _`Issue #70`: https://github.com/cpburnz/python-pathspec/issues/70
+.. _`Pull #71`: https://github.com/cpburnz/python-pathspec/pull/71
+
+
+0.10.2 (2022-11-12)
+-------------------
+
+Bug fixes:
+
+- Fix failing tests on Windows.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.pathspec.PathSpec.match_tree_files()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_entries()`.
+- Type hint on *root* parameter on `pathspec.util.iter_tree_files()`.
+- `Issue #64`_: IndexError with my .gitignore file when trying to build a Python package.
+
+Improvements:
+
+- `Pull #58`_: CI: add GitHub Actions test workflow.
+
+
+.. _`Pull #58`: https://github.com/cpburnz/python-pathspec/pull/58
+.. _`Issue #64`: https://github.com/cpburnz/python-pathspec/issues/64
+
+
+0.10.1 (2022-09-02)
+-------------------
+
+Bug fixes:
+
+- Fix documentation on `pathspec.pattern.RegexPattern.match_file()`.
+- `Pull #60`_: Remove redundant wheel dep from pyproject.toml.
+- `Issue #61`_: Dist failure for Fedora, CentOS, EPEL.
+- `Issue #62`_: Since version 0.10.0 pure wildcard does not work in some cases.
+
+Improvements:
+
+- Restore support for legacy installations using `setup.py`. See `Issue #61`_.
+
+
+.. _`Pull #60`: https://github.com/cpburnz/python-pathspec/pull/60
+.. _`Issue #61`: https://github.com/cpburnz/python-pathspec/issues/61
+.. _`Issue #62`: https://github.com/cpburnz/python-pathspec/issues/62
+
+
+0.10.0 (2022-08-30)
+-------------------
+
+Major changes:
+
+- Dropped support of EOL Python 2.7, 3.5, 3.6. See `Issue #47`_.
+- The *gitwildmatch* pattern `dir/*` is now handled the same as `dir/`. This means `dir/*` will now match all descendants rather than only direct children. See `Issue #19`_.
+- Added `pathspec.GitIgnoreSpec` class (see new features).
+- Changed build system to `pyproject.toml`_ and build backend to `setuptools.build_meta`_ which may have unforeseen consequences.
+- Renamed GitHub project from `python-path-specification`_ to `python-pathspec`_. See `Issue #35`_.
+
+API changes:
+
+- Deprecated: `pathspec.util.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.match_files()` is an old function no longer used.
+- Deprecated: `pathspec.util.normalize_files()` is no longer used.
+- Deprecated: `pathspec.util.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+- Deprecated: `pathspec.iter_tree()` is an alias for `pathspec.util.iter_tree_files()`.
+-      Deprecated: `pathspec.pattern.Pattern.match()` is no longer used. Use or implement
+       `pathspec.pattern.Pattern.match_file()`.
+
+New features:
+
+- Added class `pathspec.gitignore.GitIgnoreSpec` (with alias `pathspec.GitIgnoreSpec`) to implement *gitignore* behavior not possible with standard `PathSpec` class. The particular *gitignore* behavior implemented is prioritizing patterns matching the file directly over matching an ancestor directory.
+
+Bug fixes:
+
+- `Issue #19`_: Files inside an ignored sub-directory are not matched.
+- `Issue #41`_: Incorrectly (?) matches files inside directories that do match.
+- `Pull #51`_: Refactor deprecated unittest aliases for Python 3.11 compatibility.
+- `Issue #53`_: Symlink pathspec_meta.py breaks Windows.
+- `Issue #54`_: test_util.py uses os.symlink which can fail on Windows.
+- `Issue #55`_: Backslashes at start of pattern not handled correctly.
+- `Pull #56`_: pyproject.toml: include subpackages in setuptools config
+- `Issue #57`_: `!` doesn't exclude files in directories if the pattern doesn't have a trailing slash.
+
+Improvements:
+
+- Support Python 3.10, 3.11.
+- Modernize code to Python 3.7.
+- `Issue #52`_: match_files() is not a pure generator function, and it impacts tree_*() gravely.
+
+
+.. _`python-path-specification`: https://github.com/cpburnz/python-path-specification
+.. _`python-pathspec`: https://github.com/cpburnz/python-pathspec
+.. _`pyproject.toml`: https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/
+.. _`setuptools.build_meta`: https://setuptools.pypa.io/en/latest/build_meta.html
+.. _`Issue #19`: https://github.com/cpburnz/python-pathspec/issues/19
+.. _`Issue #35`: https://github.com/cpburnz/python-pathspec/issues/35
+.. _`Issue #41`: https://github.com/cpburnz/python-pathspec/issues/41
+.. _`Issue #47`: https://github.com/cpburnz/python-pathspec/issues/47
+.. _`Pull #51`: https://github.com/cpburnz/python-pathspec/pull/51
+.. _`Issue #52`: https://github.com/cpburnz/python-pathspec/issues/52
+.. _`Issue #53`: https://github.com/cpburnz/python-pathspec/issues/53
+.. _`Issue #54`: https://github.com/cpburnz/python-pathspec/issues/54
+.. _`Issue #55`: https://github.com/cpburnz/python-pathspec/issues/55
+.. _`Pull #56`: https://github.com/cpburnz/python-pathspec/pull/56
+.. _`Issue #57`: https://github.com/cpburnz/python-pathspec/issues/57
+
+
+0.9.0 (2021-07-17)
+------------------
+
+- `Issue #44`_/`Pull #50`_: Raise `GitWildMatchPatternError` for invalid git patterns.
+- `Pull #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 #44`: https://github.com/cpburnz/python-pathspec/issues/44
+.. _`Pull #45`: https://github.com/cpburnz/python-pathspec/pull/45
+.. _`Issue #46`: https://github.com/cpburnz/python-pathspec/issues/46
+.. _`Pull #50`: https://github.com/cpburnz/python-pathspec/pull/50
+
+
+0.8.1 (2020-11-07)
+------------------
+
+- `Pull #43`_: Add support for addition operator.
+
+.. _`Pull #43`: https://github.com/cpburnz/python-pathspec/pull/43
+
+
+0.8.0 (2020-04-09)
+------------------
+
+- `Issue #30`_: Expose what patterns matched paths. Added `util.detailed_match_files()`.
+- `Issue #31`_: `match_tree()` doesn't return symlinks.
+- `Issue #34`_: Support `pathlib.Path`\ s.
+- Add `PathSpec.match_tree_entries` and `util.iter_tree_entries()` to support directories and symlinks.
+- API change: `match_tree()` has been renamed to `match_tree_files()`. The old name `match_tree()` is still available as an alias.
+- API change: `match_tree_files()` now returns symlinks. This is a bug fix but it will change the returned results.
+
+.. _`Issue #30`: https://github.com/cpburnz/python-pathspec/issues/30
+.. _`Issue #31`: https://github.com/cpburnz/python-pathspec/issues/31
+.. _`Issue #34`: https://github.com/cpburnz/python-pathspec/issues/34
+
+
+0.7.0 (2019-12-27)
+------------------
+
+- `Pull #28`_: Add support for Python 3.8, and drop Python 3.4.
+- `Pull #29`_: Publish bdist wheel.
+
+.. _`Pull #28`: https://github.com/cpburnz/python-pathspec/pull/28
+.. _`Pull #29`: https://github.com/cpburnz/python-pathspec/pull/29
+
+
+0.6.0 (2019-10-03)
+------------------
+
+- `Pull #24`_: Drop support for Python 2.6, 3.2, and 3.3.
+- `Pull #25`_: Update README.rst.
+- `Pull #26`_: Method to escape gitwildmatch.
+
+.. _`Pull #24`: https://github.com/cpburnz/python-pathspec/pull/24
+.. _`Pull #25`: https://github.com/cpburnz/python-pathspec/pull/25
+.. _`Pull #26`: https://github.com/cpburnz/python-pathspec/pull/26
+
+
+0.5.9 (2018-09-15)
+------------------
+
+- Fixed file system error handling.
+
+
+0.5.8 (2018-09-15)
+------------------
+
+- Improved type checking.
+- Created scripts to test Python 2.6 because Tox removed support for it.
+- Improved byte string handling in Python 3.
+- `Issue #22`_: Handle dangling symlinks.
+
+.. _`Issue #22`: https://github.com/cpburnz/python-pathspec/issues/22
+
+
+0.5.7 (2018-08-14)
+------------------
+
+- `Issue #21`_: Fix collections deprecation warning.
+
+.. _`Issue #21`: https://github.com/cpburnz/python-pathspec/issues/21
+
+
+0.5.6 (2018-04-06)
+------------------
+
+- Improved unit tests.
+- Improved type checking.
+- `Issue #20`_: Support current directory prefix.
+
+.. _`Issue #20`: https://github.com/cpburnz/python-pathspec/issues/20
+
+
+0.5.5 (2017-09-09)
+------------------
+
+- Add documentation link to README.
+
+
+0.5.4 (2017-09-09)
+------------------
+
+- `Pull #17`_: Add link to Ruby implementation of *pathspec*.
+- Add sphinx documentation.
+
+.. _`Pull #17`: https://github.com/cpburnz/python-pathspec/pull/17
+
+
+0.5.3 (2017-07-01)
+------------------
+
+- `Issue #14`_: Fix byte strings for Python 3.
+- `Pull #15`_: Include "LICENSE" in source package.
+- `Issue #16`_: Support Python 2.6.
+
+.. _`Issue #14`: https://github.com/cpburnz/python-pathspec/issues/14
+.. _`Pull #15`: https://github.com/cpburnz/python-pathspec/pull/15
+.. _`Issue #16`: https://github.com/cpburnz/python-pathspec/issues/16
+
+
+0.5.2 (2017-04-04)
+------------------
+
+- Fixed change log.
+
+
+0.5.1 (2017-04-04)
+------------------
+
+- `Pull #13`_: Add equality methods to `PathSpec` and `RegexPattern`.
+
+.. _`Pull #13`: https://github.com/cpburnz/python-pathspec/pull/13
+
+
+0.5.0 (2016-08-22)
+------------------
+
+- `Issue #12`_: Add `PathSpec.match_file()`.
+- Renamed `gitignore.GitIgnorePattern` to `patterns.gitwildmatch.GitWildMatchPattern`.
+- Deprecated `gitignore.GitIgnorePattern`.
+
+.. _`Issue #12`: https://github.com/cpburnz/python-pathspec/issues/12
+
+
+0.4.0 (2016-07-15)
+------------------
+
+- `Issue #11`_: Support converting patterns into regular expressions without compiling them.
+- API change: Subclasses of `RegexPattern` should implement `pattern_to_regex()`.
+
+.. _`Issue #11`: https://github.com/cpburnz/python-pathspec/issues/11
+
+
+0.3.4 (2015-08-24)
+------------------
+
+- `Pull #7`_: Fixed non-recursive links.
+- `Pull #8`_: Fixed edge cases in gitignore patterns.
+- `Pull #9`_: Fixed minor usage documentation.
+- Fixed recursion detection.
+- Fixed trivial incompatibility with Python 3.2.
+
+.. _`Pull #7`: https://github.com/cpburnz/python-pathspec/pull/7
+.. _`Pull #8`: https://github.com/cpburnz/python-pathspec/pull/8
+.. _`Pull #9`: https://github.com/cpburnz/python-pathspec/pull/9
+
+
+0.3.3 (2014-11-21)
+------------------
+
+- Improved documentation.
+
+
+0.3.2 (2014-11-08)
+------------------
+
+- `Pull #5`_: Use tox for testing.
+- `Issue #6`_: Fixed matching Windows paths.
+- Improved documentation.
+- API change: `spec.match_tree()` and `spec.match_files()` now return iterators instead of sets.
+
+.. _`Pull #5`: https://github.com/cpburnz/python-pathspec/pull/5
+.. _`Issue #6`: https://github.com/cpburnz/python-pathspec/issues/6
+
+
+0.3.1 (2014-09-17)
+------------------
+
+- Updated README.
+
+
+0.3.0 (2014-09-17)
+------------------
+
+- `Pull #3`_: Fixed trailing slash in gitignore patterns.
+- `Pull #4`_: Fixed test for trailing slash in gitignore patterns.
+- Added registered patterns.
+
+.. _`Pull #3`: https://github.com/cpburnz/python-pathspec/pull/3
+.. _`Pull #4`: https://github.com/cpburnz/python-pathspec/pull/4
+
+
+0.2.2 (2013-12-17)
+------------------
+
+- Fixed setup.py.
+
+
+0.2.1 (2013-12-17)
+------------------
+
+- Added tests.
+- Fixed comment gitignore patterns.
+- Fixed relative path gitignore patterns.
+
+
+0.2.0 (2013-12-07)
+------------------
+
+- Initial release.
diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..e934d40
--- /dev/null
@@ -0,0 +1,165 @@
+
+PathSpec
+========
+
+*pathspec* is a utility library for pattern matching of file paths. So
+far this only includes Git's wildmatch pattern matching which itself is
+derived from Rsync's wildmatch. Git uses wildmatch for its `gitignore`_
+files.
+
+.. _`gitignore`: http://git-scm.com/docs/gitignore
+
+
+Tutorial
+--------
+
+Say you have a "Projects" directory and you want to back it up, but only
+certain files, and ignore others depending on certain conditions::
+
+       >>> import pathspec
+       >>> # The gitignore-style patterns for files to select, but we're including
+       >>> # instead of ignoring.
+       >>> spec_text = """
+       ...
+       ... # This is a comment because the line begins with a hash: "#"
+       ...
+       ... # Include several project directories (and all descendants) relative to
+       ... # the current directory. To reference a directory you must end with a
+       ... # slash: "/"
+       ... /project-a/
+       ... /project-b/
+       ... /project-c/
+       ...
+       ... # Patterns can be negated by prefixing with exclamation mark: "!"
+       ...
+       ... # Ignore temporary files beginning or ending with "~" and ending with
+       ... # ".swp".
+       ... !~*
+       ... !*~
+       ... !*.swp
+       ...
+       ... # These are python projects so ignore compiled python files from
+       ... # testing.
+       ... !*.pyc
+       ...
+       ... # Ignore the build directories but only directly under the project
+       ... # directories.
+       ... !/*/build/
+       ...
+       ... """
+
+We want to use the ``GitWildMatchPattern`` class to compile our patterns. The
+``PathSpec`` class provides an interface around pattern implementations::
+
+       >>> spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+
+That may be a mouthful but it allows for additional patterns to be implemented
+in the future without them having to deal with anything but matching the paths
+sent to them. ``GitWildMatchPattern`` is the implementation of the actual
+pattern which internally gets converted into a regular expression. ``PathSpec``
+is a simple wrapper around a list of compiled patterns.
+
+To make things simpler, we can use the registered name for a pattern class
+instead of always having to provide a reference to the class itself. The
+``GitWildMatchPattern`` class is registered as **gitwildmatch**::
+
+       >>> spec = pathspec.PathSpec.from_lines('gitwildmatch', spec_text.splitlines())
+
+If we wanted to manually compile the patterns we can just do the following::
+
+       >>> patterns = map(pathspec.patterns.GitWildMatchPattern, spec_text.splitlines())
+       >>> spec = PathSpec(patterns)
+
+``PathSpec.from_lines()`` is simply a class method which does just that.
+
+If you want to load the patterns from file, you can pass the file instance
+directly as well::
+
+       >>> with open('patterns.list', 'r') as fh:
+       >>>     spec = pathspec.PathSpec.from_lines('gitwildmatch', fh)
+
+You can perform matching on a whole directory tree with::
+
+       >>> matches = spec.match_tree('path/to/directory')
+
+Or you can perform matching on a specific set of file paths with::
+
+       >>> matches = spec.match_files(file_paths)
+
+Or check to see if an individual file matches::
+
+       >>> is_matched = spec.match_file(file_path)
+
+There is a specialized class, ``pathspec.GitIgnoreSpec``, which more closely
+implements the behavior of **gitignore**. This uses ``GitWildMatchPattern``
+pattern by default and handles some edge cases differently from the generic
+``PathSpec`` class. ``GitIgnoreSpec`` can be used without specifying the pattern
+factory::
+
+       >>> spec = pathspec.GitIgnoreSpec.from_lines(spec_text.splitlines())
+
+
+License
+-------
+
+*pathspec* is licensed under the `Mozilla Public License Version 2.0`_. See
+`LICENSE`_ or the `FAQ`_ for more information.
+
+In summary, you may use *pathspec* with any closed or open source project
+without affecting the license of the larger work so long as you:
+
+- give credit where credit is due,
+
+- and release any custom changes made to *pathspec*.
+
+.. _`Mozilla Public License Version 2.0`: http://www.mozilla.org/MPL/2.0
+.. _`LICENSE`: LICENSE
+.. _`FAQ`: http://www.mozilla.org/MPL/2.0/FAQ.html
+
+
+Source
+------
+
+The source code for *pathspec* is available from the GitHub repo
+`cpburnz/python-pathspec`_.
+
+.. _`cpburnz/python-pathspec`: https://github.com/cpburnz/python-pathspec
+
+
+Installation
+------------
+
+*pathspec* is available for install through `PyPI`_::
+
+       pip install pathspec
+
+*pathspec* can also be built from source. The following packages will be
+required:
+
+- `build`_ (>=0.6.0)
+
+*pathspec* can then be built and installed with::
+
+       python -m build
+       pip install dist/pathspec-*-py3-none-any.whl
+
+.. _`PyPI`: http://pypi.python.org/pypi/pathspec
+.. _`build`: https://pypi.org/project/build/
+
+
+Documentation
+-------------
+
+Documentation for *pathspec* is available on `Read the Docs`_.
+
+.. _`Read the Docs`: https://python-path-specification.readthedocs.io
+
+
+Other Languages
+---------------
+
+The related project `pathspec-ruby`_ (by *highb*) provides a similar library as
+a `Ruby gem`_.
+
+.. _`pathspec-ruby`: https://github.com/highb/pathspec-ruby
+.. _`Ruby gem`: https://rubygems.org/gems/pathspec
diff --git a/doc/Makefile b/doc/Makefile
new file mode 100644 (file)
index 0000000..4946d72
--- /dev/null
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = python -msphinx
+SPHINXPROJ    = PathSpecification
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+       @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+       @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/doc/requirements.txt b/doc/requirements.txt
new file mode 100644 (file)
index 0000000..6fc994d
--- /dev/null
@@ -0,0 +1 @@
+Sphinx==7.2.6\r
diff --git a/doc/source/api.rst b/doc/source/api.rst
new file mode 100644 (file)
index 0000000..c62961b
--- /dev/null
@@ -0,0 +1,71 @@
+:tocdepth: 2
+
+API
+===
+
+pathspec
+--------
+
+.. automodule:: pathspec
+
+
+pathspec.pathspec
+-----------------
+
+.. automodule:: pathspec.pathspec
+
+       .. autoclass:: PathSpec
+               :members:
+               :show-inheritance:
+               :special-members: __init__, __eq__, __len__
+
+
+pathspec.gitignore
+------------------
+
+.. automodule:: pathspec.gitignore
+
+       .. autoclass:: GitIgnoreSpec
+               :members:
+               :show-inheritance:
+               :special-members: __init__, __eq__
+
+
+pathspec.pattern
+----------------
+
+.. automodule:: pathspec.pattern
+
+       .. autoclass:: Pattern
+               :members:
+               :show-inheritance:
+               :special-members: __init__
+
+       .. autoclass:: RegexPattern
+               :members:
+               :show-inheritance:
+               :special-members: __init__, __eq__
+
+       .. autoclass:: RegexMatchResult
+               :members:
+               :show-inheritance:
+               :special-members: __init__
+
+
+pathspec.patterns.gitwildmatch
+------------------------------
+
+.. automodule:: pathspec.patterns.gitwildmatch
+
+       .. autoclass:: GitWildMatchPattern
+               :members:
+               :inherited-members:
+               :show-inheritance:
+
+
+pathspec.util
+-------------
+
+.. automodule:: pathspec.util
+       :members:
+       :show-inheritance:
diff --git a/doc/source/changes.rst b/doc/source/changes.rst
new file mode 100644 (file)
index 0000000..26ba141
--- /dev/null
@@ -0,0 +1,2 @@
+
+.. include:: ../../CHANGES.rst
diff --git a/doc/source/conf.py b/doc/source/conf.py
new file mode 100644 (file)
index 0000000..3e5ef46
--- /dev/null
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+#
+# Path Specification documentation build configuration file, created by
+# sphinx-quickstart on Fri Sep  8 09:37:49 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import os
+import sys
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+sys.path.insert(0, os.path.abspath('../../'))
+from pathspec._meta import __author__, __copyright__, __version__
+
+
+# -- General configuration ------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode']
+
+# The autodoc extension doesn't understand the `Self` typehint.
+# To avoid documentation build errors, autodoc typehints must be disabled.
+autodoc_typehints = "none"
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = "PathSpec"
+copyright = __copyright__.split("©")[1].strip()
+author = __author__
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '.'.join(__version__.split('.', 2)[:2])
+# The full version, including alpha/beta/rc tags.
+release = __version__
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = "en"
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+
+# -- Options for the autodoc extension -----------------------------------------
+
+autodoc_member_order = 'bysource'
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+html_theme_options = {
+       #'collapse_navigation': True,
+       #'navigation_depth': 4
+}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+# html_static_path = ['_static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# This is required for the alabaster theme
+# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
+'''
+html_sidebars = {
+       '**': [
+               'about.html',
+               'navigation.html',
+               'relations.html',  # needs 'show_related': True theme option to display
+               'searchbox.html',
+               'donate.html',
+       ]
+}
+'''
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = '{}doc'.format(project)
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+       # The paper size ('letterpaper' or 'a4paper').
+       #
+       # 'papersize': 'letterpaper',
+
+       # The font size ('10pt', '11pt' or '12pt').
+       #
+       # 'pointsize': '10pt',
+
+       # Additional stuff for the LaTeX preamble.
+       #
+       # 'preamble': '',
+
+       # Latex figure (float) alignment
+       #
+       # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+       (master_doc, '{}.tex'.format(project), '{} Documentation'.format(project), author, 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+       (master_doc, project, '{} Documentation'.format(project), [author], 1),
+]
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
diff --git a/doc/source/index.rst b/doc/source/index.rst
new file mode 100644 (file)
index 0000000..6649eab
--- /dev/null
@@ -0,0 +1,24 @@
+.. pathspec documentation master file, created by
+   sphinx-quickstart on Fri Sep  8 09:37:49 2017.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+
+Welcome to pathspec's documentation!
+==============================================
+
+.. toctree::
+   :caption: Contents:
+   :maxdepth: 2
+
+   readme
+   api
+   changes
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/doc/source/readme.rst b/doc/source/readme.rst
new file mode 100644 (file)
index 0000000..4d2c74b
--- /dev/null
@@ -0,0 +1,2 @@
+
+.. include:: ../../README.rst
diff --git a/packaging/python3-build.manifest b/packaging/python3-build.manifest
new file mode 100644 (file)
index 0000000..86dbb26
--- /dev/null
@@ -0,0 +1,5 @@
+<manifest>
+    <request>
+        <domain name="_" />
+    </request>
+</manifest>
diff --git a/packaging/python3-build.spec b/packaging/python3-build.spec
new file mode 100644 (file)
index 0000000..0259b29
--- /dev/null
@@ -0,0 +1,37 @@
+Name:           python3-build
+Version:        1.0.3
+Release:        0
+Summary:        Simple PEP517 package builder
+License:        MIT
+URL:            https://github.com/pypa/build
+Source0:        build-%{version}.tar.gz
+Source1001:     %{name}.manifest
+
+BuildRequires:  pkgconfig(python3)
+BuildRequires:  python3-setuptools
+
+Requires:       python3-packaging
+Requires:       python3-pyproject_hooks
+
+BuildArch:      noarch
+
+%description
+Build will invoke the PEP 517 hooks to build a distribution package.
+It is a simple build tool and does not perform any dependency management.
+
+%prep
+%setup -q -n build-%{version}
+
+%build
+cp %{SOURCE1001} .
+%{_bindir}/python3 setup.py build
+
+%install
+%{_bindir}/python3 setup.py install --prefix=%{_prefix} --root=%{buildroot}
+rm -f %{buildroot}%{python3_sitelib}/build/py.typed
+
+%files 
+%license LICENSE
+%{python3_sitelib}/build/*.py
+%{python3_sitelib}/build/__pycache__/*
+%{python3_sitelib}/build-%{version}-py%{python3_version}.egg-info
diff --git a/pathspec/__init__.py b/pathspec/__init__.py
new file mode 100644 (file)
index 0000000..32e03f7
--- /dev/null
@@ -0,0 +1,76 @@
+"""
+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.gitignore.GitIgnoreSpec`
+
+-      :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.lookup_pattern`
+
+The following deprecated functions are also imported to maintain
+backward compatibility:
+
+-      :func:`pathspec.util.iter_tree` which is an alias for
+       :func:`pathspec.util.iter_tree_files`.
+
+-      :func:`pathspec.util.match_files`
+"""
+
+from .gitignore import (
+       GitIgnoreSpec)
+from .pathspec import (
+       PathSpec)
+from .pattern import (
+       Pattern,
+       RegexPattern)
+from .util import (
+       RecursionError,
+       iter_tree,
+       lookup_pattern,
+       match_files)
+
+from ._meta import (
+       __author__,
+       __copyright__,
+       __credits__,
+       __license__,
+       __version__,
+)
+
+# Load pattern implementations.
+from . import patterns
+
+# DEPRECATED: Expose the `GitIgnorePattern` class in the root module for
+# backward compatibility with v0.4.
+from .patterns.gitwildmatch import GitIgnorePattern
+
+# Declare private imports as part of the public interface. Deprecated
+# imports are deliberately excluded.
+__all__ = [
+       'GitIgnoreSpec',
+       'PathSpec',
+       'Pattern',
+       'RecursionError',
+       'RegexPattern',
+       '__author__',
+       '__copyright__',
+       '__credits__',
+       '__license__',
+       '__version__',
+       'iter_tree',
+       'lookup_pattern',
+       'match_files',
+]
diff --git a/pathspec/_meta.py b/pathspec/_meta.py
new file mode 100644 (file)
index 0000000..4d8c89d
--- /dev/null
@@ -0,0 +1,58 @@
+"""
+This module contains the project meta-data.
+"""
+
+__author__ = "Caleb P. Burns"
+__copyright__ = "Copyright Â© 2013-2023 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>",
+       "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>",
+       "tirkarthi <https://github.com/tirkarthi>",
+       "jayvdb <https://github.com/jayvdb>",
+       "jwodder <https://github.com/jwodder>",
+       "kloczek <https://github.com/kloczek>",
+       "orens <https://github.com/orens>",
+       "spMohanty <https://github.com/spMohanty>",
+       "ichard26 <https://github.com/ichard26>",
+       "jack1142 <https://github.com/jack1142>",
+       "mgorny <https://github.com/mgorny>",
+       "bzakdd <https://github.com/bzakdd>",
+       "haimat <https://github.com/haimat>",
+       "Avasam <https://github.com/Avasam>",
+       "yschroeder <https://github.com/yschroeder>",
+       "axesider <https://github.com/axesider>",
+       "tomruk <https://github.com/tomruk>",
+       "oprypin <https://github.com/oprypin>",
+       "kurtmckee <https://github.com/kurtmckee>",
+]
+__license__ = "MPL 2.0"
+__version__ = "0.12.1"
diff --git a/pathspec/gitignore.py b/pathspec/gitignore.py
new file mode 100644 (file)
index 0000000..994a2c7
--- /dev/null
@@ -0,0 +1,157 @@
+"""
+This module provides :class:`.GitIgnoreSpec` which replicates
+*.gitignore* behavior.
+"""
+
+from typing import (
+       AnyStr,
+       Callable,  # Replaced by `collections.abc.Callable` in 3.9.
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       Optional,  # Replaced by `X | None` in 3.10.
+       Tuple,  # Replaced by `tuple` in 3.9.
+       Type,  # Replaced by `type` in 3.9.
+       TypeVar,
+       Union,  # Replaced by `X | Y` in 3.10.
+       cast,
+       overload)
+
+from .pathspec import (
+       PathSpec)
+from .pattern import (
+       Pattern)
+from .patterns.gitwildmatch import (
+       GitWildMatchPattern,
+       _DIR_MARK)
+from .util import (
+       _is_iterable)
+
+Self = TypeVar("Self", bound="GitIgnoreSpec")
+"""
+:class:`GitIgnoreSpec` self type hint to support Python v<3.11 using PEP
+673 recommendation.
+"""
+
+
+class GitIgnoreSpec(PathSpec):
+       """
+       The :class:`GitIgnoreSpec` class extends :class:`pathspec.pathspec.PathSpec` to
+       replicate *.gitignore* behavior.
+       """
+
+       def __eq__(self, other: object) -> bool:
+               """
+               Tests the equality of this gitignore-spec with *other* (:class:`GitIgnoreSpec`)
+               by comparing their :attr:`~pathspec.pattern.Pattern`
+               attributes. A non-:class:`GitIgnoreSpec` will not compare equal.
+               """
+               if isinstance(other, GitIgnoreSpec):
+                       return super().__eq__(other)
+               elif isinstance(other, PathSpec):
+                       return False
+               else:
+                       return NotImplemented
+
+       # Support reversed order of arguments from PathSpec.
+       @overload
+       @classmethod
+       def from_lines(
+               cls: Type[Self],
+               pattern_factory: Union[str, Callable[[AnyStr], Pattern]],
+               lines: Iterable[AnyStr],
+       ) -> Self:
+               ...
+
+       @overload
+       @classmethod
+       def from_lines(
+               cls: Type[Self],
+               lines: Iterable[AnyStr],
+               pattern_factory: Union[str, Callable[[AnyStr], Pattern], None] = None,
+       ) -> Self:
+               ...
+
+       @classmethod
+       def from_lines(
+               cls: Type[Self],
+               lines: Iterable[AnyStr],
+               pattern_factory: Union[str, Callable[[AnyStr], Pattern], None] = None,
+       ) -> Self:
+               """
+               Compiles the pattern lines.
+
+               *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`.
+
+               *pattern_factory* can be :data:`None`, the name of a registered
+               pattern factory (:class:`str`), or a :class:`~collections.abc.Callable`
+               used to compile patterns. The callable must accept an uncompiled
+               pattern (:class:`str`) and return the compiled pattern
+               (:class:`pathspec.pattern.Pattern`).
+               Default is :data:`None` for :class:`.GitWildMatchPattern`).
+
+               Returns the :class:`GitIgnoreSpec` instance.
+               """
+               if pattern_factory is None:
+                       pattern_factory = GitWildMatchPattern
+
+               elif (isinstance(lines, (str, bytes)) or callable(lines)) and _is_iterable(pattern_factory):
+                       # Support reversed order of arguments from PathSpec.
+                       pattern_factory, lines = lines, pattern_factory
+
+               self = super().from_lines(pattern_factory, lines)
+               return cast(Self, self)
+
+       @staticmethod
+       def _match_file(
+               patterns: Iterable[Tuple[int, GitWildMatchPattern]],
+               file: str,
+       ) -> Tuple[Optional[bool], Optional[int]]:
+               """
+               Check the file against the patterns.
+
+               .. NOTE:: Subclasses of :class:`~pathspec.pathspec.PathSpec` may override
+                  this method as an instance method. It does not have to be a static
+                  method. The signature for this method is subject to change.
+
+               *patterns* (:class:`~collections.abc.Iterable`) yields each indexed pattern
+               (:class:`tuple`) which contains the pattern index (:class:`int`) and actual
+               pattern (:class:`~pathspec.pattern.Pattern`).
+
+               *file* (:class:`str`) is the normalized file path to be matched against
+               *patterns*.
+
+               Returns a :class:`tuple` containing whether to include *file* (:class:`bool`
+               or :data:`None`), and the index of the last matched pattern (:class:`int` or
+               :data:`None`).
+               """
+               out_include: Optional[bool] = None
+               out_index: Optional[int] = None
+               out_priority = 0
+               for index, pattern in patterns:
+                       if pattern.include is not None:
+                               match = pattern.match_file(file)
+                               if match is not None:
+                                       # Pattern matched.
+
+                                       # Check for directory marker.
+                                       dir_mark = match.match.groupdict().get(_DIR_MARK)
+
+                                       if dir_mark:
+                                               # Pattern matched by a directory pattern.
+                                               priority = 1
+                                       else:
+                                               # Pattern matched by a file pattern.
+                                               priority = 2
+
+                                       if pattern.include and dir_mark:
+                                               out_include = pattern.include
+                                               out_index = index
+                                               out_priority = priority
+                                       elif priority >= out_priority:
+                                               out_include = pattern.include
+                                               out_index = index
+                                               out_priority = priority
+
+               return out_include, out_index
diff --git a/pathspec/pathspec.py b/pathspec/pathspec.py
new file mode 100644 (file)
index 0000000..bdfaccd
--- /dev/null
@@ -0,0 +1,394 @@
+"""
+This module provides an object oriented interface for pattern matching of files.
+"""
+
+from collections.abc import (
+       Collection as CollectionType)
+from itertools import (
+       zip_longest)
+from typing import (
+       AnyStr,
+       Callable,  # Replaced by `collections.abc.Callable` in 3.9.
+       Collection,  # Replaced by `collections.abc.Collection` in 3.9.
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       Iterator,  # Replaced by `collections.abc.Iterator` in 3.9.
+       Optional,  # Replaced by `X | None` in 3.10.
+       Type,  # Replaced by `type` in 3.9.
+       TypeVar,
+       Union)  # Replaced by `X | Y` in 3.10.
+
+from . import util
+from .pattern import (
+       Pattern)
+from .util import (
+       CheckResult,
+       StrPath,
+       TStrPath,
+       TreeEntry,
+       _filter_check_patterns,
+       _is_iterable,
+       normalize_file)
+
+Self = TypeVar("Self", bound="PathSpec")
+"""
+:class:`PathSpec` self type hint to support Python v<3.11 using PEP 673
+recommendation.
+"""
+
+
+class PathSpec(object):
+       """
+       The :class:`PathSpec` class is a wrapper around a list of compiled
+       :class:`.Pattern` instances.
+       """
+
+       def __init__(self, patterns: Iterable[Pattern]) -> None:
+               """
+               Initializes the :class:`PathSpec` instance.
+
+               *patterns* (:class:`~collections.abc.Collection` or :class:`~collections.abc.Iterable`)
+               yields each compiled pattern (:class:`.Pattern`).
+               """
+               if not isinstance(patterns, CollectionType):
+                       patterns = list(patterns)
+
+               self.patterns: Collection[Pattern] = patterns
+               """
+               *patterns* (:class:`~collections.abc.Collection` of :class:`.Pattern`)
+               contains the compiled patterns.
+               """
+
+       def __eq__(self, other: object) -> 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 = zip_longest(self.patterns, other.patterns)
+                       return all(a == b for a, b in paired_patterns)
+               else:
+                       return NotImplemented
+
+       def __len__(self) -> int:
+               """
+               Returns the number of compiled patterns this path-spec contains
+               (:class:`int`).
+               """
+               return len(self.patterns)
+
+       def __add__(self: Self, other: "PathSpec") -> Self:
+               """
+               Combines the :attr:`Pathspec.patterns` patterns from two
+               :class:`PathSpec` instances.
+               """
+               if isinstance(other, PathSpec):
+                       return self.__class__(self.patterns + other.patterns)
+               else:
+                       return NotImplemented
+
+       def __iadd__(self: Self, other: "PathSpec") -> Self:
+               """
+               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
+
+       def check_file(
+               self,
+               file: TStrPath,
+               separators: Optional[Collection[str]] = None,
+       ) -> CheckResult[TStrPath]:
+               """
+               Check the files against this path-spec.
+
+               *file* (:class:`str` or :class:`os.PathLike`) is the file path 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 file check result (:class:`~pathspec.util.CheckResult`).
+               """
+               norm_file = normalize_file(file, separators)
+               include, index = self._match_file(enumerate(self.patterns), norm_file)
+               return CheckResult(file, include, index)
+
+       def check_files(
+               self,
+               files: Iterable[TStrPath],
+               separators: Optional[Collection[str]] = None,
+       ) -> Iterator[CheckResult[TStrPath]]:
+               """
+               Check the files against this path-spec.
+
+               *files* (:class:`~collections.abc.Iterable` of :class:`str` or
+               :class:`os.PathLike`) contains the file paths to be checked 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 an :class:`~collections.abc.Iterator` yielding each file check
+               result (:class:`~pathspec.util.CheckResult`).
+               """
+               if not _is_iterable(files):
+                       raise TypeError(f"files:{files!r} is not an iterable.")
+
+               use_patterns = _filter_check_patterns(self.patterns)
+               for orig_file in files:
+                       norm_file = normalize_file(orig_file, separators)
+                       include, index = self._match_file(use_patterns, norm_file)
+                       yield CheckResult(orig_file, include, index)
+
+       def check_tree_files(
+               self,
+               root: StrPath,
+               on_error: Optional[Callable[[OSError], None]] = None,
+               follow_links: Optional[bool] = None,
+       ) -> Iterator[CheckResult[str]]:
+               """
+               Walks the specified root path for all files and checks them against this
+               path-spec.
+
+               *root* (:class:`str` or :class:`os.PathLike`) 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`.
+
+               *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 an :class:`~collections.abc.Iterator` yielding each file check
+               result (:class:`~pathspec.util.CheckResult`).
+               """
+               files = util.iter_tree_files(root, on_error=on_error, follow_links=follow_links)
+               yield from self.check_files(files)
+
+       @classmethod
+       def from_lines(
+               cls: Type[Self],
+               pattern_factory: Union[str, Callable[[AnyStr], Pattern]],
+               lines: Iterable[AnyStr],
+       ) -> Self:
+               """
+               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 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.
+               """
+               if isinstance(pattern_factory, str):
+                       pattern_factory = util.lookup_pattern(pattern_factory)
+
+               if not callable(pattern_factory):
+                       raise TypeError(f"pattern_factory:{pattern_factory!r} is not callable.")
+
+               if not _is_iterable(lines):
+                       raise TypeError(f"lines:{lines!r} is not an iterable.")
+
+               patterns = [pattern_factory(line) for line in lines if line]
+               return cls(patterns)
+
+       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.
+
+               *entries* (:class:`~collections.abc.Iterable` of :class:`~pathspec.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.
+
+               *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:`~pathspec.util.TreeEntry`).
+               """
+               if not _is_iterable(entries):
+                       raise TypeError(f"entries:{entries!r} is not an iterable.")
+
+               use_patterns = _filter_check_patterns(self.patterns)
+               for entry in entries:
+                       norm_file = normalize_file(entry.path, separators)
+                       include, _index = self._match_file(use_patterns, norm_file)
+
+                       if negate:
+                               include = not include
+
+                       if include:
+                               yield entry
+
+       _match_file = staticmethod(util.check_match_file)
+       """
+       Match files using the `check_match_file()` utility function. Subclasses may
+       override this method as an instance method. It does not have to be a static
+       method. The signature for this method is subject to change.
+       """
+
+       def match_file(
+               self,
+               file: StrPath,
+               separators: Optional[Collection[str]] = None,
+       ) -> bool:
+               """
+               Matches the file to this path-spec.
+
+               *file* (:class:`str` or :class:`os.PathLike`) 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 = normalize_file(file, separators)
+               include, _index = self._match_file(enumerate(self.patterns), norm_file)
+               return bool(include)
+
+       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.
+
+               *files* (:class:`~collections.abc.Iterable` of :class:`str` or
+               :class:`os.PathLike`) 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.
+
+               *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`).
+               """
+               if not _is_iterable(files):
+                       raise TypeError(f"files:{files!r} is not an iterable.")
+
+               use_patterns = _filter_check_patterns(self.patterns)
+               for orig_file in files:
+                       norm_file = normalize_file(orig_file, separators)
+                       include, _index = self._match_file(use_patterns, norm_file)
+
+                       if negate:
+                               include = not include
+
+                       if include:
+                               yield orig_file
+
+       def match_tree_entries(
+               self,
+               root: StrPath,
+               on_error: Optional[Callable[[OSError], None]] = 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`) 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`.
+
+               *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, negate=negate)
+
+       def match_tree_files(
+               self,
+               root: StrPath,
+               on_error: Optional[Callable[[OSError], None]] = 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`) 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`.
+
+               *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, negate=negate)
+
+       # Alias `match_tree_files()` as `match_tree()` for backward compatibility
+       # before v0.3.2.
+       match_tree = match_tree_files
diff --git a/pathspec/pattern.py b/pathspec/pattern.py
new file mode 100644 (file)
index 0000000..d081557
--- /dev/null
@@ -0,0 +1,213 @@
+"""
+This module provides the base definition for patterns.
+"""
+
+import dataclasses
+import re
+import warnings
+from typing import (
+       Any,
+       AnyStr,
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       Iterator,  # Replaced by `collections.abc.Iterator` in 3.9.
+       Match as MatchHint,  # Replaced by `re.Match` in 3.9.
+       Optional,  # Replaced by `X | None` in 3.10.
+       Pattern as PatternHint,  # Replaced by `re.Pattern` in 3.9.
+       Tuple,  # Replaced by `tuple` in 3.9.
+       Union)  # Replaced by `X | Y` in 3.10.
+
+
+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: 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: Iterable[str]) -> Iterator[str]:
+               """
+               DEPRECATED: This method is no longer used and has been replaced by
+               :meth:`.match_file`. Use the :meth:`.match_file` method with a loop for
+               similar results.
+
+               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`).
+               """
+               warnings.warn((
+                       "{cls.__module__}.{cls.__qualname__}.match() is deprecated. Use "
+                       "{cls.__module__}.{cls.__qualname__}.match_file() with a loop for "
+                       "similar results."
+               ).format(cls=self.__class__), DeprecationWarning, stacklevel=2)
+
+               for file in files:
+                       if self.match_file(file) is not None:
+                               yield file
+
+       def match_file(self, file: str) -> Optional[Any]:
+               """
+               Matches this pattern against the specified file.
+
+               *file* (:class:`str`) is the normalized file path to match against.
+
+               Returns the match result if *file* matched; otherwise, :data:`None`.
+               """
+               raise NotImplementedError((
+                       "{cls.__module__}.{cls.__qualname__} must override match_file()."
+               ).format(cls=self.__class__))
+
+
+class RegexPattern(Pattern):
+       """
+       The :class:`RegexPattern` class is an implementation of a pattern using
+       regular expressions.
+       """
+
+       # Keep the class dict-less.
+       __slots__ = (
+               'pattern',
+               'regex',
+       )
+
+       def __init__(
+               self,
+               pattern: Union[AnyStr, PatternHint, None],
+               include: Optional[bool] = None,
+       ) -> None:
+               """
+               Initializes the :class:`RegexPattern` instance.
+
+               *pattern* (:class:`str`, :class:`bytes`, :class:`re.Pattern`, 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.Pattern`) 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.
+               """
+
+               if isinstance(pattern, (str, bytes)):
+                       assert include is None, (
+                               f"include:{include!r} must be null when pattern:{pattern!r} is a string."
+                       )
+                       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, (
+                               f"include:{include!r} must be null when pattern:{pattern!r} is null."
+                       )
+
+               else:
+                       raise TypeError(f"pattern:{pattern!r} is not a string, re.Pattern, or None.")
+
+               super(RegexPattern, self).__init__(include)
+
+               self.pattern: Union[AnyStr, PatternHint, None] = pattern
+               """
+               *pattern* (:class:`str`, :class:`bytes`, :class:`re.Pattern`, or
+               :data:`None`) is the uncompiled, input pattern. This is for reference.
+               """
+
+               self.regex: PatternHint = regex
+               """
+               *regex* (:class:`re.Pattern`) is the regular expression for the pattern.
+               """
+
+       def __eq__(self, other: '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_file(self, file: str) -> Optional['RegexMatchResult']:
+               """
+               Matches this pattern against the specified file.
+
+               *file* (:class:`str`) contains each file relative to the root directory
+               (e.g., "relative/path/to/file").
+
+               Returns the match result (:class:`.RegexMatchResult`) if *file* matched;
+               otherwise, :data:`None`.
+               """
+               if self.include is not None:
+                       match = self.regex.match(file)
+                       if match is not None:
+                               return RegexMatchResult(match)
+
+               return None
+
+       @classmethod
+       def pattern_to_regex(cls, pattern: str) -> Tuple[str, 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
+
+
+@dataclasses.dataclass()
+class RegexMatchResult(object):
+       """
+       The :class:`RegexMatchResult` data class is used to return information about
+       the matched regular expression.
+       """
+
+       # Keep the class dict-less.
+       __slots__ = (
+               'match',
+       )
+
+       match: MatchHint
+       """
+       *match* (:class:`re.Match`) is the regex match result.
+       """
diff --git a/pathspec/patterns/__init__.py b/pathspec/patterns/__init__.py
new file mode 100644 (file)
index 0000000..7360e9c
--- /dev/null
@@ -0,0 +1,11 @@
+"""
+The *pathspec.patterns* package contains the pattern matching
+implementations.
+"""
+
+# Load pattern implementations.
+from . import gitwildmatch
+
+# DEPRECATED: Expose the `GitWildMatchPattern` class in this module for
+# backward compatibility with v0.5.
+from .gitwildmatch import GitWildMatchPattern
diff --git a/pathspec/patterns/gitwildmatch.py b/pathspec/patterns/gitwildmatch.py
new file mode 100644 (file)
index 0000000..6a3d6d5
--- /dev/null
@@ -0,0 +1,421 @@
+"""
+This module implements Git's wildmatch pattern matching which itself is derived
+from Rsync's wildmatch. Git uses wildmatch for its ".gitignore" files.
+"""
+
+import re
+import warnings
+from typing import (
+       AnyStr,
+       Optional,  # Replaced by `X | None` in 3.10.
+       Tuple)  # Replaced by `tuple` in 3.9.
+
+from .. import util
+from ..pattern import RegexPattern
+
+_BYTES_ENCODING = 'latin1'
+"""
+The encoding to use when parsing a byte string pattern.
+"""
+
+_DIR_MARK = 'ps_d'
+"""
+The regex group name for the directory marker. This is only used by
+:class:`GitIgnoreSpec`.
+"""
+
+
+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: AnyStr,
+       ) -> Tuple[Optional[AnyStr], Optional[bool]]:
+               """
+               Convert the pattern into a regular expression.
+
+               *pattern* (:class:`str` or :class:`bytes`) is the pattern to convert into a
+               regular expression.
+
+               Returns the uncompiled regular expression (:class:`str`, :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, str):
+                       return_type = str
+               elif isinstance(pattern, bytes):
+                       return_type = bytes
+                       pattern = pattern.decode(_BYTES_ENCODING)
+               else:
+                       raise TypeError(f"pattern:{pattern!r} is not a unicode or byte string.")
+
+               original_pattern = pattern
+
+               if pattern.endswith('\\ '):
+                       # EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends
+                       # with backslash followed by a space, only strip from left.
+                       pattern = pattern.lstrip()
+               else:
+                       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
+
+                       # Allow a regex override for edge cases that cannot be handled through
+                       # normalization.
+                       override_regex = None
+
+                       # Split pattern into segments.
+                       pattern_segs = pattern.split('/')
+
+                       # Check whether the pattern is specifically a directory pattern before
+                       # normalization.
+                       is_dir_pattern = not pattern_segs[-1]
+
+                       # 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 = f'^.+(?P<{_DIR_MARK}>/).*$'
+
+                       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(f"Invalid git pattern: {original_pattern!r}")
+
+                       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(f'[^/]+(?:/.*)?')
+
+                                               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.
+                                                       if is_dir_pattern:
+                                                               output.append(f'(?P<{_DIR_MARK}>/).*')
+                                                       else:
+                                                               output.append(f'/.*')
+
+                                               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('[^/]+')
+
+                                               if i == end:
+                                                       # 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.
+                                                       output.append(f'(?:(?P<{_DIR_MARK}>/).*)?')
+
+                                               need_slash = True
+
+                                       else:
+                                               # Match segment glob pattern.
+                                               if need_slash:
+                                                       output.append('/')
+
+                                               try:
+                                                       output.append(cls._translate_segment_glob(seg))
+                                               except ValueError as e:
+                                                       raise GitWildMatchPatternError(f"Invalid git pattern: {original_pattern!r}") from e
+
+                                               if i == end:
+                                                       # 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.
+                                                       output.append(f'(?:(?P<{_DIR_MARK}>/).*)?')
+
+                                               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: str) -> str:
+               """
+               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 bracket expression negation.
+                               if j < end and (pattern[j] == '!' or 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] == '!':
+                                               # Bracket 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. Git supports the using a
+                                               # caret for negation. Maintain consistency with Git because that is
+                                               # the expected behavior.
+                                               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)
+
+               if escape:
+                       raise ValueError(f"Escape character found with no next character to escape: {pattern!r}")
+
+               return regex
+
+       @staticmethod
+       def escape(s: AnyStr) -> AnyStr:
+               """
+               Escape special characters in the given string.
+
+               *s* (:class:`str` 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:`str` or :class:`bytes`).
+               """
+               if isinstance(s, str):
+                       return_type = str
+                       string = s
+               elif isinstance(s, bytes):
+                       return_type = bytes
+                       string = s.decode(_BYTES_ENCODING)
+               else:
+                       raise TypeError(f"s:{s!r} is not a unicode or byte string.")
+
+               # 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) -> None:
+               """
+               Warn about deprecation.
+               """
+               self._deprecated()
+               super(GitIgnorePattern, self).__init__(*args, **kw)
+
+       @staticmethod
+       def _deprecated() -> None:
+               """
+               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)
diff --git a/pathspec/py.typed b/pathspec/py.typed
new file mode 100644 (file)
index 0000000..b01eaaf
--- /dev/null
@@ -0,0 +1 @@
+# Marker file for PEP 561.  The pathspec package uses inline types.
diff --git a/pathspec/util.py b/pathspec/util.py
new file mode 100644 (file)
index 0000000..5883951
--- /dev/null
@@ -0,0 +1,792 @@
+"""
+This module provides utility methods for dealing with path-specs.
+"""
+
+import os
+import os.path
+import pathlib
+import posixpath
+import stat
+import sys
+import warnings
+from collections.abc import (
+       Collection as CollectionType,
+       Iterable as IterableType)
+from dataclasses import (
+       dataclass)
+from os import (
+       PathLike)
+from typing import (
+       Any,
+       AnyStr,
+       Callable,  # Replaced by `collections.abc.Callable` in 3.9.
+       Collection,  # Replaced by `collections.abc.Collection` in 3.9.
+       Dict,  # Replaced by `dict` in 3.9.
+       Generic,
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       Iterator,  # Replaced by `collections.abc.Iterator` in 3.9.
+       List,  # Replaced by `list` in 3.9.
+       Optional,  # Replaced by `X | None` in 3.10.
+       Sequence,  # Replaced by `collections.abc.Sequence` in 3.9.
+       Set,  # Replaced by `set` in 3.9.
+       Tuple,  # Replaced by `tuple` in 3.9.
+       TypeVar,
+       Union)  # Replaced by `X | Y` in 3.10.
+
+from .pattern import (
+       Pattern)
+
+if sys.version_info >= (3, 9):
+       StrPath = Union[str, PathLike[str]]
+else:
+       StrPath = Union[str, PathLike]
+
+TStrPath = TypeVar("TStrPath", bound=StrPath)
+"""
+Type variable for :class:`str` or :class:`os.PathLike`.
+"""
+
+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 append_dir_sep(path: pathlib.Path) -> str:
+       """
+       Appends the path separator to the path if the path is a directory.
+       This can be used to aid in distinguishing between directories and
+       files on the file-system by relying on the presence of a trailing path
+       separator.
+
+       *path* (:class:`pathlib.Path`) is the path to use.
+
+       Returns the path (:class:`str`).
+       """
+       str_path = str(path)
+       if path.is_dir():
+               str_path += os.sep
+
+       return str_path
+
+
+def check_match_file(
+       patterns: Iterable[Tuple[int, Pattern]],
+       file: str,
+) -> Tuple[Optional[bool], Optional[int]]:
+       """
+       Check the file against the patterns.
+
+       *patterns* (:class:`~collections.abc.Iterable`) yields each indexed pattern
+       (:class:`tuple`) which contains the pattern index (:class:`int`) and actual
+       pattern (:class:`~pathspec.pattern.Pattern`).
+
+       *file* (:class:`str`) is the normalized file path to be matched
+       against *patterns*.
+
+       Returns a :class:`tuple` containing whether to include *file* (:class:`bool`
+       or :data:`None`), and the index of the last matched pattern (:class:`int` or
+       :data:`None`).
+       """
+       out_include: Optional[bool] = None
+       out_index: Optional[int] = None
+       for index, pattern in patterns:
+               if pattern.include is not None and pattern.match_file(file) is not None:
+                       out_include = pattern.include
+                       out_index = index
+
+       return out_include, out_index
+
+
+def detailed_match_files(
+       patterns: Iterable[Pattern],
+       files: Iterable[str],
+       all_matches: Optional[bool] = None,
+) -> Dict[str, '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:`bool` 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)  # TODO: Replace with `.match_file()`.
+                       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 _filter_check_patterns(
+       patterns: Iterable[Pattern],
+) -> List[Tuple[int, Pattern]]:
+       """
+       Filters out null-patterns.
+
+       *patterns* (:class:`Iterable` of :class:`.Pattern`) contains the
+       patterns.
+
+       Returns a :class:`list` containing each indexed pattern (:class:`tuple`) which
+       contains the pattern index (:class:`int`) and the actual pattern
+       (:class:`~pathspec.pattern.Pattern`).
+       """
+       return [
+               (__index, __pat)
+               for __index, __pat in enumerate(patterns)
+               if __pat.include is not None
+       ]
+
+
+def _is_iterable(value: 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, (str, bytes))
+
+
+def iter_tree_entries(
+       root: StrPath,
+       on_error: Optional[Callable[[OSError], None]] = None,
+       follow_links: Optional[bool] = None,
+) -> Iterator['TreeEntry']:
+       """
+       Walks the specified directory for all files and directories.
+
+       *root* (:class:`str` or :class:`os.PathLike`) 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(f"on_error:{on_error!r} is not callable.")
+
+       if follow_links is None:
+               follow_links = True
+
+       yield from _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links)
+
+
+def _iter_tree_entries_next(
+       root_full: str,
+       dir_rel: str,
+       memo: Dict[str, str],
+       on_error: Callable[[OSError], None],
+       follow_links: 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)
+
+       with os.scandir(dir_full) as scan_iter:
+               node_ent: os.DirEntry
+               for node_ent in scan_iter:
+                       node_rel = os.path.join(dir_rel, node_ent.name)
+
+                       # Inspect child node.
+                       try:
+                               node_lstat = node_ent.stat(follow_symlinks=False)
+                       except OSError as e:
+                               if on_error is not None:
+                                       on_error(e)
+                               continue
+
+                       if node_ent.is_symlink():
+                               # Child node is a link, inspect the target node.
+                               try:
+                                       node_stat = node_ent.stat()
+                               except OSError as e:
+                                       if on_error is not None:
+                                               on_error(e)
+                                       continue
+                       else:
+                               node_stat = node_lstat
+
+                       if node_ent.is_dir(follow_symlinks=follow_links):
+                               # Child node is a directory, recurse into it and yield its
+                               # descendant files.
+                               yield TreeEntry(node_ent.name, node_rel, node_lstat, node_stat)
+
+                               yield from _iter_tree_entries_next(root_full, node_rel, memo, on_error, follow_links)
+
+                       elif node_ent.is_file() or node_ent.is_symlink():
+                               # Child node is either a file or an unfollowed link, yield it.
+                               yield TreeEntry(node_ent.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 iter_tree_files(
+       root: StrPath,
+       on_error: Optional[Callable[[OSError], None]] = None,
+       follow_links: Optional[bool] = None,
+) -> Iterator[str]:
+       """
+       Walks the specified directory for all files.
+
+       *root* (:class:`str` or :class:`os.PathLike`) 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*.
+       """
+       for entry in iter_tree_entries(root, on_error=on_error, follow_links=follow_links):
+               if not entry.is_dir(follow_links):
+                       yield entry.path
+
+
+def iter_tree(root, on_error=None, follow_links=None):
+       """
+       DEPRECATED: The :func:`.iter_tree` function is an alias for the
+       :func:`.iter_tree_files` function.
+       """
+       warnings.warn((
+               "util.iter_tree() is deprecated. Use util.iter_tree_files() instead."
+       ), DeprecationWarning, stacklevel=2)
+       return iter_tree_files(root, on_error=on_error, follow_links=follow_links)
+
+
+def lookup_pattern(name: str) -> 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: Iterable[Pattern], file: str) -> 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 and pattern.match_file(file) is not None:
+                       matched = pattern.include
+
+       return matched
+
+
+def match_files(
+       patterns: Iterable[Pattern],
+       files: Iterable[str],
+) -> Set[str]:
+       """
+       DEPRECATED: This is an old function no longer used. Use the
+       :func:`~pathspec.util.match_file` function with a loop for better results.
+
+       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`).
+       """
+       warnings.warn((
+               f"{__name__}.match_files() is deprecated. Use {__name__}.match_file() with "
+               f"a loop for better results."
+       ), DeprecationWarning, stacklevel=2)
+
+       use_patterns = [__pat for __pat in patterns if __pat.include is not None]
+
+       return_files = set()
+       for file in files:
+               if match_file(use_patterns, file):
+                       return_files.add(file)
+
+       return return_files
+
+
+def normalize_file(
+       file: StrPath,
+       separators: Optional[Collection[str]] = None,
+) -> str:
+       """
+       Normalizes the file path to use the POSIX path separator (i.e.,
+       ``"/"``), and make the paths relative (remove leading ``"/"``).
+
+       *file* (:class:`str` or :class:`os.PathLike`) is the file path.
+
+       *separators* (:class:`~collections.abc.Collection` of :class:`str`; or
+       ``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 ``None``
+       for ``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 = os.fspath(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: Iterable[StrPath],
+       separators: Optional[Collection[str]] = None,
+) -> Dict[str, List[StrPath]]:
+       """
+       DEPRECATED: This function is no longer used. Use the :func:`.normalize_file`
+       function with a loop for better results.
+
+       Normalizes the file paths to use the POSIX path separator.
+
+       *files* (:class:`~collections.abc.Iterable` of :class:`str` or
+       :class:`os.PathLike`) 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 each normalized file path (:class:`str`)
+       to the original file paths (:class:`list` of :class:`str` or
+       :class:`os.PathLike`).
+       """
+       warnings.warn((
+               "util.normalize_files() is deprecated. Use util.normalize_file() "
+               "with a loop for better results."
+       ), DeprecationWarning, stacklevel=2)
+
+       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: str,
+       pattern_factory: Callable[[AnyStr], Pattern],
+       override: Optional[bool] = None,
+) -> 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, str):
+               raise TypeError(f"name:{name!r} is not a string.")
+
+       if not callable(pattern_factory):
+               raise TypeError(f"pattern_factory:{pattern_factory!r} is not callable.")
+
+       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: str,
+               pattern_factory: 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) -> str:
+               """
+               *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) -> str:
+               """
+               *name* (:class:`str`) is the name of the registered pattern.
+               """
+               return self.args[0]
+
+       @property
+       def pattern_factory(self) -> 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: str,
+               first_path: str,
+               second_path: str,
+       ) -> 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) -> str:
+               """
+               *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) -> str:
+               """
+               *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) -> str:
+               """
+               *real_path* (:class:`str`) is the real path that recursion was
+               encountered on.
+               """
+               return self.args[0]
+
+       @property
+       def second_path(self) -> str:
+               """
+               *second_path* (:class:`str`) is the second path encountered for
+               :attr:`self.real_path <RecursionError.real_path>`.
+               """
+               return self.args[2]
+
+
+@dataclass(frozen=True)
+class CheckResult(Generic[TStrPath]):
+       """
+       The :class:`CheckResult` class contains information about the file and which
+       pattern matched it.
+       """
+
+       # Make the class dict-less.
+       __slots__ = (
+               'file',
+               'include',
+               'index',
+       )
+
+       file: TStrPath
+       """
+       *file* (:class:`str` or :class:`os.PathLike`) is the file path.
+       """
+
+       include: Optional[bool]
+       """
+       *include* (:class:`bool` or :data:`None`) is whether to include or exclude the
+       file. If :data:`None`, no pattern matched.
+       """
+
+       index: Optional[int]
+       """
+       *index* (:class:`int` or :data:`None`) is the index of the last pattern that
+       matched. If :data:`None`, no pattern matched.
+       """
+
+
+class MatchDetail(object):
+       """
+       The :class:`.MatchDetail` class contains information about
+       """
+
+       # Make the class dict-less.
+       __slots__ = ('patterns',)
+
+       def __init__(self, patterns: 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: str,
+               path: str,
+               lstat: os.stat_result,
+               stat: 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: os.stat_result = lstat
+               """
+               *_lstat* (:class:`os.stat_result`) is the stat result of the direct
+               entry.
+               """
+
+               self.name: str = name
+               """
+               *name* (:class:`str`) is the base name of the entry.
+               """
+
+               self.path: str = path
+               """
+               *path* (:class:`str`) is the path of the entry.
+               """
+
+               self._stat: os.stat_result = stat
+               """
+               *_stat* (:class:`os.stat_result`) is the stat result of the linked
+               entry.
+               """
+
+       def is_dir(self, follow_links: Optional[bool] = None) -> 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: Optional[bool] = None) -> 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) -> bool:
+               """
+               Returns whether the entry is a symbolic link (:class:`bool`).
+               """
+               return stat.S_ISLNK(self._lstat.st_mode)
+
+       def stat(self, follow_links: Optional[bool] = None) -> 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
diff --git a/prebuild.py b/prebuild.py
new file mode 100644 (file)
index 0000000..9c5a2bf
--- /dev/null
@@ -0,0 +1,84 @@
+"""
+This script generates files required for source and wheel distributions,
+and legacy installations.
+"""
+
+import argparse
+import configparser
+import sys
+
+import tomli
+
+
+def generate_readme_dist() -> None:
+       """
+       Generate the "README-dist.rst" file from "README.rst" and
+       "CHANGES.rst".
+       """
+       print("Read: README.rst")
+       with open("README.rst", 'r', encoding='utf8') as fh:
+               output = fh.read()
+
+       print("Read: CHANGES.rst")
+       with open("CHANGES.rst", 'r', encoding='utf8') as fh:
+               output += "\n\n"
+               output += fh.read()
+
+       print("Write: README-dist.rst")
+       with open("README-dist.rst", 'w', encoding='utf8') as fh:
+               fh.write(output)
+
+
+def generate_setup_cfg() -> None:
+       """
+       Generate the "setup.cfg" file from "pyproject.toml" in order to
+       support legacy installation with "setup.py".
+       """
+       print("Read: pyproject.toml")
+       with open("pyproject.toml", 'rb') as fh:
+               config = tomli.load(fh)
+
+       print("Write: setup.cfg")
+       output = configparser.ConfigParser()
+       output['metadata'] = {
+               'author': config['project']['authors'][0]['name'],
+               'author_email': config['project']['authors'][0]['email'],
+               'classifiers': "\n" + "\n".join(config['project']['classifiers']),
+               'description': config['project']['description'],
+               'license': config['project']['license']['text'],
+               'long_description': f"file: {config['project']['readme']}",
+               'long_description_content_type': "text/x-rst",
+               'name': config['project']['name'],
+               'url': config['project']['urls']['Source Code'],
+               'version': "attr: pathspec._meta.__version__",
+       }
+       output['options'] = {
+               'packages': "find:",
+               'python_requires': config['project']['requires-python'],
+               'setup_requires': "setuptools>=40.8.0",
+               'test_suite': "tests",
+       }
+       output['options.packages.find'] = {
+               'include': "pathspec, pathspec.*",
+       }
+
+       with open("setup.cfg", 'w', encoding='utf8') as fh:
+               output.write(fh)
+
+
+def main() -> int:
+       """
+       Run the script.
+       """
+       # Parse command-line arguments.
+       parser = argparse.ArgumentParser(description=__doc__)
+       parser.parse_args(sys.argv[1:])
+
+       generate_readme_dist()
+       generate_setup_cfg()
+
+       return 0
+
+
+if __name__ == '__main__':
+       sys.exit(main())
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..1d65322
--- /dev/null
@@ -0,0 +1,52 @@
+[build-system]
+build-backend = "flit_core.buildapi"
+requires = ["flit_core >=3.2,<4"]
+
+[project]
+authors = [
+       {name = "Caleb P. Burns", email = "cpburnz@gmail.com"},
+]
+classifiers = [
+       "Development Status :: 4 - Beta",
+       "Intended Audience :: Developers",
+       "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
+       "Operating System :: OS Independent",
+       "Programming Language :: Python",
+       "Programming Language :: Python :: 3",
+       "Programming Language :: Python :: 3.8",
+       "Programming Language :: Python :: 3.9",
+       "Programming Language :: Python :: 3.10",
+       "Programming Language :: Python :: 3.11",
+       "Programming Language :: Python :: 3.12",
+       "Programming Language :: Python :: Implementation :: CPython",
+       "Programming Language :: Python :: Implementation :: PyPy",
+       "Topic :: Software Development :: Libraries :: Python Modules",
+       "Topic :: Utilities",
+]
+description = "Utility library for gitignore style pattern matching of file paths."
+dynamic = ["version"]
+license = {text = "MPL 2.0"}
+name = "pathspec"
+readme = "README-dist.rst"
+requires-python = ">=3.8"
+
+[project.urls]
+"Source Code" = "https://github.com/cpburnz/python-pathspec"
+"Documentation" = "https://python-path-specification.readthedocs.io/en/latest/index.html"
+"Issue Tracker" = "https://github.com/cpburnz/python-pathspec/issues"
+
+
+[tool.flit.sdist]
+include = [
+       "*.cfg",
+       "*.in",
+       "*.ini",
+       "*.py",
+       "*.rst",
+       "LICENSE",
+       "doc/",
+       "tests/",
+]
+exclude = [
+       "doc/build/",
+]
diff --git a/setup.cfg b/setup.cfg
new file mode 100644 (file)
index 0000000..34abfd1
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,36 @@
+[metadata]
+author = Caleb P. Burns
+author_email = cpburnz@gmail.com
+classifiers = 
+       Development Status :: 4 - Beta
+       Intended Audience :: Developers
+       License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
+       Operating System :: OS Independent
+       Programming Language :: Python
+       Programming Language :: Python :: 3
+       Programming Language :: Python :: 3.8
+       Programming Language :: Python :: 3.9
+       Programming Language :: Python :: 3.10
+       Programming Language :: Python :: 3.11
+       Programming Language :: Python :: 3.12
+       Programming Language :: Python :: Implementation :: CPython
+       Programming Language :: Python :: Implementation :: PyPy
+       Topic :: Software Development :: Libraries :: Python Modules
+       Topic :: Utilities
+description = Utility library for gitignore style pattern matching of file paths.
+license = MPL 2.0
+long_description = file: README-dist.rst
+long_description_content_type = text/x-rst
+name = pathspec
+url = https://github.com/cpburnz/python-pathspec
+version = attr: pathspec._meta.__version__
+
+[options]
+packages = find:
+python_requires = >=3.8
+setup_requires = setuptools>=40.8.0
+test_suite = tests
+
+[options.packages.find]
+include = pathspec, pathspec.*
+
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..17788aa
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,8 @@
+"""
+This setup script only exists to support legacy installations where pip
+is cumbersome be used such as for system packages.
+"""
+
+from setuptools import setup
+
+setup()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_01_util.py b/tests/test_01_util.py
new file mode 100644 (file)
index 0000000..1385fe7
--- /dev/null
@@ -0,0 +1,503 @@
+"""
+This script tests utility functions.
+"""
+
+import errno
+import os
+import os.path
+import pathlib
+import shutil
+import tempfile
+import unittest
+from functools import (
+       partial)
+from typing import (
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       Optional,  # Replaced by `X | None` in 3.10.
+       Tuple)  # Replaced by `tuple` in 3.9.
+
+from pathspec.patterns.gitwildmatch import (
+       GitWildMatchPattern)
+from pathspec.util import (
+       RecursionError,
+       check_match_file,
+       iter_tree_entries,
+       iter_tree_files,
+       match_file,
+       normalize_file)
+from tests.util import (
+       make_dirs,
+       make_files,
+       make_links,
+       mkfile,
+       ospath)
+
+
+class CheckMatchFileTest(unittest.TestCase):
+       """
+       The :class:`CheckMatchFileTest` class tests the :meth:`.check_match_file`
+       function.
+       """
+
+       def test_01_single_1_include(self):
+               """
+               Test checking a single file that is included.
+               """
+               patterns = list(enumerate(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ])))
+
+               include_index = check_match_file(patterns, "include.txt")
+
+               self.assertEqual(include_index, (True, 0))
+
+       def test_01_single_2_exclude(self):
+               """
+               Test checking a single file that is excluded.
+               """
+               patterns = list(enumerate(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ])))
+
+               include_index = check_match_file(patterns, "test/exclude.txt")
+
+               self.assertEqual(include_index, (False, 1))
+
+       def test_01_single_3_unmatch(self):
+               """
+               Test checking a single file that is ignored.
+               """
+               patterns = list(enumerate(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ])))
+
+               include_index = check_match_file(patterns, "unmatch.bin")
+
+               self.assertEqual(include_index, (None, None))
+
+       def test_02_many(self):
+               """
+               Test matching files individually.
+               """
+               patterns = list(enumerate(map(GitWildMatchPattern, [
+                       '*.txt',
+                       '!b.txt',
+               ])))
+               files = {
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               }
+
+               includes = {
+                       __file
+                       for __file in files
+                       if check_match_file(patterns, __file)[0]
+               }
+
+               self.assertEqual(includes, {
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               })
+
+
+class IterTreeTest(unittest.TestCase):
+       """
+       The :class:`IterTreeTest` class tests :meth:`.iter_tree_entries` and
+       :meth:`.iter_tree_files` functions.
+       """
+
+       def make_dirs(self, dirs: Iterable[str]) -> None:
+               """
+               Create the specified directories.
+               """
+               make_dirs(self.temp_dir, dirs)
+
+       def make_files(self, files: Iterable[str]) -> None:
+               """
+               Create the specified files.
+               """
+               make_files(self.temp_dir, files)
+
+       def make_links(self, links: Iterable[Tuple[str, str]]) -> None:
+               """
+               Create the specified links.
+               """
+               make_links(self.temp_dir, links)
+
+       def require_symlink(self) -> None:
+               """
+               Skips the test if `os.symlink` is not supported.
+               """
+               if self.no_symlink:
+                       raise unittest.SkipTest("`os.symlink` is not supported.")
+
+       def setUp(self) -> None:
+               """
+               Called before each test.
+               """
+               self.temp_dir = pathlib.Path(tempfile.mkdtemp())
+
+       def tearDown(self) -> None:
+               """
+               Called after each test.
+               """
+               shutil.rmtree(self.temp_dir)
+
+       def test_01_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(ospath, [
+                       'a',
+                       'b',
+                       'Dir/c',
+                       'Dir/d',
+                       'Dir/Inner/e',
+                       'Dir/Inner/f',
+               ])))
+
+       def test_02_link_1_check_1_symlink(self):
+               """
+               Tests whether links can be created.
+               """
+               # NOTE: Windows Vista and greater supports `os.symlink` for Python
+               # 3.2+.
+               no_symlink: Optional[bool] = None
+               try:
+                       file = self.temp_dir / 'file'
+                       link = self.temp_dir / 'link'
+                       mkfile(file)
+
+                       try:
+                               os.symlink(file, link)
+                       except (AttributeError, NotImplementedError, OSError):
+                               no_symlink = True
+                       else:
+                               no_symlink = False
+
+               finally:
+                       self.__class__.no_symlink = no_symlink
+
+       def test_02_link_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(ospath, [
+                       'a',
+                       'ax',
+                       'b',
+                       'bx',
+                       'Dir/c',
+                       'Dir/cx',
+                       'Dir/d',
+                       'Dir/dx',
+                       'DirX/c',
+                       'DirX/cx',
+                       'DirX/d',
+                       'DirX/dx',
+               ])))
+
+       def test_02_link_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(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_02_link_4_recursive_links(self):
+               """
+               Tests detection of recursive links.
+               """
+               self.require_symlink()
+               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, ospath('Dir/Self'))
+
+       def test_02_link_5_recursive_circular_links(self):
+               """
+               Tests detection of recursion through circular links.
+               """
+               self.require_symlink()
+               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': ospath('A/Bx/Cx/Ax'),
+                       'B': ospath('B/Cx/Ax/Bx'),
+                       'C': ospath('C/Ax/Bx/Cx'),
+               }[context.exception.first_path])
+
+       def test_02_link_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_02_link_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_02_link_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(ospath, [
+                       'A',
+                       'Ax',
+                       'B',
+                       'Bx',
+                       'Dir/C',
+                       'Dir/Cx',
+                       'Dir/D',
+                       'Dir/Dx',
+                       'DirX',
+               ])))
+
+       def test_03_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(ospath, [
+                       'a',
+                       'b',
+                       'Dir',
+                       'Dir/c',
+                       'Dir/d',
+                       'Dir/Inner',
+                       'Dir/Inner/e',
+                       'Dir/Inner/f',
+                       'Empty',
+               ])))
+
+
+class MatchFileTest(unittest.TestCase):
+       """
+       The :class:`MatchFileTest` class tests the :meth:`.match_file`
+       function.
+       """
+
+       def test_01_single_1_include(self):
+               """
+               Test checking a single file that is included.
+               """
+               patterns = list(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ]))
+
+               include = match_file(patterns, "include.txt")
+
+               self.assertIs(include, True)
+
+       def test_01_single_2_exclude(self):
+               """
+               Test checking a single file that is excluded.
+               """
+               patterns = list(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ]))
+
+               include = match_file(patterns, "test/exclude.txt")
+
+               self.assertIs(include, False)
+
+       def test_01_single_3_unmatch(self):
+               """
+               Test checking a single file that is ignored.
+               """
+               patterns = list(map(GitWildMatchPattern, [
+                       "*.txt",
+                       "!test/",
+               ]))
+
+               include = match_file(patterns, "unmatch.bin")
+
+               self.assertIs(include, False)
+
+       def test_02_many(self):
+               """
+               Test matching files individually.
+               """
+               patterns = list(map(GitWildMatchPattern, [
+                       '*.txt',
+                       '!b.txt',
+               ]))
+               files = {
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               }
+
+               includes = set(filter(partial(match_file, patterns), files))
+
+               self.assertEqual(includes, {
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               })
+
+
+class NormalizeFileTest(unittest.TestCase):
+       """
+       The :class:`NormalizeFileTest` class tests the :meth:`.normalize_file`
+       function.
+       """
+
+       def test_01_purepath(self):
+               """
+               Tests normalizing a :class:`pathlib.PurePath` as argument.
+               """
+               first_spec = normalize_file(pathlib.PurePath('a.txt'))
+               second_spec = normalize_file('a.txt')
+               self.assertEqual(first_spec, second_spec)
diff --git a/tests/test_02_gitwildmatch.py b/tests/test_02_gitwildmatch.py
new file mode 100644 (file)
index 0000000..3d272a4
--- /dev/null
@@ -0,0 +1,861 @@
+"""
+This script tests :class:`.GitWildMatchPattern`.
+"""
+
+import re
+import unittest
+
+import pathspec.patterns.gitwildmatch
+from pathspec.patterns.gitwildmatch import (
+       GitWildMatchPattern,
+       GitWildMatchPatternError,
+       _BYTES_ENCODING,
+       _DIR_MARK)
+from pathspec.util import (
+       lookup_pattern)
+
+
+RE_DIR = f"(?P<{_DIR_MARK}>/)"
+"""
+This regular expression matches the directory marker.
+"""
+
+RE_SUB = f"(?:{RE_DIR}.*)?"
+"""
+This regular expression matches an optional sub-path.
+"""
+
+
+class GitWildMatchTest(unittest.TestCase):
+       """
+       The :class:`GitWildMatchTest` class tests the :class:`GitWildMatchPattern`
+       implementation.
+       """
+
+       def _check_invalid_pattern(self, git_ignore_pattern):
+               expected_message_pattern = re.escape(repr(git_ignore_pattern))
+               with self.assertRaisesRegex(GitWildMatchPatternError, expected_message_pattern):
+                       GitWildMatchPattern(git_ignore_pattern)
+
+       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, f'^an/absolute/file/path{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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_ignore(self):
+               """
+               Tests an ignore absolute path pattern.
+               """
+               regex, include = GitWildMatchPattern.pattern_to_regex('!/foo/build')
+               self.assertIs(include, False)
+               self.assertEqual(regex, f'^foo/build{RE_SUB}$')
+
+               # NOTE: The pattern match is backwards because the pattern itself
+               # does not consider the include attribute.
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       'build/file.py',
+                       'foo/build/file.py',
+               ]))
+               self.assertEqual(results, {
+                       'foo/build/file.py',
+               })
+
+       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, f'^(?:.+/)?spam{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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, f'^foo/spam{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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.assertIs(include, False)
+               self.assertEqual(regex, f'^(?:.+/)?temp{RE_SUB}$')
+
+               # NOTE: The pattern match is backwards because the pattern itself
+               # does not consider the include attribute.
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       'temp/foo',
+               ]))
+               self.assertEqual(results, {
+                       'temp/foo',
+               })
+
+       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(filter(pattern.match_file, [
+                       '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, f'^left(?:/.+)?/right{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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, f'^[^/]+(?:/.*)?$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       'x',
+                       'y.py',
+                       'A/x',
+                       'A/y.py',
+                       'A/B/x',
+                       'A/B/y.py',
+                       'A/B/C/x',
+                       'A/B/C/y.py',
+               ]))
+
+               self.assertEqual(results, {
+                       'x',
+                       'y.py',
+                       'A/x',
+                       'A/y.py',
+                       'A/B/x',
+                       'A/B/y.py',
+                       'A/B/C/x',
+                       'A/B/C/y.py',
+               })
+
+       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, f'^(?:.+/)?spam{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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, f'^(?:.+/)?api{RE_SUB}$')
+
+               equivalent_regex, include = GitWildMatchPattern.pattern_to_regex(f'**/**/api')
+               self.assertTrue(include)
+               self.assertEqual(equivalent_regex, regex)
+
+               regex, include = GitWildMatchPattern.pattern_to_regex('**/api/')
+               self.assertTrue(include)
+               self.assertEqual(regex, f'^(?:.+/)?api{RE_DIR}.*$')
+
+               equivalent_regex, include = GitWildMatchPattern.pattern_to_regex(f'**/**/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)
+
+       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, f'^.+{RE_DIR}.*$')
+
+               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, f'^(?:.+/)?foo\\-[^/]*\\-bar{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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, f'^(?:.+/)?\\~temp\\-[^/]*{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '~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, f'^(?:.+/)?[^/]*\\.py{RE_SUB}$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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, f'^(?:.+/)?dir{RE_DIR}.*$')
+
+               pattern = GitWildMatchPattern(re.compile(regex), include)
+               results = set(filter(pattern.match_file, [
+                       '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(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(lookup_pattern('gitignore'), pathspec.GitIgnorePattern)
+
+       def test_07_encode_bytes(self):
+               """
+               Test encoding bytes.
+               """
+               encoded = "".join(map(chr, range(0, 256))).encode(_BYTES_ENCODING)
+               expected = (
+                       b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10"
+                       b"\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
+                       b" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\"
+                       b"]^_`abcdefghijklmnopqrstuvwxyz{|}~"
+                       b"\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d"
+                       b"\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c"
+                       b"\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab"
+                       b"\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba"
+                       b"\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9"
+                       b"\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8"
+                       b"\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7"
+                       b"\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6"
+                       b"\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(_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(filter(pattern.match_file, [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)))
+
+               # Forward slashes cannot be escaped with the current implementation.
+               # Remove ASCII 47.
+               fs_ord = ord('/')
+               encoded = encoded[:fs_ord] + encoded[fs_ord+1:]
+
+               escaped = b"".join(b"\\" + encoded[i:i+1] for i in range(len(encoded)))
+
+               pattern = GitWildMatchPattern(escaped)
+               results = set(filter(pattern.match_file, [encoded]))
+               self.assertEqual(results, {encoded})
+
+       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):
+                       pattern.match_file('a.py')
+
+       def test_07_match_unicode_and_bytes_fail(self):
+               """
+               Test unicode patterns with byte paths.
+               """
+               pattern = GitWildMatchPattern('*.py')
+               with self.assertRaises(TypeError):
+                       pattern.match_file(b'a.py')
+
+       def test_07_match_unicode_and_unicode(self):
+               """
+               Test unicode patterns with unicode paths.
+               """
+               pattern = GitWildMatchPattern('*.py')
+               results = set(filter(pattern.match_file, ['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 test_10_escape_asterisk_end(self):
+               """
+               Test escaping an asterisk at the end of a line.
+               """
+               pattern = GitWildMatchPattern("asteris\\*")
+               results = set(filter(pattern.match_file, [
+                       "asteris*",
+                       "asterisk",
+               ]))
+               self.assertEqual(results, {"asteris*"})
+
+       def test_10_escape_asterisk_mid(self):
+               """
+               Test escaping an asterisk in the middle of a line.
+               """
+               pattern = GitWildMatchPattern("as\\*erisk")
+               results = set(filter(pattern.match_file, [
+                       "as*erisk",
+                       "asterisk",
+               ]))
+               self.assertEqual(results, {"as*erisk"})
+
+       def test_10_escape_asterisk_start(self):
+               """
+               Test escaping an asterisk at the start of a line.
+               """
+               pattern = GitWildMatchPattern("\\*sterisk")
+               results = set(filter(pattern.match_file, [
+                       "*sterisk",
+                       "asterisk",
+               ]))
+               self.assertEqual(results, {"*sterisk"})
+
+       def test_10_escape_exclamation_mark_start(self):
+               """
+               Test escaping an exclamation mark at the start of a line.
+               """
+               pattern = GitWildMatchPattern("\\!mark")
+               results = set(filter(pattern.match_file, [
+                       "!mark",
+               ]))
+               self.assertEqual(results, {"!mark"})
+
+       def test_10_escape_pound_start(self):
+               """
+               Test escaping a pound sign at the start of a line.
+               """
+               pattern = GitWildMatchPattern("\\#sign")
+               results = set(filter(pattern.match_file, [
+                       "#sign",
+               ]))
+               self.assertEqual(results, {"#sign"})
+
+       def test_11_issue_19_directory_a(self):
+               """
+               Test a directory discrepancy, scenario A.
+               """
+               # NOTE: The result from GitWildMatchPattern will differ from GitIgnoreSpec.
+               pattern = GitWildMatchPattern("dirG/")
+               results = set(filter(pattern.match_file, [
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               ]))
+               self.assertEqual(results, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               })
+
+       def test_11_issue_19_directory_b(self):
+               """
+               Test a directory discrepancy, scenario B.
+               """
+               # NOTE: The result from GitWildMatchPattern will differ from GitIgnoreSpec.
+               pattern = GitWildMatchPattern("dirG/*")
+               results = set(filter(pattern.match_file, [
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               ]))
+               self.assertEqual(results, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               })
+
+       def test_11_issue_19_directory_c(self):
+               """
+               Test a directory discrepancy, scenario C.
+               """
+               # NOTE: The result from GitWildMatchPattern will differ from GitIgnoreSpec.
+               pattern = GitWildMatchPattern("dirG/**")
+               results = set(filter(pattern.match_file, [
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               ]))
+               self.assertEqual(results, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               })
+
+       def test_12_asterisk_1_regex(self):
+               """
+               Test a relative asterisk path pattern's regular expression.
+               """
+               regex, include = GitWildMatchPattern.pattern_to_regex('*')
+               self.assertTrue(include)
+               self.assertEqual(regex, f'^(?:.+/)?[^/]+{RE_SUB}$')
+
+       def test_12_asterisk_2_regex_equivalent(self):
+               """
+               Test a path pattern equivalent to the relative asterisk using double
+               asterisk.
+               """
+               regex, include = GitWildMatchPattern.pattern_to_regex('*')
+               self.assertTrue(include)
+
+               equiv_regex, include = GitWildMatchPattern.pattern_to_regex('**/*')
+               self.assertTrue(include)
+
+               self.assertEqual(regex, equiv_regex)
+
+       def test_12_asterisk_3_child(self):
+               """
+               Test a relative asterisk path pattern matching a direct child path.
+               """
+               pattern = GitWildMatchPattern("*")
+               self.assertTrue(pattern.match_file("file.txt"))
+
+       def test_12_asterisk_4_descendant(self):
+               """
+               Test a relative asterisk path pattern matching a descendant path.
+               """
+               pattern = GitWildMatchPattern("*")
+               self.assertTrue(pattern.match_file("anydir/file.txt"))
+
+       def test_12_issue_62(self):
+               """
+               Test including all files, scenario A.
+               """
+               pattern = GitWildMatchPattern("*")
+               results = set(filter(pattern.match_file, [
+                       "file.txt",
+                       "anydir/file.txt",
+               ]))
+               self.assertEqual(results, {
+                       "file.txt",
+                       "anydir/file.txt",
+               })
+
+       def test_13_issue_77_1_negate_with_caret(self):
+               """
+               Test negation using the caret symbol ("^").
+               """
+               pattern = GitWildMatchPattern("a[^gy]c")
+               results = set(filter(pattern.match_file, [
+                       "agc",
+                       "ayc",
+                       "abc",
+                       "adc",
+               ]))
+               self.assertEqual(results, {
+                       "abc",
+                       "adc",
+               })
+
+       def test_13_issue_77_1_negate_with_exclamation_mark(self):
+               """
+               Test negation using the exclamation mark ("!").
+               """
+               pattern = GitWildMatchPattern("a[!gy]c")
+               results = set(filter(pattern.match_file, [
+                       "agc",
+                       "ayc",
+                       "abc",
+                       "adc",
+               ]))
+               self.assertEqual(results, {
+                       "abc",
+                       "adc",
+               })
+
+       def test_13_issue_77_2_regex(self):
+               """
+               Test the resulting regex for regex bracket expression negation.
+               """
+               regex, include = GitWildMatchPattern.pattern_to_regex("a[^b]c")
+               self.assertTrue(include)
+
+               equiv_regex, include = GitWildMatchPattern.pattern_to_regex("a[!b]c")
+               self.assertTrue(include)
+
+               self.assertEqual(regex, equiv_regex)
+
+       def test_14_issue_81_a(self):
+               """
+               Test ignoring files in a directory, scenario A.
+               """
+               pattern = GitWildMatchPattern("!libfoo/**")
+
+               self.assertEqual(pattern.regex.pattern, "^libfoo/.*$")
+               self.assertIs(pattern.include, False)
+               self.assertTrue(pattern.match_file("libfoo/__init__.py"))
+
+       def test_14_issue_81_b(self):
+               """
+               Test ignoring files in a directory, scenario B.
+               """
+               pattern = GitWildMatchPattern("!libfoo/*")
+
+               self.assertEqual(pattern.regex.pattern, f"^libfoo/[^/]+{RE_SUB}$")
+               self.assertIs(pattern.include, False)
+               self.assertTrue(pattern.match_file("libfoo/__init__.py"))
+
+       def test_14_issue_81_c(self):
+               """
+               Test ignoring files in a directory, scenario C.
+               """
+               # GitWildMatchPattern will match the file, but GitIgnoreSpec should not.
+               pattern = GitWildMatchPattern("!libfoo/")
+
+               self.assertEqual(pattern.regex.pattern, f"^(?:.+/)?libfoo{RE_DIR}.*$")
+               self.assertIs(pattern.include, False)
+               self.assertTrue(pattern.match_file("libfoo/__init__.py"))
diff --git a/tests/test_03_pathspec.py b/tests/test_03_pathspec.py
new file mode 100644 (file)
index 0000000..d1b18cd
--- /dev/null
@@ -0,0 +1,839 @@
+"""
+This script tests :class:`.PathSpec`.
+"""
+
+import pathlib
+import shutil
+import tempfile
+import unittest
+from typing import (
+       Iterable)
+
+from pathspec import (
+       PathSpec)
+from pathspec.patterns.gitwildmatch import (
+       GitWildMatchPatternError)
+from pathspec.util import (
+       iter_tree_entries)
+
+from .util import (
+       CheckResult,
+       debug_results,
+       get_includes,
+       make_dirs,
+       make_files,
+       ospath)
+
+
+class PathSpecTest(unittest.TestCase):
+       """
+       The :class:`PathSpecTest` class tests the :class:`.PathSpec` class.
+       """
+
+       def make_dirs(self, dirs: Iterable[str]) -> None:
+               """
+               Create the specified directories.
+               """
+               make_dirs(self.temp_dir, dirs)
+
+       def make_files(self, files: Iterable[str]) -> None:
+               """
+               Create the specified files.
+               """
+               return make_files(self.temp_dir, files)
+
+       def setUp(self) -> None:
+               """
+               Called before each test.
+               """
+               self.temp_dir = pathlib.Path(tempfile.mkdtemp())
+
+       def tearDown(self) -> None:
+               """
+               Called after each test.
+               """
+               shutil.rmtree(self.temp_dir)
+
+       def test_01_absolute_dir_paths_1(self):
+               """
+               Tests that absolute paths will be properly normalized and matched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       'foo',
+               ])
+               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',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       '/foo/a.py',
+                       '/x/foo/a.py',
+                       'foo/a.py',
+                       'x/foo/a.py',
+               }, debug)
+
+       def test_01_absolute_dir_paths_2(self):
+               """
+               Tests that absolute paths will be properly normalized and matched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '/foo',
+               ])
+               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',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       '/foo/a.py',
+                       'foo/a.py',
+               }, debug)
+
+       def test_01_check_file_1_include(self):
+               """
+               Test checking a single file that is included.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               result = spec.check_file("include.txt")
+
+               self.assertEqual(result, CheckResult("include.txt", True, 0))
+
+       def test_01_check_file_2_exclude(self):
+               """
+               Test checking a single file that is excluded.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               result = spec.check_file("test/exclude.txt")
+
+               self.assertEqual(result, CheckResult("test/exclude.txt", False, 1))
+
+       def test_01_check_file_3_unmatch(self):
+               """
+               Test checking a single file that is unmatched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               result = spec.check_file("unmatch.bin")
+
+               self.assertEqual(result, CheckResult("unmatch.bin", None, None))
+
+       def test_01_check_file_4_many(self):
+               """
+               Test that checking files one at a time yields the same results as checking
+               multiples files at once.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/',
+               ])
+               files = {
+                       'test1/a.txt',
+                       'test1/b.txt',
+                       'test1/c/c.txt',
+                       'test2/a.txt',
+                       'test2/b.txt',
+                       'test2/c/c.txt',
+               }
+
+               single_results = set(map(spec.check_file, files))
+               multi_results = set(spec.check_files(files))
+
+               self.assertEqual(single_results, multi_results)
+
+       def test_01_check_match_files(self):
+               """
+               Test that checking files and matching files yield the same results.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/**',
+               ])
+               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',
+               }
+
+               check_results = set(spec.check_files(files))
+               check_includes = get_includes(check_results)
+               match_files = set(spec.match_files(files))
+
+               self.assertEqual(check_includes, match_files)
+
+       def test_01_current_dir_paths(self):
+               """
+               Tests that paths referencing the current directory will be properly
+               normalized and matched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/',
+               ])
+               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',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       './src/test2/a.txt',
+                       './src/test2/b.txt',
+                       './src/test2/c/c.txt',
+               }, debug)
+
+       def test_01_empty_path_1(self):
+               """
+               Tests that patterns that end with an escaped space will be treated properly.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '\\ ',
+                       'abc\\ '
+               ])
+               files = {
+                       ' ',
+                       '  ',
+                       'abc ',
+                       'somefile',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       ' ',
+                       'abc '
+               }, debug)
+
+       def test_01_empty_path_2(self):
+               """
+               Tests that patterns that end with an escaped space will be treated properly.
+               """
+               with self.assertRaises(GitWildMatchPatternError):
+                       # An escape with double spaces is invalid. Disallow it. Better to be safe
+                       # than sorry.
+                       PathSpec.from_lines('gitwildmatch', [
+                               '\\  ',
+                       ])
+
+       def test_01_match_file_1_include(self):
+               """
+               Test matching a single file that is included.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               include = spec.match_file("include.txt")
+
+               self.assertIs(include, True)
+
+       def test_01_match_file_2_exclude(self):
+               """
+               Test matching a single file that is excluded.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               include = spec.match_file("test/exclude.txt")
+
+               self.assertIs(include, False)
+
+       def test_01_match_file_3_unmatch(self):
+               """
+               Test match a single file that is unmatched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       "*.txt",
+                       "!test/",
+               ])
+
+               include = spec.match_file("unmatch.bin")
+
+               self.assertIs(include, False)
+
+       def test_01_match_files(self):
+               """
+               Test that matching files one at a time yields the same results as matching
+               multiples files at once.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/',
+               ])
+               files = {
+                       'test1/a.txt',
+                       'test1/b.txt',
+                       'test1/c/c.txt',
+                       'test2/a.txt',
+                       'test2/b.txt',
+                       'test2/c/c.txt',
+               }
+
+               single_files = set(filter(spec.match_file, files))
+               multi_files = set(spec.match_files(files))
+
+               self.assertEqual(single_files, multi_files)
+
+       def test_01_windows_current_dir_paths(self):
+               """
+               Tests that paths referencing the current directory will be properly
+               normalized and matched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/',
+               ])
+               files = {
+                       '.\\test1\\a.txt',
+                       '.\\test1\\b.txt',
+                       '.\\test1\\c\\c.txt',
+                       '.\\test2\\a.txt',
+                       '.\\test2\\b.txt',
+                       '.\\test2\\c\\c.txt',
+               }
+
+               results = list(spec.check_files(files, separators=['\\']))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       '.\\test2\\a.txt',
+                       '.\\test2\\b.txt',
+                       '.\\test2\\c\\c.txt',
+               }, debug)
+
+       def test_01_windows_paths(self):
+               """
+               Tests that Windows paths will be properly normalized and matched.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/',
+               ])
+               files = {
+                       'test1\\a.txt',
+                       'test1\\b.txt',
+                       'test1\\c\\c.txt',
+                       'test2\\a.txt',
+                       'test2\\b.txt',
+                       'test2\\c\\c.txt',
+               }
+
+               results = list(spec.check_files(files, separators=['\\']))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       'test2\\a.txt',
+                       'test2\\b.txt',
+                       'test2\\c\\c.txt',
+               }, debug)
+
+       def test_02_eq(self):
+               """
+               Tests equality.
+               """
+               first_spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/**',
+               ])
+               second_spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!test1/**',
+               ])
+               self.assertEqual(first_spec, second_spec)
+
+       def test_02_ne(self):
+               """
+               Tests inequality.
+               """
+               first_spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+               ])
+               second_spec = PathSpec.from_lines('gitwildmatch', [
+                       '!*.txt',
+               ])
+               self.assertNotEqual(first_spec, second_spec)
+
+       def test_03_add(self):
+               """
+               Test spec addition using :data:`+` operator.
+               """
+               first_spec = PathSpec.from_lines('gitwildmatch', [
+                       'test.png',
+                       'test.txt',
+               ])
+               second_spec = PathSpec.from_lines('gitwildmatch', [
+                       'test.html',
+                       'test.jpg',
+               ])
+               combined_spec = first_spec + second_spec
+               files = {
+                       'test.html',
+                       'test.jpg',
+                       'test.png',
+                       'test.txt',
+               }
+
+               results = list(combined_spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(combined_spec, results)
+
+               self.assertEqual(includes, {
+                       'test.html',
+                       'test.jpg',
+                       'test.png',
+                       'test.txt',
+               }, debug)
+
+       def test_03_iadd(self):
+               """
+               Test spec addition using :data:`+=` operator.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       'test.png',
+                       'test.txt',
+               ])
+               spec += PathSpec.from_lines('gitwildmatch', [
+                       'test.html',
+                       'test.jpg',
+               ])
+               files = {
+                       'test.html',
+                       'test.jpg',
+                       'test.png',
+                       'test.txt',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       'test.html',
+                       'test.jpg',
+                       'test.png',
+                       'test.txt',
+               }, debug)
+
+       def test_04_len(self):
+               """
+               Test spec length.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       'foo',
+                       'bar',
+               ])
+               self.assertEqual(len(spec), 2)
+
+       def test_05_match_entries(self):
+               """
+               Test matching files collectively.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!b.txt',
+               ])
+               self.make_dirs([
+                       'X',
+                       'X/Z',
+                       'Y',
+                       'Y/Z',
+               ])
+               self.make_files([
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               ])
+
+               entries = iter_tree_entries(self.temp_dir)
+               includes = {
+                       __entry.path for __entry in spec.match_entries(entries)
+               }
+
+               self.assertEqual(includes, set(map(ospath, [
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               ])))
+
+       def test_05_match_file(self):
+               """
+               Test matching files individually.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!b.txt',
+               ])
+               files = {
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               }
+
+               includes = set(filter(spec.match_file, files))
+
+               self.assertEqual(includes, {
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               })
+
+       def test_05_match_files(self):
+               """
+               Test matching files collectively.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!b.txt',
+               ])
+               files = {
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               }
+
+               includes = set(spec.match_files(files))
+
+               self.assertEqual(includes, {
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               })
+
+       def test_05_match_tree_entries(self):
+               """
+               Test matching a file tree.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!b.txt',
+               ])
+               self.make_dirs([
+                       'X',
+                       'X/Z',
+                       'Y',
+                       'Y/Z',
+               ])
+               self.make_files([
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               ])
+
+               includes = {
+                       __entry.path for __entry in spec.match_tree_entries(self.temp_dir)
+               }
+
+               self.assertEqual(includes, set(map(ospath, [
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               ])))
+
+       def test_05_match_tree_files(self):
+               """
+               Test matching a file tree.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.txt',
+                       '!b.txt',
+               ])
+               self.make_dirs([
+                       'X',
+                       'X/Z',
+                       'Y',
+                       'Y/Z',
+               ])
+               self.make_files([
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               ])
+
+               includes = set(spec.match_tree_files(self.temp_dir))
+
+               self.assertEqual(includes, set(map(ospath, [
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               ])))
+
+       def test_06_issue_41_a(self):
+               """
+               Test including a file and excluding a directory with the same name pattern,
+               scenario A.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.yaml',
+                       '!*.yaml/',
+               ])
+               files = {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/file.yaml',
+                       'dir/index.txt',
+                       'file.yaml',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       #'dir.yaml/file.yaml',  # Discrepancy with Git.
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',  # Discrepancy with Git.
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_06_issue_41_b(self):
+               """
+               Test including a file and excluding a directory with the same name
+               pattern, scenario B.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '!*.yaml/',
+                       '*.yaml',
+               ])
+               files = {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/file.yaml',
+                       'dir/index.txt',
+                       'file.yaml',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',
+                       'dir.yaml/index.txt',
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_06_issue_41_c(self):
+               """
+               Test including a file and excluding a directory with the same name
+               pattern, scenario C.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.yaml',
+                       '!dir.yaml',
+               ])
+               files = {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/file.yaml',
+                       'dir/index.txt',
+                       'file.yaml',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       #'dir.yaml/file.yaml',  # Discrepancy with Git.
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',  # Discrepancy with Git.
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_07_issue_62(self):
+               """
+               Test including all files and excluding a directory.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*',
+                       '!product_dir/',
+               ])
+               files = {
+                       'anydir/file.txt',
+                       'product_dir/file.txt',
+               }
+
+               results = list(spec.check_files(files))
+               includes = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(includes, {
+                       'anydir/file.txt',
+               }, debug)
+
+       def test_08_issue_39(self):
+               """
+               Test excluding files in a directory.
+               """
+               spec = PathSpec.from_lines('gitwildmatch', [
+                       '*.log',
+                       '!important/*.log',
+                       'trace.*',
+               ])
+               files = {
+                       'a.log',
+                       'b.txt',
+                       'important/d.log',
+                       'important/e.txt',
+                       'trace.c',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'a.log',
+                       'trace.c',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'b.txt',
+                       'important/d.log',
+                       'important/e.txt',
+               }, debug)
+
+       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/tests/test_04_gitignore.py b/tests/test_04_gitignore.py
new file mode 100644 (file)
index 0000000..479bd75
--- /dev/null
@@ -0,0 +1,526 @@
+"""
+This script tests :class:`.GitIgnoreSpec`.
+"""
+
+import unittest
+
+from pathspec.gitignore import (
+       GitIgnoreSpec)
+
+from .util import (
+       debug_results,
+       get_includes)
+
+
+class GitIgnoreSpecTest(unittest.TestCase):
+       """
+       The :class:`GitIgnoreSpecTest` class tests the :class:`.GitIgnoreSpec` class.
+       """
+
+       def test_01_reversed_args(self):
+               """
+               Test reversed args for `.from_lines()`.
+               """
+               spec = GitIgnoreSpec.from_lines('gitwildmatch', ['*.txt'])
+               files = {
+                       'a.txt',
+                       'b.bin',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'a.txt',
+               }, debug)
+
+       def test_02_dir_exclusions(self):
+               """
+               Test directory exclusions.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       '*.txt',
+                       '!test1/',
+               ])
+               files = {
+                       'test1/a.txt',
+                       'test1/b.bin',
+                       'test1/c/c.txt',
+                       'test2/a.txt',
+                       'test2/b.bin',
+                       'test2/c/c.txt',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'test1/a.txt',
+                       'test1/c/c.txt',
+                       'test2/a.txt',
+                       'test2/c/c.txt',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'test1/b.bin',
+                       'test2/b.bin',
+               }, debug)
+
+       def test_02_file_exclusions(self):
+               """
+               Test file exclusions.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       '*.txt',
+                       '!b.txt',
+               ])
+               files = {
+                       'X/a.txt',
+                       'X/b.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/b.txt',
+                       'Y/Z/c.txt',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'X/a.txt',
+                       'X/Z/c.txt',
+                       'Y/a.txt',
+                       'Y/Z/c.txt',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'X/b.txt',
+                       'Y/b.txt',
+               }, debug)
+
+       def test_02_issue_41_a(self):
+               """
+               Test including a file and excluding a directory with the same name pattern,
+               scenario A.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       '*.yaml',
+                       '!*.yaml/',
+               ])
+               files = {
+                       'dir.yaml/file.sql',   # -
+                       'dir.yaml/file.yaml',  # 1:*.yaml
+                       'dir.yaml/index.txt',  # -
+                       'dir/file.sql',        # -
+                       'dir/file.yaml',       # 1:*.yaml
+                       'dir/index.txt',       # -
+                       'file.yaml',           # 1:*.yaml
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dir.yaml/file.yaml',
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_02_issue_41_b(self):
+               """
+               Test including a file and excluding a directory with the same name pattern,
+               scenario B.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       '!*.yaml/',
+                       '*.yaml',
+               ])
+               files = {
+                       'dir.yaml/file.sql',   # 2:*.yaml
+                       'dir.yaml/file.yaml',  # 2:*.yaml
+                       'dir.yaml/index.txt',  # 2:*.yaml
+                       'dir/file.sql',        # -
+                       'dir/file.yaml',       # 2:*.yaml
+                       'dir/index.txt',       # -
+                       'file.yaml',           # 2:*.yaml
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/file.yaml',
+                       'dir.yaml/index.txt',
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_02_issue_41_c(self):
+               """
+               Test including a file and excluding a directory with the same name pattern,
+               scenario C.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       '*.yaml',
+                       '!dir.yaml',
+               ])
+               files = {
+                       'dir.yaml/file.sql',   # -
+                       'dir.yaml/file.yaml',  # 1:*.yaml
+                       'dir.yaml/index.txt',  # -
+                       'dir/file.sql',        # -
+                       'dir/file.yaml',       # 1:*.yaml
+                       'dir/index.txt',       # -
+                       'file.yaml',           # 1:*.yaml
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dir.yaml/file.yaml',
+                       'dir/file.yaml',
+                       'file.yaml',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'dir.yaml/file.sql',
+                       'dir.yaml/index.txt',
+                       'dir/file.sql',
+                       'dir/index.txt',
+               }, debug)
+
+       def test_03_subdir(self):
+               """
+               Test matching files in a subdirectory of an included directory.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       "dirG/",
+               ])
+               files = {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+               }, debug)
+
+       def test_03_issue_19_a(self):
+               """
+               Test matching files in a subdirectory of an included directory, scenario A.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       "dirG/",
+               ])
+               files = {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+               }, debug)
+
+       def test_03_issue_19_b(self):
+               """
+               Test matching files in a subdirectory of an included directory, scenario B.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       "dirG/*",
+               ])
+               files = {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+               }, debug)
+
+       def test_03_issue_19_c(self):
+               """
+               Test matching files in a subdirectory of an included directory, scenario C.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       "dirG/**",
+               ])
+               files = {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'dirG/dirH/fileI',
+                       'dirG/dirH/fileJ',
+                       'dirG/fileO',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'fileA',
+                       'fileB',
+                       'dirD/fileE',
+                       'dirD/fileF',
+               }, debug)
+
+       def test_04_issue_62(self):
+               """
+               Test including all files and excluding a directory.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       '*',
+                       '!product_dir/',
+               ])
+               files = {
+                       'anydir/file.txt',
+                       'product_dir/file.txt',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'anydir/file.txt',
+                       'product_dir/file.txt',
+               }, debug)
+
+       def test_05_issue_39(self):
+               """
+               Test excluding files in a directory.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       '*.log',
+                       '!important/*.log',
+                       'trace.*',
+               ])
+               files = {
+                       'a.log',
+                       'b.txt',
+                       'important/d.log',
+                       'important/e.txt',
+                       'trace.c',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'a.log',
+                       'trace.c',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'b.txt',
+                       'important/d.log',
+                       'important/e.txt',
+               }, debug)
+
+       def test_06_issue_64(self):
+               """
+               Test using a double asterisk pattern.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       "**",
+               ])
+               files = {
+                       'x',
+                       'y.py',
+                       'A/x',
+                       'A/y.py',
+                       'A/B/x',
+                       'A/B/y.py',
+                       'A/B/C/x',
+                       'A/B/C/y.py',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, files, debug)
+
+       def test_07_issue_74(self):
+               """
+               Test include directory should override exclude file.
+               """
+               spec = GitIgnoreSpec.from_lines([
+                       '*',  # Ignore all files by default
+                       '!*/',  # but scan all directories
+                       '!*.txt',  # Text files
+                       '/test1/**',  # ignore all in the directory
+               ])
+               files = {
+                       'test1/b.bin',
+                       'test1/a.txt',
+                       'test1/c/c.txt',
+                       'test2/a.txt',
+                       'test2/b.bin',
+                       'test2/c/c.txt',
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       'test1/b.bin',
+                       'test1/a.txt',
+                       'test1/c/c.txt',
+                       'test2/b.bin',
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       'test2/a.txt',
+                       'test2/c/c.txt',
+               }, debug)
+
+       def test_08_issue_81_a(self):
+               """
+               Test issue 81 whitelist, scenario A.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       "*",
+                       "!libfoo",
+                       "!libfoo/**",
+               ])
+               files = {
+                       "ignore.txt",          # 1:*
+                       "libfoo/__init__.py",  # 3:!libfoo/**
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       "ignore.txt",
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       "libfoo/__init__.py",
+               }, debug)
+
+       def test_08_issue_81_b(self):
+               """
+               Test issue 81 whitelist, scenario B.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       "*",
+                       "!libfoo",
+                       "!libfoo/*",
+               ])
+               files = {
+                       "ignore.txt",          # 1:*
+                       "libfoo/__init__.py",  # 3:!libfoo/*
+               }
+
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+
+               self.assertEqual(ignores, {
+                       "ignore.txt",
+               }, debug)
+               self.assertEqual(files - ignores, {
+                       "libfoo/__init__.py",
+               }, debug)
+
+       def test_08_issue_81_c(self):
+               """
+               Test issue 81 whitelist, scenario C.
+               """
+               # Confirmed results with git (v2.42.0).
+               spec = GitIgnoreSpec.from_lines([
+                       "*",
+                       "!libfoo",
+                       "!libfoo/",
+               ])
+               files = {
+                       "ignore.txt",          # 1:*
+                       "libfoo/__init__.py",  # 1:*
+               }
+               results = list(spec.check_files(files))
+               ignores = get_includes(results)
+               debug = debug_results(spec, results)
+               self.assertEqual(ignores, {
+                       "ignore.txt",
+                       "libfoo/__init__.py",
+               }, debug)
+               self.assertEqual(files - ignores, set())
diff --git a/tests/util.py b/tests/util.py
new file mode 100644 (file)
index 0000000..e16bfd3
--- /dev/null
@@ -0,0 +1,148 @@
+"""
+This module provides utility functions shared by tests.
+"""
+
+import itertools
+import os
+import os.path
+import pathlib
+
+from typing import (
+       Iterable,  # Replaced by `collections.abc.Iterable` in 3.9.
+       List,  # Replaced by `set` in 3.9.
+       Set,  # Replaced by `set` in 3.9.
+       Tuple,  # Replaced by `tuple` in 3.9.
+       cast)
+
+from pathspec import (
+       PathSpec,
+       RegexPattern)
+from pathspec.util import (
+       CheckResult,
+       TStrPath)
+
+
+def debug_results(spec: PathSpec, results: Iterable[CheckResult[str]]) -> str:
+       """
+       Format the check results message.
+
+       *spec* (:class:`~pathspec.PathSpec`) is the path-spec.
+
+       *results* (:class:`~collections.abc.Iterable` or :class:`~pathspec.util.CheckResult`)
+       yields each file check result.
+
+       Returns the message (:class:`str`).
+       """
+       patterns = cast(List[RegexPattern], spec.patterns)
+
+       pattern_table = []
+       for index, pattern in enumerate(patterns, 1):
+               pattern_table.append((f"{index}:{pattern.pattern}", repr(pattern.regex.pattern)))
+
+       result_table = []
+       for result in results:
+               if result.index is not None:
+                       pattern = patterns[result.index]
+                       result_table.append((f"{result.index + 1}:{pattern.pattern}", result.file))
+               else:
+                       result_table.append(("-", result.file))
+
+       result_table.sort(key=lambda r: r[1])
+
+       first_max_len = max((
+               len(__row[0]) for __row in itertools.chain(pattern_table, result_table)
+       ), default=0)
+       first_width = min(first_max_len, 20)
+
+       pattern_lines = []
+       for row in pattern_table:
+               pattern_lines.append(f" {row[0]:<{first_width}}  {row[1]}")
+
+       result_lines = []
+       for row in result_table:
+               result_lines.append(f" {row[0]:<{first_width}}  {row[1]}")
+
+       return "\n".join([
+               "\n",
+               " DEBUG ".center(32, "-"),
+               *pattern_lines,
+               "-"*32,
+               *result_lines,
+               "-"*32,
+       ])
+
+
+def get_includes(results: Iterable[CheckResult[TStrPath]]) -> Set[TStrPath]:
+       """
+       Get the included files from the check results.
+
+       *results* (:class:`~collections.abc.Iterable` or :class:`~pathspec.util.CheckResult`)
+       yields each file check result.
+
+       Returns the included files (:class:`set` of :class:`str`).
+       """
+       return {__res.file for __res in results if __res.include}
+
+
+def make_dirs(temp_dir: pathlib.Path, dirs: Iterable[str]) -> None:
+       """
+       Create the specified directories.
+
+       *temp_dir* (:class:`pathlib.Path`) is the temporary directory to use.
+
+       *dirs* (:class:`Iterable` of :class:`str`) is the POSIX directory
+       paths (relative to *temp_dir*) to create.
+       """
+       for dir in dirs:
+               os.mkdir(temp_dir / ospath(dir))
+
+
+def make_files(temp_dir: pathlib.Path, files: Iterable[str]) -> None:
+       """
+       Create the specified files.
+
+       *temp_dir* (:class:`pathlib.Path`) is the temporary directory to use.
+
+       *files* (:class:`Iterable` of :class:`str`) is the POSIX file paths
+       (relative to *temp_dir*) to create.
+       """
+       for file in files:
+               mkfile(temp_dir / ospath(file))
+
+
+def make_links(temp_dir: pathlib.Path, links: Iterable[Tuple[str, str]]) -> None:
+       """
+       Create the specified links.
+
+       *temp_dir* (:class:`pathlib.Path`) is the temporary directory to use.
+
+       *links* (:class:`Iterable` of :class:`tuple`) contains the POSIX links
+       to create relative to *temp_dir*. Each link (:class:`tuple`) contains
+       the destination link path (:class:`str`) and source node path
+       (:class:`str`).
+       """
+       for link, node in links:
+               src = temp_dir / ospath(node)
+               dest = temp_dir / ospath(link)
+               os.symlink(src, dest)
+
+
+def mkfile(file: pathlib.Path) -> None:
+       """
+       Creates an empty file.
+
+       *file* (:class:`pathlib.Path`) is the native file path to create.
+       """
+       with open(file, 'wb'):
+               pass
+
+
+def ospath(path: str) -> str:
+       """
+       Convert the POSIX path to a native OS path.
+
+       *path* (:class:`str`) is the POSIX path.
+
+       Returns the native path (:class:`str`).
+       """
+       return os.path.join(*path.split('/'))
diff --git a/tox.ini b/tox.ini
new file mode 100644 (file)
index 0000000..7d7a17c
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,14 @@
+[tox]
+envlist =
+    py{38, 39, 310, 311, 312}
+    pypy3
+    docs
+isolated_build = True
+
+[testenv]
+commands = python -m unittest {posargs}
+
+[testenv:docs]
+base_path = py312
+deps = -rdoc/requirements.txt
+commands = sphinx-build -aWEnqb html doc/source doc/build