Skip to content
Closed
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
228 changes: 228 additions & 0 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import sys
from typing import List

import pytest
import typer
import typer.completion
from typer.testing import CliRunner
from typing_extensions import Annotated

runner = CliRunner()

Expand Down Expand Up @@ -99,3 +102,228 @@ def main(bar: str):
result = runner.invoke(app, ["--help"])
assert "Usage" in result.stdout
assert "BAR" in result.stdout


# Rich help output formatting tests
# Tests for correct display of 'metavar' in rich help output,
# and includes regression tests for non-rich output and arguments without 'metavar'.


# App with custom metavar for argument
app_with_custom_metavar = typer.Typer()


@app_with_custom_metavar.command()
def greet_with_custom_metavar(
user: Annotated[str, typer.Argument(metavar="MY_ARG", help="The user to greet.")],
):
"""
A simple command with an argument using a custom metavar.
Tests rich help output with explicit argument naming.
"""
print(f"Hello {user}")


# App with default argument naming (no custom metavar)
app_with_default_argument = typer.Typer()


@app_with_default_argument.command()
def greet_with_default_argument(
user: Annotated[str, typer.Argument(help="The user to greet.")],
):
"""
A simple command with an argument using default naming.
Tests rich help output with parameter-based naming.
"""
print(f"Hello {user}")


class TestArgumentMetavarDisplay:
"""
Tests argument metavar display in help output.

This suite ensures that arguments with custom metavar display
the metavar as the "Name" and the parameter's type as the "Type".
It includes tests for rich output and standard Click output,
covering both custom metavar and default argument naming cases.
"""

# A single runner instance can be shared
runner = CliRunner()

@staticmethod
def _normalize_output(output: str) -> List[str]:
"""
Helper function to normalize stdout for reliable assertions.
It splits output into lines, collapses all internal whitespace to a
single space, and strips leading/trailing whitespace from each line.
"""
lines = output.split("\n")
return [" ".join(line.split()).strip() for line in lines]

def test_rich_output_with_custom_metavar(self):
"""
Tests rich help output with custom metavar.

- With `rich` installed, invokes help on an app with custom metavar.
- Asserts the "Arguments" panel row correctly shows
the custom metavar as the name and `TEXT` as the type.
- Asserts the top-level `Usage:` string also uses the custom metavar.
"""
# This test requires 'rich' to be installed in the test environment.
pytest.importorskip("rich")

# Act
result = self.runner.invoke(
app_with_custom_metavar, ["--help"], prog_name="example.py"
)

# Assert
assert result.exit_code == 0
output = result.stdout
normalized_lines = self._normalize_output(output)

# Check Usage string
assert "Usage: example.py [OPTIONS] MY_ARG" in output

# Check "Arguments" panel
# We look for the key elements of the rich table row.
# Expected row: │ * MY_ARG TEXT [The user to greet.] [required] │
# We check for a normalized line containing these parts.
expected_fragment = "MY_ARG TEXT"
help_fragment = "The user to greet."
required_fragment = "[required]"

found = False
for line in normalized_lines:
if (
expected_fragment in line
and help_fragment in line
and required_fragment in line
):
found = True
break

assert found, (
f"Could not find correct rich 'Arguments' row.\n"
f"Expected fragments: '{expected_fragment}', "
f"'{help_fragment}', '{required_fragment}'\n"
f"Got output:\n{output}"
)

def test_rich_output_with_default_argument_naming(self):
"""
Tests rich help output with default argument naming.

- With `rich` installed, invokes help on an app without custom metavar.
- Asserts the "Arguments" panel falls back to the parameter
name as the name and `TEXT` as the type.
- Asserts the `Usage:` string also uses the parameter name.
"""
pytest.importorskip("rich")

# Act
result = self.runner.invoke(
app_with_default_argument, ["--help"], prog_name="example.py"
)

# Assert
assert result.exit_code == 0
output = result.stdout
normalized_lines = self._normalize_output(output)

