jmri_core.persistence.profile
Profile — JMRI's per-railroad data directory.
JMRI's settings root (~/JMRI on Windows, ~/Library/Preferences/JMRI
on macOS, ~/.jmri on Linux) holds one or more profile directories
named after the railroad — e.g. DRGW.jmri/ for the D&RGW layout. The
list of known profiles is in <settings>/profiles.xml.
Each profile is a self-contained directory holding:
PanelPro.xml— bean tables (turnouts, sensors, lights, ...) plus the rest of the JMRI layout-config (signal heads, blocks, panels, LogixNG, etc.). Older installs called thisConfiguration.xml.roster.xml— index of locomotives in the roster.roster/<id>.xml— one file per locomotive (locomotive-config).rosters/<id>.xml— per-loco CV state (programmer output).throttle/— saved throttle layouts.signal/— user signal-mast definitions.jython/— auto-load Python scripts (Stage 9.5+).programmers/— programmer XML files.
This module models the layout so persistence code never hard-codes
paths and tests can point at a temp dir. The ProfileRegistry
sister class reads profiles.xml and tracks which profile is
currently active.
1"""``Profile`` — JMRI's per-railroad data directory. 2 3JMRI's settings root (``~/JMRI`` on Windows, ``~/Library/Preferences/JMRI`` 4on macOS, ``~/.jmri`` on Linux) holds one or more *profile* directories 5named after the railroad — e.g. ``DRGW.jmri/`` for the D&RGW layout. The 6list of known profiles is in ``<settings>/profiles.xml``. 7 8Each profile is a self-contained directory holding: 9 10* ``PanelPro.xml`` — bean tables (turnouts, sensors, lights, ...) plus 11 the rest of the JMRI layout-config (signal heads, blocks, panels, 12 LogixNG, etc.). Older installs called this ``Configuration.xml``. 13* ``roster.xml`` — index of locomotives in the roster. 14* ``roster/<id>.xml`` — one file per locomotive (locomotive-config). 15* ``rosters/<id>.xml`` — per-loco CV state (programmer output). 16* ``throttle/`` — saved throttle layouts. 17* ``signal/`` — user signal-mast definitions. 18* ``jython/`` — auto-load Python scripts (Stage 9.5+). 19* ``programmers/`` — programmer XML files. 20 21This module models the layout so persistence code never hard-codes 22paths and tests can point at a temp dir. The :class:`ProfileRegistry` 23sister class reads ``profiles.xml`` and tracks which profile is 24currently active. 25""" 26from __future__ import annotations 27 28import os 29import pathlib 30import platform 31from dataclasses import dataclass 32 33 34def default_settings_dir() -> pathlib.Path: 35 """OS-appropriate default settings directory matching JMRI's Java behavior. 36 37 Honours the ``JMRI_SETTINGS_DIR`` environment variable when set — 38 useful for pointing a Linux host at a Windows-formatted JMRI tree 39 (e.g. an SMB-mounted ``//Pi/JMRI`` or an rsync of the dev machine's 40 ``%USERPROFILE%\\JMRI``). The read goes through 41 :func:`jmri_core.env.settings_dir` so operator-facing env-var 42 names live in one registry. 43 """ 44 from jmri_core.env import settings_dir # noqa: PLC0415 — avoid an import cycle at module load 45 override = settings_dir() 46 if override is not None: 47 return override 48 system = platform.system() 49 if system == "Windows": 50 # JMRI installer creates %userprofile%\JMRI on Windows. 51 return pathlib.Path(os.environ.get("USERPROFILE", "~")).expanduser() / "JMRI" 52 if system == "Darwin": 53 return pathlib.Path("~/Library/Preferences/JMRI").expanduser() 54 # Linux + everything else. 55 return pathlib.Path("~/.jmri").expanduser() 56 57 58@dataclass(slots=True) 59class Profile: 60 """One JMRI railroad profile. 61 62 Lives at ``<root>/<name>`` (e.g. ``~/JMRI/DRGW.jmri``). The bare name 63 is whatever appears in ``profiles.xml`` — JMRI conventionally 64 suffixes it with ``.jmri`` but we don't enforce that. 65 """ 66 67 root: pathlib.Path 68 name: str = "default" 69 70 @classmethod 71 def default(cls) -> Profile: 72 """Construct using the platform-default settings directory.""" 73 return cls(root=default_settings_dir()) 74 75 # ----- known paths ---------------------------------------------------- 76 77 @property 78 def profile_dir(self) -> pathlib.Path: 79 """``<root>/<name>`` — the profile's root directory.""" 80 return self.root / self.name 81 82 @property 83 def config_file(self) -> pathlib.Path: 84 """The bean-tables file. Prefers ``PanelPro.xml`` (JMRI 5.x) if 85 either it exists already; otherwise falls back to the older 86 ``Configuration.xml``. Brand-new profiles get ``PanelPro.xml``. 87 """ 88 panel = self.profile_dir / "PanelPro.xml" 89 legacy = self.profile_dir / "Configuration.xml" 90 if panel.exists(): 91 return panel 92 if legacy.exists(): 93 return legacy 94 return panel 95 96 @property 97 def roster_dir(self) -> pathlib.Path: 98 """Where per-loco ``locomotive-config`` XML files live.""" 99 return self.profile_dir / "roster" 100 101 @property 102 def roster_index(self) -> pathlib.Path: 103 """Roster index — ``<profile>/roster.xml``, alongside the roster dir.""" 104 return self.profile_dir / "roster.xml" 105 106 @property 107 def rosters_cv_dir(self) -> pathlib.Path: 108 """Per-loco CV state files (programmer output). Loaded on demand.""" 109 return self.profile_dir / "rosters" 110 111 @property 112 def throttle_dir(self) -> pathlib.Path: 113 return self.profile_dir / "throttle" 114 115 @property 116 def signal_dir(self) -> pathlib.Path: 117 """Per-profile signal-mast definitions.""" 118 return self.profile_dir / "signal" 119 120 @property 121 def programmers_dir(self) -> pathlib.Path: 122 return self.profile_dir / "programmers" 123 124 @property 125 def jython_dir(self) -> pathlib.Path: 126 """Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a 127 model attribute so imports from an older rewrite install still 128 parse — the autoloader no longer scans it. Use :attr:`python_dir` 129 for CPython 3 scripts.""" 130 return self.profile_dir / "jython" 131 132 @property 133 def python_dir(self) -> pathlib.Path: 134 """Per-profile CPython 3 script dir. 135 136 Autoload sequence (see 137 :func:`jmri_server.app._autolaunch_profile_scripts`): 138 139 1. ``<settings-root>/python/`` — cross-profile shared scripts. 140 2. ``<profile>/python/`` — per-profile scripts. 141 142 Files ending in ``.py`` at the top level run in alphabetical 143 order; files starting with ``_`` are treated as private 144 modules that other scripts may import but aren't run standalone. 145 Missing directories are silently ignored — running without 146 either dir is a valid setup for a bare install. 147 """ 148 return self.profile_dir / "python" 149 150 @property 151 def shared_python_dir(self) -> pathlib.Path: 152 """Cross-profile CPython 3 script dir at 153 ``<settings-root>/python/``. See :attr:`python_dir` for the 154 autoload sequence.""" 155 return self.root / "python" 156 157 @property 158 def operations_dir(self) -> pathlib.Path: 159 return self.profile_dir / "operations" 160 161 @property 162 def panel_dir(self) -> pathlib.Path: 163 return self.profile_dir / "panels" 164 165 @property 166 def logixng_dir(self) -> pathlib.Path: 167 """Where LogixNG programs are stored as YAML files. 168 169 Each program lives at ``<profile>/logixng/<system_name>.yaml`` 170 so a profile switch swaps the whole automation set atomically. 171 Directory is created on first save; loads treat a missing dir 172 as "no programs" rather than an error. 173 """ 174 return self.profile_dir / "logixng" 175 176 @property 177 def log_dir(self) -> pathlib.Path: 178 return self.root / "log" 179 180 @property 181 def preferences_dir(self) -> pathlib.Path: 182 return self.profile_dir / "profile" 183 184 @property 185 def signal_systems_dir(self) -> pathlib.Path: 186 """User-defined signal systems shared across profiles.""" 187 return self.root / "resources" / "signals" 188 189 # ----- helpers --------------------------------------------------------- 190 191 def ensure(self) -> None: 192 """Create every standard per-profile subdirectory if missing. 193 194 Idempotent. Does not touch the settings-root level directories 195 (those are shared across profiles). 196 """ 197 for d in ( 198 self.root, 199 self.profile_dir, 200 self.roster_dir, 201 self.rosters_cv_dir, 202 self.operations_dir, 203 self.panel_dir, 204 self.throttle_dir, 205 self.signal_dir, 206 self.log_dir, 207 self.signal_systems_dir, 208 ): 209 d.mkdir(parents=True, exist_ok=True) 210 211 def __repr__(self) -> str: 212 return f"Profile(name={self.name!r}, root={str(self.root)!r})"
35def default_settings_dir() -> pathlib.Path: 36 """OS-appropriate default settings directory matching JMRI's Java behavior. 37 38 Honours the ``JMRI_SETTINGS_DIR`` environment variable when set — 39 useful for pointing a Linux host at a Windows-formatted JMRI tree 40 (e.g. an SMB-mounted ``//Pi/JMRI`` or an rsync of the dev machine's 41 ``%USERPROFILE%\\JMRI``). The read goes through 42 :func:`jmri_core.env.settings_dir` so operator-facing env-var 43 names live in one registry. 44 """ 45 from jmri_core.env import settings_dir # noqa: PLC0415 — avoid an import cycle at module load 46 override = settings_dir() 47 if override is not None: 48 return override 49 system = platform.system() 50 if system == "Windows": 51 # JMRI installer creates %userprofile%\JMRI on Windows. 52 return pathlib.Path(os.environ.get("USERPROFILE", "~")).expanduser() / "JMRI" 53 if system == "Darwin": 54 return pathlib.Path("~/Library/Preferences/JMRI").expanduser() 55 # Linux + everything else. 56 return pathlib.Path("~/.jmri").expanduser()
OS-appropriate default settings directory matching JMRI's Java behavior.
Honours the JMRI_SETTINGS_DIR environment variable when set —
useful for pointing a Linux host at a Windows-formatted JMRI tree
(e.g. an SMB-mounted //Pi/JMRI or an rsync of the dev machine's
%USERPROFILE%\JMRI). The read goes through
jmri_core.env.settings_dir() so operator-facing env-var
names live in one registry.
59@dataclass(slots=True) 60class Profile: 61 """One JMRI railroad profile. 62 63 Lives at ``<root>/<name>`` (e.g. ``~/JMRI/DRGW.jmri``). The bare name 64 is whatever appears in ``profiles.xml`` — JMRI conventionally 65 suffixes it with ``.jmri`` but we don't enforce that. 66 """ 67 68 root: pathlib.Path 69 name: str = "default" 70 71 @classmethod 72 def default(cls) -> Profile: 73 """Construct using the platform-default settings directory.""" 74 return cls(root=default_settings_dir()) 75 76 # ----- known paths ---------------------------------------------------- 77 78 @property 79 def profile_dir(self) -> pathlib.Path: 80 """``<root>/<name>`` — the profile's root directory.""" 81 return self.root / self.name 82 83 @property 84 def config_file(self) -> pathlib.Path: 85 """The bean-tables file. Prefers ``PanelPro.xml`` (JMRI 5.x) if 86 either it exists already; otherwise falls back to the older 87 ``Configuration.xml``. Brand-new profiles get ``PanelPro.xml``. 88 """ 89 panel = self.profile_dir / "PanelPro.xml" 90 legacy = self.profile_dir / "Configuration.xml" 91 if panel.exists(): 92 return panel 93 if legacy.exists(): 94 return legacy 95 return panel 96 97 @property 98 def roster_dir(self) -> pathlib.Path: 99 """Where per-loco ``locomotive-config`` XML files live.""" 100 return self.profile_dir / "roster" 101 102 @property 103 def roster_index(self) -> pathlib.Path: 104 """Roster index — ``<profile>/roster.xml``, alongside the roster dir.""" 105 return self.profile_dir / "roster.xml" 106 107 @property 108 def rosters_cv_dir(self) -> pathlib.Path: 109 """Per-loco CV state files (programmer output). Loaded on demand.""" 110 return self.profile_dir / "rosters" 111 112 @property 113 def throttle_dir(self) -> pathlib.Path: 114 return self.profile_dir / "throttle" 115 116 @property 117 def signal_dir(self) -> pathlib.Path: 118 """Per-profile signal-mast definitions.""" 119 return self.profile_dir / "signal" 120 121 @property 122 def programmers_dir(self) -> pathlib.Path: 123 return self.profile_dir / "programmers" 124 125 @property 126 def jython_dir(self) -> pathlib.Path: 127 """Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a 128 model attribute so imports from an older rewrite install still 129 parse — the autoloader no longer scans it. Use :attr:`python_dir` 130 for CPython 3 scripts.""" 131 return self.profile_dir / "jython" 132 133 @property 134 def python_dir(self) -> pathlib.Path: 135 """Per-profile CPython 3 script dir. 136 137 Autoload sequence (see 138 :func:`jmri_server.app._autolaunch_profile_scripts`): 139 140 1. ``<settings-root>/python/`` — cross-profile shared scripts. 141 2. ``<profile>/python/`` — per-profile scripts. 142 143 Files ending in ``.py`` at the top level run in alphabetical 144 order; files starting with ``_`` are treated as private 145 modules that other scripts may import but aren't run standalone. 146 Missing directories are silently ignored — running without 147 either dir is a valid setup for a bare install. 148 """ 149 return self.profile_dir / "python" 150 151 @property 152 def shared_python_dir(self) -> pathlib.Path: 153 """Cross-profile CPython 3 script dir at 154 ``<settings-root>/python/``. See :attr:`python_dir` for the 155 autoload sequence.""" 156 return self.root / "python" 157 158 @property 159 def operations_dir(self) -> pathlib.Path: 160 return self.profile_dir / "operations" 161 162 @property 163 def panel_dir(self) -> pathlib.Path: 164 return self.profile_dir / "panels" 165 166 @property 167 def logixng_dir(self) -> pathlib.Path: 168 """Where LogixNG programs are stored as YAML files. 169 170 Each program lives at ``<profile>/logixng/<system_name>.yaml`` 171 so a profile switch swaps the whole automation set atomically. 172 Directory is created on first save; loads treat a missing dir 173 as "no programs" rather than an error. 174 """ 175 return self.profile_dir / "logixng" 176 177 @property 178 def log_dir(self) -> pathlib.Path: 179 return self.root / "log" 180 181 @property 182 def preferences_dir(self) -> pathlib.Path: 183 return self.profile_dir / "profile" 184 185 @property 186 def signal_systems_dir(self) -> pathlib.Path: 187 """User-defined signal systems shared across profiles.""" 188 return self.root / "resources" / "signals" 189 190 # ----- helpers --------------------------------------------------------- 191 192 def ensure(self) -> None: 193 """Create every standard per-profile subdirectory if missing. 194 195 Idempotent. Does not touch the settings-root level directories 196 (those are shared across profiles). 197 """ 198 for d in ( 199 self.root, 200 self.profile_dir, 201 self.roster_dir, 202 self.rosters_cv_dir, 203 self.operations_dir, 204 self.panel_dir, 205 self.throttle_dir, 206 self.signal_dir, 207 self.log_dir, 208 self.signal_systems_dir, 209 ): 210 d.mkdir(parents=True, exist_ok=True) 211 212 def __repr__(self) -> str: 213 return f"Profile(name={self.name!r}, root={str(self.root)!r})"
One JMRI railroad profile.
Lives at <root>/<name> (e.g. ~/JMRI/DRGW.jmri). The bare name
is whatever appears in profiles.xml — JMRI conventionally
suffixes it with .jmri but we don't enforce that.
71 @classmethod 72 def default(cls) -> Profile: 73 """Construct using the platform-default settings directory.""" 74 return cls(root=default_settings_dir())
Construct using the platform-default settings directory.
78 @property 79 def profile_dir(self) -> pathlib.Path: 80 """``<root>/<name>`` — the profile's root directory.""" 81 return self.root / self.name
<root>/<name> — the profile's root directory.
83 @property 84 def config_file(self) -> pathlib.Path: 85 """The bean-tables file. Prefers ``PanelPro.xml`` (JMRI 5.x) if 86 either it exists already; otherwise falls back to the older 87 ``Configuration.xml``. Brand-new profiles get ``PanelPro.xml``. 88 """ 89 panel = self.profile_dir / "PanelPro.xml" 90 legacy = self.profile_dir / "Configuration.xml" 91 if panel.exists(): 92 return panel 93 if legacy.exists(): 94 return legacy 95 return panel
The bean-tables file. Prefers PanelPro.xml (JMRI 5.x) if
either it exists already; otherwise falls back to the older
Configuration.xml. Brand-new profiles get PanelPro.xml.
97 @property 98 def roster_dir(self) -> pathlib.Path: 99 """Where per-loco ``locomotive-config`` XML files live.""" 100 return self.profile_dir / "roster"
Where per-loco locomotive-config XML files live.
102 @property 103 def roster_index(self) -> pathlib.Path: 104 """Roster index — ``<profile>/roster.xml``, alongside the roster dir.""" 105 return self.profile_dir / "roster.xml"
Roster index — <profile>/roster.xml, alongside the roster dir.
107 @property 108 def rosters_cv_dir(self) -> pathlib.Path: 109 """Per-loco CV state files (programmer output). Loaded on demand.""" 110 return self.profile_dir / "rosters"
Per-loco CV state files (programmer output). Loaded on demand.
116 @property 117 def signal_dir(self) -> pathlib.Path: 118 """Per-profile signal-mast definitions.""" 119 return self.profile_dir / "signal"
Per-profile signal-mast definitions.
125 @property 126 def jython_dir(self) -> pathlib.Path: 127 """Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a 128 model attribute so imports from an older rewrite install still 129 parse — the autoloader no longer scans it. Use :attr:`python_dir` 130 for CPython 3 scripts.""" 131 return self.profile_dir / "jython"
Legacy autoload dir (JMRI Java's Jython 2.7 tree). Kept as a
model attribute so imports from an older rewrite install still
parse — the autoloader no longer scans it. Use python_dir
for CPython 3 scripts.
133 @property 134 def python_dir(self) -> pathlib.Path: 135 """Per-profile CPython 3 script dir. 136 137 Autoload sequence (see 138 :func:`jmri_server.app._autolaunch_profile_scripts`): 139 140 1. ``<settings-root>/python/`` — cross-profile shared scripts. 141 2. ``<profile>/python/`` — per-profile scripts. 142 143 Files ending in ``.py`` at the top level run in alphabetical 144 order; files starting with ``_`` are treated as private 145 modules that other scripts may import but aren't run standalone. 146 Missing directories are silently ignored — running without 147 either dir is a valid setup for a bare install. 148 """ 149 return self.profile_dir / "python"
Per-profile CPython 3 script dir.
Autoload sequence (see
jmri_server.app._autolaunch_profile_scripts()):
<settings-root>/python/— cross-profile shared scripts.<profile>/python/— per-profile scripts.
Files ending in .py at the top level run in alphabetical
order; files starting with _ are treated as private
modules that other scripts may import but aren't run standalone.
Missing directories are silently ignored — running without
either dir is a valid setup for a bare install.
166 @property 167 def logixng_dir(self) -> pathlib.Path: 168 """Where LogixNG programs are stored as YAML files. 169 170 Each program lives at ``<profile>/logixng/<system_name>.yaml`` 171 so a profile switch swaps the whole automation set atomically. 172 Directory is created on first save; loads treat a missing dir 173 as "no programs" rather than an error. 174 """ 175 return self.profile_dir / "logixng"
Where LogixNG programs are stored as YAML files.
Each program lives at <profile>/logixng/<system_name>.yaml
so a profile switch swaps the whole automation set atomically.
Directory is created on first save; loads treat a missing dir
as "no programs" rather than an error.
185 @property 186 def signal_systems_dir(self) -> pathlib.Path: 187 """User-defined signal systems shared across profiles.""" 188 return self.root / "resources" / "signals"
User-defined signal systems shared across profiles.
192 def ensure(self) -> None: 193 """Create every standard per-profile subdirectory if missing. 194 195 Idempotent. Does not touch the settings-root level directories 196 (those are shared across profiles). 197 """ 198 for d in ( 199 self.root, 200 self.profile_dir, 201 self.roster_dir, 202 self.rosters_cv_dir, 203 self.operations_dir, 204 self.panel_dir, 205 self.throttle_dir, 206 self.signal_dir, 207 self.log_dir, 208 self.signal_systems_dir, 209 ): 210 d.mkdir(parents=True, exist_ok=True)
Create every standard per-profile subdirectory if missing.
Idempotent. Does not touch the settings-root level directories (those are shared across profiles).