jmri_scripting

jmri-scripting — user script host for the JMRI Python rewrite (Stage 9).

Loads JMRI-style Python scripts and runs them with a familiar shim of synchronous wrappers over the async core. Scripts written for JMRI's Jython runtime port with minimal changes (the most-used 200 attribute paths from the Stage 0 script-api ranking are covered).

 1"""jmri-scripting — user script host for the JMRI Python rewrite (Stage 9).
 2
 3Loads JMRI-style Python scripts and runs them with a familiar shim of
 4synchronous wrappers over the async core. Scripts written for JMRI's
 5Jython runtime port with minimal changes (the most-used 200 attribute
 6paths from the Stage 0 script-api ranking are covered).
 7"""
 8from jmri_scripting.automaton import AbstractAutomaton
 9from jmri_scripting.repl import ReplEntry, ReplResult, ReplSession
10from jmri_scripting.runner import ScriptRunner, ScriptStatus
11from jmri_scripting.shim import JmriShim
12from jmri_scripting.watcher import ScriptWatcher
13
14__version__ = "0.1.0"
15
16__all__ = [
17    "AbstractAutomaton",
18    "JmriShim",
19    "ReplEntry",
20    "ReplResult",
21    "ReplSession",
22    "ScriptRunner",
23    "ScriptStatus",
24    "ScriptWatcher",
25    "__version__",
26]
class AbstractAutomaton:
27class AbstractAutomaton:
28    """Base class for scripted automations.
29
30    Subclass and override ``init`` (one-time setup) and ``handle``
31    (called in a loop, returns ``True`` to continue). Scripts can call
32    :meth:`stop` to terminate themselves.
33    """
34
35    # The shim is injected by the runner before init() runs.
36    shim: JmriShim
37
38    def __init__(self, name: str = "") -> None:
39        self.name = name or type(self).__name__
40        self._stop_event = threading.Event()
41        self._log = logging.getLogger(f"jmri_scripting.{self.name}")
42
43    # ----- lifecycle (overridden by subclasses) -----------------------------
44
45    def init(self) -> None:
46        """One-time setup before the handle loop starts."""
47
48    def handle(self) -> bool:
49        """Run one iteration. Return True to keep running, False to stop."""
50        return False
51
52    def cleanup(self) -> None:
53        """Optional teardown after the handle loop exits."""
54
55    # ----- control -------------------------------------------------------
56
57    def stop(self) -> None:
58        """Request the automaton to stop. Effective on the next handle iteration."""
59        self._stop_event.set()
60
61    @property
62    def should_stop(self) -> bool:
63        return self._stop_event.is_set()
64
65    # ----- runner-driven loop ------------------------------------------------
66
67    def run(self) -> None:
68        """Run the full lifecycle: init → handle-loop → cleanup."""
69        try:
70            self.init()
71            while not self._stop_event.is_set():
72                if not self.handle():
73                    break
74        except Exception:
75            self._log.exception("automaton %s crashed", self.name)
76            raise
77        finally:
78            try:
79                self.cleanup()
80            except Exception:
81                self._log.exception("automaton %s cleanup failed", self.name)

Base class for scripted automations.

Subclass and override init (one-time setup) and handle (called in a loop, returns True to continue). Scripts can call stop() to terminate themselves.

AbstractAutomaton(name: str = '')
38    def __init__(self, name: str = "") -> None:
39        self.name = name or type(self).__name__
40        self._stop_event = threading.Event()
41        self._log = logging.getLogger(f"jmri_scripting.{self.name}")
shim: JmriShim
name
def init(self) -> None:
45    def init(self) -> None:
46        """One-time setup before the handle loop starts."""

One-time setup before the handle loop starts.

def handle(self) -> bool:
48    def handle(self) -> bool:
49        """Run one iteration. Return True to keep running, False to stop."""
50        return False

Run one iteration. Return True to keep running, False to stop.

def cleanup(self) -> None:
52    def cleanup(self) -> None:
53        """Optional teardown after the handle loop exits."""

Optional teardown after the handle loop exits.

def stop(self) -> None:
57    def stop(self) -> None:
58        """Request the automaton to stop. Effective on the next handle iteration."""
59        self._stop_event.set()

Request the automaton to stop. Effective on the next handle iteration.

