diff --git a/.coveragerc b/.coveragerc index 5c61dba72..e9f3731e3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,6 +6,7 @@ omit = betty/_package/** betty/_resizeimage.py betty/tests/** +parallel = True source = betty [report] diff --git a/betty/cli.py b/betty/cli.py index 894acd4b2..68da7a09c 100644 --- a/betty/cli.py +++ b/betty/cli.py @@ -7,6 +7,7 @@ import asyncio import logging import sys +from asyncio import run from contextlib import suppress, contextmanager from functools import wraps from pathlib import Path @@ -16,12 +17,11 @@ from PyQt6.QtWidgets import QMainWindow from click import get_current_context, Context, Option, Command, Parameter -from betty import about, generate, load, documentation +from betty import about, generate, load, documentation, locale from betty.app import App -from betty.asyncio import wait_to_thread from betty.contextlib import SynchronizedContextManager from betty.error import UserFacingError -from betty.locale import update_translations, init_translation, Str +from betty.locale import Str from betty.logging import CliHandler from betty.serde.load import AssertionFailed from betty.serve import AppServer @@ -46,7 +46,7 @@ def catch_exceptions() -> Iterator[None]: except KeyboardInterrupt: print("Quitting...") sys.exit(0) - except BaseException as e: # noqa: B036 + except Exception as e: logger = logging.getLogger(__name__) if isinstance(e, UserFacingError): logger.error(str(e)) @@ -55,40 +55,41 @@ def catch_exceptions() -> Iterator[None]: sys.exit(1) -def _command( - f: Callable[P, Awaitable[None]] | Callable[Concatenate[App, P], Awaitable[None]], - is_app_command: bool, -) -> Callable[P, None]: +def global_command(f: Callable[P, Awaitable[None]]) -> Callable[P, None]: + """ + Decorate a command to be global. + """ + @wraps(f) @catch_exceptions() def _command(*args: P.args, **kwargs: P.kwargs) -> None: - if is_app_command: - # We must get the current Click context from the main thread. - # Once that is done, we can wait for the async commands to complete, which MAY be done in a thread. - app = get_current_context().obj["app"] + # Use a wrapper, because the decorator uses Awaitable, but asyncio.run requires Coroutine. + async def __command(): + await f(*args, **kwargs) - async def _app_command(): - async with app: - await f(app, *args, **kwargs) - - return wait_to_thread(_app_command()) - return wait_to_thread(f(*args, **kwargs)) + return run(__command()) return _command -def global_command(f: Callable[P, Awaitable[None]]) -> Callable[P, None]: +def app_command(f: Callable[Concatenate[App, P], Awaitable[None]]) -> Callable[P, None]: """ - Decorate a command to be global. + Decorate a command to receive the currently running :py:class:`betty.app.App` as its first argument. """ - return _command(f, False) + @wraps(f) + @catch_exceptions() + def _command(*args: P.args, **kwargs: P.kwargs) -> None: + # Use a wrapper, because the decorator uses Awaitable, but asyncio.run requires Coroutine. + app = get_current_context().obj["app"] -def app_command(f: Callable[Concatenate[App, P], Awaitable[None]]) -> Callable[P, None]: - """ - Decorate a command to receive an app. - """ - return _command(f, True) + async def __command(): + async with app: + await f(app, *args, **kwargs) + + return run(__command()) + + return _command @catch_exceptions() @@ -97,7 +98,7 @@ def _init_ctx_app( __: Option | Parameter | None = None, configuration_file_path: str | None = None, ) -> None: - wait_to_thread(__init_ctx_app(ctx, configuration_file_path)) + run(__init_ctx_app(ctx, configuration_file_path)) async def __init_ctx_app( @@ -122,7 +123,7 @@ async def __init_ctx_app( "demo": _demo, "gui": _gui, } - if wait_to_thread(about.is_development()): + if await about.is_development(): ctx.obj["commands"]["init-translation"] = _init_translation ctx.obj["commands"]["update-translations"] = _update_translations ctx.obj["app"] = app @@ -183,6 +184,7 @@ def _init_ctx_verbosity( and logger.getEffectiveLevel() > logger_level ): logger.setLevel(logger_level) + raise RuntimeError([logger_level, logger, logger.level]) return _init_ctx_verbosity @@ -244,8 +246,8 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command | None: callback=_build_init_ctx_verbosity(logging.NOTSET, logging.NOTSET), ) @click.version_option( - wait_to_thread(about.version_label()), - message=wait_to_thread(about.report()), + run(about.version_label()), + message=run(about.report()), prog_name="Betty", ) def main(app: App, verbose: bool, more_verbose: bool, most_verbose: bool) -> None: @@ -283,22 +285,21 @@ async def _demo(app: App) -> None: Path(configuration_file_path) if configuration_file_path else 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: - window = WelcomeWindow(app) - else: - await app.project.configuration.read(configuration_file_path) - window = ProjectWindow(app) - window.show() - sys.exit(qapp.exec()) +@app_command +async def _gui(app: App, configuration_file_path: Path | None) -> None: + 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: + window = WelcomeWindow(app) + else: + await app.project.configuration.read(configuration_file_path) + window = ProjectWindow(app) + window.show() + sys.exit(qapp.exec()) @click.command(help="Generate a static site.") @@ -318,35 +319,34 @@ async def _serve(app: App) -> None: @click.command(help="View the documentation.") +@app_command +async def _docs(app: App): + server = documentation.DocumentationServer( + app.binary_file_cache.path, + localizer=app.localizer, + ) + async with server: + await server.show() + while True: + await asyncio.sleep(999) + + +@click.command( + short_help="Initialize a new translation", + help="Initialize a new translation.\n\nThis is available only when developing Betty.", +) +@click.argument("locale") @global_command -async def _docs(): - async with App.new_from_environment() as app: - async with app: - server = documentation.DocumentationServer( - app.binary_file_cache.path, - localizer=app.localizer, - ) - async with server: - await server.show() - while True: - await asyncio.sleep(999) +async def _init_translation(locale: str) -> None: + from betty.locale import init_translation + await init_translation(locale) -if wait_to_thread(about.is_development()): - @click.command( - short_help="Initialize a new translation", - help="Initialize a new translation.\n\nThis is available only when developing Betty.", - ) - @click.argument("locale") - @global_command - async def _init_translation(locale: str) -> None: - await init_translation(locale) - - @click.command( - short_help="Update all existing translations", - help="Update all existing translations.\n\nThis is available only when developing Betty.", - ) - @global_command - async def _update_translations() -> None: - await update_translations() +@click.command( + short_help="Update all existing translations", + help="Update all existing translations.\n\nThis is available only when developing Betty.", +) +@global_command +async def _update_translations() -> None: + await locale.update_translations() diff --git a/betty/extension/nginx/cli.py b/betty/extension/nginx/cli.py index 2c790bb1c..daaf44883 100644 --- a/betty/extension/nginx/cli.py +++ b/betty/extension/nginx/cli.py @@ -2,7 +2,7 @@ Provide Command Line Interface functionality. """ -from asyncio import sleep +import asyncio import click @@ -17,4 +17,4 @@ async def _serve(app: App) -> None: async with serve.DockerizedNginxServer(app) as server: await server.show() while True: - await sleep(999) + await asyncio.sleep(999) diff --git a/betty/tests/conftest.py b/betty/tests/conftest.py index af42857c7..17967d048 100644 --- a/betty/tests/conftest.py +++ b/betty/tests/conftest.py @@ -28,7 +28,6 @@ from betty.app import App from betty.cache.file import BinaryFileCache from betty.gui import BettyApplication -from betty.gui.app import BettyPrimaryWindow from betty.gui.error import ExceptionError from betty.locale import DEFAULT_LOCALIZER from betty.warnings import BettyDeprecationWarning @@ -154,7 +153,7 @@ def _assert_window() -> None: self.qtbot.waitUntil(_assert_window) window = windows[0] - if isinstance(window, BettyPrimaryWindow): + if isinstance(window, QMainWindow): self.qtbot.addWidget(window) return cast(QMainWindowT, window) diff --git a/betty/tests/extension/nginx/test_cli.py b/betty/tests/extension/nginx/test_cli.py index ae271636a..2757ff55d 100644 --- a/betty/tests/extension/nginx/test_cli.py +++ b/betty/tests/extension/nginx/test_cli.py @@ -1,10 +1,12 @@ +from asyncio import to_thread + 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 +from betty.tests.test_cli import run, NoOpServer class KeyboardInterruptedDockerizedNginxServer(DockerizedNginxServer): @@ -14,14 +16,16 @@ async def start(self) -> None: class TestServe: async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: + mocker.patch("asyncio.sleep", side_effect=KeyboardInterrupt) mocker.patch( - "betty.extension.nginx.serve.DockerizedNginxServer", - new=KeyboardInterruptedDockerizedNginxServer, + "betty.extension.nginx.serve.DockerizedNginxServer", new=NoOpServer ) 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( + + await to_thread( + 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 466305bd7..9ecc0fe6e 100644 --- a/betty/tests/test_cli.py +++ b/betty/tests/test_cli.py @@ -1,16 +1,19 @@ import json +from asyncio import to_thread from collections.abc import AsyncIterator from contextlib import chdir +from multiprocessing import get_context from pathlib import Path -from typing import TypeVar, ParamSpec +from typing import TypeVar, ParamSpec, Any from unittest.mock import AsyncMock import aiofiles import click import pytest +from PyQt6.QtCore import QTimer +from PyQt6.QtGui import QShowEvent from _pytest.logging import LogCaptureFixture from aiofiles.os import makedirs -from aiofiles.tempfile import TemporaryDirectory from click import Command from click.testing import CliRunner, Result from pytest_mock import MockerFixture @@ -18,33 +21,29 @@ from betty.app import App from betty.app.extension import Extension from betty.cli import main, CommandProvider, global_command, catch_exceptions -from betty.documentation import DocumentationServer from betty.error import UserFacingError -from betty.extension.demo import DemoServer -from betty.locale import Str +from betty.gui.app import BettyPrimaryWindow +from betty.locale import Str, DEFAULT_LOCALIZER from betty.project import ExtensionConfiguration from betty.serde.dump import Dump -from betty.serve import BuiltinAppServer +from betty.serve import Server, AppServer +from betty.tests.conftest import BettyQtBot T = TypeVar("T") P = ParamSpec("P") -class DummyCommandError(BaseException): - pass - - -@click.command(name="test") +@click.command(name="noop") @global_command -async def _test_command() -> None: - raise DummyCommandError +async def _noop_command() -> None: + pass -class DummyExtension(Extension, CommandProvider): +class NoOpExtension(Extension, CommandProvider): @property def commands(self) -> dict[str, Command]: return { - "test": _test_command, + "noop": _noop_command, } @@ -80,14 +79,15 @@ async def new_temporary_app(mocker: MockerFixture) -> AsyncIterator[App]: class TestMain: async def test_without_arguments(self, new_temporary_app: App) -> None: - run() + await to_thread(run) async def test_help_without_configuration(self, new_temporary_app: App) -> None: - run("--help") + await to_thread(run, "--help") async def test_configuration_without_help(self, new_temporary_app: App) -> None: await new_temporary_app.project.configuration.write() - run( + await to_thread( + run, "-c", str(new_temporary_app.project.configuration.configuration_file_path), expected_exit_code=2, @@ -95,53 +95,57 @@ async def test_configuration_without_help(self, new_temporary_app: App) -> None: async def test_help_with_configuration(self, new_temporary_app: App) -> None: new_temporary_app.project.configuration.extensions.append( - ExtensionConfiguration(DummyExtension) + ExtensionConfiguration(NoOpExtension) ) await new_temporary_app.project.configuration.write() - run( + await to_thread( + run, "-c", str(new_temporary_app.project.configuration.configuration_file_path), "--help", ) async def test_help_with_invalid_configuration_file_path( - self, new_temporary_app: App + self, new_temporary_app: App, tmp_path: Path ) -> None: - async with TemporaryDirectory() as working_directory_path_str: - working_directory_path = Path(working_directory_path_str) - configuration_file_path = working_directory_path / "non-existent-betty.json" + working_directory_path = tmp_path + configuration_file_path = working_directory_path / "non-existent-betty.json" - run("-c", str(configuration_file_path), "--help", expected_exit_code=1) + await to_thread( + run, "-c", str(configuration_file_path), "--help", expected_exit_code=1 + ) async def test_help_with_invalid_configuration( - self, new_temporary_app: App + self, new_temporary_app: App, tmp_path: Path ) -> None: - async with TemporaryDirectory() as working_directory_path_str: - working_directory_path = Path(working_directory_path_str) - configuration_file_path = working_directory_path / "betty.json" - dump: Dump = {} - 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) - - async def test_with_discovered_configuration(self, new_temporary_app: App) -> None: - async with TemporaryDirectory() as working_directory_path_str: - working_directory_path = Path(working_directory_path_str) - async with aiofiles.open( - working_directory_path / "betty.json", "w" - ) as config_file: - url = "https://example.com" - dump: Dump = { - "base_url": url, - "extensions": { - DummyExtension.name(): {}, - }, - } - await config_file.write(json.dumps(dump)) - with chdir(working_directory_path): - run("test", expected_exit_code=1) + working_directory_path = tmp_path + configuration_file_path = working_directory_path / "betty.json" + dump: Dump = {} + async with aiofiles.open(configuration_file_path, "w") as f: + await f.write(json.dumps(dump)) + + await to_thread( + run, "-c", str(configuration_file_path), "--help", expected_exit_code=1 + ) + + async def test_with_discovered_configuration( + self, new_temporary_app: App, tmp_path: Path + ) -> None: + working_directory_path = tmp_path + async with aiofiles.open( + working_directory_path / "betty.json", "w" + ) as config_file: + url = "https://example.com" + dump: Dump = { + "base_url": url, + "extensions": { + NoOpExtension.name(): {}, + }, + } + await config_file.write(json.dumps(dump)) + with chdir(working_directory_path): + await to_thread(run, "noop") class TestCatchExceptions: @@ -171,39 +175,45 @@ 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") + await to_thread(run, "clear-caches") async with new_temporary_app: async with new_temporary_app.cache.get("KeepMeAroundPlease") as cache_item: assert cache_item is None -class KeyboardInterruptedDemoServer(DemoServer): +class NoOpServer(Server): + def __init__(self, *_: Any, **__: Any): + Server.__init__(self, DEFAULT_LOCALIZER) + + @property + def public_url(self) -> str: + return "https://example.com" + async def start(self) -> None: - raise KeyboardInterrupt + pass + async def show(self) -> None: + pass -class TestDemo: - async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: - mocker.patch( - "betty.extension.demo.DemoServer", new=KeyboardInterruptedDemoServer - ) - run("demo") +class NoOpAppServer(NoOpServer, AppServer): + pass -class KeyboardInterruptedDocumentationServer(DocumentationServer): - async def start(self) -> None: - raise KeyboardInterrupt +class TestDemo: + async def test(self, mocker: MockerFixture) -> None: + mocker.patch("asyncio.sleep", side_effect=KeyboardInterrupt) + mocker.patch("betty.extension.demo.DemoServer", new=NoOpServer) + + await to_thread(run, "demo") class TestDocs: async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: - mocker.patch( - "betty.documentation.DocumentationServer", - new=KeyboardInterruptedDocumentationServer, - ) + mocker.patch("asyncio.sleep", side_effect=KeyboardInterrupt) + mocker.patch("betty.documentation.DocumentationServer", new=NoOpServer) - run("docs") + await to_thread(run, "docs") class TestGenerate: @@ -212,7 +222,8 @@ 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( + await to_thread( + run, "-c", str(new_temporary_app.project.configuration.configuration_file_path), "generate", @@ -229,20 +240,79 @@ async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: assert generate_args[0] is new_temporary_app -class KeyboardInterruptedBuiltinAppServer(BuiltinAppServer): - async def start(self) -> None: - raise KeyboardInterrupt - - class TestServe: async def test(self, mocker: MockerFixture, new_temporary_app: App) -> None: - mocker.patch( - "betty.serve.BuiltinAppServer", new=KeyboardInterruptedBuiltinAppServer - ) + mocker.patch("asyncio.sleep", side_effect=KeyboardInterrupt) + mocker.patch("betty.serve.BuiltinAppServer", new=NoOpAppServer) await new_temporary_app.project.configuration.write() await makedirs(new_temporary_app.project.configuration.www_directory_path) - run( + + await to_thread( + run, "-c", str(new_temporary_app.project.configuration.configuration_file_path), "serve", ) + + +class TestGui: + @classmethod + def _target(cls, *args: str) -> None: + def showEvent(window_self: BettyPrimaryWindow, a0: QShowEvent | None) -> None: + super(type(window_self), window_self).showEvent(a0) + timer = QTimer(window_self) + timer.timeout.connect(window_self.close) + timer.start(0) + + BettyPrimaryWindow.showEvent = showEvent # type: ignore[assignment, method-assign] + run(*args) + + async def test_without_project( + self, betty_qtbot: BettyQtBot, new_temporary_app: App + ) -> None: + process = get_context("spawn").Process(target=self._target, args=["gui"]) + try: + process.start() + finally: + process.join() + + async def test_with_project( + self, betty_qtbot: BettyQtBot, new_temporary_app: App, tmp_path: Path + ) -> None: + configuration_file_path = tmp_path / "betty.json" + configuration = { + "base_url": "https://example.com", + } + async with aiofiles.open(configuration_file_path, "w") as config_file: + await config_file.write(json.dumps(configuration)) + + process = get_context("spawn").Process( + target=self._target, args=["gui", "-c", str(configuration_file_path)] + ) + try: + process.start() + finally: + process.join() + + +class TestUnknownCommand: + async def test(self) -> None: + await to_thread(run, "unknown-command", expected_exit_code=2) + + +class TestInitTranslation: + async def test(self, mocker: MockerFixture) -> None: + locale = "nl-NL" + m_init_translation = mocker.patch("betty.locale.init_translation") + await to_thread(run, "init-translation", locale) + m_init_translation.assert_awaited_once_with(locale) + + async def test_without_locale_arg(self) -> None: + await to_thread(run, "init-translation", expected_exit_code=2) + + +class TestUpdateTranslations: + async def test(self, mocker: MockerFixture) -> None: + m_update_translations = mocker.patch("betty.locale.update_translations") + await to_thread(run, "update-translations") + m_update_translations.assert_awaited_once() diff --git a/bin/test-pytest b/bin/test-pytest index 48b607e08..cf30f548e 100755 --- a/bin/test-pytest +++ b/bin/test-pytest @@ -7,4 +7,6 @@ cd "$(dirname "$0")/.." echo 'Running pytest...' coverage erase -pytest --cov --cov-append --cov-config=.coveragerc --no-cov-on-fail "$@" +COVERAGE_PROCESS_START="$(pwd)/.coveragerc" PYTHONPATH="$(cd site; pwd)" coverage run --module pytest "$@" +coverage combine +coverage report --skip-covered --skip-empty diff --git a/pyproject.toml b/pyproject.toml index 6ac4c2dc3..77e84b9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,6 @@ test = [ 'pytest ~= 7.3, >= 7.3.1', 'pytest-aioresponses ~= 0.2, >= 0.2.0 ', 'pytest-asyncio ~= 0.23, >= 0.23.4 ', - 'pytest-cov ~= 5.0', 'pytest-mock ~= 3.10, >= 3.10.0', 'pytest-qt ~= 4.2, >= 4.2.0', 'pytest-xvfb ~= 3.0, >= 3.0.0', diff --git a/site/sitecustomize.py b/site/sitecustomize.py new file mode 100644 index 000000000..ae545c286 --- /dev/null +++ b/site/sitecustomize.py @@ -0,0 +1,3 @@ +import coverage + +coverage.process_startup()