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 this Configuration.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})"
def default_settings_dir() -> pathlib._local.Path:
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.

@dataclass(slots=True)
class Profile:
 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.

Profile(root: pathlib._local.Path, name: str = 'default')
root: pathlib._local.Path
name: str
@classmethod
def default(cls) -> Profile:
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.

profile_dir: pathlib._local.Path
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.

config_file: pathlib._local.Path
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.

roster_dir: pathlib._local.Path
 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.

roster_index: pathlib._local.Path
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.

rosters_cv_dir: pathlib._local.Path
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.

throttle_dir: pathlib._local.Path
112    @property
113    def throttle_dir(self) -> pathlib.Path:
114        return self.profile_dir / "throttle"
signal_dir: pathlib._local.Path
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.

programmers_dir: pathlib._local.Path
121    @property
122    def programmers_dir(self) -> pathlib.Path:
123        return self.profile_dir / "programmers"
jython_dir: pathlib._local.Path
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.

python_dir: pathlib._local.Path
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()):

  1. <settings-root>/python/ — cross-profile shared scripts.
  2. <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.

shared_python_dir: pathlib._local.Path
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"

Cross-profile CPython 3 script dir at <settings-root>/python/. See python_dir for the autoload sequence.

operations_dir: pathlib._local.Path
158    @property
159    def operations_dir(self) -> pathlib.Path:
160        return self.profile_dir / "operations"
panel_dir: pathlib._local.Path
162    @property
163    def panel_dir(self) -> pathlib.Path:
164        return self.profile_dir / "panels"
logixng_dir: pathlib._local.Path
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.

log_dir: pathlib._local.Path
177    @property
178    def log_dir(self) -> pathlib.Path:
179        return self.root / "log"
preferences_dir: pathlib._local.Path
181    @property
182    def preferences_dir(self) -> pathlib.Path:
183        return self.profile_dir / "profile"
signal_systems_dir: pathlib._local.Path
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.

def ensure(self) -> None:
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).