Skip to content

Commit f7bbe6b

Browse files
committed
Support multiple git config values per option
Solves #717
1 parent 7a6ca8c commit f7bbe6b

File tree

4 files changed

+234
-10
lines changed

4 files changed

+234
-10
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ Contributors are:
2828
-Yaroslav Halchenko <debian _at_ onerussian.com>
2929
-Tim Swast <swast _at_ google.com>
3030
-William Luc Ritchie
31+
-A. Jesse Jiryu Davis <jesse _at_ emptysquare.net>
3132

3233
Portions derived from other open source works and are clearly marked.

git/config.py

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,43 @@ def __exit__(self, exception_type, exception_value, traceback):
146146
self._config.__exit__(exception_type, exception_value, traceback)
147147

148148

149+
class _OMD(OrderedDict):
150+
"""Ordered multi-dict."""
151+
152+
def __setitem__(self, key, value):
153+
super(_OMD, self).__setitem__(key, [value])
154+
155+
def add(self, key, value):
156+
if key not in self:
157+
super(_OMD, self).__setitem__(key, [value])
158+
return
159+
160+
super(_OMD, self).__getitem__(key).append(value)
161+
162+
def setall(self, key, values):
163+
super(_OMD, self).__setitem__(key, values)
164+
165+
def __getitem__(self, key):
166+
return super(_OMD, self).__getitem__(key)[-1]
167+
168+
def getlast(self, key):
169+
return super(_OMD, self).__getitem__(key)[-1]
170+
171+
def setlast(self, key, value):
172+
if key not in self:
173+
super(_OMD, self).__setitem__(key, [value])
174+
return
175+
176+
prior = super(_OMD, self).__getitem__(key)
177+
prior[-1] = value
178+
179+
def getall(self, key):
180+
return super(_OMD, self).__getitem__(key)
181+
182+
def items_all(self):
183+
return [(k, self.get(k)) for k in self]
184+
185+
149186
class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, object)):
150187

151188
"""Implements specifics required to read git style configuration files.
@@ -200,7 +237,7 @@ def __init__(self, file_or_files, read_only=True, merge_includes=True):
200237
contents into ours. This makes it impossible to write back an individual configuration file.
201238
Thus, if you want to modify a single configuration file, turn this off to leave the original
202239
dataset unaltered when reading it."""
203-
cp.RawConfigParser.__init__(self, dict_type=OrderedDict)
240+
cp.RawConfigParser.__init__(self, dict_type=_OMD)
204241

205242
# Used in python 3, needs to stay in sync with sections for underlying implementation to work
206243
if not hasattr(self, '_proxies'):
@@ -348,7 +385,8 @@ def string_decode(v):
348385
is_multi_line = True
349386
optval = string_decode(optval[1:])
350387
# end handle multi-line
351-
cursect[optname] = optval
388+
# preserves multiple values for duplicate optnames
389+
cursect.add(optname, optval)
352390
else:
353391
# check if it's an option with no value - it's just ignored by git
354392
if not self.OPTVALUEONLY.match(line):
@@ -362,7 +400,8 @@ def string_decode(v):
362400
is_multi_line = False
363401
line = line[:-1]
364402
# end handle quotations
365-
cursect[optname] += string_decode(line)
403+
optval = cursect.getlast(optname)
404+
cursect.setlast(optname, optval + string_decode(line))
366405
# END parse section or option
367406
# END while reading
368407

@@ -442,9 +481,17 @@ def _write(self, fp):
442481
git compatible format"""
443482
def write_section(name, section_dict):
444483
fp.write(("[%s]\n" % name).encode(defenc))
445-
for (key, value) in section_dict.items():
446-
if key != "__name__":
447-
fp.write(("\t%s = %s\n" % (key, self._value_to_string(value).replace('\n', '\n\t'))).encode(defenc))
484+
for (key, value) in section_dict.items_all():
485+
if key == "__name__":
486+
continue
487+
elif isinstance(value, list):
488+
values = value
489+
else:
490+
# self._defaults isn't a multidict
491+
values = [value]
492+
493+
for v in values:
494+
fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace('\n', '\n\t'))).encode(defenc))
448495
# END if key is not __name__
449496
# END section writing
450497

@@ -457,6 +504,27 @@ def items(self, section_name):
457504
""":return: list((option, value), ...) pairs of all items in the given section"""
458505
return [(k, v) for k, v in super(GitConfigParser, self).items(section_name) if k != '__name__']
459506

507+
def items_all(self, section_name):
508+
""":return: list((option, [values...]), ...) pairs of all items in the given section"""
509+
rv = OrderedDict()
510+
for k, v in self._defaults:
511+
rv[k] = [v]
512+
513+
for k, v in self._sections[section_name].items_all():
514+
if k == '__name__':
515+
continue
516+
517+
if k not in rv:
518+
rv[k] = v
519+
continue
520+
521+
if rv[k] == v:
522+
continue
523+
524+
rv[k].extend(v)
525+
526+
return rv.items()
527+
460528
@needs_values
461529
def write(self):
462530
"""Write changes to our file, if there are changes at all
@@ -508,7 +576,11 @@ def read_only(self):
508576
return self._read_only
509577

