From 8fab13e13aa359fe688f7280671975a7fe6efff1 Mon Sep 17 00:00:00 2001 From: s3lph Date: Fri, 13 Jul 2018 16:02:30 +0200 Subject: [PATCH] Added support for multiple config files --- doc | 2 +- matemat/__main__.py | 6 +-- matemat/webserver/config.py | 19 +++++-- matemat/webserver/test/test_config.py | 75 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/doc b/doc index 4599a33..d5dc5f7 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit 4599a339dbd4a2299a8eca575a7b801686d9fc5a +Subproject commit d5dc5f794ad1b959b9d4dce47eeb6068e5a75115 diff --git a/matemat/__main__.py b/matemat/__main__.py index b461855..8876386 100644 --- a/matemat/__main__.py +++ b/matemat/__main__.py @@ -1,5 +1,5 @@ -from typing import Any, Dict +from typing import Any, Dict, Iterable, Union import sys @@ -12,9 +12,9 @@ if __name__ == '__main__': from matemat.webserver import MatematWebserver # Use config file name from command line, if present - configfile: str = '/etc/matemat.conf' + configfile: Union[str, Iterable[str]] = '/etc/matemat.conf' if len(sys.argv) > 1: - configfile = sys.argv[1] + configfile = sys.argv[1:] # Parse the config file config: Dict[str, Any] = parse_config_file(configfile) diff --git a/matemat/webserver/config.py b/matemat/webserver/config.py index 53386b5..c2e1c5f 100644 --- a/matemat/webserver/config.py +++ b/matemat/webserver/config.py @@ -1,15 +1,15 @@ -from typing import Any, Dict +from typing import Any, Dict, Iterable, List, Union import os from configparser import ConfigParser -def parse_config_file(path: str) -> Dict[str, Any]: +def parse_config_file(paths: Union[str, Iterable[str]]) -> Dict[str, Any]: """ Parse the configuration file at the given path. - :param path: The config file to parse. + :param paths: The config file(s) to parse. :return: A dictionary containing the parsed configuration. """ # Set up default values @@ -30,8 +30,17 @@ def parse_config_file(path: str) -> Dict[str, Any]: parser: ConfigParser = ConfigParser() # Replace the original option transformation by a string constructor to preserve the case of config keys parser.optionxform = str - # Read the configuration file - parser.read(os.path.expanduser(path), 'utf-8') + # Normalize the input argument (turn a scalar into a list and expand ~ in paths) + files: List[str] = list() + if isinstance(paths, str): + files.append(os.path.expanduser(paths)) + else: + for path in paths: + if not isinstance(path, str): + raise TypeError(f'Not a string: {path}') + files.append(os.path.expanduser(path)) + # Read the configuration files + parser.read(files, 'utf-8') # Read values from the [Matemat] section, if present, falling back to default values if 'Matemat' in parser.sections(): diff --git a/matemat/webserver/test/test_config.py b/matemat/webserver/test/test_config.py index 6ddcc83..2d37635 100644 --- a/matemat/webserver/test/test_config.py +++ b/matemat/webserver/test/test_config.py @@ -1,4 +1,6 @@ +from typing import List + from unittest import TestCase from unittest.mock import patch @@ -24,17 +26,54 @@ UploadDir= /var/test/static/upload DatabaseFile=/var/test/db/test.db ''' +_PARTIAL_CONFIG = ''' +[Matemat] +Port=443 + +[Pagelets] +Name=Matemat (Unit Test 2) +''' + + +class IterOpenMock: + """ + Enable mocking of subsequent open() class for different files. Usage: + + with mock.patch('builtins.open', IterOpenMock(['content 1', 'content 2'])): + ... + with open('foo') as f: + # Reading from f will yield 'content 1' + with open('foo') as f: + # Reading from f will yield 'content 2' + """ + + def __init__(self, files: List[str]): + self.files = files + + def __enter__(self): + return StringIO(self.files[0]) + + def __exit__(self, exc_type, exc_val, exc_tb): + self.files = self.files[1:] + class TestConfig(TestCase): def test_parse_config_empty_defualt_values(self): + """ + Test that default values are set when reading an empty config file. + """ + # Mock the open() function to return an empty config file example with patch('builtins.open', return_value=StringIO(_EMPTY_CONFIG)): + # The filename is only a placeholder, file content is determined by mocking open config = parse_config_file('test') + # Make sure all mandatory values are present self.assertIn('listen', config) self.assertIn('port', config) self.assertIn('staticroot', config) self.assertIn('templateroot', config) self.assertIn('pagelet_variables', config) + # Make sure all mandatory values are set to their default self.assertEqual('::', config['listen']) self.assertEqual(80, config['port']) self.assertEqual('/var/matemat/static', config['staticroot']) @@ -43,8 +82,14 @@ class TestConfig(TestCase): self.assertEqual(0, len(config['pagelet_variables'])) def test_parse_config_full(self): + """ + Test that all default values are overridden by the values provided in the config file. + """ + # Mock the open() function to return a full config file example with patch('builtins.open', return_value=StringIO(_FULL_CONFIG)): + # The filename is only a placeholder, file content is determined by mocking open config = parse_config_file('test') + # Make sure all mandatory values are present self.assertIn('listen', config) self.assertIn('port', config) self.assertIn('staticroot', config) @@ -53,7 +98,7 @@ class TestConfig(TestCase): self.assertIn('Name', config['pagelet_variables']) self.assertIn('UploadDir', config['pagelet_variables']) self.assertIn('DatabaseFile', config['pagelet_variables']) - + # Make sure all values are set as described in the config file self.assertEqual('fe80::0123:45ff:fe67:89ab', config['listen']) self.assertEqual(8080, config['port']) self.assertEqual('/var/test/static', config['staticroot']) @@ -61,3 +106,31 @@ class TestConfig(TestCase): self.assertEqual('Matemat\n(Unit Test)', config['pagelet_variables']['Name']) self.assertEqual('/var/test/static/upload', config['pagelet_variables']['UploadDir']) self.assertEqual('/var/test/db/test.db', config['pagelet_variables']['DatabaseFile']) + + def test_parse_config_precedence(self): + """ + Test that config items from files with higher precedence replace items with the same key from files with lower + precedence. + """ + # Mock the open() function to return a full config file on the first call, and a partial config file on the + # second call + with patch('builtins.open', return_value=IterOpenMock([_FULL_CONFIG, _PARTIAL_CONFIG])): + # These filenames are only placeholders, file content is determined by mocking open + config = parse_config_file(['full', 'partial']) + # Make sure all mandatory values are present + self.assertIn('listen', config) + self.assertIn('port', config) + self.assertIn('staticroot', config) + self.assertIn('templateroot', config) + self.assertIn('pagelet_variables', config) + self.assertIn('Name', config['pagelet_variables']) + self.assertIn('UploadDir', config['pagelet_variables']) + self.assertIn('DatabaseFile', config['pagelet_variables']) + # Make sure all values are set as described in the config files, values from the partial file take precedence + self.assertEqual('fe80::0123:45ff:fe67:89ab', config['listen']) + self.assertEqual(443, config['port']) + self.assertEqual('/var/test/static', config['staticroot']) + self.assertEqual('/var/test/templates', config['templateroot']) + self.assertEqual('Matemat (Unit Test 2)', config['pagelet_variables']['Name']) + self.assertEqual('/var/test/static/upload', config['pagelet_variables']['UploadDir']) + self.assertEqual('/var/test/db/test.db', config['pagelet_variables']['DatabaseFile'])