Help topic (#644)

* Initial implementation for #607

* Implemented #607, add help for sub topics

* Updated test workflow
This commit is contained in:
Rhet Turnbull
2022-02-27 16:53:11 -08:00
committed by GitHub
parent 8be6a98c32
commit 9dec028448
5 changed files with 214 additions and 17 deletions

View File

@@ -0,0 +1,42 @@
"""click.echo replacement that supports rich text formatting"""
import typing as t
from io import StringIO
import click
from rich.console import Console
def rich_echo(
message: t.Optional[t.Any] = None,
**kwargs: t.Any,
) -> None:
"""
Echo text to the console with rich formatting.
This is a wrapper around click.echo that supports rich text formatting.
Args:
message: The string or bytes to output. Other objects are converted to strings.
kwargs: any extra arguments are passed to rich.console.Console.print() and click.echo
if kwargs contains 'file', 'nl', 'err', 'color', these are passed to click.echo,
all other values passed to rich.console.Console.print()
"""
# args for click.echo that may have been passed in kwargs
echo_args = {}
for arg in ("file", "nl", "err", "color"):
val = kwargs.pop(arg, None)
if val is not None:
echo_args[arg] = val
# click.echo will include "\n" so don't add it here unless specified
end = kwargs.pop("end", "")
# rich.console.Console defaults to 80 chars if it can't auto-detect, which in this case it won't
# so we need to set the width manually to a ridiculously large number
width = kwargs.pop("width", 10000)
output = StringIO()
console = Console(force_terminal=True, file=output, width=width)
console.print(message, end=end, **kwargs)
click.echo(output.getvalue(), **echo_args)

View File

@@ -3,17 +3,16 @@
import datetime
import os
import pathlib
from typing import Callable
import typing as t
import click
import osxphotos
from osxphotos._version import __version__
from .click_rich_echo import rich_echo
from .param_types import *
from rich import print as rprint
# global variable to control debug output
# set via --debug
DEBUG = False
@@ -48,14 +47,15 @@ def noop(*args, **kwargs):
def verbose_print(
verbose: bool = True, timestamp: bool = False, rich=False
) -> Callable:
verbose: bool = True, timestamp: bool = False, rich=False, **kwargs: t.Any
) -> t.Callable:
"""Create verbose function to print output
Args:
verbose: if True, returns verbose print function otherwise returns no-op function
timestamp: if True, includes timestamp in verbose output
rich: use rich.print instead of click.echo
kwargs: any extra arguments to pass to click.echo or rich.print depending on whether rich==True
Returns:
function to print output
@@ -64,10 +64,10 @@ def verbose_print(
return noop
# closure to capture timestamp
def verbose_(*args, **kwargs):
def verbose_(*args):
"""print output if verbose flag set"""
styled_args = []
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
timestamp_str = f"{str(datetime.datetime.now())} -- " if timestamp else ""
for arg in args:
if type(arg) == str:
arg = timestamp_str + arg
@@ -78,9 +78,10 @@ def verbose_print(
styled_args.append(arg)
click.echo(*styled_args, **kwargs)
def rich_verbose_(*args, **kwargs):
def rich_verbose_(*args):
"""print output if verbose flag set using rich.print"""
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
timestamp_str = f"{str(datetime.datetime.now())} -- " if timestamp else ""
new_args = []
for arg in args:
if type(arg) == str:
arg = timestamp_str + arg
@@ -88,7 +89,8 @@ def verbose_print(
arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]"
elif "warning" in arg.lower():
arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]"
rprint(arg, **kwargs)
new_args.append(arg)
rich_echo(*new_args, **kwargs)
return rich_verbose_ if rich else verbose_

View File

@@ -823,7 +823,7 @@ def export(
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
)
verbose_ = verbose_print(verbose, timestamp)
verbose_ = verbose_print(verbose, timestamp, rich=True, highlight=False)
if load_config:
try:
@@ -972,7 +972,7 @@ def export(
xattr_template = cfg.xattr_template
# config file might have changed verbose
verbose_ = verbose_print(verbose, timestamp)
verbose_ = verbose_print(verbose, timestamp, rich=True, highlight=False)
verbose_(f"Loaded options from file {load_config}")
verbose_(f"osxphotos version {__version__}")

View File

@@ -1,13 +1,17 @@
"""Help text helper class for osxphotos CLI """
import inspect
import io
import re
import typing as t
import click
import osxmetadata
from rich.console import Console
from rich.markdown import Markdown
from .click_rich_echo import rich_echo
from osxphotos._constants import (
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
@@ -32,6 +36,8 @@ __all__ = [
"get_help_msg",
]
HIGHLIGHT_COLOR = "yellow"
def get_help_msg(command):
"""get help message for a Click command"""
@@ -41,18 +47,131 @@ def get_help_msg(command):
@click.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.argument("subtopic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
def help(ctx, topic, subtopic, **kw):
"""Print help; for help on commands: help <command>."""
if topic is None:
click.echo(ctx.parent.get_help())
return
elif topic in ctx.obj.group.commands:
if subtopic:
cmd = ctx.obj.group.commands[topic]
rich_echo(
get_subtopic_help(cmd, ctx, subtopic), width=click.HelpFormatter().width
)
return
if topic in ctx.obj.group.commands:
ctx.info_name = topic
click.echo_via_pager(ctx.obj.group.commands[topic].get_help(ctx))
return
# didn't find any valid help topics
click.echo(f"Invalid command: {topic}", err=True)
click.echo(ctx.parent.get_help())
def get_subtopic_help(cmd: click.Command, ctx: click.Context, subtopic: str):
"""Get help for a command including only options that match a subtopic"""
# set ctx.info_name or click prints the wrong usage str (usage for help instead of cmd)
ctx.info_name = cmd.name
usage_str = cmd.get_help(ctx)
usage_str = usage_str.partition("\n")[0]
info = cmd.to_info_dict(ctx)
help_str = info.get("help", "")
options = get_matching_options(cmd, ctx, subtopic)
# format help text and options
formatter = click.HelpFormatter()
formatter.write(usage_str)
formatter.write_paragraph()
format_help_text(help_str, formatter)
formatter.write_paragraph()
if options:
option_str = format_options_help(options, ctx, highlight=subtopic)
formatter.write(
f"Options that match '[{HIGHLIGHT_COLOR}]{subtopic}[/{HIGHLIGHT_COLOR}]':\n"
)
formatter.write_paragraph()
formatter.write(option_str)
else:
click.echo(f"Invalid command: {topic}", err=True)
click.echo(ctx.parent.get_help())
formatter.write(
f"No options match '[{HIGHLIGHT_COLOR}]{subtopic}[/{HIGHLIGHT_COLOR}]'"
)
return formatter.getvalue()
def get_matching_options(
command: click.Command, ctx: click.Context, topic: str
) -> t.List:
"""Get matching options for a command that contain a topic
Args:
command: click.Command
ctx: click.Context
topic: str, topic to match
Returns:
list of matching click.Option objects
"""
options = []
topic = topic.lower()
for option in command.params:
help_record = option.get_help_record(ctx)
if help_record and (topic in help_record[0] or topic in help_record[1]):
options.append(option)
return options
def format_options_help(
options: t.List[click.Option], ctx: click.Context, highlight: t.Optional[str] = None
) -> str:
"""Format options help for display
Args:
options: list of click.Option objects
ctx: click.Context
highlight: str, if set, add rich highlighting to options that match highlight str
Returns:
str with formatted help
"""
formatter = click.HelpFormatter()
opt_help = [opt.get_help_record(ctx) for opt in options]
if highlight:
# convert list of tuples to list of lists
opt_help = [list(opt) for opt in opt_help]
for record in opt_help:
record[0] = re.sub(
f"({highlight})",
f"[{HIGHLIGHT_COLOR}]\\1" + f"[/{HIGHLIGHT_COLOR}]",
record[0],
re.IGNORECASE,
)
record[1] = re.sub(
f"({highlight})",
f"[{HIGHLIGHT_COLOR}]\\1" + f"[/{HIGHLIGHT_COLOR}]",
record[1],
re.IGNORECASE,
)
# convert back to list of tuples as that's what write_dl expects
opt_help = [tuple(opt) for opt in opt_help]
formatter.write_dl(opt_help)
return formatter.getvalue()
def format_help_text(text: str, formatter: click.HelpFormatter):
text = inspect.cleandoc(text).partition("\f")[0]
formatter.write_paragraph()
with formatter.indentation():
formatter.write_text(text)
# TODO: The following help text could probably be done as mako template