summaryrefslogtreecommitdiff
path: root/src/hydrilla/server/serve.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/hydrilla/server/serve.py')
-rw-r--r--src/hydrilla/server/serve.py264
1 files changed, 128 insertions, 136 deletions
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)