jmri_core.persistence.programmer_xml
Programmer definition XML — parses JMRI's <programmer-config> files.
Each file under xml/programmers/*.xml declares which panes make up
one flavour of the DecoderPro programmer UI (Comprehensive.xml =
everything, Basic.xml = just Basic, ESU.xml = ESU-specific, ...). The
panes themselves live as reusable fragments in
xml/programmers/parts/*.xml and are pulled in via xi:include:
<programmer-config>
<programmer decoderFilePanes="yes" showEmptyPanes="default" ...>
<xi:include href="http://jmri.org/xml/programmers/parts/BasicPane.xml"/>
<xi:include href="http://jmri.org/xml/programmers/parts/MotorPane.xml"/>
...
</programmer>
</programmer-config>
This loader resolves those xi:include references by stripping the
http://jmri.org/xml/ URL prefix and looking them up under a local
xml_root directory (typically <JMRI install>/xml). Each
fragment is parsed via load_pane_bytes() so the resulting
DecoderPane objects share the same shape as decoder-declared
panes from step 1.
1"""Programmer definition XML — parses JMRI's ``<programmer-config>`` files. 2 3Each file under ``xml/programmers/*.xml`` declares which panes make up 4one flavour of the DecoderPro programmer UI (Comprehensive.xml = 5everything, Basic.xml = just Basic, ESU.xml = ESU-specific, ...). The 6panes themselves live as reusable fragments in 7``xml/programmers/parts/*.xml`` and are pulled in via ``xi:include``: 8 9 <programmer-config> 10 <programmer decoderFilePanes="yes" showEmptyPanes="default" ...> 11 <xi:include href="http://jmri.org/xml/programmers/parts/BasicPane.xml"/> 12 <xi:include href="http://jmri.org/xml/programmers/parts/MotorPane.xml"/> 13 ... 14 </programmer> 15 </programmer-config> 16 17This loader resolves those ``xi:include`` references by stripping the 18``http://jmri.org/xml/`` URL prefix and looking them up under a local 19``xml_root`` directory (typically ``<JMRI install>/xml``). Each 20fragment is parsed via :func:`load_pane_bytes` so the resulting 21:class:`DecoderPane` objects share the same shape as decoder-declared 22panes from step 1. 23""" 24from __future__ import annotations 25 26import logging 27import pathlib 28from collections.abc import Iterator 29 30from lxml import etree 31from pydantic import BaseModel, ConfigDict, Field 32 33from jmri_core.persistence.decoder_xml import ( 34 XINCLUDE_NS, 35 DecoderPane, 36 load_pane_bytes, 37) 38 39_log = logging.getLogger(__name__) 40 41# URL prefix JMRI uses in xi:include hrefs. Everything after this maps 42# to a path under the local ``xml/`` root. 43_JMRI_URL_PREFIX = "http://jmri.org/xml/" 44 45 46class ProgrammerConfig(BaseModel): 47 """One loaded programmer definition (e.g. Comprehensive.xml). 48 49 * ``panes`` — the standard programmer panes in include order (Basic, 50 Motor, Basic Speed Control, ...). One entry per successful 51 ``xi:include`` resolution. 52 * ``unresolved_includes`` — hrefs that couldn't be resolved to a 53 local file, preserved for diagnostics. An unresolved include just 54 means that pane doesn't appear in the tab list; the rest of the 55 programmer still works. 56 """ 57 model_config = ConfigDict(arbitrary_types_allowed=True) 58 59 name: str = "" 60 panes: list[DecoderPane] = Field(default_factory=list) 61 unresolved_includes: list[str] = Field(default_factory=list) 62 source_file: pathlib.Path | None = None 63 # ``<programmer decoderFilePanes="yes" showEmptyPanes="default">`` 64 # attributes. ``decoderFilePanes="yes"`` means: also render any 65 # decoder-declared panes in addition to the standard ones. 66 # ``showEmptyPanes="default"`` = respect per-model showEmptyPanes; 67 # ``"yes"`` = always show; ``"no"`` = always hide. 68 decoder_file_panes: bool = True 69 show_empty_panes: str = "default" 70 71 72def load_programmer_config_bytes( 73 data: bytes, 74 *, 75 xml_root: pathlib.Path, 76 source_file: pathlib.Path | None = None, 77) -> ProgrammerConfig: 78 """Parse a ``<programmer-config>`` byte string, resolving xi:includes. 79 80 *xml_root* is the directory ``http://jmri.org/xml/`` maps to 81 (typically ``<JMRI install>/xml``). Includes whose href lies outside 82 that prefix, or whose target file is missing, are recorded in 83 :attr:`ProgrammerConfig.unresolved_includes` and skipped. 84 """ 85 parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) 86 root = etree.fromstring(data, parser=parser) 87 if root.tag != "programmer-config": 88 raise ValueError(f"expected <programmer-config>, got <{root.tag}>") 89 90 programmer = root.find("programmer") 91 panes: list[DecoderPane] = [] 92 unresolved: list[str] = [] 93 decoder_file_panes = True 94 show_empty_panes = "default" 95 96 if programmer is not None: 97 decoder_file_panes = ( 98 programmer.get("decoderFilePanes", "yes").lower() == "yes" 99 ) 100 show_empty_panes = programmer.get("showEmptyPanes", "default") 101 for inc in programmer.iter(f"{XINCLUDE_NS}include"): 102 href = inc.get("href", "") 103 if not href.startswith(_JMRI_URL_PREFIX): 104 unresolved.append(href) 105 continue 106 rel = href[len(_JMRI_URL_PREFIX):] 107 fragment = xml_root / rel 108 if not fragment.is_file(): 109 unresolved.append(href) 110 _log.debug("programmer include not found: %s", fragment) 111 continue 112 try: 113 pane = load_pane_bytes(fragment.read_bytes()) 114 except (etree.XMLSyntaxError, ValueError, OSError) as e: 115 unresolved.append(href) 116 _log.warning( 117 "programmer include %s failed to parse: %s", fragment, e, 118 ) 119 continue 120 panes.append(pane) 121 122 name = source_file.stem if source_file is not None else "" 123 return ProgrammerConfig( 124 name=name, 125 panes=panes, 126 unresolved_includes=unresolved, 127 source_file=source_file, 128 decoder_file_panes=decoder_file_panes, 129 show_empty_panes=show_empty_panes, 130 ) 131 132 133def load_programmer_config( 134 path: str | pathlib.Path, 135 *, 136 xml_root: pathlib.Path | None = None, 137) -> ProgrammerConfig: 138 """Load a ``<programmer-config>`` file from disk. 139 140 *xml_root* defaults to the file's grandparent (JMRI's standard 141 layout: ``<xml_root>/programmers/foo.xml``). 142 """ 143 p = pathlib.Path(path) 144 if xml_root is None: 145 xml_root = p.parent.parent 146 return load_programmer_config_bytes( 147 p.read_bytes(), xml_root=xml_root, source_file=p, 148 ) 149 150 151def iter_programmer_configs( 152 dir_path: str | pathlib.Path, 153 *, 154 xml_root: pathlib.Path | None = None, 155) -> Iterator[ProgrammerConfig]: 156 """Walk a ``programmers/`` directory yielding one 157 :class:`ProgrammerConfig` per top-level ``*.xml``. Sub-directory 158 ``parts/`` is skipped — those are the include targets, not 159 programmer definitions. 160 """ 161 root = pathlib.Path(dir_path) 162 if xml_root is None: 163 xml_root = root.parent # <jmri>/xml 164 for xml in sorted(root.glob("*.xml")): 165 try: 166 yield load_programmer_config(xml, xml_root=xml_root) 167 except etree.XMLSyntaxError as e: 168 _log.debug("iter_programmer_configs: XML parse failure %s: %s", xml, e) 169 continue 170 except ValueError as e: 171 _log.debug("iter_programmer_configs: skipping %s (%s)", xml, e) 172 continue 173 except OSError as e: 174 _log.warning("iter_programmer_configs: cannot read %s: %s", xml, e) 175 continue
47class ProgrammerConfig(BaseModel): 48 """One loaded programmer definition (e.g. Comprehensive.xml). 49 50 * ``panes`` — the standard programmer panes in include order (Basic, 51 Motor, Basic Speed Control, ...). One entry per successful 52 ``xi:include`` resolution. 53 * ``unresolved_includes`` — hrefs that couldn't be resolved to a 54 local file, preserved for diagnostics. An unresolved include just 55 means that pane doesn't appear in the tab list; the rest of the 56 programmer still works. 57 """ 58 model_config = ConfigDict(arbitrary_types_allowed=True) 59 60 name: str = "" 61 panes: list[DecoderPane] = Field(default_factory=list) 62 unresolved_includes: list[str] = Field(default_factory=list) 63 source_file: pathlib.Path | None = None 64 # ``<programmer decoderFilePanes="yes" showEmptyPanes="default">`` 65 # attributes. ``decoderFilePanes="yes"`` means: also render any 66 # decoder-declared panes in addition to the standard ones. 67 # ``showEmptyPanes="default"`` = respect per-model showEmptyPanes; 68 # ``"yes"`` = always show; ``"no"`` = always hide. 69 decoder_file_panes: bool = True 70 show_empty_panes: str = "default"
One loaded programmer definition (e.g. Comprehensive.xml).
panes— the standard programmer panes in include order (Basic, Motor, Basic Speed Control, ...). One entry per successfulxi:includeresolution.unresolved_includes— hrefs that couldn't be resolved to a local file, preserved for diagnostics. An unresolved include just means that pane doesn't appear in the tab list; the rest of the programmer still works.
73def load_programmer_config_bytes( 74 data: bytes, 75 *, 76 xml_root: pathlib.Path, 77 source_file: pathlib.Path | None = None, 78) -> ProgrammerConfig: 79 """Parse a ``<programmer-config>`` byte string, resolving xi:includes. 80 81 *xml_root* is the directory ``http://jmri.org/xml/`` maps to 82 (typically ``<JMRI install>/xml``). Includes whose href lies outside 83 that prefix, or whose target file is missing, are recorded in 84 :attr:`ProgrammerConfig.unresolved_includes` and skipped. 85 """ 86 parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) 87 root = etree.fromstring(data, parser=parser) 88 if root.tag != "programmer-config": 89 raise ValueError(f"expected <programmer-config>, got <{root.tag}>") 90 91 programmer = root.find("programmer") 92 panes: list[DecoderPane] = [] 93 unresolved: list[str] = [] 94 decoder_file_panes = True 95 show_empty_panes = "default" 96 97 if programmer is not None: 98 decoder_file_panes = ( 99 programmer.get("decoderFilePanes", "yes").lower() == "yes" 100 ) 101 show_empty_panes = programmer.get("showEmptyPanes", "default") 102 for inc in programmer.iter(f"{XINCLUDE_NS}include"): 103 href = inc.get("href", "") 104 if not href.startswith(_JMRI_URL_PREFIX): 105 unresolved.append(href) 106 continue 107 rel = href[len(_JMRI_URL_PREFIX):] 108 fragment = xml_root / rel 109 if not fragment.is_file(): 110 unresolved.append(href) 111 _log.debug("programmer include not found: %s", fragment) 112 continue 113 try: 114 pane = load_pane_bytes(fragment.read_bytes()) 115 except (etree.XMLSyntaxError, ValueError, OSError) as e: 116 unresolved.append(href) 117 _log.warning( 118 "programmer include %s failed to parse: %s", fragment, e, 119 ) 120 continue 121 panes.append(pane) 122 123 name = source_file.stem if source_file is not None else "" 124 return ProgrammerConfig( 125 name=name, 126 panes=panes, 127 unresolved_includes=unresolved, 128 source_file=source_file, 129 decoder_file_panes=decoder_file_panes, 130 show_empty_panes=show_empty_panes, 131 )
Parse a <programmer-config> byte string, resolving xi:includes.
xml_root is the directory http://jmri.org/xml/ maps to
(typically <JMRI install>/xml). Includes whose href lies outside
that prefix, or whose target file is missing, are recorded in
ProgrammerConfig.unresolved_includes and skipped.
134def load_programmer_config( 135 path: str | pathlib.Path, 136 *, 137 xml_root: pathlib.Path | None = None, 138) -> ProgrammerConfig: 139 """Load a ``<programmer-config>`` file from disk. 140 141 *xml_root* defaults to the file's grandparent (JMRI's standard 142 layout: ``<xml_root>/programmers/foo.xml``). 143 """ 144 p = pathlib.Path(path) 145 if xml_root is None: 146 xml_root = p.parent.parent 147 return load_programmer_config_bytes( 148 p.read_bytes(), xml_root=xml_root, source_file=p, 149 )
Load a <programmer-config> file from disk.
xml_root defaults to the file's grandparent (JMRI's standard
layout: <xml_root>/programmers/foo.xml).
152def iter_programmer_configs( 153 dir_path: str | pathlib.Path, 154 *, 155 xml_root: pathlib.Path | None = None, 156) -> Iterator[ProgrammerConfig]: 157 """Walk a ``programmers/`` directory yielding one 158 :class:`ProgrammerConfig` per top-level ``*.xml``. Sub-directory 159 ``parts/`` is skipped — those are the include targets, not 160 programmer definitions. 161 """ 162 root = pathlib.Path(dir_path) 163 if xml_root is None: 164 xml_root = root.parent # <jmri>/xml 165 for xml in sorted(root.glob("*.xml")): 166 try: 167 yield load_programmer_config(xml, xml_root=xml_root) 168 except etree.XMLSyntaxError as e: 169 _log.debug("iter_programmer_configs: XML parse failure %s: %s", xml, e) 170 continue 171 except ValueError as e: 172 _log.debug("iter_programmer_configs: skipping %s (%s)", xml, e) 173 continue 174 except OSError as e: 175 _log.warning("iter_programmer_configs: cannot read %s: %s", xml, e) 176 continue
Walk a programmers/ directory yielding one
ProgrammerConfig per top-level *.xml. Sub-directory
parts/ is skipped — those are the include targets, not
programmer definitions.