You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Home-AssistantConfig/codex_skills/homeassistant-dashboard-des.../scripts/validate_lovelace_view.py

380 lines
12 KiB

#!/usr/bin/env python
"""
Lightweight validator for Lovelace view YAML files.
Goal: catch common violations of the homeassistant-dashboard-designer constraints
before Home Assistant reloads.
This is NOT a full Lovelace schema validator.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
class _Tagged(str):
pass
class _Loader(yaml.SafeLoader):
pass
def _construct_undefined(loader: _Loader, node: yaml.Node) -> Any:
# Preserve unknown tags (e.g., !include, !include_dir_list) as opaque strings.
if isinstance(node, yaml.ScalarNode):
return _Tagged(f"{node.tag} {node.value}")
if isinstance(node, yaml.SequenceNode):
seq = loader.construct_sequence(node)
return _Tagged(f"{node.tag} {seq!r}")
if isinstance(node, yaml.MappingNode):
mapping = loader.construct_mapping(node)
return _Tagged(f"{node.tag} {mapping!r}")
return _Tagged(f"{node.tag}")
_Loader.add_constructor(None, _construct_undefined) # type: ignore[arg-type]
@dataclass(frozen=True)
class Finding:
level: str # "ERROR" | "WARN"
path: str
message: str
ALLOWED_LAYOUT = {"grid", "vertical-stack"}
DISALLOWED_CARD_TYPES = {"horizontal-stack"}
# Cards allowed without justification (design-system tiering is enforced in SKILL.md; this is a lint).
ALLOWED_CARD_TYPES = {
"grid",
"vertical-stack",
"custom:button-card",
"custom:layout-card",
"custom:vertical-stack-in-card",
"custom:flex-horseshoe-card",
"custom:mini-graph-card",
# Tier-2 fallbacks (validator does not enforce justification comments).
"entities",
"markdown",
"history-graph",
"conditional",
"iframe",
}
def _is_mapping(v: Any) -> bool:
return isinstance(v, dict)
def _is_sequence(v: Any) -> bool:
return isinstance(v, list)
def _card_type(card: dict[str, Any]) -> str | None:
t = card.get("type")
if isinstance(t, str):
return t
return None
def _validate_layout_depth(
*,
node: Any,
cur_depth: int,
max_depth: int,
node_path: str,
findings: list[Finding],
) -> None:
if not _is_mapping(node):
return
ctype = _card_type(node)
if ctype in ALLOWED_LAYOUT:
cur_depth += 1
if cur_depth > max_depth:
findings.append(
Finding(
level="ERROR",
path=node_path,
message=f"Layout nesting depth {cur_depth} exceeds max {max_depth}.",
)
)
cards = node.get("cards")
if _is_sequence(cards):
for idx, child in enumerate(cards):
_validate_layout_depth(
node=child,
cur_depth=cur_depth,
max_depth=max_depth,
node_path=f"{node_path}.cards[{idx}]",
findings=findings,
)
def _walk_cards(node: Any, node_path: str, findings: list[Finding]) -> None:
if _is_mapping(node):
ctype = _card_type(node)
if ctype in DISALLOWED_CARD_TYPES:
findings.append(
Finding(
level="ERROR",
path=node_path,
message=f"Disallowed card type: {ctype}",
)
)
if ctype and ctype not in ALLOWED_CARD_TYPES:
findings.append(
Finding(
level="WARN",
path=node_path,
message=f"Unknown/unlisted card type: {ctype}",
)
)
if ctype == "custom:button-card":
tmpl = node.get("template")
if tmpl is None:
findings.append(
Finding(
level="ERROR",
path=node_path,
message="custom:button-card must set template: (string or list).",
)
)
if "styles" in node:
findings.append(
Finding(
level="ERROR",
path=node_path,
message="Per-card styles: are not allowed; move styling into centralized templates.",
)
)
if "style" in node:
findings.append(
Finding(
level="ERROR",
path=node_path,
message="Per-card style: is not allowed; move styling into centralized templates.",
)
)
if "extra_styles" in node:
findings.append(
Finding(
level="ERROR",
path=node_path,
message="Per-card extra_styles: is not allowed; move styling into centralized templates.",
)
)
if "card_mod" in node:
findings.append(
Finding(
level="ERROR",
path=node_path,
message="Per-card card_mod: is not allowed on button-card instances; use shared snippets or templates.",
)
)
state_entries = node.get("state")
if _is_sequence(state_entries):
for sidx, state_entry in enumerate(state_entries):
if _is_mapping(state_entry) and "styles" in state_entry:
findings.append(
Finding(
level="ERROR",
path=f"{node_path}.state[{sidx}]",
message="Per-card state styles are not allowed; move styling into centralized templates.",
)
)
cards = node.get("cards")
if _is_sequence(cards):
for idx, child in enumerate(cards):
_walk_cards(child, f"{node_path}.cards[{idx}]", findings)
elif _is_sequence(node):
for idx, child in enumerate(node):
_walk_cards(child, f"{node_path}[{idx}]", findings)
def _load_yaml(path: Path) -> Any:
txt = path.read_text(encoding="utf-8")
return yaml.load(txt, Loader=_Loader)
def _validate_sections_wrapper(sections: list[Any], findings: list[Finding]) -> None:
"""Validate first sections wrapper constraints from dashboard rules."""
if not sections:
findings.append(
Finding(
level="ERROR",
path="$.sections",
message="sections list cannot be empty for a sections view.",
)
)
return
first_section = sections[0]
if not _is_mapping(first_section):
findings.append(
Finding(
level="ERROR",
path="$.sections[0]",
message="First section must be a mapping.",
)
)
return
column_span = first_section.get("column_span")
if column_span != 4:
findings.append(
Finding(
level="ERROR",
path="$.sections[0].column_span",
message="First section must set column_span: 4.",
)
)
cards = first_section.get("cards")
if not _is_sequence(cards):
findings.append(
Finding(
level="ERROR",
path="$.sections[0].cards",
message="First section must define cards as a list.",
)
)
return
if len(cards) != 1:
findings.append(
Finding(
level="ERROR",
path="$.sections[0].cards",
message="First section must contain exactly one wrapper card.",
)
)
return
wrapper_card = cards[0]
if not _is_mapping(wrapper_card):
findings.append(
Finding(
level="ERROR",
path="$.sections[0].cards[0]",
message="Wrapper card must be a mapping.",
)
)
return
grid_options = wrapper_card.get("grid_options")
if not _is_mapping(grid_options) or grid_options.get("columns") != "full":
findings.append(
Finding(
level="ERROR",
path="$.sections[0].cards[0].grid_options.columns",
message='Wrapper card must set grid_options.columns: "full".',
)
)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("view_yaml", type=Path, help="Path to a Lovelace view YAML file")
ap.add_argument("--max-layout-depth", type=int, default=2)
ap.add_argument("--strict", action="store_true", help="Treat WARN findings as errors (non-zero exit).")
args = ap.parse_args(argv)
if not args.view_yaml.exists():
print(f"ERROR: file not found: {args.view_yaml}", file=sys.stderr)
return 2
try:
doc = _load_yaml(args.view_yaml)
except Exception as e:
print(f"ERROR: failed to parse YAML: {e}", file=sys.stderr)
return 2
findings: list[Finding] = []
# The repo uses "one YAML file per view (single view dict)".
if not _is_mapping(doc):
findings.append(Finding(level="ERROR", path="$", message="View file must be a single YAML mapping (one view dict)."))
else:
# Support both classic views (`cards:`) and sections views (`type: sections`, `sections:`).
if "cards" in doc:
_validate_layout_depth(
node=doc,
cur_depth=0,
max_depth=args.max_layout_depth,
node_path="$",
findings=findings,
)
_walk_cards(doc.get("cards", []), "$.cards", findings)
elif doc.get("type") == "sections" and "sections" in doc:
sections = doc.get("sections")
if isinstance(sections, _Tagged):
findings.append(
Finding(
level="WARN",
path="$.sections",
message="sections is an !include/unknown tag; validator cannot inspect included sections content.",
)
)
elif _is_sequence(sections):
_validate_sections_wrapper(sections, findings)
for sidx, section in enumerate(sections):
spath = f"$.sections[{sidx}]"
if not _is_mapping(section):
findings.append(Finding(level="ERROR", path=spath, message="Section must be a mapping."))
continue
_validate_layout_depth(
node=section,
cur_depth=0,
max_depth=args.max_layout_depth,
node_path=spath,
findings=findings,
)
_walk_cards(section.get("cards", []), f"{spath}.cards", findings)
else:
findings.append(
Finding(
level="ERROR",
path="$.sections",
message="sections must be a list (or an !include tag).",
)
)
else:
findings.append(
Finding(
level="ERROR",
path="$",
message="View dict must contain cards: or be type: sections with sections:.",
)
)
errors = [f for f in findings if f.level == "ERROR"]
warns = [f for f in findings if f.level == "WARN"]
for f in errors + warns:
print(f"{f.level}: {f.path}: {f.message}")
if errors:
return 1
if warns and args.strict:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Powered by TurnKey Linux.