Skip to content

Commit 4ee3fd7

Browse files
Embers-of-the-Firesamuelcolvinclaude
authored
re module implementation (#157)
Co-authored-by: Embers-of-the-Fire <Embers-of-the-Fire> Co-authored-by: Samuel Colvin <s@muelcolvin.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 353f982 commit 4ee3fd7

File tree

29 files changed

+4036
-28
lines changed

29 files changed

+4036
-28
lines changed

Cargo.lock

Lines changed: 15 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ What Monty **can** do:
3636
- Control resource usage - Monty can track memory usage, allocations, stack depth, and execution time and cancel execution if it exceeds preset limits
3737
- Collect stdout and stderr and return it to the caller
3838
- Run async or sync code on the host via async or sync code on the host
39+
- Use a small subset of the standard library: `sys`, `os`, `typing`, `asyncio`, `re`, `datetime` (soon), `dataclasses` (soon), `json` (soon)
3940

4041
What Monty **cannot** do:
4142

42-
- Use the standard library (except a few select modules: `sys`, `typing`, `asyncio`, `dataclasses` (soon), `json` (soon))
43+
- Use the rest of the standard library
4344
- Use third party libraries (like Pydantic), support for external python library is not a goal
4445
- define classes (support should come soon)
4546
- use match statements (again, support should come soon)

crates/monty-python/src/exceptions.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use pyo3::{
1919
PyClassInitializer, PyTypeCheck,
2020
exceptions::{self},
2121
prelude::*,
22+
sync::PyOnceLock,
2223
types::{PyDict, PyList, PyString},
2324
};
2425

@@ -408,6 +409,15 @@ pub fn exc_monty_to_py(py: Python<'_>, exc: MontyException) -> PyErr {
408409
ExcType::FileExistsError => exceptions::PyFileExistsError::new_err(msg),
409410
ExcType::IsADirectoryError => exceptions::PyIsADirectoryError::new_err(msg),
410411
ExcType::NotADirectoryError => exceptions::PyNotADirectoryError::new_err(msg),
412+
ExcType::RePatternError => {
413+
if let Ok(re_pattern_error) = get_re_pattern_error(py)
414+
&& let Ok(exc_instance) = re_pattern_error.call1((PyString::new(py, &msg),))
415+
{
416+
PyErr::from_value(exc_instance)
417+
} else {
418+
exceptions::PyRuntimeError::new_err(msg)
419+
}
420+
}
411421
}
412422
}
413423

