jmri_core.persistence.profile_registry

ProfileRegistry — parse JMRI's profiles.xml.

Real JMRI installs keep one or more profile directories (DRGW.jmri, HomeYard.jmri, etc.) under the settings root, with profiles.xml at the root listing them. Example::

<profileConfig>
  <profiles>
    <profile id="DRGW.3f28eb5e" path="settings:DRGW.jmri/" />
  </profiles>
  <searchPaths>
    <path path="settings:" default="true" />
  </searchPaths>
</profileConfig>

path values use a settings: URI prefix that means "relative to the settings dir". Some installs also store the active profile indirectly via a property file; we keep our own marker (<settings>/.active_profile) so the server can flip profiles without touching JMRI's bookkeeping.

The registry is purely a data-layer abstraction — no I/O against the hardware connection. Higher layers (jmri-server) handle the runtime switch (clearing bean managers, etc.).

  1"""``ProfileRegistry`` — parse JMRI's ``profiles.xml``.
  2
  3Real JMRI installs keep one or more profile directories (DRGW.jmri,
  4HomeYard.jmri, etc.) under the settings root, with ``profiles.xml``
  5at the root listing them. Example::
  6
  7    <profileConfig>
  8      <profiles>
  9        <profile id="DRGW.3f28eb5e" path="settings:DRGW.jmri/" />
 10      </profiles>
 11      <searchPaths>
 12        <path path="settings:" default="true" />
 13      </searchPaths>
 14    </profileConfig>
 15
 16``path`` values use a ``settings:`` URI prefix that means "relative to
 17the settings dir". Some installs also store the *active* profile
 18indirectly via a property file; we keep our own marker
 19(``<settings>/.active_profile``) so the server can flip profiles
 20without touching JMRI's bookkeeping.
 21
 22The registry is purely a data-layer abstraction — no I/O against the
 23hardware connection. Higher layers (jmri-server) handle the runtime
 24switch (clearing bean managers, etc.).
 25"""
 26from __future__ import annotations
 27
 28import pathlib
 29from dataclasses import dataclass
 30
 31from lxml import etree
 32
 33from jmri_core.persistence.profile import Profile, default_settings_dir
 34
 35PROFILES_XML = "profiles.xml"
 36ACTIVE_MARKER = ".active_profile"
 37
 38
 39@dataclass(slots=True)
 40class ProfileRef:
 41    """One ``<profile>`` entry from ``profiles.xml``."""
 42
 43    id: str
 44    path: str   # raw ``path`` attribute, possibly ``settings:Foo.jmri/``
 45    name: str   # derived display name — directory name stripped of suffix
 46
 47    def to_profile(self, settings_root: pathlib.Path) -> Profile:
 48        """Resolve to a runtime :class:`Profile` rooted at *settings_root*."""
 49        dir_name = _strip_settings_prefix(self.path).rstrip("/").split("/")[-1]
 50        if not dir_name:
 51            raise ValueError(f"profile {self.id!r} has empty resolved path")
 52        return Profile(root=settings_root, name=dir_name)
 53
 54
 55@dataclass
 56class ProfileRegistry:
 57    """In-memory view of ``<settings>/profiles.xml`` and the active profile."""
 58
 59    settings_root: pathlib.Path
 60    profiles: list[ProfileRef]
 61
 62    # ----- accessors -------------------------------------------------------
 63
 64    def by_id(self, profile_id: str) -> ProfileRef | None:
 65        for p in self.profiles:
 66            if p.id == profile_id:
 67                return p
 68        return None
 69
 70    def by_name(self, name: str) -> ProfileRef | None:
 71        for p in self.profiles:
 72            if p.name == name:
 73                return p
 74        return None
 75
 76    def active(self) -> ProfileRef | None:
 77        """Return the currently-active profile, or ``None`` if unresolved.
 78
 79        Resolution order:
 80        1. ``<settings>/.active_profile`` marker file (our own bookkeeping).
 81        2. First profile in ``profiles.xml`` (JMRI's implicit default).
 82        """
 83        marker = self.settings_root / ACTIVE_MARKER
 84        if marker.exists():
 85            stored = marker.read_text(encoding="utf-8").strip()
 86            hit = self.by_id(stored) or self.by_name(stored)
 87            if hit:
 88                return hit
 89        return self.profiles[0] if self.profiles else None
 90
 91    def set_active(self, profile_id: str) -> None:
 92        """Write the ``.active_profile`` marker. Raises if id is unknown."""
 93        if self.by_id(profile_id) is None:
 94            raise KeyError(f"no profile with id {profile_id!r}")
 95        marker = self.settings_root / ACTIVE_MARKER
 96        marker.parent.mkdir(parents=True, exist_ok=True)
 97        marker.write_text(profile_id, encoding="utf-8")
 98
 99    def active_profile(self) -> Profile | None:
