jmri_core.env

jmri_core.env — one place to parse JMRI_* environment variables.

Every operator-facing knob is a JMRI_-prefixed env var. Historically these were read directly from os.environ at each call site — which made it easy to typo names, forget to strip whitespace, or add a knob that never got mentioned in the docs. This module funnels every read through one typed getter, so:

  • the full name registry lives in one file (search for JMRI_ here to see every knob);
  • callers can't confuse JMRI_HTTP_PORT with JMRI_HTTP_PORT_;
  • defaults are documented alongside the getter;
  • tests can monkeypatch a single os.environ dict.

Naming convention: getters are named after the concept, not the env var. So http_port() reads JMRI_HTTP_PORT — the operator-facing name is stable, the Python API stays readable.

  1"""``jmri_core.env`` — one place to parse ``JMRI_*`` environment variables.
  2
  3Every operator-facing knob is a ``JMRI_``-prefixed env var. Historically
  4these were read directly from ``os.environ`` at each call site — which
  5made it easy to typo names, forget to strip whitespace, or add a knob
  6that never got mentioned in the docs. This module funnels every read
  7through one typed getter, so:
  8
  9* the full name registry lives in one file (search for ``JMRI_`` here
 10  to see every knob);
 11* callers can't confuse ``JMRI_HTTP_PORT`` with ``JMRI_HTTP_PORT_``;
 12* defaults are documented alongside the getter;
 13* tests can monkeypatch a single ``os.environ`` dict.
 14
 15Naming convention: getters are named after the concept, not the env
 16var. So :func:`http_port` reads ``JMRI_HTTP_PORT`` — the operator-facing
 17name is stable, the Python API stays readable.
 18"""
 19from __future__ import annotations
 20
 21import json
 22import logging
 23import os
 24import pathlib
 25from typing import Any
 26
 27_log = logging.getLogger(__name__)
 28
 29# ---- registry ------------------------------------------------------------
 30#
 31# Every env var this project reads. Documented here so operators have a
 32# single place to look. Order matches importance / freqency of use.
 33
 34ENV_CONNECTION = "JMRI_CONNECTION"
 35ENV_CONNECTION_SETTINGS = "JMRI_CONNECTION_SETTINGS"
 36ENV_SETTINGS_DIR = "JMRI_SETTINGS_DIR"
 37ENV_HTTP_PORT = "JMRI_HTTP_PORT"
 38ENV_WITHROTTLE_PORT = "JMRI_WITHROTTLE_PORT"
 39ENV_WITHROTTLE_HOST = "JMRI_WITHROTTLE_HOST"
 40
 41ENV_LOG_LEVEL = "JMRI_LOG_LEVEL"
 42ENV_LOG_FILE = "JMRI_LOG_FILE"
 43ENV_LOG_MAX_MB = "JMRI_LOG_MAX_MB"
 44ENV_LOG_BACKUPS = "JMRI_LOG_BACKUPS"
 45
 46ENV_NCE_DEBUG = "JMRI_NCE_DEBUG"
 47ENV_TIME_HOME = "JMRI_TIME_HOME"
 48
 49ENV_SIGNAL_XML_ROOT = "JMRI_SIGNAL_XML_ROOT"
 50ENV_DECODER_XML_ROOT = "JMRI_DECODER_XML_ROOT"
 51
 52
 53def all_names() -> list[str]:
 54    """List of every env var name this project reads. Useful for docs
 55    generation and for the ``/logs`` endpoint's "configured knobs"
 56    diagnostic block."""
 57    return [
 58        ENV_CONNECTION,
 59        ENV_CONNECTION_SETTINGS,
 60        ENV_SETTINGS_DIR,
 61        ENV_HTTP_PORT,
 62        ENV_WITHROTTLE_PORT,
 63        ENV_WITHROTTLE_HOST,
 64        ENV_LOG_LEVEL,
 65        ENV_LOG_FILE,
 66        ENV_LOG_MAX_MB,
 67        ENV_LOG_BACKUPS,
 68        ENV_NCE_DEBUG,
 69        ENV_TIME_HOME,
 70        ENV_SIGNAL_XML_ROOT,
 71        ENV_DECODER_XML_ROOT,
 72    ]
 73
 74
 75# ---- primitive parsers ---------------------------------------------------
 76
 77
 78def _get(env: dict[str, str] | None, name: str) -> str | None:
 79    """Read *name* from *env* (default ``os.environ``). Trims whitespace
 80    on both sides — a stray newline in a systemd EnvironmentFile is a
 81    frequent-enough footgun that we absorb it here rather than at
 82    every call site."""
 83    source = os.environ if env is None else env
 84    val = source.get(name)
 85    if val is None:
 86        return None
 87    val = val.strip()
 88    if not val:
 89        return None
 90    return val
 91
 92
 93def _get_int(
 94    env: dict[str, str] | None, name: str, default: int,
 95) -> int:
 96    raw = _get(env, name)
 97    if raw is None:
 98        return default
 99    try:
