From bf8fc6b04aa4f784e88b9555bf35ade99d66f221 Mon Sep 17 00:00:00 2001 From: dngr2 Date: Mon, 17 Aug 2026 02:16:40 +0300 Subject: [PATCH 1/2] Support POSIX character classes in bracket expressions Git's wildmatch supports POSIX character classes inside bracket expressions -- '[[:digit:]]', '[[:alpha:]]', '[[:space:]]', etc. pathspec did not: the inner '[:...:]' confused the closing-bracket scan (the ']' that ends the class was taken as the end of the whole expression), and even when delimited, Python's 're' has no POSIX class syntax, so '[[:digit:]]' compiled to a set of the literal characters '[', ':', 'd', 'i', 'g', 't' (with a 'Possible nested set' warning) and matched nothing sensible. The result: a .gitignore rule like '[[:upper:]]*.tmp' matched files in Git but not in pathspec, so tools built on pathspec (e.g. via GitIgnoreSpec) silently disagreed with Git about which files are ignored. Skip '[:...:]' as a unit when finding the closing bracket, and expand each POSIX class to the equivalent ASCII range (matching Git's wildmatch in the C locale). Negated ('[![:digit:]]') and mixed ('[[:alpha:]0-9]') forms work too. Adds tests covering the translation and matching. --- pathspec/patterns/gitignore/base.py | 69 +++++++++++++++++++++++++++-- tests/test_03_gitignore_basic.py | 27 +++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) 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. From 2dd8fd8aac977b464f4b151a70d630feaf3b4806 Mon Sep 17 00:00:00 2001 From: dngr2 Date: Mon, 17 Aug 2026 02:17:29 +0300 Subject: [PATCH 2/2] Add changelog entry for POSIX character class support --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) 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)