From a2c939581de35e312aa1ed479be13abc64837eb4 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 13 Apr 2026 15:21:43 +0100 Subject: [PATCH 01/10] Add test helper logic to allow for additional commands in lazy import tests --- Lib/test/support/import_helper.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Lib/test/support/import_helper.py b/Lib/test/support/import_helper.py index e8a58ed77061f5..e8a3d176ad6943 100644 --- a/Lib/test/support/import_helper.py +++ b/Lib/test/support/import_helper.py @@ -325,7 +325,7 @@ def ready_to_import(name=None, source=""): sys.modules.pop(name, None) -def ensure_lazy_imports(imported_module, modules_to_block): +def ensure_lazy_imports(imported_module, modules_to_block, *, additional_code=None): """Test that when imported_module is imported, none of the modules in modules_to_block are imported as a side effect.""" modules_to_block = frozenset(modules_to_block) @@ -343,6 +343,16 @@ def ensure_lazy_imports(imported_module, modules_to_block): raise AssertionError(f'unexpectedly imported after importing {imported_module}: {{after}}') """ ) + if additional_code: + script += additional_code + script += textwrap.dedent( + f""" + if unexpected := modules_to_block & sys.modules.keys(): + after = ", ".join(unexpected) + raise AssertionError(f'unexpectedly imported after additional code: {{after}}') + """ + ) + from .script_helper import assert_python_ok assert_python_ok("-S", "-c", script) From 629dd53c6b48925a4ce880eee50296fe1ab5a20b Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 13 Apr 2026 15:22:46 +0100 Subject: [PATCH 02/10] add tests for lazy imports --- Lib/test/test_argparse.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index e0c32976fd6f0d..ae67f6893c99af 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -80,6 +80,50 @@ def test_skip_invalid_stdout(self): self.assertRegex(mocked_stderr.getvalue(), r'usage:') +class TestLazyImports(unittest.TestCase): + LAZY_IMPORTS = { + "_colorize", + "copy", + "difflib", + "shutil", + "textwrap", + "warnings", + } + def test_module_import(self): + import_helper.ensure_lazy_imports( + "argparse", + self.LAZY_IMPORTS, + ) + + def test_create_parser(self): + # Test imports are still unused after + # creating a parser + create_parser = "argparse.ArgumentParser()" + imported_modules = {"shutil"} + + import_helper.ensure_lazy_imports( + "argparse", + self.LAZY_IMPORTS - imported_modules, + additional_code=create_parser, + ) + + def test_add_subparser(self): + # This fails as it currently imports colorize + add_subparser = textwrap.dedent( + f""" + parser = argparse.ArgumentParser() + parser.add_subparsers(dest='command', required=False) + """ + ) + imported_modules = {"shutil"} + + import_helper.ensure_lazy_imports( + "argparse", + self.LAZY_IMPORTS - imported_modules, + additional_code=add_subparser, + ) + + class TestArgumentParserPickleable(unittest.TestCase): @force_not_colorized From 4fa408edda57ffd5b801ddf507b6f8fecb5c812a Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Mon, 13 Apr 2026 15:26:59 +0100 Subject: [PATCH 03/10] Lazily import _colorize This uses a new lazy import so it works with short circuiting alongside a `_colorless_theme` object to prevent the `_colorize` import if color is set to False. _theme and _decolor are now properties to prevent `_set_color` from performing the imports on creation of a formatter. --- Lib/argparse.py | 52 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index d91707d9eec546..47ca78eb568ff6 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -92,6 +92,8 @@ from gettext import gettext as _ from gettext import ngettext +lazy import _colorize + SUPPRESS = '==SUPPRESS==' OPTIONAL = '?' @@ -156,6 +158,15 @@ def _identity(value): # Formatting Help # =============== +class _ColorlessTheme: + # 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 "" + +_colorless_theme = _ColorlessTheme() + class HelpFormatter(object): """Formatter for generating usage messages and argument help strings. @@ -196,14 +207,32 @@ def __init__( self._set_color(False) def _set_color(self, color, *, file=None): - from _colorize import can_colorize, decolor, get_theme - - if color and can_colorize(file=file): - self._theme = get_theme(force_color=True).argparse - self._decolor = decolor + # Set a new color setting and file, clear caches for theme and decolor + self._theme_color = color + self._theme_file = file + self._cached_theme = None + self._cached_decolor = None + + def _get_theme_and_decolor(self): + # If self._theme_color is false, this prevents _colorize from importing + if self._theme_color and _colorize.can_colorize(file=self._theme_file): + self._cached_theme = _colorize.get_theme(force_color=True).argparse + self._cached_decolor = _colorize.decolor else: - self._theme = get_theme(force_no_color=True).argparse - self._decolor = _identity + self._cached_theme = _colorless_theme + self._cached_decolor = _identity + + @property + def _theme(self): + if self._cached_theme is None: + self._get_theme_and_decolor() + return self._cached_theme + + @property + def _decolor(self): + if self._cached_decolor is None: + self._get_theme_and_decolor() + return self._cached_decolor # =============================== # Section and indentation methods @@ -2856,12 +2885,11 @@ def _print_message(self, message, file=None): pass def _get_theme(self, file=None): - from _colorize import can_colorize, get_theme - - if self.color and can_colorize(file=file): - return get_theme(force_color=True).argparse + # If self.color is False, _colorize is not imported + if self.color and _colorize.can_colorize(file=file): + return _colorize.get_theme(force_color=True).argparse else: - return get_theme(force_no_color=True).argparse + return _colorless_theme # =============== # Exiting methods From dd8dcf9ace683cebe28e5d7b2d03d4fdf37d6a86 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Tue, 21 Apr 2026 13:34:05 +0100 Subject: [PATCH 04/10] subparser has also been fixed --- Lib/test/test_argparse.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index ae67f6893c99af..b8ea4d3593a3f2 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -108,7 +108,6 @@ def test_create_parser(self): ) def test_add_subparser(self): - # This fails as it currently imports colorize add_subparser = textwrap.dedent( f""" parser = argparse.ArgumentParser() From 36743f6b9a925cd504e306edac9bfa6a2f45f630 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 6 May 2026 15:10:56 +0100 Subject: [PATCH 05/10] Add a test for parse_args (based on the readme example) --- Lib/test/test_argparse.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index b8ea4d3593a3f2..01bd9a071137d1 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -122,6 +122,23 @@ def test_add_subparser(self): additional_code=add_subparser, ) + def test_parse_args(self): + example_parser = textwrap.dedent( + """ + parser = argparse.ArgumentParser(prog='PROG') + parser.add_argument('-f', '--foo') + parser.add_argument('bar') + parser.parse_args(['BAR']) + parser.parse_args(['BAR', '--foo', 'FOO']) + """ + ) + imported_modules = {"shutil"} + import_helper.ensure_lazy_imports( + "argparse", + self.LAZY_IMPORTS - imported_modules, + additional_code=example_parser + ) + class TestArgumentParserPickleable(unittest.TestCase): From 594619cfd67456821bbd7d0e1b3a622aad438a6c Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 6 May 2026 15:11:38 +0100 Subject: [PATCH 06/10] remove unnecessary f-string --- 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 01bd9a071137d1..dde3a83a41c990 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -109,7 +109,7 @@ def test_create_parser(self): def test_add_subparser(self): add_subparser = textwrap.dedent( - f""" + """ parser = argparse.ArgumentParser() parser.add_subparsers(dest='command', required=False) """ From 45ca6b42d8d5149ea4a719c1efb0f1eb9c697811 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:26:46 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-05-06-14-26-37.gh-issue-148823.ySmOE4.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-05-06-14-26-37.gh-issue-148823.ySmOE4.rst diff --git a/Misc/NEWS.d/next/Library/2026-05-06-14-26-37.gh-issue-148823.ySmOE4.rst b/Misc/NEWS.d/next/Library/2026-05-06-14-26-37.gh-issue-148823.ySmOE4.rst new file mode 100644 index 00000000000000..e362fab604b704 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-06-14-26-37.gh-issue-148823.ySmOE4.rst @@ -0,0 +1 @@ +Defer the import of ``_colorize`` in ``argparse`` until needed for coloring output. From 0740ca84f341f79db649d6d9df07e1e682027d09 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 6 May 2026 18:03:46 +0100 Subject: [PATCH 08/10] Don't use colour for validation checks to avoid the _colorize import --- Lib/argparse.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/argparse.py b/Lib/argparse.py index 1f8ddaa8678ab3..4084d4f36aef33 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2847,7 +2847,9 @@ def _get_validation_formatter(self): # Return cached formatter for read-only validation operations # (_expand_help and _format_args). Avoids repeated slow _set_color calls. if self._cached_formatter is None: - self._cached_formatter = self._get_formatter() + formatter = self.formatter_class(prog=self.prog) + formatter._set_color(False) + self._cached_formatter = formatter return self._cached_formatter # ===================== From da033fe370e764c420ee7c358b39215a963fbf26 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 6 May 2026 18:14:01 +0100 Subject: [PATCH 09/10] Add a test to make sure the fake 'colorless' theme stays in sync with the real one in _colorize --- Lib/test/test_argparse.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 63b5ff40834f2f..4ea5b6f53a0426 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7861,6 +7861,14 @@ def fake_can_colorize(*, file=None): self.assertIn(output, calls) self.assertNotIn('\x1b[', output.getvalue()) + def test_fake_color_theme_matches_real(self): + from argparse import _colorless_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) + ) + class TestModule(unittest.TestCase): def test_deprecated__version__(self): From c803a10b628c41bde301776c17884853a2c03bd5 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 6 May 2026 18:44:50 +0100 Subject: [PATCH 10/10] Include the comment from the suggestion --- Lib/argparse.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/argparse.py b/Lib/argparse.py index 4084d4f36aef33..6d21823e652429 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2846,6 +2846,8 @@ def _get_formatter(self, file=None): def _get_validation_formatter(self): # Return cached formatter for read-only validation operations # (_expand_help and _format_args). Avoids repeated slow _set_color calls. + # Validation never renders output, so force color off to avoid + # importing _colorize during add_argument. if self._cached_formatter is None: formatter = self.formatter_class(prog=self.prog) formatter._set_color(False)