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
class ProgrammerConfig(pydantic.main.BaseModel):
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 successful xi:include resolution.
  • 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.
name: str = ''
panes: list[jmri_core.persistence.DecoderPane] = PydanticUndefined
unresolved_includes: list[str] = PydanticUndefined
source_file: pathlib._local.Path | None = None
decoder_file_panes: bool = True
show_empty_panes: str = 'default'
def load_programmer_config_bytes( data: bytes, *, xml_root: pathlib._local.Path, source_file: pathlib._local.Path | None = None) -> ProgrammerConfig:
 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.

def load_programmer_config( path: str | pathlib._local.Path, *, xml_root: pathlib._local.Path | None = None) -> ProgrammerConfig:
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).

def iter_programmer_configs( dir_path: str | pathlib._local.Path, *, xml_root: pathlib._local.Path | None = None) -> Iterator[ProgrammerConfig]:
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.