# Check Usage string
assert "Usage: example.py [OPTIONS] USER" in output

# Check Arguments panel
# Expected row: │ * user TEXT [The user to greet.] [required] │
expected_fragment = "user TEXT"
help_fragment = "The user to greet."
required_fragment = "[required]"

found = False
for line in normalized_lines:
if (
expected_fragment in line
and help_fragment in line
and required_fragment in line
):
found = True
break

assert found, (
f"Could not find correct rich 'Arguments' fallback row.\n"
f"Expected fragments: '{expected_fragment}', "
f"'{help_fragment}', '{required_fragment}'\n"
f"Got output:\n{output}"
)

def test_standard_output_with_custom_metavar(self, monkeypatch):
"""
Tests standard (non-rich) help output with custom metavar.

- Simulates `rich` being unavailable by patching `typer.core.HAS_RICH`.
- Invokes help on an app with custom metavar.
- Asserts the standard Click help output is generated correctly,
using the custom metavar in both the `Usage:` string and the `Arguments:` section.
"""
# Arrange
# Simulate 'rich' being unavailable.
# This will cause typer's internal checks to fail and
# fall back to the standard Click help formatter.
import typer.core

monkeypatch.setattr(typer.core, "HAS_RICH", False)

# Act
result = self.runner.invoke(
app_with_custom_metavar, ["--help"], prog_name="example.py"
)

# Assert
assert result.exit_code == 0
output = result.stdout
normalized_lines = self._normalize_output(output)

# Ensure no rich table formatting is present
assert "│" not in output, "Found rich table characters in non-rich mode"
assert "━" not in output, "Found rich table characters in non-rich mode"

# Check Usage string
assert "Usage: example.py [OPTIONS] MY_ARG" in output

# Check standard Click "Arguments" section
# Expected:
# Arguments:
# MY_ARG The user to greet. [required]
expected_fragment = "MY_ARG"
help_fragment = "The user to greet."
required_fragment = "[required]"

found = False
in_arguments_section = False
for line in normalized_lines:
if line.startswith("Arguments:"):
in_arguments_section = True
continue

if in_arguments_section:
# Check for the argument line
if (
expected_fragment in line
and help_fragment in line
and required_fragment in line
):
found = True
break
# Stop if we hit another section
if line.startswith("Options:"):
break

assert found, (
f"Could not find standard 'Arguments' line.\n"
f"Expected fragments: '{expected_fragment}', "
f"'{help_fragment}', '{required_fragment}'\n"
f"Got output:\n{output}"
)
27 changes: 21 additions & 6 deletions typer/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,22 @@ def _print_options_panel(
opt_short_strs = []
secondary_opt_long_strs = []
secondary_opt_short_strs = []
for opt_str in param.opts:
if "--" in opt_str:
opt_long_strs.append(opt_str)

# For Arguments, use the metavar as the display name
if isinstance(param, click.Argument):
if param.metavar is not None:
# Custom metavar
opt_long_strs = [param.metavar]
else:
opt_short_strs.append(opt_str)
# Default metavar (param name)
opt_long_strs = [param.name if param.name else ""]
else:
for opt_str in param.opts:
if "--" in opt_str:
opt_long_strs.append(opt_str)
else:
opt_short_strs.append(opt_str)

for opt_str in param.secondary_opts:
if "--" in opt_str:
secondary_opt_long_strs.append(opt_str)
Expand All @@ -382,8 +393,12 @@ def _print_options_panel(
# Click < 8.2
metavar_str = param.make_metavar() # type: ignore[call-arg]

# Do it ourselves if this is a positional argument
if (
# For Arguments with a custom metavar, show the type in the metavar column
if isinstance(param, click.Argument) and param.metavar is not None:
# Show the type in the metavar column
metavar_str = param.type.name.upper()
# Do it ourselves if this is a positional argument without custom metavar
elif (
isinstance(param, click.Argument)
and param.name
and metavar_str == param.name.upper()
Expand Down
Loading