Added initial project code
parent
0d8f788c59
commit
cffc928d2f
|
@ -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
|
|
@ -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__()
|
|
@ -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
|
|
@ -1,3 +1,6 @@
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools", "wheel"]
|
requires = ["setuptools", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
Loading…
Reference in New Issue