Skip to content

Commit 7175161

Browse files
kmvanbrunttleonhardt
authored andcommitted
Word wrapping long exceptions in pexcept().
1 parent 891c570 commit 7175161

File tree

4 files changed

+93
-76
lines changed

4 files changed

+93
-76
lines changed

cmd2/cmd2.py

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
shlex_split,
132132
)
133133
from .rich_utils import (
134+
Cmd2ExceptionConsole,
134135
Cmd2GeneralConsole,
135136
RichPrintKwargs,
136137
)
@@ -1207,7 +1208,7 @@ def print_to(
12071208
sep: str = " ",
12081209
end: str = "\n",
12091210
style: StyleType | None = None,
1210-
soft_wrap: bool | None = None,
1211+
soft_wrap: bool = True,
12111212
rich_print_kwargs: RichPrintKwargs | None = None,
12121213
**kwargs: Any, # noqa: ARG002
12131214
) -> None:
@@ -1218,11 +1219,8 @@ def print_to(
12181219
:param sep: string to write between print data. Defaults to " ".
12191220
:param end: string to write at end of print data. Defaults to a newline.
12201221
:param style: optional style to apply to output
1221-
:param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the
1222-
terminal width; instead, any text that doesn't fit will run onto the following line(s),
1223-
similar to the built-in print() function. Set to False to enable automatic word-wrapping.
1224-
If None (the default for this parameter), the output will default to no word-wrapping, as
1225-
configured by the Cmd2GeneralConsole.
1222+
:param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to
1223+
fit the terminal width. Defaults to True.
12261224
:param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print().
12271225
:param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this
12281226
method and still call `super()` without encountering unexpected keyword argument errors.
@@ -1254,7 +1252,7 @@ def poutput(
12541252
sep: str = " ",
12551253
end: str = "\n",
12561254
style: StyleType | None = None,
1257-
soft_wrap: bool | None = None,
1255+
soft_wrap: bool = True,
12581256
rich_print_kwargs: RichPrintKwargs | None = None,
12591257
**kwargs: Any, # noqa: ARG002
12601258
) -> None:
@@ -1278,7 +1276,7 @@ def perror(
12781276
sep: str = " ",
12791277
end: str = "\n",
12801278
style: StyleType | None = Cmd2Style.ERROR,
1281-
soft_wrap: bool | None = None,
1279+
soft_wrap: bool = True,
12821280
rich_print_kwargs: RichPrintKwargs | None = None,
12831281
**kwargs: Any, # noqa: ARG002
12841282
) -> None:
@@ -1303,7 +1301,7 @@ def psuccess(
13031301
*objects: Any,
13041302
sep: str = " ",
13051303
end: str = "\n",
1306-
soft_wrap: bool | None = None,
1304+
soft_wrap: bool = True,
13071305
rich_print_kwargs: RichPrintKwargs | None = None,
13081306
**kwargs: Any, # noqa: ARG002
13091307
) -> None:
@@ -1325,7 +1323,7 @@ def pwarning(
13251323
*objects: Any,
13261324
sep: str = " ",
13271325
end: str = "\n",
1328-
soft_wrap: bool | None = None,
1326+
soft_wrap: bool = True,
13291327
rich_print_kwargs: RichPrintKwargs | None = None,
13301328
**kwargs: Any, # noqa: ARG002
13311329
) -> None:
@@ -1345,44 +1343,59 @@ def pwarning(
13451343
def pexcept(
13461344
self,
13471345
exception: BaseException,
1348-
end: str = "\n",
1349-
rich_print_kwargs: RichPrintKwargs | None = None,
13501346
**kwargs: Any, # noqa: ARG002
13511347
) -> None:
13521348
"""Print an exception to sys.stderr.
13531349
1354-
If `debug` is true, a full exception traceback is also printed, if one exists.
1350+
If `debug` is true, a full traceback is also printed, if one exists.
13551351
13561352
:param exception: the exception to be printed.
1357-
1358-
For details on the other parameters, refer to the `print_to` method documentation.
1353+
:param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this
1354+
method and still call `super()` without encountering unexpected keyword argument errors.
13591355
"""
1360-
final_msg = Text()
1356+
console = Cmd2ExceptionConsole(sys.stderr)
13611357

1358+
# Only print a traceback if we're in debug mode and one exists.
13621359
if self.debug and sys.exc_info() != (None, None, None):
1363-
console = Cmd2GeneralConsole(sys.stderr)
1364-
console.print_exception(word_wrap=True, max_frames=0)
1365-
else:
1366-
final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}"
1360+
console.print_exception(
1361+
width=console.width,
1362+
show_locals=True,
1363+
max_frames=0, # 0 means full traceback.
1364+
word_wrap=True, # Wrap long lines of code instead of truncate
1365+
)
1366+
console.print()
1367+
return
13671368

1368-
if not self.debug and 'debug' in self.settables:
1369-
warning = "\nTo enable full traceback, run the following command: 'set debug true'"
1370-
final_msg.append(warning, style=Cmd2Style.WARNING)
1369+
# Otherwise highlight and print the exception.
1370+
from rich.highlighter import ReprHighlighter
13711371

1372-
if final_msg:
1373-
self.perror(
1374-
final_msg,
1375-
end=end,
1376-
rich_print_kwargs=rich_print_kwargs,
1372+
highlighter = ReprHighlighter()
1373+
1374+
final_msg = Text.assemble(
1375+
("EXCEPTION of type ", Cmd2Style.ERROR),
1376+
(f"{type(exception).__name__}", Cmd2Style.EXCEPTION_TYPE),
1377+
(" occurred with message: ", Cmd2Style.ERROR),
1378+
highlighter(str(exception)),
1379+
)
1380+
1381+
if not self.debug and 'debug' in self.settables:
1382+
help_msg = Text.assemble(
1383+
"\n\n",
1384+
("To enable full traceback, run the following command: ", Cmd2Style.WARNING),
1385+
("set debug true", Cmd2Style.COMMAND_LINE),
13771386
)
1387+
final_msg.append(help_msg)
1388+
1389+
console.print(final_msg)
1390+
console.print()
13781391

13791392
def pfeedback(
13801393
self,
13811394
*objects: Any,
13821395
sep: str = " ",
13831396
end: str = "\n",
13841397
style: StyleType | None = None,
1385-
soft_wrap: bool | None = None,
1398+
soft_wrap: bool = True,
13861399
rich_print_kwargs: RichPrintKwargs | None = None,
13871400
**kwargs: Any, # noqa: ARG002
13881401
) -> None:
@@ -1420,7 +1433,7 @@ def ppaged(
14201433
end: str = "\n",
14211434
style: StyleType | None = None,
14221435
chop: bool = False,
1423-
soft_wrap: bool | None = None,
1436+
soft_wrap: bool = True,
14241437
rich_print_kwargs: RichPrintKwargs | None = None,
14251438
**kwargs: Any, # noqa: ARG002
14261439
) -> None:
@@ -1436,13 +1449,11 @@ def ppaged(
14361449
False -> causes lines longer than the screen width to wrap to the next line
14371450
- wrapping is ideal when you want to keep users from having to use horizontal scrolling
14381451
WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1439-
:param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the
1440-
terminal width; instead, any text that doesn't fit will run onto the following line(s),
1441-
similar to the built-in print() function. Set to False to enable automatic word-wrapping.
1442-
If None (the default for this parameter), the output will default to no word-wrapping, as
1443-
configured by the Cmd2GeneralConsole.
1452+
:param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to
1453+
fit the terminal width. Defaults to True.
14441454
1445-
Note: If chop is True and a pager is used, soft_wrap is automatically set to True.
1455+
Note: If chop is True and a pager is used, soft_wrap is automatically set to True to
1456+
prevent wrapping and allow for horizontal scrolling.
14461457
14471458
For details on the other parameters, refer to the `print_to` method documentation.
14481459
"""
@@ -3527,7 +3538,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
35273538
alias_create_notes = Group(
35283539
"If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.",
35293540
"\n",
3530-
Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.EXAMPLE),
3541+
Text(" alias create save_results print_results \">\" out.txt\n", style=Cmd2Style.COMMAND_LINE),
35313542
(
35323543
"Since aliases are resolved during parsing, tab completion will function as it would "
35333544
"for the actual command the alias resolves to."
@@ -3740,14 +3751,14 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
37403751
"\n",
37413752
"The following creates a macro called my_macro that expects two arguments:",
37423753
"\n",
3743-
Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.EXAMPLE),
3754+
Text(" macro create my_macro make_dinner --meat {1} --veggie {2}", style=Cmd2Style.COMMAND_LINE),
37443755
"\n",
37453756
"When the macro is called, the provided arguments are resolved and the assembled command is run. For example:",
37463757
"\n",
37473758
Text.assemble(
3748-
(" my_macro beef broccoli", Cmd2Style.EXAMPLE),
3759+
(" my_macro beef broccoli", Cmd2Style.COMMAND_LINE),
37493760
(" ───> ", Style(bold=True)),
3750-
("make_dinner --meat beef --veggie broccoli", Cmd2Style.EXAMPLE),
3761+
("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE),
37513762
),
37523763
)
37533764
macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description)
@@ -3763,15 +3774,15 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
37633774
"first argument will populate both {1} instances."
37643775
),
37653776
"\n",
3766-
Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.EXAMPLE),
3777+
Text(" macro create ft file_taxes -p {1} -q {2} -r {1}", style=Cmd2Style.COMMAND_LINE),
37673778
"\n",
37683779
"To quote an argument in the resolved command, quote it during creation.",
37693780
"\n",
3770-
Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.EXAMPLE),
3781+
Text(" macro create backup !cp \"{1}\" \"{1}.orig\"", style=Cmd2Style.COMMAND_LINE),
37713782
"\n",
37723783
"If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.",
37733784
"\n",
3774-
Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.EXAMPLE),
3785+
Text(" macro create show_results print_results -type {1} \"|\" less", style=Cmd2Style.COMMAND_LINE),
37753786
"\n",
37763787
(
37773788
"Since macros don't resolve until after you press Enter, their arguments tab complete as paths. "
@@ -5316,7 +5327,7 @@ def _build_edit_parser(cls) -> Cmd2ArgumentParser:
53165327
"Note",
53175328
Text.assemble(
53185329
"To set a new editor, run: ",
5319-
("set editor <program>", Cmd2Style.EXAMPLE),
5330+
("set editor <program>", Cmd2Style.COMMAND_LINE),
53205331
),
53215332
)
53225333

cmd2/rich_utils.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __repr__(self) -> str:
4545

4646

4747
def _create_default_theme() -> Theme:
48-
"""Create a default theme for cmd2-based applications.
48+
"""Create a default theme for the application.
4949
5050
This theme combines the default styles from cmd2, rich-argparse, and Rich.
5151
"""
@@ -79,8 +79,7 @@ def set_theme(styles: Mapping[str, StyleType] | None = None) -> None:
7979
RichHelpFormatter.styles[name] = APP_THEME.styles[name]
8080

8181

82-
# The main theme for cmd2-based applications.
83-
# You can change it with set_theme().
82+
# The application-wide theme. You can change it with set_theme().
8483
APP_THEME = _create_default_theme()
8584

8685

@@ -107,12 +106,22 @@ class RichPrintKwargs(TypedDict, total=False):
107106

108107

109108
class Cmd2BaseConsole(Console):
110-
"""A base class for Rich consoles in cmd2-based applications."""
109+
"""Base class for all cmd2 Rich consoles.
111110
112-
def __init__(self, file: IO[str] | None = None, **kwargs: Any) -> None:
111+
This class handles the core logic for managing Rich behavior based on
112+
cmd2's global settings, such as `ALLOW_STYLE` and `APP_THEME`.
113+
"""
114+
115+
def __init__(
116+
self,
117+
file: IO[str] | None = None,
118+
**kwargs: Any,
119+
) -> None:
113120
"""Cmd2BaseConsole initializer.
114121
115-
:param file: optional file object where the console should write to. Defaults to sys.stdout.
122+
:param file: optional file object where the console should write to.
123+
Defaults to sys.stdout.
124+
:param kwargs: keyword arguments passed to the parent Console class.
116125
"""
117126
# Don't allow force_terminal or force_interactive to be passed in, as their
118127
# behavior is controlled by the ALLOW_STYLE setting.
@@ -160,12 +169,13 @@ def on_broken_pipe(self) -> None:
160169

161170

162171
class Cmd2GeneralConsole(Cmd2BaseConsole):
163-
"""Rich console for general-purpose printing in cmd2-based applications."""
172+
"""Rich console for general-purpose printing."""
164173

165174
def __init__(self, file: IO[str] | None = None) -> None:
166175
"""Cmd2GeneralConsole initializer.
167176
168-
:param file: optional file object where the console should write to. Defaults to sys.stdout.
177+
:param file: optional file object where the console should write to.
178+
Defaults to sys.stdout.
169179
"""
170180
# This console is configured for general-purpose printing. It enables soft wrap
171181
# and disables Rich's automatic processing for markup, emoji, and highlighting.
@@ -180,13 +190,20 @@ def __init__(self, file: IO[str] | None = None) -> None:
180190

181191

182192
class Cmd2RichArgparseConsole(Cmd2BaseConsole):
183-
"""Rich console for rich-argparse output in cmd2-based applications.
193+
"""Rich console for rich-argparse output.
184194
185195
This class ensures long lines in help text are not truncated by avoiding soft_wrap,
186196
which conflicts with rich-argparse's explicit no_wrap and overflow settings.
187197
"""
188198

189199

200+
class Cmd2ExceptionConsole(Cmd2BaseConsole):
201+
"""Rich console for printing exceptions.
202+
203+
Ensures that long exception messages word wrap for readability by keeping soft_wrap disabled.
204+
"""
205+
206+
190207
def console_width() -> int:
191208
"""Return the width of the console."""
192209
return Cmd2BaseConsole().width

cmd2/styles.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ class Cmd2Style(StrEnum):
3030
added here must have a corresponding style definition there.
3131
"""
3232

33+
COMMAND_LINE = "cmd2.example" # Command line examples in help text
3334
ERROR = "cmd2.error" # Error text (used by perror())
34-
EXAMPLE = "cmd2.example" # Command line examples in help text
35+
EXCEPTION_TYPE = "cmd2.exception.type" # Used by pexcept to mark an exception type
3536
HELP_HEADER = "cmd2.help.header" # Help table header text
3637
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
3738
SUCCESS = "cmd2.success" # Success text (used by psuccess())
@@ -41,8 +42,9 @@ class Cmd2Style(StrEnum):
4142

4243
# Default styles used by cmd2. Tightly coupled with the Cmd2Style enum.
4344
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
45+
Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True),
4446
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
45-
Cmd2Style.EXAMPLE: Style(color=Color.CYAN, bold=True),
47+
Cmd2Style.EXCEPTION_TYPE: Style(color=Color.DARK_ORANGE, bold=True),
4648
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True),
4749
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True),
4850
Cmd2Style.SUCCESS: Style(color=Color.GREEN),

