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 #130`_: Support POSIX character classes (``[[:alpha:]]``, etc.) in bracket expressions.


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


1.1.1 (2026-04-26)
Expand Down
69 changes: 66 additions & 3 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_CHAR_CLASSES = {
'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': '\\x21-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7e',
'space': ' \\t\\n\\x0b\\f\\r',
'upper': 'A-Z',
'xdigit': '0-9A-Fa-f',
}
"""
The ASCII ranges equivalent to each POSIX character class, matching Git's
``wildmatch`` (which evaluates them in the C locale). Python's ``re`` has no
POSIX class syntax, so ``[:alpha:]`` etc. must be expanded to these ranges.
"""


def _translate_bracket_body(body: str) -> str:
"""
Translate the interior of a glob bracket expression (the characters between
``[`` and its closing ``]``, with the ``]`` included) to the body of a
regular-expression bracket expression.

Backslashes are escaped so they are treated as literal slashes by regex (as
POSIX defines), and any POSIX character classes (``[:alpha:]`` etc.) are
expanded to their equivalent ranges, because Python's ``re`` does not
understand POSIX class syntax and would otherwise mis-parse them.

*body* (:class:`str`) is the bracket interior including the trailing ``]``.

Returns the regex bracket body (:class:`str`).
"""
out = []
i, end = 0, len(body)
while i < end:
if body[i] == '[' and body[i+1:i+2] == ':':
class_end = body.find(':]', i + 2)
if class_end != -1:
name = body[i+2:class_end]
if name in _POSIX_CHAR_CLASSES:
out.append(_POSIX_CHAR_CLASSES[name])
i = class_end + 2
continue
char = body[i]
out.append('\\\\' if char == '\\' else char)
i += 1
return ''.join(out)


class _GitIgnoreBasePattern(RegexPattern):
"""
Expand Down Expand Up @@ -130,8 +182,17 @@ def _translate_segment_glob(
j += 1

# Find closing bracket. Stop once we reach the end or find it.
# A POSIX character class ("[:alpha:]" etc.) is skipped as a unit
# so the ']' that closes the class is not mistaken for the ']'
# that closes the whole bracket expression.
while j < end and pattern[j] != ']':
j += 1
if pattern[j] == '[' and pattern[j+1:j+2] == ':':
class_end = pattern.find(':]', j + 2)
if class_end == -1:
break
j = class_end + 2
else:
j += 1

if j < end:
# Found end of bracket expression. Increment j to be one past the
Expand All @@ -158,8 +219,10 @@ def _translate_segment_glob(
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('\\', '\\\\')
# as literal slashes by regex as defined by POSIX, and expand any
# POSIX character classes ("[:alpha:]" etc.), which Python's `re`
# does not understand, to equivalent ranges (matching Git).
expr += _translate_bracket_body(pattern[i:j])

if range_error == 'raise':
try:
Expand Down
27 changes: 27 additions & 0 deletions tests/test_03_gitignore_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,33 @@ def test_15_issue_93_c_1_valid(self):
self.assertIs(pattern.include, True)
self.assertEqual(pattern.regex.pattern, regex)

def test_15_posix_character_class(self):
"""
Test POSIX character classes ("[:alpha:]" etc.) inside bracket
expressions, which Git's *wildmatch* supports.
"""
for raw_pattern, regex in [
('[[:digit:]]', f'^(?:.+/)?[0-9]{_DIR_OPT}'),
('[[:alpha:]]', f'^(?:.+/)?[A-Za-z]{_DIR_OPT}'),
('[![:digit:]]', f'^(?:.+/)?[^0-9]{_DIR_OPT}'),
('[^[:digit:]]', f'^(?:.+/)?[^0-9]{_DIR_OPT}'),
('[[:alnum:]_]', f'^(?:.+/)?[0-9A-Za-z_]{_DIR_OPT}'),
('a[[:digit:]]', f'^(?:.+/)?a[0-9]{_DIR_OPT}'),
('[[:alpha:][:digit:]]', f'^(?:.+/)?[A-Za-z0-9]{_DIR_OPT}'),
]:
with self.subTest(f"p={raw_pattern!r}"):
pattern = GitIgnoreBasicPattern(raw_pattern)
self.assertIs(pattern.include, True)
self.assertEqual(pattern.regex.pattern, regex)

# The class must actually match like Git.
digit = GitIgnoreBasicPattern('[[:digit:]].txt')
self.assertTrue(digit.match_file('1.txt'))
self.assertFalse(digit.match_file('a.txt'))
not_digit = GitIgnoreBasicPattern('[![:digit:]].txt')
self.assertFalse(not_digit.match_file('1.txt'))
self.assertTrue(not_digit.match_file('a.txt'))

def test_15_issue_93_c_2_invalid(self):
"""
Test patterns with invalid range notation.
Expand Down