diff --git a/README.md b/README.md index 2d40f87..6896d6e 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,25 @@ pip install git+https://github.com/Microndgt/pyconf.git Usage ===== -Load a config file + +ini config file +--- + +```python +import pyconf +c = pyconf.load('tests/sample.conf', config_class=pyconf.IniConfig) +print(c['path']) +# some_path +``` + +python config file +--- ```python import pyconf -c = pyconf.load('sample.conf') -print(c['general']) -# {'foo': 'baz'} +c = pyconf.load('tests/sample.py', config_class=pyconf.PyConfig) +print(c['path']) +# some_path ``` Tests @@ -30,5 +42,21 @@ Tests Run the tests with ```bash -python test_configs.py +python -m tests.test_ini_configs +python -m tests.test_py_configs ``` + +History +=== + +0.1.0 +--- + +1. change the Architecture +2. support the python config file + +0.0.1 +--- + +1. init project +2. ini config parser done diff --git a/pyconf/__init__.py b/pyconf/__init__.py index ee13a55..4115b70 100644 --- a/pyconf/__init__.py +++ b/pyconf/__init__.py @@ -10,15 +10,17 @@ Load a config file:: >>> import pyconf - >>> c = pyconf.load('sample.conf') + >>> c = pyconf.load('tests/sample.conf') >>> c['general'] {'foo': 'baz'} """ __title__ = 'pyconf' -__version__ = '0.0.1' +__version__ = '0.1.0' __author__ = 'Kevin Du' __license__ = 'MIT' from .ini_config import IniConfig +from .py_config import PyConfig +from .base_config import BaseConfig, Section from .api import load diff --git a/pyconf/api.py b/pyconf/api.py index 1b29793..fca21fe 100644 --- a/pyconf/api.py +++ b/pyconf/api.py @@ -1,12 +1,11 @@ from .ini_config import IniConfig +from .py_config import PyConfig -config_parsers = { - 'ini': IniConfig -} +support_configs = (IniConfig, PyConfig) -def load(config_file, ext="ini"): +def load(config_file, config_class=IniConfig): """Constructs and returns a :class:`Config ` instance. :param config_file: configuration file to be parsed @@ -16,23 +15,24 @@ def load(config_file, ext="ini"): >>> import pyconf - >>> fc = pyconf.load('sample.conf') + >>> fc = pyconf.load('tests/sample.conf') >>> fc['general']['spam'] """ # ini extensions - Config = config_parsers.get(ext, 'ini') + if config_class not in support_configs: + raise Exception("config class: {} not supported".format(config_class)) configs = {} try: - configs = Config(config_file) + configs = config_class(config_file) except FileNotFoundError: raise except Exception as e: - for extension, config_class in config_parsers.items(): - if Config == config_class: + for extra_config_class in support_configs: + if config_class == extra_config_class: continue try: - configs = config_class(config_file) + configs = extra_config_class(config_file) except FileNotFoundError: raise else: diff --git a/pyconf/base_config.py b/pyconf/base_config.py new file mode 100644 index 0000000..890600e --- /dev/null +++ b/pyconf/base_config.py @@ -0,0 +1,216 @@ +class BaseConfig: + def __init__(self, config_file): + self.sections = {} + self._add_section('root') + + self.load(config_file) + + def load(self, config_file): + raise NotImplementedError + + def _add_section(self, name, last_section=None): + """Adds an empty section with the given name. + + :param name: new section name. + """ + if last_section is None: + last_section = self.sections + last_section[name] = Section() + return last_section[name] + + def get_config(self): + """Gets all section items. + + :returns: dictionary with section name as key and section values and value. + """ + + return {section: self.sections[section].get_values() for section in self.sections} + + def get(self, key): + """Tries to get a value from the ``root`` section dict_props by the given key. + + :param key: lookup key. + :returns: value (str, bool, int, or float) if key exists, None otherwise. + """ + + if key in self.sections: + return self.sections[key] + + return self['root'].get(key) + + def _add_dict_prop_to_section(self, key, value, section=None, pure=False): + """Adds a key-value item to the given section. + + :param key: new item key. + :param value: new item value. + :param section: (optional) section name (``root`` by default). + """ + if section is None: + section = self.sections['root'] + section._add_dict_prop(key, value, pure=pure) + + def _add_list_prop_to_section(self, value, section=None): + """Adds a flag value to the given section. + + :param value: new item value. + :param section: (optional) section name (``root`` by default). + """ + if section is None: + section = self.sections['root'] + section._add_list_prop(value) + + def __repr__(self): + return str(self.get_config()) + + def __str__(self): + return str(self.get_config()) + + def __getitem__(self, key): + if key in self.sections: + return self.sections[key] + else: + try: + return self.sections['root'][key] + except KeyError: + pass + + raise KeyError(key) + + def __setitem__(self, key, value): + try: + self.sections['root'][key] = value + return None + except KeyError as e: + raise e + + def __eq__(self, other): + return self.sections == other.sections + + +class Section: + """INI configuration section. + + A Section instance stores both key-value and flag items, in ``dict_props`` and ``list_props`` attributes respectively. + + It is possible to iterate over a section; flag values are listed first, then key-value items. + """ + + def __init__(self): + self.dict_props = {} + self.list_props = [] + + def get_values(self): + """Gets section values. + + If section contains only flag values, a list is returned. + + If section contains only key-value items, a dictionary is returned. + + If section contains both flag and key-value items, a tuple of both is returned. + """ + + if self.list_props and self.dict_props: + return self.list_props, self.dict_props + + return self.list_props or self.dict_props or None + + def get(self, key): + """Tries to get a value from the dict_props by the given key. + + :param key: lookup key. + :returns: value if key exists (str, bool, int, or float), None otherwise. + """ + + return self.dict_props.get(key) + + def _get_value_type(self, value): + """Checks if the given value is boolean, float, int, of str. + + Returns converted value if conversion is possible (str, bool, int, or float). + + :param value: value to check. + """ + + value = value.strip() + + if value == 'True': + return True + elif value == 'False': + return False + else: + try: + return_value = int(value) + except ValueError: + try: + return_value = float(value) + except ValueError: + return value + + return return_value + + def _add_dict_prop(self, key, value, pure=False): + """Adds a key-value item to section.""" + if pure: + self.dict_props[key] = value + return + typed_value_map = map(self._get_value_type, value.split(',')) + + typed_value_tuple = tuple(typed_value_map) + + if len(typed_value_tuple) == 1: + self.dict_props[key] = typed_value_tuple[0] + else: + self.dict_props[key] = typed_value_tuple + + def _add_list_prop(self, value): + """Adds a flag value to section.""" + typed_value_map = map(self._get_value_type, value.split(',')) + + typed_value_tuple = tuple(typed_value_map) + + if len(typed_value_tuple) == 1: + self.list_props.append(typed_value_tuple[0]) + else: + self.list_props.append(typed_value_tuple) + + def __repr__(self): + return str(self.get_values()) + + def __str__(self): + return str(self.get_values()) + + def __iter__(self): + for list_prop in self.list_props: + yield list_prop + + for dict_prop in self.dict_props: + yield dict_prop + + def __getitem__(self, key): + try: + return self.dict_props[key] + except KeyError: + pass + + try: + return self.list_props[key] + except (KeyError, TypeError): + pass + + raise KeyError(key) + + def __setitem__(self, key, value): + try: + self.dict_props[key] = value + return None + except KeyError: + pass + + try: + self.list_props[key] = value + return None + except (KeyError, TypeError) as e: + raise e + + def __eq__(self, other): + return self.dict_props == other.dict_props and self.list_props == other.list_props diff --git a/pyconf/ini_config.py b/pyconf/ini_config.py index 1f3218e..d7dede5 100644 --- a/pyconf/ini_config.py +++ b/pyconf/ini_config.py @@ -1,137 +1,12 @@ +""" +Parsing ini config file +""" import re from os.path import abspath +from .base_config import BaseConfig -class IniSection: - """INI configuration section. - - A Section instance stores both key-value and flag items, in ``dict_props`` and ``list_props`` attributes respectively. - - It is possible to iterate over a section; flag values are listed first, then key-value items. - """ - - def __init__(self): - self.dict_props = {} - self.list_props = [] - - def get_values(self): - """Gets section values. - - If section contains only flag values, a list is returned. - - If section contains only key-value items, a dictionary is returned. - - If section contains both flag and key-value items, a tuple of both is returned. - """ - - if self.list_props and self.dict_props: - return self.list_props, self.dict_props - - return self.list_props or self.dict_props or None - - def get(self, key): - """Tries to get a value from the dict_props by the given key. - - :param key: lookup key. - :returns: value if key exists (str, bool, int, or float), None otherwise. - """ - - return self.dict_props.get(key) - - def _get_value_type(self, value): - """Checks if the given value is boolean, float, int, of str. - - Returns converted value if conversion is possible (str, bool, int, or float). - - :param value: value to check. - """ - - value = value.strip() - - if value == 'True': - return True - elif value == 'False': - return False - else: - try: - return_value = int(value) - except ValueError: - try: - return_value = float(value) - except ValueError: - return value - - return return_value - - def _add_dict_prop(self, key, value): - """Adds a key-value item to section.""" - - typed_value_map = map(self._get_value_type, value.split(',')) - - typed_value_tuple = tuple(typed_value_map) - - if len(typed_value_tuple) == 1: - self.dict_props[key] = typed_value_tuple[0] - - else: - self.dict_props[key] = typed_value_tuple - - def _add_list_prop(self, value): - """Adds a flag value to section.""" - - typed_value_map = map(self._get_value_type, value.split(',')) - - typed_value_tuple = tuple(typed_value_map) - - if len(typed_value_tuple) == 1: - self.list_props.append(typed_value_tuple[0]) - else: - self.list_props.append(typed_value_tuple) - - def __repr__(self): - return str(self.get_values()) - - def __str__(self): - return str(self.get_values()) - - def __iter__(self): - for list_prop in self.list_props: - yield list_prop - - for dict_prop in self.dict_props: - yield dict_prop - - def __getitem__(self, key): - try: - return self.dict_props[key] - except KeyError: - pass - - try: - return self.list_props[key] - except (KeyError, TypeError): - pass - - raise KeyError(key) - - def __setitem__(self, key, value): - try: - self.dict_props[key] = value - return None - except KeyError: - pass - - try: - self.list_props[key] = value - return None - except (KeyError, TypeError) as e: - raise e - - def __eq__(self, other): - return self.dict_props == other.dict_props and self.list_props == other.list_props - - -class IniConfig: +class IniConfig(BaseConfig): """Parsed configuration. Config instance includes a list of :class:`Section ` instances. @@ -142,36 +17,11 @@ class IniConfig: _dict_item = re.compile('^\s*(?P\w+)\s*\=\s*(?P.+)\s*$') _list_item = re.compile('^\s*(?P.+)\s*$') - def __init__(self, config_file): - self.sections = {} - self._add_section('root') - - self.load(config_file) - - def get_config(self): - """Gets all section items. - - :returns: dictionary with section name as key and section values and value. - """ - - return {section: self.sections[section].get_values() for section in self.sections} - - def get(self, key): - """Tries to get a value from the ``root`` section dict_props by the given key. - - :param key: lookup key. - :returns: value (str, bool, int, or float) if key exists, None otherwise. - """ - - if key in self.sections: - return self.sections[key] - - return self['root'].get(key) - def load(self, config_file): """Parse an INI configuration file. :param config_file: configuration file to be loaded. + :param silent: set to ``True`` if you want silent failure for missing files. """ current_section = None @@ -185,7 +35,7 @@ def load(self, config_file): header_match = re.match(self._header, line) if header_match: current_section = header_match.group('section') - if not current_section in self.sections: + if current_section not in self.sections: self._add_section(current_section) continue @@ -195,7 +45,7 @@ def load(self, config_file): key, value = dict_item_match.group('key'), dict_item_match.group('value') if current_section: - self._add_dict_prop_to_section(key, value, current_section) + self._add_dict_prop_to_section(key, value, self.sections[current_section]) else: self._add_dict_prop_to_section(key, value) @@ -205,70 +55,10 @@ def load(self, config_file): if list_item_match: value = list_item_match.group('value') if current_section: - self._add_list_prop_to_section(value, current_section) + self._add_list_prop_to_section(value, self.sections[current_section]) else: self._add_list_prop_to_section(value) continue self.config_full_path = abspath(f.name) - - def _add_section(self, name): - """Adds an empty section with the given name. - - :param name: new section name. - """ - - self.sections[name] = IniSection() - - def _add_dict_prop_to_section(self, key, value, section='root'): - """Adds a key-value item to the given section. - - :param key: new item key. - :param value: new item value. - :param section: (optional) section name (``root`` by default). - """ - - if section in self.sections: - self.sections[section]._add_dict_prop(key, value) - else: - raise KeyError - - def _add_list_prop_to_section(self, value, section='root'): - """Adds a flag value to the given section. - - :param value: new item value. - :param section: (optional) section name (``root`` by default). - """ - - if section in self.sections: - self.sections[section]._add_list_prop(value) - else: - raise KeyError - - def __repr__(self): - return str(self.get_config()) - - def __str__(self): - return str(self.get_config()) - - def __getitem__(self, key): - if key in self.sections: - return self.sections[key] - else: - try: - return self.sections['root'][key] - except KeyError: - pass - - raise KeyError(key) - - def __setitem__(self, key, value): - try: - self.sections['root'][key] = value - return None - except KeyError as e: - raise e - - def __eq__(self, other): - return self.sections == other.sections diff --git a/pyconf/py_config.py b/pyconf/py_config.py new file mode 100644 index 0000000..d6c6a52 --- /dev/null +++ b/pyconf/py_config.py @@ -0,0 +1,21 @@ +""" +Parsing python config file +""" +from .base_config import BaseConfig + + +class PyConfig(BaseConfig): + def _add_section_recursive(self, conf_dict, last_section): + for key, value in conf_dict.items(): + if isinstance(value, dict): + current_section = self._add_section(key, last_section) + self._add_section_recursive(value, current_section) + else: + self._add_dict_prop_to_section(key, value, section=last_section, pure=True) + + def load(self, config_file): + config_obj = {} + with open(config_file, mode='rb') as config: + exec(config.read(), None, config_obj) + self._add_section_recursive(config_obj, self.sections['root']) + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b6cacd8 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup + +import pyconf + +try: + readme = open('README.md').read() +except: + readme = 'pyconf: Configuration for Humans. Support ini config file, python config file' + +setup( + name=pyconf.__title__, + version=pyconf.__version__, + author=pyconf.__author__, + description='Configuration for humans', + long_description=readme, + author_email='dgt_x@foxmail.com', + url='https://github.com/Microndgt/pyconf', + packages=['pyconf'], + package_dir={'pyconf': 'pyconf'}, + package_data={'pyconf': ['*.conf']}, + include_package_data=True, + license=pyconf.__license__, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3'] + ) diff --git a/test_configs.py b/test_configs.py deleted file mode 100644 index 613e504..0000000 --- a/test_configs.py +++ /dev/null @@ -1,41 +0,0 @@ -import unittest - -from pyconf import api -from pyconf import IniConfig - - -class TestApi(unittest.TestCase): - - def setUp(self): - self.sample_config_filename = 'pyconf/sample.conf' - self.missing_config_filename = 'missing.conf' - - def test_load_general(self): - """Check if a valid config file is parsed correctly""" - - self.assertIsInstance(api.load(self.sample_config_filename), IniConfig) - - def test_load_missing_file(self): - """Check if the correct exception in raised when a missing config file is attempted to parse""" - - with self.assertRaises(FileNotFoundError): - api.load(self.missing_config_filename) - - def test_load_check_types(self): - """Check automatic float, integer, and boolean value conversion""" - - self.assertIsInstance(api.load(self.sample_config_filename)['list_section'][0], float) - self.assertIsInstance(api.load(self.sample_config_filename)['list_section'][1], int) - self.assertIsInstance(api.load(self.sample_config_filename)['list_section'][2], tuple) - - self.assertIsInstance(api.load(self.sample_config_filename)['list_section'][2][0], str) - self.assertIsInstance(api.load(self.sample_config_filename)['list_section'][2][1], int) - - self.assertIsInstance(api.load(self.sample_config_filename)['mixed']['boolean'], bool) - self.assertIsInstance(api.load(self.sample_config_filename)['mixed']['list'], tuple) - - self.assertIsInstance(api.load(self.sample_config_filename)['mixed']['list'][0], float) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyconf/sample.conf b/tests/sample.conf similarity index 100% rename from pyconf/sample.conf rename to tests/sample.conf diff --git a/tests/sample.py b/tests/sample.py new file mode 100644 index 0000000..9771582 --- /dev/null +++ b/tests/sample.py @@ -0,0 +1,20 @@ +path = "some_path" +hosts = ["example.com", "http://bing.com", "ssh.com:23", "www.qwe.asd"] + +section = { + "attr1": 7.1, + "attr2": 42, + "foo": 123 +} + +section2 = { + "inner_section": { + "test_section": { + 1: 2 + }, + "two": 2, + }, + "attr1": 7.1, + "attr2": 42, + "foo": 123 +} diff --git a/tests/test_ini_configs.py b/tests/test_ini_configs.py new file mode 100644 index 0000000..72de9c3 --- /dev/null +++ b/tests/test_ini_configs.py @@ -0,0 +1,46 @@ +import unittest + +from pyconf import api +from pyconf import IniConfig +from typing import Sequence + + +class TestIniConfig(unittest.TestCase): + + def setUp(self): + self.sample_config_filename = 'tests/sample.conf' + self.missing_config_filename = 'missing.conf' + + def test_load_general(self): + """Check if a valid config file is parsed correctly""" + + self.assertIsInstance(api.load(self.sample_config_filename, config_class=IniConfig), IniConfig) + + def test_load_missing_file(self): + """Check if the correct exception in raised when a missing config file is attempted to parse""" + + with self.assertRaises(FileNotFoundError): + api.load(self.missing_config_filename) + + def test_load_check_types(self): + """Check automatic float, integer, and boolean value conversion""" + config_data = api.load(self.sample_config_filename, config_class=IniConfig) + self.assertIsInstance(config_data['path'], str) + self.assertIsInstance(config_data['hosts'], Sequence) + self.assertIsInstance(config_data['hosts'][0], str) + + self.assertIsInstance(config_data['list_section'][0], float) + self.assertIsInstance(config_data['list_section'][1], int) + self.assertIsInstance(config_data['list_section'][2], tuple) + + self.assertIsInstance(config_data['list_section'][2][0], str) + self.assertIsInstance(config_data['list_section'][2][1], int) + + self.assertIsInstance(config_data['mixed']['boolean'], bool) + self.assertIsInstance(config_data['mixed']['list'], tuple) + + self.assertIsInstance(config_data['mixed']['list'][0], float) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_py_configs.py b/tests/test_py_configs.py new file mode 100644 index 0000000..b67d672 --- /dev/null +++ b/tests/test_py_configs.py @@ -0,0 +1,47 @@ +import unittest + +from pyconf import api, PyConfig, Section +from typing import Sequence + + +class TestPyConfig(unittest.TestCase): + + def setUp(self): + self.sample_config_filename = 'tests/sample.py' + self.missing_config_filename = 'missing.py' + + def test_load_general(self): + """Check if a valid config file is parsed correctly""" + + self.assertIsInstance(api.load(self.sample_config_filename, config_class=PyConfig), PyConfig) + + def test_load_missing_file(self): + """Check if the correct exception in raised when a missing config file is attempted to parse""" + + with self.assertRaises(FileNotFoundError): + api.load(self.missing_config_filename) + + def test_load_check_types(self): + """Check automatic float, integer, and boolean value conversion""" + config_data = api.load(self.sample_config_filename, config_class=PyConfig) + self.assertIsInstance(config_data['path'], str) + self.assertIsInstance(config_data['hosts'], Sequence) + self.assertIsInstance(config_data['hosts'][0], str) + + self.assertIsInstance(config_data['section'], Section) + self.assertIsInstance(config_data['section']['attr1'], float) + self.assertIsInstance(config_data['section']['attr2'], int) + self.assertIsInstance(config_data['section']['foo'], int) + + self.assertIsInstance(config_data['section2'], Section) + self.assertIsInstance(config_data['section2']['attr1'], float) + self.assertIsInstance(config_data['section2']['attr2'], int) + self.assertIsInstance(config_data['section2']['foo'], int) + self.assertIsInstance(config_data['section2']['inner_section'], Section) + self.assertIsInstance(config_data['section2']['inner_section']['two'], int) + self.assertIsInstance(config_data['section2']['inner_section']['test_section'], Section) + self.assertIsInstance(config_data['section2']['inner_section']['test_section'][1], int) + + +if __name__ == '__main__': + unittest.main()