Skip to content

gh-111881: Use lazy import in unittest #111887

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from

Conversation

vstinner
Copy link
Member

@vstinner vstinner commented Nov 9, 2023

Use lazy imports for argparse, difflib and fnmatch imports in unittest to speedup Python startup time when running tests, and the number of imported modules when running tests.

Use lazy imports for argparse, difflib and fnmatch imports in
unittest to speedup Python startup time when running tests, and the
number of imported modules when running tests.
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making difflib a lazy import in unittest.case looks like a very good idea to me. The module is only used in three assert* methods, and they're all reasonably obscure assert* methods.

I'm not so sure about the changes to the other two submodules. fnmatch seems quite core to the functionality of unittest.loader; argparse seems quite core to the functionality of unittest.main. It seems odd to me to make fnmatch a lazy import in unittest.loader or argparse a lazy import in unittest.main.

Personally, I think I would prefer to just do the difflib import in unittest.case, and leave the others, in the name of code readability and elegance.

@vstinner
Copy link
Member Author

vstinner commented Nov 9, 2023

Python test suite declares tests using unittest.TestCase, but unittest.main() is not used, and none of unittest code path using fnmatch is used by libregrtest.

This PR doesn't remove any unittest feature, it doesn't affect any API. It's just about reducing the number of modules imported at Python startup.

The problem is that the import unittest loads all sub-modules:

from .result import TestResult
from .case import (addModuleCleanup, TestCase, FunctionTestCase, SkipTest, skip,
                   skipIf, skipUnless, expectedFailure, doModuleCleanups,
                   enterModuleContext)
from .suite import BaseTestSuite, TestSuite
from .loader import TestLoader, defaultTestLoader
from .main import TestProgram, main
from .runner import TextTestRunner, TextTestResult
from .signals import installHandler, registerResult, removeResult, removeHandler

argparse is imported, even if you don't use unittest.main().

fnmatch is imported even if you don't use unittest test discovery.

It seems odd to me to make fnmatch a lazy import in unittest.loader or argparse a lazy import in unittest.main.

Maybe the lazyness can be implemented differently?

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 15, 2023

The problem is that the import unittest loads all sub-modules:

Yes, it's a slightly unfortunate design...

Maybe the lazyness can be implemented differently?

For unittest.loader, what if, instead of making imports inside the module lazy, we just made it so that import unittest loaded the loader submodule lazily? Something like this?

diff --git a/Lib/unittest/__init__.py b/Lib/unittest/__init__.py
index f1f6c911ef..b09a2f4047 100644
--- a/Lib/unittest/__init__.py
+++ b/Lib/unittest/__init__.py
@@ -58,23 +58,29 @@ def testMultiply(self):
                    skipIf, skipUnless, expectedFailure, doModuleCleanups,
                    enterModuleContext)
 from .suite import BaseTestSuite, TestSuite
-from .loader import TestLoader, defaultTestLoader
 from .main import TestProgram, main
 from .runner import TextTestRunner, TextTestResult
 from .signals import installHandler, registerResult, removeResult, removeHandler
-# IsolatedAsyncioTestCase will be imported lazily.
+# Some things will be imported lazily.


 # Lazy import of IsolatedAsyncioTestCase from .async_case
 # It imports asyncio, which is relatively heavy, but most tests
-# do not need it.
+# do not need it. Similarly, unittest.loader imports fnmatch:
+# unneeded by most tests.
+

 def __dir__():
-    return globals().keys() | {'IsolatedAsyncioTestCase'}
+    extras = {'IsolatedAsyncioTestCase', 'TestLoader', 'defaultTestLoader'}
+    return globals().keys() | extras

 def __getattr__(name):
     if name == 'IsolatedAsyncioTestCase':
-        global IsolatedAsyncioTestCase
-        from .async_case import IsolatedAsyncioTestCase
-        return IsolatedAsyncioTestCase
-    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+        from .async_case import IsolatedAsyncioTestCase as obj
+    elif name in {"TestLoader", "defaultTestLoader"}:
+        import unittest.loader
+        obj = getattr(unittest.loader, name)
+    else:
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+    globals()[name] = obj
+    return obj

It's harder to do something like that for the unittest.main submodule, because of the fact that unittest has a function main() and a submodule main(), and the name clash there makes things complicated (I tried locally). But I can live with the changes you've made to unittest.main(); it's quite common for argparse to only be imported lazily, after all.

@vstinner
Copy link
Member Author

I lost track of this change, so I just close it.

@vstinner vstinner closed this Dec 20, 2023
@vstinner vstinner deleted the import_unittest branch December 20, 2023 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants