Skip to content

feat: zero-copy buffer-protocol subject support (mmap, bytearray, array) - #98

Merged
Qubitium merged 3 commits into
mainfrom
devin/buffer-protocol-subjects
Jul 27, 2026
Merged

feat: zero-copy buffer-protocol subject support (mmap, bytearray, array)#98
Qubitium merged 3 commits into
mainfrom
devin/buffer-protocol-subjects

Conversation

@Qubitium

@Qubitium Qubitium commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Allow Pattern.match(), search(), fullmatch(), finditer(), findall(), split(), and sub() to operate directly on any object that implements the Python buffer protocol (e.g. mmap.mmap, bytearray, array.array) without first copying it into a bytes object. The change is safe under GIL=0 because the memoryview that exposes the buffer pins the exporter's storage for the lifetime of the match/finditer iterator.

Upstream credit: The buffer-protocol subject support is based on Jack Wong's work in iforapsy/PyPcre (commit e08f2ab). This port rebases it onto main, adds UTF-8 validation for the zero-copy buffer path, and includes expanded tests.

What changed

  • pcre_ext/util.c adds buffer_view_from_object(obj, &data, &len), which uses PyMemoryView_FromObject to borrow a contiguous, single-byte C pointer into the exporter's storage. Callers keep the returned memoryview alive as utf8_owner, which both Pattern_execute() and Pattern_create_finditer() now do.
  • pcre_ext/pcre2.c adds a PyObject_CheckBuffer(subject_obj) branch in Pattern_execute() and Pattern_create_finditer(). It uses the helper above, treats the subject as raw byte data (byte offsets, bytes group values), and calls ensure_valid_utf8_for_bytes_subject() before the PCRE2_NO_UTF_CHECK fast-path so UTF-8 patterns cannot receive invalid bytes.
  • pcre/re_compat.py broadens is_bytes_like() to accept other buffer-protocol objects by attempting memoryview(value), while still returning False for str.
  • tests/test_mmap.py adds coverage for mmap.mmap and bytearray subjects across pcre module-level functions and pattern methods, including findall/finditer, split/sub, pos/endpos byte offsets, non-contiguous buffer rejection, and the lifetime contract that prevents mmap.close() while a match object is alive.

GIL=0 / safety notes

  • Object lifetime: PyMemoryView_FromObject increments the exporter's buffer export count and holds the exporter alive. The C MatchObject / FinditerObject stores the memoryview as utf8_owner, so the buffer stays pinned until the match/finditer is freed.
  • Mutability: a memoryview only prevents resize/close, not concurrent element writes from other threads/processes. That is a caller contract; the README already warns not to mutate writable buffers during a match.
  • UTF-8 safety: the new buffer branch validates UTF-8 for PCRE2_UTF patterns, matching the existing bytes behavior, so PCRE2_NO_UTF_CHECK is safe to use.

Example

import mmap, pcre

with open("data.txt", "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
    match = pcre.search(rb"needle-(\d+)", mm)
    print(match.group(1))  # b"42"

Port the e08f2ab buffer support from the iforapsy fork:

- Add buffer_view_from_object() helper in pcre_ext/util.c to obtain a
  contiguous memoryview over bytes-like objects without copying.
- Wire the zero-copy path into Pattern_execute() and Pattern_create_finditer()
  so mmap.mmap, bytearray, array.array, and other buffer-protocol objects can
  be matched directly.
- Broaden is_bytes_like() in pcre/re_compat.py to accept buffer-protocol
  exporters by attempting PyMemoryView_FromObject() (with BufferError handled).
- Add UTF-8 validation for buffer-protocol subjects when the pattern has
  PCRE2_UTF, preventing PCRE2_NO_UTF_CHECK from passing malformed UTF-8 to
  PCRE2.
- Add tests/test_mmap.py exercising match/fullmatch/search/finditer/split/sub
  against mmap and bytearray subjects, non-contiguous buffer rejection, and
  lifetime behavior (mmap cannot be closed while a match is alive).

The GIL=0 object-lifetime contract is safe because PyMemoryView_FromObject
holds the exporter alive and pins its buffer for the lifetime of the match or
finditer iterator. Concurrent mutation of writable buffers remains a caller
contract, not something the library can prevent; this is documented in the
README section added by the original c8e8e4b commit and already exists in the
repo's README.
@Qubitium Qubitium self-assigned this Jul 27, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread tests/test_mmap.py Outdated

def test_split_on_mmap(self):
pattern = pcre.compile(rb"\s+")
parts = pattern.split(self.mm[:20])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Test claims to cover mmap splitting but actually tests plain bytes

The split test passes an already-sliced value (self.mm[:20] at tests/test_mmap.py:81) which is a plain bytes object rather than the memory-mapped file, so the new zero-copy path is never exercised for splitting.

Impact: The feature area appears covered but has no real test, so a future regression in splitting directly on a buffer object would go undetected.

Why the sliced value is bytes, not mmap

Slicing an mmap.mmap (self.mm[:20]) returns a bytes object, not another mmap or memoryview. Therefore pattern.split(self.mm[:20]) splits over a bytes subject and never enters the buffer-protocol branch added in pcre_ext/pcre2.c. To actually exercise the new path the test should split over self.mm directly (e.g. pattern.split(self.mm)), which routes through Pattern.split -> finditer(self.mm) -> the new buffer branch.

Suggested change
parts = pattern.split(self.mm[:20])
parts = pattern.split(self.mm)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch — self.mm[:20] returns plain bytes, so the zero-copy buffer path was not exercised. Fixed in 9710449 by passing self.mm directly to pattern.split(). Test still passes and now routes through Pattern.split -> finditer(self.mm) -> the new buffer branch.

@Qubitium
Qubitium merged commit 2cd83e1 into main Jul 27, 2026
19 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.

1 participant