From 8e989e991ba1409405421256be9bcbfb304a4771 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 1 Jul 2026 13:06:30 +0300 Subject: [PATCH 1/2] 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. From 1326857ab11404f21a9f912a0a4c3d25d2960b29 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 22:09:54 +0300 Subject: [PATCH 2/2] Apply review comments Say in the name of the test what it tests, and name the path after the file it points to, not after the state of that file. Co-Authored-By: Claude Opus 5 --- Lib/idlelib/idle_test/test_config.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index 9cc3e5d22c8dd5..49c7d1048dfb91 100644 --- a/Lib/idlelib/idle_test/test_config.py +++ b/Lib/idlelib/idle_test/test_config.py @@ -312,29 +312,29 @@ 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. + 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) - badpath = os.path.join(tmpdir.name, 'config-extensions.cfg') - with open(badpath, 'w') as f: + 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(badpath) + 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, badpath) + self.assertEqual(file, confpath) # 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.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(badpath, message) + self.assertIn(confpath, message) self.assertIn('MissingSectionHeaderError', message) def test_load_cfg_files_bad_encoding(self): @@ -342,18 +342,18 @@ def test_load_cfg_files_bad_encoding(self): 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: + 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(badpath) + 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(badpath)) - self.assertTrue(os.path.exists(badpath + '.bad')) + self.assertFalse(os.path.exists(confpath)) + self.assertTrue(os.path.exists(confpath + '.bad')) def test_save_user_cfg_files(self): conf = self.mock_config()