should_stop: bool
61    @property
62    def should_stop(self) -> bool:
63        return self._stop_event.is_set()
def run(self) -> None:
67    def run(self) -> None:
68        """Run the full lifecycle: init → handle-loop → cleanup."""
69        try:
70            self.init()
71            while not self._stop_event.is_set():
72                if not self.handle():
73                    break
74        except Exception:
75            self._log.exception("automaton %s crashed", self.name)
76            raise
77        finally:
78            try:
79                self.cleanup()
80            except Exception:
81                self._log.exception("automaton %s cleanup failed", self.name)

Run the full lifecycle: init → handle-loop → cleanup.

class JmriShim:
535class JmriShim:
536    """Bundle of script globals — the namespace every script sees.
537
538    A script's globals dict is populated from :meth:`globals`. JMRI-style
539    bare names (``sensors``, ``turnouts``, ``powermanager``, ``ACTIVE``,
540    ``CLOSED``, ``ON``, …) work without imports, matching the existing
541    Jython convention.
542    """
543
544    def __init__(
545        self, loop: asyncio.AbstractEventLoop, connection: SystemConnection
546    ) -> None:
547        self._loop = loop
548        self.connection = connection
549
550        self.sensors: _ManagerShim[Any, _SensorShim] = _ManagerShim(
551            loop, connection.sensors, lambda b: _SensorShim(loop, b),
552        )
553        self.turnouts: _ManagerShim[Any, _TurnoutShim] = _ManagerShim(
554            loop, connection.turnouts, lambda b: _TurnoutShim(loop, b),
555        )
556        self.lights: _ManagerShim[Any, _LightShim] = _ManagerShim(
557            loop, connection.lights, lambda b: _LightShim(loop, b),
558        )
559        self.memories: _ManagerShim[Any, _MemoryShim] = _ManagerShim(
560            loop, connection.memories, lambda b: _MemoryShim(loop, b),
561        )
562        self.reporters: _ManagerShim[Any, _ReporterShim] = _ManagerShim(
563            loop, connection.reporters, lambda b: _ReporterShim(loop, b),
564        )
565        self.blocks: _ManagerShim[Any, _BlockShim] = _ManagerShim(
566            loop, connection.blocks, lambda b: _BlockShim(loop, b),
567        )
568        # Routes/signals are less commonly mutated from scripts; expose
569        # them as raw managers so users can fall through to async if needed.
570        self.routes: _ManagerShim[Any, Any] = _ManagerShim(
571            loop, connection.routes, lambda b: b,
572        )
573        self.signals: _ManagerShim[Any, Any] = _ManagerShim(
574            loop, connection.signal_heads, lambda b: b,
575        )
576        self.masts: _ManagerShim[Any, Any] = _ManagerShim(
577            loop, connection.signal_masts, lambda b: b,
578        )
579
580        self.powermanager = _PowerShim(loop, connection.power, connection)
581        # Consists + programmers — both were on the deferred list
582        # for the Jython shim through beta 3. Wire them here so
583        # ``InstanceManager.getDefault(ConsistManager)`` and the
584        # ``ProgrammerManager`` equivalent resolve for scripts
585        # migrating from JMRI Java.
586        self.consists = _ConsistShim(loop, connection.consists)
587        self.programmers = _ProgrammerShim(loop, connection.programmers)
588
589        # State constants — JMRI scripts use bare ``ACTIVE``, ``CLOSED``, etc.
590        self.ACTIVE = SensorState.ACTIVE
591        self.INACTIVE = SensorState.INACTIVE
592        self.CLOSED = TurnoutState.CLOSED
593        self.THROWN = TurnoutState.THROWN
594        self.ON = LightState.ON
595        self.OFF = LightState.OFF
596        self.FORWARD = ThrottleDirection.FORWARD
597        self.REVERSE = ThrottleDirection.REVERSE
598
599    def globals(self) -> dict[str, Any]:
600        """Return the globals dict to inject into a script's ``exec`` namespace."""
601        return {
602            "sensors": self.sensors,
603            "turnouts": self.turnouts,
604            "lights": self.lights,
605            "memories": self.memories,
606            "reporters": self.reporters,
607            "blocks": self.blocks,
608            "routes": self.routes,
609            "signals": self.signals,
610            "masts": self.masts,
611            "powermanager": self.powermanager,
612            "consists": self.consists,
613            "programmers": self.programmers,
614            "ACTIVE": self.ACTIVE,
615            "INACTIVE": self.INACTIVE,
616            "CLOSED": self.CLOSED,
617            "THROWN": self.THROWN,
618            "ON": self.ON,
619            "OFF": self.OFF,
620            "FORWARD": self.FORWARD,
621            "REVERSE": self.REVERSE,
622            "SensorState": SensorState,
623            "TurnoutState": TurnoutState,
624            "LightState": LightState,
625            "PowerState": PowerState,
626            "shim": self,
627        }

