Skip to content

Commit 0f38bf0

Browse files
authored
#21 Config class
Config class
2 parents 3d57b8b + 1b1dcec commit 0f38bf0

File tree

9 files changed

+467
-46
lines changed

9 files changed

+467
-46
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"python.testing.pytestArgs": [
3-
"tests"
3+
"tests",
4+
"dpytools"
45
],
56
"python.testing.unittestEnabled": false,
67
"python.testing.pytestEnabled": true

dpytools/config/config.py

+69-25
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,82 @@
1-
from typing import Dict
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Any, Dict, List
25

36
from .properties.base import BaseProperty
7+
from .properties.intproperty import IntegerProperty
8+
from .properties.string import StringProperty
49

510
class Config:
11+
12+
def __init__(self):
13+
self._properties_to_validate: List[BaseProperty] = []
614

715
@staticmethod
8-
def from_env(config_dict: Dict[str, BaseProperty]):
9-
# TODO = read in and populate property classes as
10-
# per the example in the main readme.
11-
# You need to populate with dot notation in mind so:
12-
#
13-
# StringProperty("fieldname", "fieldvalue")
14-
#
15-
# should be accessed on Config/self, so:
16-
#
17-
# value = config.fieldvalue.value
18-
# i.e
19-
# config.fieldvalue = StringProperty("fieldname", "fieldvalue")
20-
#
21-
# Worth looking at the __setattr_ dunder method and a loop
22-
# for how to do this.
23-
#
24-
# Do track the BaseProperty's that you add ready for
25-
# assert_valid_config call.
26-
...
16+
def from_env(config_dict: Dict[str, Dict[str, Any]]) -> Config:
17+
18+
config = Config()
19+
20+
for env_var_name, value in config_dict.items():
21+
22+
value_for_property = os.environ.get(env_var_name, None)
23+
assert value_for_property is not None, f'Required envionrment value "{env_var_name}" could not be found.'
24+
25+
if value["class"] == StringProperty:
26+
if value["kwargs"]:
27+
regex = value["kwargs"].get("regex")
28+
min_len = value["kwargs"].get("min_len")
29+
max_len = value["kwargs"].get("max_len")
30+
else:
31+
regex = None
32+
min_len = None
33+
max_len = None
34+
35+
stringprop = StringProperty(
36+
_name = value["property"],
37+
_value = value_for_property,
38+
regex = regex,
39+
min_len = min_len,
40+
max_len = max_len
41+
)
42+
43+
prop_name = value["property"]
44+
setattr(config, prop_name, stringprop)
45+
config._properties_to_validate.append(stringprop)
46+
47+
elif value["class"] == IntegerProperty:
48+
if value["kwargs"]:
49+
min_val = value["kwargs"].get("min_val")
50+
max_val = value["kwargs"].get("max_val")
51+
else:
52+
min_val = None
53+
max_val = None
54+
55+
intprop = IntegerProperty(
56+
_name = value["property"],
57+
_value = value_for_property,
58+
min_val = min_val,
59+
max_val = max_val
60+
)
61+
62+
prop_name = value["property"]
63+
setattr(config, prop_name, intprop)
64+
config._properties_to_validate.append(intprop)
65+
66+
else:
67+
prop_type = value["class"]
68+
raise TypeError(f"Unsupported property type specified via 'property' field, got {prop_type}. Should be of type StringProperty or IntegerProperty")
69+
70+
return config
71+
2772

2873
def assert_valid_config(self):
2974
"""
3075
Assert that then Config class has the properties that
3176
provided properties.
3277
"""
78+
for property in self._properties_to_validate:
79+
property.type_is_valid()
80+
property.secondary_validation()
3381

34-
# For each of the properties you imbided above, run
35-
# self.type_is_valid()
36-
# self.secondary_validation()
37-
38-
82+
self._properties_to_validate = []
+2-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
from .string import StringProperty
1+
from .string import StringProperty
2+
from .intproperty import IntegerProperty

