Skip to content

Commit 2f93fb4

Browse files
samuelcolvinclaude
andauthored
Repl type checking (#319)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e9df4b commit 2f93fb4

File tree

6 files changed

+1159
-38
lines changed

6 files changed

+1159
-38
lines changed

crates/monty-python/python/pydantic_monty/_monty.pyi

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,28 @@ class MontyRepl:
256256
*,
257257
script_name: str = 'main.py',
258258
limits: ResourceLimits | None = None,
259+
type_check: bool = False,
260+
type_check_stubs: str | None = None,
259261
dataclass_registry: list[type] | None = None,
260262
) -> Self:
261263
"""
262264
Create an empty REPL session ready to receive snippets via `feed_run()`.
263265
264266
No code is parsed or executed at construction time.
267+
268+
Arguments:
269+
script_name: Name used in tracebacks and error messages
270+
limits: Optional resource limits configuration
271+
type_check: Whether to type-check each snippet before execution.
272+
When enabled, each `feed_run`/`feed_run_async`/`feed_start` call
273+
runs static type checking before executing the code, and each
274+
successfully executed snippet is appended to the accumulated
275+
context used for type-checking subsequent snippets.
276+
type_check_stubs: Optional stub code providing type declarations for
277+
variables and functions available in the REPL, e.g. input variable
278+
types or external function signatures.
279+
dataclass_registry: Optional list of dataclass types to register for proper
280+
isinstance() support on output.
265281
"""
266282

267283
@property
@@ -273,6 +289,24 @@ class MontyRepl:
273289
Register a dataclass type for proper isinstance() support on output.
274290
"""
275291

292+
def type_check(self, code: str, prefix_code: str | None = None) -> None:
293+
"""
294+
Perform static type checking on the given code snippet.
295+
296+
Checks the snippet in isolation using `prefix_code` as stub context.
297+
This does not use the accumulated code from previous `feed_run` calls —
298+
use `prefix_code` to provide any needed declarations.
299+
300+
Arguments:
301+
code: The code to type check
302+
prefix_code: Optional code to prepend before type checking,
303+
e.g. with input variable declarations or external function signatures
304+
305+
Raises:
306+
RuntimeError: If type checking infrastructure fails
307+
MontyTypingError: If type errors are found
308+
"""
309+
276310
def feed_run(
277311
self,
278312
code: str,
@@ -282,16 +316,27 @@ class MontyRepl:
282316
print_callback: Callable[[Literal['stdout'], str], None] | None = None,
283317
mount: MountDir | list[MountDir] | None = None,
284318
os: Callable[[str, tuple[Any, ...], dict[str, Any]], Any] | None = None,
319+
skip_type_check: bool = False,
285320
) -> Any:
286321
"""
287322
Execute one incremental snippet and return its output.
288323
289-
When `inputs` is provided, the key-value pairs are injected into
290-
the REPL namespace before executing the snippet.
291-
292-
When `external_functions` is provided, external function calls and
293-
name lookups are dispatched to the provided callables — matching the
294-
behavior of `Monty.run(external_functions=...)`.
324+
Arguments:
325+
code: The Python code snippet to execute
326+
inputs: Dict of input values injected into the REPL namespace
327+
before executing the snippet
328+
external_functions: Dict of external function callbacks. When
329+
provided, external function calls and name lookups are
330+
dispatched to the provided callables — matching the behavior
331+
of `Monty.run(external_functions=...)`.
332+
print_callback: Optional callback for print output
333+
mount: Optional filesystem mount(s) to expose inside the sandbox
334+
os: Optional OS access handler for filesystem operations
335+
skip_type_check: When `True`, static type checking is bypassed for
336+
this snippet AND the snippet is NOT appended to the accumulated
337+
type-check context, so later type-checked snippets will not see
338+
any names it defined. Has no effect unless `type_check=True`
339+
was set on the REPL.
295340
"""
296341

297342
def feed_run_async(
@@ -302,6 +347,7 @@ class MontyRepl:
302347
external_functions: dict[str, Callable[..., Any]] | None = None,
303348
print_callback: Callable[[Literal['stdout'], str], None] | None = None,
304349
os: AbstractOS | None = None,
350+
skip_type_check: bool = False,
305351
) -> Coroutine[Any, Any, Any]:
306352
"""
307353
Execute one incremental snippet and return its output with support for async external functions.
@@ -315,6 +361,11 @@ class MontyRepl:
315361
external_functions: Dict of external function callbacks (sync or async)
316362
print_callback: Optional callback for print output
317363
os: Optional OS access handler for filesystem operations
364+
skip_type_check: When `True`, static type checking is bypassed for
365+
this snippet AND the snippet is NOT appended to the accumulated
366+
type-check context, so later type-checked snippets will not see
367+
any names it defined. Has no effect unless `type_check=True`
368+
was set on the REPL.
318369
319370
Returns:
320371
A coroutine that resolves to the output of the snippet
@@ -329,6 +380,7 @@ class MontyRepl:
329380
*,
330381
inputs: dict[str, Any] | None = None,
331382
print_callback: Callable[[Literal['stdout'], str], None] | None = None,
383+
skip_type_check: bool = False,
332384
) -> FunctionSnapshot | NameLookupSnapshot | FutureSnapshot | MontyComplete:
333385
"""
334386
Start executing an incremental snippet, yielding snapshots for external calls.
@@ -343,6 +395,17 @@ class MontyRepl:
343395
including support for async external functions via `FutureSnapshot`.
344396
345397
On completion or error, the REPL state is automatically restored.
398+
399+
Arguments:
400+
code: The Python code snippet to execute
401+
inputs: Dict of input values injected into the REPL namespace
402+
before executing the snippet
403+
print_callback: Optional callback for print output
404+
skip_type_check: When `True`, static type checking is bypassed for
405+
this snippet AND the snippet is NOT appended to the accumulated
406+
type-check context, so later type-checked snippets will not see
407+
any names it defined. Has no effect unless `type_check=True`
408+
was set on the REPL.
346409
"""
347410

