Spaces:
Sleeping
Sleeping
| """ | |
| core/validator.py | |
| Two validators: | |
| validate(spec) — legacy single-component spec (unchanged) | |
| validate_page_spec(spec) — new page spec | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| logger = logging.getLogger(__name__) | |
| SUPPORTED_COMPONENTS = { | |
| "Slider", "Textbox", "Image", "Dropdown", "Checkbox", | |
| "Button", "Audio", "File", "Plot", "Dataframe", "Number", | |
| "HTML", "Radio", | |
| } | |
| FALLBACK_SPEC = { | |
| "component": "Textbox", | |
| "props": {"label": "Output", "placeholder": "Result will appear here"}, | |
| "layout": "vertical", | |
| "connected_output": None, | |
| } | |
| ALLOWED_PROPS: dict[str, set[str]] = { | |
| "Slider": {"label", "minimum", "maximum", "step", "value", "info"}, | |
| "Textbox": {"label", "placeholder", "value", "lines", "max_lines", "info"}, | |
| "Image": {"label", "type", "height", "width", "info"}, | |
| "Dropdown": {"label", "choices", "value", "multiselect", "info"}, | |
| "Checkbox": {"label", "value", "info"}, | |
| "Button": {"value", "variant", "size", "icon"}, | |
| "Audio": {"label", "type", "format", "info"}, | |
| "File": {"label", "file_count", "file_types", "info"}, | |
| "Plot": {"label", "format"}, | |
| "Dataframe": {"label", "headers", "datatype", "row_count", "col_count", "info"}, | |
| "Number": {"label", "value", "minimum", "maximum", "step", "info"}, | |
| "HTML": {"html_template", "css"}, | |
| "Radio": {"label", "choices", "value", "info"}, | |
| } | |
| _CSS_FORBIDDEN = re.compile(r"url\s*\(|@import|fetch\s*\(|<script", re.IGNORECASE) | |
| def validate(spec: dict) -> tuple[bool, dict]: | |
| """Validate a single-component spec dict. Returns (ok, validated_spec_or_fallback).""" | |
| if not isinstance(spec, dict): | |
| logger.warning("spec is not a dict — using fallback") | |
| return False, FALLBACK_SPEC.copy() | |
| component = spec.get("component", "") | |
| if component not in SUPPORTED_COMPONENTS: | |
| logger.warning("Unknown component %r — using fallback", component) | |
| return False, FALLBACK_SPEC.copy() | |
| if "props" not in spec or not isinstance(spec.get("props"), dict): | |
| logger.warning("Missing or invalid props for %r — using fallback", component) | |
| return False, FALLBACK_SPEC.copy() | |
| allowed = ALLOWED_PROPS.get(component, set()) | |
| filtered = {k: v for k, v in spec["props"].items() if k in allowed} | |
| clean_spec = { | |
| "component": component, | |
| "props": filtered, | |
| "layout": spec.get("layout", "vertical"), | |
| "connected_output": spec.get("connected_output", None), | |
| } | |
| return True, clean_spec | |
| _VALID_LAYOUTS = {"centered", "sidebar", "dashboard", "split", "fullpage", "tabbed"} | |
| _VALID_SECTION_TYPES = {"hero", "form", "result", "chart", "table", "gallery", | |
| "nav", "footer", "custom"} | |
| _VALID_ROLES = {"input", "output", "control", "display"} | |
| _VALID_ACTIONS = {"new", "edit", "add", "remove", "replace", "wire", "restyle"} | |
| def validate_page_spec(spec: dict) -> tuple[bool, list[str]]: | |
| """ | |
| Validate a page spec. | |
| Returns (ok, errors) where errors is a list of human-readable strings. | |
| ok is True only when errors is empty. | |
| Checks: | |
| - page.sections is a non-empty list | |
| - every section has id, type, components list | |
| - every component has id, component, role | |
| - no duplicate ids across all sections and all components | |
| - every glue_fn trigger references a real section+component id | |
| - every glue_fn output references a real section+component id | |
| - html_shell is non-empty for non-custom sections | |
| - CSS fields do not contain url(), @import, fetch(), or <script | |
| """ | |
| errors: list[str] = [] | |
| page = spec.get("page", spec) | |
| if not isinstance(page, dict): | |
| return False, ["page is not a dict"] | |
| # layout | |
| layout = page.get("layout", "centered") | |
| if layout not in _VALID_LAYOUTS: | |
| errors.append(f"Unknown layout {layout!r}. Valid: {sorted(_VALID_LAYOUTS)}") | |
| # sections | |
| sections = page.get("sections", []) | |
| if not sections: | |
| errors.append("page.sections is empty — at least one section required") | |
| return False, errors | |
| all_section_ids: set[str] = set() | |
| all_component_ids: set[str] = set() | |
| for sec in sections: | |
| sec_id = sec.get("id", "") | |
| sec_type = sec.get("type", "") | |
| if not sec_id: | |
| errors.append("A section is missing its 'id' field") | |
| elif sec_id in all_section_ids: | |
| errors.append(f"Duplicate section id: {sec_id!r}") | |
| else: | |
| all_section_ids.add(sec_id) | |
| if sec_type not in _VALID_SECTION_TYPES: | |
| errors.append(f"Section {sec_id!r} has unknown type {sec_type!r}") | |
| shell = sec.get("html_shell", "") | |
| if sec_type != "custom" and not shell.strip(): | |
| errors.append(f"Section {sec_id!r} (type={sec_type!r}) has empty html_shell") | |
| sec_css = sec.get("css", "") | |
| if sec_css and _CSS_FORBIDDEN.search(sec_css): | |
| errors.append(f"Section {sec_id!r} CSS contains forbidden pattern") | |
| components = sec.get("components", []) | |
| if not isinstance(components, list): | |
| errors.append(f"Section {sec_id!r} components is not a list") | |
| continue | |
| for comp in components: | |
| comp_id = comp.get("id", "") | |
| comp_type = comp.get("component", "") | |
| role = comp.get("role", "") | |
| if not comp_id: | |
| errors.append(f"A component in section {sec_id!r} is missing 'id'") | |
| elif comp_id in all_component_ids: | |
| errors.append(f"Duplicate component id: {comp_id!r}") | |
| else: | |
| all_component_ids.add(comp_id) | |
| if comp_type not in SUPPORTED_COMPONENTS: | |
| errors.append( | |
| f"Component {comp_id!r} in section {sec_id!r} has " | |
| f"unsupported type {comp_type!r}" | |
| ) | |
| if role not in _VALID_ROLES: | |
| errors.append( | |
| f"Component {comp_id!r} has invalid role {role!r}. " | |
| f"Valid: {sorted(_VALID_ROLES)}" | |
| ) | |
| # global CSS | |
| global_css = page.get("global_css", "") | |
| if global_css and _CSS_FORBIDDEN.search(global_css): | |
| errors.append("global_css contains forbidden pattern (url/import/fetch/script)") | |
| # glue_fns | |
| glue_fns = page.get("glue_fns", []) | |
| if not isinstance(glue_fns, list): | |
| errors.append("page.glue_fns is not a list") | |
| glue_fns = [] | |
| for i, fn in enumerate(glue_fns): | |
| if not isinstance(fn, dict): | |
| errors.append(f"glue_fns[{i}] is not a dict (got {type(fn).__name__!r}) — skipping") | |
| continue | |
| fn_id = fn.get("id", "<unknown>") | |
| trigger = fn.get("trigger", {}) | |
| if not isinstance(trigger, dict): | |
| errors.append(f"glue_fn {fn_id!r} trigger is not a dict") | |
| continue | |
| t_sec = trigger.get("section", "") | |
| t_comp = trigger.get("component", "") | |
| if t_sec not in all_section_ids: | |
| errors.append(f"glue_fn {fn_id!r} trigger section {t_sec!r} not found") | |
| if t_comp not in all_component_ids: | |
| errors.append(f"glue_fn {fn_id!r} trigger component {t_comp!r} not found") | |
| for ref in fn.get("inputs", []) + fn.get("outputs", []): | |
| if not isinstance(ref, dict): | |
| continue | |
| ref_sec = ref.get("section", "") | |
| ref_comp = ref.get("component", "") | |
| if ref_sec and ref_sec not in all_section_ids: | |
| errors.append( | |
| f"glue_fn {fn_id!r} references unknown section {ref_sec!r}" | |
| ) | |
| if ref_comp and ref_comp not in all_component_ids: | |
| errors.append( | |
| f"glue_fn {fn_id!r} references unknown component {ref_comp!r}" | |
| ) | |
| return len(errors) == 0, errors | |