Initial commit

This commit is contained in:
2026-03-12 19:34:25 +01:00
commit b00e5d9829
22 changed files with 2916 additions and 0 deletions
@@ -0,0 +1,29 @@
# 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.
'''
The parsers
Modules:
- ini: INI parser.
- json: JSON parser.
'''
from . import json
from . import ini
__all__ = [
'json',
'ini'
]
+173
View File
@@ -0,0 +1,173 @@
# 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)
+616
View File
@@ -0,0 +1,616 @@
# 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.
from ..exceptions import EscapeSequenceSyntaxError, JSONValueSyntaxError, JSONNumberSyntaxError, JSONStringSyntaxError, JSONBooleanSyntaxError, JSONNullSyntaxError, JSONArraySyntaxError, JSONObjectSyntaxError
import typing
import re
__all__ = [
'JSONType',
'JSONNumber',
'JSONString',
'JSONBoolean',
'JSONNull',
'JSONArray',
'JSONObject',
'parse_escape_sequences',
'parse_type',
'parse_json'
]
_JSON_ESCAPE_SEQUENCES = {
'"': '"',
'\\': '\\',
'/': '/',
'b': '\b',
'f': '\f',
'n': '\n',
'r': '\r',
't': '\t',
}
_JSON_NUMBER_RE = re.compile(
r"""
-? # optional minus
(?:0|[1-9][0-9]*) # integer part
(?:\.[0-9]+)? # optional fraction
(?:[eE][+-]?[0-9]+)? # optional exponent
\Z
""",
re.VERBOSE,
)
class JSONType:
def __init__(self, value):
self.value = value
def __repr__(self):
return self.value.__repr__()
class JSONNumber(JSONType):
@property
def _type(self):
return 'number'
@classmethod
def parse(cls, value: str) -> typing.Union[int, float]:
'''
Parses a JSON number value.
:param value: The value
:type value: str
:raises JSONNumberSyntaxError: If the number is not a valid JSON number.
:raises JSONTypeError: If the value is not a number.
:return: The number in ``value``
:rtype: JSONNumber
'''
value = value.strip()
if not isinstance(value, str):
raise TypeError('JSON numbers must be provided as strings')
if not _JSON_NUMBER_RE.match(value):
raise JSONNumberSyntaxError(f'Invalid JSON number: {value!r}')
if '.' not in value and 'e' not in value and 'E' not in value:
return cls(int(value))
return cls(float(value))
class JSONString(JSONType):
@property
def _type(self):
return 'string'
@classmethod
def parse(cls, value: str):
'''
Parses a JSON string value.
:param value: The value
:type value: str
:raises JSONStringSyntaxError: If the string is not a valid JSON string.
:return: The string in ``value``
:rtype: JSONString
'''
value = value.strip()
if not (value[0] == '"' and value[-1] == '"' and value.count('"') - value.count('\\"') == 2):
raise JSONStringSyntaxError(f'Invalid JSON string (JSON string have to start and end with quotation marks): {value}')
else:
return cls(parse_escape_sequences(value[1:-1]))
class JSONBoolean(JSONType):
@property
def _type(self):
return 'boolean'
@classmethod
def parse(cls, value: str):
'''
Parses a JSON boolean value.
:param value: The value
:type value: str
:raises JSONBooleanSyntaxError: If the boolean is neither ``true`` nor ``false``.
:return: The boolean in ``value``
:rtype: JSONBoolean
'''
value = value.strip()
if value in ('true', 'false'):
return cls(True if value == 'true' else False)
else:
raise JSONBooleanSyntaxError(f'Invalid JSON boolean: {value}')
class JSONNull(JSONType):
@property
def _type(self):
return 'null'
@classmethod
def parse(cls, value: str):
'''
Parses a JSON null value.
:param value: The value
:type value: str
:raises JSONNullSyntaxError: If the null value is not ``null``.
:return: The null value
:rtype: JSONNull
'''
value = value.strip()
if value == 'null':
return cls(None)
else:
raise JSONNullSyntaxError(f'Invalid JSON null value: {value}')
class JSONArray(JSONType):
@property
def _type(self):
return 'array'
@classmethod
def parse(cls, value: str):
'''
Parses a JSON array.
:param value: The array
:type value: str
:raises JSONArraySyntaxError: If the array is invalid.
:raises JSONValueSyntaxError: If a value is invalid
:raises JSONObjectSyntaxError: If an object is invalid.
:raises JSONStringSyntaxError: If a string is invalid.
:raises JSONNullSyntaxError: If a null value is invalid.
:raises JSONBooleanSyntaxError: If a null value is invalid.
:raises JSONNumberSyntaxError: If a number is invalid.
:raises EscapeSequenceSyntaxError: If an escape sequence is invalid.
:return: The array as a dictionary
:rtype: dict
'''
value = value.strip()
value_type = parse_type(value)
if value_type != JSONArray:
raise JSONValueSyntaxError('expected an array, got', value_type._type)
string = False
arrays = 0
objects = 0
current_value = ''
escape_sequence = False
_array = []
for c in value[1:-1]:
if c == '"' and not escape_sequence:
string = not string
if c == '[' and not string:
arrays += 1
if c == ']' and not string:
arrays -= 1
if c == '{' and not string:
objects += 1
if c == '}' and not string:
objects -= 1
current_value += c
if string and c == '\\':
if not escape_sequence:
escape_sequence = True
else:
escape_sequence = False
else:
escape_sequence = False
if c == ',' and not string and not arrays and not objects:
_array.append(current_value[:-1])
current_value = ''
if current_value:
_array.append(current_value)
array = []
for e in _array:
array.append(parse_type(e).parse(e).value)
return cls(array)
class JSONObject(JSONType):
@property
def _type(self):
return 'object'
@classmethod
def parse(cls, value: str) -> str:
'''
Parses a JSON object.
:param value: The value
:type value: str
:raises JSONObjectSyntaxError: If the object is invalid.
:raises JSONValueSyntaxError: If a value is invalid
:raises JSONArraySyntaxError: If an array is invalid.
:raises JSONStringSyntaxError: If a string is invalid.
:raises JSONNullSyntaxError: If a null value is invalid.
:raises JSONBooleanSyntaxError: If a null value is invalid.
:raises JSONNumberSyntaxError: If a number is invalid.
:raises EscapeSequenceSyntaxError: If an escape sequence is invalid.
:return: The object as a dictionary
:rtype: dict
'''
value = value.strip()
value_type = parse_type(value)
if value_type != JSONObject:
raise JSONValueSyntaxError('expected an object, got', value._type)
string = False
current_key = ''
current_value = None
arrays = 0
objects = 0
escape_sequence = False
_object = {}
for c in value[1:-1]:
if c == '"' and not escape_sequence:
string = not string
if c == '[' and not string:
arrays += 1
if c == ']' and not string:
arrays -= 1
if c == '{' and not string:
objects += 1
if c == '}' and not string:
objects -= 1
if current_value is None:
current_key += c
else:
current_value += c
if string and c == '\\':
if not escape_sequence:
escape_sequence = True
else:
escape_sequence = False
else:
escape_sequence = False
if not string and c == ':' and not arrays and not objects:
current_key = current_key[:-1]
current_value = ''
if not string and c == ',' and not arrays and not objects:
if current_value is not None:
_object[current_key] = current_value[:-1]
current_key = ''
current_value = None
else:
raise JSONObjectSyntaxError(f'expected \':\'')
if current_key:
if current_value is not None:
_object[current_key] = current_value
current_key = ''
current_value = None
else:
raise JSONObjectSyntaxError(f'expected value')
json_object = {}
for k, v in _object.items():
json_object[parse_type(k).parse(k).value] = parse_type(v).parse(v).value
return cls(json_object)
def parse_escape_sequences(string: str) -> bool:
'''
Returns the unescaped string.
:param string: The string to validate
:type string: str
:return: The unescaped string
:rtype: str
'''
result = ''
unicode_escape_sequence = None
escape_sequence = False
for c in string:
if c == '\\' and not escape_sequence:
escape_sequence = True
continue
if unicode_escape_sequence is not None:
if len(unicode_escape_sequence) == 4:
result += chr(int(unicode_escape_sequence, 16))
unicode_escape_sequence = None
else:
if c.lower() not in '0123456789abcdef':
raise EscapeSequenceSyntaxError(f'Invalid unicode escape sequence in JSON string: \\u{unicode_escape_sequence}{c}')
unicode_escape_sequence += c
if not escape_sequence and not unicode_escape_sequence:
result += c
if escape_sequence:
escape_sequence = False
if c not in ('"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u'):
raise EscapeSequenceSyntaxError(f'Invalid escape sequence in JSON string: \\{c}')
if c == 'u':
unicode_escape_sequence = ''
else:
result += _JSON_ESCAPE_SEQUENCES[c]
if unicode_escape_sequence:
raise EscapeSequenceSyntaxError(f'Invalid unicode escape sequence in JSON string: \\u{unicode_escape_sequence}')
return result
def parse_type(value: str) -> typing.Type[JSONType]:
'''
Parses the type of the value.
:param value: The value
:type value: str
:raises JSONValueSyntaxError: If a value is invalid
:raises JSONObjectSyntaxError: If an object is invalid.
:raises JSONArraySyntaxError: If an array is invalid.
:raises JSONStringSyntaxError: If a string is invalid.
:raises JSONNullSyntaxError: If a null value is invalid.
:raises JSONBooleanSyntaxError: If a null value is invalid.
:raises JSONNumberSyntaxError: If a number is invalid.
:raises EscapeSequenceSyntaxError: If an escape sequence is invalid.
:return: The JSON type
:rtype: type
'''
value = value.strip()
if not value:
raise JSONValueSyntaxError('the value cannot be empty')
if value[0] == '{':
if value[-1] != '}':
raise JSONArraySyntaxError(f'an object has to be closed with \'}}\', but found \'{value[-1]}\'')
string = False
current_key = ''
current_value = None
arrays = 0
objects = 0
escape_sequence = False
for c in value[1:-1]:
terminating_quotation_mark = False
if c == '"' and not escape_sequence:
if string:
terminating_quotation_mark = True
string = not string
if c == '[' and not string:
arrays += 1
if c == ']' and not string:
arrays -= 1
if arrays < 0:
raise JSONObjectSyntaxError('unexpected char: \']\'')
if c == '{' and not string:
objects += 1
if c == '}' and not string:
objects -= 1
if objects < 0:
raise JSONObjectSyntaxError('unexpected char: \'}\'')
if not string and not terminating_quotation_mark and not arrays and not objects:
if c.strip().lower() not in '0123456789abcdefghijklmnopqrstuvwxyz,:[]{}.-' + ('"' if escape_sequence else ''):
raise JSONObjectSyntaxError(f'unexpected char: \'{c}\'')
if current_value is None:
current_key += c
else:
current_value += c
if string and c == '\\':
if not escape_sequence:
escape_sequence = True
else:
escape_sequence = False
else:
escape_sequence = False
if not string and c == ':' and not arrays and not objects:
current_key = current_key[:-1]
if current_value is None:
current_value = ''
else:
raise JSONObjectSyntaxError(f'expected \',\'')
if not string and c == ',' and not arrays and not objects:
if current_value is not None:
if parse_type(current_key) != JSONString:
raise JSONObjectSyntaxError(f'keys have to be strings')
parse_type(current_value[:-1])
current_key = ''
current_value = None
else:
raise JSONObjectSyntaxError(f'expected \':\'')
if current_key:
if current_value is not None:
if parse_type(current_key) != JSONString:
raise JSONObjectSyntaxError(f'keys have to be strings')
parse_type(current_value)
current_key = ''
current_value = None
else:
raise JSONObjectSyntaxError(f'expected value')
if string:
raise JSONStringSyntaxError('unterminated string literal')
if value[-2] == ',':
raise JSONObjectSyntaxError('expected value after \',\'')
return JSONObject
elif value[0] == '[':
if value[-1] != ']':
raise JSONArraySyntaxError(f'an array has to be closed with \']\', but found \'{value[-1]}\'')
string = False
arrays = 0
objects = 0
current_value = ''
escape_sequence = False
for c in value[1:-1]:
terminating_quotation_mark = False
if c == '"' and not escape_sequence:
if string:
terminating_quotation_mark = True
string = not string
if c == '[' and not string:
arrays += 1
if c == ']' and not string:
arrays -= 1
if arrays < 0:
raise JSONArraySyntaxError('unexpected char: \']\'')
if c == '{' and not string:
objects += 1
if c == '}' and not string:
objects -= 1
if objects < 0:
raise JSONArraySyntaxError('unexpected char: \'}\'')
if (not string) and (not terminating_quotation_mark) and (not arrays) and (not objects):
if c.strip().lower() not in '0123456789abcdefghijklmnopqrstuvwxyz,[]{}.-' + ('"' if escape_sequence else ''):
raise JSONArraySyntaxError(f'unexpected char: \'{c}\'')
current_value += c
if string and c == '\\':
if not escape_sequence:
escape_sequence = True
else:
escape_sequence = False
else:
escape_sequence = False
if c == ',' and not string and not arrays and not objects:
parse_type(current_value[:-1])
current_value = ''
if string:
raise JSONStringSyntaxError('unterminated string literal')
if value[-2] == ',':
raise JSONArraySyntaxError('expected value after \',\'')
if current_value:
parse_type(current_value)
return JSONArray
elif value[0] == '"': # string
if value[0] != '"':
raise JSONStringSyntaxError(f'expected \'"\' as first char, got \'{value[0]}\'')
if '\n' in value:
raise JSONStringSyntaxError(f'line feeds are not allowed inside of strings. Use escape sequences.')
if value.count('"') < 2:
raise JSONStringSyntaxError('unterminated string literal')
if value.count('"') - value.count('\\"') > 2 or value[-1] != '"':
raise JSONStringSyntaxError(f'unexpected token: \'{"\"".join(value.split("\"")[2:])}\'')
parse_escape_sequences(value)
return JSONString
elif value in ('null', 'false', 'true'): # null or boolean
if value == 'null': # null
return JSONNull
else: # boolean
return JSONBoolean
elif _JSON_NUMBER_RE.match(value): # number
try:
float(value)
return JSONNumber
except:
raise JSONNumberSyntaxError(f'invalid number: {value}')
else:
raise JSONValueSyntaxError(f'unexpected token: \'{value}\'')
def parse_json(json: str) -> dict | list | str | int | float | None | bool:
'''
Parses the JSON.
:param json: The JSON data
:type json: str
:raises JSONValueSyntaxError: If a value is invalid
:raises JSONObjectSyntaxError: If an object is invalid.
:raises JSONArraySyntaxError: If an array is invalid.
:raises JSONStringSyntaxError: If a string is invalid.
:raises JSONNullSyntaxError: If a null value is invalid.
:raises JSONBooleanSyntaxError: If a null value is invalid.
:raises JSONNumberSyntaxError: If a number is invalid.
:raises EscapeSequenceSyntaxError: If an escape sequence is invalid.
:return: The JSON as a Python object (``dict``, ``list``, ``str``, ``int``/``float``, ``boolean``, ``None``)
:rtype: dict | list | str | int | float | boolean | None
'''
json_type = parse_type(json)
return json_type.parse(json).value