core/script/hassfest/manifest.py

406 lines
12 KiB
Python
Raw Normal View History

"""Manifest validation."""
2021-03-18 21:58:19 +00:00
from __future__ import annotations
from enum import IntEnum
2023-02-08 20:48:58 +00:00
import json
from pathlib import Path
2023-02-08 20:48:58 +00:00
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",
Add application credentials platform (#69148) * Initial developer credentials scaffolding - Support websocket list/add/delete - Add developer credentials protocol from yaml config - Handle OAuth credential registration and de-registration - Tests for websocket and integration based registration * Fix pydoc text * Remove translations and update owners * Update homeassistant/components/developer_credentials/__init__.py Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> * Update homeassistant/components/developer_credentials/__init__.py Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> * Remove _async_get_developer_credential * Rename to application credentials platform * Fix race condition and add import support * Increase code coverage (92%) * Increase test coverage 93% * Increase test coverage (94%) * Increase test coverage (97%) * Increase test covearge (98%) * Increase test coverage (99%) * Increase test coverage (100%) * Remove http router frozen comment * Remove auth domain override on import * Remove debug statement * Don't import the same client id multiple times * Add auth dependency for local oauth implementation * Revert older oauth2 changes from merge * Update homeassistant/components/application_credentials/__init__.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Move config credential import to its own fixture * Override the mock_application_credentials_integration fixture instead per test * Update application credentials * Add dictionary typing * Use f-strings as per feedback * Add additional structure needed for an MVP application credential Add additional structure needed for an MVP, including a target component Xbox * Add websocket to list supported integrations for frontend selector * Application credentials config * Import xbox credentials * Remove unnecessary async calls * Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update script/hassfest/application_credentials.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Import credentials with a fixed auth domain Resolve an issue with compatibility of exisiting config entries when importing client credentials Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-04-30 15:06:43 +00:00
"application_credentials",
"auth",
"automation",
"blueprint",
"color_extractor",
"config",
"configurator",
"counter",
"default_config",
"device_automation",
"device_tracker",
2022-01-18 04:42:18 +00:00
"diagnostics",
"downloader",
"ffmpeg",
"file_upload",
"frontend",
"hardkernel",
2022-05-25 18:39:15 +00:00
"hardware",
"history",
"homeassistant",
"homeassistant_alerts",
2023-08-30 14:37:13 +00:00
"homeassistant_green",
"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",
2022-05-25 18:39:15 +00:00
"raspberry_pi",
"recovery_mode",
"repairs",
"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
2022-11-23 18:05:31 +00:00
def verify_lowercase(value: str) -> str:
2020-12-31 00:06:26 +00:00
"""Verify a value is lowercase."""
if value.lower() != value:
raise vol.Invalid("Value needs to be lowercase")
return value
2022-11-23 18:05:31 +00:00
def verify_uppercase(value: str) -> str:
2020-12-31 00:06:26 +00:00
"""Verify a value is uppercase."""
if value.upper() != value:
raise vol.Invalid("Value needs to be uppercase")
return value
2022-11-23 18:05:31 +00:00
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
2022-11-23 18:05:31 +00:00
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(
2019-07-31 19:25:30 +00:00
{
vol.Required("domain"): str,
vol.Required("name"): str,
vol.Optional("integration_type", default="hub"): vol.In(
[
"device",
"entity",
"hardware",
"helper",
"hub",
"service",
"system",
]
),
2019-07-31 19:25:30 +00:00
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}
),
}
),
),
)
],
2019-07-31 19:25:30 +00:00
vol.Optional("ssdp"): vol.Schema(
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
2019-07-31 19:25:30 +00:00
),
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],
}
)
],
2019-07-31 19:25:30 +00:00
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],
2019-07-31 19:25:30 +00:00
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),
vol.Optional("import_executor"): bool,
vol.Optional("single_config_entry"): bool,
2019-07-31 19:25:30 +00:00
}
)
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),
}
)
2022-11-23 18:05:31 +00:00
def validate_version(integration: Integration) -> None:
2023-02-03 22:08:48 +00:00
"""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)}"
2019-07-31 19:25:30 +00:00
)
if (domain := integration.manifest["domain"]) != integration.path.name:
2019-07-31 19:25:30 +00:00
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)
2023-02-08 20:48:58 +00:00
_SORT_KEYS = {"domain": ".domain", "name": ".name"}
def _sort_manifest_keys(key: str) -> str:
return _SORT_KEYS.get(key, key)
def sort_manifest(integration: Integration, config: Config) -> bool:
2023-02-08 20:48:58 +00:00
"""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}
if config.action == "generate":
integration.manifest_path.write_text(json.dumps(manifest, indent=2))
text = "have been sorted"
else:
text = "are not sorted correctly"
2023-02-08 20:48:58 +00:00
integration.add_error(
"manifest",
f"Manifest keys {text}: domain, name, then alphabetical order",
2023-02-08 20:48:58 +00:00
)
return True
return False
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Handle all integrations manifests."""
core_components_dir = config.root / "homeassistant/components"
2023-02-08 20:48:58 +00:00
manifests_resorted = []
for integration in integrations.values():
validate_manifest(integration, core_components_dir)
2023-02-08 20:48:58 +00:00
if not integration.errors:
if sort_manifest(integration, config):
2023-02-08 20:48:58 +00:00
manifests_resorted.append(integration.manifest_path)
if config.action == "generate" and manifests_resorted:
2023-02-08 20:48:58 +00:00
subprocess.run(
["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"]
+ manifests_resorted,
stdout=subprocess.DEVNULL,
2023-08-19 12:17:17 +00:00
check=True,
2023-02-08 20:48:58 +00:00
)