Source code for file_config.contrib.ini_parser

# Copyright (c) 2019 Stephen Bunn <stephen@bunn.io>
# ISC License <https://opensource.org/licenses/isc>

import io
import re
import collections
import configparser

DEFAULT_DELIMITER = ":"


[docs]class INIParser(configparser.ConfigParser): """ A custom INI (config) parser which obeys the Mozilla files specification. .. important:: This parser conforms to what Mozilla says configuration files and their encoded values and types should look like. You can find their specification `here <https://bit.ly/2DksT5u>`_ .. warning:: This parser is very unstable across the extremely wide range of ways that INI files can be represented. Mainly this was just created to be reflective with it's own results rather than building a new :mod:`configparser`. So building a config instance that parses things like ``tox.ini`` might take a bit of hacking to work. """ # characters that require the string to be quoted requires_quotes = (" ", "\t", "=") quoted_string_regex = re.compile(r"^(\'.*\')|(\".*\")$") is_digit_regex = re.compile(r"\d+")
[docs] @classmethod def _encode_var(cls, var): """ Encodes a variable to the appropriate string format for ini files. :param var: The variable to encode :return: The ini representation of the variable :rtype: str """ if isinstance(var, str): if any(_ in var for _ in cls.requires_quotes): # NOTE: quoted strings should just use '"' according to the spec return '"' + var.replace('"', '\\"') + '"' return var else: return str(var)
[docs] @classmethod def _decode_var(cls, string): """ Decodes a given string into the appropriate type in Python. :param str string: The string to decode :return: The decoded value """ str_match = cls.quoted_string_regex.match(string) if str_match: return string.strip("'" if str_match.groups()[0] else '"') # NOTE: "ยน".isdigit() results in True because they are idiots elif string.isdigit() and cls.is_digit_regex.match(string) is not None: return int(string) elif string.lower() in ("true", "false"): return string.lower() == "true" elif string.lstrip("-").isdigit(): try: return int(string) except ValueError: # case where we mistake something like "--0" as a int return string elif "." in string.lstrip("-"): try: return float(string) except ValueError: # one off case where we mistake a single "." as a float return string else: return string
[docs] @classmethod def _build_dict( cls, parser_dict, delimiter=DEFAULT_DELIMITER, dict_type=collections.OrderedDict ): """ Builds a dictionary of ``dict_type`` given the ``parser._sections`` dict. :param dict parser_dict: The ``parser._sections`` mapping :param str delimiter: The delimiter for nested dictionaries, defaults to ":", optional :param class dict_type: The dictionary type to use for building the dict, defaults to :class:`collections.OrderedDict`, optional :return: The resulting dictionary :rtype: dict """ result = dict_type() for (key, value) in parser_dict.items(): if isinstance(value, dict): nestings = key.split(delimiter) # build nested dictionaries if they don't exist (up to 2nd to last key) base_dict = result for nested_key in nestings[:-1]: if nested_key not in base_dict: base_dict[nested_key] = dict_type() base_dict = base_dict[nested_key] base_dict[nestings[-1]] = cls._build_dict( parser_dict.get(key), delimiter=delimiter, dict_type=dict_type ) else: if "\n" in value: result[key] = [ cls._decode_var(_) for _ in value.lstrip("\n").split("\n") ] else: result[key] = cls._decode_var(value) return result
[docs] @classmethod def _build_parser( cls, dictionary, parser, section_name, delimiter=DEFAULT_DELIMITER, empty_sections=False, ): """ Populates a parser instance with the content of a dictionary. :param dict dictionary: The dictionary to use for populating the parser instance :param configparser.ConfigParser parser: The parser instance :param str section_name: The current section name to add the dictionary keys to :param str delimiter: The nested dictionary delimiter character, defaults to ":", optional :param bool empty_sections: Flag to allow the representation of empty sections to exist, defaults to False, optional :return: The populated parser :rtype: configparser.ConfigParser """ for (key, value) in dictionary.items(): if isinstance(value, dict): nested_section = delimiter.join([section_name, key]) is_empty = all(isinstance(_, dict) for _ in value.values()) if not is_empty or empty_sections: parser.add_section(nested_section) cls._build_parser(value, parser, nested_section, delimiter=delimiter) elif isinstance(value, (list, tuple, set, frozenset)): if any(isinstance(_, dict) for _ in value): raise ValueError( f"INI files cannot support arrays with mappings, " f"found in key {key!r}" ) parser.set( section_name, key, "\n".join(cls._encode_var(_) for _ in value) ) else: parser.set(section_name, key, cls._encode_var(value)) return parser
[docs] @classmethod def from_dict( cls, dictionary, root_section="root", delimiter=DEFAULT_DELIMITER, empty_sections=False, ): """ Create an instance of ``INIParser`` from a given dictionary. :param dict dictionary: The dictionary to create an instance from :param str root_section: The root key of the ini content, defaults to "root", optional :param str delimiter: The delimiter character to use for nested dictionaries, defaults to ":", optional :param bool empty_sections: Flag to allow representation of empty sections to exist, defaults to False, optional :return: The new ``INIParser`` instance :rtype: INIParser """ parser = cls() parser.add_section(root_section) return cls._build_parser( dictionary, parser, root_section, delimiter=delimiter, empty_sections=empty_sections, )
[docs] @classmethod def from_ini(cls, content): """ Create an instance of ``INIParser`` from some ini content string. :param str content: The ini content to create an instance from :return: The new ``INIParser`` instance :rtype: INIParser """ parser = cls() parser.read_string(content) return parser
[docs] def to_dict(self, delimiter=DEFAULT_DELIMITER, dict_type=collections.OrderedDict): """ Get the dictionary representation of the current parser. :param str delimiter: The delimiter used for nested dictionaries, defaults to ":", optional :param class dict_type: The dictionary type to use for building the dictionary reperesentation, defaults to collections.OrderedDict, optional :return: The dictionary representation of the parser instance :rtype: dict """ root_key = self.sections()[0] return self._build_dict( self._sections, delimiter=delimiter, dict_type=dict_type ).get(root_key, {})
[docs] def to_ini(self): """ Get the ini string of the current parser. :return: The ini string of the current parser :rtype: str """ fake_io = io.StringIO() self.write(fake_io) return fake_io.getvalue()