"""
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]