Skip to content

Commit

Permalink
Increase coverage of the Command Line Interface (#1495)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartfeenstra committed May 13, 2024
1 parent dfd590b commit 16f1d4a
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 162 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ omit =
betty/_package/**
betty/_resizeimage.py
betty/tests/**
parallel = True
source = betty

[report]
Expand Down
148 changes: 74 additions & 74 deletions betty/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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()
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
Expand All @@ -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()
4 changes: 2 additions & 2 deletions betty/extension/nginx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Provide Command Line Interface functionality.
"""

from asyncio import sleep
import asyncio

import click

Expand All @@ -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)
3 changes: 1 addition & 2 deletions betty/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 8 additions & 4 deletions betty/tests/extension/nginx/test_cli.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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",
Expand Down

0 comments on commit 16f1d4a

Please sign in to comment.