"""Strict profile decoding driven by the typed field declarations.""" from __future__ import annotations from dataclasses import asdict, fields, is_dataclass from typing import Any from python_app.models.gui_profile_schema import GuiProfileModel, GuiStateModel, Setting from python_app.models.json_fields import finite_number, json_object from python_app.models.run_config_schema import RunConfigModel def _decode_state(model: object, payload: dict[str, Any], context: str, *, check_constraints: bool = True) -> None: """Read known fields recursively; unknown fields do not survive re-encoding.""" if not is_dataclass(model) or isinstance(model, type): raise TypeError("GUI state must be a dataclass instance") for member in fields(model): default = getattr(model, member.name) value = payload.get(member.name, default) name = f"{context}.{member.name}" if is_dataclass(default): _decode_state(default, json_object(payload.get(member.name), name), name, check_constraints=check_constraints) continue if isinstance(default, float): value = finite_number(value, name) elif type(value) is not type(default): raise ValueError(f"{name} must be a JSON {type(default).__name__}") spec = member.metadata.get("setting") if check_constraints and isinstance(spec, Setting): if spec.choices and value not in spec.choices: raise ValueError(f"{name} must be one of: {', '.join(spec.choices)}") if spec.minimum is not None: if value < spec.minimum or (spec.exclusive_minimum and value == spec.minimum): operator = ">" if spec.exclusive_minimum else ">=" raise ValueError(f"{name} must be {operator} {spec.minimum:g}") if spec.maximum is not None and value > spec.maximum: raise ValueError(f"{name} must be <= {spec.maximum:g}") setattr(model, member.name, value) def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: """Read the pipeline config and optional GUI state, preserving old GPR profiles.""" profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload)) if payload.get("gui") is None: return profile gui_object = json_object(payload["gui"], "gui") processing = json_object(gui_object.get("processing"), "gui.processing") old_gpr = json_object(processing.get("gpr"), "gui.processing.gpr") gui = GuiStateModel() # Older profiles kept both algorithms under gpr. Root mode and the short-lived # algorithm selector choose the legacy page; explicit legacy values win. root_gpr = payload.get("gpr") root_mode = root_gpr.get("mode") if isinstance(root_gpr, dict) else None algorithm_mode = {"legacy_point": "point", "legacy_extended": "extended"}.get( str(old_gpr.get("algorithm", ""))) if root_mode in ("point", "extended"): gui.processing.legacy_gpr.mode = root_mode if algorithm_mode is not None: gui.processing.legacy_gpr.mode = algorithm_mode _decode_state(gui.processing.legacy_gpr, old_gpr, "gui.processing.gpr", check_constraints=False) _decode_state(gui, gui_object, "gui") if gui.version != 1: raise ValueError(f"Unsupported gui.version: {gui.version}") if gui.processing.selected_mode == "gpr" and ( algorithm_mode is not None or root_mode in ("point", "extended") ): gui.processing.selected_mode = "legacy_gpr" gui.preprocess_dialog.median_sweep_count = max(1, gui.preprocess_dialog.median_sweep_count) profile.gui = gui return profile def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: """Encode the canonical run config and every declared GUI field.""" payload = model.run_config.to_dict() payload["gui"] = asdict(model.gui if model.gui is not None else GuiStateModel()) return payload