From 496f77a1e0d7417c479eb176ba713e6063924b69 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:54:56 -0300 Subject: [PATCH] Fix re.findall reporting a group that did not match 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 --- crates/vm/src/stdlib/_sre.rs | 16 ++++++++--- extra_tests/snippets/stdlib_re.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index a9f98ca7015..4575901d3e8 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -452,15 +452,25 @@ mod _sre { let mut match_list: Vec = 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); diff --git a/extra_tests/snippets/stdlib_re.py b/extra_tests/snippets/stdlib_re.py index 8613ddd30fc..80006aaf3e8 100644 --- a/extra_tests/snippets/stdlib_re.py +++ b/extra_tests/snippets/stdlib_re.py @@ -82,3 +82,47 @@ # 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"(?Pa)?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()