Repository: janehmueller/python-json-config

Issue:
Add new keys to the config
It needs to be possible to attach new keys to config during runtime.

So for example:
`config['foo.bar'] = 'xyz'` and/or `config.foo.bar = 'xyz'` or `config.add('foo.bar', 'xyz')`

Discussion:

Patch:
diff --git a/README.md b/README.md
index 87bbe08..41490a2 100644
--- a/README.md
+++ b/README.md
@@ -95,3 +95,20 @@ assert isinstance(important_date, datetime)
 jwt_access_token_expires = config.jwt.access_token_expires
 assert isinstance(jwt_access_token_expires, timedelta)
 ```
+
+## Change config values
+```
+config = ConfigBuilder().parse_config({"server.port": 1024})
+
+config.add("server.host", "localhost")
+assert config.server.host == "localhost"
+
+config.add("cache", "redis")
+assert config.cache == "redis"
+
+config.update("server.port", 1025)
+assert config.server.port == 1025
+
+config.update("server.user", "user", upsert=True)
+assert config.server.user == "user"
+```
diff --git a/python_json_config/config_node.py b/python_json_config/config_node.py
index 028e807..8684037 100644
--- a/python_json_config/config_node.py
+++ b/python_json_config/config_node.py
@@ -1,3 +1,4 @@
+import warnings
 from typing import List, Union, Tuple
 
 from .utils import normalize_path
@@ -84,10 +85,34 @@ class ConfigNode(object):
             if key in self.optional_fields or (not self.strict_access and key not in self.required_fields):
                 return None
             else:
-                print_path = '.'.join(self.__path) + ('.' if len(self.__path) > 0 else '')
-                raise AttributeError(f'No value exists for key "{print_path}{key}"') from exception
+                raise AttributeError(f'No value exists for key "{self.__path_for_key(key)}"') from exception
 
-    def update(self, path: Union[str, List[str]], value) -> None:
+    def add(self, path: Union[str, List[str]], value, overwrite: bool = True):
+        """
+        Add a new field to the config.
+        :param path: The name of the field. Can be either a string with '.' as delimiter of the nesting levels or a list
+                     of keys with each element being one nesting level.
+                     E.g., the string 'key1.key2' and list ['key1', 'key2'] reference the same config element.
+        :param value: The value that will be inserted. If this value is a dictionary it is transformed into
+                      a ConfigNode.
+        :param overwrite: If True, the value will be inserted if it already exists. Otherwise, a warning is printed.
+        """
+        path = normalize_path(path)
+        key = path[0]
+        if len(path) == 1:
+            if isinstance(value, dict):
+                self.__node_dict[key] = ConfigNode(value, path=self.__path + [key])
+            else:
+                if key in self.__node_dict and not overwrite:
+                    warnings.warn(RuntimeWarning(f'Overwriting already existing key {self.__path_for_key(key)} '
+                                                 f'(old value: "{self.__node_dict[key]}", new value: "{value}")'))
+                self.__node_dict[key] = value
+        else:
+            if key not in self.__node_dict:
+                self.__node_dict[key] = ConfigNode({}, path=self.__path + [key])
+            self.get(key).add(path=path[1:], value=value, overwrite=overwrite)
+
+    def update(self, path: Union[str, List[str]], value, upsert: bool = True) -> None:
         """
         Update field in the config.
         :param path: The name of the field. Can be either a string with '.' as delimiter of the nesting levels or a list
@@ -95,16 +120,20 @@ class ConfigNode(object):
                      E.g., the string 'key1.key2' and list ['key1', 'key2'] reference the same config element.
         :param value: The value that should replace the old value. If this value is a dictionary it is transformed into
                       a ConfigNode.
+        :param upsert: If True, the value will be inserted if it doesn't exist. Otherwise, an exception is raised.
         """
         path = normalize_path(path)
         key = path[0]
         if len(path) == 1:
+            if key not in self.__node_dict and not upsert:
+                raise RuntimeError(f"Updating not existing key {self.__path_for_key(key)}. To insert non existing keys"
+                                   f"set upsert=True.")
             if isinstance(value, dict):
                 self.__node_dict[key] = ConfigNode(value, path=self.__path + [key])
             else:
                 self.__node_dict[key] = value
         else:
-            self.get(key).update(path[1:], value)
+            self.get(key).update(path=path[1:], value=value, upsert=upsert)
 
     def __contains__(self, item: Union[str, List[str]]) -> bool:
         """
@@ -127,6 +156,14 @@ class ConfigNode(object):
 
     __repr__ = __str__
 
+    @property
+    def __path_str(self):
+        return ".".join(self.__path)
+
+    def __path_for_key(self, key: str):
+        print_path = self.__path_str + '.' * bool(self.__path)
+        return print_path + key
+
     def __getstate__(self):
         """
         This method is needed to enable pickling since this class overwrites __getattr__.


Test patch:
diff --git a/tests/config_node_test.py b/tests/config_node_test.py
index 5210470..e415a7e 100644
--- a/tests/config_node_test.py
+++ b/tests/config_node_test.py
@@ -1,4 +1,5 @@
 import pickle
+import warnings
 from unittest import TestCase
 
 from python_json_config.config_node import ConfigNode
@@ -41,8 +42,38 @@ class ConfigNodeTest(TestCase):
 
         self.assertEqual(node.get('key2.key3'), 3)
 
+    def test_add(self):
+        node = ConfigNode({'key1': 1})
+        self.assertEqual(node.key1, 1)
+
+        with self.assertRaises(AttributeError):
+            node.key2
+        node.add('key2', 2)
+        self.assertEqual(node.key2, 2)
+
+        with self.assertRaises(AttributeError):
+            node.key3.key4
+        node.add('key3.key4', "test")
+        self.assertEqual(node.key3.key4, "test")
+
+    def test_add_overwrite(self):
+        node = ConfigNode({'key1': 1})
+        self.assertEqual(node.key1, 1)
+
+        with warnings.catch_warnings(record=True) as warning_log:
+            self.assertEqual(len(warning_log), 0)
+
+            node.add('key1', 2, overwrite=False)
+            self.assertEqual(len(warning_log), 1)
+            self.assertEqual(node.key1, 2)
+
+            node.add('key1', 3)
+            self.assertEqual(len(warning_log), 1)
+            self.assertEqual(node.key1, 3)
+
     def test_update(self):
         node = ConfigNode(self.config_dict)
+
         self.assertEqual(node.key1, 1)
         node.update('key1', 2)
         self.assertEqual(node.key1, 2)
@@ -51,18 +82,26 @@ class ConfigNodeTest(TestCase):
         self.assertIsInstance(node.key1, ConfigNode)
         self.assertEqual(node.key1.newkey, 1)
 
-        with self.assertRaises(AttributeError):
-            node.key3
-        node.update('key3', 'asd')
-        self.assertEqual(node.key3, 'asd')
-
         self.assertIsInstance(node.key2.key4, ConfigNode)
         node.update('key2.key4', 1337)
         self.assertEqual(node.key2.key4, 1337)
 
+    def test_update_upsert(self):
+        node = ConfigNode({'key1': 1})
+
+        with self.assertRaises(AttributeError):
+            node.key2
+
+        with self.assertRaises(RuntimeError):
+            node.update('key2', 'asd', upsert=False)
+
+        node.update('key2', 'asd', upsert=True)
+        self.assertEqual(node.key2, 'asd')
+
     def test_string(self):
         node = ConfigNode({1: 2, 3: 4})
-        node_str = 'ConfigNode(path=[], values={1: 2, 3: 4}, strict_access=True, required_fields=[], optional_fields=[])'
+        node_str = 'ConfigNode(path=[], values={1: 2, 3: 4}, strict_access=True, required_fields=[], optional_fields' \
+                   '=[])'
         self.assertEqual(str(node), node_str)
 
     def test_pickle(self):
@@ -81,15 +120,18 @@ class ConfigNodeTest(TestCase):
         self.assertFalse('key2.key4.key6' in config)
 
     def test_strict_access(self):
-        config = ConfigNode(self.config_dict, strict_access=True, optional_fields=['nokey', ['key2', 'nokey'], 'key2.nokey2'])
+        config = ConfigNode(self.config_dict,
+                            strict_access=True,
+                            optional_fields=['nokey', ['key2', 'nokey'], 'key2.nokey2'])
         self.assertIsNone(config.nokey)
         self.assertIsNone(config.key2.nokey)
         self.assertIsNone(config.key2.nokey2)
         with self.assertRaises(AttributeError):
             config.key2.nokey3
 
-
-        config = ConfigNode(self.config_dict, strict_access=False, required_fields=['nokey', ['key2', 'nokey'], 'key2.nokey2'])
+        config = ConfigNode(self.config_dict,
+                            strict_access=False,
+                            required_fields=['nokey', ['key2', 'nokey'], 'key2.nokey2'])
         self.assertIsNone(config.key2.nokey3)
         with self.assertRaises(AttributeError):
             self.assertIsNone(config.nokey)
@@ -97,4 +139,3 @@ class ConfigNodeTest(TestCase):
             self.assertIsNone(config.key2.nokey)
         with self.assertRaises(AttributeError):
             self.assertIsNone(config.key2.nokey2)
-