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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 214 additions and 17 deletions

View File

@ -162,7 +162,41 @@ Commands:
uuid Print out unique IDs (UUID) of photos selected in Photos
```
To get help on a specific command, use `osxphotos help <command_name>`
To get help on a specific command, use `osxphotos help command_name`, for example, `osxphotos help export` to get help on the `export` command.
Some of the commands such as `export` and `query` have a large number of options. To search for options related to a specific topic, you can use `osxphotos help command_name topic_name`. For example, `osxphotos help export raw` finds the options related to RAW files (search is case-insensitive):
```
Usage: osxphotos export [OPTIONS] [PHOTOS_LIBRARY]... DEST
Export photos from the Photos database. Export path DEST is required.
Optionally, query the Photos database using 1 or more search options; if
more than one option is provided, they are treated as "AND" (e.g. search for
photos matching all options). If no query options are provided, all photos
will be exported. By default, all versions of all photos will be exported
including edited versions, live photo movies, burst photos, and associated
raw images. See --skip-edited, --skip-live, --skip-bursts, and --skip-raw
options to modify this behavior.
Options that match 'raw':
--has-raw Search for photos with both a jpeg and
raw version
--skip-raw Do not export associated RAW image of a
RAW+JPEG pair. Note: this does not skip RAW
photos if the RAW photo does not have an
associated JPEG image (e.g. the RAW file was
imported to Photos without a JPEG preview).
--convert-to-jpeg Convert all non-JPEG images (e.g. RAW, HEIC,
PNG, etc) to JPEG upon export. Note: does not
convert the RAW component of a RAW+JPEG pair as
the associated JPEG image will be exported. You
can use --skip-raw to skip
exporting the associated RAW image of a
RAW+JPEG pair. See also --jpeg-quality and
--jpeg-ext. Only works if your Mac has a GPU
(thus may not work on virtual machines).
```
### Command line examples

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