100        """Convenience: resolved :class:`Profile` for the active entry."""
101        ref = self.active()
102        return ref.to_profile(self.settings_root) if ref else None
103
104
105# ----- parse ----------------------------------------------------------------
106
107
108def _strip_settings_prefix(path: str) -> str:
109    """``settings:DRGW.jmri/`` → ``DRGW.jmri/``."""
110    return path[len("settings:"):] if path.startswith("settings:") else path
111
112
113def _name_from_path(path: str) -> str:
114    """Display name = last path segment with trailing ``/`` stripped."""
115    stripped = _strip_settings_prefix(path).rstrip("/")
116    return stripped.split("/")[-1] if stripped else ""
117
118
119def _parse_profile_elem(elem: etree._Element) -> ProfileRef | None:
120    pid = elem.get("id") or ""
121    path = elem.get("path") or ""
122    if not pid or not path:
123        return None
124    name = _name_from_path(path)
125    return ProfileRef(id=pid, path=path, name=name or pid)
126
127
128def load_profile_registry(settings_root: pathlib.Path | None = None) -> ProfileRegistry:
129    """Load ``profiles.xml`` from *settings_root* (default: platform default)."""
130    if settings_root is None:
131        settings_root = default_settings_dir()
132    path = settings_root / PROFILES_XML
133    if not path.exists():
134        return ProfileRegistry(settings_root=settings_root, profiles=[])
135    parser = etree.XMLParser(
136        remove_blank_text=False,
137        resolve_entities=False,
138        load_dtd=False,
139        no_network=True,
140    )
141    root = etree.fromstring(path.read_bytes(), parser=parser)
142    if root.tag != "profileConfig":
143        raise ValueError(
144            f"expected <profileConfig> root in {path}, got <{root.tag}>"
145        )
146    profiles_elem = root.find("profiles")
147    profiles: list[ProfileRef] = []
148    if profiles_elem is not None:
149        for child in profiles_elem.findall("profile"):
150            ref = _parse_profile_elem(child)
151            if ref is not None:
152                profiles.append(ref)
153    return ProfileRegistry(settings_root=settings_root, profiles=profiles)
154
155
156def save_profile_registry(registry: ProfileRegistry) -> None:
157    """Write the registry back to ``<settings>/profiles.xml``.
158
159    Preserves a simple shape — we don't model ``<searchPaths>`` because
160    we never need to vary it. If a user's existing ``profiles.xml`` has
161    one, it gets dropped on save; this is acceptable for our alpha
162    since we don't create new profiles from the UI yet.
163    """
164    root = etree.Element("profileConfig")
165    profiles_elem = etree.SubElement(root, "profiles")
166    for ref in registry.profiles:
167        etree.SubElement(profiles_elem, "profile", attrib={
168            "id": ref.id,
169            "path": ref.path,
170        })
171    search = etree.SubElement(root, "searchPaths")
172    etree.SubElement(search, "path", attrib={
173        "path": "settings:",
174        "default": "true",
175    })
176    out = etree.tostring(
177        root,
178        xml_declaration=True,
179        encoding="utf-8",
180        pretty_print=True,
181        standalone=False,
182    )
183    registry.settings_root.mkdir(parents=True, exist_ok=True)
184    (registry.settings_root / PROFILES_XML).write_bytes(out)
PROFILES_XML = 'profiles.xml'
ACTIVE_MARKER = '.active_profile'
@dataclass(slots=True)
class ProfileRef:
40@dataclass(slots=True)
41class ProfileRef:
42    """One ``<profile>`` entry from ``profiles.xml``."""
43
44    id: str
45    path: str   # raw ``path`` attribute, possibly ``settings:Foo.jmri/``
46    name: str   # derived display name — directory name stripped of suffix
47
48    def to_profile(self, settings_root: pathlib.Path) -> Profile:
49        """Resolve to a runtime :class:`Profile` rooted at *settings_root*."""
50        dir_name = _strip_settings_prefix(self.path).rstrip("/").split("/")[-1]
51        if not dir_name:
52            raise ValueError(f"profile {self.id!r} has empty resolved path")
53        return Profile(root=settings_root, name=dir_name)

