jmri_logixng.manager

LogixNGManager — profile-scoped registry of LogixNG programs.

Programs live on disk as YAML files under <profile>/logixng/. Filenames are the program's system name plus .yaml. The manager provides:

  • list() / iteration
  • get(system_name)
  • create(system_name, ...)
  • put(system_name, logixng) — replace by name
  • delete(system_name)
  • save_all(root) / load_all(root)

Signals added / removed / updated fire on mutations so the JSON push side and any future UI can react without polling.

  1"""``LogixNGManager`` — profile-scoped registry of :class:`LogixNG` programs.
  2
  3Programs live on disk as YAML files under ``<profile>/logixng/``.
  4Filenames are the program's system name plus ``.yaml``. The manager
  5provides:
  6
  7* ``list()`` / iteration
  8* ``get(system_name)``
  9* ``create(system_name, ...)``
 10* ``put(system_name, logixng)`` — replace by name
 11* ``delete(system_name)``
 12* ``save_all(root)`` / ``load_all(root)``
 13
 14Signals ``added`` / ``removed`` / ``updated`` fire on mutations so the
 15JSON push side and any future UI can react without polling.
 16"""
 17from __future__ import annotations
 18
 19import logging
 20import pathlib
 21import re
 22from collections.abc import Iterator
 23
 24from jmri_core.events import Signal
 25
 26from jmri_logixng.logix import LogixNG
 27from jmri_logixng.serialize import from_yaml, load_yaml, save_yaml
 28
 29_log = logging.getLogger(__name__)
 30
 31# JMRI Java's convention for LogixNG system names is ``IQ`` (LogixNG
 32# root) + digits, distinct from the ``IX`` Logix (legacy) prefix. We
 33# follow it so system names round-trip against real JMRI XML. Callers
 34# using scripts can also pass any string — validation is deliberately
 35# lax (a user-friendly ``IQ:MyRoutine`` is fine).
 36_DEFAULT_PREFIX = "IQ"
 37
 38# Filenames are only allowed the same character class as JMRI system
 39# names to keep the ``<system_name>.yaml`` mapping unambiguous. Slashes
 40# and other separators would be a path-traversal risk on disk. Colons
 41# in particular are excluded because Windows won't allow them as file
 42# names — the save silently truncates ``"IQ:x.yaml"`` to ``"IQ"``.
 43# Colons are permitted because JMRI Java's LogixNG system names use
 44# them (``IQ:AUTO:0001``); a name imported from a JMRI XML would be
 45# rejected without them. Slashes and spaces still excluded — those
 46# would break file-per-program persistence.
 47_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.:-]+$")
 48
 49
 50class LogixNGManager:
 51    """Registry of :class:`LogixNG` programs, one per profile.
 52
 53    Not a :class:`~jmri_core.manager.Manager` — LogixNG isn't a
 54    NamedBean and the system-name shape is program-oriented rather
 55    than address-oriented. The signal / index shape mirrors Manager
 56    closely so callers can subscribe in the same pattern.
 57    """
 58
 59    def __init__(self, prefix: str = _DEFAULT_PREFIX) -> None:
 60        self.prefix = prefix
 61        self._by_name: dict[str, LogixNG] = {}
 62        self.added: Signal[LogixNG] = Signal()
 63        self.removed: Signal[LogixNG] = Signal()
 64        self.updated: Signal[LogixNG] = Signal()
 65
 66    # ---- read ------------------------------------------------------------
 67
 68    def __iter__(self) -> Iterator[LogixNG]:
 69        return iter(self._by_name.values())
 70
 71    def __len__(self) -> int:
 72        return len(self._by_name)
 73
 74    def __contains__(self, name: str) -> bool:
 75        return name in self._by_name
 76
 77    def get(self, system_name: str) -> LogixNG | None:
 78        return self._by_name.get(system_name)
 79
 80    def names(self) -> list[str]:
 81        return list(self._by_name)
 82
 83    # ---- mutate ---------------------------------------------------------
 84
 85    async def put(self, system_name: str, logixng: LogixNG) -> LogixNG:
 86        """Insert or replace *logixng* under *system_name*. Fires
 87        ``added`` on a new entry and ``updated`` on a replace."""
 88        if not _SAFE_NAME_RE.match(system_name):
 89            raise ValueError(
 90                f"logixng system name {system_name!r} must match "
 91                f"[A-Za-z0-9_.:-]+"
 92            )
 93        existing = self._by_name.get(system_name)
 94        self._by_name[system_name] = logixng
 95        if existing is None:
 96            await self.added.emit(logixng)
 97        else:
 98            await self.updated.emit(logixng)
 99        return logixng
