"""Dash app factory for the results viewer.
Builds a configured :class:`dash.Dash` instance with its tile-serving
and colony-crop Flask blueprints mounted, the validated
:class:`~phenotypic.gui.results_viewer._output_root.OutputRoot` plus the
curation-state
:class:`~phenotypic.gui.results_viewer._curation_labels.CurationLabels`
stashed on ``app.server.config``, the layout assembled by
:func:`~phenotypic.gui.results_viewer._layout.build_app_layout`, and
all callbacks registered via
:func:`~phenotypic.gui.results_viewer._callbacks.register_callbacks`.
Phase 5 additions:
* Optional ``output_root`` — when ``None`` the factory returns a
Dash app whose layout is :func:`._layout.build_empty_state_layout`
and which has NO blueprints, NO callbacks, and NO ``filtered_state``
on ``app.server.config``. The hub uses this path to reach the
``/results/`` page before the user has selected a CLI output.
* ``url_prefix`` — Mount-point prefix passed to ``dash.Dash`` as
both ``requests_pathname_prefix`` and ``routes_pathname_prefix``,
and stashed on ``app.server.config["pheno_url_prefix"]`` so
callbacks (notably the colony-grid crop URLs) can construct
hub-aware URLs at request time.
* ``window.__phenotypicAppPrefix`` — injected via
``app.index_string`` so ``results_viewer.js`` can build DZI tile
URLs that include the mount prefix.
"""
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 dash import Input, Output, State
from phenotypic.gui._config import (
CFG_FILTERED_STATE,
CFG_MEASUREMENT_SCHEMA,
CFG_OUTPUT_ROOT,
CFG_QC_AUGMENTED_FRAME,
CFG_QC_INSTANCES_CACHE,
CFG_QC_PIPELINE,
CFG_QC_RECIPE,
CFG_URL_PREFIX,
DEFAULT_URL_PREFIX,
MOUNT_HOME,
QC_CROPS_URL_SEGMENT,
SANDBOX_API_VIEWER_OUTPUT_ROOT,
TITLE_VIEWER,
join_url_prefix,
)
from phenotypic.gui._schema_cache import MeasurementSchema
from phenotypic.gui._design import COLOR_BLUE, COLOR_SURFACE, inject_design_tokens
from phenotypic.gui._shared import register_shared_static
from phenotypic.gui._shared.tiles import register_crop_route
from phenotypic.gui.results_viewer import _ids as ids, _tile_routes
from phenotypic.gui.results_viewer._callbacks import register_callbacks
from phenotypic.gui.results_viewer._curation_labels import CurationLabels
from phenotypic.gui.results_viewer._layout import (
build_app_layout,
build_empty_state_layout,
)
from phenotypic.gui.results_viewer._output_root import OutputRoot
from phenotypic.gui.results_viewer.colony_view import _crop_routes as colony_crop_routes
from phenotypic.gui.shell._ids import SHELL_SIDEBAR_SELECTION_STORE
from phenotypic.sdk_._qc_recipe import QcRecipe
logger = logging.getLogger(__name__)
def _index_string_with_prefix(url_prefix: str) -> str:
"""Return a Dash ``index_string`` template that injects the URL prefix.
The injected ``<script>`` defines ``window.__phenotypicAppPrefix`` so
``results_viewer.js`` can build hub-aware URLs for DZI tiles and the
vendored OpenSeadragon assets.
``url_prefix`` is escaped before embedding inside the JS string
literal:
* ``\\`` -> ``\\\\`` and ``"`` -> ``\\"`` keep the JS string
well-formed.
* ``</`` -> ``<\\/`` prevents a stray ``</script>`` from
terminating the inline script tag (forward-slash escapes are
legal in JS string literals).
In practice only the hub composer sets the prefix and the values are
static literals (``"/"``, ``"/results/"``); the escaping is defence
in depth against future callers passing user-controlled values.
"""
safe_prefix = (
url_prefix.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("</", "<\\/")
)
return (
"<!DOCTYPE html>\n"
"<html>\n"
" <head>\n"
" {%metas%}\n"
" <title>{%title%}</title>\n"
" {%favicon%}\n"
" {%css%}\n"
f' <script>window.__phenotypicAppPrefix = "{safe_prefix}";</script>\n'
" </head>\n"
" <body>\n"
" {%app_entry%}\n"
" <footer>\n"
" {%config%}\n"
" {%scripts%}\n"
" {%renderer%}\n"
" </footer>\n"
" </body>\n"
"</html>"
)
[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 Dash application instance for the results viewer.
Args:
output_root: Validated, read-only handle on a CLI output
directory (see
:meth:`phenotypic.gui.results_viewer._output_root.OutputRoot.discover`).
``None`` triggers the empty-state pathway: the factory skips
blueprint registration, ``FilteredMeasurements.load``, and
callback registration; ``app.layout`` is the empty-state
placeholder.
url_prefix: Mount-point prefix. Defaults to ``"/"`` (standalone
launcher); the hub composer passes ``"/results/"``. Set as
``requests_pathname_prefix``/``routes_pathname_prefix`` on
the Dash constructor and stashed on
``app.server.config["pheno_url_prefix"]``.
api_url_prefix: Browser-visible base prefix for shell-level
Flask APIs. Defaults to ``"/"``; the hub passes the external
proxy prefix when configured.
Returns:
A configured :class:`dash.Dash` instance whose ``app.run(...)``
is the responsibility of the caller.
"""
app = dash.Dash(
__name__,
external_stylesheets=[dbc.themes.BOOTSTRAP],
suppress_callback_exceptions=True,
title=TITLE_VIEWER,
# Pin to the in-package directory so the assets ship correctly
# regardless of the user's CWD at launch.
assets_folder=str(Path(__file__).parent / "_assets"),
# See builder/_app.py for the rationale: when mounted under
# the hub's DispatcherMiddleware the dispatcher strips the
# mount prefix from PATH_INFO, so Dash must route at "/".
requests_pathname_prefix=url_prefix,
routes_pathname_prefix=MOUNT_HOME,
)
# Inject window.__phenotypicAppPrefix so results_viewer.js can build
# mount-aware URLs for DZI tiles and OSD assets.
app.index_string = _index_string_with_prefix(url_prefix)
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,
)
logger.debug(
"Results viewer built in empty-state mode (url_prefix=%s)",
url_prefix,
)
return app
app.server.config[CFG_OUTPUT_ROOT] = output_root
_tile_routes.register(app, output_root)
filtered_state = CurationLabels.load(output_root.root, output_root.master_df)
app.server.config[CFG_FILTERED_STATE] = filtered_state
colony_crop_routes.register(app, output_root)
# QC Review tab serves the same centered crops under its own segment
# so the colony-view ``/crops`` and the Review gallery never collide.
register_crop_route(app, output_root, QC_CROPS_URL_SEGMENT)
# MeasurementSchema cache shared by the Heatmap tab (and a future
# QC tab) - lazily built once per app instance. Idempotent: do not
# clobber an existing instance e.g. when the analysis sub-app has
# already populated the key.
if app.server.config.get(CFG_MEASUREMENT_SCHEMA) is None:
app.server.config[CFG_MEASUREMENT_SCHEMA] = MeasurementSchema(
output_root=Path(output_root.root)
)
# QC tab's augmented-frame cache starts empty; Wave E's QC writer
# fills it on its first card refresh. The heatmap render callback
# gracefully falls back to the plain filtered frame until then.
app.server.config.setdefault(CFG_QC_AUGMENTED_FRAME, None)
# QC recipe is now the ``qc`` section of ``pipeline.json`` (pipeline-
# backed adapter), not the legacy ``.viewer_cache/qc_recipe.json``
# sidecar. Fold any legacy sidecar into the pipeline exactly once, then
# load the recipe + the full pipeline (for the Review tab's in-session
# recompute via ``run_qc``). The per-revision instance cache is keyed
# on the recipe revision counter so a stale entry can never serve a
# moved configuration.
QcRecipe.migrate_from_sidecar(Path(output_root.root))
app.server.config[CFG_QC_RECIPE] = QcRecipe.load(Path(output_root.root))
app.server.config[CFG_QC_PIPELINE] = _load_qc_pipeline(Path(output_root.root))
app.server.config.setdefault(CFG_QC_INSTANCES_CACHE, {})
app.layout = build_app_layout(output_root, filtered_state, url_prefix=url_prefix)
register_callbacks(app, output_root)
return app
def _load_qc_pipeline(output_root_path: Path):
"""Deserialize the output root's ``pipeline.json`` for QC recompute.
The QC Review tab's per-group recompute hands this pipeline to
:func:`phenotypic.sdk_._qc_recipe._runner.run_qc`, so it must carry the same ``qc``
entries the CLI persisted. Loaded tolerantly (``skip_unknown_analyzers``)
so a stale analyzer class never blocks viewer boot, and degrades to
``None`` when the file is absent or unreadable — recompute then no-ops
rather than raising.
Args:
output_root_path: The results-viewer output root.
Returns:
The deserialized ``ImagePipeline``, or ``None`` when no usable
``pipeline.json`` exists.
"""
from phenotypic._core._image_pipeline import ImagePipeline
from phenotypic.sdk_ import resolve_pipeline_config_path
pipeline_path = resolve_pipeline_config_path(output_root_path)
if not pipeline_path.exists():
return None
try:
return ImagePipeline.from_json(
pipeline_path, skip_unknown_analyzers=True, load_warnings=[]
)
except Exception: # noqa: BLE001 - boot-time tolerance; recompute no-ops
logger.warning(
"Could not load pipeline.json at %s for QC recompute; the Review "
"tab's per-group recompute will be unavailable this session.",
pipeline_path,
exc_info=True,
)
return None
def _register_empty_state_callbacks(
app: dash.Dash,
*,
url_prefix: str,
api_url_prefix: str,
) -> None:
"""Wire the empty-state hand-off banner.
Two callbacks:
1. **Mirror selection -> banner.** A serverside callback watches
:data:`SHELL_SIDEBAR_SELECTION_STORE` (mounted on this app's
chrome wrapper) and toggles the banner's visibility, label, and
Open-button ``disabled`` flag based on whether the selection
has the ``is_cli_output`` capability.
2. **Open button -> POST + redirect.** A clientside callback fetches
``/sandbox/api/viewer/output-root`` with the selection's
``abs_path`` (or rel ``path`` as fallback). On success the
browser navigates to ``url_prefix`` so :class:`_ViewerProxy`
resolves a freshly-built loaded viewer; on 4xx the JSON
``error`` is rendered into the inline error slot.
"""
@app.callback(
Output(ids.EMPTY_HANDOFF_BANNER, "style"),
Output(ids.EMPTY_HANDOFF_LABEL, "children"),
Output(ids.EMPTY_HANDOFF_OPEN_BUTTON, "disabled"),
Input(SHELL_SIDEBAR_SELECTION_STORE, "data"),
)
def _populate_handoff_banner(
selection: "dict | None",
) -> "tuple":
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": "6px",
}
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
# Clientside POST + navigate. Uses ``window.fetch`` with the prefix
# so it works under any DispatcherMiddleware mount. On success the
# callback calls ``window.location.assign(prefix)`` directly (forces
# a full reload even though the URL is unchanged), which is what
# ``_ViewerProxy`` needs to resolve the freshly-built session. On
# 4xx the JSON ``error`` is rendered into the inline error slot.
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_VIEWER_PREFIX__);
return "";
} catch (err) {
return String(err);
}
}
""".replace("__PHENO_API_OUTPUT_ROOT__", api_output_root).replace("__PHENO_VIEWER_PREFIX__", repr(url_prefix)),
Output(ids.EMPTY_HANDOFF_ERROR, "children"),
Input(ids.EMPTY_HANDOFF_OPEN_BUTTON, "n_clicks"),
State(SHELL_SIDEBAR_SELECTION_STORE, "data"),
prevent_initial_call=True,
)
__all__ = ["create_app"]