jmri_core
jmri_core — Python rewrite of JMRI's core domain model.
This package is pure logic with no I/O. It contains:
jmri_core.bean—NamedBeanprotocol andAbstractNamedBeanmixin.jmri_core.events—Signal, the async-friendly event bus that replaces JMRI'sPropertyChangeListener.jmri_core.manager—Managergeneric, the per-type registry underlying every JMRI*Manager.jmri_core.instance—InstanceManager, the service-locator container.jmri_core.names— system-name parsing ("LT12"→ connection prefix"L", type letter"T", address"12").jmri_core.state— shared state enums.jmri_core.beans— concrete bean classes (Turnout, Sensor, …).jmri_core.hal—SystemConnectionABC + internal stub.jmri_core.persistence— XML load/save for roster and config files.jmri_core.plugins— entry-point discovery for drivers and extensions.
Hardware drivers and UI live in separate packages that depend on this one; this package never depends on them.
1"""jmri_core — Python rewrite of JMRI's core domain model. 2 3This package is **pure logic with no I/O**. It contains: 4 5- :mod:`jmri_core.bean` — :class:`NamedBean` protocol and 6 :class:`AbstractNamedBean` mixin. 7- :mod:`jmri_core.events` — :class:`Signal`, the async-friendly event bus 8 that replaces JMRI's ``PropertyChangeListener``. 9- :mod:`jmri_core.manager` — :class:`Manager` generic, the per-type registry 10 underlying every JMRI ``*Manager``. 11- :mod:`jmri_core.instance` — :class:`InstanceManager`, the service-locator 12 container. 13- :mod:`jmri_core.names` — system-name parsing (``"LT12"`` → connection 14 prefix ``"L"``, type letter ``"T"``, address ``"12"``). 15- :mod:`jmri_core.state` — shared state enums. 16- :mod:`jmri_core.beans` — concrete bean classes (Turnout, Sensor, …). 17- :mod:`jmri_core.hal` — :class:`SystemConnection` ABC + internal stub. 18- :mod:`jmri_core.persistence` — XML load/save for roster and config files. 19- :mod:`jmri_core.plugins` — entry-point discovery for drivers and extensions. 20 21Hardware drivers and UI live in separate packages that depend on this one; 22this package never depends on them. 23""" 24from jmri_core.bean import AbstractNamedBean, NamedBean 25from jmri_core.events import Signal 26from jmri_core.instance import InstanceManager 27from jmri_core.manager import Manager 28from jmri_core.names import SystemName 29 30__version__ = "0.1.0" 31 32__all__ = [ 33 "AbstractNamedBean", 34 "InstanceManager", 35 "Manager", 36 "NamedBean", 37 "Signal", 38 "SystemName", 39 "__version__", 40]
34class AbstractNamedBean: 35 """Concrete mixin providing the standard ``NamedBean`` machinery. 36 37 Subclasses extend this and add their state attributes + transition logic. 38 """ 39 40 __slots__ = ("comment", "properties", "state_changed", "system_name", "user_name") 41 42 def __init__(self, system_name: str, user_name: str | None = None) -> None: 43 if not system_name: 44 raise ValueError("system_name must be a non-empty string") 45 self.system_name = system_name 46 self.user_name = user_name 47 self.comment: str | None = None 48 self.state_changed: Signal[Any] = Signal() 49 self.properties: dict[str, Any] = {} 50 51 @property 52 def display_name(self) -> str: 53 """``user_name`` if set, else ``system_name``. Mirrors JMRI's getDisplayName().""" 54 return self.user_name if self.user_name else self.system_name 55 56 def __repr__(self) -> str: 57 return f"{type(self).__name__}({self.system_name!r})" 58 59 def __eq__(self, other: object) -> bool: 60 # Equality by (type, system_name) — JMRI manager invariant. 61 if type(self) is not type(other): 62 return False 63 return self.system_name == other.system_name 64 65 def __hash__(self) -> int: 66 return hash((type(self).__name__, self.system_name))
Concrete mixin providing the standard NamedBean machinery.
Subclasses extend this and add their state attributes + transition logic.
42 def __init__(self, system_name: str, user_name: str | None = None) -> None: 43 if not system_name: 44 raise ValueError("system_name must be a non-empty string") 45 self.system_name = system_name 46 self.user_name = user_name 47 self.comment: str | None = None 48 self.state_changed: Signal[Any] = Signal() 49 self.properties: dict[str, Any] = {}
23class InstanceManager: 24 """Per-application service container. Not a global singleton. 25 26 Construct one and pass it explicitly (or via dependency injection) into 27 components that need to resolve services. This avoids the global-state 28 pitfalls of JMRI's Java ``InstanceManager`` while keeping the locator 29 pattern that scripts and tools expect. 30 """ 31 32 def __init__(self) -> None: 33 self._services: dict[type, object] = {} 34 self._factories: dict[type, Callable[[], object]] = {} 35 36 def register(self, iface: type[T], impl: T) -> None: 37 """Bind a concrete instance to *iface*. Replaces any prior binding.""" 38 self._services[iface] = impl 39 # Clearing a factory ensures explicit register() wins over factory. 40 self._factories.pop(iface, None) 41 42 def register_factory(self, iface: type[T], factory: Callable[[], T]) -> None: 43 """Bind a zero-arg factory invoked on first :meth:`get`. Idempotent.""" 44 # If something is already bound, prefer the factory only if no live 45 # instance exists. 46 self._factories[iface] = factory 47 48 def get(self, iface: type[T]) -> T: 49 """Return the bound implementation of *iface*. Build via factory if needed.""" 50 if iface in self._services: 51 return self._services[iface] # type: ignore[return-value] 52 if iface in self._factories: 53 built = self._factories[iface]() 54 self._services[iface] = built 55 return built # type: ignore[return-value] 56 raise LookupError(f"No service registered for {iface.__name__}") 57 58 def try_get(self, iface: type[T]) -> T | None: 59 """Like :meth:`get` but returns ``None`` if no binding exists.""" 60 try: 61 return self.get(iface) 62 except LookupError: 63 return None 64 65 def contains(self, iface: type) -> bool: 66 return iface in self._services or iface in self._factories 67 68 def remove(self, iface: type) -> None: 69 """Drop the binding for *iface*. No-op if absent.""" 70 self._services.pop(iface, None) 71 self._factories.pop(iface, None)
Per-application service container. Not a global singleton.
Construct one and pass it explicitly (or via dependency injection) into
components that need to resolve services. This avoids the global-state
pitfalls of JMRI's Java InstanceManager while keeping the locator
pattern that scripts and tools expect.
36 def register(self, iface: type[T], impl: T) -> None: 37 """Bind a concrete instance to *iface*. Replaces any prior binding.""" 38 self._services[iface] = impl 39 # Clearing a factory ensures explicit register() wins over factory. 40 self._factories.pop(iface, None)
Bind a concrete instance to iface. Replaces any prior binding.
42 def register_factory(self, iface: type[T], factory: Callable[[], T]) -> None: 43 """Bind a zero-arg factory invoked on first :meth:`get`. Idempotent.""" 44 # If something is already bound, prefer the factory only if no live 45 # instance exists. 46 self._factories[iface] = factory
Bind a zero-arg factory invoked on first get(). Idempotent.
48 def get(self, iface: type[T]) -> T: 49 """Return the bound implementation of *iface*. Build via factory if needed.""" 50 if iface in self._services: 51 return self._services[iface] # type: ignore[return-value] 52 if iface in self._factories: 53 built = self._factories[iface]() 54 self._services[iface] = built 55 return built # type: ignore[return-value] 56 raise LookupError(f"No service registered for {iface.__name__}")
Return the bound implementation of iface. Build via factory if needed.
58 def try_get(self, iface: type[T]) -> T | None: 59 """Like :meth:`get` but returns ``None`` if no binding exists.""" 60 try: 61 return self.get(iface) 62 except LookupError: 63 return None
Like get() but returns None if no binding exists.
25class Manager[T: NamedBean]: 26 """Per-type bean registry. Subclass and override :meth:`_create`. 27 28 Attributes set on the class describe the bean type: 29 30 * ``type_letter`` — single uppercase letter the bean class owns 31 (e.g. ``"T"`` for Turnout). Must be set by subclass. 32 * ``system_prefix`` — connection prefix (e.g. ``"L"``). Set per-instance 33 because one bean class can have many connection-specific managers. 34 """ 35 36 type_letter: str = "" # subclass must override 37 38 def __init__(self, system_prefix: str) -> None: 39 if not self.type_letter: 40 raise TypeError( 41 f"{type(self).__name__}.type_letter must be set on the class" 42 ) 43 if not system_prefix: 44 raise ValueError("system_prefix must be a non-empty string") 45 self.system_prefix = system_prefix 46 self._by_system: dict[str, T] = {} 47 self._by_user: dict[str, T] = {} 48 self.added: Signal[T] = Signal() 49 self.removed: Signal[T] = Signal() 50 51 # ------------------------------------------------------------------ public 52 53 @property 54 def full_prefix(self) -> str: 55 """The connection prefix + type letter, e.g. ``"LT"``.""" 56 return f"{self.system_prefix}{self.type_letter}" 57 58 def __iter__(self) -> Iterator[T]: 59 return iter(self._by_system.values()) 60 61 def __len__(self) -> int: 62 return len(self._by_system) 63 64 def __contains__(self, name: str) -> bool: 65 return name in self._by_system or name in self._by_user 66 67 def get_by_system_name(self, name: str) -> T | None: 68 return self._by_system.get(name) 69 70 def get_by_user_name(self, name: str) -> T | None: 71 return self._by_user.get(name) 72 73 def get(self, name: str) -> T | None: 74 """Look up by either system or user name; system first.""" 75 return self._by_system.get(name) or self._by_user.get(name) 76 77 def named_bean_set(self) -> list[T]: 78 """Snapshot list, JMRI naming.""" 79 return list(self._by_system.values()) 80 81 async def provide(self, name: str) -> T: 82 """Get-or-create. Accepts either system name or address-only form. 83 84 Address-only example: passing ``"12"`` to a LocoNet turnout manager 85 with ``system_prefix='L'`` resolves to ``"LT12"``. Passing the full 86 system name ``"LT12"`` is also accepted. 87 """ 88 if name in self._by_user: 89 return self._by_user[name] 90 sysname = self._normalize_name(name) 91 if sysname in self._by_system: 92 return self._by_system[sysname] 93 bean = self._create(sysname) 94 await self.register(bean) 95 return bean 96 97 async def register(self, bean: T) -> None: 98 """Add an externally constructed *bean* to the registry.""" 99 if bean.system_name in self._by_system: 100 raise ValueError(f"bean with system_name {bean.system_name!r} already registered") 101 self._by_system[bean.system_name] = bean 102 if bean.user_name: 103 if bean.user_name in self._by_user: 104 raise ValueError(f"user_name {bean.user_name!r} already in use") 105 self._by_user[bean.user_name] = bean 106 await self.added.emit(bean) 107 108 async def deregister(self, bean: T) -> None: 109 """Remove *bean* from the registry. No-op if not present.""" 110 if bean.system_name not in self._by_system: 111 return 112 del self._by_system[bean.system_name] 113 if bean.user_name and self._by_user.get(bean.user_name) is bean: 114 del self._by_user[bean.user_name] 115 await self.removed.emit(bean) 116 117 # ---------------------------------------------------------------- internal 118 119 def _normalize_name(self, name: str) -> str: 120 """Turn *name* into a full system name. Accepts either form.""" 121 if name.startswith(self.full_prefix): 122 return name 123 # Treat as bare address. Validate it parses cleanly. 124 return SystemName.make(self.system_prefix, self.type_letter, name) 125 126 def _create(self, system_name: str) -> T: 127 """Subclasses must override to construct their concrete bean type.""" 128 raise NotImplementedError( 129 f"{type(self).__name__}._create must be implemented by subclasses" 130 ) 131 132 # Convenience for tests / persistence ------------------------------------ 133 134 def _set_user_name(self, bean: T, new_name: str | None) -> None: 135 """Rename *bean* maintaining the user-name index. Internal.""" 136 # Remove old binding if any 137 old = bean.user_name 138 if old and self._by_user.get(old) is bean: 139 del self._by_user[old] 140 bean.user_name = new_name 141 if new_name: 142 if new_name in self._by_user and self._by_user[new_name] is not bean: 143 raise ValueError(f"user_name {new_name!r} already in use") 144 self._by_user[new_name] = bean 145 146 def to_dict(self) -> dict[str, Any]: 147 """Serializable snapshot used by persistence layer.""" 148 return { 149 "type_letter": self.type_letter, 150 "system_prefix": self.system_prefix, 151 "beans": [ 152 { 153 "system_name": b.system_name, 154 "user_name": b.user_name, 155 "comment": b.comment, 156 } 157 for b in self._by_system.values() 158 ], 159 }
Per-type bean registry. Subclass and override _create().
Attributes set on the class describe the bean type:
type_letter— single uppercase letter the bean class owns (e.g."T"for Turnout). Must be set by subclass.system_prefix— connection prefix (e.g."L"). Set per-instance because one bean class can have many connection-specific managers.
38 def __init__(self, system_prefix: str) -> None: 39 if not self.type_letter: 40 raise TypeError( 41 f"{type(self).__name__}.type_letter must be set on the class" 42 ) 43 if not system_prefix: 44 raise ValueError("system_prefix must be a non-empty string") 45 self.system_prefix = system_prefix 46 self._by_system: dict[str, T] = {} 47 self._by_user: dict[str, T] = {} 48 self.added: Signal[T] = Signal() 49 self.removed: Signal[T] = Signal()
53 @property 54 def full_prefix(self) -> str: 55 """The connection prefix + type letter, e.g. ``"LT"``.""" 56 return f"{self.system_prefix}{self.type_letter}"
The connection prefix + type letter, e.g. "LT".
73 def get(self, name: str) -> T | None: 74 """Look up by either system or user name; system first.""" 75 return self._by_system.get(name) or self._by_user.get(name)
Look up by either system or user name; system first.
77 def named_bean_set(self) -> list[T]: 78 """Snapshot list, JMRI naming.""" 79 return list(self._by_system.values())
Snapshot list, JMRI naming.
81 async def provide(self, name: str) -> T: 82 """Get-or-create. Accepts either system name or address-only form. 83 84 Address-only example: passing ``"12"`` to a LocoNet turnout manager 85 with ``system_prefix='L'`` resolves to ``"LT12"``. Passing the full 86 system name ``"LT12"`` is also accepted. 87 """ 88 if name in self._by_user: 89 return self._by_user[name] 90 sysname = self._normalize_name(name) 91 if sysname in self._by_system: 92 return self._by_system[sysname] 93 bean = self._create(sysname) 94 await self.register(bean) 95 return bean
Get-or-create. Accepts either system name or address-only form.
Address-only example: passing "12" to a LocoNet turnout manager
with system_prefix='L' resolves to "LT12". Passing the full
system name "LT12" is also accepted.
97 async def register(self, bean: T) -> None: 98 """Add an externally constructed *bean* to the registry.""" 99 if bean.system_name in self._by_system: 100 raise ValueError(f"bean with system_name {bean.system_name!r} already registered") 101 self._by_system[bean.system_name] = bean 102 if bean.user_name: 103 if bean.user_name in self._by_user: 104 raise ValueError(f"user_name {bean.user_name!r} already in use") 105 self._by_user[bean.user_name] = bean 106 await self.added.emit(bean)
Add an externally constructed bean to the registry.
108 async def deregister(self, bean: T) -> None: 109 """Remove *bean* from the registry. No-op if not present.""" 110 if bean.system_name not in self._by_system: 111 return 112 del self._by_system[bean.system_name] 113 if bean.user_name and self._by_user.get(bean.user_name) is bean: 114 del self._by_user[bean.user_name] 115 await self.removed.emit(bean)
Remove bean from the registry. No-op if not present.
146 def to_dict(self) -> dict[str, Any]: 147 """Serializable snapshot used by persistence layer.""" 148 return { 149 "type_letter": self.type_letter, 150 "system_prefix": self.system_prefix, 151 "beans": [ 152 { 153 "system_name": b.system_name, 154 "user_name": b.user_name, 155 "comment": b.comment, 156 } 157 for b in self._by_system.values() 158 ], 159 }
Serializable snapshot used by persistence layer.
24@runtime_checkable 25class NamedBean(Protocol): 26 """Structural protocol for any JMRI domain object.""" 27 28 system_name: str 29 user_name: str | None 30 comment: str | None 31 state_changed: Signal[Any]
Structural protocol for any JMRI domain object.
1945def _no_init_or_replace_init(self, *args, **kwargs): 1946 cls = type(self) 1947 1948 if cls._is_protocol: 1949 raise TypeError('Protocols cannot be instantiated') 1950 1951 # Already using a custom `__init__`. No need to calculate correct 1952 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1953 if cls.__init__ is not _no_init_or_replace_init: 1954 return 1955 1956 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1957 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1958 # searches for a proper new `__init__` in the MRO. The new `__init__` 1959 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1960 # instantiation of the protocol subclass will thus use the new 1961 # `__init__` and no longer call `_no_init_or_replace_init`. 1962 for base in cls.__mro__: 1963 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1964 if init is not _no_init_or_replace_init: 1965 cls.__init__ = init 1966 break 1967 else: 1968 # should not happen 1969 cls.__init__ = object.__init__ 1970 1971 cls.__init__(self, *args, **kwargs)
31class Signal[T]: 32 """An emitter with zero or more listeners. See module docstring.""" 33 34 __slots__ = ("_listeners",) 35 36 def __init__(self) -> None: 37 self._listeners: list[Listener[T]] = [] 38 39 def connect(self, listener: Listener[T]) -> Callable[[], None]: 40 """Register *listener*. Returns a callable that disconnects it. 41 42 ``listener`` may be a sync or async callable taking one argument. 43 Returns a zero-arg disconnect function. 44 """ 45 self._listeners.append(listener) 46 47 def disconnect() -> None: 48 # Idempotent: removing twice is a no-op. 49 with contextlib.suppress(ValueError): 50 self._listeners.remove(listener) 51 52 return disconnect 53 54 def disconnect_all(self) -> None: 55 """Drop every listener. Useful in teardown.""" 56 self._listeners.clear() 57 58 @property 59 def listener_count(self) -> int: 60 return len(self._listeners) 61 62 async def emit(self, payload: T) -> None: 63 """Deliver *payload* to every connected listener. 64 65 Sync listeners run inline. Async listeners are awaited sequentially in 66 registration order — emission preserves ordering, which beans rely on 67 for state-change semantics (commanded-state notifications must arrive 68 before known-state notifications). If you need concurrency, use 69 :meth:`emit_concurrent`. 70 """ 71 first_error: BaseException | None = None 72 # Snapshot the list so listeners that disconnect during dispatch don't 73 # mutate the iteration. 74 for listener in list(self._listeners): 75 try: 76 result = listener(payload) 77 if inspect.iscoroutine(result): 78 await result 79 except BaseException as e: # noqa: BLE001 — re-raised below 80 if first_error is None: 81 first_error = e 82 if first_error is not None: 83 raise first_error 84 85 async def emit_concurrent(self, payload: T) -> None: 86 """Fan out to every async listener concurrently via ``asyncio.gather``. 87 88 Sync listeners still run inline before dispatching the async batch. 89 Use only when listeners are explicitly independent. 90 """ 91 async_results: list[Awaitable[None]] = [] 92 for listener in list(self._listeners): 93 result = listener(payload) 94 if inspect.iscoroutine(result): 95 async_results.append(result) 96 if async_results: 97 await asyncio.gather(*async_results)
An emitter with zero or more listeners. See module docstring.
39 def connect(self, listener: Listener[T]) -> Callable[[], None]: 40 """Register *listener*. Returns a callable that disconnects it. 41 42 ``listener`` may be a sync or async callable taking one argument. 43 Returns a zero-arg disconnect function. 44 """ 45 self._listeners.append(listener) 46 47 def disconnect() -> None: 48 # Idempotent: removing twice is a no-op. 49 with contextlib.suppress(ValueError): 50 self._listeners.remove(listener) 51 52 return disconnect
Register listener. Returns a callable that disconnects it.
listener may be a sync or async callable taking one argument.
Returns a zero-arg disconnect function.
54 def disconnect_all(self) -> None: 55 """Drop every listener. Useful in teardown.""" 56 self._listeners.clear()
Drop every listener. Useful in teardown.
62 async def emit(self, payload: T) -> None: 63 """Deliver *payload* to every connected listener. 64 65 Sync listeners run inline. Async listeners are awaited sequentially in 66 registration order — emission preserves ordering, which beans rely on 67 for state-change semantics (commanded-state notifications must arrive 68 before known-state notifications). If you need concurrency, use 69 :meth:`emit_concurrent`. 70 """ 71 first_error: BaseException | None = None 72 # Snapshot the list so listeners that disconnect during dispatch don't 73 # mutate the iteration. 74 for listener in list(self._listeners): 75 try: 76 result = listener(payload) 77 if inspect.iscoroutine(result): 78 await result 79 except BaseException as e: # noqa: BLE001 — re-raised below 80 if first_error is None: 81 first_error = e 82 if first_error is not None: 83 raise first_error
Deliver payload to every connected listener.
Sync listeners run inline. Async listeners are awaited sequentially in
registration order — emission preserves ordering, which beans rely on
for state-change semantics (commanded-state notifications must arrive
before known-state notifications). If you need concurrency, use
emit_concurrent().
85 async def emit_concurrent(self, payload: T) -> None: 86 """Fan out to every async listener concurrently via ``asyncio.gather``. 87 88 Sync listeners still run inline before dispatching the async batch. 89 Use only when listeners are explicitly independent. 90 """ 91 async_results: list[Awaitable[None]] = [] 92 for listener in list(self._listeners): 93 result = listener(payload) 94 if inspect.iscoroutine(result): 95 async_results.append(result) 96 if async_results: 97 await asyncio.gather(*async_results)
Fan out to every async listener concurrently via asyncio.gather.
Sync listeners still run inline before dispatching the async batch. Use only when listeners are explicitly independent.
32@dataclass(frozen=True, slots=True) 33class SystemName: 34 """Parsed system name.""" 35 36 prefix: str # connection prefix, e.g. "L" or "L2" 37 type_letter: str # one-letter bean type, e.g. "T", "S", "H" 38 address: str # whatever follows; may be numeric or hierarchical 39 40 def __str__(self) -> str: 41 return f"{self.prefix}{self.type_letter}{self.address}" 42 43 @classmethod 44 def parse(cls, system_name: str, expected_prefix: str | None = None) -> SystemName: 45 """Parse *system_name*. Optionally enforce *expected_prefix*. 46 47 Raises :class:`ValueError` if the name doesn't match the JMRI shape or 48 if a prefix is specified and doesn't match. 49 """ 50 if expected_prefix is not None: 51 if not system_name.startswith(expected_prefix): 52 raise ValueError( 53 f"system name {system_name!r} does not start with expected " 54 f"prefix {expected_prefix!r}" 55 ) 56 # Split: prefix + first-char-after = type letter; remainder = address. 57 rest = system_name[len(expected_prefix):] 58 if ( 59 len(rest) < _MIN_TYPE_PLUS_ADDR_LEN 60 or not rest[0].isupper() 61 or not rest[0].isalpha() 62 ): 63 raise ValueError( 64 f"system name {system_name!r} has no valid type letter after " 65 f"prefix {expected_prefix!r}" 66 ) 67 return cls(prefix=expected_prefix, type_letter=rest[0], address=rest[1:]) 68 69 m = _NAME_RE.match(system_name) 70 if not m: 71 raise ValueError(f"system name {system_name!r} is not in <prefix><T><address> form") 72 return cls(prefix=m["prefix"], type_letter=m["type"], address=m["address"]) 73 74 @classmethod 75 def make(cls, prefix: str, type_letter: str, address: str | int) -> str: 76 """Convenience: format a system name from parts. 77 78 Returns the string form, not a ``SystemName`` — most callers want the 79 flat name for storage and pass it through :meth:`parse` again later. 80 """ 81 if len(type_letter) != 1 or not type_letter.isupper() or not type_letter.isalpha(): 82 raise ValueError(f"type_letter must be a single uppercase letter, got {type_letter!r}") 83 return f"{prefix}{type_letter}{address}"
Parsed system name.
43 @classmethod 44 def parse(cls, system_name: str, expected_prefix: str | None = None) -> SystemName: 45 """Parse *system_name*. Optionally enforce *expected_prefix*. 46 47 Raises :class:`ValueError` if the name doesn't match the JMRI shape or 48 if a prefix is specified and doesn't match. 49 """ 50 if expected_prefix is not None: 51 if not system_name.startswith(expected_prefix): 52 raise ValueError( 53 f"system name {system_name!r} does not start with expected " 54 f"prefix {expected_prefix!r}" 55 ) 56 # Split: prefix + first-char-after = type letter; remainder = address. 57 rest = system_name[len(expected_prefix):] 58 if ( 59 len(rest) < _MIN_TYPE_PLUS_ADDR_LEN 60 or not rest[0].isupper() 61 or not rest[0].isalpha() 62 ): 63 raise ValueError( 64 f"system name {system_name!r} has no valid type letter after " 65 f"prefix {expected_prefix!r}" 66 ) 67 return cls(prefix=expected_prefix, type_letter=rest[0], address=rest[1:]) 68 69 m = _NAME_RE.match(system_name) 70 if not m: 71 raise ValueError(f"system name {system_name!r} is not in <prefix><T><address> form") 72 return cls(prefix=m["prefix"], type_letter=m["type"], address=m["address"])
Parse system_name. Optionally enforce expected_prefix.
Raises ValueError if the name doesn't match the JMRI shape or
if a prefix is specified and doesn't match.
74 @classmethod 75 def make(cls, prefix: str, type_letter: str, address: str | int) -> str: 76 """Convenience: format a system name from parts. 77 78 Returns the string form, not a ``SystemName`` — most callers want the 79 flat name for storage and pass it through :meth:`parse` again later. 80 """ 81 if len(type_letter) != 1 or not type_letter.isupper() or not type_letter.isalpha(): 82 raise ValueError(f"type_letter must be a single uppercase letter, got {type_letter!r}") 83 return f"{prefix}{type_letter}{address}"
Convenience: format a system name from parts.
Returns the string form, not a SystemName — most callers want the
flat name for storage and pass it through parse() again later.