Source code for betty.cli.commands

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

from __future__ import annotations

import logging
from abc import abstractmethod
from contextlib import suppress
from functools import wraps
from pathlib import Path
from typing import Any, Concatenate, ParamSpec, TYPE_CHECKING, overload, TypeVar, cast

import asyncclick as click

from betty import about
from betty.assertion import assert_path, assert_none, assert_or
from betty.assertion.error import AssertionFailed
from betty.cli.error import user_facing_error_to_bad_parameter
from betty.config import assert_configuration_file
from betty.error import UserFacingError, FileNotFound
from betty.locale.localizable import _
from betty.plugin import Plugin, PluginRepository
from betty.plugin.entry_point import EntryPointPluginRepository
from betty.plugin.proxy import ProxyPluginRepository
from betty.project import Project
from betty.project.config import ProjectConfiguration
from betty.serde.format import FORMAT_REPOSITORY

if TYPE_CHECKING:
    from betty.cli import ContextAppObject
    from collections.abc import Callable, Coroutine

_T = TypeVar("_T")
_P = ParamSpec("_P")


[docs] class Command(Plugin): """ Define a CLI command plugin. Read more about :doc:`/development/plugin/command`. """
[docs] @abstractmethod async def click_command(self) -> click.Command: """ Get the plugin's Click command. """ pass
COMMAND_REPOSITORY: PluginRepository[Command] = ProxyPluginRepository( EntryPointPluginRepository("betty.command"), *( [EntryPointPluginRepository("betty.dev.command")] if about.is_development() else [] ), ) """ The Command Line Interface command repository. Read more about :doc:`/development/plugin/command`. """ def _command_build_init_ctx_verbosity( betty_logger_level: int, root_logger_level: int | None = None, ) -> Callable[[click.Context, click.Option | click.Parameter | None, bool], None]: def _init_ctx_verbosity( _: click.Context, __: click.Option | click.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 @overload def command( name: Callable[..., Coroutine[Any, Any, Any]], ) -> click.Command: pass @overload def command( name: str | None, **attrs: Any, ) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], click.Command]: pass @overload def command( name: None = None, **attrs: Any, ) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], click.Command]: pass @overload def command( name: str | None = None, **attrs: Any, ) -> Callable[[Callable[..., Coroutine[Any, Any, Any]]], click.Command]: pass
[docs] def command( name: str | None | Callable[..., Coroutine[Any, Any, Any]] = None, **attrs: Any, ) -> click.Command | Callable[[Callable[..., Coroutine[Any, Any, Any]]], click.Command]: """ Mark something a Betty command. This is almost identical to :py:func:`asyncclick.command`. Functions decorated with ``@command`` may choose to raise :py:class:`betty.error.UserFacingError`, which will automatically be localized and reraised as :py:class:`asyncclick.ClickException`. Read more about :doc:`/development/plugin/command`. """ def decorator(f: Callable[..., Coroutine[Any, Any, Any]]) -> click.Command: @click.command(cast(str | None, name), **attrs) @click.option( "-v", "--verbose", is_eager=True, default=False, is_flag=True, expose_value=False, help="Show verbose output, including informative log messages.", callback=_command_build_init_ctx_verbosity(logging.INFO), ) @click.option( "-vv", "--more-verbose", "more_verbose", is_eager=True, default=False, is_flag=True, expose_value=False, help="Show more verbose output, including debug log messages.", callback=_command_build_init_ctx_verbosity(logging.DEBUG), ) @click.option( "-vvv", "--most-verbose", "most_verbose", is_eager=True, default=False, is_flag=True, expose_value=False, help="Show most verbose output, including all log messages.", callback=_command_build_init_ctx_verbosity(logging.NOTSET, logging.NOTSET), ) @click.pass_obj @wraps(f) async def _command( obj: ContextAppObject, *args: _P.args, **kwargs: _P.kwargs ) -> Any: try: return await f(*args, **kwargs) except UserFacingError as error: raise click.ClickException(error.localize(obj.localizer)) from error return cast(click.Command, _command) if callable(name): return decorator(name) return decorator
_ReturnT = TypeVar("_ReturnT")
[docs] def parameter_callback( f: Callable[Concatenate[_T, _P], _ReturnT], *args: _P.args, **kwargs: _P.kwargs ) -> Callable[[click.Context, click.Parameter, _T], _ReturnT]: """ Convert a callback that takes a parameter (option, argument) value and returns it after processing. This handles errors so Click can gracefully exit. """ from betty.cli import ctx_app_object def _callback(ctx: click.Context, __: click.Parameter, value: _T) -> _ReturnT: with user_facing_error_to_bad_parameter(ctx_app_object(ctx).localizer): return f(value, *args, **kwargs) return _callback
async def _read_project_configuration( project: Project, provided_configuration_file_path_str: Path | None ) -> None: project_directory_path = Path.cwd() if provided_configuration_file_path_str is None: try_configuration_file_paths = [ project_directory_path / f"betty{extension}" for extension in await FORMAT_REPOSITORY.extensions() ] for try_configuration_file_path in try_configuration_file_paths: with suppress(FileNotFound): return await _read_project_configuration_file( project, try_configuration_file_path ) raise AssertionFailed( _( "Could not find any of the following configuration files in {project_directory_path}: {configuration_file_names}." ).format( configuration_file_names=", ".join( str(try_configuration_file_path.relative_to(project_directory_path)) for try_configuration_file_path in try_configuration_file_paths ), project_directory_path=str(project_directory_path), ) ) else: await _read_project_configuration_file( project, (project_directory_path / provided_configuration_file_path_str) .expanduser() .resolve(), ) async def _read_project_configuration_file( project: Project, configuration_file_path: Path ) -> None: localizer = await project.app.localizer logger = logging.getLogger(__name__) assert_configuration = await assert_configuration_file(project.configuration) try: assert_configuration(configuration_file_path) except UserFacingError as error: logger.debug(error.localize(localizer)) raise else: project.configuration.configuration_file_path = configuration_file_path logger.info( localizer._( "Loaded the configuration from {configuration_file_path}." ).format(configuration_file_path=str(configuration_file_path)), )
[docs] def project_option( f: Callable[Concatenate[Project, _P], Coroutine[Any, Any, _ReturnT]], ) -> Callable[_P, Coroutine[Any, Any, _ReturnT]]: """ Decorate a command that requires a :py:class:`betty.project.Project`. """ @click.option( "--configuration", "-c", "configuration_file_path", help="The path to a Betty project configuration file. Defaults to betty.json|yaml|yml in the current working directory.", callback=parameter_callback(assert_or(assert_path(), assert_none())), ) @click.pass_obj @wraps(f) async def _project_option( obj: ContextAppObject, *args: Any, configuration_file_path: Path | None, **kwargs: Any, ) -> _ReturnT: project = await Project.new( obj.app, configuration=await ProjectConfiguration.new(Path()) ) await _read_project_configuration(project, configuration_file_path) async with project: return await f(project, *args, **kwargs) return _project_option # type: ignore[return-value]