Source code for betty.cli.commands

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

from __future__ import annotations

import logging
from asyncio import run
from functools import wraps
from importlib import metadata
from pathlib import Path
from typing import Any, Concatenate, ParamSpec, ClassVar, TYPE_CHECKING, Mapping

import click
from click import get_current_context, Context, option, Option, Parameter
from typing_extensions import override

from betty import about
from betty.app import App
from betty.assertion.error import AssertionFailed
from betty.asyncio import wait_to_thread
from betty.cli import catch_exceptions
from betty.config import assert_configuration_file
from betty.contextlib import SynchronizedContextManager
from betty.locale.localizable import _, Localizable, plain
from betty.plugin import Plugin, PluginId, PluginRepository
from betty.plugin.lazy import LazyPluginRepositoryBase
from betty.project import Project

if TYPE_CHECKING:
    from collections.abc import Callable, Coroutine

_P = ParamSpec("_P")


[docs] class Command(Plugin): """ Define a CLI command plugin. """ _click_command: ClassVar[click.Command]
[docs] @classmethod def click_command(cls) -> click.Command: """ Get the plugin's Click command. """ return cls._click_command
[docs] @override @classmethod def plugin_id(cls) -> PluginId: return cls.click_command().name
[docs] @override @classmethod def plugin_label(cls) -> Localizable: return plain(cls.click_command().name)
[docs] @override @classmethod def plugin_description(cls) -> Localizable | None: command_help = cls.click_command().short_help if command_help is None: return None return plain(command_help)
class _CommandRepository(LazyPluginRepositoryBase[Command]): """ Discover and manage CLI commands. """ async def _load_plugins(self) -> Mapping[str, type[Command]]: plugins = self._load_plugins_group("betty.command") if await about.is_development(): plugins = {**plugins, **(self._load_plugins_group("betty.dev.command"))} return plugins def _load_plugins_group( self, entry_point_group: str ) -> Mapping[str, type[Command]]: plugins = {} for entry_point in metadata.entry_points( group=entry_point_group, ): class _Command(Command): _click_command = entry_point.load() plugins[_Command.plugin_id()] = _Command return plugins COMMAND_REPOSITORY: PluginRepository[Command] = _CommandRepository() """ The Command Line Interface command repository. """ 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
[docs] def command( f: Callable[_P, Coroutine[Any, Any, None]], ) -> Callable[Concatenate[_P], None]: """ Mark something a Betty command. """ @wraps(f) @catch_exceptions() @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), ) def _command(*args: _P.args, **kwargs: _P.kwargs) -> None: return run(f(*args, **kwargs)) return _command
[docs] def pass_app( f: Callable[Concatenate[App, _P], None], ) -> Callable[_P, None]: """ Decorate a command to receive the currently running :py:class:`betty.app.App` as its first argument. """ @wraps(f) def _command(*args: _P.args, **kwargs: _P.kwargs) -> None: ctx = get_current_context() _init_ctx_app(ctx) return f(ctx.obj["app"], *args, **kwargs) return _command
async def _read_project_configuration( project: Project, provided_configuration_file_path: str | None ) -> None: project_directory_path = Path.cwd() logger = logging.getLogger(__name__) if provided_configuration_file_path is None: try_configuration_file_paths = [ project_directory_path / f"betty{extension}" for extension in (".json", ".yaml", ".yml") ] else: try_configuration_file_paths = [ project_directory_path / provided_configuration_file_path ] assert_configuration = assert_configuration_file(project.configuration) for try_configuration_file_path in try_configuration_file_paths: try: assert_configuration(try_configuration_file_path) project.configuration.configuration_file_path = try_configuration_file_path except AssertionFailed: continue else: logger.info( project.app.localizer._( "Loaded the configuration from {configuration_file_path}." ).format(configuration_file_path=str(try_configuration_file_path)), ) return if provided_configuration_file_path is None: 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: raise AssertionFailed( _('Configuration file "{configuration_file_path}" does not exist.').format( configuration_file_path=provided_configuration_file_path, ) )
[docs] def pass_project( f: Callable[Concatenate[Project, _P], None], ) -> Callable[_P, None]: """ Decorate a command to receive the currently running :py:class:`betty.project.Project` as its first argument. """ def _project( ctx: Context, __: Parameter, configuration_file_path: str | None ) -> Project: _init_ctx_app(ctx) app = ctx.obj["app"] project: Project = ctx.with_resource( # type: ignore[attr-defined] SynchronizedContextManager(Project.new_temporary(app)) ) wait_to_thread(_read_project_configuration(project, configuration_file_path)) ctx.with_resource( # type: ignore[attr-defined] SynchronizedContextManager(project) ) return project return option( # type: ignore[return-value] "--configuration", "-c", "project", help="The path to a Betty project configuration file. Defaults to betty.json|yaml|yml in the current working directory.", callback=_project, )(f)
@catch_exceptions() def _init_ctx_app(ctx: Context, __: Option | Parameter | None = None, *_: Any) -> None: obj = ctx.ensure_object(dict) if "app" in obj: return app_factory = ctx.with_resource( # type: ignore[attr-defined] SynchronizedContextManager(App.new_from_environment()) ) obj["app"] = ctx.with_resource( # type: ignore[attr-defined] SynchronizedContextManager(app_factory) )