Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,14 @@ def _format_usage(self, usage, actions, groups, prefix):
if len(prefix) + len(self._decolor(usage)) > text_width:

# break usage into wrappable parts
opt_parts = self._get_actions_usage_parts(optionals, groups)
pos_parts = self._get_actions_usage_parts(positionals, groups)
# keep optionals and positionals together to preserve
# mutually exclusive group formatting (gh-75949)
all_actions = optionals + positionals
parts, pos_start = self._get_actions_usage_parts_with_split(
all_actions, groups, len(optionals)
)
opt_parts = parts[:pos_start]
pos_parts = parts[pos_start:]

# helper for wrapping lines
def get_lines(parts, indent, prefix=None):
Expand Down Expand Up @@ -418,6 +424,17 @@ def _is_long_option(self, string):
return len(string) > 2

def _get_actions_usage_parts(self, actions, groups):
parts, _ = self._get_actions_usage_parts_with_split(actions, groups)
return parts

def _get_actions_usage_parts_with_split(self, actions, groups, opt_count=None):
"""Get usage parts with split index for optionals/positionals.
Returns (parts, pos_start) where pos_start is the index in parts
where positionals begin. When opt_count is None, pos_start is None.
This preserves mutually exclusive group formatting across the
optionals/positionals boundary (gh-75949).
"""
# find group indices and identify actions in groups
group_actions = set()
inserts = {}
Expand Down Expand Up @@ -513,8 +530,16 @@ def _get_actions_usage_parts(self, actions, groups):
for i in range(start + group_size, end):
parts[i] = None

# return the usage parts
return [item for item in parts if item is not None]
# if opt_count is provided, calculate where positionals start in
# the final parts list (for wrapping onto separate lines).
# Count before filtering None entries since indices shift after.
if opt_count is not None:
pos_start = sum(1 for p in parts[:opt_count] if p is not None)
else:
pos_start = None

# return the usage parts and split point (gh-75949)
return [item for item in parts if item is not None], pos_start

def _format_text(self, text):
if '%(prog)' in text:
Expand Down
19 changes: 19 additions & 0 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -4966,6 +4966,25 @@ def test_long_mutex_groups_wrap(self):
''')
self.assertEqual(parser.format_usage(), usage)

def test_mutex_groups_with_mixed_optionals_positionals_wrap(self):
# https://github.com/python/cpython/issues/75949
# Mutually exclusive groups containing both optionals and positionals
# should preserve pipe separators when the usage line wraps.
parser = argparse.ArgumentParser(prog='PROG')
g = parser.add_mutually_exclusive_group()
g.add_argument('-v', '--verbose', action='store_true')
g.add_argument('-q', '--quiet', action='store_true')
g.add_argument('-x', '--extra-long-option-name', nargs='?')
g.add_argument('-y', '--yet-another-long-option', nargs='?')
g.add_argument('positional', nargs='?')

usage = textwrap.dedent('''\
usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
-y [YET_ANOTHER_LONG_OPTION] |
positional]
''')
self.assertEqual(parser.format_usage(), usage)


class TestHelpVariableExpansion(HelpTestCase):
"""Test that variables are expanded properly in help messages"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix :mod:`argparse` to preserve ``|`` separators in mutually exclusive groups when the usage line wraps due to length.
Loading