From 40c24168fcaf9251f56e8570538e9a7dd48795e9 Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Sat, 12 Feb 2022 11:31:36 +0100 Subject: remake internationalization, using Babel this time --- src/hydrilla/server/serve.py | 264 +++++++++++++++++++++---------------------- 1 file changed, 128 insertions(+), 136 deletions(-) (limited to 'src/hydrilla/server/serve.py') diff --git a/src/hydrilla/server/serve.py b/src/hydrilla/server/serve.py index d56085c..6cfceaa 100644 --- a/src/hydrilla/server/serve.py +++ b/src/hydrilla/server/serve.py @@ -28,7 +28,6 @@ import re import os import pathlib import json -import gettext import logging from pathlib import Path @@ -36,55 +35,15 @@ from hashlib import sha256 from abc import ABC, abstractmethod from typing import Optional, Union, Iterable -from flask import Flask, Blueprint, current_app, url_for, abort, request, \ - redirect, send_file -from jinja2 import Environment, PackageLoader +import click +import flask + from werkzeug import Response from .. import util +from . import config -here = pathlib.Path(__file__).resolve().parent - -def load_config(config_path: Path) -> dict: - config = {} - to_load = [config_path] - failures_ok = [False] - - while to_load: - path = to_load.pop() - can_fail = failures_ok.pop() - - try: - json_text = util.strip_json_comments(config_path.read_text()) - new_config = json.loads(json_text) - except Exception as e: - if can_fail: - continue - raise e from None - - config.update(new_config) - - for key, failure_ok in [('try_configs', True), ('use_configs', False)]: - paths = new_config.get(key, []) - paths.reverse() - to_load.extend(paths) - failures_ok.extend([failure_ok] * len(paths)) - - for key in ('try_configs', 'use_configs'): - if key in config: - config.pop(key) - - for key in ('malcontent_dir', 'hydrilla_project_url'): - if key not in config: - raise ValueError(_('config_key_absent_{}').format(key)) - - malcontent_path = Path(config['malcontent_dir']) - if not malcontent_path.is_absolute(): - malcontent_path = config_path.parent / malcontent_path - - config['malcontent_dir'] = str(malcontent_path.resolve()) - - return config +here = Path(__file__).resolve().parent class ItemInfo(ABC): """Shortened data of a resource/mapping.""" @@ -148,14 +107,14 @@ class VersionedItemInfo: self.identifier = item_info.identifier self.uuid = item_info.uuid elif self.uuid != item_info.uuid: - raise ValueError(_('uuid_mismatch_{identifier}') + raise ValueError(f_('uuid_mismatch_{identifier}') .format(identifier=self.identifier)) ver = item_info.version ver_str = util.version_string(ver) if ver_str in self.by_version: - raise ValueError(_('version_clash_{identifier}_{version}') + raise ValueError(f_('version_clash_{identifier}_{version}') .format(identifier=self.identifier, version=ver_str)) @@ -271,11 +230,11 @@ class DeconstructedUrl: match = proto_regex.match(url) if not match: - raise UrlError(_('invalid_URL_{}').format(url)) + raise UrlError(f_('invalid_URL_{}').format(url)) self.proto = match.group('proto') if self.proto not in ('http', 'https', 'ftp'): - raise UrlError(_('disallowed_protocol_{}').format(proto)) + raise UrlError(f_('disallowed_protocol_{}').format(proto)) if self.proto == 'ftp': match = ftp_regex.match(match.group('rest')) @@ -283,7 +242,7 @@ class DeconstructedUrl: match = http_regex.match(match.group('rest')) if not match: - raise UrlError(_('invalid_URL_{}').format(url)) + raise UrlError(f_('invalid_URL_{}').format(url)) self.domain = match.group('domain').split('.') self.domain.reverse() @@ -316,7 +275,7 @@ class Malcontent: Instance of this class represents a directory with files that can be loaded and served by Hydrilla. """ - def __init__(self, malcontent_dir_path: Union[Path, str]): + def __init__(self, malcontent_dir_path: Path): """ When an instance of Malcontent is constructed, it searches malcontent_dir_path for serveable site-modifying packages and loads @@ -325,10 +284,11 @@ class Malcontent: self.infos = {'resource': {}, 'mapping': {}} self.pattern_tree = {} - self.malcontent_dir_path = pathlib.Path(malcontent_dir_path).resolve() + self.malcontent_dir_path = malcontent_dir_path if not self.malcontent_dir_path.is_dir(): - raise ValueError(_('malcontent_dir_path_not_dir')) + raise ValueError(f_('malcontent_dir_path_not_dir_{}') + .format(malcontent_dir_path)) for item_type in ('mapping', 'resource'): type_path = self.malcontent_dir_path / item_type @@ -343,10 +303,10 @@ class Malcontent: try: self._load_item(item_type, ver_file) except Exception as e: - if current_app._hydrilla_werror: + if flask.current_app._hydrilla_werror: raise e from None - msg = _('couldnt_load_item_from_{}').format(ver_file) + msg = f_('couldnt_load_item_from_{}').format(ver_file) logging.error(msg, exc_info=True) self._report_missing() @@ -372,13 +332,13 @@ class Malcontent: item_info = MappingInfo(item_json) if item_info.identifier != identifier: - msg = _('item_{item}_in_file_{file}')\ + msg = f_('item_{item}_in_file_{file}')\ .format({'item': item_info.identifier, 'file': ver_file}) raise ValueError(msg) if item_info.version != version: ver_str = util.version_string(item_info.version) - msg = _('item_version_{ver}_in_file_{file}')\ + msg = f_('item_version_{ver}_in_file_{file}')\ .format({'ver': ver_str, 'file': ver_file}) raise ValueError(msg) @@ -401,7 +361,7 @@ class Malcontent: were not loaded. """ def report_missing_dependency(info: ResourceInfo, dep: str) -> None: - msg = _('no_dep_%(resource)s_%(ver)s_%(dep)s')\ + msg = f_('no_dep_{resource}_{ver}_{dep}')\ .format(dep=dep, resource=info.identifier, ver=util.version_string(info.version)) logging.error(msg) @@ -412,7 +372,7 @@ class Malcontent: report_missing_dependency(resource_info, dep) def report_missing_payload(info: MappingInfo, payload: str) -> None: - msg = _('no_payload_{mapping}_{ver}_{payload}')\ + msg = f_('no_payload_{mapping}_{ver}_{payload}')\ .format(mapping=info.identifier, payload=payload, ver=util.version_string(info.version)) logging.error(msg) @@ -436,9 +396,9 @@ class Malcontent: try: PatternMapping(pattern, info).register(self.pattern_tree) except Exception as e: - if current_app._hydrilla_werror: + if flask.current_app._hydrilla_werror: raise e from None - msg = _('couldnt_register_{mapping}_{ver}_{pattern}')\ + msg = f_('couldnt_register_{mapping}_{ver}_{pattern}')\ .format(mapping=info.identifier, pattern=pattern, ver=util.version_string(info.version)) logging.error(msg) @@ -473,91 +433,66 @@ class Malcontent: return list(collected.values()) -bp = Blueprint('bp', __package__) - -def create_app(config_path: Path=(here / 'config.json'), flask_config: dict={}): - """Create the Flask instance.""" - config = load_config(config_path) - - app = Flask(__package__, static_url_path='/', - static_folder=config['malcontent_dir']) - app.config.update(flask_config) - - language = flask_config.get('lang', 'en') - translation = gettext.translation('hydrilla', localedir=(here / 'locales'), - languages=[language]) +bp = flask.Blueprint('bp', __package__) - app._hydrilla_gettext = translation.gettext +class HydrillaApp(flask.Flask): + """Flask app that implements a Hydrilla server.""" + def __init__(self, hydrilla_config: dict, flask_config: dict={}): + """Create the Flask instance according to the configuration""" + super().__init__(__package__, static_url_path='/', + static_folder=hydrilla_config['malcontent_dir']) + self.config.update(flask_config) - # https://stackoverflow.com/questions/9449101/how-to-stop-flask-from-initialising-twice-in-debug-mode - if app.debug and os.environ.get('WERKZEUG_RUN_MAIN') != 'true': - return app + # https://stackoverflow.com/questions/9449101/how-to-stop-flask-from-initialising-twice-in-debug-mode + if self.debug and os.environ.get('WERKZEUG_RUN_MAIN') != 'true': + return - app._hydrilla_project_url = config['hydrilla_project_url'] - app._hydrilla_werror = config.get('werror', False) - if 'hydrilla_parent' in config: - raise MyNotImplError('hydrilla_parent', config_path.name) + self.jinja_options['extensions'] = ['jinja2.ext.i18n'] - malcontent_dir = pathlib.Path(config['malcontent_dir']) - if not malcontent_dir.is_absolute(): - malcontent_dir = config_path.parent / malcontent_dir - with app.app_context(): - app._hydrilla_malcontent = Malcontent(malcontent_dir.resolve()) + self._hydrilla_translation = \ + util.translation(here / 'locales', hydrilla_config['language']) + self._hydrilla_project_url = hydrilla_config['hydrilla_project_url'] + self._hydrilla_port = hydrilla_config['port'] + self._hydrilla_werror = hydrilla_config.get('werror', False) - app.register_blueprint(bp) + if 'hydrilla_parent' in hydrilla_config: + raise ValueError("Option 'hydrilla_parent' is not implemented.") - return app + malcontent_dir = Path(hydrilla_config['malcontent_dir']).resolve() + with self.app_context(): + self._hydrilla_malcontent = Malcontent(malcontent_dir) -def _(text_key): - return current_app._hydrilla_gettext(text_key) + self.register_blueprint(bp) -def malcontent(): - return current_app._hydrilla_malcontent - -# TODO: override create_jinja_environment() method of Flask instead of wrapping -# Jinja environment -class MyEnvironment(Environment): - """ - A wrapper class around jinja2.Environment that causes GNU gettext function - (as '_' and '__'), url_for function and 'hydrilla_project_url' config option - to be passed to every call of each template's render() method. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def get_template(self, *args, **kwargs): - template = super().get_template(*args, **kwargs) - old_render = template.render - - def new_render(*args, **kwargs): - _ = current_app._hydrilla_gettext - project_url = current_app._hydrilla_project_url - - def escaping_gettext(text_key): - from markupsafe import escape - - return str(escape(_(text_key))) - - final_kwargs = { - '_': escaping_gettext, - '__': escaping_gettext, - 'url_for': url_for, - 'hydrilla_project_url' : project_url - } - final_kwargs.update(kwargs) + def create_jinja_environment(self, *args, **kwargs) \ + -> flask.templating.Environment: + """ + Flask's create_jinja_environment(), but tweaked to always include the + 'hydrilla_project_url' global variable and to install proper + translations. + """ + env = super().create_jinja_environment(*args, **kwargs) + env.install_gettext_translations(self._hydrilla_translation) + env.globals['hydrilla_project_url'] = self._hydrilla_project_url - return old_render(*args, **final_kwargs) + return env - template.render = new_render + def run(self, *args, **kwargs): + """ + Flask's run(), but tweaked to use the port from hydrilla configuration + by default. + """ + return super().run(*args, port=self._hydrilla_port, **kwargs) - return template +def f_(text_key): + return flask.current_app._hydrilla_translation.gettext(text_key) -j2env = MyEnvironment(loader=PackageLoader(__package__), autoescape=False) +def malcontent(): + return flask.current_app._hydrilla_malcontent -indexpage = j2env.get_template('index.html') @bp.route('/') def index(): - return indexpage.render() + return flask.render_template('index.html') identifier_json_re = re.compile(r'^([-0-9a-z.]+)\.json$') @@ -568,7 +503,7 @@ def get_resource_or_mapping(item_type: str, identifier: str) -> Response: """ match = identifier_json_re.match(identifier) if not match: - abort(404) + flask.abort(404) identifier = match.group(1) @@ -576,10 +511,11 @@ def get_resource_or_mapping(item_type: str, identifier: str) -> Response: info = versioned_info and versioned_info.get_by_ver() if info is None: - abort(404) + flask.abort(404) # no need for send_from_directory(); path is safe, constructed by us - return send_file(malcontent().malcontent_dir_path / item_type / info.path()) + file_path = malcontent().malcontent_dir_path / item_type / info.path() + return flask.send_file(file_path) @bp.route('/mapping/') def get_newest_mapping(identifier_dot_json: str) -> Response: @@ -591,7 +527,7 @@ def get_newest_resource(identifier_dot_json: str) -> Response: @bp.route('/query') def query(): - url = request.args['url'] + url = flask.request.args['url'] mapping_refs = [i.as_query_result() for i in malcontent().query(url)] result = { @@ -603,3 +539,59 @@ def query(): } return json.dumps(result) + +default_config_path = Path('/etc/hydrilla/config.json') +default_malcontent_dir = '/var/lib/hydrilla/malcontent' +default_project_url = 'https://hydrillabugs.koszko.org/projects/hydrilla/wiki' + +console_gettext = util.translation(here / 'locales').gettext +_ = console_gettext + +@click.option('-m', '--malcontent-dir', + type=click.Path(exists=True, file_okay=False), + help=_('directory_to_serve_from_overrides_config')) +@click.option('-h', '--hydrilla-project-url', type=click.STRING, + help=_('project_url_to_display_overrides_config')) +@click.option('-p', '--port', type=click.INT, + help=_('tcp_port_to_listen_on_overrides_config')) +@click.option('-c', '--config', 'config_path', + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + help=_('path_to_config_file_explain_default')) +@click.option('-l', '--language', type=click.STRING, + help=_('language_to_use_overrides_config')) +def start(malcontent_dir: Optional[str], hydrilla_project_url: Optional[str], + port: Optional[int], config_path: Optional[str], + language: Optional[str]) -> None: + """""" + if config_path is None: + config_path = here / 'config.json' + else: + config_path = Path(config) + + hydrilla_config = config.load([config_path]) + + if malcontent_dir is not None: + hydrilla_config['malcontent_dir'] = malcontent_dir + + if hydrilla_project_url is not None: + hydrilla_config['hydrilla_project_url'] = hydrilla_project_url + + if port is not None: + hydrilla_config['port'] = port + + if language is not None: + hydrilla_config['language'] = language + + lang = hydrilla_config.get('language') + _ = console_gettext if lang is None else \ + util.translation(here / 'locales', [lang]).gettext + + for opt in ('malcontent_dir', 'hydrilla_project_url', 'port', 'language'): + if opt not in hydrilla_config: + raise ValueError(_('config_option_{}_not_supplied').format(opt)) + + HydrillaApp(hydrilla_config).run() + +start.__doc__ = _('serve_hydrilla_packages_explain_wsgi_considerations') + +start = click.command()(start) -- cgit v1.2.3