Skip to content

Commit 4cd5b49

Browse files
Fix dropped pipes when mutex args wrap
1 parent b3bf212 commit 4cd5b49

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

Lib/argparse.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,14 @@ def _format_usage(self, usage, actions, groups, prefix):
355355
if len(prefix) + len(self._decolor(usage)) > text_width:
356356

357357
# break usage into wrappable parts
358-
opt_parts = self._get_actions_usage_parts(optionals, groups)
359-
pos_parts = self._get_actions_usage_parts(positionals, groups)
358+
# keep optionals and positionals together to preserve
359+
# mutually exclusive group formatting (gh-75949)
360+
all_actions = optionals + positionals
361+
parts, pos_start = self._get_actions_usage_parts_split(
362+
all_actions, groups, len(optionals)
363+
)
364+
opt_parts = parts[:pos_start]
365+
pos_parts = parts[pos_start:]
360366

361367
# helper for wrapping lines
362368
def get_lines(parts, indent, prefix=None):
@@ -420,6 +426,17 @@ def _is_long_option(self, string):
420426
return len(string) > 2
421427

422428
def _get_actions_usage_parts(self, actions, groups):
429+
parts, _ = self._get_actions_usage_parts_split(actions, groups, None)
430+
return parts
431+
432+
def _get_actions_usage_parts_split(self, actions, groups, opt_count):
433+
"""Get usage parts with split index for optionals/positionals.
434+
435+
Returns (parts, pos_start) where pos_start is the index in parts
436+
where positionals begin. When opt_count is None, pos_start is None.
437+
This preserves mutually exclusive group formatting across the
438+
optionals/positionals boundary (gh-75949).
439+
"""
423440
# find group indices and identify actions in groups
424441
group_actions = set()
425442
inserts = {}
@@ -515,8 +532,16 @@ def _get_actions_usage_parts(self, actions, groups):
515532
for i in range(start + group_size, end):
516533
parts[i] = None
517534

518-
# return the usage parts
519-
return [item for item in parts if item is not None]
535+
# calculate the split point for optionals/positionals
536+
# before filtering out None entries
537+
if opt_count is not None:
538+
# Count non-None parts in the optionals section
539+
pos_start = sum(1 for p in parts[:opt_count] if p is not None)
540+
else:
541+
pos_start = None
542+
543+
# return the usage parts and split point
544+
return [item for item in parts if item is not None], pos_start
520545

521546
def _format_text(self, text):
522547
if '%(prog)' in text:

Lib/test/test_argparse.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4966,6 +4966,28 @@ def test_long_mutex_groups_wrap(self):
49664966
''')
49674967
self.assertEqual(parser.format_usage(), usage)
49684968

4969+
def test_mutex_groups_with_mixed_optionals_positionals_wrap(self):
4970+
# https://github.com/python/cpython/issues/75949
4971+
# Mutually exclusive groups containing both optionals and positionals
4972+
# should preserve pipe separators when the usage line wraps.
4973+
parser = argparse.ArgumentParser(prog='PROG')
4974+
g = parser.add_mutually_exclusive_group()
4975+
g.add_argument('-v', '--verbose', action='store_true')
4976+
g.add_argument('-q', '--quiet', action='store_true')
4977+
g.add_argument('-x', '--extra-long-option-name', nargs='?')
4978+
g.add_argument('-y', '--yet-another-long-option', nargs='?')
4979+
g.add_argument('positional1', nargs='?')
4980+
g.add_argument('positional2', nargs='?')
4981+
g.add_argument('positional3', nargs='?')
4982+
g.add_argument('positional4', nargs='?')
4983+
4984+
usage = textwrap.dedent('''\
4985+
usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
4986+
-y [YET_ANOTHER_LONG_OPTION] |
4987+
positional1 | positional2 | positional3 | positional4]
4988+
''')
4989+
self.assertEqual(parser.format_usage(), usage)
4990+
49694991

49704992
class TestHelpVariableExpansion(HelpTestCase):
49714993
"""Test that variables are expanded properly in help messages"""

0 commit comments

Comments
 (0)