tests/test_cmd2.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -906,29 +906,14 @@ def test_base_timing(base_app) -> None:
906906
assert err[0].startswith('Elapsed: 0:00:00.0')
907907

908908

909-
def _expected_no_editor_error():
910-
expected_exception = 'OSError'
911-
# If PyPy, expect a different exception than with Python 3
912-
if hasattr(sys, "pypy_translation_info"):
913-
expected_exception = 'EnvironmentError'
914-
915-
return normalize(
916-
f"""
917-
EXCEPTION of type '{expected_exception}' occurred with message: Please use 'set editor' to specify your text editing program of choice.
918-
To enable full traceback, run the following command: 'set debug true'
919-
"""
920-
)
921-
922-
923909
def test_base_debug(base_app) -> None:
924910
# Purposely set the editor to None
925911
base_app.editor = None
926912

927913
# Make sure we get an exception, but cmd2 handles it
928914
out, err = run_cmd(base_app, 'edit')
929-
930-
expected = _expected_no_editor_error()
931-
assert err == expected
915+
assert "EXCEPTION of type" in err[0]
916+
assert "Please use 'set editor'" in err[0]
932917

933918
# Set debug true
934919
out, err = run_cmd(base_app, 'set debug True')
@@ -2589,7 +2574,9 @@ def test_pexcept_style(base_app, capsys) -> None:
25892574