Bundle of script globals — the namespace every script sees.

A script's globals dict is populated from globals(). JMRI-style bare names (sensors, turnouts, powermanager, ACTIVE, CLOSED, ON, …) work without imports, matching the existing Jython convention.

JmriShim( loop: asyncio.events.AbstractEventLoop, connection: jmri_core.hal.SystemConnection)
544    def __init__(
545        self, loop: asyncio.AbstractEventLoop, connection: SystemConnection
546    ) -> None:
547        self._loop = loop
548        self.connection = connection
549
550        self.sensors: _ManagerShim[Any, _SensorShim] = _ManagerShim(
551            loop, connection.sensors, lambda b: _SensorShim(loop, b),
552        )
553        self.turnouts: _ManagerShim[Any, _TurnoutShim] = _ManagerShim(
554            loop, connection.turnouts, lambda b: _TurnoutShim(loop, b),
555        )
556        self.lights: _ManagerShim[Any, _LightShim] = _ManagerShim(
557            loop, connection.lights, lambda b: _LightShim(loop, b),
558        )
559        self.memories: _ManagerShim[Any, _MemoryShim] = _ManagerShim(
560            loop, connection.memories, lambda b: _MemoryShim(loop, b),
561        )
562        self.reporters: _ManagerShim[Any, _ReporterShim] = _ManagerShim(
563            loop, connection.reporters, lambda b: _ReporterShim(loop, b),
564        )
565        self.blocks: _ManagerShim[Any, _BlockShim] = _ManagerShim(
566            loop, connection.blocks, lambda b: _BlockShim(loop, b),
567        )
568        # Routes/signals are less commonly mutated from scripts; expose
569        # them as raw managers so users can fall through to async if needed.
570        self.routes: _ManagerShim[Any, Any] = _ManagerShim(
571            loop, connection.routes, lambda b: b,
572        )
573        self.signals: _ManagerShim[Any, Any] = _ManagerShim(
574            loop, connection.signal_heads, lambda b: b,
575        )
576        self.masts: _ManagerShim[Any, Any] = _ManagerShim(
577            loop, connection.signal_masts, lambda b: b,
578        )
579
580        self.powermanager = _PowerShim(loop, connection.power, connection)
581        # Consists + programmers — both were on the deferred list
582        # for the Jython shim through beta 3. Wire them here so
583        # ``InstanceManager.getDefault(ConsistManager)`` and the
584        # ``ProgrammerManager`` equivalent resolve for scripts
585        # migrating from JMRI Java.
586        self.consists = _ConsistShim(loop, connection.consists)
587        self.programmers = _ProgrammerShim(loop, connection.programmers)
588
589        # State constants — JMRI scripts use bare ``ACTIVE``, ``CLOSED``, etc.
590        self.ACTIVE = SensorState.ACTIVE
591        self.INACTIVE = SensorState.INACTIVE
592        self.CLOSED = TurnoutState.CLOSED
593        self.THROWN = TurnoutState.THROWN
594        self.ON = LightState.ON
595        self.OFF = LightState.OFF
596        self.FORWARD = ThrottleDirection.FORWARD
597        self.REVERSE = ThrottleDirection.REVERSE
connection
sensors: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._SensorShim]
turnouts: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._TurnoutShim]
lights: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._LightShim]
memories: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._MemoryShim]
reporters: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._ReporterShim]
blocks: jmri_scripting.shim._ManagerShim[typing.Any, jmri_scripting.shim._BlockShim]
routes: jmri_scripting.shim._ManagerShim[typing.Any, typing.Any]
signals: jmri_scripting.shim._ManagerShim[typing.Any, typing.Any]
masts: jmri_scripting.shim._ManagerShim[typing.Any, typing.Any]
powermanager
consists
programmers
ACTIVE
INACTIVE
CLOSED
THROWN
ON
OFF
FORWARD
REVERSE
def globals(self) -> dict[str, typing.Any]:
599    def globals(self) -> dict[str, Any]:
600        """Return the globals dict to inject into a script's ``exec`` namespace."""
601        return {
602            "sensors": self.sensors,
603            "turnouts": self.turnouts,
604            "lights": self.lights,
605            "memories": self.memories,
606            "reporters": self.reporters,
607            "blocks": self.blocks,
608            "routes": self.routes,
609            "signals": self.signals,
610            "masts": self.masts,
611            "powermanager": self.powermanager,
612            "consists": self.consists,
613            "programmers": self.programmers,
614            "ACTIVE": self.ACTIVE,
615            "INACTIVE": self.INACTIVE,
616            "CLOSED": self.CLOSED,
617            "THROWN": self.THROWN,
618            "ON": self.ON,
619            "OFF": self.OFF,
620            "FORWARD": self.FORWARD,
621            "REVERSE": self.REVERSE,
622            "SensorState": SensorState,
623            "TurnoutState": TurnoutState,
624            "LightState": LightState,
625            "PowerState": PowerState,
626            "shim": self,
627        }

