Source code for betty.cli

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

from __future__ import annotations

import logging
import sys
from asyncio import run
from dataclasses import dataclass
from logging import (
    Handler,
    CRITICAL,
    ERROR,
    WARNING,
    INFO,
    DEBUG,
    NOTSET,
    LogRecord,
)
from sys import stderr
from typing import final, IO, Any, TYPE_CHECKING

import asyncclick as click
from typing_extensions import override, ClassVar

from betty import about
from betty.app import App

if TYPE_CHECKING:
    from betty.locale.localizer import Localizer
    from betty.machine_name import MachineName
    from collections.abc import Mapping


@final
class _ClickHandler(Handler):
    """
    Output log records to stderr with :py:func:`asyncclick.secho`.
    """

    COLOR_LEVELS = {
        CRITICAL: "red",
        ERROR: "red",
        WARNING: "yellow",
        INFO: "green",
        DEBUG: "white",
        NOTSET: "white",
    }

    def __init__(self, stream: IO[Any] = stderr):
        super().__init__(-1)
        self._stream = stream

    @override
    def emit(self, record: LogRecord) -> None:
        click.secho(self.format(record), file=self._stream, fg=self._color(record))

    def _color(self, record: LogRecord) -> str:
        for level, color in self.COLOR_LEVELS.items():
            if record.levelno >= level:
                return color
        return self.COLOR_LEVELS[NOTSET]


class _BettyCommands(click.MultiCommand):
    terminal_width: ClassVar[int | None] = None
    _bootstrapped = False
    _app: ClassVar[App]
    _localizer: ClassVar[Localizer]
    _commands: ClassVar[Mapping[MachineName, click.Command]]

    @classmethod
    async def new_type_for_app(cls, app: App) -> type[_BettyCommands]:
        from betty.cli import commands

        return await cls._new_type(
            app,
            await app.localizer,
            {
                command.plugin_id(): await (
                    await app.new_target(command)
                ).click_command()
                async for command in commands.COMMAND_REPOSITORY
            },
        )

    @classmethod
    async def _new_type(
        cls,
        app: App,
        localizer: Localizer,
        commands: Mapping[MachineName, click.Command],
    ) -> type[_BettyCommands]:
        class __BettyCommands(_BettyCommands):
            _app = app
            _localizer = localizer
            _commands = commands

        return __BettyCommands

    def _bootstrap(self) -> None:
        if not self._bootstrapped:
            logging.getLogger().addHandler(_ClickHandler())
            self._bootstrapped = True

    @override
    def list_commands(self, ctx: click.Context) -> list[str]:
        self._bootstrap()
        return list(self._commands)

    @override
    def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
        self._bootstrap()
        try:
            return self._commands[cmd_name]
        except KeyError:
            return None

    @override
    async def make_context(
        self,
        info_name: str | None,
        args: list[str],
        parent: click.Context | None = None,
        **extra: Any,
    ) -> click.Context:
        if self.terminal_width is not None:
            extra["terminal_width"] = self.terminal_width
        ctx = await super().make_context(info_name, args, parent, **extra)
        ctx.obj = ContextAppObject(self._app, self._localizer)
        return ctx


[docs] @final @dataclass(frozen=True) class ContextAppObject: """ The running Betty application and it localizer. """ app: App localizer: Localizer
[docs] def ctx_app_object(ctx: click.Context) -> ContextAppObject: """ Get the running application object from a context. :param ctx: The context to get the application from. Defaults to the current context. """ app = ctx.find_object(ContextAppObject) assert isinstance(app, ContextAppObject) return app
[docs] def main() -> Any: """ Launch Betty's Command-Line Interface. This is a stand-alone entry point that will manage an event loop and Betty application. """ # Because this function creates and owns the event loop, Click cannot handle exceptions that are the result of the # event loop shutting down. To allow for graceful cancellations, catch any propagated KeyboardInterrupt from the # loop, and handle it as similar to how Click does it as possible. try: return run(_main()) except KeyboardInterrupt: print("Aborted!") # noqa T201 sys.exit(1)
async def _main() -> Any: async with App.new_from_environment() as app, app: main_command = await new_main_command(app) return await main_command.main(sys.argv[1:])
[docs] async def new_main_command(app: App) -> click.Command: """ Create a new Click command for the Betty Command Line Interface. """ @click.command( "betty", cls=await _BettyCommands.new_type_for_app(app), # Set an empty help text so Click does not automatically use the function's docstring. help="", ) @click.version_option( about.version_label(), message=about.report(), prog_name="Betty", ) def main_command(*args: str) -> None: pass # pragma: no cover return main_command