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)
app: fastapi.applications.FastAPI
templates: starlette.templating.Jinja2Templates
def build_web_app( *, metadata: jmri_server.context.ServerMetadata | None = None) -> WebHandle:
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.