https://github.com/python/cpython/commit/393773ae8763202ecf7afff81c4d57bd37c62ff6
commit: 393773ae8763202ecf7afff81c4d57bd37c62ff6
branch: main
author: Serhiy Storchaka <storch...@gmail.com>
committer: serhiy-storchaka <storch...@gmail.com>
date: 2025-05-23T21:07:49+03:00
summary:

gh-134565: Use ExceptionGroup to handle multiple errors in 
unittest.doModuleCleanups() (GH-134566)

files:
A Misc/NEWS.d/next/Library/2025-05-23-10-15-36.gh-issue-134565.zmb66C.rst
M Lib/test/test_unittest/test_result.py
M Lib/test/test_unittest/test_runner.py
M Lib/unittest/case.py
M Lib/unittest/suite.py

diff --git a/Lib/test/test_unittest/test_result.py 
b/Lib/test/test_unittest/test_result.py
index 9ac4c52449c2ff..3f44e617303f81 100644
--- a/Lib/test/test_unittest/test_result.py
+++ b/Lib/test/test_unittest/test_result.py
@@ -1282,14 +1282,22 @@ def setUpModule():
         suite(result)
         expected_out = '\nStdout:\ndo cleanup2\ndo cleanup1\n'
         self.assertEqual(stdout.getvalue(), expected_out)