100        return int(raw)
101    except ValueError:
102        _log.warning(
103            "%s=%r isn't an int; falling back to %d", name, raw, default,
104        )
105        return default
106
107
108def _get_bool(env: dict[str, str] | None, name: str) -> bool:
109    """Truthy strings: ``1``, ``true``, ``yes``, ``on`` (case-insensitive).
110    Anything else — including unset — is false. Deliberately narrow so
111    ``JMRI_NCE_DEBUG=0`` reads as off."""
112    raw = _get(env, name)
113    if raw is None:
114        return False
115    return raw.lower() in ("1", "true", "yes", "on")
116
117
118# ---- typed getters -------------------------------------------------------
119
120
121def connection_name(env: dict[str, str] | None = None) -> str | None:
122    """Which hardware driver to start (``nce``, ``loconet``, ...). ``None``
123    means the server picks its default (Internal)."""
124    return _get(env, ENV_CONNECTION)
125
126
127def connection_settings(env: dict[str, str] | None = None) -> dict[str, Any]:
128    """Driver-specific settings blob, JSON-encoded in the env var. Missing
129    or malformed → empty dict, and the parse error is logged so the
130    operator sees why their config didn't take."""
131    raw = _get(env, ENV_CONNECTION_SETTINGS)
132    if raw is None:
133        return {}
134    try:
135        parsed = json.loads(raw)
136    except json.JSONDecodeError as e:
137        _log.warning(
138            "%s isn't valid JSON (%s); ignoring", ENV_CONNECTION_SETTINGS, e,
139        )
140        return {}
141    if not isinstance(parsed, dict):
142        _log.warning(
143            "%s must be a JSON object; got %s",
144            ENV_CONNECTION_SETTINGS, type(parsed).__name__,
145        )
146        return {}
147    return parsed
148
149
150def settings_dir(env: dict[str, str] | None = None) -> pathlib.Path | None:
151    """Override for the JMRI profile-root directory. ``None`` means the
152    OS default (handled by :func:`jmri_core.persistence.profile.default_settings_dir`)."""
153    raw = _get(env, ENV_SETTINGS_DIR)
154    return pathlib.Path(raw).expanduser() if raw else None
155
156
157def http_port(env: dict[str, str] | None = None, default: int = 12080) -> int:
158    return _get_int(env, ENV_HTTP_PORT, default)
159
160
161def withrottle_port(
162    env: dict[str, str] | None = None, default: int = 12090,
163) -> int:
164    return _get_int(env, ENV_WITHROTTLE_PORT, default)
165
166
167def withrottle_host(
168    env: dict[str, str] | None = None, default: str = "0.0.0.0",
169) -> str:
170    return _get(env, ENV_WITHROTTLE_HOST) or default
171
172
173def log_level(
174    env: dict[str, str] | None = None, default: str = "INFO",
175) -> str:
176    """Log level for the root logger (``DEBUG`` / ``INFO`` / ``WARNING`` /
177    ``ERROR``). Case-insensitive; unknown values fall back to the
178    default with a warning logged once the logging is up."""
179    raw = _get(env, ENV_LOG_LEVEL)
180    if raw is None:
181        return default.upper()
182    return raw.upper()
183
184
185def log_file(env: dict[str, str] | None = None) -> pathlib.Path | None:
186    """Path to write logs to. ``None`` means "console only" — matches
187    the pre-Tier-2 behaviour so unattended installs don't suddenly
188    grow files."""
189    raw = _get(env, ENV_LOG_FILE)
190    return pathlib.Path(raw).expanduser() if raw else None
191
192
193def log_max_mb(env: dict[str, str] | None = None, default: int = 5) -> int:
194    """Max size of a single log file before rotation, in MiB."""
195    return max(_get_int(env, ENV_LOG_MAX_MB, default), 1)
196
197
198def log_backups(env: dict[str, str] | None = None, default: int = 3) -> int:
199    """Number of rotated log files to keep."""
200    return max(_get_int(env, ENV_LOG_BACKUPS, default), 0)
201
202
203def nce_debug(env: dict[str, str] | None = None) -> bool:
204    """Verbose NCE frame logging. Off in production; NCE drivers flip
205    their own logger to DEBUG when this is true."""
206    return _get_bool(env, ENV_NCE_DEBUG)
207
208
209def time_home(env: dict[str, str] | None = None) -> bool:
210    """Emit per-phase timing warnings on the home-page render path.
211    Used to diagnose the "profile-load takes 9 s" class of bug. Noisy
212    if left on — hence env-var gated."""
213    return _get_bool(env, ENV_TIME_HOME)
214
215
216def signal_xml_root(env: dict[str, str] | None = None) -> pathlib.Path | None:
217    """Root directory of JMRI's signal-system XML corpus (typically
218    ``<jmri>/xml/signals``). ``None`` means no root is configured —
219    the loader treats that as "no systems available" and the JSON
220    service returns an empty list."""
221    raw = _get(env, ENV_SIGNAL_XML_ROOT)
222    return pathlib.Path(raw).expanduser() if raw else None
223
224
225def decoder_xml_root(env: dict[str, str] | None = None) -> pathlib.Path | None:
226    """Root directory of JMRI's decoder-definition XML corpus
227    (typically ``<jmri>/xml/decoders``). When set, the programmer
228    page pre-populates its "Load catalog from directory" input so
229    the operator only needs to click Load. Unset → same
230    empty-input behaviour as before this knob existed."""
231    raw = _get(env, ENV_DECODER_XML_ROOT)
232    return pathlib.Path(raw).expanduser() if raw else None
ENV_CONNECTION = 'JMRI_CONNECTION'
ENV_CONNECTION_SETTINGS = 'JMRI_CONNECTION_SETTINGS'
ENV_SETTINGS_DIR = 'JMRI_SETTINGS_DIR'
ENV_HTTP_PORT = 'JMRI_HTTP_PORT'
ENV_WITHROTTLE_PORT = 'JMRI_WITHROTTLE_PORT'
ENV_WITHROTTLE_HOST = 'JMRI_WITHROTTLE_HOST'
ENV_LOG_LEVEL = 'JMRI_LOG_LEVEL'
ENV_LOG_FILE = 'JMRI_LOG_FILE'
ENV_LOG_MAX_MB = 'JMRI_LOG_MAX_MB'
ENV_LOG_BACKUPS = 'JMRI_LOG_BACKUPS'
ENV_NCE_DEBUG = 'JMRI_NCE_DEBUG'
ENV_TIME_HOME = 'JMRI_TIME_HOME'
ENV_SIGNAL_XML_ROOT = 'JMRI_SIGNAL_XML_ROOT'
ENV_DECODER_XML_ROOT = 'JMRI_DECODER_XML_ROOT'
def all_names() -> list[str]:
54def all_names() -> list[str]:
55    """List of every env var name this project reads. Useful for docs
56    generation and for the ``/logs`` endpoint's "configured knobs"
57    diagnostic block."""
58    return [
59        ENV_CONNECTION,
60        ENV_CONNECTION_SETTINGS,
61        ENV_SETTINGS_DIR,
62        ENV_HTTP_PORT,
63        ENV_WITHROTTLE_PORT,
64        ENV_WITHROTTLE_HOST,
65        ENV_LOG_LEVEL,
66        ENV_LOG_FILE,
67        ENV_LOG_MAX_MB,
68        ENV_LOG_BACKUPS,
69        ENV_NCE_DEBUG,
70        ENV_TIME_HOME,
71        ENV_SIGNAL_XML_ROOT,
72        ENV_DECODER_XML_ROOT,
73    ]

