Source code for qualpipe_webapp.frontend.page_config

"""Frontend page configuration loader and schema."""

from __future__ import annotations

import logging
from functools import lru_cache
from pathlib import Path
from typing import Annotated, Any, Literal

import yaml
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    TypeAdapter,
    ValidationError,
    model_validator,
)

logger = logging.getLogger(__name__)


[docs] class LayoutConfig(BaseModel): """Layout configuration for a page.""" rows: int = 1 cols: int = 1 max_per_row: int = 1
[docs] class PageConfig(BaseModel): """Configuration for a single page.""" name: str label: str plots: list[dict[str, Any]] = Field(default_factory=list) layout: LayoutConfig = Field( default_factory=lambda: LayoutConfig(rows=2, cols=2, max_per_row=2) ) css: list[str] = Field(default_factory=list) js: list[str] = Field(default_factory=list) template: str = "pages/array_element_type/generic_page.html" @model_validator(mode="after") def _validate_plots(self) -> PageConfig: if not self.plots: raise ValueError("Page config must define plots.") for plot in self.plots: title = plot.get("title") if not title: raise ValueError("Plot title is required for every plot entry.") return self
[docs] def plot_titles(self) -> list[str]: """Return plot titles in the order defined by the page config.""" return [plot.get("title") or "" for plot in self.plots]
[docs] def plot_keys(self) -> list[str]: """Return generated internal plot keys in the order defined by the page config.""" keys: list[str] = [] seen: dict[str, int] = {} for idx, plot in enumerate(self.plots): metric = plot.get("metric") plot_type = plot.get("plotType") base_seed = ( f"{metric}_{plot_type}" if metric and plot_type else plot.get("title") ) base = self._slugify(base_seed or f"plot_{idx + 1}") if not base: base = f"plot_{idx + 1}" count = seen.get(base, 0) + 1 seen[base] = count suffix = "" if count == 1 else f"_{count}" keys.append(f"{base}{suffix}") return keys
[docs] def plot_metrics(self) -> list[str]: """Return plot metric names in the order defined by the page config.""" return [plot.get("metric") or "" for plot in self.plots]
def _slugify(self, value: str) -> str: return "".join(ch if ch.isalnum() else "_" for ch in value.lower()).strip("_")
[docs] class AxisConfigBase(BaseModel): """Base axis configuration for plots.""" model_config = ConfigDict(extra="forbid", populate_by_name=True) label: str domain: list[float] | None = None unit: str | None = None field: str | None = None
[docs] class ScatterAxisConfig(AxisConfigBase): """Axis configuration for scatter plots.""" scale: Literal["linear", "log", "time", "ordinal", "band", "point"] | None = None
[docs] class HistogramAxisConfig(AxisConfigBase): """Axis configuration for histogram plots.""" scale: Literal["linear", "log"] | None = None
[docs] class CameraAxisConfig(AxisConfigBase): """Axis configuration for camera views.""" label: str | None = None scale: Literal["linear"] | None = None
[docs] class ScatterMarksConfig(BaseModel): """Marks configuration for scatter plots.""" model_config = ConfigDict(extra="forbid", populate_by_name=True) type: ( Literal[ "circle", "triangle", "cross", "wye", "star", "square", "diamond", ] | None ) = None fill: str | None = None stroke: str | None = None stroke_width: float | None = Field(default=None, alias="strokeWidth") opacity: float | None = None size: float | None = None
[docs] class HistogramBarConfig(BaseModel): """Bar style configuration for histogram plots.""" model_config = ConfigDict(extra="forbid", populate_by_name=True) fill: str | None = None stroke: str | None = None stroke_width: float | None = Field(default=None, alias="strokeWidth") opacity: float | None = None
[docs] class BasePlotConfig(BaseModel): """Shared configuration for plot types.""" model_config = ConfigDict(extra="forbid", populate_by_name=True) metric: str plot_type: str = Field(alias="plotType") title: str subtitle: str | None = None
[docs] class ScatterPlotConfig(BasePlotConfig): """Configuration for scatter plots.""" plot_type: Literal["scatterplot"] = Field(alias="plotType") x: ScatterAxisConfig y: ScatterAxisConfig marks: ScatterMarksConfig | None = None line: ( Literal[ "none", "solid", "dashed", "dotted", "dot-dashed", "dot-dashed-dashed", ] | None ) = None
[docs] class CameraViewConfig(BasePlotConfig): """Configuration for camera views.""" plot_type: Literal["cameraview"] = Field(alias="plotType") x: CameraAxisConfig | None = None y: CameraAxisConfig | None = None
[docs] class Histogram1DConfig(BasePlotConfig): """Configuration for 1D histogram plots.""" plot_type: Literal["histogram1d"] = Field(alias="plotType") x: HistogramAxisConfig y: HistogramAxisConfig bins: int | None = None marks: HistogramBarConfig | None = None
PlotConfig = Annotated[ ScatterPlotConfig | CameraViewConfig | Histogram1DConfig, Field(discriminator="plot_type"), ] PLOT_CONFIG_ADAPTER = TypeAdapter(PlotConfig)
[docs] class PageIndexEntry(BaseModel): """Reference to a page config file.""" name: str source: str
[docs] class BaseFrontendConfig(BaseModel): """Shared frontend configuration fields.""" array_element_types: list[str] = Field( default_factory=lambda: ["LSTs", "MSTs", "SSTs"] ) auxiliary_subitems: list[str] = Field( default_factory=lambda: ["Lidar", "FRAM", "Weather Station"] )
[docs] class FrontendIndexConfig(BaseFrontendConfig): """Index configuration listing available pages.""" pages: list[PageIndexEntry]
[docs] class UserIndexConfig(BaseModel): """Optional user overrides for pages and auxiliary subitems.""" pages: list[PageIndexEntry] | None = None auxiliary_subitems: list[str] | None = None
def _merge_page_entries( default_entries: list[PageIndexEntry], user_entries: list[PageIndexEntry] ) -> list[PageIndexEntry]: """Merge user page entries into defaults without allowing name collisions. User entries are appended preserving user order. If a user entry has the same name as a default entry, the user index is considered invalid. """ merged = list(default_entries) default_names = {entry.name for entry in default_entries} for user_entry in user_entries: if user_entry.name in default_names: raise ValueError( f"User page '{user_entry.name}' conflicts with an existing default page." ) merged.append(user_entry) return merged
[docs] class FrontendConfig(BaseFrontendConfig): """Resolved frontend configuration.""" pages: list[PageConfig] page_entries: list[PageIndexEntry] = Field(default_factory=list) page_errors: dict[str, str] = Field(default_factory=dict) user_index_error: str | None = None
[docs] def page_by_name(self, name: str) -> PageConfig | None: """Return a page configuration by name.""" return next((page for page in self.pages if page.name == name), None)
[docs] def page_error(self, name: str) -> str | None: """Return a page validation error by name, if any.""" return self.page_errors.get(name)
[docs] def page_label(self, name: str) -> str: """Return a label for a page, falling back to the name.""" page = self.page_by_name(name) if page: return page.label return name.replace("_", " ")
def _default_config_path() -> Path: return Path(__file__).resolve().parent / "view" / "pages" / "pages_index.yaml" def _user_index_path() -> Path: return ( Path(__file__).resolve().parent / "view" / "pages" / "user_pages" / "pages_index.yaml" ) def _load_page_config(config_path: Path) -> PageConfig: raw: Any = yaml.safe_load(config_path.read_text()) page = PageConfig.model_validate(raw) return page
[docs] @lru_cache(maxsize=1) def load_frontend_config(config_path: Path | None = None) -> FrontendConfig: """Load and validate the frontend configuration file.""" path = config_path or _default_config_path() try: raw: Any = yaml.safe_load(path.read_text()) except FileNotFoundError as exc: logger.error("Frontend config not found at %s", path) raise FileNotFoundError(f"Frontend config not found at {path}.") from exc index = FrontendIndexConfig.model_validate(raw) user_index_error: str | None = None user_index_path = _user_index_path() if user_index_path.exists(): try: user_raw: Any = yaml.safe_load(user_index_path.read_text()) user_index = UserIndexConfig.model_validate(user_raw) if user_index.pages is not None: index.pages = _merge_page_entries(index.pages, user_index.pages) if user_index.auxiliary_subitems is not None: index.auxiliary_subitems = user_index.auxiliary_subitems except Exception as exc: logger.error("Invalid user index config: %s", exc) user_index_error = str(exc) base_dir = path.parent pages: list[PageConfig] = [] page_errors: dict[str, str] = {} for entry in index.pages: page_path = (base_dir / entry.source).resolve() try: page = _load_page_config(page_path) if page.name != entry.name: raise ValueError( "Page name mismatch for " f"{entry.source}: expected {entry.name}, got {page.name}" ) pages.append(page) except Exception as exc: logger.error("Invalid page config for %s: %s", entry.name, exc) page_errors[entry.name] = str(exc) return FrontendConfig( array_element_types=index.array_element_types, auxiliary_subitems=index.auxiliary_subitems, pages=pages, page_entries=index.pages, page_errors=page_errors, user_index_error=user_index_error, )
[docs] def build_nav_pages(config: FrontendConfig) -> list[dict[str, str]]: """Build navigation entries from config pages.""" if config.page_entries: return [ {"name": entry.name, "label": config.page_label(entry.name)} for entry in config.page_entries ] return [{"name": page.name, "label": page.label} for page in config.pages]
[docs] def build_plot_state( page: PageConfig, ) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: """Build plot configurations and collect validation errors per plot.""" plot_configs: dict[str, dict[str, Any]] = {} plot_errors: dict[str, str] = {} for plot_key, plot in zip(page.plot_keys(), page.plots): if not plot.get("plotType"): # Title-only placeholder plots are allowed without config validation. continue try: validated = PLOT_CONFIG_ADAPTER.validate_python(plot) except ValidationError as exc: logger.error("Invalid plot configuration for %s: %s", plot_key, exc) plot_errors[plot_key] = str(exc) continue plot_configs[plot_key] = validated.model_dump(exclude_none=True, by_alias=True) return plot_configs, plot_errors