diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index bd6e58799f..0127aa13f5 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,3 +1,4 @@ +import re import sys import pytest @@ -298,3 +299,72 @@ def run(name: str) -> None: usage_line = result.output.splitlines()[0] assert usage_line.startswith("Usage: typer [path_or_module] run [OPTIONS] {name}") assert "Try 'typer [path_or_module] run --help' for help." in result.output + + +def _align_panels_app(align: bool, required: bool = False): + app = typer.Typer(align_panel_columns=align, add_completion=False) + + def _required_opt(): + if required: + return typer.Option(..., help="required", rich_help_panel="Selection") + return typer.Option( + None, "--optional-opt", help="optional", rich_help_panel="Selection" + ) + + @app.command() + def run( + verbose: int = typer.Option( + 0, "-v", count=True, help="verbosity", rich_help_panel="Logging" + ), + log_path: str = typer.Option( + None, "--log-path", help="log file", rich_help_panel="Logging" + ), + years: str = typer.Option( + None, "--years", "-Y", help="year range", rich_help_panel="Selection" + ), + limit: int = typer.Option( + 5, "--limit", min=1, max=10, help="limit", rich_help_panel="Selection" + ), + opt: str = _required_opt(), + human: bool = typer.Option( + False, "--human", "-H", help="human", rich_help_panel="Output" + ), + ) -> None: + pass # pragma: no cover + + return app + + +def _option_type_columns(output: str) -> list[int]: + """Column index of the `` metavar in every option row, across panels. + + Box-drawing detection is style-agnostic: Windows CI renders Rich panels with + ASCII borders (``|``) instead of the rounded Unicode borders (``│``), so rows + are matched by stripping any leading border char + spaces, not by border style. + """ + columns: list[int] = [] + for line in output.splitlines(): + if line.lstrip(" │|").startswith("-"): + match = re.search(r"|", line) + if match: + columns.append(match.start()) + return columns + + +@pytest.mark.parametrize("required", [False, True]) +def test_align_panel_columns_true_aligns_columns(required: bool) -> None: + app = _align_panels_app(True, required) + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + columns = _option_type_columns(result.output) + assert columns, "no option type columns found" + assert len(set(columns)) == 1, f"type columns not aligned: {columns}" + + +def test_align_panel_columns_false_is_default_unaligned() -> None: + app = _align_panels_app(False) + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + columns = _option_type_columns(result.output) + assert columns, "no option type columns found" + assert len(set(columns)) > 1, f"expected unaligned type columns: {columns}" diff --git a/typer/core.py b/typer/core.py index 97f96d89ab..83e4cf7c81 100644 --- a/typer/core.py +++ b/typer/core.py @@ -919,6 +919,7 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, + align_panel_columns: bool = False, ) -> None: super().__init__( name=name, @@ -936,6 +937,7 @@ def __init__( ) self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel + self.align_panel_columns: bool = align_panel_columns def format_options( self, ctx: _click.Context, formatter: _click.HelpFormatter @@ -985,6 +987,7 @@ def format_help(self, ctx: _click.Context, formatter: _click.HelpFormatter) -> N obj=self, ctx=ctx, markup_mode=self.rich_markup_mode, + align_panel_columns=self.align_panel_columns, ) @@ -1003,6 +1006,7 @@ def __init__( rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, suggest_commands: bool = True, + align_panel_columns: bool = False, # Click settings invoke_without_command: bool = False, no_args_is_help: bool = False, @@ -1014,6 +1018,7 @@ def __init__( self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel self.suggest_commands = suggest_commands + self.align_panel_columns: bool = align_panel_columns # copied from Click's init if commands is None: @@ -1214,6 +1219,7 @@ def format_help(self, ctx: _click.Context, formatter: _click.HelpFormatter) -> N obj=self, ctx=ctx, markup_mode=self.rich_markup_mode, + align_panel_columns=self.align_panel_columns, ) def list_commands(self, ctx: _click.Context) -> list[str]: diff --git a/typer/main.py b/typer/main.py index 700648da66..3bab4979a2 100644 --- a/typer/main.py +++ b/typer/main.py @@ -427,6 +427,25 @@ def callback(): """ ), ] = DEFAULT_MARKUP_MODE, + align_panel_columns: Annotated[ + bool, + Doc( + """ + Align option columns across all Rich help panels by giving every + panel the same fixed column widths. When `False` (the default), + each panel sizes its columns independently, matching prior + behaviour. See [the tutorial on help formatting](https://typer.tiangolo.com/tutorial/commands/help/). + + **Example** + + ```python + import typer + + app = typer.Typer(align_panel_columns=True) + ``` + """ + ), + ] = False, rich_help_panel: Annotated[ str | None, Doc( @@ -519,6 +538,7 @@ def callback(): ): self._add_completion = add_completion self.rich_markup_mode: MarkupMode = rich_markup_mode + self.align_panel_columns: bool = align_panel_columns self.rich_help_panel = rich_help_panel self.suggest_commands = suggest_commands self.pretty_exceptions_enable = pretty_exceptions_enable @@ -1166,6 +1186,7 @@ def get_group(typer_instance: Typer) -> TyperGroup: pretty_exceptions_short=typer_instance.pretty_exceptions_short, rich_markup_mode=typer_instance.rich_markup_mode, suggest_commands=typer_instance.suggest_commands, + align_panel_columns=typer_instance.align_panel_columns, ) return group @@ -1198,6 +1219,7 @@ def get_command(typer_instance: Typer) -> _click.Command: single_command, pretty_exceptions_short=typer_instance.pretty_exceptions_short, rich_markup_mode=typer_instance.rich_markup_mode, + align_panel_columns=typer_instance.align_panel_columns, ) if typer_instance._add_completion: click_command.params.append(click_install_param) @@ -1286,6 +1308,7 @@ def get_group_from_info( pretty_exceptions_short: bool, suggest_commands: bool, rich_markup_mode: MarkupMode, + align_panel_columns: bool = False, ) -> TyperGroup: assert group_info.typer_instance, ( "A Typer instance is needed to generate a Click Group" @@ -1296,6 +1319,7 @@ def get_group_from_info( command_info=command_info, pretty_exceptions_short=pretty_exceptions_short, rich_markup_mode=rich_markup_mode, + align_panel_columns=align_panel_columns, ) if command.name: commands[command.name] = command @@ -1305,6 +1329,7 @@ def get_group_from_info( pretty_exceptions_short=pretty_exceptions_short, rich_markup_mode=rich_markup_mode, suggest_commands=suggest_commands, + align_panel_columns=align_panel_columns, ) if sub_group.name: commands[sub_group.name] = sub_group @@ -1394,6 +1419,7 @@ def get_command_from_info( *, pretty_exceptions_short: bool, rich_markup_mode: MarkupMode, + align_panel_columns: bool = False, ) -> _click.Command: assert command_info.callback, "A command must have a callback function" name = command_info.name or get_command_name(command_info.callback.__name__) # ty: ignore @@ -1430,6 +1456,7 @@ def get_command_from_info( rich_markup_mode=rich_markup_mode, # Rich settings rich_help_panel=command_info.rich_help_panel, + align_panel_columns=align_panel_columns, ) return command diff --git a/typer/rich_utils.py b/typer/rich_utils.py index d974c5a0a8..30dd1a7030 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -352,6 +352,63 @@ def _make_command_help( ) +def _option_columns( + param: TyperOption, ctx: _click.Context +) -> tuple[str, str, str, str, str]: + """Return the rendered option columns (long, short, secondary, type).""" + long_strs = ",".join(opt for opt in param.opts if "--" in opt) + short_strs = ",".join(opt for opt in param.opts if "--" not in opt) + sec_long_strs = ",".join(opt for opt in param.secondary_opts if "--" in opt) + sec_short_strs = ",".join(opt for opt in param.secondary_opts if "--" not in opt) + + metavar = param.make_metavar(ctx=ctx) + type_text = "" + if metavar and "bool" not in metavar.lower(): + type_text = metavar + if isinstance(param.type, types._NumberRangeBase) and not ( + param.count and param.type.min == 0 and param.type.max is None + ): + range_str = param.type._describe_range() + if range_str: + type_text += RANGE_STRING.format(range_str) + return long_strs, short_strs, sec_long_strs, sec_short_strs, type_text + + +def _get_align_option_panel_widths( + ctx: _click.Context, +) -> tuple[bool, int, int, int, int, int]: + """Compute fixed column widths shared across option panels. + + Widths are derived from every visible option in the command so each panel + renders with the same column geometry and rows line up across panels. + Returns ``(has_required, long, short, secondary_long, secondary_short, + metavar)``. + """ + options = [ + param + for param in ctx.command.get_params(ctx) + if isinstance(param, TyperOption) and not getattr(param, "hidden", False) + ] + long_w = short_w = sec_long_w = sec_short_w = metavar_w = 0 + for param in options: + long_strs, short_strs, sec_long_strs, sec_short_strs, type_text = ( + _option_columns(param, ctx) + ) + long_w = max(long_w, len(long_strs)) + short_w = max(short_w, len(short_strs)) + sec_long_w = max(sec_long_w, len(sec_long_strs)) + sec_short_w = max(sec_short_w, len(sec_short_strs)) + metavar_w = max(metavar_w, len(type_text)) + return ( + any(param.required for param in options), + long_w, + short_w, + sec_long_w, + sec_short_w, + metavar_w, + ) + + def _print_options_panel( *, name: str, @@ -359,6 +416,7 @@ def _print_options_panel( ctx: _click.Context, markup_mode: MarkupModeStrict, console: Console, + align_panel_columns: bool = False, ) -> None: options_rows: list[list[RenderableType]] = [] required_rows: list[str | Text] = [] @@ -464,7 +522,28 @@ def _print_options_panel( box=box_style, **t_styles, ) - for row in rows_with_required: + if align_panel_columns: + has_required, long_w, short_w, sec_long_w, sec_short_w, metavar_w = ( + _get_align_option_panel_widths(ctx) + ) + if has_required: + options_table.add_column(width=1, no_wrap=True) + options_table.add_column(width=long_w, no_wrap=True) + options_table.add_column(width=short_w, no_wrap=True) + options_table.add_column(width=sec_long_w, no_wrap=True) + options_table.add_column(width=sec_short_w, no_wrap=True) + options_table.add_column(width=metavar_w, no_wrap=True) + options_table.add_column(justify="left", no_wrap=False, ratio=10) + if has_required: + rows_to_print = [ + [required if required else "", *row] + for required, row in zip(required_rows, options_rows, strict=True) + ] + else: + rows_to_print = options_rows + else: + rows_to_print = rows_with_required + for row in rows_to_print: options_table.add_row(*row) console.print( Panel( @@ -557,6 +636,7 @@ def rich_format_help( obj: _click.Command | TyperGroup, ctx: _click.Context, markup_mode: MarkupModeStrict, + align_panel_columns: bool = False, ) -> None: """Print nicely formatted help text using rich. @@ -611,6 +691,7 @@ def rich_format_help( ctx=ctx, markup_mode=markup_mode, console=console, + align_panel_columns=align_panel_columns, ) for panel_name, arguments in panel_to_arguments.items(): if panel_name == ARGUMENTS_PANEL_TITLE: @@ -622,6 +703,7 @@ def rich_format_help( ctx=ctx, markup_mode=markup_mode, console=console, + align_panel_columns=align_panel_columns, ) default_options = panel_to_options.get(OPTIONS_PANEL_TITLE, []) _print_options_panel( @@ -630,6 +712,7 @@ def rich_format_help( ctx=ctx, markup_mode=markup_mode, console=console, + align_panel_columns=align_panel_columns, ) for panel_name, options in panel_to_options.items(): if panel_name == OPTIONS_PANEL_TITLE: @@ -641,6 +724,7 @@ def rich_format_help( ctx=ctx, markup_mode=markup_mode, console=console, + align_panel_columns=align_panel_columns, ) if isinstance(obj, TyperGroup):