diff --git a/README.md b/README.md index d83d9782..23af0e76 100644 --- a/README.md +++ b/README.md @@ -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 ` +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 diff --git a/osxphotos/cli/click_rich_echo.py b/osxphotos/cli/click_rich_echo.py new file mode 100644 index 00000000..fe01d54e --- /dev/null +++ b/osxphotos/cli/click_rich_echo.py @@ -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) diff --git a/osxphotos/cli/common.py b/osxphotos/cli/common.py index df0d7047..ca8335a0 100644 --- a/osxphotos/cli/common.py +++ b/osxphotos/cli/common.py @@ -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_ diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index a4c12504..5e9542cb 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -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__}") diff --git a/osxphotos/cli/help.py b/osxphotos/cli/help.py index c7b2bb10..cdd1394d 100644 --- a/osxphotos/cli/help.py +++ b/osxphotos/cli/help.py @@ -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 .""" 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