-
-
Notifications
You must be signed in to change notification settings - Fork 32.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support serving of backend translations #12453
Changes from 12 commits
d01424b
6db8da6
bb2f328
e70bafc
4f6529b
b613332
83336a4
cb3bb00
c8afa71
936b36e
dd5b365
1833f29
cce1ae4
4f5539f
8895207
5f0ff59
9edf9dd
3006593
1ae2b3c
2287676
dcd9f9d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -103,3 +103,6 @@ desktop.ini | |
|
||
# mypy | ||
/.mypy_cache/* | ||
|
||
# Secrets | ||
.lokalise_token |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"state": { | ||
"autumn": "Autumn", | ||
"spring": "Spring", | ||
"summer": "Summer", | ||
"winter": "Winter" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"state": { | ||
"spring": "Spring", | ||
"summer": "Summer", | ||
"autumn": "Autumn", | ||
"winter": "Winter" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
"""Translation string lookup helpers.""" | ||
import asyncio | ||
import logging | ||
# pylint: disable=unused-import | ||
from typing import Optional # NOQA | ||
from os import path | ||
|
||
from homeassistant.loader import get_component, bind_hass | ||
from homeassistant.util.json import load_json | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
TRANSLATION_STRING_CACHE = 'translation_string_cache' | ||
|
||
|
||
def recursive_flatten(prefix, data): | ||
"""Return a flattened representation of dict data.""" | ||
output = {} | ||
for key, value in data.items(): | ||
if isinstance(value, dict): | ||
output.update( | ||
recursive_flatten('{}{}.'.format(prefix, key), value)) | ||
else: | ||
output['{}{}'.format(prefix, key)] = value | ||
return output | ||
|
||
|
||
def flatten(data): | ||
"""Return a flattened representation of dict data.""" | ||
return recursive_flatten('', data) | ||
|
||
|
||
def component_translation_file(component, language): | ||
"""Return the translation json file location for a component.""" | ||
if '.' in component: | ||
name = component.split('.', 1)[1] | ||
else: | ||
name = component | ||
|
||
module = get_component(component) | ||
component_path = path.dirname(module.__file__) | ||
|
||
# If loading translations for the package root, (__init__.py), the | ||
# prefix should be skipped. | ||
if module.__name__ == module.__package__: | ||
filename = '{}.json'.format(language) | ||
else: | ||
filename = '{}.{}.json'.format(name, language) | ||
|
||
return path.join(component_path, '.translations', filename) | ||
|
||
|
||
def load_translations_files(translation_files): | ||
"""Load and parse translation.json files.""" | ||
loaded = {} | ||
for translation_file in translation_files: | ||
try: | ||
loaded[translation_file] = load_json(translation_file) | ||
except FileNotFoundError: | ||
loaded[translation_file] = {} | ||
|
||
return loaded | ||
|
||
|
||
def build_resources(translation_cache, components): | ||
"""Build the resources response for the given components.""" | ||
# Build response | ||
resources = {} | ||
for component in components: | ||
if '.' not in component: | ||
domain = component | ||
else: | ||
domain = component.split('.', 1)[0] | ||
|
||
if domain not in resources: | ||
resources[domain] = {} | ||
|
||
# Add the translations for this component to the domain resources. | ||
# Since clients cannot determine which platform an entity belongs to, | ||
# all translations for a domain will be returned together. | ||
resources[domain].update(translation_cache[component]) | ||
|
||
return resources | ||
|
||
|
||
@asyncio.coroutine | ||
@bind_hass | ||
def async_get_component_resources(hass, language): | ||
"""Return translation resources for all components.""" | ||
if TRANSLATION_STRING_CACHE not in hass.data: | ||
hass.data[TRANSLATION_STRING_CACHE] = {} | ||
if language not in hass.data[TRANSLATION_STRING_CACHE]: | ||
hass.data[TRANSLATION_STRING_CACHE][language] = {} | ||
translation_cache = hass.data[TRANSLATION_STRING_CACHE][language] | ||
|
||
# Get the set of components | ||
components = hass.config.components | ||
|
||
# Load missing files | ||
missing = set() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI Sets can be subtracted from one another. missing = hass.config.components - set(translation_cache) |
||
for component in components: | ||
if component not in translation_cache: | ||
missing.add(component_translation_file(component, language)) | ||
|
||
if missing: | ||
loaded = yield from hass.async_add_job( | ||
load_translations_files, missing) | ||
|
||
# Update cache | ||
for component in components: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you calculating |
||
if component not in translation_cache: | ||
json_file = component_translation_file(component, language) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we can send a dictionary mapping component -> file to |
||
translation_cache[component] = loaded[json_file] | ||
|
||
resources = build_resources(translation_cache, components) | ||
|
||
# Return the component translations resources under the 'component' | ||
# translation namespace | ||
return flatten({'component': resources}) | ||
|
||
|
||
@asyncio.coroutine | ||
@bind_hass | ||
def async_get_translations(hass, language): | ||
"""Return all backend translations.""" | ||
if language is 'en': | ||
resources = yield from async_get_component_resources(hass, language) | ||
else: | ||
# Fetch the English resources, as a fallback for missing keys | ||
resources = yield from async_get_component_resources(hass, 'en') | ||
native_resources = yield from async_get_component_resources(hass, language) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. line too long (83 > 79 characters) |
||
resources.update(native_resources) | ||
|
||
return resources |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
#!/usr/bin/env bash | ||
|
||
# Safe bash settings | ||
# -e Exit on command fail | ||
# -u Exit on unset variable | ||
# -o pipefail Exit if piped command has error code | ||
set -eu -o pipefail | ||
|
||
cd "$(dirname "$0")/.." | ||
|
||
if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then | ||
echo "Lokalise API token is required to download the latest set of" \ | ||
"translations. Please create an account by using the following link:" \ | ||
"https://lokalise.co/signup/104514435a91b786c77a78.62627628/all/" \ | ||
"Place your token in a new file \".lokalise_token\" in the repo" \ | ||
"root directory." | ||
exit 1 | ||
fi | ||
|
||
# Load token from file if not already in the environment | ||
[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)" | ||
|
||
PROJECT_ID="104514435a91b786c77a78.62627628" | ||
LOCAL_DIR="$(pwd)/build/translations-download" | ||
FILE_FORMAT=json | ||
|
||
mkdir -p ${LOCAL_DIR} | ||
|
||
docker pull lokalise/lokalise-cli | ||
docker run \ | ||
-v ${LOCAL_DIR}:/opt/dest/locale \ | ||
lokalise/lokalise-cli lokalise \ | ||
--token ${LOKALISE_TOKEN} \ | ||
export ${PROJECT_ID} \ | ||
--export_empty skip \ | ||
--type json \ | ||
--unzip_to /opt/dest | ||
|
||
script/translations_download_split.py |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
#!/usr/bin/env python3 | ||
"""Merge all translation sources into a single JSON file.""" | ||
import glob | ||
import os | ||
import re | ||
|
||
from homeassistant.util import json as json_util | ||
|
||
FILENAME_FORMAT = re.compile(r'strings\.(?P<suffix>\w+)\.json') | ||
|
||
|
||
def get_language(path): | ||
"""Get the language code for the given file path.""" | ||
return os.path.splitext(os.path.basename(path))[0] | ||
|
||
|
||
def get_component_path(lang, component): | ||
"""Get the component translation path.""" | ||
if os.path.isdir(os.path.join("homeassistant", "components", component)): | ||
return os.path.join( | ||
"homeassistant", "components", component, ".translations", | ||
"{}.json".format(lang)) | ||
else: | ||
return os.path.join( | ||
"homeassistant", "components", ".translations", | ||
"{}.{}.json".format(component, lang)) | ||
|
||
|
||
def get_platform_path(lang, component, platform): | ||
"""Get the platform translation path.""" | ||
if os.path.isdir(os.path.join( | ||
"homeassistant", "components", component, platform)): | ||
return os.path.join( | ||
"homeassistant", "components", component, platform, | ||
".translations", "{}.json".format(lang)) | ||
else: | ||
return os.path.join( | ||
"homeassistant", "components", component, ".translations", | ||
"{}.{}.json".format(platform, lang)) | ||
|
||
|
||
def get_component_translations(translations): | ||
"""Get the component level translations.""" | ||
translations = translations.copy() | ||
translations.pop('platform', None) | ||
|
||
return translations | ||
|
||
|
||
def save_language_translations(lang, translations): | ||
"""Distribute the translations for this language.""" | ||
components = translations.get('component', {}) | ||
for component, component_translations in components.items(): | ||
base_translations = get_component_translations(component_translations) | ||
if base_translations: | ||
path = get_component_path(lang, component) | ||
os.makedirs(os.path.dirname(path), exist_ok=True) | ||
json_util.save_json(path, base_translations) | ||
|
||
for platform, platform_translations in component_translations.get( | ||
'platform', {}).items(): | ||
path = get_platform_path(lang, component, platform) | ||
os.makedirs(os.path.dirname(path), exist_ok=True) | ||
json_util.save_json(path, platform_translations) | ||
|
||
|
||
def main(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. too many blank lines (3) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. too many blank lines (3) |
||
"""Main section of the script.""" | ||
if not os.path.isfile("requirements_all.txt"): | ||
print("Run this from HA root dir") | ||
return | ||
|
||
paths = glob.iglob("build/translations-download/*.json") | ||
for path in paths: | ||
lang = get_language(path) | ||
translations = json_util.load_json(path) | ||
save_language_translations(lang, translations) | ||
|
||
|
||
if __name__ == '__main__': | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something I was thinking about the other day, especially now that we're doing more entity based config: should we store platform as an attribute in the state machine?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is going to get massive for sensor component.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The platform as an attribute would certainly be a big help here. It would let us completely avoid this problem and keep the translations namespaced separately. That's something we could add later though if we decide to make it available, as the conflict only exists in the API response. All of our translations at rest are stored separately per platform.
And FWIW this will only return translations for loaded components, so although we'll have lots of translations merged, each instance should only serve the translations it's using.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The platform as an attribute would certainly be a big help here. It would let us completely avoid this problem and keep the translations namespaced separately. That's something we could add later though if we decide to make it available, as the conflict only exists in the API response. All of our translations at rest are stored separately per platform.
And FWIW this will only return translations for loaded components, so although we'll have lots of translations merged, each instance should only serve the translations it's using.