jmri_core.manager

Manager[T] — the per-type bean registry.

Every JMRI bean type (turnouts, sensors, signal heads, …) has a manager that owns the set of beans of that type for one or more system connections. Two lookup keys are supported:

  • system_name — globally unique, e.g. "LT12";
  • user_name — optional human label, e.g. "Main Street".

The canonical operation is provide() — get-or-create by either key. Drivers subclass Manager and override _create() to build their concrete bean type.

  1"""``Manager[T]`` — the per-type bean registry.
  2
  3Every JMRI bean type (turnouts, sensors, signal heads, …) has a manager that
  4owns the set of beans of that type for one or more system connections. Two
  5lookup keys are supported:
  6
  7  * ``system_name`` — globally unique, e.g. ``"LT12"``;
  8  * ``user_name``   — optional human label, e.g. ``"Main Street"``.
  9
 10The canonical operation is :meth:`provide` — get-or-create by either key.
 11Drivers subclass :class:`Manager` and override :meth:`_create` to build
 12their concrete bean type.
 13"""
 14from __future__ import annotations
 15
 16from collections.abc import Iterator
 17from typing import Any
 18
 19from jmri_core.bean import NamedBean
 20from jmri_core.events import Signal
 21from jmri_core.names import SystemName
 22
 23
 24class Manager[T: NamedBean]:
 25    """Per-type bean registry. Subclass and override :meth:`_create`.
 26
 27    Attributes set on the class describe the bean type:
 28
 29    * ``type_letter`` — single uppercase letter the bean class owns
 30      (e.g. ``"T"`` for Turnout). Must be set by subclass.
 31    * ``system_prefix`` — connection prefix (e.g. ``"L"``). Set per-instance
 32      because one bean class can have many connection-specific managers.
 33    """
 34
 35    type_letter: str = ""  # subclass must override
 36
 37    def __init__(self, system_prefix: str) -> None:
 38        if not self.type_letter:
 39            raise TypeError(
 40                f"{type(self).__name__}.type_letter must be set on the class"
 41            )
 42        if not system_prefix:
 43            raise ValueError("system_prefix must be a non-empty string")
 44        self.system_prefix = system_prefix
 45        self._by_system: dict[str, T] = {}
 46        self._by_user: dict[str, T] = {}
 47        self.added: Signal[T] = Signal()
 48        self.removed: Signal[T] = Signal()
 49
 50    # ------------------------------------------------------------------ public
 51
 52    @property
 53    def full_prefix(self) -> str:
 54        """The connection prefix + type letter, e.g. ``"LT"``."""
 55        return f"{self.system_prefix}{self.type_letter}"
 56
 57    def __iter__(self) -> Iterator[T]:
 58        return iter(self._by_system.values())
 59
 60    def __len__(self) -> int:
 61        return len(self._by_system)
 62
 63    def __contains__(self, name: str) -> bool:
 64        return name in self._by_system or name in self._by_user
 65
 66    def get_by_system_name(self, name: str) -> T | None:
 67        return self._by_system.get(name)
 68
 69    def get_by_user_name(self, name: str) -> T | None:
 70        return self._by_user.get(name)
 71
 72    def get(self, name: str) -> T | None:
 73        """Look up by either system or user name; system first."""
 74        return self._by_system.get(name) or self._by_user.get(name)
 75
 76    def named_bean_set(self) -> list[T]:
 77        """Snapshot list, JMRI naming."""
 78        return list(self._by_system.values())
 79
 80    async def provide(self, name: str) -> T:
 81        """Get-or-create. Accepts either system name or address-only form.
 82
 83        Address-only example: passing ``"12"`` to a LocoNet turnout manager
 84        with ``system_prefix='L'`` resolves to ``"LT12"``. Passing the full
 85        system name ``"LT12"`` is also accepted.
 86        """
 87        if name in self._by_user:
 88            return self._by_user[name]
 89        sysname = self._normalize_name(name)
 90        if sysname in self._by_system:
 91            return self._by_system[sysname]
 92        bean = self._create(sysname)
 93        await self.register(bean)
 94        return bean
 95
 96    async def register(self, bean: T) -> None:
 97        """Add an externally constructed *bean* to the registry."""
 98        if bean.system_name in self._by_system:
 99            raise ValueError(f"bean with system_name {bean.system_name!r} already registered")
100        self._by_system[bean.system_name] = bean
101        if bean.user_name:
102            if bean.user_name in self._by_user:
103                raise ValueError(f"user_name {bean.user_name!r} already in use")
104            self._by_user[bean.user_name] = bean
105        await self.added.emit(bean)
106
107    async def deregister(self, bean: T) -> None:
108        """Remove *bean* from the registry. No-op if not present."""
109        if bean.system_name not in self._by_system:
110            return
111        del self._by_system[bean.system_name]
112        if bean.user_name and self._by_user.get(bean.user_name) is bean:
113            del self._by_user[bean.user_name]
114        await self.removed.emit(bean)
115
116    # ---------------------------------------------------------------- internal
117
118    def _normalize_name(self, name: str) -> str:
119        """Turn *name* into a full system name. Accepts either form."""
120        if name.startswith(self.full_prefix):
121            return name
122        # Treat as bare address. Validate it parses cleanly.
123        return SystemName.make(self.system_prefix, self.type_letter, name)
124
125    def _create(self, system_name: str) -> T:
126        """Subclasses must override to construct their concrete bean type."""
127        raise NotImplementedError(
128            f"{type(self).__name__}._create must be implemented by subclasses"
129        )
130
131    # Convenience for tests / persistence ------------------------------------
132
133    def _set_user_name(self, bean: T, new_name: str | None) -> None:
134        """Rename *bean* maintaining the user-name index. Internal."""
135        # Remove old binding if any
136        old = bean.user_name
137        if old and self._by_user.get(old) is bean:
138            del self._by_user[old]
139        bean.user_name = new_name
140        if new_name:
141            if new_name in self._by_user and self._by_user[new_name] is not bean:
142                raise ValueError(f"user_name {new_name!r} already in use")
143            self._by_user[new_name] = bean
144
145    def to_dict(self) -> dict[str, Any]:
146        """Serializable snapshot used by persistence layer."""
147        return {
148            "type_letter": self.type_letter,
149            "system_prefix": self.system_prefix,
150            "beans": [
151                {
152                    "system_name": b.system_name,
153                    "user_name": b.user_name,
154                    "comment": b.comment,
155                }
156                for b in self._by_system.values()
157            ],
158        }
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.