https://github.com/python/cpython/commit/ae0d6cc79118114f70afe5f65e5ab3a8d33359cc
commit: ae0d6cc79118114f70afe5f65e5ab3a8d33359cc
branch: main
author: Serhiy Storchaka <[email protected]>
committer: terryjreedy <[email protected]>
date: 2026-09-13T15:46:04-04:00
summary:

gh-66172: Don't let a corrupt config file prevent IDLE from starting (#152764)

If a user configuration file cannot be read, because of either bad unicode or 
configuration,
rename it with a ".bad" suffix, use default settings, and warn the user with a 
message box.
---------

Co-authored-by: Claude Opus 5 <[email protected]>

files:
A Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst
M Lib/idlelib/config.py
M Lib/idlelib/idle_test/test_config.py
M Lib/idlelib/pyshell.py

diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py
index 82afd6c49269d2d..0e0c884bcf9b838 100644
--- a/Lib/idlelib/config.py
+++ b/Lib/idlelib/config.py
@@ -25,7 +25,7 @@
 """
 # TODOs added Oct 2014, tjr
 
-from configparser import ConfigParser
+from configparser import ConfigParser, Error as ConfigParserError
 import os
 import sys
 
@@ -74,7 +74,7 @@ def GetOptionList(self, section):
     def Load(self):
         "Load the configuration file from disk."
         if self.file and os.path.exists(self.file):
-            with open(self.file, encoding='utf-8', errors='replace') as f:
+            with open(self.file, encoding='utf-8') as f:
                 self.read_file(f)
 
 class IdleUserConfParser(IdleConfParser):
@@ -159,6 +159,7 @@ def __init__(self, _utest=False):
         self.defaultCfg = {}
         self.userCfg = {}
         self.cfg = {}  # TODO use to select userCfg vs defaultCfg
+        self.file_load_errors = []  # (file, error) for unparsable cfg files.
 
         # See https://bugs.python.org/issue4630#msg356516 for following.
         # self.blink_off_time = <first editor text>['insertofftime']
@@ -795,7 +796,28 @@ def LoadCfgFiles(self):
         "Load all configuration files."
         for key in self.defaultCfg:
             self.defaultCfg[key].Load()
-            self.userCfg[key].Load() #same keys
+            try:
+                self.userCfg[key].Load()  # same keys
+            except (ConfigParserError, UnicodeDecodeError) as err:
+                # Move an invalid user file aside instead of losing it
+                # or failing to start (gh-66172).
+                file = self.userCfg[key].file
+                self.file_load_errors.append((file, err))
+                try:
+                    os.replace(file, file + '.bad')
+                except OSError:
+                    pass
+
+    def file_load_error_message(self):
+        "Return a warning about invalid config files, or None."
+        if not self.file_load_errors:
+            return None
+        files = '\n'.join(
+            f'  {file}:\n    {type(err).__name__}: {str(err).splitlines()[0]}'
+            for file, err in self.file_load_errors)
+        return ('The following IDLE configuration files could not be read.  '
+                'They were renamed by appending ".bad", and default settings '
+                'are used instead:\n\n' + files)
 
     def SaveUserCfgFiles(self):
         "Write all loaded user configuration files to disk."
diff --git a/Lib/idlelib/idle_test/test_config.py 
b/Lib/idlelib/idle_test/test_config.py
index 028d9f9dbd613ce..f8f0415263dd226 100644
--- a/Lib/idlelib/idle_test/test_config.py
+++ b/Lib/idlelib/idle_test/test_config.py
@@ -312,6 +312,49 @@ def test_load_cfg_files(self):
         eq(conf.userCfg['foo'].Get('Foo Bar', 'foo'), 'newbar')
         eq(conf.userCfg['foo'].GetOptionList('Foo Bar'), ['foo'])
 
+    def test_load_cfg_files_bad_format(self):
+        # gh-66172: rename an unparsable user file and save the exception.
+        conf = self.new_config(_utest=True)
+        tmpdir = tempfile.TemporaryDirectory()
+        self.addCleanup(tmpdir.cleanup)
+        confpath = os.path.join(tmpdir.name, 'config-extensions.cfg')
+        with open(confpath, 'w') as f:
+            f.write('enable=1\n')  # No section header.
+        conf.defaultCfg['foo'] = config.IdleConfParser('')  # Empty, valid.
+        conf.userCfg['foo'] = config.IdleUserConfParser(confpath)
+
+        self.assertIsNone(conf.file_load_error_message())
+        conf.LoadCfgFiles()  # Must not raise.
+
+        self.assertEqual(len(conf.file_load_errors), 1)
+        file, err = conf.file_load_errors[0]
+        self.assertEqual(file, confpath)
+        # The bad file is moved aside, not left to be overwritten or deleted.
+        self.assertFalse(os.path.exists(confpath))
+        with open(confpath + '.bad') as f:
+            self.assertEqual(f.read(), 'enable=1\n')
+        message = conf.file_load_error_message()
+        self.assertIn(confpath, message)
+        self.assertIn('MissingSectionHeaderError', message)
+
+    def test_load_cfg_files_bad_encoding(self):
+        # gh-66172: a file that is not valid UTF-8 is handled like a bad parse.
+        conf = self.new_config(_utest=True)
+        tmpdir = tempfile.TemporaryDirectory()
+        self.addCleanup(tmpdir.cleanup)
+        confpath = os.path.join(tmpdir.name, 'config-main.cfg')
+        with open(confpath, 'wb') as f:
+            f.write(b'[Section]\nkey = \xff\n')  # Invalid UTF-8.
+        conf.defaultCfg['foo'] = config.IdleConfParser('')  # Empty, valid.
+        conf.userCfg['foo'] = config.IdleUserConfParser(confpath)
+
+        conf.LoadCfgFiles()  # Must not raise.
+
+        self.assertEqual(len(conf.file_load_errors), 1)
+        self.assertIsInstance(conf.file_load_errors[0][1], UnicodeDecodeError)
+        self.assertFalse(os.path.exists(confpath))
+        self.assertTrue(os.path.exists(confpath + '.bad'))
+
     def test_save_user_cfg_files(self):
         conf = self.mock_config()
 
diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py
index ef3d014d936ce85..6e57e306a3678d9 100755
--- a/Lib/idlelib/pyshell.py
+++ b/Lib/idlelib/pyshell.py
@@ -1612,6 +1612,12 @@ def main():
     root.withdraw()
     fix_scaling(root)
 
+    # Warn about configuration files that could not be parsed (gh-66172).
+    config_error = idleConf.file_load_error_message()
+    if config_error:
+        messagebox.showwarning('IDLE Configuration Warning', config_error,
+                               parent=root)
+
     # set application icon
     icondir = os.path.join(os.path.dirname(__file__), 'Icons')
     if system() == 'Windows':
diff --git 
a/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst 
b/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst
new file mode 100644
index 000000000000000..c4248f85b98a258
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst
@@ -0,0 +1,3 @@
+IDLE no longer fails to start when a user configuration file is corrupt.
+The unparsable file is renamed with a ".bad" suffix, default settings are
+used instead, and a warning lists the affected files.

_______________________________________________
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