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.
380 lines
12 KiB
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:]))
|