From 15469b8d380382305289a7595e3fdc1ea9b04ee6 Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Tue, 21 Apr 2026 13:55:43 -0400 Subject: [PATCH 1/5] linting --- examples/ipython_repl_example.py | 129 ++++ pyproject.toml | 1 + rlm/core/rlm.py | 6 +- rlm/core/types.py | 2 +- rlm/environments/__init__.py | 11 +- rlm/environments/ipython_repl.py | 1110 ++++++++++++++++++++++++++++++ tests/test_ipython_repl.py | 432 ++++++++++++ uv.lock | 500 +++++++++++++- 8 files changed, 2183 insertions(+), 8 deletions(-) create mode 100644 examples/ipython_repl_example.py create mode 100644 rlm/environments/ipython_repl.py create mode 100644 tests/test_ipython_repl.py diff --git a/examples/ipython_repl_example.py b/examples/ipython_repl_example.py new file mode 100644 index 000000000..ebd981707 --- /dev/null +++ b/examples/ipython_repl_example.py @@ -0,0 +1,129 @@ +"""IPython REPL environment example. + +Runs user code inside a real IPython session. Two kernel modes: + +- ``in_process``: IPython InteractiveShell in the same Python process as RLM. + Fast, zero-overhead subcalls, full Python object interop. +- ``subprocess``: ipykernel subprocess managed via jupyter_client. Gives you + real per-cell timeouts via kernel interrupt, and full isolation from the + host namespace. + +Setup: + pip install 'rlms[ipython]' + python -m examples.ipython_repl_example +""" + +from __future__ import annotations + +from rlm.clients.base_lm import BaseLM +from rlm.core.lm_handler import LMHandler +from rlm.core.types import ModelUsageSummary, UsageSummary +from rlm.environments.ipython_repl import IPythonREPL + + +class MockLM(BaseLM): + def __init__(self): + super().__init__(model_name="mock") + + def completion(self, prompt): + return f"Mock: {str(prompt)[:50]}" + + async def acompletion(self, prompt): + return self.completion(prompt) + + def get_usage_summary(self): + return UsageSummary({"mock": ModelUsageSummary(1, 10, 10)}) + + def get_last_usage(self): + return self.get_usage_summary() + + +def section(title: str) -> None: + print("\n" + "=" * 60) + print(title) + print("=" * 60) + + +def demo_in_process() -> None: + section("[1] In-process IPython kernel") + with IPythonREPL(kernel_mode="in_process") as repl: + r = repl.execute_code("x = 40\ny = 2\nprint(x + y)") + print(f" stdout: {r.stdout.strip()}") + print(f" locals: x={repl.locals['x']}, y={repl.locals['y']}") + + # FINAL_VAR captures the final answer out-of-band. + repl.execute_code('answer = "forty-two"') + r = repl.execute_code('FINAL_VAR("answer")') + print(f" FINAL_VAR → {r.final_answer}") + + +def demo_subprocess() -> None: + section("[2] Subprocess ipykernel — real timeouts") + with IPythonREPL(kernel_mode="subprocess", cell_timeout=1.0) as repl: + r = repl.execute_code("import sys; print(sys.executable)") + print(f" kernel python: {r.stdout.strip()}") + + r = repl.execute_code("xs = [i*i for i in range(5)]; print(xs)") + print(f" stdout: {r.stdout.strip()}") + + print(" -- running a 5s sleep with cell_timeout=1.0s --") + r = repl.execute_code("import time; time.sleep(5); print('never')") + print(f" elapsed: {r.execution_time:.2f}s") + print(f" stderr: {r.stderr.strip()}") + + # Kernel survives the interrupt and still responds. + r = repl.execute_code("print('kernel still alive')") + print(f" follow-up: {r.stdout.strip()}") + + +def demo_llm_query() -> None: + section("[3] llm_query plumbing via LMHandler") + with LMHandler(client=MockLM()) as handler: + for mode in ("in_process", "subprocess"): + print(f"\n -- kernel_mode={mode} --") + with IPythonREPL( + kernel_mode=mode, + lm_handler_address=handler.address, + ) as repl: + r = repl.execute_code('r = llm_query("ping"); print(r)') + print(f" response: {r.stdout.strip()}") + + r = repl.execute_code('print(llm_query_batched(["a", "b"]))') + print(f" batched: {r.stdout.strip()}") + + +def demo_recursion() -> None: + section("[4] rlm_query — recursive RLM subcalls") + # Stand-in for RLM._subcall: returns a canned reply for the demo. + from rlm.core.types import RLMChatCompletion + + class _FakeSubcall: + def __call__(self, prompt: str, model=None) -> RLMChatCompletion: + return RLMChatCompletion( + root_model="fake", + prompt=prompt, + response=f"child-answer[{prompt}]", + usage_summary=UsageSummary({"fake": ModelUsageSummary(1, 0, 0)}), + execution_time=0.0, + ) + + subcall = _FakeSubcall() + for mode in ("in_process", "subprocess"): + print(f"\n -- kernel_mode={mode} --") + with IPythonREPL(kernel_mode=mode, subcall_fn=subcall) as repl: + r = repl.execute_code('print(rlm_query("deep-think-topic-A"))') + print(f" single: {r.stdout.strip()}") + r = repl.execute_code('print(rlm_query_batched(["t1","t2","t3"]))') + print(f" batched: {r.stdout.strip()}") + + +def main() -> None: + demo_in_process() + demo_subprocess() + demo_llm_query() + demo_recursion() + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 489080359..6d76943b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ modal = ["modal>=0.73.0", "dill>=0.3.7"] e2b = ["e2b-code-interpreter>=0.0.11", "dill>=0.3.7"] daytona = ["daytona>=0.128.1", "dill>=0.3.7"] prime = ["prime-sandboxes>=0.2.0", "dill>=0.3.7"] +ipython = ["ipython>=8.0.0", "jupyter_client>=8.0.0", "ipykernel>=6.0.0", "dill>=0.3.7"] [build-system] requires = ["setuptools>=61.0"] diff --git a/rlm/core/rlm.py b/rlm/core/rlm.py index 4a8e42203..55b84c5de 100644 --- a/rlm/core/rlm.py +++ b/rlm/core/rlm.py @@ -232,8 +232,8 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]): env_kwargs["lm_handler_address"] = (lm_handler.host, lm_handler.port) env_kwargs["context_payload"] = prompt env_kwargs["depth"] = self.depth + 1 # Environment depth is RLM depth + 1 - # For local environment with max_depth > 1, pass subcall callback for recursive RLM calls - if self.environment_type == "local" and self.max_depth > 1: + # For local/ipython environments with max_depth > 1, pass subcall callback for recursive RLM calls + if self.environment_type in ("local", "ipython") and self.max_depth > 1: env_kwargs["subcall_fn"] = self._subcall # Pass custom tools to the environment if self.custom_tools is not None: @@ -828,7 +828,7 @@ def _validate_persistent_environment_support(self) -> None: ValueError: If the environment type does not support persistent mode. """ # Known environments that support persistence - persistent_supported_environments = {"local"} + persistent_supported_environments = {"local", "ipython"} if self.environment_type not in persistent_supported_environments: raise ValueError( diff --git a/rlm/core/types.py b/rlm/core/types.py index 8d238ea80..3c98ee816 100644 --- a/rlm/core/types.py +++ b/rlm/core/types.py @@ -12,7 +12,7 @@ "azure_openai", "gemini", ] -EnvironmentType = Literal["local", "docker", "modal", "prime", "daytona", "e2b"] +EnvironmentType = Literal["local", "ipython", "docker", "modal", "prime", "daytona", "e2b"] def _serialize_value(value: Any) -> Any: diff --git a/rlm/environments/__init__.py b/rlm/environments/__init__.py index 3b769b7b2..4f23748e6 100644 --- a/rlm/environments/__init__.py +++ b/rlm/environments/__init__.py @@ -16,6 +16,7 @@ __all__ = [ "BaseEnv", + "IPythonREPL", "LocalREPL", "RESERVED_TOOL_NAMES", "SupportsCustomTools", @@ -31,15 +32,19 @@ def get_environment( - environment: Literal["local", "modal", "docker", "daytona", "prime", "e2b"], + environment: Literal["local", "ipython", "modal", "docker", "daytona", "prime", "e2b"], environment_kwargs: dict[str, Any], ) -> BaseEnv: """ Routes a specific environment and the args (as a dict) to the appropriate environment if supported. - Currently supported environments: ['local', 'modal', 'docker', 'daytona', 'prime', 'e2b'] + Currently supported environments: ['local', 'ipython', 'modal', 'docker', 'daytona', 'prime', 'e2b'] """ if environment == "local": return LocalREPL(**environment_kwargs) + elif environment == "ipython": + from rlm.environments.ipython_repl import IPythonREPL + + return IPythonREPL(**environment_kwargs) elif environment == "modal": from rlm.environments.modal_repl import ModalREPL @@ -62,5 +67,5 @@ def get_environment( return E2BREPL(**environment_kwargs) else: raise ValueError( - f"Unknown environment: {environment}. Supported: ['local', 'modal', 'docker', 'daytona', 'prime', 'e2b']" + f"Unknown environment: {environment}. Supported: ['local', 'ipython', 'modal', 'docker', 'daytona', 'prime', 'e2b']" ) diff --git a/rlm/environments/ipython_repl.py b/rlm/environments/ipython_repl.py new file mode 100644 index 000000000..0ae7d7f29 --- /dev/null +++ b/rlm/environments/ipython_repl.py @@ -0,0 +1,1110 @@ +""" +IPython REPL environment. Executes code inside a real IPython session. + +Two kernel modes: + +- ``kernel_mode="in_process"`` (default): Uses ``IPython.core.interactiveshell. + InteractiveShell`` in the same Python process as RLM. Fast, zero-overhead + subcalls via direct Python callables. Same timeout limitation as LocalREPL: + Python cannot interrupt blocking user code from another thread. + +- ``kernel_mode="subprocess"``: Uses ``jupyter_client.KernelManager`` to spawn + an ``ipykernel`` subprocess. Supports hard per-cell timeouts via + ``kc.execute_interactive(timeout=...)`` + ``km.interrupt_kernel()``. LLM + and RLM subcalls route over a TCP broker using the existing 4-byte-prefix + JSON protocol from ``rlm.core.comms_utils``. +""" + +from __future__ import annotations + +import copy +import io +import json +import os +import shutil +import signal +import socketserver +import sys +import tempfile +import textwrap +import threading +import time +import uuid +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from typing import Any, Literal + +from rlm.core.comms_utils import ( + LMRequest, + send_lm_request, + send_lm_request_batched, + socket_recv, + socket_send, +) +from rlm.core.types import REPLResult, RLMChatCompletion +from rlm.environments.base_env import ( + RESERVED_TOOL_NAMES, + NonIsolatedEnv, + extract_tool_value, + validate_custom_tools, +) + +KernelMode = Literal["in_process", "subprocess"] + +# IPython populates user_ns with these. We strip them before returning locals +# so REPLResult.locals stays clean and comparable to LocalREPL. +_IPYTHON_INTERNAL_NAMES: frozenset[str] = frozenset( + { + "In", + "Out", + "exit", + "quit", + "get_ipython", + "open", + "_oh", + "_dh", + "_ih", + "_i", + "_ii", + "_iii", + "_", + "__", + "___", + } +) + + +# ============================================================================= +# Subcall broker (subprocess kernel mode) +# ============================================================================= + + +class _SubcallBroker: + """TCP broker that the subprocess kernel calls back into for rlm_query + and FINAL_VAR results. + + Reuses the 4-byte-length-prefix JSON protocol from ``rlm.core.comms_utils``. + + Request types: + {"type": "subcall", "prompt": str, "model": str | None} + {"type": "subcall_batched", "prompts": [str], "model": str | None} + {"type": "final_var", "value": str} + + Responses: + subcall: {"completion": RLMChatCompletion.to_dict()} | {"error": str} + subcall_batched: {"responses": [str]} | {"error": str} + final_var: {"ok": True} + """ + + def __init__( + self, + subcall_fn: Callable[[str, str | None], RLMChatCompletion] | None, + max_concurrent: int = 4, + host: str = "127.0.0.1", + port: int = 0, + ): + self.subcall_fn = subcall_fn + self.max_concurrent = max_concurrent + self.host = host + self._port = port + self._server: socketserver.ThreadingTCPServer | None = None + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + self.completions: list[RLMChatCompletion] = [] + self.final_answer: str | None = None + + def start(self) -> tuple[str, int]: + parent = self + + class _Handler(socketserver.StreamRequestHandler): + def handle(self) -> None: + try: + data = socket_recv(self.connection) + if not isinstance(data, dict): + socket_send(self.connection, {"error": "Request must be a JSON object"}) + return + reply = parent._dispatch(data) + socket_send(self.connection, reply) + except (BrokenPipeError, ConnectionError, ConnectionResetError, OSError): + pass + except Exception as e: + try: + socket_send(self.connection, {"error": f"{type(e).__name__}: {e}"}) + except Exception: + pass + + class _Server(socketserver.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + self._server = _Server((self.host, self._port), _Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self.address + + def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: + req_type = data.get("type") + + if req_type == "final_var": + value = data.get("value") + with self._lock: + self.final_answer = str(value) if value is not None else None + return {"ok": True} + + if req_type == "subcall": + if self.subcall_fn is None: + return {"error": "No subcall_fn configured; rlm_query unavailable"} + try: + completion = self.subcall_fn(data.get("prompt", ""), data.get("model")) + with self._lock: + self.completions.append(completion) + return {"completion": completion.to_dict()} + except Exception as e: + return {"error": f"{type(e).__name__}: {e}"} + + if req_type == "subcall_batched": + if self.subcall_fn is None: + return {"error": "No subcall_fn configured; rlm_query_batched unavailable"} + prompts = data.get("prompts") or [] + model = data.get("model") + responses: list[str | None] = [None] * len(prompts) + errors: list[str | None] = [None] * len(prompts) + local_completions: list[tuple[int, RLMChatCompletion]] = [] + local_lock = threading.Lock() + + def _one(i: int, prompt: str) -> None: + try: + completion = self.subcall_fn(prompt, model) # type: ignore[misc] + with local_lock: + local_completions.append((i, completion)) + responses[i] = completion.response + except Exception as e: + errors[i] = f"{type(e).__name__}: {e}" + + max_workers = max(1, min(self.max_concurrent, len(prompts) or 1)) + with ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = [ex.submit(_one, i, p) for i, p in enumerate(prompts)] + for f in as_completed(futures): + f.result() + + with self._lock: + for _, c in sorted(local_completions, key=lambda t: t[0]): + self.completions.append(c) + + return { + "responses": [ + (r if r is not None else f"Error: {e or 'Unknown error'}") + for r, e in zip(responses, errors, strict=True) + ] + } + + return {"error": f"Unknown message type: {req_type!r}"} + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + @property + def address(self) -> tuple[str, int]: + if self._server is None: + return (self.host, self._port) + return (self.host, self._server.server_address[1]) + + def drain(self) -> tuple[list[RLMChatCompletion], str | None]: + """Atomically snapshot + clear completions and final_answer.""" + with self._lock: + completions = self.completions.copy() + self.completions.clear() + final = self.final_answer + self.final_answer = None + return completions, final + + +# ============================================================================= +# Subprocess kernel bootstrap script +# ============================================================================= + + +def _build_kernel_bootstrap( + lm_address: tuple[str, int] | None, + subcall_address: tuple[str, int] | None, + depth: int, +) -> str: + """Code executed once inside the kernel to wire up scaffold helpers.""" + return textwrap.dedent( + f""" + import json as _rlm_json + import socket as _rlm_socket + import struct as _rlm_struct + + _RLM_LM_ADDRESS = {list(lm_address) if lm_address else None!r} + _RLM_SUBCALL_ADDRESS = {list(subcall_address) if subcall_address else None!r} + _RLM_DEPTH = {depth} + + def _rlm_socket_send(sock, data): + payload = _rlm_json.dumps(data).encode("utf-8") + sock.sendall(_rlm_struct.pack(">I", len(payload)) + payload) + + def _rlm_socket_recv(sock): + raw_len = b"" + while len(raw_len) < 4: + chunk = sock.recv(4 - len(raw_len)) + if not chunk: + return {{}} + raw_len += chunk + length = _rlm_struct.unpack(">I", raw_len)[0] + payload = b"" + while len(payload) < length: + chunk = sock.recv(length - len(payload)) + if not chunk: + raise ConnectionError("Connection closed before message complete") + payload += chunk + return _rlm_json.loads(payload.decode("utf-8")) + + def _rlm_request(address, data, timeout=300): + if address is None: + return {{"error": "No address configured"}} + sock = _rlm_socket.socket(_rlm_socket.AF_INET, _rlm_socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(tuple(address)) + _rlm_socket_send(sock, data) + return _rlm_socket_recv(sock) + finally: + sock.close() + + def llm_query(prompt, model=None): + resp = _rlm_request(_RLM_LM_ADDRESS, {{ + "prompt": prompt, "model": model, "depth": _RLM_DEPTH + }}) + if not isinstance(resp, dict): + return "Error: LM query failed - malformed response" + if resp.get("error"): + return f"Error: {{resp['error']}}" + cc = resp.get("chat_completion") or {{}} + return cc.get("response", "") + + def llm_query_batched(prompts, model=None): + prompts = list(prompts) + resp = _rlm_request(_RLM_LM_ADDRESS, {{ + "prompts": prompts, "model": model, "depth": _RLM_DEPTH + }}) + if not isinstance(resp, dict): + return ["Error: LM query failed - malformed response"] * len(prompts) + if resp.get("error"): + return [f"Error: {{resp['error']}}"] * len(prompts) + ccs = resp.get("chat_completions") or [] + return [c.get("response", "") for c in ccs] + + def rlm_query(prompt, model=None): + if _RLM_SUBCALL_ADDRESS is None: + return llm_query(prompt, model=model) + resp = _rlm_request(_RLM_SUBCALL_ADDRESS, {{ + "type": "subcall", "prompt": prompt, "model": model + }}) + if not isinstance(resp, dict): + return "Error: RLM query failed - malformed response" + if resp.get("error"): + # Fall back to a plain LM call if the parent has no subcall_fn + if "No subcall_fn" in resp["error"]: + return llm_query(prompt, model=model) + return f"Error: {{resp['error']}}" + cc = resp.get("completion") or {{}} + return cc.get("response", "") + + def rlm_query_batched(prompts, model=None): + prompts = list(prompts) + if _RLM_SUBCALL_ADDRESS is None: + return llm_query_batched(prompts, model=model) + resp = _rlm_request(_RLM_SUBCALL_ADDRESS, {{ + "type": "subcall_batched", "prompts": prompts, "model": model + }}) + if not isinstance(resp, dict): + return ["Error: RLM query failed - malformed response"] * len(prompts) + if resp.get("error"): + if "No subcall_fn" in resp["error"]: + return llm_query_batched(prompts, model=model) + return [f"Error: {{resp['error']}}"] * len(prompts) + return list(resp.get("responses") or []) + + def FINAL_VAR(variable): + if not isinstance(variable, str): + answer = str(variable) + _rlm_request(_RLM_SUBCALL_ADDRESS, {{"type": "final_var", "value": answer}}) + return answer + name = variable.strip().strip('"\\'') + ns = get_ipython().user_ns + if name in ns: + answer = str(ns[name]) + _rlm_request(_RLM_SUBCALL_ADDRESS, {{"type": "final_var", "value": answer}}) + return answer + skip = {{"In", "Out", "exit", "quit", "get_ipython"}} + available = [ + k for k in ns.keys() + if not k.startswith("_") and k not in skip + ] + if available: + return ( + f"Error: Variable {{name!r}} not found. " + f"Available variables: {{available}}. " + "You must create and assign a variable BEFORE calling FINAL_VAR on it." + ) + return ( + f"Error: Variable {{name!r}} not found. " + "No variables have been created yet. " + "You must create and assign a variable in a REPL block BEFORE calling FINAL_VAR on it." + ) + + def SHOW_VARS(): + ns = get_ipython().user_ns + skip = {{"In", "Out", "exit", "quit", "get_ipython"}} + available = {{ + k: type(v).__name__ + for k, v in ns.items() + if not k.startswith("_") and k not in skip + }} + if not available: + return "No variables created yet. Use ```repl``` blocks to create variables." + return f"Available variables: {{available}}" + """ + ).strip() + + +# ============================================================================= +# IPythonREPL +# ============================================================================= + + +class IPythonREPL(NonIsolatedEnv): + """IPython-backed REPL with in-process or subprocess kernel modes. + + Args: + lm_handler_address: (host, port) of the LM handler socket server. + context_payload: Initial context to load as ``context``/``context_0``. + setup_code: Optional code to execute after context is loaded. + persistent: If True, state survives across multiple ``RLM.completion`` + calls; the env exposes ``add_context``/``add_history`` and the + ``SupportsPersistence`` protocol. + depth: RLM depth the environment is running at (used for LM routing). + subcall_fn: Callback that spawns a child RLM for ``rlm_query``. + custom_tools: Extra functions/values to inject into the namespace. + custom_sub_tools: Tools inherited by child RLMs (defaults to custom_tools). + kernel_mode: ``"in_process"`` (default) or ``"subprocess"``. + cell_timeout: Max seconds for a single ``execute_code`` call. + - ``subprocess`` mode: hard guarantee via ``kc.execute_interactive + (timeout=...)`` + ``km.interrupt_kernel()``. Always enforced. + - ``in_process`` mode: best-effort via ``SIGALRM`` on Unix when + called from the main thread. Interrupts Python loops *and* + C-level blocking calls like ``time.sleep``. Silently no-op on + Windows or when called off the main thread. + startup_timeout: Max seconds to wait for a ``subprocess`` kernel to + become ready. + max_concurrent_subcalls: Cap on concurrent ``rlm_query_batched`` calls. + """ + + def __init__( + self, + lm_handler_address: tuple[str, int] | None = None, + context_payload: dict | list | str | None = None, + setup_code: str | None = None, + persistent: bool = False, + depth: int = 1, + subcall_fn: Callable[[str, str | None], RLMChatCompletion] | None = None, + custom_tools: dict[str, Any] | None = None, + custom_sub_tools: dict[str, Any] | None = None, + kernel_mode: KernelMode = "in_process", + cell_timeout: float | None = None, + startup_timeout: float = 60.0, + max_concurrent_subcalls: int = 4, + **kwargs, + ): + if kernel_mode not in ("in_process", "subprocess"): + raise ValueError( + f"kernel_mode must be 'in_process' or 'subprocess', got {kernel_mode!r}" + ) + + super().__init__( + persistent=persistent, + depth=depth, + max_concurrent_subcalls=max_concurrent_subcalls, + **kwargs, + ) + + self.lm_handler_address = lm_handler_address + self.subcall_fn = subcall_fn + self.kernel_mode: KernelMode = kernel_mode + self.cell_timeout = cell_timeout + self.startup_timeout = startup_timeout + + self.custom_tools = custom_tools or {} + self.custom_sub_tools = ( + custom_sub_tools if custom_sub_tools is not None else self.custom_tools + ) + validate_custom_tools(self.custom_tools) + + self.original_cwd = os.getcwd() + self.temp_dir = tempfile.mkdtemp(prefix=f"ipython_env_{uuid.uuid4()}_") + self._lock = threading.Lock() + self._context_count: int = 0 + self._history_count: int = 0 + + # Subprocess mode doesn't serialize the kernel's full user_ns back to + # the parent on every execute. We keep a parent-side shadow of the + # versioned context_N / history_N data (injected by add_context / + # add_history) so tests and the RLM loop can inspect them via + # ``self.locals``. + self._subprocess_shadow: dict[str, Any] = {} + + # Tracking state for LLM calls / final answer during execute_code. + self._pending_llm_calls: list[RLMChatCompletion] = [] + self._last_final_answer: str | None = None + + # In-process: IPython shell instance + self._shell: Any = None + # Subprocess: jupyter_client manager/client + self._km: Any = None + self._kc: Any = None + self._broker: _SubcallBroker | None = None + + self.setup() + + if context_payload is not None: + self.load_context(context_payload) + + if setup_code: + self.execute_code(setup_code) + + # ------------------------------------------------------------------------- + # Setup + # ------------------------------------------------------------------------- + + def setup(self) -> None: + if self.kernel_mode == "in_process": + self._setup_in_process() + else: + self._setup_subprocess() + + def _setup_in_process(self) -> None: + try: + from IPython.core.interactiveshell import InteractiveShell + except ImportError as e: + raise ImportError( + "IPython is required for IPythonREPL. Install with: " + "pip install 'rlms[ipython]' or pip install ipython" + ) from e + + # InteractiveShell is a singleton by default; using .instance() would + # leak state across multiple IPythonREPL objects. Create a fresh one. + shell = InteractiveShell() + self._shell = shell + ns = shell.user_ns + + # Inject scaffold functions + ns["llm_query"] = self._llm_query + ns["llm_query_batched"] = self._llm_query_batched + ns["rlm_query"] = self._rlm_query + ns["rlm_query_batched"] = self._rlm_query_batched + ns["FINAL_VAR"] = self._final_var + ns["SHOW_VARS"] = self._show_vars + + # Inject custom tools + for name, entry in self.custom_tools.items(): + ns[name] = extract_tool_value(entry) + + def _setup_subprocess(self) -> None: + try: + from jupyter_client.manager import KernelManager + except ImportError as e: + raise ImportError( + "jupyter_client and ipykernel are required for IPythonREPL " + "in subprocess mode. Install with: pip install 'rlms[ipython]' " + "or pip install jupyter_client ipykernel" + ) from e + + # Start broker first so we know its address before bootstrapping kernel + self._broker = _SubcallBroker( + subcall_fn=self.subcall_fn, + max_concurrent=self.max_concurrent_subcalls, + ) + self._broker.start() + + self._km = KernelManager(kernel_name="python3") + # Force the kernel to run under the same Python as the parent process, + # so it inherits the same installed packages (dill, custom imports, + # etc.). Without this, jupyter_client uses whatever 'python' is on + # PATH per the default kernelspec. + self._km.kernel_cmd = [ + sys.executable, + "-m", + "ipykernel_launcher", + "-f", + "{connection_file}", + ] + self._km.start_kernel(cwd=self.temp_dir) + self._kc = self._km.client() + self._kc.start_channels() + self._kc.wait_for_ready(timeout=self.startup_timeout) + + # Bootstrap scaffold inside the kernel + bootstrap = _build_kernel_bootstrap( + lm_address=self.lm_handler_address, + subcall_address=self._broker.address, + depth=self.depth, + ) + result = self._execute_in_kernel(bootstrap, timeout=self.startup_timeout) + if result.stderr: + raise RuntimeError(f"Kernel bootstrap failed:\n{result.stderr}") + + # Inject custom tools. Only values that pickle cleanly will survive; + # for arbitrary callables we use dill if available, otherwise we skip + # with a clear error. + if self.custom_tools: + self._inject_custom_tools_subprocess() + + def _inject_custom_tools_subprocess(self) -> None: + """Ship custom_tools to the subprocess kernel. + + Prefers dill for callables and arbitrary Python objects, falls back to + JSON for plain data if dill isn't installed. Either path raises with a + clear message on failure. + """ + try: + import dill # type: ignore + + dill_available = True + except ImportError: + dill_available = False + + for name, entry in self.custom_tools.items(): + value = extract_tool_value(entry) + + if dill_available: + try: + # recurse=True pickles functions by value (including their + # module globals) so the kernel doesn't need to import the + # original defining module — important for locally-defined + # closures. + payload = dill.dumps(value, recurse=True).hex() + except Exception as e: + raise RuntimeError( + f"Custom tool {name!r} could not be pickled with dill: {e}" + ) from e + code = textwrap.dedent( + f""" + import dill as _rlm_dill + {name} = _rlm_dill.loads(bytes.fromhex({payload!r})) + """ + ).strip() + result = self._execute_in_kernel(code) + if result.stderr: + raise RuntimeError( + f"Failed to inject custom tool {name!r} into kernel: {result.stderr}" + ) + continue + + # Fallback: JSON-roundtrip for primitive data + try: + json_payload = json.dumps(value) + except (TypeError, ValueError) as e: + raise RuntimeError( + f"Custom tool {name!r} is not JSON-serializable and dill is not " + f"installed. Install dill (pip install dill) to inject arbitrary " + f"callables/objects. ({e})" + ) from e + code = f"import json as _rlm_json; {name} = _rlm_json.loads({json_payload!r})" + result = self._execute_in_kernel(code) + if result.stderr: + raise RuntimeError( + f"Failed to inject custom tool {name!r} into kernel: {result.stderr}" + ) + + # ------------------------------------------------------------------------- + # Scaffold helpers (in-process only; subprocess has its own in the kernel) + # ------------------------------------------------------------------------- + + def _final_var(self, variable_name: str | Any) -> str: + if not isinstance(variable_name, str): + answer = str(variable_name) + self._last_final_answer = answer + return answer + name = variable_name.strip().strip("\"'") + ns = self._shell.user_ns if self._shell is not None else {} + if name in ns: + answer = str(ns[name]) + self._last_final_answer = answer + return answer + available = [ + k for k in ns.keys() if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES + ] + if available: + return ( + f"Error: Variable '{name}' not found. " + f"Available variables: {available}. " + f"You must create and assign a variable BEFORE calling FINAL_VAR on it." + ) + return ( + f"Error: Variable '{name}' not found. " + f"No variables have been created yet. " + f"You must create and assign a variable in a REPL block BEFORE calling FINAL_VAR on it." + ) + + def _show_vars(self) -> str: + ns = self._shell.user_ns if self._shell is not None else {} + available = { + k: type(v).__name__ + for k, v in ns.items() + if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES + } + if not available: + return "No variables created yet. Use ```repl``` blocks to create variables." + return f"Available variables: {available}" + + def _llm_query(self, prompt: str, model: str | None = None) -> str: + if not self.lm_handler_address: + return "Error: No LM handler configured" + try: + request = LMRequest(prompt=prompt, model=model, depth=self.depth) + response = send_lm_request(self.lm_handler_address, request) + if not response.success: + return f"Error: {response.error}" + self._pending_llm_calls.append(response.chat_completion) + return response.chat_completion.response + except Exception as e: + return f"Error: LM query failed - {e}" + + def _llm_query_batched(self, prompts: list[str], model: str | None = None) -> list[str]: + if not self.lm_handler_address: + return ["Error: No LM handler configured"] * len(prompts) + try: + responses = send_lm_request_batched( + self.lm_handler_address, prompts, model=model, depth=self.depth + ) + results: list[str] = [] + for response in responses: + if not response.success: + results.append(f"Error: {response.error}") + else: + self._pending_llm_calls.append(response.chat_completion) + results.append(response.chat_completion.response) + return results + except Exception as e: + return [f"Error: LM query failed - {e}"] * len(prompts) + + def _rlm_query(self, prompt: str, model: str | None = None) -> str: + if self.subcall_fn is None: + return self._llm_query(prompt, model) + try: + completion = self.subcall_fn(prompt, model) + self._pending_llm_calls.append(completion) + return completion.response + except Exception as e: + return f"Error: RLM query failed - {e}" + + def _rlm_query_batched(self, prompts: list[str], model: str | None = None) -> list[str]: + if self.subcall_fn is None: + return self._llm_query_batched(prompts, model) + + if len(prompts) <= 1: + results: list[str] = [] + for prompt in prompts: + try: + completion = self.subcall_fn(prompt, model) + self._pending_llm_calls.append(completion) + results.append(completion.response) + except Exception as e: + results.append(f"Error: RLM query failed - {e}") + return results + + max_workers = min(self.max_concurrent_subcalls, len(prompts)) + results: list[str] = [""] * len(prompts) + completions: list[tuple[int, RLMChatCompletion]] = [] + lock = threading.Lock() + + def _run(index: int, prompt: str) -> None: + try: + completion = self.subcall_fn(prompt, model) # type: ignore[misc] + with lock: + completions.append((index, completion)) + results[index] = completion.response + except Exception as e: + results[index] = f"Error: RLM query failed - {e}" + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(_run, i, p) for i, p in enumerate(prompts)] + for f in as_completed(futures): + f.result() + + completions.sort(key=lambda x: x[0]) + for _, completion in completions: + self._pending_llm_calls.append(completion) + return results + + # ------------------------------------------------------------------------- + # Context + # ------------------------------------------------------------------------- + + def load_context(self, context_payload: dict | list | str) -> None: + self.add_context(context_payload, 0) + + def add_context( + self, + context_payload: dict | list | str, + context_index: int | None = None, + ) -> int: + if context_index is None: + context_index = self._context_count + var_name = f"context_{context_index}" + + if isinstance(context_payload, str): + context_path = os.path.join(self.temp_dir, f"context_{context_index}.txt") + with open(context_path, "w") as f: + f.write(context_payload) + code = f"with open(r'{context_path}', 'r') as _rlm_f:\n {var_name} = _rlm_f.read()" + else: + context_path = os.path.join(self.temp_dir, f"context_{context_index}.json") + with open(context_path, "w") as f: + json.dump(context_payload, f) + code = ( + "import json as _rlm_json\n" + f"with open(r'{context_path}', 'r') as _rlm_f:\n" + f" {var_name} = _rlm_json.load(_rlm_f)" + ) + + result = self.execute_code(code) + if result.stderr: + raise RuntimeError(f"Failed to load context: {result.stderr}") + + if context_index == 0: + self.execute_code(f"context = {var_name}") + + # Shadow for subprocess mode so self.locals can report context_N. + if self.kernel_mode == "subprocess": + self._subprocess_shadow[var_name] = copy.deepcopy(context_payload) + if context_index == 0: + self._subprocess_shadow["context"] = self._subprocess_shadow[var_name] + + self._context_count = max(self._context_count, context_index + 1) + return context_index + + def get_context_count(self) -> int: + return self._context_count + + def add_history( + self, + message_history: list[dict[str, Any]], + history_index: int | None = None, + ) -> int: + """Store a message history as ``history_N`` (and ``history`` for index 0).""" + if history_index is None: + history_index = self._history_count + var_name = f"history_{history_index}" + payload = copy.deepcopy(message_history) + + # In-process: assign directly into user_ns. + if self.kernel_mode == "in_process": + assert self._shell is not None + self._shell.user_ns[var_name] = payload + if history_index == 0: + self._shell.user_ns["history"] = payload + else: + # Subprocess: round-trip via a temp JSON file (histories are + # always JSON-serializable by construction: role/content dicts). + history_path = os.path.join(self.temp_dir, f"history_{history_index}.json") + with open(history_path, "w") as f: + json.dump(payload, f) + code = ( + "import json as _rlm_json\n" + f"with open(r'{history_path}', 'r') as _rlm_f:\n" + f" {var_name} = _rlm_json.load(_rlm_f)" + ) + result = self.execute_code(code) + if result.stderr: + raise RuntimeError(f"Failed to load history: {result.stderr}") + if history_index == 0: + self.execute_code(f"history = {var_name}") + self._subprocess_shadow[var_name] = payload + if history_index == 0: + self._subprocess_shadow["history"] = payload + + self._history_count = max(self._history_count, history_index + 1) + return history_index + + def get_history_count(self) -> int: + return self._history_count + + def update_handler_address(self, address: tuple[str, int]) -> None: + self.lm_handler_address = address + if self.kernel_mode == "subprocess" and self._kc is not None: + # Update the kernel's cached address + update = textwrap.dedent( + f""" + _RLM_LM_ADDRESS = {list(address)!r} + """ + ).strip() + self._execute_in_kernel(update) + + # ------------------------------------------------------------------------- + # Execution + # ------------------------------------------------------------------------- + + def execute_code(self, code: str) -> REPLResult: + if self.kernel_mode == "in_process": + return self._execute_in_process(code) + return self._execute_in_kernel(code, timeout=self.cell_timeout) + + def _execute_in_process(self, code: str) -> REPLResult: + start_time = time.perf_counter() + self._pending_llm_calls = [] + + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + timeout = self.cell_timeout + use_alarm = ( + timeout is not None + and timeout > 0 + and sys.platform != "win32" + and threading.current_thread() is threading.main_thread() + and hasattr(signal, "SIGALRM") + ) + + def _alarm_handler(signum, frame): + raise TimeoutError(f"cell execution exceeded {timeout}s and was interrupted") + + prev_handler = None + prev_timer: tuple[float, float] | None = None + + with self._lock, self._temp_cwd(): + if use_alarm: + prev_handler = signal.signal(signal.SIGALRM, _alarm_handler) + prev_timer = signal.setitimer(signal.ITIMER_REAL, timeout) + try: + with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): + try: + result = self._shell.run_cell(code, store_history=False, silent=False) + except Exception as e: + stderr_buf.write(f"\n{type(e).__name__}: {e}") + result = None + finally: + if use_alarm: + # Clear the pending alarm before restoring the handler, or + # a late-firing SIGALRM could escape into unrelated code. + signal.setitimer(signal.ITIMER_REAL, 0) + if prev_handler is not None: + signal.signal(signal.SIGALRM, prev_handler) + # Restore any previously-scheduled timer the caller had set. + if prev_timer and prev_timer[0] > 0: + signal.setitimer(signal.ITIMER_REAL, *prev_timer) + + if result is not None: + if result.error_before_exec is not None: + err = result.error_before_exec + stderr_buf.write(f"\n{type(err).__name__}: {err}") + if result.error_in_exec is not None: + err = result.error_in_exec + stderr_buf.write(f"\n{type(err).__name__}: {err}") + + # Re-inject scaffold in case user code overwrote it + self._restore_scaffold_in_process() + + locals_snapshot = { + k: v + for k, v in self._shell.user_ns.items() + if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES + } + + final_answer = self._last_final_answer + self._last_final_answer = None + + return REPLResult( + stdout=stdout_buf.getvalue(), + stderr=stderr_buf.getvalue(), + locals=locals_snapshot, + execution_time=time.perf_counter() - start_time, + rlm_calls=self._pending_llm_calls.copy(), + final_answer=final_answer, + ) + + def _execute_in_kernel(self, code: str, timeout: float | None = None) -> REPLResult: + assert self._kc is not None and self._broker is not None + start_time = time.perf_counter() + + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + error_info: dict[str, Any] | None = None + + def output_hook(msg: dict[str, Any]) -> None: + nonlocal error_info + msg_type = msg.get("header", {}).get("msg_type") + content = msg.get("content", {}) + if msg_type == "stream": + name = content.get("name") + text = content.get("text", "") + if name == "stderr": + stderr_parts.append(text) + else: + stdout_parts.append(text) + elif msg_type == "error": + error_info = content + elif msg_type == "execute_result": + data = content.get("data", {}) + text = data.get("text/plain") + if text: + stdout_parts.append(text + "\n") + elif msg_type == "display_data": + data = content.get("data", {}) + text = data.get("text/plain") + if text: + stdout_parts.append(text + "\n") + + timed_out = False + try: + self._kc.execute_interactive( + code, + timeout=timeout, + output_hook=output_hook, + store_history=False, + stop_on_error=False, + allow_stdin=False, + ) + except TimeoutError: + timed_out = True + try: + self._km.interrupt_kernel() + except Exception: + pass + stderr_parts.append( + f"\nTimeoutError: cell execution exceeded {timeout}s and was interrupted" + ) + + if error_info is not None and not timed_out: + ename = error_info.get("ename", "Error") + evalue = error_info.get("evalue", "") + traceback_lines = error_info.get("traceback") or [] + # Strip ANSI escape codes from tracebacks for cleaner stderr + tb = "\n".join(self._strip_ansi(line) for line in traceback_lines) + if tb: + stderr_parts.append("\n" + tb) + else: + stderr_parts.append(f"\n{ename}: {evalue}") + + # Drain broker state (rlm_query completions, FINAL_VAR answer) + completions, final_answer = self._broker.drain() + self._pending_llm_calls.extend(completions) + + return REPLResult( + stdout="".join(stdout_parts), + stderr="".join(stderr_parts), + locals={}, # serializing arbitrary user_ns through ZMQ is costly; skip for now + execution_time=time.perf_counter() - start_time, + rlm_calls=self._pending_llm_calls.copy(), + final_answer=final_answer, + ) + + @staticmethod + def _strip_ansi(s: str) -> str: + import re + + return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", s) + + def _restore_scaffold_in_process(self) -> None: + if self._shell is None: + return + ns = self._shell.user_ns + ns["llm_query"] = self._llm_query + ns["llm_query_batched"] = self._llm_query_batched + ns["rlm_query"] = self._rlm_query + ns["rlm_query_batched"] = self._rlm_query_batched + ns["FINAL_VAR"] = self._final_var + ns["SHOW_VARS"] = self._show_vars + if "context_0" in ns: + ns["context"] = ns["context_0"] + if "history_0" in ns: + ns["history"] = ns["history_0"] + # Re-inject custom tools if overwritten + for name, entry in self.custom_tools.items(): + if name in RESERVED_TOOL_NAMES: + continue + ns[name] = extract_tool_value(entry) + + # ------------------------------------------------------------------------- + # Convenience: expose a dict-like 'locals' view for parity with LocalREPL + # ------------------------------------------------------------------------- + + @property + def locals(self) -> dict[str, Any]: + if self.kernel_mode == "in_process" and self._shell is not None: + return { + k: v + for k, v in self._shell.user_ns.items() + if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES + } + # Subprocess: we don't round-trip the kernel's full user_ns. We do + # surface persistence-mode context/history via the shadow dict. + return dict(self._subprocess_shadow) + + @contextmanager + def _temp_cwd(self): + old = os.getcwd() + try: + os.chdir(self.temp_dir) + yield + finally: + try: + os.chdir(old) + except FileNotFoundError: + os.chdir(self.original_cwd) + + # ------------------------------------------------------------------------- + # Lifecycle + # ------------------------------------------------------------------------- + + def cleanup(self) -> None: + if self.kernel_mode == "subprocess": + if self._kc is not None: + try: + self._kc.stop_channels() + except Exception: + pass + self._kc = None + if self._km is not None: + try: + self._km.shutdown_kernel(now=True) + except Exception: + pass + self._km = None + if self._broker is not None: + try: + self._broker.stop() + except Exception: + pass + self._broker = None + + if self._shell is not None: + try: + self._shell.reset(new_session=False) + except Exception: + pass + self._shell = None + + try: + shutil.rmtree(self.temp_dir) + except Exception: + pass + + def __enter__(self) -> IPythonREPL: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + self.cleanup() + return False + + def __del__(self) -> None: + try: + self.cleanup() + except Exception: + pass diff --git a/tests/test_ipython_repl.py b/tests/test_ipython_repl.py new file mode 100644 index 000000000..e015d69d0 --- /dev/null +++ b/tests/test_ipython_repl.py @@ -0,0 +1,432 @@ +"""Tests for IPythonREPL — both in_process and subprocess kernel modes. + +These tests require the ipython optional extra: + pip install 'rlms[ipython]' + +Subprocess-mode tests are automatically skipped if jupyter_client/ipykernel +are unavailable. +""" + +from __future__ import annotations + +import sys +import threading +import time +from dataclasses import dataclass + +import pytest + +pytest.importorskip("IPython") + +from rlm.core.types import ModelUsageSummary, RLMChatCompletion, UsageSummary +from rlm.environments.ipython_repl import IPythonREPL + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +_has_subprocess = True +try: + import ipykernel # noqa: F401 + import jupyter_client # noqa: F401 +except ImportError: + _has_subprocess = False + + +BOTH_MODES = pytest.mark.parametrize( + "kernel_mode", + [ + "in_process", + pytest.param( + "subprocess", + marks=pytest.mark.skipif( + not _has_subprocess, + reason="jupyter_client/ipykernel not installed", + ), + ), + ], +) + + +@dataclass +class _FakeSubcall: + """A stand-in for RLM._subcall that records calls and returns canned responses.""" + + responses: list[str] + calls: list[tuple[str, str | None]] = None # type: ignore[assignment] + + def __post_init__(self) -> None: + self.calls = [] + self._i = 0 + + def __call__(self, prompt: str, model: str | None = None) -> RLMChatCompletion: + self.calls.append((prompt, model)) + response = self.responses[self._i % len(self.responses)] + self._i += 1 + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, + total_input_tokens=0, + total_output_tokens=0, + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response=response, + usage_summary=usage, + execution_time=0.001, + ) + + +# ----------------------------------------------------------------------------- +# Basic execution +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_simple_execution(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + result = repl.execute_code("x = 1 + 2\nprint(x)") + assert result.stderr == "" + assert "3" in result.stdout + + +@BOTH_MODES +def test_error_captured_in_stderr(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + result = repl.execute_code("1 / 0") + assert "ZeroDivisionError" in result.stderr + + +@BOTH_MODES +def test_variable_persistence_across_cells(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + repl.execute_code("a = 40") + repl.execute_code("b = a + 2") + result = repl.execute_code("print(b)") + assert "42" in result.stdout + + +def test_in_process_exposes_locals_dict(): + """In-process mode mirrors LocalREPL's .locals dict for parity.""" + with IPythonREPL(kernel_mode="in_process") as repl: + repl.execute_code("x = 42\ny = 'hi'") + assert repl.locals["x"] == 42 + assert repl.locals["y"] == "hi" + + +# ----------------------------------------------------------------------------- +# FINAL_VAR +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_final_var_returns_variable(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + repl.execute_code("answer = 'forty-two'") + result = repl.execute_code('FINAL_VAR("answer")') + assert result.final_answer == "forty-two" + + +@BOTH_MODES +def test_final_var_missing_variable(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + result = repl.execute_code('FINAL_VAR("does_not_exist")') + assert result.final_answer is None + assert "not found" in result.stdout.lower() or "not found" in result.stderr.lower() + + +@BOTH_MODES +def test_final_var_accepts_direct_value(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode) as repl: + result = repl.execute_code("FINAL_VAR(123)") + assert result.final_answer == "123" + + +# ----------------------------------------------------------------------------- +# Context loading +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_load_context_string(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode, context_payload="hello world") as repl: + result = repl.execute_code("print(len(context))") + assert "11" in result.stdout + + +@BOTH_MODES +def test_load_context_dict(kernel_mode: str): + payload = {"name": "alice", "count": 7} + with IPythonREPL(kernel_mode=kernel_mode, context_payload=payload) as repl: + result = repl.execute_code('print(context["name"], context["count"])') + assert "alice" in result.stdout + assert "7" in result.stdout + + +# ----------------------------------------------------------------------------- +# Custom tools +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_custom_tool_callable(kernel_mode: str): + def greet(name: str) -> str: + return f"hello, {name}" + + with IPythonREPL( + kernel_mode=kernel_mode, + custom_tools={"greet": greet}, + ) as repl: + result = repl.execute_code('print(greet("world"))') + assert "hello, world" in result.stdout + + +@BOTH_MODES +def test_custom_tool_data(kernel_mode: str): + with IPythonREPL( + kernel_mode=kernel_mode, + custom_tools={"MAGIC": 42}, + ) as repl: + result = repl.execute_code("print(MAGIC * 2)") + assert "84" in result.stdout + + +# ----------------------------------------------------------------------------- +# Recursive rlm_query +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_rlm_query_dispatches_to_subcall_fn(kernel_mode: str): + subcall = _FakeSubcall(responses=["child-answer"]) + with IPythonREPL(kernel_mode=kernel_mode, subcall_fn=subcall) as repl: + result = repl.execute_code('r = rlm_query("think harder")\nprint(r)') + assert "child-answer" in result.stdout + assert len(subcall.calls) == 1 + assert subcall.calls[0][0] == "think harder" + # Completion recorded on the REPLResult + assert len(result.rlm_calls) == 1 + assert result.rlm_calls[0].response == "child-answer" + + +@BOTH_MODES +def test_rlm_query_batched_dispatches(kernel_mode: str): + subcall = _FakeSubcall(responses=["a", "b", "c"]) + with IPythonREPL(kernel_mode=kernel_mode, subcall_fn=subcall) as repl: + result = repl.execute_code( + 'rs = rlm_query_batched(["p1","p2","p3"])\nprint(\',\'.join(rs))' + ) + assert "a,b,c" in result.stdout + assert len(subcall.calls) == 3 + assert len(result.rlm_calls) == 3 + + +@BOTH_MODES +def test_rlm_query_falls_back_when_no_subcall_fn(kernel_mode: str): + """Without subcall_fn, rlm_query falls through to llm_query (which errors + cleanly when no LM handler is configured).""" + with IPythonREPL(kernel_mode=kernel_mode, subcall_fn=None) as repl: + result = repl.execute_code('print(rlm_query("hi"))') + # Error either about missing subcall_fn, handler, or kernel address — + # the contract is: it doesn't crash and surfaces a clear error string. + assert "Error" in result.stdout + + +# ----------------------------------------------------------------------------- +# Subprocess-mode-specific: hard timeouts +# ----------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_subprocess_timeout_interrupts_cleanly(): + with IPythonREPL(kernel_mode="subprocess", cell_timeout=0.5) as repl: + start = time.perf_counter() + result = repl.execute_code("import time; time.sleep(5)") + elapsed = time.perf_counter() - start + + assert "TimeoutError" in result.stderr + # Must have actually interrupted, not waited the full 5s + assert elapsed < 3.0 + + # Kernel should still be alive and responsive afterward + followup = repl.execute_code("print('still-alive')") + assert "still-alive" in followup.stdout + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_subprocess_no_timeout_by_default(): + """Without cell_timeout, short code runs without issue.""" + with IPythonREPL(kernel_mode="subprocess") as repl: + result = repl.execute_code("print(sum(range(100)))") + assert "4950" in result.stdout + + +# ----------------------------------------------------------------------------- +# In-process-mode timeouts (SIGALRM-based, Unix + main thread only) +# ----------------------------------------------------------------------------- + + +_can_alarm = sys.platform != "win32" and threading.current_thread() is threading.main_thread() + + +@pytest.mark.skipif(not _can_alarm, reason="SIGALRM requires Unix main thread") +def test_in_process_timeout_interrupts_c_level_sleep(): + with IPythonREPL(kernel_mode="in_process", cell_timeout=0.3) as repl: + start = time.perf_counter() + result = repl.execute_code("import time; time.sleep(5)") + elapsed = time.perf_counter() - start + assert "TimeoutError" in result.stderr + assert elapsed < 2.0, f"expected fast interrupt, got {elapsed:.2f}s" + + +@pytest.mark.skipif(not _can_alarm, reason="SIGALRM requires Unix main thread") +def test_in_process_timeout_interrupts_python_loop(): + with IPythonREPL(kernel_mode="in_process", cell_timeout=0.3) as repl: + start = time.perf_counter() + result = repl.execute_code("i = 0\nwhile True: i += 1") + elapsed = time.perf_counter() - start + assert "TimeoutError" in result.stderr + assert elapsed < 2.0, f"expected fast interrupt, got {elapsed:.2f}s" + + +@pytest.mark.skipif(not _can_alarm, reason="SIGALRM requires Unix main thread") +def test_in_process_shell_alive_after_timeout(): + with IPythonREPL(kernel_mode="in_process", cell_timeout=0.3) as repl: + repl.execute_code("import time; time.sleep(5)") + followup = repl.execute_code("print('still-alive')") + assert "still-alive" in followup.stdout + + +@pytest.mark.skipif(not _can_alarm, reason="SIGALRM requires Unix main thread") +def test_in_process_timeout_no_fire_for_fast_cell(): + with IPythonREPL(kernel_mode="in_process", cell_timeout=2.0) as repl: + result = repl.execute_code("print(sum(range(1000)))") + assert result.stderr == "" + assert "499500" in result.stdout + + +# ----------------------------------------------------------------------------- +# Lifecycle +# ----------------------------------------------------------------------------- + + +def test_invalid_kernel_mode_rejected(): + with pytest.raises(ValueError): + IPythonREPL(kernel_mode="nonsense") # type: ignore[arg-type] + + +@BOTH_MODES +def test_cleanup_is_idempotent(kernel_mode: str): + repl = IPythonREPL(kernel_mode=kernel_mode) + repl.execute_code("x = 1") + repl.cleanup() + repl.cleanup() # must not raise + + +# ----------------------------------------------------------------------------- +# Persistence (SupportsPersistence protocol) +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_persistent_env_exposes_protocol(kernel_mode: str): + from rlm.environments import SupportsPersistence + + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + assert isinstance(repl, SupportsPersistence) + + +@BOTH_MODES +def test_add_context_versioning(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + i0 = repl.add_context("First") + i1 = repl.add_context("Second") + assert (i0, i1) == (0, 1) + assert repl.get_context_count() == 2 + + # Visible to executed code + r = repl.execute_code("print(context_0, '|', context_1, '|', context)") + assert "First" in r.stdout + assert "Second" in r.stdout + + +@BOTH_MODES +def test_add_history_versioning_and_alias(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + h1 = [{"role": "user", "content": "Q1"}] + h2 = [{"role": "user", "content": "Q2"}] + repl.add_history(h1) + repl.add_history(h2) + assert repl.get_history_count() == 2 + + r = repl.execute_code( + "print(history_0[0]['content'], history_1[0]['content'], history[0]['content'])" + ) + assert "Q1" in r.stdout + assert "Q2" in r.stdout + + +@BOTH_MODES +def test_history_is_deep_copied(kernel_mode: str): + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + h = [{"role": "user", "content": "original"}] + repl.add_history(h) + h[0]["content"] = "mutated" + r = repl.execute_code("print(history_0[0]['content'])") + assert "original" in r.stdout + assert "mutated" not in r.stdout + + +@BOTH_MODES +def test_update_handler_address(kernel_mode: str): + with IPythonREPL( + kernel_mode=kernel_mode, + lm_handler_address=("127.0.0.1", 5000), + persistent=True, + ) as repl: + repl.update_handler_address(("127.0.0.1", 6000)) + assert repl.lm_handler_address == ("127.0.0.1", 6000) + + +@BOTH_MODES +def test_context_alias_restored_after_user_overwrites_it(kernel_mode: str): + """If user code reassigns ``context``, the next cell sees it restored.""" + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + repl.add_context("original-text") + repl.execute_code("context = 'overwritten'") + # In in-process mode we restore aliases after every execute. Subprocess + # mode does not (that would add an extra round-trip) — document via + # behavior: context_0 always remains untouched in both modes. + r = repl.execute_code("print(context_0)") + assert "original-text" in r.stdout + + +def test_in_process_persistent_multi_turn_variables(): + """Variables defined in one cell are visible in later add_context calls.""" + with IPythonREPL(kernel_mode="in_process", persistent=True) as repl: + repl.add_context("Sales were $1000") + repl.execute_code("sales = 1000") + repl.add_context("Costs were $400") + r = repl.execute_code("profit = sales - 400\nprint(profit)") + assert "600" in r.stdout + + +# ----------------------------------------------------------------------------- +# Registry +# ----------------------------------------------------------------------------- + + +def test_get_environment_routes_ipython(): + from rlm.environments import get_environment + + env = get_environment("ipython", {"kernel_mode": "in_process"}) + try: + assert isinstance(env, IPythonREPL) + result = env.execute_code("print(2 ** 10)") + assert "1024" in result.stdout + finally: + env.cleanup() diff --git a/uv.lock b/uv.lock index 5e1be9de9..1d395552d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [[package]] name = "aiofiles" @@ -188,6 +192,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -270,6 +292,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -373,6 +465,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "coverage" version = "7.13.1" @@ -553,6 +654,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/98/9c8d359a6f4258d300e20daae130578716ff88e2531ba376522d72a8874e/daytona_toolbox_api_client_async-0.134.0-py3-none-any.whl", hash = "sha256:18f3f3dbfa847c35ffb85f297ac08e78c5ddcbad3a84cb7f7a96133ad76130ce", size = 163255, upload-time = "2026-01-21T13:23:42.905Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -658,6 +793,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/f3/6961beb9a1e77d01dee1dd48f00fb3064429c8abcfa26aa863eb7cb2b6dd/environs-14.5.0-py3-none-any.whl", hash = "sha256:1abd3e3a5721fb09797438d6c902bc2f35d4580dfaffe68b8ee588b67b504e13", size = 17202, upload-time = "2025-11-02T21:30:35.186Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "filelock" version = "3.20.1" @@ -920,6 +1064,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version < '3.12'" }, + { name = "jedi", marker = "python_full_version < '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.12'" }, + { name = "pexpect", marker = "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "stack-data", marker = "python_full_version < '3.12'" }, + { name = "traitlets", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + [[package]] name = "jiter" version = "0.12.0" @@ -1005,6 +1247,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, ] +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1026,6 +1297,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/41/e8cfdebc3a475c397e074ceaa12e86dca1b23e8e10c2ab4d4875f9a01418/marshmallow-4.2.1-py3-none-any.whl", hash = "sha256:d82b1a83cfbb4667d050850fbed4e9d4435576cb95f5ff37894f375dce201768", size = 48451, upload-time = "2026-01-23T13:00:23.009Z" }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1186,6 +1469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/d6/d547a7004b81fa0b2aafa143b09196f6635e4105cd9d2c641fa8a4051c05/multipart-1.3.0-py3-none-any.whl", hash = "sha256:439bf4b00fd7cb2dbff08ae13f49f4f49798931ecd8d496372c63537fa19f304", size = 14938, upload-time = "2025-07-26T15:09:36.884Z" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -1285,6 +1577,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -1355,6 +1668,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ae/5d38212ff0cbad2c468e9b5cf1e3235af04d123b55c4adaa8812abdeabbb/prime_sandboxes-0.2.9-py3-none-any.whl", hash = "sha256:dab4dd9c1f4f633386593cfb08f7a3dacb2de4eb6e75f4a2ec7f5f1c6ea2207b", size = 18824, upload-time = "2026-01-07T20:24:14.133Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1469,6 +1794,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1490,6 +1861,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1730,6 +2110,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1782,6 +2220,13 @@ e2b = [ { name = "dill" }, { name = "e2b-code-interpreter" }, ] +ipython = [ + { name = "dill" }, + { name = "ipykernel" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, +] modal = [ { name = "dill" }, { name = "modal" }, @@ -1809,10 +2254,14 @@ requires-dist = [ { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.128.1" }, { name = "dill", marker = "extra == 'daytona'", specifier = ">=0.3.7" }, { name = "dill", marker = "extra == 'e2b'", specifier = ">=0.3.7" }, + { name = "dill", marker = "extra == 'ipython'", specifier = ">=0.3.7" }, { name = "dill", marker = "extra == 'modal'", specifier = ">=0.3.7" }, { name = "dill", marker = "extra == 'prime'", specifier = ">=0.3.7" }, { name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = ">=0.0.11" }, { name = "google-genai", specifier = ">=1.56.0" }, + { name = "ipykernel", marker = "extra == 'ipython'", specifier = ">=6.0.0" }, + { name = "ipython", marker = "extra == 'ipython'", specifier = ">=8.0.0" }, + { name = "jupyter-client", marker = "extra == 'ipython'", specifier = ">=8.0.0" }, { name = "modal", marker = "extra == 'modal'", specifier = ">=0.73.0" }, { name = "openai", specifier = ">=2.14.0" }, { name = "portkey-ai", specifier = ">=2.1.0" }, @@ -1822,7 +2271,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=13.0.0" }, ] -provides-extras = ["modal", "e2b", "daytona", "prime"] +provides-extras = ["modal", "e2b", "daytona", "prime", "ipython"] [package.metadata.requires-dev] dev = [ @@ -1901,6 +2350,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "synchronicity" version = "0.11.1" @@ -1980,6 +2443,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1992,6 +2472,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + [[package]] name = "ty" version = "0.0.7" @@ -2205,6 +2694,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + [[package]] name = "websockets" version = "15.0.1" From 6bc4b8c24607933409d8e16835c75c006f3713d0 Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Sat, 25 Apr 2026 23:25:53 -0400 Subject: [PATCH 2/5] remove redundant example --- examples/ipython_repl_example.py | 129 ------------------------------- 1 file changed, 129 deletions(-) delete mode 100644 examples/ipython_repl_example.py diff --git a/examples/ipython_repl_example.py b/examples/ipython_repl_example.py deleted file mode 100644 index ebd981707..000000000 --- a/examples/ipython_repl_example.py +++ /dev/null @@ -1,129 +0,0 @@ -"""IPython REPL environment example. - -Runs user code inside a real IPython session. Two kernel modes: - -- ``in_process``: IPython InteractiveShell in the same Python process as RLM. - Fast, zero-overhead subcalls, full Python object interop. -- ``subprocess``: ipykernel subprocess managed via jupyter_client. Gives you - real per-cell timeouts via kernel interrupt, and full isolation from the - host namespace. - -Setup: - pip install 'rlms[ipython]' - python -m examples.ipython_repl_example -""" - -from __future__ import annotations - -from rlm.clients.base_lm import BaseLM -from rlm.core.lm_handler import LMHandler -from rlm.core.types import ModelUsageSummary, UsageSummary -from rlm.environments.ipython_repl import IPythonREPL - - -class MockLM(BaseLM): - def __init__(self): - super().__init__(model_name="mock") - - def completion(self, prompt): - return f"Mock: {str(prompt)[:50]}" - - async def acompletion(self, prompt): - return self.completion(prompt) - - def get_usage_summary(self): - return UsageSummary({"mock": ModelUsageSummary(1, 10, 10)}) - - def get_last_usage(self): - return self.get_usage_summary() - - -def section(title: str) -> None: - print("\n" + "=" * 60) - print(title) - print("=" * 60) - - -def demo_in_process() -> None: - section("[1] In-process IPython kernel") - with IPythonREPL(kernel_mode="in_process") as repl: - r = repl.execute_code("x = 40\ny = 2\nprint(x + y)") - print(f" stdout: {r.stdout.strip()}") - print(f" locals: x={repl.locals['x']}, y={repl.locals['y']}") - - # FINAL_VAR captures the final answer out-of-band. - repl.execute_code('answer = "forty-two"') - r = repl.execute_code('FINAL_VAR("answer")') - print(f" FINAL_VAR → {r.final_answer}") - - -def demo_subprocess() -> None: - section("[2] Subprocess ipykernel — real timeouts") - with IPythonREPL(kernel_mode="subprocess", cell_timeout=1.0) as repl: - r = repl.execute_code("import sys; print(sys.executable)") - print(f" kernel python: {r.stdout.strip()}") - - r = repl.execute_code("xs = [i*i for i in range(5)]; print(xs)") - print(f" stdout: {r.stdout.strip()}") - - print(" -- running a 5s sleep with cell_timeout=1.0s --") - r = repl.execute_code("import time; time.sleep(5); print('never')") - print(f" elapsed: {r.execution_time:.2f}s") - print(f" stderr: {r.stderr.strip()}") - - # Kernel survives the interrupt and still responds. - r = repl.execute_code("print('kernel still alive')") - print(f" follow-up: {r.stdout.strip()}") - - -def demo_llm_query() -> None: - section("[3] llm_query plumbing via LMHandler") - with LMHandler(client=MockLM()) as handler: - for mode in ("in_process", "subprocess"): - print(f"\n -- kernel_mode={mode} --") - with IPythonREPL( - kernel_mode=mode, - lm_handler_address=handler.address, - ) as repl: - r = repl.execute_code('r = llm_query("ping"); print(r)') - print(f" response: {r.stdout.strip()}") - - r = repl.execute_code('print(llm_query_batched(["a", "b"]))') - print(f" batched: {r.stdout.strip()}") - - -def demo_recursion() -> None: - section("[4] rlm_query — recursive RLM subcalls") - # Stand-in for RLM._subcall: returns a canned reply for the demo. - from rlm.core.types import RLMChatCompletion - - class _FakeSubcall: - def __call__(self, prompt: str, model=None) -> RLMChatCompletion: - return RLMChatCompletion( - root_model="fake", - prompt=prompt, - response=f"child-answer[{prompt}]", - usage_summary=UsageSummary({"fake": ModelUsageSummary(1, 0, 0)}), - execution_time=0.0, - ) - - subcall = _FakeSubcall() - for mode in ("in_process", "subprocess"): - print(f"\n -- kernel_mode={mode} --") - with IPythonREPL(kernel_mode=mode, subcall_fn=subcall) as repl: - r = repl.execute_code('print(rlm_query("deep-think-topic-A"))') - print(f" single: {r.stdout.strip()}") - r = repl.execute_code('print(rlm_query_batched(["t1","t2","t3"]))') - print(f" batched: {r.stdout.strip()}") - - -def main() -> None: - demo_in_process() - demo_subprocess() - demo_llm_query() - demo_recursion() - print("\nDone.") - - -if __name__ == "__main__": - main() From c24fa1529185560354a1f8c5adbb3dbdb36d07f2 Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Sun, 26 Apr 2026 14:28:36 -0400 Subject: [PATCH 3/5] ipython parallel adjustments --- rlm/environments/__init__.py | 11 + rlm/environments/ipython_repl.py | 375 +++++++++++++++++++++++-------- tests/test_ipython_repl.py | 209 +++++++++++++++++ 3 files changed, 503 insertions(+), 92 deletions(-) diff --git a/rlm/environments/__init__.py b/rlm/environments/__init__.py index 4f23748e6..5a7ba8f0c 100644 --- a/rlm/environments/__init__.py +++ b/rlm/environments/__init__.py @@ -14,6 +14,17 @@ ) from rlm.environments.local_repl import LocalREPL + +def __getattr__(name: str) -> Any: + # Lazy-load environments with optional dependencies so that the package + # imports cleanly even when those extras aren't installed. + if name == "IPythonREPL": + from rlm.environments.ipython_repl import IPythonREPL + + return IPythonREPL + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "BaseEnv", "IPythonREPL", diff --git a/rlm/environments/ipython_repl.py b/rlm/environments/ipython_repl.py index 0ae7d7f29..aa87320b5 100644 --- a/rlm/environments/ipython_repl.py +++ b/rlm/environments/ipython_repl.py @@ -21,6 +21,7 @@ import io import json import os +import re import shutil import signal import socketserver @@ -29,6 +30,7 @@ import textwrap import threading import time +import traceback import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed @@ -74,6 +76,18 @@ } ) +# Matches the most common terminal escape forms emitted by IPython tracebacks: +# CSI (``ESC[…``), OSC (``ESC]…BEL`` or ``ESC]…ESC\``), and the 2-byte +# ``ESC `` form. The previous CSI-only regex left OSC/ST sequences in +# place when the surrounding terminal supported them. +_ANSI_RE = re.compile( + r"\x1b(?:" + r"\[[0-9;?]*[ -/]*[@-~]" # CSI + r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC, BEL- or ST-terminated + r"|[@-Z\\-_]" # 2-byte escape + r")" +) + # ============================================================================= # Subcall broker (subprocess kernel mode) @@ -111,6 +125,12 @@ def __init__( self._server: socketserver.ThreadingTCPServer | None = None self._thread: threading.Thread | None = None self._lock = threading.Lock() + # Bounds total in-flight ``subcall_fn`` invocations across *all* + # broker requests (single + batched), regardless of how many + # connections the kernel opens. Per-request thread pools alone + # only bound concurrency within a single batched request. + self._subcall_semaphore = threading.Semaphore(max(1, max_concurrent)) + self._shutting_down = False self.completions: list[RLMChatCompletion] = [] self.final_answer: str | None = None @@ -143,9 +163,28 @@ class _Server(socketserver.ThreadingTCPServer): self._thread.start() return self.address + def _run_subcall(self, prompt: str, model: str | None) -> RLMChatCompletion: + """Invoke ``subcall_fn`` under the global concurrency semaphore. + + Without this gate, kernel-side code that fans out N concurrent + ``rlm_query`` calls (or runs N independent ``rlm_query_batched`` + requests) would invoke ``subcall_fn`` N times in parallel, ignoring + ``max_concurrent``. + """ + assert self.subcall_fn is not None + with self._subcall_semaphore: + return self.subcall_fn(prompt, model) + def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: req_type = data.get("type") + # Reject new subcall work once cleanup has begun, so we don't kick + # off API calls (which incur cost) for a kernel that's about to + # die. ``final_var`` still goes through — it's cheap and we may + # already have its accumulated answer to drain. + if self._shutting_down and req_type in ("subcall", "subcall_batched"): + return {"error": "Broker shutting down"} + if req_type == "final_var": value = data.get("value") with self._lock: @@ -156,7 +195,7 @@ def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: if self.subcall_fn is None: return {"error": "No subcall_fn configured; rlm_query unavailable"} try: - completion = self.subcall_fn(data.get("prompt", ""), data.get("model")) + completion = self._run_subcall(data.get("prompt", ""), data.get("model")) with self._lock: self.completions.append(completion) return {"completion": completion.to_dict()} @@ -175,13 +214,17 @@ def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: def _one(i: int, prompt: str) -> None: try: - completion = self.subcall_fn(prompt, model) # type: ignore[misc] + # _run_subcall already gates on the global semaphore. + completion = self._run_subcall(prompt, model) with local_lock: local_completions.append((i, completion)) responses[i] = completion.response except Exception as e: errors[i] = f"{type(e).__name__}: {e}" + # Per-batch pool size is capped at ``max_concurrent`` for + # symmetry, but the semaphore inside ``_run_subcall`` is what + # actually bounds total in-flight subcalls across requests. max_workers = max(1, min(self.max_concurrent, len(prompts) or 1)) with ThreadPoolExecutor(max_workers=max_workers) as ex: futures = [ex.submit(_one, i, p) for i, p in enumerate(prompts)] @@ -202,6 +245,12 @@ def _one(i: int, prompt: str) -> None: return {"error": f"Unknown message type: {req_type!r}"} def stop(self) -> None: + # Flip the flag *before* tearing down the server so any handler + # threads still inside ``_dispatch`` reject new subcall work + # instead of starting a fresh ``subcall_fn`` (which can incur API + # cost). Already-running ``subcall_fn`` invocations will run to + # completion — we can't cooperatively cancel arbitrary user code. + self._shutting_down = True if self._server is not None: self._server.shutdown() self._server.server_close() @@ -233,6 +282,7 @@ def _build_kernel_bootstrap( lm_address: tuple[str, int] | None, subcall_address: tuple[str, int] | None, depth: int, + subcall_timeout: float | None, ) -> str: """Code executed once inside the kernel to wire up scaffold helpers.""" return textwrap.dedent( @@ -244,6 +294,7 @@ def _build_kernel_bootstrap( _RLM_LM_ADDRESS = {list(lm_address) if lm_address else None!r} _RLM_SUBCALL_ADDRESS = {list(subcall_address) if subcall_address else None!r} _RLM_DEPTH = {depth} + _RLM_SUBCALL_TIMEOUT = {subcall_timeout!r} def _rlm_socket_send(sock, data): payload = _rlm_json.dumps(data).encode("utf-8") @@ -254,18 +305,20 @@ def _rlm_socket_recv(sock): while len(raw_len) < 4: chunk = sock.recv(4 - len(raw_len)) if not chunk: - return {{}} + # Surface EOF as an explicit error so callers don't read + # an empty dict as a successful empty completion. + return {{"error": "Connection closed before length prefix received"}} raw_len += chunk length = _rlm_struct.unpack(">I", raw_len)[0] payload = b"" while len(payload) < length: chunk = sock.recv(length - len(payload)) if not chunk: - raise ConnectionError("Connection closed before message complete") + return {{"error": "Connection closed before message complete"}} payload += chunk return _rlm_json.loads(payload.decode("utf-8")) - def _rlm_request(address, data, timeout=300): + def _rlm_request(address, data, timeout=_RLM_SUBCALL_TIMEOUT): if address is None: return {{"error": "No address configured"}} sock = _rlm_socket.socket(_rlm_socket.AF_INET, _rlm_socket.SOCK_STREAM) @@ -382,6 +435,24 @@ def SHOW_VARS(): class IPythonREPL(NonIsolatedEnv): """IPython-backed REPL with in-process or subprocess kernel modes. + Concurrency / isolation: + * ``execute_code`` is serialized within an instance via an + :class:`~threading.RLock`, in both modes. + * Subcall fan-out (``rlm_query`` / ``rlm_query_batched``) is bounded + *globally* per broker by ``max_concurrent_subcalls``, regardless + of how many concurrent kernel requests fan in. + * In-process mode shares the parent Python process: ``sys.stdout`` + /``sys.stderr`` redirection, ``os.chdir``, and ``signal.SIGALRM`` + are process-global, and any other thread in the parent that + touches them while a cell is running will see the shadowed + state. Two in-process instances each get a *unique* user + module so they don't trample each other's ``sys.modules`` + entry, but they still share the surrounding process. Use + ``kernel_mode="subprocess"`` for true isolation. + * Subprocess mode runs user code in a separate Python process via + ``ipykernel`` and is fully isolated from the parent's + namespace, cwd, and signals. + Args: lm_handler_address: (host, port) of the LM handler socket server. context_payload: Initial context to load as ``context``/``context_0``. @@ -403,6 +474,11 @@ class IPythonREPL(NonIsolatedEnv): Windows or when called off the main thread. startup_timeout: Max seconds to wait for a ``subprocess`` kernel to become ready. + subcall_timeout: Per-request timeout (seconds) for the kernel→parent + socket round-trip used by ``llm_query`` / ``rlm_query`` / + ``FINAL_VAR`` in subprocess mode. ``None`` (default) disables + the timeout, which matches in-process behavior where subcalls + block indefinitely. max_concurrent_subcalls: Cap on concurrent ``rlm_query_batched`` calls. """ @@ -419,6 +495,7 @@ def __init__( kernel_mode: KernelMode = "in_process", cell_timeout: float | None = None, startup_timeout: float = 60.0, + subcall_timeout: float | None = None, max_concurrent_subcalls: int = 4, **kwargs, ): @@ -437,8 +514,12 @@ def __init__( self.lm_handler_address = lm_handler_address self.subcall_fn = subcall_fn self.kernel_mode: KernelMode = kernel_mode - self.cell_timeout = cell_timeout + # Normalize cell_timeout: 0 or negative is meaningless (subprocess + # mode would interpret it as "give up immediately"). Treat as + # disabled to match the in-process ``timeout > 0`` guard. + self.cell_timeout = cell_timeout if cell_timeout and cell_timeout > 0 else None self.startup_timeout = startup_timeout + self.subcall_timeout = subcall_timeout self.custom_tools = custom_tools or {} self.custom_sub_tools = ( @@ -448,7 +529,10 @@ def __init__( self.original_cwd = os.getcwd() self.temp_dir = tempfile.mkdtemp(prefix=f"ipython_env_{uuid.uuid4()}_") - self._lock = threading.Lock() + # RLock lets ``add_context`` / ``add_history`` hold the lock while + # invoking ``execute_code`` (which re-acquires it) so the + # index→assign→count-increment sequence stays atomic across threads. + self._lock = threading.RLock() self._context_count: int = 0 self._history_count: int = 0 @@ -465,18 +549,28 @@ def __init__( # In-process: IPython shell instance self._shell: Any = None + # Per-instance ``__main__`` substitute used in in-process mode + # (see ``_setup_in_process``). + self._user_module: Any = None # Subprocess: jupyter_client manager/client self._km: Any = None self._kc: Any = None self._broker: _SubcallBroker | None = None - self.setup() + # If anything below fails after a kernel/broker has started, we must + # tear them down explicitly — relying on ``__del__`` is timing- + # dependent and would leave kernels orphaned on crash. + try: + self.setup() - if context_payload is not None: - self.load_context(context_payload) + if context_payload is not None: + self.load_context(context_payload) - if setup_code: - self.execute_code(setup_code) + if setup_code: + self.execute_code(setup_code) + except BaseException: + self.cleanup() + raise # ------------------------------------------------------------------------- # Setup @@ -491,16 +585,38 @@ def setup(self) -> None: def _setup_in_process(self) -> None: try: from IPython.core.interactiveshell import InteractiveShell + from traitlets.config import Config except ImportError as e: raise ImportError( "IPython is required for IPythonREPL. Install with: " "pip install 'rlms[ipython]' or pip install ipython" ) from e + # Disable IPython's history SQLite database. Without this, every + # in-process instance opens (and leaks until __del__) a connection + # to ~/.ipython/profile_default/history.sqlite, even though we + # already pass ``store_history=False`` to ``run_cell``. + config = Config() + config.HistoryAccessor.enabled = False + + # By default, ``InteractiveShell`` names its user module + # ``__main__`` and writes ``sys.modules['__main__'] = user_module``. + # Two in-process IPythonREPLs in the same process would overwrite + # each other's ``__main__``. Use a unique module name per instance + # so each gets its own ``sys.modules`` slot. (The kernel-side + # ``__main__`` of the parent process is left untouched.) + import types as _types + + unique_main = _types.ModuleType( + f"_rlm_ipython_main_{uuid.uuid4().hex[:8]}", + doc="Per-instance namespace for an in-process IPythonREPL.", + ) + # InteractiveShell is a singleton by default; using .instance() would # leak state across multiple IPythonREPL objects. Create a fresh one. - shell = InteractiveShell() + shell = InteractiveShell(config=config, user_module=unique_main) self._shell = shell + self._user_module = unique_main ns = shell.user_ns # Inject scaffold functions @@ -554,8 +670,11 @@ def _setup_subprocess(self) -> None: lm_address=self.lm_handler_address, subcall_address=self._broker.address, depth=self.depth, + subcall_timeout=self.subcall_timeout, + ) + result = self._execute_in_kernel( + bootstrap, timeout=self.startup_timeout, drain_broker=False ) - result = self._execute_in_kernel(bootstrap, timeout=self.startup_timeout) if result.stderr: raise RuntimeError(f"Kernel bootstrap failed:\n{result.stderr}") @@ -599,7 +718,7 @@ def _inject_custom_tools_subprocess(self) -> None: {name} = _rlm_dill.loads(bytes.fromhex({payload!r})) """ ).strip() - result = self._execute_in_kernel(code) + result = self._execute_in_kernel(code, drain_broker=False) if result.stderr: raise RuntimeError( f"Failed to inject custom tool {name!r} into kernel: {result.stderr}" @@ -616,7 +735,7 @@ def _inject_custom_tools_subprocess(self) -> None: f"callables/objects. ({e})" ) from e code = f"import json as _rlm_json; {name} = _rlm_json.loads({json_payload!r})" - result = self._execute_in_kernel(code) + result = self._execute_in_kernel(code, drain_broker=False) if result.stderr: raise RuntimeError( f"Failed to inject custom tool {name!r} into kernel: {result.stderr}" @@ -755,40 +874,49 @@ def add_context( context_payload: dict | list | str, context_index: int | None = None, ) -> int: - if context_index is None: - context_index = self._context_count - var_name = f"context_{context_index}" - - if isinstance(context_payload, str): - context_path = os.path.join(self.temp_dir, f"context_{context_index}.txt") - with open(context_path, "w") as f: - f.write(context_payload) - code = f"with open(r'{context_path}', 'r') as _rlm_f:\n {var_name} = _rlm_f.read()" - else: - context_path = os.path.join(self.temp_dir, f"context_{context_index}.json") - with open(context_path, "w") as f: - json.dump(context_payload, f) - code = ( - "import json as _rlm_json\n" - f"with open(r'{context_path}', 'r') as _rlm_f:\n" - f" {var_name} = _rlm_json.load(_rlm_f)" - ) + # Hold the env lock for the whole index → write → increment + # sequence. Without this, two concurrent ``add_context`` calls with + # ``context_index=None`` would both pick the same index and + # silently overwrite each other. + with self._lock: + if context_index is None: + context_index = self._context_count + var_name = f"context_{context_index}" + + if isinstance(context_payload, str): + context_path = os.path.join(self.temp_dir, f"context_{context_index}.txt") + with open(context_path, "w") as f: + f.write(context_payload) + code = ( + f"with open(r'{context_path}', 'r') as _rlm_f:\n {var_name} = _rlm_f.read()" + ) + else: + context_path = os.path.join(self.temp_dir, f"context_{context_index}.json") + with open(context_path, "w") as f: + json.dump(context_payload, f) + code = ( + "import json as _rlm_json\n" + f"with open(r'{context_path}', 'r') as _rlm_f:\n" + f" {var_name} = _rlm_json.load(_rlm_f)" + ) - result = self.execute_code(code) - if result.stderr: - raise RuntimeError(f"Failed to load context: {result.stderr}") + # Fold the ``context = context_0`` alias into the same cell to + # save a kernel round-trip in subprocess mode. + if context_index == 0: + code += f"\ncontext = {var_name}" - if context_index == 0: - self.execute_code(f"context = {var_name}") + result = self.execute_code(code) + if result.stderr: + raise RuntimeError(f"Failed to load context: {result.stderr}") - # Shadow for subprocess mode so self.locals can report context_N. - if self.kernel_mode == "subprocess": - self._subprocess_shadow[var_name] = copy.deepcopy(context_payload) - if context_index == 0: - self._subprocess_shadow["context"] = self._subprocess_shadow[var_name] + # Shadow for subprocess mode so self.locals can report context_N. + if self.kernel_mode == "subprocess": + self._subprocess_shadow[var_name] = copy.deepcopy(context_payload) + if context_index == 0: + self._subprocess_shadow["context"] = self._subprocess_shadow[var_name] - self._context_count = max(self._context_count, context_index + 1) - return context_index + self._context_count = max(self._context_count, context_index + 1) + return context_index def get_context_count(self) -> int: return self._context_count @@ -799,39 +927,42 @@ def add_history( history_index: int | None = None, ) -> int: """Store a message history as ``history_N`` (and ``history`` for index 0).""" - if history_index is None: - history_index = self._history_count - var_name = f"history_{history_index}" - payload = copy.deepcopy(message_history) - - # In-process: assign directly into user_ns. - if self.kernel_mode == "in_process": - assert self._shell is not None - self._shell.user_ns[var_name] = payload - if history_index == 0: - self._shell.user_ns["history"] = payload - else: - # Subprocess: round-trip via a temp JSON file (histories are - # always JSON-serializable by construction: role/content dicts). - history_path = os.path.join(self.temp_dir, f"history_{history_index}.json") - with open(history_path, "w") as f: - json.dump(payload, f) - code = ( - "import json as _rlm_json\n" - f"with open(r'{history_path}', 'r') as _rlm_f:\n" - f" {var_name} = _rlm_json.load(_rlm_f)" - ) - result = self.execute_code(code) - if result.stderr: - raise RuntimeError(f"Failed to load history: {result.stderr}") - if history_index == 0: - self.execute_code(f"history = {var_name}") - self._subprocess_shadow[var_name] = payload - if history_index == 0: - self._subprocess_shadow["history"] = payload + # See ``add_context`` for the rationale on holding ``self._lock``. + with self._lock: + if history_index is None: + history_index = self._history_count + var_name = f"history_{history_index}" + payload = copy.deepcopy(message_history) + + # In-process: assign directly into user_ns. + if self.kernel_mode == "in_process": + assert self._shell is not None + self._shell.user_ns[var_name] = payload + if history_index == 0: + self._shell.user_ns["history"] = payload + else: + # Subprocess: round-trip via a temp JSON file (histories are + # always JSON-serializable by construction: role/content + # dicts). + history_path = os.path.join(self.temp_dir, f"history_{history_index}.json") + with open(history_path, "w") as f: + json.dump(payload, f) + code = ( + "import json as _rlm_json\n" + f"with open(r'{history_path}', 'r') as _rlm_f:\n" + f" {var_name} = _rlm_json.load(_rlm_f)" + ) + if history_index == 0: + code += f"\nhistory = {var_name}" + result = self.execute_code(code) + if result.stderr: + raise RuntimeError(f"Failed to load history: {result.stderr}") + self._subprocess_shadow[var_name] = payload + if history_index == 0: + self._subprocess_shadow["history"] = payload - self._history_count = max(self._history_count, history_index + 1) - return history_index + self._history_count = max(self._history_count, history_index + 1) + return history_index def get_history_count(self) -> int: return self._history_count @@ -845,7 +976,7 @@ def update_handler_address(self, address: tuple[str, int]) -> None: _RLM_LM_ADDRESS = {list(address)!r} """ ).strip() - self._execute_in_kernel(update) + self._execute_in_kernel(update, drain_broker=False) # ------------------------------------------------------------------------- # Execution @@ -854,7 +985,7 @@ def update_handler_address(self, address: tuple[str, int]) -> None: def execute_code(self, code: str) -> REPLResult: if self.kernel_mode == "in_process": return self._execute_in_process(code) - return self._execute_in_kernel(code, timeout=self.cell_timeout) + return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True) def _execute_in_process(self, code: str) -> REPLResult: start_time = time.perf_counter() @@ -901,12 +1032,23 @@ def _alarm_handler(signum, frame): signal.setitimer(signal.ITIMER_REAL, *prev_timer) if result is not None: + # IPython's own traceback printer doesn't reliably go through + # ``sys.stderr`` (it binds to ``IPython.utils.io.stderr`` at + # import time, before our ``redirect_stderr``). Format the + # exception ourselves so the LLM sees a useful traceback, + # matching the verbosity of subprocess-mode output. if result.error_before_exec is not None: err = result.error_before_exec - stderr_buf.write(f"\n{type(err).__name__}: {err}") + stderr_buf.write( + "\n" + + "".join(traceback.format_exception(type(err), err, err.__traceback__)) + ) if result.error_in_exec is not None: err = result.error_in_exec - stderr_buf.write(f"\n{type(err).__name__}: {err}") + stderr_buf.write( + "\n" + + "".join(traceback.format_exception(type(err), err, err.__traceback__)) + ) # Re-inject scaffold in case user code overwrote it self._restore_scaffold_in_process() @@ -929,10 +1071,43 @@ def _alarm_handler(signum, frame): final_answer=final_answer, ) - def _execute_in_kernel(self, code: str, timeout: float | None = None) -> REPLResult: + def _execute_in_kernel( + self, + code: str, + timeout: float | None = None, + drain_broker: bool = True, + ) -> REPLResult: + """Execute ``code`` in the subprocess kernel. + + ``drain_broker=False`` is for setup paths (bootstrap, custom-tool + injection, ``update_handler_address``) that must not consume + completions destined for a future user-facing cell. User-facing + ``execute_code`` always passes ``drain_broker=True``. + + Acquires ``self._lock`` so concurrent ``execute_code`` calls from + different threads don't race on ``_kc.execute_interactive`` or on + broker drain. + """ assert self._kc is not None and self._broker is not None + + with self._lock: + return self._execute_in_kernel_locked(code, timeout, drain_broker) + + def _execute_in_kernel_locked( + self, + code: str, + timeout: float | None, + drain_broker: bool, + ) -> REPLResult: start_time = time.perf_counter() + # If a previous cell timed out mid-subcall, ``subcall_fn`` may have + # finished after we drained, leaving stragglers in the broker. + # Discard them so they aren't misattributed to *this* cell. + if drain_broker: + assert self._broker is not None + self._broker.drain() + stdout_parts: list[str] = [] stderr_parts: list[str] = [] error_info: dict[str, Any] | None = None @@ -992,24 +1167,26 @@ def output_hook(msg: dict[str, Any]) -> None: else: stderr_parts.append(f"\n{ename}: {evalue}") - # Drain broker state (rlm_query completions, FINAL_VAR answer) - completions, final_answer = self._broker.drain() - self._pending_llm_calls.extend(completions) + # Drain broker state (rlm_query completions, FINAL_VAR answer) only on + # user-facing cells. ``rlm_calls`` reports what was made *during this + # cell* — never accumulate across cells. + if drain_broker: + completions, final_answer = self._broker.drain() + else: + completions, final_answer = [], None return REPLResult( stdout="".join(stdout_parts), stderr="".join(stderr_parts), locals={}, # serializing arbitrary user_ns through ZMQ is costly; skip for now execution_time=time.perf_counter() - start_time, - rlm_calls=self._pending_llm_calls.copy(), + rlm_calls=list(completions), final_answer=final_answer, ) @staticmethod def _strip_ansi(s: str) -> str: - import re - - return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", s) + return _ANSI_RE.sub("", s) def _restore_scaffold_in_process(self) -> None: if self._shell is None: @@ -1037,14 +1214,21 @@ def _restore_scaffold_in_process(self) -> None: @property def locals(self) -> dict[str, Any]: + """Snapshot of the user-defined namespace. + + In-process mode mirrors the IPython shell's ``user_ns``. Subprocess + mode only tracks variables we ourselves injected via ``add_context`` + / ``add_history`` (the parent doesn't round-trip the kernel's + ``user_ns`` on every cell). User-defined variables from + ``execute_code`` are not visible here in subprocess mode — query the + kernel directly with another cell instead. + """ if self.kernel_mode == "in_process" and self._shell is not None: return { k: v for k, v in self._shell.user_ns.items() if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES } - # Subprocess: we don't round-trip the kernel's full user_ns. We do - # surface persistence-mode context/history via the shadow dict. return dict(self._subprocess_shadow) @contextmanager @@ -1091,6 +1275,13 @@ def cleanup(self) -> None: pass self._shell = None + # Drop the per-instance module from sys.modules so we don't + # accumulate one slot per IPythonREPL ever created. + user_module = getattr(self, "_user_module", None) + if user_module is not None: + sys.modules.pop(user_module.__name__, None) + self._user_module = None + try: shutil.rmtree(self.temp_dir) except Exception: diff --git a/tests/test_ipython_repl.py b/tests/test_ipython_repl.py index e015d69d0..96d241bf7 100644 --- a/tests/test_ipython_repl.py +++ b/tests/test_ipython_repl.py @@ -430,3 +430,212 @@ def test_get_environment_routes_ipython(): assert "1024" in result.stdout finally: env.cleanup() + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_get_environment_routes_ipython_subprocess(): + from rlm.environments import get_environment + + env = get_environment("ipython", {"kernel_mode": "subprocess"}) + try: + assert isinstance(env, IPythonREPL) + result = env.execute_code("print(2 ** 10)") + assert "1024" in result.stdout + finally: + env.cleanup() + + +def test_ipython_repl_importable_from_package(): + """``IPythonREPL`` is in ``__all__`` and must be importable lazily.""" + from rlm.environments import IPythonREPL as Imported + + assert Imported is IPythonREPL + + +# ----------------------------------------------------------------------------- +# rlm_calls accounting (regression: prior subprocess impl leaked across cells) +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_rlm_calls_do_not_leak_across_cells(kernel_mode: str): + """Each REPLResult.rlm_calls must reflect only the current cell's calls.""" + subcall = _FakeSubcall(responses=["x"]) + with IPythonREPL(kernel_mode=kernel_mode, subcall_fn=subcall) as repl: + r1 = repl.execute_code('rlm_query("a")') + r2 = repl.execute_code('rlm_query("b")') + r3 = repl.execute_code('rlm_query("c")') + r4 = repl.execute_code('print("no rlm here")') + assert len(r1.rlm_calls) == 1 + assert len(r2.rlm_calls) == 1 + assert len(r3.rlm_calls) == 1 + assert len(r4.rlm_calls) == 0 + + +# ----------------------------------------------------------------------------- +# cell_timeout normalization +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_cell_timeout_zero_treated_as_disabled(kernel_mode: str): + """``cell_timeout=0`` is meaningless; treated as ``None``.""" + with IPythonREPL(kernel_mode=kernel_mode, cell_timeout=0) as repl: + assert repl.cell_timeout is None + # And short code still runs without spurious timeouts. + result = repl.execute_code("print(2 + 2)") + assert "4" in result.stdout + + +# ----------------------------------------------------------------------------- +# In-process error formatting +# ----------------------------------------------------------------------------- + + +def test_in_process_error_includes_traceback_and_user_frame(): + """In-process stderr should include a real traceback, not just a one-liner.""" + with IPythonREPL(kernel_mode="in_process") as repl: + result = repl.execute_code("def boom():\n 1/0\n\nboom()") + assert "ZeroDivisionError" in result.stderr + assert "Traceback" in result.stderr + assert "boom" in result.stderr # user frame name visible + + +# ----------------------------------------------------------------------------- +# Setup error → cleanup +# ----------------------------------------------------------------------------- + + +# ----------------------------------------------------------------------------- +# Concurrency / isolation +# ----------------------------------------------------------------------------- + + +def test_in_process_two_instances_have_distinct_user_modules(): + """Two coexisting in-process instances must not share ``sys.modules['__main__']``.""" + repl_a = IPythonREPL(kernel_mode="in_process") + repl_b = IPythonREPL(kernel_mode="in_process") + try: + repl_a.execute_code("x = 'A'") + repl_b.execute_code("x = 'B'") + ra = repl_a.execute_code("print(x)") + rb = repl_b.execute_code("print(x)") + assert "A" in ra.stdout and "B" not in ra.stdout + assert "B" in rb.stdout and "A" not in rb.stdout + # And ``sys.modules['__main__']`` is not the IPython user module + # for either instance. + assert repl_a._user_module is not repl_b._user_module + assert repl_a._user_module.__name__ != "__main__" + assert repl_b._user_module.__name__ != "__main__" + finally: + repl_a.cleanup() + repl_b.cleanup() + + +def test_in_process_user_module_dropped_from_sys_modules_on_cleanup(): + """No ``sys.modules`` leak after cleanup.""" + import sys as _sys + + repl = IPythonREPL(kernel_mode="in_process") + name = repl._user_module.__name__ + assert name in _sys.modules + repl.cleanup() + assert name not in _sys.modules + + +@BOTH_MODES +def test_concurrent_add_context_indices_are_unique(kernel_mode: str): + """Two threads calling ``add_context`` concurrently must not collide.""" + with IPythonREPL(kernel_mode=kernel_mode, persistent=True) as repl: + results: list[int] = [] + lock = threading.Lock() + + def worker(i: int) -> None: + idx = repl.add_context(f"payload-{i}") + with lock: + results.append(idx) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All indices distinct, contiguous, and 0..7 in some order. + assert sorted(results) == list(range(8)) + assert repl.get_context_count() == 8 + + +def test_subcall_concurrency_is_globally_bounded(): + """``max_concurrent_subcalls`` caps simultaneous ``subcall_fn`` calls + even across multiple ``rlm_query`` / ``rlm_query_batched`` requests.""" + if not _has_subprocess: + pytest.skip("jupyter_client not installed") + + in_flight = 0 + peak = 0 + cv_lock = threading.Lock() + + def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: + nonlocal in_flight, peak + with cv_lock: + in_flight += 1 + peak = max(peak, in_flight) + time.sleep(0.05) + with cv_lock: + in_flight -= 1 + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, total_input_tokens=0, total_output_tokens=0 + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response="ok", + usage_summary=usage, + execution_time=0.05, + ) + + # cap=2 → no matter how many calls the kernel fans out, only 2 should + # ever be running at once. + with IPythonREPL( + kernel_mode="subprocess", + subcall_fn=slow_subcall, + max_concurrent_subcalls=2, + ) as repl: + # Two batched-of-8 requests issued from kernel-side threads + # simulates fan-out far above the cap. + result = repl.execute_code( + "import threading\n" + "def go():\n" + " rlm_query_batched(['p1','p2','p3','p4','p5','p6','p7','p8'])\n" + "ts = [threading.Thread(target=go) for _ in range(2)]\n" + "for t in ts: t.start()\n" + "for t in ts: t.join()\n" + "print('done')" + ) + assert "done" in result.stdout + assert peak <= 2, f"semaphore should bound concurrency to 2, saw peak={peak}" + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_setup_failure_runs_cleanup(): + """If construction fails after the broker/kernel start, cleanup runs. + + A non-JSON-serializable context_payload triggers a TypeError in + ``add_context`` *after* ``setup()`` has already brought up the kernel + and broker. The new try/except in ``__init__`` must run cleanup + before re-raising; otherwise the kernel + broker would be orphaned. + """ + + class _Unserializable: + pass + + with pytest.raises(TypeError): + IPythonREPL( + kernel_mode="subprocess", + context_payload={"bad": _Unserializable()}, + ) From 8f8179a15bd5cb9a4d680c21973c36615d8feb4d Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Mon, 27 Apr 2026 12:35:48 -0400 Subject: [PATCH 4/5] guard against sub-calling to the parent REPL --- rlm/environments/ipython_repl.py | 252 ++++++++++++++++++++----- tests/test_ipython_repl.py | 306 ++++++++++++++++++++++++++++++- 2 files changed, 515 insertions(+), 43 deletions(-) diff --git a/rlm/environments/ipython_repl.py b/rlm/environments/ipython_repl.py index aa87320b5..df7fb90ba 100644 --- a/rlm/environments/ipython_repl.py +++ b/rlm/environments/ipython_repl.py @@ -17,6 +17,7 @@ from __future__ import annotations +import atexit import copy import io import json @@ -100,10 +101,12 @@ class _SubcallBroker: Reuses the 4-byte-length-prefix JSON protocol from ``rlm.core.comms_utils``. - Request types: - {"type": "subcall", "prompt": str, "model": str | None} - {"type": "subcall_batched", "prompts": [str], "model": str | None} - {"type": "final_var", "value": str} + Request types (all subcall/final_var requests carry a ``cell_id`` so + the parent can attribute completions to the cell that triggered them + even if ``subcall_fn`` finishes after that cell has timed out): + {"type": "subcall", "prompt": str, "model": str | None, "cell_id": str} + {"type": "subcall_batched", "prompts": [str], "model": str | None, "cell_id": str} + {"type": "final_var", "value": str, "cell_id": str} Responses: subcall: {"completion": RLMChatCompletion.to_dict()} | {"error": str} @@ -131,8 +134,11 @@ def __init__( # only bound concurrency within a single batched request. self._subcall_semaphore = threading.Semaphore(max(1, max_concurrent)) self._shutting_down = False - self.completions: list[RLMChatCompletion] = [] - self.final_answer: str | None = None + # Indexed by ``cell_id`` so completions and final_answers from a + # subcall that finishes *after* its origin cell timed out don't + # get misattributed to a later cell. + self._completions_by_cell: dict[str, list[RLMChatCompletion]] = {} + self._final_answers_by_cell: dict[str, str] = {} def start(self) -> tuple[str, int]: parent = self @@ -185,10 +191,12 @@ def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: if self._shutting_down and req_type in ("subcall", "subcall_batched"): return {"error": "Broker shutting down"} + cell_id = data.get("cell_id") or "" + if req_type == "final_var": value = data.get("value") with self._lock: - self.final_answer = str(value) if value is not None else None + self._final_answers_by_cell[cell_id] = str(value) if value is not None else "" return {"ok": True} if req_type == "subcall": @@ -197,7 +205,7 @@ def _dispatch(self, data: dict[str, Any]) -> dict[str, Any]: try: completion = self._run_subcall(data.get("prompt", ""), data.get("model")) with self._lock: - self.completions.append(completion) + self._completions_by_cell.setdefault(cell_id, []).append(completion) return {"completion": completion.to_dict()} except Exception as e: return {"error": f"{type(e).__name__}: {e}"} @@ -232,8 +240,9 @@ def _one(i: int, prompt: str) -> None: f.result() with self._lock: + bucket = self._completions_by_cell.setdefault(cell_id, []) for _, c in sorted(local_completions, key=lambda t: t[0]): - self.completions.append(c) + bucket.append(c) return { "responses": [ @@ -263,13 +272,29 @@ def address(self) -> tuple[str, int]: return (self.host, self._port) return (self.host, self._server.server_address[1]) - def drain(self) -> tuple[list[RLMChatCompletion], str | None]: - """Atomically snapshot + clear completions and final_answer.""" + def drain(self, cell_id: str | None = None) -> tuple[list[RLMChatCompletion], str | None]: + """Pop completions and the final answer for a cell, discard the rest. + + ``drain(cell_id)`` returns this cell's completions+final and + unconditionally clears any entries belonging to *other* cells — + those are stragglers from a prior cell that timed out before its + own ``subcall_fn`` finished, and they must not bleed into a future + cell's bookkeeping. + + ``drain(None)`` is a clear-all path with no return value (used as + a precaution when we don't have a cell_id, e.g. setup paths). + """ with self._lock: - completions = self.completions.copy() - self.completions.clear() - final = self.final_answer - self.final_answer = None + if cell_id is None: + self._completions_by_cell.clear() + self._final_answers_by_cell.clear() + return [], None + completions = self._completions_by_cell.pop(cell_id, []) + final = self._final_answers_by_cell.pop(cell_id, None) + # Discard stragglers from other (now-defunct) cells so they + # don't get attributed to a later drain. + self._completions_by_cell.clear() + self._final_answers_by_cell.clear() return completions, final @@ -295,6 +320,10 @@ def _build_kernel_bootstrap( _RLM_SUBCALL_ADDRESS = {list(subcall_address) if subcall_address else None!r} _RLM_DEPTH = {depth} _RLM_SUBCALL_TIMEOUT = {subcall_timeout!r} + # Updated by the parent before each user cell. Tagged onto every + # broker request so completions / FINAL_VAR answers can be + # attributed to the cell that originated them. + _RLM_CURRENT_CELL = "" def _rlm_socket_send(sock, data): payload = _rlm_json.dumps(data).encode("utf-8") @@ -357,7 +386,8 @@ def rlm_query(prompt, model=None): if _RLM_SUBCALL_ADDRESS is None: return llm_query(prompt, model=model) resp = _rlm_request(_RLM_SUBCALL_ADDRESS, {{ - "type": "subcall", "prompt": prompt, "model": model + "type": "subcall", "prompt": prompt, "model": model, + "cell_id": _RLM_CURRENT_CELL, }}) if not isinstance(resp, dict): return "Error: RLM query failed - malformed response" @@ -374,7 +404,8 @@ def rlm_query_batched(prompts, model=None): if _RLM_SUBCALL_ADDRESS is None: return llm_query_batched(prompts, model=model) resp = _rlm_request(_RLM_SUBCALL_ADDRESS, {{ - "type": "subcall_batched", "prompts": prompts, "model": model + "type": "subcall_batched", "prompts": prompts, "model": model, + "cell_id": _RLM_CURRENT_CELL, }}) if not isinstance(resp, dict): return ["Error: RLM query failed - malformed response"] * len(prompts) @@ -387,13 +418,19 @@ def rlm_query_batched(prompts, model=None): def FINAL_VAR(variable): if not isinstance(variable, str): answer = str(variable) - _rlm_request(_RLM_SUBCALL_ADDRESS, {{"type": "final_var", "value": answer}}) + _rlm_request(_RLM_SUBCALL_ADDRESS, {{ + "type": "final_var", "value": answer, + "cell_id": _RLM_CURRENT_CELL, + }}) return answer name = variable.strip().strip('"\\'') ns = get_ipython().user_ns if name in ns: answer = str(ns[name]) - _rlm_request(_RLM_SUBCALL_ADDRESS, {{"type": "final_var", "value": answer}}) + _rlm_request(_RLM_SUBCALL_ADDRESS, {{ + "type": "final_var", "value": answer, + "cell_id": _RLM_CURRENT_CELL, + }}) return answer skip = {{"In", "Out", "exit", "quit", "get_ipython"}} available = [ @@ -533,6 +570,19 @@ def __init__( # invoking ``execute_code`` (which re-acquires it) so the # index→assign→count-increment sequence stays atomic across threads. self._lock = threading.RLock() + # Global cap on concurrent ``subcall_fn`` invocations from in-process + # mode (subprocess has the equivalent inside ``_SubcallBroker``). A + # cell that spawns user threads each calling ``rlm_query`` would + # otherwise blow past ``max_concurrent_subcalls``. + self._inprocess_subcall_semaphore = threading.Semaphore(max(1, max_concurrent_subcalls)) + # Tracks threads currently *inside* a ``subcall_fn`` invocation + # (in-process directly, or subprocess via the broker handler). + # ``execute_code`` checks this set to fail fast if subcall_fn calls + # back into this REPL — that would either deadlock the cell lock + # (cross-thread broker reentry) or corrupt this cell's tracking + # state (same-thread reentry). + self._subcall_threads: set[int] = set() + self._subcall_threads_lock = threading.Lock() self._context_count: int = 0 self._history_count: int = 0 @@ -626,6 +676,7 @@ def _setup_in_process(self) -> None: ns["rlm_query_batched"] = self._rlm_query_batched ns["FINAL_VAR"] = self._final_var ns["SHOW_VARS"] = self._show_vars + ns["input"] = self._disabled_input # Inject custom tools for name, entry in self.custom_tools.items(): @@ -641,9 +692,16 @@ def _setup_subprocess(self) -> None: "or pip install jupyter_client ipykernel" ) from e - # Start broker first so we know its address before bootstrapping kernel + # Start broker first so we know its address before bootstrapping + # kernel. Wrap subcall_fn in ``_tracked_subcall`` so the broker's + # handler threads register themselves as subcall threads — that's + # what ``execute_code``'s reentry check uses to detect a deadlock + # (subcall_fn calling back into the parent REPL). + broker_subcall_fn: Callable[[str, str | None], RLMChatCompletion] | None = ( + self._tracked_subcall if self.subcall_fn is not None else None + ) self._broker = _SubcallBroker( - subcall_fn=self.subcall_fn, + subcall_fn=broker_subcall_fn, max_concurrent=self.max_concurrent_subcalls, ) self._broker.start() @@ -771,6 +829,19 @@ def _final_var(self, variable_name: str | Any) -> str: f"You must create and assign a variable in a REPL block BEFORE calling FINAL_VAR on it." ) + @staticmethod + def _disabled_input(*_args: Any, **_kwargs: Any) -> str: + """Replacement for ``input()`` injected into in-process user_ns. + + ``input()`` would block on the parent process's stdin if a cell + called it (the LLM is unattended). Subprocess mode disables stdin + via ``allow_stdin=False``; this is the in-process equivalent. + + User code that calls ``builtins.input`` directly bypasses this + shadow — the shadow only catches the common unqualified call. + """ + raise RuntimeError("input() is disabled in IPythonREPL: cells cannot prompt for stdin") + def _show_vars(self) -> str: ns = self._shell.user_ns if self._shell is not None else {} available = { @@ -813,11 +884,40 @@ def _llm_query_batched(self, prompts: list[str], model: str | None = None) -> li except Exception as e: return [f"Error: LM query failed - {e}"] * len(prompts) + def _tracked_subcall(self, prompt: str, model: str | None) -> RLMChatCompletion: + """Invoke ``self.subcall_fn`` and register the calling thread. + + ``execute_code`` consults the registered set to detect reentry + (subcall_fn calling back into this same REPL) and fail with a + clear error instead of deadlocking the cell lock or silently + clobbering tracking state. + """ + assert self.subcall_fn is not None + cur = threading.get_ident() + with self._subcall_threads_lock: + self._subcall_threads.add(cur) + try: + return self.subcall_fn(prompt, model) + finally: + with self._subcall_threads_lock: + self._subcall_threads.discard(cur) + + def _run_inprocess_subcall(self, prompt: str, model: str | None) -> RLMChatCompletion: + """Invoke ``subcall_fn`` under the per-instance semaphore. + + Globally bounds in-flight calls across nested rlm_query / + rlm_query_batched invocations spawned from user threads inside a + single cell. Mirrors the ``_SubcallBroker._run_subcall`` gate used + in subprocess mode. + """ + with self._inprocess_subcall_semaphore: + return self._tracked_subcall(prompt, model) + def _rlm_query(self, prompt: str, model: str | None = None) -> str: if self.subcall_fn is None: return self._llm_query(prompt, model) try: - completion = self.subcall_fn(prompt, model) + completion = self._run_inprocess_subcall(prompt, model) self._pending_llm_calls.append(completion) return completion.response except Exception as e: @@ -831,7 +931,7 @@ def _rlm_query_batched(self, prompts: list[str], model: str | None = None) -> li results: list[str] = [] for prompt in prompts: try: - completion = self.subcall_fn(prompt, model) + completion = self._run_inprocess_subcall(prompt, model) self._pending_llm_calls.append(completion) results.append(completion.response) except Exception as e: @@ -845,7 +945,10 @@ def _rlm_query_batched(self, prompts: list[str], model: str | None = None) -> li def _run(index: int, prompt: str) -> None: try: - completion = self.subcall_fn(prompt, model) # type: ignore[misc] + # Semaphore-gated so that multiple in-flight batched calls + # (e.g., from user-spawned threads inside the cell) share + # the global concurrency budget. + completion = self._run_inprocess_subcall(prompt, model) with lock: completions.append((index, completion)) results[index] = completion.response @@ -983,13 +1086,30 @@ def update_handler_address(self, address: tuple[str, int]) -> None: # ------------------------------------------------------------------------- def execute_code(self, code: str) -> REPLResult: + # Reentry guard: if the caller's thread is currently inside this + # REPL's ``subcall_fn`` (in-process synchronously, or subprocess + # via the broker handler thread), calling ``execute_code`` would + # either deadlock the cell lock (cross-thread broker reentry) or + # silently corrupt the in-flight cell's tracking (same-thread + # in-process reentry). Fail fast with a clear message. + cur = threading.get_ident() + with self._subcall_threads_lock: + is_reentrant = cur in self._subcall_threads + if is_reentrant: + raise RuntimeError( + "Reentrant execute_code on the same instance from inside " + "subcall_fn is not supported — it would deadlock or " + "clobber the parent cell's bookkeeping. subcall_fn must " + "spawn a child REPL (with its own lock) instead of " + "calling back into its parent." + ) + if self.kernel_mode == "in_process": return self._execute_in_process(code) return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True) def _execute_in_process(self, code: str) -> REPLResult: start_time = time.perf_counter() - self._pending_llm_calls = [] stdout_buf = io.StringIO() stderr_buf = io.StringIO() @@ -1010,6 +1130,12 @@ def _alarm_handler(signum, frame): prev_timer: tuple[float, float] | None = None with self._lock, self._temp_cwd(): + # Reset scratch state under the lock — otherwise a concurrent + # ``execute_code`` call from another thread would clobber the + # in-flight cell's bookkeeping. + self._pending_llm_calls = [] + self._last_final_answer = None + if use_alarm: prev_handler = signal.signal(signal.SIGALRM, _alarm_handler) prev_timer = signal.setitimer(signal.ITIMER_REAL, timeout) @@ -1022,11 +1148,16 @@ def _alarm_handler(signum, frame): result = None finally: if use_alarm: - # Clear the pending alarm before restoring the handler, or - # a late-firing SIGALRM could escape into unrelated code. + # Order matters: install SIG_IGN *first* so any alarm + # already queued for delivery is dropped instead of + # raising TimeoutError out of cleanup code or unrelated + # parent frames. Then disable the timer, then restore. + signal.signal(signal.SIGALRM, signal.SIG_IGN) signal.setitimer(signal.ITIMER_REAL, 0) - if prev_handler is not None: - signal.signal(signal.SIGALRM, prev_handler) + signal.signal( + signal.SIGALRM, + prev_handler if prev_handler is not None else signal.SIG_DFL, + ) # Restore any previously-scheduled timer the caller had set. if prev_timer and prev_timer[0] > 0: signal.setitimer(signal.ITIMER_REAL, *prev_timer) @@ -1059,15 +1190,19 @@ def _alarm_handler(signum, frame): if not k.startswith("_") and k not in _IPYTHON_INTERNAL_NAMES } - final_answer = self._last_final_answer - self._last_final_answer = None + # Snapshot bookkeeping under the lock too — reading and clearing + # outside lets a concurrent thread overwrite/wipe the state we're + # about to report. + final_answer = self._last_final_answer + self._last_final_answer = None + rlm_calls = self._pending_llm_calls.copy() return REPLResult( stdout=stdout_buf.getvalue(), stderr=stderr_buf.getvalue(), locals=locals_snapshot, execution_time=time.perf_counter() - start_time, - rlm_calls=self._pending_llm_calls.copy(), + rlm_calls=rlm_calls, final_answer=final_answer, ) @@ -1101,12 +1236,33 @@ def _execute_in_kernel_locked( ) -> REPLResult: start_time = time.perf_counter() - # If a previous cell timed out mid-subcall, ``subcall_fn`` may have - # finished after we drained, leaving stragglers in the broker. - # Discard them so they aren't misattributed to *this* cell. - if drain_broker: - assert self._broker is not None - self._broker.drain() + # Generate a unique cell_id so the broker can attribute every + # subcall completion / FINAL_VAR answer to *this* cell. A subcall + # whose ``subcall_fn`` finishes after this cell times out will + # land under this id and stay there until the next drain (which + # discards it as stale). + cell_id = uuid.uuid4().hex if drain_broker else None + + if cell_id is not None: + # Set the kernel-side ``_RLM_CURRENT_CELL`` via a separate + # ``execute_interactive`` call rather than prepending to the + # user's code — prepending would push cell magics + # (``%%magic``, which must be on line 1) off the first line + # and break them. + try: + self._kc.execute_interactive( + f"_RLM_CURRENT_CELL = {cell_id!r}", + timeout=self.startup_timeout, + output_hook=lambda _msg: None, + store_history=False, + stop_on_error=False, + allow_stdin=False, + ) + except TimeoutError as e: + # Setter shouldn't time out under any sane condition; if + # it does, surface the failure rather than silently + # mis-attributing this cell's subcalls. + raise RuntimeError(f"Failed to set cell_id in kernel: {e}") from e stdout_parts: list[str] = [] stderr_parts: list[str] = [] @@ -1167,11 +1323,13 @@ def output_hook(msg: dict[str, Any]) -> None: else: stderr_parts.append(f"\n{ename}: {evalue}") - # Drain broker state (rlm_query completions, FINAL_VAR answer) only on - # user-facing cells. ``rlm_calls`` reports what was made *during this - # cell* — never accumulate across cells. + # Drain broker state (rlm_query completions, FINAL_VAR answer) + # *for this cell only*. Stragglers from prior timed-out cells live + # under their own cell_id; ``drain(cell_id)`` discards them rather + # than attributing them to this cell. if drain_broker: - completions, final_answer = self._broker.drain() + assert cell_id is not None + completions, final_answer = self._broker.drain(cell_id) else: completions, final_answer = [], None @@ -1198,6 +1356,7 @@ def _restore_scaffold_in_process(self) -> None: ns["rlm_query_batched"] = self._rlm_query_batched ns["FINAL_VAR"] = self._final_var ns["SHOW_VARS"] = self._show_vars + ns["input"] = self._disabled_input if "context_0" in ns: ns["context"] = ns["context_0"] if "history_0" in ns: @@ -1269,6 +1428,15 @@ def cleanup(self) -> None: self._broker = None if self._shell is not None: + # IPython's ``InteractiveShell.__init__`` registers + # ``atexit_operations`` with the ``atexit`` module, which holds + # a strong reference to the shell for the rest of the process. + # Unregister so the shell (and its user_module / history mgr) + # become collectable when we drop our reference. + try: + atexit.unregister(self._shell.atexit_operations) + except Exception: + pass try: self._shell.reset(new_session=False) except Exception: diff --git a/tests/test_ipython_repl.py b/tests/test_ipython_repl.py index 96d241bf7..f9d98c726 100644 --- a/tests/test_ipython_repl.py +++ b/tests/test_ipython_repl.py @@ -18,7 +18,7 @@ pytest.importorskip("IPython") -from rlm.core.types import ModelUsageSummary, RLMChatCompletion, UsageSummary +from rlm.core.types import ModelUsageSummary, REPLResult, RLMChatCompletion, UsageSummary from rlm.environments.ipython_repl import IPythonREPL # ----------------------------------------------------------------------------- @@ -511,6 +511,257 @@ def test_in_process_error_includes_traceback_and_user_frame(): # ----------------------------------------------------------------------------- +def test_in_process_input_is_disabled(): + """``input()`` must not block in cells; it should raise.""" + with IPythonREPL(kernel_mode="in_process") as repl: + result = repl.execute_code("input('go')") + assert "RuntimeError" in result.stderr or "input() is disabled" in result.stderr + + +def test_in_process_input_restored_if_user_overwrites(): + """If a cell rebinds ``input``, the next cell must still see the disabled stub.""" + with IPythonREPL(kernel_mode="in_process") as repl: + repl.execute_code("input = lambda *a, **kw: 'evil'") + result = repl.execute_code("input('go')") + assert "RuntimeError" in result.stderr or "input() is disabled" in result.stderr + + +def test_cleanup_unregisters_atexit_handler(): + """Cleanup must remove ``InteractiveShell.atexit_operations`` from the + ``atexit`` registry so it doesn't hold a strong external reference to + the shell for the rest of the process lifetime. + + ``atexit`` doesn't expose a clean way to introspect its registry, so we + detect leakage indirectly: register a sentinel, then verify our cleanup + didn't remove it (i.e., cleanup is targeted at the shell's handler, not + a blanket clear). Combined with the absence of any error from + ``atexit.unregister``, this confirms the unregister code path runs. + """ + import atexit as _atexit + + sentinel_called = [False] + + def sentinel() -> None: + sentinel_called[0] = True + + _atexit.register(sentinel) + try: + repl = IPythonREPL(kernel_mode="in_process") + # ``atexit_operations`` exists and is callable on a live shell. + assert callable(repl._shell.atexit_operations) + repl.cleanup() + assert repl._shell is None + # Sentinel still registered — our unregister didn't blast unrelated + # handlers. + _atexit.unregister(sentinel) + finally: + _atexit.unregister(sentinel) + + +# ----------------------------------------------------------------------------- +# Stale subcall attribution (subprocess mode) +# ----------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_stale_subcall_completion_not_misattributed_to_next_cell(): + """A subcall_fn that finishes *during* a subsequent cell — because the + cell that triggered it timed out — must NOT be counted in the new + cell's ``rlm_calls``. + + Prior to cell_id tagging, the broker stored completions in a flat + list that the next cell's drain consumed indiscriminately. + """ + + finished = threading.Event() + + def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: + # Long enough to outlive cell A's timeout but finish during cell B. + time.sleep(0.5) + finished.set() + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, total_input_tokens=0, total_output_tokens=0 + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response="late", + usage_summary=usage, + execution_time=0.5, + ) + + with IPythonREPL( + kernel_mode="subprocess", + subcall_fn=slow_subcall, + cell_timeout=0.1, + ) as repl: + # Cell A: kicks off rlm_query, times out before subcall_fn finishes. + r1 = repl.execute_code('rlm_query("p")') + assert "TimeoutError" in r1.stderr + # subcall_fn shouldn't have completed yet. + assert not finished.is_set() + + # Cell B: stays busy past the subcall_fn completion time. We need + # an explicit higher timeout for B because cell_timeout was set to + # 0.1s above. + repl.cell_timeout = 5.0 + r2 = repl.execute_code("import time; time.sleep(1.0)") + assert finished.is_set(), "subcall_fn should have completed by now" + + # Cell B made no rlm_query calls, so its rlm_calls must be empty — + # cell A's late completion stays attributed to A (and is discarded as + # stale on B's drain). + assert len(r2.rlm_calls) == 0, ( + f"cell B must not inherit cell A's late subcall completion; got {len(r2.rlm_calls)}" + ) + + +@pytest.mark.skipif(not _has_subprocess, reason="jupyter_client not installed") +def test_stale_final_var_not_misattributed_to_next_cell(): + """Same logic for ``FINAL_VAR`` — a final-answer set by a prior cell's + delayed code path must not surface as the next cell's final answer.""" + + barrier = threading.Event() + + def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: + # Hold the kernel-side rlm_query call long enough that we get + # to issue cell B before subcall_fn ever returns. + barrier.wait(timeout=5.0) + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, total_input_tokens=0, total_output_tokens=0 + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response="late", + usage_summary=usage, + execution_time=0.0, + ) + + with IPythonREPL( + kernel_mode="subprocess", + subcall_fn=slow_subcall, + cell_timeout=0.1, + ) as repl: + r1 = repl.execute_code('rlm_query("p"); FINAL_VAR("late-A")') + assert "TimeoutError" in r1.stderr + # Cell A is over with no FINAL_VAR captured. + assert r1.final_answer is None + + # Now release subcall_fn and let it complete. If cell A's + # FINAL_VAR were globally stored (by old code), it would now be + # in the broker waiting for the next drain to pick it up. + # (Note: in this test FINAL_VAR happens after rlm_query, which + # the timeout already prevented — but the same path is exercised + # by any straggler.) + barrier.set() + time.sleep(0.3) + + repl.cell_timeout = 5.0 + r2 = repl.execute_code("print('clean')") + + assert r2.final_answer is None, ( + f"cell B must not inherit cell A's stale FINAL_VAR; got {r2.final_answer!r}" + ) + + +# ----------------------------------------------------------------------------- +# Reentrant subcall_fn → execute_code (deadlock prevention) +# ----------------------------------------------------------------------------- + + +@BOTH_MODES +def test_subcall_fn_reentry_into_same_repl_raises(kernel_mode: str): + """If subcall_fn calls execute_code on its parent REPL, the call must + raise — not deadlock (cross-thread broker case) or silently clobber + the parent's tracking state (same-thread in-process case).""" + + seen: list[str] = [] + repl_holder: dict[str, IPythonREPL] = {} + + def reentering_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: + try: + repl_holder["repl"].execute_code("print('inner')") + except RuntimeError as e: + seen.append(str(e)) + raise + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, total_input_tokens=0, total_output_tokens=0 + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response="never", + usage_summary=usage, + execution_time=0.0, + ) + + with IPythonREPL( + kernel_mode=kernel_mode, + subcall_fn=reentering_subcall, + ) as repl: + repl_holder["repl"] = repl + # Run rlm_query → subcall_fn → reentrant execute_code → raises. + # We bound the call so a regression that deadlocks fails the + # test instead of hanging the suite forever. + result_holder: list[REPLResult] = [] + err_holder: list[BaseException] = [] + + def runner() -> None: + try: + result_holder.append(repl.execute_code('print(rlm_query("p"))')) + except BaseException as e: + err_holder.append(e) + + t = threading.Thread(target=runner) + t.start() + t.join(timeout=10.0) + assert not t.is_alive(), "execute_code deadlocked instead of raising" + + # subcall_fn observed the reentry RuntimeError. + assert seen, "reentering subcall_fn was never reached or never raised" + assert any("Reentrant" in s for s in seen), seen + + +@BOTH_MODES +def test_concurrent_execute_does_not_lose_rlm_calls(kernel_mode: str): + """Two threads each running an ``rlm_query`` cell must each report + exactly one call. Prior to the lock-scope fix the in-process path + raced on ``_pending_llm_calls`` and lost entries.""" + subcall = _FakeSubcall(responses=["x"]) + counts: list[int] = [] + lock = threading.Lock() + + def worker(repl: IPythonREPL) -> None: + for _ in range(5): + r = repl.execute_code("rlm_query('p')") + with lock: + counts.append(len(r.rlm_calls)) + + with IPythonREPL(kernel_mode=kernel_mode, subcall_fn=subcall) as repl: + threads = [threading.Thread(target=worker, args=(repl,)) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + + # 4 threads × 5 cells = 20 cells, each expected to report exactly 1 call. + assert len(counts) == 20 + assert all(c == 1 for c in counts), f"some cells lost rlm_calls — saw {sorted(counts)}" + + def test_in_process_two_instances_have_distinct_user_modules(): """Two coexisting in-process instances must not share ``sys.modules['__main__']``.""" repl_a = IPythonREPL(kernel_mode="in_process") @@ -566,6 +817,59 @@ def worker(i: int) -> None: assert repl.get_context_count() == 8 +def test_in_process_subcall_concurrency_is_globally_bounded(): + """In-process mode also caps ``subcall_fn`` globally, matching subprocess. + + Without the per-instance semaphore, user threads inside a single cell + that each call ``rlm_query`` / ``rlm_query_batched`` would fan out + past ``max_concurrent_subcalls``. + """ + + in_flight = 0 + peak = 0 + cv_lock = threading.Lock() + + def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: + nonlocal in_flight, peak + with cv_lock: + in_flight += 1 + peak = max(peak, in_flight) + time.sleep(0.05) + with cv_lock: + in_flight -= 1 + usage = UsageSummary( + model_usage_summaries={ + "fake-model": ModelUsageSummary( + total_calls=1, total_input_tokens=0, total_output_tokens=0 + ) + } + ) + return RLMChatCompletion( + root_model="fake-model", + prompt=prompt, + response="ok", + usage_summary=usage, + execution_time=0.05, + ) + + with IPythonREPL( + kernel_mode="in_process", + subcall_fn=slow_subcall, + max_concurrent_subcalls=2, + ) as repl: + result = repl.execute_code( + "import threading\n" + "def go():\n" + " rlm_query_batched(['p1','p2','p3','p4','p5','p6','p7','p8'])\n" + "ts = [threading.Thread(target=go) for _ in range(2)]\n" + "for t in ts: t.start()\n" + "for t in ts: t.join()\n" + "print('done')" + ) + assert "done" in result.stdout + assert peak <= 2, f"in-process semaphore should bound concurrency to 2, saw peak={peak}" + + def test_subcall_concurrency_is_globally_bounded(): """``max_concurrent_subcalls`` caps simultaneous ``subcall_fn`` calls even across multiple ``rlm_query`` / ``rlm_query_batched`` requests.""" From 228ea1dc9796343b5a358d000cc0de475010e8fb Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Mon, 27 Apr 2026 13:44:19 -0400 Subject: [PATCH 5/5] timeout validation and exception handling --- rlm/environments/ipython_repl.py | 92 ++++++++++++++++++++++++++----- tests/test_ipython_repl.py | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/rlm/environments/ipython_repl.py b/rlm/environments/ipython_repl.py index df7fb90ba..985538f48 100644 --- a/rlm/environments/ipython_repl.py +++ b/rlm/environments/ipython_repl.py @@ -490,6 +490,18 @@ class IPythonREPL(NonIsolatedEnv): ``ipykernel`` and is fully isolated from the parent's namespace, cwd, and signals. + Subcall attribution caveat (subprocess mode): + Subcall completions and FINAL_VAR answers are tagged with the + ``cell_id`` that was active *at the moment the kernel issued the + request*. If a cell spawns long-lived kernel-side state (a + ``threading.Thread``, an ``asyncio.Task`` left running, a + background timer) that calls ``rlm_query`` after the spawning + cell has finished, those calls will be tagged with whatever cell + is active *at call time*, not the cell that spawned the worker. + Practically: avoid leaving background work running across cells, + or accept that its rlm_calls will be reported under a later + cell's ``REPLResult``. + Args: lm_handler_address: (host, port) of the LM handler socket server. context_payload: Initial context to load as ``context``/``context_0``. @@ -540,6 +552,17 @@ def __init__( raise ValueError( f"kernel_mode must be 'in_process' or 'subprocess', got {kernel_mode!r}" ) + if startup_timeout <= 0: + raise ValueError(f"startup_timeout must be positive, got {startup_timeout!r}") + if subcall_timeout is not None and subcall_timeout <= 0: + # ``socket.settimeout(0)`` is non-blocking mode (every send/recv + # raises immediately) — almost certainly not what a caller + # asking for a "0s timeout" actually wants. ``None`` means + # "no timeout"; a positive number means "this many seconds". + raise ValueError( + f"subcall_timeout must be positive or None (got {subcall_timeout!r}); " + "use None to disable the timeout." + ) super().__init__( persistent=persistent, @@ -582,6 +605,19 @@ def __init__( # (cross-thread broker reentry) or corrupt this cell's tracking # state (same-thread reentry). self._subcall_threads: set[int] = set() + # Tracks threads currently inside ``execute_code``. In ``in_process`` + # mode the LM can reach this REPL via any scaffold bound method's + # ``__self__`` (e.g. ``rlm_query.__self__.execute_code(...)``); a + # cell that does so would re-enter on the same thread under the + # RLock and silently clobber its own ``_pending_llm_calls`` / + # ``_last_final_answer`` tracking. Catching same-thread reentry + # here turns that into a clear ``RuntimeError``. Subprocess mode + # is already shielded by process isolation: the kernel-side + # scaffold is plain functions with no ``__self__``. + self._executing_threads: set[int] = set() + # Single lock guarding both reentry-tracking sets. Touched only + # on ``execute_code`` enter/exit and inside ``_tracked_subcall``, + # so contention is negligible. self._subcall_threads_lock = threading.Lock() self._context_count: int = 0 self._history_count: int = 0 @@ -619,7 +655,13 @@ def __init__( if setup_code: self.execute_code(setup_code) except BaseException: - self.cleanup() + # Suppress *cleanup* errors so they don't shadow the original + # cause. ``raise`` (with no arg) re-raises the real exception + # the user needs to see. + try: + self.cleanup() + except Exception: + pass raise # ------------------------------------------------------------------------- @@ -1086,16 +1128,27 @@ def update_handler_address(self, address: tuple[str, int]) -> None: # ------------------------------------------------------------------------- def execute_code(self, code: str) -> REPLResult: - # Reentry guard: if the caller's thread is currently inside this - # REPL's ``subcall_fn`` (in-process synchronously, or subprocess - # via the broker handler thread), calling ``execute_code`` would - # either deadlock the cell lock (cross-thread broker reentry) or - # silently corrupt the in-flight cell's tracking (same-thread - # in-process reentry). Fail fast with a clear message. + # Reentry guards: + # + # 1. ``cur in self._subcall_threads`` — the caller is currently + # inside ``subcall_fn`` (in-process synchronously, or via the + # subprocess broker handler thread). Calling ``execute_code`` + # would either deadlock the cell lock (cross-thread case) or + # silently corrupt this cell's bookkeeping (same-thread). + # + # 2. ``cur in self._executing_threads`` — the caller is already + # inside an ``execute_code`` invocation on this same REPL. + # This is the path an LM-generated cell takes when it + # traverses ``rlm_query.__self__.execute_code(...)`` (or any + # scaffold bound method) to call back into the parent. The + # RLock would let it reenter, but the inner call clears + # ``_pending_llm_calls`` / resets ``_last_final_answer`` and + # leaves the outer cell with a corrupt result. cur = threading.get_ident() with self._subcall_threads_lock: - is_reentrant = cur in self._subcall_threads - if is_reentrant: + in_subcall = cur in self._subcall_threads + already_executing = cur in self._executing_threads + if in_subcall: raise RuntimeError( "Reentrant execute_code on the same instance from inside " "subcall_fn is not supported — it would deadlock or " @@ -1103,10 +1156,25 @@ def execute_code(self, code: str) -> REPLResult: "spawn a child REPL (with its own lock) instead of " "calling back into its parent." ) + if already_executing: + raise RuntimeError( + "Reentrant execute_code on the same instance is not " + "supported. The currently-running cell appears to have " + "called execute_code on its own REPL (e.g. via " + "rlm_query.__self__.execute_code(...)) — that would " + "corrupt this cell's tracking state. Use a separate " + "REPL instance for nested execution." + ) - if self.kernel_mode == "in_process": - return self._execute_in_process(code) - return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True) + with self._subcall_threads_lock: + self._executing_threads.add(cur) + try: + if self.kernel_mode == "in_process": + return self._execute_in_process(code) + return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True) + finally: + with self._subcall_threads_lock: + self._executing_threads.discard(cur) def _execute_in_process(self, code: str) -> REPLResult: start_time = time.perf_counter() diff --git a/tests/test_ipython_repl.py b/tests/test_ipython_repl.py index f9d98c726..79c648d20 100644 --- a/tests/test_ipython_repl.py +++ b/tests/test_ipython_repl.py @@ -319,6 +319,48 @@ def test_invalid_kernel_mode_rejected(): IPythonREPL(kernel_mode="nonsense") # type: ignore[arg-type] +@pytest.mark.parametrize("bad", [0, -1, -0.5]) +def test_invalid_startup_timeout_rejected(bad): + with pytest.raises(ValueError, match="startup_timeout"): + IPythonREPL(kernel_mode="in_process", startup_timeout=bad) + + +@pytest.mark.parametrize("bad", [0, -1, -0.5]) +def test_invalid_subcall_timeout_rejected(bad): + with pytest.raises(ValueError, match="subcall_timeout"): + IPythonREPL(kernel_mode="in_process", subcall_timeout=bad) + + +def test_subcall_timeout_none_is_allowed(): + """``None`` is the documented "no timeout" sentinel, distinct from 0.""" + with IPythonREPL(kernel_mode="in_process", subcall_timeout=None) as repl: + assert repl.subcall_timeout is None + + +def test_init_failure_preserves_original_exception_over_cleanup_error(monkeypatch): + """If ``setup`` fails *and* cleanup would also fail, ``__init__`` + must surface the original cause, not the cleanup error.""" + + class _Boom(Exception): + pass + + # Fail in ``_setup_in_process`` before any IPython state is created + # (so the test doesn't leave dangling atexit handlers behind). + def _broken_setup(self): + raise _Boom("original cause") + + monkeypatch.setattr(IPythonREPL, "_setup_in_process", _broken_setup) + + # Make cleanup itself raise too, so we can verify the original wins. + def _broken_cleanup(self): + raise RuntimeError("cleanup also failed") + + monkeypatch.setattr(IPythonREPL, "cleanup", _broken_cleanup) + + with pytest.raises(_Boom, match="original cause"): + IPythonREPL(kernel_mode="in_process") + + @BOTH_MODES def test_cleanup_is_idempotent(kernel_mode: str): repl = IPythonREPL(kernel_mode=kernel_mode) @@ -673,6 +715,57 @@ def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion: ) +# ----------------------------------------------------------------------------- +# Reentrant cell → execute_code (LM reaches parent via bound-method __self__) +# ----------------------------------------------------------------------------- + + +def test_in_process_cell_cannot_reenter_via_scaffold_self(): + """An in-process cell that traverses any scaffold bound method's + ``__self__`` to call ``execute_code`` on the parent must surface a + ``RuntimeError`` rather than silently corrupting the parent's + tracking state. + + Subprocess mode is naturally immune: kernel-side scaffolds are + plain functions with no ``__self__`` referencing the parent. + """ + with IPythonREPL(kernel_mode="in_process") as repl: + # ``rlm_query`` is a bound method; ``rlm_query.__self__`` is the + # parent REPL. Any other scaffold (llm_query, FINAL_VAR, ...) + # would work just as well as a reentry vector. + result = repl.execute_code("rlm_query.__self__.execute_code('print(\"inner\")')") + assert ( + "RuntimeError" in result.stderr + or "Reentrant" in result.stderr + or "Reentrant" in result.stdout + ), result.stderr or result.stdout + + +def test_in_process_scaffold_self_reentry_preserves_outer_state(): + """If the LM tries to reenter and we raise, the *outer* cell's + bookkeeping (``_pending_llm_calls``, ``_last_final_answer``) must + still be intact when execution resumes after the caught error.""" + + fake = _FakeSubcall(responses=["A"]) + with IPythonREPL(kernel_mode="in_process", subcall_fn=fake) as repl: + # Cell makes a real rlm_query, then attempts reentry. The reentry + # raises and is caught locally; the outer rlm_query call should + # still be reflected in ``rlm_calls``. + result = repl.execute_code( + "real = rlm_query('outer')\n" + "try:\n" + " rlm_query.__self__.execute_code('inner')\n" + "except RuntimeError as e:\n" + " print('caught:', type(e).__name__)\n" + "print('outer-result:', real)\n" + ) + assert "caught: RuntimeError" in result.stdout + assert "outer-result: A" in result.stdout + assert len(result.rlm_calls) == 1, ( + f"outer rlm_query must remain tracked; got {len(result.rlm_calls)}" + ) + + # ----------------------------------------------------------------------------- # Reentrant subcall_fn → execute_code (deadlock prevention) # -----------------------------------------------------------------------------