diff --git a/Lib/configparser.py b/Lib/configparser.py index 239fda60a02ca0..3110ca8e1cacf3 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 @@ -690,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 @@ -1048,6 +1050,40 @@ 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: {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, + "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": len(self._sections), + "sections": list(self._sections.keys())[: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): 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.