Repository: janehmueller/python-json-config

Issue:
Better error reporting
There is currently no way to specify what exactly failed while parsing the config. In the optimal case, every validator should be able to define a message to show the user what exactly went wrong.

For example: `"There was an error while validating the host field, 127.0.0." is not a valid IP address."`

Discussion:

Patch:
diff --git a/README.md b/README.md
index a4bb335..2868625 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ builder.validate_field_value('server.ip', is_ipv4_address)
 builder.validate_field_value('server.port', is_unreserved_port)
 builder.validate_field_value('jwt.access_token_expires', is_timedelta)
 
+# you can also return custom error messages in your lambdas
+builder.validate_field_value('server.ip', lambda ip: (ip != "0.0.0.0", "IP is unroutable."))
+
 # parse a timedelta (e.g., Jun 1 2005) into a datetime object
 builder.transform_field_value('important_date', lambda date: datetime.strptime(date, '%b %d %Y'))
 builder.transform_field_value('jwt.access_token_expires', to_timedelta)
@@ -39,7 +42,10 @@ config = builder.parse_config('path/to/config.json')
 
 # access config values
 port = config.server.port
-assert port > 1024
+assert port > 1023
+
+ip = config.server.ip
+assert ip != "0.0.0.0"
 
 important_date = config.important_date
 assert isinstance(important_date, datetime)
diff --git a/python_json_config/config_builder.py b/python_json_config/config_builder.py
index ba76f6d..2e219b3 100644
--- a/python_json_config/config_builder.py
+++ b/python_json_config/config_builder.py
@@ -40,7 +40,14 @@ class ConfigBuilder(object):
     def __validate_field_values(self):
         for field_name, validation_function in self.__validation_functions.items():
             value = self.__config.get(field_name)
-            assert validation_function(value), f'Config field "{field_name}" contains invalid value "{value}"'
+            validation_result = validation_function(value)
+            error_message = f'Error validating field "{field_name}" with value "{value}"'
+
+            if isinstance(validation_result, tuple):
+                result, validation_error = validation_result
+                assert result, f"{error_message}: {validation_error}"
+            else:
+                assert validation_result, error_message
 
     def __transform_field_values(self):
         for field_name, transformation_function in self.__transformation_functions.items():
diff --git a/python_json_config/validators/generic_validators.py b/python_json_config/validators/generic_validators.py
index 4914b03..8fb28fa 100644
--- a/python_json_config/validators/generic_validators.py
+++ b/python_json_config/validators/generic_validators.py
@@ -1,4 +1,7 @@
-def is_timedelta(value: str) -> bool:
+from typing import Union, Tuple
+
+
+def is_timedelta(value: str) -> Union[bool, Tuple[bool, str]]:
     """
     Tests if the given value is a valid timedelta specification.
     The timedelta needs to be specified as a colon separated string, e.g.: "0:0:23:00:00"
@@ -13,12 +16,12 @@ def is_timedelta(value: str) -> bool:
     """
     split_values = value.split(':')
     if len(split_values) > 5:
-        return False
+        return False, "Timedelta contains more than 5 elements."
 
     try:
         [int(element) for element in split_values]
     except ValueError:
-        return False
+        return False, "Timedelta contains non-integer elements."
 
     return True
 
@@ -29,7 +32,7 @@ def is_valid_choice(options):
     :return: A functions that takes a value and tests if it is within the specified choices. This function returns True
              if the value in the config is in the passed options.
     """
-    def validator(value):
-        return value in options
+    def validator(value) -> Tuple[bool, str]:
+        return value in options, f"Value is not contained in the options {options}"
 
     return validator
diff --git a/python_json_config/validators/network_validators.py b/python_json_config/validators/network_validators.py
index 6579ccf..eb3ca8e 100644
--- a/python_json_config/validators/network_validators.py
+++ b/python_json_config/validators/network_validators.py
@@ -1,7 +1,8 @@
 import socket
+from typing import Union, Tuple
 
 
-def is_ipv4_address(ip_address: str):
+def is_ipv4_address(ip_address: str) -> Union[bool, Tuple[bool, str]]:
     """
     Tests if the given value is a valid IPv4 address
     :param ip_address: The ip address that is tested.
