Support for a default configuration for INI files
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
+1
-1
@@ -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."
|
||||
@@ -1,5 +1,9 @@
|
||||
import copy
|
||||
|
||||
__all__ = [
|
||||
'Configuration'
|
||||
]
|
||||
|
||||
class Configuration:
|
||||
def __init__(self, _config: dict = {}):
|
||||
'''A base class for configurations.'''
|
||||
@@ -38,3 +42,6 @@ class Configuration:
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._config.items())
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self._config
|
||||
@@ -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): ...
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from ..exceptions import INISyntaxError
|
||||
__all__ = [
|
||||
'serialize'
|
||||
]
|
||||
|
||||
def serialize(data: dict, separator: str) -> str:
|
||||
'''
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+16
-2
@@ -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]
|
||||
@@ -44,3 +44,17 @@ invalid_line2
|
||||
assert False, 'Expected INISyntaxError for unterminated literal'
|
||||
except INISyntaxError:
|
||||
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'}}
|
||||
Reference in New Issue
Block a user