diff --git a/.gitignore b/.gitignore index 0cf72cd4..cf0a6565 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ profile.stats *.css.map *.js.map +/translations/*/LC_MESSAGES/*.mo + pillar/web/static/assets/css/*.css pillar/web/static/assets/js/*.min.js pillar/web/static/storage/ diff --git a/README.md b/README.md index 8af7d93c..5f4a74de 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,15 @@ RabbitMQ. You can run the Celery Worker using `manage.py celery worker`. Find other Celery operations with the `manage.py celery` command. + +## Translations + +If the language you want to support doesn't exist, you need to run: `translations init es_AR`. + +Every time a new string is marked for translation you need to update the entire catalog: `translations update` + +And once more strings are translated, you need to compile the translations: `translations compile` + +*To mark strings strings for translations in Python scripts you need to +wrap them with the `flask_babel.gettext` function. +For .pug templates wrap them with `_()`.* diff --git a/pillar/__init__.py b/pillar/__init__.py index e4cd3db2..13175f1a 100644 --- a/pillar/__init__.py +++ b/pillar/__init__.py @@ -11,11 +11,13 @@ import tempfile import typing import os import os.path +import pathlib import jinja2 from eve import Eve import flask -from flask import render_template, request +from flask import g, render_template, request +from flask_babel import Babel, gettext as _ from flask.templating import TemplateNotFound import pymongo.collection import pymongo.database @@ -118,6 +120,8 @@ class PillarServer(Eve): self._config_caching() + self._config_translations() + # Celery itself is configured after all extensions have loaded. self.celery: Celery = None @@ -234,6 +238,74 @@ class PillarServer(Eve): from flask_cache import Cache self.cache = Cache(self) + def set_languages(self, translations_folder: pathlib.Path): + """Set the supported languages based on translations folders + + English is an optional language included by default, since we will + never have a translations folder for it. + """ + self.default_locale = self.config['DEFAULT_LOCALE'] + self.config['BABEL_DEFAULT_LOCALE'] = self.default_locale + + # Determine available languages. + languages = list() + + # The available languages will be determined based on available + # translations in the //translations/ folder. The exception is (American) English + # since all the text is originally in English already. + # That said, if rare occasions we may want to never show + # the site in English. + + if self.config['SUPPORT_ENGLISH']: + languages.append('en_US') + + base_path = pathlib.Path(self.app_root) / 'translations' + + if not base_path.is_dir(): + self.log.debug('Project has no translations folder: %s', base_path) + else: + languages.extend(i.name for i in base_path.iterdir() if i.is_dir()) + + # Use set for quicker lookup + self.languages = set(languages) + + self.log.info('Available languages: %s' % ', '.join(self.languages)) + + def _config_translations(self): + """ + Initialize translations variable. + + The BABEL_TRANSLATION_DIRECTORIES has the folder for the compiled + translations files. It uses ; separation for the extension folders. + """ + self.log.info('Configure translations') + translations_path = pathlib.Path(__file__).parents[1].joinpath('translations') + + self.config['BABEL_TRANSLATION_DIRECTORIES'] = str(translations_path) + babel = Babel(self) + + self.set_languages(translations_path) + + # get_locale() is registered as a callback for locale selection. + # That prevents the function from being garbage collected. + @babel.localeselector + def get_locale() -> str: + """ + Callback runs before each request to give us a chance to choose the + language to use when producing its response. + + We set g.locale to be able to access it from the template pages. + We still need to return it explicitly, since this function is + called as part of the babel translation framework. + + We are using the 'Accept-Languages' header to match the available + translations with the user supported languages. + """ + locale = request.accept_languages.best_match( + self.languages, self.default_locale) + g.locale = locale + return locale + def load_extension(self, pillar_extension, url_prefix): from .extension import PillarExtension @@ -293,6 +365,19 @@ class PillarServer(Eve): self.config['DOMAIN'].update(eve_settings['DOMAIN']) + # Configure the extension translations + trpath = pillar_extension.translations_path + if not trpath: + self.log.debug('Extension %s does not have a translations folder', + pillar_extension.name) + return + + self.log.info('Extension %s: adding translations path %s', + pillar_extension.name, trpath) + + # Babel requires semi-colon string separation + self.config['BABEL_TRANSLATION_DIRECTORIES'] += ';' + str(trpath) + def _config_jinja_env(self): # Start with the extensions... paths_list = [ @@ -512,7 +597,7 @@ class PillarServer(Eve): self.log.info('Forwarding ResourceInvalid exception to client: %s', error, exc_info=True) # Raising a Werkzeug 422 exception doens't work, as Flask turns it into a 500. - return 'The submitted data could not be validated.', 422 + return _('The submitted data could not be validated.'), 422 def handle_sdk_method_not_allowed(self, error): """Forwards 405 Method Not Allowed to the client. diff --git a/pillar/cli/__init__.py b/pillar/cli/__init__.py index dcfc07a7..3cb59ef5 100644 --- a/pillar/cli/__init__.py +++ b/pillar/cli/__init__.py @@ -19,4 +19,4 @@ manager = Manager(current_app) manager.add_command('celery', manager_celery) manager.add_command("maintenance", manager_maintenance) manager.add_command("setup", manager_setup) -manager.add_command("operations", manager_operations) +manager.add_command("operations", manager_operations) \ No newline at end of file diff --git a/pillar/cli/translations.py b/pillar/cli/translations.py new file mode 100644 index 00000000..67debbbf --- /dev/null +++ b/pillar/cli/translations.py @@ -0,0 +1,104 @@ +import argparse +import contextlib +import pathlib +import subprocess +import sys + +BABEL_CONFIG = pathlib.Path('translations.cfg') + + +@contextlib.contextmanager +def create_messages_pot() -> pathlib.Path: + """Extract the translatable strings from the source code + + This creates a temporary messages.pot file, to be used to init or + update the translation .mo files. + + It works as a generator, yielding the temporarily created pot file. + The messages.pot file will be deleted at the end of it if all went well. + + :return The path of the messages.pot file created. + """ + if not BABEL_CONFIG.is_file(): + print("No translations config file found: %s" % (BABEL_CONFIG)) + sys.exit(-1) + return + + messages_pot = pathlib.Path('messages.pot') + subprocess.run(('pybabel', 'extract', '-F', BABEL_CONFIG, '-k', 'lazy_gettext', '-o', messages_pot, '.')) + yield messages_pot + messages_pot.unlink() + + +def init(locale): + """ + Initialize the translations for a new language. + """ + with create_messages_pot() as messages_pot: + subprocess.run(('pybabel', 'init', '-i', messages_pot, '-d', 'translations', '-l', locale)) + + +def update(): + """ + Update the strings to be translated. + """ + with create_messages_pot() as messages_pot: + subprocess.run(('pybabel', 'update', '-i', messages_pot, '-d', 'translations')) + + +def compile(): + """ + Compile the translation to be used. + """ + if pathlib.Path('translations').is_dir(): + subprocess.run(('pybabel', 'compile','-d', 'translations')) + else: + print("No translations folder available") + + +def parse_arguments() -> argparse.Namespace: + """ + Parse command-line arguments. + """ + parser = argparse.ArgumentParser(description='Translate Pillar') + + parser.add_argument( + 'mode', + type=str, + help='Init once, update often, compile before deploying.', + choices=['init', 'update', 'compile']) + + parser.add_argument( + 'languages', + nargs='*', + type=str, + help='Languages to initialize: pt it es ...') + + args = parser.parse_args() + if args.mode == 'init' and not args.languages: + parser.error("init requires languages") + + return args + + +def main(): + """ + When calling from the setup.py entry-point we need to parse the arguments + and init/update/compile the translations strings + """ + args = parse_arguments() + + if args.mode == 'init': + for language in args.languages: + init(language) + + elif args.mode == 'update': + update() + + else: # mode == 'compile' + compile() + + +if __name__ == '__main__': + main() + diff --git a/pillar/config.py b/pillar/config.py index e67a4bc5..057a5b5c 100644 --- a/pillar/config.py +++ b/pillar/config.py @@ -187,3 +187,23 @@ USER_CAPABILITIES = defaultdict(**{ 'view-pending-nodes', 'edit-project-node-types', 'create-organization'}, 'org-subscriber': {'subscriber', 'home-project'}, }, default_factory=frozenset) + + +# Internationalization and localization + +# The default locale is US English. +# A locale can include a territory, a codeset and a modifier. +# We only support locale strings with or without territories though. +# For example, nl_NL and pt_BR are not the same language as nl_BE, and pt_PT. +# However we can have a nl, or a pt translation, to be used as a common +# translation when no territorial specific locale is available. +# All translations should be in UTF-8. +# This setting is used as a fallback when there is no good match between the +# browser language and the available translations. +DEFAULT_LOCALE = 'en_US' +# All the available languages will be determined based on available translations +# in the //translations/ folder. The exception is English, since all the text is +# originally in English already. That said, if rare occasions we may want to +# never show the site in English. +SUPPORT_ENGLISH = True + diff --git a/pillar/extension.py b/pillar/extension.py index adb51302..e98b14ae 100644 --- a/pillar/extension.py +++ b/pillar/extension.py @@ -16,6 +16,8 @@ can then be registered to the application at app creation time: """ import abc +import inspect +import pathlib import typing import flask @@ -112,6 +114,23 @@ class PillarExtension(object, metaclass=abc.ABCMeta): """ return None + @property + def translations_path(self) -> typing.Union[pathlib.Path, None]: + """Returns the path where the translations for this extension are stored. + + This is top folder that contains a "translations" sub-folder + + May return None, in which case English will always be used for this extension. + """ + class_filename = pathlib.Path(inspect.getfile(self.__class__)) + + # Pillar extensions instantiate the PillarExtension from a sub-folder in + # the main project (e.g. //blender_cloud/blender_cloud/__init__.py), but + # the translations folders is in the main project folder. + translations_path = class_filename.parents[1] / 'translations' + + return translations_path if translations_path.is_dir() else None + def setup_app(self, app): """Called during app startup, after all extensions have loaded.""" diff --git a/requirements.txt b/requirements.txt index 6e78994f..93c9d774 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ celery[redis]==4.0.2 CommonMark==0.7.2 Eve==0.7.3 Flask==0.12 +Flask-Babel==0.11.2 Flask-Cache==0.13.1 Flask-Script==2.0.5 Flask-Login==0.3.2 diff --git a/setup.py b/setup.py index 6c6c1151..33cce541 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,30 @@ """Setup file for testing, not for packaging/distribution.""" import setuptools +from setuptools.command.develop import develop +from setuptools.command.install import install + + +def translations_compile(): + """Compile any existent translation. + """ + from pillar import cli + cli.translations.compile() + + +class PostDevelopCommand(develop): + """Post-installation for develop mode.""" + def run(self): + super().run() + translations_compile() + + +class PostInstallCommand(install): + """Post-installation for installation mode.""" + def run(self): + super().run() + translations_compile() + setuptools.setup( name='pillar', @@ -36,5 +60,12 @@ setuptools.setup( 'pytest-cov>=2.2.1', 'mock>=2.0.0', ], + entry_points = {'console_scripts': [ + 'translations = pillar.cli.translations:main', + ]}, + cmdclass={ + 'install': PostInstallCommand, + 'develop': PostDevelopCommand, + }, zip_safe=False, ) diff --git a/src/templates/errors/404.pug b/src/templates/errors/404.pug index e1216177..8c75c96f 100644 --- a/src/templates/errors/404.pug +++ b/src/templates/errors/404.pug @@ -3,15 +3,24 @@ #error-container(class="error-404") #error-box .error-top-container - .error-title Not found. + .error-title {{ _("Not found.") }} .error-lead - p Whatever you're looking for, it's not here. + p {{ _("Whatever you're looking for, it's not here.") }} .error-lead.extra p. - Looking for the Open Movies? Check out Blender Foundation's YouTube channel.
Were you looking for tutorials instead? blender.org has a good selection. + {% autoescape false %} + {{ gettext("Looking for the Open Movies? Check out %(link_start)s Blender Foundation's Youtube %(link_end)s channel.", + link_start='', link_end='') }}
+ {{ gettext("Were you looking for tutorials instead? %(blender_org)s has a good selection.", + blender_org='blender.org') }} + {% endautoescape %} p. - Is this content missing? Let us know on Twitter - or e-mail. + {% autoescape false %} + {{ gettext("Is this content missing? Let us know on %(twitter)s or %(email_start)s e-mail %(email_end)s", + twitter='Twitter', + email_start='', + email_end='') }} + {% endautoescape %} | {% endblock %} diff --git a/translations.cfg b/translations.cfg new file mode 100644 index 00000000..c8052d0b --- /dev/null +++ b/translations.cfg @@ -0,0 +1,14 @@ +# This file is used to crawl over the source code looking for +# strings to be extracted for translation. +# +# This file is structured to be parsed by the ConfigParser Python module +# +# For more information please visit: +# http://babel.pocoo.org/en/latest/messages.html + +# Extraction from Python source files +[python: pillar/**.py] + +# Extraction from the template files +[jinja2: src/templates/**.pug] +extensions=jinja2.ext.autoescape,jinja2.ext.with_