Support for a default configuration for INI files

This commit is contained in:
2026-02-19 19:57:49 +01:00
parent 02e64e3e45
commit a5ccd67143
11 changed files with 157 additions and 73 deletions
+5
View File
@@ -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'}}` 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 ## 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 ### 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` - 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`
+1 -1
View File
@@ -4,5 +4,5 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "config-parser" name = "config-parser"
version = "1.1.1" version = "1.2.0"
description = "A configuration file parser." description = "A configuration file parser."
+8 -1
View File
@@ -1,5 +1,9 @@
import copy import copy
__all__ = [
'Configuration'
]
class Configuration: class Configuration:
def __init__(self, _config: dict = {}): def __init__(self, _config: dict = {}):
'''A base class for configurations.''' '''A base class for configurations.'''
@@ -37,4 +41,7 @@ class Configuration:
del self._config[key] del self._config[key]
def __iter__(self): def __iter__(self):
return iter(self._config.items()) return iter(self._config.items())
def __contains__(self, item):
return item in self._config
+18
View File
@@ -1,3 +1,21 @@
__all__ = [
'ConfigurationSyntaxError',
'EscapeSequenceSyntaxError',
'INISyntaxError',
'INIInvalidGroupHeader',
'INIInvalidKeyValueLine',
'JSONSyntaxError',
'JSONValueSyntaxError',
'JSONNumberSyntaxError',
'JSONStringSyntaxError',
'JSONBooleanSyntaxError',
'JSONNullSyntaxError',
'JSONArraySyntaxError',
'JSONObjectSyntaxError',
'ConfigurationTypeError',
'JSONTypeError'
]
class ConfigurationSyntaxError(SyntaxError): ... class ConfigurationSyntaxError(SyntaxError): ...
class EscapeSequenceSyntaxError(ConfigurationSyntaxError): ... class EscapeSequenceSyntaxError(ConfigurationSyntaxError): ...
+22 -6
View File
@@ -1,14 +1,20 @@
from __future__ import annotations
import typing import typing
from ._configuration import Configuration 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 from .serialize.ini import serialize as serialize_ini
__all__ = [
'INIConfiguration',
'INIConfigurationSection'
]
class INIConfiguration(Configuration): class INIConfiguration(Configuration):
def __iter__(self): 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 @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. 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] :type quotation_marks: typing.Collection[str]
:param ignore_errors: If True, parsing errors will be ignored. :param ignore_errors: If True, parsing errors will be ignored.
:type ignore_errors: bool :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``. :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() data = data.decode()
configuration = cls() configuration = cls()
for group_name, content in parse_ini(data, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors).items(): for section_name, content in parse_ini(data, comment_prefixes=comment_prefixes, quotation_marks=quotation_marks, ignore_errors=ignore_errors).items():
group = INIConfigurationGroup(group_name) group = INIConfigurationSection(section_name)
for key, value in content.items(): for key, value in content.items():
group[key] = value 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 return configuration
@@ -53,7 +69,7 @@ class INIConfiguration(Configuration):
return serialize_ini(self._config, separator) return serialize_ini(self._config, separator)
class INIConfigurationGroup(INIConfiguration): class INIConfigurationSection(INIConfiguration):
def __init__(self, name: str): def __init__(self, name: str):
''' '''
A class representing a group in an INI configuration. A class representing a group in an INI configuration.
+5 -1
View File
@@ -2,6 +2,10 @@ from ._configuration import Configuration
from .parse.json import parse_type, JSONObject, JSONString, JSONNumber, JSONNull, JSONBoolean, JSONArray from .parse.json import parse_type, JSONObject, JSONString, JSONNumber, JSONNull, JSONBoolean, JSONArray
from .exceptions import JSONTypeError from .exceptions import JSONTypeError
__all__ = [
'JSONConfiguration'
]
def _configuration(data): def _configuration(data):
if isinstance(data, str): if isinstance(data, str):
return JSONString(data) return JSONString(data)
@@ -52,4 +56,4 @@ class JSONConfiguration(Configuration):
if ignore_errors: if ignore_errors:
return cls() return cls()
else: else:
raise raise
+16 -14
View File
@@ -1,13 +1,17 @@
import typing import typing
from ..exceptions import INIInvalidGroupHeader, INIInvalidKeyValueLine, INISyntaxError from ..exceptions import INIInvalidGroupHeader, INIInvalidKeyValueLine, INISyntaxError
__all__ = [
'parse_ini'
]
_QUOTATION_MARKS = ['"', "'"] _QUOTATION_MARKS = ['"', "'"]
_COMMENT_PREFIXES = ['#', ';'] _COMMENT_PREFIXES = ['#', ';']
_KEY_VALUE_LINE = 0 _KEY_VALUE_LINE = 0
_GROUP_HEADER_LINE = 1 _GROUP_HEADER_LINE = 1
_COMMENT_LINE = 2 _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. 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()))} 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. Parses a key-value line.
@@ -70,8 +74,7 @@ def parse_key_value_line(line: str, comment_prefixes: typing.Collection[str] = _
return {key: value} 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. Parses a group header line.
@@ -92,8 +95,7 @@ def parse_group_header(line: str, ignore_errors: bool = False) -> str:
return '' return ''
raise INIInvalidGroupHeader('the header is invalid') 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. 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 return _COMMENT_LINE
raise INISyntaxError('the line is invalid') raise INISyntaxError('the line is invalid')
def compress_conf(conf): def _compress_conf(conf):
while '\n\n' in conf: while '\n\n' in conf:
conf = conf.replace('\n\n', '\n') conf = conf.replace('\n\n', '\n')
return conf.strip() 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]] :rtype: typing.Dict[str, typing.Dict[str, str]]
''' '''
conf = compress_conf(conf) conf = _compress_conf(conf)
result = {} result = {}
current_group = {} current_group = {}
for line in list(reversed(conf.split('\n'))): for line in list(reversed(conf.split('\n'))):
line = line.strip() 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 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) result[_parse_group_header(line, ignore_errors=ignore_errors)] = _reverse_dict(current_group)
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) 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) return _reverse_dict(result)
+58 -45
View File
@@ -2,6 +2,19 @@ from ..exceptions import EscapeSequenceSyntaxError, JSONValueSyntaxError, JSONNu
import typing import typing
import re import re
__all__ = [
'JSONType',
'JSONNumber',
'JSONString',
'JSONBoolean',
'JSONNull',
'JSONArray',
'JSONObject',
'parse_escape_sequences',
'parse_type',
'parse_json'
]
_JSON_ESCAPE_SEQUENCES = { _JSON_ESCAPE_SEQUENCES = {
'"': '"', '"': '"',
'\\': '\\', '\\': '\\',
@@ -23,51 +36,6 @@ _JSON_NUMBER_RE = re.compile(
re.VERBOSE, 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: class JSONType:
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
@@ -362,6 +330,51 @@ class JSONObject(JSONType):
return cls(json_object) 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]: def parse_type(value: str) -> typing.Type[JSONType]:
''' '''
Parses the type of the value. Parses the type of the value.
+3 -1
View File
@@ -1,4 +1,6 @@
from ..exceptions import INISyntaxError __all__ = [
'serialize'
]
def serialize(data: dict, separator: str) -> str: def serialize(data: dict, separator: str) -> str:
''' '''
+4 -1
View File
@@ -2,6 +2,10 @@ import copy
from typing import * from typing import *
import collections.abc import collections.abc
__all__ = [
'serialize'
]
_ESCAPE_SEQUENCES = { _ESCAPE_SEQUENCES = {
'\"': r'\"', '\"': r'\"',
'\\': r'\\', '\\': r'\\',
@@ -80,7 +84,6 @@ def _serialize_string(data: str) -> str:
data += _ESCAPE_SEQUENCES.get(c, c) data += _ESCAPE_SEQUENCES.get(c, c)
return '"' + data + '"' return '"' + data + '"'
def _serialize_number(data: int | float) -> str: def _serialize_number(data: int | float) -> str:
return str(data) return str(data)
+17 -3
View File
@@ -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 from src.config_parser.exceptions import INISyntaxError
def test_generating(): def test_generating():
config = INIConfiguration() config = INIConfiguration()
config.test_group = INIConfigurationGroup('test_group') config.test_group = INIConfigurationSection('test_group')
config.test_group.key1 = 'value1' config.test_group.key1 = 'value1'
config.test_group.key2 = 'value2' config.test_group.key2 = 'value2'
assert config.to_string() == '''[test_group] assert config.to_string() == '''[test_group]
@@ -43,4 +43,18 @@ invalid_line2
INIConfiguration.from_string('key=value"') INIConfiguration.from_string('key=value"')
assert False, 'Expected INISyntaxError for unterminated literal' assert False, 'Expected INISyntaxError for unterminated literal'
except INISyntaxError: except INISyntaxError:
pass 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'}}