jmri_scripting.repl
Interactive Python REPL bound to the JMRI shim.
The batch script runner in jmri_scripting.runner compiles and
runs a whole file per submission with a fresh globals namespace
each time. That's the right shape for automatons and one-shot
utilities, but it's a lousy fit for the "let me poke at the layout
interactively" workflow every layout builder actually wants — you
lose every variable and import as soon as the script ends.
This module provides the interactive companion. One persistent
code.InteractiveInterpreter per server, pre-seeded with the
shim's globals (sensors, turnouts, ACTIVE, everything).
Submit a fragment via ReplSession.submit() and get back a
ReplResult carrying the captured stdout, stderr, and the
last expression's repr — the Jupyter-cell shape.
State persists across submissions until ReplSession.reset()
is called, which rebuilds the interpreter (and drops every user
variable, import, and function).
Multi-line handling — a submission that ends with an
unfinished statement (an open def, a hanging if, an
unclosed bracket) needs to accumulate until the next submission.
_needs_continuation() uses code.compile_command() to
detect that case; the caller sees result.incomplete == True
and knows to append the next fragment before re-submitting.
Concurrency — a single lock serialises submissions so two
browser tabs can't race and corrupt the interpreter state. The
lock is contested only during submit, which does CPU-bound
work with the GIL held anyway; overhead is negligible.
1"""Interactive Python REPL bound to the JMRI shim. 2 3The batch script runner in :mod:`jmri_scripting.runner` compiles and 4runs a whole file per submission with a **fresh** globals namespace 5each time. That's the right shape for automatons and one-shot 6utilities, but it's a lousy fit for the "let me poke at the layout 7interactively" workflow every layout builder actually wants — you 8lose every variable and import as soon as the script ends. 9 10This module provides the interactive companion. One persistent 11:class:`code.InteractiveInterpreter` per server, pre-seeded with the 12shim's globals (``sensors``, ``turnouts``, ``ACTIVE``, everything). 13Submit a fragment via :meth:`ReplSession.submit` and get back a 14:class:`ReplResult` carrying the captured stdout, stderr, and the 15last expression's ``repr`` — the Jupyter-cell shape. 16 17State persists across submissions until :meth:`ReplSession.reset` 18is called, which rebuilds the interpreter (and drops every user 19variable, import, and function). 20 21**Multi-line handling** — a submission that ends with an 22unfinished statement (an open ``def``, a hanging ``if``, an 23unclosed bracket) needs to accumulate until the next submission. 24:func:`_needs_continuation` uses :func:`code.compile_command` to 25detect that case; the caller sees ``result.incomplete == True`` 26and knows to append the next fragment before re-submitting. 27 28**Concurrency** — a single lock serialises submissions so two 29browser tabs can't race and corrupt the interpreter state. The 30lock is contested only during ``submit``, which does CPU-bound 31work with the GIL held anyway; overhead is negligible. 32""" 33from __future__ import annotations 34 35import code 36import contextlib 37import io 38import logging 39import threading 40import time 41import traceback 42from dataclasses import dataclass, field 43from typing import TYPE_CHECKING, Any 44 45if TYPE_CHECKING: 46 from jmri_scripting.shim import JmriShim 47 48_log = logging.getLogger(__name__) 49 50 51# The interpreter's globals are seeded once at :meth:`ReplSession.reset` 52# time; subsequent submissions mutate that dict as normal Python code 53# would. When ``reset`` runs again, we build a fresh dict — the old 54# one is left for the GC. 55 56 57@dataclass(slots=True) 58class ReplEntry: 59 """One completed submission's record: what the user typed and 60 what the interpreter produced. 61 62 Kept lightweight so the running history buffer can hold ~100 63 entries without meaningful memory pressure.""" 64 65 source: str 66 stdout: str = "" 67 stderr: str = "" 68 result_repr: str = "" # last-expression ``repr()`` (Jupyter style) 69 error: str = "" # formatted traceback on failure 70 at: float = field(default_factory=time.monotonic) 71 72 73@dataclass(slots=True) 74class ReplResult: 75 """Return value of :meth:`ReplSession.submit`. 76 77 Distinguishes the three outcomes: 78 79 * ``incomplete=True`` — the source parses as an unfinished 80 statement (e.g. a ``def`` with no body yet). Client shows 81 the continuation prompt. 82 * ``error`` non-empty — the source ran and raised. 83 * Otherwise — the source ran cleanly. ``stdout`` / ``stderr`` / 84 ``result_repr`` carry whatever was produced. 85 """ 86 87 entry: ReplEntry 88 incomplete: bool = False 89 90 91# History cap — 100 entries × ~1 KB each = ~100 KB. If a user submits 92# a huge print loop the individual entry stdout is bounded by 93# ``_MAX_STDOUT_PER_ENTRY`` too. 94_MAX_HISTORY_ENTRIES = 100 95_MAX_STDOUT_PER_ENTRY = 64 * 1024 96 97 98def _needs_continuation(source: str) -> bool: 99 """Return True when *source* is syntactically incomplete — 100 i.e. the user needs to keep typing before we can compile. 101 102 :func:`code.compile_command` returns ``None`` for that case, 103 a code object when compilable, and raises for a hard syntax 104 error. We treat the hard error as "compilable enough to try 105 executing", letting the ``exec`` path surface the traceback 106 — matches how Python's own REPL behaves.""" 107 try: 108 result = code.compile_command(source, "<repl>", "single") 109 except (SyntaxError, OverflowError, ValueError): 110 return False 111 return result is None 112 113 114class ReplSession: 115 """Persistent Python interpreter bound to the JMRI shim. 116 117 One instance per server. Callers submit source fragments via 118 :meth:`submit`; state persists across submissions until 119 :meth:`reset` is called. 120 """ 121 122 def __init__(self, shim: JmriShim) -> None: 123 self._shim = shim 124 self._lock = threading.Lock() 125 self._history: list[ReplEntry] = [] 126 self._globals: dict[str, Any] = {} 127 # Build the initial namespace so the very first submission 128 # already has ``sensors`` / ``turnouts`` / etc. available. 129 self._install_globals() 130 131 def _install_globals(self) -> None: 132 """Populate ``self._globals`` with the shim's injected names 133 plus a couple of REPL-friendly built-ins.""" 134 self._globals = { 135 "__name__": "__jmri_repl__", 136 "__builtins__": __builtins__, 137 **self._shim.globals(), 138 } 139 # A friendly reference to the shim itself, for scripts that 140 # want to reach through to the underlying async connection. 141 self._globals["shim"] = self._shim 142 143 # ---- public API ---------------------------------------------------- 144 145 @property 146 def history(self) -> list[ReplEntry]: 147 """Snapshot of the history buffer, oldest first.""" 148 with self._lock: 149 return list(self._history) 150 151 def reset(self) -> None: 152 """Rebuild the interpreter — drops every user variable, 153 import, and function definition. History is cleared too.""" 154 with self._lock: 155 self._history.clear() 156 self._install_globals() 157 158 def submit(self, source: str) -> ReplResult: 159 """Execute *source* against the persistent interpreter. 160 161 Returns a :class:`ReplResult`. Bounded stdout capture means 162 a script that prints megabytes only surfaces the first 163 64 KB — a runaway loop can't OOM the server. 164 165 **Threading note**: this method blocks synchronously on the 166 current thread. It must NOT be called from the event-loop 167 thread if the submitted source might touch the shim, because 168 the shim's ``_block(loop, coro)`` schedules coroutines onto 169 that loop and waits for them — the loop can't advance if the 170 caller is holding its thread. Two supported patterns: 171 172 * **From an async coroutine on the loop thread**: wrap in 173 ``asyncio.to_thread(repl.submit, source)`` — that off-loads 174 the exec to a worker so the loop stays free. 175 * **From any other thread** (batch worker, sync test): call 176 directly. 177 178 :meth:`submit_async` is a thin async wrapper that does the 179 ``to_thread`` for you; web routes use it. 180 """ 181 with self._lock: 182 # Incomplete? Client keeps accumulating. 183 if _needs_continuation(source): 184 entry = ReplEntry(source=source) 185 return ReplResult(entry=entry, incomplete=True) 186 187 entry = ReplEntry(source=source) 188 self._exec_one(source, entry) 189 self._push_entry(entry) 190 return ReplResult(entry=entry, incomplete=False) 191 192 async def submit_async(self, source: str) -> ReplResult: 193 """Async wrapper — safe to call from a coroutine running on 194 the same event loop the shim binds to. Off-loads the sync 195 exec to a worker thread so the loop stays free to service 196 shim coroutines.""" 197 import asyncio # noqa: PLC0415 198 return await asyncio.to_thread(self.submit, source) 199 200 # ---- internals ---------------------------------------------------- 201 202 def _exec_one(self, source: str, entry: ReplEntry) -> None: 203 """Run *source* under stdout/stderr capture, filling *entry*. 204 205 Binds the shim to the current thread so 206 ``jmri.InstanceManager.getDefault(...)`` resolves — the shim 207 is thread-local (matches the batch runner's convention where 208 each script's worker thread gets its own bind). 209 """ 210 from jmri_scripting import jython_shim # noqa: PLC0415 211 212 stdout_buf = io.StringIO() 213 stderr_buf = io.StringIO() 214 215 jython_shim._bind_shim(self._shim) 216 try: 217 with ( 218 contextlib.redirect_stdout(stdout_buf), 219 contextlib.redirect_stderr(stderr_buf), 220 ): 221 self._run_source_capturing(source) 222 except SystemExit: 223 entry.error = "SystemExit: refused to exit the server" 224 except BaseException: # noqa: BLE001 225 entry.error = traceback.format_exc() 226 finally: 227 jython_shim._unbind_shim() 228 229 entry.stdout = _clip(stdout_buf.getvalue(), _MAX_STDOUT_PER_ENTRY) 230 entry.stderr = _clip(stderr_buf.getvalue(), _MAX_STDOUT_PER_ENTRY) 231 232 def _run_source_capturing(self, source: str) -> None: 233 """Two-pass compile: try as an expression to surface the 234 result via ``repr()``, else exec as statements. 235 236 Mirrors CPython's REPL: a bare expression at the prompt 237 prints its ``repr``; a statement produces no automatic 238 output. Storing the result on the *entry* is done by the 239 caller via ``__last_result__``. 240 """ 241 # ``self._entry_being_run`` set by ``_exec_one`` so the 242 # ``compile...eval`` branch can stash the result. Threaded 243 # through a per-call attribute rather than a return value so 244 # the exception paths above can still see the partial 245 # entry state. 246 try: 247 expr_code = compile(source, "<repl>", "eval") 248 except SyntaxError: 249 expr_code = None 250 251 if expr_code is not None: 252 result = eval(expr_code, self._globals) # noqa: S307 253 if result is not None: 254 # Print ``repr`` to stdout so the console rendering 255 # is one uniform place — no separate "result" cell. 256 print(repr(result)) 257 return 258 259 # Statement form: compile + exec. 260 stmt_code = compile(source, "<repl>", "exec") 261 exec(stmt_code, self._globals) # noqa: S102 262 263 def _push_entry(self, entry: ReplEntry) -> None: 264 self._history.append(entry) 265 overflow = len(self._history) - _MAX_HISTORY_ENTRIES 266 if overflow > 0: 267 del self._history[:overflow] 268 269 270def _clip(text: str, limit: int) -> str: 271 """Truncate *text* to *limit* bytes with a leading marker. 272 273 Called for both stdout and stderr per entry. Keeping the 274 trailing bytes (the freshest output) matches what an operator 275 watching a runaway loop expects — the "last thing before it 276 went wrong" is what they need to see.""" 277 if len(text) <= limit: 278 return text 279 dropped = len(text) - limit 280 marker = f"... ({dropped} bytes elided) ...\n" 281 return marker + text[dropped + len(marker):]
58@dataclass(slots=True) 59class ReplEntry: 60 """One completed submission's record: what the user typed and 61 what the interpreter produced. 62 63 Kept lightweight so the running history buffer can hold ~100 64 entries without meaningful memory pressure.""" 65 66 source: str 67 stdout: str = "" 68 stderr: str = "" 69 result_repr: str = "" # last-expression ``repr()`` (Jupyter style) 70 error: str = "" # formatted traceback on failure 71 at: float = field(default_factory=time.monotonic)
One completed submission's record: what the user typed and what the interpreter produced.
Kept lightweight so the running history buffer can hold ~100 entries without meaningful memory pressure.
74@dataclass(slots=True) 75class ReplResult: 76 """Return value of :meth:`ReplSession.submit`. 77 78 Distinguishes the three outcomes: 79 80 * ``incomplete=True`` — the source parses as an unfinished 81 statement (e.g. a ``def`` with no body yet). Client shows 82 the continuation prompt. 83 * ``error`` non-empty — the source ran and raised. 84 * Otherwise — the source ran cleanly. ``stdout`` / ``stderr`` / 85 ``result_repr`` carry whatever was produced. 86 """ 87 88 entry: ReplEntry 89 incomplete: bool = False
Return value of ReplSession.submit().
Distinguishes the three outcomes:
incomplete=True— the source parses as an unfinished statement (e.g. adefwith no body yet). Client shows the continuation prompt.errornon-empty — the source ran and raised.- Otherwise — the source ran cleanly.
stdout/stderr/result_reprcarry whatever was produced.
115class ReplSession: 116 """Persistent Python interpreter bound to the JMRI shim. 117 118 One instance per server. Callers submit source fragments via 119 :meth:`submit`; state persists across submissions until 120 :meth:`reset` is called. 121 """ 122 123 def __init__(self, shim: JmriShim) -> None: 124 self._shim = shim 125 self._lock = threading.Lock() 126 self._history: list[ReplEntry] = [] 127 self._globals: dict[str, Any] = {} 128 # Build the initial namespace so the very first submission 129 # already has ``sensors`` / ``turnouts`` / etc. available. 130 self._install_globals() 131 132 def _install_globals(self) -> None: 133 """Populate ``self._globals`` with the shim's injected names 134 plus a couple of REPL-friendly built-ins.""" 135 self._globals = { 136 "__name__": "__jmri_repl__", 137 "__builtins__": __builtins__, 138 **self._shim.globals(), 139 } 140 # A friendly reference to the shim itself, for scripts that 141 # want to reach through to the underlying async connection. 142 self._globals["shim"] = self._shim 143 144 # ---- public API ---------------------------------------------------- 145 146 @property 147 def history(self) -> list[ReplEntry]: 148 """Snapshot of the history buffer, oldest first.""" 149 with self._lock: 150 return list(self._history) 151 152 def reset(self) -> None: 153 """Rebuild the interpreter — drops every user variable, 154 import, and function definition. History is cleared too.""" 155 with self._lock: 156 self._history.clear() 157 self._install_globals() 158 159 def submit(self, source: str) -> ReplResult: 160 """Execute *source* against the persistent interpreter. 161 162 Returns a :class:`ReplResult`. Bounded stdout capture means 163 a script that prints megabytes only surfaces the first 164 64 KB — a runaway loop can't OOM the server. 165 166 **Threading note**: this method blocks synchronously on the 167 current thread. It must NOT be called from the event-loop 168 thread if the submitted source might touch the shim, because 169 the shim's ``_block(loop, coro)`` schedules coroutines onto 170 that loop and waits for them — the loop can't advance if the 171 caller is holding its thread. Two supported patterns: 172 173 * **From an async coroutine on the loop thread**: wrap in 174 ``asyncio.to_thread(repl.submit, source)`` — that off-loads 175 the exec to a worker so the loop stays free. 176 * **From any other thread** (batch worker, sync test): call 177 directly. 178 179 :meth:`submit_async` is a thin async wrapper that does the 180 ``to_thread`` for you; web routes use it. 181 """ 182 with self._lock: 183 # Incomplete? Client keeps accumulating. 184 if _needs_continuation(source): 185 entry = ReplEntry(source=source) 186 return ReplResult(entry=entry, incomplete=True) 187 188 entry = ReplEntry(source=source) 189 self._exec_one(source, entry) 190 self._push_entry(entry) 191 return ReplResult(entry=entry, incomplete=False) 192 193 async def submit_async(self, source: str) -> ReplResult: 194 """Async wrapper — safe to call from a coroutine running on 195 the same event loop the shim binds to. Off-loads the sync 196 exec to a worker thread so the loop stays free to service 197 shim coroutines.""" 198 import asyncio # noqa: PLC0415 199 return await asyncio.to_thread(self.submit, source) 200 201 # ---- internals ---------------------------------------------------- 202 203 def _exec_one(self, source: str, entry: ReplEntry) -> None: 204 """Run *source* under stdout/stderr capture, filling *entry*. 205 206 Binds the shim to the current thread so 207 ``jmri.InstanceManager.getDefault(...)`` resolves — the shim 208 is thread-local (matches the batch runner's convention where 209 each script's worker thread gets its own bind). 210 """ 211 from jmri_scripting import jython_shim # noqa: PLC0415 212 213 stdout_buf = io.StringIO() 214 stderr_buf = io.StringIO() 215 216 jython_shim._bind_shim(self._shim) 217 try: 218 with ( 219 contextlib.redirect_stdout(stdout_buf), 220 contextlib.redirect_stderr(stderr_buf), 221 ): 222 self._run_source_capturing(source) 223 except SystemExit: 224 entry.error = "SystemExit: refused to exit the server" 225 except BaseException: # noqa: BLE001 226 entry.error = traceback.format_exc() 227 finally: 228 jython_shim._unbind_shim() 229 230 entry.stdout = _clip(stdout_buf.getvalue(), _MAX_STDOUT_PER_ENTRY) 231 entry.stderr = _clip(stderr_buf.getvalue(), _MAX_STDOUT_PER_ENTRY) 232 233 def _run_source_capturing(self, source: str) -> None: 234 """Two-pass compile: try as an expression to surface the 235 result via ``repr()``, else exec as statements. 236 237 Mirrors CPython's REPL: a bare expression at the prompt 238 prints its ``repr``; a statement produces no automatic 239 output. Storing the result on the *entry* is done by the 240 caller via ``__last_result__``. 241 """ 242 # ``self._entry_being_run`` set by ``_exec_one`` so the 243 # ``compile...eval`` branch can stash the result. Threaded 244 # through a per-call attribute rather than a return value so 245 # the exception paths above can still see the partial 246 # entry state. 247 try: 248 expr_code = compile(source, "<repl>", "eval") 249 except SyntaxError: 250 expr_code = None 251 252 if expr_code is not None: 253 result = eval(expr_code, self._globals) # noqa: S307 254 if result is not None: 255 # Print ``repr`` to stdout so the console rendering 256 # is one uniform place — no separate "result" cell. 257 print(repr(result)) 258 return 259 260 # Statement form: compile + exec. 261 stmt_code = compile(source, "<repl>", "exec") 262 exec(stmt_code, self._globals) # noqa: S102 263 264 def _push_entry(self, entry: ReplEntry) -> None: 265 self._history.append(entry) 266 overflow = len(self._history) - _MAX_HISTORY_ENTRIES 267 if overflow > 0: 268 del self._history[:overflow]
Persistent Python interpreter bound to the JMRI shim.
One instance per server. Callers submit source fragments via
submit(); state persists across submissions until
reset() is called.
123 def __init__(self, shim: JmriShim) -> None: 124 self._shim = shim 125 self._lock = threading.Lock() 126 self._history: list[ReplEntry] = [] 127 self._globals: dict[str, Any] = {} 128 # Build the initial namespace so the very first submission 129 # already has ``sensors`` / ``turnouts`` / etc. available. 130 self._install_globals()
146 @property 147 def history(self) -> list[ReplEntry]: 148 """Snapshot of the history buffer, oldest first.""" 149 with self._lock: 150 return list(self._history)
Snapshot of the history buffer, oldest first.
152 def reset(self) -> None: 153 """Rebuild the interpreter — drops every user variable, 154 import, and function definition. History is cleared too.""" 155 with self._lock: 156 self._history.clear() 157 self._install_globals()
Rebuild the interpreter — drops every user variable, import, and function definition. History is cleared too.
159 def submit(self, source: str) -> ReplResult: 160 """Execute *source* against the persistent interpreter. 161 162 Returns a :class:`ReplResult`. Bounded stdout capture means 163 a script that prints megabytes only surfaces the first 164 64 KB — a runaway loop can't OOM the server. 165 166 **Threading note**: this method blocks synchronously on the 167 current thread. It must NOT be called from the event-loop 168 thread if the submitted source might touch the shim, because 169 the shim's ``_block(loop, coro)`` schedules coroutines onto 170 that loop and waits for them — the loop can't advance if the 171 caller is holding its thread. Two supported patterns: 172 173 * **From an async coroutine on the loop thread**: wrap in 174 ``asyncio.to_thread(repl.submit, source)`` — that off-loads 175 the exec to a worker so the loop stays free. 176 * **From any other thread** (batch worker, sync test): call 177 directly. 178 179 :meth:`submit_async` is a thin async wrapper that does the 180 ``to_thread`` for you; web routes use it. 181 """ 182 with self._lock: 183 # Incomplete? Client keeps accumulating. 184 if _needs_continuation(source): 185 entry = ReplEntry(source=source) 186 return ReplResult(entry=entry, incomplete=True) 187 188 entry = ReplEntry(source=source) 189 self._exec_one(source, entry) 190 self._push_entry(entry) 191 return ReplResult(entry=entry, incomplete=False)
Execute source against the persistent interpreter.
Returns a ReplResult. Bounded stdout capture means
a script that prints megabytes only surfaces the first
64 KB — a runaway loop can't OOM the server.
Threading note: this method blocks synchronously on the
current thread. It must NOT be called from the event-loop
thread if the submitted source might touch the shim, because
the shim's _block(loop, coro) schedules coroutines onto
that loop and waits for them — the loop can't advance if the
caller is holding its thread. Two supported patterns:
- From an async coroutine on the loop thread: wrap in
asyncio.to_thread(repl.submit, source)— that off-loads the exec to a worker so the loop stays free. - From any other thread (batch worker, sync test): call directly.
submit_async() is a thin async wrapper that does the
to_thread for you; web routes use it.
193 async def submit_async(self, source: str) -> ReplResult: 194 """Async wrapper — safe to call from a coroutine running on 195 the same event loop the shim binds to. Off-loads the sync 196 exec to a worker thread so the loop stays free to service 197 shim coroutines.""" 198 import asyncio # noqa: PLC0415 199 return await asyncio.to_thread(self.submit, source)
Async wrapper — safe to call from a coroutine running on the same event loop the shim binds to. Off-loads the sync exec to a worker thread so the loop stays free to service shim coroutines.