Return the globals dict to inject into a script's exec namespace.

@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: 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.

class ScriptRunner:
220class ScriptRunner:
221    """Owns the script lifecycle for one ServerContext."""
222
223    def __init__(self, shim: JmriShim) -> None:
224        self._shim = shim
225        self._scripts: dict[str, ScriptStatus] = {}
226        self._lock = threading.Lock()
227        # Install the stdout/stderr dispatch streams lazily on runner
228        # construction so ``sys.stdout``'s current identity (which may
229        # be pytest's capture, a systemd journal wrapper, or the real
230        # terminal) becomes the fallback for non-script threads.
231        self._stdout_proxy, self._stderr_proxy = _install_dispatch_streams()
232        # Register the synthetic ``jmri.*`` module tree so Jython-shaped
233        # scripts (``from jmri import InstanceManager``) import
234        # unchanged. Idempotent — modules are only registered once
235        # per process.
236        jython_shim.install()
237
238    @classmethod
239    def for_connection(
240        cls, loop: asyncio.AbstractEventLoop, connection: SystemConnection
241    ) -> ScriptRunner:
242        from jmri_scripting.shim import JmriShim  # noqa: PLC0415
243        return cls(JmriShim(loop, connection))
244
245    @property
246    def shim(self) -> JmriShim:
247        return self._shim
248
249    # ----- public API ----------------------------------------------------
250
251    def run_file(self, path: str | pathlib.Path, *, name: str | None = None) -> ScriptStatus:
252        """Compile and start *path*. Returns immediately; script runs in background."""
253        p = pathlib.Path(path)
254        source = p.read_text(encoding="utf-8")
255        name = name or p.stem
256        return self._start(name, source, p)
257
258    def load_directory(
259        self, path: str | pathlib.Path, *, glob: str = "*.py",
260    ) -> list[ScriptStatus]:
261        """Autoload every ``*.py`` under *path* and start it.
262
263        Used on server startup to resume scripts the user parked in the
264        profile's ``python/`` directory — this is the profile-scoped
265        persistence layer.
266
267        **Three opt-outs from autoload**, in order of aggressiveness:
268
269        1. **``_``-prefix** (``_helpers.py``) — private module. Skipped
270           entirely; not run and not visible under Python's ``import``
271           system as a top-level module either.
272        2. **``lib_``-prefix** (``lib_stuff.py``) — importable helper.
273           Skipped from auto-execute but the directory is on
274           ``sys.path`` so ``main.py`` can ``import lib_stuff``.
275        3. **Magic comment** (``# jmri-autoload: false`` in the first
276           few lines) — the file is a regular module, importable by
277           its own name, but the autoloader leaves it alone. Ideal
278           for scripts you want to keep on disk for the on-demand
279           Run button but don't want firing at every server start.
280
281        Read errors (permissions, encoding) skip the file silently.
282        Scripts that fail to compile still get a status entry with
283        ``error`` populated so the UI can surface them.
284        """
285        root = pathlib.Path(path)
286        if not root.is_dir():
287            return []
288        # Register the directory on sys.path once so scripts can
289        # ``import`` peer files as regular Python modules — the
290        # recursive-scripting affordance. Idempotent via ``in`` check.
291        root_str = str(root.resolve())
292        if root_str not in sys.path:
293            sys.path.insert(0, root_str)
294        started: list[ScriptStatus] = []
295        for p in sorted(root.glob(glob)):
296            if p.name.startswith("_") or not p.is_file():
297                continue
298            # ``lib_*.py`` are library modules for other scripts to
299            # ``import``. Don't auto-execute them.
300            if p.name.startswith("lib_"):
301                continue
302            try:
303                source = p.read_text(encoding="utf-8")
304            except OSError:
305                continue
306            if _autoload_opt_out(source):
307                # The magic-comment opt-out. File stays on disk +
308                # visible to the /scripts UI's Run button, but not
309                # autoexecuted at server startup.
310                continue
311            started.append(self._start(p.stem, source, p))
312        return started
313
314    def run_source(self, source: str, *, name: str) -> ScriptStatus:
315        """Compile and start *source* (inline script body)."""
316        return self._start(name, source, None)
317
318    def stop(self, name: str) -> bool:
319        """Signal the named script to stop. Returns True if it was running."""
320        with self._lock:
321            status = self._scripts.get(name)
322            if status is None:
323                return False
324            if status.automaton is not None:
325                status.automaton.stop()
326            # Plain scripts can't be stopped mid-statement; they'll finish
327            # naturally on the next handle() boundary if they're using an
328            # automaton, or run to completion otherwise.
329            return True
330
331    def list(self) -> list[ScriptStatus]:
332        with self._lock:
333            return list(self._scripts.values())
334
335    def get(self, name: str) -> ScriptStatus | None:
336        with self._lock:
337            return self._scripts.get(name)
338
339    def stop_all(self, *, join_timeout: float = 2.0) -> None:
340        with self._lock:
341            statuses = list(self._scripts.values())
342        for s in statuses:
343            if s.automaton is not None:
344                s.automaton.stop()
345        for s in statuses:
346            if s.thread is not None and s.thread.is_alive():
347                s.thread.join(timeout=join_timeout)
348
349    # ----- internals -----------------------------------------------------
350
351    def _start(self, name: str, source: str, path: pathlib.Path | None) -> ScriptStatus:
352        # Re-verify the dispatch streams are still installed. Some test
353        # runners (pytest) and long-running processes (uvicorn --reload)
354        # swap ``sys.stdout`` after our ``__init__`` first installed the
355        # proxy — re-installing here wraps whatever is current so a
356        # freshly-started script still routes through us.
357        if sys.stdout is not self._stdout_proxy or sys.stderr is not self._stderr_proxy:
358            self._stdout_proxy, self._stderr_proxy = _install_dispatch_streams()
359        status = ScriptStatus(
360            name=name, path=path, started_at=time.time(),
361        )
362        with self._lock:
363            # If a previous script with the same name is around, stop it first.
364            existing = self._scripts.get(name)
365            if existing and existing.automaton is not None:
366                existing.automaton.stop()
367            self._scripts[name] = status
368
369        thread = threading.Thread(
370            target=self._exec_script,
371            args=(name, source, path, status),
372            name=f"jmri-script:{name}",
373            daemon=True,
374        )
375        status.thread = thread
376        thread.start()
377        return status
378
379    def _exec_script(
380        self,
381        name: str,
382        source: str,
383        path: pathlib.Path | None,
384        status: ScriptStatus,
385    ) -> None:
386        # Route this thread's stdout/stderr into the status buffers so
387        # ``print()`` and any raised traceback surface in the web UI
388        # rather than getting lost in the server's log stream. The
389        # dispatch streams were installed on runner construction; here
390        # we just register/unregister this thread's target.
391        self._stdout_proxy.register(status._stdout_buffer)
392        self._stderr_proxy.register(status._stderr_buffer)
393        # Bind the shim to this thread so ``jmri.InstanceManager``
394        # can find the active connection during script execution.
395        jython_shim._bind_shim(self._shim)
396        try:
397            self._exec_script_body(name, source, path, status)
398        finally:
399            jython_shim._unbind_shim()
400            self._stdout_proxy.unregister()
401            self._stderr_proxy.unregister()
402
403    def _exec_script_body(
404        self,
405        name: str,
406        source: str,
407        path: pathlib.Path | None,
408        status: ScriptStatus,
409    ) -> None:
410        globals_dict = self._shim.globals() | {
411            "AbstractAutomaton": AbstractAutomaton,
412            "__name__": "__jmri_script__",
413            "__file__": str(path) if path else f"<{name}>",
414        }
415        try:
416            code = compile(source, str(path) if path else f"<{name}>", "exec")
417            exec(code, globals_dict)  # noqa: S102 — script execution is the feature
418        except Exception:
419            status.error = traceback.format_exc()
420            status.finished = True
421            return
422
423        # After top-level execution, look for an automaton subclass to run.
424        automaton_cls = _find_automaton_class(globals_dict)
425        if automaton_cls is not None:
426            automaton = automaton_cls(name=name)
427            automaton.shim = self._shim
428            with self._lock:
429                status.automaton = automaton
430            try:
431                automaton.run()
432            except Exception:
433                status.error = traceback.format_exc()
434        # Or look for a `main(shim)` callable.
435        else:
436            main_fn = globals_dict.get("main")
437            if callable(main_fn):
438                try:
439                    sig = inspect.signature(main_fn)
440                    if len(sig.parameters) > 0:
441                        main_fn(self._shim)
442                    else:
443                        main_fn()
444                except Exception:
445                    status.error = traceback.format_exc()
446        status.finished = True

