#!/usr/bin/env python3 """Validate the integration's static metadata. This replaces the parts of Home Assistant's `hassfest` action that matter for a manually installed custom component. hassfest itself is a GitHub-hosted action and cannot run on the Gitea runner, and it also enforces rules that only apply to integrations vendored into HA core. Checks performed: * every JSON file in the component parses * manifest.json has the keys HA requires of a custom integration, with sane values (domain matches the directory, version is present, etc.) * every translation file has exactly the same key structure as strings.json, so a missing translation shows up here rather than as a blank label in the UI Exits non-zero with one line per problem. """ from __future__ import annotations import json from pathlib import Path import sys REPO_ROOT = Path(__file__).resolve().parent.parent COMPONENTS_DIR = REPO_ROOT / "custom_components" # Keys HA reads from a custom integration's manifest. `version` is required for # custom components specifically (core integrations must omit it). REQUIRED_MANIFEST_KEYS = ( "domain", "name", "codeowners", "documentation", "iot_class", "version", ) VALID_IOT_CLASSES = { "assumed_state", "cloud_polling", "cloud_push", "local_polling", "local_push", "calculated", } VALID_INTEGRATION_TYPES = { "device", "entity", "hardware", "helper", "hub", "service", "system", "virtual", } def key_structure(value: object, prefix: str = "") -> set[str]: """Flatten a nested dict into dotted key paths, ignoring leaf values.""" if not isinstance(value, dict): return {prefix} paths: set[str] = set() for key, child in value.items(): paths |= key_structure(child, f"{prefix}.{key}" if prefix else key) return paths def check_json_parses(errors: list[str]) -> dict[Path, object]: """Parse every JSON file under custom_components/, recording failures.""" parsed: dict[Path, object] = {} for path in sorted(COMPONENTS_DIR.rglob("*.json")): try: parsed[path] = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as err: errors.append(f"{path.relative_to(REPO_ROOT)}: invalid JSON: {err}") return parsed def check_manifest(component: Path, manifest: object, errors: list[str]) -> None: """Validate a single manifest.json payload.""" rel = (component / "manifest.json").relative_to(REPO_ROOT) if not isinstance(manifest, dict): errors.append(f"{rel}: expected a JSON object") return errors.extend( f"{rel}: missing required key '{key}'" for key in REQUIRED_MANIFEST_KEYS if key not in manifest ) if manifest.get("domain") != component.name: errors.append( f"{rel}: domain '{manifest.get('domain')}' does not match " f"directory name '{component.name}'" ) iot_class = manifest.get("iot_class") if iot_class is not None and iot_class not in VALID_IOT_CLASSES: errors.append(f"{rel}: unknown iot_class '{iot_class}'") integration_type = manifest.get("integration_type") if integration_type is not None and integration_type not in VALID_INTEGRATION_TYPES: errors.append(f"{rel}: unknown integration_type '{integration_type}'") codeowners = manifest.get("codeowners") if not isinstance(codeowners, list): errors.append(f"{rel}: codeowners must be a list") else: errors.extend( f"{rel}: codeowner '{owner}' must start with '@'" for owner in codeowners if not isinstance(owner, str) or not owner.startswith("@") ) # config_flow: true requires strings.json to describe the flow, otherwise # the user sees untranslated keys. if manifest.get("config_flow") and not (component / "strings.json").is_file(): errors.append(f"{rel}: config_flow is true but strings.json is missing") def check_translations( component: Path, parsed: dict[Path, object], errors: list[str] ) -> None: """Ensure each translation file mirrors strings.json exactly.""" strings_path = component / "strings.json" if strings_path not in parsed: return expected = key_structure(parsed[strings_path]) translations_dir = component / "translations" if not translations_dir.is_dir(): errors.append( f"{component.relative_to(REPO_ROOT)}: strings.json exists but " "translations/ is missing" ) return for path in sorted(translations_dir.glob("*.json")): if path not in parsed: continue rel = path.relative_to(REPO_ROOT) actual = key_structure(parsed[path]) errors.extend( f"{rel}: missing key '{key}'" for key in sorted(expected - actual) ) errors.extend( f"{rel}: key '{key}' not present in strings.json" for key in sorted(actual - expected) ) def main() -> int: """Run every check and report the problems found.""" errors: list[str] = [] parsed = check_json_parses(errors) components = sorted(p for p in COMPONENTS_DIR.iterdir() if p.is_dir()) if not components: errors.append("custom_components/ contains no integration directory") for component in components: manifest_path = component / "manifest.json" if manifest_path not in parsed: if not manifest_path.is_file(): errors.append( f"{component.relative_to(REPO_ROOT)}: manifest.json is missing" ) continue check_manifest(component, parsed[manifest_path], errors) check_translations(component, parsed, errors) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) print(f"\n{len(errors)} problem(s) found.", file=sys.stderr) return 1 print( f"OK: validated {len(components)} integration(s), {len(parsed)} JSON file(s)." ) return 0 if __name__ == "__main__": sys.exit(main())