jmri_core

jmri_core — Python rewrite of JMRI's core domain model.

This package is pure logic with no I/O. It contains:

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]
class AbstractNamedBean:
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.

AbstractNamedBean(system_name: str, user_name: str | None = None)
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] = {}
system_name
user_name
comment: str | None
state_changed: Signal[typing.Any]
properties: dict[str, typing.Any]
display_name: str
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

user_name if set, else system_name. Mirrors JMRI's getDisplayName().

class InstanceManager:
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.

def register(self, iface: type[~T], impl: ~T) -> None:
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.

def register_factory(self, iface: type[~T], factory: Callable[[], ~T]) -> None:
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.

def get(self, iface: type[~T]) -> ~T:
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.

def try_get(self, iface: type[~T]) -> Optional[~T]:
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.

def contains(self, iface: type) -> bool:
65    def contains(self, iface: type) -> bool:
66        return iface in self._services or iface in self._factories
def remove(self, iface: type) -> None:
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)

Drop the binding for iface. No-op if absent.

class Manager(typing.Generic[T]):
 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.
Manager(system_prefix: str)
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()
type_letter: str = ''
system_prefix
added: 'Signal[T]'
removed: 'Signal[T]'
full_prefix: str
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".

def get_by_system_name(self, name: str) -> 'T | None':
67    def get_by_system_name(self, name: str) -> T | None:
68        return self._by_system.get(name)
def get_by_user_name(self, name: str) -> 'T | None':
70    def get_by_user_name(self, name: str) -> T | None:
71        return self._by_user.get(name)
def get(self, name: str) -> 'T | None':
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.

def named_bean_set(self) -> 'list[T]':
77    def named_bean_set(self) -> list[T]:
78        """Snapshot list, JMRI naming."""
79        return list(self._by_system.values())

Snapshot list, JMRI naming.

async def provide(self, name: str) -> 'T':
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.

async def register(self, bean: 'T') -> None:
 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.

async def deregister(self, bean: 'T') -> None:
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

@runtime_checkable
class NamedBean(typing.Protocol):
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.

NamedBean(*args, **kwargs)
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)
system_name: str
user_name: str | None
comment: str | None
state_changed: Signal[typing.Any]
class Signal(typing.Generic[T]):
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.

def connect(self, listener: 'Listener[T]') -> Callable[[], None]:
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.

def disconnect_all(self) -> None:
54    def disconnect_all(self) -> None:
55        """Drop every listener. Useful in teardown."""
56        self._listeners.clear()

Drop every listener. Useful in teardown.

listener_count: int
58    @property
59    def listener_count(self) -> int:
60        return len(self._listeners)
async def emit(self, payload: 'T') -> None:
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().

async def emit_concurrent(self, payload: 'T') -> None:
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.

@dataclass(frozen=True, slots=True)
class SystemName:
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.

SystemName(prefix: str, type_letter: str, address: str)
prefix: str
type_letter: str
address: str
@classmethod
def parse( cls, system_name: str, expected_prefix: str | None = None) -> SystemName:
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.

@classmethod
def make(cls, prefix: str, type_letter: str, address: str | int) -> str:
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.

__version__ = '0.1.0'