gradio-forge / core /validator.py
Sokheng's picture
update final
344419c
Raw
History Blame Contribute Delete
8.03 kB
"""
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