]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-66449: configparser: Add support for unnamed sections (#117273)
authorPedro Lacerda <pslacerda@users.noreply.github.com>
Fri, 29 Mar 2024 15:05:00 +0000 (12:05 -0300)
committerGitHub <noreply@github.com>
Fri, 29 Mar 2024 15:05:00 +0000 (15:05 +0000)
Co-authored-by: Jason R. Coombs <jaraco@jaraco.com>
Doc/library/configparser.rst
Doc/whatsnew/3.13.rst
Lib/configparser.py
Lib/test/test_configparser.py
Misc/NEWS.d/next/Library/2024-03-28-17-55-22.gh-issue-66449.4jhuEV.rst [new file with mode: 0644]

index 445626c267fb6fe31cf61a54de4e4d120651ec1f..9e7638d087a7ce35757d631c4559a27d2af53943 100644 (file)
@@ -274,6 +274,11 @@ may be treated as parts of multiline values or ignored.
 By default, a valid section name can be any string that does not contain '\\n'.
 To change this, see :attr:`ConfigParser.SECTCRE`.
 
+The first section name may be omitted if the parser is configured to allow an
+unnamed top level section with ``allow_unnamed_section=True``. In this case,
+the keys/values may be retrieved by :const:`UNNAMED_SECTION` as in
+``config[UNNAMED_SECTION]``.
+
 Configuration files may include comments, prefixed by specific
 characters (``#`` and ``;`` by default [1]_).  Comments may appear on
 their own on an otherwise empty line, possibly indented. [1]_
@@ -325,6 +330,27 @@ For example:
            # Did I mention we can indent comments, too?
 
 
+.. _unnamed-sections:
+
+Unnamed Sections
+----------------
+
+The name of the first section (or unique) may be omitted and values
+retrieved by the :const:`UNNAMED_SECTION` attribute.
+
+.. doctest::
+
+   >>> config = """
+   ... option = value
+   ...
+   ... [  Section 2  ]
+   ... another = val
+   ... """
+   >>> unnamed = configparser.ConfigParser(allow_unnamed_section=True)
+   >>> unnamed.read_string(config)
+   >>> unnamed.get(configparser.UNNAMED_SECTION, 'option')
+   'value'
+
 Interpolation of values
 -----------------------
 
@@ -1216,6 +1242,11 @@ ConfigParser Objects
       names is stripped before :meth:`optionxform` is called.
 
 
+.. data:: UNNAMED_SECTION
+
+   A special object representing a section name used to reference the unnamed section (see :ref:`unnamed-sections`).
+
+
 .. data:: MAX_INTERPOLATION_DEPTH
 
    The maximum depth for recursive interpolation for :meth:`~configparser.ConfigParser.get` when the *raw*
index 5a5c506d83d735bf9b98bc13189da13bce9d9780..f50364a7ddcc2a860439123f49dd3d8a8c173b8c 100644 (file)
@@ -214,6 +214,12 @@ Other Language Changes
 
   (Contributed by William Woodruff in :gh:`112389`.)
 
+* The :class:`configparser.ConfigParser` now accepts unnamed sections before named
+   ones if configured to do so.
+
+   (Contributed by Pedro Sousa Lacerda in :gh:`66449`)
+
+
 New Modules
 ===========
 
index 8f182eec306b8ba7e9906892c48857f47be066d9..3040e1fbe5b9c1832ab19d16e950bcd066d66e95 100644 (file)
@@ -18,8 +18,8 @@ ConfigParser -- responsible for parsing a list of
              delimiters=('=', ':'), comment_prefixes=('#', ';'),
              inline_comment_prefixes=None, strict=True,
              empty_lines_in_values=True, default_section='DEFAULT',
-             interpolation=<unset>, converters=<unset>):
-
+             interpolation=<unset>, converters=<unset>,
+             allow_unnamed_section=False):
         Create the parser. When `defaults` is given, it is initialized into the
         dictionary or intrinsic defaults. The keys must be strings, the values
         must be appropriate for %()s string interpolation.
@@ -68,6 +68,10 @@ ConfigParser -- responsible for parsing a list of
         converter gets its corresponding get*() method on the parser object and
         section proxies.
 
+        When `allow_unnamed_section` is True (default: False), options
+        without section are accepted: the section for these is
+        ``configparser.UNNAMED_SECTION``.
+
     sections()
         Return all the configuration section names, sans DEFAULT.
 
@@ -156,7 +160,7 @@ __all__ = ("NoSectionError", "DuplicateOptionError", "DuplicateSectionError",
            "ConfigParser", "RawConfigParser",
            "Interpolation", "BasicInterpolation",  "ExtendedInterpolation",
            "SectionProxy", "ConverterMapping",
-           "DEFAULTSECT", "MAX_INTERPOLATION_DEPTH")
+           "DEFAULTSECT", "MAX_INTERPOLATION_DEPTH", "UNNAMED_SECTION")
 
 _default_dict = dict
 DEFAULTSECT = "DEFAULT"
