gh-156444: Fix a negated character set with IGNORECASE and LOCALE (GH-156445)
charset_loc_ignore() tested the whole set once per locale case and took
the disjunction, which complements a set before closing it under case
instead of after: [bc] matched b'B', but so did [^bc]. Match both cases
of the character against every set member instead.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/Lib/test/test_re.py b/Lib/test/test_re.py
index d086fd5..ff106c1 100644
--- a/Lib/test/test_re.py
+++ b/Lib/test/test_re.py
@@ -2145,6 +2145,28 @@ def test_locale_flag(self):
self.assertRaises(ValueError, re.compile, b'(?a)', re.LOCALE)
self.assertRaises(re.PatternError, re.compile, b'(?aL)')
+ def test_locale_ignorecase_negated_set(self):
+ IL = re.LOCALE | re.IGNORECASE
+ # [bc] matches b'B', so [^bc] must not.
+ self.assertTrue(re.fullmatch(rb'[bc]', b'B', IL))
+ self.assertIsNone(re.fullmatch(rb'[^bc]', b'B', IL))
+ self.assertIsNone(re.fullmatch(rb'[^b-c]', b'C', IL))
+ self.assertIsNone(re.fullmatch(rb'[^bc]', b'c', IL))
+ self.assertTrue(re.fullmatch(rb'[^bc]', b'a', IL))
+ # A one-member set compiles to NOT_LITERAL_LOC_IGNORE.
+ self.assertIsNone(re.fullmatch(rb'[^b]', b'B', IL))
+ self.assertTrue(re.fullmatch(rb'[^b]', b'a', IL))
+ self.assertIsNone(re.fullmatch(rb'[^\wq]', b'Q', IL))
+ # A sparse set compiles to a bitmap instead of ranges.
+ self.assertTrue(re.fullmatch(rb'[ace]', b'C', IL))
+ self.assertIsNone(re.fullmatch(rb'[^ace]', b'C', IL))
+ self.assertTrue(re.fullmatch(rb'[^ace]', b'b', IL))
+ # An alternation folded into a set puts NEGATE in the middle of it.
+ self.assertIsNone(re.fullmatch(rb'(?:a|[^bc])', b'B', IL))
+ self.assertTrue(re.fullmatch(rb'(?:a|[^bc])', b'A', IL))
+ self.assertIsNone(re.fullmatch(rb'\w(?<!b)', b'B', IL))
+ self.assertTrue(re.fullmatch(rb'\w(?<!b)', b'A', IL))
+
def test_scoped_flags(self):
self.assertTrue(re.match(r'(?i:a)b', 'Ab'))
self.assertIsNone(re.match(r'(?i:a)b', 'aB'))
diff --git a/Misc/NEWS.d/next/Library/2026-08-27-08-31-29.gh-issue-156444.klcySy.rst b/Misc/NEWS.d/next/Library/2026-08-27-08-31-29.gh-issue-156444.klcySy.rst
new file mode 100644
index 0000000..cae73e5
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-27-08-31-29.gh-issue-156444.klcySy.rst
@@ -0,0 +1,3 @@
+Fix matching of a negated character set in a bytes pattern compiled with both
+:const:`re.IGNORECASE` and :const:`re.LOCALE`. The case closure is now applied
+to the members of the set, so that ``[^bc]`` no longer matches ``b'B'``.
diff --git a/Modules/_sre/sre_lib.h b/Modules/_sre/sre_lib.h
index 444cd39..fbdbaef 100644
--- a/Modules/_sre/sre_lib.h
+++ b/Modules/_sre/sre_lib.h
@@ -175,16 +175,71 @@ SRE(charset)(SRE_STATE* state, const SRE_CODE* set, SRE_CODE ch)
}
}
+/* Like SRE(charset), but matches both locale cases of ch against every set
+ member. Testing the whole set once per case would complement it before
+ closing it under case instead of after, so that [^bc] matched b'B'.
+ BIGCHARSET and RANGE_UNI_IGNORE are not handled: they never occur in a
+ set of a bytes pattern. */
LOCAL(int)
SRE(charset_loc_ignore)(SRE_STATE* state, const SRE_CODE* set, SRE_CODE ch)
{
SRE_CODE lo, up;
- lo = sre_lower_locale(ch);
- if (SRE(charset)(state, set, lo))
- return 1;
+ int ok = 1;
+ lo = sre_lower_locale(ch);
up = sre_upper_locale(ch);
- return up != lo && SRE(charset)(state, set, up);
+ if (up == lo)
+ return SRE(charset)(state, set, lo);
+
+ for (;;) {
+ switch (*set++) {
+
+ case SRE_OP_FAILURE:
+ return !ok;
+
+ case SRE_OP_LITERAL:
+ /* <LITERAL> <code> */
+ if (lo == set[0] || up == set[0])
+ return ok;
+ set++;
+ break;
+
+ case SRE_OP_CATEGORY:
+ /* <CATEGORY> <code> */
+ if (sre_category(set[0], (int) lo) ||
+ sre_category(set[0], (int) up))
+ return ok;
+ set++;
+ break;
+
+ case SRE_OP_CHARSET:
+ /* <CHARSET> <bitmap> */
+ if ((lo < 256 && (set[lo/SRE_CODE_BITS]
+ & (1u << (lo & (SRE_CODE_BITS-1))))) ||
+ (up < 256 && (set[up/SRE_CODE_BITS]
+ & (1u << (up & (SRE_CODE_BITS-1))))))
+ return ok;
+ set += 256/SRE_CODE_BITS;
+ break;
+
+ case SRE_OP_RANGE:
+ /* <RANGE> <lower> <upper> */
+ if ((set[0] <= lo && lo <= set[1]) ||
+ (set[0] <= up && up <= set[1]))
+ return ok;
+ set += 2;
+ break;
+
+ case SRE_OP_NEGATE:
+ ok = !ok;
+ break;
+
+ default:
+ /* internal error -- there's not much we can do about it
+ here, so let's just pretend it didn't match... */
+ return 0;
+ }
+ }
}
LOCAL(Py_ssize_t) SRE(match)(SRE_STATE* state, const SRE_CODE* pattern, int toplevel);