"""
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")
[docs]
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