From 8e989e991ba1409405421256be9bcbfb304a4771 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 1 Jul 2026 13:06:30 +0300 Subject: [PATCH] gh-66172: Don't let a corrupt config file prevent IDLE from starting If a user configuration file cannot be read, rename it with a ".bad" suffix, use default settings, and warn the user with a message box. --- Lib/idlelib/config.py | 28 ++++++++++-- Lib/idlelib/idle_test/test_config.py | 43 +++++++++++++++++++ Lib/idlelib/pyshell.py | 6 +++ ...6-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst | 3 ++ 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py index 82afd6c49269d2..0e0c884bcf9b83 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 = ['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 6d75cf7aa67dcc..9cc3e5d22c8dd5 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(self): + # gh-66172: an unparsable user file is renamed, not fatal, not lost. + conf = self.new_config(_utest=True) + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + badpath = os.path.join(tmpdir.name, 'config-extensions.cfg') + with open(badpath, 'w') as f: + f.write('enable=1\n') # No section header. + conf.defaultCfg['foo'] = config.IdleConfParser('') # Empty, valid. + conf.userCfg['foo'] = config.IdleUserConfParser(badpath) + + 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, badpath) + # The bad file is moved aside, not left to be overwritten or deleted. + self.assertFalse(os.path.exists(badpath)) + with open(badpath + '.bad') as f: + self.assertEqual(f.read(), 'enable=1\n') + message = conf.file_load_error_message() + self.assertIn(badpath, 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) + badpath = os.path.join(tmpdir.name, 'config-main.cfg') + with open(badpath, '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(badpath) + + 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(badpath)) + self.assertTrue(os.path.exists(badpath + '.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 b1662491935e4a..518e2bf35af1fd 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -1610,6 +1610,12 @@ def main(): from idlelib.run import fix_scaling 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 00000000000000..c4248f85b98a25 --- /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.