Skip to content

Preserve externally compiled regex flags during backend selection - #151

Merged
cpburnz merged 1 commit into
cpburnz:masterfrom
Master-Norna:fix/preserve-compiled-regex-flags
Oct 11, 2026
Merged

cpburnz merged 1 commit into
cpburnz:masterfrom
Master-Norna:fix/preserve-compiled-regex-flags

Conversation

@Master-Norna

@Master-Norna Master-Norna commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Both native backends rebuild RegexPattern.regex.pattern without its compile-time flags, so installing an optional backend silently changes the results of a precompiled re.Pattern:

>>> from pathspec import PathSpec, RegexPattern
>>> pattern = RegexPattern(re.compile('^Secret$', re.I | re.M | re.S), include=True)
>>> PathSpec([pattern], backend='simple').match_file('x\nSECRET\ny')
True
>>> PathSpec([pattern], backend='re2').match_file('x\nSECRET\ny')      # before
False

The flags are translated into global inline flags where translate_end_anchor() already rewrites the expression — including both variants produced when GitIgnoreSpecPattern splits its directory marker, and as bytes for a bytes expression. No pattern is recompiled to probe for flags.

Measured with google-re2 1.1.20251105 and python-hyperscan 0.9.1 (Hyperscan 5.4) through the APIs pathspec uses:

flag translation
re.IGNORECASE, re.MULTILINE, re.DOTALL prepended as (?ims); both engines accept them, and (?i) is indistinguishable from re2.Options(case_sensitive=False) / HS_FLAG_CASELESS
re.UNICODE dropped. Neither engine accepts (?u), both match \w \d \s \b as ASCII regardless, and (?U) would be a trap: RE2 reads that letter as non-greedy while every str pattern carries re.UNICODE implicitly
re.ASCII no prefix needed, for the same reason — except with re.IGNORECASE, where the engines fold Unicode that Python under re.A does not (^[a-z]+$ matches ſ)
re.VERBOSE, re.LOCALE, other bits can't be expressed: RE2 rejects (?x) (invalid perl operator: (?x) and has no verbose option; Hyperscan's (?x) rejects a trailing # comment Python compiles (Unterminated comment.)

For flags that can't be expressed, None/best selects simple and an explicit native backend raises ValueError naming backend='simple', as before. Ordinary gitignore patterns keep their native backend.

This restores the flag, not Python's case-folding: İ/ı and Unicode \w still differ, as they already do today.

Tests cover the three flags and their combinations on all backends, the default backend, bytes expressions, the directory-marker split, the fallback and the explicit-backend error, and the helpers. The in-place-addition hardening from the first revision is dropped — Pull #159 landed it upstream.

Validation (both native libraries installed): python -m unittest discover -t . -s tests/ → 266 OK on Linux (py3.10) and Windows (py3.12), base 254; 8 of 8 sample patterns agree across the three backends (was 3 of 8); mypy and pyright report the same 11 and 8 errors as the unmodified base. README.rst documents the mapping and README-dist.rst is synced.

@cpburnz

cpburnz commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Thanks for finding this bug! I definitely overlooked the flags detail.

I don't think has_regex_flags() is the right approach. Instead of recompiling the pattern to find in-string global flags, and bailing out in re2/hyperscan when they're detected, the .flags should be prepended to the regex string as global flags in re2/hyperscan.

See the docs on (?aiLmsux):

(?aiLmsux)
(One or more letters from the set 'a', 'i', 'L', 'm', 's', 'u', 'x'.) The group matches the empty string; the letters set the corresponding flags for the entire regular expression:

re.A (ASCII-only matching)
re.I (ignore case)
re.L (locale dependent)
re.M (multi-line)
re.S (dot matches all)
re.U (Unicode matching)
re.X (verbose)

So a regex.flags == re.I | re.S would result in a "(?is)" prefix. the re.U flag can be ignored entirely.

@cpburnz cpburnz added the Bug label Oct 10, 2026
@Master-Norna
Master-Norna force-pushed the fix/preserve-compiled-regex-flags branch from 21e4342 to c37fbd0 Compare October 10, 2026 23:58
@Master-Norna

Copy link
Copy Markdown
Contributor Author

Agreed — dropped has_regex_flags() and the recompile probe. The native backends now prepend the compile flags as inline global flags at the same points where translate_end_anchor() rewrites the expression, including both variants from the GitIgnoreSpecPattern directory-marker split, and as bytes for a bytes expression.

Support, measured with what CI uses (google-re2 1.1.20251105, python-hyperscan 0.9.1 over Hyperscan 5.4) through the APIs pathspec calls:

  • (?i) (?m) (?s) — accepted by both; (?i) is indistinguishable from re2.Options(case_sensitive=False) and from HS_FLAG_CASELESS.
  • (?x) — Hyperscan only. RE2 rejects it (invalid perl operator: (?x) and has no verbose option; Hyperscan additionally rejects (?x)abc # comment (Unterminated comment.), which Python compiles.
  • (?a) (?u) (?L) — rejected by both. (?U) is accepted by RE2 but means non-greedy there, and every str pattern carries re.UNICODE implicitly — so it's dropped, as you said.

Mapping used: (?ims) prefix; re.UNICODE dropped; re.ASCII taken as already satisfied (both engines match \w \d \s \b as ASCII regardless) except combined with re.IGNORECASE, where the engines fold Unicode Python forbids under re.A — ^[a-z]+$ matches ſ; re.VERBOSE, re.LOCALE and unknown bits can't be expressed, so best uses simple and an explicit native backend raises, as before. VERBOSE falls back on both backends rather than Hyperscan-only — say the word if you'd rather split it.

Dropped the += hunk since Pull #159 landed it upstream, and renamed the test module to tests/test_08_regex_flags.py.

PathSpec([RegexPattern(re.compile('^Secret$', re.I | re.M | re.S), include=True)]) now matches 'x\nSECRET\ny' on all three backends (previously only "simple"), likewise for each flag alone, for bytes, and through the directory-marker split — 8 of 8 sample cases agree across backends, was 3 of 8. unittest discover: 266 OK on Linux (py3.10) and Windows (py3.12), base 254; mypy (11) and pyright (8) unchanged from base. This restores the flag, not Python's case-folding (İ/ı) or Unicode \w — both already differ today.

@cpburnz

cpburnz commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Looks good, thanks!

@cpburnz
cpburnz merged commit 157bccf into cpburnz:master Oct 11, 2026
7 checks passed
cpburnz added a commit that referenced this pull request Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants