From 093ea9ede9d828df78196e61ea33553935a3ae3c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 1 Nov 2024 00:03:29 -0400 Subject: [PATCH] Integrated rich-argparse with cmd2. --- .github/workflows/mypy.yml | 2 +- Pipfile | 2 + cmd2/argparse_custom.py | 127 ++++++++++- cmd2/cmd2.py | 203 +++++++++--------- docs/conf.py | 3 + docs/features/help.rst | 21 +- setup.py | 2 + tests/conftest.py | 17 +- tests/test_argparse.py | 20 +- tests/test_argparse_custom.py | 2 +- tests/test_cmd2.py | 2 +- tests/test_transcript.py | 4 +- tests/transcripts/from_cmdloop.txt | 2 +- .../test_argparse_subcommands.py | 14 +- 14 files changed, 266 insertions(+), 155 deletions(-) diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 6c07f536..b1de6489 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -19,5 +19,5 @@ jobs: # Only a single commit is fetched by default, for the ref/SHA that triggered the workflow. # Set fetch-depth: 0 to fetch all history for all branches and tags. fetch-depth: 0 # Needed for setuptools_scm to work correctly - - run: pip install -U --user pip mypy + - run: pip install -U --user pip mypy rich rich-argparse - run: mypy . diff --git a/Pipfile b/Pipfile index f8997310..54ee0449 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,8 @@ verify_ssl = true [packages] pyperclip = "*" +rich = "*" +rich-argparse = "*" setuptools = "*" wcwidth = "*" diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index bed8bb47..2a49d8ea 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -7,7 +7,7 @@ parser that inherits from it. This will give a consistent look-and-feel between the help/error output of built-in cmd2 commands and the app-specific commands. If you wish to override the parser used by cmd2's built-in commands, see -override_parser.py example. +custom_parser.py example. Since the new capabilities are added by patching at the argparse API level, they are available whether or not Cmd2ArgumentParser is used. However, the help @@ -265,6 +265,18 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens) runtime_checkable, ) +from rich.console import ( + Group, + RenderableType, +) +from rich_argparse import ( + ArgumentDefaultsRichHelpFormatter, + MetavarTypeRichHelpFormatter, + RawDescriptionRichHelpFormatter, + RawTextRichHelpFormatter, + RichHelpFormatter, +) + from . import ( ansi, constants, @@ -1042,9 +1054,14 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) ############################################################################################################ -class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): +class Cmd2HelpFormatter(RichHelpFormatter): """Custom help formatter to configure ordering of help text""" + # Render markup in usage, help, description, and epilog text. + RichHelpFormatter.usage_markup = True + RichHelpFormatter.help_markup = True + RichHelpFormatter.text_markup = True + def _format_usage( self, usage: Optional[str], @@ -1249,6 +1266,84 @@ def _format_args(self, action: argparse.Action, default_metavar: Union[str, Tupl return super()._format_args(action, default_metavar) # type: ignore[arg-type] +class RawDescriptionCmd2HelpFormatter( + RawDescriptionRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains any formatting in descriptions and epilogs.""" + + +class RawTextCmd2HelpFormatter( + RawTextRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which retains formatting of all help text.""" + + +class ArgumentDefaultsCmd2HelpFormatter( + ArgumentDefaultsRichHelpFormatter, + Cmd2HelpFormatter, +): + """Cmd2 help message formatter which adds default values to argument help.""" + + +class MetavarTypeCmd2HelpFormatter( + MetavarTypeRichHelpFormatter, + Cmd2HelpFormatter, +): + """ + Cmd2 help message formatter which uses the argument 'type' as the default + metavar value (instead of the argument 'dest'). + """ + + +class TextGroup: + """ + A block of text which is formatted like an argparse argument group, including a title. + + Title: + Here is the first row of text. + Here is yet another row of text. + """ + + def __init__( + self, + title: str, + text: RenderableType, + formatter_creator: Callable[[], Cmd2HelpFormatter], + ) -> None: + """ + :param title: the group's title + :param text: the group's text (string or object that may be rendered by Rich) + :param formatter_creator: callable which returns a Cmd2HelpFormatter instance + """ + self.title = title + self.text = text + self.formatter_creator = formatter_creator + + def __rich__(self) -> Group: + """Custom rendering logic.""" + import rich + + formatter = self.formatter_creator() + + styled_title = rich.text.Text( + type(formatter).group_name_formatter(f"{self.title}:"), + style=formatter.styles["argparse.groups"], + ) + + # Left pad the text like an argparse argument group does + left_padding = formatter._indent_increment + + text_table = rich.table.Table( + box=None, + show_header=False, + padding=(0, 0, 0, left_padding), + ) + text_table.add_row(self.text) + return Group(styled_title, text_table) + + class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" @@ -1256,10 +1351,10 @@ def __init__( self, prog: Optional[str] = None, usage: Optional[str] = None, - description: Optional[str] = None, - epilog: Optional[str] = None, + description: Optional[RenderableType] = None, + epilog: Optional[RenderableType] = None, parents: Sequence[argparse.ArgumentParser] = (), - formatter_class: Type[argparse.HelpFormatter] = Cmd2HelpFormatter, + formatter_class: Type[Cmd2HelpFormatter] = Cmd2HelpFormatter, prefix_chars: str = '-', fromfile_prefix_chars: Optional[str] = None, argument_default: Optional[str] = None, @@ -1279,10 +1374,10 @@ def __init__( super(Cmd2ArgumentParser, self).__init__( prog=prog, usage=usage, - description=description, - epilog=epilog, + description=description, # type: ignore[arg-type] + epilog=epilog, # type: ignore[arg-type] parents=parents if parents else [], - formatter_class=formatter_class, # type: ignore[arg-type] + formatter_class=formatter_class, prefix_chars=prefix_chars, fromfile_prefix_chars=fromfile_prefix_chars, argument_default=argument_default, @@ -1291,6 +1386,10 @@ def __init__( allow_abbrev=allow_abbrev, ) + # Recast to assist type checkers since in a Cmd2HelpFormatter, these can be Rich renderables. + self.description: Optional[RenderableType] = self.description # type: ignore[assignment] + self.epilog: Optional[RenderableType] = self.epilog # type: ignore[assignment] + self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined] def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore @@ -1321,6 +1420,10 @@ def error(self, message: str) -> NoReturn: formatted_message = ansi.style_error(formatted_message) self.exit(2, f'{formatted_message}\n\n') + def _get_formatter(self) -> Cmd2HelpFormatter: + """Copy of _get_formatter() with a different return type to assist type checkers.""" + return cast(Cmd2HelpFormatter, super()._get_formatter()) + def format_help(self) -> str: """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" formatter = self._get_formatter() @@ -1329,7 +1432,7 @@ def format_help(self) -> str: formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # type: ignore[arg-type] # description - formatter.add_text(self.description) + formatter.add_text(self.description) # type: ignore[arg-type] # Begin cmd2 customization (separate required and optional arguments) @@ -1370,7 +1473,7 @@ def format_help(self) -> str: # End cmd2 customization # epilog - formatter.add_text(self.epilog) + formatter.add_text(self.epilog) # type: ignore[arg-type] # determine help from format above return formatter.format_help() + '\n' @@ -1382,6 +1485,10 @@ def _print_message(self, message: str, file: Optional[IO[str]] = None) -> None: file = sys.stderr ansi.style_aware_write(file, message) + def create_text_group(self, title: str, text: RenderableType) -> TextGroup: + """Create a TextGroup using this parser's formatter creator.""" + return TextGroup(title, text, self._get_formatter) + class Cmd2AttributeWrapper: """ diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 25fcc735..8711f841 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -220,10 +220,6 @@ class Cmd(cmd.Cmd): DEFAULT_EDITOR = utils.find_editor() - INTERNAL_COMMAND_EPILOG = ( - "Notes:\n" " This command is for internal use and is not intended to be called from the\n" " command line." - ) - # Sorting keys for strings ALPHABETICAL_SORT_KEY = utils.norm_fold NATURAL_SORT_KEY = utils.natural_keys @@ -3273,9 +3269,7 @@ def _cmdloop(self) -> None: # Top-level parser for alias @staticmethod def _build_alias_parser() -> Cmd2ArgumentParser: - alias_description = ( - "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string." - ) + alias_description = "Manage aliases." alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description) alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') alias_subparsers.required = True @@ -3285,7 +3279,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser: # Preserve quotes since we are passing strings to other commands @with_argparser(_build_alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace) -> None: - """Manage aliases""" + """Manage aliases.""" # Call handler for whatever subcommand was selected handler = args.cmd2_handler.get() handler(args) @@ -3293,26 +3287,30 @@ def do_alias(self, args: argparse.Namespace) -> None: # alias -> create @staticmethod def _build_alias_create_parser() -> Cmd2ArgumentParser: - alias_create_description = "Create or overwrite an alias" + from rich.console import Group - alias_create_epilog = ( - "Notes:\n" - " If you want to use redirection, pipes, or terminators in the value of the\n" - " alias, then quote them.\n" - "\n" - " Since aliases are resolved during parsing, tab completion will function as\n" - " it would for the actual command the alias resolves to.\n" + alias_create_description = "Create or overwrite an alias." + + alias_create_notes = ( + "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.\n" "\n" - "Examples:\n" - " alias create ls !ls -lF\n" - " alias create show_log !cat \"log file.txt\"\n" - " alias create save_results print_results \">\" out.txt\n" + "Since aliases are resolved during parsing, tab completion will function as it would " + "for the actual command the alias resolves to." ) - alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=alias_create_description, - epilog=alias_create_epilog, + alias_create_examples = ( + "alias create ls !ls -lF\n" + "alias create show_log !cat \"log file.txt\"\n" + "alias create save_results print_results \">\" out.txt" ) + + alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description) + + # Create epilog + notes_group = alias_create_parser.create_text_group("Notes", alias_create_notes) + examples_group = alias_create_parser.create_text_group("Examples", alias_create_examples) + alias_create_parser.epilog = Group(notes_group, "\n", examples_group) + alias_create_parser.add_argument('name', help='name of this alias') alias_create_parser.add_argument( 'command', @@ -3330,7 +3328,7 @@ def _build_alias_create_parser() -> Cmd2ArgumentParser: @as_subcommand_to('alias', 'create', _build_alias_create_parser, help="create or overwrite an alias") def _alias_create(self, args: argparse.Namespace) -> None: - """Create or overwrite an alias""" + """Create or overwrite an alias.""" self.last_result = False # Validate the alias name @@ -3363,7 +3361,7 @@ def _alias_create(self, args: argparse.Namespace) -> None: # alias -> delete @staticmethod def _build_alias_delete_parser() -> Cmd2ArgumentParser: - alias_delete_description = "Delete specified aliases or all aliases if --all is used" + alias_delete_description = "Delete specified aliases or all aliases if --all is used." alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") @@ -3379,7 +3377,7 @@ def _build_alias_delete_parser() -> Cmd2ArgumentParser: @as_subcommand_to('alias', 'delete', _build_alias_delete_parser, help="delete aliases") def _alias_delete(self, args: argparse.Namespace) -> None: - """Delete aliases""" + """Delete aliases.""" self.last_result = True if args.all: @@ -3399,12 +3397,7 @@ def _alias_delete(self, args: argparse.Namespace) -> None: # alias -> list @staticmethod def _build_alias_list_parser() -> Cmd2ArgumentParser: - alias_list_description = ( - "List specified aliases in a reusable form that can be saved to a startup\n" - "script to preserve aliases across sessions\n" - "\n" - "Without arguments, all aliases will be listed." - ) + alias_list_description = "List specified aliases or all aliases if no arguments are given." alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description) alias_list_parser.add_argument( @@ -3419,7 +3412,7 @@ def _build_alias_list_parser() -> Cmd2ArgumentParser: @as_subcommand_to('alias', 'list', _build_alias_list_parser, help="list aliases") def _alias_list(self, args: argparse.Namespace) -> None: - """List some or all aliases as 'alias create' commands""" + """List some or all aliases as 'alias create' commands.""" self.last_result = {} # Dict[alias_name, alias_value] tokens_to_quote = constants.REDIRECTION_TOKENS @@ -3483,7 +3476,7 @@ def complete_help_subcommands( @staticmethod def _build_help_parser() -> Cmd2ArgumentParser: help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="List available commands or provide detailed help for a specific command" + description="List available commands or provide detailed help for a specific command." ) help_parser.add_argument( '-v', @@ -3511,7 +3504,7 @@ def _build_help_parser() -> Cmd2ArgumentParser: @with_argparser(_build_help_parser) def do_help(self, args: argparse.Namespace) -> None: - """List available commands or provide detailed help for a specific command""" + """List available commands or provide detailed help for a specific command.""" self.last_result = True if not args.command or args.verbose: @@ -3734,11 +3727,11 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: @staticmethod def _build_shortcuts_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.") @with_argparser(_build_shortcuts_parser) def do_shortcuts(self, _: argparse.Namespace) -> None: - """List available shortcuts""" + """List available shortcuts.""" # Sort the shortcut tuples by name sorted_shortcuts = sorted(self.statement_parser.shortcuts, key=lambda x: self.default_sort_key(x[0])) result = "\n".join('{}: {}'.format(sc[0], sc[1]) for sc in sorted_shortcuts) @@ -3747,11 +3740,14 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: @staticmethod def _build_eof_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Called when Ctrl-D is pressed", - epilog=Cmd.INTERNAL_COMMAND_EPILOG, + eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.") + eof_parser.epilog = eof_parser.create_text_group( + "Note", + "This command is for internal use and is not intended to be called from the command line.", ) + return eof_parser + @with_argparser(_build_eof_parser) def do_eof(self, _: argparse.Namespace) -> Optional[bool]: """ @@ -3765,11 +3761,11 @@ def do_eof(self, _: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_quit_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application.") @with_argparser(_build_quit_parser) def do_quit(self, _: argparse.Namespace) -> Optional[bool]: - """Exit this application""" + """Exit this application.""" # Return True to stop the command loop self.last_result = True return True @@ -3858,11 +3854,7 @@ def complete_set_value( def _build_base_set_parser() -> Cmd2ArgumentParser: # When tab completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a base parser with all the common elements. - set_description = ( - "Set a settable parameter or show current settings of parameters\n\n" - "Call without arguments for a list of all settable parameters with their values.\n" - "Call with just param to view that parameter's value." - ) + set_description = "Set a settable parameter or show current settings of parameters." base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description) base_set_parser.add_argument( 'param', @@ -3872,6 +3864,13 @@ def _build_base_set_parser() -> Cmd2ArgumentParser: descriptive_header=Cmd._settable_completion_table.generate_header(), ) + base_set_parser_notes = ( + "Call without arguments for a list of all settable parameters with their values.\n" + "\n" + "Call with just param to view that parameter's value." + ) + base_set_parser.epilog = base_set_parser.create_text_group("Notes", base_set_parser_notes) + return base_set_parser @staticmethod @@ -3948,7 +3947,7 @@ def do_set(self, args: argparse.Namespace) -> None: @staticmethod def _build_shell_parser() -> Cmd2ArgumentParser: - shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") + shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt.") shell_parser.add_argument('command', help='the command to run', completer=Cmd.shell_cmd_complete) shell_parser.add_argument( 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=Cmd.path_complete @@ -3959,7 +3958,7 @@ def _build_shell_parser() -> Cmd2ArgumentParser: # Preserve quotes since we are passing these strings to the shell @with_argparser(_build_shell_parser, preserve_quotes=True) def do_shell(self, args: argparse.Namespace) -> None: - """Execute a command as if at the OS prompt""" + """Execute a command as if at the OS prompt.""" import signal import subprocess @@ -4257,12 +4256,13 @@ def py_quit() -> None: @staticmethod def _build_py_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell.") @with_argparser(_build_py_parser) def do_py(self, _: argparse.Namespace) -> Optional[bool]: """ - Run an interactive Python shell + Run an interactive Python shell. + :return: True if running of commands should stop """ # self.last_resort will be set by _run_python() @@ -4271,7 +4271,7 @@ def do_py(self, _: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_run_pyscript_parser() -> Cmd2ArgumentParser: run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description="Run a Python script file inside the console" + description="Run Python script within this application's environment." ) run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=Cmd.path_complete) run_pyscript_parser.add_argument( @@ -4283,7 +4283,7 @@ def _build_run_pyscript_parser() -> Cmd2ArgumentParser: @with_argparser(_build_run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: """ - Run a Python script file inside the console + Run Python script within this application's environment. :return: True if running of commands should stop """ @@ -4317,12 +4317,12 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_ipython_parser() -> Cmd2ArgumentParser: - return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell") + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.") @with_argparser(_build_ipython_parser) def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover """ - Enter an interactive IPython shell + Run an interactive IPython shell. :return: True if running of commands should stop """ @@ -4390,9 +4390,13 @@ def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover @staticmethod def _build_history_parser() -> Cmd2ArgumentParser: - history_description = "View, run, edit, save, or clear previously entered commands" + from .argparse_custom import RawTextCmd2HelpFormatter - history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description) + history_description = "View, run, edit, save, or clear previously entered commands." + + history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( + description=history_description, formatter_class=RawTextCmd2HelpFormatter + ) history_action_group = history_parser.add_mutually_exclusive_group() history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') @@ -4400,14 +4404,14 @@ def _build_history_parser() -> Cmd2ArgumentParser: '-o', '--output_file', metavar='FILE', - help='output commands to a script file, implies -s', + help='output commands to a script file, implies --script', completer=Cmd.path_complete, ) history_action_group.add_argument( '-t', '--transcript', metavar='TRANSCRIPT_FILE', - help='output commands and results to a transcript file,\nimplies -s', + help='output commands and results to a transcript file,\nimplies --script', completer=Cmd.path_complete, ) history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') @@ -4423,7 +4427,7 @@ def _build_history_parser() -> Cmd2ArgumentParser: '-x', '--expanded', action='store_true', - help='output fully parsed commands with aliases and shortcuts expanded', + help='output fully parsed commands with aliases and shortcuts\nexpanded', ) history_format_group.add_argument( '-v', @@ -4452,7 +4456,7 @@ def _build_history_parser() -> Cmd2ArgumentParser: @with_argparser(_build_history_parser) def do_history(self, args: argparse.Namespace) -> Optional[bool]: """ - View, run, edit, save, or clear previously entered commands + View, run, edit, save, or clear previously entered commands. :return: True if running of commands should stop """ @@ -4778,15 +4782,16 @@ def _generate_transcript( @staticmethod def _build_edit_parser() -> Cmd2ArgumentParser: - edit_description = ( - "Run a text editor and optionally open a file with it\n" - "\n" - "The editor used is determined by a settable parameter. To set it:\n" - "\n" - " set editor (program-name)" - ) + from rich.markdown import Markdown + edit_description = "Run a text editor and optionally open a file with it." edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description) + + edit_parser.epilog = edit_parser.create_text_group( + "Note", + Markdown("To set a new editor, run: `set editor `"), + ) + edit_parser.add_argument( 'file_path', nargs=argparse.OPTIONAL, @@ -4797,7 +4802,7 @@ def _build_edit_parser() -> Cmd2ArgumentParser: @with_argparser(_build_edit_parser) def do_edit(self, args: argparse.Namespace) -> None: - """Run a text editor and optionally open a file with it""" + """Run a text editor and optionally open a file with it.""" # self.last_result will be set by do_shell() which is called by run_editor() self.run_editor(args.file_path) @@ -4826,19 +4831,24 @@ def _current_script_dir(self) -> Optional[str]: else: return None - run_script_description = ( - "Run commands in script file that is encoded as either ASCII or UTF-8 text\n" - "\n" - "Script should contain one command per line, just like the command would be\n" - "typed in the console.\n" - "\n" - "If the -t/--transcript flag is used, this command instead records\n" - "the output of the script commands to a transcript for testing purposes.\n" - ) + @staticmethod + def _build_base_run_script_parser() -> Cmd2ArgumentParser: + from rich.table import Table + + run_script_description = Table( + box=None, + show_header=False, + padding=(0, 0), + ) + run_script_description.add_row("Run text script.") + run_script_description.add_row() + run_script_description.add_row("Scripts contain one command, written as it would be typed in the console, per line.") + + return argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description) @staticmethod def _build_run_script_parser() -> Cmd2ArgumentParser: - run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=Cmd.run_script_description) + run_script_parser = Cmd._build_base_run_script_parser() run_script_parser.add_argument( '-t', '--transcript', @@ -4846,17 +4856,14 @@ def _build_run_script_parser() -> Cmd2ArgumentParser: help='record the output of the script as a transcript file', completer=Cmd.path_complete, ) - run_script_parser.add_argument( - 'script_path', - help="path to the script file", - completer=Cmd.path_complete, - ) + run_script_parser.add_argument('script_path', help="path to the script file", completer=Cmd.path_complete) return run_script_parser @with_argparser(_build_run_script_parser) def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: - """Run commands in script file that is encoded as either ASCII or UTF-8 text. + """ + Run text script. :return: True if running of commands should stop """ @@ -4919,19 +4926,21 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: @staticmethod def _build_relative_run_script_parser() -> Cmd2ArgumentParser: - relative_run_script_description = Cmd.run_script_description - relative_run_script_description += ( - "\n\n" - "If this is called from within an already-running script, the filename will be\n" - "interpreted relative to the already-running script's directory." - ) + from rich.table import Table - relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts." + relative_run_script_parser = Cmd._build_base_run_script_parser() + relative_run_script_parser.description = cast(Table, relative_run_script_parser.description) + relative_run_script_parser.description.add_row() + relative_run_script_parser.description.add_row( + "If this is run from within a script, script_path will be interpreted relative to that script's directory." + ) - relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER( - description=relative_run_script_description, epilog=relative_run_script_epilog + relative_run_script_parser.epilog = relative_run_script_parser.create_text_group( + "Note", + "This command is intended to be used from within a text script.", ) - relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') + + relative_run_script_parser.add_argument('script_path', help="path to the script file") return relative_run_script_parser @@ -4942,9 +4951,9 @@ def do__relative_run_script(self, args: argparse.Namespace) -> Optional[bool]: :return: True if running of commands should stop """ - file_path = args.file_path + script_path = args.script_path # NOTE: Relative path is an absolute path, it is just relative to the current script directory - relative_path = os.path.join(self._current_script_dir or '', file_path) + relative_path = os.path.join(self._current_script_dir or '', script_path) # self.last_result will be set by do_run_script() return self.do_run_script(utils.quote_string(relative_path)) diff --git a/docs/conf.py b/docs/conf.py index 499aa274..ba7b8e99 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -185,4 +185,7 @@ ('py:class', 'argparse._SubParsersAction'), ('py:class', 'cmd2.utils._T'), ('py:class', 'types.FrameType'), + ('py:class', 'rich.console.Console'), + ('py:class', 'rich.console.ConsoleRenderable'), + ('py:class', 'rich.console.RichCast'), ] diff --git a/docs/features/help.rst b/docs/features/help.rst index 759a5f15..c4cd0948 100644 --- a/docs/features/help.rst +++ b/docs/features/help.rst @@ -173,17 +173,18 @@ The ``help`` command also has a verbose option (``help -v`` or ``help Other ====================================================================================================== - alias Manage aliases - config Config command - edit Run a text editor and optionally open a file with it - help List available commands or provide detailed help for a specific command - history View, run, edit, save, or clear previously entered commands - quit Exit this application - run_pyscript Run a Python script file inside the console - run_script Run commands in script file that is encoded as either ASCII or UTF-8 text. + alias Manage aliases. + edit Run a text editor and optionally open a file with it. + help List available commands or provide detailed help for a specific command. + history View, run, edit, save, or clear previously entered commands. + ipy Run an interactive IPython shell. + py Run an interactive Python shell. + quit Exit this application. + run_pyscript Run Python script within this application's environment. + run_script Run text script. set Set a settable parameter or show current settings of parameters - shell Execute a command as if at the OS prompt - shortcuts List available shortcuts + shell Execute a command as if at the OS prompt. + shortcuts List available shortcuts. version Version command When called with the ``-v`` flag for verbose help, the one-line description for diff --git a/setup.py b/setup.py index 7db19ac1..6659a8d6 100755 --- a/setup.py +++ b/setup.py @@ -46,6 +46,8 @@ INSTALL_REQUIRES = [ 'pyperclip', + 'rich', + 'rich-argparse', 'wcwidth', ] diff --git a/tests/conftest.py b/tests/conftest.py index 0b3a0178..20b9ebd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,35 +53,36 @@ def verify_help_text( assert verbose_string in help_text -# Help text for the history command +# Help text for the history command (Generated when terminal width is 80) HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] [-v] [-a] [arg] -View, run, edit, save, or clear previously entered commands +View, run, edit, save, or clear previously entered commands. -positional arguments: +Positional Arguments: arg empty all history items a one history item by number a..b, a:b, a:, ..b items by indices (inclusive) string items containing string /regex/ items matching regular expression -optional arguments: +Optional Arguments: -h, --help show this help message and exit -r, --run run selected history items -e, --edit edit and then run selected history items -o, --output_file FILE - output commands to a script file, implies -s + output commands to a script file, implies --script -t, --transcript TRANSCRIPT_FILE output commands and results to a transcript file, - implies -s + implies --script -c, --clear clear all history -formatting: +Formatting: -s, --script output commands in script format, i.e. without command numbers - -x, --expanded output fully parsed commands with aliases and shortcuts expanded + -x, --expanded output fully parsed commands with aliases and shortcuts + expanded -v, --verbose display history and include expanded commands if they differ from the typed command -a, --all display all commands, including ones persisted from diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 4fe884b9..13a28b9b 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -41,8 +41,7 @@ def _say_parser_builder() -> cmd2.Cmd2ArgumentParser: @cmd2.with_argparser(_say_parser_builder) def do_say(self, args, *, keyword_arg: Optional[str] = None): """ - Repeat what you - tell me to. + Repeat what you tell me to. :param args: argparse namespace :param keyword_arg: Optional keyword arguments @@ -212,8 +211,7 @@ def test_argparse_help_docstring(argparse_app): out, err = run_cmd(argparse_app, 'help say') assert out[0].startswith('Usage: say') assert out[1] == '' - assert out[2] == 'Repeat what you' - assert out[3] == 'tell me to.' + assert out[2] == 'Repeat what you tell me to.' for line in out: assert not line.startswith(':') @@ -358,40 +356,26 @@ def test_subcommand_help(subcommand_app): # foo has no aliases out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') - assert out[1] == '' - assert out[2] == 'positional arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') - assert out[1] == '' - assert out[2] == 'positional arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') - assert out[1] == '' - assert out[2] == 'positional arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') - assert out[1] == '' - assert out[2] == 'positional arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') - assert out[1] == '' - assert out[2] == 'positional arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') - assert out[1] == '' - assert out[2] == 'positional arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') - assert out[1] == '' - assert out[2] == 'positional arguments:' def test_subcommand_invalid_help(subcommand_app): diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index f9eeec5d..3a5b14aa 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -240,7 +240,7 @@ def test_apcustom_required_options(): # Make sure a 'required arguments' section shows when a flag is marked required parser = Cmd2ArgumentParser() parser.add_argument('--required_flag', required=True) - assert 'required arguments' in parser.format_help() + assert 'Required Arguments' in parser.format_help() def test_apcustom_metavar_tuple(): diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index efa4868b..b75980e8 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1567,7 +1567,7 @@ def test_help_with_no_docstring(capsys): out == """Usage: greet [-h] [-s] -optional arguments: +Optional Arguments: -h, --help show this help message and exit -s, --shout N00B EMULATION MODE diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 986221ff..96ee118b 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -48,7 +48,9 @@ def __init__(self, *args, **kwargs): speak_parser = cmd2.Cmd2ArgumentParser() speak_parser.add_argument('-p', '--piglatin', action="store_true", help="atinLay") speak_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE") - speak_parser.add_argument('-r', '--repeat', type=int, help="output [n] times") + + # Escape open bracket since help text can contain markup + speak_parser.add_argument('-r', '--repeat', type=int, help="output \[n] times") @cmd2.with_argparser(speak_parser, with_unknown_args=True) def do_speak(self, opts, arg): diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index a615d243..60455904 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -6,7 +6,7 @@ Usage: say [-h] [-p] [-s] [-r REPEAT]/ */ Repeats what you tell me to./ */ -optional arguments:/ */ +Optional Arguments:/ */ -h, --help show this help message and exit/ */ -p, --piglatin atinLay/ */ -s, --shout N00B EMULATION MODE/ */ diff --git a/tests_isolated/test_commandset/test_argparse_subcommands.py b/tests_isolated/test_commandset/test_argparse_subcommands.py index 0fc8c47e..2a744467 100644 --- a/tests_isolated/test_commandset/test_argparse_subcommands.py +++ b/tests_isolated/test_commandset/test_argparse_subcommands.py @@ -99,39 +99,39 @@ def test_subcommand_help(subcommand_app): out, err = run_cmd(subcommand_app, 'help base foo') assert out[0].startswith('Usage: base foo') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # bar has aliases (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base bar') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_1') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base bar_2') assert out[0].startswith('Usage: base bar') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' # helpless has aliases and no help text (usage should never show alias name) out, err = run_cmd(subcommand_app, 'help base helpless') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_1') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' out, err = run_cmd(subcommand_app, 'help base helpless_2') assert out[0].startswith('Usage: base helpless') assert out[1] == '' - assert out[2] == 'positional arguments:' + assert out[2] == 'Positional Arguments:' def test_subcommand_invalid_help(subcommand_app):