"""Manifest validation.""" from __future__ import annotations from enum import IntEnum import json from pathlib import Path import subprocess from typing import Any from urllib.parse import urlparse from awesomeversion import ( AwesomeVersion, AwesomeVersionException, AwesomeVersionStrategy, ) import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv from .model import Config, Integration DOCUMENTATION_URL_SCHEMA = "https" DOCUMENTATION_URL_HOST = "www.home-assistant.io" DOCUMENTATION_URL_PATH_PREFIX = "/integrations/" DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} class QualityScale(IntEnum): """Supported manifest quality scales.""" INTERNAL = -1 SILVER = 1 GOLD = 2 PLATINUM = 3 SUPPORTED_QUALITY_SCALES = [enum.name.lower() for enum in QualityScale] SUPPORTED_IOT_CLASSES = [ "assumed_state", "calculated", "cloud_polling", "cloud_push", "local_polling", "local_push", ] # List of integrations that are supposed to have no IoT class NO_IOT_CLASS = [ *{platform.value for platform in Platform}, "api", "application_credentials", "auth", "automation", "blueprint", "color_extractor", "config", "configurator", "counter", "default_config", "device_automation", "device_tracker", "diagnostics", "downloader", "ffmpeg", "file_upload", "frontend", "hardkernel", "hardware", "history", "homeassistant", "homeassistant_alerts", "homeassistant_hardware", "homeassistant_sky_connect", "homeassistant_yellow", "image_upload", "input_boolean", "input_button", "input_datetime", "input_number", "input_select", "input_text", "intent_script", "intent", "logbook", "logger", "lovelace", "map", "media_source", "my", "onboarding", "panel_custom", "panel_iframe", "plant", "profiler", "proxy", "python_script", "raspberry_pi", "repairs", "safe_mode", "schedule", "script", "search", "system_health", "system_log", "tag", "timer", "trace", "webhook", "websocket_api", "zone", ] def documentation_url(value: str) -> str: """Validate that a documentation url has the correct path and domain.""" if value in DOCUMENTATION_URL_EXCEPTIONS: return value parsed_url = urlparse(value) if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA: raise vol.Invalid("Documentation url is not prefixed with https") if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith( DOCUMENTATION_URL_PATH_PREFIX ): raise vol.Invalid( "Documentation url does not begin with www.home-assistant.io/integrations" ) return value def verify_lowercase(value: str) -> str: """Verify a value is lowercase.""" if value.lower() != value: raise vol.Invalid("Value needs to be lowercase") return value def verify_uppercase(value: str) -> str: """Verify a value is uppercase.""" if value.upper() != value: raise vol.Invalid("Value needs to be uppercase") return value def verify_version(value: str) -> str: """Verify the version.""" try: AwesomeVersion( value, ensure_strategy=[ AwesomeVersionStrategy.CALVER, AwesomeVersionStrategy.SEMVER, AwesomeVersionStrategy.SIMPLEVER, AwesomeVersionStrategy.BUILDVER, AwesomeVersionStrategy.PEP440, ], ) except AwesomeVersionException as err: raise vol.Invalid(f"'{value}' is not a valid version.") from err return value def verify_wildcard(value: str) -> str: """Verify the matcher contains a wildcard.""" if "*" not in value: raise vol.Invalid(f"'{value}' needs to contain a wildcard matcher") return value INTEGRATION_MANIFEST_SCHEMA = vol.Schema( { vol.Required("domain"): str, vol.Required("name"): str, vol.Optional("integration_type", default="hub"): vol.In( [ "device", "entity", "hardware", "helper", "hub", "service", "system", ] ), vol.Optional("config_flow"): bool, vol.Optional("mqtt"): [str], vol.Optional("zeroconf"): [ vol.Any( str, vol.All( cv.deprecated("macaddress"), cv.deprecated("model"), cv.deprecated("manufacturer"), vol.Schema( { vol.Required("type"): str, vol.Optional("macaddress"): vol.All( str, verify_uppercase, verify_wildcard ), vol.Optional("manufacturer"): vol.All( str, verify_lowercase ), vol.Optional("model"): vol.All(str, verify_lowercase), vol.Optional("name"): vol.All(str, verify_lowercase), vol.Optional("properties"): vol.Schema( {str: verify_lowercase} ), } ), ), ) ], vol.Optional("ssdp"): vol.Schema( vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))]) ), vol.Optional("bluetooth"): [ vol.Schema( { vol.Optional("connectable"): bool, vol.Optional("service_uuid"): vol.All(str, verify_lowercase), vol.Optional("service_data_uuid"): vol.All(str, verify_lowercase), vol.Optional("local_name"): vol.All(str), vol.Optional("manufacturer_id"): int, vol.Optional("manufacturer_data_start"): [int], } ) ], vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}), vol.Optional("dhcp"): [ vol.Schema( { vol.Optional("macaddress"): vol.All( str, verify_uppercase, verify_wildcard ), vol.Optional("hostname"): vol.All(str, verify_lowercase), vol.Optional("registered_devices"): cv.boolean, } ) ], vol.Optional("usb"): [ vol.Schema( { vol.Optional("vid"): vol.All(str, verify_uppercase), vol.Optional("pid"): vol.All(str, verify_uppercase), vol.Optional("serial_number"): vol.All(str, verify_lowercase), vol.Optional("manufacturer"): vol.All(str, verify_lowercase), vol.Optional("description"): vol.All(str, verify_lowercase), vol.Optional("known_devices"): [str], } ) ], vol.Required("documentation"): vol.All(vol.Url(), documentation_url), vol.Optional("issue_tracker"): vol.Url(), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], vol.Optional("after_dependencies"): [str], vol.Required("codeowners"): [str], vol.Optional("loggers"): [str], vol.Optional("disabled"): str, vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES), } ) VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema( { vol.Required("domain"): str, vol.Required("name"): str, vol.Required("integration_type"): "virtual", vol.Exclusive("iot_standards", "virtual_integration"): [ vol.Any("homekit", "zigbee", "zwave") ], vol.Exclusive("supported_by", "virtual_integration"): str, } ) def manifest_schema(value: dict[str, Any]) -> vol.Schema: """Validate integration manifest.""" if value.get("integration_type") == "virtual": return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value) return INTEGRATION_MANIFEST_SCHEMA(value) CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { vol.Optional("version"): vol.All(str, verify_version), } ) def validate_version(integration: Integration) -> None: """Validate the version of the integration. Will be removed when the version key is no longer optional for custom integrations. """ if not integration.manifest.get("version"): integration.add_error("manifest", "No 'version' key in the manifest file.") return def validate_manifest(integration: Integration, core_components_dir: Path) -> None: """Validate manifest.""" try: if integration.core: manifest_schema(integration.manifest) else: CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) except vol.Invalid as err: integration.add_error( "manifest", f"Invalid manifest: {humanize_error(integration.manifest, err)}" ) if (domain := integration.manifest["domain"]) != integration.path.name: integration.add_error("manifest", "Domain does not match dir name") if not integration.core and (core_components_dir / domain).exists(): integration.add_warning( "manifest", "Domain collides with built-in core integration" ) if domain in NO_IOT_CLASS and "iot_class" in integration.manifest: integration.add_error("manifest", "Domain should not have an IoT Class") if ( domain not in NO_IOT_CLASS and "iot_class" not in integration.manifest and integration.manifest.get("integration_type") != "virtual" ): integration.add_error("manifest", "Domain is missing an IoT Class") if ( integration.manifest.get("integration_type") == "virtual" and (supported_by := integration.manifest.get("supported_by")) and not (core_components_dir / supported_by).exists() ): integration.add_error( "manifest", "Virtual integration points to non-existing supported_by integration", ) if ( (quality_scale := integration.manifest.get("quality_scale")) and QualityScale[quality_scale.upper()] > QualityScale.SILVER and not integration.manifest.get("codeowners") ): integration.add_error( "manifest", f"{quality_scale} integration does not have a code owner", ) if not integration.core: validate_version(integration) _SORT_KEYS = {"domain": ".domain", "name": ".name"} def _sort_manifest_keys(key: str) -> str: return _SORT_KEYS.get(key, key) def sort_manifest(integration: Integration) -> bool: """Sort manifest.""" keys = list(integration.manifest.keys()) if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: manifest = {key: integration.manifest[key] for key in keys_sorted} integration.manifest_path.write_text(json.dumps(manifest, indent=2)) integration.add_error( "manifest", "Manifest keys have been sorted: domain, name, then alphabetical order", ) return True return False def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle all integrations manifests.""" core_components_dir = config.root / "homeassistant/components" manifests_resorted = [] for integration in integrations.values(): validate_manifest(integration, core_components_dir) if not integration.errors: if sort_manifest(integration): manifests_resorted.append(integration.manifest_path) if manifests_resorted: subprocess.run( ["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"] + manifests_resorted, stdout=subprocess.DEVNULL, check=True, )