510578
def get_value(self, section, option, default=None):
511-
"""
579+
"""Get an option's value.
580+
581+
If multiple values are specified for this option in the section, the
582+
last one specified is returned.
583+
512584
:param default:
513585
If not None, the given default value will be returned in case
514586
the option did not exist
@@ -523,6 +595,31 @@ def get_value(self, section, option, default=None):
523595
return default
524596
raise
525597

598+
return self._string_to_value(valuestr)
599+
600+
def get_values(self, section, option, default=None):
601+
"""Get an option's values.
602+
603+
If multiple values are specified for this option in the section, all are
604+
returned.
605+
606+
:param default:
607+
If not None, a list containing the given default value will be
608+
returned in case the option did not exist
609+
:return: a list of properly typed values, either int, float or string
610+
611+
:raise TypeError: in case the value could not be understood
612+
Otherwise the exceptions known to the ConfigParser will be raised."""
613+
try:
614+
lst = self._sections[section].getall(option)
615+
except Exception:
616+
if default is not None:
617+
return [default]
618+
raise
619+
620+
return [self._string_to_value(valuestr) for valuestr in lst]
621+
622+
def _string_to_value(self, valuestr):
526623
types = (int, float)
527624
for numtype in types:
528625
try:
@@ -545,7 +642,9 @@ def get_value(self, section, option, default=None):
545642
return True
546643

547644
if not isinstance(valuestr, string_types):
548-
raise TypeError("Invalid value type: only int, long, float and str are allowed", valuestr)
645+
raise TypeError(
646+
"Invalid value type: only int, long, float and str are allowed",
647+
valuestr)
549648

550649
return valuestr
551650

@@ -572,6 +671,25 @@ def set_value(self, section, option, value):
572671
self.set(section, option, self._value_to_string(value))
573672
return self
574673

