from __future__ import annotations
import re
import textwrap
from dataclasses import replace
from typing import Any
from stdl.st import ansi_len, with_style
from interfacy.appearance.layout import HelpLayout
from interfacy.executable_flag import ExecutableFlag, executable_flag_to_argument
from interfacy.schema.schema import Argument, ArgumentKind, Command, ParserSchema, ValueShape
from interfacy.util import get_terminal_width
_DEFAULT_HELP_ARGUMENT = object()
def _make_help_argument(
help_text: str,
*,
flags: tuple[str, ...] = ("--help",),
) -> Argument:
return Argument(
name="help",
display_name="help",
kind=ArgumentKind.OPTION,
value_shape=ValueShape.FLAG,
flags=flags,
required=False,
default=None,
help=help_text,
type=None,
parser=None,
is_help_action=True,
)
[docs]
def has_grouped_commands(commands: dict[str, Command] | None) -> bool:
"""Return whether any command in a mapping has a help-group label."""
if not commands:
return False
return any(command.help_group is not None for command in commands.values())
[docs]
def command_has_grouped_subcommands(command: Command | None) -> bool:
"""Return whether a command has subcommands with help-group labels."""
if command is None:
return False
return has_grouped_commands(command.subcommands)
class SchemaHelpRenderer:
"""
Render parser and command help text from schema objects.
Attributes:
layout (HelpLayout): Active help layout used for formatting.
terminal_width (int): Target terminal width in columns.
"""
def __init__(
self,
layout: HelpLayout,
terminal_width: int | None = None,
help_argument: Argument | None | Any = _DEFAULT_HELP_ARGUMENT,
) -> None:
self.layout = layout
self.terminal_width = terminal_width or get_terminal_width()
self._help_argument = help_argument
[docs]
def render_parser_help(self, schema: ParserSchema, prog: str) -> str:
"""
Render help text for a parser schema and program name.
Args:
schema (ParserSchema): Parser schema to render.
prog (str): Program name or invocation prefix.
"""
if len(schema.commands) == 1:
cmd = next(iter(schema.commands.values()))
previous_help_argument = self._help_argument
if previous_help_argument is _DEFAULT_HELP_ARGUMENT:
self._help_argument = _make_help_argument(
self.layout.help_option_description,
flags=schema.help_flags,
)
try:
return self.render_command_help(
cmd,
prog,
parser_description=schema.description,
parser_epilog=schema.epilog,
parser_executable_flags=schema.executable_flags,
)
finally:
self._help_argument = previous_help_argument
previous_help_argument = self._help_argument
if previous_help_argument is _DEFAULT_HELP_ARGUMENT:
self._help_argument = _make_help_argument(
self.layout.help_option_description,
flags=schema.help_flags,
)
try:
return self._render_multi_command_help(schema, prog)
finally:
self._help_argument = previous_help_argument
[docs]
def render_command_help(
self,
command: Command,
prog: str,
*,
parser_description: str | None = None,
parser_epilog: str | None = None,
parser_executable_flags: list[ExecutableFlag] | None = None,
) -> str:
"""
Render help text for one command schema.
Args:
command (Command): Command schema to render.
prog (str): Program name or invocation prefix.
parser_description (str | None): Optional parser-level description override.
parser_epilog (str | None): Optional parser-level epilog text.
parser_executable_flags (list[ExecutableFlag] | None): Parser-level executable
flags to merge into single-command help output.
"""
layout = self.layout
all_args = command.initializer + command.parameters
positionals = [a for a in all_args if a.kind == ArgumentKind.POSITIONAL]
options = self._ordered_option_arguments(
[a for a in all_args if a.kind == ArgumentKind.OPTION],
command.executable_flags,
parser_executable_flags=parser_executable_flags,
rules=command.help_option_sort_effective,
)
help_arg = self._get_help_argument()
layout.prepare_default_field_width_for_arguments(
[*([help_arg] if help_arg is not None else []), *positionals, *options]
)
sections: list[str] = []
usage = self._build_usage(command, prog, parser_executable_flags=parser_executable_flags)
description = parser_description or command.description
self._append_usage_and_description(sections=sections, usage=usage, description=description)
positionals_section = self._render_argument_section("positional arguments", positionals)
if positionals_section is not None:
sections.append(positionals_section)
options_with_help = [*([help_arg] if help_arg is not None else []), *options]
options_section = self._render_argument_section(
"options",
options_with_help,
normalize_help_only=help_arg is not None and not options,
)
if options_section is not None:
sections.append(options_section)
if command.subcommands:
subcommand_help = layout.get_help_for_multiple_commands(
command.subcommands,
rules=command.help_subcommand_sort_effective,
)
sections.append(subcommand_help)
epilog_block = self._build_epilog_block(command, parser_epilog)
if epilog_block is not None:
sections.append(epilog_block)
return "\n\n".join(sections) + "\n"
def _append_usage_and_description(
self,
*,
sections: list[str],
usage: str,
description: str | None,
) -> None:
rendered_description = self._wrap_description(description)
if self.layout.should_render_description_before_usage():
if rendered_description:
sections.append(rendered_description)
sections.append(usage)
return
sections.append(usage)
if rendered_description:
sections.append(rendered_description)
def _wrap_description(self, description: str | None) -> str | None:
if not description:
return None
formatted = self.layout.format_description(description)
paragraphs = formatted.splitlines()
if not paragraphs:
return None
wrapped: list[str] = []
for paragraph in paragraphs:
if not paragraph.strip():
wrapped.append("")
continue
wrapped.append(
textwrap.fill(
paragraph.strip(),
width=max(10, self.terminal_width),
break_long_words=False,
break_on_hyphens=False,
)
)
return "\n".join(wrapped)
def _render_argument_section(
self,
heading: str,
arguments: list[Argument],
*,
normalize_help_only: bool = False,
) -> str | None:
if not arguments:
return None
previous_keep = self.layout.keep_empty_default_slot_for_help
self.layout.keep_empty_default_slot_for_help = (
self.layout.keep_help_default_slot_for_arguments(arguments)
)
lines = [self._style_section_heading(heading)]
try:
for arg in arguments:
if self.layout._use_template_layout():
rendered = self.layout.format_argument(arg)
else:
rendered = self.layout.format_adaptive_argument_row(arg)
if normalize_help_only and arg.is_help_action:
rendered = self._normalize_help_only_option_line(rendered, arg)
lines.append(self._indent(rendered))
finally:
self.layout.keep_empty_default_slot_for_help = previous_keep
return "\n".join(lines)
def _build_epilog_block(self, command: Command, parser_epilog: str | None) -> str | None:
epilog_parts: list[str] = []
if command.epilog:
normalized_epilog = re.sub(r"\x1b\[[0-9;]*m", "", command.epilog).strip()
is_generated_subcommand_epilog = False
if command.subcommands is not None:
generated_subcommand_help = self.layout.get_help_for_multiple_commands(
command.subcommands,
rules=command.help_subcommand_sort_effective,
)
normalized_generated_help = re.sub(
r"\x1b\[[0-9;]*m",
"",
generated_subcommand_help,
).strip()
is_generated_subcommand_epilog = (
normalized_epilog.lower().startswith("commands:")
or normalized_epilog == normalized_generated_help
)
if not is_generated_subcommand_epilog:
epilog_parts.append(command.epilog)
if parser_epilog:
epilog_parts.append(parser_epilog)
if not epilog_parts:
return None
return "\n\n".join(epilog_parts)
def _render_multi_command_help(self, schema: ParserSchema, prog: str) -> str:
layout = self.layout
sections: list[str] = []
usage_prog = self._style_usage_text(self._normalize_prog(prog))
usage_prefix = self._get_usage_prefix()
usage_suffix = layout.get_parser_command_usage_suffix()
usage_text = f"{usage_prog} {usage_suffix}"
usage_prefix_len = ansi_len(usage_prefix)
if usage_prefix_len + ansi_len(usage_text) > self.terminal_width:
wrapped_usage = self._wrap_usage_parts(
[usage_prog, usage_suffix],
self.terminal_width,
usage_prefix_len,
" " * usage_prefix_len,
)
usage = f"{usage_prefix}{wrapped_usage}"
else:
usage = f"{usage_prefix}{usage_text}"
if layout.should_render_description_before_usage():
if schema.description:
sections.append(self._wrap_description(schema.description) or "")
sections.append(usage)
else:
sections.append(usage)
if schema.description:
sections.append(self._wrap_description(schema.description) or "")
help_arg = self._get_help_argument()
root_options = self._ordered_option_arguments(
[],
schema.executable_flags,
rules=schema.help_option_sort_effective,
)
root_options_with_help = [*([help_arg] if help_arg is not None else []), *root_options]
if root_options_with_help:
layout.prepare_default_field_width_for_arguments(root_options_with_help)
sections.append(
self._render_argument_section(
"options",
root_options_with_help,
normalize_help_only=help_arg is not None and not root_options,
)
or ""
)
if schema.commands_help:
sections.append(schema.commands_help)
elif schema.commands:
commands_help = layout.get_help_for_multiple_commands(schema.commands)
sections.append(commands_help)
if schema.epilog:
sections.append(schema.epilog)
return "\n\n".join(sections) + "\n"
def _build_usage(
self,
command: Command,
prog: str,
*,
parser_executable_flags: list[ExecutableFlag] | None = None,
) -> str:
all_args = command.initializer + command.parameters
positionals = [a for a in all_args if a.kind == ArgumentKind.POSITIONAL]
options = self._ordered_option_arguments(
[a for a in all_args if a.kind == ArgumentKind.OPTION],
command.executable_flags,
parser_executable_flags=parser_executable_flags,
rules=command.help_option_sort_effective,
)
compact_options_usage = self.layout.compact_options_usage
usage_prefix = self._get_usage_prefix()
parts: list[str] = [self._style_usage_text(self._normalize_prog(prog))]
if compact_options_usage:
parts.append("[OPTIONS]")
parts.extend(
self._usage_token_for_option(arg, compact_style=True)
for arg in options
if arg.required
)
else:
help_arg = self._get_help_argument()
if help_arg is not None:
parts.append(self._usage_token_for_option(help_arg))
parts.extend(self._usage_token_for_option(arg) for arg in options)
for arg in positionals:
raw_name = arg.metavar
if raw_name is None or "\b" in raw_name:
raw_name = arg.display_name or arg.name or "arg"
name = raw_name.upper()
metavar_name = self.layout.format_usage_metavar(name, is_varargs=False)
if arg.value_shape == ValueShape.LIST:
token = (
self.layout.format_usage_metavar(name, is_varargs=True)
if compact_options_usage
else f"{name} ..."
)
parts.append(token if arg.nargs == "+" else f"[{token}]")
continue
if arg.value_shape == ValueShape.TUPLE and isinstance(arg.nargs, int) and arg.nargs > 1:
token_atom = metavar_name if compact_options_usage else name
token = " ".join([token_atom] * arg.nargs)
parts.append(token if arg.required else f"[{token}]")
continue
token = metavar_name if compact_options_usage else name
parts.append(token if arg.required else f"[{token}]")
if command.subcommands:
parts.append(self._usage_token_for_subcommands(command))
usage_text = " ".join(parts)
text_width = self.terminal_width
prefix_len = ansi_len(usage_prefix)
if prefix_len + ansi_len(usage_text) > text_width:
indent = " " * prefix_len
wrapped = self._wrap_usage_parts(parts, text_width, prefix_len, indent)
return f"{usage_prefix}{wrapped}"
return f"{usage_prefix}{usage_text}"
def _ordered_option_arguments(
self,
options: list[Argument],
executable_flags: list[ExecutableFlag],
*,
parser_executable_flags: list[ExecutableFlag] | None = None,
rules: list[Any] | None = None,
) -> list[Argument]:
flag_arguments = [
executable_flag_to_argument(flag)
for flag in [*(parser_executable_flags or []), *executable_flags]
]
return self.layout.order_option_arguments_for_help(
[*options, *flag_arguments],
rules=rules,
)
def _usage_token_for_subcommands(self, command: Command) -> str:
token = self.layout.get_subcommand_usage_token()
if "{command}" not in token or not command.subcommands:
return token
ordered_subcommands = self.layout.order_commands_for_help(
command.subcommands,
rules=command.help_subcommand_sort_effective,
)
choices = [subcommand.cli_name for subcommand in ordered_subcommands]
if not choices:
return token
return token.replace("{command}", "{" + ",".join(choices) + "}")
def _usage_token_for_option(self, arg: Argument, *, compact_style: bool = False) -> str:
longs = [flag for flag in arg.flags if len(flag) > 2]
shorts = [flag for flag in arg.flags if len(flag) <= 2]
primary_flag = longs[0] if longs else (shorts[0] if shorts else f"--{arg.display_name}")
is_bool = self.layout.is_argument_boolean(arg)
if is_bool:
primary_bool = self.layout.get_primary_boolean_flag_for_argument(arg) or primary_flag
return primary_bool if arg.required else f"[{primary_bool}]"
if self.layout.clear_metavar and not self.layout.include_metavar_in_flag_display:
return primary_flag if arg.required else f"[{primary_flag}]"
raw_metavar = arg.metavar
if raw_metavar is None or "\b" in raw_metavar:
raw_metavar = arg.display_name or arg.name or "value"
metavar = raw_metavar.upper()
if arg.value_shape == ValueShape.LIST:
if compact_style:
value_token = self.layout.format_usage_metavar(metavar, is_varargs=True)
else:
value_token = f"[{metavar} ...]"
elif arg.value_shape == ValueShape.TUPLE and isinstance(arg.nargs, int) and arg.nargs > 1:
atom = (
self.layout.format_usage_metavar(metavar, is_varargs=False)
if compact_style
else metavar
)
value_token = " ".join([atom] * arg.nargs)
else:
value_token = (
self.layout.format_usage_metavar(metavar, is_varargs=False)
if compact_style
else metavar
)
token = f"{primary_flag} {value_token}"
return token if arg.required else f"[{token}]"
def _wrap_usage_parts(
self,
parts: list[str],
text_width: int,
prefix_len: int,
indent: str,
) -> str:
lines: list[str] = []
current_line: list[str] = []
current_len = prefix_len
for part in self._expand_usage_parts(parts, max(10, text_width - len(indent))):
part_len = ansi_len(part)
if current_line and current_len + 1 + part_len > text_width:
lines.append(" ".join(current_line))
current_line = [part]
current_len = len(indent) + part_len
else:
current_line.append(part)
current_len += part_len + (1 if len(current_line) > 1 else 0)
if current_line:
lines.append(" ".join(current_line))
if len(lines) <= 1:
return lines[0] if lines else ""
return lines[0] + "\n" + "\n".join(indent + line for line in lines[1:])
@staticmethod
def _expand_usage_parts(parts: list[str], available_width: int) -> list[str]:
expanded: list[str] = []
for part in parts:
if ansi_len(part) <= available_width:
expanded.append(part)
continue
if part.startswith("{") and part.endswith("}") and "," in part:
choices = part[1:-1].split(",")
for idx, choice in enumerate(choices):
prefix = "{" if idx == 0 else ""
suffix = "}" if idx == len(choices) - 1 else ","
expanded.append(f"{prefix}{choice}{suffix}")
continue
expanded.append(part)
return expanded
def _get_usage_prefix(self) -> str:
layout = self.layout
prefix = layout.usage_prefix or "usage: "
if layout.usage_style is not None:
prefix = with_style(prefix, layout.usage_style)
return prefix
def _normalize_prog(self, prog: str) -> str:
return re.sub(
r"^(?:\x1b\[[0-9;]*m)*\s*usage:\s*",
"",
prog,
flags=re.IGNORECASE,
).strip()
def _style_usage_text(self, text: str) -> str:
if self.layout.usage_text_style is not None:
return with_style(text, self.layout.usage_text_style)
return text
def _style_section_heading(self, heading: str) -> str:
layout = self.layout
title_map = layout.section_title_map
if title_map is not None:
heading_key = heading.rstrip(":").strip().lower()
mapped = title_map.get(heading) or title_map.get(heading_key)
if mapped:
heading = mapped
if layout.section_heading_style is not None:
heading = with_style(heading, layout.section_heading_style)
return heading + ":"
def _indent(self, text: str, width: int = 2) -> str:
prefix = " " * width
lines: list[str] = []
for line in text.splitlines():
indented = prefix + line
if ansi_len(indented) <= self.terminal_width:
lines.append(indented)
continue
leading = len(indented) - len(indented.lstrip(" "))
wrap_indent = " " * (leading if leading < self.terminal_width - 10 else width)
lines.extend(
textwrap.wrap(
indented.strip(),
width=max(10, self.terminal_width),
initial_indent=wrap_indent,
subsequent_indent=f"{wrap_indent} ",
break_long_words=True,
break_on_hyphens=False,
)
)
return "\n".join(lines)
def _get_help_argument(self) -> Argument | None:
if self._help_argument is _DEFAULT_HELP_ARGUMENT:
return _make_help_argument(self.layout.help_option_description)
if self._help_argument is None:
return None
return replace(
self._help_argument,
help=self.layout.help_option_description,
is_help_action=True,
)
def _normalize_help_only_option_line(self, line: str, help_arg: Argument) -> str:
"""Normalize synthetic help-only rows so the configured help flag stays visible."""
normalized = line.lstrip()
removed = len(line) - len(normalized)
if removed and "\n" in normalized:
normalized_lines = normalized.splitlines()
dedented = [normalized_lines[0]]
for continuation in normalized_lines[1:]:
leading = len(continuation) - len(continuation.lstrip(" "))
dedented.append(continuation[min(removed, leading) :])
normalized = "\n".join(dedented)
if any(flag and flag in normalized for flag in help_arg.flags):
return normalized
description = line.strip()
primary_flag = self.layout.get_primary_boolean_flag_for_argument(help_arg) or (
help_arg.flags[0] if help_arg.flags else "--help"
)
if not description:
return primary_flag
help_position = self.layout.help_position
padding = max(2, help_position - len(primary_flag)) if help_position is not None else 2
return f"{primary_flag}{' ' * padding}{description}"
__all__ = [
"SchemaHelpRenderer",
"command_has_grouped_subcommands",
"has_grouped_commands",
]