@@ -336,6 +340,15 @@ class MultilineContinuationError(ParsingError):
         self.line = line
         self.args = (filename, lineno, line)
 
+class _UnnamedSection:
+
+    def __repr__(self):
+        return "<UNNAMED_SECTION>"
+
+
+UNNAMED_SECTION = _UnnamedSection()
+
+
 # Used in parser getters to indicate the default behaviour when a specific
 # option is not found it to raise an exception. Created to enable `None` as
 # a valid fallback value.
@@ -550,7 +563,8 @@ class RawConfigParser(MutableMapping):
                  comment_prefixes=('#', ';'), inline_comment_prefixes=None,
                  strict=True, empty_lines_in_values=True,
                  default_section=DEFAULTSECT,
-                 interpolation=_UNSET, converters=_UNSET):
+                 interpolation=_UNSET, converters=_UNSET,
+                 allow_unnamed_section=False,):
 
         self._dict = dict_type
         self._sections = self._dict()
@@ -589,6 +603,7 @@ class RawConfigParser(MutableMapping):
             self._converters.update(converters)
         if defaults:
             self._read_defaults(defaults)
+        self._allow_unnamed_section = allow_unnamed_section
 
     def defaults(self):
         return self._defaults
@@ -862,13 +877,19 @@ class RawConfigParser(MutableMapping):
         if self._defaults:
             self._write_section(fp, self.default_section,
                                     self._defaults.items(), d)
+        if UNNAMED_SECTION in self._sections:
+            self._write_section(fp, UNNAMED_SECTION, self._sections[UNNAMED_SECTION].items(), d, unnamed=True)
+
         for section in self._sections:
+            if section is UNNAMED_SECTION:
+                continue
             self._write_section(fp, section,
                                 self._sections[section].items(), d)
 
-    def _write_section(self, fp, section_name, section_items, delimiter):
-        """Write a single section to the specified `fp`."""
-        fp.write("[{}]\n".format(section_name))
+    def _write_section(self, fp, section_name, section_items, delimiter, unnamed=False):
+        """Write a single section to the specified `fp'."""
+        if not unnamed:
+            fp.write("[{}]\n".format(section_name))
         for key, value in section_items:
             value = self._interpolation.before_write(self, section_name, key,
                                                      value)
@@ -961,6 +982,7 @@ class RawConfigParser(MutableMapping):
         lineno = 0
         indent_level = 0
         e = None                              # None, or an exception
+
         try:
             for lineno, line in enumerate(fp, start=1):
                 comment_start = sys.maxsize
@@ -1007,6 +1029,13 @@ class RawConfigParser(MutableMapping):
                     cursect[optname].append(value)
                 # a section header or option header?
                 else:
+                    if self._allow_unnamed_section and cursect is None:
+                        sectname = UNNAMED_SECTION
+                        cursect = self._dict()
+                        self._sections[sectname] = cursect
+                        self._proxies[sectname] = SectionProxy(self, sectname)
+                        elements_added.add(sectname)
+
                     indent_level = cur_indent_level
                     # is it a section header?
                     mo = self.SECTCRE.match(value)
@@ -1027,36 +1056,61 @@ class RawConfigParser(MutableMapping):
                             elements_added.add(sectname)
                         # So sections can't start with a continuation line
                         optname = None
-                    # no section header in the file?
+                    # no section header?
                     elif cursect is None:
                         raise MissingSectionHeaderError(fpname, lineno, line)
-                    # an option line?
+                        # an option line?
                     else:
-                        mo = self._optcre.match(value)
+                        indent_level = cur_indent_level
+                        # is it a section header?
+                        mo = self.SECTCRE.match(value)
                         if mo:
-                            optname, vi, optval = mo.group('option', 'vi', 'value')
-                            if not optname:
-                                e = self._handle_error(e, fpname, lineno, line)
-                            optname = self.optionxform(optname.rstrip())
-                            if (self._strict and
-                                (sectname, optname) in elements_added):
-                                raise DuplicateOptionError(sectname, optname,
-                                                        fpname, lineno)
-                            elements_added.add((sectname, optname))
-                            # This check is fine because the OPTCRE cannot
-                            # match if it would set optval to None
-                            if optval is not None:
-                                optval = optval.strip()
-                                cursect[optname] = [optval]
+                            sectname = mo.group('header')
+                            if sectname in self._sections:
+                                if self._strict and sectname in elements_added:
+                                    raise DuplicateSectionError(sectname, fpname,
+                                                                lineno)
+                                cursect = self._sections[sectname]
+                                elements_added.add(sectname)
+                            elif sectname == self.default_section:
+                                cursect = self._defaults
                             else:
-                                # valueless option handling
-                                cursect[optname] = None
+                                cursect = self._dict()
+                                self._sections[sectname] = cursect
+                                self._proxies[sectname] = SectionProxy(self, sectname)
+                                elements_added.add(sectname)
+                            # So sections can't start with a continuation line
+                            optname = None
+                        # no section header in the file?
+                        elif cursect is None:
+                            raise MissingSectionHeaderError(fpname, lineno, line)
+                        # an option line?
                         else:
-                            # a non-fatal parsing error occurred. set up the
-                            # exception but keep going. the exception will be
-                            # raised at the end of the file and will contain a
-                            # list of all bogus lines
-                            e = self._handle_error(e, fpname, lineno, line)
+                            mo = self._optcre.match(value)
+                            if mo:
+                                optname, vi, optval = mo.group('option', 'vi', 'value')
+                                if not optname:
+                                    e = self._handle_error(e, fpname, lineno, line)
+                                optname = self.optionxform(optname.rstrip())
+                                if (self._strict and
+                                    (sectname, optname) in elements_added):
+                                    raise DuplicateOptionError(sectname, optname,
+                                                            fpname, lineno)
+                                elements_added.add((sectname, optname))
+                                # This check is fine because the OPTCRE cannot
+                                # match if it would set optval to None
+                                if optval is not None:
+                                    optval = optval.strip()
+                                    cursect[optname] = [optval]
+                                else:
+                                    # valueless option handling
+                                    cursect[optname] = None
+                            else:
+                                # a non-fatal parsing error occurred. set up the
+                                # exception but keep going. the exception will be
+                                # raised at the end of the file and will contain a
+                                # list of all bogus lines
+                                e = self._handle_error(e, fpname, lineno, line)
         finally:
             self._join_multiline_values()
         # if any parsing errors occurred, raise an exception
index 6340e378c4f21a16834287cea3620baf9e11eb98..fe09472db89cd286595fb3b279d94d841fd17c5c 100644 (file)
@@ -2115,6 +2115,54 @@ class BlatantOverrideConvertersTestCase(unittest.TestCase):
             self.assertEqual(cfg['two'].getlen('one'), 5)
 
 
+class SectionlessTestCase(unittest.TestCase):
+
+    def fromstring(self, string):
+        cfg = configparser.ConfigParser(allow_unnamed_section=True)
+        cfg.read_string(string)
+        return cfg
+
+    def test_no_first_section(self):
+        cfg1 = self.fromstring("""
+        a = 1
+        b = 2
+        [sect1]
+        c = 3
+        """)
+
+        self.assertEqual(set([configparser.UNNAMED_SECTION, 'sect1']), set(cfg1.sections()))
+        self.assertEqual('1', cfg1[configparser.UNNAMED_SECTION]['a'])
+        self.assertEqual('2', cfg1[configparser.UNNAMED_SECTION]['b'])
+        self.assertEqual('3', cfg1['sect1']['c'])
+
+        output = io.StringIO()
+        cfg1.write(output)
+        cfg2 = self.fromstring(output.getvalue())
+
+        #self.assertEqual(set([configparser.UNNAMED_SECTION, 'sect1']), set(cfg2.sections()))
+        self.assertEqual('1', cfg2[configparser.UNNAMED_SECTION]['a'])
+        self.assertEqual('2', cfg2[configparser.UNNAMED_SECTION]['b'])
+        self.assertEqual('3', cfg2['sect1']['c'])
+
+    def test_no_section(self):
+        cfg1 = self.fromstring("""
+        a = 1
+        b = 2
+        """)
+
+        self.assertEqual([configparser.UNNAMED_SECTION], cfg1.sections())
+        self.assertEqual('1', cfg1[configparser.UNNAMED_SECTION]['a'])
+        self.assertEqual('2', cfg1[configparser.UNNAMED_SECTION]['b'])
+
+        output = io.StringIO()
+        cfg1.write(output)
+        cfg2 = self.fromstring(output.getvalue())
+
+        self.assertEqual([configparser.UNNAMED_SECTION], cfg2.sections())
+        self.assertEqual('1', cfg2[configparser.UNNAMED_SECTION]['a'])
+        self.assertEqual('2', cfg2[configparser.UNNAMED_SECTION]['b'])
+
+
 class MiscTestCase(unittest.TestCase):
     def test__all__(self):
         support.check__all__(self, configparser, not_exported={"Error"})
diff --git a/Misc/NEWS.d/next/Library/2024-03-28-17-55-22.gh-issue-66449.4jhuEV.rst b/Misc/NEWS.d/next/Library/2024-03-28-17-55-22.gh-issue-66449.4jhuEV.rst
new file mode 100644 (file)
index 0000000..898100b
--- /dev/null
@@ -0,0 +1,2 @@
+:class:`configparser.ConfigParser` now accepts unnamed sections before named
+ones, if configured to do so.