diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..1d1c7f5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +# vim: ft=cfg +[flake8] +max-complexity = 7 +docstring-convention=google + +# Required to be compatible with black +extend-ignore = E203,W503 +inline-quotes = double diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..b82bad8 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,55 @@ +"""Common exceptions raised by the program.""" +from typing import Union, List + + +class InvalidKeyError(Exception): + """Thrown when a config file contains an invalid key.""" + + def __init__(self, keys: Union[str, List[str]]): + """Create a new InvalidKeyError object with the given key. + + Args: + keys: the invalid key(s) + """ + if type(keys) == str: + keys = [keys] + + self.message = "Invalid key(s): {}".format(", ".join(keys)) + + super().__init__() + + +class MissingKeyError(Exception): + """Thrown when a required key is missing from a config.""" + + def __init__(self, keys: Union[str, List[str]]): + """Create a new MissingKeyError object with the given key. + + Args: + keys: the invalid key(s) + """ + if type(keys) == str: + keys = [keys] + + self.message = "Missing key(s): {}".format(", ".join(keys)) + + super().__init__() + + +class InvalidValueError(Exception): + """Thrown when a key contains an invalid value.""" + + def __init__(self, key: str, expected: str, actual: str): + """Create a new InvalidValueError given the arguments. + + Args: + key: the key containing the invalid value + expected: name of the expected type + actual: name of the actual type + """ + self.message = ( + f"Invalid value for key {key}: expected {expected}, " + f"got {actual}" + ) + + super().__init__() diff --git a/app/skeleton.py b/app/skeleton.py new file mode 100644 index 0000000..c12bd45 --- /dev/null +++ b/app/skeleton.py @@ -0,0 +1,93 @@ +"""Handles merging with the skeleton config.""" +from typing import Dict +from .exceptions import InvalidKeyError, MissingKeyError + + +def merge(*dicts: [Dict]) -> Dict: + """Merge multiple dicts into one. + + It reads the dicts from left to right, always preferring the "right" + dictionary's values. Therefore, the dictionaries should be sorted from + least important to most important (e.g. a default values skeleton should be + to the left of a dict of selected values). + + Args: + dicts: the dictionaries to merge + + Returns: + a new dictionary representing the merged dictionaries + + Todo: + * Make sure an infinite loop is not possible + """ + # Base cases + if len(dicts) == 0: + return {} + + if len(dicts) == 1: + return dicts[0] + + # We merge the first two dicts + d1, d2 = dicts[0], dicts[1] + + output = d1.copy() + + for key, value in d2.items(): + if type(value) == dict: + # Merge the two sub-dictionaries + output[key] = ( + merge(output[key], value) + if type(output.get(key)) == dict + else value + ) + + else: + output[key] = value + + return merge(output, *dicts[2:]) + + +def merge_with_skeleton(data: Dict, skel: Dict) -> Dict: + """Merge a dictionary with a skeleton containing default values. + + The skeleton not only defines what the default values are, but also + enforces a certain shape. This allows us to define a config file using a + dictionary and parse it. + + Args: + data: dictionary containing the selected config values + skel: dictionary containing the skeleton (aka the def) + + Returns: + a new dictionary representing the two merged dictionaries + + Todo: + * Check if an infinite loop is possible + * Split info less complex functions + """ + # First, check for illegal keys + invalid_keys = list(filter(lambda k: k not in skel, data)) + + if invalid_keys: + raise InvalidKeyError(invalid_keys) + + # Then, check the default values + for key, value in skel.items(): + if key not in data: + # Raise error if there's not default value + if value is None: + raise MissingKeyError(key) + + # Replace with default value + data[key] = value + + # Error if value is not same type as default value + elif type(data[key]) != type(value) and value is not None: + # TODO make this error message more verbose + raise TypeError("Invalid value type") + + # Recurse into dicts + elif type(value) == dict: + data[key] = merge_with_skeleton(data[key], value) + + return data diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..46e8364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 79 diff --git a/tests/test_dict_merge.py b/tests/test_dict_merge.py new file mode 100644 index 0000000..2da81bf --- /dev/null +++ b/tests/test_dict_merge.py @@ -0,0 +1,69 @@ +"""Tests for the skeleton module.""" +from app.skeleton import merge + + +def test_merge_empty(): + """Test correct response for an empty merge.""" + assert merge() == {} + + +def test_merge_single(): + """Test merge command with a single input.""" + assert merge({}) == {} + + dic = {"test": "value", "test2": "value2"} + + assert merge(dic) == dic + + +def test_merge_double_no_overlap(): + """Test merge command with two non-overlapping inputs.""" + d1 = {"test": "value", "test2": "value2"} + d2 = {"test3": "value3"} + d_res = {"test": "value", "test2": "value2", "test3": "value3"} + + assert merge(d1, d2) == d_res + + +def test_merge_double_overlap(): + """Test merge command with two overlapping inputs.""" + d1 = {"test": "value", "test2": "value2"} + d2 = {"test2": "value3"} + d_res = {"test": "value", "test2": "value3"} + + assert merge(d1, d2) == d_res + + +def test_merge_triple_no_overlap(): + """Test merge command with three non-overlapping inputs. + + This test tells us that the recursion works. + """ + d1 = {"test": "value", "test2": "value2"} + d2 = {"test3": "value3"} + d3 = {"test4": "value4"} + d_res = { + "test": "value", + "test2": "value2", + "test3": "value3", + "test4": "value4", + } + + assert merge(d1, d2, d3) == d_res + + +def test_merge_triple_overlap(): + """Test merge command with three overlapping inputs. + + This test tells us that the recursion works. + """ + d1 = {"test": "value", "test2": "value2"} + d2 = {"test3": "value3"} + d3 = {"test2": "value4"} + d_res = { + "test": "value", + "test2": "value4", + "test3": "value3", + } + + assert merge(d1, d2, d3) == d_res diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py new file mode 100644 index 0000000..9ade87e --- /dev/null +++ b/tests/test_skeleton.py @@ -0,0 +1,70 @@ +"""Tests wether the skeleton merge works.""" +from app.skeleton import merge_with_skeleton +from app.exceptions import InvalidKeyError, MissingKeyError +import pytest + + +def test_single_invalid_key(): + """Tests wether an InvalidKeyError is correctly thrown for a single key.""" + data = { + "test": 1, + "test2": "test" + } + skel = { + "test": None, + } + + with pytest.raises(InvalidKeyError) as e_info: + merge_with_skeleton(data, skel) + + assert e_info.value.message == "Invalid key(s): test2" + + +def test_multiple_invalid_keys(): + """Tests wether an InvalidKeyError is thrown for multiple keys.""" + data = { + "test": 1, + "test2": "test", + "test3": "test", + } + skel = { + "test": None, + } + + with pytest.raises(InvalidKeyError) as e_info: + merge_with_skeleton(data, skel) + + assert e_info.value.message == "Invalid key(s): test2, test3" + + +def test_single_missing_key(): + """Tests wether a MissingKeyError is correctly thrown for a single key.""" + data = { + "test": 1, + } + skel = { + "test": None, + "test2": None, + } + + with pytest.raises(MissingKeyError) as e_info: + merge_with_skeleton(data, skel) + + assert e_info.value.message == "Missing key(s): test2" + + +def test_multiple_missing_keys(): + """Tests wether a MissingKeyError is correctly thrown for multiple keys.""" + data = { + "test": 1, + } + skel = { + "test": None, + "test2": None, + "test3": None, + } + + with pytest.raises(MissingKeyError) as e_info: + merge_with_skeleton(data, skel) + + assert e_info.value.message == "Missing key(s): test2, test3"