Owns the script lifecycle for one ServerContext.

ScriptRunner(shim: JmriShim)
223    def __init__(self, shim: JmriShim) -> None:
224        self._shim = shim
225        self._scripts: dict[str, ScriptStatus] = {}
226        self._lock = threading.Lock()
227        # Install the stdout/stderr dispatch streams lazily on runner
228        # construction so ``sys.stdout``'s current identity (which may
229        # be pytest's capture, a systemd journal wrapper, or the real
230        # terminal) becomes the fallback for non-script threads.
231        self._stdout_proxy, self._stderr_proxy = _install_dispatch_streams()
232        # Register the synthetic ``jmri.*`` module tree so Jython-shaped
233        # scripts (``from jmri import InstanceManager``) import
234        # unchanged. Idempotent — modules are only registered once
235        # per process.
236        jython_shim.install()
@classmethod
def for_connection( cls, loop: asyncio.events.AbstractEventLoop, connection: jmri_core.hal.SystemConnection) -> ScriptRunner:
238    @classmethod
239    def for_connection(
240        cls, loop: asyncio.AbstractEventLoop, connection: SystemConnection
241    ) -> ScriptRunner:
242        from jmri_scripting.shim import JmriShim  # noqa: PLC0415
243        return cls(JmriShim(loop, connection))
shim: JmriShim
245    @property
246    def shim(self) -> JmriShim:
247        return self._shim
def run_file( self, path: str | pathlib._local.Path, *, name: str | None = None) -> ScriptStatus:
251    def run_file(self, path: str | pathlib.Path, *, name: str | None = None) -> ScriptStatus:
252        """Compile and start *path*. Returns immediately; script runs in background."""
253        p = pathlib.Path(path)
254        source = p.read_text(encoding="utf-8")
255        name = name or p.stem
256        return self._start(name, source, p)

