Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ Major changes:
Bug fixes:

- `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`.
- `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions.


.. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116
.. _`Pull #123`: https://github.com/cpburnz/python-pathspec/pull/123
.. _`Pull #128`: https://github.com/cpburnz/python-pathspec/pull/128


1.1.1 (2026-04-26)
Expand Down
89 changes: 87 additions & 2 deletions pathspec/patterns/gitignore/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,58 @@
The encoding to use when parsing a byte string pattern.
"""

_POSIX_CLASS_TO_REGEX = {
# Git's wildmatch implements POSIX bracket character classes using its own
# ASCII (locale-independent) ``is*`` functions, so each class maps to an
# explicit ASCII set. These are NOT the Unicode-aware equivalents (``\w``,
# ``\d``, ``\s``); using those would over-match non-ASCII characters that
# git never matches.
'alnum': '0-9A-Za-z',
'alpha': 'A-Za-z',
'blank': '\\t ',
'cntrl': '\\x00-\\x1f\\x7f',
'digit': '0-9',
'graph': '\\x21-\\x7e',
'lower': 'a-z',
'print': '\\x20-\\x7e',
'punct': '!-/:-@\\[-`{-~',
'space': '\\t\\n\\r ',
'upper': 'A-Z',
'xdigit': '0-9A-Fa-f',
}
"""
Maps each POSIX bracket character class name to the ASCII regex range that
reproduces git's wildmatch behavior.
"""

_POSIX_CLASS_REGEX = re.compile(r'\[:(\^?)([^:\]]*):\]')
"""
Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a
bracket expression. Group 1 captures a leading caret (unsupported by git);
group 2 captures the class name.
"""


class _InvalidPosixClass(Exception):
"""
Raised internally when a bracket expression contains an unknown or negated
POSIX character class name. Git treats such a pattern as malformed.
"""
pass


def _translate_posix_class(match: 're.Match') -> str:
"""
Translate a single POSIX character class token to its ASCII regex range.
Raises :class:`_InvalidPosixClass` for a negated (``[:^name:]``) or unknown
class name, matching git's treatment of it as a malformed pattern.
"""
negated, name = match.group(1), match.group(2)
class_regex = _POSIX_CLASS_TO_REGEX.get(name)
if negated or class_regex is None:
raise _InvalidPosixClass()
return class_regex


class _GitIgnoreBasePattern(RegexPattern):
"""
Expand Down Expand Up @@ -118,6 +170,7 @@ def _translate_segment_glob(
# - "[][!]" matches ']', '[' and '!'.
# - "[]-]" matches ']' and '-'.
# - "[!]a-]" matches any character except ']', 'a' and '-'.
bracket_start = i - 1
j = i

# Pass bracket expression negation.
Expand All @@ -131,7 +184,17 @@ def _translate_segment_glob(

# Find closing bracket. Stop once we reach the end or find it.
while j < end and pattern[j] != ']':
j += 1
if pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':':
# Skip over a POSIX character class token ("[:name:]") so its
# internal closing bracket is not mistaken for the end of the
# whole bracket expression.
close = pattern.find(':]', j + 2)
if close == -1:
j = end
break
j = close + 2
else:
j += 1

if j < end:
# Found end of bracket expression. Increment j to be one past the
Expand Down Expand Up @@ -159,7 +222,29 @@ def _translate_segment_glob(

# Build regex bracket expression. Escape slashes so they are treated
# as literal slashes by regex as defined by POSIX.
expr += pattern[i:j].replace('\\', '\\\\')
body = pattern[i:j].replace('\\', '\\\\')

# Translate POSIX character classes (e.g. "[:alpha:]") into their
# ASCII regex equivalents. Git's wildmatch supports these but
# Python's `re` does not, so passing them through verbatim builds a
# broken regex that silently mismatches (and warns about a nested
# set).
try:
body = _POSIX_CLASS_REGEX.sub(_translate_posix_class, body)
except _InvalidPosixClass:
# Git treats an unknown or negated class name as a malformed
# pattern that matches nothing.
if range_error == 'raise':
raise _RangeError((
f"Invalid character class found in pattern={pattern!r}."
))
else:
# Treat the whole bracket expression as a literal.
regex += re.escape(pattern[bracket_start:j])
i = j
continue

expr += body

if range_error == 'raise':
try:
Expand Down
111 changes: 111 additions & 0 deletions tests/test_04_gitignore_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,3 +969,114 @@ def test_15_issue_93_c_3_unclosed(self):
pattern = GitIgnoreSpecPattern(raw_pattern)
self.assertIs(pattern.include, None)
self.assertIs(pattern.regex, None)

def test_16_posix_class_a_regex(self):
"""
Test that each POSIX bracket character class is translated to the ASCII
range that git's wildmatch uses. Git implements these classes with its
own locale-independent ASCII functions, so they are NOT the Unicode-aware
equivalents (``\\w``, ``\\d``, ``\\s``).
"""
for raw_pattern, expr in [
('[[:alnum:]]', '[0-9A-Za-z]'),
('[[:alpha:]]', '[A-Za-z]'),
('[[:blank:]]', '[\\t ]'),
('[[:cntrl:]]', '[\\x00-\\x1f\\x7f]'),
('[[:digit:]]', '[0-9]'),
('[[:graph:]]', '[\\x21-\\x7e]'),
('[[:lower:]]', '[a-z]'),
('[[:print:]]', '[\\x20-\\x7e]'),
('[[:punct:]]', '[!-/:-@\\[-`{-~]'),
('[[:space:]]', '[\\t\\n\\r ]'),
('[[:upper:]]', '[A-Z]'),
('[[:xdigit:]]', '[0-9A-Fa-f]'),
]:
with self.subTest(f"p={raw_pattern!r}"):
pattern = GitIgnoreSpecPattern(raw_pattern)
self.assertIs(pattern.include, True)
self.assertEqual(
pattern.regex.pattern, f'^(?:.+/)?{expr}{_DIR_MARK_OPT}',
)

def test_16_posix_class_b_match(self):
"""
Test that each POSIX bracket character class matches the same characters
as git (spot checks matching git's ``check-ignore``).
"""
matches = {
'[[:alnum:]]': (['a', 'Z', '5'], ['_', '-', '.']),
'[[:alpha:]]': (['a', 'Z'], ['5', '_']),
'[[:blank:]]': ([' ', '\t'], ['\n', 'a']),
'[[:digit:]]': (['0', '9'], ['a', '/']),
'[[:graph:]]': (['a', '!', '~'], [' ']),
'[[:lower:]]': (['a', 'z'], ['A', '5']),
'[[:print:]]': (['a', ' ', '~'], ['\t']),
'[[:punct:]]': (['!', '.', '~', '@'], ['a', '5', ' ']),
'[[:space:]]': ([' ', '\t', '\n', '\r'], ['a', '\x0b', '\x0c']),
'[[:upper:]]': (['A', 'Z'], ['a', '5']),
'[[:xdigit:]]': (['0', '9', 'a', 'F'], ['g', 'G', '_']),
}
for raw_pattern, (positives, negatives) in matches.items():
pattern = GitIgnoreSpecPattern(raw_pattern)
for path in positives:
with self.subTest(f"p={raw_pattern!r} match {path!r}"):
self.assertTrue(pattern.match_file(path))
for path in negatives:
with self.subTest(f"p={raw_pattern!r} no-match {path!r}"):
self.assertFalse(pattern.match_file(path))

def test_16_posix_class_c_composed(self):
"""
Test that POSIX classes compose with other bracket members, ranges, and
bracket negation, as git allows.
"""
for raw_pattern, positives, negatives in [
('[[:digit:]a-f_]', ['5', 'c', '_'], ['g', 'z']),
('[[:alpha:]0-9]', ['a', 'Z', '5'], ['_', '.']),
('[[:upper:][:digit:]]', ['A', '5'], ['a', '_']),
('[![:digit:]]', ['a', '_'], ['5']),
('*.[[:alpha:]]', ['f.c'], ['f.5']),
('[[:alnum:]_].py', ['_.py', 'a.py'], ['-.py']),
]:
pattern = GitIgnoreSpecPattern(raw_pattern)
for path in positives:
with self.subTest(f"p={raw_pattern!r} match {path!r}"):
self.assertTrue(pattern.match_file(path))
for path in negatives:
with self.subTest(f"p={raw_pattern!r} no-match {path!r}"):
self.assertFalse(pattern.match_file(path))

def test_16_posix_class_d_ascii_only(self):
"""
Test that the classes stay ASCII-only, matching git rather than Python's
Unicode-aware regex. Non-ASCII digits/letters/spaces must NOT match.
"""
for raw_pattern, path in [
('[[:digit:]]', '٠'), # Arabic-Indic digit zero.
('[[:digit:]]', '²'), # Superscript two.
('[[:alpha:]]', 'é'), # Latin small e with acute.
('[[:alpha:]]', 'Ω'), # Greek capital omega.
('[[:upper:]]', 'À'), # Latin capital A with grave.
('[[:alnum:]]', 'é'), # Latin small e with acute.
('[[:space:]]', '\xa0'), # No-break space.
]:
with self.subTest(f"p={raw_pattern!r} no-match U+{ord(path):04X}"):
pattern = GitIgnoreSpecPattern(raw_pattern)
self.assertFalse(pattern.match_file(path))

def test_16_posix_class_e_invalid(self):
"""
Test that an unknown or negated POSIX class name is discarded, matching
git's treatment of it as a malformed pattern.
"""
for raw_pattern in [
'[[:bogus:]]',
'[[:Alpha:]]',
'[[:^alpha:]]',
'[[::]]',
'a[[:nope:]]',
]:
with self.subTest(f"p={raw_pattern!r}"):
pattern = GitIgnoreSpecPattern(raw_pattern)
self.assertIs(pattern.include, None)
self.assertIs(pattern.regex, None)