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):]
@dataclass(slots=True)
class ReplEntry:
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.

ReplEntry( source: str, stdout: str = '', stderr: str = '', result_repr: str = '', error: str = '', at: float = <factory>)
source: str
stdout: str
stderr: str
result_repr: str
error: str
at: float
@dataclass(slots=True)
class ReplResult:
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. a def with no body yet). Client shows the continuation prompt.
  • error non-empty — the source ran and raised.
  • Otherwise — the source ran cleanly. stdout / stderr / result_repr carry whatever was produced.
ReplResult(entry: ReplEntry, incomplete: bool = False)
entry: ReplEntry
incomplete: bool
class ReplSession:
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.

ReplSession(shim: jmri_scripting.JmriShim)
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()
history: list[ReplEntry]
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.

def reset(self) -> None:
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.

def submit(self, source: str) -> ReplResult:
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.

async def submit_async(self, source: str) -> ReplResult:
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.