List of every env var name this project reads. Useful for docs generation and for the /logs endpoint's "configured knobs" diagnostic block.

def connection_name(env: dict[str, str] | None = None) -> str | None:
122def connection_name(env: dict[str, str] | None = None) -> str | None:
123    """Which hardware driver to start (``nce``, ``loconet``, ...). ``None``
124    means the server picks its default (Internal)."""
125    return _get(env, ENV_CONNECTION)

Which hardware driver to start (nce, loconet, ...). None means the server picks its default (Internal).

def connection_settings(env: dict[str, str] | None = None) -> dict[str, typing.Any]:
128def connection_settings(env: dict[str, str] | None = None) -> dict[str, Any]:
129    """Driver-specific settings blob, JSON-encoded in the env var. Missing
130    or malformed → empty dict, and the parse error is logged so the
131    operator sees why their config didn't take."""
132    raw = _get(env, ENV_CONNECTION_SETTINGS)
133    if raw is None:
134        return {}
135    try:
136        parsed = json.loads(raw)
137    except json.JSONDecodeError as e:
138        _log.warning(
139            "%s isn't valid JSON (%s); ignoring", ENV_CONNECTION_SETTINGS, e,
140        )
141        return {}
142    if not isinstance(parsed, dict):
143        _log.warning(
144            "%s must be a JSON object; got %s",
145            ENV_CONNECTION_SETTINGS, type(parsed).__name__,
146        )
147        return {}
148    return parsed

