jev-mcp-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jev_mcp/__init__.py +1 -0
- jev_mcp/__main__.py +3 -0
- jev_mcp/domain/__init__.py +32 -0
- jev_mcp/domain/answers.py +25 -0
- jev_mcp/domain/json.py +49 -0
- jev_mcp/domain/questions.py +75 -0
- jev_mcp/domain/usage.py +16 -0
- jev_mcp/errors.py +59 -0
- jev_mcp/extract/__init__.py +1 -0
- jev_mcp/extract/candidates.py +75 -0
- jev_mcp/extract/dialect.py +400 -0
- jev_mcp/extract/executor.py +118 -0
- jev_mcp/extract/worker.py +198 -0
- jev_mcp/ids.py +49 -0
- jev_mcp/limits.py +218 -0
- jev_mcp/policy/__init__.py +98 -0
- jev_mcp/policy/actions.py +41 -0
- jev_mcp/policy/claims.py +103 -0
- jev_mcp/policy/extract.py +73 -0
- jev_mcp/policy/ranking.py +41 -0
- jev_mcp/policy/review.py +73 -0
- jev_mcp/policy/screen.py +48 -0
- jev_mcp/policy/thresholds.py +74 -0
- jev_mcp/providers/__init__.py +26 -0
- jev_mcp/providers/base.py +236 -0
- jev_mcp/providers/cloudflare.py +59 -0
- jev_mcp/providers/compatible.py +43 -0
- jev_mcp/providers/openrouter.py +47 -0
- jev_mcp/providers/resolver.py +106 -0
- jev_mcp/providers/typesafe.py +127 -0
- jev_mcp/py.typed +0 -0
- jev_mcp/serialize.py +199 -0
- jev_mcp/server.py +176 -0
- jev_mcp/settings.py +73 -0
- jev_mcp/stdio.py +99 -0
- jev_mcp/telemetry.py +223 -0
- jev_mcp/text.py +42 -0
- jev_mcp/tools/__init__.py +20 -0
- jev_mcp/tools/arguments.py +447 -0
- jev_mcp/tools/base.py +153 -0
- jev_mcp/tools/classify.py +187 -0
- jev_mcp/tools/common.py +96 -0
- jev_mcp/tools/compare.py +143 -0
- jev_mcp/tools/decide.py +206 -0
- jev_mcp/tools/extract.py +262 -0
- jev_mcp/tools/find.py +113 -0
- jev_mcp/tools/gate.py +236 -0
- jev_mcp/tools/observed.py +69 -0
- jev_mcp/tools/rerank.py +139 -0
- jev_mcp/tools/review.py +236 -0
- jev_mcp/tools/screen.py +126 -0
- jev_mcp/tools/toolset.py +92 -0
- jev_mcp/tools/verify.py +141 -0
- jev_mcp/validation/__init__.py +25 -0
- jev_mcp/validation/caps.py +93 -0
- jev_mcp/validation/choice.py +65 -0
- jev_mcp/validation/extract.py +48 -0
- jev_mcp/validation/noul.py +15 -0
- jev_mcp/validation/numbers.py +21 -0
- jev_mcp/validation/score.py +20 -0
- jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
- jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
- jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
- jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
- jev_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""The jev_extract regex dialect: an ECMAScript subset, translated to Python and matched in UTF-16 units (ADR-0004).
|
|
2
|
+
|
|
3
|
+
Both the pattern and the document are read as UTF-16 code units, one Python character per unit
|
|
4
|
+
(`to_units`), so `.`, quantifiers, and classes see what V8 sees without the `u` flag. The translation
|
|
5
|
+
spells every construct out explicitly, so no Python default leaks in:
|
|
6
|
+
|
|
7
|
+
- `.` is one unit other than U+000A, U+000D, U+2028, U+2029. `^` and `$` are the ends of the input.
|
|
8
|
+
- `\\d`, `\\w`, `\\b` are ASCII. `\\s` is ECMAScript WhiteSpace plus LineTerminator.
|
|
9
|
+
- Literals are escaped as `\\uXXXX`, so a JS literal `{` or `]` can never become Python syntax.
|
|
10
|
+
- `i` is ASCII-only case folding, and is refused when a literal or class endpoint is not ASCII.
|
|
11
|
+
|
|
12
|
+
Anything else is refused with a named reason rather than guessed at: the flags `d m s u v y`, named
|
|
13
|
+
groups, backreferences, legacy octal escapes, property escapes, `\\u{...}`, lookbehind that is not
|
|
14
|
+
fixed-length, quantified assertions, a variable repeat of a group that can match empty (V8 and `re`
|
|
15
|
+
treat its empty iterations differently), and alphanumeric identity escapes. Flags V8 itself rejects keep
|
|
16
|
+
V8's message, so that text matches the reference.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from array import array
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import NoReturn
|
|
24
|
+
|
|
25
|
+
V8_FLAGS = "dgimsuvy"
|
|
26
|
+
SUBSET_REFUSED_FLAGS = "dmsuvy"
|
|
27
|
+
MAX_QUANTIFIER_BOUND = 2**31 - 1
|
|
28
|
+
|
|
29
|
+
_UNIT_CODEC = "utf-16-le" if sys.byteorder == "little" else "utf-16-be"
|
|
30
|
+
_WORD = "A-Za-z0-9_"
|
|
31
|
+
_WHITESPACE_UNITS = (
|
|
32
|
+
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680,
|
|
33
|
+
*range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF,
|
|
34
|
+
) # fmt: skip
|
|
35
|
+
_LINE_TERMINATORS = (0x0A, 0x0D, 0x2028, 0x2029)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PatternRejected(Exception):
|
|
39
|
+
"""The pattern or flags are invalid, or outside the subset. `str()` is the `invalid_pattern` reason."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Translated:
|
|
44
|
+
"""A Python `re` pattern over unit-space text, and its compile flags."""
|
|
45
|
+
|
|
46
|
+
source: str
|
|
47
|
+
flags: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_units(text: str) -> str:
|
|
51
|
+
"""One character per UTF-16 code unit, each with the unit's value. Lone surrogates are units too."""
|
|
52
|
+
units = array("H")
|
|
53
|
+
units.frombytes(text.encode(_UNIT_CODEC, "surrogatepass"))
|
|
54
|
+
return "".join(map(chr, units))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def from_units(units: str) -> str:
|
|
58
|
+
"""The text a unit-space string stands for: pairs rejoin, a lone surrogate stays one code point."""
|
|
59
|
+
return units.encode(_UNIT_CODEC, "surrogatepass").decode(_UNIT_CODEC, "surrogatepass")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def translate(pattern: str, flags: str) -> Translated:
|
|
63
|
+
"""The Python translation of JS `new RegExp(pattern, flags)`, or `PatternRejected`.
|
|
64
|
+
|
|
65
|
+
`flags` is already normalized as the reference does: letters only, with one `g`.
|
|
66
|
+
"""
|
|
67
|
+
if any(flag not in V8_FLAGS for flag in flags) or len(set(flags)) != len(flags) or {"u", "v"} <= set(flags):
|
|
68
|
+
raise PatternRejected(f"Invalid flags supplied to RegExp constructor '{flags}'")
|
|
69
|
+
for flag in flags:
|
|
70
|
+
if flag in SUBSET_REFUSED_FLAGS:
|
|
71
|
+
_refuse(f"the '{flag}' flag")
|
|
72
|
+
parser = _Parser(to_units(pattern))
|
|
73
|
+
try:
|
|
74
|
+
body = parser.parse()
|
|
75
|
+
except RecursionError:
|
|
76
|
+
_refuse("group nesting this deep")
|
|
77
|
+
ignore_case = "i" in flags
|
|
78
|
+
if ignore_case and parser.non_ascii:
|
|
79
|
+
_refuse("the 'i' flag with a non-ASCII character in the pattern")
|
|
80
|
+
python_flags = re.ASCII | (re.IGNORECASE if ignore_case else 0)
|
|
81
|
+
try:
|
|
82
|
+
re.compile(body.text, python_flags)
|
|
83
|
+
except (re.error, RecursionError, OverflowError) as error:
|
|
84
|
+
_refuse(f"the pattern cannot be matched here ({error})")
|
|
85
|
+
return Translated(body.text, python_flags)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _refuse(what: str) -> NoReturn:
|
|
89
|
+
raise PatternRejected(f"unsupported regular expression: {what} is outside the supported subset")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _invalid(what: str) -> NoReturn:
|
|
93
|
+
raise PatternRejected(f"invalid regular expression: {what}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class _Piece:
|
|
98
|
+
text: str
|
|
99
|
+
min: int
|
|
100
|
+
max: int | None
|
|
101
|
+
"""Match length bounds in units; `None` is unbounded."""
|
|
102
|
+
assertion: bool = False
|
|
103
|
+
lookaround: bool = False
|
|
104
|
+
"""Contains a lookaround somewhere inside."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _unit(value: int) -> str:
|
|
108
|
+
"""One unit as Python regex syntax: ASCII letters and digits raw, everything else escaped."""
|
|
109
|
+
char = chr(value)
|
|
110
|
+
return char if char.isascii() and char.isalnum() else f"\\u{value:04x}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ranges(units: tuple[int, ...] | list[int]) -> str:
|
|
114
|
+
return "".join(_unit(u) for u in units)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _complement(members: set[int]) -> str:
|
|
118
|
+
"""Class content matching every unit (0-FFFF) outside `members`, as ranges."""
|
|
119
|
+
out: list[str] = []
|
|
120
|
+
start = 0
|
|
121
|
+
for member in [*sorted(members), 0x10000]:
|
|
122
|
+
if member > start:
|
|
123
|
+
out.append(_unit(start) if member - 1 == start else f"{_unit(start)}-{_unit(member - 1)}")
|
|
124
|
+
start = member + 1
|
|
125
|
+
return "".join(out)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_DIGITS = set(range(0x30, 0x3A))
|
|
129
|
+
_WORDS = _DIGITS | set(range(0x41, 0x5B)) | set(range(0x61, 0x7B)) | {0x5F}
|
|
130
|
+
_CLASS_ESCAPES = {
|
|
131
|
+
"d": "0-9",
|
|
132
|
+
"w": _WORD,
|
|
133
|
+
"s": _ranges(_WHITESPACE_UNITS),
|
|
134
|
+
"D": _complement(_DIGITS),
|
|
135
|
+
"W": _complement(_WORDS),
|
|
136
|
+
"S": _complement(set(_WHITESPACE_UNITS)),
|
|
137
|
+
}
|
|
138
|
+
"""Class escapes as class content, so they can stand inside a bracketed class too."""
|
|
139
|
+
_DOT = f"[^{_ranges(_LINE_TERMINATORS)}]"
|
|
140
|
+
_NOTHING = "[^\\u0000-\\uffff]"
|
|
141
|
+
_ANYTHING = "[\\u0000-\\uffff]"
|
|
142
|
+
_BOUNDARY = f"(?:(?<=[{_WORD}])(?![{_WORD}])|(?<![{_WORD}])(?=[{_WORD}]))"
|
|
143
|
+
_NOT_BOUNDARY = f"(?:(?<=[{_WORD}])(?=[{_WORD}])|(?<![{_WORD}])(?![{_WORD}]))"
|
|
144
|
+
_QUANTIFIER = re.compile(r"\{([0-9]+)(,([0-9]*))?\}")
|
|
145
|
+
_ASCII_DIGITS = "0123456789"
|
|
146
|
+
_CONTROL_ESCAPES = {"f": 0x0C, "n": 0x0A, "r": 0x0D, "t": 0x09, "v": 0x0B}
|
|
147
|
+
_HEX = set("0123456789abcdefABCDEF")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _Parser:
|
|
151
|
+
"""Recursive descent over the ECMAScript `Pattern` grammar without `u`, restricted to the subset."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, units: str) -> None:
|
|
154
|
+
self.units = units
|
|
155
|
+
self.at = 0
|
|
156
|
+
self.non_ascii = False
|
|
157
|
+
|
|
158
|
+
def parse(self) -> _Piece:
|
|
159
|
+
body = self._disjunction()
|
|
160
|
+
if self.at < len(self.units):
|
|
161
|
+
_invalid("unmatched ')'") # the only way a top-level disjunction stops early
|
|
162
|
+
return body
|
|
163
|
+
|
|
164
|
+
def _peek(self, offset: int = 0) -> str | None:
|
|
165
|
+
index = self.at + offset
|
|
166
|
+
return self.units[index] if index < len(self.units) else None
|
|
167
|
+
|
|
168
|
+
def _take(self) -> str:
|
|
169
|
+
char = self.units[self.at]
|
|
170
|
+
self.at += 1
|
|
171
|
+
return char
|
|
172
|
+
|
|
173
|
+
def _disjunction(self) -> _Piece:
|
|
174
|
+
alternatives = [self._alternative()]
|
|
175
|
+
while self._peek() == "|":
|
|
176
|
+
self.at += 1
|
|
177
|
+
alternatives.append(self._alternative())
|
|
178
|
+
if len(alternatives) == 1:
|
|
179
|
+
return alternatives[0]
|
|
180
|
+
maxima = [a.max for a in alternatives]
|
|
181
|
+
return _Piece(
|
|
182
|
+
"|".join(a.text for a in alternatives),
|
|
183
|
+
min(a.min for a in alternatives),
|
|
184
|
+
None if None in maxima else max(m for m in maxima if m is not None),
|
|
185
|
+
lookaround=any(a.lookaround for a in alternatives),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _alternative(self) -> _Piece:
|
|
189
|
+
terms: list[_Piece] = []
|
|
190
|
+
while (char := self._peek()) is not None and char not in "|)":
|
|
191
|
+
terms.append(self._term())
|
|
192
|
+
maxima = [t.max for t in terms]
|
|
193
|
+
return _Piece(
|
|
194
|
+
"".join(t.text for t in terms),
|
|
195
|
+
sum(t.min for t in terms),
|
|
196
|
+
None if None in maxima else sum(m for m in maxima if m is not None),
|
|
197
|
+
lookaround=any(t.lookaround for t in terms),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _term(self) -> _Piece:
|
|
201
|
+
atom = self._atom()
|
|
202
|
+
quantifier = self._quantifier()
|
|
203
|
+
if quantifier is None:
|
|
204
|
+
return atom
|
|
205
|
+
low, high, suffix = quantifier
|
|
206
|
+
if atom.assertion and atom.lookaround:
|
|
207
|
+
_refuse("a quantified assertion")
|
|
208
|
+
if atom.assertion:
|
|
209
|
+
_invalid("nothing to repeat")
|
|
210
|
+
if atom.min == 0 and (high is None or high > low):
|
|
211
|
+
# V8 fails an optional iteration that matches empty and backtracks into the body for a
|
|
212
|
+
# longer one; Python's `re` accepts the empty iteration. The two disagree on the match.
|
|
213
|
+
_refuse("a repeated group that can match empty")
|
|
214
|
+
return _Piece(
|
|
215
|
+
f"{atom.text}{suffix}",
|
|
216
|
+
atom.min * low,
|
|
217
|
+
None if high is None or atom.max is None else atom.max * high,
|
|
218
|
+
lookaround=atom.lookaround,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _quantifier(self) -> tuple[int, int | None, str] | None:
|
|
222
|
+
char = self._peek()
|
|
223
|
+
if char in ("*", "+", "?"):
|
|
224
|
+
self.at += 1
|
|
225
|
+
low, high = {"*": (0, None), "+": (1, None), "?": (0, 1)}[char]
|
|
226
|
+
text = char
|
|
227
|
+
elif char == "{" and (braced := _QUANTIFIER.match(self.units, self.at)):
|
|
228
|
+
self.at = braced.end()
|
|
229
|
+
low = int(braced.group(1))
|
|
230
|
+
high = low if braced.group(2) is None else (int(braced.group(3)) if braced.group(3) else None)
|
|
231
|
+
if max(low, high or 0) > MAX_QUANTIFIER_BOUND:
|
|
232
|
+
_refuse("a quantifier bound above 2147483647")
|
|
233
|
+
if high is not None and high < low:
|
|
234
|
+
_invalid("numbers out of order in {} quantifier")
|
|
235
|
+
text = f"{{{low}}}" if braced.group(2) is None else f"{{{low},{'' if high is None else high}}}"
|
|
236
|
+
else:
|
|
237
|
+
return None
|
|
238
|
+
if self._peek() == "?":
|
|
239
|
+
self.at += 1
|
|
240
|
+
text += "?"
|
|
241
|
+
return low, high, text
|
|
242
|
+
|
|
243
|
+
def _atom(self) -> _Piece:
|
|
244
|
+
char = self._take()
|
|
245
|
+
match char:
|
|
246
|
+
case "^":
|
|
247
|
+
return _Piece("\\A", 0, 0, assertion=True)
|
|
248
|
+
case "$":
|
|
249
|
+
return _Piece("\\Z", 0, 0, assertion=True)
|
|
250
|
+
case ".":
|
|
251
|
+
return _Piece(_DOT, 1, 1)
|
|
252
|
+
case "(":
|
|
253
|
+
return self._group()
|
|
254
|
+
case "[":
|
|
255
|
+
return self._class()
|
|
256
|
+
case "\\":
|
|
257
|
+
return self._atom_escape()
|
|
258
|
+
case "*" | "+" | "?":
|
|
259
|
+
_invalid("nothing to repeat")
|
|
260
|
+
case "{" if _QUANTIFIER.match(self.units, self.at - 1):
|
|
261
|
+
_invalid("nothing to repeat")
|
|
262
|
+
case _:
|
|
263
|
+
return self._literal(ord(char))
|
|
264
|
+
|
|
265
|
+
def _literal(self, value: int) -> _Piece:
|
|
266
|
+
if value > 0x7F:
|
|
267
|
+
self.non_ascii = True
|
|
268
|
+
return _Piece(_unit(value), 1, 1)
|
|
269
|
+
|
|
270
|
+
def _group(self) -> _Piece:
|
|
271
|
+
if self._peek() != "?":
|
|
272
|
+
body = self._disjunction()
|
|
273
|
+
self._close_group()
|
|
274
|
+
# Captures are never read (only the whole match is), so every group is non-capturing.
|
|
275
|
+
return _Piece(f"(?:{body.text})", body.min, body.max, lookaround=body.lookaround)
|
|
276
|
+
self.at += 1
|
|
277
|
+
kind = self._peek()
|
|
278
|
+
if kind == ":":
|
|
279
|
+
self.at += 1
|
|
280
|
+
body = self._disjunction()
|
|
281
|
+
self._close_group()
|
|
282
|
+
return _Piece(f"(?:{body.text})", body.min, body.max, lookaround=body.lookaround)
|
|
283
|
+
if kind in ("=", "!"):
|
|
284
|
+
self.at += 1
|
|
285
|
+
body = self._disjunction()
|
|
286
|
+
self._close_group()
|
|
287
|
+
return _Piece(f"(?{kind}{body.text})", 0, 0, assertion=True, lookaround=True)
|
|
288
|
+
if kind == "<" and self._peek(1) in ("=", "!"):
|
|
289
|
+
sign = self._peek(1)
|
|
290
|
+
self.at += 2
|
|
291
|
+
body = self._disjunction()
|
|
292
|
+
self._close_group()
|
|
293
|
+
if body.lookaround:
|
|
294
|
+
_refuse("a lookaround inside a lookbehind")
|
|
295
|
+
if body.max != body.min:
|
|
296
|
+
_refuse("variable-length lookbehind")
|
|
297
|
+
return _Piece(f"(?<{sign}{body.text})", 0, 0, assertion=True, lookaround=True)
|
|
298
|
+
if kind == "<":
|
|
299
|
+
_refuse("a named group")
|
|
300
|
+
_invalid("invalid group")
|
|
301
|
+
|
|
302
|
+
def _close_group(self) -> None:
|
|
303
|
+
if self._peek() != ")":
|
|
304
|
+
_invalid("unterminated group")
|
|
305
|
+
self.at += 1
|
|
306
|
+
|
|
307
|
+
def _atom_escape(self) -> _Piece:
|
|
308
|
+
char = self._peek()
|
|
309
|
+
if char is None:
|
|
310
|
+
_invalid("\\ at end of pattern")
|
|
311
|
+
if char == "b":
|
|
312
|
+
self.at += 1
|
|
313
|
+
return _Piece(_BOUNDARY, 0, 0, assertion=True)
|
|
314
|
+
if char == "B":
|
|
315
|
+
self.at += 1
|
|
316
|
+
return _Piece(_NOT_BOUNDARY, 0, 0, assertion=True)
|
|
317
|
+
if char in _CLASS_ESCAPES:
|
|
318
|
+
self.at += 1
|
|
319
|
+
return _Piece(f"[{_CLASS_ESCAPES[char]}]", 1, 1)
|
|
320
|
+
return self._literal(self._character_escape())
|
|
321
|
+
|
|
322
|
+
def _character_escape(self) -> int:
|
|
323
|
+
"""The unit a `\\` escape stands for (the `\\` already consumed), shared by atoms and classes."""
|
|
324
|
+
char = self._take()
|
|
325
|
+
if char in _CONTROL_ESCAPES:
|
|
326
|
+
return _CONTROL_ESCAPES[char]
|
|
327
|
+
if char == "c":
|
|
328
|
+
letter = self._peek()
|
|
329
|
+
if letter is None or not (letter.isascii() and letter.isalpha()):
|
|
330
|
+
_refuse("'\\c' without a control letter")
|
|
331
|
+
self.at += 1
|
|
332
|
+
return ord(letter) % 32
|
|
333
|
+
following = self._peek()
|
|
334
|
+
if char == "0" and (following is None or following not in _ASCII_DIGITS):
|
|
335
|
+
return 0
|
|
336
|
+
if char in _ASCII_DIGITS:
|
|
337
|
+
_refuse("a backreference or legacy octal escape")
|
|
338
|
+
if char in ("x", "u"):
|
|
339
|
+
width = 2 if char == "x" else 4
|
|
340
|
+
digits = self.units[self.at : self.at + width]
|
|
341
|
+
if len(digits) == width and set(digits) <= _HEX:
|
|
342
|
+
self.at += width
|
|
343
|
+
return int(digits, 16)
|
|
344
|
+
_refuse("'\\u{...}'" if char == "u" and self._peek() == "{" else f"an incomplete '\\{char}' escape")
|
|
345
|
+
if char in ("k", "p", "P"):
|
|
346
|
+
_refuse("a named backreference" if char == "k" else "a Unicode property escape")
|
|
347
|
+
if char.isascii() and char.isalnum():
|
|
348
|
+
_refuse(f"the escape '\\{char}'")
|
|
349
|
+
return ord(char)
|
|
350
|
+
|
|
351
|
+
def _class(self) -> _Piece:
|
|
352
|
+
negate = self._peek() == "^"
|
|
353
|
+
if negate:
|
|
354
|
+
self.at += 1
|
|
355
|
+
members: list[str] = []
|
|
356
|
+
while True:
|
|
357
|
+
char = self._peek()
|
|
358
|
+
if char is None:
|
|
359
|
+
_invalid("unterminated character class")
|
|
360
|
+
if char == "]":
|
|
361
|
+
self.at += 1
|
|
362
|
+
break
|
|
363
|
+
start = self._class_atom()
|
|
364
|
+
if self._peek() == "-" and self._peek(1) not in (None, "]"):
|
|
365
|
+
self.at += 1
|
|
366
|
+
end = self._class_atom()
|
|
367
|
+
if isinstance(start, str) or isinstance(end, str):
|
|
368
|
+
_refuse("a class range with a class escape endpoint")
|
|
369
|
+
if start > end:
|
|
370
|
+
_invalid("range out of order in character class")
|
|
371
|
+
members.append(f"{self._endpoint(start)}-{self._endpoint(end)}")
|
|
372
|
+
else:
|
|
373
|
+
members.append(start if isinstance(start, str) else self._endpoint(start))
|
|
374
|
+
if not members:
|
|
375
|
+
return _Piece(_ANYTHING if negate else _NOTHING, 1, 1)
|
|
376
|
+
return _Piece(f"[{'^' if negate else ''}{''.join(members)}]", 1, 1)
|
|
377
|
+
|
|
378
|
+
def _endpoint(self, value: int) -> str:
|
|
379
|
+
if value > 0x7F:
|
|
380
|
+
self.non_ascii = True
|
|
381
|
+
return _unit(value)
|
|
382
|
+
|
|
383
|
+
def _class_atom(self) -> int | str:
|
|
384
|
+
"""A unit, or a class escape's content (`str`)."""
|
|
385
|
+
char = self._take()
|
|
386
|
+
if char != "\\":
|
|
387
|
+
return ord(char)
|
|
388
|
+
escaped = self._peek()
|
|
389
|
+
if escaped is None:
|
|
390
|
+
_invalid("\\ at end of pattern")
|
|
391
|
+
if escaped == "b":
|
|
392
|
+
self.at += 1
|
|
393
|
+
return 0x08
|
|
394
|
+
if escaped == "-":
|
|
395
|
+
self.at += 1
|
|
396
|
+
return ord("-")
|
|
397
|
+
if escaped in _CLASS_ESCAPES:
|
|
398
|
+
self.at += 1
|
|
399
|
+
return _CLASS_ESCAPES[escaped]
|
|
400
|
+
return self._character_escape()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The regex execution port (ADR-0016): an executor compiles and matches a translated pattern.
|
|
2
|
+
|
|
3
|
+
`RegexExecutor.find` runs one translated pattern over unit-space text under an absolute monotonic
|
|
4
|
+
deadline and answers with a `MatchResult`: `Matches`, or one of three plain refusals — `Timeout`
|
|
5
|
+
(the deadline passed), `Saturated` (admission refused at the queue bound, ADR-0025), `Invalid` (no
|
|
6
|
+
result: the pattern did not compile or the executor could not produce one). Results carry no
|
|
7
|
+
caller-facing text; `extract/candidates.py` owns what each one tells the caller.
|
|
8
|
+
|
|
9
|
+
Two adapters: `worker.ProcessRegexExecutor`, killable worker processes for production, and
|
|
10
|
+
`InProcessRegexExecutor` here, which cannot stop a runaway match and exists for trusted corpora
|
|
11
|
+
such as the Node differential test.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from time import monotonic
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from jev_mcp.extract.dialect import Translated
|
|
21
|
+
from jev_mcp.limits import EXTRACT, ExtractCaps
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Matches:
|
|
26
|
+
"""The kept matches, in unit space (`dialect.from_units` maps them back)."""
|
|
27
|
+
|
|
28
|
+
candidates: list[str]
|
|
29
|
+
truncated: bool
|
|
30
|
+
too_long: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Timeout:
|
|
35
|
+
"""The deadline passed before a result; a process executor killed the run."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Saturated:
|
|
40
|
+
"""Admission refused at the queue bound: no time was spent, nothing ran."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Unavailable(Enum):
|
|
44
|
+
NO_RESULT = "no_result"
|
|
45
|
+
"""The run ended without a result: the pattern did not compile, or its worker exited."""
|
|
46
|
+
NOT_STARTED = "not_started"
|
|
47
|
+
"""The executor could not start somewhere to run the pattern."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Invalid:
|
|
52
|
+
cause: Unavailable
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
type MatchResult = Matches | Timeout | Saturated | Invalid
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RegexExecutor(Protocol):
|
|
59
|
+
async def find(self, pattern: Translated, text: str, *, deadline: float) -> MatchResult:
|
|
60
|
+
"""Match `pattern` over unit-space `text`, finishing by monotonic `deadline`."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def match_all(pattern: Translated, units: str, max_candidates: int, max_units: int) -> Matches:
|
|
67
|
+
"""`document.matchAll` with the reference's frozen pipeline (`index.ts:894-903`).
|
|
68
|
+
|
|
69
|
+
Drop zero-length matches, dedup by value (first wins), skip and count matches over `max_units`,
|
|
70
|
+
then stop at `max_candidates` kept, flagging truncation. After an empty match the scan moves one
|
|
71
|
+
unit on, as `AdvanceStringIndex` does without `u`; `finditer` would retry the same position.
|
|
72
|
+
Raises `re.error` if the pattern does not compile.
|
|
73
|
+
"""
|
|
74
|
+
compiled = re.compile(pattern.source, pattern.flags)
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
candidates: list[str] = []
|
|
77
|
+
truncated = False
|
|
78
|
+
too_long = 0
|
|
79
|
+
position = 0
|
|
80
|
+
while position <= len(units):
|
|
81
|
+
match = compiled.search(units, position)
|
|
82
|
+
if match is None:
|
|
83
|
+
break
|
|
84
|
+
start, end = match.span()
|
|
85
|
+
position = end if end > start else end + 1
|
|
86
|
+
value = units[start:end]
|
|
87
|
+
if not value or value in seen:
|
|
88
|
+
continue
|
|
89
|
+
seen.add(value)
|
|
90
|
+
if len(value) > max_units:
|
|
91
|
+
too_long += 1
|
|
92
|
+
continue
|
|
93
|
+
if len(candidates) >= max_candidates:
|
|
94
|
+
truncated = True
|
|
95
|
+
break
|
|
96
|
+
candidates.append(value)
|
|
97
|
+
return Matches(candidates, truncated, too_long)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InProcessRegexExecutor:
|
|
101
|
+
"""Matches on the calling thread. `re` cannot be interrupted mid-match, so a result that lands
|
|
102
|
+
after the deadline is reported as `Timeout`, but a catastrophic pattern still blocks the caller:
|
|
103
|
+
never serve untrusted patterns with it."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, caps: ExtractCaps = EXTRACT) -> None:
|
|
106
|
+
self._caps = caps
|
|
107
|
+
|
|
108
|
+
async def find(self, pattern: Translated, text: str, *, deadline: float) -> MatchResult:
|
|
109
|
+
if monotonic() >= deadline:
|
|
110
|
+
return Timeout()
|
|
111
|
+
try:
|
|
112
|
+
matches = match_all(pattern, text, self._caps.candidates_per_field, self._caps.candidate_units)
|
|
113
|
+
except re.error:
|
|
114
|
+
return Invalid(Unavailable.NO_RESULT)
|
|
115
|
+
return Timeout() if monotonic() > deadline else matches
|
|
116
|
+
|
|
117
|
+
async def aclose(self) -> None:
|
|
118
|
+
return None
|