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
2022-11-23 18:05:31 +00:00
from typing import Any
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:(.+) \ % \ ] "
2023-01-15 16:43:34 +00:00
RE_TRANSLATION_KEY = re . compile ( r " ^(?!.+[_-] {2} )(?![_-])[a-z0-9-_]+(?<![_-])$ " )
2023-04-17 07:36:25 +00:00
RE_COMBINED_REFERENCE = re . compile ( r " (.+ \ [ % )|( % \ ].+) " )
2020-04-20 23:25:35 +00:00
2022-07-20 12:46:06 +00:00
# Only allow translation of integration names if they contain non-brand names
2021-07-22 06:37:33 +00:00
ALLOW_NAME_TRANSLATION = {
" cert_expiry " ,
2021-12-28 20:17:53 +00:00
" cpuspeed " ,
2021-07-22 06:37:33 +00:00
" emulated_roku " ,
2022-03-28 17:41:39 +00:00
" faa_delays " ,
2021-07-22 06:37:33 +00:00
" garages_amsterdam " ,
2023-07-08 09:43:14 +00:00
" generic " ,
2021-07-22 06:37:33 +00:00
" google_travel_time " ,
" homekit_controller " ,
" islamic_prayer_times " ,
2023-07-05 06:35:32 +00:00
" local_calendar " ,
2021-07-22 06:37:33 +00:00
" local_ip " ,
" nmap_tracker " ,
" rpi_power " ,
" waze_travel_time " ,
2023-07-10 13:49:25 +00:00
" zodiac " ,
2021-07-22 06:37:33 +00:00
}
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 "
)
2022-11-23 18:05:31 +00:00
def allow_name_translation ( integration : Integration ) - > bool :
2022-03-28 17:41:39 +00:00
""" Validate that the translation name is not the same as the integration name. """
# Only enforce for core because custom integrations can't be
# added to allow list.
return integration . core and (
integration . domain in ALLOW_NAME_TRANSLATION
or integration . quality_scale == " internal "
)
2020-04-21 23:11:05 +00:00
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
2022-11-23 18:05:31 +00:00
def find_references (
strings : dict [ str , Any ] ,
prefix : str ,
found : list [ dict [ str , str ] ] ,
) - > None :
2020-04-20 23:25:35 +00:00
""" Find references. """
for key , value in strings . items ( ) :
if isinstance ( value , dict ) :
find_references ( value , f " { prefix } :: { key } " , found )
continue
2023-01-15 16:43:34 +00:00
if match := re . match ( RE_REFERENCE , value ) :
2020-04-20 23:25:35 +00:00
found . append ( { " source " : f " { prefix } :: { key } " , " ref " : match . groups ( ) [ 0 ] } )
2022-11-23 18:05:31 +00:00
def removed_title_validator (
config : Config ,
integration : Integration ,
value : Any ,
) - > Any :
2020-04-17 01:00:30 +00:00
""" 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
2023-01-15 16:43:34 +00:00
def translation_key_validator ( value : str ) - > str :
""" Validate value is valid translation key. """
if RE_TRANSLATION_KEY . match ( value ) is None :
raise vol . Invalid (
f " Invalid translation key ' { value } ' , need to be [a-z0-9-_]+ and "
" cannot start or end with a hyphen or underscore. "
)
2020-04-20 23:25:35 +00:00
return value
2023-04-17 07:36:25 +00:00
def translation_value_validator ( value : Any ) - > str :
""" Validate that the value is a valid translation.
- prevents string with HTML
- prevents combined translations
"""
value = cv . string_with_no_html ( value )
if RE_COMBINED_REFERENCE . search ( value ) :
raise vol . Invalid ( " the string should not contain combined translations " )
return str ( 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 ,
2022-03-28 17:32:15 +00:00
mandatory_description : str | None = None ,
2022-11-23 18:05:31 +00:00
) - > vol . All :
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 = {
2023-04-17 07:36:25 +00:00
vol . Optional ( " flow_title " ) : translation_value_validator ,
2020-04-15 23:58:20 +00:00
vol . Required ( " step " ) : {
str : {
2023-04-17 07:36:25 +00:00
step_title_class ( " title " ) : translation_value_validator ,
vol . Optional ( " description " ) : translation_value_validator ,
vol . Optional ( " data " ) : { str : translation_value_validator } ,
vol . Optional ( " data_description " ) : { str : translation_value_validator } ,
vol . Optional ( " menu_options " ) : { str : translation_value_validator } ,
vol . Optional ( " submit " ) : translation_value_validator ,
2020-04-15 23:58:20 +00:00
}
} ,
2023-04-17 07:36:25 +00:00
vol . Optional ( " error " ) : { str : translation_value_validator } ,
vol . Optional ( " abort " ) : { str : translation_value_validator } ,
vol . Optional ( " progress " ) : { str : translation_value_validator } ,
vol . Optional ( " create_entry " ) : { str : translation_value_validator } ,
2020-04-15 23:58:20 +00:00
}
2020-04-17 01:00:30 +00:00
if flow_title == REQUIRED :
2023-04-17 07:36:25 +00:00
schema [ vol . Required ( " title " ) ] = translation_value_validator
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
2022-11-23 18:05:31 +00:00
def data_description_validator ( value : dict [ str , Any ] ) - > dict [ str , Any ] :
2022-03-25 00:25:50 +00:00
""" Validate data description. """
for step_info in value [ " step " ] . values ( ) :
if " data_description " not in step_info :
continue
for key in step_info [ " data_description " ] :
if key not in step_info [ " data " ] :
raise vol . Invalid ( f " data_description key { key } is not in data " )
return value
2022-03-28 17:32:15 +00:00
validators = [ vol . Schema ( schema ) , data_description_validator ]
if mandatory_description is not None :
2022-11-23 18:05:31 +00:00
def validate_description_set ( value : dict [ str , Any ] ) - > dict [ str , Any ] :
2022-03-28 17:32:15 +00:00
""" Validate description is set. """
steps = value [ " step " ]
if mandatory_description not in steps :
raise vol . Invalid ( f " { mandatory_description } needs to be defined " )
if " description " not in steps [ mandatory_description ] :
raise vol . Invalid ( f " Step { mandatory_description } needs a description " )
return value
validators . append ( validate_description_set )
2022-03-28 17:41:39 +00:00
if not allow_name_translation ( integration ) :
2022-11-23 18:05:31 +00:00
def name_validator ( value : dict [ str , Any ] ) - > dict [ str , Any ] :
2022-03-28 17:41:39 +00:00
""" Validate name. """
for step_id , info in value [ " step " ] . items ( ) :
if info . get ( " title " ) == integration . name :
raise vol . Invalid (
f " Do not set title of step { step_id } if it ' s a brand name "
" or add exception to ALLOW_NAME_TRANSLATION "
)
return value
validators . append ( name_validator )
2022-03-28 17:32:15 +00:00
return vol . All ( * validators )
2020-04-17 01:00:30 +00:00
2022-07-20 12:46:06 +00:00
def gen_strings_schema ( config : Config , integration : Integration ) - > vol . Schema :
2020-04-17 01:00:30 +00:00
""" Generate a strings schema. """
return vol . Schema (
{
2023-04-17 07:36:25 +00:00
vol . Optional ( " title " ) : translation_value_validator ,
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 ,
2022-03-28 17:32:15 +00:00
mandatory_description = (
" user " if integration . integration_type == " helper " else None
) ,
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 ,
) ,
2023-01-17 15:22:19 +00:00
vol . Optional ( " selector " ) : cv . schema_with_slug_keys (
{
" options " : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
slug_validator = translation_key_validator ,
2023-01-17 15:22:19 +00:00
)
} ,
slug_validator = vol . Any ( " _ " , cv . slug ) ,
) ,
2020-04-17 01:00:30 +00:00
vol . Optional ( " device_automation " ) : {
2023-04-17 07:36:25 +00:00
vol . Optional ( " action_type " ) : { str : translation_value_validator } ,
vol . Optional ( " condition_type " ) : { str : translation_value_validator } ,
vol . Optional ( " trigger_type " ) : { str : translation_value_validator } ,
vol . Optional ( " trigger_subtype " ) : { str : translation_value_validator } ,
2020-04-17 01:00:30 +00:00
} ,
2020-11-10 22:56:50 +00:00
vol . Optional ( " system_health " ) : {
2023-01-15 16:43:34 +00:00
vol . Optional ( " info " ) : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
slug_validator = translation_key_validator ,
2023-01-15 16:43:34 +00:00
) ,
2020-11-10 22:56:50 +00:00
} ,
2021-04-30 16:29:34 +00:00
vol . Optional ( " config_panel " ) : cv . schema_with_slug_keys (
cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
slug_validator = translation_key_validator ,
2021-04-30 16:29:34 +00:00
) ,
slug_validator = vol . Any ( " _ " , cv . slug ) ,
) ,
2022-06-08 14:09:32 +00:00
vol . Optional ( " application_credentials " ) : {
2023-04-17 07:36:25 +00:00
vol . Optional ( " description " ) : translation_value_validator ,
2022-06-08 14:09:32 +00:00
} ,
2022-07-20 12:46:06 +00:00
vol . Optional ( " issues " ) : {
str : vol . All (
cv . has_at_least_one_key ( " description " , " fix_flow " ) ,
vol . Schema (
{
2023-04-17 07:36:25 +00:00
vol . Required ( " title " ) : translation_value_validator ,
2022-07-20 12:46:06 +00:00
vol . Exclusive (
" description " , " fixable "
2023-04-17 07:36:25 +00:00
) : translation_value_validator ,
2022-07-20 12:46:06 +00:00
vol . Exclusive ( " fix_flow " , " fixable " ) : gen_data_entry_schema (
config = config ,
integration = integration ,
flow_title = UNDEFINED ,
require_step_title = False ,
) ,
} ,
) ,
)
} ,
2023-03-16 11:16:08 +00:00
vol . Optional ( " entity_component " ) : cv . schema_with_slug_keys (
{
2023-03-16 20:10:20 +00:00
vol . Optional ( " name " ) : str ,
2023-03-16 11:16:08 +00:00
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
2023-03-16 11:16:08 +00:00
slug_validator = translation_key_validator ,
) ,
vol . Optional ( " state_attributes " ) : cv . schema_with_slug_keys (
{
vol . Optional ( " name " ) : str ,
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
2023-03-16 11:16:08 +00:00
slug_validator = translation_key_validator ,
) ,
} ,
slug_validator = translation_key_validator ,
) ,
} ,
slug_validator = vol . Any ( " _ " , cv . slug ) ,
) ,
vol . Optional ( " entity " ) : cv . schema_with_slug_keys (
cv . schema_with_slug_keys (
{
2023-04-17 07:36:25 +00:00
vol . Optional ( " name " ) : translation_value_validator ,
2023-03-16 11:16:08 +00:00
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
2023-03-16 11:16:08 +00:00
slug_validator = translation_key_validator ,
) ,
vol . Optional ( " state_attributes " ) : cv . schema_with_slug_keys (
{
2023-04-17 07:36:25 +00:00
vol . Optional ( " name " ) : translation_value_validator ,
2023-01-15 16:43:34 +00:00
vol . Optional ( " state " ) : cv . schema_with_slug_keys (
2023-04-17 07:36:25 +00:00
translation_value_validator ,
2023-01-15 16:43:34 +00:00
slug_validator = translation_key_validator ,
) ,
2023-03-16 11:16:08 +00:00
} ,
2023-01-15 16:43:34 +00:00
slug_validator = translation_key_validator ,
) ,
2023-03-16 11:16:08 +00:00
} ,
slug_validator = translation_key_validator ,
) ,
slug_validator = cv . slug ,
) ,
2023-07-11 13:52:12 +00:00
vol . Optional ( " services " ) : cv . schema_with_slug_keys (
{
vol . Required ( " name " ) : translation_value_validator ,
vol . Required ( " description " ) : translation_value_validator ,
vol . Optional ( " fields " ) : cv . schema_with_slug_keys (
{
vol . Required ( " name " ) : str ,
vol . Required ( " description " ) : translation_value_validator ,
2023-07-24 13:57:02 +00:00
vol . Optional ( " example " ) : translation_value_validator ,
2023-07-11 13:52:12 +00:00
} ,
slug_validator = translation_key_validator ,
) ,
} ,
slug_validator = translation_key_validator ,
) ,
2020-04-15 23:58:20 +00:00
}
2020-04-17 01:00:30 +00:00
)
2022-11-23 18:05:31 +00:00
def gen_auth_schema ( config : Config , integration : Integration ) - > vol . Schema :
2020-04-17 01:00:30 +00:00
""" 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
2022-11-29 21:34:55 +00:00
def gen_ha_hardware_schema ( config : Config , integration : Integration ) :
""" Generate auth schema. """
return vol . Schema (
{
str : {
vol . Optional ( " options " ) : gen_data_entry_schema (
config = config ,
integration = integration ,
flow_title = UNDEFINED ,
require_step_title = False ,
)
}
}
)
2022-11-23 18:05:31 +00:00
def gen_platform_strings_schema ( config : Config , integration : Integration ) - > vol . Schema :
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
2022-11-23 18:05:31 +00:00
def device_class_validator ( value : str ) - > str :
2021-10-26 18:41:44 +00:00
""" Key validator for platform states.
2020-04-20 23:25:35 +00:00
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 (
2023-01-15 16:43:34 +00:00
cv . schema_with_slug_keys ( str , slug_validator = translation_key_validator ) ,
2020-04-20 23:25:35 +00:00
slug_validator = device_class_validator ,
2020-04-20 03:35:49 +00:00
)
}
)
2023-04-17 07:36:25 +00:00
ONBOARDING_SCHEMA = vol . Schema (
{ vol . Required ( " area " ) : { str : translation_value_validator } }
)
2020-04-15 23:58:20 +00:00
2022-09-28 15:31:48 +00:00
def validate_translation_file ( # noqa: C901
2022-11-23 18:05:31 +00:00
config : Config ,
integration : Integration ,
all_strings : dict [ str , Any ] | None ,
) - > None :
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 " )
2022-11-23 18:05:31 +00:00
references : list [ dict [ str , str ] ] = [ ]
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
2022-11-29 21:34:55 +00:00
elif integration . domain == " homeassistant_hardware " :
strings_schema = gen_ha_hardware_schema ( config , integration )
2020-04-22 13:24:45 +00:00
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 )
2022-09-28 15:31:48 +00:00
if ( title := strings . get ( " title " ) ) is not None :
integration . translated_name = True
if title == integration . name and not allow_name_translation (
integration
) :
integration . add_error (
" translations " ,
" Don ' t specify title in translation strings if it ' s a brand "
" name or add exception to ALLOW_NAME_TRANSLATION " ,
)
2021-07-22 06:37:33 +00:00
2020-04-22 13:24:45 +00:00
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
2022-11-23 18:05:31 +00:00
if not all_strings : # Nothing to validate against
return
2020-04-20 23:25:35 +00:00
# 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
2022-11-23 18:05:31 +00:00
def validate ( integrations : dict [ str , Integration ] , config : Config ) - > None :
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 :
2022-11-23 18:05:31 +00:00
all_strings = upload . generate_upload_data ( ) # type: ignore[no-untyped-call]
2020-04-20 23:25:35 +00:00
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 )