Source code for phenotypic.gui.shell._app

"""Shell Dash factory + DispatcherMiddleware composer.

Phase 5 ships the unified hub: ``create_app(sandbox)`` returns a
:class:`dash.Dash` whose ``server.wsgi_app`` is wrapped in a
:class:`werkzeug.middleware.dispatcher.DispatcherMiddleware`. The
dispatcher routes:

    * ``/builder/...``  → builder Dash factory (eager — small).
    * ``/results/...``  → :class:`_ViewerProxy` over a viewer
      :class:`ToolSession` (lazy — viewer is heavy).
    * ``/run/...``      → run-console Dash factory (eager — small,
      Phase 5 placeholder; Phase 6 fills in the form/iframe/log/recents).
    * everything else   → the shell's Flask server. Flask blueprints
      (``/sandbox/api/*``, ``/runs/*``) are registered on that server
      so they answer regardless of which sub-app is active.

The viewer is wrapped in a :class:`ToolSession` because it loads heavy
parquet tables on every build; the session lets the user release+rebuild
to reclaim object-graph memory between explorations. Each Dash app gets
its chrome wrapped via :func:`wrap_in_chrome` so the top-bar tabs and
sidebar appear identically across mounts.

Standalone shell mode (Phase 3 backwards-compat): the same
``create_app(sandbox)`` is used by Phase 3 tests; the dispatcher's
fallback is the shell Flask, so every Phase 3 endpoint (``/``,
``/_dash-layout``, ``/sandbox/api/*``, ``/runs/*``) keeps working.
The sub-app mounts simply add new routable prefixes.
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable

import dash
import dash_bootstrap_components as dbc  # type: ignore[import-untyped]
from dash import html
from werkzeug.middleware.dispatcher import DispatcherMiddleware

from phenotypic.gui._config import (
    DEFAULT_URL_PREFIX,
    CFG_RUN_REGISTRY,
    CFG_RUNNER,
    DEFAULT_IDLE_RELEASE_SECONDS,
    MOUNT_ANALYSIS,
    MOUNT_BROWSE,
    MOUNT_BUILDER,
    MOUNT_HOME,
    MOUNT_RUN,
    MOUNT_TUNE,
    MOUNT_VIEWER,
    TITLE_HUB,
    join_url_prefix,
    normalize_url_prefix,
)
from phenotypic.gui.shell._home import build_home_layout
from phenotypic.gui.shell._ids import (
    SHELL_TAB_ANALYSIS,
    SHELL_TAB_BROWSE,
    SHELL_TAB_BUILDER,
    SHELL_TAB_HOME,
    SHELL_TAB_RUN,
    SHELL_TAB_TUNE,
    SHELL_TAB_VIEWER,
)
from phenotypic.gui.shell._layout import wrap_in_chrome
from phenotypic.gui._shared import register_shared_static
from phenotypic.gui.shell._routes import register_sandbox_api
from phenotypic.gui.shell._runs_blueprint import register as register_runs
from phenotypic.gui.shell._sandbox import SandboxRoot
from phenotypic.gui.shell._session import ToolSession, start_idle_release_thread

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)

__all__ = ["create_app", "compose_hub"]


# ---------------------------------------------------------------------------
# _ViewerProxy — per-request resolution of the viewer's WSGI app
# ---------------------------------------------------------------------------

class _SessionProxy:
    """WSGI callable that resolves a tool's Dash app per request.

    The dispatcher's mount point is fixed at composition time, but the
    underlying Dash instance changes whenever the :class:`ToolSession`
    rebuilds (release + first ``get`` after release). Going through a
    proxy keeps the dispatcher's mount stable while the wrapped state
    floats: each request asks the session for the current Dash app and
    forwards the WSGI tuple to its Flask ``wsgi_app``.

    ``ToolSession.get()`` is itself thread-safe and updates
    ``_last_access`` so a steady stream of requests prevents the idle
    daemon from releasing the tool mid-session.
    """

    def __init__(self, session: "ToolSession[dash.Dash]") -> None:
        self._session = session

    def __call__(
        self,
        environ: dict[str, Any],
        start_response: Callable[..., Any],
    ) -> Any:
        app = self._session.get()
        return app.server.wsgi_app(environ, start_response)


# Back-compat alias — the analysis sub-app reuses the same proxy machinery,
# so the historical ``_ViewerProxy`` name now points at the generic class.
_ViewerProxy = _SessionProxy
_AnalysisProxy = _SessionProxy


# ---------------------------------------------------------------------------
# Shell-only Dash builder (used as the dispatcher's default fallback app)
# ---------------------------------------------------------------------------

def _build_shell_dash_app(
    sandbox: SandboxRoot,
    *,
    url_prefix: str = MOUNT_HOME,
    viewer_session: "ToolSession[Any] | None" = None,
    viewer_state: "dict[str, Any] | None" = None,
    extra_release_sessions: "tuple[ToolSession[Any], ...] | None" = None,
) -> dash.Dash:
    """Build the shell's home Dash (chrome + home pane + Flask blueprints).

    The returned Dash app is the dispatcher's default fallback in the
    composed hub: any path that doesn't start with ``/builder``,
    ``/results``, or ``/run`` reaches its Flask server, which carries
    the ``/sandbox/api/*`` and ``/runs/*`` blueprints alongside the
    home Dash routes.

    Args:
        sandbox: Frozen-at-launch sandbox root.
        url_prefix: Mount prefix. Hub uses ``"/"`` (the shell IS the
            fallback, so it serves the root); standalone same.
        viewer_session: Optional :class:`ToolSession` for the viewer.
            The Phase 2 blueprints call ``viewer_session.touch()``
            on each request so iframe-driven dashboard polls keep
            the viewer alive even when no Dash callback fires.
    """
    assets_folder = str(Path(__file__).parent / "_assets")

    app = dash.Dash(
        __name__,
        external_stylesheets=[dbc.themes.BOOTSTRAP],
        suppress_callback_exceptions=True,
        assets_folder=assets_folder,
        title=TITLE_HUB,
        requests_pathname_prefix=url_prefix,
        routes_pathname_prefix=MOUNT_HOME,
    )
    app.layout = html.Div(build_home_layout(sandbox), className="shell-page")
    wrap_in_chrome(
        app,
        active_tab=SHELL_TAB_HOME,
        sandbox=sandbox,
        url_prefix=url_prefix,
    )

    register_sandbox_api(
        app.server,
        sandbox,
        viewer_session=viewer_session,
        viewer_state=viewer_state,
        extra_release_sessions=extra_release_sessions,
    )
    register_runs(app.server, sandbox, viewer_session=viewer_session)
    register_shared_static(app.server)
    return app


# ---------------------------------------------------------------------------
# Composer — the full hub
# ---------------------------------------------------------------------------

def compose_hub(
    sandbox: SandboxRoot,
    *,
    url_prefix: str = DEFAULT_URL_PREFIX,
    idle_release_seconds: float = DEFAULT_IDLE_RELEASE_SECONDS,
    start_idle_thread: bool = True,
    progress: Callable[[str], None] | None = None,
) -> tuple[dash.Dash, ToolSession[dash.Dash]]:
    """Build the shell + builder + viewer-session + run console; mount via DispatcherMiddleware.

    Returns:
        Tuple of ``(shell_app, viewer_session)``. The shell Dash's
        ``server.wsgi_app`` has been replaced with a
        :class:`DispatcherMiddleware` so HTTP requests through any of
        ``/``, ``/builder/...``, ``/results/...``, ``/run/...``,
        ``/sandbox/api/...``, ``/runs/...`` resolve correctly.

    Args:
        sandbox: Frozen-at-launch sandbox root.
        idle_release_seconds: How long the viewer session may go without
            a ``get`` or ``touch`` before the daemon releases it. The
            default (15 minutes) matches the spec.
        start_idle_thread: When ``True`` (default), spawn the daemon
            thread that releases idle sessions. Tests pass ``False``
            so they don't leak background threads.
        progress: Optional callback invoked with a short label before each
            eager sub-app is built (``"sub-app modules"``, ``"shell"``,
            ``"builder"``, …). The launcher passes
            :meth:`StartupReporter.detail` so the startup bar reflects which
            sub-app is currently being composed. ``None`` (default) is a
            no-op for non-interactive / test callers.
    """
    base_url_prefix = normalize_url_prefix(url_prefix)

    def _tick(label: str) -> None:
        if progress is not None:
            progress(label)

    # Local imports to keep boot-time cycles minimal.
    _tick("sub-app modules")
    from phenotypic.gui import (
        analysis,
        browse,
        builder,
        results_viewer,
        run_console,
        tune,
    )
    from phenotypic.gui.results_viewer._output_root import OutputRoot

    # Mutable handoff slot so the sidebar can hand a CLI output path to the
    # viewer without changing the ToolSession's build identity. The
    # ``/sandbox/api/viewer/output-root`` endpoint validates an incoming
    # path with ``OutputRoot.discover``, stamps the result here, and calls
    # ``viewer_session.release()``; the next GET to ``/results/`` rebuilds
    # the viewer with this OutputRoot.
    viewer_state: dict[str, "OutputRoot | None"] = {"output_root": None}

    # 1. Viewer session (lazy — heavy parquet load deferred to first GET).
    def _build_viewer() -> dash.Dash:
        viewer_app = results_viewer.create_app(
            output_root=viewer_state["output_root"],
            url_prefix=join_url_prefix(base_url_prefix, MOUNT_VIEWER),
            api_url_prefix=base_url_prefix,
        )
        wrap_in_chrome(
            viewer_app,
            active_tab=SHELL_TAB_VIEWER,
            sandbox=sandbox,
            url_prefix=base_url_prefix,
        )
        return viewer_app

    def _teardown_viewer(viewer_app: dash.Dash) -> None:
        # Intentionally a no-op: the released ``viewer_app`` is dropped
        # by ``ToolSession.release()`` and reaches GC once all in-flight
        # requests through ``_ViewerProxy`` finish. Eagerly popping
        # ``filtered_state`` / ``output_root`` from ``app.server.config``
        # would race in-flight callbacks reading those keys (a Phase-6
        # callback issuing ``current_app.config["filtered_state"]`` could
        # see ``KeyError``). Letting GC reclaim the heavy state via the
        # config-dict-of-the-released-app is one cycle slower but
        # race-free; Phase 6 can add an in-flight ref counter if eager
        # reclamation becomes a hard requirement.
        del viewer_app  # pragma: no cover - intentional no-op

    viewer_session: ToolSession[dash.Dash] = ToolSession(
        "viewer",
        build=_build_viewer,
        teardown=_teardown_viewer,
    )

    # 1b. Analysis session built up front so the bind endpoint in step 2
    #     can release it alongside the viewer when the sidebar hands off
    #     a CLI output. The session's build closure reads
    #     ``viewer_state["output_root"]`` lazily, so the order between
    #     this and the analysis layout factory doesn't matter.
    def _build_analysis() -> dash.Dash:
        analysis_app = analysis.create_app(
            output_root=viewer_state["output_root"],
            url_prefix=join_url_prefix(base_url_prefix, MOUNT_ANALYSIS),
            api_url_prefix=base_url_prefix,
        )
        wrap_in_chrome(
            analysis_app,
            active_tab=SHELL_TAB_ANALYSIS,
            sandbox=sandbox,
            url_prefix=base_url_prefix,
        )
        return analysis_app

    def _teardown_analysis(_app: dash.Dash) -> None:
        del _app  # pragma: no cover

    analysis_session: ToolSession[dash.Dash] = ToolSession(
        "analysis",
        build=_build_analysis,
        teardown=_teardown_analysis,
    )

    # 2. Shell Dash (registers the API + runs blueprints with the
    #    viewer-session touch hook + analysis-session release hook).
    _tick("shell")
    shell_app = _build_shell_dash_app(
        sandbox,
        url_prefix=base_url_prefix,
        viewer_session=viewer_session,
        viewer_state=viewer_state,
        extra_release_sessions=(analysis_session,),
    )

    # 3. Builder Dash (eager — single-process registry build).
    _tick("builder")
    builder_app = builder.create_app(
        image_root=sandbox.root,
        url_prefix=join_url_prefix(base_url_prefix, MOUNT_BUILDER),
    )
    wrap_in_chrome(
        builder_app,
        active_tab=SHELL_TAB_BUILDER,
        sandbox=sandbox,
        url_prefix=base_url_prefix,
    )

    # 4. Run console Dash (eager). Build the process-wide runner +
    #    registry HERE so the shell's ``/runs/`` blueprint, the Recent
    #    Runs panel, and the run-console callbacks all share the same
    #    state. Rehydrate the registry from disk so historical runs are
    #    visible immediately without waiting for a refresh.
    _tick("run console")
    from phenotypic.gui.run_console._runner import LocalRunner
    from phenotypic.gui.shell._runs_registry import RunRegistry

    registry = RunRegistry()
    registry.rehydrate_from_sandbox(sandbox)
    runner = LocalRunner()

    run_app = run_console.create_app(
        sandbox,
        url_prefix=join_url_prefix(base_url_prefix, MOUNT_RUN),
        server_url_prefix=base_url_prefix,
        registry=registry,
        runner=runner,
    )
    wrap_in_chrome(
        run_app,
        active_tab=SHELL_TAB_RUN,
        sandbox=sandbox,
        url_prefix=base_url_prefix,
    )

    # 4b. Tune co-pilot Dash (eager). Read-only and lightweight — no heavy
    #     parquet load, so no ToolSession is needed. Mounted empty-state: the
    #     user binds a tune run from the sidebar (Chunk C), at which point the
    #     page re-reads the bound run. The factory stays optuna-free; the live
    #     study is opened lazily inside the Monitor poll callback only.
    _tick("tune")
    tune_app = tune.create_app(
        root=None,
        url_prefix=join_url_prefix(base_url_prefix, MOUNT_TUNE),
        sandbox=sandbox,
        registry=registry,
        runner=runner,
    )
    wrap_in_chrome(
        tune_app,
        active_tab=SHELL_TAB_TUNE,
        sandbox=sandbox,
        url_prefix=base_url_prefix,
    )

    # 4c. Browse Dash (eager — lightweight source-image viewer). No
    #     ToolSession: it loads no heavy parquet, just lists files + serves
    #     ephemeral tiles.
    _tick("browse")
    browse_app = browse.create_app(
        sandbox, url_prefix=join_url_prefix(base_url_prefix, MOUNT_BROWSE)
    )
    wrap_in_chrome(
        browse_app,
        active_tab=SHELL_TAB_BROWSE,
        sandbox=sandbox,
        url_prefix=base_url_prefix,
    )

    # Stash on the shell server too so any future cross-tool callback
    # (e.g. the sidebar's "open in run console" hand-off) can reach the
    # same singletons.
    shell_app.server.config[CFG_RUNNER] = runner
    shell_app.server.config[CFG_RUN_REGISTRY] = registry

    # 5. Compose at the WSGI layer. The dispatcher receives the shell's
    #    Flask app as its default; any path not matching a mount prefix
    #    falls through to it (which carries the API + runs blueprints).
    viewer_proxy = _ViewerProxy(viewer_session)
    analysis_proxy = _AnalysisProxy(analysis_session)
    # ``wsgi_app`` is the standard Flask seam for WSGI middleware
    # injection (this is the same recipe Werkzeug docs recommend).
    # DispatcherMiddleware mount keys are prefixes WITHOUT the trailing "/"
    # (e.g. "/builder", not "/builder/"). Strip the trailing slash from
    # the MOUNT_* constants by index.
    shell_app.server.wsgi_app = DispatcherMiddleware(  # type: ignore[method-assign]
        shell_app.server.wsgi_app,
        {
            MOUNT_BUILDER.rstrip("/"): builder_app.server,
            MOUNT_VIEWER.rstrip("/"): viewer_proxy,
            MOUNT_RUN.rstrip("/"): run_app.server,
            MOUNT_TUNE.rstrip("/"): tune_app.server,
            MOUNT_ANALYSIS.rstrip("/"): analysis_proxy,
            MOUNT_BROWSE.rstrip("/"): browse_app.server,
        },
    )

    logger.info(
        "GUI hub composed: sandbox=%s mounts=%s, %s, %s, %s, %s, %s",
        sandbox.root,
        MOUNT_BUILDER,
        MOUNT_VIEWER,
        MOUNT_RUN,
        MOUNT_TUNE,
        MOUNT_ANALYSIS,
        MOUNT_BROWSE,
    )

    if start_idle_thread:
        start_idle_release_thread(
            [viewer_session, analysis_session],  # type: ignore[list-item]
            idle_release_seconds=idle_release_seconds,
        )

    return shell_app, viewer_session


[docs] def create_app( sandbox: SandboxRoot, *, url_prefix: str = MOUNT_HOME, viewer_session: "ToolSession[Any] | None" = None, idle_release_seconds: float = DEFAULT_IDLE_RELEASE_SECONDS, start_idle_thread: bool | None = None, progress: Callable[[str], None] | None = None, ) -> dash.Dash: """Build the unified GUI hub Dash app. Phase 5 default: composes the full hub (shell + builder + viewer via :class:`ToolSession` + run console) and returns the shell's Dash app with its ``server.wsgi_app`` replaced by a :class:`DispatcherMiddleware`. Tests can hit any HTTP route through ``app.server.test_client()`` (which goes through ``wsgi_app``). Backwards-compat for Phase 3 tests: passing ``viewer_session`` explicitly opts out of full composition — the call returns just the shell Dash app with the API + runs blueprints registered against the supplied session. Phase 3 lifecycle tests rely on this to inject a stub session and assert ``touch()`` is called. Args: sandbox: Frozen-at-launch sandbox root. url_prefix: Reserved for future composer nesting. Hub keeps it at ``"/"`` since the shell Dash is itself the dispatcher's fallback. Standalone shell launches accept any value. viewer_session: Phase 3 escape hatch — pass to test the shell in isolation against a specific session. When set, ``create_app`` returns just the shell Dash without composing the sub-apps. ``None`` (default) triggers the full hub composition. idle_release_seconds: Forwarded to :func:`compose_hub`. start_idle_thread: Forwarded to :func:`compose_hub`. When ``None`` (default) the daemon is started under production launch but skipped under pytest (``PYTEST_CURRENT_TEST`` in env). Tests that want the daemon explicitly should pass ``True``. progress: Optional per-sub-app progress callback forwarded to :func:`compose_hub` (the launcher passes :meth:`StartupReporter.detail`). Ignored on the Phase 3 ``viewer_session`` escape-hatch path. Returns: Configured :class:`dash.Dash` instance. ``app.run()`` (or Werkzeug ``run_simple``) starts the unified server. """ if viewer_session is not None: # Phase 3 backwards-compat: test path injects a stub session # and stops at the shell Dash (no sub-app composition). return _build_shell_dash_app( sandbox, url_prefix=url_prefix, viewer_session=viewer_session ) if start_idle_thread is None: # Don't leak daemon threads in pytest unless the test asks. import os start_idle_thread = "PYTEST_CURRENT_TEST" not in os.environ shell_app, _viewer_session = compose_hub( sandbox, url_prefix=url_prefix, idle_release_seconds=idle_release_seconds, start_idle_thread=start_idle_thread, progress=progress, ) return shell_app