jmri_core.persistence.operations_xml
JMRI Operations module XML files — load/save with structural preservation.
The Operations module persists six file families, all sharing the
<operations-config> envelope:
- Operations.xml — global setup (railroad, fonts, manifest format, …)
- OperationsEngineRoster.xml — locomotive instances (models, consists, engines)
- OperationsCarRoster.xml — car instances (roads, types, loads, kernels, cars)
- OperationsLocationRoster.xml — locations and their tracks/schedules
- OperationsRouteRoster.xml — routes (location sequences)
- OperationsTrainRoster.xml — trains (route assignments, schedules)
These files have a lot of attributes per element and JMRI evolves the schema regularly. Rather than enumerate every attribute, this module:
- Parses the file into an
OperationsFilethat preserves the complete lxml element tree. - Exposes typed Pydantic accessors over the major top-level elements
(engines, cars, routes, trains, locations) — these surface the common
attributes callers actually need (id, name, type, length, etc.) while
carrying any other attributes in an
extradict. - Serialization writes the underlying lxml tree, so unknown attributes and elements survive round-trip unchanged.
This is "semantic round-trip" — the same XML structure goes in and out. True byte-equality with the JMRI Java writer is deferred to Stage 2.5 (attribute-order canonicalization, DTD declaration preservation, comment spacing).
1"""JMRI Operations module XML files — load/save with structural preservation. 2 3The Operations module persists six file families, all sharing the 4``<operations-config>`` envelope: 5 6* **Operations.xml** — global setup (railroad, fonts, manifest format, …) 7* **OperationsEngineRoster.xml** — locomotive instances (models, consists, engines) 8* **OperationsCarRoster.xml** — car instances (roads, types, loads, kernels, cars) 9* **OperationsLocationRoster.xml** — locations and their tracks/schedules 10* **OperationsRouteRoster.xml** — routes (location sequences) 11* **OperationsTrainRoster.xml** — trains (route assignments, schedules) 12 13These files have a *lot* of attributes per element and JMRI evolves the 14schema regularly. Rather than enumerate every attribute, this module: 15 161. Parses the file into an :class:`OperationsFile` that preserves the 17 complete lxml element tree. 182. Exposes typed Pydantic accessors over the major top-level elements 19 (engines, cars, routes, trains, locations) — these surface the *common* 20 attributes callers actually need (id, name, type, length, etc.) while 21 carrying any other attributes in an ``extra`` dict. 223. Serialization writes the underlying lxml tree, so unknown attributes 23 and elements survive round-trip unchanged. 24 25This is "semantic round-trip" — the same XML structure goes in and out. 26True byte-equality with the JMRI Java writer is deferred to Stage 2.5 27(attribute-order canonicalization, DTD declaration preservation, comment 28spacing). 29""" 30from __future__ import annotations 31 32import pathlib 33from collections.abc import Iterator 34from dataclasses import dataclass 35 36from lxml import etree 37from pydantic import BaseModel, ConfigDict, Field 38 39# ---- typed accessors (Pydantic, used for tests + scripting) ----------------- 40 41 42class _AttrModel(BaseModel): 43 """Base for the typed accessors. Stores the *common* attributes and 44 overflows everything else into ``extra``. ``__source`` holds the 45 originating element if loaded from XML.""" 46 47 model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) 48 49 extra: dict[str, str] = Field(default_factory=dict) 50 51 52class Engine(_AttrModel): 53 id: str 54 road_name: str = Field(alias="roadName", default="") 55 road_number: str = Field(alias="roadNumber", default="") 56 type: str = "" 57 length: str = "" 58 location_id: str = Field(alias="locationId", default="") 59 model: str = "" 60 consist: str = "" 61 62 63class Car(_AttrModel): 64 id: str 65 road_name: str = Field(alias="roadName", default="") 66 road_number: str = Field(alias="roadNumber", default="") 67 type: str = "" 68 length: str = "" 69 location_id: str = Field(alias="locationId", default="") 70 load: str = "" 71 72 73class Location(_AttrModel): 74 id: str 75 name: str = "" 76 location_type: str = Field(alias="locationOps", default="") 77 78 79class RouteLocation(_AttrModel): 80 id: str 81 name: str = "" 82 location_id: str = Field(alias="locationId", default="") 83 sequence_id: str = Field(alias="sequenceId", default="") 84 85 86class Route(_AttrModel): 87 id: str 88 name: str = "" 89 comment: str = "" 90 locations: list[RouteLocation] = Field(default_factory=list) 91 92 93class Train(_AttrModel): 94 id: str 95 name: str = "" 96 description: str = "" 97 route_id: str = Field(alias="route", default="") 98 99 100# ---- the OperationsFile wrapper -------------------------------------------- 101 102 103@dataclass 104class OperationsFile: 105 """An operations-config XML file as a structured object. 106 107 Keeps the full lxml tree (``root``) so unknown attributes/elements 108 survive round-trip. Typed accessors (:meth:`engines`, :meth:`cars`, …) 109 project the major top-level elements into Pydantic models for callers 110 who want type-checked access. 111 """ 112 113 root: etree._Element 114 doctype: str | None = None 115 116 # ----- read accessors --------------------------------------------------- 117 118 @property 119 def kind(self) -> str: 120 """Best-guess label for this file based on the top-level children.""" 121 for guess in ( 122 "operations", "engines", "cars", "routes", 123 "locations", "trains", "schedules", 124 ): 125 if self.root.find(guess) is not None: 126 return guess 127 return "unknown" 128 129 def engines(self) -> list[Engine]: 130 elem = self.root.find("engines") 131 if elem is None: 132 return [] 133 return [_elem_to_model(e, Engine) for e in elem.findall("engine")] 134 135 def cars(self) -> list[Car]: 136 elem = self.root.find("cars") 137 if elem is None: 138 return [] 139 return [_elem_to_model(c, Car) for c in elem.findall("car")] 140 141 def locations(self) -> list[Location]: 142 elem = self.root.find("locations") 143 if elem is None: 144 return [] 145 return [_elem_to_model(loc, Location) for loc in elem.findall("location")] 146 147 def routes(self) -> list[Route]: 148 elem = self.root.find("routes") 149 if elem is None: 150 return [] 151 out: list[Route] = [] 152 for r in elem.findall("route"): 153 route = _elem_to_model(r, Route) 154 route.locations = [ 155 _elem_to_model(loc_el, RouteLocation) for loc_el in r.findall("location") 156 ] 157 out.append(route) 158 return out 159 160 def trains(self) -> list[Train]: 161 elem = self.root.find("trains") 162 if elem is None: 163 return [] 164 return [_elem_to_model(t, Train) for t in elem.findall("train")] 165 166 def setup(self) -> dict[str, dict[str, str]]: 167 """Return the ``<operations>`` setup block as nested attribute dicts. 168 169 Each child element of ``<operations>`` becomes one entry keyed by 170 tag, value is the attribute dict. Order of children is preserved 171 only in the underlying lxml tree, not in the returned dict. 172 """ 173 op = self.root.find("operations") 174 if op is None: 175 return {} 176 return { 177 str(child.tag): {str(k): str(v) for k, v in child.attrib.items()} 178 for child in op 179 } 180 181 # ----- iteration -------------------------------------------------------- 182 183 def iter_top_level(self) -> Iterator[etree._Element]: 184 return iter(self.root) 185 186 187# ---- public load/save ----------------------------------------------------- 188 189 190def load_operations_bytes(data: bytes) -> OperationsFile: 191 parser = etree.XMLParser( 192 remove_blank_text=False, 193 resolve_entities=False, 194 load_dtd=False, 195 no_network=True, 196 ) 197 tree = etree.fromstring(data, parser=parser) 198 if tree.tag != "operations-config": 199 raise ValueError(f"expected <operations-config>, got <{tree.tag}>") 200 return OperationsFile(root=tree, doctype=_extract_doctype(tree)) 201 202 203def load_operations(path: str | pathlib.Path) -> OperationsFile: 204 return load_operations_bytes(pathlib.Path(path).read_bytes()) 205 206 207def save_operations_bytes(file: OperationsFile) -> bytes: 208 if file.doctype: 209 return etree.tostring( 210 file.root, 211 xml_declaration=True, 212 encoding="utf-8", 213 pretty_print=False, 214 standalone=False, 215 doctype=file.doctype, 216 ) 217 return etree.tostring( 218 file.root, 219 xml_declaration=True, 220 encoding="utf-8", 221 pretty_print=False, 222 standalone=False, 223 ) 224 225 226def save_operations(path: str | pathlib.Path, file: OperationsFile) -> None: 227 pathlib.Path(path).write_bytes(save_operations_bytes(file)) 228 229 230# ---- internals ------------------------------------------------------------ 231 232 233def _elem_to_model[M: _AttrModel](elem: etree._Element, cls: type[M]) -> M: 234 """Project *elem* onto Pydantic *cls*. Known fields use the schema mapping; 235 everything else falls into ``extra``.""" 236 known_names: set[str] = set() 237 for name, info in cls.model_fields.items(): 238 if info.alias: 239 known_names.add(info.alias) 240 known_names.add(name) 241 242 init: dict[str, object] = {} 243 extra: dict[str, str] = {} 244 for raw_k, raw_v in elem.attrib.items(): 245 k = str(raw_k) 246 v = str(raw_v) 247 if k in known_names: 248 init[k] = v 249 else: 250 extra[k] = v 251 init["extra"] = extra 252 return cls.model_validate(init) 253 254 255def _extract_doctype(root: etree._Element) -> str | None: 256 """Read DOCTYPE off the document if present. Returns ``None`` for fragments 257 or documents without a DOCTYPE. ``getattr`` shields us from lxml stub gaps.""" 258 tree = root.getroottree() 259 docinfo = tree.docinfo 260 return getattr(docinfo, "doctype", None) or None
53class Engine(_AttrModel): 54 id: str 55 road_name: str = Field(alias="roadName", default="") 56 road_number: str = Field(alias="roadNumber", default="") 57 type: str = "" 58 length: str = "" 59 location_id: str = Field(alias="locationId", default="") 60 model: str = "" 61 consist: str = ""
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
64class Car(_AttrModel): 65 id: str 66 road_name: str = Field(alias="roadName", default="") 67 road_number: str = Field(alias="roadNumber", default="") 68 type: str = "" 69 length: str = "" 70 location_id: str = Field(alias="locationId", default="") 71 load: str = ""
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
74class Location(_AttrModel): 75 id: str 76 name: str = "" 77 location_type: str = Field(alias="locationOps", default="")
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
80class RouteLocation(_AttrModel): 81 id: str 82 name: str = "" 83 location_id: str = Field(alias="locationId", default="") 84 sequence_id: str = Field(alias="sequenceId", default="")
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
87class Route(_AttrModel): 88 id: str 89 name: str = "" 90 comment: str = "" 91 locations: list[RouteLocation] = Field(default_factory=list)
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
94class Train(_AttrModel): 95 id: str 96 name: str = "" 97 description: str = "" 98 route_id: str = Field(alias="route", default="")
Base for the typed accessors. Stores the common attributes and
overflows everything else into extra. __source holds the
originating element if loaded from XML.
Inherited Members
104@dataclass 105class OperationsFile: 106 """An operations-config XML file as a structured object. 107 108 Keeps the full lxml tree (``root``) so unknown attributes/elements 109 survive round-trip. Typed accessors (:meth:`engines`, :meth:`cars`, …) 110 project the major top-level elements into Pydantic models for callers 111 who want type-checked access. 112 """ 113 114 root: etree._Element 115 doctype: str | None = None 116 117 # ----- read accessors --------------------------------------------------- 118 119 @property 120 def kind(self) -> str: 121 """Best-guess label for this file based on the top-level children.""" 122 for guess in ( 123 "operations", "engines", "cars", "routes", 124 "locations", "trains", "schedules", 125 ): 126 if self.root.find(guess) is not None: 127 return guess 128 return "unknown" 129 130 def engines(self) -> list[Engine]: 131 elem = self.root.find("engines") 132 if elem is None: 133 return [] 134 return [_elem_to_model(e, Engine) for e in elem.findall("engine")] 135 136 def cars(self) -> list[Car]: 137 elem = self.root.find("cars") 138 if elem is None: 139 return [] 140 return [_elem_to_model(c, Car) for c in elem.findall("car")] 141 142 def locations(self) -> list[Location]: 143 elem = self.root.find("locations") 144 if elem is None: 145 return [] 146 return [_elem_to_model(loc, Location) for loc in elem.findall("location")] 147 148 def routes(self) -> list[Route]: 149 elem = self.root.find("routes") 150 if elem is None: 151 return [] 152 out: list[Route] = [] 153 for r in elem.findall("route"): 154 route = _elem_to_model(r, Route) 155 route.locations = [ 156 _elem_to_model(loc_el, RouteLocation) for loc_el in r.findall("location") 157 ] 158 out.append(route) 159 return out 160 161 def trains(self) -> list[Train]: 162 elem = self.root.find("trains") 163 if elem is None: 164 return [] 165 return [_elem_to_model(t, Train) for t in elem.findall("train")] 166 167 def setup(self) -> dict[str, dict[str, str]]: 168 """Return the ``<operations>`` setup block as nested attribute dicts. 169 170 Each child element of ``<operations>`` becomes one entry keyed by 171 tag, value is the attribute dict. Order of children is preserved 172 only in the underlying lxml tree, not in the returned dict. 173 """ 174 op = self.root.find("operations") 175 if op is None: 176 return {} 177 return { 178 str(child.tag): {str(k): str(v) for k, v in child.attrib.items()} 179 for child in op 180 } 181 182 # ----- iteration -------------------------------------------------------- 183 184 def iter_top_level(self) -> Iterator[etree._Element]: 185 return iter(self.root)
An operations-config XML file as a structured object.
Keeps the full lxml tree (root) so unknown attributes/elements
survive round-trip. Typed accessors (engines(), cars(), …)
project the major top-level elements into Pydantic models for callers
who want type-checked access.
119 @property 120 def kind(self) -> str: 121 """Best-guess label for this file based on the top-level children.""" 122 for guess in ( 123 "operations", "engines", "cars", "routes", 124 "locations", "trains", "schedules", 125 ): 126 if self.root.find(guess) is not None: 127 return guess 128 return "unknown"
Best-guess label for this file based on the top-level children.
148 def routes(self) -> list[Route]: 149 elem = self.root.find("routes") 150 if elem is None: 151 return [] 152 out: list[Route] = [] 153 for r in elem.findall("route"): 154 route = _elem_to_model(r, Route) 155 route.locations = [ 156 _elem_to_model(loc_el, RouteLocation) for loc_el in r.findall("location") 157 ] 158 out.append(route) 159 return out
167 def setup(self) -> dict[str, dict[str, str]]: 168 """Return the ``<operations>`` setup block as nested attribute dicts. 169 170 Each child element of ``<operations>`` becomes one entry keyed by 171 tag, value is the attribute dict. Order of children is preserved 172 only in the underlying lxml tree, not in the returned dict. 173 """ 174 op = self.root.find("operations") 175 if op is None: 176 return {} 177 return { 178 str(child.tag): {str(k): str(v) for k, v in child.attrib.items()} 179 for child in op 180 }
Return the <operations> setup block as nested attribute dicts.
Each child element of <operations> becomes one entry keyed by
tag, value is the attribute dict. Order of children is preserved
only in the underlying lxml tree, not in the returned dict.
191def load_operations_bytes(data: bytes) -> OperationsFile: 192 parser = etree.XMLParser( 193 remove_blank_text=False, 194 resolve_entities=False, 195 load_dtd=False, 196 no_network=True, 197 ) 198 tree = etree.fromstring(data, parser=parser) 199 if tree.tag != "operations-config": 200 raise ValueError(f"expected <operations-config>, got <{tree.tag}>") 201 return OperationsFile(root=tree, doctype=_extract_doctype(tree))
208def save_operations_bytes(file: OperationsFile) -> bytes: 209 if file.doctype: 210 return etree.tostring( 211 file.root, 212 xml_declaration=True, 213 encoding="utf-8", 214 pretty_print=False, 215 standalone=False, 216 doctype=file.doctype, 217 ) 218 return etree.tostring( 219 file.root, 220 xml_declaration=True, 221 encoding="utf-8", 222 pretty_print=False, 223 standalone=False, 224 )