Fix re.findall reporting a group that did not match (#8563)

findall returns the matched text rather than a match object, so a group that
took no part in the match is reported as an empty value. With one group it
was reported as `None`:

    >>> re.findall(r"(a)?b", "b ab")
    [None, 'a']          # CPython: ['', 'a']

The branch for two or more groups was already right, since it passes `""` to
`Match.groups` as the default, so the two halves of the same function
disagreed with each other.

That default is built as a `str` whatever the pattern is, so a `bytes`
pattern came back with `str` mixed into it:

    >>> re.findall(rb"(a)|(b)", b"ab")
    [(b'a', ''), ('', b'b')]     # CPython: [(b'a', b''), (b'', b'b')]

The empty value is now built once from `isbytes` and both branches use it.
`Match.groups` still reports `None`, which is what CPython does.

Assisted-by: Claude Code:claude-opus-5
This commit is contained in:
Luan Taraschi authored and GitHub committed 2026-08-21 17:13:29 +09:00
1 parent c0773738d8
commit 6079ad06e5
2 files changed
+57 -3

No files matched your search

+13 -3
View File
@@ -452,15 +452,25 @@ mod _sre {
let mut match_list: Vec<PyObjectRef> = Vec::new();
let mut iter = SearchIter { req, state };
// What a group that took no part in the match is reported as.
// `findall` hands back the matched text rather than a match
// object, so the stand-in has to be an empty value of the type
// the pattern works on. `Match.groups` still reports `None` and
// is not affected by this.
let empty: PyObjectRef = if zelf.isbytes {
vm.ctx.new_bytes(vec![]).into()
} else {
vm.ctx.new_str(ascii!("")).into()
};
while iter.next().is_some() {
let m = Match::new(&mut iter.state, zelf.clone(), string_args.string.clone());
let item = if zelf.groups == 0 || zelf.groups == 1 {
m.get_slice(zelf.groups, s, vm)
.unwrap_or_else(|| vm.ctx.none())
.unwrap_or_else(|| empty.clone())
} else {
m.groups(OptionalArg::Present(vm.ctx.new_str(ascii!("")).into()), vm)?
.into()
m.groups(OptionalArg::Present(empty.clone()), vm)?.into()
};
match_list.push(item);
+44
View File
@@ -82,3 +82,47 @@ assert re.fullmatch(r"([0-9]++(?:\.[0-9]+)*+)", "1.25.38").group(0) == "1.25.38"
# Combining characters; issue #7518
assert not re.match(r"\w", "\u0345"), r"\w should not match U+0345 (category Mn)"
def test_findall_group_that_did_not_participate():
# findall returns the matched text, not a match object, so a group that
# took no part in the match stands in as an empty value of the type the
# pattern works on. One group used to come back as None, and a bytes
# pattern used to mix str into its results.
assert re.findall(r"(a)?b", "b ab") == ["", "a"]
assert re.findall(r"(x)?", "a") == ["", ""]
assert re.findall(r"(a|b)?c", "c ac bc") == ["", "a", "b"]
assert re.findall(r"(?P<g>a)?b", "b ab") == ["", "a"]
assert re.compile(r"(a)?b").findall("b ab") == ["", "a"]
assert re.findall(rb"(a)?b", b"b ab") == [b"", b"a"]
assert re.findall(rb"(x)?", b"a") == [b"", b""]
# Two or more groups give a tuple per match, with the same stand-in.
assert re.findall(r"(a)|(b)", "ab") == [("a", ""), ("", "b")]
assert re.findall(rb"(a)|(b)", b"ab") == [(b"a", b""), (b"", b"b")]
assert re.findall(rb"(a)(b)?", b"a ab") == [(b"a", b""), (b"a", b"b")]
# The type is the pattern's, never the other one.
assert [type(x) for x in re.findall(r"(a)?b", "b ab")] == [str, str]
assert [type(x) for x in re.findall(rb"(a)?b", b"b ab")] == [bytes, bytes]
assert [type(y) for x in re.findall(rb"(a)|(b)", b"ab") for y in x] == [
bytes,
bytes,
bytes,
bytes,
]
# A group that does participate, and no group at all, are unchanged.
assert re.findall(r"(a)", "aa") == ["a", "a"]
assert re.findall(r"a", "aa") == ["a", "a"]
assert re.findall(rb"a", b"aa") == [b"a", b"a"]
# A match object still reports None, which is where the difference lies.
assert re.match(r"(a)?b", "b").groups() == (None,)
assert re.match(r"(a)?b", "b").group(1) is None
assert [m.groups() for m in re.finditer(r"(a)?b", "b ab")] == [(None,), ("a",)]
assert re.split(r"(a)|(b)", "xaybz") == ["x", "a", None, "y", None, "b", "z"]
test_findall_group_that_did_not_participate()