Driver-specific settings blob, JSON-encoded in the env var. Missing or malformed → empty dict, and the parse error is logged so the operator sees why their config didn't take.

def settings_dir(env: dict[str, str] | None = None) -> pathlib._local.Path | None:
151def settings_dir(env: dict[str, str] | None = None) -> pathlib.Path | None:
152    """Override for the JMRI profile-root directory. ``None`` means the
153    OS default (handled by :func:`jmri_core.persistence.profile.default_settings_dir`)."""
154    raw = _get(env, ENV_SETTINGS_DIR)
155    return pathlib.Path(raw).expanduser() if raw else None

Override for the JMRI profile-root directory. None means the OS default (handled by jmri_core.persistence.profile.default_settings_dir()).

def http_port(env: dict[str, str] | None = None, default: int = 12080) -> int:
158def http_port(env: dict[str, str] | None = None, default: int = 12080) -> int:
159    return _get_int(env, ENV_HTTP_PORT, default)
def withrottle_port(env: dict[str, str] | None = None, default: int = 12090) -> int:
162def withrottle_port(
163    env: dict[str, str] | None = None, default: int = 12090,
164) -> int:
165    return _get_int(env, ENV_WITHROTTLE_PORT, default)
def withrottle_host(env: dict[str, str] | None = None, default: str = '0.0.0.0') -> str:
168def withrottle_host(
169    env: dict[str, str] | None = None, default: str = "0.0.0.0",
170) -> str:
171    return _get(env, ENV_WITHROTTLE_HOST) or default
def log_level(env: dict[str, str] | None = None, default: str = 'INFO') -> str:
174def log_level(
175    env: dict[str, str] | None = None, default: str = "INFO",
176) -> str:
177    """Log level for the root logger (``DEBUG`` / ``INFO`` / ``WARNING`` /
178    ``ERROR``). Case-insensitive; unknown values fall back to the
179    default with a warning logged once the logging is up."""
180    raw = _get(env, ENV_LOG_LEVEL)
181    if raw is None:
182        return default.upper()
183    return raw.upper()