100
101    async def create(
102        self, system_name: str, *, user_name: str = "",
103        enabled: bool = True,
104    ) -> LogixNG:
105        """Create an empty LogixNG program at *system_name*. Idempotent
106        by name — returns the existing program if one already lives
107        there (matches how the base bean managers behave)."""
108        if system_name in self._by_name:
109            return self._by_name[system_name]
110        prog = LogixNG(user_name=user_name, enabled=enabled)
111        return await self.put(system_name, prog)
112
113    async def delete(self, system_name: str) -> bool:
114        """Remove the program at *system_name*. Returns True if one
115        was found. Fires ``removed``."""
116        prog = self._by_name.pop(system_name, None)
117        if prog is None:
118            return False
119        await self.removed.emit(prog)
120        return True
121
122    # ---- disk ----------------------------------------------------------
123
124    def save_all(self, root: pathlib.Path) -> None:
125        """Write every program to ``root/<system_name>.yaml``.
126
127        Creates ``root`` if absent. Overwrites any existing files;
128        stale files (programs deleted since the last save) are
129        removed so the on-disk set matches memory.
130        """
131        root.mkdir(parents=True, exist_ok=True)
132        keep: set[pathlib.Path] = set()
133        for system_name, prog in self._by_name.items():
134            path = root / f"{system_name}.yaml"
135            save_yaml(prog, path)
136            keep.add(path)
137        for existing in root.glob("*.yaml"):
138            if existing not in keep:
139                try:
140                    existing.unlink()
141                except OSError as e:
142                    _log.warning(
143                        "logixng: couldn't remove stale %s (%s)", existing, e,
144                    )
145
146    def save_one(self, root: pathlib.Path, system_name: str) -> None:
147        """Persist a single program without touching siblings. Cheaper
148        than :meth:`save_all` when only one entry changed."""
149        prog = self._by_name.get(system_name)
150        if prog is None:
151            return
152        root.mkdir(parents=True, exist_ok=True)
153        save_yaml(prog, root / f"{system_name}.yaml")
154
155    def delete_on_disk(self, root: pathlib.Path, system_name: str) -> None:
156        """Remove ``<root>/<system_name>.yaml`` if it exists. Used by
157        the service's ``delete`` handler so the file goes away when the
158        in-memory entry does."""
159        path = root / f"{system_name}.yaml"
160        if path.exists():
161            try:
162                path.unlink()
163            except OSError as e:
164                _log.warning(
165                    "logixng: couldn't unlink %s (%s)", path, e,
166                )
167
168    async def load_all(self, root: pathlib.Path) -> int:
169        """Populate the manager from ``root/*.yaml``.
170
171        Returns the number of programs loaded. Files that fail to
172        parse are logged and skipped — one broken file doesn't
173        prevent the rest from loading.
174        """
175        if not root.is_dir():
176            return 0
177        count = 0
178        for yaml_path in sorted(root.glob("*.yaml")):
179            system_name = yaml_path.stem
180            if not _SAFE_NAME_RE.match(system_name):
181                _log.warning(
182                    "logixng: skipping %s (unsafe filename)", yaml_path,
183                )
184                continue
185            try:
186                node = load_yaml(yaml_path)
187            except Exception as e:  # noqa: BLE001 — one bad file mustn't block the rest
188                # YAML parse errors, decoder failures, IO errors — all
189                # get logged and skipped. The manager keeps loading
190                # siblings so one broken file doesn't nuke the whole
191                # set.
192                _log.warning(
193                    "logixng: skipping %s (%s)", yaml_path, e,
194                )
195                continue
196            if not isinstance(node, LogixNG):
197                _log.warning(
198                    "logixng: %s isn't a LogixNG root, skipping", yaml_path,
199                )
200                continue
201            await self.put(system_name, node)
202            count += 1
203        return count
204
205    async def load_yaml_text(
206        self, system_name: str, text: str,
207    ) -> LogixNG:
208        """Parse *text* as a LogixNG YAML program and register it at
209        *system_name*. Convenient for JSON-service uploads that ship
210        the tree inline."""
211        node = from_yaml(text)
212        if not isinstance(node, LogixNG):
213            raise ValueError("payload is not a LogixNG root")
214        return await self.put(system_name, node)
class LogixNGManager:
 51class LogixNGManager:
 52    """Registry of :class:`LogixNG` programs, one per profile.
 53
 54    Not a :class:`~jmri_core.manager.Manager` — LogixNG isn't a
 55    NamedBean and the system-name shape is program-oriented rather
 56    than address-oriented. The signal / index shape mirrors Manager
 57    closely so callers can subscribe in the same pattern.
 58    """
 59
 60    def __init__(self, prefix: str = _DEFAULT_PREFIX) -> None:
 61        self.prefix = prefix
 62        self._by_name: dict[str, LogixNG] = {}
 63        self.added: Signal[LogixNG] = Signal()
 64        self.removed: Signal[LogixNG] = Signal()
 65        self.updated: Signal[LogixNG] = Signal()
 66
 67    # ---- read ------------------------------------------------------------
 68
 69    def __iter__(self) -> Iterator[LogixNG]:
 70        return iter(self._by_name.values())
 71
 72    def __len__(self) -> int:
 73        return len(self._by_name)
 74
 75    def __contains__(self, name: str) -> bool:
 76        return name in self._by_name
 77
 78    def get(self, system_name: str) -> LogixNG | None:
 79        return self._by_name.get(system_name)
 80
 81    def names(self) -> list[str]:
 82        return list(self._by_name)
 83
 84    # ---- mutate ---------------------------------------------------------
 85
 86    async def put(self, system_name: str, logixng: LogixNG) -> LogixNG:
 87        """Insert or replace *logixng* under *system_name*. Fires
 88        ``added`` on a new entry and ``updated`` on a replace."""
 89        if not _SAFE_NAME_RE.match(system_name):
 90            raise ValueError(
 91                f"logixng system name {system_name!r} must match "
 92                f"[A-Za-z0-9_.:-]+"
 93            )
 94        existing = self._by_name.get(system_name)
 95        self._by_name[system_name] = logixng
 96        if existing is None:
 97            await self.added.emit(logixng)
 98        else:
 99            await self.updated.emit(logixng)
