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}"
@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.