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
15 changes: 10 additions & 5 deletions pcre/pcre.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,12 @@ def _normalise_flags(flags: FlagInput) -> int:


def _pcre2_replacement_from_parsed(parsed: Any, is_bytes: bool) -> Any:
"""Convert a parsed Python replacement template to a PCRE2 replacement string."""
"""Convert a parsed Python replacement template to a PCRE2 replacement string.

Group references are emitted as ``${n}``: PCRE2 only accepts ``\\g<n>`` in
replacement strings from 10.44 onwards, while ``${n}`` works on every
supported runtime. Literal text is escaped so ``$`` and ``\\`` stay literal.
"""

if (
isinstance(parsed, tuple)
Expand All @@ -282,15 +287,15 @@ def _pcre2_replacement_from_parsed(parsed: Any, is_bytes: bool) -> Any:
parts = []
for i, lit in enumerate(literals):
if i in slot_to_group:
parts.append(("\\g<" + str(slot_to_group[i]) + ">").encode("ascii"))
parts.append(("${" + str(slot_to_group[i]) + "}").encode("ascii"))
if lit is not None:
parts.append(lit.replace(b"\\", b"\\\\").replace(b"$", b"$$"))
return b"".join(parts)

parts = []
for i, lit in enumerate(literals):
if i in slot_to_group:
parts.append("\\g<" + str(slot_to_group[i]) + ">")
parts.append("${" + str(slot_to_group[i]) + "}")
if lit is not None:
parts.append(lit.replace("\\", "\\\\").replace("$", "$$"))
return "".join(parts)
Expand All @@ -299,15 +304,15 @@ def _pcre2_replacement_from_parsed(parsed: Any, is_bytes: bool) -> Any:
parts = []
for item in parsed:
if isinstance(item, int):
parts.append(("\\g<" + str(item) + ">").encode("ascii"))
parts.append(("${" + str(item) + "}").encode("ascii"))
else:
parts.append(item.replace(b"\\", b"\\\\").replace(b"$", b"$$"))
return b"".join(parts)

parts = []
for item in parsed:
if isinstance(item, int):
parts.append("\\g<" + str(item) + ">")
parts.append("${" + str(item) + "}")
else:
parts.append(item.replace("\\", "\\\\").replace("$", "$$"))
return "".join(parts)
Expand Down
96 changes: 80 additions & 16 deletions pcre_ext/pcre2.c
Original file line number Diff line number Diff line change
Expand Up @@ -5188,12 +5188,17 @@ pattern_translate_single_replacement(PatternObject *self,
) >= 0) {
return NULL;
}
if (length > PY_SSIZE_T_MAX - 3) {
/* Emit PCRE2's ${n} form: \g<n> in replacement strings is only
* understood by PCRE2 >= 10.44 and raises BADREPESCAPE on older
* runtimes (Ubuntu 24.04 ships 10.42). ${n} has been valid since
* 10.00 and the surrounding literal text is guaranteed to contain
* no '$' by the checks above. */
if (length > PY_SSIZE_T_MAX - 2) {
PyErr_NoMemory();
return NULL;
}
*handled = 1;
Py_ssize_t result_length = length + 3;
Py_ssize_t result_length = length + 2;
if (replacement_is_bytes) {
PyObject *result = PyBytes_FromStringAndSize(NULL, result_length);
if (result == NULL) {
Expand All @@ -5202,12 +5207,11 @@ pattern_translate_single_replacement(PatternObject *self,
char *output = PyBytes_AS_STRING(result);
const char *input = PyBytes_AS_STRING(replacement);
memcpy(output, input, (size_t)slash_index);
output[slash_index] = '\\';
output[slash_index + 1] = 'g';
output[slash_index + 2] = '<';
output[slash_index + 3] = (char)following;
output[slash_index + 4] = '>';
memcpy(output + slash_index + 5,
output[slash_index] = '$';
output[slash_index + 1] = '{';
output[slash_index + 2] = (char)following;
output[slash_index + 3] = '}';
memcpy(output + slash_index + 4,
input + slash_index + 2,
(size_t)(length - slash_index - 2));
return result;
Expand All @@ -5223,14 +5227,13 @@ pattern_translate_single_replacement(PatternObject *self,
PyUnicode_CopyCharacters(
result, 0, replacement, 0, slash_index
) < 0) ||
PyUnicode_WriteChar(result, slash_index, '\\') < 0 ||
PyUnicode_WriteChar(result, slash_index + 1, 'g') < 0 ||
PyUnicode_WriteChar(result, slash_index + 2, '<') < 0 ||
PyUnicode_WriteChar(result, slash_index + 3, following) < 0 ||
PyUnicode_WriteChar(result, slash_index + 4, '>') < 0 ||
PyUnicode_WriteChar(result, slash_index, '$') < 0 ||
PyUnicode_WriteChar(result, slash_index + 1, '{') < 0 ||
PyUnicode_WriteChar(result, slash_index + 2, following) < 0 ||
PyUnicode_WriteChar(result, slash_index + 3, '}') < 0 ||
(slash_index + 2 < length &&
PyUnicode_CopyCharacters(result,
slash_index + 5,
slash_index + 4,
replacement,
slash_index + 2,
length - slash_index - 2) < 0)) {
Expand Down Expand Up @@ -5287,8 +5290,62 @@ pattern_translate_single_replacement(PatternObject *self,
return NULL;
}
*handled = 1;
Py_INCREF(replacement);
return replacement;
/* Rewrite \g<name> as ${name} (same runtime-compatibility reason as
* above): "\\g<" + name + ">" (4 + n chars) becomes "${" + name + "}"
* (3 + n chars), so the result is exactly one character shorter. */
{
Py_ssize_t result_length = length - 1;
Py_ssize_t tail_start = cursor + 1;
Py_ssize_t tail_length = length - tail_start;
if (replacement_is_bytes) {
PyObject *result = PyBytes_FromStringAndSize(NULL, result_length);
if (result == NULL) {
return NULL;
}
char *output = PyBytes_AS_STRING(result);
const char *input = PyBytes_AS_STRING(replacement);
memcpy(output, input, (size_t)slash_index);
output[slash_index] = '$';
output[slash_index + 1] = '{';
memcpy(output + slash_index + 2, name, (size_t)name_length);
output[slash_index + 2 + name_length] = '}';
memcpy(output + slash_index + 3 + name_length,
input + tail_start,
(size_t)tail_length);
return result;
}
PyObject *result = PyUnicode_New(
result_length, PyUnicode_MAX_CHAR_VALUE(replacement)
);
if (result == NULL) {
return NULL;
}
if ((slash_index > 0 &&
PyUnicode_CopyCharacters(result, 0, replacement, 0, slash_index) < 0) ||
PyUnicode_WriteChar(result, slash_index, '$') < 0 ||
PyUnicode_WriteChar(result, slash_index + 1, '{') < 0) {
Py_DECREF(result);
return NULL;
}
for (Py_ssize_t i = 0; i < name_length; ++i) {
if (PyUnicode_WriteChar(result, slash_index + 2 + i,
(Py_UCS4)(unsigned char)name[i]) < 0) {
Py_DECREF(result);
return NULL;
}
}
if (PyUnicode_WriteChar(result, slash_index + 2 + name_length, '}') < 0 ||
(tail_length > 0 &&
PyUnicode_CopyCharacters(result,
slash_index + 3 + name_length,
replacement,
tail_start,
tail_length) < 0)) {
Py_DECREF(result);
return NULL;
}
return result;
}
}

static PyObject *
Expand Down Expand Up @@ -5671,6 +5728,13 @@ Pattern_create(PyObject *pattern_obj, uint32_t options, int jit, int jit_explici
} else if (!jit_explicit && jit_rc == PCRE2_ERROR_JIT_UNSUPPORTED) {
pattern_jit_set(pattern, 0);
#endif
} else if (!jit_explicit && jit_rc == PCRE2_ERROR_NOMEMORY) {
/* PCRE2 < 10.43 has no JIT_UNSUPPORTED code and reports every
* construct the JIT cannot compile (e.g. \C in UTF mode) as
* NOMEMORY. For implicit JIT requests fall back to the
* interpreter exactly as newer runtimes do; an explicit jit=True
* still surfaces the error. */
pattern_jit_set(pattern, 0);
} else {
Py_DECREF(pattern);
raise_pcre_error("jit_compile", jit_rc, 0);
Expand Down
46 changes: 43 additions & 3 deletions tests/test_clobber_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@
_EXT_DURATION = max(10.0, _DIFF_DURATION * 0.6)
_JOIN_GRACE = 60.0
_MAX_DIFF_SUBJECT = 48 # short: the re oracle has no backtracking limits


def _pcre2_runtime_version() -> tuple[int, int]:
import pcre_ext_c

text = str(getattr(pcre_ext_c, "PCRE2_VERSION", "0.0")).split()[0]
major, _, minor = text.partition(".")
try:
return int(major), int(minor)
except ValueError:
return (0, 0)


# Older PCRE2 runtimes have known engine-level wrong-result bugs that pypcre
# cannot paper over (e.g. 10.42 start-optimization loses matches for
# (?=2{1,3}\D?)(?:.?2){1,1}e{0,} in BOTH interpreter and JIT unless compiled
# with PCRE2_NO_START_OPTIMIZE; fixed by 10.46). Accuracy mismatches on such
# runtimes are reported rather than failed; crashes, errors and hangs still
# fail everywhere. PYPCRE_CLOBBER_STRICT_ENGINE=1 restores hard failures.
_TRUSTED_ENGINE = (
_pcre2_runtime_version() >= (10, 46)
or bool(os.getenv("PYPCRE_CLOBBER_STRICT_ENGINE"))
)
_MAX_EXT_SUBJECT = 2048


Expand Down Expand Up @@ -251,8 +274,11 @@ def _gen_group(ctx: _GenCtx, parent_bounded: bool) -> tuple[str, bool]:
body, body_nullable = _gen_seq(
ctx, bounded_only=parent_bounded or quantify, max_pieces=3
)
# Never quantify a group whose body can match empty (see _gen_piece).
quantify = quantify and not body_nullable
# Never quantify a group whose body can match empty (see _gen_piece), and
# never quantify a group nested inside an already-quantified group: even
# with bounded ranges, stacked group quantifiers multiply the re oracle's
# backtracking (e.g. ((.{1,3}|0{2,4}){1,3}\1?){2,4} took 30 s per call).
quantify = quantify and not body_nullable and not parent_bounded
if not capturing:
if r < 0.30 or not ctx.allow_pcre_only:
out = f"(?:{body})"
Expand Down Expand Up @@ -468,11 +494,15 @@ def _differential_case(
)

def fail(op: str, got, want) -> None:
failures.record(
message = (
f"MISMATCH {op}: seed={seed} pattern={pattern_input!r} "
f"subject={subject!r} pos={pos} endpos={endpos} "
f"pcre={got!r} re={want!r}"
)
if _TRUSTED_ENGINE:
failures.record(message)
else:
_ENGINE_DIVERGENCES.record(message, halt=False)

try:
for op in ("search", "match", "fullmatch"):
Expand Down Expand Up @@ -543,6 +573,7 @@ def fail(op: str, got, want) -> None:


_JIT_DIVERGENCES = _Failures()
_ENGINE_DIVERGENCES = _Failures()


def _differential_worker(worker_id: int, seed: int, deadline: float, failures: _Failures, ops: list[int]) -> None:
Expand All @@ -565,6 +596,15 @@ def test_clobber_differential_accuracy_threaded() -> None:
_run_workers(_differential_worker, _DIFF_DURATION, failures, seed, ops)
if failures.items():
pytest.fail("\n---\n".join(failures.items()))
engine_reports = _ENGINE_DIVERGENCES.items()
if engine_reports:
print(
f"[clobber-verify diff] {len(engine_reports)} accuracy divergence(s) "
f"on untrusted PCRE2 runtime {_pcre2_runtime_version()} (reported only):",
flush=True,
)
for report in engine_reports[:3]:
print(" " + report, flush=True)
jit_reports = _JIT_DIVERGENCES.items()
if jit_reports:
print(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_coverage_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,10 @@ def test_pcre2_replacement_conversion_tuple_format() -> None:
parsed_text = ([(1, 1)], ["$\\", None, "tail"])
parsed_bytes = ([(1, 1)], [b"$\\", None, b"tail"])
assert pcre_mod._pcre2_replacement_from_parsed(parsed_text, False) == (
"$$" + "\\" * 3 + "g<1>tail"
"$$" + "\\" * 2 + "${1}tail"
)
assert pcre_mod._pcre2_replacement_from_parsed(parsed_bytes, True) == (
b"$$" + b"\\" * 3 + b"g<1>tail"
b"$$" + b"\\" * 2 + b"${1}tail"
)


Expand Down
5 changes: 3 additions & 2 deletions tests/test_python_coverage_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ def test_stdlib_parser_exported_path(monkeypatch: pytest.MonkeyPatch) -> None:


def test_flat_replacement_conversion_paths() -> None:
assert pcre_mod._pcre2_replacement_from_parsed([1, b"$"], True) == b"\\g<1>$$"
assert pcre_mod._pcre2_replacement_from_parsed([1, "$"], False) == r"\g<1>$$"
# ${n} rather than \g<n>: PCRE2 only accepts \g<n> replacements from 10.44.
assert pcre_mod._pcre2_replacement_from_parsed([1, b"$"], True) == b"${1}$$"
assert pcre_mod._pcre2_replacement_from_parsed([1, "$"], False) == "${1}$$"


def test_replacement_template_cache_reuses_and_clears(
Expand Down
1 change: 1 addition & 0 deletions tests/test_threaded_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def test_parallel_map_with_flag(self):
self.assertTrue(pattern.use_threads)

def test_parallel_map_batches_work_without_changing_order(self):
self._skip_if_thread_barred()
pattern = pcre.compile(r"\w+", Flag.THREADS)
subjects = [word * 2_000 for word in ("alpha", "beta", "gamma", "delta", "epsilon")]
executor = thread_utils.ensure_thread_pool(2)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
import pcre.threads as threads_mod


_requires_thread_backend = pytest.mark.skipif(
not threads_mod.threading_supported(),
reason="threaded backend requires >=8 CPU cores",
)


def test_threading_supported_false_on_low_core_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -51,6 +57,7 @@ def test_configure_threads_threshold_validation(
threads_mod.configure_threads(threshold=original)


@_requires_thread_backend
def test_configure_thread_pool_clamps_workers(monkeypatch: pytest.MonkeyPatch) -> None:
original_workers = getattr(threads_mod, "_THREAD_POOL_WORKERS", None)
original_pool = getattr(threads_mod, "_THREAD_POOL", None)
Expand All @@ -71,13 +78,15 @@ def test_configure_thread_pool_clamps_workers(monkeypatch: pytest.MonkeyPatch) -
threads_mod._THREAD_POOL_WORKERS = original_workers


@_requires_thread_backend
def test_configure_thread_pool_rejects_invalid_worker_count() -> None:
with pytest.raises(TypeError):
threads_mod.configure_thread_pool(max_workers="x")
with pytest.raises(ValueError):
threads_mod.configure_thread_pool(max_workers=0)


@_requires_thread_backend
def test_get_thread_pool_size_initializes_once(monkeypatch: pytest.MonkeyPatch) -> None:
original_workers = threads_mod._THREAD_POOL_WORKERS
original_pool = threads_mod._THREAD_POOL
Expand Down Expand Up @@ -159,6 +168,7 @@ def test_determine_worker_count_requires_supported_backend(
threads_mod._determine_worker_count(None)


@_requires_thread_backend
def test_configure_thread_pool_shuts_down_existing_pool() -> None:
original_pool = threads_mod._THREAD_POOL
original_workers = threads_mod._THREAD_POOL_WORKERS
Expand All @@ -176,6 +186,7 @@ def test_configure_thread_pool_shuts_down_existing_pool() -> None:
threads_mod._THREAD_POOL_WORKERS = original_workers


@_requires_thread_backend
def test_ensure_thread_pool_resizes_existing_pool() -> None:
original_pool = threads_mod._THREAD_POOL
original_workers = threads_mod._THREAD_POOL_WORKERS
Expand All @@ -192,6 +203,7 @@ def test_ensure_thread_pool_resizes_existing_pool() -> None:
threads_mod._THREAD_POOL_WORKERS = original_workers


@_requires_thread_backend
def test_pool_submission_cannot_race_executor_replacement(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down