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)
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.
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.
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.
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:
<settings>/.active_profilemarker file (our own bookkeeping).- First profile in
profiles.xml(JMRI's implicit default).
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.
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).
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.