100        return logixng
101
102    async def create(
103        self, system_name: str, *, user_name: str = "",
104        enabled: bool = True,
105    ) -> LogixNG:
106        """Create an empty LogixNG program at *system_name*. Idempotent
107        by name — returns the existing program if one already lives
108        there (matches how the base bean managers behave)."""
109        if system_name in self._by_name:
110            return self._by_name[system_name]
111        prog = LogixNG(user_name=user_name, enabled=enabled)
112        return await self.put(system_name, prog)
113
114    async def delete(self, system_name: str) -> bool:
115        """Remove the program at *system_name*. Returns True if one
116        was found. Fires ``removed``."""
117        prog = self._by_name.pop(system_name, None)
118        if prog is None:
119            return False
120        await self.removed.emit(prog)
121        return True
122
123    # ---- disk ----------------------------------------------------------
124
125    def save_all(self, root: pathlib.Path) -> None:
126        """Write every program to ``root/<system_name>.yaml``.
127
128        Creates ``root`` if absent. Overwrites any existing files;
129        stale files (programs deleted since the last save) are
130        removed so the on-disk set matches memory.
131        """
132        root.mkdir(parents=True, exist_ok=True)
133        keep: set[pathlib.Path] = set()
134        for system_name, prog in self._by_name.items():
135            path = root / f"{system_name}.yaml"
136            save_yaml(prog, path)
137            keep.add(path)
138        for existing in root.glob("*.yaml"):
139            if existing not in keep:
140                try:
141                    existing.unlink()
142                except OSError as e:
143                    _log.warning(
144                        "logixng: couldn't remove stale %s (%s)", existing, e,
145                    )
146
147    def save_one(self, root: pathlib.Path, system_name: str) -> None:
148        """Persist a single program without touching siblings. Cheaper
149        than :meth:`save_all` when only one entry changed."""
150        prog = self._by_name.get(system_name)
151        if prog is None:
152            return
153        root.mkdir(parents=True, exist_ok=True)
154        save_yaml(prog, root / f"{system_name}.yaml")
155
156    def delete_on_disk(self, root: pathlib.Path, system_name: str) -> None:
157        """Remove ``<root>/<system_name>.yaml`` if it exists. Used by
158        the service's ``delete`` handler so the file goes away when the
159        in-memory entry does."""
160        path = root / f"{system_name}.yaml"
161        if path.exists():
162            try:
163                path.unlink()
164            except OSError as e:
165                _log.warning(
166                    "logixng: couldn't unlink %s (%s)", path, e,
167                )
168
169    async def load_all(self, root: pathlib.Path) -> int:
170        """Populate the manager from ``root/*.yaml``.
171
172        Returns the number of programs loaded. Files that fail to
173        parse are logged and skipped — one broken file doesn't
174        prevent the rest from loading.
175        """
176        if not root.is_dir():
177            return 0
178        count = 0
179        for yaml_path in sorted(root.glob("*.yaml")):
180            system_name = yaml_path.stem
181            if not _SAFE_NAME_RE.match(system_name):
182                _log.warning(
183                    "logixng: skipping %s (unsafe filename)", yaml_path,
184                )
185                continue
186            try:
187                node = load_yaml(yaml_path)
188            except Exception as e:  # noqa: BLE001 — one bad file mustn't block the rest
189                # YAML parse errors, decoder failures, IO errors — all
190                # get logged and skipped. The manager keeps loading
191                # siblings so one broken file doesn't nuke the whole
192                # set.
193                _log.warning(
194                    "logixng: skipping %s (%s)", yaml_path, e,
195                )
196                continue
197            if not isinstance(node, LogixNG):
198                _log.warning(
199                    "logixng: %s isn't a LogixNG root, skipping", yaml_path,
200                )
201                continue
202            await self.put(system_name, node)
203            count += 1
204        return count
205
206    async def load_yaml_text(
207        self, system_name: str, text: str,
208    ) -> LogixNG:
209        """Parse *text* as a LogixNG YAML program and register it at
210        *system_name*. Convenient for JSON-service uploads that ship
211        the tree inline."""
212        node = from_yaml(text)
213        if not isinstance(node, LogixNG):
214            raise ValueError("payload is not a LogixNG root")
215        return await self.put(system_name, node)

