diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/hydrilla/server/__init__.py | 3 | ||||
-rw-r--r-- | src/hydrilla/server/__main__.py | 9 | ||||
-rw-r--r-- | src/hydrilla/server/config.json | 16 | ||||
-rw-r--r-- | src/hydrilla/server/config.py | 110 | ||||
-rw-r--r-- | src/hydrilla/server/locales/en/LC_MESSAGES/hydrilla.po | 127 | ||||
-rw-r--r-- | src/hydrilla/server/locales/en_US/LC_MESSAGES/messages.po | 122 | ||||
-rw-r--r-- | src/hydrilla/server/serve.py | 264 | ||||
-rw-r--r-- | src/hydrilla/server/templates/base.html | 1 | ||||
-rw-r--r-- | src/hydrilla_dev_helper.py | 308 | ||||
-rw-r--r-- | src/test/config.json (renamed from src/test/development_config.json) | 10 | ||||
-rw-r--r-- | src/test/test_server.py | 26 |
11 files changed, 406 insertions, 590 deletions
diff --git a/src/hydrilla/server/__init__.py b/src/hydrilla/server/__init__.py index f5a799e..baa78cc 100644 --- a/src/hydrilla/server/__init__.py +++ b/src/hydrilla/server/__init__.py @@ -4,4 +4,5 @@ # # Available under the terms of Creative Commons Zero v1.0 Universal. -from .serve import create_app +from . import config +from .serve import HydrillaApp diff --git a/src/hydrilla/server/__main__.py b/src/hydrilla/server/__main__.py new file mode 100644 index 0000000..037b388 --- /dev/null +++ b/src/hydrilla/server/__main__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# Available under the terms of Creative Commons Zero v1.0 Universal. + +from . import serve + +serve.start() diff --git a/src/hydrilla/server/config.json b/src/hydrilla/server/config.json index 7c9f22b..bde341c 100644 --- a/src/hydrilla/server/config.json +++ b/src/hydrilla/server/config.json @@ -19,6 +19,18 @@ "hydrilla_project_url": "https://hydrillabugs.koszko.org/projects/hydrilla/wiki", // Tell Hydrilla to look for additional configuration in those files, in - // this order. - "try_configs": ["/etc/hydrilla/config.json"] + // this order. Raise an error if the file does not exist. + //"use_configs": ["/etc/hydrilla/config.json"], + + // Same as above but don't raise an error if the file does not exist. + "try_configs": ["/etc/hydrilla/config.json"], + + // What port to listen on (if not being run through WSGI). + "port": 10112, + + // What localization to use for console messages and served HTML files. + "language": "en_US", + + // Whether to exit upon emitting a warning. + "werror": false } diff --git a/src/hydrilla/server/config.py b/src/hydrilla/server/config.py new file mode 100644 index 0000000..4b5bcd7 --- /dev/null +++ b/src/hydrilla/server/config.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Loading Hydrilla server configuration file. +# +# This file is part of Hydrilla +# +# Copyright (C) 2022 Wojtek Kosior +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this +# file's license. Although I request that you do not make use this code +# in a proprietary program, I am not going to enforce this in court. + +import json + +from pathlib import Path + +import jsonschema + +from .. import util + +config_schema = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': { + 'malcontent_dir': { + 'type': 'string' + }, + 'malcontent_dir': { + 'type': 'string' + }, + 'hydrilla_project_url': { + 'type': 'string' + }, + 'try_configs': { + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + 'use_configs': { + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + 'port': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 65535 + }, + 'werror': { + 'type': 'boolean' + } + } +} + +def load(config_paths: list[Path], can_fail: list[bool]=[]) -> dict: + config = {} + + bools_missing = max(0, len(config_paths) - len(can_fail)) + can_fail = [*can_fail[:len(config_paths)], *([False] * bools_missing)] + + while config_paths: + path = config_paths.pop() + fail_ok = can_fail.pop() + + try: + json_text = path.read_text() + except Exception as e: + if fail_ok: + continue + raise e from None + + new_config = json.loads(util.strip_json_comments(json_text)) + jsonschema.validate(new_config, config_schema) + + config.update(new_config) + + if 'malcontent_dir' in config: + malcontent_dir = Path(config['malcontent_dir']) + if not malcontent_dir.is_absolute(): + malcontent_dir = path.parent / malcontent_dir + + config['malcontent_dir'] = str(malcontent_dir.resolve()) + + for key, failure_ok in [('try_configs', True), ('use_configs', False)]: + paths = new_config.get(key, []) + paths.reverse() + config_paths.extend(paths) + can_fail.extend([failure_ok] * len(paths)) + + for key in ('try_configs', 'use_configs'): + if key in config: + config.pop(key) + + return config diff --git a/src/hydrilla/server/locales/en/LC_MESSAGES/hydrilla.po b/src/hydrilla/server/locales/en/LC_MESSAGES/hydrilla.po deleted file mode 100644 index f9e6a82..0000000 --- a/src/hydrilla/server/locales/en/LC_MESSAGES/hydrilla.po +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-License-Identifier: CC0-1.0 - -# English localization -# -# This file is part of Hydrilla -# -# Copyright (C) 2021 Wojtek Kosior -# -# This file is free cultural work: you can redistribute it with or -# without modification under the terms of the CC0 1.0 Universal License -# as published by the Creative Commons Corporation. -# -# This file is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# CC0 1.0 Universal License for more details. - -msgid "" -msgstr "" -"Project-Id-Version: Hydrilla 0.2\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-11-13 19:03+0100\n" -"PO-Revision-Date: 2021-11-06 08:42+0100\n" -"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n" -"Language-Team: English\n" -"Language: en\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" - -#: pydrilla.py:97 -msgid "path_is_absolute_{}" -msgstr "Provided path '{}' is absolute." - -#: pydrilla.py:104 -#, python-brace-format -msgid "not_implemented_{what}_{where}" -msgstr "" -"Attempt to use '{what}' in '{where}' but this feature is not yet implemented." - -#: pydrilla.py:194 -#, python-brace-format -msgid "uuid_mismatch_{identifier}" -msgstr "Two different uuids were specified for item '{identifier}'." - -#: pydrilla.py:201 -#, python-brace-format -msgid "version_clash_{identifier}_{version}" -msgstr "Version '{version}' specified more than once for item '{identifier}'." - -#: pydrilla.py:297 pydrilla.py:309 -msgid "invalid_URL_{}" -msgstr "Invalid URL/pattern: '{}'." - -#: pydrilla.py:301 -msgid "disallowed_protocol_{}" -msgstr "Disallowed protocol: '{}'." - -#: pydrilla.py:391 -msgid "license_clash_{}" -msgstr "License '{}' defined more than once." - -#: pydrilla.py:408 -msgid "source_name_clash_{}" -msgstr "Source name '{}' used more than once." - -#: pydrilla.py:426 -#, python-format -msgid "couldnt_load_definition_from_%s" -msgstr "Couldn't load definition from '%s'." - -#: pydrilla.py:442 -#, python-format -msgid "no_index_license_%(source)s_%(lic)s" -msgstr "Unknown license '%(lic)s' used by index.json of '%(source)s'." - -#: pydrilla.py:449 -#, python-format -msgid "no_resource_license_%(resource)s_%(ver)s_%(lic)s" -msgstr "" -"Unknown license '%(lic)s' used by resource '%(resource)s', version '%(ver)s'." - -#: pydrilla.py:451 -#, python-format -msgid "no_mapping_license_%(mapping)s_%(ver)s_%(lic)s" -msgstr "" -"Unknown license '%(lic)s' used by mapping '%(mapping)s', version '%(ver)s'." - -#: pydrilla.py:474 -#, python-format -msgid "no_dep_%(resource)s_%(ver)s_%(dep)s" -msgstr "" -"Unknown dependency '%(dep)s' of resource '%(resource)s', version '%(ver)s'." - -#: pydrilla.py:484 -#, python-format -msgid "no_payload_%(mapping)s_%(ver)s_%(payload)s" -msgstr "" -"Unknown payload '%(payload)s' of mapping '%(mapping)s', version '%(ver)s'." - -#: pydrilla.py:512 -#, python-format -msgid "couldnt_register_%(mapping)s_%(ver)s_%(pattern)s" -msgstr "" -"Couldn't register mapping '%(mapping)s', version '%(ver)s' (pattern " -"'%(pattern)s')." - -#: pydrilla.py:566 -msgid "content_dir_path_not_dir" -msgstr "Provided \"content_dir\" path does not name a direcotry." - -#: pydrilla.py:578 -#, python-format -msgid "couldnt_load_content_from_%s" -msgstr "Couldn't load content from '%s'." - -#: pydrilla.py:603 -msgid "config_key_absent_{}" -msgstr "Config key \"{}\" not provided." - -#: templates/index.html:4 -msgid "hydrilla_welcome" -msgstr "Welcome to Hydrilla!" - -#: templates/base.html:55 templates/base.html:61 -msgid "hydrilla" -msgstr "Hydrilla" diff --git a/src/hydrilla/server/locales/en_US/LC_MESSAGES/messages.po b/src/hydrilla/server/locales/en_US/LC_MESSAGES/messages.po new file mode 100644 index 0000000..d953246 --- /dev/null +++ b/src/hydrilla/server/locales/en_US/LC_MESSAGES/messages.po @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: CC0-1.0 +# +# English (United States) translations for hydrilla. +# Copyright (C) 2021, 2022 Wojtek Kosior <koszko@koszko.org> +# Available under the terms of Creative Commons Zero v1.0 Universal. +msgid "" +msgstr "" +"Project-Id-Version: hydrilla.builder 0.1\n" +"Report-Msgid-Bugs-To: koszko@koszko.org\n" +"POT-Creation-Date: 2022-02-12 16:10+0100\n" +"PO-Revision-Date: 2022-02-12 00:00+0000\n" +"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n" +"Language: en_US\n" +"Language-Team: en_US <koszko@koszko.org>\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.8.0\n" + +#: src/hydrilla/server/serve.py:110 +#, python-brace-format +msgid "uuid_mismatch_{identifier}" +msgstr "Two different uuids were specified for item '{identifier}'." + +#: src/hydrilla/server/serve.py:117 +#, python-brace-format +msgid "version_clash_{identifier}_{version}" +msgstr "Version '{version}' specified more than once for item '{identifier}'." + +#: src/hydrilla/server/serve.py:233 src/hydrilla/server/serve.py:245 +msgid "invalid_URL_{}" +msgstr "Invalid URL/pattern: '{}'." + +#: src/hydrilla/server/serve.py:237 +msgid "disallowed_protocol_{}" +msgstr "Disallowed protocol: '{}'." + +#: src/hydrilla/server/serve.py:290 +msgid "malcontent_dir_path_not_dir_{}" +msgstr "Provided 'malcontent_dir' path does not name a directory: {}" + +#: src/hydrilla/server/serve.py:309 +msgid "couldnt_load_item_from_{}" +msgstr "Couldn't load item from {}." + +#: src/hydrilla/server/serve.py:335 +msgid "item_{item}_in_file_{file}" +msgstr "Item {item} incorrectly present under {file}." + +#: src/hydrilla/server/serve.py:341 +msgid "item_version_{ver}_in_file_{file}" +msgstr "Item version {ver} incorrectly present under {file}." + +#: src/hydrilla/server/serve.py:364 +msgid "no_dep_{resource}_{ver}_{dep}" +msgstr "Unknown dependency '{dep}' of resource '{resource}', version '{ver}'." + +#: src/hydrilla/server/serve.py:375 +msgid "no_payload_{mapping}_{ver}_{payload}" +msgstr "Unknown payload '{payload}' of mapping '{mapping}', version '{ver}'." + +#: src/hydrilla/server/serve.py:401 +msgid "couldnt_register_{mapping}_{ver}_{pattern}" +msgstr "" +"Couldn't register mapping '{mapping}', version '{ver}' (pattern " +"'{pattern}')." + +#: src/hydrilla/server/serve.py:552 +msgid "directory_to_serve_from_overrides_config" +msgstr "" +"Directory to serve files from. Overrides value from the config file (if " +"any)." + +#: src/hydrilla/server/serve.py:554 +msgid "project_url_to_display_overrides_config" +msgstr "" +"Project url to display on generated HTML pages. Overrides value from the " +"config file (if any)." + +#: src/hydrilla/server/serve.py:556 +msgid "tcp_port_to_listen_on_overrides_config" +msgstr "" +"TCP port number to listen on (0-65535). Overrides value from the config " +"file (if any)." + +#: src/hydrilla/server/serve.py:559 +msgid "path_to_config_file_explain_default" +msgstr "" +"Path to Hydrilla server configuration file (optional, by default Hydrilla" +" loads its own config file, which in turn tries to load " +"/etc/hydrilla/config.json)." + +#: src/hydrilla/server/serve.py:561 +msgid "language_to_use_overrides_config" +msgstr "" +"Language to use (also affects served HTML files). Overrides value from " +"the config file (if any)\"" + +#: src/hydrilla/server/serve.py:591 +msgid "config_option_{}_not_supplied" +msgstr "Missing configuration option '{}'." + +#: src/hydrilla/server/serve.py:595 +msgid "serve_hydrilla_packages_explain_wsgi_considerations" +msgstr "" +"Serve Hydrilla packages.\n" +"\n" +"This command is meant to be a quick way to run a local or development " +"Hydrilla instance. For better performance, consider deployment using " +"WSGI." + +#. 'hydrilla' as a title +#: src/hydrilla/server/templates/base.html:99 +#: src/hydrilla/server/templates/base.html:105 +msgid "hydrilla" +msgstr "Hydrilla" + +#: src/hydrilla/server/templates/index.html:29 +msgid "hydrilla_welcome" +msgstr "Welcome to Hydrilla!" + 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/<string:identifier_dot_json>') 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: + """<this will be replaced by a localized docstring for Click to pick up>""" + 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) diff --git a/src/hydrilla/server/templates/base.html b/src/hydrilla/server/templates/base.html index f95ce54..34cb214 100644 --- a/src/hydrilla/server/templates/base.html +++ b/src/hydrilla/server/templates/base.html @@ -95,6 +95,7 @@ in a proprietary program, I am not going to enforce this in court. } {% endblock %} </style> + {# TRANSLATORS: 'hydrilla' as a title#} <title>{% block title %}{{ _('hydrilla') }}{% endblock %}</title> {% endblock %} </head> diff --git a/src/hydrilla_dev_helper.py b/src/hydrilla_dev_helper.py deleted file mode 100644 index 925f414..0000000 --- a/src/hydrilla_dev_helper.py +++ /dev/null @@ -1,308 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later - -# Definitions of helper commands to use with setuptools -# -# This file is part of Hydrilla -# -# Copyright (C) 2021 Wojtek Kosior -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. -# -# -# I, Wojtek Kosior, thereby promise not to sue for violation of this file's -# license. Although I request that you do not make use this code in a -# proprietary program, I am not going to enforce this in court. - -from setuptools import Command -from setuptools.command.build_py import build_py -import sys -from pathlib import Path -import subprocess -import re -import os -import json -import importlib - -def mypath(path_or_string): - return Path(path_or_string).resolve() - -class Helper: - def __init__(self, project_root, app_package_name, locales_dir, - locales=['en', 'pl'], default_locale='en', locale_domain=None, - packages_root=None, debian_dir=None, config_path=None): - self.project_root = mypath(project_root) - self.app_package_name = app_package_name - self.locales_dir = mypath(locales_dir) - self.locales = locales - self.default_locale = default_locale - self.locale_domain = locale_domain or app_package_name - self.packages_root = mypath(packages_root or project_root / 'src') - self.app_package_dir = self.packages_root / app_package_name - self.debian_dir = mypath(debian_dir or project_root / 'debian') - self.config_path = config_path and mypath(config_path) - self.locale_files_list = None - - def run_command(self, command, verbose, runner=subprocess.run, **kwargs): - cwd = kwargs.get('cwd') - if cwd: - cwd = mypath(cwd) - where = f'from {cwd} ' - else: - cwd = Path.cwd().resolve() - where = '' - - str_command = [str(command[0])] - - for arg in command[1:]: - if isinstance(arg, Path): - try: - arg = str(arg.relative_to(cwd)) - except ValueError: - arg = str(arg) - - str_command.append(arg) - - if verbose: - print(f'{where}executing {" ".join(str_command)}') - runner(str_command, **kwargs) - - def create_mo_files(self, dry_run=False, verbose=False): - self.locale_files_list = [] - - for locale in self.locales: - messages_dir = self.locales_dir / locale / 'LC_MESSAGES' - - for po_path in messages_dir.glob('*.po'): - mo_path = po_path.with_suffix('.mo') - - if not dry_run: - command = ['msgfmt', po_path, '-o', mo_path] - self.run_command(command, verbose=verbose, check=True) - - self.locale_files_list.extend([po_path, mo_path]) - - def locale_files(self): - if self.locale_files_list is None: - self.create_mo_files(dry_run=True) - - return self.locale_files_list - - def locale_files_relative(self, to=None): - if to is None: - to = self.app_package_dir - - return [file.relative_to(to) for file in self.locale_files()] - - def flask_run(self, locale=None): - for var, val in (('ENV', 'development'), ('DEBUG', 'True')): - os.environ[f'FLASK_{var}'] = os.environ.get(f'FLASK_{var}', val) - - config = {'lang': locale or self.default_locale} - - sys.path.insert(0, str(self.packages_root)) - package = importlib.import_module(self.app_package_name) - - # make relative paths in json config resolve from project's directory - os.chdir(self.project_root) - - kwargs = {'config_path': self.config_path} if self.config_path else {} - package.create_app(flask_config=config, **kwargs).run() - - def update_po_files(self, verbose=False): - pot_path = self.locales_dir / f'{self.locale_domain}.pot' - rglob = self.app_package_dir.rglob - command = ['xgettext', '-d', self.locale_domain, '--language=Python', - '-o', pot_path, *rglob('*.py'), *rglob('*.html')] - - self.run_command(command, verbose=verbose, check=True, - cwd=self.app_package_dir) - - for locale in self.locales: - messages_dir = self.locales_dir / locale / 'LC_MESSAGES' - - for po_path in messages_dir.glob('*.po'): - if po_path.stem != self.app_package_name: - continue; - - if po_path.exists(): - command = ['msgmerge', '--update', po_path, pot_path] - else: - command = ['cp', po_path, pot_path] - - self.run_command(command, verbose=verbose, check=True) - - if (verbose): - print('removing generated .pot file') - pot_path.unlink() - - # we exclude these from the source archive we produce - bad_file_regex = re.compile(r'^\..*|build|debian|dist') - - changelog_line_regex = re.compile(r''' - ^ # match from the beginning of each line - \s* # skip initial whitespace (if any) - (?P<source_name> # capture name - [^\s(]+ - ) - \s* # again skip whitespace (if any) - \( - (?P<version> # capture version which is enclosed in parantheses - [^)]+ - ) - - - (?P<debrel> # capture debrel part of version separately - [0-9]+ - ) - \) - ''', re.VERBOSE) - - def make_tarballs(self, verbose=False): - changelog_path = self.project_root / 'debian' / 'changelog' - with open(changelog_path, 'rt') as file_handle: - for line in file_handle.readlines(): - match = changelog_line_regex.match(line) - if match: - break - - if not match: - raise ValueError("Couldn't extract version from debian/changelog.") - - name, ver, debrel = \ - [match.group(gn) for gn in ('source_name', 'version', 'debrel')] - - source_dirname = f'{name}-{ver}' - source_tarball_name = f'{name}_{ver}.orig.tar.gz' - debian_tarball_name = f'{name}_{ver}-{debrel}.debian.tar.gz' - - source_args = [f'--prefix={source_dirname}/', '-o', - self.project_root.parent / source_tarball_name, 'HEAD'] - - for filepath in self.project_root.iterdir(): - if not self.bad_file_regex.search(filepath.parts[-1]): - source_args.append(filepath) - - debian_args = ['-o', self.project_root.parent / debian_tarball_name, - 'HEAD', self.debian_dir] - - for args in [source_args, debian_args]: - command = ['git', 'archive', '--format=tar.gz', *args] - self.run_command(command, verbose=verbose, check=True) - - def commands(self): - helper = self - - class MsgfmtCommand(Command): - '''A custom command to run msgfmt on all .po files below '{}'.''' - - description = 'use msgfmt to generate .mo files from .po files' - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - helper.create_mo_files(verbose=self.verbose) - - MsgfmtCommand.__doc__ = MsgfmtCommand.__doc__.format(helper.locales_dir) - - class RunCommand(Command): - ''' - A custom command to run the app using flask. - - This is similar in effect to: - PYTHONPATH='{packages_root}' FLASK_APP={app_package_name} \\ - FLASK_ENV=development flask run - ''' - - description = 'run the Flask app from source directory' - - user_options = [ - ('locale=', 'l', - "app locale (one of: %s; default: '%s')" % - (', '.join([f"'{l}'" for l in helper.locales]), - helper.default_locale)) - ] - - def initialize_options(self): - self.locale = helper.default_locale - - def finalize_options(self): - if self.locale not in helper.locales: - raise ValueError("Locale '%s' not supported" % self.lang) - - def run(self): - helper.flask_run(locale=self.locale) - - RunCommand.__doc__ = RunCommand.__doc__.format( - packages_root=self.packages_root, - app_package_name=self.app_package_name - ) - - class MsgmergeCommand(Command): - ''' - A custom command to run xgettext and msgmerge to update project's - .po files below '{}'. - ''' - - description = 'use xgettext and msgmerge to update (or generate) .po files for this project' - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - helper.update_po_files(verbose=self.verbose) - - MsgmergeCommand.__doc__ = \ - MsgmergeCommand.__doc__.format(helper.locales_dir) - - class TarballsCommand(Command): - ''' - A custom command to run git archive to create debian tarballs of - this project. - ''' - - description = 'use git archive to create .orig.tar.gz and .debian.tar.gz files for this project' - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - helper.make_tarballs(verbose=self.verbose) - - class BuildCommand(build_py): - ''' - The build command but runs the custom msgfmt command before build. - ''' - def run(self, *args, **kwargs): - self.run_command('msgfmt') - super().run(*args, **kwargs) - - return { - 'msgfmt': MsgfmtCommand, - 'run': RunCommand, - 'msgmerge': MsgmergeCommand, - 'tarballs': TarballsCommand, - 'build_py': BuildCommand - } diff --git a/src/test/development_config.json b/src/test/config.json index c2382f7..a75e2b1 100644 --- a/src/test/development_config.json +++ b/src/test/config.json @@ -6,8 +6,8 @@ // // Available under the terms of Creative Commons Zero v1.0 Universal. -// this config is meant to be used in development environment; -// unlike config.json, it shall not be included in distribution +// this config is meant to be used in development environment; unlike +// src/hydrilla/server/config.json, it shall not be included in distribution { // Relative paths now get resolved from config's containing direcotry. "malcontent_dir": "./sample_malcontent", @@ -17,6 +17,12 @@ // compliance with the AGPL. "hydrilla_project_url": "https://hydrillabugs.koszko.org/projects/hydrilla/wiki", + // Port to listen on (not relevant when Flask.test_client() is used). + "port": 10112, + + // Use english for HTML files and generated messages. + "language": "en_US", + // Make Hydrilla error out on any warning "werror": true diff --git a/src/test/test_server.py b/src/test/test_server.py index b3ea741..b283a00 100644 --- a/src/test/test_server.py +++ b/src/test/test_server.py @@ -39,10 +39,10 @@ from markupsafe import escape from hydrilla import util as hydrilla_util from hydrilla.builder import Build -from hydrilla.server import create_app +from hydrilla.server import HydrillaApp, config here = Path(__file__).resolve().parent -config_path = here / 'development_config.json' +config_path = here / 'config.json' source_path = here / 'source-package-example' @pytest.fixture(scope="session") @@ -62,25 +62,23 @@ def default_setup() -> Iterable[dict[str, Path]]: yield setup @pytest.fixture(scope="session") -def client(default_setup: dict[str, Path]) -> Iterable[FlaskClient]: +def test_config(default_setup) -> Iterable[dict]: + """Provide the contents of JSON config file fed to the client.""" + yield config.load([default_setup['config_path']]) + +@pytest.fixture(scope="session") +def client(test_config: dict) -> Iterable[FlaskClient]: """Provide app client that serves the object from built sample package.""" - app = create_app(default_setup['config_path'], - flask_config={'TESTING': True}) + app = HydrillaApp(test_config, flask_config={'TESTING': True}) with app.test_client() as client: yield client -@pytest.fixture(scope="session") -def development_config(default_setup) -> Iterable[dict]: - """Provide the contents of JSON config file fed to the client.""" - contents = default_setup['config_path'].read_text() - yield json.loads(hydrilla_util.strip_json_comments(contents)) - -def test_project_url(client: FlaskClient, development_config: dict) -> None: - """Fetch index.html and verify project URL fro config is present there.""" +def test_project_url(client: FlaskClient, test_config: dict) -> None: + """Fetch index.html and verify project URL from config is present there.""" response = client.get('/') assert b'html' in response.data - project_url = development_config['hydrilla_project_url'] + project_url = test_config['hydrilla_project_url'] assert escape(project_url).encode() in response.data @pytest.mark.parametrize('item_type', ['resource', 'mapping']) |