dpytools/config/properties/base.py

+16-9
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@
44

55
@dataclass
66
class BaseProperty(metaclass=ABCMeta):
7-
name: str
8-
value: Any
7+
_name: str
8+
_value: Any
99

10-
# TODO: getter
11-
# allow someone to get the property
10+
@property
11+
def name(self):
12+
return self._name
1213

13-
# TODO: setter
14-
# categorically disallow anyone from
15-
# changing a property after the class
16-
# has been instantiated.
17-
# Refuse to do it, and log an error.
14+
@name.setter
15+
def name(self, value):
16+
raise ValueError(f"Trying to change name property to value {value} but you cannot change a property name after instantiation.")
17+
18+
@property
19+
def value(self):
20+
return self._value
21+
22+
@value.setter
23+
def value(self, value):
24+
raise ValueError(f"Trying to change value to {value} but you cannot change a property value after instantiation.")
1825

1926
@abstractmethod
2027
def type_is_valid(self):
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from typing import Optional
2+
from dataclasses import dataclass
3+
from .base import BaseProperty
4+
5+
@dataclass
6+
class IntegerProperty(BaseProperty):
7+
min_val: Optional[int]
8+
max_val: Optional[int]
9+
10+
def type_is_valid(self):
11+
"""
12+
Validate that the property looks like
13+
its of the correct type
14+
"""
15+
try:
16+
int(self._value)
17+
except Exception as err:
18+
raise Exception(f"Cannot cast {self._name} value {self._value} to integer.") from err
19+
20+
def secondary_validation(self):
21+
"""
22+
Non type based validation you might want to
23+
run against a configuration value of this kind.
24+
"""
25+
if not self._value:
26+
raise ValueError(f"Integer value for {self._name} does not exist.")
27+
28+
if self.min_val and self._value < self.min_val:
29+
raise ValueError(f"Integer value for {self._name} is lower than allowed minimum.")
30+
31+
if self.max_val and self._value > self.max_val:
32+
raise ValueError(f"Integer value for {self._name} is higher than allowed maximum.")

dpytools/config/properties/string.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import Optional
2-
2+
from dataclasses import dataclass
33
from .base import BaseProperty
44

5+
import re
56