Compile and start path. Returns immediately; script runs in background.

def load_directory( self, path: str | pathlib._local.Path, *, glob: str = '*.py') -> 'list[ScriptStatus]':
258    def load_directory(
259        self, path: str | pathlib.Path, *, glob: str = "*.py",
260    ) -> list[ScriptStatus]:
261        """Autoload every ``*.py`` under *path* and start it.
262
263        Used on server startup to resume scripts the user parked in the
264        profile's ``python/`` directory — this is the profile-scoped
265        persistence layer.
266
267        **Three opt-outs from autoload**, in order of aggressiveness:
268
269        1. **``_``-prefix** (``_helpers.py``) — private module. Skipped
270           entirely; not run and not visible under Python's ``import``
271           system as a top-level module either.
272        2. **``lib_``-prefix** (``lib_stuff.py``) — importable helper.
273           Skipped from auto-execute but the directory is on
274           ``sys.path`` so ``main.py`` can ``import lib_stuff``.
275        3. **Magic comment** (``# jmri-autoload: false`` in the first
276           few lines) — the file is a regular module, importable by
277           its own name, but the autoloader leaves it alone. Ideal
278           for scripts you want to keep on disk for the on-demand
279           Run button but don't want firing at every server start.
280
281        Read errors (permissions, encoding) skip the file silently.
282        Scripts that fail to compile still get a status entry with
283        ``error`` populated so the UI can surface them.
284        """
285        root = pathlib.Path(path)
286        if not root.is_dir():
287            return []
288        # Register the directory on sys.path once so scripts can
289        # ``import`` peer files as regular Python modules — the
290        # recursive-scripting affordance. Idempotent via ``in`` check.
291        root_str = str(root.resolve())
292        if root_str not in sys.path:
293            sys.path.insert(0, root_str)
294        started: list[ScriptStatus] = []
295        for p in sorted(root.glob(glob)):
296            if p.name.startswith("_") or not p.is_file():
297                continue
298            # ``lib_*.py`` are library modules for other scripts to
299            # ``import``. Don't auto-execute them.
300            if p.name.startswith("lib_"):
301                continue
302            try:
303                source = p.read_text(encoding="utf-8")
304            except OSError:
305                continue
306            if _autoload_opt_out(source):
307                # The magic-comment opt-out. File stays on disk +
308                # visible to the /scripts UI's Run button, but not
309                # autoexecuted at server startup.
310                continue
311            started.append(self._start(p.stem, source, p))
312        return started

