diff --git a/Lib/argparse.py b/Lib/argparse.py index d1a6350c3fda6d..1f2e9fdb1fbf1e 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -153,7 +153,6 @@ def _copy_items(items): # Formatting Help # =============== - class HelpFormatter(object): """Formatter for generating usage messages and argument help strings. @@ -181,10 +180,12 @@ def __init__( self._max_help_position = min(max_help_position, max(width - 20, indent_increment * 2)) self._width = width + self._adaptive_help_start_column = min(max_help_position, + max(self._width - 20, indent_increment * 2)) + self._globally_calculated_help_start_col = self._adaptive_help_start_column self._current_indent = 0 self._level = 0 - self._action_max_length = 0 self._root_section = self._Section(self, None) self._current_section = self._root_section @@ -192,6 +193,37 @@ def __init__( self._whitespace_matcher = _re.compile(r'\s+', _re.ASCII) self._long_break_matcher = _re.compile(r'\n\n\n+') + def _get_action_details_for_pass(self, section, current_indent_for_section_items): + """ + Recursively collects details for actions within a given section and its subsections. + These details (action object, invocation length, indent) are used for calculating + the global help text alignment column. + """ + collected_details = [] + + for func_to_call, args_for_func in section.items: + if func_to_call == self._format_action and args_for_func: + action_object = args_for_func[0] + if action_object.help is not SUPPRESS: + invocation_string = self._format_action_invocation(action_object) + # Length without color codes is needed for alignment. + invocation_length = len(self._decolor(invocation_string)) + + collected_details.append({ + 'action': action_object, + 'inv_len': invocation_length, + 'indent': current_indent_for_section_items, + }) + elif hasattr(func_to_call, '__self__') and isinstance(func_to_call.__self__, self._Section): + sub_section_object = func_to_call.__self__ + + indent_for_subsection_items = current_indent_for_section_items + self._indent_increment + + collected_details.extend( + self._get_action_details_for_pass(sub_section_object, indent_for_subsection_items) + ) + return collected_details + def _set_color(self, color): from _colorize import can_colorize, decolor, get_theme @@ -224,32 +256,43 @@ def __init__(self, formatter, parent, heading=None): self.items = [] def format_help(self): - # format the indented section - if self.parent is not None: + """ + Formats the help for this section, including its heading and all items. + """ + is_subsection = self.parent is not None + if is_subsection: self.formatter._indent() - join = self.formatter._join_parts - item_help = join([func(*args) for func, args in self.items]) - if self.parent is not None: + + # Generate help strings for all items (actions, text, subsections) in this section + item_help_strings = [func(*args) for func, args in self.items] + rendered_items_help = self.formatter._join_parts(item_help_strings) + + if is_subsection: + # Restore indent level after formatting subsection items self.formatter._dedent() # return nothing if the section was empty - if not item_help: + if not rendered_items_help: return '' - # add the heading if the section was non-empty + formatted_heading_output_part = "" if self.heading is not SUPPRESS and self.heading is not None: - current_indent = self.formatter._current_indent - heading_text = _('%(heading)s:') % dict(heading=self.heading) - t = self.formatter._theme - heading = ( - f'{" " * current_indent}' - f'{t.heading}{heading_text}{t.reset}\n' + current_section_heading_indent = ' ' * self.formatter._current_indent + heading_title_text = _('%(heading)s:') % dict(heading=self.heading) + theme_colors = self.formatter._theme + formatted_heading_output_part = ( + f'{current_section_heading_indent}{theme_colors.heading}' + f'{heading_title_text}{theme_colors.reset}\n' ) - else: - heading = '' - # join the section-initial newline, the heading and the help - return join(['\n', heading, item_help, '\n']) + section_output_parts = [ + '\n', + formatted_heading_output_part, + rendered_items_help, + '\n' + ] + + return self.formatter._join_parts(section_output_parts) def _add_item(self, func, args): self._current_section.items.append((func, args)) @@ -286,11 +329,6 @@ def add_argument(self, action): for subaction in self._iter_indented_subactions(action): invocation_lengths.append(len(get_invocation(subaction)) + self._current_indent) - # update the maximum item length - action_length = max(invocation_lengths) - self._action_max_length = max(self._action_max_length, - action_length) - # add the item to the list self._add_item(self._format_action, [action]) @@ -302,12 +340,93 @@ def add_arguments(self, actions): # Help-formatting methods # ======================= + def _collect_all_action_details(self): + """ + Helper for format_help: Traverses all sections starting from the root + and collects details about each action (like its invocation string length + and current indent level). This information is used to determine the + optimal global alignment for help text. + """ + all_details = [] + # Indent for actions directly within top-level sections. + initial_actions_indent = self._indent_increment + + for item_func, _ in self._root_section.items: + # Attempt to get the section object if item_func is a bound method of a section + section_candidate = getattr(item_func, '__self__', None) + if isinstance(section_candidate, self._Section): + details_from_section = self._get_action_details_for_pass( + section_candidate, + initial_actions_indent + ) + all_details.extend(details_from_section) + return all_details + + def _calculate_global_help_start_column(self, all_action_details): + """ + Helper for format_help: Calculates the single, globally optimal starting column + for all help text associated with actions. This aims to align help texts neatly. + """ + if not all_action_details: + # No actions with help were found, so use the default adaptive start column. + return self._adaptive_help_start_column + + min_padding = 2 # Shortened for local brevity + max_end_col_for_reasonable_actions = 0 + + for detail in all_action_details: + # The column where this action's invocation string (not including color codes) ends. + action_invocation_end_col = detail['indent'] + detail['inv_len'] + + # An action is "reasonable" to align with if its help text can start + # at or before the general adaptive help start column. + is_reasonable_to_align = ( + action_invocation_end_col + min_padding <= self._adaptive_help_start_column + ) + + if is_reasonable_to_align: + max_end_col_for_reasonable_actions = max( + max_end_col_for_reasonable_actions, + action_invocation_end_col + ) + + # If at least one "reasonable" action was found (whose end column > 0) + if max_end_col_for_reasonable_actions > 0: + desired_global_alignment_col = max_end_col_for_reasonable_actions + min_padding + # The global alignment should not exceed the adaptive limit. + return min(desired_global_alignment_col, self._adaptive_help_start_column) + else: + # No actions were "reasonable" to use for alignment, or all had end_col 0. + return self._adaptive_help_start_column + + def format_help(self): - help = self._root_section.format_help() - if help: - help = self._long_break_matcher.sub('\n\n', help) - help = help.strip('\n') + '\n' - return help + """ + Formats the full help message. + This orchestrates the collection of action details for alignment, + calculates the global help start column, and then formats all sections. + """ + # First Pass: Collect details from all actions to determine alignment. + all_action_details = self._collect_all_action_details() + + # Calculate and store the global starting column for help text. + # This value will be used by _format_action during the actual formatting pass. + self._globally_calculated_help_start_col = \ + self._calculate_global_help_start_column(all_action_details) + + # Second Pass: Actually format the help content using the calculated alignment. + raw_help_output = self._root_section.format_help() + + if not raw_help_output: # Handles None or empty string + return "" + + # Post-processing: + # 1. Consolidate multiple consecutive blank lines into a single blank line. + processed_help = self._long_break_matcher.sub('\n\n', raw_help_output) + # 2. Ensure the help message is stripped of leading/trailing newlines and ends with a single newline. + processed_help = processed_help.strip('\n') + '\n' + + return processed_help def _join_parts(self, part_strings): return ''.join([part @@ -527,59 +646,80 @@ def _format_text(self, text): return self._fill_text(text, text_width, indent) + '\n\n' 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) - action_header_no_color = self._decolor(action_header) - - # 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 len(action_header_no_color) <= action_width: - # calculate widths without color codes - action_header_color = action_header - tup = self._current_indent, '', action_width, action_header_no_color - action_header = '%*s%-*s ' % tup - # swap in the colored header - action_header = action_header.replace( - action_header_no_color, action_header_color - ) - indent_first = 0 + """ + Formats the help for a single action (argument). + This includes the action's invocation string and its help text, + aligning the help text based on _globally_calculated_help_start_col. + """ + action_invocation_str = self._format_action_invocation(action) + action_invocation_len_no_color = len(self._decolor(action_invocation_str)) + + current_indent_str = ' ' * self._current_indent + # The column where help text should ideally start. + help_alignment_col = self._globally_calculated_help_start_col + min_padding_after_invocation = 2 + + output_parts = [] - # long action name; start on the next line + # Check if there's meaningful help text (not None, not empty, not just whitespace) + has_meaningful_help = action.help and action.help.strip() + + help_starts_on_same_line = False + if has_meaningful_help: + # Determine if the action invocation is short enough for help to start on the same line + max_invocation_len_for_same_line = ( + help_alignment_col - self._current_indent - min_padding_after_invocation + ) + if action_invocation_len_no_color <= max_invocation_len_for_same_line: + help_starts_on_same_line = True + + if not has_meaningful_help: + output_parts.append(f"{current_indent_str}{action_invocation_str}\n") + elif help_starts_on_same_line: + # Help will start on the same line. Add invocation and necessary padding. + # No newline yet, as the first line of help will be appended to this part. + num_padding_spaces = help_alignment_col - \ + (self._current_indent + action_invocation_len_no_color) + padding_str = ' ' * num_padding_spaces + output_parts.append(f"{current_indent_str}{action_invocation_str}{padding_str}") 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 and action.help.strip(): - help_text = self._expand_help(action) - if help_text: - help_lines = self._split_lines(help_text, help_width) - 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 + output_parts.append(f"{current_indent_str}{action_invocation_str}\n") + + if has_meaningful_help: + expanded_help_text = self._expand_help(action) + + # Calculate available width for wrapping, ensuring a minimum sensible width (e.g., 11). + help_text_wrapping_width = max(self._width - help_alignment_col, 11) + + split_help_lines = self._split_lines(expanded_help_text, help_text_wrapping_width) + + if not split_help_lines: + # Help was present (e.g., " details ") but became empty after expansion/splitting. + # If the invocation part doesn't already end with a newline (because help was intended for the same line), + # add a newline now. + if help_starts_on_same_line and output_parts and not output_parts[-1].endswith('\n'): + output_parts[-1] += '\n' + else: + first_help_line = split_help_lines[0] + remaining_help_lines = split_help_lines[1:] + + help_line_indent_str = ' ' * help_alignment_col + + if help_starts_on_same_line: + # Append the first help line to the existing invocation part. + output_parts[-1] += f"{first_help_line}\n" + else: + # Help starts on a new line, indented to the help_alignment_col. + output_parts.append(f"{help_line_indent_str}{first_help_line}\n") + + # Add any subsequent wrapped help lines, each indented. + for line_content in remaining_help_lines: + output_parts.append(f"{help_line_indent_str}{line_content}\n") + for subaction in self._iter_indented_subactions(action): - parts.append(self._format_action(subaction)) + output_parts.append(self._format_action(subaction)) - # return a single string - return self._join_parts(parts) + return self._join_parts(output_parts) def _format_action_invocation(self, action): t = self._theme diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 58853ba4eb3674..ba1c44b576f307 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -2749,7 +2749,8 @@ def test_help_blank(self): main description positional arguments: - foo \n + foo + options: -h, --help show this help message and exit ''')) @@ -2920,18 +2921,18 @@ def test_alias_help(self): main description positional arguments: - bar bar help + bar bar help options: - -h, --help show this help message and exit - --foo foo help + -h, --help show this help message and exit + --foo foo help commands: COMMAND 1 (1alias1, 1alias2) - 1 help - 2 2 help - 3 3 help + 1 help + 2 2 help + 3 3 help """)) # ============ @@ -3293,7 +3294,7 @@ def test_help_subparser_all_mutually_exclusive_group_members_suppressed(self): [{longopt} {longmeta}] options: - -h, --help show this help message and exit + -h, --help show this help message and exit {longopt} {longmeta} ''' self.assertEqual(cmd_foo.format_help(), textwrap.dedent(expected)) @@ -4349,27 +4350,23 @@ class TestHelpWrappingLongNames(HelpTestCase): positional arguments: yyyyyyyyyyyyyyyyyyyyyyyyy - YH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH \ -YHYH YHYH - YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YH + YH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH + YHYH YHYH YHYH YHYH YHYH YHYH YHYH YHYH YH options: - -h, --help show this help message and exit - -v, --version show program's version number and exit + -h, --help show this help message and exit + -v, --version show program's version number and exit -x XXXXXXXXXXXXXXXXXXXXXXXXX - XH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH \ -XHXH XHXH - XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XH + XH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH + XHXH XHXH XHXH XHXH XHXH XHXH XHXH XHXH XH ALPHAS: -a AAAAAAAAAAAAAAAAAAAAAAAAA - AH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH \ -AHAH AHAH - AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AH + AH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH + AHAH AHAH AHAH AHAH AHAH AHAH AHAH AHAH AH zzzzzzzzzzzzzzzzzzzzzzzzz - ZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH \ -ZHZH ZHZH - ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZH + ZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH + ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZHZH ZH ''' version = '''\ V VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV VV \ @@ -4413,24 +4410,24 @@ class TestHelpUsage(HelpTestCase): help = usage + '''\ positional arguments: - a a - b b - c c + a a + b b + c c options: - -h, --help show this help message and exit - -w W [W ...] w - -x [X ...] x - --foo, --no-foo Whether to foo - --bar, --no-bar Whether to bar + -h, --help show this help message and exit + -w W [W ...] w + -x [X ...] x + --foo, --no-foo Whether to foo + --bar, --no-bar Whether to bar -f, --foobar, --no-foobar, --barfoo, --no-barfoo - --bazz, --no-bazz Bazz! + --bazz, --no-bazz Bazz! group: - -y [Y] y - -z Z Z Z z - d d - e e + -y [Y] y + -z Z Z Z z + d d + e e ''' version = '' @@ -4544,7 +4541,7 @@ class TestHelpUsageLongProgOptionsWrap(HelpTestCase): b options: - -h, --help show this help message and exit + -h, --help show this help message and exit -w WWWWWWWWWWWWWWWWWWWWWWWWW -x XXXXXXXXXXXXXXXXXXXXXXXXX -y YYYYYYYYYYYYYYYYYYYYYYYYY @@ -4607,7 +4604,7 @@ class TestHelpUsageOptionalsWrap(HelpTestCase): c options: - -h, --help show this help message and exit + -h, --help show this help message and exit -w WWWWWWWWWWWWWWWWWWWWWWWWW -x XXXXXXXXXXXXXXXXXXXXXXXXX -y YYYYYYYYYYYYYYYYYYYYYYYYY @@ -4642,7 +4639,7 @@ class TestHelpUsagePositionalsWrap(HelpTestCase): ccccccccccccccccccccccccc options: - -h, --help show this help message and exit + -h, --help show this help message and exit -x X -y Y -z Z @@ -4678,7 +4675,7 @@ class TestHelpUsageOptionalsPositionalsWrap(HelpTestCase): ccccccccccccccccccccccccc options: - -h, --help show this help message and exit + -h, --help show this help message and exit -x XXXXXXXXXXXXXXXXXXXXXXXXX -y YYYYYYYYYYYYYYYYYYYYYYYYY -z ZZZZZZZZZZZZZZZZZZZZZZZZZ @@ -4704,7 +4701,7 @@ class TestHelpUsageOptionalsOnlyWrap(HelpTestCase): help = usage + '''\ options: - -h, --help show this help message and exit + -h, --help show this help message and exit -x XXXXXXXXXXXXXXXXXXXXXXXXX -y YYYYYYYYYYYYYYYYYYYYYYYYY -z ZZZZZZZZZZZZZZZZZZZZZZZZZ @@ -5548,13 +5545,14 @@ def custom_formatter(prog): usage: PROG [-h] CMD ... options: - -h, --help show this help message and exit + -h, --help show this help message and exit commands: - CMD command to use - add add something - remove remove something - a-very-long-command command that does something + CMD command to use + add add something + remove remove something + a-very-long-command + command that does something ''')) @@ -6836,7 +6834,7 @@ def test_help_with_metavar(self): [-h] [--proxy ] options: - -h, --help show this help message and exit + -h, --help show this help message and exit --proxy ''')) @@ -7199,24 +7197,24 @@ def test_argparse_color(self): Colorful help {heading}positional arguments:{reset} - {pos_b}x{reset} the base - {pos_b}y{reset} the exponent + {pos_b}x{reset} the base + {pos_b}y{reset} the exponent {pos_b}this_indeed_is_a_very_long_action_name{reset} - the exponent + the exponent {heading}options:{reset} - {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit - {short_b}-v{reset}, {long_b}--verbose{reset} more spam (default: False) - {short_b}-q{reset}, {long_b}--quiet{reset} less spam (default: False) + {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit + {short_b}-v{reset}, {long_b}--verbose{reset} more spam (default: False) + {short_b}-q{reset}, {long_b}--quiet{reset} less spam (default: False) {short_b}-o{reset}, {long_b}--optional1{reset} {long_b}--optional2{reset} {label_b}OPTIONAL2{reset} - pick one (default: None) + pick one (default: None) {long_b}--optional3{reset} {label_b}{{X,Y,Z}}{reset} - {long_b}--optional4{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) - {long_b}--optional5{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) - {long_b}--optional6{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) + {long_b}--optional4{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) + {long_b}--optional5{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) + {long_b}--optional6{reset} {label_b}{{X,Y,Z}}{reset} pick one (default: None) {short_b}-p{reset}, {long_b}--optional7{reset} {label_b}{{Aaaaa,Bbbbb,Ccccc,Ddddd}}{reset} - pick one (default: None) + pick one (default: None) {short_b}+f{reset} {label_b}F{reset} {long_b}++bar{reset} {label_b}BAR{reset} {long_b}-+baz{reset} {label_b}BAZ{reset} @@ -7225,9 +7223,9 @@ def test_argparse_color(self): {heading}subcommands:{reset} valid subcommands - {pos_b}{{sub1,sub2}}{reset} additional help - {pos_b}sub1{reset} sub1 help - {pos_b}sub2{reset} sub2 help + {pos_b}{{sub1,sub2}}{reset} additional help + {pos_b}sub1{reset} sub1 help + {pos_b}sub2{reset} sub2 help """ ), ) @@ -7290,11 +7288,11 @@ def custom_formatter(prog): {heading}usage: {reset}{prog}PROG{reset} [{short}-h{reset}] [{short}+f {label}FOO{reset}] {pos}spam{reset} {heading}positional arguments:{reset} - {pos_b}spam{reset} spam help + {pos_b}spam{reset} spam help {heading}options:{reset} - {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit - {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset} foo help + {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit + {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset} foo help ''')) def test_custom_formatter_class(self): @@ -7327,11 +7325,11 @@ def __init__(self, prog): {heading}usage: {reset}{prog}PROG{reset} [{short}-h{reset}] [{short}+f {label}FOO{reset}] {pos}spam{reset} {heading}positional arguments:{reset} - {pos_b}spam{reset} spam help + {pos_b}spam{reset} spam help {heading}options:{reset} - {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit - {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset} foo help + {short_b}-h{reset}, {long_b}--help{reset} show this help message and exit + {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset} foo help '''))