@@ -11,13 +12,13 @@ def is_ipv4_address(ip_address: str):
         socket.inet_aton(ip_address)
         return True
     except socket.error:
-        return False
+        return False, "IP address is not a valid IPv4 address."
 
 
-def is_unreserved_port(port: int):
+def is_unreserved_port(port: int) -> Union[bool, Tuple[bool, str]]:
     """
     Tests if the given port is an unreserved port.
     :param port: The port that is tested.
     :return: True if the port is not reserved otherwise False.
     """
-    return port > 1023
+    return port > 1023, "Port is reserved."


Test patch:
diff --git a/tests/config_builder_test.py b/tests/config_builder_test.py
index e4e516e..ec267b2 100644
--- a/tests/config_builder_test.py
+++ b/tests/config_builder_test.py
@@ -1,6 +1,7 @@
 from unittest import TestCase
 
 from python_json_config import ConfigBuilder
+from python_json_config.validators import is_unreserved_port
 
 
 class ConfigBuilderTest(TestCase):
@@ -52,7 +53,8 @@ class ConfigBuilderTest(TestCase):
         builder.validate_field_type('server.port', int)
         builder.parse_config(self.path)
         builder.validate_field_type('server.host', int)
-        with self.assertRaises(AssertionError): builder.parse_config(self.path)
+        with self.assertRaises(AssertionError):
+            builder.parse_config(self.path)
 
     def test_value_validation(self):
         builder = ConfigBuilder()
@@ -60,7 +62,8 @@ class ConfigBuilderTest(TestCase):
         builder.validate_field_value('server.port', lambda x: x < 10000)
         builder.parse_config(self.path)
         builder.validate_field_value('cache.ttl', lambda x: x > 200)
-        with self.assertRaises(AssertionError): builder.parse_config(self.path)
+        with self.assertRaises(AssertionError):
+            builder.parse_config(self.path)
 
     def test_value_transformation(self):
         builder = ConfigBuilder()
@@ -74,4 +77,9 @@ class ConfigBuilderTest(TestCase):
         self.assertTrue(config.server.debug_mode)
         self.assertEqual(config.server.port, 5004)
 
-
+    def test_value_validation_error_messages(self):
+        builder = ConfigBuilder()
+        builder.validate_field_value('server.debug_mode', lambda x: (x, "test error"))
+        with self.assertRaises(AssertionError,
+                               msg='Error validating field "server.debug_mode" with value "False": test error'):
+            builder.parse_config(self.path)
diff --git a/tests/config_node_test.py b/tests/config_node_test.py
index 0dc62b6..27f0e67 100644
--- a/tests/config_node_test.py
+++ b/tests/config_node_test.py
@@ -33,7 +33,8 @@ class ConfigNodeTest(TestCase):
     def test_get(self):
         node = ConfigNode(self.config_dict)
         self.assertEqual(node.key1, 1)
-        with self.assertRaises(AttributeError): node.nokey
+        with self.assertRaises(AttributeError):
+            node.nokey
 
         self.assertEqual(node.get('key1'), 1)
         with self.assertRaises(AttributeError): node.get('nokey')
@@ -50,7 +51,8 @@ class ConfigNodeTest(TestCase):
         self.assertIsInstance(node.key1, ConfigNode)
         self.assertEqual(node.key1.newkey, 1)
 
-        with self.assertRaises(AttributeError): node.key3
+        with self.assertRaises(AttributeError):
+            node.key3
         node.update('key3', 'asd')
         self.assertEqual(node.key3, 'asd')
 
diff --git a/tests/validators/generic_validators_test.py b/tests/validators/generic_validators_test.py
index ca3a93c..0285750 100644
--- a/tests/validators/generic_validators_test.py
+++ b/tests/validators/generic_validators_test.py
@@ -6,24 +6,27 @@ from python_json_config.validators import is_timedelta, is_valid_choice
 class GenericValidatorsTest(TestCase):
 
     def test_is_timedelta(self):
