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]
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.
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.
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.
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.
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.
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
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.
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.
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.
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()
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.
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:
_-prefix (_helpers.py) — private module. Skipped entirely; not run and not visible under Python'simportsystem as a top-level module either.lib_-prefix (lib_stuff.py) — importable helper. Skipped from auto-execute but the directory is onsys.pathsomain.pycanimport lib_stuff.- Magic comment (
# jmri-autoload: falsein 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.
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).
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.
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)
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.
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.
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()