diff --git a/CHANGES.rst b/CHANGES.rst index f47bc6e..08d25da 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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) diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 5c0a4d9..95ab485 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -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): """ @@ -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 @@ -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: diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index 6346d9f..4fc347d 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -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.