174 lines
6.8 KiB
Python
174 lines
6.8 KiB
Python
# 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) |