25902575
base_app.pexcept(msg)
25912576
out, err = capsys.readouterr()
2592-
assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing")
2577+
expected = su.stylize("EXCEPTION of type ", style=Cmd2Style.ERROR)
2578+
expected += su.stylize("Exception", style=Cmd2Style.EXCEPTION_TYPE)
2579+
assert err.startswith(expected)
25932580

25942581

25952582
@with_ansi_style(ru.AllowStyle.NEVER)
@@ -2598,17 +2585,17 @@ def test_pexcept_no_style(base_app, capsys) -> None:
25982585

25992586
base_app.pexcept(msg)
26002587
out, err = capsys.readouterr()
2601-
assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...")
2588+
assert err.startswith("EXCEPTION of type Exception occurred with message: testing...")
26022589

26032590

2604-
@with_ansi_style(ru.AllowStyle.ALWAYS)
2591+
@with_ansi_style(ru.AllowStyle.NEVER)
26052592
def test_pexcept_not_exception(base_app, capsys) -> None:
26062593
# Pass in a msg that is not an Exception object
26072594
msg = False
26082595

26092596
base_app.pexcept(msg)
26102597
out, err = capsys.readouterr()
2611-
assert err.startswith("\x1b[91mEXCEPTION of type 'bool' occurred with message: False")
2598+
assert err.startswith("EXCEPTION of type bool occurred with message: False")
26122599

26132600

26142601
@pytest.mark.parametrize('chop', [True, False])

0 commit comments

Comments
 (0)