diff --git a/action/config_template.py b/action/config_template.py index 60a9c3e..07ddab5 100644 --- a/action/config_template.py +++ b/action/config_template.py @@ -12,11 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. - -import ConfigParser +try: + import ConfigParser +except ImportError: + import configparser as ConfigParser import io import json import os +import re import yaml from ansible import errors @@ -32,13 +35,210 @@ CONFIG_TYPES = { } +class MultiKeyDict(dict): + """Dictionary class which supports duplicate keys. + + This class allows for an item to be added into a standard python dictionary + however if a key is created more than once the dictionary will convert the + singular value to a python set. This set type forces all values to be a + string. + + Example Usage: + >>> z = MultiKeyDict() + >>> z['a'] = 1 + >>> z['b'] = ['a', 'b', 'c'] + >>> z['c'] = {'a': 1} + >>> print(z) + ... {'a': 1, 'b': ['a', 'b', 'c'], 'c': {'a': 1}} + >>> z['a'] = 2 + >>> print(z) + ... {'a': set(['1', '2']), 'c': {'a': 1}, 'b': ['a', 'b', 'c']} + """ + def __setitem__(self, key, value): + if key in self: + if isinstance(self[key], set): + items = self[key] + items.add(str(value)) + super(MultiKeyDict, self).__setitem__(key, items) + else: + items = [str(value), str(self[key])] + super(MultiKeyDict, self).__setitem__(key, set(items)) + else: + return dict.__setitem__(self, key, value) + + +class ConfigTemplateParser(ConfigParser.RawConfigParser): + """ConfigParser which supports multi key value. + + The parser will use keys with multiple variables in a set as a multiple + key value within a configuration file. + + Default Configuration file: + [DEFAULT] + things = + url1 + url2 + url3 + + other = 1,2,3 + + [section1] + key = var1 + key = var2 + key = var3 + + Example Usage: + >>> cp = ConfigTemplateParser(dict_type=MultiKeyDict) + >>> cp.read('/tmp/test.ini') + ... ['/tmp/test.ini'] + >>> cp.get('DEFAULT', 'things') + ... \nurl1\nurl2\nurl3 + >>> cp.get('DEFAULT', 'other') + ... '1,2,3' + >>> cp.set('DEFAULT', 'key1', 'var1') + >>> cp.get('DEFAULT', 'key1') + ... 'var1' + >>> cp.get('section1', 'key') + ... {'var1', 'var2', 'var3'} + >>> cp.set('section1', 'key', 'var4') + >>> cp.get('section1', 'key') + ... {'var1', 'var2', 'var3', 'var4'} + >>> with open('/tmp/test2.ini', 'w') as f: + ... cp.write(f) + + Output file: + [DEFAULT] + things = + url1 + url2 + url3 + key1 = var1 + other = 1,2,3 + + [section1] + key = var4 + key = var1 + key = var3 + key = var2 + """ + def _write(self, fp, section, item, entry): + if section: + if (item is not None) or (self._optcre == self.OPTCRE): + fp.write(entry) + else: + fp.write(entry) + + def _write_check(self, fp, key, value, section=False): + if isinstance(value, set): + for item in value: + item = str(item).replace('\n', '\n\t') + entry = "%s = %s\n" % (key, item) + self._write(fp, section, item, entry) + else: + if isinstance(value, list): + _value = [str(i.replace('\n', '\n\t')) for i in value] + entry = '%s = %s\n' % (key, ','.join(_value)) + else: + entry = '%s = %s\n' % (key, str(value).replace('\n', '\n\t')) + self._write(fp, section, value, entry) + + def write(self, fp): + if self._defaults: + fp.write("[%s]\n" % 'DEFAULT') + for key, value in self._defaults.items(): + self._write_check(fp, key=key, value=value) + else: + fp.write("\n") + + for section in self._sections: + fp.write("[%s]\n" % section) + for key, value in self._sections[section].items(): + self._write_check(fp, key=key, value=value, section=True) + else: + fp.write("\n") + + def _read(self, fp, fpname): + cursect = None + optname = None + lineno = 0 + e = None + while True: + line = fp.readline() + if not line: + break + lineno += 1 + if line.strip() == '' or line[0] in '#;': + continue + if line.split(None, 1)[0].lower() == 'rem' and line[0] in "rR": + continue + if line[0].isspace() and cursect is not None and optname: + value = line.strip() + if value: + if isinstance(cursect[optname], set): + _temp_item = list(cursect[optname]) + del cursect[optname] + cursect[optname] = _temp_item + elif isinstance(cursect[optname], (str, unicode)): + _temp_item = [cursect[optname]] + del cursect[optname] + cursect[optname] = _temp_item + cursect[optname].append(value) + else: + mo = self.SECTCRE.match(line) + if mo: + sectname = mo.group('header') + if sectname in self._sections: + cursect = self._sections[sectname] + elif sectname == 'DEFAULT': + cursect = self._defaults + else: + cursect = self._dict() + self._sections[sectname] = cursect + optname = None + elif cursect is None: + raise ConfigParser.MissingSectionHeaderError( + fpname, + lineno, + line + ) + else: + mo = self._optcre.match(line) + if mo: + optname, vi, optval = mo.group('option', 'vi', 'value') + optname = self.optionxform(optname.rstrip()) + if optval is not None: + if vi in ('=', ':') and ';' in optval: + pos = optval.find(';') + if pos != -1 and optval[pos - 1].isspace(): + optval = optval[:pos] + optval = optval.strip() + if optval == '""': + optval = '' + cursect[optname] = optval + else: + if not e: + e = ConfigParser.ParsingError(fpname) + e.append(lineno, repr(line)) + if e: + raise e + all_sections = [self._defaults] + all_sections.extend(self._sections.values()) + for options in all_sections: + for name, val in options.items(): + if isinstance(val, list): + _temp_item = '\n'.join(val) + del options[name] + options[name] = _temp_item + + class ActionModule(object): TRANSFERS_FILES = True def __init__(self, runner): self.runner = runner - def grab_options(self, complex_args, module_args): + @staticmethod + def grab_options(complex_args, module_args): """Grab passed options from Ansible complex and module args. :param complex_args: ``dict`` @@ -53,14 +253,31 @@ class ActionModule(object): return options @staticmethod - def return_config_overrides_ini(config_overrides, resultant): + def _option_write(config, section, key, value): + config.remove_option(str(section), str(key)) + try: + if not any(i for i in value.values()): + value = set(value) + except AttributeError: + pass + if isinstance(value, set): + config.set(str(section), str(key), value) + elif isinstance(value, list): + config.set(str(section), str(key), ','.join(value)) + else: + config.set(str(section), str(key), str(value)) + + def return_config_overrides_ini(self, config_overrides, resultant): """Returns string value from a modified config file. :param config_overrides: ``dict`` :param resultant: ``str`` || ``unicode`` :returns: ``str`` """ - config = ConfigParser.RawConfigParser(allow_no_value=True) + config = ConfigTemplateParser( + dict_type=MultiKeyDict, + allow_no_value=True + ) config_object = io.BytesIO(resultant.encode('utf-8')) config.readfp(config_object) for section, items in config_overrides.items(): @@ -69,7 +286,7 @@ class ActionModule(object): if not isinstance(items, dict): if isinstance(items, list): items = ','.join(items) - config.set('DEFAULT', str(section), str(items)) + self._option_write(config, 'DEFAULT', section, items) else: # Attempt to add a section to the config file passing if # an error is raised that is related to the section @@ -79,9 +296,7 @@ class ActionModule(object): except (ConfigParser.DuplicateSectionError, ValueError): pass for key, value in items.items(): - if isinstance(value, list): - value = ','.join(value) - config.set(str(section), str(key), str(value)) + self._option_write(config, section, key, value) else: config_object.close() @@ -241,4 +456,3 @@ class ActionModule(object): inject=inject, complex_args=complex_args ) - diff --git a/releasenotes/notes/config_template-MultiStrOps-support-c28e33fd5044e14d.yaml b/releasenotes/notes/config_template-MultiStrOps-support-c28e33fd5044e14d.yaml new file mode 100644 index 0000000..84610c8 --- /dev/null +++ b/releasenotes/notes/config_template-MultiStrOps-support-c28e33fd5044e14d.yaml @@ -0,0 +1,32 @@ +--- +features: + - | + The ability to support MultiStrOps has been added to + the config_template action plugin. This change updates + the parser to use the ``set()`` type to determine if + values within a given key are to be rendered as + ``MultiStrOps``. If an override is used in an INI + config file the set type is defined using the standard + yaml construct of "?" as the item marker. + + :: + + # Example Override Entries + Section: + typical_list_things: + - 1 + - 2 + multistrops_things: + ? a + ? b + + :: + + # Example Rendered Config: + [Section] + typical_list_things = 1,2 + multistrops_things = a + multistrops_things = b + +fixes: + - Resolves issue https://bugs.launchpad.net/openstack-ansible/+bug/1542513 diff --git a/test-requirements.txt b/test-requirements.txt index f9f762e..a06b604 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,4 @@ ansible>=1.9.1,<2.0.0 # this is required for the docs build jobs sphinx!=1.2.0,!=1.3b1,<1.3,>=1.1.2 oslosphinx>=2.5.0 # Apache-2.0 +reno>=0.1.1 # Apache-2.0