From b3deacc9bda5dfab6867812050dd801c2ed81a54 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 18 Dec 2025 14:48:51 -0800 Subject: [PATCH 1/5] Format before colourization --- Lib/argparse.py | 27 +++++++++++++++++++++++---- Lib/test/test_argparse.py | 16 ++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 633fec69ea4615..14a06ceebb1226 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -688,11 +688,30 @@ def _expand_help(self, action): params[name] = value.__name__ if params.get('choices') is not None: params['choices'] = ', '.join(map(str, params['choices'])) - # Before interpolating, wrap the values with color codes + t = self._theme - for name, value in params.items(): - params[name] = f"{t.interpolated_value}{value}{t.reset}" - return help_string % params + + if not t.reset: + return help_string % params + + # Format first to preserve types for specifiers, like %x that require int. + def colorize(match): + spec, name = match.group(0, 1) + if spec == '%%': + return '%' + if name in params: + formatted = spec % {name: params[name]} + return f'{t.interpolated_value}{formatted}{t.reset}' + return spec + + # Match %% (literal %) or %(name)... format specifiers + result = _re.sub(r'%%|%\((\w+)\)[^a-z]*[a-z]', colorize, + help_string, flags=_re.IGNORECASE) + + # Check for invalid/unmatched % specifiers + if '%' in result: + raise ValueError(f"invalid format specifier in: {help_string!r}") + return result def _iter_indented_subactions(self, action): try: diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 758af98d5cb046..a48b80a42f8548 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7663,6 +7663,22 @@ def test_backtick_markup_special_regex_chars(self): help_text = parser.format_help() self.assertIn(f'{prog_extra}grep "foo.*bar" | sort{reset}', help_text) + def test_help_with_format_specifiers(self): + # GH-142950: format specifiers like %x should work with color=True + parser = argparse.ArgumentParser(prog='PROG', color=True) + parser.add_argument('--hex', type=int, default=255, + help='hex: %(default)x') + parser.add_argument('--str', default='test', + help='str: %(default)s') + + help_text = parser.format_help() + + interp = self.theme.interpolated_value + reset = self.theme.reset + + self.assertIn(f'hex: {interp}ff{reset}', help_text) + self.assertIn(f'str: {interp}test{reset}', help_text) + def test_print_help_uses_target_file_for_color_decision(self): parser = argparse.ArgumentParser(prog='PROG', color=True) parser.add_argument('--opt') From 987af8b5761582c50356fad81acedb33fcb4c949 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 18 Dec 2025 14:52:53 -0800 Subject: [PATCH 2/5] Clean up --- Lib/argparse.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 14a06ceebb1226..b2121aadcb223e 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -708,7 +708,6 @@ def colorize(match): result = _re.sub(r'%%|%\((\w+)\)[^a-z]*[a-z]', colorize, help_string, flags=_re.IGNORECASE) - # Check for invalid/unmatched % specifiers if '%' in result: raise ValueError(f"invalid format specifier in: {help_string!r}") return result From 2e73cd542d7bee51713b3e7d6f0502070d8e18ed Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 18 Dec 2025 14:54:27 -0800 Subject: [PATCH 3/5] Comments --- Lib/argparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index b2121aadcb223e..32d33551c0e1e3 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -704,7 +704,7 @@ def colorize(match): return f'{t.interpolated_value}{formatted}{t.reset}' return spec - # Match %% (literal %) or %(name)... format specifiers + # Match %% or %(name)... format specifiers result = _re.sub(r'%%|%\((\w+)\)[^a-z]*[a-z]', colorize, help_string, flags=_re.IGNORECASE) From 8fc4cd2261becfdd5c55d33b6d8ccda3ddf0fccf Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:58:51 +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-18-22-58-46.gh-issue-142950.EJ8w-T.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-12-18-22-58-46.gh-issue-142950.EJ8w-T.rst diff --git a/Misc/NEWS.d/next/Library/2025-12-18-22-58-46.gh-issue-142950.EJ8w-T.rst b/Misc/NEWS.d/next/Library/2025-12-18-22-58-46.gh-issue-142950.EJ8w-T.rst new file mode 100644 index 00000000000000..219930c638384f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-18-22-58-46.gh-issue-142950.EJ8w-T.rst @@ -0,0 +1 @@ +Fix regression in :mod:`argparse` where format specifiers in help strings raised :exc:`ValueError`. From 62ceb27c412e25b2aa0bc0529cc637e9b34dd07d Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 18 Dec 2025 15:09:43 -0800 Subject: [PATCH 5/5] Lint --- Lib/argparse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 32d33551c0e1e3..a2a48fee019693 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -688,12 +688,12 @@ def _expand_help(self, action): params[name] = value.__name__ if params.get('choices') is not None: params['choices'] = ', '.join(map(str, params['choices'])) - + t = self._theme if not t.reset: return help_string % params - + # Format first to preserve types for specifiers, like %x that require int. def colorize(match): spec, name = match.group(0, 1)