betty.cli - Betty documentation

Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

# Source code for betty.cli

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

    from __future__ import annotations

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

    import click
    from click import Context
    from typing_extensions import override, ClassVar

    from betty import about
    from betty.asyncio import wait_to_thread
    from betty.error import UserFacingError
    from betty.plugin import PluginRepository, PluginNotFound
    from betty.cli.commands import BettyCommand

    if TYPE_CHECKING:
        from betty.locale.localizer import Localizer
        from betty.assertion import Assertion
        from betty.cli.commands import Command

    _ValueT = TypeVar("_ValueT")
    _ReturnT = TypeVar("_ReturnT")

    def assertion_to_value_proc(
        assertion: Assertion[_ValueT, _ReturnT], localizer: Localizer
    ) -> Assertion[_ValueT, _ReturnT]:
        """
        Convert an :py:class:`betty.assertion.Assertion` to a Click ``value_proc`` callback.
        """

        def _assert(value: _ValueT) -> _ReturnT:
            try:
                return assertion(value)
            except UserFacingError as error:
                message = error.localize(localizer)
                logging.getLogger(__name__).debug(message)
                raise click.BadParameter(message) from None

        return _assert

    @final
    class _ClickHandler(Handler):
        """
        Output log records to stderr with :py:func:`click.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(BettyCommand, click.MultiCommand):
        terminal_width: ClassVar[int | None] = None
        _bootstrapped = False
        commands: PluginRepository[Command]

        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]:
            from betty.cli import commands

            self._bootstrap()
            return [
                command.plugin_id()
                for command in wait_to_thread(commands.COMMAND_REPOSITORY.select())
            ]

        @override
        def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
            from betty.cli import commands

            self._bootstrap()
            try:
                return wait_to_thread(
                    commands.COMMAND_REPOSITORY.get(cmd_name)
                ).click_command()
            except PluginNotFound:
                return None

        @override
        def make_context(
            self,
            info_name: str | None,
            args: list[str],
            parent: Context | None = None,
            **extra: Any,
        ) -> Context:
            if self.terminal_width is not None:
                extra["terminal_width"] = self.terminal_width
            return super().make_context(
                info_name,  # type: ignore[arg-type]
                args,
                parent,
                **extra,
            )

    @click.command(
        "betty",
        cls=_BettyCommands,
        # Set an empty help text so Click does not automatically use the function's docstring.
        help="",
    )
    @click.version_option(
        wait_to_thread(about.version_label()),
        message=wait_to_thread(about.report()),
        prog_name="Betty",
    )
    def main() -> None:
        """
        Launch Betty's Command-Line Interface.
        """
        pass  # pragma: no cover