jmri_web.app
FastAPI app factory — mounts jmri-server's JSON API + adds template routes.
1"""FastAPI app factory — mounts jmri-server's JSON API + adds template routes.""" 2from __future__ import annotations 3 4import pathlib 5from contextlib import asynccontextmanager 6from dataclasses import dataclass 7 8from fastapi import FastAPI 9from fastapi.staticfiles import StaticFiles 10from fastapi.templating import Jinja2Templates 11from jmri_server.app import AppHandle 12from jmri_server.app import build_app as build_server_app 13from jmri_server.context import ServerMetadata 14 15from jmri_web.routes import install_template_routes 16 17_PKG = pathlib.Path(__file__).resolve().parent 18TEMPLATES_DIR = _PKG / "templates" 19STATIC_DIR = _PKG / "static" 20 21 22@dataclass 23class WebHandle: 24 """Bundle returned by :func:`build_web_app` — exposes the server + templates 25 so tests can drive both layers.""" 26 27 app: FastAPI 28 server: AppHandle 29 templates: Jinja2Templates 30 31 32def build_web_app(*, metadata: ServerMetadata | None = None) -> WebHandle: 33 server = build_server_app(metadata=metadata) 34 app = server.app 35 36 # Static assets + Jinja2 templates. 37 if STATIC_DIR.is_dir(): 38 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 39 # Generated pdoc reference lives at ``static/docs/``. Mounting 40 # it directly at ``/docs/`` gives a cleaner URL for the "read 41 # the docs" links in topic-help panels + the OVERVIEW page. 42 # When docs haven't been built yet the directory is missing; 43 # skip the mount silently so ``build_docs.py`` remains an 44 # optional developer step, not a hard startup requirement. 45 docs_dir = STATIC_DIR / "docs" 46 if docs_dir.is_dir(): 47 app.mount( 48 "/docs", 49 StaticFiles(directory=str(docs_dir), html=True), 50 name="docs", 51 ) 52 templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) 53 54 install_template_routes(app, server, templates) 55 56 # Auto-load the decoder catalog when ``JMRI_DECODER_XML_ROOT`` is 57 # set. Chained onto the server's existing lifespan so it runs 58 # AFTER server startup (config store, hardware connection, etc. 59 # are all up) but before any user request lands. The load itself 60 # runs in a background thread so startup stays fast — pages that 61 # check ``len(catalog) > 0`` see an empty catalog until the scan 62 # completes (same as the "user hasn't clicked Load yet" state). 63 # 64 # FastAPI removed ``add_event_handler`` in newer releases; the 65 # lifespan-chaining pattern is the supported replacement. 66 from jmri_web.catalog_autoload import schedule_autoload # noqa: PLC0415 67 68 _server_lifespan = app.router.lifespan_context 69 70 @asynccontextmanager 71 async def _web_lifespan(_app): # type: ignore[no-untyped-def] 72 async with _server_lifespan(_app): 73 schedule_autoload(server) 74 yield 75 76 app.router.lifespan_context = _web_lifespan 77 78 return WebHandle(app=app, server=server, templates=templates) 79 80 81def create_app() -> FastAPI: 82 """Default ASGI entry point. ``uvicorn jmri_web:create_app --factory``. 83 84 Production wiring: builds the full web + server stack and attaches 85 the multi-profile registry + ``ConfigStore`` (reading 86 ``<settings>/profiles.xml``, honouring ``JMRI_SETTINGS_DIR``). 87 Tests that go through :func:`build_web_app` directly skip this for 88 hermeticity. 89 """ 90 from jmri_core.persistence import default_settings_dir # noqa: PLC0415 91 from jmri_server.app import ( # noqa: PLC0415 92 attach_auth, 93 attach_profile_persistence, 94 attach_withrottle_server, 95 ) 96 handle = build_web_app() 97 attach_profile_persistence(handle.server) 98 attach_withrottle_server(handle.server) 99 # Auth is opt-in via ``JMRI_AUTH_ENABLED=1`` — the attach is a 100 # no-op when the env var isn't set, so this line is safe to 101 # keep unconditionally. 102 attach_auth(handle.server, default_settings_dir()) 103 return handle.app
TEMPLATES_DIR =
WindowsPath('C:/Users/jpetr/jmri-rewrite/jmri-web/src/jmri_web/templates')
STATIC_DIR =
WindowsPath('C:/Users/jpetr/jmri-rewrite/jmri-web/src/jmri_web/static')
@dataclass
class
WebHandle:
23@dataclass 24class WebHandle: 25 """Bundle returned by :func:`build_web_app` — exposes the server + templates 26 so tests can drive both layers.""" 27 28 app: FastAPI 29 server: AppHandle 30 templates: Jinja2Templates
Bundle returned by build_web_app() — exposes the server + templates
so tests can drive both layers.
WebHandle( app: fastapi.applications.FastAPI, server: jmri_server.app.AppHandle, templates: starlette.templating.Jinja2Templates)
server: jmri_server.app.AppHandle
33def build_web_app(*, metadata: ServerMetadata | None = None) -> WebHandle: 34 server = build_server_app(metadata=metadata) 35 app = server.app 36 37 # Static assets + Jinja2 templates. 38 if STATIC_DIR.is_dir(): 39 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 40 # Generated pdoc reference lives at ``static/docs/``. Mounting 41 # it directly at ``/docs/`` gives a cleaner URL for the "read 42 # the docs" links in topic-help panels + the OVERVIEW page. 43 # When docs haven't been built yet the directory is missing; 44 # skip the mount silently so ``build_docs.py`` remains an 45 # optional developer step, not a hard startup requirement. 46 docs_dir = STATIC_DIR / "docs" 47 if docs_dir.is_dir(): 48 app.mount( 49 "/docs", 50 StaticFiles(directory=str(docs_dir), html=True), 51 name="docs", 52 ) 53 templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) 54 55 install_template_routes(app, server, templates) 56 57 # Auto-load the decoder catalog when ``JMRI_DECODER_XML_ROOT`` is 58 # set. Chained onto the server's existing lifespan so it runs 59 # AFTER server startup (config store, hardware connection, etc. 60 # are all up) but before any user request lands. The load itself 61 # runs in a background thread so startup stays fast — pages that 62 # check ``len(catalog) > 0`` see an empty catalog until the scan 63 # completes (same as the "user hasn't clicked Load yet" state). 64 # 65 # FastAPI removed ``add_event_handler`` in newer releases; the 66 # lifespan-chaining pattern is the supported replacement. 67 from jmri_web.catalog_autoload import schedule_autoload # noqa: PLC0415 68 69 _server_lifespan = app.router.lifespan_context 70 71 @asynccontextmanager 72 async def _web_lifespan(_app): # type: ignore[no-untyped-def] 73 async with _server_lifespan(_app): 74 schedule_autoload(server) 75 yield 76 77 app.router.lifespan_context = _web_lifespan 78 79 return WebHandle(app=app, server=server, templates=templates)
def
create_app() -> fastapi.applications.FastAPI:
82def create_app() -> FastAPI: 83 """Default ASGI entry point. ``uvicorn jmri_web:create_app --factory``. 84 85 Production wiring: builds the full web + server stack and attaches 86 the multi-profile registry + ``ConfigStore`` (reading 87 ``<settings>/profiles.xml``, honouring ``JMRI_SETTINGS_DIR``). 88 Tests that go through :func:`build_web_app` directly skip this for 89 hermeticity. 90 """ 91 from jmri_core.persistence import default_settings_dir # noqa: PLC0415 92 from jmri_server.app import ( # noqa: PLC0415 93 attach_auth, 94 attach_profile_persistence, 95 attach_withrottle_server, 96 ) 97 handle = build_web_app() 98 attach_profile_persistence(handle.server) 99 attach_withrottle_server(handle.server) 100 # Auth is opt-in via ``JMRI_AUTH_ENABLED=1`` — the attach is a 101 # no-op when the env var isn't set, so this line is safe to 102 # keep unconditionally. 103 attach_auth(handle.server, default_settings_dir()) 104 return handle.app
Default ASGI entry point. uvicorn jmri_web:create_app --factory.
Production wiring: builds the full web + server stack and attaches
the multi-profile registry + ConfigStore (reading
<settings>/profiles.xml, honouring JMRI_SETTINGS_DIR).
Tests that go through build_web_app() directly skip this for
hermeticity.