From b99abf6a7587221796faebf90165fd90559d326f Mon Sep 17 00:00:00 2001 From: Bart Feenstra Date: Wed, 8 May 2024 13:39:41 +0100 Subject: [PATCH] Add a command to serve sites using the Dockerized nginx server (#1476) --- betty/cli.py | 12 ++++++---- betty/extension/nginx/__init__.py | 16 ++++++++++++- betty/extension/nginx/cli.py | 20 ++++++++++++++++ betty/tests/extension/nginx/test_cli.py | 28 ++++++++++++++++++++++ betty/tests/test_cli.py | 32 ++++++++++++------------- betty/tests/test_documentation.py | 6 ++++- documentation/usage/cli.rst | 1 + 7 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 betty/extension/nginx/cli.py create mode 100644 betty/tests/extension/nginx/test_cli.py diff --git a/betty/cli.py b/betty/cli.py index fd8b47664..7cfd6fb20 100644 --- a/betty/cli.py +++ b/betty/cli.py @@ -21,10 +21,6 @@ from betty.asyncio import wait_to_thread from betty.contextlib import SynchronizedContextManager from betty.error import UserFacingError -from betty.extension import demo -from betty.gui import BettyApplication -from betty.gui.app import WelcomeWindow -from betty.gui.project import ProjectWindow from betty.locale import update_translations, init_translation, Str from betty.logging import CliHandler from betty.serde.load import AssertionFailed @@ -268,7 +264,9 @@ async def _clear_caches(app: App) -> None: @click.command(help="Explore a demonstration site.") @app_command async def _demo(app: App) -> None: - async with demo.DemoServer(app=app) as server: + from betty.extension.demo import DemoServer + + async with DemoServer(app=app) as server: await server.show() while True: await asyncio.sleep(999) @@ -288,6 +286,10 @@ async def _demo(app: App) -> None: @global_command async def _gui(configuration_file_path: Path | None) -> None: async with App.new_from_environment() as app: + from betty.gui import BettyApplication + from betty.gui.app import WelcomeWindow + from betty.gui.project import ProjectWindow + async with BettyApplication([sys.argv[0]]).with_app(app) as qapp: window: QMainWindow if configuration_file_path is None: diff --git a/betty/extension/nginx/__init__.py b/betty/extension/nginx/__init__.py index 42dcb0f8b..8e5a1df05 100644 --- a/betty/extension/nginx/__init__.py +++ b/betty/extension/nginx/__init__.py @@ -3,11 +3,15 @@ from collections.abc import Sequence from pathlib import Path +from click import Command + from betty.app.extension import ConfigurableExtension +from betty.cli import CommandProvider from betty.extension.nginx.artifact import ( generate_configuration_file, generate_dockerfile_file, ) +from betty.extension.nginx.cli import _serve from betty.extension.nginx.config import NginxConfiguration from betty.extension.nginx.gui import _NginxGuiWidget from betty.generate import Generator, GenerationContext @@ -17,7 +21,11 @@ class Nginx( - ConfigurableExtension[NginxConfiguration], Generator, ServerProvider, GuiBuilder + ConfigurableExtension[NginxConfiguration], + Generator, + ServerProvider, + GuiBuilder, + CommandProvider, ): @classmethod def label(cls) -> Str: @@ -63,3 +71,9 @@ def www_directory_path(self) -> str: def gui_build(self) -> _NginxGuiWidget: return _NginxGuiWidget(self._app, self._configuration) + + @property + def commands(self) -> dict[str, Command]: + return { + "serve-nginx-docker": _serve, + } diff --git a/betty/extension/nginx/cli.py b/betty/extension/nginx/cli.py new file mode 100644 index 000000000..2c790bb1c --- /dev/null +++ b/betty/extension/nginx/cli.py @@ -0,0 +1,20 @@ +""" +Provide Command Line Interface functionality. +""" + +from asyncio import sleep + +import click + +from betty.app import App +from betty.cli import app_command +from betty.extension.nginx import serve + + +@click.command(help="Serve a generated site with nginx in a Docker container.") +@app_command +async def _serve(app: App) -> None: + async with serve.DockerizedNginxServer(app) as server: + await server.show() + while True: + await sleep(999) diff --git a/betty/tests/extension/nginx/test_cli.py b/betty/tests/extension/nginx/test_cli.py new file mode 100644 index 000000000..ae271636a --- /dev/null +++ b/betty/tests/extension/nginx/test_cli.py @@ -0,0 +1,28 @@ +from aiofiles.os import makedirs +from pytest_mock import MockerFixture + +from betty.app import App +from betty.extension import Nginx +from betty.extension.nginx.serve import DockerizedNginxServer +from betty.tests.test_cli import run + + +class KeyboardInterruptedDockerizedNginxServer(DockerizedNginxServer): + async def start(self) -> None: + raise KeyboardInterrupt() + + +class TestServe: + async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: + mocker.patch( + "betty.extension.nginx.serve.DockerizedNginxServer", + new=KeyboardInterruptedDockerizedNginxServer, + ) + new_temporary_app.project.configuration.extensions.enable(Nginx) + await new_temporary_app.project.configuration.write() + await makedirs(new_temporary_app.project.configuration.www_directory_path) + run( + "-c", + str(new_temporary_app.project.configuration.configuration_file_path), + "serve-nginx-docker", + ) diff --git a/betty/tests/test_cli.py b/betty/tests/test_cli.py index 6e2eb5d0f..db78cf148 100644 --- a/betty/tests/test_cli.py +++ b/betty/tests/test_cli.py @@ -53,7 +53,7 @@ def commands(self) -> dict[str, Command]: } -def _run( +def run( *args: str, expected_exit_code: int = 0, ) -> Result: @@ -65,8 +65,8 @@ def _run( The Betty command `{" ".join(args)}` unexpectedly exited with code {result.exit_code}, but {expected_exit_code} was expected. Stdout: {result.stdout} -Stdout: -{result.stdout} +Stderr: +{result.stderr} """ ) return result @@ -85,14 +85,14 @@ async def new_temporary_app(mocker: MockerFixture) -> AsyncIterator[App]: class TestMain: async def test_without_arguments(self, new_temporary_app: App) -> None: - _run() + run() async def test_help_without_configuration(self, new_temporary_app: App) -> None: - _run("--help") + run("--help") async def test_configuration_without_help(self, new_temporary_app: App) -> None: await new_temporary_app.project.configuration.write() - _run( + run( "-c", str(new_temporary_app.project.configuration.configuration_file_path), expected_exit_code=2, @@ -104,7 +104,7 @@ async def test_help_with_configuration(self, new_temporary_app: App) -> None: ) await new_temporary_app.project.configuration.write() - _run( + run( "-c", str(new_temporary_app.project.configuration.configuration_file_path), "--help", @@ -117,7 +117,7 @@ async def test_help_with_invalid_configuration_file_path( working_directory_path = Path(working_directory_path_str) configuration_file_path = working_directory_path / "non-existent-betty.json" - _run("-c", str(configuration_file_path), "--help", expected_exit_code=1) + run("-c", str(configuration_file_path), "--help", expected_exit_code=1) async def test_help_with_invalid_configuration( self, new_temporary_app: App @@ -129,7 +129,7 @@ async def test_help_with_invalid_configuration( async with aiofiles.open(configuration_file_path, "w") as f: await f.write(json.dumps(dump)) - _run("-c", str(configuration_file_path), "--help", expected_exit_code=1) + run("-c", str(configuration_file_path), "--help", expected_exit_code=1) async def test_with_discovered_configuration(self, new_temporary_app: App) -> None: async with TemporaryDirectory() as working_directory_path_str: @@ -146,7 +146,7 @@ async def test_with_discovered_configuration(self, new_temporary_app: App) -> No } await config_file.write(json.dumps(dump)) with chdir(working_directory_path): - _run("test", expected_exit_code=1) + run("test", expected_exit_code=1) class TestCatchExceptions: @@ -168,7 +168,7 @@ async def test_logging_uncaught_exception(self, caplog: LogCaptureFixture) -> No class TestVersion: async def test(self, new_temporary_app: App) -> None: - result = _run("--version") + result = run("--version") assert "Betty" in result.stdout @@ -176,7 +176,7 @@ class TestClearCaches: async def test(self, new_temporary_app: App) -> None: async with new_temporary_app: await new_temporary_app.cache.set("KeepMeAroundPlease", "") - _run("clear-caches") + run("clear-caches") async with new_temporary_app: async with new_temporary_app.cache.get("KeepMeAroundPlease") as cache_item: assert cache_item is None @@ -193,7 +193,7 @@ async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: "betty.extension.demo.DemoServer", new=KeyboardInterruptedDemoServer ) - _run("demo") + run("demo") class KeyboardInterruptedDocumentationServer(DocumentationServer): @@ -208,7 +208,7 @@ async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: new=KeyboardInterruptedDocumentationServer, ) - _run("docs") + run("docs") class TestGenerate: @@ -217,7 +217,7 @@ async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: m_load = mocker.patch("betty.load.load", new_callable=AsyncMock) await new_temporary_app.project.configuration.write() - _run( + run( "-c", str(new_temporary_app.project.configuration.configuration_file_path), "generate", @@ -246,7 +246,7 @@ async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: ) await new_temporary_app.project.configuration.write() await makedirs(new_temporary_app.project.configuration.www_directory_path) - _run( + run( "-c", str(new_temporary_app.project.configuration.configuration_file_path), "serve", diff --git a/betty/tests/test_documentation.py b/betty/tests/test_documentation.py index 98e1ad40b..8d551e8b7 100644 --- a/betty/tests/test_documentation.py +++ b/betty/tests/test_documentation.py @@ -17,6 +17,7 @@ from betty.functools import Do from betty.locale import DEFAULT_LOCALIZER from betty.project import ProjectConfiguration +from betty.serde.dump import DictDump, Dump from betty.serde.format import Format, Json, Yaml from betty.subprocess import run_process @@ -37,8 +38,11 @@ class TestDocumentation: async def test_should_contain_cli_help(self) -> None: async with TemporaryDirectory() as working_directory_path_str: working_directory_path = Path(working_directory_path_str) - configuration = { + configuration: DictDump[Dump] = { "base_url": "https://example.com", + "extensions": { + "betty.extension.Nginx": {}, + }, } async with aiofiles.open(working_directory_path / "betty.json", "w") as f: await f.write(json.dumps(configuration)) diff --git a/documentation/usage/cli.rst b/documentation/usage/cli.rst index 724c730fa..7861dd7c0 100644 --- a/documentation/usage/cli.rst +++ b/documentation/usage/cli.rst @@ -30,6 +30,7 @@ After installing Betty :doc:`via pip ` or :doc:`from source < update-translations Update all existing translations generate Generate a static site. serve Serve a generated site. + serve-nginx-docker Serve a generated site with nginx in a Docker... Generally you will be using Betty for a specific site. When you call ``betty`` with a :doc:`configuration file ` (e.g. ``betty -c betty.yaml``), additional commands provided by the extensions