One <profile> entry from profiles.xml.

ProfileRef(id: str, path: str, name: str)
id: str
path: str
name: str
def to_profile( self, settings_root: pathlib._local.Path) -> jmri_core.persistence.Profile:
48    def to_profile(self, settings_root: pathlib.Path) -> Profile:
49        """Resolve to a runtime :class:`Profile` rooted at *settings_root*."""
50        dir_name = _strip_settings_prefix(self.path).rstrip("/").split("/")[-1]
51        if not dir_name:
52            raise ValueError(f"profile {self.id!r} has empty resolved path")
53        return Profile(root=settings_root, name=dir_name)

Resolve to a runtime Profile rooted at settings_root.

@dataclass
class ProfileRegistry:
 56@dataclass
 57class ProfileRegistry:
 58    """In-memory view of ``<settings>/profiles.xml`` and the active profile."""
 59
 60    settings_root: pathlib.Path
 61    profiles: list[ProfileRef]
 62
 63    # ----- accessors -------------------------------------------------------
 64
 65    def by_id(self, profile_id: str) -> ProfileRef | None:
 66        for p in self.profiles:
 67            if p.id == profile_id:
 68                return p
 69        return None
 70
 71    def by_name(self, name: str) -> ProfileRef | None:
 72        for p in self.profiles:
 73            if p.name == name:
 74                return p
 75        return None
 76
 77    def active(self) -> ProfileRef | None:
 78        """Return the currently-active profile, or ``None`` if unresolved.
 79
 80        Resolution order:
 81        1. ``<settings>/.active_profile`` marker file (our own bookkeeping).
 82        2. First profile in ``profiles.xml`` (JMRI's implicit default).
 83        """
 84        marker = self.settings_root / ACTIVE_MARKER
 85        if marker.exists():
 86            stored = marker.read_text(encoding="utf-8").strip()
 87            hit = self.by_id(stored) or self.by_name(stored)
 88            if hit:
 89                return hit
 90        return self.profiles[0] if self.profiles else None
 91
 92    def set_active(self, profile_id: str) -> None:
 93        """Write the ``.active_profile`` marker. Raises if id is unknown."""
 94        if self.by_id(profile_id) is None:
 95            raise KeyError(f"no profile with id {profile_id!r}")
 96        marker = self.settings_root / ACTIVE_MARKER
 97        marker.parent.mkdir(parents=True, exist_ok=True)
 98        marker.write_text(profile_id, encoding="utf-8")
 99
100    def active_profile(self) -> Profile | None:
101        """Convenience: resolved :class:`Profile` for the active entry."""
102        ref = self.active()
103        return ref.to_profile(self.settings_root) if ref else None

In-memory view of <settings>/profiles.xml and the active profile.

ProfileRegistry( settings_root: pathlib._local.Path, profiles: list[ProfileRef])
settings_root: pathlib._local.Path
profiles: list[ProfileRef]
def by_id( self, profile_id: str) -> ProfileRef | None:
65    def by_id(self, profile_id: str) -> ProfileRef | None:
66        for p in self.profiles:
67            if p.id == profile_id:
68                return p
69        return None
def by_name( self, name: str) -> ProfileRef | None:
71    def by_name(self, name: str) -> ProfileRef | None:
72        for p in self.profiles:
73            if p.name == name:
74                return p
75        return None
def active(self) -> ProfileRef | None:
77    def active(self) -> ProfileRef | None:
78        """Return the currently-active profile, or ``None`` if unresolved.
79
80        Resolution order:
81        1. ``<settings>/.active_profile`` marker file (our own bookkeeping).
82        2. First profile in ``profiles.xml`` (JMRI's implicit default).
83        """
84        marker = self.settings_root / ACTIVE_MARKER
85        if marker.exists():
86            stored = marker.read_text(encoding="utf-8").strip()
87            hit = self.by_id(stored) or self.by_name(stored)
88            if hit:
89                return hit
90        return self.profiles[0] if self.profiles else None

