Repository: janehmueller/python-json-config

Issue:
Enable config iterations
It would be great if it were possible to iterate over the configuration.

For example:
```python
for key, value in config.items():
    print(f'{key} --> {value}')
```

Discussion:

Patch:
diff --git a/python_json_config/config_node.py b/python_json_config/config_node.py
index 1195954..4cf2627 100644
--- a/python_json_config/config_node.py
+++ b/python_json_config/config_node.py
@@ -46,20 +46,9 @@ class ConfigNode(object):
 
         self.__node_dict = node_dict
 
-    def __getattr__(self, item: str):
-        """
-        Enables access of config elements via dots (i.e. config.field1 instead of config["field1"]). This method wraps
-        the get method.
-        If strict access is defined or the field is a required field, an AttributeError is thrown if the referenced
-        field does not exist. Otherwise, i.e. non-strict access is defined or the field is an optional field, None is
-        returned in the field does not exist.
-        :raises AttributeError: Raised when a non-existing field is accessed when either strict access is defined or the
-                                field is a required field.
-        :param item: the field that is accessed.
-        :return: The value of the referenced field.
-        """
-        return self.get(item)
-
+    """
+    CRUD methods to access and modify the config contents.
+    """
     def get(self, path: Union[str, List[str]]):
         """
         Retrieve a value in the config.
@@ -135,6 +124,59 @@ class ConfigNode(object):
         else:
             self.get(key).update(path=path[1:], value=value, upsert=upsert)
 
+    """
+    Iteration functions
+    """
+    def keys(self):
+        for key, value in self.__node_dict.items():
+            if isinstance(value, ConfigNode):
+                yield from value.keys()
+            else:
+                yield self.__path_for_key(key)
+
+    def values(self):
+        for key, value in self.__node_dict.items():
+            if isinstance(value, ConfigNode):
+                yield from value.values()
+            else:
+                yield value
+
+    def items(self):
+        for key, value in self.__node_dict.items():
+            if isinstance(value, ConfigNode):
+                yield from value.items()
+            else:
+                yield self.__path_for_key(key), value
+
+    def to_dict(self):
+        config_dict = {}
+        for key, value in self.__node_dict.items():
+            if isinstance(value, ConfigNode):
+                config_dict[key] = value.to_dict()
+            else:
+                config_dict[key] = value
+        return config_dict
+
+    """
+    Built-in python functions
+    """
+    def __iter__(self):
+        yield from self.keys()
+
+    def __getattr__(self, item: str):
+        """
+        Enables access of config elements via dots (i.e. config.field1 instead of config["field1"]). This method wraps
+        the get method.
+        If strict access is defined or the field is a required field, an AttributeError is thrown if the referenced
+        field does not exist. Otherwise, i.e. non-strict access is defined or the field is an optional field, None is
+        returned in the field does not exist.
+        :raises AttributeError: Raised when a non-existing field is accessed when either strict access is defined or the
+                                field is a required field.
+        :param item: the field that is accessed.
+        :return: The value of the referenced field.
+        """
+        return self.get(item)
+
     def __contains__(self, item: Union[str, List[str]]) -> bool:
         """
         Test if a field exists in the config and is not None (result in the case of a non-existing optional field).
