Skip to content

Commit

Permalink
Merge pull request #166 from BrianPugh/bugfix/consistent-help-formatting
Browse files Browse the repository at this point in the history
Bugfix/consistent help formatting
  • Loading branch information
BrianPugh authored May 6, 2024
2 parents 7aafc8e + 444439e commit 1f5e3d6
Show file tree
Hide file tree
Showing 7 changed files with 605 additions and 286 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ repos:
- id: typos

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.360
rev: v1.1.361
hooks:
- id: pyright
46 changes: 31 additions & 15 deletions cyclopts/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
format_command_entries,
format_doc,
format_usage,
resolve_help_format,
)
from cyclopts.parameter import Parameter, validate_command
from cyclopts.protocols import Dispatcher
Expand Down Expand Up @@ -204,9 +205,16 @@ class App:
alias="help_flags",
kw_only=True,
)
help_format: Union[None, Literal["plaintext", "markdown", "md", "restructuredtext", "rst"]] = field(
default=None, kw_only=True
)
help_format: Optional[
Literal[
"markdown",
"md",
"plaintext",
"restructuredtext",
"rst",
"rich",
]
] = field(default=None, kw_only=True)

# This can ONLY ever be Tuple[Union[Group, str], ...] due to converter.
# The other types is to make mypy happy for Cyclopts users.
Expand Down Expand Up @@ -817,14 +825,10 @@ def help_print(
console.print(executing_app.usage + "\n")

# Print the App/Command's Doc String.
# Resolve help_format; None fallsback to parent; non-None overwrites parent.
help_format = "restructuredtext"
for app in apps:
if app.help_format is not None:
help_format = app.help_format
help_format = resolve_help_format(apps)
console.print(format_doc(self, executing_app, help_format))

for help_panel in self._assemble_help_panels(tokens):
for help_panel in self._assemble_help_panels(tokens, help_format):
console.print(help_panel)

def _resolve_command(
Expand All @@ -848,10 +852,16 @@ def _resolve_command(

def _assemble_help_panels(
self,
tokens: Union[None, str, Iterable[str]] = None,
tokens: Union[None, str, Iterable[str]],
help_format,
) -> List[HelpPanel]:
from rich.console import Group as RichGroup
from rich.console import NewLine

_, apps, _ = self.parse_commands(tokens)

help_format = resolve_help_format(apps)

panels: Dict[str, Tuple[Group, HelpPanel]] = {}
# Handle commands first; there's an off chance they may be "upgraded"
# to an argument/parameter panel.
Expand All @@ -871,12 +881,16 @@ def _assemble_help_panels(
panels[group.name] = (group, command_panel)

if group.help:
import cyclopts.help

group_help = cyclopts.help._format(group.help, format=help_format)

if command_panel.description:
command_panel.description += "\n" + group.help
command_panel.description = RichGroup(command_panel.description, NewLine(), group_help)
else:
command_panel.description = group.help
command_panel.description = group_help

command_panel.entries.extend(format_command_entries(elements))
command_panel.entries.extend(format_command_entries(elements, format=help_format))

# Handle Arguments/Parameters
for subapp in walk_metas(apps[-1]):
Expand All @@ -896,15 +910,17 @@ def _assemble_help_panels(
_, existing_panel = panels[group.name]
except KeyError:
existing_panel = None
new_panel = create_parameter_help_panel(group, iparams, cparams)
new_panel = create_parameter_help_panel(group, iparams, cparams, help_format)

if existing_panel:
# An imperfect merging process
existing_panel.format = "parameter"
existing_panel.entries = new_panel.entries + existing_panel.entries # Commands go last
if new_panel.description:
if existing_panel.description:
existing_panel.description += "\n" + new_panel.description
existing_panel.description = RichGroup(
existing_panel.description, NewLine(), new_panel.description
)
else:
existing_panel.description = new_panel.description
else:
Expand Down
151 changes: 120 additions & 31 deletions cyclopts/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@
from enum import Enum
from functools import lru_cache, partial
from inspect import isclass
from typing import TYPE_CHECKING, Callable, List, Literal, Tuple, Type, Union, get_args, get_origin
from typing import (
TYPE_CHECKING,
Callable,
Iterable,
List,
Literal,
Tuple,
Type,
Union,
get_args,
get_origin,
)

import docstring_parser
from attrs import define, field, frozen
Expand All @@ -12,6 +23,8 @@
from cyclopts.parameter import Parameter, get_hint_parameter

if TYPE_CHECKING:
from rich.console import RenderableType

from cyclopts.core import App


Expand All @@ -33,23 +46,30 @@ def docstring_parse(doc: str):
@frozen
class HelpEntry:
name: str
short: str = ""
description: str = ""
short: str
description: "RenderableType"
required: bool = False


def _text_factory():
from rich.text import Text

return Text()


@define
class HelpPanel:
format: Literal["command", "parameter"]
title: str
description: str = ""
description: "RenderableType" = field(factory=_text_factory)
entries: List[HelpEntry] = field(factory=list)

def remove_duplicates(self):
seen, out = set(), []
for item in self.entries:
if item not in seen:
seen.add(item)
hashable = (item.name, item.short)
if hashable not in seen:
seen.add(hashable)
out.append(item)
self.entries = out

Expand All @@ -61,17 +81,26 @@ def __rich__(self):
return _silent
from rich.box import ROUNDED
from rich.console import Group as RichGroup
from rich.console import NewLine
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

table = Table.grid(padding=(0, 1))
text = Text(end="")
if self.description:
text.append(self.description + "\n\n")
from rich.panel import Panel
panel_description = self.description

if isinstance(panel_description, Text):
panel_description.end = ""

if panel_description.plain:
panel_description = RichGroup(panel_description, NewLine(2))
else:
# Should be either a RST or Markdown object
if panel_description.markup: # pyright: ignore[reportAttributeAccessIssue]
panel_description = RichGroup(panel_description, NewLine(1))

panel = Panel(
RichGroup(text, table),
RichGroup(panel_description, table),
box=ROUNDED,
expand=True,
title_align="left",
Expand Down Expand Up @@ -163,37 +192,75 @@ def format_usage(

def format_doc(root_app, app: "App", format: str = "restructuredtext"):
from rich.console import Group as RichGroup
from rich.console import NewLine
from rich.text import Text

from cyclopts.core import App # noqa: F811

raw_doc_string = app.help

if not raw_doc_string:
return _silent

parsed = docstring_parse(raw_doc_string)

components: List[Tuple[str, str]] = []
components: List[Union[str, Tuple[str, str]]] = []
if parsed.short_description:
components.append((parsed.short_description + "\n", "default"))
components.append(parsed.short_description + "\n")

if parsed.long_description:
if parsed.short_description:
components.append(("\n", "default"))
components.append("\n")
components.append((parsed.long_description + "\n", "info"))

return RichGroup(_format(*components, format=format), NewLine())


def _format(*components: Union[str, Tuple[str, str]], format: str = "restructuredtext") -> "RenderableType":
format = format.lower()

if format == "plaintext":
return Text.assemble(*components)
from rich.text import Text

aggregate = []
for component in components:
if isinstance(component, str):
aggregate.append(component)
else:
aggregate.append(component[0])
return Text.assemble("".join(aggregate).rstrip())
elif format in ("markdown", "md"):
from rich.markdown import Markdown

return RichGroup(Markdown("".join(x[0] for x in components)), Text(""))
aggregate = []
for component in components:
if isinstance(component, str):
aggregate.append(component)
else:
# Ignore style for now :(
aggregate.append(component[0])

return Markdown("".join(aggregate))
elif format in ("restructuredtext", "rst"):
from rich_rst import RestructuredText

return RestructuredText("".join(x[0] for x in components))
aggregate = []
for component in components:
if isinstance(component, str):
aggregate.append(component)
else:
# Ignore style for now :(
aggregate.append(component[0])
return RestructuredText("".join(aggregate))
elif format == "rich":
from rich.text import Text

def walk_components():
for component in components:
if isinstance(component, str):
yield Text.from_markup(component.rstrip())
else:
yield Text.from_markup(component[0].rstrip(), style=component[1])

return Text().join(walk_components())
else:
raise ValueError(f'Unknown help_format "{format}"')

Expand All @@ -216,15 +283,28 @@ def _get_choices(type_: Type, name_transform: Callable[[str], str]) -> str:
return choices


def create_parameter_help_panel(group: "Group", iparams, cparams: List[Parameter]) -> HelpPanel:
help_panel = HelpPanel(format="parameter", title=group.name, description=group.help)
def create_parameter_help_panel(
group: "Group",
iparams,
cparams: List[Parameter],
format: str,
) -> HelpPanel:
help_panel = HelpPanel(format="parameter", title=group.name, description=_format(group.help, format=format))
icparams = [(ip, cp) for ip, cp in zip(iparams, cparams) if cp.show]

if not icparams:
return help_panel

iparams, cparams = (list(x) for x in zip(*icparams))

def help_append(text, style=""):
if help_components:
text = " " + text
if style:
help_components.append((text, style))
else:
help_components.append(text)

for iparam, cparam in icparams:
assert cparam.name is not None
assert cparam.name_transform is not None
Expand All @@ -248,16 +328,16 @@ def create_parameter_help_panel(group: "Group", iparams, cparams: List[Parameter
help_components = []

if cparam.help:
help_components.append(cparam.help)
help_append(cparam.help)

if cparam.show_choices:
choices = _get_choices(type_, cparam.name_transform)
if choices:
help_components.append(rf"[dim]\[choices: {choices}][/dim]")
help_append(rf"[choices: {choices}]", "dim")

if cparam.show_env_var and cparam.env_var:
env_vars = " ".join(cparam.env_var)
help_components.append(rf"[dim]\[env var: {env_vars}][/dim]")
help_append(rf"[env var: {env_vars}]", "dim")

if cparam.show_default or (
cparam.show_default is None and iparam.default not in {None, inspect.Parameter.empty}
Expand All @@ -268,16 +348,16 @@ def create_parameter_help_panel(group: "Group", iparams, cparams: List[Parameter
else:
default = iparam.default

help_components.append(rf"[dim]\[default: {default}][/dim]")
help_append(rf"[default: {default}]", "dim")

if cparam.required:
help_components.append(r"[red][dim]\[required][/dim][/red]")
help_append(r"[required]", "dim red")

# populate row
help_panel.entries.append(
HelpEntry(
name=",".join(long_options),
description=" ".join(help_components),
description=_format(*help_components, format=format),
short=",".join(short_options),
required=bool(cparam.required),
)
Expand All @@ -286,17 +366,26 @@ def create_parameter_help_panel(group: "Group", iparams, cparams: List[Parameter
return help_panel


def format_command_entries(elements) -> List:
def format_command_entries(apps: Iterable["App"], format: str) -> List:
entries = []
for element in elements:
for app in apps:
short_names, long_names = [], []
for name in element.name:
for name in app.name:
short_names.append(name) if _is_short(name) else long_names.append(name)
entry = HelpEntry(
name=",".join(long_names),
short=",".join(short_names),
description=docstring_parse(element.help).short_description or "",
description=_format(docstring_parse(app.help).short_description or "", format=format),
)
if entry not in entries:
entries.append(entry)
return entries


def resolve_help_format(app_chain: Iterable["App"]) -> str:
# Resolve help_format; None fallsback to parent; non-None overwrites parent.
help_format = "restructuredtext"
for app in app_chain:
if app.help_format is not None:
help_format = app.help_format
return help_format
Loading

0 comments on commit 1f5e3d6

Please sign in to comment.