From e4da3fc98ad076c1350f5522c62e55205d247ef8 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Thu, 25 Aug 2022 20:20:15 -0700 Subject: [PATCH] Improve formatting for long helptext --- dcargs/_argparse_formatter.py | 88 ++++++++++++++++++++++++++++++++--- dcargs/_instantiators.py | 18 ++++--- dcargs/_strings.py | 14 ++++++ tests/test_dict_namedtuple.py | 6 ++- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/dcargs/_argparse_formatter.py b/dcargs/_argparse_formatter.py index b88251f7..dc5ddebc 100644 --- a/dcargs/_argparse_formatter.py +++ b/dcargs/_argparse_formatter.py @@ -1,10 +1,21 @@ import argparse import contextlib +import itertools +import re as _re +from gettext import gettext as _ +from gettext import ngettext from typing import Any, ContextManager, Generator from . import _strings +def monkeypatch_len(obj: Any) -> int: + if isinstance(obj, str): + return len(_strings.strip_ansi_sequences(obj)) + else: + return len(obj) + + def ansi_context() -> ContextManager[None]: """Context for working with ANSI codes + argparse: - Applies a temporary monkey patch for making argparse ignore ANSI codes when @@ -14,14 +25,9 @@ def ansi_context() -> ContextManager[None]: @contextlib.contextmanager def inner() -> Generator[None, None, None]: - def monkeypatched_len(obj: Any) -> int: - if isinstance(obj, str): - return len(_strings.strip_ansi_sequences(obj)) - else: - return len(obj) if not hasattr(argparse, "len"): - argparse.len = monkeypatched_len # type: ignore + argparse.len = monkeypatch_len # type: ignore try: # Use Colorama to support coloring in Windows shells. import colorama # type: ignore @@ -54,8 +60,78 @@ def monkeypatched_len(obj: Any) -> int: class ArgparseHelpFormatter(argparse.RawDescriptionHelpFormatter): + def __init__( + self, + prog, + indent_increment=2, + max_help_position=64, # Usually 24 + width=None, + ): + super().__init__(prog, indent_increment, max_help_position, width) + def _format_args(self, action, default_metavar): """Override _format_args() to ignore nargs and always expect single string metavars.""" get_metavar = self._metavar_formatter(action, default_metavar) return get_metavar(1)[0] + + def _format_action(self, action): + # determine the required width and the entry label + help_position = min(self._action_max_length + 2, self._max_help_position) + help_width = max(self._width - help_position, 11) + action_width = help_position - self._current_indent - 2 + action_header = self._format_action_invocation(action) + + # no help; start on same line and add a final newline + if not action.help: + tup = self._current_indent, "", action_header + action_header = "%*s%s\n" % tup + + # short action name; start on the same line and pad two spaces + elif monkeypatch_len(action_header) <= action_width: + # Original: + # tup = self._current_indent, "", action_width, action_header + # action_header = "%*s%-*s " % tup + # + action_header = ( + " " * self._current_indent + + action_header + + " " * (action_width - monkeypatch_len(action_header)) + ) + # + indent_first = 0 + + # long action name; start on the next line + else: + tup = self._current_indent, "", action_header + action_header = "%*s%s\n" % tup + indent_first = help_position + + # collect the pieces of the action help + parts = [action_header] + + # if there was help for the action, add lines of help text + if action.help: + help_text = self._expand_help(action) + # + # Respect existing line breaks. + help_lines = tuple( + itertools.chain( + *(self._split_lines(h, help_width) for h in help_text.split("\n")) + ) + ) + # + parts.append("%*s%s\n" % (indent_first, "", help_lines[0])) + for line in help_lines[1:]: + parts.append("%*s%s\n" % (help_position, "", line)) + + # or add a newline if the description doesn't end with one + elif not action_header.endswith("\n"): + parts.append("\n") + + # if there are any sub-actions, add their help as well + for subaction in self._iter_indented_subactions(action): + parts.append(self._format_action(subaction)) + + # return a single string + return self._join_parts(parts) diff --git a/dcargs/_instantiators.py b/dcargs/_instantiators.py index b2482cb4..72db1b5f 100644 --- a/dcargs/_instantiators.py +++ b/dcargs/_instantiators.py @@ -56,6 +56,8 @@ import termcolor from typing_extensions import Annotated, Final, Literal, get_args, get_origin +from . import _strings + _StandardInstantiator = Callable[[List[str]], Any] # Special case: the only time that argparse doesn't give us a string is when the # argument action is set to `store_true` or `store_false`. In this case, we get @@ -94,10 +96,6 @@ class UnsupportedTypeAnnotationError(Exception): ) -def _format_metavar(x: str) -> str: - return termcolor.colored(x, attrs=["bold"]) - - def instantiator_from_type( typ: Type, type_from_typevar: Dict[TypeVar, Type] ) -> Tuple[Instantiator, InstantiatorMetadata]: @@ -131,7 +129,7 @@ def instantiator(strings: List[str]) -> None: return instantiator, InstantiatorMetadata( nargs=1, - metavar="{" + _format_metavar("None") + "}", + metavar="{" + _strings.format_metavar("None") + "}", choices=("None",), ) @@ -218,9 +216,9 @@ def instantiator_base_case(strings: List[str]) -> Any: return instantiator_base_case, InstantiatorMetadata( nargs=1, - metavar=_format_metavar(typ.__name__.upper()) + metavar=_strings.format_metavar(typ.__name__.upper()) if auto_choices is None - else "{" + ",".join(map(_format_metavar, map(str, auto_choices))) + "}", + else "{" + ",".join(map(_strings.format_metavar, map(str, auto_choices))) + "}", choices=auto_choices, ) @@ -519,7 +517,7 @@ def dict_instantiator(strings: List[str]) -> Any: pair_metavar = f"{key_meta.metavar} {val_meta.metavar}" return dict_instantiator, InstantiatorMetadata( nargs="+", - metavar=f"{pair_metavar} [{pair_metavar} ...]", + metavar=_strings.multi_metavar_from_single(pair_metavar), choices=None, ) @@ -563,7 +561,7 @@ def sequence_instantiator(strings: List[str]) -> Any: return sequence_instantiator, InstantiatorMetadata( nargs="+", - metavar=f"{inner_meta.metavar} [{inner_meta.metavar} ...]", + metavar=_strings.multi_metavar_from_single(inner_meta.metavar), choices=inner_meta.choices, ) @@ -579,7 +577,7 @@ def _instantiator_from_literal( lambda strings: choices[str_choices.index(strings[0])], InstantiatorMetadata( nargs=1, - metavar="{" + ",".join(map(_format_metavar, str_choices)) + "}", + metavar="{" + ",".join(map(_strings.format_metavar, str_choices)) + "}", choices=str_choices, ), ) diff --git a/dcargs/_strings.py b/dcargs/_strings.py index e9a13a57..7ac05c46 100644 --- a/dcargs/_strings.py +++ b/dcargs/_strings.py @@ -5,6 +5,8 @@ import textwrap from typing import Iterable, List, Sequence, Type, Union +import termcolor + from . import _resolver dummy_field_name = "__dcargs_dummy_field_name__" @@ -80,3 +82,15 @@ def _get_ansi_pattern() -> re.Pattern: def strip_ansi_sequences(x: str): return _get_ansi_pattern().sub("", x) + + +def format_metavar(x: str) -> str: + return termcolor.colored(x, attrs=["bold"]) + + +def multi_metavar_from_single(single: str) -> str: + if len(strip_ansi_sequences(single)) >= 32: + # Shorten long metavars + return f"{single} [...]" + else: + return f"{single} [{single} ...]" diff --git a/tests/test_dict_namedtuple.py b/tests/test_dict_namedtuple.py index a9ebf83e..d1898e18 100644 --- a/tests/test_dict_namedtuple.py +++ b/tests/test_dict_namedtuple.py @@ -246,9 +246,11 @@ class HelptextNamedTupleDefault(NamedTuple): dcargs.cli(HelptextNamedTupleDefault, args=["--help"]) helptext = dcargs._strings.strip_ansi_sequences(f.getvalue()) assert cast(str, HelptextNamedTupleDefault.__doc__) in helptext - assert "--x INT Documentation 1 (required)\n" in helptext - assert "--y INT Documentation 2 (required)\n" in helptext + assert "--x INT" in helptext + assert "--y INT" in helptext assert "--z INT" in helptext + assert "Documentation 1 (required)\n" in helptext + assert "Documentation 2 (required)\n" in helptext assert "Documentation 3 (default: 3)\n" in helptext