@@ -156,14 +198,6 @@ 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__.
@@ -176,6 +210,17 @@ class ConfigNode(object):
         """
         vars(self).update(state)
 
+    """
+    Private functions used in this class (e.g., for utility).
+    """
+    @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 __parse_field_settings(self, field_names: List[Union[str, List[str]]]) -> Tuple[List[str], List[List[str]]]:
         """
         Parses settings (required or optional) for fields and subfields of this node.


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 787665f..3f566de 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,10 +1,13 @@
-from .config_node_test import ConfigNodeTest
+from .config_node import ConfigNodeSerializationTest, ConfigNodeIteratorsTest, ConfigNodeCrudTest, ConfigNodeUtilsTest
 from .config_builder_test import ConfigBuilderTest
 from .validators import NetworkValidatorsTest, GenericValidatorsTest
 from .transformers import GenericTransformersTest
 
 __all__ = [
-    'ConfigNodeTest',
+    'ConfigNodeIteratorsTest',
+    'ConfigNodeCrudTest',
+    'ConfigNodeSerializationTest',
+    'ConfigNodeUtilsTest',
     'ConfigBuilderTest',
     'NetworkValidatorsTest',
     'GenericValidatorsTest',
diff --git a/tests/config_node/__init__.py b/tests/config_node/__init__.py
new file mode 100644
index 0000000..63b60f0
--- /dev/null
+++ b/tests/config_node/__init__.py
@@ -0,0 +1,11 @@
+from .test_crud import ConfigNodeCrudTest
+from .test_iterators import ConfigNodeIteratorsTest
+from .test_serialization import ConfigNodeSerializationTest
+from .test_utils import ConfigNodeUtilsTest
+
+__all__ = [
+    'ConfigNodeCrudTest',
+    'ConfigNodeSerializationTest',
+    'ConfigNodeIteratorsTest',
+    'ConfigNodeUtilsTest'
+]
diff --git a/tests/config_node_test.py b/tests/config_node/test_crud.py
similarity index 81%
rename from tests/config_node_test.py
rename to tests/config_node/test_crud.py
index e415a7e..7dc8f02 100644
--- a/tests/config_node_test.py
+++ b/tests/config_node/test_crud.py
@@ -1,11 +1,10 @@
-import pickle
 import warnings
 from unittest import TestCase
 
 from python_json_config.config_node import ConfigNode
 
 
-class ConfigNodeTest(TestCase):
+class ConfigNodeCrudTest(TestCase):
 
     def setUp(self):
         self.config_dict = {
@@ -98,27 +97,6 @@ class ConfigNodeTest(TestCase):
         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' \
-                   '=[])'
-        self.assertEqual(str(node), node_str)
-
-    def test_pickle(self):
-        config = ConfigNode(self.config_dict)
-        pickle_conf = pickle.loads(pickle.dumps(config))
-        self.assertEqual(pickle_conf.key1, 1)
-        self.assertEqual(pickle_conf.key2.key3, 3)
-        self.assertEqual(pickle_conf.key2.key4.key5, 5)
-
-    def test_contains(self):
-        config = ConfigNode(self.config_dict)
-        self.assertTrue('key1' in config)
-        self.assertFalse('key1.key2' in config)
-        self.assertTrue('key2.key3' in config)
-        self.assertTrue('key2.key4.key5' in config)
-        self.assertFalse('key2.key4.key6' in config)
-
     def test_strict_access(self):
         config = ConfigNode(self.config_dict,
                             strict_access=True,
diff --git a/tests/config_node/test_iterators.py b/tests/config_node/test_iterators.py
new file mode 100644
index 0000000..0528ed1
--- /dev/null
+++ b/tests/config_node/test_iterators.py
@@ -0,0 +1,63 @@
+from unittest import TestCase
+
+from python_json_config.config_node import ConfigNode
+
+
+class ConfigNodeIteratorsTest(TestCase):
+
+    def setUp(self):
+        self.config_dict = {
+            'key1': 1,
+            'key2': {
+                'key3': 3,
+                'key4': {'key5': 5},
+                'key6': 6
+            },
+            'key7': 7
+        }
+
+    def test_contains(self):
+        config = ConfigNode(self.config_dict)
+        self.assertTrue('key1' in config)
+        self.assertFalse('key1.key2' in config)
+        self.assertTrue('key2.key3' in config)
+        self.assertTrue('key2.key4.key5' in config)
+        self.assertFalse('key2.key4.key6' in config)
+
+    def test_keys(self):
+        config = ConfigNode(self.config_dict)
+        keys = list(config.keys())
+        expected_keys = ['key1', 'key2.key3', 'key2.key4.key5', 'key2.key6', 'key7']
+        self.assertEqual(keys, expected_keys)
+
+    def test_values(self):
+        config = ConfigNode(self.config_dict)
+        values = list(config.values())
+        self.assertEqual(values, [1, 3, 5, 6, 7])
+
+    def test_items(self):
+        config = ConfigNode(self.config_dict)
+        items = list(config.items())
+        expected_items = [('key1', 1), ('key2.key3', 3), ('key2.key4.key5', 5), ('key2.key6', 6), ('key7', 7)]
+        self.assertEqual(items, expected_items)
+
+    def test_iter(self):
+        config = ConfigNode(self.config_dict)
+        expected_keys = ['key1', 'key2.key3', 'key2.key4.key5', 'key2.key6', 'key7']
+        self.assertEqual(list(config), expected_keys)
+
+    def test_iteration(self):
+        config = ConfigNode(self.config_dict)
+        for iter_key, key, value, item in zip(config, config.keys(), config.values(), config.items()):
+            item_key, item_value = item
+            self.assertEqual(iter_key, key)
+            self.assertEqual(item_key, key)
+            self.assertEqual(item_value, value)
+            self.assertEqual(config.get(key), value)
+
+    def test_to_dict(self):
+        config = ConfigNode(self.config_dict)
+        self.assertEqual(config.to_dict(), self.config_dict)
+        self.assertEqual(config.key2.to_dict(), self.config_dict["key2"])
+        self.assertEqual(config.key2.key4.to_dict(), self.config_dict["key2"]["key4"])
+
diff --git a/tests/config_node/test_serialization.py b/tests/config_node/test_serialization.py
new file mode 100644
index 0000000..963afba
--- /dev/null
+++ b/tests/config_node/test_serialization.py
@@ -0,0 +1,23 @@
+import pickle
+from unittest import TestCase
+
+from python_json_config.config_node import ConfigNode
+
+
+class ConfigNodeSerializationTest(TestCase):
+
+    def setUp(self):
+        self.config_dict = {
+            'key1': 1,
+            'key2': {
+                'key3': 3,
+                'key4': { 'key5': 5 }
+            }
+        }
+
+    def test_pickle(self):
+        config = ConfigNode(self.config_dict)
+        pickle_conf = pickle.loads(pickle.dumps(config))
+        self.assertEqual(pickle_conf.key1, 1)
+        self.assertEqual(pickle_conf.key2.key3, 3)
+        self.assertEqual(pickle_conf.key2.key4.key5, 5)
diff --git a/tests/config_node/test_utils.py b/tests/config_node/test_utils.py
new file mode 100644
index 0000000..edbd480
--- /dev/null
+++ b/tests/config_node/test_utils.py
@@ -0,0 +1,11 @@
+from unittest import TestCase
+
+from python_json_config.config_node import ConfigNode
+
+
+class ConfigNodeUtilsTest(TestCase):
+    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' \
+                   '=[])'
+        self.assertEqual(str(node), node_str)