Add services.yaml validator (#23205)

* Add services.yaml validator

* Fix path
This commit is contained in:
Paulus Schoutsen 2019-04-18 13:40:46 -07:00 committed by GitHub
parent 37cd711c96
commit 33b8241d26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 172 additions and 71 deletions

View File

@ -80,7 +80,6 @@ set_swing_mode:
example: 'climate.nest' example: 'climate.nest'
swing_mode: swing_mode:
description: New value of swing mode. description: New value of swing mode.
example:
turn_on: turn_on:
description: Turn climate device on. description: Turn climate device on.

View File

@ -19,6 +19,7 @@ configure:
device_refresh: device_refresh:
description: Refresh device lists from deCONZ. description: Refresh device lists from deCONZ.
fields:
bridgeid: bridgeid:
description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name.
example: '00212EFFFF012345' example: '00212EFFFF012345'

View File

@ -25,8 +25,7 @@ see:
description: Battery level of device. description: Battery level of device.
example: '100' example: '100'
icloud: icloud_lost_iphone:
icloud_lost_iphone:
description: Service to play the lost iphone sound on an iDevice. description: Service to play the lost iphone sound on an iDevice.
fields: fields:
account_name: account_name:
@ -35,7 +34,7 @@ icloud:
device_name: device_name:
description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account. description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account.
example: 'iphonebart' example: 'iphonebart'
icloud_set_interval: icloud_set_interval:
description: Service to set the interval of an iDevice. description: Service to set the interval of an iDevice.
fields: fields:
account_name: account_name:
@ -47,7 +46,7 @@ icloud:
interval: interval:
description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state. description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state.
example: 1 example: 1
icloud_update: icloud_update:
description: Service to ask for an update of an iDevice. description: Service to ask for an update of an iDevice.
fields: fields:
account_name: account_name:
@ -56,7 +55,7 @@ icloud:
device_name: device_name:
description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account. description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account.
example: 'iphonebart' example: 'iphonebart'
icloud_reset_account: icloud_reset_account:
description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device. description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device.
fields: fields:
account_name: account_name:

View File

@ -19,7 +19,7 @@ send_command:
are source and destination, second byte is command and optional other bytes are source and destination, second byte is command and optional other bytes
are command parameters. If raw command specified, other params are ignored.', are command parameters. If raw command specified, other params are ignored.',
example: '"10:36"'} example: '"10:36"'}
src: {desctiption: 'Source of command. Could be decimal number or string with src: {description: 'Source of command. Could be decimal number or string with
hexadeximal notation: "0x10".', example: 12 or "0xc"} hexadeximal notation: "0x10".', example: 12 or "0xc"}
standby: {description: Standby all devices which supports it.} standby: {description: Standby all devices which supports it.}
update: {description: Update devices state from network.} update: {description: Update devices state from network.}

View File

@ -1,7 +1,7 @@
system_log: clear:
clear:
description: Clear all log entries. description: Clear all log entries.
write:
write:
description: Write log entry. description: Write log entry.
fields: fields:
message: message:

View File

@ -3,12 +3,13 @@ import pathlib
import sys import sys
from .model import Integration, Config from .model import Integration, Config
from . import dependencies, manifest, codeowners from . import dependencies, manifest, codeowners, services
PLUGINS = [ PLUGINS = [
manifest, manifest,
dependencies, dependencies,
codeowners, codeowners,
services,
] ]
@ -37,6 +38,7 @@ def main():
manifest.validate(integrations, config) manifest.validate(integrations, config)
dependencies.validate(integrations, config) dependencies.validate(integrations, config)
codeowners.validate(integrations, config) codeowners.validate(integrations, config)
services.validate(integrations, config)
# When we generate, all errors that are fixable will be ignored, # When we generate, all errors that are fixable will be ignored,
# as generating them will be fixed. # as generating them will be fixed.

View File

@ -61,26 +61,22 @@ class Integration:
"""Integration domain.""" """Integration domain."""
return self.path.name return self.path.name
@property
def manifest_path(self) -> pathlib.Path:
"""Integration manifest path."""
return self.path / 'manifest.json'
def add_error(self, *args, **kwargs): def add_error(self, *args, **kwargs):
"""Add an error.""" """Add an error."""
self.errors.append(Error(*args, **kwargs)) self.errors.append(Error(*args, **kwargs))
def load_manifest(self) -> None: def load_manifest(self) -> None:
"""Load manifest.""" """Load manifest."""
if not self.manifest_path.is_file(): manifest_path = self.path / 'manifest.json'
if not manifest_path.is_file():
self.add_error( self.add_error(
'model', 'model',
"Manifest file {} not found".format(self.manifest_path) "Manifest file {} not found".format(manifest_path)
) )
return return
try: try:
manifest = json.loads(self.manifest_path.read_text()) manifest = json.loads(manifest_path.read_text())
except ValueError as err: except ValueError as err:
self.add_error( self.add_error(
'model', 'model',

104
script/hassfest/services.py Normal file
View File

@ -0,0 +1,104 @@
"""Validate dependencies."""
import pathlib
from typing import Dict
import re
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util.yaml import load_yaml
from .model import Integration
def exists(value):
"""Check if value exists."""
if value is None:
raise vol.Invalid("Value cannot be None")
return value
FIELD_SCHEMA = vol.Schema({
vol.Required('description'): str,
vol.Optional('example'): exists,
vol.Optional('default'): exists,
vol.Optional('values'): exists,
vol.Optional('required'): bool,
})
SERVICE_SCHEMA = vol.Schema({
vol.Required('description'): str,
vol.Optional('fields'): vol.Schema({
str: FIELD_SCHEMA
})
})
SERVICES_SCHEMA = vol.Schema({
cv.slug: SERVICE_SCHEMA
})
def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) \
-> bool:
"""Recursively go through a dir and it's children and find the regex."""
pattern = re.compile(search_pattern)
for fil in path.glob(glob_pattern):
if not fil.is_file():
continue
if pattern.search(fil.read_text()):
return True
return False
def validate_services(integration: Integration):
"""Validate services."""
# Find if integration uses services
has_services = grep_dir(integration.path, "**/*.py",
r"hass\.(services|async_register)")
if not has_services:
return
try:
data = load_yaml(str(integration.path / 'services.yaml'))
except FileNotFoundError:
print(
"Warning: {} registeres services but has no services.yaml".format(
integration.domain))
# integration.add_error(
# 'services', 'Registers services but has no services.yaml')
return
except HomeAssistantError:
integration.add_error(
'services', 'Registers services but unable to load services.yaml')
return
try:
SERVICES_SCHEMA(data)
except vol.Invalid as err:
integration.add_error(
'services',
"Invalid services.yaml: {}".format(humanize_error(data, err)))
def validate(integrations: Dict[str, Integration], config):
"""Handle dependencies for integrations."""
# check services.yaml is cool
for integration in integrations.values():
if not integration.manifest:
continue
validate_services(integration)
# check that all referenced dependencies exist
for dep in integration.manifest['dependencies']:
if dep not in integrations:
integration.add_error(
'dependencies',
"Dependency {} does not exist"
)