From e3c3dbec1dbfaaaf0c81c69778a8db98b3e3d4d8 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 20:55:51 +0100 Subject: [PATCH 1/9] Add tests to check an argument parser is copiable. --- Lib/test/test_argparse.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 4ea5b6f53a0426..1efd41b06fe92c 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -140,6 +140,36 @@ def test_parse_args(self): ) +class TestArgumentParserCopiable(unittest.TestCase): + def _get_parser(self): + parser = argparse.ArgumentParser(exit_on_error=False) + parser.add_argument('--foo', type=int, default=42) + parser.add_argument('bar', nargs='?', default='baz') + return parser + + def test_copiable(self): + import copy + parser = self._get_parser() + parser2 = copy.copy(parser) + ns = parser2.parse_args(['--foo', '123', 'quux']) + self.assertEqual(ns.foo, 123) + self.assertEqual(ns.bar, 'quux') + ns2 = parser2.parse_args([]) + self.assertEqual(ns2.foo, 42) + self.assertEqual(ns2.bar, 'baz') + + def test_deepcopiable(self): + import copy + parser = self._get_parser() + parser2 = copy.deepcopy(parser) + ns = parser2.parse_args(['--foo', '123', 'quux']) + self.assertEqual(ns.foo, 123) + self.assertEqual(ns.bar, 'quux') + ns2 = parser2.parse_args([]) + self.assertEqual(ns2.foo, 42) + self.assertEqual(ns2.bar, 'baz') + + class TestArgumentParserPickleable(unittest.TestCase): @force_not_colorized From 0457d17a57ee4c8221f03321986a1535af899b6e Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 20:57:16 +0100 Subject: [PATCH 2/9] Only return empty strings for the specific color fields in argparse. --- Lib/argparse.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 6d21823e652429..db7e7e3d2f3e71 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -159,11 +159,20 @@ def _identity(value): # =============== class _ColorlessTheme: + color_fields = { + 'usage', 'prog', 'prog_extra', 'heading', 'summary_long_option', + 'summary_short_option', 'summary_label', 'summary_action', + 'long_option', 'short_option', 'label', 'action', 'default', + 'interpolated_value', 'reset', 'error', 'warning', 'message', + } + # A 'fake' theme for no colors def __getattr__(self, name): # _colorize's no_color themes are just all empty strings # by directly using empty strings the import is avoided - return "" + if name in self.color_fields: + return "" + return super().__getattribute__(name) _colorless_theme = _ColorlessTheme() From 9516991ebd005224e823a63c3b97b29a06667a3f Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 21:07:34 +0100 Subject: [PATCH 3/9] rename class attribute --- Lib/argparse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index db7e7e3d2f3e71..e9a9fc7726c2b2 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -159,7 +159,7 @@ def _identity(value): # =============== class _ColorlessTheme: - color_fields = { + _COLOR_FIELDS = { 'usage', 'prog', 'prog_extra', 'heading', 'summary_long_option', 'summary_short_option', 'summary_label', 'summary_action', 'long_option', 'short_option', 'label', 'action', 'default', @@ -170,7 +170,7 @@ class _ColorlessTheme: def __getattr__(self, name): # _colorize's no_color themes are just all empty strings # by directly using empty strings the import is avoided - if name in self.color_fields: + if name in self._COLOR_FIELDS: return "" return super().__getattribute__(name) From e290465e71427dc8a82494dabc6bcf3cc23a3f02 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 21:10:29 +0100 Subject: [PATCH 4/9] Test the field lists match directly --- Lib/test/test_argparse.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 1efd41b06fe92c..c79cf16731847d 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7894,6 +7894,10 @@ def fake_can_colorize(*, file=None): def test_fake_color_theme_matches_real(self): from argparse import _colorless_theme _colorize_nocolor = _colorize.get_theme(force_no_color=True).argparse + self.assertEqual( + _colorless_theme._COLOR_FIELDS, + set(_colorize_nocolor.__dataclass_fields__) + ) for k in _colorize_nocolor: self.assertEqual( getattr(_colorless_theme, k), getattr(_colorize_nocolor, k) From 3b5f6356a930c7cf4dee1d40b0892201d808c245 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 21:02:12 +0000 Subject: [PATCH 5/9] =?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/2026-05-09-21-02-08.gh-issue-149614.U4snj3.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-05-09-21-02-08.gh-issue-149614.U4snj3.rst diff --git a/Misc/NEWS.d/next/Library/2026-05-09-21-02-08.gh-issue-149614.U4snj3.rst b/Misc/NEWS.d/next/Library/2026-05-09-21-02-08.gh-issue-149614.U4snj3.rst new file mode 100644 index 00000000000000..78e10eecb1c411 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-09-21-02-08.gh-issue-149614.U4snj3.rst @@ -0,0 +1 @@ +Fix a regression that broke the ability to deepcopy ``argparse.ArgumentParser`` instances. From 0475e1adc13800c6fa93d1b4e4b4d285a88df530 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 22:52:14 +0100 Subject: [PATCH 6/9] Only raise on dunder names --- Lib/argparse.py | 13 +++---------- Lib/test/test_argparse.py | 7 +++---- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index e9a9fc7726c2b2..9eb75d3a08a84d 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -159,20 +159,13 @@ def _identity(value): # =============== class _ColorlessTheme: - _COLOR_FIELDS = { - 'usage', 'prog', 'prog_extra', 'heading', 'summary_long_option', - 'summary_short_option', 'summary_label', 'summary_action', - 'long_option', 'short_option', 'label', 'action', 'default', - 'interpolated_value', 'reset', 'error', 'warning', 'message', - } - # A 'fake' theme for no colors def __getattr__(self, name): # _colorize's no_color themes are just all empty strings # by directly using empty strings the import is avoided - if name in self._COLOR_FIELDS: - return "" - return super().__getattribute__(name) + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return "" _colorless_theme = _ColorlessTheme() diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index c79cf16731847d..147bbf992ac972 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7894,15 +7894,14 @@ def fake_can_colorize(*, file=None): def test_fake_color_theme_matches_real(self): from argparse import _colorless_theme _colorize_nocolor = _colorize.get_theme(force_no_color=True).argparse - self.assertEqual( - _colorless_theme._COLOR_FIELDS, - set(_colorize_nocolor.__dataclass_fields__) - ) for k in _colorize_nocolor: self.assertEqual( getattr(_colorless_theme, k), getattr(_colorize_nocolor, k) ) + with self.assertRaises(AttributeError): + _colorless_theme.__deepcopy__ + class TestModule(unittest.TestCase): def test_deprecated__version__(self): From dab2c92d230c157c602c10ea1ff1d55fdd4a4109 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 22:53:47 +0100 Subject: [PATCH 7/9] On the unlikely chance that object adds a `__deepcopy__`, let's just use an arbitrary dunder name. --- Lib/test/test_argparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 147bbf992ac972..d4e6c2da27f579 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7900,7 +7900,7 @@ def test_fake_color_theme_matches_real(self): ) with self.assertRaises(AttributeError): - _colorless_theme.__deepcopy__ + _colorless_theme.__unknown_dunder__ class TestModule(unittest.TestCase): From b42718dbd65e6b5039766bc02a34c249ee6a132a Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 22:57:26 +0100 Subject: [PATCH 8/9] Raise on any private attribute --- Lib/argparse.py | 2 +- Lib/test/test_argparse.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 9eb75d3a08a84d..29e6ebb9634261 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -163,7 +163,7 @@ class _ColorlessTheme: def __getattr__(self, name): # _colorize's no_color themes are just all empty strings # by directly using empty strings the import is avoided - if name.startswith("__") and name.endswith("__"): + if name.startswith("_"): raise AttributeError(name) return "" diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index d4e6c2da27f579..445ffc775f2772 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7893,15 +7893,25 @@ def fake_can_colorize(*, file=None): def test_fake_color_theme_matches_real(self): from argparse import _colorless_theme + + # Check the attributes match those of the 'real' theme _colorize_nocolor = _colorize.get_theme(force_no_color=True).argparse for k in _colorize_nocolor: self.assertEqual( getattr(_colorless_theme, k), getattr(_colorize_nocolor, k) ) + def test_fake_color_theme_raises(self): + from argparse import _colorless_theme + + # Make sure the _colorless_theme doesn't return empty strings + # for magic methods or private attributes with self.assertRaises(AttributeError): _colorless_theme.__unknown_dunder__ + with self.assertRaises(AttributeError): + _colorless_theme._private_attribute + class TestModule(unittest.TestCase): def test_deprecated__version__(self): From 564637658a17f19341490e4850d683eaf7510c17 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 9 May 2026 23:05:47 +0100 Subject: [PATCH 9/9] Test shallow and deep copy behaviour is as expected. --- Lib/test/test_argparse.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 445ffc775f2772..1dc3f538f4ad8b 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -147,6 +147,7 @@ def _get_parser(self): parser.add_argument('bar', nargs='?', default='baz') return parser + @force_not_colorized def test_copiable(self): import copy parser = self._get_parser() @@ -158,6 +159,12 @@ def test_copiable(self): self.assertEqual(ns2.foo, 42) self.assertEqual(ns2.bar, 'baz') + # Test shallow copy also gets new arguments + parser.add_argument("--extra") + ns3 = parser2.parse_args(["--extra", "bar"]) + self.assertEqual(ns3.extra, "bar") + + @force_not_colorized def test_deepcopiable(self): import copy parser = self._get_parser() @@ -169,6 +176,11 @@ def test_deepcopiable(self): self.assertEqual(ns2.foo, 42) self.assertEqual(ns2.bar, 'baz') + # Test deep copy does not get new arguments + parser.add_argument("--extra") + with self.assertRaises(argparse.ArgumentError): + parser2.parse_args(["--extra", "bar"]) + class TestArgumentParserPickleable(unittest.TestCase):