-        self.assertEqual(len(result.errors), 1)
+        self.assertEqual(len(result.errors), 2)
         description = 'tearDownModule (Module)'
         test_case, formatted_exc = result.errors[0]
         self.assertEqual(test_case.description, description)
         self.assertIn('ValueError: bad cleanup2', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
         self.assertNotIn('TypeError', formatted_exc)
         self.assertIn(expected_out, formatted_exc)
 
+        test_case, formatted_exc = result.errors[1]
+        self.assertEqual(test_case.description, description)
+        self.assertIn('TypeError: bad cleanup1', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
+        self.assertNotIn('ValueError', formatted_exc)
+        self.assertIn(expected_out, formatted_exc)
+
     def testBufferSetUpModule_DoModuleCleanups(self):
         with captured_stdout() as stdout:
             result = unittest.TestResult()
@@ -1313,22 +1321,34 @@ def setUpModule():
         suite(result)
         expected_out = '\nStdout:\nset up module\ndo cleanup2\ndo cleanup1\n'
         self.assertEqual(stdout.getvalue(), expected_out)
-        self.assertEqual(len(result.errors), 2)
+        self.assertEqual(len(result.errors), 3)
         description = 'setUpModule (Module)'
         test_case, formatted_exc = result.errors[0]
         self.assertEqual(test_case.description, description)
         self.assertIn('ZeroDivisionError: division by zero', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
         self.assertNotIn('ValueError', formatted_exc)
         self.assertNotIn('TypeError', formatted_exc)
         self.assertIn('\nStdout:\nset up module\n', formatted_exc)
+
         test_case, formatted_exc = result.errors[1]
         self.assertIn(expected_out, formatted_exc)
         self.assertEqual(test_case.description, description)
         self.assertIn('ValueError: bad cleanup2', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
         self.assertNotIn('ZeroDivisionError', formatted_exc)
         self.assertNotIn('TypeError', formatted_exc)
         self.assertIn(expected_out, formatted_exc)
 
+        test_case, formatted_exc = result.errors[2]
+        self.assertIn(expected_out, formatted_exc)
+        self.assertEqual(test_case.description, description)
+        self.assertIn('TypeError: bad cleanup1', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
+        self.assertNotIn('ZeroDivisionError', formatted_exc)
+        self.assertNotIn('ValueError', formatted_exc)
+        self.assertIn(expected_out, formatted_exc)
+
     def testBufferTearDownModule_DoModuleCleanups(self):
         with captured_stdout() as stdout:
             result = unittest.TestResult()
@@ -1355,21 +1375,32 @@ def tearDownModule():
         suite(result)
         expected_out = '\nStdout:\ntear down module\ndo cleanup2\ndo 
cleanup1\n'
         self.assertEqual(stdout.getvalue(), expected_out)
-        self.assertEqual(len(result.errors), 2)
+        self.assertEqual(len(result.errors), 3)
         description = 'tearDownModule (Module)'
         test_case, formatted_exc = result.errors[0]
         self.assertEqual(test_case.description, description)
         self.assertIn('ZeroDivisionError: division by zero', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
         self.assertNotIn('ValueError', formatted_exc)
         self.assertNotIn('TypeError', formatted_exc)
         self.assertIn('\nStdout:\ntear down module\n', formatted_exc)
+
         test_case, formatted_exc = result.errors[1]
         self.assertEqual(test_case.description, description)
         self.assertIn('ValueError: bad cleanup2', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
         self.assertNotIn('ZeroDivisionError', formatted_exc)
         self.assertNotIn('TypeError', formatted_exc)
         self.assertIn(expected_out, formatted_exc)
 
+        test_case, formatted_exc = result.errors[2]
+        self.assertEqual(test_case.description, description)
+        self.assertIn('TypeError: bad cleanup1', formatted_exc)
+        self.assertNotIn('ExceptionGroup', formatted_exc)
+        self.assertNotIn('ZeroDivisionError', formatted_exc)
+        self.assertNotIn('ValueError', formatted_exc)
+        self.assertIn(expected_out, formatted_exc)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/Lib/test/test_unittest/test_runner.py 
b/Lib/test/test_unittest/test_runner.py
index 4d3cfd60b8d9c3..a47e2ebb59da02 100644
--- a/Lib/test/test_unittest/test_runner.py
+++ b/Lib/test/test_unittest/test_runner.py
@@ -13,6 +13,7 @@
     LoggingResult,
     ResultWithNoStartTestRunStopTestRun,
 )
+from test.support.testcase import ExceptionIsLikeMixin
 
 
 def resultFactory(*_):
@@ -604,7 +605,7 @@ class EmptyTest(unittest.TestCase):
 
 
 @support.force_not_colorized_test_class
-class TestModuleCleanUp(unittest.TestCase):
+class TestModuleCleanUp(ExceptionIsLikeMixin, unittest.TestCase):
     def test_add_and_do_ModuleCleanup(self):
         module_cleanups = []
 
@@ -646,11 +647,50 @@ class Module(object):
                          [(module_cleanup_good, (1, 2, 3),
                            dict(four='hello', five='goodbye')),
                           (module_cleanup_bad, (), {})])
-        with self.assertRaises(CustomError) as e:
+        with self.assertRaises(Exception) as e:
             unittest.case.doModuleCleanups()
-        self.assertEqual(str(e.exception), 'CleanUpExc')
+        self.assertExceptionIsLike(e.exception,
+                ExceptionGroup('module cleanup failed',
+                               [CustomError('CleanUpExc')]))
         self.assertEqual(unittest.case._module_cleanups, [])
 
+    def test_doModuleCleanup_with_multiple_errors_in_addModuleCleanup(self):
+        def module_cleanup_bad1():
+            raise TypeError('CleanUpExc1')
+
+        def module_cleanup_bad2():
+            raise ValueError('CleanUpExc2')
+
+        class Module:
+            unittest.addModuleCleanup(module_cleanup_bad1)
+            unittest.addModuleCleanup(module_cleanup_bad2)
+        with self.assertRaises(ExceptionGroup) as e:
+            unittest.case.doModuleCleanups()
+        self.assertExceptionIsLike(e.exception,
+                ExceptionGroup('module cleanup failed', [
+                    ValueError('CleanUpExc2'),
+                    TypeError('CleanUpExc1'),
+                ]))
+
+    def test_doModuleCleanup_with_exception_group_in_addModuleCleanup(self):
+        def module_cleanup_bad():
+            raise ExceptionGroup('CleanUpExc', [
+                ValueError('CleanUpExc2'),
+                TypeError('CleanUpExc1'),
+            ])
+
+        class Module:
+            unittest.addModuleCleanup(module_cleanup_bad)
+        with self.assertRaises(ExceptionGroup) as e:
+            unittest.case.doModuleCleanups()
+        self.assertExceptionIsLike(e.exception,
+                ExceptionGroup('module cleanup failed', [
+                    ExceptionGroup('CleanUpExc', [
+                        ValueError('CleanUpExc2'),
+                        TypeError('CleanUpExc1'),
+                    ]),
+                ]))
+
     def test_addModuleCleanup_arg_errors(self):
         cleanups = []
         def cleanup(*args, **kwargs):
@@ -871,9 +911,11 @@ def tearDownClass(cls):
         ordering = []
         blowUp = True
         suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestableTest)
-        with self.assertRaises(CustomError) as cm:
+        with self.assertRaises(Exception) as cm:
             suite.debug()
-        self.assertEqual(str(cm.exception), 'CleanUpExc')
+        self.assertExceptionIsLike(cm.exception,
+                ExceptionGroup('module cleanup failed',
+                               [CustomError('CleanUpExc')]))
         self.assertEqual(ordering, ['setUpModule', 'setUpClass', 'test',
                                     'tearDownClass', 'tearDownModule', 
'cleanup_exc'])
         self.assertEqual(unittest.case._module_cleanups, [])
diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py
index 884fc1b21f64d8..db10de68e4ac73 100644
--- a/Lib/unittest/case.py
+++ b/Lib/unittest/case.py
@@ -149,9 +149,7 @@ def doModuleCleanups():
         except Exception as exc:
             exceptions.append(exc)
     if exceptions:
-        # Swallows all but first exception. If a multi-exception handler
-        # gets written we should use that here instead.
-        raise exceptions[0]
+        raise ExceptionGroup('module cleanup failed', exceptions)
 
 
 def skip(reason):
diff --git a/Lib/unittest/suite.py b/Lib/unittest/suite.py
index 6f45b6fe5f6039..ae9ca2d615de06 100644
--- a/Lib/unittest/suite.py
+++ b/Lib/unittest/suite.py
@@ -223,6 +223,11 @@ def _handleModuleFixture(self, test, result):
                 if result._moduleSetUpFailed:
                     try:
                         case.doModuleCleanups()
+                    except ExceptionGroup as eg:
+                        for e in eg.exceptions:
+                            self._createClassOrModuleLevelException(result, e,
+                                                                    
'setUpModule',
+                                                                    
currentModule)
                     except Exception as e:
                         self._createClassOrModuleLevelException(result, e,
                                                                 'setUpModule',
@@ -235,15 +240,15 @@ def _createClassOrModuleLevelException(self, result, exc, 
method_name,
         errorName = f'{method_name} ({parent})'
         self._addClassOrModuleLevelException(result, exc, errorName, info)
 
-    def _addClassOrModuleLevelException(self, result, exception, errorName,
+    def _addClassOrModuleLevelException(self, result, exc, errorName,
                                         info=None):
         error = _ErrorHolder(errorName)
         addSkip = getattr(result, 'addSkip', None)
-        if addSkip is not None and isinstance(exception, case.SkipTest):
-            addSkip(error, str(exception))
+        if addSkip is not None and isinstance(exc, case.SkipTest):
+            addSkip(error, str(exc))
         else:
             if not info:
-                result.addError(error, sys.exc_info())
+                result.addError(error, (type(exc), exc, exc.__traceback__))
             else:
                 result.addError(error, info)
 
@@ -273,6 +278,13 @@ def _handleModuleTearDown(self, result):
                                                             previousModule)
             try:
                 case.doModuleCleanups()
+            except ExceptionGroup as eg:
+                if isinstance(result, _DebugResult):
+                    raise
+                for e in eg.exceptions:
+                    self._createClassOrModuleLevelException(result, e,
+                                                            'tearDownModule',
+                                                            previousModule)
             except Exception as e:
                 if isinstance(result, _DebugResult):
                     raise
diff --git 
a/Misc/NEWS.d/next/Library/2025-05-23-10-15-36.gh-issue-134565.zmb66C.rst 
b/Misc/NEWS.d/next/Library/2025-05-23-10-15-36.gh-issue-134565.zmb66C.rst
new file mode 100644
index 00000000000000..17d2b23b62d3c3
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-05-23-10-15-36.gh-issue-134565.zmb66C.rst
@@ -0,0 +1,3 @@
+:func:`unittest.doModuleCleanups` no longer swallows all but first exception
+raised in the cleanup code, but raises a :exc:`ExceptionGroup` if multiple
+errors occurred.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: arch...@mail-archive.com

Reply via email to