From 4cd5b494aba4fbc97d71d2a38d83ad10773cb03d Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 5 Dec 2025 07:49:36 -0800 Subject: [PATCH 1/5] Fix dropped pipes when mutex args wrap --- Lib/argparse.py | 33 +++++++++++++++++++++++++++++---- Lib/test/test_argparse.py | 22 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 55ecdadd8c9398..a59c1c6d4e33ea 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -355,8 +355,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_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): @@ -420,6 +426,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_split(actions, groups, None) + return parts + + def _get_actions_usage_parts_split(self, actions, groups, opt_count): + """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 = {} @@ -515,8 +532,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] + # calculate the split point for optionals/positionals + # before filtering out None entries + if opt_count is not None: + # Count non-None parts in the optionals section + 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 + return [item for item in parts if item is not None], pos_start def _format_text(self, text): if '%(prog)' in text: diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index ef90d4bcbb2a36..66e64304627a97 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -4966,6 +4966,28 @@ 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('positional1', nargs='?') + g.add_argument('positional2', nargs='?') + g.add_argument('positional3', nargs='?') + g.add_argument('positional4', nargs='?') + + usage = textwrap.dedent('''\ + usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] | + -y [YET_ANOTHER_LONG_OPTION] | + positional1 | positional2 | positional3 | positional4] + ''') + self.assertEqual(parser.format_usage(), usage) + class TestHelpVariableExpansion(HelpTestCase): """Test that variables are expanded properly in help messages""" From e17dd02f5b1cf57f16dea7e1aca598c03fe09455 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 5 Dec 2025 08:30:09 -0800 Subject: [PATCH 2/5] Remove wrapper --- Lib/argparse.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index a59c1c6d4e33ea..54f0915707a848 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -358,7 +358,7 @@ def _format_usage(self, usage, actions, groups, prefix): # 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_split( + parts, pos_start = self._get_actions_usage_parts( all_actions, groups, len(optionals) ) opt_parts = parts[:pos_start] @@ -420,23 +420,13 @@ def get_lines(parts, indent, prefix=None): return f'{t.usage}{prefix}{t.reset}{usage}\n\n' def _format_actions_usage(self, actions, groups): - return ' '.join(self._get_actions_usage_parts(actions, groups)) + parts, _ = self._get_actions_usage_parts(actions, groups) + return ' '.join(parts) def _is_long_option(self, string): return len(string) > 2 - def _get_actions_usage_parts(self, actions, groups): - parts, _ = self._get_actions_usage_parts_split(actions, groups, None) - return parts - - def _get_actions_usage_parts_split(self, actions, groups, opt_count): - """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). - """ + def _get_actions_usage_parts(self, actions, groups, opt_count=None): # find group indices and identify actions in groups group_actions = set() inserts = {} @@ -532,15 +522,15 @@ def _get_actions_usage_parts_split(self, actions, groups, opt_count): for i in range(start + group_size, end): parts[i] = None - # calculate the split point for optionals/positionals - # before filtering out None entries + # 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: - # Count non-None parts in the optionals section 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 + # 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): From 0af2bda98f3e0aebc3142d2f3983fbb6d3600885 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 5 Dec 2025 08:36:03 -0800 Subject: [PATCH 3/5] Changed my mind, back to wrapper for subclass backwards compat --- Lib/argparse.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 54f0915707a848..5e4b8e1d2593c3 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -358,7 +358,7 @@ def _format_usage(self, usage, actions, groups, prefix): # 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( + parts, pos_start = self._get_actions_usage_parts_with_split( all_actions, groups, len(optionals) ) opt_parts = parts[:pos_start] @@ -420,13 +420,23 @@ def get_lines(parts, indent, prefix=None): return f'{t.usage}{prefix}{t.reset}{usage}\n\n' def _format_actions_usage(self, actions, groups): - parts, _ = self._get_actions_usage_parts(actions, groups) - return ' '.join(parts) + return ' '.join(self._get_actions_usage_parts(actions, groups)) def _is_long_option(self, string): return len(string) > 2 - def _get_actions_usage_parts(self, actions, groups, opt_count=None): + 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 = {} From d7ea128111933653b95944e27df22e24ac406e52 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:39:21 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst diff --git a/Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst b/Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst new file mode 100644 index 00000000000000..5ca3fc05b9816d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst @@ -0,0 +1 @@ +Fix :mod:`argparse` to preserve ``|`` separators in mutually exclusive groups when the usage line wraps due to length. From 4bdadf5375015f4ee3d7b09bd63259b384716cec Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Sat, 6 Dec 2025 06:45:40 -0800 Subject: [PATCH 5/5] Update test --- Lib/test/test_argparse.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 66e64304627a97..dff7ba750fa559 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -4976,15 +4976,12 @@ def test_mutex_groups_with_mixed_optionals_positionals_wrap(self): 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('positional1', nargs='?') - g.add_argument('positional2', nargs='?') - g.add_argument('positional3', nargs='?') - g.add_argument('positional4', nargs='?') + g.add_argument('positional', nargs='?') usage = textwrap.dedent('''\ usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] | -y [YET_ANOTHER_LONG_OPTION] | - positional1 | positional2 | positional3 | positional4] + positional] ''') self.assertEqual(parser.format_usage(), usage)