jmri_core.names
System-name parsing.
A JMRI system name has three parts:
<connection-prefix><type-letter><system-address>
For example, "LT12" = LocoNet (prefix L) Turnout (type T) at
address 12. Connection prefixes are user-configurable and can be multiple
characters: "L1T12" means a second LocoNet connection (prefix L1).
The convention is that the first character or characters up to and
including the last digit-or-letter run is the connection prefix; the next
single letter is the type; everything after is the address (which may
itself contain colons, dots, slashes for hierarchical addressing — e.g.
"MS1:5:3" for an OpenLCB sensor).
1"""System-name parsing. 2 3A JMRI system name has three parts: 4 5 ``<connection-prefix><type-letter><system-address>`` 6 7For example, ``"LT12"`` = LocoNet (prefix ``L``) Turnout (type ``T``) at 8address ``12``. Connection prefixes are user-configurable and can be multiple 9characters: ``"L1T12"`` means a second LocoNet connection (prefix ``L1``). 10 11The convention is that the *first* character or characters up to and 12including the last digit-or-letter run is the connection prefix; the next 13single letter is the type; everything after is the address (which may 14itself contain colons, dots, slashes for hierarchical addressing — e.g. 15``"MS1:5:3"`` for an OpenLCB sensor). 16""" 17from __future__ import annotations 18 19import re 20from dataclasses import dataclass 21 22# Connection prefix: one letter, optionally followed by digits. 23# Type letter: a single uppercase letter from the JMRI bean-type alphabet. 24# Address: anything that follows. Empty addresses are not allowed. 25_NAME_RE = re.compile(r"^(?P<prefix>[A-Z][A-Z0-9]*?)(?P<type>[A-Z])(?P<address>.+)$") 26 27# Minimum length of the post-prefix substring: type letter + at least 1 address char. 28_MIN_TYPE_PLUS_ADDR_LEN = 2 29 30 31@dataclass(frozen=True, slots=True) 32class SystemName: 33 """Parsed system name.""" 34 35 prefix: str # connection prefix, e.g. "L" or "L2" 36 type_letter: str # one-letter bean type, e.g. "T", "S", "H" 37 address: str # whatever follows; may be numeric or hierarchical 38 39 def __str__(self) -> str: 40 return f"{self.prefix}{self.type_letter}{self.address}" 41 42 @classmethod 43 def parse(cls, system_name: str, expected_prefix: str | None = None) -> SystemName: 44 """Parse *system_name*. Optionally enforce *expected_prefix*. 45 46 Raises :class:`ValueError` if the name doesn't match the JMRI shape or 47 if a prefix is specified and doesn't match. 48 """ 49 if expected_prefix is not None: 50 if not system_name.startswith(expected_prefix): 51 raise ValueError( 52 f"system name {system_name!r} does not start with expected " 53 f"prefix {expected_prefix!r}" 54 ) 55 # Split: prefix + first-char-after = type letter; remainder = address. 56 rest = system_name[len(expected_prefix):] 57 if ( 58 len(rest) < _MIN_TYPE_PLUS_ADDR_LEN 59 or not rest[0].isupper() 60 or not rest[0].isalpha() 61 ): 62 raise ValueError( 63 f"system name {system_name!r} has no valid type letter after " 64 f"prefix {expected_prefix!r}" 65 ) 66 return cls(prefix=expected_prefix, type_letter=rest[0], address=rest[1:]) 67 68 m = _NAME_RE.match(system_name) 69 if not m: 70 raise ValueError(f"system name {system_name!r} is not in <prefix><T><address> form") 71 return cls(prefix=m["prefix"], type_letter=m["type"], address=m["address"]) 72 73 @classmethod 74 def make(cls, prefix: str, type_letter: str, address: str | int) -> str: 75 """Convenience: format a system name from parts. 76 77 Returns the string form, not a ``SystemName`` — most callers want the 78 flat name for storage and pass it through :meth:`parse` again later. 79 """ 80 if len(type_letter) != 1 or not type_letter.isupper() or not type_letter.isalpha(): 81 raise ValueError(f"type_letter must be a single uppercase letter, got {type_letter!r}") 82 return f"{prefix}{type_letter}{address}"
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.