diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b38853..aa5d433 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "python.testing.pytestArgs": [ - "tests" + "tests", + "dpytools" ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/dpytools/config/config.py b/dpytools/config/config.py index efc38e3..419f457 100644 --- a/dpytools/config/config.py +++ b/dpytools/config/config.py @@ -1,38 +1,82 @@ -from typing import Dict +from __future__ import annotations + +import os +from typing import Any, Dict, List from .properties.base import BaseProperty +from .properties.intproperty import IntegerProperty +from .properties.string import StringProperty class Config: + + def __init__(self): + self._properties_to_validate: List[BaseProperty] = [] @staticmethod - def from_env(config_dict: Dict[str, BaseProperty]): - # TODO = read in and populate property classes as - # per the example in the main readme. - # You need to populate with dot notation in mind so: - # - # StringProperty("fieldname", "fieldvalue") - # - # should be accessed on Config/self, so: - # - # value = config.fieldvalue.value - # i.e - # config.fieldvalue = StringProperty("fieldname", "fieldvalue") - # - # Worth looking at the __setattr_ dunder method and a loop - # for how to do this. - # - # Do track the BaseProperty's that you add ready for - # assert_valid_config call. - ... + def from_env(config_dict: Dict[str, Dict[str, Any]]) -> Config: + + config = Config() + + for env_var_name, value in config_dict.items(): + + value_for_property = os.environ.get(env_var_name, None) + assert value_for_property is not None, f'Required envionrment value "{env_var_name}" could not be found.' + + if value["class"] == StringProperty: + if value["kwargs"]: + regex = value["kwargs"].get("regex") + min_len = value["kwargs"].get("min_len") + max_len = value["kwargs"].get("max_len") + else: + regex = None + min_len = None + max_len = None + + stringprop = StringProperty( + _name = value["property"], + _value = value_for_property, + regex = regex, + min_len = min_len, + max_len = max_len + ) + + prop_name = value["property"] + setattr(config, prop_name, stringprop) + config._properties_to_validate.append(stringprop) + + elif value["class"] == IntegerProperty: + if value["kwargs"]: + min_val = value["kwargs"].get("min_val") + max_val = value["kwargs"].get("max_val") + else: + min_val = None + max_val = None + + intprop = IntegerProperty( + _name = value["property"], + _value = value_for_property, + min_val = min_val, + max_val = max_val + ) + + prop_name = value["property"] + setattr(config, prop_name, intprop) + config._properties_to_validate.append(intprop) + + else: + prop_type = value["class"] + raise TypeError(f"Unsupported property type specified via 'property' field, got {prop_type}. Should be of type StringProperty or IntegerProperty") + + return config + def assert_valid_config(self): """ Assert that then Config class has the properties that provided properties. """ + for property in self._properties_to_validate: + property.type_is_valid() + property.secondary_validation() - # For each of the properties you imbided above, run - # self.type_is_valid() - # self.secondary_validation() - - + self._properties_to_validate = [] \ No newline at end of file diff --git a/dpytools/config/properties/__init__.py b/dpytools/config/properties/__init__.py index d6eca2d..f23e61e 100644 --- a/dpytools/config/properties/__init__.py +++ b/dpytools/config/properties/__init__.py @@ -1 +1,2 @@ -from .string import StringProperty \ No newline at end of file +from .string import StringProperty +from .intproperty import IntegerProperty \ No newline at end of file diff --git a/dpytools/config/properties/base.py b/dpytools/config/properties/base.py index 02dd34c..a5cfe2b 100644 --- a/dpytools/config/properties/base.py +++ b/dpytools/config/properties/base.py @@ -4,17 +4,24 @@ @dataclass class BaseProperty(metaclass=ABCMeta): - name: str - value: Any + _name: str + _value: Any - # TODO: getter - # allow someone to get the property + @property + def name(self): + return self._name - # TODO: setter - # categorically disallow anyone from - # changing a property after the class - # has been instantiated. - # Refuse to do it, and log an error. + @name.setter + def name(self, value): + raise ValueError(f"Trying to change name property to value {value} but you cannot change a property name after instantiation.") + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + raise ValueError(f"Trying to change value to {value} but you cannot change a property value after instantiation.") @abstractmethod def type_is_valid(self): diff --git a/dpytools/config/properties/intproperty.py b/dpytools/config/properties/intproperty.py new file mode 100644 index 0000000..f776738 --- /dev/null +++ b/dpytools/config/properties/intproperty.py @@ -0,0 +1,32 @@ +from typing import Optional +from dataclasses import dataclass +from .base import BaseProperty + +@dataclass +class IntegerProperty(BaseProperty): + min_val: Optional[int] + max_val: Optional[int] + + def type_is_valid(self): + """ + Validate that the property looks like + its of the correct type + """ + try: + int(self._value) + except Exception as err: + raise Exception(f"Cannot cast {self._name} value {self._value} to integer.") from err + + def secondary_validation(self): + """ + Non type based validation you might want to + run against a configuration value of this kind. + """ + if not self._value: + raise ValueError(f"Integer value for {self._name} does not exist.") + + if self.min_val and self._value < self.min_val: + raise ValueError(f"Integer value for {self._name} is lower than allowed minimum.") + + if self.max_val and self._value > self.max_val: + raise ValueError(f"Integer value for {self._name} is higher than allowed maximum.") \ No newline at end of file diff --git a/dpytools/config/properties/string.py b/dpytools/config/properties/string.py index 74aa102..e56a92f 100644 --- a/dpytools/config/properties/string.py +++ b/dpytools/config/properties/string.py @@ -1,8 +1,10 @@ from typing import Optional - +from dataclasses import dataclass from .base import BaseProperty +import re +@dataclass class StringProperty(BaseProperty): regex: Optional[str] min_len: Optional[int] @@ -14,26 +16,29 @@ def type_is_valid(self): its of the correct type """ try: - str(self.value) + str(self._value) except Exception as err: - raise Exception(f"Cannot cast {self.name} value {self.value} to string.") from err + raise Exception(f"Cannot cast {self.name} value {self._value} to string.") from err - def secondary_validation_passed(self): + def secondary_validation(self): """ Non type based validation you might want to run against a configuration value of this kind. """ - if len(self.value) == 0: + + if len(self._value) == 0: raise ValueError(f"Str value for {self.name} is an empty string") if self.regex: # TODO - confirm the value matches the regex - ... + regex_search = re.search(self.regex, self._value) + if not regex_search: + raise ValueError(f"Str value for {self.name} does not match the given regex.") if self.min_len: - # TODO - confirm the string matches of exceeds the minimum length - ... + if len(self._value) < self.min_len: + raise ValueError(f"Str value for {self.name} is shorter than minimum length {self.min_len}") if self.max_len: - # TODO - confirm the value matches or is less than the max length - ... \ No newline at end of file + if len(self._value) > self.max_len: + raise ValueError(f"Str value for {self.name} is longer than maximum length {self.max_len}") \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3f8934c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,116 @@ +from _pytest.monkeypatch import monkeypatch +import pytest + +from dpytools.config.config import Config +from dpytools.config.properties.string import StringProperty +from dpytools.config.properties.intproperty import IntegerProperty + +def test_config_loader(monkeypatch): + """ + Tests that a config object can be created and its attributes + dynamically generated from an input config dictionary with the + expected contents. + """ + + # Assigning environment variable values for config dictionary values + monkeypatch.setenv("SOME_STRING_ENV_VAR", "Some string value") + monkeypatch.setenv("SOME_URL_ENV_VAR", "https://test.com/some-url") + monkeypatch.setenv("SOME_INT_ENV_VAR", "6") + + config_dictionary = { + "SOME_STRING_ENV_VAR": { + "class": StringProperty, + "property": "name1", + "kwargs": { + "regex": "string value", + "min_len": 10 + }, + }, + "SOME_URL_ENV_VAR": { + "class": StringProperty, + "property": "name2", + "kwargs": { + "regex": "https://.*", + "max_len": 100 + }, + }, + "SOME_INT_ENV_VAR": { + "class": IntegerProperty, + "property": "name3", + "kwargs": { + "min_val": 5, + "max_val": 27 + } + }, +} + + config = Config.from_env(config_dictionary) + + # Assertions + + assert config.name1.name == "name1" + assert config.name1.value == "Some string value" + assert config.name1.min_len == 10 + assert config.name1.regex == "string value" + + assert config.name2.name == "name2" + assert config.name2.value == "https://test.com/some-url" + assert config.name2.regex == "https://.*" + assert config.name2.max_len == 100 + + assert config.name3.name == "name3" + assert config.name3.min_val == 5 + assert config.name3.max_val == 27 + + +def test_config_loader_no_values_error(): + """ + Tests that an exception will be raised when a config object + is created using the from_env() method but the environment + variable values have not been assigned (values are None). + """ + + # No environment variable values assigned in this test + + config_dictionary = { + "SOME_STRING_ENV_VAR": { + "class": StringProperty, + "property": "name1", + "kwargs": { + "min_len": 10 + }, + } +} + + with pytest.raises(Exception) as e: + + config = Config.from_env(config_dictionary) + + assert 'Required environment value "SOME_STRING_ENV_VAR" could not be found.' in str(e.value) + + +def test_config_loader_incorrect_type_error(monkeypatch): + """ + Tests that a TypeError will be raised when a config object + is created using the from_env() method but the type of an + attribute being created is not either a StringProperty or IntegerProperty. + """ + + monkeypatch.setenv("SOME_STRING_ENV_VAR", "Some string value") + + config_dictionary = { + "SOME_STRING_ENV_VAR": { + "class": int, + "property": "name1", + "kwargs": { + "min_val": 10, + + }, + } +} + + with pytest.raises(TypeError) as e: + + config = Config.from_env(config_dictionary) + + assert "Unsupported property type specified via 'property' field, got . Should be of type StringProperty or IntegerProperty" in str(e.value) \ No newline at end of file diff --git a/tests/test_intproperty.py b/tests/test_intproperty.py new file mode 100644 index 0000000..04efea6 --- /dev/null +++ b/tests/test_intproperty.py @@ -0,0 +1,104 @@ +import pytest +from dpytools.config.properties import IntegerProperty + +def test_int_property(): + """ + Tests if an integer property instance can be created + and validated with no errors. + """ + + test_property = IntegerProperty( + _name = "Test Integer property", + _value = 24, + min_val = 0, + max_val = 101 + ) + + test_property.type_is_valid() + test_property.secondary_validation() + + assert test_property.name == "Test Integer property" + assert test_property.value == 24 + assert test_property.min_val == 0 + assert test_property.max_val == 101 + + +def test_int_property_type_invalid(): + """ + Tests if an integer property with a type of value that + cannot be cast to string raises an exception. + """ + + test_property = IntegerProperty( + _name = "Test Integer Property", + _value = "Not an integer", + min_val = 0, + max_val = 101 + ) + + with pytest.raises(Exception) as e: + + test_property.type_is_valid() + + assert "Cannot cast Test Integer Property value Not an integer to integer." in str(e.value) + + +def test_int_property_empty_val(): + """ + Tests if an integer property with nothing as the value + raises the expected exception from the secondary validation. + """ + + test_property = IntegerProperty( + _name = "Test Integer Property", + _value = None, + min_val = 0, + max_val = 101 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert "Integer value for Test Integer property does not exist." in str(e.value) + + +def test_int_property_min_val(): + """ + Tests if an integer property with a value lower than the allowed minimum + raises the expected exception from the secondary validation. + """ + + test_property = IntegerProperty( + _name = "Test Integer Property", + _value = 9, + min_val = 10, + max_val = 101 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert "Integer value for Test Integer property is lower than allowed minimum." in str(e.value) + + + +def test_int_property_max_val(): + """ + Tests if an integer property with a value higher than the allowed maximum + raises the expected exception from the secondary validation. + """ + + test_property = IntegerProperty( + _name = "Test Integer Property", + _value = 102, + min_val = 0, + max_val = 101 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert "Integer value for Test Integer property is higher than allowed maximum." in str(e.value) \ No newline at end of file diff --git a/tests/test_stringproperty.py b/tests/test_stringproperty.py new file mode 100644 index 0000000..a976980 --- /dev/null +++ b/tests/test_stringproperty.py @@ -0,0 +1,111 @@ +import pytest +from dpytools.config.properties.string import StringProperty + +def test_string_property(): + """ + Tests if a string property instance can be created + and validated with no errors. + """ + + test_property = StringProperty( + _name = "Test String Property", + _value = "Test string value", + regex = "Test", + min_len = 1, + max_len = 40 + ) + + test_property.secondary_validation() + + assert test_property.name == "Test String Property" + assert test_property.value == "Test string value" + assert test_property.regex == "Test" + assert test_property.min_len == 1 + assert test_property.max_len == 40 + + +def test_string_property_empty_val(): + """ + Tests if a string property with an empty string as the value + raises the expected exception from the secondary validation. + """ + + test_property = StringProperty( + _name = "Test String Property", + _value = "", + regex = "Test regex", + min_len = 1, + max_len = 40 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert ( + f"Str value for Test String Property is an empty string") in str(e.value) + + +def test_string_property_min_len(): + """ + Tests if a string property instance with a non-matching minimum + length string raises the expected error from secondary validation. + """ + + test_property = StringProperty( + _name = "Test String Property", + _value = "Test string value", + regex = "Test regex", + min_len = 50, + max_len = 51 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert "Str value for Test String Property is shorter than minimum length 50" in str(e.value) + + +def test_string_property_max_len(): + """ + Tests if a string property instance with a non-matching maximum + length string raises the expected error from secondary validation. + """ + + test_property = StringProperty( + _name = "Test String Property", + _value = "Test string value", + regex = "Test regex", + min_len = 1, + max_len = 2 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert ( + "Str value for Test String Property is longer than maximum length 2") in str(e.value) + + +def test_string_property_regex_no_match(): + """ + Tests if a string property instance with a non-matching regex/value + raises the expected error from secondary validation. + """ + + test_property = StringProperty( + _name = "Test String Property", + _value = "Test string value", + regex = "Test regex", + min_len = 1, + max_len = 50 + ) + + with pytest.raises(ValueError) as e: + + test_property.secondary_validation() + + assert ( + "Str value for Test String Property does not match the given regex.") in str(e.value) \ No newline at end of file