674+
@needs_values
675+
@set_dirty_and_flush_changes
676+
def add_value(self, section, option, value):
677+
"""Adds a value for the given option in section.
678+
It will create the section if required, and will not throw as opposed to the default
679+
ConfigParser 'set' method. The value becomes the new value of the option as returned
680+
by 'get_value', and appends to the list of values returned by 'get_values`'.
681+
682+
:param section: Name of the section in which the option resides or should reside
683+
:param option: Name of the option
684+
685+
:param value: Value to add to option. It must be a string or convertible
686+
to a string
687+
:return: this instance"""
688+
if not self.has_section(section):
689+
self.add_section(section)
690+
self._sections[section].add(option, self._value_to_string(value))
691+
return self
692+
575693
def rename_section(self, section, new_name):
576694
"""rename the given section to new_name
577695
:raise ValueError: if section doesn't exit
@@ -584,8 +702,9 @@ def rename_section(self, section, new_name):
584702
raise ValueError("Destination section '%s' already exists" % new_name)
585703

586704
super(GitConfigParser, self).add_section(new_name)
587-
for k, v in self.items(section):
588-
self.set(new_name, k, self._value_to_string(v))
705+
new_section = self._sections[new_name]
706+
for k, vs in self.items_all(section):
707+
new_section.setall(k, vs)
589708
# end for each value to copy
590709

591710
# This call writes back the changes, which is why we don't have the respective decorator

git/test/fixtures/git_config_multiple

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[section0]
2+
option0 = value0
3+
4+
[section1]
5+
option1 = value1a
6+
option1 = value1b
7+
other_option1 = other_value1

git/test/test_config.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,100 @@ def test_empty_config_value(self):
265265

266266
with self.assertRaises(cp.NoOptionError):
267267
cr.get_value('color', 'ui')
268+
269+
def test_multiple_values(self):
270+
file_obj = self._to_memcache(fixture_path('git_config_multiple'))
271+
with GitConfigParser(file_obj, read_only=False) as cw:
272+
self.assertEqual(cw.get('section0', 'option0'), 'value0')
273+
self.assertEqual(cw.get_values('section0', 'option0'), ['value0'])
274+
self.assertEqual(cw.items('section0'), [('option0', 'value0')])
275+
276+
# Where there are multiple values, "get" returns the last.
277+
self.assertEqual(cw.get('section1', 'option1'), 'value1b')
278+
self.assertEqual(cw.get_values('section1', 'option1'),
279+
['value1a', 'value1b'])
280+
self.assertEqual(cw.items('section1'),
281+
[('option1', 'value1b'),
282+
('other_option1', 'other_value1')])
283+
self.assertEqual(cw.items_all('section1'),
284+
[('option1', ['value1a', 'value1b']),
285+
('other_option1', ['other_value1'])])
286+
with self.assertRaises(KeyError):
287+
cw.get_values('section1', 'missing')
288+
289+
self.assertEqual(cw.get_values('section1', 'missing', 1), [1])
290+
self.assertEqual(cw.get_values('section1', 'missing', 's'), ['s'])
291+
292+
def test_multiple_values_rename(self):
293+
file_obj = self._to_memcache(fixture_path('git_config_multiple'))
294+
with GitConfigParser(file_obj, read_only=False) as cw:
295+
cw.rename_section('section1', 'section2')
296+
cw.write()
297+
file_obj.seek(0)
298+
cr = GitConfigParser(file_obj, read_only=True)
299+
self.assertEqual(cr.get_value('section2', 'option1'), 'value1b')
300+
self.assertEqual(cr.get_values('section2', 'option1'),
301+
['value1a', 'value1b'])
302+
self.assertEqual(cr.items('section2'),
303+
[('option1', 'value1b'),
304+
('other_option1', 'other_value1')])
305+
self.assertEqual(cr.items_all('section2'),
306+
[('option1', ['value1a', 'value1b']),
307+
('other_option1', ['other_value1'])])
308+
309+
def test_multiple_to_single(self):
310+
file_obj = self._to_memcache(fixture_path('git_config_multiple'))
311+
with GitConfigParser(file_obj, read_only=False) as cw:
312+
cw.set_value('section1', 'option1', 'value1c')
313+
314+
cw.write()
315+
file_obj.seek(0)
316+
cr = GitConfigParser(file_obj, read_only=True)
317+
self.assertEqual(cr.get_value('section1', 'option1'), 'value1c')
318+
self.assertEqual(cr.get_values('section1', 'option1'), ['value1c'])
319+
self.assertEqual(cr.items('section1'),
320+
[('option1', 'value1c'),
321+
('other_option1', 'other_value1')])
322+
self.assertEqual(cr.items_all('section1'),
323+
[('option1', ['value1c']),
324+
('other_option1', ['other_value1'])])
325+
326+
def test_single_to_multiple(self):
327+
file_obj = self._to_memcache(fixture_path('git_config_multiple'))
328+
with GitConfigParser(file_obj, read_only=False) as cw:
329+
cw.add_value('section1', 'other_option1', 'other_value1a')
330+
331+
cw.write()
332+
file_obj.seek(0)
333+
cr = GitConfigParser(file_obj, read_only=True)
334+
self.assertEqual(cr.get_value('section1', 'option1'), 'value1b')
335+
self.assertEqual(cr.get_values('section1', 'option1'),
336+
['value1a', 'value1b'])
337+
self.assertEqual(cr.get_value('section1', 'other_option1'),
338+
'other_value1a')
339+
self.assertEqual(cr.get_values('section1', 'other_option1'),
340+
['other_value1', 'other_value1a'])
341+
self.assertEqual(cr.items('section1'),
342+
[('option1', 'value1b'),
343+
('other_option1', 'other_value1a')])
344+
self.assertEqual(
345+
cr.items_all('section1'),
346+
[('option1', ['value1a', 'value1b']),
347+
('other_option1', ['other_value1', 'other_value1a'])])
348+
349+
def test_add_to_multiple(self):
350+
file_obj = self._to_memcache(fixture_path('git_config_multiple'))
351+
with GitConfigParser(file_obj, read_only=False) as cw:
352+
cw.add_value('section1', 'option1', 'value1c')
353+
cw.write()
354+
file_obj.seek(0)
355+
cr = GitConfigParser(file_obj, read_only=True)
356+
self.assertEqual(cr.get_value('section1', 'option1'), 'value1c')
357+
self.assertEqual(cr.get_values('section1', 'option1'),
358+
['value1a', 'value1b', 'value1c'])
359+
self.assertEqual(cr.items('section1'),
360+
[('option1', 'value1c'),
361+
('other_option1', 'other_value1')])
362+
self.assertEqual(cr.items_all('section1'),
363+
[('option1', ['value1a', 'value1b', 'value1c']),
364+
('other_option1', ['other_value1'])])

0 commit comments

Comments
 (0)