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:

  1. Parses the file into an OperationsFile that preserves the complete lxml element tree.
  2. 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 extra dict.
  3. 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
class Engine(_AttrModel):
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.

id: str = PydanticUndefined
road_name: str = ''
road_number: str = ''
type: str = ''
length: str = ''
location_id: str = ''
model: str = ''
consist: str = ''
Inherited Members
_AttrModel
extra
class Car(_AttrModel):
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.

id: str = PydanticUndefined
road_name: str = ''
road_number: str = ''
type: str = ''
length: str = ''
location_id: str = ''
load: str = ''
Inherited Members
_AttrModel
extra
class Location(_AttrModel):
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.

id: str = PydanticUndefined
name: str = ''
location_type: str = ''
Inherited Members
_AttrModel
extra
class RouteLocation(_AttrModel):
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.

id: str = PydanticUndefined
name: str = ''
location_id: str = ''
sequence_id: str = ''
Inherited Members
_AttrModel
extra
class Route(_AttrModel):
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.

id: str = PydanticUndefined
name: str = ''
comment: str = ''
locations: list[RouteLocation] = PydanticUndefined
Inherited Members
_AttrModel
extra
class Train(_AttrModel):
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.

id: str = PydanticUndefined
name: str = ''
description: str = ''
route_id: str = ''
Inherited Members
_AttrModel
extra
@dataclass
class OperationsFile:
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.

OperationsFile(root: lxml.etree._Element, doctype: str | None = None)
root: lxml.etree._Element
doctype: str | None = None
kind: str
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.

def engines(self) -> list[Engine]:
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")]
def cars(self) -> list[Car]:
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")]
def locations(self) -> list[Location]:
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")]
def routes(self) -> list[Route]:
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
def trains(self) -> list[Train]:
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")]
def setup(self) -> dict[str, dict[str, str]]:
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.

def iter_top_level(self) -> Iterator[lxml.etree._Element]:
184    def iter_top_level(self) -> Iterator[etree._Element]:
185        return iter(self.root)
def load_operations_bytes(data: bytes) -> OperationsFile:
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))
def load_operations( path: str | pathlib._local.Path) -> OperationsFile:
204def load_operations(path: str | pathlib.Path) -> OperationsFile:
205    return load_operations_bytes(pathlib.Path(path).read_bytes())
def save_operations_bytes(file: OperationsFile) -> bytes:
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    )
def save_operations( path: str | pathlib._local.Path, file: OperationsFile) -> None:
227def save_operations(path: str | pathlib.Path, file: OperationsFile) -> None:
228    pathlib.Path(path).write_bytes(save_operations_bytes(file))