jmri_server.services.scripts

ScriptsService — JSON wire interface for the scripting host.

JMRI's stock JSON API doesn't expose script management; this is a rewrite-specific extension. Wire shapes:

  • method=list → [{name, started_at, finished, error, stdout, stderr, running}].
  • method=get, data={"name": ...} → one status entry.
  • method=put, data={"name": ..., "source": "...", "save": bool} → start a script from inline source; when save is true, also write it to <profile>/python/<name>.py so it autoloads next boot.
  • method=post, data={"name": ..., "action": ...} — verbs:
    • "stop" (default) — signal the automaton to stop
    • "delete" — stop AND remove the on-disk file. Refuses when the file lives under the shared <settings-root>/python/ dir because those are operator-managed globals; the UI only manages per-profile scripts.
  • method=list, data={"library": true} → list scripts from the shared and per-profile python dirs so the UI can render the library table. Each entry carries a source tag ("shared" or "profile") so the UI can lock down the actions for shared scripts.

Autoload / library scan sequence — same for both:

  1. <settings-root>/python/ — cross-profile shared helpers.
  2. <profile>/python/ — per-profile scripts.

The service uses one ScriptRunner per server context, lazily constructed on first use. The runner's event loop is the FastAPI app's event loop, so the sync shim wrappers correctly bridge.

  1"""``ScriptsService`` — JSON wire interface for the scripting host.
  2
  3JMRI's stock JSON API doesn't expose script management; this is a
  4rewrite-specific extension. Wire shapes:
  5
  6* ``method=list`` → ``[{name, started_at, finished, error, stdout,
  7  stderr, running}]``.
  8* ``method=get``, ``data={"name": ...}`` → one status entry.
  9* ``method=put``, ``data={"name": ..., "source": "...", "save": bool}``
 10  → start a script from inline source; when ``save`` is true, also
 11  write it to ``<profile>/python/<name>.py`` so it autoloads next boot.
 12* ``method=post``, ``data={"name": ..., "action": ...}`` — verbs:
 13    - ``"stop"`` (default) — signal the automaton to stop
 14    - ``"delete"`` — stop AND remove the on-disk file. Refuses when
 15      the file lives under the shared ``<settings-root>/python/`` dir
 16      because those are operator-managed globals; the UI only
 17      manages per-profile scripts.
 18* ``method=list``, ``data={"library": true}`` → list scripts from
 19  the shared and per-profile python dirs so the UI can render the
 20  library table. Each entry carries a ``source`` tag (``"shared"``
 21  or ``"profile"``) so the UI can lock down the actions for shared
 22  scripts.
 23
 24Autoload / library scan sequence — same for both:
 25
 261. ``<settings-root>/python/`` — cross-profile shared helpers.
 272. ``<profile>/python/`` — per-profile scripts.
 28
 29The service uses one :class:`ScriptRunner` per server context, lazily
 30constructed on first use. The runner's event loop is the FastAPI app's
 31event loop, so the sync shim wrappers correctly bridge.
 32"""
 33from __future__ import annotations
 34
 35import asyncio
 36import pathlib
 37from typing import Any
 38
 39from jmri_core.persistence.profile import Profile
 40from jmri_scripting import ScriptRunner
 41
 42from jmri_server.auth.models import Role
 43from jmri_server.service import JsonService, JsonServiceError
 44
 45
 46def _status_to_dict(s: Any) -> dict[str, Any]:
 47    return {
 48        "name": s.name,
 49        "started_at": s.started_at,
 50        "finished": s.finished,
 51        "error": s.error,
 52        "running": s.thread is not None and s.thread.is_alive(),
 53        # Captured per-script stdout/stderr. Bounded (~64 KB with FIFO
 54        # truncation and a leading marker) so a runaway loop doesn't
 55        # OOM the response. Empty string if the script produced none.
 56        "stdout": s.stdout,
 57        "stderr": s.stderr,
 58    }
 59
 60
 61_LIBRARY_NAME_RE = __import__("re").compile(r"^[A-Za-z0-9_.-]+$")
 62
 63
 64def _sanitize_library_name(name: str) -> str:
 65    """Reject anything that isn't a plain filename component.
 66
 67    Prevents path traversal (``../../etc/passwd``), absolute paths, and
 68    other tricks against the profile scripts directory. The rule is
 69    intentionally strict — script names live in the URL space and get
 70    stored as filenames, so restricting to ``[A-Za-z0-9_.-]`` costs
 71    nothing and avoids edge cases.
 72    """
 73    if not name or not _LIBRARY_NAME_RE.match(name) or ".." in name:
 74        raise JsonServiceError(400, f"invalid script name: {name!r}")
 75    return name
 76
 77
 78class ScriptsService(JsonService):
 79    topic = "scripts"
 80    # Scripts execute arbitrary Python on the server — that's
 81    # remote-code-execution surface. Restrict mutations to admin
 82    # so a compromised operator token can't drop a script that
 83    # steals the settings dir or reads users.yaml. Reads (list /
 84    # get) are fine for operators — they may want to inspect
 85    # what's running.
 86    REQUIRED_ROLE = {
 87        "list": Role.OPERATOR,
 88        "get": Role.OPERATOR,
 89        "put": Role.ADMIN,
 90        "post": Role.ADMIN,
 91    }
 92
 93    @property
 94    def _runner(self) -> ScriptRunner:
 95        # Cache the runner on the context. Lazy because the event loop
 96        # isn't running at app-construction time.
 97        cached = getattr(self.ctx, "_script_runner", None)
 98        if cached is None:
 99            loop = asyncio.get_event_loop()
