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 PanelFile exposing 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))
@dataclass
class PanelFile:
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.

PanelFile(root: lxml.etree._Element, doctype: str | None = None)
root: lxml.etree._Element
doctype: str | None = None
root_tag: str
39    @property
40    def root_tag(self) -> str:
41        return str(self.root.tag)
jmri_version: str
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.

def panels(self) -> list[lxml.etree._Element]:
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).

def layout_editors(self) -> list[lxml.etree._Element]:
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).

def named_beans(self, manager_class: str) -> list[lxml.etree._Element]:
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.

def load_panel_bytes(data: bytes) -> PanelFile:
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)
def load_panel( path: str | pathlib._local.Path) -> PanelFile:
79def load_panel(path: str | pathlib.Path) -> PanelFile:
80    return load_panel_bytes(pathlib.Path(path).read_bytes())
def save_panel_bytes(file: PanelFile) -> bytes:
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    )
def save_panel( path: str | pathlib._local.Path, file: PanelFile) -> None:
102def save_panel(path: str | pathlib.Path, file: PanelFile) -> None:
103    pathlib.Path(path).write_bytes(save_panel_bytes(file))