Autoload every *.py under path and start it.

Used on server startup to resume scripts the user parked in the profile's python/ directory — this is the profile-scoped persistence layer.

Three opt-outs from autoload, in order of aggressiveness:

  1. _-prefix (_helpers.py) — private module. Skipped entirely; not run and not visible under Python's import system as a top-level module either.
  2. lib_-prefix (lib_stuff.py) — importable helper. Skipped from auto-execute but the directory is on sys.path so main.py can import lib_stuff.
  3. Magic comment (# jmri-autoload: false in the first few lines) — the file is a regular module, importable by its own name, but the autoloader leaves it alone. Ideal for scripts you want to keep on disk for the on-demand Run button but don't want firing at every server start.

Read errors (permissions, encoding) skip the file silently. Scripts that fail to compile still get a status entry with error populated so the UI can surface them.

def run_source(self, source: str, *, name: str) -> ScriptStatus:
314    def run_source(self, source: str, *, name: str) -> ScriptStatus:
315        """Compile and start *source* (inline script body)."""
316        return self._start(name, source, None)

Compile and start source (inline script body).

def stop(self, name: str) -> bool:
318    def stop(self, name: str) -> bool:
319        """Signal the named script to stop. Returns True if it was running."""
320        with self._lock:
321            status = self._scripts.get(name)
322            if status is None:
323                return False
324            if status.automaton is not None:
325                status.automaton.stop()
326            # Plain scripts can't be stopped mid-statement; they'll finish
327            # naturally on the next handle() boundary if they're using an
328            # automaton, or run to completion otherwise.
329            return True

Signal the named script to stop. Returns True if it was running.

def list(self) -> 'list[ScriptStatus]':
331    def list(self) -> list[ScriptStatus]:
332        with self._lock:
333            return list(self._scripts.values())
def get(self, name: str) -> ScriptStatus | None:
335    def get(self, name: str) -> ScriptStatus | None:
336        with self._lock:
337            return self._scripts.get(name)
def stop_all(self, *, join_timeout: float = 2.0) -> None:
339    def stop_all(self, *, join_timeout: float = 2.0) -> None:
340        with self._lock:
341            statuses = list(self._scripts.values())
342        for s in statuses:
343            if s.automaton is not None:
344                s.automaton.stop()
345        for s in statuses:
346            if s.thread is not None and s.thread.is_alive():
347                s.thread.join(timeout=join_timeout)
@dataclass
class ScriptStatus:
187@dataclass
188class ScriptStatus:
189    """One running (or finished) script."""
190
191    name: str
192    path: pathlib.Path | None
193    started_at: float
194    automaton: AbstractAutomaton | None = None
195    thread: threading.Thread | None = None
196    error: str | None = None
197    finished: bool = False
198    # Per-script captured stdout/stderr. Populated on the script's
199    # worker thread via the dispatch streams above; readable from any
200    # thread — the buffer's underlying string is immutable per read
201    # since ``getvalue`` returns a snapshot.
202    _stdout_buffer: _BoundedTextBuffer = field(
203        default_factory=_BoundedTextBuffer,
204    )
205    _stderr_buffer: _BoundedTextBuffer = field(
206        default_factory=_BoundedTextBuffer,
207    )
208
209    @property
210    def stdout(self) -> str:
211        """Captured ``print()`` output. Truncated FIFO once ~64 KB."""
212        return self._stdout_buffer.getvalue()
213
214    @property
215    def stderr(self) -> str:
216        """Captured ``sys.stderr`` output (raised tracebacks, warnings)."""
217        return self._stderr_buffer.getvalue()

One running (or finished) script.

ScriptStatus( name: str, path: pathlib._local.Path | None, started_at: float, automaton: AbstractAutomaton | None = None, thread: threading.Thread | None = None, error: str | None = None, finished: bool = False, _stdout_buffer: jmri_scripting.runner._BoundedTextBuffer = <factory>, _stderr_buffer: jmri_scripting.runner._BoundedTextBuffer = <factory>)
name: str
path: pathlib._local.Path | None
started_at: float
automaton: AbstractAutomaton | None = None
thread: threading.Thread | None = None
error: str | None = None
finished: bool = False
stdout: str
209    @property
210    def stdout(self) -> str:
211        """Captured ``print()`` output. Truncated FIFO once ~64 KB."""
212        return self._stdout_buffer.getvalue()

Captured print() output. Truncated FIFO once ~64 KB.

stderr: str
214    @property
215    def stderr(self) -> str:
216        """Captured ``sys.stderr`` output (raised tracebacks, warnings)."""
217        return self._stderr_buffer.getvalue()

Captured sys.stderr output (raised tracebacks, warnings).

class ScriptWatcher:
22class ScriptWatcher:
23    """Re-runs scripts in a directory when their .py files change."""
24
25    def __init__(
26        self,
27        runner: ScriptRunner,
28        directory: str | pathlib.Path,
29        *,
30        interval: float = 1.0,
31    ) -> None:
32        self._runner = runner
33        self._dir = pathlib.Path(directory)
34        self._interval = interval
35        self._mtimes: dict[pathlib.Path, float] = {}
36        self._task: asyncio.Task[None] | None = None
37        self._stop = asyncio.Event()
38
39    async def start(self) -> None:
40        if self._task is not None:
41            return
42        # Seed the initial mtimes.
43        for p in self._dir.glob("*.py"):
44            self._mtimes[p] = p.stat().st_mtime
45        self._task = asyncio.create_task(self._poll(), name="script-watcher")
46
47    async def stop(self) -> None:
48        self._stop.set()
49        if self._task is not None:
50            import contextlib  # noqa: PLC0415
51            self._task.cancel()
52            with contextlib.suppress(asyncio.CancelledError, Exception):
53                await self._task
54            self._task = None
55
56    async def _poll(self) -> None:
57        import contextlib  # noqa: PLC0415
58        try:
59            while not self._stop.is_set():
60                self._tick()
61                with contextlib.suppress(TimeoutError):
62                    await asyncio.wait_for(self._stop.wait(), timeout=self._interval)
63        except asyncio.CancelledError:
64            return
65
66    def _tick(self) -> None:
67        if not self._dir.is_dir():
68            return
69        current: dict[pathlib.Path, float] = {}
70        for p in self._dir.glob("*.py"):
71            try:
72                current[p] = p.stat().st_mtime
73            except FileNotFoundError:
74                continue
75
76        # New + changed files: restart.
77        for p, mtime in current.items():
78            prev = self._mtimes.get(p)
79            if prev is None or mtime > prev + 1e-6:
80                _log.info("hot-reload: %s", p.name)
81                try:
82                    self._runner.run_file(p, name=p.stem)
83                except Exception as e:  # noqa: BLE001
84                    _log.warning("failed to re-run %s: %s", p, e)
85
86        # Deleted files: stop the running script.
87        for p in list(self._mtimes):
88            if p not in current:
89                _log.info("hot-reload: %s removed, stopping", p.name)
90                self._runner.stop(p.stem)
91
92        self._mtimes = current

Re-runs scripts in a directory when their .py files change.

ScriptWatcher( runner: ScriptRunner, directory: str | pathlib._local.Path, *, interval: float = 1.0)
25    def __init__(
26        self,
27        runner: ScriptRunner,
28        directory: str | pathlib.Path,
29        *,
30        interval: float = 1.0,
31    ) -> None:
32        self._runner = runner
33        self._dir = pathlib.Path(directory)
34        self._interval = interval
35        self._mtimes: dict[pathlib.Path, float] = {}
36        self._task: asyncio.Task[None] | None = None
37        self._stop = asyncio.Event()
async def start(self) -> None:
39    async def start(self) -> None:
40        if self._task is not None:
41            return
42        # Seed the initial mtimes.
43        for p in self._dir.glob("*.py"):
44            self._mtimes[p] = p.stat().st_mtime
45        self._task = asyncio.create_task(self._poll(), name="script-watcher")
async def stop(self) -> None:
47    async def stop(self) -> None:
48        self._stop.set()
49        if self._task is not None:
50            import contextlib  # noqa: PLC0415
51            self._task.cancel()
52            with contextlib.suppress(asyncio.CancelledError, Exception):
53                await self._task
54            self._task = None
__version__ = '0.1.0'