100            cached = ScriptRunner.for_connection(loop, self.ctx.connection)
101            self.ctx._script_runner = cached  # type: ignore[attr-defined]
102        return cached
103
104    def _python_dirs(self) -> list[tuple[pathlib.Path, str]]:
105        """The two autoload/library dirs, in scan order, tagged by
106        source (``"shared"`` for the settings-root dir, ``"profile"``
107        for the active profile's dir). Missing profile → empty list."""
108        profile = getattr(self.ctx, "_roster_profile", None)
109        if not isinstance(profile, Profile):
110            return []
111        return [
112            (profile.shared_python_dir, "shared"),
113            (profile.python_dir, "profile"),
114        ]
115
116    def _profile_python_dir(self) -> pathlib.Path | None:
117        """Save target for user-submitted scripts. Shared scripts are
118        operator-managed; the UI writes to the per-profile dir only."""
119        profile = getattr(self.ctx, "_roster_profile", None)
120        return profile.python_dir if isinstance(profile, Profile) else None
121
122    def _find_script_on_disk(self, name: str) -> pathlib.Path | None:
123        """Locate ``<name>.py`` under the per-profile or shared dir.
124
125        Per-profile wins when both exist — matches the autoload
126        order that shipped in the python-dir migration. Returns
127        None when neither dir has the file; the caller surfaces
128        that as 404 so the UI can distinguish "script not on
129        disk" from "run failed"."""
130        for dir_path, _tag in self._python_dirs():
131            candidate = dir_path / f"{name}.py"
132            if candidate.is_file():
133                return candidate
134        return None
135
136    async def on_list(self, data: dict[str, Any] | None) -> Any:
137        data = data or {}
138        if data.get("library"):
139            # Library shape: everything the runner *knows about* (either
140            # running or persisted on disk) so the UI can render a
141            # single table. Scripts are keyed by name — a per-profile
142            # file with the same name as a shared one wins (last write
143            # in scan order), matching the runner's autoload behaviour.
144            statuses = {s.name: _status_to_dict(s) for s in self._runner.list()}
145            for dir_path, source_tag in self._python_dirs():
146                if not dir_path.is_dir():
147                    continue
148                for p in sorted(dir_path.glob("*.py")):
149                    if p.name.startswith("_"):
150                        continue
151                    entry = statuses.setdefault(p.stem, {
152                        "name": p.stem, "started_at": None,
153                        "finished": False, "error": None, "running": False,
154                        "stdout": "", "stderr": "",
155                    })
156                    entry["path"] = str(p)
157                    entry["source"] = source_tag
158            return list(statuses.values())
159        return [_status_to_dict(s) for s in self._runner.list()]
160
161    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
162        data = data or {}
163        name = data.get("name")
164        if not isinstance(name, str):
165            raise JsonServiceError(400, "scripts.get requires 'name'")
166        status = self._runner.get(name)
167        if status is None:
168            raise JsonServiceError(404, f"no script named {name!r}")
169        return _status_to_dict(status)
170
171    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
172        data = data or {}
173        name = data.get("name")
174        source = data.get("source")
175        save = bool(data.get("save", False))
176        if not isinstance(name, str) or not isinstance(source, str):
177            raise JsonServiceError(400, "scripts.put requires 'name' and 'source'")
178        if save:
179            # Persist to <profile>/python/<name>.py so the script
180            # autoloads on next boot. Requires an active profile.
181            # Shared scripts under <settings>/python/ are not writable
182            # via the API — operator drops them by hand.
183            _sanitize_library_name(name)
184            pdir = self._profile_python_dir()
185            if pdir is None:
186                raise JsonServiceError(
187                    409,
188                    "cannot save: no active profile has a python dir",
189                )
190            pdir.mkdir(parents=True, exist_ok=True)
191            path = pdir / f"{name}.py"
192            path.write_text(source, encoding="utf-8")
193            status = self._runner.run_file(path, name=name)
194        else:
195            status = self._runner.run_source(source, name=name)
196        return _status_to_dict(status)
197
198    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
199        data = data or {}
200        name = data.get("name")
201        action = data.get("action", "stop")
202        if not isinstance(name, str):
203            raise JsonServiceError(400, "scripts.post requires 'name'")
204        if action == "delete":
205            _sanitize_library_name(name)
206            # Stop any running instance, then remove the on-disk file.
207            # Only per-profile scripts are removable via the API —
208            # shared scripts under <settings>/python/ are operator
209            # artefacts. A shared file with the same name blocks the
210            # delete with 409 so the caller sees why.
211            pdir = self._profile_python_dir()
212            profile_path = (pdir / f"{name}.py") if pdir else None
213            shared_dir = None
214            profile = getattr(self.ctx, "_roster_profile", None)
215            if isinstance(profile, Profile):
216                shared_dir = profile.shared_python_dir
217            shared_path = (
218                shared_dir / f"{name}.py" if shared_dir else None
219            )
220            if (
221                (profile_path is None or not profile_path.is_file())
222                and shared_path is not None
223                and shared_path.is_file()
224            ):
225                raise JsonServiceError(
226                    409,
227                    f"script {name!r} lives under the shared python dir; "
228                    f"remove it from disk manually",
229                )
230            self._runner.stop(name)
231            if profile_path is not None and profile_path.is_file():
232                profile_path.unlink()
233            status = self._runner.get(name)
234            return _status_to_dict(status) if status else {"name": name, "deleted": True}
235        if action == "run_file":
236            # Re-read the on-disk file (per-profile first, then
237            # shared) and hand it to the runner. This is the "debug
238            # loop" primitive — edit the file externally, click
239            # Run in the UI, see fresh output. The runner replaces
240            # any existing status entry with the same name so
241            # re-runs stack cleanly (previous automatons get
242            # stopped first).
243            _sanitize_library_name(name)
244            path = self._find_script_on_disk(name)
245            if path is None:
246                raise JsonServiceError(
247                    404,
248                    f"no script named {name!r} on disk under the "
249                    "profile or shared python dirs",
250                )
251            status = self._runner.run_file(path, name=name)
252            return _status_to_dict(status)
253        if action != "stop":
254            raise JsonServiceError(400, f"unknown scripts action: {action!r}")
255        if not self._runner.stop(name):
256            raise JsonServiceError(404, f"no script named {name!r}")
257        status = self._runner.get(name)
258        return _status_to_dict(status) if status else {"name": name}
class ScriptsService(jmri_server.service.JsonService):
 79class ScriptsService(JsonService):
 80    topic = "scripts"
 81    # Scripts execute arbitrary Python on the server — that's
 82    # remote-code-execution surface. Restrict mutations to admin
 83    # so a compromised operator token can't drop a script that
 84    # steals the settings dir or reads users.yaml. Reads (list /
 85    # get) are fine for operators — they may want to inspect
 86    # what's running.
 87    REQUIRED_ROLE = {
 88        "list": Role.OPERATOR,
 89        "get": Role.OPERATOR,
 90        "put": Role.ADMIN,
 91        "post": Role.ADMIN,
 92    }
 93
 94    @property
 95    def _runner(self) -> ScriptRunner:
 96        # Cache the runner on the context. Lazy because the event loop
 97        # isn't running at app-construction time.
 98        cached = getattr(self.ctx, "_script_runner", None)
 99        if cached is None:
100            loop = asyncio.get_event_loop()
101            cached = ScriptRunner.for_connection(loop, self.ctx.connection)
102            self.ctx._script_runner = cached  # type: ignore[attr-defined]
103        return cached
104
105    def _python_dirs(self) -> list[tuple[pathlib.Path, str]]:
106        """The two autoload/library dirs, in scan order, tagged by
107        source (``"shared"`` for the settings-root dir, ``"profile"``
108        for the active profile's dir). Missing profile → empty list."""
109        profile = getattr(self.ctx, "_roster_profile", None)
110        if not isinstance(profile, Profile):
111            return []
112        return [
113            (profile.shared_python_dir, "shared"),
114            (profile.python_dir, "profile"),
115        ]
116
117    def _profile_python_dir(self) -> pathlib.Path | None:
118        """Save target for user-submitted scripts. Shared scripts are
119        operator-managed; the UI writes to the per-profile dir only."""
120        profile = getattr(self.ctx, "_roster_profile", None)
121        return profile.python_dir if isinstance(profile, Profile) else None
122
123    def _find_script_on_disk(self, name: str) -> pathlib.Path | None:
124        """Locate ``<name>.py`` under the per-profile or shared dir.
125
126        Per-profile wins when both exist — matches the autoload
127        order that shipped in the python-dir migration. Returns
128        None when neither dir has the file; the caller surfaces
129        that as 404 so the UI can distinguish "script not on
130        disk" from "run failed"."""
131        for dir_path, _tag in self._python_dirs():
132            candidate = dir_path / f"{name}.py"
133            if candidate.is_file():
134                return candidate
135        return None
136
137    async def on_list(self, data: dict[str, Any] | None) -> Any:
138        data = data or {}
139        if data.get("library"):
140            # Library shape: everything the runner *knows about* (either
141            # running or persisted on disk) so the UI can render a
142            # single table. Scripts are keyed by name — a per-profile
143            # file with the same name as a shared one wins (last write
144            # in scan order), matching the runner's autoload behaviour.
145            statuses = {s.name: _status_to_dict(s) for s in self._runner.list()}
146            for dir_path, source_tag in self._python_dirs():
147                if not dir_path.is_dir():
148                    continue
149                for p in sorted(dir_path.glob("*.py")):
150                    if p.name.startswith("_"):
151                        continue
152                    entry = statuses.setdefault(p.stem, {
153                        "name": p.stem, "started_at": None,
154                        "finished": False, "error": None, "running": False,
155                        "stdout": "", "stderr": "",
156                    })
157                    entry["path"] = str(p)
158                    entry["source"] = source_tag
159            return list(statuses.values())
160        return [_status_to_dict(s) for s in self._runner.list()]
161
162    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
163        data = data or {}
164        name = data.get("name")
165        if not isinstance(name, str):
166            raise JsonServiceError(400, "scripts.get requires 'name'")
167        status = self._runner.get(name)
168        if status is None:
169            raise JsonServiceError(404, f"no script named {name!r}")
170        return _status_to_dict(status)
171
172    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        name = data.get("name")
175        source = data.get("source")
176        save = bool(data.get("save", False))
177        if not isinstance(name, str) or not isinstance(source, str):
178            raise JsonServiceError(400, "scripts.put requires 'name' and 'source'")
179        if save:
180            # Persist to <profile>/python/<name>.py so the script
181            # autoloads on next boot. Requires an active profile.
182            # Shared scripts under <settings>/python/ are not writable
183            # via the API — operator drops them by hand.
184            _sanitize_library_name(name)
185            pdir = self._profile_python_dir()
186            if pdir is None:
187                raise JsonServiceError(
188                    409,
189                    "cannot save: no active profile has a python dir",
190                )
191            pdir.mkdir(parents=True, exist_ok=True)
192            path = pdir / f"{name}.py"
193            path.write_text(source, encoding="utf-8")
194            status = self._runner.run_file(path, name=name)
195        else:
196            status = self._runner.run_source(source, name=name)
197        return _status_to_dict(status)
198
199    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
200        data = data or {}
201        name = data.get("name")
202        action = data.get("action", "stop")
203        if not isinstance(name, str):
204            raise JsonServiceError(400, "scripts.post requires 'name'")
205        if action == "delete":
206            _sanitize_library_name(name)
207            # Stop any running instance, then remove the on-disk file.
208            # Only per-profile scripts are removable via the API —
209            # shared scripts under <settings>/python/ are operator
210            # artefacts. A shared file with the same name blocks the
211            # delete with 409 so the caller sees why.
212            pdir = self._profile_python_dir()
213            profile_path = (pdir / f"{name}.py") if pdir else None
214            shared_dir = None
215            profile = getattr(self.ctx, "_roster_profile", None)
216            if isinstance(profile, Profile):
217                shared_dir = profile.shared_python_dir
218            shared_path = (
219                shared_dir / f"{name}.py" if shared_dir else None
220            )
221            if (
222                (profile_path is None or not profile_path.is_file())
223                and shared_path is not None
224                and shared_path.is_file()
225            ):
226                raise JsonServiceError(
227                    409,
228                    f"script {name!r} lives under the shared python dir; "
229                    f"remove it from disk manually",
230                )
231            self._runner.stop(name)
232            if profile_path is not None and profile_path.is_file():
233                profile_path.unlink()
234            status = self._runner.get(name)
235            return _status_to_dict(status) if status else {"name": name, "deleted": True}
236        if action == "run_file":
237            # Re-read the on-disk file (per-profile first, then
238            # shared) and hand it to the runner. This is the "debug
239            # loop" primitive — edit the file externally, click
240            # Run in the UI, see fresh output. The runner replaces
241            # any existing status entry with the same name so
242            # re-runs stack cleanly (previous automatons get
243            # stopped first).
244            _sanitize_library_name(name)
245            path = self._find_script_on_disk(name)
246            if path is None:
247                raise JsonServiceError(
248                    404,
249                    f"no script named {name!r} on disk under the "
250                    "profile or shared python dirs",
251                )
252            status = self._runner.run_file(path, name=name)
253            return _status_to_dict(status)
254        if action != "stop":
255            raise JsonServiceError(400, f"unknown scripts action: {action!r}")
256        if not self._runner.stop(name):
257            raise JsonServiceError(404, f"no script named {name!r}")
258        status = self._runner.get(name)
259        return _status_to_dict(status) if status else {"name": name}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'scripts'
REQUIRED_ROLE = {'list': <Role.OPERATOR: 'operator'>, 'get': <Role.OPERATOR: 'operator'>, 'put': <Role.ADMIN: 'admin'>, 'post': <Role.ADMIN: 'admin'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> Any:
137    async def on_list(self, data: dict[str, Any] | None) -> Any:
138        data = data or {}
139        if data.get("library"):
140            # Library shape: everything the runner *knows about* (either
141            # running or persisted on disk) so the UI can render a
142            # single table. Scripts are keyed by name — a per-profile
143            # file with the same name as a shared one wins (last write
144            # in scan order), matching the runner's autoload behaviour.
145            statuses = {s.name: _status_to_dict(s) for s in self._runner.list()}
146            for dir_path, source_tag in self._python_dirs():
147                if not dir_path.is_dir():
148                    continue
149                for p in sorted(dir_path.glob("*.py")):
150                    if p.name.startswith("_"):
151                        continue
152                    entry = statuses.setdefault(p.stem, {
153                        "name": p.stem, "started_at": None,
154                        "finished": False, "error": None, "running": False,
155                        "stdout": "", "stderr": "",
156                    })
157                    entry["path"] = str(p)
158                    entry["source"] = source_tag
159            return list(statuses.values())
160        return [_status_to_dict(s) for s in self._runner.list()]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
162    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
163        data = data or {}
164        name = data.get("name")
165        if not isinstance(name, str):
166            raise JsonServiceError(400, "scripts.get requires 'name'")
167        status = self._runner.get(name)
168        if status is None:
169            raise JsonServiceError(404, f"no script named {name!r}")
170        return _status_to_dict(status)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
172    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        name = data.get("name")
175        source = data.get("source")
176        save = bool(data.get("save", False))
177        if not isinstance(name, str) or not isinstance(source, str):
178            raise JsonServiceError(400, "scripts.put requires 'name' and 'source'")
179        if save:
180            # Persist to <profile>/python/<name>.py so the script
181            # autoloads on next boot. Requires an active profile.
182            # Shared scripts under <settings>/python/ are not writable
183            # via the API — operator drops them by hand.
184            _sanitize_library_name(name)
185            pdir = self._profile_python_dir()
186            if pdir is None:
187                raise JsonServiceError(
188                    409,
189                    "cannot save: no active profile has a python dir",
190                )
191            pdir.mkdir(parents=True, exist_ok=True)
192            path = pdir / f"{name}.py"
193            path.write_text(source, encoding="utf-8")
194            status = self._runner.run_file(path, name=name)
195        else:
196            status = self._runner.run_source(source, name=name)
197        return _status_to_dict(status)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
199    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
200        data = data or {}
201        name = data.get("name")
202        action = data.get("action", "stop")
203        if not isinstance(name, str):
204            raise JsonServiceError(400, "scripts.post requires 'name'")
205        if action == "delete":
206            _sanitize_library_name(name)
207            # Stop any running instance, then remove the on-disk file.
208            # Only per-profile scripts are removable via the API —
209            # shared scripts under <settings>/python/ are operator
210            # artefacts. A shared file with the same name blocks the
211            # delete with 409 so the caller sees why.
212            pdir = self._profile_python_dir()
213            profile_path = (pdir / f"{name}.py") if pdir else None
214            shared_dir = None
215            profile = getattr(self.ctx, "_roster_profile", None)
216            if isinstance(profile, Profile):
217                shared_dir = profile.shared_python_dir
218            shared_path = (
219                shared_dir / f"{name}.py" if shared_dir else None
220            )
221            if (
222                (profile_path is None or not profile_path.is_file())
223                and shared_path is not None
224                and shared_path.is_file()
225            ):
226                raise JsonServiceError(
227                    409,
228                    f"script {name!r} lives under the shared python dir; "
229                    f"remove it from disk manually",
230                )
231            self._runner.stop(name)
232            if profile_path is not None and profile_path.is_file():
233                profile_path.unlink()
234            status = self._runner.get(name)
235            return _status_to_dict(status) if status else {"name": name, "deleted": True}
236        if action == "run_file":
237            # Re-read the on-disk file (per-profile first, then
238            # shared) and hand it to the runner. This is the "debug
239            # loop" primitive — edit the file externally, click
240            # Run in the UI, see fresh output. The runner replaces
241            # any existing status entry with the same name so
242            # re-runs stack cleanly (previous automatons get
243            # stopped first).
244            _sanitize_library_name(name)
245            path = self._find_script_on_disk(name)
246            if path is None:
247                raise JsonServiceError(
248                    404,
249                    f"no script named {name!r} on disk under the "
250                    "profile or shared python dirs",
251                )
252            status = self._runner.run_file(path, name=name)
253            return _status_to_dict(status)
254        if action != "stop":
255            raise JsonServiceError(400, f"unknown scripts action: {action!r}")
256        if not self._runner.stop(name):
257            raise JsonServiceError(404, f"no script named {name!r}")
258        status = self._runner.get(name)
259        return _status_to_dict(status) if status else {"name": name}