Source code for phenotypic.gui.analysis._app

"""Dash app factory for the analysis sub-app.

Mirrors the pattern in
:mod:`phenotypic.gui.results_viewer._app`: builds a configured
:class:`dash.Dash` instance with the validated
:class:`~phenotypic.gui.results_viewer._output_root.OutputRoot` plus the
loaded :class:`~phenotypic.gui.analysis._recipe_state.RecipeState`
stashed on ``app.server.config``, the layout assembled by
:func:`._layout.build_app_layout` (or
:func:`._layout.build_empty_state_layout` when no output root is bound),
and all callbacks registered via :func:`._callbacks.register_callbacks`.
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Optional

import dash
import dash_bootstrap_components as dbc  # type: ignore[import-untyped]

from phenotypic.gui._config import (
    CFG_MEASUREMENT_SCHEMA,
    CFG_OUTPUT_ROOT,
    CFG_RECIPE_STATE,
    CFG_URL_PREFIX,
    DEFAULT_URL_PREFIX,
    MOUNT_HOME,
    SANDBOX_API_VIEWER_OUTPUT_ROOT,
    TITLE_ANALYSIS,
    join_url_prefix,
)
from dash import Input, Output, State

from phenotypic.gui._design import (
    COLOR_BLUE,
    COLOR_SURFACE,
    RADIUS,
    inject_design_tokens,
)
from phenotypic.gui._shared import register_shared_static
from phenotypic.gui.analysis import _ids as analysis_ids
from phenotypic.gui.analysis._callbacks import register_callbacks
from phenotypic.gui.analysis._layout import (
    build_app_layout,
    build_empty_state_layout,
)
from phenotypic.gui.analysis._recipe_state import RecipeState
from phenotypic.gui._schema_cache import MeasurementSchema
from phenotypic.gui.results_viewer._output_root import OutputRoot
from phenotypic.gui.shell._ids import SHELL_SIDEBAR_SELECTION_STORE

logger = logging.getLogger(__name__)


[docs] def create_app( *, output_root: Optional[OutputRoot] = None, url_prefix: str = MOUNT_HOME, api_url_prefix: str = DEFAULT_URL_PREFIX, ) -> dash.Dash: """Build a configured Dash instance for the analysis sub-app. Args: output_root: Validated CLI output root; when ``None`` (e.g. the hub mounted ``/analysis/`` before the user picked an output directory), the factory returns a Dash whose layout is the empty-state placeholder and which has no callbacks registered. url_prefix: Mount-point prefix passed to ``dash.Dash`` as both ``requests_pathname_prefix`` and ``routes_pathname_prefix``. Standalone launches collapse to ``MOUNT_HOME`` ("/"); :func:`compose_hub` passes ``MOUNT_ANALYSIS``. api_url_prefix: Browser-visible base prefix for shell-level Flask APIs. Defaults to ``"/"``; the hub passes the external proxy prefix when configured. Returns: Configured :class:`dash.Dash` instance. """ assets_folder = str(Path(__file__).parent / "_assets") # ``DispatcherMiddleware`` strips the mount prefix before forwarding, so # the Dash internal routes must answer at ``/``. ``requests_pathname_prefix`` # is what Dash uses to build client-facing URLs (so it gets the mount # prefix); ``routes_pathname_prefix`` is what Dash listens on (always # ``/`` so the dispatcher's stripped path matches). Standalone launches # collapse to identical prefixes when ``url_prefix == MOUNT_HOME``. app = dash.Dash( __name__, external_stylesheets=[dbc.themes.BOOTSTRAP], suppress_callback_exceptions=True, assets_folder=assets_folder, title=TITLE_ANALYSIS, requests_pathname_prefix=url_prefix, routes_pathname_prefix=MOUNT_HOME, ) inject_design_tokens(app) register_shared_static(app.server) app.server.config[CFG_URL_PREFIX] = url_prefix if output_root is None: app.layout = build_empty_state_layout() _register_empty_state_callbacks( app, url_prefix=url_prefix, api_url_prefix=api_url_prefix, ) return app recipe = RecipeState.load(Path(output_root.root)) schema = MeasurementSchema(output_root=Path(output_root.root)) app.server.config[CFG_OUTPUT_ROOT] = output_root app.server.config[CFG_RECIPE_STATE] = recipe app.server.config[CFG_MEASUREMENT_SCHEMA] = schema app.layout = build_app_layout( output_root, recipe, url_prefix=url_prefix, columns_provider=schema.columns_for, ) register_callbacks(app) logger.info( "Analysis sub-app ready: output_root=%s pipeline=%s", output_root.root, recipe.pipeline.name, ) return app
def _register_empty_state_callbacks( app: dash.Dash, *, url_prefix: str, api_url_prefix: str, ) -> None: """Wire the hand-off banner: selection store -> banner; click -> bind. Mirrors the results-viewer empty-state pattern. The clientside callback POSTs to the shared ``/sandbox/api/viewer/output-root`` endpoint, which releases both viewer + analysis ToolSessions and rebuilds them against the new ``viewer_state["output_root"]``. On success the page navigates to ``url_prefix`` so the dispatcher proxy resolves a freshly-built loaded analysis app. """ @app.callback( Output(analysis_ids.EMPTY_HANDOFF_BANNER, "style"), Output(analysis_ids.EMPTY_HANDOFF_LABEL, "children"), Output(analysis_ids.EMPTY_HANDOFF_OPEN_BUTTON, "disabled"), Input(SHELL_SIDEBAR_SELECTION_STORE, "data"), ) def _populate_handoff_banner(selection): hidden = {"display": "none"} visible = { "display": "flex", "alignItems": "center", "gap": "0.5rem", "marginTop": "1rem", "padding": "0.5rem 0.75rem", "background": COLOR_SURFACE, "border": f"1px solid {COLOR_BLUE}", "borderRadius": RADIUS, } if not selection or not isinstance(selection, dict): return hidden, "(none)", True path = selection.get("path") or "" if not path: return hidden, "(none)", True caps = selection.get("capabilities") or {} is_cli_output = bool(caps.get("is_cli_output")) return visible, path, not is_cli_output api_output_root = join_url_prefix(api_url_prefix, SANDBOX_API_VIEWER_OUTPUT_ROOT) app.clientside_callback( """ async function(n_clicks, selection) { if (!n_clicks || !selection) { return window.dash_clientside.no_update; } const path = selection.path; if (!path) { return "No sidebar selection."; } try { const resp = await fetch( "__PHENO_API_OUTPUT_ROOT__", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({path: path}), } ); const data = await resp.json().catch(() => ({})); if (!resp.ok) { return (data && data.error) || ("HTTP " + resp.status); } window.location.assign(__PHENO_ANALYSIS_PREFIX__); return ""; } catch (err) { return String(err); } } """.replace("__PHENO_API_OUTPUT_ROOT__", api_output_root).replace("__PHENO_ANALYSIS_PREFIX__", repr(url_prefix)), Output(analysis_ids.EMPTY_HANDOFF_ERROR, "children"), Input(analysis_ids.EMPTY_HANDOFF_OPEN_BUTTON, "n_clicks"), State(SHELL_SIDEBAR_SELECTION_STORE, "data"), prevent_initial_call=True, ) __all__ = ["create_app"]