Registry of LogixNG programs, one per profile.

Not a ~jmri_core.manager.Manager — LogixNG isn't a NamedBean and the system-name shape is program-oriented rather than address-oriented. The signal / index shape mirrors Manager closely so callers can subscribe in the same pattern.

LogixNGManager(prefix: str = 'IQ')
60    def __init__(self, prefix: str = _DEFAULT_PREFIX) -> None:
61        self.prefix = prefix
62        self._by_name: dict[str, LogixNG] = {}
63        self.added: Signal[LogixNG] = Signal()
64        self.removed: Signal[LogixNG] = Signal()
65        self.updated: Signal[LogixNG] = Signal()
prefix
def get(self, system_name: str) -> jmri_logixng.LogixNG | None:
78    def get(self, system_name: str) -> LogixNG | None:
79        return self._by_name.get(system_name)
def names(self) -> list[str]:
81    def names(self) -> list[str]:
82        return list(self._by_name)
async def put( self, system_name: str, logixng: jmri_logixng.LogixNG) -> jmri_logixng.LogixNG:
 86    async def put(self, system_name: str, logixng: LogixNG) -> LogixNG:
 87        """Insert or replace *logixng* under *system_name*. Fires
 88        ``added`` on a new entry and ``updated`` on a replace."""
 89        if not _SAFE_NAME_RE.match(system_name):
 90            raise ValueError(
 91                f"logixng system name {system_name!r} must match "
 92                f"[A-Za-z0-9_.:-]+"
 93            )
 94        existing = self._by_name.get(system_name)
 95        self._by_name[system_name] = logixng
 96        if existing is None:
 97            await self.added.emit(logixng)
 98        else:
 99            await self.updated.emit(logixng)
100        return logixng

Insert or replace logixng under system_name. Fires added on a new entry and updated on a replace.

async def create( self, system_name: str, *, user_name: str = '', enabled: bool = True) -> jmri_logixng.LogixNG:
102    async def create(
103        self, system_name: str, *, user_name: str = "",
104        enabled: bool = True,
105    ) -> LogixNG:
106        """Create an empty LogixNG program at *system_name*. Idempotent
107        by name — returns the existing program if one already lives
108        there (matches how the base bean managers behave)."""
109        if system_name in self._by_name:
110            return self._by_name[system_name]
111        prog = LogixNG(user_name=user_name, enabled=enabled)
112        return await self.put(system_name, prog)

Create an empty LogixNG program at system_name. Idempotent by name — returns the existing program if one already lives there (matches how the base bean managers behave).

async def delete(self, system_name: str) -> bool:
114    async def delete(self, system_name: str) -> bool:
115        """Remove the program at *system_name*. Returns True if one
116        was found. Fires ``removed``."""
117        prog = self._by_name.pop(system_name, None)
118        if prog is None:
119            return False
120        await self.removed.emit(prog)
121        return True

