Source code for betty.cli

"""
Provide the Command Line Interface.
"""

from __future__ import annotations

import asyncio
import logging
import sys
from asyncio import run
from contextlib import suppress, contextmanager
from functools import wraps
from pathlib import Path
from typing import (
    Callable,
    cast,
    Iterator,
    Awaitable,
    ParamSpec,
    Concatenate,
    TYPE_CHECKING,
)

import click
from click import get_current_context, Context, Option, Command, Parameter

from betty import about, generate, load, documentation, locale, serve
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 Str, DEFAULT_LOCALIZER
from betty.logging import CliHandler
from betty.serde.load import AssertionFailed

if TYPE_CHECKING:
    from PyQt6.QtWidgets import QMainWindow


_P = ParamSpec("_P")


[docs] class CommandProvider: """ Provide additional commands. """ @property def commands(self) -> dict[str, Command]: """ The commands to provide. """ raise NotImplementedError(repr(self))
[docs] @contextmanager def catch_exceptions() -> Iterator[None]: """ Catch and log all exceptions. """ try: yield except KeyboardInterrupt: print("Quitting...") # noqa T201 sys.exit(0) except Exception as e: logger = logging.getLogger(__name__) if isinstance(e, UserFacingError): logger.error(e.localize(DEFAULT_LOCALIZER)) else: logger.exception(e) sys.exit(1)
[docs] 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: # Use a wrapper, because the decorator uses Awaitable, but asyncio.run requires Coroutine. async def __command(): await f(*args, **kwargs) return run(__command()) return _command
[docs] def app_command( f: Callable[Concatenate[App, _P], Awaitable[None]], ) -> Callable[_P, None]: """ Decorate a command to receive the currently running :py:class:`betty.app.App` as its first argument. """ @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"] async def __command(): async with app: await f(app, *args, **kwargs) return run(__command()) return _command
@catch_exceptions() def _init_ctx_app( ctx: Context, __: Option | Parameter | None = None, configuration_file_path: str | None = None, ) -> None: run(__init_ctx_app(ctx, configuration_file_path)) async def __init_ctx_app( ctx: Context, configuration_file_path: str | None = None, ) -> None: ctx.ensure_object(dict) if "initialized" in ctx.obj: return ctx.obj["initialized"] = True logging.getLogger().addHandler(CliHandler()) logger = logging.getLogger(__name__) app = ctx.with_resource( # type: ignore[attr-defined] SynchronizedContextManager(App.new_from_environment()) ) ctx.obj["commands"] = { "docs": _docs, "clear-caches": _clear_caches, "demo": _demo, "gui": _gui, } if await about.is_development(): ctx.obj["commands"]["init-translation"] = _init_translation ctx.obj["commands"]["update-translations"] = _update_translations ctx.obj["app"] = app if configuration_file_path is None: try_configuration_file_paths = [ Path.cwd() / f"betty{extension}" for extension in {".json", ".yaml", ".yml"} ] else: try_configuration_file_paths = [Path.cwd() / configuration_file_path] async with app: for try_configuration_file_path in try_configuration_file_paths: try: await app.project.configuration.read(try_configuration_file_path) except FileNotFoundError: continue else: ctx.obj["commands"]["generate"] = _generate ctx.obj["commands"]["serve"] = _serve for extension in app.extensions.flatten(): if isinstance(extension, CommandProvider): for command_name, command in extension.commands.items(): ctx.obj["commands"][command_name] = command logger.info( app.localizer._( "Loaded the configuration from {configuration_file_path}." ).format(configuration_file_path=str(try_configuration_file_path)), ) return if configuration_file_path is not None: raise AssertionFailed( Str._( 'Configuration file "{configuration_file_path}" does not exist.', configuration_file_path=configuration_file_path, ) ) def _build_init_ctx_verbosity( betty_logger_level: int, root_logger_level: int | None = None, ) -> Callable[[Context, Option | Parameter | None, bool], None]: def _init_ctx_verbosity( ctx: Context, __: Option | Parameter | None = None, is_verbose: bool = False, ) -> None: if is_verbose: for logger_name, logger_level in ( ("betty", betty_logger_level), (None, root_logger_level), ): logger = logging.getLogger(logger_name) if ( logger_level is not None and logger.getEffectiveLevel() > logger_level ): logger.setLevel(logger_level) return _init_ctx_verbosity class _BettyCommands(click.MultiCommand): @catch_exceptions() def list_commands(self, ctx: Context) -> list[str]: _init_ctx_app(ctx) return list(ctx.obj["commands"].keys()) @catch_exceptions() def get_command(self, ctx: Context, cmd_name: str) -> Command | None: _init_ctx_app(ctx) with suppress(KeyError): return cast(Command, ctx.obj["commands"][cmd_name]) return None @click.command( cls=_BettyCommands, # Set an empty help text so Click does not automatically use the function's docstring. help="", ) @click.option( "--configuration", "-c", "app", is_eager=True, help="The path to a Betty project configuration file. Defaults to betty.json|yaml|yml in the current working directory. This will make additional commands available.", callback=_init_ctx_app, ) @click.option( "-v", "--verbose", is_eager=True, default=False, is_flag=True, help="Show verbose output, including informative log messages.", callback=_build_init_ctx_verbosity(logging.INFO), ) @click.option( "-vv", "--more-verbose", "more_verbose", is_eager=True, default=False, is_flag=True, help="Show more verbose output, including debug log messages.", callback=_build_init_ctx_verbosity(logging.DEBUG), ) @click.option( "-vvv", "--most-verbose", "most_verbose", is_eager=True, default=False, is_flag=True, help="Show most verbose output, including all log messages.", callback=_build_init_ctx_verbosity(logging.NOTSET, logging.NOTSET), ) @click.version_option( wait_to_thread(about.version_label()), message=wait_to_thread(about.report()), prog_name="Betty", ) def main(app: App, verbose: bool, more_verbose: bool, most_verbose: bool) -> None: """ Launch Betty's Command-Line Interface. """ pass # pragma: no cover @click.command(help="Clear all caches.") @app_command async def _clear_caches(app: App) -> None: await app.cache.clear() @click.command(help="Explore a demonstration site.") @app_command async def _demo(app: App) -> None: from betty.extension.demo import DemoServer async with DemoServer(app=app) as server: await server.show() while True: await asyncio.sleep(999) @click.command(help="Open Betty's graphical user interface (GUI).") @click.option( "--configuration", "-c", "configuration_file_path", is_eager=True, help="The path to a Betty project configuration file. Defaults to betty.json|yaml|yml in the current working directory.", callback=lambda _, __, configuration_file_path: ( Path(configuration_file_path) if configuration_file_path else None ), ) @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.") @app_command async def _generate(app: App) -> None: await load.load(app) await generate.generate(app) @click.command(help="Serve a generated site.") @app_command async def _serve(app: App) -> None: async with serve.BuiltinAppServer(app) as server: await server.show() while True: await asyncio.sleep(999) @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 _init_translation(locale: str) -> None: from betty.locale import init_translation 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 locale.update_translations()