Source code for onyo.main

from __future__ import annotations

import os
import re
import sys
import textwrap
from argparse import ArgumentParser, PARSER, RawTextHelpFormatter
from pathlib import Path
from subprocess import CalledProcessError
from typing import TYPE_CHECKING

import rich

from onyo import cli
from onyo.lib.exceptions import (
    InvalidArgumentError,
    OnyoCLIExitCode,
    UIInputError,
)
from onyo.lib.ui import ui

if TYPE_CHECKING:
    from argparse import Action
    from typing import (
        IO,
        List,
    )


[docs] class OnyoArgumentParser(ArgumentParser): r"""Rich-ified ArgumentParser. See Also -------- argparse.ArgumentParser """ def _print_message(self, message: str, file: IO[str] | None = None) -> None: r"""Print help text with Rich.""" if message: rich.print(message, file=file)
[docs] class OnyoRawTextHelpFormatter(RawTextHelpFormatter): r"""Fix the sins of argparse's formatting; convert RST to Rich markup. See Also -------- argparse.ArgumentParser.RawTextHelpFormatter """ def _fill_text(self, text: str, width: int, indent: str) -> str: r"""Wrap lines of text according to width. Just a wrapper to convert RST->Rich first. Parameters ---------- text Text to wrap. width Max character of lines before wrapping. indent Indentation text to precede lines with. """ text = rst_to_rich(text) return super()._fill_text(text, width, indent) def _format_action(self, action: Action) -> str: r"""Build the full text for an Action (command, option, argument, etc). Just a wrapper to strip <COMMANDS> from subcommands section of help. Parameters ---------- action ArgParse Action. """ action_text = super()._format_action(action) # remove the superfluous first line (<COMMANDS>) of the subcommands section if action.nargs == PARSER: action_text = action_text.split("\n", 1)[1] return action_text def _format_action_invocation(self, action: Action) -> str: r"""Build the options/options+arguments/arguments string. Functionally identical to upstream, but with Rich markup added. Parameters ---------- action ArgParse Action. """ if action.option_strings: # -s, --long rendered_options = ', '.join([f"[cyan]{x}[/cyan]" for x in action.option_strings]) if action.nargs != 0: # -s, --long ARGS default = self._get_default_metavar_for_optional(action) args_string = self._format_args(action, default) rendered_options += ' ' + f"[dark_cyan]{args_string}[/dark_cyan]" return rendered_options else: # ARGS default = self._get_default_metavar_for_positional(action) metavar, = self._metavar_formatter(action, default)(1) return f"[dark_cyan]{metavar}[/dark_cyan]" def _split_lines(self, text: str, width: int) -> List[str]: r"""Wrap lines according to width. Just a wrapper to convert RST->Rich first. Parameters ---------- text Text to wrap. width Max character length to wrap lines at. """ text = rst_to_rich(text) return super()._split_lines(text, width)
[docs] def start_section(self, heading: str | None) -> None: r"""Start a section. Just a wrapper to stylize headings for Rich. Parameters ---------- heading Heading text. """ if heading: heading = f'[orange1]{heading.title()}[/orange1]' super().start_section(heading)
[docs] def rst_to_rich(text: str) -> str: r"""Convert RST to Rich syntax. Naively convert reStructuredText to Rich syntax, de-indent, and apply other cleanups to prepare text to print to the terminal. Parameters ---------- text reStructuredText to convert. """ # de-indent text text = textwrap.dedent(text).strip() # stylize arg descriptors (ALL CAPS ARGS) text = re.sub(r'\*\*([A-Z\-]+)\*\*', r'[dark_cyan]\1[/dark_cyan]', text) # stylize ** (bold) text = re.sub(r'\*\*([^*]+)\*\*', r'[bold]\1[/bold]', text) # stylize ``` (code blocks) text = re.sub('```\\n([^`]+)\\n```', r'\n[underline]\1[/underline]', text) # remove .. code:: statements # Onyo uses them for code that should be stylized in HTML but not help text. text = re.sub('.. code::[^\\n]+', '', text) # stylize `` (inline code markers) for flags text = re.sub('``(-[^`]+)``', r'[cyan]\1[/cyan]', text) # stylize remaining `` (inline code markers) text = re.sub('``([^`]+)``', r'[bold magenta]\1[/bold magenta]', text) # stylize headings text = re.sub('([^\\n]+)\\n---+\\n', r'[orange1]\1:[/orange1]\n', text) # and "rubric" as a hack because sphinx-argparse chokes on headings in # help/epilog text. text = re.sub('.. rubric:: ([^\\n]+)', r'[orange1]\1:[/orange1]', text) # make bullet points prettier text = text.replace(' * ', ' • ') # remove space-escaping for pluralizing arguments # (RST oddity that ``ASSET``s is illegal, but ``ASSET``\ s -> ASSETs) text = text.replace('\\ s', 's') return text
[docs] def build_parser(parser: ArgumentParser, args: dict) -> None: r"""Add options or arguments to an ArgumentParser. Parameters ---------- parser Parser to add arguments to. args Dictionary of option/argument dictionaries containing key-values to pass to ArgumentParser.add_argument(). The key name of the option/argument dictionary is passed as the value to ``dest``. See Also -------- ArgumentParser.add_argument() Example ------- An example option/argument dictionary:: args = { 'debug': dict( args=('-d', '--debug'), required=False, default=False, action='store_true', help=r"Enable debug logging." ), } build_parser(parser, args) """ for cmd in args: args[cmd]['dest'] = cmd try: # option flag parser.add_argument( *args[cmd]['args'], **{k: v for k, v in args[cmd].items() if k != 'args'}) except KeyError: # argument parser.add_argument( **{k: v for k, v in args[cmd].items()})
subcmds = None
[docs] def setup_parser() -> OnyoArgumentParser: r"""Return a fully populated OnyoArgumentParser for Onyo and all subcommands.""" from onyo.cli.config import args_config, epilog_config from onyo.cli.edit import args_edit, epilog_edit from onyo.cli.fsck import epilog_fsck from onyo.cli.get import args_get, epilog_get from onyo.cli.history import args_history, epilog_history from onyo.cli.init import args_init, epilog_init from onyo.cli.mkdir import args_mkdir, epilog_mkdir from onyo.cli.mv import args_mv, epilog_mv from onyo.cli.new import args_new, epilog_new from onyo.cli.rm import args_rm, epilog_rm from onyo.cli.rmdir import args_rmdir, epilog_rmdir from onyo.cli.set import args_set, epilog_set from onyo.cli.shell_completion import args_shell_completion, epilog_shell_completion from onyo.cli.show import args_show, epilog_show from onyo.cli.tree import args_tree, epilog_tree from onyo.cli.tsv_to_yaml import args_tsv_to_yaml, epilog_tsv_to_yaml from onyo.cli.unset import args_unset, epilog_unset from onyo.onyo_arguments import args_onyo global subcmds parser = OnyoArgumentParser( description='A text-based inventory system backed by git.', formatter_class=OnyoRawTextHelpFormatter ) build_parser(parser, args_onyo) # subcommands subcmds = parser.add_subparsers( title='commands', dest='cmd' ) subcmds.metavar = '<command>' # # subcommand "config" # cmd_config = subcmds.add_parser( 'config', description=cli.config.__doc__, epilog=epilog_config, formatter_class=parser.formatter_class, help='Set, query, and unset Onyo repository configuration options.' ) cmd_config.set_defaults(run=cli.config) build_parser(cmd_config, args_config) # # subcommand "edit" # cmd_edit = subcmds.add_parser( 'edit', description=cli.edit.__doc__, epilog=epilog_edit, formatter_class=parser.formatter_class, help='Open assets using an editor.' ) cmd_edit.set_defaults(run=cli.edit) build_parser(cmd_edit, args_edit) # # subcommand "fsck" # cmd_fsck = subcmds.add_parser( 'fsck', description=cli.fsck.__doc__, epilog=epilog_fsck, formatter_class=parser.formatter_class, help='Run a suite of integrity checks on the Onyo repository and its contents.' ) cmd_fsck.set_defaults(run=cli.fsck) # # subcommand "get" # cmd_get = subcmds.add_parser( 'get', description=cli.get.__doc__, epilog=epilog_get, formatter_class=parser.formatter_class, help='Return and sort asset values matching query patterns.' ) cmd_get.set_defaults(run=cli.get) build_parser(cmd_get, args_get) # # subcommand "history" # cmd_history = subcmds.add_parser( 'history', description=cli.history.__doc__, epilog=epilog_history, formatter_class=parser.formatter_class, help='Display the history of an asset or directory.' ) cmd_history.set_defaults(run=cli.history) build_parser(cmd_history, args_history) # # subcommand "init" # cmd_init = subcmds.add_parser( 'init', description=cli.init.__doc__, epilog=epilog_init, formatter_class=parser.formatter_class, help='Initialize a new Onyo repository.' ) cmd_init.set_defaults(run=cli.init) build_parser(cmd_init, args_init) # # subcommand "mkdir" # cmd_mkdir = subcmds.add_parser( 'mkdir', description=cli.mkdir.__doc__, epilog=epilog_mkdir, formatter_class=parser.formatter_class, help='Create directories and/or convert Asset Files to Asset Directories.' ) cmd_mkdir.set_defaults(run=cli.mkdir) build_parser(cmd_mkdir, args_mkdir) # # subcommand "mv" # cmd_mv = subcmds.add_parser( 'mv', description=cli.mv.__doc__, epilog=epilog_mv, formatter_class=parser.formatter_class, help='Move assets and/or directories into a destination directory; or rename a directory.' ) cmd_mv.set_defaults(run=cli.mv) build_parser(cmd_mv, args_mv) # # subcommand "new" # cmd_new = subcmds.add_parser( 'new', description=cli.new.__doc__, epilog=epilog_new, formatter_class=parser.formatter_class, help='Create new assets and populate with key-value pairs.' ) cmd_new.set_defaults(run=cli.new) build_parser(cmd_new, args_new) # # subcommand "rm" # cmd_rm = subcmds.add_parser( 'rm', description=cli.rm.__doc__, epilog=epilog_rm, formatter_class=parser.formatter_class, help='Delete assets and/or directories.' ) cmd_rm.set_defaults(run=cli.rm) build_parser(cmd_rm, args_rm) # # subcommand "rmdir" # cmd_rmdir = subcmds.add_parser( 'rmdir', description=cli.rmdir.__doc__, epilog=epilog_rmdir, formatter_class=parser.formatter_class, help='Delete empty directories or convert empty Asset Directories into Asset Files.' ) cmd_rmdir.set_defaults(run=cli.rmdir) build_parser(cmd_rmdir, args_rmdir) # # subcommand "set" # cmd_set = subcmds.add_parser( 'set', description=cli.set.__doc__, epilog=epilog_set, formatter_class=parser.formatter_class, help='Set the value of keys for assets.' ) cmd_set.set_defaults(run=cli.set) build_parser(cmd_set, args_set) # # subcommand "shell-completion" # cmd_shell_completion = subcmds.add_parser( 'shell-completion', description=cli.shell_completion.__doc__, epilog=epilog_shell_completion, formatter_class=parser.formatter_class, help='Display a tab-completion script for Onyo.' ) cmd_shell_completion.set_defaults(run=cli.shell_completion) build_parser(cmd_shell_completion, args_shell_completion) # # subcommand "show" # cmd_show = subcmds.add_parser( 'show', description=cli.show.__doc__, epilog=epilog_show, formatter_class=parser.formatter_class, help='Serialize assets and directories into a multidocument YAML stream.' ) cmd_show.set_defaults(run=cli.show) build_parser(cmd_show, args_show) # # subcommand "tree" # cmd_tree = subcmds.add_parser( 'tree', description=cli.tree.__doc__, epilog=epilog_tree, formatter_class=parser.formatter_class, help='List the assets and directories of a directory in ``tree`` format.' ) cmd_tree.set_defaults(run=cli.tree) build_parser(cmd_tree, args_tree) # # subcommand "tsv-to-yaml" # cmd_tsv_to_yaml = subcmds.add_parser( 'tsv-to-yaml', description=cli.tsv_to_yaml.__doc__, epilog=epilog_tsv_to_yaml, formatter_class=parser.formatter_class, help='Convert a TSV file to YAML.' ) cmd_tsv_to_yaml.set_defaults(run=cli.tsv_to_yaml) build_parser(cmd_tsv_to_yaml, args_tsv_to_yaml) # # subcommand "unset" # cmd_unset = subcmds.add_parser( 'unset', description=cli.unset.__doc__, epilog=epilog_unset, formatter_class=parser.formatter_class, help='Remove keys from assets.' ) cmd_unset.set_defaults(run=cli.unset) build_parser(cmd_unset, args_unset) return parser
[docs] def get_subcmd_index(arglist: list, start: int = 1) -> int | None: r"""Get the index of the Onyo subcommand in a list of arguments. Parameters ---------- arglist The list of command line arguments passed to a Python script. Usually ``sys.argv``. start The index to start searching from. Example ------- >>> subcmd_index = get_subcmd_index(sys.argv) """ onyo_flags_with_args = ['-C', '--onyopath'] try: # find the first non-flag argument nonflag = next((a for a in arglist[start:] if a[0] != '-')) index = arglist.index(nonflag, start) except (StopIteration, ValueError): return None # check if it's the subcommand, or just an argument to a flag if arglist[index - 1] in onyo_flags_with_args: index = get_subcmd_index(arglist, index + 1) return index
[docs] def main() -> None: r"""Execute Onyo's CLI.""" # # ARGPARSE Hack #1 # # This unfortunately-located-hack passes uninterpreted args to "onyo config". # nargs=argparse.REMAINDER is supposed to do this, but did not work for our # needs, and as of Python 3.8 is soft-deprecated (due to being buggy). # See https://bugs.python.org/issue17050#msg315716 ; https://bugs.python.org/issue9334 passthrough_subcmds = ['config'] subcmd_index = get_subcmd_index(sys.argv) if subcmd_index and sys.argv[subcmd_index] in passthrough_subcmds: # display the onyo subcmd's --help, and don't pass it through if not any(x in sys.argv for x in ['-h', '--help']): sys.argv.insert(subcmd_index + 1, '--') # # ARGPARSE Hack #2 # # This hack makes argparse print the help text for the subcommand when an # unknown argument is encountered. Previously it only printed help for the # top-level command. # See https://bugs.python.org/issue34479 global subcmds # parse the arguments parser = setup_parser() args, extras = parser.parse_known_args() if extras: if args.cmd: subcmds._name_parser_map[args.cmd].print_usage(file=sys.stderr) parser.error("unrecognized arguments: %s" % " ".join(extras)) # # Begin normal, non-hack `main` stuff # # configure user interface ui.set_debug(args.debug) ui.set_yes(args.yes) ui.set_quiet(args.quiet) # run the subcommand if subcmd_index: old_cwd = Path.cwd() os.chdir(args.opdir) # normally exit 1 on error. `get` is a special case. Exit with 2 on # error to mimic `grep`'s behavior. cmd_error_codes = { 'get': 2, } error_returncode = cmd_error_codes.get(args.cmd, 1) try: args.run(args) except InvalidArgumentError as e: # Captures malformed calls that aren't covered by argparse itself. # Same style of reporting as any other argparse error: subcmds._name_parser_map[args.cmd].print_usage(file=sys.stderr) parser.error(str(e)) except OnyoCLIExitCode as e: ui.log_debug(ui.format_traceback(e)) sys.exit(e.returncode) except UIInputError as e: ui.error(e) ui.error("Use the --yes switch for non-interactive mode.") except (CalledProcessError, Exception) as e: # Generic catcher-of-last-resort: print the exception/error and exit # non-zero. ui.error(e) code = getattr(e, 'returncode', error_returncode) sys.exit(code) except KeyboardInterrupt: ui.error("User interrupted.") sys.exit(1) finally: os.chdir(old_cwd) if ui.error_count > 0: # Errors may have been encountered while still being able to proceed # (hence no exception bubbled up). # Exit non-zero. sys.exit(error_returncode) else: parser.print_help() sys.exit(1)
if __name__ == '__main__': main()