Index: Lib/configparser.py =================================================================== --- Lib/configparser.py (revision 84996) +++ Lib/configparser.py (working copy) @@ -123,13 +123,9 @@ between keys and values are surrounded by spaces. """ -try: - from collections import OrderedDict as _default_dict -except ImportError: - # fallback for setup.py which hasn't yet built _collections - _default_dict = dict - +from collections import MutableMapping, OrderedDict as _default_dict import io +import itertools import re import sys import warnings @@ -366,7 +362,7 @@ _UNSET = object() -class RawConfigParser: +class RawConfigParser(MutableMapping): """ConfigParser that does not do interpolation.""" # Regular expressions for parsing section headers and options @@ -413,6 +409,8 @@ self._dict = dict_type self._sections = self._dict() self._defaults = self._dict() + self._views = self._dict() + self._views[DEFAULTSECT] = SectionView(self, DEFAULTSECT) if defaults: for key, value in defaults.items(): self._defaults[self.optionxform(key)] = value @@ -451,12 +449,13 @@ already exists. Raise ValueError if name is DEFAULT or any of it's case-insensitive variants. """ - if section.lower() == "default": + if section.upper() == DEFAULTSECT: raise ValueError('Invalid section name: %s' % section) if section in self._sections: raise DuplicateSectionError(section) self._sections[section] = self._dict() + self._views[section] = SectionView(self, section) def has_section(self, section): """Indicate whether the named section is present in the configuration. @@ -698,8 +697,41 @@ existed = section in self._sections if existed: del self._sections[section] + del self._views[section] return existed + def __getitem__(self, key): + if key != DEFAULTSECT and not self.has_section(key): + raise KeyError(key) + return self._views[key] + + def __setitem__(self, key, value): + # To conform with the mapping protocol, overwrites existing values in + # the section. + + # XXX this is not atomic if read_dict fails at any point. Then again, + # no update method in configparser is atomic in this implementation. + # We might consider atomicity a fourth big feature for 3.2. + self.remove_section(key) + self.read_dict({key: value}) + + def __delitem__(self, key): + if key == DEFAULTSECT: + raise ValueError("Cannot remove the default section.") + if not self.has_section(key): + raise KeyError(key) + self.remove_section(key) + + def __contains__(self, key): + return key == DEFAULTSECT or self.has_section(key) + + def __len__(self): + return len(self._sections) + 1 # the default section + + def __iter__(self): + # XXX does it break when underlying container state changed? + return itertools.chain((DEFAULTSECT,), self._sections.keys()) + def _read(self, fp, fpname): """Parse a sectioned configuration file. @@ -776,6 +808,7 @@ cursect = self._dict() cursect['__name__'] = sectname self._sections[sectname] = cursect + self._views[sectname] = SectionView(self, sectname) elements_added.add(sectname) # So sections can't start with a continuation line optname = None @@ -818,8 +851,8 @@ self._join_multiline_values() def _join_multiline_values(self): - all_sections = [self._defaults] - all_sections.extend(self._sections.values()) + all_sections = itertools.chain((self._defaults,), + self._sections.values()) for options in all_sections: for name, val in options.items(): if isinstance(val, list): @@ -1053,3 +1086,53 @@ raise ValueError("invalid interpolation syntax in %r at " "position %d" % (value, tmp_value.find('%'))) ConfigParser.set(self, section, option, value) + + +class SectionView(MutableMapping): + """A mutable view on a single section from a parser.""" + + _noname = ("__name__ special key access and modification " + "not supported through the mapping interface.") + + def __init__(self, parser, section_name): + """Creates a view on a section named `section_name` in `parser`.""" + self._parser = parser + self._section = section_name + + def __repr__(self): + return ''.format(self._section) + + def __getitem__(self, key): + if key == '__name__': + raise ValueError(self._noname) + if not self._parser.has_option(self._section, key): + raise KeyError(key) + return self._parser.get(self._section, key) + + def __setitem__(self, key, value): + if key == '__name__': + raise ValueError(self._noname) + return self._parser.set(self._section, key, value) + + def __delitem__(self, key): + if key == '__name__': + raise ValueError(self._noname) + if not self._parser.has_option(self._section, key): + raise KeyError(key) + return self._parser.remove_option(self._section, key) + + def __contains__(self, key): + if key == '__name__': + return False + return self._parser.has_option(self._section, key) + + def __len__(self): + # __name__ is properly hidden by .options() + # XXX weak performance + return len(self._parser.options(self._section)) + + def __iter__(self): + # __name__ is properly hidden by .options() + # XXX weak performance + # XXX does not break when underlying container state changed + return self._parser.options(self._section).__iter__() Index: Lib/test/test_cfgparser.py =================================================================== --- Lib/test/test_cfgparser.py (revision 84996) +++ Lib/test/test_cfgparser.py (working copy) @@ -52,8 +52,6 @@ class BasicTestCase(CfgParserTestCaseClass): def basic_test(self, cf): - L = cf.sections() - L.sort() E = ['Commented Bar', 'Foo Bar', 'Internationalized Stuff', @@ -64,20 +62,34 @@ 'Spacey Bar From The Beginning', 'Types', ] + if self.allow_no_value: E.append('NoValue') E.sort() + + # API access + L = cf.sections() + L.sort() eq = self.assertEqual eq(L, E) + # mapping access + L = [section for section in cf] + L.sort() + E.append(configparser.DEFAULTSECT) + E.sort() + eq(L, E) + # The use of spaces in the section names serves as a # regression test for SourceForge bug #583248: # http://www.python.org/sf/583248 - eq(cf.get('Foo Bar', 'foo'), 'bar') - eq(cf.get('Spacey Bar', 'foo'), 'bar') - eq(cf.get('Spacey Bar From The Beginning', 'foo'), 'bar') + + # API access + eq(cf.get('Foo Bar', 'foo'), 'bar1') + eq(cf.get('Spacey Bar', 'foo'), 'bar2') + eq(cf.get('Spacey Bar From The Beginning', 'foo'), 'bar3') eq(cf.get('Spacey Bar From The Beginning', 'baz'), 'qwe') - eq(cf.get('Commented Bar', 'foo'), 'bar') + eq(cf.get('Commented Bar', 'foo'), 'bar4') eq(cf.get('Commented Bar', 'baz'), 'qwe') eq(cf.get('Spaces', 'key with spaces'), 'value') eq(cf.get('Spaces', 'another with spaces'), 'splat!') @@ -90,7 +102,7 @@ eq(cf.get('NoValue', 'option-without-value'), None) # test vars= and default= - eq(cf.get('Foo Bar', 'foo', default='baz'), 'bar') + eq(cf.get('Foo Bar', 'foo', default='baz'), 'bar1') eq(cf.get('Foo Bar', 'foo', vars={'foo': 'baz'}), 'baz') with self.assertRaises(configparser.NoSectionError): cf.get('No Such Foo Bar', 'foo') @@ -98,7 +110,7 @@ cf.get('Foo Bar', 'no-such-foo') eq(cf.get('No Such Foo Bar', 'foo', default='baz'), 'baz') eq(cf.get('Foo Bar', 'no-such-foo', default='baz'), 'baz') - eq(cf.get('Spacey Bar', 'foo', default=None), 'bar') + eq(cf.get('Spacey Bar', 'foo', default=None), 'bar2') eq(cf.get('No Such Spacey Bar', 'foo', default=None), None) eq(cf.getint('Types', 'int', default=18), 42) eq(cf.getint('Types', 'no-such-int', default=18), 18) @@ -118,11 +130,40 @@ eq(cf.get('NoValue', 'no-such-option-without-value', default=False), False) + # mapping access + eq(cf['Foo Bar']['foo'], 'bar1') + eq(cf['Spacey Bar']['foo'], 'bar2') + eq(cf['Spacey Bar From The Beginning']['foo'], 'bar3') + eq(cf['Spacey Bar From The Beginning']['baz'], 'qwe') + eq(cf['Commented Bar']['foo'], 'bar4') + eq(cf['Commented Bar']['baz'], 'qwe') + eq(cf['Spaces']['key with spaces'], 'value') + eq(cf['Spaces']['another with spaces'], 'splat!') + eq(cf['Long Line']['foo'], + 'this line is much, much longer than my editor\nlikes it.') + if self.allow_no_value: + eq(cf['NoValue']['option-without-value'], None) + + # API access self.assertNotIn('__name__', cf.options("Foo Bar"), '__name__ "option" should not be exposed by the API!') + # mapping access + self.assertNotIn('__name__', cf['Foo Bar'], + '__name__ "option" should not be exposed by ' + 'mapping protocol access') + self.assertFalse('__name__' in cf['Foo Bar']) + with self.assertRaises(ValueError): + cf['Foo Bar']['__name__'] + with self.assertRaises(ValueError): + del cf['Foo Bar']['__name__'] + with self.assertRaises(ValueError): + cf['Foo Bar']['__name__'] = "can't write to this special name" + # Make sure the right things happen for remove_option(); # added to include check for SourceForge bug #123324: + + # API acceess self.assertTrue(cf.remove_option('Foo Bar', 'foo'), "remove_option() failed to report existence of option") self.assertFalse(cf.has_option('Foo Bar', 'foo'), @@ -138,17 +179,25 @@ eq(cf.get('Long Line', 'foo'), 'this line is much, much longer than my editor\nlikes it.') + # mapping access + del cf['Spacey Bar']['foo'] + self.assertFalse('foo' in cf['Spacey Bar']) + with self.assertRaises(KeyError): + del cf['Spacey Bar']['foo'] + with self.assertRaises(KeyError): + del cf['No Such Section']['foo'] + def test_basic(self): config_string = """\ [Foo Bar] -foo{0[0]}bar +foo{0[0]}bar1 [Spacey Bar] -foo {0[0]} bar +foo {0[0]} bar2 [Spacey Bar From The Beginning] - foo {0[0]} bar + foo {0[0]} bar3 baz {0[0]} qwe [Commented Bar] -foo{0[1]} bar {1[1]} comment +foo{0[1]} bar4 {1[1]} comment baz{0[0]}qwe {1[0]}another one [Long Line] foo{0[1]} this line is much, much longer than my editor @@ -205,17 +254,17 @@ def test_basic_from_dict(self): config = { "Foo Bar": { - "foo": "bar", + "foo": "bar1", }, "Spacey Bar": { - "foo": "bar", + "foo": "bar2", }, "Spacey Bar From The Beginning": { - "foo": "bar", + "foo": "bar3", "baz": "qwe", }, "Commented Bar": { - "foo": "bar", + "foo": "bar4", "baz": "qwe", }, "Long Line": { @@ -270,14 +319,18 @@ cf = self.newconfig() cf.add_section("A") cf.add_section("a") + cf.add_section("B") L = cf.sections() L.sort() eq = self.assertEqual - eq(L, ["A", "a"]) + eq(L, ["A", "B", "a"]) cf.set("a", "B", "value") eq(cf.options("a"), ["b"]) eq(cf.get("a", "b"), "value", "could not locate option, expecting case-insensitive option names") + with self.assertRaises(configparser.NoSectionError): + # section names are case-sensitive + cf.set("b", "A", "value") self.assertTrue(cf.has_option("a", "b")) cf.set("A", "A-B", "A-B value") for opt in ("a-b", "A-b", "a-B", "A-B"): @@ -291,7 +344,7 @@ # SF bug #432369: cf = self.fromstring( - "[MySection]\nOption{} first line\n\tsecond line\n".format( + "[MySection]\nOption{} first line \n\tsecond line \n".format( self.delimiters[0])) eq(cf.options("MySection"), ["option"]) eq(cf.get("MySection", "Option"), "first line\nsecond line") @@ -303,6 +356,46 @@ self.assertTrue(cf.has_option("section", "Key")) + def test_case_sensitivity_mapping_access(self): + cf = self.newconfig() + cf["A"] = {} + cf["a"] = {"B": "value"} + cf["B"] = {} + L = [section for section in cf] + L.sort() + eq = self.assertEqual + elem_eq = self.assertItemsEqual + eq(L, ["A", "B", configparser.DEFAULTSECT, "a"]) + eq(cf["a"].keys(), {"b"}) + eq(cf["a"]["b"], "value", + "could not locate option, expecting case-insensitive option names") + with self.assertRaises(KeyError): + # section names are case-sensitive + cf["b"]["A"] = "value" + self.assertTrue("b" in cf["a"]) + cf["A"]["A-B"] = "A-B value" + for opt in ("a-b", "A-b", "a-B", "A-B"): + self.assertTrue( + opt in cf["A"], + "has_option() returned false for option which should exist") + eq(cf["A"].keys(), {"a-b"}) + eq(cf["a"].keys(), {"b"}) + del cf["a"]["B"] + elem_eq(cf["a"].keys(), {}) + + # SF bug #432369: + cf = self.fromstring( + "[MySection]\nOption{} first line \n\tsecond line \n".format( + self.delimiters[0])) + eq(cf["MySection"].keys(), {"option"}) + eq(cf["MySection"]["Option"], "first line\nsecond line") + + # SF bug #561822: + cf = self.fromstring("[section]\n" + "nekey{}nevalue\n".format(self.delimiters[0]), + defaults={"key":"value"}) + self.assertTrue("Key" in cf["section"]) + def test_default_case_sensitivity(self): cf = self.newconfig({"foo": "Bar"}) self.assertEqual(