Skip to content

Commit c318d3c

Browse files
authored
Fix RestrictedEvalProvider to retain state and print (#686)
Closes #685. Two defects in `RestrictedEvalProvider.exec`, fine for single-shot synthesis decoding but breaking any caller that reuses the namespace across calls (e.g. a persistent REPL): - Rebinding a name already in the namespace was silently dropped: `keys_before` was snapshotted after `rglobals.update(env)`, so the copy-back only saw never-before-present keys. Replace with an identity diff that copies back every binding effect — new names and rebindings of seeded names alike. - `print(...)` raised `NameError: '_print_'`: RestrictedPython rewrites print into its `_print_` collector protocol, which the provider never supplied. Add `_StdoutPrintCollector`, a `_print_` factory routing output to the real `sys.stdout` so output-capturing callers see it. The `exec` op's contract — after `exec(bytecode, env)`, `env` reflects all top-level binding effects — now holds under both providers. Backward-compatible with the synthesis decode caller (its dict is read once and discarded).
1 parent b68a742 commit c318d3c

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

effectful/handlers/llm/evaluation.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
compile_restricted,
2424
safe_globals,
2525
)
26+
from RestrictedPython.PrintCollector import PrintCollector
2627

2728
from effectful.internals.unification import nested_type
2829
from effectful.ops.syntax import ObjectInterpretation, defop, implements
@@ -724,6 +725,16 @@ def exec(
724725
builtins.exec(bytecode, env, env)
725726

726727

728+
class _StdoutPrintCollector(PrintCollector):
729+
"""`_print_` factory whose `print(...)` writes to the real `sys.stdout`
730+
(so output-capturing callers see it) rather than accumulating into the
731+
collector's discarded `printed` buffer."""
732+
733+
def _call_print(self, *objects, **kwargs):
734+
kwargs.setdefault("file", sys.stdout)
735+
builtins.print(*objects, **kwargs)
736+
737+
727738
class RestrictedEvalProvider(ObjectInterpretation):
728739
"""
729740
Safer provider using RestrictedPython.
@@ -800,12 +811,21 @@ def exec(
800811
rglobals["setattr"] = Guards.guarded_setattr
801812
rglobals["_write_"] = lambda x: x
802813

803-
# Track keys before execution to identify new definitions
804-
keys_before = set(rglobals.keys())
814+
# RestrictedPython rewrites `print(...)` into its `_print_` collector
815+
# protocol; route it to the real stdout so output-capturing callers
816+
# (e.g. redirect_stdout) see it instead of a discarded collector.
817+
rglobals["_print_"] = _StdoutPrintCollector
805818

819+
# Snapshot value identities before execution so we can copy back every
820+
# *binding effect* — both new names and rebindings of seeded names.
821+
before = dict(rglobals)
806822
builtins.exec(bytecode, rglobals, rglobals)
807823

808-
# Copy newly defined items back to env so caller can access them
809-
for key in rglobals:
810-
if key not in keys_before:
811-
env[key] = rglobals[key]
824+
sentinel = object()
825+
env.update(
826+
{
827+
key: value
828+
for key, value in rglobals.items()
829+
if key != "__builtins__" and before.get(key, sentinel) is not value
830+
}
831+
)

tests/test_handlers_llm_evaluation.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import ast
44
import builtins
5+
import contextlib
56
import inspect
7+
import io
68
import sys
79
import textwrap
810
import types
@@ -25,6 +27,9 @@
2527
mypy_type_check,
2628
type_to_ast,
2729
)
30+
from effectful.handlers.llm.evaluation import compile as compile_op
31+
from effectful.handlers.llm.evaluation import exec as exec_op
32+
from effectful.handlers.llm.evaluation import parse as parse_op
2833
from effectful.internals.unification import nested_type
2934
from effectful.ops.semantics import handler
3035
from effectful.ops.syntax import defop
@@ -1537,3 +1542,55 @@ def test_builtins_in_env_does_not_bypass_security():
15371542
source_private, context=dangerous_ctx
15381543
)
15391544
fn("test")
1545+
1546+
1547+
# ============================================================================
1548+
# RestrictedEvalProvider state-retention and print (#685)
1549+
# ============================================================================
1550+
1551+
1552+
def _restricted_run(source: str, ns: dict, capture: bool = False) -> str | None:
1553+
"""Run one snippet through the parse/compile/exec ops under
1554+
RestrictedEvalProvider, optionally capturing stdout."""
1555+
with handler(RestrictedEvalProvider()):
1556+
code = compile_op(parse_op(source, "<f>"), "<f>")
1557+
if capture:
1558+
buf = io.StringIO()
1559+
with contextlib.redirect_stdout(buf):
1560+
exec_op(code, ns)
1561+
return buf.getvalue()
1562+
exec_op(code, ns)
1563+
return None
1564+
1565+
1566+
def test_restricted_exec_copies_back_rebound_seed():
1567+
"""#685: rebinding a name already present in the namespace writes the new
1568+
value back, not just never-before-seen names."""
1569+
ns = {"x": 1}
1570+
_restricted_run("x = 99", ns)
1571+
assert ns["x"] == 99
1572+
1573+
1574+
def test_restricted_exec_copies_back_rebound_new_key():
1575+
"""#685: a key that becomes a 'seed' after its first definition is still
1576+
rebindable on subsequent calls."""
1577+
ns: dict = {}
1578+
_restricted_run("y = 1", ns)
1579+
_restricted_run("y = 2", ns)
1580+
assert ns["y"] == 2
1581+
1582+
1583+
def test_restricted_exec_persists_and_rebinds_across_calls():
1584+
"""#685: the namespace is a real REPL session — a binding from one call is
1585+
usable in the next, and rebinding it using its prior value works."""
1586+
ns: dict = {}
1587+
_restricted_run("x = 10", ns)
1588+
_restricted_run("x = x + 1", ns)
1589+
assert ns["x"] == 11
1590+
1591+
1592+
def test_restricted_exec_print_captured_to_stdout():
1593+
"""#685: RestrictedPython's `print` is routed to the real stdout so
1594+
output-capturing callers see it (rather than NameError on `_print_`)."""
1595+
out = _restricted_run("print('hi')", {}, capture=True)
1596+
assert out == "hi\n"

0 commit comments

Comments
 (0)