From 00a5f08171158265945806a69697dd45791b37cc Mon Sep 17 00:00:00 2001 From: Jakob Scheid Date: Wed, 18 Feb 2026 17:10:34 +0100 Subject: [PATCH] =?UTF-8?q?=09ge=C3=A4ndert:=20=20=20=20=20=20=20README.md?= =?UTF-8?q?=20=09ge=C3=A4ndert:=20=20=20=20=20=20=20pyproject.toml=20=09ge?= =?UTF-8?q?=C3=A4ndert:=20=20=20=20=20=20=20src/config=5Fparser/=5F=5Finit?= =?UTF-8?q?=5F=5F.py=20=09gel=C3=B6scht:=20=20=20=20=20=20=20src/config=5F?= =?UTF-8?q?parser/=5F=5Fmain=5F=5F.py=20=09ge=C3=A4ndert:=20=20=20=20=20?= =?UTF-8?q?=20=20src/config=5Fparser/ini.py=20=09neue=20Datei:=20=20=20=20?= =?UTF-8?q?=20src/config=5Fparser/parse/=5F=5Finit=5F=5F.py=20=09ge=C3=A4n?= =?UTF-8?q?dert:=20=20=20=20=20=20=20src/config=5Fparser/parse/ini.py=20?= =?UTF-8?q?=09ge=C3=A4ndert:=20=20=20=20=20=20=20src/config=5Fparser/parse?= =?UTF-8?q?/json.py=20=09neue=20Datei:=20=20=20=20=20src/config=5Fparser/s?= =?UTF-8?q?erialize/=5F=5Finit=5F=5F.py=20=09neue=20Datei:=20=20=20=20=20s?= =?UTF-8?q?rc/config=5Fparser/serialize/ini.py=20=09neue=20Datei:=20=20=20?= =?UTF-8?q?=20=20src/config=5Fparser/serialize/json.py=20=09neue=20Datei:?= =?UTF-8?q?=20=20=20=20=20tests/json/test=5Fserializer.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++- pyproject.toml | 2 +- src/config_parser/__init__.py | 21 +++- src/config_parser/__main__.py | 0 src/config_parser/ini.py | 24 ++--- src/config_parser/parse/__init__.py | 15 +++ src/config_parser/parse/ini.py | 30 +++--- src/config_parser/parse/json.py | 4 +- src/config_parser/serialize/__init__.py | 15 +++ src/config_parser/serialize/ini.py | 23 ++++ src/config_parser/serialize/json.py | 135 ++++++++++++++++++++++++ tests/json/test_serializer.py | 51 +++++++++ 12 files changed, 297 insertions(+), 37 deletions(-) delete mode 100644 src/config_parser/__main__.py create mode 100644 src/config_parser/parse/__init__.py create mode 100644 src/config_parser/serialize/__init__.py create mode 100644 src/config_parser/serialize/ini.py create mode 100644 src/config_parser/serialize/json.py create mode 100644 tests/json/test_serializer.py diff --git a/README.md b/README.md index cb08640..b960514 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,16 @@ If the configuration file content is: } ``` -the result will be `{'section1': {'key1': 'value1', 'key2': 'value2', 'number': 42, 'number2': 3.14, 'number3': -1, 'boolean': True, 'boolean2': False, 'null': None}, 'section2': {'hello': 'world'}, 'section3': {'key': 'value'}}` \ No newline at end of file +the result will be `{'section1': {'key1': 'value1', 'key2': 'value2', 'number': 42, 'number2': 3.14, 'number3': -1, 'boolean': True, 'boolean2': False, 'null': None}, 'section2': {'hello': 'world'}, 'section3': {'key': 'value'}}` + +## Changelog +### Version 1.1.0 +- JSON and INI serializer + +### Version 1.0.0 +- JSON parser +- better API + +### Version 0.1.0 +- First release +- INI parser \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8207932..24ce1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,5 +4,5 @@ build-backend = "setuptools.build_meta" [project] name = "config-parser" -version = "1.0.0" +version = "1.1.0" description = "A configuration file parser." \ No newline at end of file diff --git a/src/config_parser/__init__.py b/src/config_parser/__init__.py index 95ce383..b70bd3a 100644 --- a/src/config_parser/__init__.py +++ b/src/config_parser/__init__.py @@ -2,13 +2,26 @@ A library for parsing configuration files in various formats. Modules: -- configuration: Base classes. -- ini: INI file parser and serializer. -- exceptions: Custom exceptions for configuration parsing. +- _configuration: Base classes. +- ini: INI configuration. +- json: JSON configuration. +- exceptions: Exceptions for configuration parsing. +- parse: A package including the parsers. +- serialize: A package including the serializers. ''' from ._configuration import Configuration +# from . import exceptions +# from . import json +# from . import ini +# from . import parse +# from . import serialize __all__ = [ - 'Configuration' + 'Configuration', + 'exceptions', + 'json', + 'ini', + 'parse', + 'serialize' ] \ No newline at end of file diff --git a/src/config_parser/__main__.py b/src/config_parser/__main__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/config_parser/ini.py b/src/config_parser/ini.py index 7c5c6cf..5450a64 100644 --- a/src/config_parser/ini.py +++ b/src/config_parser/ini.py @@ -1,16 +1,17 @@ import typing from ._configuration import Configuration -from .parse.ini import COMMENT_PREFIXES, QUOTATION_MARKS, parse_ini +from .parse.ini import _COMMENT_PREFIXES, _QUOTATION_MARKS, parse_ini +from .serialize.ini import serialize as serialize_ini class INIConfiguration(Configuration): def __iter__(self): return iter({k: dict(v) if isinstance(v, INIConfigurationGroup) else v for k, v in self._config.items()}.items()) @classmethod - def from_string(cls, data: str | bytes, comment_prefixes: typing.Collection[str] = COMMENT_PREFIXES, quotation_marks: typing.Collection[str] = QUOTATION_MARKS, ignore_errors: bool = False): + def from_string(cls, data: str | bytes, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, quotation_marks: typing.Collection[str] = _QUOTATION_MARKS, ignore_errors: bool = False): ''' Parses INI configuration from a string and returns an instance of INIConfiguration. - + :param cls: The class method is called on. :type cls: typing.Type[INIConfiguration] :param data: The INI configuration data to parse. @@ -42,26 +43,21 @@ class INIConfiguration(Configuration): def to_string(self, separator: str = '='): ''' Serializes the INIConfiguration instance to an INI formatted string. - + :param separator: The separator to use between keys and values in the output string. :type separator: str :return: An INI formatted string representing the INIConfiguration instance. :rtype: str ''' - result = '' - for key, value in self._config.items(): - if isinstance(value, INIConfigurationGroup): - result += value.to_string(separator=separator) - else: - result += f'{key}{separator}{value}\n' - return result.strip() + + return serialize_ini(self._config, separator) class INIConfigurationGroup(INIConfiguration): def __init__(self, name: str): ''' A class representing a group in an INI configuration. - + :param name: The name of the group. :type name: str ''' @@ -71,11 +67,11 @@ class INIConfigurationGroup(INIConfiguration): @property def name(self): return self._name - + def to_string(self, separator: str = '='): ''' Serializes the INIConfigurationGroup instance to an INI formatted string. - + :param separator: The separator to use between keys and values in the output string. :type separator: str diff --git a/src/config_parser/parse/__init__.py b/src/config_parser/parse/__init__.py new file mode 100644 index 0000000..cf2a98c --- /dev/null +++ b/src/config_parser/parse/__init__.py @@ -0,0 +1,15 @@ +''' +The parsers + +Modules: +- ini: INI parser. +- json: JSON parser. +''' + +# from . import json +# from . import ini + +__all__ = [ + 'json', + 'ini' +] \ No newline at end of file diff --git a/src/config_parser/parse/ini.py b/src/config_parser/parse/ini.py index c3c153d..5b3631d 100644 --- a/src/config_parser/parse/ini.py +++ b/src/config_parser/parse/ini.py @@ -1,11 +1,11 @@ import typing from ..exceptions import INIInvalidGroupHeader, INIInvalidKeyValueLine, INISyntaxError -QUOTATION_MARKS = ['"', "'"] -COMMENT_PREFIXES = ['#', ';'] -KEY_VALUE_LINE = 0 -GROUP_HEADER_LINE = 1 -COMMENT_LINE = 2 +_QUOTATION_MARKS = ['"', "'"] +_COMMENT_PREFIXES = ['#', ';'] +_KEY_VALUE_LINE = 0 +_GROUP_HEADER_LINE = 1 +_COMMENT_LINE = 2 def reverse_dict(d: typing.Dict) -> typing.Dict: ''' @@ -19,7 +19,7 @@ def reverse_dict(d: typing.Dict) -> 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]: +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. @@ -93,7 +93,7 @@ def parse_group_header(line: str, ignore_errors: bool = False) -> str: raise INIInvalidGroupHeader('the header is invalid') -def parse_line_type(line: str, comment_prefixes: typing.Collection[str] = COMMENT_PREFIXES, ignore_errors: bool = False) -> int: +def parse_line_type(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, ignore_errors: bool = False) -> int: ''' Returns the type of a line. @@ -111,14 +111,14 @@ def parse_line_type(line: str, comment_prefixes: typing.Collection[str] = COMMEN ''' line = line.strip() if any(line.startswith(prefix) for prefix in comment_prefixes) or not line.strip(): - return COMMENT_LINE + return _COMMENT_LINE elif line.startswith('[') and line.endswith(']') and len(line) > 2: - return GROUP_HEADER_LINE + return _GROUP_HEADER_LINE elif '=' in line or ':' in line: - return KEY_VALUE_LINE + return _KEY_VALUE_LINE else: if ignore_errors: - return COMMENT_LINE + return _COMMENT_LINE raise INISyntaxError('the line is invalid') def compress_conf(conf): @@ -126,7 +126,7 @@ def compress_conf(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]]: +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. @@ -147,11 +147,11 @@ def parse_ini(conf, comment_prefixes=COMMENT_PREFIXES, quotation_marks=QUOTATION 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: + 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: + 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: + 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) \ No newline at end of file diff --git a/src/config_parser/parse/json.py b/src/config_parser/parse/json.py index 42ac428..4ce1ff5 100644 --- a/src/config_parser/parse/json.py +++ b/src/config_parser/parse/json.py @@ -2,7 +2,7 @@ from ..exceptions import EscapeSequenceSyntaxError, JSONValueSyntaxError, JSONNu import typing import re -JSON_ESCAPE_SEQUENCES = { +_JSON_ESCAPE_SEQUENCES = { '"': '"', '\\': '\\', '/': '/', @@ -62,7 +62,7 @@ def parse_escape_sequences(string: str) -> bool: if c == 'u': unicode_escape_sequence = '' else: - result += JSON_ESCAPE_SEQUENCES[c] + result += _JSON_ESCAPE_SEQUENCES[c] if unicode_escape_sequence: raise EscapeSequenceSyntaxError(f'Invalid unicode escape sequence in JSON string: \\u{unicode_escape_sequence}') diff --git a/src/config_parser/serialize/__init__.py b/src/config_parser/serialize/__init__.py new file mode 100644 index 0000000..c0f2339 --- /dev/null +++ b/src/config_parser/serialize/__init__.py @@ -0,0 +1,15 @@ +''' +The serializers + +Modules: +- ini: INI serializer. +- json: JSON serializer. +''' + +# from . import json +# from . import ini + +__all__ = [ + 'json', + 'ini' +] \ No newline at end of file diff --git a/src/config_parser/serialize/ini.py b/src/config_parser/serialize/ini.py new file mode 100644 index 0000000..fffd843 --- /dev/null +++ b/src/config_parser/serialize/ini.py @@ -0,0 +1,23 @@ +from ..exceptions import INISyntaxError + +def serialize(data: dict, separator: str) -> str: + ''' + Serializes the data to an INI formatted string. + + :param data: The INI configuration. + :type data: dict + + :param separator: The separator. + :type separator: str + + :return: The INI formatted string. + :rtype: str + ''' + + result = '' + for key, value in data.items(): + if hasattr(value, '_name'): + result += value.to_string(separator=separator) + else: + result += f'{key}{separator}{value}\n' + return result.strip() \ No newline at end of file diff --git a/src/config_parser/serialize/json.py b/src/config_parser/serialize/json.py new file mode 100644 index 0000000..7415b9e --- /dev/null +++ b/src/config_parser/serialize/json.py @@ -0,0 +1,135 @@ +import copy +from typing import * +import collections.abc + +_ESCAPE_SEQUENCES = { + '\"': r'\"', + '\\': r'\\', + '\b': r'\b', + '\f': r'\f', + '\n': r'\n', + '\r': r'\r', + '\t': r'\t', +} + +class _Mapper: + def __init__(self, mapping = {}): + self._mapping = copy.deepcopy(mapping) + + def _set(self, key, value): + self._mapping[key] = value + + def set(self, key, value): + self._set(key, value) + return self + + def map(self, value): + return self._mapping[value] + +_MAPPER = _Mapper().set(True, 'true').set(False, 'false').set(None, 'null') + +def _serialize_object(data: dict, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + result = '{' + _count = 0 + for _key, value in data.items(): + _count += 1 + key = _serialize(_key, 0, 0, '', separators) + value = _serialize(value, level + 1, indent, indent_char, separators) + if not isinstance(_key, str): + key = '"' + key + '"' + if indent: + result += '\n' + result += (level + 1) * indent * indent_char + result += key + result += separators[1] + result += value + if _count != len(data.keys()): + if indent: + result += separators[0].strip() + else: + result += separators[0] + if indent: + result += '\n' + result += level * indent * indent_char + '}' + return result + +def _serialize_array(data: Sequence, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + result = '[' + _count = 0 + for value in data: + _count += 1 + value = _serialize(value, level + 1, indent, indent_char, separators) + if indent: + result += '\n' + result += (level + 1) * indent * indent_char + result += value + if _count != len(data): + if indent: + result += separators[0].strip() + else: + result += separators[0] + if indent: + result += '\n' + result += level * indent * indent_char + ']' + return result + +def _serialize_string(data: str) -> str: + _data = data + data = '' + for c in _data: + data += _ESCAPE_SEQUENCES.get(c, c) + return '"' + data + '"' + + +def _serialize_number(data: int | float) -> str: + return str(data) + +def _serialize(data: dict | list | str | int | float | bool | None, level: int, indent: int, indent_char: str, separators: Tuple[str, str]) -> str: + if isinstance(data, (bool, type(None))): + return _MAPPER.map(data) + elif isinstance(data, dict): + return _serialize_object(data, level, indent, indent_char, separators) + elif isinstance(data, str): + return _serialize_string(data) + elif isinstance(data, (int, float)): + return _serialize_number(data) + elif isinstance(data, collections.abc.Sequence): + return _serialize_array(data, level, indent, indent_char, separators) + else: + raise TypeError(f'Object of type {type(data).__name__} is not JSON serializable') + +def _char_whitespaces(whitespaces: Tuple[int | str, int | str] | int | str) -> Tuple[str, str]: + if isinstance(whitespaces, collections.abc.Sequence) and not isinstance(whitespaces, str): + if len(whitespaces) != 2: + raise ValueError(f'expected exactly two elements in whitespaces, got {len(whitespaces)}') + if isinstance(whitespaces, (str, int)): + whitespaces = whitespaces, whitespaces + if isinstance(whitespaces[0], int): + whitespaces = whitespaces[0] * ' ', whitespaces[1] + if isinstance(whitespaces[1], int): + whitespaces = whitespaces[0], whitespaces[1] * ' ' + return whitespaces + +def serialize(data: dict | list | str | int | float | bool | None, indent: int = 0, indent_char: str = ' ', separators: Tuple[str, str] = (', ', ': ')) -> str: + ''' + Serializes the data to a JSON formatted string. + + :param data: The data. + :type data: dict | list | str | int | float | bool | None + :param indent: The indentation + :type indent: int + :param indent_char: The character the indentation will be filled with + :type indent_char: str + :param separators: A tuple with the separators. The first string is the element separator and the second string is the key value separator. + :type separators: tuple + + :raises TypeError: If the type is not JSON serializable. + + :return: The JSON formatted string. + :rtype: str + ''' + + if len(separators) != 2: + raise ValueError(f'expected exactly two elements in separators, got {len(separators)}.') + + return _serialize(data, 0, indent, indent_char, separators) \ No newline at end of file diff --git a/tests/json/test_serializer.py b/tests/json/test_serializer.py new file mode 100644 index 0000000..325f90f --- /dev/null +++ b/tests/json/test_serializer.py @@ -0,0 +1,51 @@ +from src.config_parser.serialize.json import serialize as serialize_json + +def test_serialize_json(): + assert serialize_json(True) == 'true' + assert serialize_json(False) == 'false' + assert serialize_json(True, indent = 4) == 'true' + + assert serialize_json(None) == 'null' + + assert serialize_json({1: {None: True}}, indent = 4) == '''{ + "1": { + "null": true + } +}''' + assert serialize_json({1: {None: True}}) == '{"1": {"null": true}}' + assert serialize_json({1: {None: True}, 2: 'Hello, World!'}, separators = (',', ':')) == '{"1":{"null":true},"2":"Hello, World!"}' + + array = [True, False, 1, None, 'Hello, World!'] + assert serialize_json(array) == '[true, false, 1, null, "Hello, World!"]' + assert serialize_json(array, 4, 'X', (';', ':')) == '''[ +XXXXtrue; +XXXXfalse; +XXXX1; +XXXXnull; +XXXX"Hello, World!" +]''' + assert serialize_json((1, 2, 3)) == '[1, 2, 3]' + assert serialize_json(range(3)) == '[0, 1, 2]' + assert serialize_json(b'\x0142') == '[1, 52, 50]' + + assert serialize_json('Hello, World!') == '"Hello, World!"' + assert serialize_json('Hello,\nWorld!') == '"Hello,\\nWorld!"' + assert serialize_json('Hello,"World!') == '"Hello,\\"World!"' + assert serialize_json('Hello,\\World!') == '"Hello,\\\\World!"' + assert serialize_json('Hello,\bWorld!') == '"Hello,\\bWorld!"' + assert serialize_json('Hello,\tWorld!') == '"Hello,\\tWorld!"' + assert serialize_json('Hello,\rWorld!') == '"Hello,\\rWorld!"' + assert serialize_json('Hello,\fWorld!') == '"Hello,\\fWorld!"' + + assert serialize_json(0) == '0' + assert serialize_json(1) == '1' + assert serialize_json(-1) == '-1' + assert serialize_json(0.0) == '0.0' + assert serialize_json(-0.0) == '-0.0' + assert serialize_json(-42.0) == '-42.0' + + try: + serialize_json({1, 2, 3}) + assert False, 'Expected TypeError' + except TypeError: + pass \ No newline at end of file