Return the currently-active profile, or None if unresolved.

Resolution order:

  1. <settings>/.active_profile marker file (our own bookkeeping).
  2. First profile in profiles.xml (JMRI's implicit default).
def set_active(self, profile_id: str) -> None:
92    def set_active(self, profile_id: str) -> None:
93        """Write the ``.active_profile`` marker. Raises if id is unknown."""
94        if self.by_id(profile_id) is None:
95            raise KeyError(f"no profile with id {profile_id!r}")
96        marker = self.settings_root / ACTIVE_MARKER
97        marker.parent.mkdir(parents=True, exist_ok=True)
98        marker.write_text(profile_id, encoding="utf-8")

Write the .active_profile marker. Raises if id is unknown.

def active_profile(self) -> jmri_core.persistence.Profile | None:
100    def active_profile(self) -> Profile | None:
101        """Convenience: resolved :class:`Profile` for the active entry."""
102        ref = self.active()
103        return ref.to_profile(self.settings_root) if ref else None

Convenience: resolved Profile for the active entry.

def load_profile_registry( settings_root: pathlib._local.Path | None = None) -> ProfileRegistry:
129def load_profile_registry(settings_root: pathlib.Path | None = None) -> ProfileRegistry:
130    """Load ``profiles.xml`` from *settings_root* (default: platform default)."""
131    if settings_root is None:
132        settings_root = default_settings_dir()
133    path = settings_root / PROFILES_XML
134    if not path.exists():
135        return ProfileRegistry(settings_root=settings_root, profiles=[])
136    parser = etree.XMLParser(
137        remove_blank_text=False,
138        resolve_entities=False,
139        load_dtd=False,
140        no_network=True,
141    )
142    root = etree.fromstring(path.read_bytes(), parser=parser)
143    if root.tag != "profileConfig":
144        raise ValueError(
145            f"expected <profileConfig> root in {path}, got <{root.tag}>"
146        )
147    profiles_elem = root.find("profiles")
148    profiles: list[ProfileRef] = []
149    if profiles_elem is not None:
150        for child in profiles_elem.findall("profile"):
151            ref = _parse_profile_elem(child)
152            if ref is not None:
153                profiles.append(ref)
154    return ProfileRegistry(settings_root=settings_root, profiles=profiles)

Load profiles.xml from settings_root (default: platform default).

def save_profile_registry(registry: ProfileRegistry) -> None:
157def save_profile_registry(registry: ProfileRegistry) -> None:
158    """Write the registry back to ``<settings>/profiles.xml``.
159
160    Preserves a simple shape — we don't model ``<searchPaths>`` because
161    we never need to vary it. If a user's existing ``profiles.xml`` has
162    one, it gets dropped on save; this is acceptable for our alpha
163    since we don't create new profiles from the UI yet.
164    """
165    root = etree.Element("profileConfig")
166    profiles_elem = etree.SubElement(root, "profiles")
167    for ref in registry.profiles:
168        etree.SubElement(profiles_elem, "profile", attrib={
169            "id": ref.id,
170            "path": ref.path,
171        })
172    search = etree.SubElement(root, "searchPaths")
173    etree.SubElement(search, "path", attrib={
174        "path": "settings:",
175        "default": "true",
176    })
177    out = etree.tostring(
178        root,
179        xml_declaration=True,
180        encoding="utf-8",
181        pretty_print=True,
182        standalone=False,
183    )
184    registry.settings_root.mkdir(parents=True, exist_ok=True)
185    (registry.settings_root / PROFILES_XML).write_bytes(out)

Write the registry back to <settings>/profiles.xml.

Preserves a simple shape — we don't model <searchPaths> because we never need to vary it. If a user's existing profiles.xml has one, it gets dropped on save; this is acceptable for our alpha since we don't create new profiles from the UI yet.