@@ -535,3 +545,13 @@ fn is_frozen_instance_error(exc: &Bound<'_, exceptions::PyBaseException>) -> boo
535545
false
536546
}
537547
}
548+
549+
fn get_re_pattern_error(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
550+
static RE_PATTERN_ERROR: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
551+
552+
if cfg!(Py_3_13) {
553+
RE_PATTERN_ERROR.import(py, "re", "PatternError")
554+
} else {
555+
RE_PATTERN_ERROR.import(py, "re", "error")
556+
}
557+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import re
2+
import sys
3+
4+
import pytest
5+
from inline_snapshot import snapshot
6+
7+
import pydantic_monty
8+
9+
10+
def test_re_module():
11+
m = pydantic_monty.Monty('import re')
12+
output = m.run()
13+
assert output is None
14+
15+
16+
def test_re_compile():
17+
code = """
18+
import re
19+
pattern = re.compile(r'\\d+')
20+
matches = pattern.findall('There are 24 hours in a day and 365 days in a year.')
21+
"""
22+
m = pydantic_monty.Monty(code)
23+
output = m.run()
24+
assert output is None
25+
26+
27+
supported_flags = [
28+
(['re.I', 're.IGNORECASE'], re.IGNORECASE),
29+
(['re.M', 're.MULTILINE'], re.MULTILINE),
30+
(['re.S', 're.DOTALL'], re.DOTALL),
31+
]
32+
if sys.version_info >= (3, 11):
33+
supported_flags.append((['re.NOFLAG'], re.NOFLAG))
34+
35+
36+
@pytest.mark.parametrize(
37+
'flags,target',
38+
supported_flags,
39+
ids=str,
40+
)
41+
def test_re_constant(flags: list[str], target: int):
42+
code = f'import re; ({",".join(flags)},)'
43+
m = pydantic_monty.Monty(code)
44+
output = m.run()
45+
assert all(map(lambda orig: orig == target, output))
46+
47+
48+
def test_re_compile_repr():
49+
code = r"""
50+
import re
51+
pattern = re.compile(r'\d+', re.IGNORECASE | re.DOTALL)
52+
pattern
53+
"""
54+
m = pydantic_monty.Monty(code)
55+
output = m.run()
56+
assert output == r"re.compile('\\d+', re.IGNORECASE|re.DOTALL)"
57+
58+
59+
def test_re_match_repr():
60+
code = """
61+
import re
62+
pattern = re.compile(r'\\d+')
63+
pattern.match('123abc')
64+
"""
65+
m = pydantic_monty.Monty(code)
66+
output = m.run()
67+
assert output == "<re.Match object; span=(0, 3), match='123'>"
68+
69+
70+
def test_re_match_groups():
71+
code = """
72+
import re
73+
pattern = re.compile(r'(\\d+)-(\\w+)')
74+
match = pattern.match('123-abc')
75+
match.groups()
76+
"""
77+
m = pydantic_monty.Monty(code)
78+
output = m.run()
79+
assert output == ('123', 'abc')
80+
81+
82+
def test_re_substitution():
83+
code = """
84+
import re
85+
pattern = re.compile(r'\\s+')
86+
result = pattern.sub('-', 'This is a test.')
87+
result
88+
"""
89+
m = pydantic_monty.Monty(code)
90+
output = m.run()
91+
assert output == 'This-is-a-test.'
92+
93+
94+
def test_re_error_handling():
95+
code = """
96+
import re
97+
try:
98+
pattern = re.compile(r'[')
99+
except Exception as e:
100+
error_message = str(e)
101+
error_message
102+
"""
103+
m = pydantic_monty.Monty(code)
104+
output = m.run()
105+
error = 'Parsing error at position 1: Invalid character class'
106+
assert error in output
107+
108+
109+
def test_re_resume():
110+
code = """
111+
import re
112+
pattern = re.compile(func())
113+
matches = pattern.findall('Sample 123 text 456')
114+
dump(matches)
115+
"""
116+
m = pydantic_monty.Monty(code)
117+
progress = m.start()
118+
assert isinstance(progress, pydantic_monty.FunctionSnapshot)
119+
120+
assert progress.function_name == snapshot('func')
121+
assert progress.args == snapshot(())
122+
assert progress.kwargs == snapshot({})
123+
124+
progress2 = progress.resume(return_value='\\d+')
125+
assert isinstance(progress2, pydantic_monty.FunctionSnapshot)
126+
127+
result = progress2.resume(return_value=['123', '456'])
128+
assert isinstance(result, pydantic_monty.MontyComplete)
129+
assert result.output == snapshot(['123', '456'])
130+
131+
132+
def test_re_persistence():
133+
code = """
134+
import re
135+
pattern = re.compile(r'\\w+')
136+
dump()
137+
matches = pattern.findall('Test 123!')
138+
matches
139+
"""
140+
m = pydantic_monty.Monty(code)
141+
progress = m.start()
142+
assert isinstance(progress, pydantic_monty.FunctionSnapshot)
143+
144+
data = progress.dump()
145+
146+
progress2 = pydantic_monty.FunctionSnapshot.load(data)
147+
148+
result = progress2.resume(return_value=None)
149+
assert isinstance(result, pydantic_monty.MontyComplete)
150+
assert result.output == snapshot(['Test', '123'])
151+
152+
153+
def test_re_error_upcast():
154+
code = """
155+
import re
156+
re.compile(r'[')
157+
"""
158+
m = pydantic_monty.Monty(code)
159+
try:
160+
m.run()
161+
assert False, 'Expected an exception to be raised'
162+
except pydantic_monty.MontyRuntimeError as e:
163+
error_message = str(e)
164+
assert True, 'Expected an exception to be raised'
165+
if sys.version_info >= (3, 13):
166+
assert type(e.exception()) is re.PatternError
167+
else:
168+
assert type(e.exception()) is re.error
169+
assert 'Parsing error at position 1: Invalid character class' in error_message

crates/monty-type-checking/tests/good_types.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import asyncio
22
import os
3+
import re
34
import sys
45
from dataclasses import dataclass
56
from pathlib import Path
6-
from typing import assert_type
7+
from typing import Any, assert_type
78

89
# === Type checking helper functions ===
910

@@ -428,3 +429,47 @@ class Point:
428429

429430
x2 = os.environ.get('foobar')
430431
assert_type(x2, str | None)
432+
433+
434+
# === re module ===
435+
436+
# re.search returns Match or None
437+
s1 = re.search(r'\d+', 'abc 42')
438+
assert_type(s1, re.Match[str] | None)
439+
440+
# re.match returns Match or None
441+
s2 = re.match(r'\w+', 'hello')
442+
assert_type(s2, re.Match[str] | None)
443+
444+
# re.fullmatch returns Match or None
445+
s3 = re.fullmatch(r'\w+', 'hello')
446+
assert_type(s3, re.Match[str] | None)
447+
448+
# re.compile returns Pattern
449+
p_re = re.compile(r'\d+')
450+
assert_type(p_re, re.Pattern[str])
451+
452+
# re.findall returns list of Any
453+
fa = re.findall(r'\d+', 'a1 b2 c3')
454+
assert_type(fa, list[Any])
455+
456+
# re.sub returns str
457+
s4 = re.sub(r'\d+', 'X', 'a1 b2')
458+
assert_type(s4, str)
459+
460+
# Pattern.search returns Match or None
461+
p_re2 = re.compile(r'(\w+)')
462+
s5 = p_re2.search('hello world')
463+
assert_type(s5, re.Match[str] | None)
464+
465+
# Pattern.match returns Match or None
466+
s6 = p_re2.match('hello world')
467+
assert_type(s6, re.Match[str] | None)
468+
469+
# Pattern.sub returns str
470+
s7 = p_re2.sub('X', 'hello world')
471+
assert_type(s7, str)
472+
473+
# Pattern.findall returns list of Any
474+
fa2 = p_re2.findall('hello world')
475+
assert_type(fa2, list[Any])

crates/monty-typeshed/update.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106
'dataclasses.pyi',
107107
# used by dataclasses
108108
'enum.pyi',
109+
# the re std lib module is not mostly implemented
110+
're.pyi',
109111
# ==============================
110112
# all all collections dir
111113
'collections/__init__.pyi',
@@ -120,9 +122,10 @@
120122
]
121123
# content for typeshed's `VERSIONS` file
122124
VERSIONS = """\
123-
# absolutely minimal VERSIONS file exposing only the modules required
124-
# all these modules are required to get type checking working with ty
125-
# or for the stdlib modules we (partially) implement
125+
# DO NOT EDIT THIS FILE DIRECTLY
126+
# instead edit crates/monty-typeshed/update.py
127+
# this file should match the modules
128+
# which monty's minimimal typeshed includes
126129
127130
_collections_abc: 3.3-
128131
_typeshed: 3.0- # not present at runtime, only for type checking
@@ -133,6 +136,7 @@
133136
os: 3.0-
134137
pathlib: 3.4-
135138
pathlib.types: 3.14-
139+
re: 3.0-
136140
sys: 3.0-
137141
typing: 3.5-
138142
typing_extensions: 3.7-

crates/monty-typeshed/vendor/typeshed/stdlib/VERSIONS

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# absolutely minimal VERSIONS file exposing only the modules required
2-
# all these modules are required to get type checking working with ty
3-
# or for the stdlib modules we (partially) implement
1+
# DO NOT EDIT THIS FILE DIRECTLY
2+
# instead edit crates/monty-typeshed/update.py
3+
# this file should match the modules
4+
# which monty's minimimal typeshed includes
45

56
_collections_abc: 3.3-
67
_typeshed: 3.0- # not present at runtime, only for type checking
@@ -11,6 +12,7 @@ dataclasses: 3.7-
1112
os: 3.0-
1213
pathlib: 3.4-
1314
pathlib.types: 3.14-
15+
re: 3.0-
1416
sys: 3.0-
1517
typing: 3.5-
1618
typing_extensions: 3.7-

0 commit comments

Comments
 (0)