From 4cdb0ae20af1e6867244f0a6555988ea2e78d925 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 Aug 2025 16:12:32 +0530 Subject: [PATCH 1/2] gh-127011: Add __str__ and __repr__ to ConfigParser - Implement __str__ method showing formatted configuration content - Implement __repr__ method with constructor parameters and state summary - Add comprehensive tests for both methods - Update ACKS and add NEWS entry --- Lib/configparser.py | 48 ++++++++++++++-- Lib/test/test_configparser.py | 56 +++++++++++++++++-- Misc/ACKS | 1 + ...-04-25-18-29-10.gh-issue-127011.Ipem5z.rst | 2 + 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-04-25-18-29-10.gh-issue-127011.Ipem5z.rst diff --git a/Lib/configparser.py b/Lib/configparser.py index 239fda60a02ca0..51957433c3e688 100644 --- a/Lib/configparser.py +++ b/Lib/configparser.py @@ -670,6 +670,7 @@ def __init__(self, defaults=None, dict_type=_default_dict, self._optcre = re.compile(self._OPT_TMPL.format(delim=d), re.VERBOSE) self._comments = _CommentSpec(comment_prefixes or (), inline_comment_prefixes or ()) + self._loaded_sources = [] self._strict = strict self._allow_no_value = allow_no_value self._empty_lines_in_values = empty_lines_in_values @@ -757,6 +758,7 @@ def read(self, filenames, encoding=None): if isinstance(filename, os.PathLike): filename = os.fspath(filename) read_ok.append(filename) + self._loaded_sources.append(read_ok) return read_ok def read_file(self, f, source=None): @@ -773,6 +775,7 @@ def read_file(self, f, source=None): except AttributeError: source = '' self._read(f, source) + self._loaded_sources.append(source) def read_string(self, string, source=''): """Read configuration from a given string.""" @@ -809,6 +812,7 @@ def read_dict(self, dictionary, source=''): raise DuplicateOptionError(section, key, source) elements_added.add((section, key)) self.set(section, key, value) + self._loaded_sources.append(source) def get(self, section, option, *, raw=False, vars=None, fallback=_UNSET): """Get an option value for a given section. @@ -1048,6 +1052,38 @@ def __iter__(self): # XXX does it break when underlying container state changed? return itertools.chain((self.default_section,), self._sections.keys()) + def __str__(self): + config_dict = { + section: dict(self.items(section, raw=True)) + for section in self.sections() + } + return f"" + + def __repr__(self): + params = { + "defaults": self._defaults if self._defaults else None, + "dict_type": type(self._dict).__name__, + "allow_no_value": self._allow_no_value, + "delimiters": self._delimiters, + "strict": self._strict, + "default_section": self.default_section, + "interpolation": type(self._interpolation).__name__, + } + params = {k: v for k, v in params.items() if v is not None} + sections_count = len(self._sections) + state = { + "loaded_sources": self._loaded_sources, + "sections_count": sections_count, + "sections": list(self._sections)[:5], # limit to 5 section names for readability + } + + if sections_count > 5: + state["sections_truncated"] = f"...and {sections_count - 5} more" + + return (f"<{self.__class__.__name__}(" + f"params={params}, " + f"state={state})>") + def _read(self, fp, fpname): """Parse a sectioned configuration file. @@ -1068,6 +1104,7 @@ def _read(self, fp, fpname): try: ParsingError._raise_all(self._read_inner(fp, fpname)) finally: + self._loaded_sources.append(fpname) self._join_multiline_values() def _read_inner(self, fp, fpname): @@ -1218,11 +1255,14 @@ def _convert_to_boolean(self, value): def _validate_key_contents(self, key): """Raises an InvalidWriteError for any keys containing - delimiters or that match the section header pattern""" + delimiters or that begins with the section header pattern""" if re.match(self.SECTCRE, key): - raise InvalidWriteError("Cannot write keys matching section pattern") - if any(delim in key for delim in self._delimiters): - raise InvalidWriteError("Cannot write key that contains delimiters") + raise InvalidWriteError( + f"Cannot write key {key}; begins with section pattern") + for delim in self._delimiters: + if delim in key: + raise InvalidWriteError( + f"Cannot write key {key}; contains delimiter {delim}") def _validate_value_types(self, *, section="", option="", value=""): """Raises a TypeError for illegal non-string values. diff --git a/Lib/test/test_configparser.py b/Lib/test/test_configparser.py index 23904d17d326d8..02356c6fdb2b39 100644 --- a/Lib/test/test_configparser.py +++ b/Lib/test/test_configparser.py @@ -980,18 +980,62 @@ def test_set_nonstring_types(self): self.assertRaises(TypeError, cf.set, "sect", 123, "invalid opt name!") self.assertRaises(TypeError, cf.add_section, 123) + def test_str(self): + self.maxDiff = None + cf = self.config_class(allow_no_value=True, delimiters=('=',), strict=True) + cf.add_section("sect1") + cf.add_section("sect2") + cf.set("sect1", "option1", "foo") + cf.set("sect2", "option2", "bar") + + expected_str = ( + "" + ) + self.assertEqual(str(cf), expected_str) + + def test_repr(self): + self.maxDiff = None + cf = self.config_class(allow_no_value=True, delimiters=('=',), strict=True) + cf.add_section("sect1") + cf.add_section("sect2") + cf.add_section("sect3") + cf.add_section("sect4") + cf.add_section("sect5") + cf.add_section("sect6") + cf.set("sect1", "option1", "foo") + cf.set("sect2", "option2", "bar") + cf.read_string("") # to trigger the loading of sources + + dict_type = type(cf._dict).__name__ + params = { + 'dict_type': dict_type, + 'allow_no_value': True, + 'delimiters': ('=',), + 'strict': True, + 'default_section': 'DEFAULT', + 'interpolation': 'BasicInterpolation', + } + state = { + 'loaded_sources': [''], + 'sections_count': 6, + 'sections': ['sect1', 'sect2', 'sect3', 'sect4', 'sect5'], + 'sections_truncated': '...and 1 more', + } + expected = f"<{type(cf).__name__}({params=}, {state=})>" + self.assertEqual(repr(cf), expected) + def test_add_section_default(self): cf = self.newconfig() self.assertRaises(ValueError, cf.add_section, self.default_section) def test_defaults_keyword(self): """bpo-23835 fix for ConfigParser""" - cf = self.newconfig(defaults={1: 2.4}) - self.assertEqual(cf[self.default_section]['1'], '2.4') - self.assertAlmostEqual(cf[self.default_section].getfloat('1'), 2.4) - cf = self.newconfig(defaults={"A": 5.2}) - self.assertEqual(cf[self.default_section]['a'], '5.2') - self.assertAlmostEqual(cf[self.default_section].getfloat('a'), 5.2) + cf = self.newconfig(defaults={1: 2.5}) + self.assertEqual(cf[self.default_section]['1'], '2.5') + self.assertAlmostEqual(cf[self.default_section].getfloat('1'), 2.5) + cf = self.newconfig(defaults={"A": 5.25}) + self.assertEqual(cf[self.default_section]['a'], '5.25') + self.assertAlmostEqual(cf[self.default_section].getfloat('a'), 5.25) class ConfigParserTestCaseNoInterpolation(BasicTestCase, unittest.TestCase): diff --git a/Misc/ACKS b/Misc/ACKS index 571142e7e49763..e400daa4d1acf0 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1602,6 +1602,7 @@ Joel Rosdahl Erik Rose Mark Roseman Josh Rosenberg +Prince Roshan Jim Roskind Brian Rosner Ignacio Rossi diff --git a/Misc/NEWS.d/next/Library/2025-04-25-18-29-10.gh-issue-127011.Ipem5z.rst b/Misc/NEWS.d/next/Library/2025-04-25-18-29-10.gh-issue-127011.Ipem5z.rst new file mode 100644 index 00000000000000..f98b5f36d59585 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-25-18-29-10.gh-issue-127011.Ipem5z.rst @@ -0,0 +1,2 @@ +Implement :meth:`~object.__str__` and :meth:`~object.__repr__` +for :class:`configparser.RawConfigParser` objects. From c5450fbb94839836c304ed7ed12abae07adec3c7 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 Aug 2025 16:28:56 +0530 Subject: [PATCH 2/2] fix test failure --- Lib/configparser.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/Lib/configparser.py b/Lib/configparser.py index 51957433c3e688..3110ca8e1cacf3 100644 --- a/Lib/configparser.py +++ b/Lib/configparser.py @@ -691,6 +691,7 @@ def __init__(self, defaults=None, dict_type=_default_dict, self._read_defaults(defaults) self._allow_unnamed_section = allow_unnamed_section + def defaults(self): return self._defaults @@ -758,7 +759,6 @@ def read(self, filenames, encoding=None): if isinstance(filename, os.PathLike): filename = os.fspath(filename) read_ok.append(filename) - self._loaded_sources.append(read_ok) return read_ok def read_file(self, f, source=None): @@ -775,7 +775,6 @@ def read_file(self, f, source=None): except AttributeError: source = '' self._read(f, source) - self._loaded_sources.append(source) def read_string(self, string, source=''): """Read configuration from a given string.""" @@ -812,7 +811,6 @@ def read_dict(self, dictionary, source=''): raise DuplicateOptionError(section, key, source) elements_added.add((section, key)) self.set(section, key, value) - self._loaded_sources.append(source) def get(self, section, option, *, raw=False, vars=None, fallback=_UNSET): """Get an option value for a given section. @@ -1054,11 +1052,12 @@ def __iter__(self): def __str__(self): config_dict = { - section: dict(self.items(section, raw=True)) + section: {key: value for key, value in self.items(section, raw=True)} for section in self.sections() } return f"" + def __repr__(self): params = { "defaults": self._defaults if self._defaults else None, @@ -1073,8 +1072,8 @@ def __repr__(self): sections_count = len(self._sections) state = { "loaded_sources": self._loaded_sources, - "sections_count": sections_count, - "sections": list(self._sections)[:5], # limit to 5 section names for readability + "sections_count": len(self._sections), + "sections": list(self._sections.keys())[:5], # Limit to 5 section names for readability } if sections_count > 5: @@ -1084,6 +1083,7 @@ def __repr__(self): f"params={params}, " f"state={state})>") + def _read(self, fp, fpname): """Parse a sectioned configuration file. @@ -1255,14 +1255,11 @@ def _convert_to_boolean(self, value): def _validate_key_contents(self, key): """Raises an InvalidWriteError for any keys containing - delimiters or that begins with the section header pattern""" + delimiters or that match the section header pattern""" if re.match(self.SECTCRE, key): - raise InvalidWriteError( - f"Cannot write key {key}; begins with section pattern") - for delim in self._delimiters: - if delim in key: - raise InvalidWriteError( - f"Cannot write key {key}; contains delimiter {delim}") + raise InvalidWriteError("Cannot write keys matching section pattern") + if any(delim in key for delim in self._delimiters): + raise InvalidWriteError("Cannot write key that contains delimiters") def _validate_value_types(self, *, section="", option="", value=""): """Raises a TypeError for illegal non-string values.