From 1e87866108065a67ee944610ccd265cd438a49ed Mon Sep 17 00:00:00 2001 From: Qubitium Date: Mon, 24 Aug 2026 11:15:22 +0000 Subject: [PATCH] fix: align Pattern pos/endpos signatures with re and add sliced-search fallback Pattern.match/search/fullmatch/finditer/findall required keyword-only pos/endpos, breaking drop-in re-style positional calls such as pattern.search(s, 2). Accept them positionally (options stays keyword-only) and, for minimal non-C backends whose search() lacks pos support, fall back to searching a slice with offset tracking so reported spans stay absolute in the original subject. --- pcre/pcre.py | 115 ++++++++++++++++++++++++++++++++++++--- tests/test_api_parity.py | 85 +++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 9 deletions(-) diff --git a/pcre/pcre.py b/pcre/pcre.py index f6c3f4f..9e23324 100644 --- a/pcre/pcre.py +++ b/pcre/pcre.py @@ -313,6 +313,83 @@ def _pcre2_replacement_from_parsed(parsed: Any, is_bytes: bool) -> Any: return "".join(parts) +class _OffsetMatch: + """Present a match found inside ``subject[offset:]`` with absolute offsets.""" + + __slots__ = ("_match", "_offset") + + def __init__(self, match: Match, offset: int) -> None: + self._match = match + self._offset = offset + + def __getitem__(self, item: Any) -> Any: + return self._match[item] + + def __repr__(self) -> str: # pragma: no cover - delegated repr + return repr(self._match) + + def group(self, *indices: Any) -> Any: + return self._match.group(*indices) + + def groups(self, default: Any = None) -> tuple[Any, ...]: + return self._match.groups(default) + + def groupdict(self, default: Any = None) -> dict[str, Any]: + return self._match.groupdict(default) + + def expand(self, template: Any) -> Any: + return self._match.expand(template) + + def start(self, group: Any = 0) -> int: + value = self._match.start(group) + if value == -1: + return -1 + return value + self._offset + + def end(self, group: Any = 0) -> int: + value = self._match.end(group) + if value == -1: + return -1 + return value + self._offset + + def span(self, group: Any = 0) -> tuple[int, int]: + start = self._match.start(group) + if start == -1: + return (-1, -1) + return (start + self._offset, self._match.end(group) + self._offset) + + @property + def re(self) -> Any: + return self._match.re + + @property + def string(self) -> Any: + return self._match.string + + @property + def pos(self) -> int: + return self._match.pos + + @property + def endpos(self) -> int: + return self._match.endpos + + @property + def lastindex(self) -> int | None: + return self._match.lastindex + + @property + def lastgroup(self) -> str | None: + return self._match.lastgroup + + @property + def regs(self) -> tuple[tuple[int, int], ...]: + return tuple( + (-1, -1) if span[0] == -1 else (span[0] + self._offset, span[1] + self._offset) + for span in self._match.regs + ) + + class Pattern: """High-level wrapper around the C-backed :class:`pcre_ext_c.Pattern`.""" @@ -458,9 +535,9 @@ def _wrap_match( def match( self, subject: Any, - *, pos: int = 0, endpos: int | None = None, + *, options: int = 0, ) -> Match | None: if type(subject) is memoryview: @@ -485,9 +562,9 @@ def match( def search( self, subject: Any, - *, pos: int = 0, endpos: int | None = None, + *, options: int = 0, ) -> Match | None: if type(subject) is memoryview: @@ -497,22 +574,42 @@ def search( return self._pattern.search(subject, pos, compiled_end, options, self) if endpos is None: resolved_end = len(subject) - raw = self._pattern.search(subject, pos=pos, options=options) + try: + raw = self._pattern.search(subject, pos=pos, options=options) + except TypeError: + return self._search_via_slice(subject, pos, resolved_end) else: resolved_end = resolve_endpos(subject, endpos) - raw = self._pattern.search( - subject, pos=pos, endpos=resolved_end, options=options - ) + try: + raw = self._pattern.search( + subject, pos=pos, endpos=resolved_end, options=options + ) + except TypeError: + return self._search_via_slice(subject, pos, resolved_end) if raw is None: return None return self._wrap_match(raw, subject, pos, resolved_end) + def _search_via_slice(self, subject: Any, pos: int, end_boundary: int) -> Match | None: + """Search a minimal backend that lacks re-style ``pos`` support. + + The subject is sliced to ``subject[pos:end]`` and every offset of the + resulting match is shifted back by ``pos`` so spans stay absolute in + the original subject. + """ + + raw = self._pattern.search(subject[pos:end_boundary]) + if raw is None: + return None + wrapped = self._wrap_match(raw, subject, pos, end_boundary) + return _OffsetMatch(wrapped, pos) + def fullmatch( self, subject: Any, - *, pos: int = 0, endpos: int | None = None, + *, options: int = 0, ) -> Match | None: if type(subject) is memoryview: @@ -535,9 +632,9 @@ def fullmatch( def finditer( self, subject: Any, - *, pos: int = 0, endpos: int | None = None, + *, options: int = 0, ) -> Iterator[Match]: if type(subject) is memoryview: @@ -621,9 +718,9 @@ def _generator(): def findall( self, subject: Any, - *, pos: int = 0, endpos: int | None = None, + *, options: int = 0, ) -> List[Any]: if type(subject) is memoryview: diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py index 0a32e41..27ebb63 100644 --- a/tests/test_api_parity.py +++ b/tests/test_api_parity.py @@ -370,6 +370,91 @@ def test_match_attributes_bytes(): assert match.expand(br"[\1\2]") == b"[ab]" +def test_pattern_methods_accept_positional_pos_like_re(): + text = "abcabc" + expected_pattern = re.compile("b") + + search = pcre.compile("b").search(text, 2) + expected_search = expected_pattern.search(text, 2) + assert (search.span(), search.pos) == (expected_search.span(), expected_search.pos) + + match = pcre.compile("c").match(text, 2) + expected_match = re.compile("c").match(text, 2) + assert (match.span(), match.pos) == (expected_match.span(), expected_match.pos) + + fullmatch = pcre.compile("a+").fullmatch("xaax", 1, 3) + expected_fullmatch = re.compile("a+").fullmatch("xaax", 1, 3) + assert fullmatch.span() == expected_fullmatch.span() + + finditer = [m.span() for m in pcre.compile("b").finditer(text, 1)] + assert finditer == [m.span() for m in re.compile("b").finditer(text, 1)] + + findall = pcre.compile("(b)").findall(text, 4) + assert findall == re.compile("(b)").findall(text, 4) + + endpos_search = pcre.compile("b").search(text, 0, 3) + assert endpos_search.span() == re.compile("b").search(text, 0, 3).span() + + +def test_search_falls_back_to_slice_with_offset_tracking(): + class _MinimalRawMatch: + def __init__(self, span): + self._span = span + + def group(self, *indices): + if not indices or indices == (0,): + return "b" + raise IndexError(indices) + + def groups(self, default=None): + return () + + def groupdict(self, default=None): + return {} + + def start(self, group=0): + return self._span[0] if group == 0 else -1 + + def end(self, group=0): + return self._span[1] if group == 0 else -1 + + def span(self, group=0): + return (self.start(group), self.end(group)) + + def expand(self, template): + return "b" + + class _MinimalBackend: + # Deliberately lacks re-style pos/endpos/options support. + pattern = "b" + groupindex = {} + flags = 0 + capture_count = 0 + jit = False + + @staticmethod + def search(subject): + index = subject.find("b") + if index < 0: + return None + return _MinimalRawMatch((index, index + 1)) + + pattern = pcre.Pattern(_MinimalBackend()) + no_hit = pattern.search("acac", 1) + assert no_hit is None + + hit = pattern.search("acacbx", 2) + assert hit is not None + assert hit.span() == (4, 5) + assert hit.start() == 4 + assert hit.end() == 5 + assert hit.group() == "b" + assert hit.regs == ((4, 5),) + assert hit.pos == 2 + assert hit.string == "acacbx" + assert hit.re is pattern + + def _signature_fingerprint(func): signature = inspect.signature(func) return tuple((param.name, param.kind, param.default) for param in signature.parameters.values())