https://github.com/python/cpython/commit/89fe8f277f029466a5106b7f5f824538aa45dcdc
commit: 89fe8f277f029466a5106b7f5f824538aa45dcdc
branch: 3.15
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-11-01T20:58:02Z
summary:

[3.15] gh-161108: Make configparser clear() linear time (GH-161109) (#161134)

Give SectionProxy its own popitem() and clear() and RawConfigParser its
own clear() instead of the MutableMapping ones, which copied every
remaining key for each removal.

https://github.com/python/cpython/pull/161109
(cherry picked from commit 2a1282b95289c170eb0941549f31d67f53791945)

files:
A Misc/NEWS.d/next/Library/2026-10-22-05-20-18.gh-issue-161108.xOVsRA.rst
M Lib/configparser.py
M Lib/test/test_configparser.py

diff --git a/Lib/configparser.py b/Lib/configparser.py
index 88015ef6086569..930afd4b5311cc 100644
--- a/Lib/configparser.py
+++ b/Lib/configparser.py
@@ -923,12 +923,20 @@ def popitem(self):
 
         The section DEFAULT is never returned because it cannot be removed.
         """
-        for key in self.sections():
+        for key in self._sections:
             value = self[key]
             del self[key]
             return key, value
         raise KeyError
 
+    def clear(self):
+        """Remove all sections from the parser.
+
+        The section DEFAULT is left intact because it cannot be removed.
+        """
+        for section in self.sections():
+            del self[section]
+
     def optionxform(self, optionstr):
         return optionstr.lower()
 
@@ -1341,6 +1349,39 @@ def _options(self):
         else:
             return self._parser.defaults()
 
+    # MutableMapping.popitem() and MutableMapping.clear() create a new
+    # iterator for every removed option, and __iter__() copies the names
+    # of all options.  Emptying a section that way takes quadratic time.
+
+    def popitem(self):
+        """Remove an option from the section and return it as
+        an (option_name, value) tuple. If no option is present, raise KeyError.
+
+        Options of the section DEFAULT are only returned for that section
+        because they cannot be removed from other sections.
+        """
+        if self._name != self._parser.default_section:
+            try:
+                options = self._parser._sections[self._name]
+            except KeyError:
+                raise NoSectionError(self._name) from None
+        else:
+            options = self._parser.defaults()
+        for key in options:
+            value = self[key]
+            del self[key]
+            return key, value
+        raise KeyError
+
+    def clear(self):
+        """Remove all options from the section.
+
+        Options of the section DEFAULT are left intact for other sections
+        because they cannot be removed from them.
+        """
+        for key in list(self._options()):
+            self._parser.remove_option(self._name, key)
+
     @property
     def parser(self):
         # The parser object of the proxy is read-only.
diff --git a/Lib/test/test_configparser.py b/Lib/test/test_configparser.py
index 7d1e68fe38100e..7aece5746333fa 100644
--- a/Lib/test/test_configparser.py
+++ b/Lib/test/test_configparser.py
@@ -884,6 +884,52 @@ def test_clear(self):
         self.assertEqual(set(cf.sections()), set())
         self.assertEqual(set(cf[self.default_section].keys()), {'foo'})
 
+    def test_section_popitem(self):
+        cf = self.fromstring(f"""
+            [section]
+            name1 {self.delimiters[0]} value1
+            name2 {self.delimiters[0]} value2
+            name3 {self.delimiters[0]} value3
+        """, defaults={"name2": "<default>", "name4": "<default>"})
+        section = cf['section']
+        self.assertEqual(section.popitem(), ('name1', 'value1'))
+        self.assertEqual(section.popitem(), ('name2', 'value2'))
+        self.assertEqual(section.popitem(), ('name3', 'value3'))
+        with self.assertRaises(KeyError):
+            section.popitem()
+        self.assertEqual(dict(section), {'name2': '<default>',
+                                         'name4': '<default>'})
+        default = cf[self.default_section]
+        self.assertEqual(default.popitem(), ('name2', '<default>'))
+        self.assertEqual(default.popitem(), ('name4', '<default>'))
+        with self.assertRaises(KeyError):
+            default.popitem()
+        self.assertEqual(dict(section), {})
+        del cf['section']
+        with self.assertRaises(configparser.NoSectionError):
+            section.popitem()
+
+    def test_section_clear(self):
+        cf = self.fromstring(f"""
+            [section1]
+            name1 {self.delimiters[0]} value1
+            name2 {self.delimiters[0]} value2
+            [section2]
+            name3 {self.delimiters[0]} value3
+        """, defaults={"name2": "<default>", "name4": "<default>"})
+        section = cf['section1']
+        section.clear()
+        self.assertEqual(dict(section), {'name2': '<default>',
+                                         'name4': '<default>'})
+        self.assertEqual(cf.sections(), ['section1', 'section2'])
+        cf[self.default_section].clear()
+        self.assertEqual(dict(cf[self.default_section]), {})
+        self.assertEqual(dict(section), {})
+        self.assertEqual(dict(cf['section2']), {'name3': 'value3'})
+        del cf['section1']
+        with self.assertRaises(configparser.NoSectionError):
+            section.clear()
+
     def test_setitem(self):
         cf = self.fromstring("""
             [section1]
@@ -970,6 +1016,11 @@ def test_interpolation_missing_value(self):
             self.assertEqual(e.args, ('name', 'Interpolation Error',
                                     '%(reference)s', 'reference'))
 
+    def test_section_clear_does_not_interpolate(self):
+        cf = self.get_interpolation_config()
+        cf["Interpolation Error"].clear()
+        self.assertEqual(cf.options("Interpolation Error"), [])
+
     def test_items(self):
         self.check_items_config([('default', '<default>'),
                                  ('getdefault', '|<default>|'),
@@ -2341,6 +2392,33 @@ def test_option_regex_no_value_does_not_backtrack(self):
         self.assertTrue(parser.has_option("section", "x" + " " * 40000 + "y"))
 
 
+class MappingComplexityTestCase(unittest.TestCase):
+    """Removing all sections or all options should take linear time."""
+
+    @support.requires_resource('cpu')
+    def test_clear_many_sections(self):
+        n = 50000
+        parser = configparser.RawConfigParser()
+        parser.read_string("".join(f"[{i}]\n" for i in range(n)))
+        self.assertEqual(len(parser.sections()), n)
+        # A quadratic clear() would copy the names of all remaining
+        # sections for every removed section.
+        parser.clear()
+        self.assertEqual(parser.sections(), [])
+
+    @support.requires_resource('cpu')
+    def test_clear_many_options(self):
+        n = 50000
+        parser = configparser.RawConfigParser({"key": "value"})
+        parser["section"] = {str(i): str(i) for i in range(n)}
+        section = parser["section"]
+        self.assertEqual(len(section), n + 1)
+        # A quadratic clear() would copy the whole section merged with
+        # the defaults for every removed option.
+        section.clear()
+        self.assertEqual(dict(section), {"key": "value"})
+
+
 class MiscTestCase(unittest.TestCase):
     def test__all__(self):
         support.check__all__(self, configparser, not_exported={"Error"})
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-22-05-20-18.gh-issue-161108.xOVsRA.rst 
b/Misc/NEWS.d/next/Library/2026-10-22-05-20-18.gh-issue-161108.xOVsRA.rst
new file mode 100644
index 00000000000000..d58214c823a552
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-05-20-18.gh-issue-161108.xOVsRA.rst
@@ -0,0 +1,4 @@
+Fix quadratic time complexity of :meth:`!clear` and :meth:`!popitem` on
+:class:`configparser.ConfigParser` objects and their section proxies.
+Clearing a section no longer fails part way with
+:exc:`~configparser.InterpolationError` on a value it removes.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to