diff --git a/README.md b/README.md index 3bb6944f6..9a6a69955 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ extensions: - file: ./gramps.gpkg betty.extension.HttpApiDoc: {} betty.extension.Maps: {} + # @todo + betty.extension.Nginx: {} betty.extension.Privatizer: {} betty.extension.Trees: {} betty.extension.Wikipedia: {} @@ -183,6 +185,7 @@ extensions: - `betty.extension.HttpApiDoc` (optional): Renders interactive and user-friendly HTTP API documentation using [ReDoc](https://github.com/Redocly/redoc). - `betty.extension.Maps` (optional): Renders interactive maps using [Leaflet](https://leafletjs.com/). + - `betty.extension.Nginx` (optional): @todo - `betty.extension.Privatizer` (optional): Marks living people private. Configuration: `{}`. - `betty.extension.Trees` (optional): Renders interactive ancestry trees using [Cytoscape.js](http://js.cytoscape.org/). - `betty.extension.Wikipedia` (optional): Lets templates and other extensions retrieve complementary Wikipedia diff --git a/betty/app/__init__.py b/betty/app/__init__.py index 9fae08c17..0f2bd1453 100644 --- a/betty/app/__init__.py +++ b/betty/app/__init__.py @@ -118,7 +118,6 @@ def __init__( ): super().__init__() self._started = False - self._stopped = False self._configuration = configuration or AppConfiguration() self._assets: FileSystem | None = None self._extensions = _AppExtensions() @@ -166,12 +165,10 @@ async def start(self) -> None: if self._started: raise RuntimeError('This app has started already.') self._started = True - self._stopped = False async def stop(self) -> None: - self._stopped = True - del self.http_client self._started = False + del self.http_client def __del__(self) -> None: if self._started: @@ -413,8 +410,8 @@ def servers(self) -> Mapping[str, Server]: if isinstance(extension, serve.ServerProvider) for server in extension.servers ), - serve.BuiltinServer(self.localizer, self.project), - DemoServer(self.localizer), + serve.BuiltinServer(self), + DemoServer(), ] } diff --git a/betty/assets/betty.pot b/betty/assets/betty.pot index ef15ec972..ae762825b 100644 --- a/betty/assets/betty.pot +++ b/betty/assets/betty.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-12-18 22:34+0000\n" +"POT-Creation-Date: 2023-12-19 18:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -398,6 +398,9 @@ msgstr "" msgid "General" msgstr "" +msgid "Generate nginx configuration for your site, as well as a Dockerfile to build a Docker container around it." +msgstr "" + msgid "Generate entity listing pages" msgstr "" @@ -760,6 +763,9 @@ msgstr "" msgid "This must be a whole number." msgstr "" +msgid "This must be none/null." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "" diff --git a/betty/assets/locale/fr-FR/betty.po b/betty/assets/locale/fr-FR/betty.po index dbb2188b5..79c360441 100644 --- a/betty/assets/locale/fr-FR/betty.po +++ b/betty/assets/locale/fr-FR/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-12-18 22:34+0000\n" +"POT-Creation-Date: 2023-12-19 18:46+0000\n" "PO-Revision-Date: 2020-11-27 19:49+0100\n" "Last-Translator: \n" "Language: fr\n" @@ -473,6 +473,12 @@ msgstr "Funérailles" msgid "General" msgstr "" +msgid "" +"Generate nginx configuration for your site, as well as a" +" Dockerfile to build a Docker container around it." +msgstr "" + msgid "Generate entity listing pages" msgstr "" @@ -852,6 +858,9 @@ msgstr "" msgid "This must be a whole number." msgstr "" +msgid "This must be none/null." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "" "Les détails concernant cette personne ne sont pas disponibles afin de " diff --git a/betty/assets/locale/nl-NL/betty.po b/betty/assets/locale/nl-NL/betty.po index 81fec8732..c560d7d13 100644 --- a/betty/assets/locale/nl-NL/betty.po +++ b/betty/assets/locale/nl-NL/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-12-18 22:34+0000\n" +"POT-Creation-Date: 2023-12-19 18:46+0000\n" "PO-Revision-Date: 2022-04-08 01:58+0100\n" "Last-Translator: \n" "Language: nl\n" @@ -504,6 +504,12 @@ msgstr "Uitvaart" msgid "General" msgstr "Algemeen" +msgid "" +"Generate nginx configuration for your site, as well as a" +" Dockerfile to build a Docker container around it." +msgstr "Genereer nginx-configuratie voor je site, evenals een Dockerfile om er een Docker-container omheen te bouwen." + msgid "Generate entity listing pages" msgstr "Genereer pagina's met entiteitsoverzichten" @@ -890,6 +896,9 @@ msgstr "Dit moet een tekenreeks zijn." msgid "This must be a whole number." msgstr "Dit moet een geheel getal zijn." +msgid "This must be none/null." +msgstr "Dit moet none/null zijn." + msgid "This person's details are unavailable to protect their privacy." msgstr "De gegevens van deze persoon zijn niet beschikbaar vanwege privacyredenen." diff --git a/betty/assets/locale/uk/betty.po b/betty/assets/locale/uk/betty.po index a05e8bc58..133d88413 100644 --- a/betty/assets/locale/uk/betty.po +++ b/betty/assets/locale/uk/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-12-18 22:34+0000\n" +"POT-Creation-Date: 2023-12-19 18:46+0000\n" "PO-Revision-Date: 2020-05-02 22:29+0100\n" "Last-Translator: FULL NAME \n" "Language: uk\n" @@ -474,6 +474,12 @@ msgstr "Похорон" msgid "General" msgstr "" +msgid "" +"Generate nginx configuration for your site, as well as a" +" Dockerfile to build a Docker container around it." +msgstr "" + msgid "Generate entity listing pages" msgstr "" @@ -855,6 +861,9 @@ msgstr "" msgid "This must be a whole number." msgstr "" +msgid "This must be none/null." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "Дані цієї особи недоступні для захисту їх конфіденційності." diff --git a/betty/extension/cotton_candy/assets/public/localized/.error/401.html.j2 b/betty/assets/public/localized/.error/401.html.j2 similarity index 100% rename from betty/extension/cotton_candy/assets/public/localized/.error/401.html.j2 rename to betty/assets/public/localized/.error/401.html.j2 diff --git a/betty/extension/cotton_candy/assets/public/localized/.error/403.html.j2 b/betty/assets/public/localized/.error/403.html.j2 similarity index 100% rename from betty/extension/cotton_candy/assets/public/localized/.error/403.html.j2 rename to betty/assets/public/localized/.error/403.html.j2 diff --git a/betty/extension/cotton_candy/assets/public/localized/.error/404.html.j2 b/betty/assets/public/localized/.error/404.html.j2 similarity index 100% rename from betty/extension/cotton_candy/assets/public/localized/.error/404.html.j2 rename to betty/assets/public/localized/.error/404.html.j2 diff --git a/betty/cli.py b/betty/cli.py index 1dec76352..6d2ef62f4 100644 --- a/betty/cli.py +++ b/betty/cli.py @@ -3,7 +3,6 @@ import asyncio import logging import sys -import time from contextlib import suppress, contextmanager from functools import wraps from pathlib import Path @@ -24,7 +23,7 @@ from betty.locale import update_translations, init_translation, Str from betty.logging import CliHandler from betty.serde.load import AssertionFailed -from betty.serve import ProjectServer +from betty.serve import AppServer T = TypeVar('T') P = ParamSpec('P') @@ -181,11 +180,10 @@ async def _clear_caches() -> None: @click.command(help='Explore a demonstration site.') @global_command async def _demo() -> None: - async with App() as app: - async with demo.DemoServer(app.localizer) as server: - await server.show() - while True: - time.sleep(999) + async with demo.DemoServer() as server: + await server.show() + while True: + await asyncio.sleep(999) @click.command(help="Open Betty's graphical user interface (GUI).") @@ -221,7 +219,7 @@ async def _generate(app: App) -> None: @click.command(help='Serve a generated site.') @app_command async def _serve(app: App) -> None: - async with ProjectServer.get(app) as server: + async with AppServer.get(app) as server: await server.show() while True: await asyncio.sleep(999) diff --git a/betty/config.py b/betty/config.py index 66dbe6cc6..e612ebef1 100644 --- a/betty/config.py +++ b/betty/config.py @@ -476,8 +476,7 @@ def dump(self) -> VoidableDump: item_dump, configuration_key = self._dump_key(item_dump) if self._minimize_item_dump(): item_dump = minimize(item_dump) - if item_dump is not Void: - dump[configuration_key] = item_dump + dump[configuration_key] = item_dump return minimize(dump) def prepend(self, *configurations: ConfigurationT) -> None: diff --git a/betty/error.py b/betty/error.py index b98e6cf71..9cf501260 100644 --- a/betty/error.py +++ b/betty/error.py @@ -1,5 +1,5 @@ import traceback -from typing import TypeVar +from typing import TypeVar, Self from betty.locale import Localizable, DEFAULT_LOCALIZER, Localizer @@ -35,6 +35,9 @@ def __init__(self, message: Localizable): ) self._localizable_message = message + def __reduce__(self) -> tuple[type[Self], tuple[Localizable]]: + return type(self), (self._localizable_message,) + def __str__(self) -> str: return self.localize(DEFAULT_LOCALIZER) diff --git a/betty/extension/__init__.py b/betty/extension/__init__.py index 3ef9f22e8..f1c2777f8 100644 --- a/betty/extension/__init__.py +++ b/betty/extension/__init__.py @@ -6,6 +6,7 @@ from betty.extension.gramps import _Gramps from betty.extension.http_api_doc import _HttpApiDoc from betty.extension.maps import _Maps +from betty.extension.nginx import _Nginx from betty.extension.privatizer import _Privatizer from betty.extension.trees import _Trees from betty.extension.wikipedia import _Wikipedia @@ -35,6 +36,10 @@ class Maps(_Maps): pass +class Nginx(_Nginx): + pass + + class Privatizer(_Privatizer): pass diff --git a/betty/extension/demo/__init__.py b/betty/extension/demo/__init__.py index 2ad4c852f..649bb522c 100644 --- a/betty/extension/demo/__init__.py +++ b/betty/extension/demo/__init__.py @@ -1,18 +1,20 @@ from __future__ import annotations +from contextlib import AsyncExitStack + from geopy import Point from betty import load, generate from betty.app import App from betty.app.extension import Extension from betty.load import Loader -from betty.locale import Date, DateRange, Str, Localizer +from betty.locale import Date, DateRange, Str from betty.model import Entity from betty.model.ancestry import Place, PlaceName, Person, Presence, Subject, PersonName, Link, Source, Citation, Event, \ Enclosure from betty.model.event_type import Marriage, Birth, Death from betty.project import LocaleConfiguration, ExtensionConfiguration, EntityReference -from betty.serve import Server, ProjectServer, NoPublicUrlBecauseServerNotStartedError +from betty.serve import Server, AppServer, NoPublicUrlBecauseServerNotStartedError class _Demo(Extension, Loader): @@ -359,9 +361,19 @@ async def load(self) -> None: class DemoServer(Server): - def __init__(self, localizer: Localizer): - super().__init__(localizer) + def __init__(self): + self._app = App() + super().__init__(self._app.localizer) + self._app.project.configuration.extensions.append(ExtensionConfiguration(_Demo)) + # Include all of the translations Betty ships with. + self._app.project.configuration.locales.replace( + LocaleConfiguration('en-US', 'en'), + LocaleConfiguration('nl-NL', 'nl'), + LocaleConfiguration('fr-FR', 'fr'), + LocaleConfiguration('uk', 'uk'), + ) self._server: Server | None = None + self._exit_stack = AsyncExitStack() @classmethod def label(cls) -> Str: @@ -374,21 +386,17 @@ def public_url(self) -> str: raise NoPublicUrlBecauseServerNotStartedError() async def start(self) -> None: - app = App() - app.project.configuration.extensions.append(ExtensionConfiguration(_Demo)) - # Include all of the translations Betty ships with. - app.project.configuration.locales.replace( - LocaleConfiguration('en-US', 'en'), - LocaleConfiguration('nl-NL', 'nl'), - LocaleConfiguration('fr-FR', 'fr'), - LocaleConfiguration('uk', 'uk'), - ) - await load.load(app) - self._server = ProjectServer.get(app) - await self._server.start() - app.project.configuration.base_url = self._server.public_url - await generate.generate(app) + try: + await super().start() + await self._exit_stack.enter_async_context(self._app) + await load.load(self._app) + self._server = AppServer.get(self._app) + await self._exit_stack.enter_async_context(self._server) + self._app.project.configuration.base_url = self._server.public_url + await generate.generate(self._app) + finally: + await self.stop() async def stop(self) -> None: - if self._server: - await self._server.stop() + await self._exit_stack.aclose() + await super().stop() diff --git a/betty/extension/nginx/__init__.py b/betty/extension/nginx/__init__.py new file mode 100644 index 000000000..e9852f3dd --- /dev/null +++ b/betty/extension/nginx/__init__.py @@ -0,0 +1,176 @@ +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Self + +from PyQt6.QtWidgets import QFormLayout, QButtonGroup, QRadioButton, QWidget, QHBoxLayout, QLineEdit, \ + QFileDialog, QPushButton +from reactives.instance.property import reactive_property + +from betty.app import App +from betty.app.extension import ConfigurableExtension +from betty.config import Configuration +from betty.extension.nginx.artifact import generate_configuration_file, generate_dockerfile_file +from betty.generate import Generator, GenerationContext +from betty.gui import GuiBuilder +from betty.gui.error import catch_exceptions +from betty.locale import Str +from betty.serde.dump import Dump, VoidableDump, minimize, Void, VoidableDictDump +from betty.serde.load import Asserter, Fields, OptionalField, Assertions +from betty.serve import ServerProvider, Server + + +class NginxConfiguration(Configuration): + def __init__(self, www_directory_path: str | None = None, https: bool | None = None): + super().__init__() + self._https = https + self.www_directory_path = www_directory_path + + @property + @reactive_property + def https(self) -> bool | None: + return self._https + + @https.setter + def https(self, https: bool | None) -> None: + self._https = https + + @property + @reactive_property + def www_directory_path(self) -> str | None: + return self._www_directory_path + + @www_directory_path.setter + def www_directory_path(self, www_directory_path: str | None) -> None: + self._www_directory_path = www_directory_path + + def update(self, other: Self) -> None: + self._https = other._https + self._www_directory_path = other._www_directory_path + + @classmethod + def load( + cls, + dump: Dump, + configuration: Self | None = None, + ) -> Self: + if configuration is None: + configuration = cls() + asserter = Asserter() + asserter.assert_record(Fields( + OptionalField( + 'https', + Assertions(asserter.assert_or(asserter.assert_bool(), asserter.assert_none())) | asserter.assert_setattr(configuration, 'https'), + ), + OptionalField( + 'www_directory_path', + Assertions(asserter.assert_path()) | asserter.assert_setattr(configuration, 'www_directory_path'), + ), + ))(dump) + return configuration + + def dump(self) -> VoidableDump: + dump: VoidableDictDump[VoidableDump] = { + 'https': self.https, + 'www_directory_path': Void if self.www_directory_path is None else str(self.www_directory_path), + } + return minimize(dump, True) + + +class _Nginx(ConfigurableExtension[NginxConfiguration], Generator, ServerProvider, GuiBuilder): + @classmethod + def label(cls) -> Str: + return Str.plain('Nginx') + + @classmethod + def description(cls) -> Str: + return Str._('Generate nginx configuration for your site, as well as a Dockerfile to build a Docker container around it.') + + @classmethod + def default_configuration(cls) -> NginxConfiguration: + return NginxConfiguration() + + @property + def servers(self) -> Sequence[Server]: + from betty.extension.nginx.serve import DockerizedNginxServer + + if DockerizedNginxServer.is_available(): + return [DockerizedNginxServer(self._app)] + return [] + + async def generate(self, task_context: GenerationContext) -> None: + await generate_configuration_file(self._app) + await generate_dockerfile_file(self._app) + + @classmethod + def assets_directory_path(cls) -> Path | None: + return Path(__file__).parent / 'assets' + + @property + def https(self) -> bool: + if self._configuration.https is None: + return self._app.project.configuration.base_url.startswith('https') + return self._configuration.https + + @property + def www_directory_path(self) -> str: + return self._configuration.www_directory_path or str(self._app.project.configuration.www_directory_path) + + def gui_build(self) -> QWidget: + return _NginxGuiWidget(self._app, self._configuration) + + +class _NginxGuiWidget(QWidget): + def __init__(self, app: App, configuration: NginxConfiguration, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self._app = app + self._configuration = configuration + layout = QFormLayout() + + self.setLayout(layout) + + https_button_group = QButtonGroup() + + def _update_configuration_https_base_url(checked: bool) -> None: + if checked: + self._configuration.https = None + self._nginx_https_base_url = QRadioButton("Use HTTPS and HTTP/2 if the site's URL starts with https://") + self._nginx_https_base_url.setChecked(self._configuration.https is None) + self._nginx_https_base_url.toggled.connect(_update_configuration_https_base_url) + layout.addRow(self._nginx_https_base_url) + https_button_group.addButton(self._nginx_https_base_url) + + def _update_configuration_https_https(checked: bool) -> None: + if checked: + self._configuration.https = True + self._nginx_https_https = QRadioButton('Use HTTPS and HTTP/2') + self._nginx_https_https.setChecked(self._configuration.https is True) + self._nginx_https_https.toggled.connect(_update_configuration_https_https) + layout.addRow(self._nginx_https_https) + https_button_group.addButton(self._nginx_https_https) + + def _update_configuration_https_http(checked: bool) -> None: + if checked: + self._configuration.https = False + self._nginx_https_http = QRadioButton('Use HTTP') + self._nginx_https_http.setChecked(self._configuration.https is False) + self._nginx_https_http.toggled.connect(_update_configuration_https_http) + layout.addRow(self._nginx_https_http) + https_button_group.addButton(self._nginx_https_http) + + def _update_configuration_www_directory_path(www_directory_path: str) -> None: + self._configuration.www_directory_path = None if www_directory_path == '' or www_directory_path == str(self._app.project.configuration.www_directory_path) else www_directory_path + self._nginx_www_directory_path = QLineEdit() + self._nginx_www_directory_path.setText(str(self._configuration.www_directory_path) if self._configuration.www_directory_path is not None else str(self._app.project.configuration.www_directory_path)) + self._nginx_www_directory_path.textChanged.connect(_update_configuration_www_directory_path) + www_directory_path_layout = QHBoxLayout() + www_directory_path_layout.addWidget(self._nginx_www_directory_path) + + @catch_exceptions + def find_www_directory_path() -> None: + found_www_directory_path = QFileDialog.getExistingDirectory(self, 'Serve your site from...', directory=self._nginx_www_directory_path.text()) + if '' != found_www_directory_path: + self._nginx_www_directory_path.setText(found_www_directory_path) + self._nginx_www_directory_path_find = QPushButton('...') + self._nginx_www_directory_path_find.released.connect(find_www_directory_path) + www_directory_path_layout.addWidget(self._nginx_www_directory_path_find) + layout.addRow('WWW directory', www_directory_path_layout) diff --git a/betty/extension/nginx/artifact.py b/betty/extension/nginx/artifact.py new file mode 100644 index 000000000..513d00a3c --- /dev/null +++ b/betty/extension/nginx/artifact.py @@ -0,0 +1,41 @@ +from pathlib import Path +from shutil import copyfile +from urllib.parse import urlparse + +import aiofiles +from aiofiles.os import makedirs +from jinja2 import FileSystemLoader + +from betty.app import App +from betty.path import rootname + + +async def generate_configuration_file( + app: App, + destination_file_path: Path | None = None, + www_directory_path: str | None = None, + https: bool | None = None, +) -> None: + from betty.extension import Nginx + + kwargs = { + 'server_name': urlparse(app.project.configuration.base_url).netloc, + 'www_directory_path': www_directory_path or app.extensions[Nginx].www_directory_path, + 'https': https or app.extensions[Nginx].https, + } + if destination_file_path is None: + destination_file_path = app.project.configuration.output_directory_path / 'nginx' / 'nginx.conf' + root_path = rootname(Path(__file__)) + configuration_file_template_name = '/'.join((Path(__file__).parent / 'assets' / 'nginx.conf.j2').relative_to(root_path).parts) + template = FileSystemLoader(root_path).load(app.jinja2_environment, configuration_file_template_name, app.jinja2_environment.globals) + await makedirs(destination_file_path.parent, exist_ok=True) + configuration_file_contents = await template.render_async(kwargs) + async with aiofiles.open(destination_file_path, 'w', encoding='utf-8') as f: + await f.write(configuration_file_contents) + + +async def generate_dockerfile_file(app: App, destination_file_path: Path | None = None) -> None: + if destination_file_path is None: + destination_file_path = app.project.configuration.output_directory_path / 'nginx' / 'docker' / 'Dockerfile' + await makedirs(destination_file_path.parent, exist_ok=True) + copyfile(Path(__file__).parent / 'assets' / 'docker' / 'Dockerfile', destination_file_path) diff --git a/betty/extension/nginx/assets/docker/Dockerfile b/betty/extension/nginx/assets/docker/Dockerfile new file mode 100644 index 000000000..9fcd644b6 --- /dev/null +++ b/betty/extension/nginx/assets/docker/Dockerfile @@ -0,0 +1,5 @@ +FROM openresty/openresty:alpine + +RUN mkdir /cone +RUN wget -O /cone/cone.lua https://raw.githubusercontent.com/bartfeenstra/cone/master/cone.lua +RUN echo "lua_package_path '/cone/?.lua;;';" > /etc/nginx/conf.d/default.conf diff --git a/betty/extension/nginx/assets/nginx.conf.j2 b/betty/extension/nginx/assets/nginx.conf.j2 new file mode 100644 index 000000000..596bf75f9 --- /dev/null +++ b/betty/extension/nginx/assets/nginx.conf.j2 @@ -0,0 +1,87 @@ +{% if https %} + server { + listen 80; + server_name {{ server_name }}; + return 301 https://$host$request_uri; + } +{% endif %} +server { + listen {% if https %}443 ssl http2{% else %}80{% endif %}; + server_name {{ server_name }}; + root {{ www_directory_path }}; + {% if https %} + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + {% endif %} + {% if app.project.configuration.debug %} + add_header Cache-Control "no-cache"; + {% else %} + add_header Cache-Control "max-age=86400"; + {% endif %} + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + {% if app.project.configuration.content_negotiation %} + set_by_lua_block $media_type_extension { + local available_media_types = {'text/html', 'application/json'} + local media_type_extensions = {} + media_type_extensions['text/html'] = 'html' + media_type_extensions['application/json'] = 'json' + local media_type = require('cone').negotiate(ngx.req.get_headers()['Accept'], available_media_types) + return media_type_extensions[media_type] + } + {% else %} + set $media_type_extension html; + {% endif %} + index index.$media_type_extension; + + {% if app.project.configuration.locales.multilingual %} + location ~ ^/({{ app.project.configuration.locales.values() | map(attribute='alias') | join('|') }})(/|$) { + set $locale $1; + + add_header Content-Language "$locale" always; + + # Handle HTTP error responses. + error_page 401 /$locale/.error/401.$media_type_extension; + error_page 403 /$locale/.error/403.$media_type_extension; + error_page 404 /$locale/.error/404.$media_type_extension; + location ~ ^/$locale/\.error { + internal; + } + + try_files $uri $uri/ =404; + } + location @localized_redirect { + {% if app.project.configuration.content_negotiation %} + set_by_lua_block $locale_alias { + local available_locales = {'{{ app.project.configuration.locales | join("', '") }}'} + local locale_aliases = {} + {% for locale_configuration in app.project.configuration.locales.values() %} + locale_aliases['{{ locale_configuration.locale }}'] = '{{ locale_configuration.alias }}' + {% endfor %} + local locale = require('cone').negotiate(ngx.req.get_headers()['Accept-Language'], available_locales) + return locale_aliases[locale] + } + {% else %} + set $locale_alias {{ app.project.configuration.locales.default.alias }}; + {% endif %} + return 301 /$locale_alias$uri; + } + location / { + try_files $uri @localized_redirect; + } + {% else %} + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } + {% endif %} +} diff --git a/betty/extension/nginx/docker.py b/betty/extension/nginx/docker.py new file mode 100644 index 000000000..d1778f1c6 --- /dev/null +++ b/betty/extension/nginx/docker.py @@ -0,0 +1,62 @@ +import asyncio +from pathlib import Path +from types import TracebackType + +import docker +from docker.models.containers import Container as DockerContainer + + +class Container: + _IMAGE_TAG = 'betty-serve' + + def __init__(self, www_directory_path: Path, docker_directory_path: Path, nginx_configuration_file_path: Path): + self._docker_directory_path = docker_directory_path + self._nginx_configuration_file_path = nginx_configuration_file_path + self._www_directory_path = www_directory_path + self._client = docker.from_env() + self.__container: DockerContainer | None = None + + async def __aenter__(self) -> None: + await self.start() + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: + await self.stop() + + async def start(self) -> None: + await asyncio.to_thread(self._start) + + def _start(self) -> None: + self._client.images.build(path=str(self._docker_directory_path), tag=self._IMAGE_TAG) + self._container.start() + self._container.exec_run(['nginx', '-s', 'reload']) + + async def stop(self) -> None: + await asyncio.to_thread(self._stop) + + def _stop(self) -> None: + if self._container is not None: + self._container.stop() + + @property + def _container(self) -> DockerContainer: + if self.__container is None: + self.__container = self._client.containers.create( + self._IMAGE_TAG, + auto_remove=True, + detach=True, + volumes={ + self._nginx_configuration_file_path: { + 'bind': '/etc/nginx/conf.d/betty.conf', + 'mode': 'ro', + }, + self._www_directory_path: { + 'bind': '/var/www/betty', + 'mode': 'ro', + }, + }, + ) + return self.__container + + @property + def ip(self) -> DockerContainer: + return self._client.api.inspect_container(self._container.id)['NetworkSettings']['Networks']['bridge']['IPAddress'] diff --git a/betty/extension/nginx/serve.py b/betty/extension/nginx/serve.py new file mode 100644 index 000000000..4cb705ba9 --- /dev/null +++ b/betty/extension/nginx/serve.py @@ -0,0 +1,76 @@ +import logging +from pathlib import Path +from typing import Any + +import dill +import docker +from aiofiles.tempfile import TemporaryDirectory, AiofilesContextManagerTempDir +from docker.errors import DockerException + +from betty.app import App +from betty.extension.nginx.artifact import generate_dockerfile_file, generate_configuration_file +from betty.extension.nginx.docker import Container +from betty.serve import NoPublicUrlBecauseServerNotStartedError, AppServer + + +class DockerizedNginxServer(AppServer): + def __init__(self, app: App) -> None: + super().__init__( + # Create a new app so we can modify it later. + dill.loads(dill.dumps(app)) + ) + self._container: Container | None = None + self._output_directory: AiofilesContextManagerTempDir[None, Any, Any] | None = None + + async def start(self) -> None: + from betty.extension import Nginx + + await super().start() + logging.getLogger().info('Starting a Dockerized nginx web server...') + self._output_directory = TemporaryDirectory() + output_directory_name = await self._output_directory.__aenter__() + nginx_configuration_file_path = Path(output_directory_name) / 'nginx.conf' + docker_directory_path = Path(output_directory_name) / 'docker' + dockerfile_file_path = docker_directory_path / 'Dockerfile' + + self._app.project.configuration.debug = True + # Work around https://github.com/bartfeenstra/betty/issues/1056. + self._app.extensions[Nginx].configuration.https = False + + await generate_configuration_file( + self._app, + destination_file_path=nginx_configuration_file_path, + https=False, + www_directory_path='/var/www/betty', + ) + await generate_dockerfile_file( + self._app, + destination_file_path=dockerfile_file_path, + ) + self._container = Container( + self._app.project.configuration.www_directory_path, + docker_directory_path, + nginx_configuration_file_path, + ) + await self._container.start() + + async def stop(self) -> None: + if self._container is not None: + await self._container.stop() + if self._output_directory is not None: + await self._output_directory.__aexit__(None, None, None) + + @property + def public_url(self) -> str: + if self._container is not None: + return 'http://%s' % self._container.ip + raise NoPublicUrlBecauseServerNotStartedError() + + @classmethod + def is_available(cls) -> bool: + try: + docker.from_env().info() + return True + except DockerException as e: + logging.getLogger().warning(e) + return False diff --git a/betty/gui/serve.py b/betty/gui/serve.py index 627ae4e98..6162f9ad0 100644 --- a/betty/gui/serve.py +++ b/betty/gui/serve.py @@ -11,7 +11,7 @@ from betty.gui.error import catch_exceptions from betty.gui.text import Text from betty.project import Project -from betty.serve import Server, ProjectServer +from betty.serve import Server, AppServer class _ServeThread(QThread): @@ -122,7 +122,7 @@ def _stop(self) -> None: class ServeProjectWindow(_ServeWindow): def _server_name(self) -> str: - return ProjectServer.get(self._app).name() + return AppServer.get(self._app).name() @property def title(self) -> str: diff --git a/betty/serde/error.py b/betty/serde/error.py index cd7bc65d7..d4f497e17 100644 --- a/betty/serde/error.py +++ b/betty/serde/error.py @@ -45,9 +45,12 @@ class SerdeErrorCollection(SerdeError): A collection of zero or more serialization or deserialization errors. """ - def __init__(self): + def __init__( + self, + errors: list[SerdeError] | None = None, + ): super().__init__(Str._('The following errors occurred')) - self._errors: list[SerdeError] = [] + self._errors: list[SerdeError] = errors or [] def __iter__(self) -> Iterator[SerdeError]: yield from self._errors @@ -55,6 +58,9 @@ def __iter__(self) -> Iterator[SerdeError]: def localize(self, localizer: Localizer) -> str: return '\n\n'.join(map(lambda error: error.localize(localizer), self._errors)) + def __reduce__(self) -> tuple[type[Self], tuple[list[SerdeError]]]: # type: ignore[override] + return type(self), (self._errors,) + def __len__(self) -> int: return len(self._errors) diff --git a/betty/serde/load.py b/betty/serde/load.py index 591954b59..d9cf61d7b 100644 --- a/betty/serde/load.py +++ b/betty/serde/load.py @@ -113,6 +113,7 @@ def _assert_type_violation_error_message( asserted_type: type[DumpType], ) -> Str: messages = { + None: Str._('This must be none/null.'), bool: Str._('This must be a boolean.'), int: Str._('This must be a whole number.'), float: Str._('This must be a decimal number.'), @@ -149,6 +150,11 @@ def _assert_or(value: Any) -> ReturnT | ReturnU: raise errors return _assert_or + def assert_none(self) -> Assertion[Any, None]: + def _assert_none(value: Any) -> None: + self._assert_type(value, type(None)) + return _assert_none + def assert_bool(self) -> Assertion[Any, bool]: def _assert_bool(value: Any) -> bool: return self._assert_type(value, bool) diff --git a/betty/serve.py b/betty/serve.py index dd284e376..0c940de3f 100644 --- a/betty/serve.py +++ b/betty/serve.py @@ -15,7 +15,6 @@ from betty.asyncio import sync from betty.error import UserFacingError from betty.locale import Str, Localizer -from betty.project import Project DEFAULT_PORT = 8000 @@ -53,7 +52,7 @@ async def start(self) -> None: """ Starts the server. """ - raise NotImplementedError(repr(self)) + pass async def show(self) -> None: """ @@ -68,7 +67,7 @@ async def stop(self) -> None: """ Stops the server. """ - raise NotImplementedError(repr(self)) + pass @property def public_url(self) -> str: @@ -82,20 +81,20 @@ async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseExc await self.stop() -class ProjectServer(Server): - def __init__(self, localizer: Localizer, project: Project) -> None: - super().__init__(localizer) - self._project = project +class AppServer(Server): + def __init__(self, app: App) -> None: + super().__init__(app.localizer) + self._app = app @staticmethod - def get(app: App) -> ProjectServer: + def get(app: App) -> AppServer: for server in app.servers.values(): - if isinstance(server, ProjectServer): + if isinstance(server, AppServer): return server raise RuntimeError(f'Cannot find a project server. This must never happen, because {BuiltinServer} should be the fallback.') async def start(self) -> None: - await makedirs(self._project.configuration.www_directory_path, exist_ok=True) + await makedirs(self._app.project.configuration.www_directory_path, exist_ok=True) await super().start() @@ -111,9 +110,9 @@ def end_headers(self) -> None: super().end_headers() -class BuiltinServer(ProjectServer): - def __init__(self, localizer: Localizer, project: Project) -> None: - super().__init__(localizer, project) +class BuiltinServer(AppServer): + def __init__(self, app: App) -> None: + super().__init__(app) self._http_server: HTTPServer | None = None self._port: int | None = None self._thread: threading.Thread | None = None @@ -123,6 +122,7 @@ def label(cls) -> Str: return Str._('Python built-in') async def start(self) -> None: + await super().start() logging.getLogger().info(self._localizer._("Starting Python's built-in web server...")) for self._port in range(DEFAULT_PORT, 65535): with contextlib.suppress(OSError): @@ -132,13 +132,13 @@ async def start(self) -> None: request, client_address, server, - directory=str(self._project.configuration.www_directory_path), + directory=str(self._app.project.configuration.www_directory_path), ), ) break if self._http_server is None: raise OsError(Str._('Cannot find an available port to bind the web server to.')) - self._thread = threading.Thread(target=self._serve, args=(self._project,)) + self._thread = threading.Thread(target=self._serve) self._thread.start() @property @@ -148,12 +148,13 @@ def public_url(self) -> str: raise NoPublicUrlBecauseServerNotStartedError() @sync - async def _serve(self, project: Project) -> None: + async def _serve(self) -> None: with contextlib.redirect_stderr(StringIO()): assert self._http_server self._http_server.serve_forever() async def stop(self) -> None: + await super().stop() if self._http_server is not None: self._http_server.shutdown() self._http_server.server_close() diff --git a/betty/tests/extension/nginx/__init__.py b/betty/tests/extension/nginx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/betty/tests/extension/nginx/test___init__.py b/betty/tests/extension/nginx/test___init__.py new file mode 100644 index 000000000..d4a1e130c --- /dev/null +++ b/betty/tests/extension/nginx/test___init__.py @@ -0,0 +1,333 @@ +import re +import sys +from typing import Optional + +from betty.app import App +from betty.extension import Nginx +from betty.extension.nginx import NginxConfiguration +from betty.generate import generate +from betty.project import ExtensionConfiguration, LocaleConfiguration + + +class TestNginx: + _LEADING_WHITESPACE_PATTERN = re.compile(r'^\s*(.*?)$') + + def _normalize_configuration(self, configuration: str) -> str: + return '\n'.join(filter(None, map(self._normalize_configuration_line, configuration.splitlines()))) + + def _normalize_configuration_line(self, line: str) -> Optional[str]: + match = self._LEADING_WHITESPACE_PATTERN.fullmatch(line) + if match is None: + return None + return match.group(1) + + async def _assert_configuration_equals(self, expected: str, app: App): + async with app: + await generate(app) + with open(app.project.configuration.output_directory_path / 'nginx' / 'nginx.conf') as f: + actual = f.read() + assert self._normalize_configuration(expected) == self._normalize_configuration(actual) + + async def test_post_render_config(self): + app = App() + app.project.configuration.base_url = 'http://example.com' + app.project.configuration.extensions.append( + ExtensionConfiguration(Nginx) + ) + expected = r''' +server { + listen 80; + server_name example.com; + root %s; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_with_clean_urls(self): + app = App() + app.project.configuration.base_url = 'http://example.com' + app.project.configuration.clean_urls = True + app.project.configuration.extensions.append( + ExtensionConfiguration(Nginx) + ) + expected = r''' +server { + listen 80; + server_name example.com; + root %s; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_multilingual(self): + app = App() + app.project.configuration.base_url = 'http://example.com' + app.project.configuration.locales.replace( + LocaleConfiguration('en-US', 'en'), + LocaleConfiguration('nl-NL', 'nl'), + ) + app.project.configuration.extensions.append( + ExtensionConfiguration(Nginx) + ) + expected = r''' +server { + listen 80; + server_name example.com; + root %s; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location ~ ^/(en|nl)(/|$) { + set $locale $1; + + add_header Content-Language "$locale" always; + + # Handle HTTP error responses. + error_page 401 /$locale/.error/401.$media_type_extension; + error_page 403 /$locale/.error/403.$media_type_extension; + error_page 404 /$locale/.error/404.$media_type_extension; + location ~ ^/$locale/\.error { + internal; + } + + try_files $uri $uri/ =404; + } + location @localized_redirect { + set $locale_alias en; + return 301 /$locale_alias$uri; + } + location / { + try_files $uri @localized_redirect; + } +} +''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_multilingual_with_content_negotiation(self): + app = App() + app.project.configuration.base_url = 'http://example.com' + app.project.configuration.content_negotiation = True + app.project.configuration.locales.replace( + LocaleConfiguration('en-US', 'en'), + LocaleConfiguration('nl-NL', 'nl'), + ) + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = r''' +server { + listen 80; + server_name example.com; + root %s; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set_by_lua_block $media_type_extension { + local available_media_types = {'text/html', 'application/json'} + local media_type_extensions = {} + media_type_extensions['text/html'] = 'html' + media_type_extensions['application/json'] = 'json' + local media_type = require('cone').negotiate(ngx.req.get_headers()['Accept'], available_media_types) + return media_type_extensions[media_type] + } + index index.$media_type_extension; + + location ~ ^/(en|nl)(/|$) { + set $locale $1; + + add_header Content-Language "$locale" always; + + # Handle HTTP error responses. + error_page 401 /$locale/.error/401.$media_type_extension; + error_page 403 /$locale/.error/403.$media_type_extension; + error_page 404 /$locale/.error/404.$media_type_extension; + location ~ ^/$locale/\.error { + internal; + } + + try_files $uri $uri/ =404; + } + location @localized_redirect { + set_by_lua_block $locale_alias { + local available_locales = {'en-US', 'nl-NL'} + local locale_aliases = {} + locale_aliases['en-US'] = 'en' + locale_aliases['nl-NL'] = 'nl' + local locale = require('cone').negotiate(ngx.req.get_headers()['Accept-Language'], available_locales) + return locale_aliases[locale] + } + return 301 /$locale_alias$uri; + } + location / { + try_files $uri @localized_redirect; + } +} +''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_with_content_negotiation(self): + app = App() + app.project.configuration.base_url = 'http://example.com' + app.project.configuration.content_negotiation = True + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = r''' +server { + listen 80; + server_name example.com; + root %s; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set_by_lua_block $media_type_extension { + local available_media_types = {'text/html', 'application/json'} + local media_type_extensions = {} + media_type_extensions['text/html'] = 'html' + media_type_extensions['application/json'] = 'json' + local media_type = require('cone').negotiate(ngx.req.get_headers()['Accept'], available_media_types) + return media_type_extensions[media_type] + } + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +}''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_with_https(self): + app = App() + app.project.configuration.base_url = 'https://example.com' + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx)) + expected = r''' +server { + listen 80; + server_name example.com; + return 301 https://$host$request_uri; +} +server { + listen 443 ssl http2; + server_name example.com; + root %s; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +''' % app.project.configuration.www_directory_path + await self._assert_configuration_equals(expected, app) + + async def test_post_render_config_with_overridden_www_directory_path(self): + app = App() + app.project.configuration.extensions.append(ExtensionConfiguration(Nginx, True, NginxConfiguration( + www_directory_path='/tmp/overridden-www', + ))) + expected_root_path = '\\tmp\\overridden-www' if sys.platform == 'win32' else '/tmp/overridden-www' + expected = ''' +server { + listen 80; + server_name example.com; + return 301 https://$host$request_uri; +} +server { + listen 443 ssl http2; + server_name example.com; + root %s; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Cache-Control "max-age=86400"; + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_types text/css application/javascript application/json application/xml; + + set $media_type_extension html; + index index.$media_type_extension; + + location / { + # Handle HTTP error responses. + error_page 401 /.error/401.$media_type_extension; + error_page 403 /.error/403.$media_type_extension; + error_page 404 /.error/404.$media_type_extension; + location /.error { + internal; + } + + try_files $uri $uri/ =404; + } +} +''' % expected_root_path + await self._assert_configuration_equals(expected, app) diff --git a/betty/tests/extension/nginx/test_integration.py b/betty/tests/extension/nginx/test_integration.py new file mode 100644 index 000000000..96c951123 --- /dev/null +++ b/betty/tests/extension/nginx/test_integration.py @@ -0,0 +1,184 @@ +import json +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import html5lib +import jsonschema +import pytest +import requests +from requests import Response + +from betty import generate +from betty.app import App +from betty.extension import Nginx +from betty.extension.nginx import NginxConfiguration +from betty.extension.nginx.serve import DockerizedNginxServer +from betty.project import ProjectConfiguration, ExtensionConfiguration, LocaleConfiguration +from betty.serve import Server + + +@pytest.mark.skipif(sys.platform in {'darwin', 'win32'}, reason='Mac OS and Windows do not natively support Docker.') +class TestNginx: + @asynccontextmanager + async def server(self, configuration: ProjectConfiguration) -> AsyncIterator[Server]: + async with App() as app: + app.project.configuration.update(configuration) + await generate.generate(app) + async with DockerizedNginxServer(app) as server: + yield server + + def assert_betty_html(self, response: Response) -> None: + assert 'text/html' == response.headers['Content-Type'] + parser = html5lib.HTMLParser() + parser.parse(response.text) + assert 'Betty' in response.text + + def assert_betty_json(self, response: Response) -> None: + assert 'application/json' == response.headers['Content-Type'] + data = response.json() + with open(Path(__file__).parents[3] / 'assets' / 'public' / 'static' / 'schema.json') as f: + jsonschema.validate(data, json.load(f)) + + def monolingual_configuration(self) -> ProjectConfiguration: + configuration = ProjectConfiguration() + configuration.extensions.append(ExtensionConfiguration(Nginx, True, NginxConfiguration( + www_directory_path='/var/www/betty/', + ))) + return configuration + + def monolingual_content_negotiation_configuration(self) -> ProjectConfiguration: + configuration = ProjectConfiguration() + configuration.extensions.append(ExtensionConfiguration(Nginx, True, NginxConfiguration( + www_directory_path='/var/www/betty/', + ))) + configuration.content_negotiation = True + return configuration + + def multilingual_configuration(self) -> ProjectConfiguration: + configuration = ProjectConfiguration() + configuration.extensions.append(ExtensionConfiguration(Nginx, True, NginxConfiguration( + www_directory_path='/var/www/betty/', + ))) + configuration.locales.replace( + LocaleConfiguration('en-US', 'en'), + LocaleConfiguration('nl-NL', 'nl'), + ) + return configuration + + def multilingual_content_negotiation_configuration(self) -> ProjectConfiguration: + configuration = ProjectConfiguration() + configuration.extensions.append(ExtensionConfiguration(Nginx, True, NginxConfiguration( + www_directory_path='/var/www/betty/', + ))) + configuration.content_negotiation = True + configuration.locales.replace( + LocaleConfiguration('en-US', 'en'), + LocaleConfiguration('nl-NL', 'nl'), + ) + return configuration + + async def test_front_page(self): + async with self.server(self.monolingual_configuration()) as server: + response = requests.get(server.public_url) + assert 200 == response.status_code + self.assert_betty_html(response) + + async def test_default_html_404(self): + async with self.server(self.monolingual_configuration()) as server: + response = requests.get('%s/non-existent' % server.public_url) + assert 404 == response.status_code + self.assert_betty_html(response) + + async def test_negotiated_json_404(self): + async with self.server(self.monolingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent' % server.public_url, headers={ + 'Accept': 'application/json', + }) + assert 404 == response.status_code + self.assert_betty_json(response) + + async def test_default_localized_front_page(self): + async with self.server(self.multilingual_configuration()) as server: + response = requests.get(server.public_url) + assert 200 == response.status_code + assert 'en' == response.headers['Content-Language'] + assert f'{server.public_url}/en/' == response.url + self.assert_betty_html(response) + + async def test_explicitly_localized_404(self): + async with self.server(self.multilingual_configuration()) as server: + response = requests.get('%s/nl/non-existent' % server.public_url) + assert 404 == response.status_code + assert 'nl' == response.headers['Content-Language'] + self.assert_betty_html(response) + + async def test_negotiated_localized_front_page(self): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get(server.public_url, headers={ + 'Accept-Language': 'nl-NL', + }) + assert 200 == response.status_code + assert 'nl' == response.headers['Content-Language'] + assert f'{server.public_url}/nl/' == response.url + self.assert_betty_html(response) + + async def test_negotiated_localized_default_html_404(self): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent' % server.public_url, headers={ + 'Accept-Language': 'nl-NL', + }) + assert 404 == response.status_code + assert 'nl' == response.headers['Content-Language'] + self.assert_betty_html(response) + + async def test_negotiated_localized_negotiated_json_404(self): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent' % server.public_url, headers={ + 'Accept': 'application/json', + 'Accept-Language': 'nl-NL', + }) + assert 404 == response.status_code + self.assert_betty_json(response) + + async def test_default_html_resource(self): + async with self.server(self.monolingual_content_negotiation_configuration()) as server: + response = requests.get('%s/place/' % server.public_url) + assert 200 == response.status_code + self.assert_betty_html(response) + + async def test_negotiated_html_resource(self): + async with self.server(self.monolingual_content_negotiation_configuration()) as server: + response = requests.get('%s/place/' % server.public_url, headers={ + 'Accept': 'text/html', + }) + assert 200 == response.status_code + self.assert_betty_html(response) + + async def test_negotiated_json_resource(self): + async with self.server(self.monolingual_content_negotiation_configuration()) as server: + response = requests.get('%s/place/' % server.public_url, headers={ + 'Accept': 'application/json', + }) + assert 200 == response.status_code + self.assert_betty_json(response) + + async def test_default_html_static_resource(self): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent-path/' % server.public_url) + self.assert_betty_html(response) + + async def test_negotiated_html_static_resource(self, tmp_path: Path): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent-path/' % server.public_url, headers={ + 'Accept': 'text/html', + }) + self.assert_betty_html(response) + + async def test_negotiated_json_static_resource(self): + async with self.server(self.multilingual_content_negotiation_configuration()) as server: + response = requests.get('%s/non-existent-path/' % server.public_url, headers={ + 'Accept': 'application/json', + }) + self.assert_betty_json(response) diff --git a/betty/tests/extension/nginx/test_serve.py b/betty/tests/extension/nginx/test_serve.py new file mode 100644 index 000000000..134d94090 --- /dev/null +++ b/betty/tests/extension/nginx/test_serve.py @@ -0,0 +1,35 @@ +import sys +from time import sleep + +import aiofiles +import pytest +import requests +from aiofiles.os import makedirs + +from betty.app import App +from betty.asyncio import sync +from betty.extension import Nginx +from betty.extension.nginx import NginxConfiguration +from betty.extension.nginx.serve import DockerizedNginxServer +from betty.project import ExtensionConfiguration + + +@pytest.mark.skipif(sys.platform in {'darwin', 'win32'}, reason='Mac OS and Windows do not natively support Docker.') +class TestDockerizedNginxServer: + @sync + async def test(self): + content = 'Hello, and welcome to my site!' + async with App() as app: + app.project.configuration.extensions.append( + ExtensionConfiguration(Nginx, True, NginxConfiguration(www_directory_path='/var/www/betty')) + ) + await makedirs(app.project.configuration.www_directory_path) + async with aiofiles.open(app.project.configuration.www_directory_path / 'index.html', 'w') as f: + await f.write(content) + async with DockerizedNginxServer(app) as server: + # Wait for the server to start. + sleep(1) + response = requests.get(server.public_url) + assert 200 == response.status_code + assert content == response.content.decode('utf-8') + assert 'no-cache' == response.headers['Cache-Control'] diff --git a/betty/tests/test_cli.py b/betty/tests/test_cli.py index 77bdf777f..e41f140ca 100644 --- a/betty/tests/test_cli.py +++ b/betty/tests/test_cli.py @@ -13,11 +13,11 @@ from betty import fs from betty.error import UserFacingError -from betty.locale import DEFAULT_LOCALIZER, Str +from betty.locale import Str from betty.os import ChDir -from betty.project import ProjectConfiguration, ExtensionConfiguration, Project +from betty.project import ProjectConfiguration, ExtensionConfiguration from betty.serde.dump import Dump -from betty.serve import ProjectServer +from betty.serve import AppServer from betty.tests import patch_cache try: @@ -166,7 +166,7 @@ async def test(self) -> None: class TestDemo: async def test(self, mocker: MockerFixture) -> None: - mocker.patch('betty.serve.BuiltinServer', new_callable=lambda: _KeyboardInterruptedProjectServer) + mocker.patch('betty.serve.BuiltinServer', new_callable=lambda: _KeyboardInterruptedAppServer) mocker.patch('webbrowser.open_new_tab') runner = CliRunner() result = runner.invoke(main, ('demo',), catch_exceptions=False) @@ -199,9 +199,9 @@ async def test(self, mocker: MockerFixture) -> None: assert {} == render_kwargs -class _KeyboardInterruptedProjectServer(ProjectServer): +class _KeyboardInterruptedAppServer(AppServer): def __init__(self, *_: Any, **__: Any): - super().__init__(DEFAULT_LOCALIZER, Project()) + super().__init__(App()) async def start(self) -> None: raise KeyboardInterrupt @@ -209,7 +209,7 @@ async def start(self) -> None: class Serve: async def test(self, mocker: MockerFixture) -> None: - mocker.patch('betty.serve.BuiltinServer', new_callable=lambda: _KeyboardInterruptedProjectServer) + mocker.patch('betty.serve.BuiltinServer', new_callable=lambda: _KeyboardInterruptedAppServer) configuration = ProjectConfiguration() await configuration.write() os.makedirs(configuration.www_directory_path) diff --git a/betty/tests/test_serve.py b/betty/tests/test_serve.py index ddd4672b4..7af941e39 100644 --- a/betty/tests/test_serve.py +++ b/betty/tests/test_serve.py @@ -5,7 +5,6 @@ from pytest_mock import MockerFixture from betty.app import App -from betty.locale import DEFAULT_LOCALIZER from betty.serve import BuiltinServer @@ -17,7 +16,7 @@ async def test(self, mocker: MockerFixture) -> None: os.makedirs(app.project.configuration.www_directory_path) with open(app.project.configuration.www_directory_path / 'index.html', 'w') as f: f.write(content) - async with BuiltinServer(DEFAULT_LOCALIZER, app.project) as server: + async with BuiltinServer(app) as server: # Wait for the server to start. sleep(1) response = requests.get(server.public_url) diff --git a/mypy.ini b/mypy.ini index 8432ebcf6..4ebcaec4d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -23,6 +23,9 @@ ignore_missing_imports = True [mypy-dill.*] ignore_missing_imports = True +[mypy-docker.*] +ignore_missing_imports = True + [mypy-geopy.*] ignore_missing_imports = True diff --git a/setup.py b/setup.py index 4c131f56d..e91e7fdea 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ 'babel ~= 2.12, >= 2.12.0', 'click ~= 8.1, >= 8.1.2', 'dill ~= 0.3, >= 0.3.6', + 'docker ~= 7.0, >= 7.0.0', 'geopy ~= 2.3, >= 2.3.0', 'jinja2 ~= 3.1, >= 3.1.1', 'jsonschema ~= 4.17, >= 4.17.0',