jmri_core.persistence.panel_xml
Panel / Layout XML — envelope-level round-trip.
JMRI panel files (layout-config / layout-5-5-5.xsd) hold an editor's
entire visual layout: every positionable icon, signal, sensor, layout-block
graph node, train icon, memory icon, route table, Logix etc. Modeling every
element type is Stage 11 (the full layout editor port); for Stage 2 we
ship envelope-level structural round-trip:
- parse the file into an
PanelFileexposing the top-level metadata (jmri-version, encoding, panel/layout list) and the raw lxml tree; - save writes the lxml tree back, preserving every interior element as-is.
Callers that need to inspect or mutate individual elements use the lxml
tree directly via PanelFile.root. When Stage 11 lands, those raw
elements will be replaced by typed Pydantic models.
1"""Panel / Layout XML — envelope-level round-trip. 2 3JMRI panel files (``layout-config`` / ``layout-5-5-5.xsd``) hold an editor's 4entire visual layout: every positionable icon, signal, sensor, layout-block 5graph node, train icon, memory icon, route table, Logix etc. Modeling every 6element type is **Stage 11** (the full layout editor port); for Stage 2 we 7ship envelope-level structural round-trip: 8 9* parse the file into an :class:`PanelFile` exposing the top-level metadata 10 (jmri-version, encoding, panel/layout list) and the raw lxml tree; 11* save writes the lxml tree back, preserving every interior element as-is. 12 13Callers that need to inspect or mutate individual elements use the lxml 14tree directly via :attr:`PanelFile.root`. When Stage 11 lands, those raw 15elements will be replaced by typed Pydantic models. 16""" 17from __future__ import annotations 18 19import pathlib 20from dataclasses import dataclass 21 22from lxml import etree 23 24 25@dataclass 26class PanelFile: 27 """A panel / layout XML file with preserved structure. 28 29 Attributes: 30 root: top-level lxml ``<layout-config>`` element (or whatever the 31 file's root happens to be — we don't reject unknown roots). 32 doctype: original DOCTYPE declaration string, preserved across save. 33 """ 34 35 root: etree._Element 36 doctype: str | None = None 37 38 @property 39 def root_tag(self) -> str: 40 return str(self.root.tag) 41 42 @property 43 def jmri_version(self) -> str: 44 """JMRI version that wrote this file, from the root attributes.""" 45 return self.root.get("jmriversion") or self.root.get("jmriVersion") or "" 46 47 def panels(self) -> list[etree._Element]: 48 """List of top-level ``<panel>`` elements (PanelPro-style files).""" 49 return self.root.findall("panel") 50 51 def layout_editors(self) -> list[etree._Element]: 52 """List of ``<LayoutEditor>`` elements (LayoutEditor-style files).""" 53 return self.root.findall("LayoutEditor") 54 55 def named_beans(self, manager_class: str) -> list[etree._Element]: 56 """All ``<*Manager>`` child elements matching *manager_class*. 57 58 Panel files persist managed-bean state inside the panel-config so 59 ``turnoutmanager``, ``sensormanager``, etc. show up at the root. 60 """ 61 return self.root.findall(manager_class) 62 63 64def load_panel_bytes(data: bytes) -> PanelFile: 65 parser = etree.XMLParser( 66 remove_blank_text=False, 67 resolve_entities=False, 68 load_dtd=False, 69 no_network=True, 70 ) 71 root = etree.fromstring(data, parser=parser) 72 tree = root.getroottree() 73 # getattr shields us from lxml stub gaps around DocInfo.doctype. 74 doctype: str | None = getattr(tree.docinfo, "doctype", None) or None 75 return PanelFile(root=root, doctype=doctype) 76 77 78def load_panel(path: str | pathlib.Path) -> PanelFile: 79 return load_panel_bytes(pathlib.Path(path).read_bytes()) 80 81 82def save_panel_bytes(file: PanelFile) -> bytes: 83 if file.doctype: 84 return etree.tostring( 85 file.root, 86 xml_declaration=True, 87 encoding="utf-8", 88 pretty_print=False, 89 standalone=False, 90 doctype=file.doctype, 91 ) 92 return etree.tostring( 93 file.root, 94 xml_declaration=True, 95 encoding="utf-8", 96 pretty_print=False, 97 standalone=False, 98 ) 99 100 101def save_panel(path: str | pathlib.Path, file: PanelFile) -> None: 102 pathlib.Path(path).write_bytes(save_panel_bytes(file))
26@dataclass 27class PanelFile: 28 """A panel / layout XML file with preserved structure. 29 30 Attributes: 31 root: top-level lxml ``<layout-config>`` element (or whatever the 32 file's root happens to be — we don't reject unknown roots). 33 doctype: original DOCTYPE declaration string, preserved across save. 34 """ 35 36 root: etree._Element 37 doctype: str | None = None 38 39 @property 40 def root_tag(self) -> str: 41 return str(self.root.tag) 42 43 @property 44 def jmri_version(self) -> str: 45 """JMRI version that wrote this file, from the root attributes.""" 46 return self.root.get("jmriversion") or self.root.get("jmriVersion") or "" 47 48 def panels(self) -> list[etree._Element]: 49 """List of top-level ``<panel>`` elements (PanelPro-style files).""" 50 return self.root.findall("panel") 51 52 def layout_editors(self) -> list[etree._Element]: 53 """List of ``<LayoutEditor>`` elements (LayoutEditor-style files).""" 54 return self.root.findall("LayoutEditor") 55 56 def named_beans(self, manager_class: str) -> list[etree._Element]: 57 """All ``<*Manager>`` child elements matching *manager_class*. 58 59 Panel files persist managed-bean state inside the panel-config so 60 ``turnoutmanager``, ``sensormanager``, etc. show up at the root. 61 """ 62 return self.root.findall(manager_class)
A panel / layout XML file with preserved structure.
Attributes:
root: top-level lxml <layout-config> element (or whatever the
file's root happens to be — we don't reject unknown roots).
doctype: original DOCTYPE declaration string, preserved across save.
43 @property 44 def jmri_version(self) -> str: 45 """JMRI version that wrote this file, from the root attributes.""" 46 return self.root.get("jmriversion") or self.root.get("jmriVersion") or ""
JMRI version that wrote this file, from the root attributes.
48 def panels(self) -> list[etree._Element]: 49 """List of top-level ``<panel>`` elements (PanelPro-style files).""" 50 return self.root.findall("panel")
List of top-level <panel> elements (PanelPro-style files).
52 def layout_editors(self) -> list[etree._Element]: 53 """List of ``<LayoutEditor>`` elements (LayoutEditor-style files).""" 54 return self.root.findall("LayoutEditor")
List of <LayoutEditor> elements (LayoutEditor-style files).
56 def named_beans(self, manager_class: str) -> list[etree._Element]: 57 """All ``<*Manager>`` child elements matching *manager_class*. 58 59 Panel files persist managed-bean state inside the panel-config so 60 ``turnoutmanager``, ``sensormanager``, etc. show up at the root. 61 """ 62 return self.root.findall(manager_class)
All <*Manager> child elements matching manager_class.
Panel files persist managed-bean state inside the panel-config so
turnoutmanager, sensormanager, etc. show up at the root.
65def load_panel_bytes(data: bytes) -> PanelFile: 66 parser = etree.XMLParser( 67 remove_blank_text=False, 68 resolve_entities=False, 69 load_dtd=False, 70 no_network=True, 71 ) 72 root = etree.fromstring(data, parser=parser) 73 tree = root.getroottree() 74 # getattr shields us from lxml stub gaps around DocInfo.doctype. 75 doctype: str | None = getattr(tree.docinfo, "doctype", None) or None 76 return PanelFile(root=root, doctype=doctype)
83def save_panel_bytes(file: PanelFile) -> bytes: 84 if file.doctype: 85 return etree.tostring( 86 file.root, 87 xml_declaration=True, 88 encoding="utf-8", 89 pretty_print=False, 90 standalone=False, 91 doctype=file.doctype, 92 ) 93 return etree.tostring( 94 file.root, 95 xml_declaration=True, 96 encoding="utf-8", 97 pretty_print=False, 98 standalone=False, 99 )