From a5ccd67143434493dee4d8e803bb1b0ff933a312 Mon Sep 17 00:00:00 2001 From: Jakob Scheid Date: Thu, 19 Feb 2026 19:57:49 +0100 Subject: [PATCH] Support for a default configuration for INI files --- README.md | 5 ++ pyproject.toml | 2 +- src/config_parser/_configuration.py | 9 ++- src/config_parser/exceptions.py | 18 +++++ src/config_parser/ini.py | 28 ++++++-- src/config_parser/json.py | 6 +- src/config_parser/parse/ini.py | 30 ++++---- src/config_parser/parse/json.py | 103 ++++++++++++++++------------ src/config_parser/serialize/ini.py | 4 +- src/config_parser/serialize/json.py | 5 +- tests/ini/test_ini.py | 20 +++++- 11 files changed, 157 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 638ed41..c38f166 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ 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'}}` ## Changelog +### Version 1.2.0 +- Support for a default configuration for INI files +- Consistent `__all__` lists in all modules +- Support for `in` at configuration classes + ### Version 1.1.1 - Updated the `__init__.py` files in the packages. Now you do not need to run e. g. `import config_parser.parse`, you can simply run `import config_parser` to access `config_parser.parse` diff --git a/pyproject.toml b/pyproject.toml index 2c5f9a6..458c5cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,5 +4,5 @@ build-backend = "setuptools.build_meta" [project] name = "config-parser" -version = "1.1.1" +version = "1.2.0" description = "A configuration file parser." \ No newline at end of file diff --git a/src/config_parser/_configuration.py b/src/config_parser/_configuration.py index e84d4e2..1f9bb22 100644 --- a/src/config_parser/_configuration.py +++ b/src/config_parser/_configuration.py @@ -1,5 +1,9 @@ import copy +__all__ = [ + 'Configuration' +] + class Configuration: def __init__(self, _config: dict = {}): '''A base class for configurations.''' @@ -37,4 +41,7 @@ class Configuration: del self._config[key] def __iter__(self): - return iter(self._config.items()) \ No newline at end of file + return iter(self._config.items()) + + def __contains__(self, item): + return item in self._config \ No newline at end of file diff --git a/src/config_parser/exceptions.py b/src/config_parser/exceptions.py index a779e18..7a51c05 100644 --- a/src/config_parser/exceptions.py +++ b/src/config_parser/exceptions.py @@ -1,3 +1,21 @@ +__all__ = [ + 'ConfigurationSyntaxError', + 'EscapeSequenceSyntaxError', + 'INISyntaxError', + 'INIInvalidGroupHeader', + 'INIInvalidKeyValueLine', + 'JSONSyntaxError', + 'JSONValueSyntaxError', + 'JSONNumberSyntaxError', + 'JSONStringSyntaxError', + 'JSONBooleanSyntaxError', + 'JSONNullSyntaxError', + 'JSONArraySyntaxError', + 'JSONObjectSyntaxError', + 'ConfigurationTypeError', + 'JSONTypeError' +] + class ConfigurationSyntaxError(SyntaxError): ... class EscapeSequenceSyntaxError(ConfigurationSyntaxError): ... diff --git a/src/config_parser/ini.py b/src/config_parser/ini.py index 5450a64..0d82a27 100644 --- a/src/config_parser/ini.py +++ b/src/config_parser/ini.py @@ -1,14 +1,20 @@ +from __future__ import annotations import typing from ._configuration import Configuration from .parse.ini import _COMMENT_PREFIXES, _QUOTATION_MARKS, parse_ini from .serialize.ini import serialize as serialize_ini +__all__ = [ + 'INIConfiguration', + 'INIConfigurationSection' +] + 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()) + return iter({k: dict(v) if isinstance(v, INIConfigurationSection) 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, default: INIConfiguration = None): ''' Parses INI configuration from a string and returns an instance of INIConfiguration. @@ -22,6 +28,8 @@ class INIConfiguration(Configuration): :type quotation_marks: typing.Collection[str] :param ignore_errors: If True, parsing errors will be ignored. :type ignore_errors: bool + :param default: The default configuration. + :type default: INIConfiguration :raises INISyntaxError: If there is a syntax error in the INI configuration and ``ignore_errors`` is ``False``. @@ -32,11 +40,19 @@ class INIConfiguration(Configuration): data = data.decode() configuration = cls() - for group_name, content in parse_ini(data, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors).items(): - group = INIConfigurationGroup(group_name) + for section_name, content in parse_ini(data, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors).items(): + group = INIConfigurationSection(section_name) for key, value in content.items(): group[key] = value - configuration[group_name] = group + configuration[section_name] = group + + if default is not None: + for section in default._config: + if section not in configuration: + configuration[section] = INIConfigurationSection(section) + for propk, propv in default[section]._config.items(): + if propk not in dict(configuration[section]).keys(): + configuration[section][propk] = propv return configuration @@ -53,7 +69,7 @@ class INIConfiguration(Configuration): return serialize_ini(self._config, separator) -class INIConfigurationGroup(INIConfiguration): +class INIConfigurationSection(INIConfiguration): def __init__(self, name: str): ''' A class representing a group in an INI configuration. diff --git a/src/config_parser/json.py b/src/config_parser/json.py index e56563f..6de3370 100644 --- a/src/config_parser/json.py +++ b/src/config_parser/json.py @@ -2,6 +2,10 @@ from ._configuration import Configuration from .parse.json import parse_type, JSONObject, JSONString, JSONNumber, JSONNull, JSONBoolean, JSONArray from .exceptions import JSONTypeError +__all__ = [ + 'JSONConfiguration' +] + def _configuration(data): if isinstance(data, str): return JSONString(data) @@ -52,4 +56,4 @@ class JSONConfiguration(Configuration): if ignore_errors: return cls() else: - raise + raise \ No newline at end of file diff --git a/src/config_parser/parse/ini.py b/src/config_parser/parse/ini.py index 5b3631d..af14891 100644 --- a/src/config_parser/parse/ini.py +++ b/src/config_parser/parse/ini.py @@ -1,13 +1,17 @@ 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: +def _reverse_dict(d: typing.Dict) -> typing.Dict: ''' Reverses the order of items in a dictionary. @@ -19,7 +23,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. @@ -70,8 +74,7 @@ def parse_key_value_line(line: str, comment_prefixes: typing.Collection[str] = _ return {key: value} - -def parse_group_header(line: str, ignore_errors: bool = False) -> str: +def _parse_group_header(line: str, ignore_errors: bool = False) -> str: ''' Parses a group header line. @@ -92,8 +95,7 @@ def parse_group_header(line: str, ignore_errors: bool = False) -> str: 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: +def _parse_line_type(line: str, comment_prefixes: typing.Collection[str] = _COMMENT_PREFIXES, ignore_errors: bool = False) -> int: ''' Returns the type of a line. @@ -121,7 +123,7 @@ def parse_line_type(line: str, comment_prefixes: typing.Collection[str] = _COMME return _COMMENT_LINE raise INISyntaxError('the line is invalid') -def compress_conf(conf): +def _compress_conf(conf): while '\n\n' in conf: conf = conf.replace('\n\n', '\n') return conf.strip() @@ -142,16 +144,16 @@ def parse_ini(conf, comment_prefixes=_COMMENT_PREFIXES, quotation_marks=_QUOTATI :rtype: typing.Dict[str, typing.Dict[str, str]] ''' - conf = compress_conf(conf) + 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: + 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) + 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) \ No newline at end of file + 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 4ce1ff5..b8fae03 100644 --- a/src/config_parser/parse/json.py +++ b/src/config_parser/parse/json.py @@ -2,6 +2,19 @@ from ..exceptions import EscapeSequenceSyntaxError, JSONValueSyntaxError, JSONNu import typing import re +__all__ = [ + 'JSONType', + 'JSONNumber', + 'JSONString', + 'JSONBoolean', + 'JSONNull', + 'JSONArray', + 'JSONObject', + 'parse_escape_sequences', + 'parse_type', + 'parse_json' +] + _JSON_ESCAPE_SEQUENCES = { '"': '"', '\\': '\\', @@ -23,51 +36,6 @@ _JSON_NUMBER_RE = re.compile( re.VERBOSE, ) -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 - class JSONType: def __init__(self, value): self.value = value @@ -362,6 +330,51 @@ class JSONObject(JSONType): 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. diff --git a/src/config_parser/serialize/ini.py b/src/config_parser/serialize/ini.py index fffd843..1cb47e7 100644 --- a/src/config_parser/serialize/ini.py +++ b/src/config_parser/serialize/ini.py @@ -1,4 +1,6 @@ -from ..exceptions import INISyntaxError +__all__ = [ + 'serialize' +] def serialize(data: dict, separator: str) -> str: ''' diff --git a/src/config_parser/serialize/json.py b/src/config_parser/serialize/json.py index 7415b9e..d8dc148 100644 --- a/src/config_parser/serialize/json.py +++ b/src/config_parser/serialize/json.py @@ -2,6 +2,10 @@ import copy from typing import * import collections.abc +__all__ = [ + 'serialize' +] + _ESCAPE_SEQUENCES = { '\"': r'\"', '\\': r'\\', @@ -80,7 +84,6 @@ def _serialize_string(data: str) -> str: data += _ESCAPE_SEQUENCES.get(c, c) return '"' + data + '"' - def _serialize_number(data: int | float) -> str: return str(data) diff --git a/tests/ini/test_ini.py b/tests/ini/test_ini.py index 9fdf532..2275943 100644 --- a/tests/ini/test_ini.py +++ b/tests/ini/test_ini.py @@ -1,9 +1,9 @@ -from src.config_parser.ini import INIConfiguration, INIConfigurationGroup +from src.config_parser.ini import INIConfiguration, INIConfigurationSection from src.config_parser.exceptions import INISyntaxError def test_generating(): config = INIConfiguration() - config.test_group = INIConfigurationGroup('test_group') + config.test_group = INIConfigurationSection('test_group') config.test_group.key1 = 'value1' config.test_group.key2 = 'value2' assert config.to_string() == '''[test_group] @@ -43,4 +43,18 @@ invalid_line2 INIConfiguration.from_string('key=value"') assert False, 'Expected INISyntaxError for unterminated literal' except INISyntaxError: - pass \ No newline at end of file + pass + +def test_default(): + default_configuration = INIConfiguration.from_string('''[section1] +k1=v1 +k2=v2 +k3=v3 + +[section2] +l1=w1 +l2=w2''') + assert dict(INIConfiguration.from_string('''[section1] +k1=v1 +k2=v2 +k4=v4''', default = default_configuration)) == {'section1': {'k1': 'v1', 'k2': 'v2', 'k3': 'v3', 'k4': 'v4'}, 'section2': {'l1': 'w1', 'l2': 'w2'}} \ No newline at end of file