2020-04-15 23:58:20 +00:00
""" Validate integration translation files. """
2021-03-18 21:58:19 +00:00
from __future__ import annotations
2020-04-17 01:00:30 +00:00
from functools import partial
2020-04-22 13:24:45 +00:00
from itertools import chain
2020-04-15 23:58:20 +00:00
import json
2020-04-20 23:25:35 +00:00
import re
2020-04-15 23:58:20 +00:00
import voluptuous as vol
from voluptuous . humanize import humanize_error
2020-04-20 03:35:49 +00:00
import homeassistant . helpers . config_validation as cv
from homeassistant . util import slugify
2020-08-29 06:23:55 +00:00
from script . translations import upload
2020-04-20 03:35:49 +00:00
2020-04-17 01:00:30 +00:00
from . model import Config , Integration
2020-04-15 23:58:20 +00:00
2020-04-17 01:00:30 +00:00
UNDEFINED = 0
REQUIRED = 1
REMOVED = 2
2020-04-20 23:25:35 +00:00
RE_REFERENCE = r " \ [ \ % key:(.+) \ % \ ] "
2020-04-17 01:00:30 +00:00
REMOVED_TITLE_MSG = (
" config.title key has been moved out of config and into the root of strings.json. "
" Starting Home Assistant 0.109 you only need to define this key in the root "
" if the title needs to be different than the name of your integration in the "
" manifest. "
)
2020-04-21 23:11:05 +00:00
MOVED_TRANSLATIONS_DIRECTORY_MSG = (
" The ' .translations ' directory has been moved, the new name is ' translations ' , "
2020-06-23 08:58:11 +00:00
" starting with Home Assistant 0.112 your translations will no longer "
2020-04-21 23:11:05 +00:00
" load if you do not move/rename this "
)
def check_translations_directory_name ( integration : Integration ) - > None :
""" Check that the correct name is used for the translations directory. """
legacy_translations = integration . path / " .translations "
translations = integration . path / " translations "
if translations . is_dir ( ) :
# No action required
return
if legacy_translations . is_dir ( ) :
2020-06-23 08:58:11 +00:00
integration . add_error ( " translations " , MOVED_TRANSLATIONS_DIRECTORY_MSG )
2020-04-21 23:11:05 +00:00
2020-04-17 01:00:30 +00:00
2020-04-20 23:25:35 +00:00
def find_references ( strings , prefix , found ) :
""" Find references. """
for key , value in strings . items ( ) :
if isinstance ( value , dict ) :
find_references ( value , f " { prefix } :: { key } " , found )
continue
match = re . match ( RE_REFERENCE , value )
if match :
found . append ( { " source " : f " { prefix } :: { key } " , " ref " : match . groups ( ) [ 0 ] } )
2020-04-17 01:00:30 +00:00
def removed_title_validator ( config , integration , value ) :
""" Mark removed title. """
if not config . specific_integrations :
raise vol . Invalid ( REMOVED_TITLE_MSG )
# Don't mark it as an error yet for custom components to allow backwards compat.
integration . add_warning ( " translations " , REMOVED_TITLE_MSG )
return value
2020-04-20 23:25:35 +00:00
def lowercase_validator ( value ) :
""" Validate value is lowercase. """
if value . lower ( ) != value :
raise vol . Invalid ( " Needs to be lowercase " )
return value
2020-04-17 01:00:30 +00:00
def gen_data_entry_schema (
* ,
config : Config ,
integration : Integration ,
flow_title : int ,
require_step_title : bool ,
) :
2020-04-15 23:58:20 +00:00
""" Generate a data entry schema. """
step_title_class = vol . Required if require_step_title else vol . Optional
2020-04-17 01:00:30 +00:00
schema = {
2020-05-14 17:33:14 +00:00
vol . Optional ( " flow_title " ) : cv . string_with_no_html ,
2020-04-15 23:58:20 +00:00
vol . Required ( " step " ) : {
str : {
2020-05-14 17:33:14 +00:00
step_title_class ( " title " ) : cv . string_with_no_html ,
vol . Optional ( " description " ) : cv . string_with_no_html ,
vol . Optional ( " data " ) : { str : cv . string_with_no_html } ,
2020-04-15 23:58:20 +00:00
}
} ,
2020-05-14 17:33:14 +00:00
vol . Optional ( " error " ) : { str : cv . string_with_no_html } ,
vol . Optional ( " abort " ) : { str : cv . string_with_no_html } ,
2020-11-17 10:44:06 +00:00
vol . Optional ( " progress " ) : { str : cv . string_with_no_html } ,
2020-05-14 17:33:14 +00:00
vol . Optional ( " create_entry " ) : { str : cv . string_with_no_html } ,
2020-04-15 23:58:20 +00:00
}
2020-04-17 01:00:30 +00:00
if flow_title == REQUIRED :
2020-05-14 17:33:14 +00:00
schema [ vol . Required ( " title " ) ] = cv . string_with_no_html
2020-04-17 01:00:30 +00:00
elif flow_title == REMOVED :
schema [ vol . Optional ( " title " , msg = REMOVED_TITLE_MSG ) ] = partial (
removed_title_validator , config , integration
)
2020-04-15 23:58:20 +00:00
2020-04-17 01:00:30 +00:00
return schema
def gen_strings_schema ( config : Config , integration : Integration ) :
""" Generate a strings schema. """
return vol . Schema (
{
2020-05-14 17:33:14 +00:00
vol . Optional ( " title " ) : cv . string_with_no_html ,
2020-04-17 01:00:30 +00:00
vol . Optional ( " config " ) : gen_data_entry_schema (
config = config ,
integration = integration ,
flow_title = REMOVED ,
2020-04-22 13:05:39 +00:00
require_step_title = False ,
2020-04-17 01:00:30 +00:00
) ,
vol . Optional ( " options " ) : gen_data_entry_schema (
config = config ,
integration = integration ,
flow_title = UNDEFINED ,
require_step_title = False ,
) ,
vol . Optional ( " device_automation " ) : {
2020-05-14 17:33:14 +00:00
vol . Optional ( " action_type " ) : { str : cv . string_with_no_html } ,
vol . Optional ( " condition_type " ) : { str : cv . string_with_no_html } ,
vol . Optional ( " trigger_type " ) : { str : cv . string_with_no_html } ,
vol . Optional ( " trigger_subtype " ) : { str : cv . string_with_no_html } ,
2020-04-17 01:00:30 +00:00
} ,
2020-04-20 03:35:49 +00:00
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2020-04-20 23:25:35 +00:00
cv . schema_with_slug_keys ( str , slug_validator = lowercase_validator ) ,
slug_validator = vol . Any ( " _ " , cv . slug ) ,
2020-04-20 03:35:49 +00:00
) ,
2020-11-10 22:56:50 +00:00
vol . Optional ( " system_health " ) : {
vol . Optional ( " info " ) : { str : cv . string_with_no_html }
} ,
2020-04-15 23:58:20 +00:00
}
2020-04-17 01:00:30 +00:00
)
def gen_auth_schema ( config : Config , integration : Integration ) :
""" Generate auth schema. """
return vol . Schema (
{
vol . Optional ( " mfa_setup " ) : {
str : gen_data_entry_schema (
config = config ,
integration = integration ,
flow_title = REQUIRED ,
require_step_title = True ,
)
}
}
)
2020-04-15 23:58:20 +00:00
2020-04-20 03:35:49 +00:00
def gen_platform_strings_schema ( config : Config , integration : Integration ) :
2020-04-20 23:25:35 +00:00
""" Generate platform strings schema like strings.sensor.json.
Example of valid data :
{
" state " : {
" moon__phase " : {
" full " : " Full "
}
}
}
"""
2020-04-20 03:35:49 +00:00
def device_class_validator ( value ) :
2020-04-20 23:25:35 +00:00
""" Key validator for platorm states.
Platform states are only allowed to provide states for device classes they prefix .
"""
2020-04-20 03:35:49 +00:00
if not value . startswith ( f " { integration . domain } __ " ) :
raise vol . Invalid (
2020-05-12 17:50:44 +00:00
f " Device class need to start with ' { integration . domain } __ ' . Key { value } is invalid. See https://developers.home-assistant.io/docs/internationalization/core#stringssensorjson "
2020-04-20 03:35:49 +00:00
)
slug_friendly = value . replace ( " __ " , " _ " , 1 )
slugged = slugify ( slug_friendly )
if slug_friendly != slugged :
2020-04-20 23:25:35 +00:00
raise vol . Invalid (
f " invalid device class { value } . After domain__, needs to be all lowercase, no spaces. "
)
2020-04-20 03:35:49 +00:00
return value
return vol . Schema (
{
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2020-04-20 23:25:35 +00:00
cv . schema_with_slug_keys ( str , slug_validator = lowercase_validator ) ,
slug_validator = device_class_validator ,
2020-04-20 03:35:49 +00:00
)
}
)
2020-05-14 17:33:14 +00:00
ONBOARDING_SCHEMA = vol . Schema ( { vol . Required ( " area " ) : { str : cv . string_with_no_html } } )
2020-04-15 23:58:20 +00:00
2020-04-20 23:25:35 +00:00
def validate_translation_file ( config : Config , integration : Integration , all_strings ) :
2020-04-15 23:58:20 +00:00
""" Validate translation files for integration. """
2020-04-21 23:11:05 +00:00
if config . specific_integrations :
check_translations_directory_name ( integration )
2020-04-22 13:24:45 +00:00
strings_files = [ integration . path / " strings.json " ]
# Also validate translations for custom integrations
if config . specific_integrations :
# Only English needs to be always complete
strings_files . append ( integration . path / " translations/en.json " )
2020-04-20 23:25:35 +00:00
references = [ ]
2020-04-15 23:58:20 +00:00
2020-04-22 13:24:45 +00:00
if integration . domain == " auth " :
strings_schema = gen_auth_schema ( config , integration )
elif integration . domain == " onboarding " :
strings_schema = ONBOARDING_SCHEMA
else :
strings_schema = gen_strings_schema ( config , integration )
2020-04-20 03:35:49 +00:00
2020-04-22 13:24:45 +00:00
for strings_file in strings_files :
if not strings_file . is_file ( ) :
continue
name = str ( strings_file . relative_to ( integration . path ) )
2020-04-20 03:35:49 +00:00
try :
2020-04-22 13:24:45 +00:00
strings = json . loads ( strings_file . read_text ( ) )
except ValueError as err :
integration . add_error ( " translations " , f " Invalid JSON in { name } : { err } " )
continue
try :
strings_schema ( strings )
2020-04-20 03:35:49 +00:00
except vol . Invalid as err :
integration . add_error (
2020-04-22 13:24:45 +00:00
" translations " , f " Invalid { name } : { humanize_error ( strings , err ) } "
2020-04-20 03:35:49 +00:00
)
2020-04-20 23:25:35 +00:00
else :
2020-04-22 13:24:45 +00:00
if strings_file . name == " strings.json " :
find_references ( strings , name , references )
platform_string_schema = gen_platform_strings_schema ( config , integration )
platform_strings = [ integration . path . glob ( " strings.*.json " ) ]
if config . specific_integrations :
platform_strings . append ( integration . path . glob ( " translations/*.en.json " ) )
2020-04-20 03:35:49 +00:00
2020-04-22 13:24:45 +00:00
for path in chain ( * platform_strings ) :
name = str ( path . relative_to ( integration . path ) )
try :
strings = json . loads ( path . read_text ( ) )
except ValueError as err :
integration . add_error ( " translations " , f " Invalid JSON in { name } : { err } " )
continue
2020-04-20 03:35:49 +00:00
try :
2020-04-22 13:24:45 +00:00
platform_string_schema ( strings )
2020-04-20 03:35:49 +00:00
except vol . Invalid as err :
msg = f " Invalid { path . name } : { humanize_error ( strings , err ) } "
if config . specific_integrations :
integration . add_warning ( " translations " , msg )
else :
integration . add_error ( " translations " , msg )
2020-04-20 23:25:35 +00:00
else :
find_references ( strings , path . name , references )
if config . specific_integrations :
return
# Validate references
for reference in references :
parts = reference [ " ref " ] . split ( " :: " )
search = all_strings
key = parts . pop ( 0 )
while parts and key in search :
search = search [ key ]
key = parts . pop ( 0 )
2020-04-22 00:57:21 +00:00
if parts or key not in search :
2020-04-20 23:25:35 +00:00
integration . add_error (
" translations " ,
f " { reference [ ' source ' ] } contains invalid reference { reference [ ' ref ' ] } : Could not find { key } " ,
)
2020-04-15 23:58:20 +00:00
2021-03-18 21:58:19 +00:00
def validate ( integrations : dict [ str , Integration ] , config : Config ) :
2020-04-15 23:58:20 +00:00
""" Handle JSON files inside integrations. """
2020-04-20 23:25:35 +00:00
if config . specific_integrations :
all_strings = None
else :
all_strings = upload . generate_upload_data ( )
2020-04-15 23:58:20 +00:00
for integration in integrations . values ( ) :
2020-04-20 23:25:35 +00:00
validate_translation_file ( config , integration , all_strings )