Skip to content

Commit 56c0ee3

Browse files
committed
gh-134200: Add adaptive global alignment for help text
1 parent e3dda8f commit 56c0ee3

2 files changed

Lines changed: 329 additions & 139 deletions

File tree

Lib/argparse.py

Lines changed: 265 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ def __init__(
181181
self._max_help_position = min(max_help_position,
182182
max(width - 20, indent_increment * 2))
183183
self._width = width
184+
self._adaptive_help_start_column = min(max_help_position,
185+
max(self._width - 20, indent_increment * 2))
186+
self._globally_calculated_help_start_col = self._adaptive_help_start_column
184187

185188
self._current_indent = 0
186189
self._level = 0
@@ -192,6 +195,37 @@ def __init__(
192195
self._whitespace_matcher = _re.compile(r'\s+', _re.ASCII)
193196
self._long_break_matcher = _re.compile(r'\n\n\n+')
194197

198+
def _get_action_details_for_pass(self, section, current_indent_for_section_items):
199+
"""
200+
Recursively collects details for actions within a given section and its subsections.
201+
These details (action object, invocation length, indent) are used for calculating
202+
the global help text alignment column.
203+
"""
204+
collected_details = []
205+
206+
for func_to_call, args_for_func in section.items:
207+
if func_to_call == self._format_action and args_for_func:
208+
action_object = args_for_func[0]
209+
if action_object.help is not SUPPRESS:
210+
invocation_string = self._format_action_invocation(action_object)
211+
# Length without color codes is needed for alignment.
212+
invocation_length = len(self._decolor(invocation_string))
213+
214+
collected_details.append({
215+
'action': action_object,
216+
'inv_len': invocation_length,
217+
'indent': current_indent_for_section_items,
218+
})
219+
elif hasattr(func_to_call, '__self__') and isinstance(func_to_call.__self__, self._Section):
220+
sub_section_object = func_to_call.__self__
221+
222+
indent_for_subsection_items = current_indent_for_section_items + self._indent_increment
223+
224+
collected_details.extend(
225+
self._get_action_details_for_pass(sub_section_object, indent_for_subsection_items)
226+
)
227+
return collected_details
228+
195229
def _set_color(self, color):
196230
from _colorize import can_colorize, decolor, get_theme
197231

@@ -224,32 +258,59 @@ def __init__(self, formatter, parent, heading=None):
224258
self.items = []
225259

226260
def format_help(self):
227-
# format the indented section
228-
if self.parent is not None:
261+
"""
262+
Formats the help for this section, including its heading and all items.
263+
"""
264+
is_subsection = self.parent is not None
265+
if is_subsection:
229266
self.formatter._indent()
230-
join = self.formatter._join_parts
231-
item_help = join([func(*args) for func, args in self.items])
232-
if self.parent is not None:
267+
268+
# Generate help strings for all items (actions, text, subsections) in this section
269+
item_help_strings = [func(*args) for func, args in self.items]
270+
rendered_items_help = self.formatter._join_parts(item_help_strings)
271+
272+
if is_subsection:
273+
# Restore indent level after formatting subsection items
233274
self.formatter._dedent()
234275

235276
# return nothing if the section was empty
236-
if not item_help:
277+
if not rendered_items_help:
237278
return ''
238279

239-
# add the heading if the section was non-empty
280+
# If we're here, rendered_items_help is not empty.
281+
# Now, format the heading for this section if it exists and is not suppressed.
282+
formatted_heading_output_part = ""
240283
if self.heading is not SUPPRESS and self.heading is not None:
241-
current_indent = self.formatter._current_indent
242-
heading_text = _('%(heading)s:') % dict(heading=self.heading)
243-
t = self.formatter._theme
244-
heading = (
245-
f'{" " * current_indent}'
246-
f'{t.heading}{heading_text}{t.reset}\n'
284+
current_section_heading_indent = ' ' * self.formatter._current_indent
285+
286+
try:
287+
# This line checks if global `_` is defined.
288+
# If `_` is not defined in any accessible scope, it raises NameError.
289+
_
290+
except NameError:
291+
# If global `_` was not found, this line defines `_` in the global scope
292+
# as a no-op lambda function.
293+
_ = lambda text_to_translate: text_to_translate
294+
295+
# Now, call `_` directly. It is guaranteed to be defined at this point
296+
# (either as the original gettext `_` or the no-op lambda).
297+
# `xgettext` will correctly identify `_('%(heading)s:')` as translatable.
298+
heading_title_text = _('%(heading)s:') % {'heading': self.heading}
299+
300+
theme_colors = self.formatter._theme
301+
formatted_heading_output_part = (
302+
f'{current_section_heading_indent}{theme_colors.heading}'
303+
f'{heading_title_text}{theme_colors.reset}\n'
247304
)
248-
else:
249-
heading = ''
250-
251-
# join the section-initial newline, the heading and the help
252-
return join(['\n', heading, item_help, '\n'])
305+
306+
section_output_parts = [
307+
'\n',
308+
formatted_heading_output_part,
309+
rendered_items_help,
310+
'\n'
311+
]
312+
313+
return self.formatter._join_parts(section_output_parts)
253314

254315
def _add_item(self, func, args):
255316
self._current_section.items.append((func, args))
@@ -302,12 +363,105 @@ def add_arguments(self, actions):
302363
# Help-formatting methods
303364
# =======================
304365

366+
def _collect_all_action_details(self):
367+
"""
368+
Helper for format_help: Traverses all sections starting from the root
369+
and collects details about each action (like its invocation string length
370+
and current indent level). This information is used to determine the
371+
optimal global alignment for help text.
372+
"""
373+
all_details = []
374+
# Actions within top-level sections (direct children of _root_section, like "options:")
375+
# will have an initial indent.
376+
indent_for_actions_in_top_level_sections = self._indent_increment # Typically 2 spaces
377+
378+
for item_func, _item_args in self._root_section.items:
379+
section_candidate = getattr(item_func, '__self__', None)
380+
if isinstance(section_candidate, self._Section):
381+
top_level_section = section_candidate
382+
details_from_this_section = self._get_action_details_for_pass(
383+
top_level_section,
384+
indent_for_actions_in_top_level_sections
385+
)
386+
all_details.extend(details_from_this_section)
387+
return all_details
388+
389+
def _calculate_global_help_start_column(self, all_action_details):
390+
"""
391+
Helper for format_help: Calculates the single, globally optimal starting column
392+
for all help text associated with actions. This aims to align help texts neatly.
393+
"""
394+
if not all_action_details:
395+
# No actions with help were found, so use the default adaptive start column.
396+
return self._adaptive_help_start_column
397+
398+
min_padding_between_action_and_help = 2
399+
# Track the maximum endpoint (indent + length) of action strings that can
400+
# "reasonably" have their help text start on the same line without exceeding
401+
# the _adaptive_help_start_column for the help text itself.
402+
max_endpoint_of_reasonable_actions = 0
403+
404+
for detail in all_action_details:
405+
# The column where this action's invocation string ends.
406+
action_invocation_end_column = detail['indent'] + detail['inv_len']
407+
408+
# An action is "reasonable" if its help text can start on the same line,
409+
# aligned at or before _adaptive_help_start_column, while maintaining minimum padding.
410+
is_reasonable_to_align_with_others = \
411+
(action_invocation_end_column + min_padding_between_action_and_help <=
412+
self._adaptive_help_start_column)
413+
414+
if is_reasonable_to_align_with_others:
415+
max_endpoint_of_reasonable_actions = max(
416+
max_endpoint_of_reasonable_actions,
417+
action_invocation_end_column
418+
)
419+
420+
if max_endpoint_of_reasonable_actions > 0:
421+
# At least one action fits the "reasonable" criteria.
422+
# The desired alignment column is after the longest of these "reasonable" actions, plus padding.
423+
desired_global_alignment_column = \
424+
max_endpoint_of_reasonable_actions + min_padding_between_action_and_help
425+
426+
# However, this alignment should not exceed the user's preferred _adaptive_help_start_column.
427+
return min(desired_global_alignment_column, self._adaptive_help_start_column)
428+
else:
429+
# No action was "reasonable" (e.g., all actions are very long, or _adaptive_help_start_column is too small).
430+
# In this scenario, fall back to using _adaptive_help_start_column.
431+
# Help text for most actions will likely start on a new line, indented to this column.
432+
return self._adaptive_help_start_column
433+
434+
305435
def format_help(self):
306-
help = self._root_section.format_help()
307-
if help:
308-
help = self._long_break_matcher.sub('\n\n', help)
309-
help = help.strip('\n') + '\n'
310-
return help
436+
"""
437+
Formats the full help message.
438+
This orchestrates the collection of action details for alignment,
439+
calculates the global help start column, and then formats all sections.
440+
"""
441+
# First Pass: Collect details of all actions to determine optimal help text alignment.
442+
# This populates a list of dictionaries, each with action details.
443+
all_action_details = self._collect_all_action_details()
444+
445+
# Calculate and set the global starting column for help text based on these details.
446+
# This value (self._globally_calculated_help_start_col) will be used by _format_action.
447+
self._globally_calculated_help_start_col = \
448+
self._calculate_global_help_start_column(all_action_details)
449+
450+
# Second Pass: Actually format the help.
451+
# This will recursively call _Section.format_help for all sections,
452+
# which in turn call item formatters like _format_action, _format_text.
453+
# These formatting methods will use the self._globally_calculated_help_start_col set above.
454+
raw_help_output = self._root_section.format_help()
455+
456+
# Post-process the generated help string for final presentation.
457+
if raw_help_output:
458+
# Consolidate multiple consecutive blank lines into a single blank line.
459+
processed_help_output = self._long_break_matcher.sub('\n\n', raw_help_output)
460+
# Ensure the help message ends with a single newline and strip any other leading/trailing newlines.
461+
processed_help_output = processed_help_output.strip('\n') + '\n'
462+
return processed_help_output
463+
464+
return "" # Return an empty string if no help content was generated.
311465

312466
def _join_parts(self, part_strings):
313467
return ''.join([part
@@ -527,59 +681,97 @@ def _format_text(self, text):
527681
return self._fill_text(text, text_width, indent) + '\n\n'
528682

529683
def _format_action(self, action):
530-
# determine the required width and the entry label
531-
help_position = min(self._action_max_length + 2,
532-
self._max_help_position)
533-
help_width = max(self._width - help_position, 11)
534-
action_width = help_position - self._current_indent - 2
535-
action_header = self._format_action_invocation(action)
536-
action_header_no_color = self._decolor(action_header)
537-
538-
# no help; start on same line and add a final newline
539-
if not action.help:
540-
tup = self._current_indent, '', action_header
541-
action_header = '%*s%s\n' % tup
542-
543-
# short action name; start on the same line and pad two spaces
544-
elif len(action_header_no_color) <= action_width:
545-
# calculate widths without color codes
546-
action_header_color = action_header
547-
tup = self._current_indent, '', action_width, action_header_no_color
548-
action_header = '%*s%-*s ' % tup
549-
# swap in the colored header
550-
action_header = action_header.replace(
551-
action_header_no_color, action_header_color
552-
)
553-
indent_first = 0
554-
555-
# long action name; start on the next line
684+
"""
685+
Formats the help for a single action (argument).
686+
This includes the action's invocation string and its help text,
687+
aligning the help text based on _globally_calculated_help_start_col.
688+
"""
689+
action_invocation_string = self._format_action_invocation(action)
690+
action_invocation_len_no_color = len(self._decolor(action_invocation_string))
691+
current_action_item_indent_str = ' ' * self._current_indent
692+
globally_determined_help_start_col = self._globally_calculated_help_start_col
693+
min_padding_after_action_invocation = 2
694+
output_parts = []
695+
696+
# Determine the maximum length the action_invocation_string (decolored) can be
697+
# for its help text to start on the same line, aligned at globally_determined_help_start_col,
698+
# while respecting self._current_indent and min_padding_after_action_invocation.
699+
max_action_invocation_len_for_same_line_help = \
700+
globally_determined_help_start_col - self._current_indent - min_padding_after_action_invocation
701+
702+
action_invocation_line_part = ""
703+
help_should_start_on_new_line = True
704+
705+
# The actual column where the first line of help text (and subsequent wrapped lines) will start.
706+
actual_help_text_alignment_column = globally_determined_help_start_col
707+
708+
has_help_text = action.help and action.help.strip()
709+
710+
if has_help_text:
711+
if action_invocation_len_no_color <= max_action_invocation_len_for_same_line_help:
712+
# Action invocation is short enough: help text can start on the same line.
713+
# Calculate the number of padding spaces needed to align the help text correctly.
714+
num_padding_spaces = globally_determined_help_start_col - \
715+
(self._current_indent + action_invocation_len_no_color)
716+
717+
action_invocation_line_part = (
718+
f"{current_action_item_indent_str}{action_invocation_string}"
719+
f"{' ' * num_padding_spaces}"
720+
)
721+
help_should_start_on_new_line = False
722+
else:
723+
action_invocation_line_part = f"{current_action_item_indent_str}{action_invocation_string}\n"
556724
else:
557-
tup = self._current_indent, '', action_header
558-
action_header = '%*s%s\n' % tup
559-
indent_first = help_position
560-
561-
# collect the pieces of the action help
562-
parts = [action_header]
563-
564-
# if there was help for the action, add lines of help text
565-
if action.help and action.help.strip():
566-
help_text = self._expand_help(action)
567-
if help_text:
568-
help_lines = self._split_lines(help_text, help_width)
569-
parts.append('%*s%s\n' % (indent_first, '', help_lines[0]))
570-
for line in help_lines[1:]:
571-
parts.append('%*s%s\n' % (help_position, '', line))
572-
573-
# or add a newline if the description doesn't end with one
574-
elif not action_header.endswith('\n'):
575-
parts.append('\n')
576-
577-
# if there are any sub-actions, add their help as well
725+
action_invocation_line_part = f"{current_action_item_indent_str}{action_invocation_string}\n"
726+
727+
output_parts.append(action_invocation_line_part)
728+
729+
if has_help_text:
730+
expanded_help_text = self._expand_help(action)
731+
732+
# Calculate the available width for wrapping the help text.
733+
# The help text block starts at actual_help_text_alignment_column and extends to self._width.
734+
help_text_wrapping_width = max(self._width - actual_help_text_alignment_column, 11)
735+
736+
split_help_lines = self._split_lines(expanded_help_text, help_text_wrapping_width)
737+
738+
if split_help_lines: # Proceed only if splitting the help text yields any lines.
739+
first_help_line_content = split_help_lines[0]
740+
remaining_help_lines_content = split_help_lines[1:]
741+
742+
if help_should_start_on_new_line:
743+
# Help starts on a new line, indented to actual_help_text_alignment_column.
744+
output_parts.append(f"{' ' * actual_help_text_alignment_column}{first_help_line_content}\n")
745+
else:
746+
# Help starts on the same line as action_invocation_line_part.
747+
# Append the first_help_line_content to the last part in output_parts.
748+
# (action_invocation_line_part does not end with \n in this case).
749+
output_parts[-1] += f"{first_help_line_content}\n"
750+
751+
# Add any subsequent wrapped help lines, each indented to actual_help_text_alignment_column.
752+
for line_content in remaining_help_lines_content:
753+
output_parts.append(f"{' ' * actual_help_text_alignment_column}{line_content}\n")
754+
755+
elif not output_parts[-1].endswith('\n'):
756+
# Case: has_help_text was true (action.help existed), but it was empty after strip()
757+
# or _split_lines returned empty. If action_invocation_line_part didn't end with \n
758+
# (because help_should_start_on_new_line was false), add a newline.
759+
output_parts[-1] += '\n'
760+
761+
elif output_parts and not output_parts[-1].endswith('\n'):
762+
# This handles the unlikely case where there's no help text, but action_invocation_line_part
763+
# (which is output_parts[-1]) somehow didn't end with a newline.
764+
# Based on the logic above, action_invocation_line_part should always end with \n if no help text.
765+
# This is mostly defensive.
766+
output_parts[-1] += '\n'
767+
768+
# Recursively format any subactions associated with this action.
769+
# The _iter_indented_subactions method manages _indent() and _dedent() calls internally,
770+
# ensuring self._current_indent is correctly set for these recursive _format_action calls.
578771
for subaction in self._iter_indented_subactions(action):
579-
parts.append(self._format_action(subaction))
772+
output_parts.append(self._format_action(subaction))
580773

581-
# return a single string
582-
return self._join_parts(parts)
774+
return self._join_parts(output_parts)
583775

584776
def _format_action_invocation(self, action):
585777
t = self._theme

0 commit comments

Comments
 (0)