# Copyright 2026 jCloud Services GbR # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import typing from ..exceptions import INIInvalidGroupHeader, INIInvalidKeyValueLine, INISyntaxError __all__ = [ 'parse_ini' ] _QUOTATION_MARKS = ['"', "'"] _COMMENT_PREFIXES = ['#', ';'] _KEY_VALUE_LINE = 0 _GROUP_HEADER_LINE = 1 _COMMENT_LINE = 2 def _reverse_dict(d: typing.Dict) -> typing.Dict: ''' Reverses the order of items in a dictionary. :param d: The dictionary to reverse. :type d: typing.Dict :return: A new dictionary with the items in reversed order. :rtype: typing.Dict ''' return {k: v for k, v in list(reversed(d.items()))} def _parse_key_value_line(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, quotation_marks: typing.Collection[str] = _QUOTATION_MARKS, ignore_errors: bool = False) -> typing.Dict[str, str]: ''' Parses a key-value line. :param line: The key-value-line :type line: str :param comment_prefixes: A collection of prefixes that denote comments in the INI file. :type comment_prefixes: typing.Collection[str] :param quotation_marks: A collection of characters used for quoting values in the INI file. :type quotation_marks: typing.Collection[str] :param ignore_errors: If True, errors are ignored. :type ignore_errors: bool :raises INIInvalidKeyValueLine: If the key-value line is invalid and ignore_errors is False. :return: A dictionary with the key and the value (`{'key': 'value'}`) or an empty dictionary if an error has occured and ignore_errors is True. :rtype: Dict[str, str] ''' separator = None if ':' in line and '=' not in line: separator = ':' if '=' in line and ':' not in line: separator = '=' if ':' in line and '=' in line: separator = '=' if line.index('=') < line.index(':') else ':' if separator is None: if ignore_errors: return {} raise INIInvalidKeyValueLine('the key-value line is invalid') key = line.split(separator)[0].strip() value = separator.join(line.split(separator)[1:]).strip() if any(value.endswith(mark) and not value.startswith(mark) for mark in quotation_marks): if ignore_errors: return {} raise INISyntaxError('unterminated literal') if any(value.startswith(mark) for mark in quotation_marks): if value[0] == value[-1]: value = value[1:-1] else: if ignore_errors: return {} raise INISyntaxError('unterminated literal') else: for prefix in comment_prefixes: if prefix in value: value = value.split(prefix)[0].strip() return {key: value} def _parse_group_header(line: str, ignore_errors: bool = False) -> str: ''' Parses a group header line. :param line: The group header line to parse. :type line: str :param ignore_errors: If True, errors are ignored. :type ignore_errors: bool :raises INIInvalidGroupHeader: If the group header is invalid and ignore_errors is False. :return: The name of the group. :rtype: str ''' if line.startswith('[') and line.endswith(']') and len(line) > 2: return line[1:-1] else: if ignore_errors: return '' raise INIInvalidGroupHeader('the header is invalid') def _parse_line_type(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, ignore_errors: bool = False) -> int: ''' Returns the type of a line. :param line: The line to check. :type line: str :param comment_prefixes: A collection of prefixes that denote comments in the INI file. :type comment_prefixes: typing.Collection[str] :param ignore_errors: If True, errors are ignored. :type ignore_errors: bool :raises INISyntaxError: If the line is invalid and ignore_errors is False. :return: The type of the line (COMMENT_LINE, GROUP_HEADER_LINE, or KEY_VALUE_LINE). :rtype: int ''' line = line.strip() if any(line.startswith(prefix) for prefix in comment_prefixes) or not line.strip(): return _COMMENT_LINE elif line.startswith('[') and line.endswith(']') and len(line) > 2: return _GROUP_HEADER_LINE elif '=' in line or ':' in line: return _KEY_VALUE_LINE else: if ignore_errors: return _COMMENT_LINE raise INISyntaxError('the line is invalid') def _compress_conf(conf): while '\n\n' in conf: conf = conf.replace('\n\n', '\n') return conf.strip() def parse_ini(conf, comment_prefixes=_COMMENT_PREFIXES, quotation_marks=_QUOTATION_MARKS, ignore_errors: bool = False, global_group: bool = True) -> typing.Dict[str, typing.Dict[str, str]]: ''' Parses INI configuration from a string and returns a nested dictionary. :param conf: The INI configuration string to parse. :param comment_prefixes: A collection of prefixes that denote comments in the INI file. :param quotation_marks: A collection of characters used for quoting values in the INI file. :param ignore_errors: If True, parsing errors will be ignored. :param global_group: If True, key-value pairs outside of any group will be included. Otherwise, an exception will be raised. :return: A nested dictionary representing the parsed INI configuration. :raises INISyntaxError: If there is a syntax error in the INI configuration and ignore_errors is False. :rtype: typing.Dict[str, typing.Dict[str, str]] ''' conf = _compress_conf(conf) result = {} current_group = {} for line in list(reversed(conf.split('\n'))): line = line.strip() if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _COMMENT_LINE: continue if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _GROUP_HEADER_LINE: result[_parse_group_header(line, ignore_errors=ignore_errors)] = _reverse_dict(current_group) current_group = {} if _parse_line_type(line, comment_prefixes=comment_prefixes, ignore_errors=ignore_errors) == _KEY_VALUE_LINE: current_group = current_group | _parse_key_value_line(line, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors) return _reverse_dict(result)