| doc_radar |
|
|---|
A regex engine for Python that matches in linear time, shipped as a single wheel with the engine inside it. No catastrophic backtracking, so no ReDoS.
pip install irregexYou install irregex and you import irgx - the distribution keeps the
project's name, the module is the short one you type all day.
That is the whole install. There is no compiler step, no Zig toolchain, and no separate binary to put on your PATH. The engine is written in Zig and ships as a shared library inside the package, and Python reaches it two ways depending on what pip found for your interpreter - see How it talks to the engine. Either way there is nothing to configure and nothing to install alongside it.
The re module backtracks. On most patterns that is fine, and on a few it is
not: (a+)+b against a string of forty as will hang your process. That is
not a bug in re; it is what a backtracking engine does. It has a name when an
attacker reaches it - regular expression denial of service, ReDoS - and if your
patterns come from a config file, an API request, or a user, you are one
unlucky pattern away from a stalled worker.
irregex runs a finite automaton instead. Match time is linear in the length of
the text and independent of how the pattern is shaped. In exchange, the default
grammar has no lookaround and no backreferences, because those are the features
that force backtracking. When you need them, ask for them explicitly with
pcre=True and you get a PCRE2 backend, with PCRE2's performance profile.
Here is the module-level surface, doing the thing it is for:
import irgx
for m in irgx.finditer(r"(\w+)@(\w+\.\w+)", "write me@example.com or you@other.org"):
print(m.span(), m.group(1), m.group(2))(6, 20) me example.com
(24, 37) you other.org
The module-level verbs are compile, search, match, fullmatch, finditer,
findall, split, sub, subn, is_match, and escape. compile returns a Pattern with the
same verbs as methods. A Match has group, groups, groupdict, start,
end, span, expand, __getitem__, and the .re and .string attributes.
pattern = irgx.compile(r"(?P<key>\w+)=(?P<value>\d+)")
pattern.is_match("a=1") # True, and the cheapest question
pattern.findall("a=1 bb=22") # [('a', '1'), ('bb', '22')]
pattern.sub(r"\g<value>:\g<key>", "a=1") # '1:a'
pattern.split("a=1, bb=22") # keeps the groups, like re.split
irgx.escape("1+1=2") # '1\\+1=2'sub and subn take a template string or a callable. In a template, \1 and
\g<name> refer to groups and \g<0> is the whole match. A callable receives
the Match.
A compiled Pattern's search, finditer, findall and is_match take
re's pos and endpos, which bound the search without slicing the subject:
pattern = irgx.compile("^b")
pattern.search("abc", 1) # None - `^` is still offset 0, not offset 1
irgx.compile("b$").search("abc", 0, 2) # matches (1, 2) - `endpos` IS the endThe asymmetry is re's and this follows it: pos moves where the search
starts, endpos moves where the text ends, so $ and \b see it. Both clamp
into the subject rather than raising, and both count in the subject's own units —
characters for str, bytes for bytes. They live on the compiled pattern only,
as in re, where the module-level functions take flags in that position.
irgx.sub(r"\d+", lambda m: str(int(m.group()) * 2), "a1 b20") # 'a2 b40'There is no bitmask. Every option is a keyword on compile, and on the
module-level verbs.
fixedtreats the pattern as a literal string, with no metacharacters.ignore_casefolds case, including outside ASCII.wordonly reports a match that stands alone as a word.smart_casefolds case only if the pattern has no uppercase in it.unicodeturns on Unicode classes, folding, and boundaries; it is on by default.pcreswitches to the PCRE2 grammar - lookaround and backreferences, and no linear-time guarantee.
We use Unicode 17 in both engines, independently of the interpreter. New Unicode 17 letters and digits can match here while re on Python 3.12-3.14 still treats them as unassigned; use unicode=False for ASCII semantics. str offsets count characters and bytes offsets count bytes.
Each flag composes with the others, and none of them touches the pattern text:
irgx.findall("a.c", "abc a.c", fixed=True) # ['a.c']
irgx.findall("cat", "cat cats concat", word=True) # ['cat']
irgx.findall("café", "CAFÉ", ignore_case=True) # ['CAFÉ']
irgx.findall(r"(?<=@)\w+", "me@example", pcre=True) # ['example']fixed, word and smart_case have no spelling in re at all. They are the
options a command-line searcher has had for decades, and they are frequently
what you actually meant.
A pattern compiled from str searches str and reports codepoint indices. A
pattern compiled from bytes searches bytes and reports byte offsets. Mixing
the two raises TypeError, the same way re refuses it.
This matters more than it sounds like it does. The engine works in UTF-8 bytes,
so a naive binding hands you byte offsets for a str you cannot slice with
them. Here the translation is done for you, and this holds for every match:
text = "naïve café"
for m in irgx.finditer(r"\w+", text):
assert text[m.start() : m.end()] == m.group()ASCII text takes a fast path where the two are identical, so you pay nothing for the guarantee when it costs nothing.
compile_set answers which of these patterns match somewhere in this text. A
tokenizer needs the other question — starting exactly here, what is the longest
thing, and which terminal was it — and re has no verb for it either:
lex = irgx.compile_munch(["if", r"[a-z]+", r"[0-9]+", r"\s+"])
scan = lex.over("if x")
token = scan.token(0)
token.length # 2
token.patterns # (0, 1) - the keyword AND the identifierThe pair is the design, not a shortcoming. if is both terminals and both reach
length 2; which one wins is your lexer's business — declaration order, usually — so
the engine names every terminal that tied and resolves nothing. Choosing one here
would make keyword recognition impossible to build on top.
over(text) is the loop's friend: it encodes the subject once and answers every
cursor against that, so a whole-file tokenization pays one encode rather than one
per token. Offsets are in the subject's own units throughout, as everywhere else
here — characters for str, bytes for bytes — so token.length is a number you
can slice with. lex.token(text, at) is the one-shot form.
It is one walk, not one per terminal, because every pattern is determinized
together. That is also why the flags belong to the slate rather than a terminal:
there is nowhere to put "terminal 3 folds case". ignore_case and dotall are
carried; multiline is refused, since it asks for the line-anchor reading and an
anchored scan cannot observe the difference either way.
A refusal is partial. A slate of a hundred and fifty terminals where one is
outside the linear grammar is a working lexer, so the rest are seated and
lex.declined reports a Refusal per terminal that was not, each with a Why.
Why separates a budget from a wall: STATES means a bigger build would take the
terminal, BUFFER_ANCHOR that none ever will — a scan already starts where you
pointed, which leaves \A redundant and \z unsatisfiable, so the fix is deleting
it rather than raising a bound. Only a slate that seated nothing raises.
shortest=True is the other reading of the same cursor, skipping the empty one so
a nullable terminal cannot answer zero everywhere, and allow= restricts a single
call to a subset without a second compile — which is how context-sensitive lexing
works.
re searches a string you already have. Most of the work in a real search tool
is deciding which strings to have at all, and the engine exposes that as six more
planes. Every one of them is imported lazily, so a program that only calls
search never pays to declare them.
import irgx
# Which files may a search read? A materialized set, narrowed by term, not a
# generator you filter afterwards.
with irgx.walk(".", globs=["*.py"], not_globs=["*_test.py"]) as files:
print(len(files), "eligible")
# Search a corpus rather than a buffer. `None` means no tier answered and you
# should fall back; an EMPTY cursor means the corpus really holds nothing.
with irgx.corpus(".") as corpus:
cursor = corpus.search("WalletService", before=1, after=1)
if cursor is not None:
with cursor:
for record in cursor.pull():
print(f"{record.path}:{record.line_number}: {record.line}")
# An FM-index over a text it does not keep: counting needs no enumeration,
# because the count IS the width of the row interval the pattern admits.
with irgx.build_codex(open("big.log").read()) as codex:
print(codex.count("ERROR"), codex.locate("ERROR"))
# The line grid, so the byte-offset-to-row conversion happens once, in one place.
text = "one\ntwo\nthree\n"
band = irgx.line_context(text, at=irgx.search("two", text).start(), before=2, after=2)
print(band.center, [row.number for row in band.rows])
# What a pattern promises before it runs, plus the Unicode tables behind it.
facts = irgx.literals(irgx.compile("hello world"))
if facts is not None: # None for a pcre=True pattern: nothing to promise
with facts:
print(facts.set(irgx.Place.PREFIX))
print(irgx.unicode_version(), irgx.fold_orbit(ord("k")))
# N literal strings in one pass, keeping which one hit - what an alternation
# throws away.
with irgx.compile_needles(["cat", "the", "zebra"]) as needles:
print(needles.which("the cat sat")) # (0, 1) - zebra is absentThe sixth plane is sieve, which narrows a corpus against a persisted artifact
so most files are never opened. It is the one plane that declines routinely: with
no artifact on disk there is nothing to narrow with, so irgx.sieve(path)
returns None and your next move is to walk the tree yourself.
Two rules hold across all of them, and both are the kind of thing a ctypes binding gets wrong invisibly:
- Every value is copied at the boundary. A tree record's path, a walk
entry's path and a sieve's candidate list all point into an arena the handle
owns. In Python a
strbuilt from borrowed memory looks exactly like any other, so nothing here hands one back: close the handle, drop it, let the GC run, and the values you already read still work. - A declinature is
None, never an exception.IRGX_STALEis the engine stepping aside with no fault set. The caller's next move is a fallback, not a traceback.
Handles are context managers, close idempotently, and refuse use afterward instead of following a dangling pointer.
Put a compiled pattern at module scope and use it from a thread pool. That is what people do, and it works here.
PATTERN = irgx.compile(r"(\w+)=(\d+)")
with ThreadPoolExecutor() as pool:
results = list(pool.map(PATTERN.findall, many_texts))Underneath, the engine's handle is not thread-safe; it owns the scratch space
its searches run in. The Pattern keeps the pattern text and the flags, which
are immutable, and hands each thread its own handle the first time that thread
uses it. Compiling is pure, so this costs one compile per thread and nothing
after that. When a thread ends, its handle is released with it.
irgx.error is the base, named to match re.error so except clauses
port unchanged. It carries the same three attributes re.error does - msg,
pattern, pos - so a caller compiling patterns out of a config file can say
which one broke and where.
An unbalanced class is refused at compile time, with pos naming the byte:
>>> irgx.compile("[abc")
Traceback (most recent call last):
...
irgx.error: could not compile pattern '[abc': BadPattern; invalid: bad argument, or a pattern this arm cannot compileA pattern can also be refused for a reason that has a remedy. Lookaround, a
backreference and an atomic group are outside the linear grammar but perfectly
well-formed, and the engine does not call those a failure at all - it declines
them, which is a different status code saying "not me, try the fallback". That
arrives as irgx.UnsupportedPattern, a subclass, with pos of None,
because a tier that stepped aside has nothing to point at.
try:
pattern = irgx.compile(r"(?<=\$)\d+")
except irgx.UnsupportedPattern:
pattern = irgx.compile(r"(?<=\$)\d+", pcre=True) # this always worksWhich of the two you get is the engine's ruling, not a guess made here: it asks
PCRE2 whether PCRE2 can express the pattern, and answers on the return value.
Because UnsupportedPattern is a subclass, except irgx.error still catches
both.
Running out of memory raises MemoryError, because that is the exception a
Python caller already handles for it.
Most patterns behave identically, and the test suite checks a set of them
against re on every run. These are the places they part ways, all of them on
purpose.
-
fullmatchrefuses instead of guessing when groups would be wrong. Bothmatchandfullmatchare here, and they are not faked on top of an unanchored search -matchis a leftmost search plus a start comparison, which is exact because this engine is leftmost-first exactly asreis, andfullmatchasks the anchored-longest automaton, soa|abfull-matches"ab"here the same way it does underre's backtracking. What that automaton cannot do is report capture groups, and for the handful of patterns whose full match takes a path their leftmost match does not -(a)|(ab)over"ab"- the groups would belong to the wrong match, so it raises and says so rather than answering. Anchoring in the pattern still works and needs none of this:\Aand\zare in the grammar. The canonical end anchor is\z, as in Rust and RE2;\Zis accepted as that same absolute end, so arepattern carrying it ports unchanged. PCRE reads\Zas the end or just before a trailing newline, and that is deliberately not the reading you get here. -
finditeris eager. The engine reports the whole match sequence in one call rather than handing back a cursor a Python loop advances, so the iterator knows its ownlen()before you walk it. The sequence itself isre's — every empty match at every position, including the one at the end of the text and the one abutting a previous match — because deriving it in Python is exactly where a binding gets nullable patterns wrong:[m.span() for m in irgx.finditer("a*", "abc")] # [(0, 1), (1, 1), (2, 2), (3, 3)]
-
findallreportsNonefor a group that did not participate, wherere.findallreports"". A group that did not match and a group that matched the empty string are different facts, and.groups()already tells them apart in both libraries.irgx.findall(r"(a)|(b)", "ab") # [('a', None), (None, 'b')] re.findall(r"(a)|(b)", "ab") # [('a', ''), ('', 'b')]
-
There is an
is_match. It asks whether the text holds a match at all and lets the engine stop at the first one without building a span.rehas no equivalent, so it is named after what it does. -
$is the end of the text, and not the byte before a trailing newline. This one is not an improvement, it is a gap, and it is the only place the two libraries disagree about whether a pattern matches at all.reinherits Perl's rule where$also matches just before a final\n; Rust'sregexand Go'sregexpdo not, and neither does this engine. So a pattern ending in$against text that ends in\nfinds nothing here:[m.span() for m in irgx.finditer(r"[a-z]+$", "cat\ndog\n")] # [] [m.span() for m in re.finditer(r"[a-z]+$", "cat\ndog\n")] # [(4, 7)]
Three spellings do agree, and one of them is probably what you meant:
(?m)[a-z]+$(every line's end, which is where a trailing newline stops being special),[a-z]+\z(the text's end, said exactly), or stripping the newline first.pcre=Truealso has Perl's rule, since it is Perl's.
A few module attributes describe what is actually loaded, which matters the
moment IRGX_LIB enters the picture:
irgx.__version__ # this package
irgx.ENGINE_VERSION # the Zig engine bundled in this wheel
irgx.PCRE2_VERSION # the PCRE2 the pcre=True arm runs on
irgx.LIBRARY # the resolved path of the loaded shared librarySet IRGX_LIB to the path of a shared library to load that one instead of
the bundled copy. It names a file, not a directory, and a path that is not
there fails loudly at import rather than silently falling back.
Nothing in this section changes an answer. It is here because on a short string the interesting number is not how fast the engine matches but how much it costs to ask it, and that is worth being honest about.
The floor is small: irgx_is_match_in runs in about 13 ns and find_all in
about 66 ns on a short text. Calling either through ctypes costs 300-600 ns on
top, because ctypes converts every argument, allocates a buffer object per call,
and builds the result out of Python objects one at a time. On a megabyte that is
invisible. On a 17-byte string it is most of the wall clock.
So the sixteen verbs that get asked once per text - search, finditer,
is_match, the group spans behind Match, the set / needle / munch scans, and
the whole-answer verbs behind findall, sub and split, which walk the
matches, run the capture pass and build the finished answer in a single
crossing - run through a small C extension when one is installed:
from irgx import _engine
_engine.native() # the verbs answering natively; () means all ctypesIt takes your str directly, reads the UTF-8 CPython already keeps for it
rather than encoding a copy, keeps small span buffers on the C stack, and builds
the tuples itself. Same engine, same answers, 5-8x less overhead on a short
text: reading three groups out of a match goes from ~1.4 us to ~180 ns, a
one-match finditer from ~1.1 us to ~190 ns. The margin narrows as the text
grows, which is the point - the fix is to overhead, and overhead is most of what
a short string costs. The ninety-odd other symbols - opening a handle, compiling
a slate, describing the build - stay on ctypes, because a verb you call once per
program is not worth C.
You do not need it, and you cannot end up without a working package. Every
one of those verbs has its ctypes implementation sitting beside it, the routing
is per verb, and the fallback is what this package shipped before. Set
IRGX_NO_ACCEL=1 to decline it for a process. If you are developing against a
checkout and want the fast path, python3 scripts/build_accel.py builds it in
place; --clean removes it.
Wheels are built for macOS on arm64 and x86_64, Linux on x86_64 and aarch64 (manylinux, glibc 2.17 and newer), and Windows on x86_64 and arm64 (Windows 10 RS4 and newer). Python 3.12 and newer. Every platform gets the same engine and the same answers, and the suite runs on each of them rather than on one and by assumption on the rest. The wheels are platform-tagged, because they contain a native library; a wheel for the wrong platform will not install rather than failing at import.
Most platforms get two wheels, and pip picks between them for you. The
py3-none-<platform> one is the ctypes build and runs on any interpreter that
can load the library at all. The cp312-abi3-<platform> one additionally
carries the accelerator above; it is built only where the release machine is
the target, since a C extension needs its target's own Python headers. pip
prefers it wherever it fits and falls back to the portable wheel everywhere
else - a free-threaded build, PyPy, a platform no release box runs. One abi3
binary covers 3.12 and every version after it.
This is the engine. If what you actually want is a tool, three are built on it and each ships its own package.
gist-searchanswers where an exact pattern is.relate-searchanswers what resembles this, and what repeats.blast-searchanswers what breaks if this symbol changes.
Apache-2.0. The bundled library includes PCRE2 and other third-party components; see NOTICE.