Remove the program at system_name. Returns True if one was found. Fires removed.

def save_all(self, root: pathlib._local.Path) -> None:
125    def save_all(self, root: pathlib.Path) -> None:
126        """Write every program to ``root/<system_name>.yaml``.
127
128        Creates ``root`` if absent. Overwrites any existing files;
129        stale files (programs deleted since the last save) are
130        removed so the on-disk set matches memory.
131        """
132        root.mkdir(parents=True, exist_ok=True)
133        keep: set[pathlib.Path] = set()
134        for system_name, prog in self._by_name.items():
135            path = root / f"{system_name}.yaml"
136            save_yaml(prog, path)
137            keep.add(path)
138        for existing in root.glob("*.yaml"):
139            if existing not in keep:
140                try:
141                    existing.unlink()
142                except OSError as e:
143                    _log.warning(
144                        "logixng: couldn't remove stale %s (%s)", existing, e,
145                    )

Write every program to root/<system_name>.yaml.

Creates root if absent. Overwrites any existing files; stale files (programs deleted since the last save) are removed so the on-disk set matches memory.

def save_one(self, root: pathlib._local.Path, system_name: str) -> None:
147    def save_one(self, root: pathlib.Path, system_name: str) -> None:
148        """Persist a single program without touching siblings. Cheaper
149        than :meth:`save_all` when only one entry changed."""
150        prog = self._by_name.get(system_name)
151        if prog is None:
152            return
153        root.mkdir(parents=True, exist_ok=True)
154        save_yaml(prog, root / f"{system_name}.yaml")

Persist a single program without touching siblings. Cheaper than save_all() when only one entry changed.

def delete_on_disk(self, root: pathlib._local.Path, system_name: str) -> None:
156    def delete_on_disk(self, root: pathlib.Path, system_name: str) -> None:
157        """Remove ``<root>/<system_name>.yaml`` if it exists. Used by
158        the service's ``delete`` handler so the file goes away when the
159        in-memory entry does."""
160        path = root / f"{system_name}.yaml"
161        if path.exists():
162            try:
163                path.unlink()
164            except OSError as e:
165                _log.warning(
166                    "logixng: couldn't unlink %s (%s)", path, e,
167                )

Remove <root>/<system_name>.yaml if it exists. Used by the service's delete handler so the file goes away when the in-memory entry does.

async def load_all(self, root: pathlib._local.Path) -> int:
169    async def load_all(self, root: pathlib.Path) -> int:
170        """Populate the manager from ``root/*.yaml``.
171
172        Returns the number of programs loaded. Files that fail to
173        parse are logged and skipped — one broken file doesn't
174        prevent the rest from loading.
175        """
176        if not root.is_dir():
177            return 0
178        count = 0
179        for yaml_path in sorted(root.glob("*.yaml")):
180            system_name = yaml_path.stem
181            if not _SAFE_NAME_RE.match(system_name):
182                _log.warning(
183                    "logixng: skipping %s (unsafe filename)", yaml_path,
184                )
185                continue
186            try:
187                node = load_yaml(yaml_path)
188            except Exception as e:  # noqa: BLE001 — one bad file mustn't block the rest
189                # YAML parse errors, decoder failures, IO errors — all
190                # get logged and skipped. The manager keeps loading
191                # siblings so one broken file doesn't nuke the whole
192                # set.
193                _log.warning(
194                    "logixng: skipping %s (%s)", yaml_path, e,
195                )
196                continue
197            if not isinstance(node, LogixNG):
198                _log.warning(
199                    "logixng: %s isn't a LogixNG root, skipping", yaml_path,
200                )
201                continue
202            await self.put(system_name, node)
203            count += 1
204        return count

Populate the manager from root/*.yaml.

Returns the number of programs loaded. Files that fail to parse are logged and skipped — one broken file doesn't prevent the rest from loading.

async def load_yaml_text(self, system_name: str, text: str) -> jmri_logixng.LogixNG:
206    async def load_yaml_text(
207        self, system_name: str, text: str,
208    ) -> LogixNG:
209        """Parse *text* as a LogixNG YAML program and register it at
210        *system_name*. Convenient for JSON-service uploads that ship
211        the tree inline."""
212        node = from_yaml(text)
213        if not isinstance(node, LogixNG):
214            raise ValueError("payload is not a LogixNG root")
215        return await self.put(system_name, node)

Parse text as a LogixNG YAML program and register it at system_name. Convenient for JSON-service uploads that ship the tree inline.