Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 106 additions & 9 deletions pcre/pcre.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."""

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions tests/test_api_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down