Log level for the root logger (DEBUG / INFO / WARNING / ERROR). Case-insensitive; unknown values fall back to the default with a warning logged once the logging is up.

def log_file(env: dict[str, str] | None = None) -> pathlib._local.Path | None:
186def log_file(env: dict[str, str] | None = None) -> pathlib.Path | None:
187    """Path to write logs to. ``None`` means "console only" — matches
188    the pre-Tier-2 behaviour so unattended installs don't suddenly
189    grow files."""
190    raw = _get(env, ENV_LOG_FILE)
191    return pathlib.Path(raw).expanduser() if raw else None

Path to write logs to. None means "console only" — matches the pre-Tier-2 behaviour so unattended installs don't suddenly grow files.

def log_max_mb(env: dict[str, str] | None = None, default: int = 5) -> int:
194def log_max_mb(env: dict[str, str] | None = None, default: int = 5) -> int:
195    """Max size of a single log file before rotation, in MiB."""
196    return max(_get_int(env, ENV_LOG_MAX_MB, default), 1)

Max size of a single log file before rotation, in MiB.

def log_backups(env: dict[str, str] | None = None, default: int = 3) -> int:
199def log_backups(env: dict[str, str] | None = None, default: int = 3) -> int:
200    """Number of rotated log files to keep."""
201    return max(_get_int(env, ENV_LOG_BACKUPS, default), 0)

Number of rotated log files to keep.

def nce_debug(env: dict[str, str] | None = None) -> bool:
204def nce_debug(env: dict[str, str] | None = None) -> bool:
205    """Verbose NCE frame logging. Off in production; NCE drivers flip
206    their own logger to DEBUG when this is true."""
207    return _get_bool(env, ENV_NCE_DEBUG)

Verbose NCE frame logging. Off in production; NCE drivers flip their own logger to DEBUG when this is true.

def time_home(env: dict[str, str] | None = None) -> bool:
210def time_home(env: dict[str, str] | None = None) -> bool:
211    """Emit per-phase timing warnings on the home-page render path.
212    Used to diagnose the "profile-load takes 9 s" class of bug. Noisy
213    if left on — hence env-var gated."""
214    return _get_bool(env, ENV_TIME_HOME)

Emit per-phase timing warnings on the home-page render path. Used to diagnose the "profile-load takes 9 s" class of bug. Noisy if left on — hence env-var gated.

def signal_xml_root(env: dict[str, str] | None = None) -> pathlib._local.Path | None:
217def signal_xml_root(env: dict[str, str] | None = None) -> pathlib.Path | None:
218    """Root directory of JMRI's signal-system XML corpus (typically
219    ``<jmri>/xml/signals``). ``None`` means no root is configured —
220    the loader treats that as "no systems available" and the JSON
221    service returns an empty list."""
222    raw = _get(env, ENV_SIGNAL_XML_ROOT)
223    return pathlib.Path(raw).expanduser() if raw else None

Root directory of JMRI's signal-system XML corpus (typically <jmri>/xml/signals). None means no root is configured — the loader treats that as "no systems available" and the JSON service returns an empty list.

def decoder_xml_root(env: dict[str, str] | None = None) -> pathlib._local.Path | None:
226def decoder_xml_root(env: dict[str, str] | None = None) -> pathlib.Path | None:
227    """Root directory of JMRI's decoder-definition XML corpus
228    (typically ``<jmri>/xml/decoders``). When set, the programmer
229    page pre-populates its "Load catalog from directory" input so
230    the operator only needs to click Load. Unset → same
231    empty-input behaviour as before this knob existed."""
232    raw = _get(env, ENV_DECODER_XML_ROOT)
233    return pathlib.Path(raw).expanduser() if raw else None

Root directory of JMRI's decoder-definition XML corpus (typically <jmri>/xml/decoders). When set, the programmer page pre-populates its "Load catalog from directory" input so the operator only needs to click Load. Unset → same empty-input behaviour as before this knob existed.