348411
def dump(self) -> bytes:

crates/monty-python/src/async_dispatch.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ where
217217
progress_guard.disarm();
218218
return Python::attach(|py| {
219219
let owner = repl_owner.bind(py).get();
220-
owner.put_repl(EitherRepl::from_core(repl));
220+
owner.put_repl_after_commit(EitherRepl::from_core(repl));
221221
cleanup_notifier.finish();
222222
monty_to_py(py, &value, &dc_registry)
223223
});
@@ -605,7 +605,7 @@ fn restore_repl<T: ResourceTracker>(
605605
{
606606
Python::attach(|py| {
607607
let owner = repl_owner.bind(py).get();
608-
owner.put_repl(EitherRepl::from_core(repl));
608+
owner.put_repl_after_rollback(EitherRepl::from_core(repl));
609609
});
610610
cleanup_notifier.finish();
611611
}
@@ -635,7 +635,7 @@ where
635635
{
636636
let py_err = Python::attach(|py| {
637637
let owner = repl_owner.bind(py).get();
638-
owner.put_repl(EitherRepl::from_core(err.repl));
638+
owner.put_repl_after_rollback(EitherRepl::from_core(err.repl));
639639
MontyError::new_err(py, err.error)
640640
});
641641
cleanup_notifier.finish();

crates/monty-python/src/monty_cls.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ impl PyMonty {
8080
let input_names = list_str(inputs, "inputs")?;
8181

8282
if type_check {
83-
py_type_check(py, &code, script_name, type_check_stubs)?;
83+
py_type_check(py, &code, script_name, type_check_stubs, "type_stubs.pyi")?;
8484
}
8585

8686
// Create the snapshot (parses the code)
@@ -122,7 +122,7 @@ impl PyMonty {
122122
/// * `MontyTypingError` if type errors are found
123123
#[pyo3(signature = (prefix_code=None))]
124124
fn type_check(&self, py: Python<'_>, prefix_code: Option<&str>) -> PyResult<()> {
125-
py_type_check(py, self.runner.code(), &self.script_name, prefix_code)
125+
py_type_check(py, self.runner.code(), &self.script_name, prefix_code, "type_stubs.pyi")
126126
}
127127

128128
/// Executes the code and returns the result.
@@ -390,8 +390,14 @@ impl PyMonty {
390390
}
391391
}
392392

393-
fn py_type_check(py: Python<'_>, code: &str, script_name: &str, type_stubs: Option<&str>) -> PyResult<()> {
394-
let type_stubs = type_stubs.map(|type_stubs| SourceFile::new(type_stubs, "type_stubs.pyi"));
393+
pub(crate) fn py_type_check(
394+
py: Python<'_>,
395+
code: &str,
396+
script_name: &str,
397+
type_stubs: Option<&str>,
398+
stubs_name: &str,
399+
) -> PyResult<()> {
400+
let type_stubs = type_stubs.map(|type_stubs| SourceFile::new(type_stubs, stubs_name));
395401

396402
let opt_diagnostics =
397403
type_check(&SourceFile::new(code, script_name), type_stubs.as_ref()).map_err(PyRuntimeError::new_err)?;
@@ -645,7 +651,7 @@ where
645651
{
646652
match progress {
647653
ReplProgress::Complete { repl, value } => {
648-
repl_owner.get().put_repl(EitherRepl::from_core(repl));
654+
repl_owner.get().put_repl_after_commit(EitherRepl::from_core(repl));
649655
PyMontyComplete::create(py, &value, &dc_registry)
650656
}
651657
ReplProgress::FunctionCall(call) => {
@@ -1352,7 +1358,7 @@ impl PyNameLookupSnapshot {
13521358
/// `ValueError` if serialization fails.
13531359
/// `RuntimeError` if the progress has already been resumed.
13541360
fn dump<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
1355-
let bytes = serialization::dump_lookup_snapshot(&self.snapshot, &self.script_name, &self.variable_name)?;
1361+
let bytes = serialization::dump_lookup_snapshot(py, &self.snapshot, &self.script_name, &self.variable_name)?;
13561362
Ok(PyBytes::new(py, &bytes))
13571363
}
13581364

@@ -1597,7 +1603,7 @@ impl PyFutureSnapshot {
15971603
/// `ValueError` if serialization fails.
15981604
/// `RuntimeError` if the progress has already been resumed.
15991605
fn dump<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
1600-
let bytes = serialization::dump_future_snapshot(&self.snapshot, &self.script_name)?;
1606+
let bytes = serialization::dump_future_snapshot(py, &self.snapshot, &self.script_name)?;
16011607
Ok(PyBytes::new(py, &bytes))
16021608
}
16031609

@@ -1762,7 +1768,9 @@ fn restore_repl_from_repl_start_error<T: ResourceTracker>(
17621768
where
17631769
EitherRepl: FromCoreRepl<T>,
17641770
{
1765-
repl_owner.get().put_repl(EitherRepl::from_core(err.repl));
1771+
repl_owner
1772+
.get()
1773+
.put_repl_after_rollback(EitherRepl::from_core(err.repl));
17661774
MontyError::new_err(py, err.error)
17671775
}
17681776

0 commit comments

Comments
 (0)