7+
@dataclass
68
class StringProperty(BaseProperty):
79
regex: Optional[str]
810
min_len: Optional[int]
@@ -14,26 +16,29 @@ def type_is_valid(self):
1416
its of the correct type
1517
"""
1618
try:
17-
str(self.value)
19+
str(self._value)
1820
except Exception as err:
19-
raise Exception(f"Cannot cast {self.name} value {self.value} to string.") from err
21+
raise Exception(f"Cannot cast {self.name} value {self._value} to string.") from err
2022

21-
def secondary_validation_passed(self):
23+
def secondary_validation(self):
2224
"""
2325
Non type based validation you might want to
2426
run against a configuration value of this kind.
2527
"""
26-
if len(self.value) == 0:
28+
29+
if len(self._value) == 0:
2730
raise ValueError(f"Str value for {self.name} is an empty string")
2831

2932
if self.regex:
3033
# TODO - confirm the value matches the regex
31-
...
34+
regex_search = re.search(self.regex, self._value)
35+
if not regex_search:
36+
raise ValueError(f"Str value for {self.name} does not match the given regex.")
3237

3338
if self.min_len:
34-
# TODO - confirm the string matches of exceeds the minimum length
35-
...
39+
if len(self._value) < self.min_len:
40+
raise ValueError(f"Str value for {self.name} is shorter than minimum length {self.min_len}")
3641

3742
if self.max_len:
38-
# TODO - confirm the value matches or is less than the max length
39-
...
43+
if len(self._value) > self.max_len:
44+
raise ValueError(f"Str value for {self.name} is longer than maximum length {self.max_len}")

tests/test_config.py

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from _pytest.monkeypatch import monkeypatch
2+
import pytest
3+
4+
from dpytools.config.config import Config
5+
from dpytools.config.properties.string import StringProperty
6+
from dpytools.config.properties.intproperty import IntegerProperty
7+
8+
def test_config_loader(monkeypatch):
9+
"""
10+
Tests that a config object can be created and its attributes
11+
dynamically generated from an input config dictionary with the
12+
expected contents.
13+
"""
14+
15+
# Assigning environment variable values for config dictionary values
16+
monkeypatch.setenv("SOME_STRING_ENV_VAR", "Some string value")
17+
monkeypatch.setenv("SOME_URL_ENV_VAR", "https://test.com/some-url")
18+
monkeypatch.setenv("SOME_INT_ENV_VAR", "6")
19+
20+
config_dictionary = {
21+
"SOME_STRING_ENV_VAR": {
22+
"class": StringProperty,
23+
"property": "name1",
24+
"kwargs": {
25+
"regex": "string value",
26+
"min_len": 10
27+
},
28+
},
29+
"SOME_URL_ENV_VAR": {
30+
"class": StringProperty,
31+
"property": "name2",
32+
"kwargs": {
33+
"regex": "https://.*",
34+
"max_len": 100
35+
},
36+
},
37+
"SOME_INT_ENV_VAR": {
38+
"class": IntegerProperty,
39+
"property": "name3",
40+
"kwargs": {
41+
"min_val": 5,
42+
"max_val": 27
43+
}
44+
},
45+
}
46+
47+
config = Config.from_env(config_dictionary)
48+
49+
# Assertions
50+
51+
assert config.name1.name == "name1"
52+
assert config.name1.value == "Some string value"
53+
assert config.name1.min_len == 10
54+
assert config.name1.regex == "string value"
55+
56+
assert config.name2.name == "name2"
57+
assert config.name2.value == "https://test.com/some-url"
58+
assert config.name2.regex == "https://.*"
59+
assert config.name2.max_len == 100
60+
61+
assert config.name3.name == "name3"
62+
assert config.name3.min_val == 5
63+
assert config.name3.max_val == 27
64+
65+
66+
def test_config_loader_no_values_error():
67+
"""
68+
Tests that an exception will be raised when a config object
69+
is created using the from_env() method but the environment
70+
variable values have not been assigned (values are None).
71+
"""
72+
73+
# No environment variable values assigned in this test
74+
75+
config_dictionary = {
76+
"SOME_STRING_ENV_VAR": {
77+
"class": StringProperty,
78+
"property": "name1",
79+
"kwargs": {
80+
"min_len": 10
81+
},
82+
}
83+
}
84+
85+
with pytest.raises(Exception) as e:
86+
87+
config = Config.from_env(config_dictionary)
88+
89+
assert 'Required environment value "SOME_STRING_ENV_VAR" could not be found.' in str(e.value)
90+
91+
92+
def test_config_loader_incorrect_type_error(monkeypatch):
93+
"""
94+
Tests that a TypeError will be raised when a config object
95+
is created using the from_env() method but the type of an
96+
attribute being created is not either a StringProperty or IntegerProperty.
97+
"""
98+
99+
monkeypatch.setenv("SOME_STRING_ENV_VAR", "Some string value")
100+
101+
config_dictionary = {
102+
"SOME_STRING_ENV_VAR": {
103+
"class": int,
104+
"property": "name1",
105+
"kwargs": {
106+
"min_val": 10,
107+
108+
},
109+
}
110+
}
111+
112+
with pytest.raises(TypeError) as e:
113+
114+
config = Config.from_env(config_dictionary)
115+
116+
assert "Unsupported property type specified via 'property' field, got <class 'int'>. Should be of type StringProperty or IntegerProperty" in str(e.value)

0 commit comments

Comments
 (0)