import math
import textwrap
from collections.abc import Iterable
from operator import attrgetter
from typing import TYPE_CHECKING, Literal, Optional, Union
from attrs import evolve
from cyclopts.utils import frozen
if TYPE_CHECKING:
from rich.box import Box
from rich.console import Console, ConsoleOptions, RenderableType
from rich.padding import PaddingDimensions
from rich.panel import Panel
from rich.style import StyleType
from rich.table import Table
from cyclopts.help import HelpEntry
from cyclopts.help.protocols import Renderer
class NameRenderer:
"""Renderer for parameter/command names with optional text wrapping.
Parameters
----------
max_width : int | None
Maximum width for wrapping. If None, no wrapping is applied.
"""
def __init__(self, max_width: int | None = None):
"""Initialize the renderer with formatting options.
Parameters
----------
max_width : int | None
Maximum width for wrapping. If None, no wrapping is applied.
"""
self.max_width = max_width
def __call__(self, entry: "HelpEntry") -> "RenderableType":
"""Render the names column with optional text wrapping.
Parameters
----------
entry : HelpEntry
The table entry to render.
Returns
-------
~rich.console.RenderableType
Combined names and shorts, optionally wrapped.
Order: positive_names, positive_shorts, negative_names, negative_shorts
"""
text = " ".join(entry.all_options)
if self.max_width is None:
return text
wrapped = textwrap.wrap(
text,
self.max_width,
subsequent_indent=" ",
break_on_hyphens=False,
tabsize=4,
)
return "\n".join(wrapped)
class CommandNameRenderer:
"""Renderer for command names with aliases in parentheses.
Displays commands in argparse-style format: ``primary (alias1, alias2)``.
Parameters
----------
max_width : int | None
Maximum width for wrapping. If None, no wrapping is applied.
"""
def __init__(self, max_width: int | None = None):
"""Initialize the renderer with formatting options.
Parameters
----------
max_width : int | None
Maximum width for wrapping. If None, no wrapping is applied.
"""
self.max_width = max_width
def __call__(self, entry: "HelpEntry") -> "RenderableType":
"""Render command name with aliases in parentheses.
Parameters
----------
entry : HelpEntry
The table entry to render.
Returns
-------
~rich.console.RenderableType
Primary command name with aliases in parentheses.
"""
primary = entry.all_options[0] if entry.all_options else ""
aliases = list(entry.all_options[1:])
if aliases:
text = f"{primary} ({', '.join(aliases)})"
else:
text = primary
if self.max_width is None:
return text
wrapped = textwrap.wrap(
text,
self.max_width,
subsequent_indent=" ",
break_on_hyphens=False,
tabsize=4,
)
return "\n".join(wrapped)
class DescriptionRenderer:
"""Renderer for descriptions with configurable metadata formatting.
Parameters
----------
newline_metadata : bool
If True, display metadata (choices, env vars, defaults) on separate lines.
If False (default), display metadata inline with the description.
"""
def __init__(self, newline_metadata: bool = False):
"""Initialize the renderer with formatting options.
Parameters
----------
newline_metadata : bool
If True, display metadata on separate lines instead of inline.
"""
self.newline_metadata = newline_metadata
def __call__(self, entry: "HelpEntry") -> "RenderableType":
"""Render parameter description with metadata annotations.
Enriches the base description with choices, environment variables,
default values, and required status.
Parameters
----------
entry : HelpEntry
The table entry to render.
Returns
-------
~rich.console.RenderableType
Description with appended metadata.
"""
from rich.text import Text
from cyclopts.help.inline_text import InlineText
description = entry.description
if description is None:
description = InlineText(Text())
elif not isinstance(description, InlineText):
# Convert to InlineText if it isn't already
if hasattr(entry.description, "__rich_console__"):
# It's already a Rich renderable, wrap it
description = InlineText(description)
else:
# Convert to Text first, then wrap in InlineText
from rich.text import Text
description = InlineText(Text(str(description)))
# Collect metadata items
metadata_items = []
if entry.choices:
choices_str = ", ".join(entry.choices)
metadata_items.append(Text(rf"[choices: {choices_str}]", "dim"))
if entry.env_var:
env_vars_str = ", ".join(entry.env_var)
metadata_items.append(Text(rf"[env var: {env_vars_str}]", "dim"))
if entry.default is not None:
metadata_items.append(Text(rf"[default: {entry.default}]", "dim"))
if entry.required:
metadata_items.append(Text(r"[required]", "dim red"))
# Apply metadata based on formatting mode
if self.newline_metadata and metadata_items:
# Add metadata on separate lines with indentation
from rich.console import Group as RichGroup
from rich.text import Text
# Create a list of renderables to group
renderables = []
# Add the original description first
if description.primary_renderable:
renderables.append(description.primary_renderable)
# Add each metadata item without indentation
for item in metadata_items:
renderables.append(item)
# Return a Rich Group that stacks these vertically
return RichGroup(*renderables) if renderables else Text()
else:
# Original inline behavior
for item in metadata_items:
description.append(item)
return description
class AsteriskRenderer:
"""Renderer for required parameter asterisk indicator.
A simple renderer that displays an asterisk (*) for required parameters.
"""
def __call__(self, entry: "HelpEntry") -> "RenderableType":
"""Render an asterisk for required parameters.
Parameters
----------
entry : HelpEntry
The table entry to render.
Returns
-------
~rich.console.RenderableType
An asterisk if the entry is required, empty string otherwise.
"""
return "*" if entry.required else ""
@frozen
class ColumnSpec:
"""Specification for a single column in a help table.
Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to define
how individual columns are rendered in help tables. Each column can have its
own renderer, styling, and layout properties.
See Also
--------
~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs.
~cyclopts.help.specs.TableSpec : Specification for the entire table.
~cyclopts.help.specs.PanelSpec : Specification for the outer panel.
"""
renderer: Union[str, "Renderer"]
"""Specifies how to extract and render cell content from a :class:`~cyclopts.help.HelpEntry`.
Can be either:
- A string: The attribute name to retrieve from :class:`~cyclopts.help.HelpEntry` (e.g., 'names',
'description', 'required', 'type'). The string is displayed as-is.
- A callable: A function matching the :class:`~cyclopts.help.protocols.Renderer` protocol.
The function receives a :class:`~cyclopts.help.HelpEntry` and should return a
:class:`~rich.console.RenderableType` (str, :class:`~rich.text.Text`, or other Rich renderable).
Examples::
# String renderer - get attribute directly
ColumnSpec(renderer="description")
# Callable renderer - custom formatting
def format_names(entry: HelpEntry) -> str:
return ", ".join(entry.names) if entry.names else ""
ColumnSpec(renderer=format_names)
"""
header: str = ""
"""Column header text displayed at the top of the column.
Example::
header="Options" renders:
┌─────────┬─────────────┐
│ Options │ Description │
├─────────┼─────────────┤
│ --help │ Show help │
└─────────┴─────────────┘
"""
footer: str = ""
"""Column footer text displayed at the bottom of the column.
Example::
footer="Required" renders:
┌──────────┬────────────┐
│ --help │ Show help │
├──────────┼────────────┤
│ Required │ │
└──────────┴────────────┘
"""
header_style: Optional["StyleType"] = None
"""Style applied to the column header text.
Corresponds to the ``header_style`` parameter of :meth:`rich.table.Table.add_column`.
"""
footer_style: Optional["StyleType"] = None
"""Style applied to the column footer text.
Corresponds to the ``footer_style`` parameter of :meth:`rich.table.Table.add_column`.
"""
style: Optional["StyleType"] = None
"""Default style applied to all cells in this column.
Corresponds to the ``style`` parameter of :meth:`rich.table.Table.add_column`.
"""
justify: Literal["default", "left", "center", "right", "full"] = "left"
"""Text justification within the column.
Corresponds to the ``justify`` parameter of :meth:`rich.table.Table.add_column`.
"""
vertical: Literal["top", "middle", "bottom"] = "top"
"""Vertical alignment of text within cells.
Corresponds to the ``vertical`` parameter of :meth:`rich.table.Table.add_column`.
"""
overflow: Literal["fold", "crop", "ellipsis", "ignore"] = "ellipsis"
"""How to handle text that exceeds column width.
Corresponds to the ``overflow`` parameter of :meth:`rich.table.Table.add_column`.
"""
width: int | None = None
"""Fixed width for the column in characters.
Corresponds to the ``width`` parameter of :meth:`rich.table.Table.add_column`.
"""
min_width: int | None = None
"""Minimum width for the column in characters.
Corresponds to the ``min_width`` parameter of :meth:`rich.table.Table.add_column`.
"""
max_width: int | None = None
"""Maximum width for the column in characters.
Corresponds to the ``max_width`` parameter of :meth:`rich.table.Table.add_column`.
"""
ratio: int | None = None
"""Relative width ratio compared to other columns.
Corresponds to the ``ratio`` parameter of :meth:`rich.table.Table.add_column`.
"""
no_wrap: bool = False
"""Prevent text wrapping in the column.
Corresponds to the ``no_wrap`` parameter of :meth:`rich.table.Table.add_column`.
"""
highlight: bool | None = None
"""Enable automatic highlighting of text in the column.
Corresponds to the ``highlight`` parameter of :meth:`rich.table.Table.add_column`.
"""
def _render_cell(self, entry: "HelpEntry") -> "RenderableType":
"""Render the cell content based on the renderer type.
If renderer is a string, retrieves that attribute from the entry.
If renderer is callable, calls it with the entry.
"""
if isinstance(self.renderer, str):
value = attrgetter(self.renderer)(entry)
elif callable(self.renderer):
value = self.renderer(entry)
else:
value = None
return "" if value is None else value
def copy(self, **kwargs):
return evolve(self, **kwargs)
# For Parameters:
AsteriskColumn = ColumnSpec(
renderer=AsteriskRenderer(),
header="",
justify="left",
width=1,
style="red bold",
)
NameColumn = ColumnSpec(
renderer=NameRenderer(),
header="Option",
justify="left",
style="cyan",
)
DescriptionColumn = ColumnSpec(renderer=DescriptionRenderer(), header="Description", justify="left", overflow="fold")
def get_default_command_columns(
console: "Console", options: "ConsoleOptions", entries: list["HelpEntry"]
) -> tuple[ColumnSpec, ...]:
"""Get default column specifications for command display.
Parameters
----------
console : ~rich.console.Console
Rich console for width calculations.
options : ~rich.console.ConsoleOptions
Console rendering options.
entries : list[HelpEntry]
Command entries to display.
Returns
-------
tuple[ColumnSpec, ...]
Column specifications for command table.
"""
max_width = math.ceil(console.width * 0.35)
command_column = ColumnSpec(
renderer=CommandNameRenderer(max_width=max_width),
header="Command",
justify="left",
style="cyan",
max_width=max_width,
)
return (
command_column,
DescriptionColumn,
)
def get_default_parameter_columns(
console: "Console", options: "ConsoleOptions", entries: list["HelpEntry"]
) -> tuple[ColumnSpec, ...]:
"""Get default column specifications for parameter display.
Parameters
----------
console : ~rich.console.Console
Rich console for width calculations.
options : ~rich.console.ConsoleOptions
Console rendering options.
entries : list[HelpEntry]
Parameter entries to display.
Returns
-------
tuple[ColumnSpec, ...]
Column specifications for parameter table.
"""
max_width = math.ceil(console.width * 0.35)
name_column = ColumnSpec(
renderer=NameRenderer(max_width=max_width),
header="Option",
justify="left",
style="cyan",
max_width=max_width,
)
if any(x.required for x in entries):
return (
AsteriskColumn,
name_column,
DescriptionColumn,
)
else:
return (
name_column,
DescriptionColumn,
)
@frozen
class TableSpec:
"""Specification for table layout and styling.
Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to control
the appearance of tables that display commands and parameters. This spec defines
table-wide properties like borders, headers, and padding.
See Also
--------
~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs.
~cyclopts.help.specs.ColumnSpec : Specification for individual columns.
~cyclopts.help.specs.PanelSpec : Specification for the outer panel.
"""
# Intrinsic table styling/config
title: str | None = None
"""Title text displayed above the table.
Corresponds to the ``title`` parameter of :class:`~rich.table.Table`.
"""
caption: str | None = None
"""Caption text displayed below the table.
Corresponds to the ``caption`` parameter of :class:`~rich.table.Table`.
"""
style: Optional["StyleType"] = None
"""Default style applied to the entire table.
Corresponds to the ``style`` parameter of :class:`~rich.table.Table`.
"""
border_style: Optional["StyleType"] = None
"""Style applied to table borders.
Corresponds to the ``border_style`` parameter of :class:`~rich.table.Table`.
"""
header_style: Optional["StyleType"] = None
"""Default style for all table headers (can be overridden per column).
Corresponds to the ``header_style`` parameter of :class:`~rich.table.Table`.
"""
footer_style: Optional["StyleType"] = None
"""Default style for all table footers (can be overridden per column).
Corresponds to the ``footer_style`` parameter of :class:`~rich.table.Table`.
"""
box: Optional["Box"] = None
"""Box drawing style for the table borders.
Corresponds to the ``box`` parameter of :class:`~rich.table.Table`. See :mod:`rich.box` for available styles.
"""
show_header: bool = False
"""Whether to display column headers.
Corresponds to the ``show_header`` parameter of :class:`~rich.table.Table`.
"""
show_footer: bool = False
"""Whether to display column footers.
Corresponds to the ``show_footer`` parameter of :class:`~rich.table.Table`.
"""
show_lines: bool = False
"""Whether to show horizontal lines between rows.
Corresponds to the ``show_lines`` parameter of :class:`~rich.table.Table`.
"""
show_edge: bool = True
"""Whether to draw a box around the outside of the table.
Corresponds to the ``show_edge`` parameter of :class:`~rich.table.Table`.
"""
expand: bool = False
"""Whether the table should expand to fill available width.
Corresponds to the ``expand`` parameter of :class:`~rich.table.Table`.
"""
pad_edge: bool = False
"""Whether to add padding to the table edges.
Corresponds to the ``pad_edge`` parameter of :class:`~rich.table.Table`.
"""
padding: "PaddingDimensions" = (0, 2, 0, 0)
"""Padding around cell content (top, right, bottom, left).
Corresponds to the ``padding`` parameter of :class:`~rich.table.Table`.
"""
collapse_padding: bool = False
"""Whether to collapse padding when adjacent cells are empty.
Corresponds to the ``collapse_padding`` parameter of :class:`~rich.table.Table`.
"""
width: int | None = None
"""Fixed width for the table in characters.
Corresponds to the ``width`` parameter of :class:`~rich.table.Table`.
"""
min_width: int | None = None
"""Minimum width for the table in characters.
Corresponds to the ``min_width`` parameter of :class:`~rich.table.Table`.
"""
safe_box: bool | None = None
"""Whether to use ASCII-safe box characters for compatibility.
Corresponds to the ``safe_box`` parameter of :class:`~rich.table.Table`.
"""
def build(
self,
columns: tuple[ColumnSpec, ...],
entries: Iterable["HelpEntry"],
**overrides,
) -> "Table":
"""Construct and populate a rich.Table.
Parameters
----------
columns : tuple[ColumnSpec, ...]
Column specifications defining the table structure.
entries : Iterable[HelpEntry]
Table entries to populate the table with.
**overrides
Per-render overrides for table settings.
Returns
-------
Table
A populated Rich Table.
"""
# If show_header is True but all columns have empty headers, don't show the header
# This prevents an empty line from appearing at the top of the table
show_header = self.show_header
if show_header and all(not col.header for col in columns):
show_header = False
opts = {
"title": self.title,
"caption": self.caption,
"style": self.style,
"border_style": self.border_style,
"header_style": self.header_style,
"footer_style": self.footer_style,
"box": self.box,
"show_header": show_header,
"show_footer": self.show_footer,
"show_lines": self.show_lines,
"show_edge": self.show_edge,
"expand": self.expand,
"pad_edge": self.pad_edge,
"padding": self.padding,
"collapse_padding": self.collapse_padding,
"width": self.width,
"min_width": self.min_width,
"safe_box": self.safe_box,
}
opts.update(overrides)
from rich.table import Table
table = Table(**opts)
# Add columns
for column in columns:
col_opts = {
"header": column.header,
"footer": column.footer,
"header_style": column.header_style,
"footer_style": column.footer_style,
"style": column.style,
"justify": column.justify,
"vertical": column.vertical,
"overflow": column.overflow,
"width": column.width,
"min_width": column.min_width,
"max_width": column.max_width,
"ratio": column.ratio,
"no_wrap": column.no_wrap,
}
if column.highlight is not None:
col_opts["highlight"] = column.highlight
table.add_column(**col_opts)
# Add entries
for e in entries:
cells = [col._render_cell(e) for col in columns]
table.add_row(*cells)
return table
def copy(self, **kwargs):
return evolve(self, **kwargs)
@frozen
class PanelSpec:
"""Specification for panel (outer box) styling.
Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to control
the appearance of the outer panel that wraps help sections. This spec defines
the panel's border, title, subtitle, and overall styling.
See Also
--------
~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs.
~cyclopts.help.specs.TableSpec : Specification for the inner table.
~cyclopts.help.specs.ColumnSpec : Specification for individual columns.
"""
# Content-independent panel chrome
title: Optional["RenderableType"] = None
"""Title text displayed at the top of the panel.
Corresponds to the ``title`` parameter of :class:`~rich.panel.Panel`.
"""
subtitle: Optional["RenderableType"] = None
"""Subtitle text displayed at the bottom of the panel.
Corresponds to the ``subtitle`` parameter of :class:`~rich.panel.Panel`.
"""
title_align: Literal["left", "center", "right"] = "left"
"""Alignment of the title text within the panel.
Corresponds to the ``title_align`` parameter of :class:`~rich.panel.Panel`.
"""
subtitle_align: Literal["left", "center", "right"] = "center"
"""Alignment of the subtitle text within the panel.
Corresponds to the ``subtitle_align`` parameter of :class:`~rich.panel.Panel`.
"""
style: Optional["StyleType"] = "none"
"""Style applied to the panel background.
Corresponds to the ``style`` parameter of :class:`~rich.panel.Panel`.
"""
border_style: Optional["StyleType"] = "none"
"""Style applied to the panel border.
Corresponds to the ``border_style`` parameter of :class:`~rich.panel.Panel`.
"""
box: Optional["Box"] = None # Will use ROUNDED as default when building
"""Box drawing style for the panel border.
Corresponds to the ``box`` parameter of :class:`~rich.panel.Panel`. See :mod:`rich.box` for available styles.
Defaults to ``rich.box.ROUNDED``.
"""
padding: "PaddingDimensions" = (0, 1)
"""Padding inside the panel (top/bottom, left/right) or (top, right, bottom, left).
Corresponds to the ``padding`` parameter of :class:`~rich.panel.Panel`.
"""
expand: bool = True
"""Whether the panel should expand to fill available width.
Corresponds to the ``expand`` parameter of :class:`~rich.panel.Panel`.
"""
width: int | None = None
"""Fixed width for the panel in characters.
Corresponds to the ``width`` parameter of :class:`~rich.panel.Panel`.
"""
height: int | None = None
"""Fixed height for the panel in lines.
Corresponds to the ``height`` parameter of :class:`~rich.panel.Panel`.
"""
safe_box: bool | None = None
"""Whether to use ASCII-safe box characters for compatibility.
Corresponds to the ``safe_box`` parameter of :class:`~rich.panel.Panel`.
"""
highlight: bool = False
"""Enable automatic highlighting of panel contents.
Corresponds to the ``highlight`` parameter of :class:`~rich.panel.Panel`.
"""
def build(self, renderable: "RenderableType", **overrides) -> "Panel":
"""Create a Panel around `renderable`. Use kwargs to override spec per render."""
# Import box here for lazy loading
box = self.box
if box is None:
from rich.box import ROUNDED
box = ROUNDED
opts = {
"title_align": self.title_align,
"subtitle_align": self.subtitle_align,
"style": self.style,
"border_style": self.border_style,
"box": box,
"padding": self.padding,
"expand": self.expand,
"width": self.width,
"height": self.height,
"safe_box": self.safe_box,
"highlight": self.highlight,
}
if self.title is not None:
opts["title"] = self.title
if self.subtitle is not None:
opts["subtitle"] = self.subtitle
opts.update(overrides)
from rich.panel import Panel
return Panel(renderable, **opts)
def copy(self, **kwargs):
return evolve(self, **kwargs)