summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/hydrilla/server/__init__.py3
-rw-r--r--src/hydrilla/server/__main__.py9
-rw-r--r--src/hydrilla/server/config.json16
-rw-r--r--src/hydrilla/server/config.py110
-rw-r--r--src/hydrilla/server/locales/en/LC_MESSAGES/hydrilla.po127
-rw-r--r--src/hydrilla/server/locales/en_US/LC_MESSAGES/messages.po122
-rw-r--r--src/hydrilla/server/serve.py264
-rw-r--r--src/hydrilla/server/templates/base.html1
-rw-r--r--src/hydrilla_dev_helper.py308
-rw-r--r--src/test/config.json (renamed from src/test/development_config.json)10
-rw-r--r--src/test/test_server.py26
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'])