Skip to content
Open
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
70 changes: 70 additions & 0 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import sys

import pytest
Expand Down Expand Up @@ -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 `<type>` 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"<int>|<str>", 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}"
6 changes: 6 additions & 0 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
)


Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
27 changes: 27 additions & 0 deletions typer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
86 changes: 85 additions & 1 deletion typer/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,71 @@ 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,
params: list[TyperOption] | list[TyperArgument],
ctx: _click.Context,
markup_mode: MarkupModeStrict,
console: Console,
align_panel_columns: bool = False,
) -> None:
options_rows: list[list[RenderableType]] = []
required_rows: list[str | Text] = []
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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):
Expand Down
Loading