-        valid_timedeltas = ["1:3:24:30:23", "0:0:1", "0:0:0:0:1", "01:02:02:03:04"]
-        invalid_timedeltas = ["1:3:24:30:23:45", "01:a:02:03:04"]
+        valid_timedeltas = ['1:3:24:30:23', '0:0:1', '0:0:0:0:1', '01:02:02:03:04']
+        invalid_timedeltas = ['1:3:24:30:23:45', '01:a:02:03:04']
 
         for timedelta in valid_timedeltas:
             self.assertTrue(is_timedelta(timedelta))
 
-        for timedelta in invalid_timedeltas:
-            self.assertFalse(is_timedelta(timedelta))
+        self.assertEqual(is_timedelta(invalid_timedeltas[0]), (False, 'Timedelta contains more than 5 elements.'))
+        self.assertEqual(is_timedelta(invalid_timedeltas[1]), (False, 'Timedelta contains non-integer elements.'))
+
 
     def test_is_valid_choice(self):
-        list_validator = is_valid_choice([1, 2, "3"])
+        list_options = [1, 2, '3']
+        list_validator = is_valid_choice(list_options)
         self.assertTrue(list_validator(1))
-        self.assertFalse(list_validator(3))
-        self.assertTrue(list_validator("3"))
-        self.assertFalse(list_validator(4))
+        self.assertEqual(list_validator(3), (False, f'Value is not contained in the options {list_options}'))
+        self.assertTrue(list_validator('3'))
+        self.assertEqual(list_validator(4), (False, f'Value is not contained in the options {list_options}'))
 
-        dict_validator = is_valid_choice({1: "2", "3": 4})
+        dict_options = {1: '2', '3': 4}
+        dict_validator = is_valid_choice(dict_options)
         self.assertTrue(dict_validator(1))
-        self.assertFalse(dict_validator("2"))
-        self.assertTrue(dict_validator("3"))
-        self.assertFalse(dict_validator(4))
+        self.assertEqual(dict_validator('2'), (False, f'Value is not contained in the options {dict_options}'))
+        self.assertTrue(dict_validator('3'))
+        self.assertEqual(dict_validator(4), (False, f'Value is not contained in the options {dict_options}'))
diff --git a/tests/validators/network_validators_test.py b/tests/validators/network_validators_test.py
index 575e690..df6be47 100644
--- a/tests/validators/network_validators_test.py
+++ b/tests/validators/network_validators_test.py
@@ -6,19 +6,19 @@ from python_json_config.validators import is_ipv4_address, is_unreserved_port
 class NetworkValidatorsTest(TestCase):
 
     def test_is_ipv4_address(self):
-        valid_ips = ["127.0.0.1", "8.8.8.8", "127.1", "8.526344"]
-        invalid_ips = ["327.0.0.1", "8.8.8.8.8", "127.-1", "256.526344"]
+        valid_ips = ['127.0.0.1', '8.8.8.8', '127.1', '8.526344']
+        invalid_ips = ['327.0.0.1', '8.8.8.8.8', '127.-1', '256.526344']
 
         for address in valid_ips:
             self.assertTrue(is_ipv4_address(address))
 
         for address in invalid_ips:
-            self.assertFalse(is_ipv4_address(address))
+            self.assertEqual(is_ipv4_address(address), (False, 'IP address is not a valid IPv4 address.'))
 
     def test_is_unreserved_port(self):
-        self.assertFalse(is_unreserved_port(1))
-        self.assertFalse(is_unreserved_port(-1))
-        self.assertFalse(is_unreserved_port(22))
-        self.assertFalse(is_unreserved_port(1023))
+        self.assertEqual(is_unreserved_port(1), (False, 'Port is reserved.'))
+        self.assertEqual(is_unreserved_port(-1), (False, 'Port is reserved.'))
+        self.assertEqual(is_unreserved_port(22), (False, 'Port is reserved.'))
+        self.assertEqual(is_unreserved_port(1023), (False, 'Port is reserved.'))
         self.assertTrue(is_unreserved_port(1024))
         self.assertTrue(is_unreserved_port(14302))