Skip to content
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

Merged
merged 21 commits into from
Mar 1, 2018
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ desktop.ini

# mypy
/.mypy_cache/*

# Secrets
.lokalise_token
9 changes: 9 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,13 @@ cache:
install: pip install -U tox coveralls
language: python
script: travis_wait 30 tox --develop
services:
- docker
before_deploy:
- docker pull lokalise/lokalise-cli
deploy:
provider: script
script: script/travis_deploy
on:
branch: dev
after_success: coveralls
20 changes: 20 additions & 0 deletions homeassistant/components/frontend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from homeassistant.config import find_config_file, load_yaml_config_file
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback
from homeassistant.helpers.translation import async_get_translations
from homeassistant.loader import bind_hass

REQUIREMENTS = ['home-assistant-frontend==20180221.1', 'user-agents==1.1.0']
Expand Down Expand Up @@ -379,6 +380,8 @@ def finalize_panel(panel):

async_setup_themes(hass, conf.get(CONF_THEMES))

hass.http.register_view(TranslationsView)

return True


Expand Down Expand Up @@ -541,6 +544,23 @@ def get(self, request):
})


class TranslationsView(HomeAssistantView):
"""View to return backend defined translations."""

url = '/api/translations/{language}'
name = 'api:translations'

@asyncio.coroutine
def get(self, request, language):
"""Return translations."""
hass = request.app['hass']

resources = yield from async_get_translations(hass, language)
return self.json({
'resources': resources,
})


def _fingerprint(path):
"""Fingerprint a file."""
with open(path) as fil:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"state": {
"autumn": "Autumn",
"spring": "Spring",
"summer": "Summer",
"winter": "Winter"
}
}
8 changes: 4 additions & 4 deletions homeassistant/components/sensor/season.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
NORTHERN = 'northern'
SOUTHERN = 'southern'
EQUATOR = 'equator'
STATE_SPRING = 'Spring'
STATE_SUMMER = 'Summer'
STATE_AUTUMN = 'Autumn'
STATE_WINTER = 'Winter'
STATE_SPRING = 'spring'
STATE_SUMMER = 'summer'
STATE_AUTUMN = 'autumn'
STATE_WINTER = 'winter'
TYPE_ASTRONOMICAL = 'astronomical'
TYPE_METEOROLOGICAL = 'meteorological'
VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL]
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/sensor/strings.season.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"state": {
"spring": "Spring",
"summer": "Summer",
"autumn": "Autumn",
"winter": "Winter"
}
}
129 changes: 129 additions & 0 deletions homeassistant/helpers/translation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Translation string lookup helpers."""
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:
loaded[translation_file] = load_json(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.
Copy link
Member

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?

Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

# 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


@bind_hass
async 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()
Copy link
Member

@balloob balloob Feb 26, 2018

Choose a reason for hiding this comment

The 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 = await hass.async_add_job(
load_translations_files, missing)

# Update cache
for component in components:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you calculating missing again here?

if component not in translation_cache:
json_file = component_translation_file(component, language)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can send a dictionary mapping component -> file to load_translation_files so we don't have to generate all the keys again.

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})


@bind_hass
async def async_get_translations(hass, language):
"""Return all backend translations."""
if language == 'en':
resources = await async_get_component_resources(hass, language)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move this before the if and turn the else into an if language != 'en'

else:
# Fetch the English resources, as a fallback for missing keys
resources = await async_get_component_resources(hass, 'en')
native_resources = await async_get_component_resources(
hass, language)
resources.update(native_resources)

return resources
4 changes: 2 additions & 2 deletions homeassistant/util/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \
return {} if default is _UNDEFINED else default


def save_json(filename: str, config: Union[List, Dict]):
def save_json(filename: str, data: Union[List, Dict]):
"""Save JSON data to a file.

Returns True on success.
"""
try:
data = json.dumps(config, sort_keys=True, indent=4)
data = json.dumps(data, sort_keys=True, indent=4)
with open(filename, 'w', encoding='utf-8') as fdesc:
fdesc.write(data)
return True
Expand Down
39 changes: 39 additions & 0 deletions script/translations_download
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
81 changes: 81 additions & 0 deletions 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():

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too many blank lines (3)

Choose a reason for hiding this comment

The 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()
Loading