jmri_scripting.jython_shim
Synthetic jmri namespace so real JMRI Jython scripts import unchanged.
JMRI's Jython environment exposes classes and utilities under the
jmri.* package tree — jmri.InstanceManager, jmri.Path,
jmri.jmrit.automat.AbstractAutomaton, and so on. Our runtime's
injected globals cover the most common access pattern (sensors,
turnouts, ACTIVE, CLOSED), but scripts that reach in via the
JMRI namespace fail with ImportError: No module named 'jmri' before
they even start.
This module builds a synthetic module tree and registers it in
sys.modules on first install() call. Attribute coverage
targets the top-200 paths from stage-0/contracts/script-api.md —
the tail below rank 200 is a long list of one-off references not worth
the maintenance cost.
Design:
- Each synthetic module has
__all__for the attributes we actually populate. Attribute misses fall through_missing()which raises a clearAttributeErrornaming the shim's coverage rank + a hint. - Bean classes (
jmri.Turnout,jmri.Sensor, …) are re-exports ofjmri_core.beans.*soisinstancechecks work. jmri.InstanceManageris a static-method class whosegetDefault(cls)maps a JMRI Java class object (or one of our shim classes) to the corresponding shim manager on the runningJmriShiminstance. The shim is picked up from a thread-local set by the runner just beforeexec().- Modules under
jmri.jmrix.*and the deepjmri.jmrit.*sub-trees are stubs — scripts that touch them get a helpful error instead of a bareImportError.
1"""Synthetic ``jmri`` namespace so real JMRI Jython scripts import unchanged. 2 3JMRI's Jython environment exposes classes and utilities under the 4``jmri.*`` package tree — ``jmri.InstanceManager``, ``jmri.Path``, 5``jmri.jmrit.automat.AbstractAutomaton``, and so on. Our runtime's 6injected globals cover the *most* common access pattern (``sensors``, 7``turnouts``, ``ACTIVE``, ``CLOSED``), but scripts that reach in via the 8JMRI namespace fail with ``ImportError: No module named 'jmri'`` before 9they even start. 10 11This module builds a synthetic module tree and registers it in 12``sys.modules`` on first :func:`install` call. Attribute coverage 13targets the top-200 paths from ``stage-0/contracts/script-api.md`` — 14the tail below rank 200 is a long list of one-off references not worth 15the maintenance cost. 16 17Design: 18 19* Each synthetic module has ``__all__`` for the attributes we actually 20 populate. Attribute misses fall through :func:`_missing` which raises 21 a clear ``AttributeError`` naming the shim's coverage rank + a hint. 22* Bean classes (:class:`jmri.Turnout`, :class:`jmri.Sensor`, …) are 23 re-exports of :mod:`jmri_core.beans.*` so ``isinstance`` checks work. 24* :class:`jmri.InstanceManager` is a static-method class whose 25 ``getDefault(cls)`` maps a JMRI Java class object (or one of our 26 shim classes) to the corresponding shim manager on the running 27 :class:`JmriShim` instance. The shim is picked up from a 28 thread-local set by the runner just before ``exec()``. 29* Modules under ``jmri.jmrix.*`` and the deep ``jmri.jmrit.*`` sub-trees 30 are stubs — scripts that touch them get a helpful error instead of a 31 bare ``ImportError``. 32""" 33from __future__ import annotations 34 35import types 36from typing import TYPE_CHECKING, Any 37 38from jmri_core.beans.block import Block 39from jmri_core.beans.light import Light 40from jmri_core.beans.memory import Memory 41from jmri_core.beans.reporter import Reporter 42from jmri_core.beans.route import Route 43from jmri_core.beans.sensor import Sensor 44from jmri_core.beans.signal_head import SignalHead 45from jmri_core.beans.signal_mast import SignalMast 46from jmri_core.beans.throttle import Throttle 47from jmri_core.beans.turnout import Turnout 48from jmri_core.state import LightState, PowerState, SensorState, TurnoutState 49 50from jmri_scripting.automaton import AbstractAutomaton 51 52if TYPE_CHECKING: 53 from jmri_scripting.shim import JmriShim 54 55 56# Thread-local slot the runner fills right before exec() so 57# ``InstanceManager.getDefault(...)`` can find the active shim. 58import threading # noqa: E402 59_active_shim: threading.local = threading.local() 60 61 62def _get_active_shim() -> JmriShim: 63 shim = getattr(_active_shim, "value", None) 64 if shim is None: 65 raise RuntimeError( 66 "jmri.InstanceManager accessed outside a running script; the " 67 "runtime shim is only available on script worker threads.", 68 ) 69 return shim 70 71 72def _bind_shim(shim: JmriShim) -> None: 73 _active_shim.value = shim 74 75 76def _unbind_shim() -> None: 77 if hasattr(_active_shim, "value"): 78 del _active_shim.value 79 80 81# ----- jmri.Path ----------------------------------------------------------- 82 83 84class Path: 85 """Layout Path constants and helpers. 86 87 JMRI's ``jmri.Path`` is a compass-direction bit field used by block 88 tracking and route logic. We expose the constants and 89 :meth:`decodeDirection`; anything that needs the full path/turnout 90 linkage falls back to a clear AttributeError. 91 """ 92 93 NONE = 0 94 NORTH = 1 95 SOUTH = 2 96 EAST = 4 97 WEST = 8 98 CW = 16 # clockwise 99 CCW = 32 # counter-clockwise 100 LEFT = 64 101 RIGHT = 128 102 UP = 256 103 DOWN = 512 104 105 _NAMES = { 106 NONE: "None", NORTH: "North", SOUTH: "South", EAST: "East", 107 WEST: "West", CW: "CW", CCW: "CCW", LEFT: "Left", 108 RIGHT: "Right", UP: "Up", DOWN: "Down", 109 } 110 111 @staticmethod 112 def decodeDirection(direction: int) -> str: # noqa: N802 — JMRI casing 113 """Return the human-readable name for a Path direction bit.""" 114 return Path._NAMES.get(direction, f"Unknown({direction})") 115 116 117# ----- jmri.DccLocoAddress ------------------------------------------------- 118 119 120class DccLocoAddress: 121 """Address container matching JMRI's ``jmri.DccLocoAddress`` shape. 122 123 Constructor + ``getNumber`` + ``isLongAddress`` cover every use in 124 the top-200 scripts. Equality follows JMRI's semantics: two 125 addresses are equal iff both number and long-address flag match. 126 """ 127 128 __slots__ = ("_num", "_long") 129 130 def __init__(self, number: int, long_address: bool) -> None: 131 self._num = int(number) 132 self._long = bool(long_address) 133 134 def getNumber(self) -> int: # noqa: N802 135 return self._num 136 137 def isLongAddress(self) -> bool: # noqa: N802 138 return self._long 139 140 def __eq__(self, other: object) -> bool: 141 if not isinstance(other, DccLocoAddress): 142 return NotImplemented 143 return self._num == other._num and self._long == other._long 144 145 def __hash__(self) -> int: 146 return hash((self._num, self._long)) 147 148 def __repr__(self) -> str: 149 return f"DccLocoAddress({self._num}, long={self._long})" 150 151 152# ----- jmri.PowerManager --------------------------------------------------- 153 154 155class PowerManager: 156 """Static holder for JMRI PowerManager constants + instance methods. 157 158 The class itself doubles as the type key for 159 ``InstanceManager.getDefault(PowerManager)``; instances of it (rare 160 in scripts) fall through to the active shim's PowerShim. 161 """ 162 163 ON = PowerState.ON 164 OFF = PowerState.OFF 165 IDLE = PowerState.IDLE 166 UNKNOWN = PowerState.UNKNOWN 167 168 169# ---- InstanceManager.getDefault type keys -------------------------------- 170 171# These are placeholder classes used as class keys — scripts do 172# ``InstanceManager.getDefault(ConsistManager)`` and we route by 173# ``is`` identity to the shim's manager. Kept as bare classes (not 174# instances) to match how JMRI Java Jython scripts pass the class 175# object itself as the lookup key. 176 177 178class ConsistManager: 179 """Type key for :meth:`_InstanceManager.getDefault` — resolves to 180 the shim's :class:`~jmri_scripting.shim._ConsistShim`. See the 181 active shim's ``consists`` attribute for the runtime object.""" 182 183 184class ProgrammerManager: 185 """Type key for :meth:`_InstanceManager.getDefault` — resolves to 186 the shim's :class:`~jmri_scripting.shim._ProgrammerShim`.""" 187 188 189# ----- jmri.InstanceManager ----------------------------------------------- 190 191 192class _InstanceManager: 193 """Service locator matching JMRI's ``jmri.InstanceManager``. 194 195 ``getDefault(cls)`` returns the shim manager that services *cls* on 196 the currently running script's :class:`JmriShim`. Recognised 197 classes: 198 199 * :class:`Sensor`, :class:`Turnout`, :class:`Light`, :class:`Memory`, 200 :class:`Reporter`, :class:`Block`, :class:`Route`, 201 :class:`SignalHead`, :class:`SignalMast` — return the matching 202 manager. 203 * :class:`PowerManager` — return the shim's power manager. 204 205 Unknown types raise a clear AttributeError so a script author can 206 tell it's a shim-coverage gap, not a JMRI runtime bug. 207 """ 208 209 @staticmethod 210 def getDefault(cls: Any) -> Any: # noqa: N802 — JMRI casing 211 shim = _get_active_shim() 212 # Map class → shim manager. Uses identity comparison because 213 # scripts import the same class objects we exported. 214 if cls is Sensor: 215 return shim.sensors 216 if cls is Turnout: 217 return shim.turnouts 218 if cls is Light: 219 return shim.lights 220 if cls is Memory: 221 return shim.memories 222 if cls is Reporter: 223 return shim.reporters 224 if cls is Block: 225 return shim.blocks 226 if cls is Route: 227 return shim.routes 228 if cls is SignalHead: 229 return shim.signals 230 if cls is SignalMast: 231 return shim.masts 232 if cls is PowerManager: 233 return shim.powermanager 234 if cls is ConsistManager: 235 return shim.consists 236 if cls is ProgrammerManager: 237 return shim.programmers 238 raise AttributeError( 239 f"InstanceManager.getDefault({cls!r}) is not shimmed. Common " 240 "shimmed types: Sensor, Turnout, Light, Memory, Reporter, " 241 "Block, Route, SignalHead, SignalMast, PowerManager, " 242 "ConsistManager, ProgrammerManager.", 243 ) 244 245 @staticmethod 246 def getList(cls: Any) -> list[Any]: # noqa: N802 247 """Return all instances of the manager's beans as a plain list. 248 249 JMRI's version returns a Java List; here we hand back a Python 250 list of shim-wrapped beans. Scripts that call ``len(...)`` or 251 iterate over the return value work either way. 252 """ 253 mgr = _InstanceManager.getDefault(cls) 254 return list(mgr) 255 256 257# ----- jmri.util.FileUtil -------------------------------------------------- 258 259 260class _FileUtil: 261 """Subset of ``jmri.util.FileUtil`` used by the top-200 scripts. 262 263 JMRI's version implements a portable ``preference:``, ``profile:``, 264 ``resource:`` URL scheme. Our lightweight version maps the same 265 prefixes onto the current profile's directories where possible, 266 and returns raw paths otherwise so most script uses ``open(...)`` 267 calls still work. 268 """ 269 270 @staticmethod 271 def getExternalFilename(name: str) -> str: # noqa: N802 272 """Resolve a JMRI-style filename to a native path. 273 274 Supported prefixes: 275 276 * ``profile:X`` → ``<profile>/X`` when a profile is bound. 277 * ``preference:X`` → same as profile (JMRI conflates them at 278 this layer). 279 * Anything else → passthrough (assumed already-native). 280 """ 281 for prefix in ("profile:", "preference:"): 282 if name.startswith(prefix): 283 try: 284 shim = _get_active_shim() 285 profile_dir = getattr(shim, "profile_dir", None) 286 if profile_dir is not None: 287 return str(profile_dir / name[len(prefix):]) 288 except RuntimeError: 289 pass 290 return name[len(prefix):] 291 return name 292 293 294# ----- module tree construction -------------------------------------------- 295 296 297def _missing(module_name: str, attr: str) -> "AttributeError": 298 """Build a helpful AttributeError for an un-shimmed attribute.""" 299 return AttributeError( 300 f"module {module_name!r} has no attribute {attr!r}. " 301 "The Jython namespace shim covers the top-200 paths from " 302 "stage-0/contracts/script-api.md; this one falls outside. " 303 "Either use the shim's injected globals (sensors, turnouts, " 304 "…) or file an issue if this path is important.", 305 ) 306 307 308def _make_module(name: str, **attrs: Any) -> types.ModuleType: 309 """Create a synthetic ModuleType with attribute-level fallthrough.""" 310 mod = types.ModuleType(name) 311 mod.__all__ = list(attrs.keys()) 312 for k, v in attrs.items(): 313 setattr(mod, k, v) 314 315 def _module_getattr(item: str) -> Any: 316 raise _missing(name, item) 317 318 mod.__getattr__ = _module_getattr # type: ignore[method-assign] 319 return mod 320 321 322def _build_tree() -> dict[str, types.ModuleType]: 323 """Assemble the ``jmri.*`` module tree without registering yet.""" 324 # jmri.jmrit.automat — the AbstractAutomaton lives here in JMRI Java. 325 jmri_jmrit_automat = _make_module( 326 "jmri.jmrit.automat", AbstractAutomaton=AbstractAutomaton, 327 ) 328 # Populated stubs for the sub-namespaces most scripts probe by 329 # existence check; each raises via _missing on unknown attribute 330 # access. 331 jmri_jmrit_roster = _make_module("jmri.jmrit.roster") 332 jmri_jmrit_operations = _make_module("jmri.jmrit.operations") 333 jmri_jmrit_operations_trains = _make_module( 334 "jmri.jmrit.operations.trains", 335 ) 336 jmri_jmrit_operations_rollingstock = _make_module( 337 "jmri.jmrit.operations.rollingstock", 338 ) 339 jmri_jmrit_display = _make_module("jmri.jmrit.display") 340 jmri_jmrit_catalog = _make_module("jmri.jmrit.catalog") 341 jmri_jmrit_tracker = _make_module("jmri.jmrit.tracker") 342 343 jmri_jmrit = _make_module( 344 "jmri.jmrit", 345 automat=jmri_jmrit_automat, 346 roster=jmri_jmrit_roster, 347 operations=jmri_jmrit_operations, 348 display=jmri_jmrit_display, 349 catalog=jmri_jmrit_catalog, 350 tracker=jmri_jmrit_tracker, 351 ) 352 353 jmri_jmrix = _make_module("jmri.jmrix") 354 jmri_jmrix_loconet = _make_module("jmri.jmrix.loconet") 355 jmri_jmrix_mqtt = _make_module("jmri.jmrix.mqtt") 356 357 jmri_util = _make_module( 358 "jmri.util", 359 FileUtil=_FileUtil, 360 ) 361 jmri_implementation = _make_module("jmri.implementation") 362 363 jmri = _make_module( 364 "jmri", 365 # Service locator + constants used by the vast majority of scripts. 366 InstanceManager=_InstanceManager, 367 Path=Path, 368 PowerManager=PowerManager, 369 ConsistManager=ConsistManager, 370 ProgrammerManager=ProgrammerManager, 371 DccLocoAddress=DccLocoAddress, 372 # Bean class re-exports — used for isinstance and as 373 # InstanceManager.getDefault keys. 374 Sensor=Sensor, 375 Turnout=Turnout, 376 Light=Light, 377 Memory=Memory, 378 Reporter=Reporter, 379 Block=Block, 380 Route=Route, 381 SignalHead=SignalHead, 382 SignalMast=SignalMast, 383 Throttle=Throttle, 384 # Wire-value enums, so a script that does 385 # ``jmri.Turnout.CLOSED`` still works via the class attribute 386 # even though our Turnout doesn't define a nested constant. 387 # We expose them as sibling attributes so both forms resolve. 388 SensorState=SensorState, 389 TurnoutState=TurnoutState, 390 LightState=LightState, 391 PowerState=PowerState, 392 # Submodules — must be settable so ``from jmri import jmrit`` works. 393 jmrit=jmri_jmrit, 394 jmrix=jmri_jmrix, 395 util=jmri_util, 396 implementation=jmri_implementation, 397 ) 398 399 return { 400 "jmri": jmri, 401 "jmri.jmrit": jmri_jmrit, 402 "jmri.jmrit.automat": jmri_jmrit_automat, 403 "jmri.jmrit.roster": jmri_jmrit_roster, 404 "jmri.jmrit.operations": jmri_jmrit_operations, 405 "jmri.jmrit.operations.trains": jmri_jmrit_operations_trains, 406 "jmri.jmrit.operations.rollingstock": jmri_jmrit_operations_rollingstock, 407 "jmri.jmrit.display": jmri_jmrit_display, 408 "jmri.jmrit.catalog": jmri_jmrit_catalog, 409 "jmri.jmrit.tracker": jmri_jmrit_tracker, 410 "jmri.jmrix": jmri_jmrix, 411 "jmri.jmrix.loconet": jmri_jmrix_loconet, 412 "jmri.jmrix.mqtt": jmri_jmrix_mqtt, 413 "jmri.util": jmri_util, 414 "jmri.implementation": jmri_implementation, 415 } 416 417 418_INSTALLED = False 419 420 421def install() -> None: 422 """Register the synthetic ``jmri.*`` modules in :data:`sys.modules`. 423 424 Idempotent — invoked at module import time (see the bottom of this 425 file) so ``import jmri`` works immediately, and again from 426 :meth:`ScriptRunner.__init__` as a defensive belt. After this runs, 427 scripts can do ``from jmri import InstanceManager`` and 428 ``from jmri.jmrit.automat import AbstractAutomaton`` without 429 triggering the real Python import machinery. 430 431 ``sys.modules.setdefault`` is used so that if someone has a 432 legitimate ``jmri`` package pip-installed (rare but possible), we 433 don't clobber it — the real package wins. 434 """ 435 global _INSTALLED 436 if _INSTALLED: 437 return 438 import sys # noqa: PLC0415 439 for name, mod in _build_tree().items(): 440 sys.modules.setdefault(name, mod) 441 _INSTALLED = True 442 443 444# Install eagerly. Importing this module means the caller wants the 445# ``jmri.*`` names available; running the registration at load time 446# keeps ``import jmri`` working from any code path that touches 447# jmri_scripting, including tests that inspect the shim without 448# constructing a ScriptRunner. 449install()
85class Path: 86 """Layout Path constants and helpers. 87 88 JMRI's ``jmri.Path`` is a compass-direction bit field used by block 89 tracking and route logic. We expose the constants and 90 :meth:`decodeDirection`; anything that needs the full path/turnout 91 linkage falls back to a clear AttributeError. 92 """ 93 94 NONE = 0 95 NORTH = 1 96 SOUTH = 2 97 EAST = 4 98 WEST = 8 99 CW = 16 # clockwise 100 CCW = 32 # counter-clockwise 101 LEFT = 64 102 RIGHT = 128 103 UP = 256 104 DOWN = 512 105 106 _NAMES = { 107 NONE: "None", NORTH: "North", SOUTH: "South", EAST: "East", 108 WEST: "West", CW: "CW", CCW: "CCW", LEFT: "Left", 109 RIGHT: "Right", UP: "Up", DOWN: "Down", 110 } 111 112 @staticmethod 113 def decodeDirection(direction: int) -> str: # noqa: N802 — JMRI casing 114 """Return the human-readable name for a Path direction bit.""" 115 return Path._NAMES.get(direction, f"Unknown({direction})")
Layout Path constants and helpers.
JMRI's jmri.Path is a compass-direction bit field used by block
tracking and route logic. We expose the constants and
decodeDirection(); anything that needs the full path/turnout
linkage falls back to a clear AttributeError.
112 @staticmethod 113 def decodeDirection(direction: int) -> str: # noqa: N802 — JMRI casing 114 """Return the human-readable name for a Path direction bit.""" 115 return Path._NAMES.get(direction, f"Unknown({direction})")
Return the human-readable name for a Path direction bit.
121class DccLocoAddress: 122 """Address container matching JMRI's ``jmri.DccLocoAddress`` shape. 123 124 Constructor + ``getNumber`` + ``isLongAddress`` cover every use in 125 the top-200 scripts. Equality follows JMRI's semantics: two 126 addresses are equal iff both number and long-address flag match. 127 """ 128 129 __slots__ = ("_num", "_long") 130 131 def __init__(self, number: int, long_address: bool) -> None: 132 self._num = int(number) 133 self._long = bool(long_address) 134 135 def getNumber(self) -> int: # noqa: N802 136 return self._num 137 138 def isLongAddress(self) -> bool: # noqa: N802 139 return self._long 140 141 def __eq__(self, other: object) -> bool: 142 if not isinstance(other, DccLocoAddress): 143 return NotImplemented 144 return self._num == other._num and self._long == other._long 145 146 def __hash__(self) -> int: 147 return hash((self._num, self._long)) 148 149 def __repr__(self) -> str: 150 return f"DccLocoAddress({self._num}, long={self._long})"
Address container matching JMRI's jmri.DccLocoAddress shape.
Constructor + getNumber + isLongAddress cover every use in
the top-200 scripts. Equality follows JMRI's semantics: two
addresses are equal iff both number and long-address flag match.
156class PowerManager: 157 """Static holder for JMRI PowerManager constants + instance methods. 158 159 The class itself doubles as the type key for 160 ``InstanceManager.getDefault(PowerManager)``; instances of it (rare 161 in scripts) fall through to the active shim's PowerShim. 162 """ 163 164 ON = PowerState.ON 165 OFF = PowerState.OFF 166 IDLE = PowerState.IDLE 167 UNKNOWN = PowerState.UNKNOWN
Static holder for JMRI PowerManager constants + instance methods.
The class itself doubles as the type key for
InstanceManager.getDefault(PowerManager); instances of it (rare
in scripts) fall through to the active shim's PowerShim.
179class ConsistManager: 180 """Type key for :meth:`_InstanceManager.getDefault` — resolves to 181 the shim's :class:`~jmri_scripting.shim._ConsistShim`. See the 182 active shim's ``consists`` attribute for the runtime object."""
Type key for _InstanceManager.getDefault() — resolves to
the shim's ~jmri_scripting.shim._ConsistShim. See the
active shim's consists attribute for the runtime object.
185class ProgrammerManager: 186 """Type key for :meth:`_InstanceManager.getDefault` — resolves to 187 the shim's :class:`~jmri_scripting.shim._ProgrammerShim`."""
Type key for _InstanceManager.getDefault() — resolves to
the shim's ~jmri_scripting.shim._ProgrammerShim.
422def install() -> None: 423 """Register the synthetic ``jmri.*`` modules in :data:`sys.modules`. 424 425 Idempotent — invoked at module import time (see the bottom of this 426 file) so ``import jmri`` works immediately, and again from 427 :meth:`ScriptRunner.__init__` as a defensive belt. After this runs, 428 scripts can do ``from jmri import InstanceManager`` and 429 ``from jmri.jmrit.automat import AbstractAutomaton`` without 430 triggering the real Python import machinery. 431 432 ``sys.modules.setdefault`` is used so that if someone has a 433 legitimate ``jmri`` package pip-installed (rare but possible), we 434 don't clobber it — the real package wins. 435 """ 436 global _INSTALLED 437 if _INSTALLED: 438 return 439 import sys # noqa: PLC0415 440 for name, mod in _build_tree().items(): 441 sys.modules.setdefault(name, mod) 442 _INSTALLED = True
Register the synthetic jmri.* modules in sys.modules.
Idempotent — invoked at module import time (see the bottom of this
file) so import jmri works immediately, and again from
ScriptRunner.__init__() as a defensive belt. After this runs,
scripts can do from jmri import InstanceManager and
from jmri.jmrit.automat import AbstractAutomaton without
triggering the real Python import machinery.
sys.modules.setdefault is used so that if someone has a
legitimate jmri package pip-installed (rare but possible), we
don't clobber it — the real package wins.