Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 64 additions & 62 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3508,9 +3508,9 @@ def _cmdloop(self) -> None:
# Top-level parser for alias
@staticmethod
def _build_alias_parser() -> Cmd2ArgumentParser:
alias_description = Group(
alias_description = Text.assemble(
"Manage aliases.",
"\n",
"\n\n",
"An alias is a command that enables replacement of a word by another string.",
)
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description)
Expand All @@ -3537,10 +3537,11 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description)

# Add Notes epilog
alias_create_notes = Group(
alias_create_notes = Text.assemble(
"If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.",
"\n",
Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.COMMAND_LINE),
"\n\n",
(" alias create save_results print_results \">\" out.txt\n", Cmd2Style.COMMAND_LINE),
"\n\n",
(
"Since aliases are resolved during parsing, tab completion will function as it would "
"for the actual command the alias resolves to."
Expand Down Expand Up @@ -3639,12 +3640,12 @@ def _alias_delete(self, args: argparse.Namespace) -> None:
# alias -> list
@classmethod
def _build_alias_list_parser(cls) -> Cmd2ArgumentParser:
alias_list_description = Group(
alias_list_description = Text.assemble(
(
"List specified aliases in a reusable form that can be saved to a startup "
"script to preserve aliases across sessions."
),
"\n",
"\n\n",
"Without arguments, all aliases will be listed.",
)

Expand Down Expand Up @@ -3719,9 +3720,9 @@ def macro_arg_complete(
# Top-level parser for macro
@staticmethod
def _build_macro_parser() -> Cmd2ArgumentParser:
macro_description = Group(
macro_description = Text.assemble(
"Manage macros.",
"\n",
"\n\n",
"A macro is similar to an alias, but it can contain argument placeholders.",
)
macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description)
Expand All @@ -3744,48 +3745,46 @@ def do_macro(self, args: argparse.Namespace) -> None:
# macro -> create
@classmethod
def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
macro_create_description = Group(
macro_create_description = Text.assemble(
"Create or overwrite a macro.",
"\n",
"\n\n",
"A macro is similar to an alias, but it can contain argument placeholders.",
"\n",
"\n\n",
"Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.",
"\n",
"\n\n",
"The following creates a macro called my_macro that expects two arguments:",
"\n",
Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.COMMAND_LINE),
"\n",
"\n\n",
(" macro create my_macro make_dinner --meat {1} --veggie {2}", Cmd2Style.COMMAND_LINE),
"\n\n",
"When the macro is called, the provided arguments are resolved and the assembled command is run. For example:",
"\n",
Text.assemble(
(" my_macro beef broccoli", Cmd2Style.COMMAND_LINE),
(" ───> ", Style(bold=True)),
("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE),
),
"\n\n",
(" my_macro beef broccoli", Cmd2Style.COMMAND_LINE),
(" ───> ", Style(bold=True)),
("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE),
)
macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description)

# Add Notes epilog
macro_create_notes = Group(
macro_create_notes = Text.assemble(
"To use the literal string {1} in your command, escape it this way: {{1}}.",
"\n",
"\n\n",
"Extra arguments passed to a macro are appended to resolved command.",
"\n",
"\n\n",
(
"An argument number can be repeated in a macro. In the following example the "
"first argument will populate both {1} instances."
),
"\n",
Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.COMMAND_LINE),
"\n",
"\n\n",
(" macro create ft file_taxes -p {1} -q {2} -r {1}", Cmd2Style.COMMAND_LINE),
"\n\n",
"To quote an argument in the resolved command, quote it during creation.",
"\n",
Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.COMMAND_LINE),
"\n",
"\n\n",
(" macro create backup !cp \"{1}\" \"{1}.orig\"", Cmd2Style.COMMAND_LINE),
"\n\n",
"If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.",
"\n",
Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.COMMAND_LINE),
"\n",
"\n\n",
(" macro create show_results print_results -type {1} \"|\" less", Cmd2Style.COMMAND_LINE),
"\n\n",
(
"Since macros don't resolve until after you press Enter, their arguments tab complete as paths. "
"This default behavior changes if custom tab completion for macro arguments has been implemented."
Expand Down Expand Up @@ -3926,11 +3925,10 @@ def _macro_delete(self, args: argparse.Namespace) -> None:

# macro -> list
macro_list_help = "list macros"
macro_list_description = (
"List specified macros in a reusable form that can be saved to a startup script\n"
"to preserve macros across sessions\n"
"\n"
"Without arguments, all macros will be listed."
macro_list_description = Text.assemble(
"List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.",
"\n\n",
"Without arguments, all macros will be listed.",
)

macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
Expand Down Expand Up @@ -4079,14 +4077,23 @@ def do_help(self, args: argparse.Namespace) -> None:
self.poutput(self.doc_leader, style=Cmd2Style.HELP_LEADER)
self.poutput()

if not cmds_cats:
# No categories found, fall back to standard behavior
self._print_documented_command_topics(self.doc_header, cmds_doc, args.verbose)
else:
# Categories found, Organize all commands by category
for category in sorted(cmds_cats.keys(), key=self.default_sort_key):
self._print_documented_command_topics(category, cmds_cats[category], args.verbose)
self._print_documented_command_topics(self.default_category, cmds_doc, args.verbose)
# Print any categories first and then the default category.
sorted_categories = sorted(cmds_cats.keys(), key=self.default_sort_key)
all_cmds = {category: cmds_cats[category] for category in sorted_categories}
all_cmds[self.doc_header] = cmds_doc

# Used to provide verbose table separation for better readability.
previous_table_printed = False

for category, commands in all_cmds.items():
if previous_table_printed:
self.poutput()

self._print_documented_command_topics(category, commands, args.verbose)
previous_table_printed = bool(commands) and args.verbose

if previous_table_printed and (help_topics or cmds_undoc):
self.poutput()

self.print_topics(self.misc_header, help_topics, 15, 80)
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
Expand All @@ -4102,7 +4109,7 @@ def do_help(self, args: argparse.Namespace) -> None:
completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
completer.print_help(args.subcommands, self.stdout)

# If there is a help func delegate to do_help
# If the command has a custom help function, then call it
elif help_func is not None:
help_func()

Expand Down Expand Up @@ -4376,9 +4383,9 @@ def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: s
def _build_base_set_parser(cls) -> 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 = Group(
set_description = Text.assemble(
"Set a settable parameter or show current settings of parameters.",
"\n",
"\n\n",
(
"Call without arguments for a list of all settable parameters with their values. "
"Call with just param to view that parameter's value."
Expand Down Expand Up @@ -5371,9 +5378,9 @@ def _current_script_dir(self) -> str | None:

@classmethod
def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser:
run_script_description = Group(
run_script_description = Text.assemble(
"Run text script.",
"\n",
"\n\n",
"Scripts should contain one command per line, entered as you would in the console.",
)

Expand Down Expand Up @@ -5617,11 +5624,9 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: #
cursor_offset=rl_get_point(),
alert_msg=alert_msg,
)
if rl_type == RlType.GNU:
sys.stderr.write(terminal_str)
sys.stderr.flush()
elif rl_type == RlType.PYREADLINE:
readline.rl.mode.console.write(terminal_str)

sys.stdout.write(terminal_str)
sys.stdout.flush()

# Redraw the prompt and input lines below the alert
rl_force_redisplay()
Expand Down Expand Up @@ -5679,9 +5684,6 @@ def need_prompt_refresh(self) -> bool: # pragma: no cover
def set_window_title(title: str) -> None: # pragma: no cover
"""Set the terminal window title.

NOTE: This function writes to stderr. Therefore, if you call this during a command run by a pyscript,
the string which updates the title will appear in that command's CommandResult.stderr data.

:param title: the new window title
"""
if not vt100_support:
Expand All @@ -5690,8 +5692,8 @@ def set_window_title(title: str) -> None: # pragma: no cover
from .terminal_utils import set_title_str

try:
sys.stderr.write(set_title_str(title))
sys.stderr.flush()
sys.stdout.write(set_title_str(title))
sys.stdout.flush()
except AttributeError:
# Debugging in Pycharm has issues with setting terminal title
pass
Expand Down
18 changes: 17 additions & 1 deletion cmd2/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def __init__(self, file: IO[str] | None = None) -> None:
Defaults to sys.stdout.
"""
# This console is configured for general-purpose printing. It enables soft wrap
# and disables Rich's automatic processing for markup, emoji, and highlighting.
# and disables Rich's automatic detection for markup, emoji, and highlighting.
# These defaults can be overridden in calls to the console's or cmd2's print methods.
super().__init__(
file=file,
Expand All @@ -201,6 +201,22 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole):
which conflicts with rich-argparse's explicit no_wrap and overflow settings.
"""

def __init__(self, file: IO[str] | None = None) -> None:
"""Cmd2RichArgparseConsole initializer.

:param file: optional file object where the console should write to.
Defaults to sys.stdout.
"""
# Disable Rich's automatic detection for markup, emoji, and highlighting.
# rich-argparse does markup and highlighting without involving the console
# so these won't affect its internal functionality.
super().__init__(
file=file,
markup=False,
emoji=False,
highlight=False,
)


class Cmd2ExceptionConsole(Cmd2BaseConsole):
"""Rich console for printing exceptions.
Expand Down
27 changes: 23 additions & 4 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
"""Defines custom Rich styles and their corresponding names for cmd2.

This module provides a centralized and discoverable way to manage Rich styles used
within the cmd2 framework. It defines a StrEnum for style names and a dictionary
that maps these names to their default style objects.
This module provides a centralized and discoverable way to manage Rich styles
used within the cmd2 framework. It defines a StrEnum for style names and a
dictionary that maps these names to their default style objects.

**Notes**

Cmd2 uses Rich for its terminal output, and while this module defines a set of
cmd2-specific styles, it's important to understand that these aren't the only
styles that can appear. Components like Rich tracebacks and the rich-argparse
library, which cmd2 uses for its help output, also apply their own built-in
styles. Additionally, app developers may use other Rich objects that have
their own default styles.

For a complete theming experience, you can create a custom theme that includes
styles from Rich and rich-argparse. The `cmd2.rich_utils.set_theme()` function
automatically updates rich-argparse's styles with any custom styles provided in
your theme dictionary, so you don't have to modify them directly.

You can find Rich's default styles in the `rich.default_styles` module.
For rich-argparse, the style names are defined in the
`rich_argparse.RichHelpFormatter.styles` dictionary.

"""

import sys
Expand All @@ -26,7 +45,7 @@ class Cmd2Style(StrEnum):
Using this enum allows for autocompletion and prevents typos when
referencing cmd2-specific styles.

This StrEnum is tightly coupled with DEFAULT_CMD2_STYLES. Any name
This StrEnum is tightly coupled with `DEFAULT_CMD2_STYLES`. Any name
added here must have a corresponding style definition there.
"""

Expand Down
5 changes: 3 additions & 2 deletions tests/test_argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import cast

import pytest
from rich.text import Text

import cmd2
import cmd2.string_utils as su
Expand Down Expand Up @@ -115,7 +116,7 @@ def do_pos_and_flag(self, args: argparse.Namespace) -> None:
CompletionItem('choice_1', ['Description 1']),
# Make this the longest description so we can test display width.
CompletionItem('choice_2', [su.stylize("String with style", style=cmd2.Color.BLUE)]),
CompletionItem('choice_3', [su.stylize("Text with style", style=cmd2.Color.RED)]),
CompletionItem('choice_3', [Text("Text with style", style=cmd2.Color.RED)]),
)

# This tests that CompletionItems created with numerical values are sorted as numbers.
Expand Down Expand Up @@ -739,7 +740,7 @@ def test_completion_items(ac_app) -> None:
assert lines[3].endswith("\x1b[34mString with style\x1b[0m ")

# Verify that the styled Rich Text also rendered.
assert lines[4].endswith("\x1b[31mText with style\x1b[0m ")
assert lines[4].endswith("\x1b[31mText with style \x1b[0m ")

# Now test CompletionItems created from numbers
text = ''
Expand Down
Loading