Added initial project code

pull/1/head
Jef Roosens 2021-05-22 18:30:24 +02:00
parent 0d8f788c59
commit cffc928d2f
Signed by: Jef Roosens
GPG Key ID: 955C0660072F691F
6 changed files with 298 additions and 0 deletions

8
.flake8 100644
View File

@ -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

55
app/exceptions.py 100644
View File

@ -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__()

93
app/skeleton.py 100644
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"