Skip to content

Commit 7cec731

Browse files
authored
Merge pull request #229 from dwreeves/main
Handful of small updates
2 parents 87aa8e8 + 3e32b49 commit 7cec731

8 files changed

+123
-22
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Version 1.8.8 (2025-03-09)
4+
5+
- Make text wrap instead of using ellipses for overflowing metavars in options tables.
6+
- Added `--errors-in-output-format` flag to `rich-click` CLI.
7+
- Actually fixed regression in stderr handling [[#164](https://github.com/ewels/rich-click/issues/164)].
8+
39
## Version 1.8.7 (2025-03-08)
410

511
- Add ability to turn off option/command deduplication in groups [[#226](https://github.com/ewels/rich-click/issues/226)]

src/rich_click/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
customization required.
77
"""
88

9-
__version__ = "1.8.7"
9+
__version__ = "1.8.8"
1010

1111
# Import the entire click API here.
1212
# We need to manually import these instead of `from click import *` to force

src/rich_click/cli.py

+10
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ def _get_module_path_and_function_name(script: str, suppress_warnings: bool) ->
158158
type=click.Choice(["html", "svg"], case_sensitive=False),
159159
help="Optionally render help text as HTML or SVG. By default, help text is rendered normally.",
160160
)
161+
@click.option(
162+
"--errors-in-output-format",
163+
is_flag=True,
164+
help="If set, forces the CLI to render CLI error messages"
165+
" in the format specified by the --output option."
166+
" By default, error messages render normally, i.e. they are not converted to html or svg.",
167+
)
161168
@click.option(
162169
"--suppress-warnings/--do-not-suppress-warnings",
163170
is_flag=True,
@@ -188,6 +195,7 @@ def main(
188195
ctx: RichContext,
189196
script_and_args: List[str],
190197
output: Literal[None, "html", "svg"],
198+
errors_in_output_format: bool,
191199
suppress_warnings: bool,
192200
rich_config: Optional[RichHelpConfiguration],
193201
show_help: bool,
@@ -241,6 +249,8 @@ def main(
241249

242250
if output is not None:
243251
RichContext.export_console_as = output
252+
if errors_in_output_format:
253+
RichContext.errors_in_output_format = True
244254

245255
prog = module_path.split(".", 1)[0]
246256
sys.argv = [prog, *args]

src/rich_click/rich_command.py

+22-11
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ def _generate_rich_help_config(self) -> RichHelpConfiguration:
9898
click.echo(f"{e.__class__.__name__}{e.args}", file=sys.stderr)
9999
return RichHelpConfiguration()
100100

101+
def _error_formatter(self, ctx: Optional[RichContext]) -> RichHelpFormatter:
102+
if ctx is not None:
103+
formatter = ctx.make_formatter(error=True)
104+
else:
105+
config = self._generate_rich_help_config()
106+
if self.context_class.errors_in_output_format and self.context_class.export_console_as is not None:
107+
formatter = self.context_class.formatter_class(
108+
console=self.console, config=config, file=open(os.devnull, "w")
109+
)
110+
formatter.console.record = True
111+
else:
112+
formatter = self.context_class.formatter_class(console=self.console, config=config, file=sys.stderr)
113+
return formatter
114+
101115
def main(
102116
self,
103117
args: Optional[Sequence[str]] = None,
@@ -166,14 +180,15 @@ def main(
166180
except click.exceptions.ClickException as e:
167181
if not standalone_mode:
168182
raise
169-
if ctx is not None:
170-
formatter = ctx.make_formatter()
171-
else:
172-
config = self._generate_rich_help_config()
173-
formatter = self.context_class.formatter_class(console=self.console, config=config, file=sys.stderr)
183+
formatter = self._error_formatter(ctx)
174184
from rich_click.rich_help_rendering import rich_format_error
175185

176-
rich_format_error(e, formatter)
186+
if self.context_class.errors_in_output_format:
187+
export_console_as = self.context_class.export_console_as
188+
else:
189+
export_console_as = None
190+
191+
rich_format_error(e, formatter, export_console_as=export_console_as)
177192
sys.exit(e.exit_code)
178193
except OSError as e:
179194
if e.errno == errno.EPIPE:
@@ -191,11 +206,7 @@ def main(
191206
if not standalone_mode:
192207
raise
193208
try:
194-
if ctx is not None:
195-
formatter = ctx.make_formatter()
196-
else:
197-
config = self._generate_rich_help_config()
198-
formatter = self.context_class.formatter_class(console=self.console, config=config, file=sys.stderr)
209+
formatter = self._error_formatter(ctx)
199210
except Exception:
200211
click.echo("Aborted!", file=sys.stderr)
201212
else:

src/rich_click/rich_context.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import sys
23
from typing import TYPE_CHECKING, Any, Mapping, Optional, Type, Union
34

45
import click
@@ -20,6 +21,7 @@ class RichContext(click.Context):
2021
formatter_class: Type[RichHelpFormatter] = RichHelpFormatter
2122
console: Optional["Console"] = None
2223
export_console_as: Literal[None, "html", "svg"] = None
24+
errors_in_output_format: bool = False
2325

2426
def __init__(
2527
self,
@@ -64,14 +66,18 @@ def __init__(
6466
else:
6567
self.help_config = rich_help_config
6668

67-
def make_formatter(self) -> RichHelpFormatter:
69+
def make_formatter(self, error: bool = False) -> RichHelpFormatter:
6870
"""Create the Rich Help Formatter."""
6971
formatter = self.formatter_class(
7072
width=self.terminal_width,
7173
max_width=self.max_content_width,
7274
config=self.help_config,
7375
console=self.console,
74-
file=open(os.devnull, "w") if self.export_console_as is not None else None,
76+
file=(
77+
open(os.devnull, "w")
78+
if error and self.export_console_as is not None and self.errors_in_output_format
79+
else sys.stderr if error else open(os.devnull, "w") if self.export_console_as is not None else None
80+
),
7581
)
7682
if self.export_console_as is not None:
7783
if self.console is None:
@@ -98,4 +104,6 @@ def exit(self, code: int = 0) -> NoReturn:
98104
print(self.console.export_html(inline_styles=True, code_format="{code}"))
99105
elif self.export_console_as == "svg":
100106
print(self.console.export_svg())
107+
# Todo: In 1.9, replace above with the following:
108+
# print(self.console.export_svg(title="rich-click " + " ".join(sys.argv)))
101109
super().exit(code)

src/rich_click/rich_help_formatter.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import IO, TYPE_CHECKING, Any, Optional
33

44
import click
5+
from typing_extensions import Literal
56

67
from rich_click.rich_help_configuration import RichHelpConfiguration
78

@@ -68,6 +69,8 @@ class RichHelpFormatter(click.HelpFormatter):
6869
not be created directly
6970
"""
7071

72+
export_console_as: Literal[None, "html", "svg"] = None
73+
7174
def __init__(
7275
self,
7376
indent_increment: int = 2,
@@ -101,8 +104,8 @@ def __init__(
101104

102105
if console:
103106
self.console = console
104-
# if file:
105-
# self.console.file = file
107+
if file:
108+
self.console.file = file
106109
else:
107110
self.console = create_console(self.config, file=file)
108111

src/rich_click/rich_help_rendering.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import re
3+
import sys
34
from fnmatch import fnmatch
45
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, TypeVar, Union, overload
56

@@ -605,6 +606,8 @@ class MetavarHighlighter(RegexHighlighter):
605606

606607
kw.update(option_group.get("panel_styles", {}))
607608

609+
options_table.columns[0].overflow = "fold"
610+
608611
formatter.write(Panel(options_table, **kw))
609612

610613

@@ -721,7 +724,9 @@ def get_rich_epilog(
721724
)
722725

723726

724-
def rich_format_error(self: click.ClickException, formatter: RichHelpFormatter) -> None:
727+
def rich_format_error(
728+
self: click.ClickException, formatter: RichHelpFormatter, export_console_as: Literal[None, "html", "svg"] = None
729+
) -> None:
725730
"""
726731
Print richly formatted click errors.
727732
@@ -732,6 +737,7 @@ def rich_format_error(self: click.ClickException, formatter: RichHelpFormatter)
732737
----
733738
self (click.ClickException): Click exception to format.
734739
formatter: formatter object.
740+
export_console_as: If set, outputs error message as HTML or SVG.
735741
"""
736742
config = formatter.config
737743
# Print usage
@@ -798,6 +804,11 @@ def rich_format_error(self: click.ClickException, formatter: RichHelpFormatter)
798804
)
799805
if config.errors_epilogue:
800806
formatter.write(Padding(config.errors_epilogue, (0, 1, 1, 1)))
807+
if formatter.console.record:
808+
if export_console_as == "html":
809+
print(formatter.console.export_html(inline_styles=True, code_format="{code}"))
810+
elif export_console_as == "svg":
811+
print(formatter.console.export_svg(title="rich-click " + " ".join(sys.argv)))
801812

802813

803814
def rich_abort_error(formatter: RichHelpFormatter) -> None:

tests/test_rich_click_cli.py

+57-5
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@
1818
from tests.conftest import AssertStr
1919

2020

21-
# TODO:
22-
# I need to make it so these tests don't cause side effects
23-
# pytest.skip(allow_module_level=True)
24-
25-
2621
@pytest.fixture(autouse=True)
2722
def default_config(initialize_rich_click: None) -> None:
2823
# Default config settings from https://github.com/Textualize/rich/blob/master/tests/render.py
@@ -373,3 +368,60 @@ def cli():
373368
"""
374369

375370
assert_str(actual=res.stdout.decode(), expectation=expected_output)
371+
372+
373+
@pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="Warning message gets in the way.")
374+
def test_error_to_stderr(mock_script_writer: Callable[[str], Path], assert_str: AssertStr) -> None:
375+
mock_script_writer(
376+
'''
377+
import click
378+
379+
@click.group("foo")
380+
def foo():
381+
"""foo group"""
382+
383+
@foo.command("bar")
384+
def bar():
385+
"""bar command"""
386+
'''
387+
)
388+
389+
res_grp = subprocess.run(
390+
[sys.executable, "-m", "src.rich_click", "mymodule:foo", "--bad-input"],
391+
stdout=subprocess.PIPE,
392+
stderr=subprocess.PIPE,
393+
env={**os.environ, "TERMINAL_WIDTH": "100", "FORCE_COLOR": "False"},
394+
)
395+
assert res_grp.returncode == 2
396+
397+
expected_output_grp = """
398+
Usage: python -m src.rich_click.mymodule [OPTIONS] COMMAND [ARGS]...
399+
400+
Try 'python -m src.rich_click.mymodule --help' for help
401+
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────╮
402+
│ No such option: --bad-input │
403+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
404+
"""
405+
406+
assert_str(actual=res_grp.stdout.decode(), expectation="")
407+
assert_str(actual=res_grp.stderr.decode(), expectation=expected_output_grp)
408+
409+
res_cmd = subprocess.run(
410+
[sys.executable, "-m", "src.rich_click", "mymodule:foo", "bar", "--bad-input"],
411+
stdout=subprocess.PIPE,
412+
stderr=subprocess.PIPE,
413+
env={**os.environ, "TERMINAL_WIDTH": "100", "FORCE_COLOR": "False"},
414+
)
415+
assert res_cmd.returncode == 2
416+
417+
expected_output_grp = """
418+
Usage: python -m src.rich_click.mymodule bar [OPTIONS]
419+
420+
Try 'python -m src.rich_click.mymodule bar --help' for help
421+
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────╮
422+
│ No such option: --bad-input │
423+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
424+
"""
425+
426+
assert_str(actual=res_cmd.stdout.decode(), expectation="")
427+
assert_str(actual=res_cmd.stderr.decode(), expectation=expected_output_grp)

0 commit comments

Comments
 (0)