regex_test
Run a Python regular expression over a text and list every match with its groups, named groups and spans, plus an optional substitution preview and split, under a hard time limit. Flags IGNORECASE, MULTILINE, DOTALL, VERBOSE and ASCII; search, match or fullmatch anchoring. A pattern that backtracks catastrophically is stopped by a timer and reported as a timeout instead of hanging; invalid patterns report the error position.
Install
/tools install regex_test /tools install regex_test@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · regex_test 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool regex_test; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
pattern | string | yes | Python re syntax. length ≤ 10000 | |
text | string | yes | length ≤ 200000 | |
flags | array of string | no | [] | |
anchor | string | no | "search" | search: every match anywhere; match: at the start; fullmatch: the whole text. one of "search", "match", "fullmatch" |
max_matches | integer | no | 1000 | ≥ 1; ≤ 10000 |
replacement | string | no | re.sub template (\1, \g<name>) for a substitution preview | |
count | integer | no | 0 | Maximum substitutions; 0 = all. ≥ 0 |
split | boolean | no | false | Also split the text on the pattern |
maxsplit | integer | no | 0 | ≥ 0 |
timeout | number | no | 2 | Seconds before the run is stopped. ≥ 0.1; ≤ 10 |
Input schema (JSON)
{
"type": "object",
"required": [
"pattern",
"text"
],
"additionalProperties": false,
"properties": {
"pattern": {
"type": "string",
"maxLength": 10000,
"description": "Python re syntax"
},
"text": {
"type": "string",
"maxLength": 200000
},
"flags": {
"type": "array",
"items": {
"type": "string",
"enum": [
"IGNORECASE",
"MULTILINE",
"DOTALL",
"VERBOSE",
"ASCII",
"i",
"m",
"s",
"x",
"a",
"I",
"M",
"S",
"X",
"A"
]
},
"default": []
},
"anchor": {
"type": "string",
"enum": [
"search",
"match",
"fullmatch"
],
"default": "search",
"description": "search: every match anywhere; match: at the start; fullmatch: the whole text"
},
"max_matches": {
"type": "integer",
"minimum": 1,
"maximum": 10000,
"default": 1000
},
"replacement": {
"type": "string",
"description": "re.sub template (\\1, \\g<name>) for a substitution preview"
},
"count": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Maximum substitutions; 0 = all"
},
"split": {
"type": "boolean",
"default": false,
"description": "Also split the text on the pattern"
},
"maxsplit": {
"type": "integer",
"minimum": 0,
"default": 0
},
"timeout": {
"type": "number",
"minimum": 0.1,
"maximum": 10,
"default": 2,
"description": "Seconds before the run is stopped"
}
}
}
Example
Input
{
"pattern": "(?P<year>\\d{4})-(?P<month>\\d{2})",
"text": "from 2026-10 to 2027-01"
}
Output (stdout)
{
"match_count": 2,
"matches": [
{
"match": "2026-10",
"span": [
5,
12
],
"groups": [
"2026",
"10"
],
"named": {
"year": "2026",
"month": "10"
}
},
{
"match": "2027-01",
"span": [
16,
23
],
"groups": [
"2027",
"01"
],
"named": {
"year": "2027",
"month": "01"
}
}
]
}
Files
The command the install card registers is python3 regex_test.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
regex_test.py · sha256 f16b3622a8b02f8d99f9a6a85285506b7222283187ad5bf516da2ab19c3a5d3a
#!/usr/bin/env python3
"""Run a Python regular expression over a text: matches with groups, named groups and spans,
a substitution preview and a split, under a hard time limit.
Input (JSON on stdin):
pattern the regular expression (Python `re` syntax)
text the subject text
flags list of IGNORECASE/i, MULTILINE/m, DOTALL/s, VERBOSE/x, ASCII/a (optional)
anchor "search" (default: every match anywhere), "match" (at the start) or "fullmatch"
max_matches at most this many matches are listed (default 1000, at most 10000)
replacement a re.sub template (\\1, \\g<name>) for a substitution preview (optional)
count maximum substitutions (default 0 = all)
split true: also split the text on the pattern
maxsplit maximum splits (default 0 = all)
timeout seconds, 0.1 to 10 (default 2)
"""
import json
import re
import signal
import sys
FLAGS = {"IGNORECASE": re.IGNORECASE, "I": re.IGNORECASE, "MULTILINE": re.MULTILINE, "M": re.MULTILINE,
"DOTALL": re.DOTALL, "S": re.DOTALL, "VERBOSE": re.VERBOSE, "X": re.VERBOSE,
"ASCII": re.ASCII, "A": re.ASCII}
CANON = {re.IGNORECASE: "IGNORECASE", re.MULTILINE: "MULTILINE", re.DOTALL: "DOTALL", re.VERBOSE: "VERBOSE",
re.ASCII: "ASCII"}
MAX_PATTERN = 10000
MAX_TEXT = 200_000
KNOWN = {"pattern", "text", "flags", "anchor", "max_matches", "replacement", "count", "split", "maxsplit", "timeout"}
def fail(message):
print(json.dumps({"error": message}, ensure_ascii=False))
sys.exit(2)
class Timeout(Exception):
pass
def on_alarm(signum, frame):
raise Timeout()
def integer(request, key, default, low, high):
value = request.get(key, default)
if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high:
fail(f"{key} must be an integer from {low} to {high}")
return value
def describe(m, names):
item = {"match": m.group(0), "span": list(m.span()), "groups": list(m.groups()),
"group_spans": [list(m.span(i)) if m.group(i) is not None else None for i in range(1, (m.re.groups or 0) + 1)]}
if names:
item["named"] = m.groupdict()
return item
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("stdin must be a JSON object")
extra = sorted(set(request) - KNOWN)
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
pattern, text = request.get("pattern"), request.get("text")
if not isinstance(pattern, str):
fail("pattern must be a string")
if not isinstance(text, str):
fail("text must be a string")
if len(pattern) > MAX_PATTERN:
fail(f"pattern is longer than {MAX_PATTERN} characters")
if len(text) > MAX_TEXT:
fail(f"text is longer than {MAX_TEXT} characters")
flags = request.get("flags", [])
if not isinstance(flags, list) or not all(isinstance(f, str) for f in flags):
fail("flags must be a list of flag names")
value = 0
for f in flags:
key = f.upper().replace("RE.", "")
if key not in FLAGS:
fail(f"unknown flag {f!r}; use IGNORECASE, MULTILINE, DOTALL, VERBOSE or ASCII (or i, m, s, x, a)")
value |= FLAGS[key]
anchor = request.get("anchor", "search")
if anchor not in ("search", "match", "fullmatch"):
fail("anchor must be search, match or fullmatch")
max_matches = integer(request, "max_matches", 1000, 1, 10000)
count = integer(request, "count", 0, 0, 10_000_000)
maxsplit = integer(request, "maxsplit", 0, 0, 10_000_000)
replacement = request.get("replacement")
if replacement is not None and not isinstance(replacement, str):
fail("replacement must be a string")
do_split = request.get("split", False)
if not isinstance(do_split, bool):
fail("split must be true or false")
timeout = request.get("timeout", 2)
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.1 <= timeout <= 10:
fail("timeout must be a number of seconds from 0.1 to 10")
signal.signal(signal.SIGALRM, on_alarm)
signal.setitimer(signal.ITIMER_REAL, float(timeout))
stage = "compiling the pattern"
try:
try:
rx = re.compile(pattern, value)
except re.error as error:
where = f" at position {error.pos}" if error.pos is not None else ""
fail(f"invalid pattern{where}: {error.msg}")
names = sorted(rx.groupindex, key=rx.groupindex.get)
stage = "matching"
matches, truncated = [], False
if anchor == "search":
for m in rx.finditer(text):
if len(matches) == max_matches:
truncated = True
break
matches.append(describe(m, names))
else:
m = rx.match(text) if anchor == "match" else rx.fullmatch(text)
if m:
matches.append(describe(m, names))
result = {"pattern": pattern, "flags": sorted({CANON[v] for k, v in FLAGS.items() if value & v}),
"anchor": anchor, "groups": rx.groups, "group_names": names,
"match_count": len(matches), "matched": bool(matches), "matches": matches}
if truncated:
result["truncated"] = f"only the first {max_matches} matches are listed"
if replacement is not None:
stage = "substituting"
try:
new, n = rx.subn(replacement, text, count=count)
except (re.error, IndexError) as error:
fail(f"invalid replacement: {error}")
result["substitution"] = {"replacement": replacement, "result": new, "replacements": n}
if do_split:
stage = "splitting"
result["split"] = rx.split(text, maxsplit=maxsplit)
except Timeout:
fail(f"timed out after {timeout} s while {stage}: the pattern probably backtracks catastrophically "
f"(nested quantifiers such as (a+)+ or (a|a)*); make the inner repetition unambiguous or possessive")
except (OverflowError, RecursionError, MemoryError) as error:
fail(f"the pattern is too large to run: {type(error).__name__}")
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/regex_test/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/regex_test/1.0.0/regex_test.py