Skip to content

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

Merged
youknowone merged 1 commit into
RustPython:mainfrom
luantaraschi:fix/findall-single-group-none
Aug 21, 2026
Merged

Fix re.findall reporting a group that did not match#8563
youknowone merged 1 commit into
RustPython:mainfrom
luantaraschi:fix/findall-single-group-none

Conversation

@luantaraschi

@luantaraschi luantaraschi commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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

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

Anything reading the result as a list of strings gets a TypeError from "".join(...), from max(..., key=len), from a .strip() in a loop. With two groups it was already right, because that branch passes "" to Match.groups as the default while the one-group branch fell through to None. The two halves of the same function disagreed.

While pinning that down, the same line turned up a second one. The default is built as a str whatever the pattern is, so a bytes pattern comes back with str mixed into it:

>>> re.findall(rb"(a)|(b)", b"ab")
[(b'a', ''), ('', b'b')]        # CPython: [(b'a', b''), (b'', b'b')]
>>> [type(y).__name__ for x in re.findall(rb"(a)|(b)", b"ab") for y in x]
['bytes', 'str', 'str', 'bytes']

That one is on the two-group branch, so it is there on main today and is not a consequence of the first fix. Both come from the same decision, so the empty value is now built once from zelf.isbytes, using the idiom sub_impl already uses a few lines up, and both branches take it.

Match.groups() keeps reporting None, which is what CPython does and is the reason the two are easy to confuse. re.split keeps None as well. Both are covered by the tests so a later change cannot quietly pull them along.

Checked against CPython 3.14.7 over 41 cases: one group participating and not, named groups, zero groups, two and three groups, str and bytes, the compiled-pattern form, and the neighbours that share the match machinery (finditer, group, groups, groupdict, split, sub, expand). All 41 agree now. Without the change 20 of them differ.

Lib/test/test_re.py::test_re_findall only uses groups that participate, or two groups, so the suite passes either way. It still passes here, 166 tests. The new cases are in extra_tests/snippets/stdlib_re.py, and they also assert the result types, since that is where the bytes half went wrong.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed re.findall so unmatched capturing groups consistently return an empty value matching the pattern type: "" for text patterns and b"" for byte patterns.
    • Preserved existing behavior for participating groups, patterns without groups, and other matching APIs.
  • Tests

    • Added coverage for unmatched groups across text and byte patterns, including single- and multi-group results.

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
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 02edaaf7-3cbc-4a54-a232-1e5117d7da7e

📥 Commits

Reviewing files that changed from the base of the PR and between dd2cc4d and 496f77a.

📒 Files selected for processing (2)
  • crates/vm/src/stdlib/_sre.rs
  • extra_tests/snippets/stdlib_re.py

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Pattern.findall now uses a type-matching empty value for unmatched groups. Tests cover string and bytes patterns and confirm that other match APIs still return None.

Changes

Pattern findall fallback behavior

Layer / File(s) Summary
Type-specific unmatched-group fallback
crates/vm/src/stdlib/_sre.rs
Pattern.findall uses "" for string patterns and b"" for bytes patterns. Match.groups behavior remains unchanged.
Coverage for unmatched groups
extra_tests/snippets/stdlib_re.py
Tests cover single and multiple groups, string and bytes patterns, and the unchanged None behavior of match APIs and re.split.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 496f7

The change corrects re.findall results for unmatched groups while preserving related APIs and adds focused coverage; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: youknowone, joshuamegnauth54

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change to unmatched groups returned by re.findall.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I am surprised this was not covered by test_re

@youknowone
youknowone merged commit 6079ad0 into RustPython:main Aug 21, 2026
24 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants