Repository: NetherlandsForensicInstitute/confidence (language: python)

Issue:
RepresentationError raised when saving a new Configuration object that consists sequences
The example below will raise a  `yaml.representer.RepresenterError` exception.
```
import confidence
from confidence import Configuration

a = Configuration({'a_1': '1', 'a_2': [2, 2, 2]})
b = Configuration({'b_1': '2', 'b_2': [2, 2, 2]})

confidence.dumpf(Configuration({'a': a, 'b':b}), 'output.yaml')
````
The workaround is  use `confidence.dumpf(Configuration({'a': a._source, 'b':b._source}), 'output.yaml')`, but it would be nicer if there was no need for use of private variables.



Discussion / hints:
You're right, there should be no need to use protected variables. The error that comes back is related to the lists, that show up to the YAML representer as a `ConfigurationSequence`. The real issue here is that creating a new `Configuration` should make sure there's no `Configuration` objects further down the line; the `dict` you're providing should be unwrapped / unraveled into simple `dict`s and `list`s, which would make the dumpers able to dump any kind of `Configuration` object again.

Thanks for submitting this, let's look into how we can best fix this :)
Calling this as part of the collect / merge stap during init of `Configuration` fixes the issue:

```python
def unwrap(source):
    while isinstance(source, Configuration):
        source = source._source

    if isinstance(source, Mapping):
        return {key: unwrap(value) for key, value in source.items()}
    else:
        return source
```

Not sure yet if that's the best option, as that is a fairly expensive recursive cleaning of the source, just before it gets recursively traversed again to be merged. Ideally, this should be integrated into the merge, but that would currently cause a circular import :sweat_smile: 

It's fixable, just need to wedge it into the right spot :thinking: 

Fix patch:
diff --git a/CHANGES.md b/CHANGES.md
index cc17adf..4a2a421 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,7 @@ development (master)
 --------------------
 
 - Avoid checking for existence of files, try to open them instead.
+- Fix dumping / serialization issues by unwrapping complex wrapper types to their simple counterparts during initialization of `Configuration`.
 
 0.12 (2022-03-01)
 -----------------
diff --git a/confidence/models.py b/confidence/models.py
index bc34bc1..80a09d4 100644
--- a/confidence/models.py
+++ b/confidence/models.py
@@ -22,6 +22,30 @@ class Missing(Enum):
 })()  # create instance of that new type to assign to NoDefault
 
 
+def _unwrap(source: typing.Any) -> typing.Any:
+    """
+    Recursively walks *source* to turn occurrences of wrapper types into their
+    simple counterparts.
+
+    :param source: the object to be unwrapped
+    :return: *source*, recursively unwrapped if needed
+    """
+    while isinstance(source, Configuration):
+        # unwrap a Configuration into its source attribute
+        source = source._source
+
+    if isinstance(source, ConfigurationSequence):
+        # sequence will resolve references, unwrap values in its source
+        return [_unwrap(value) for value in source._source]
+
+    if isinstance(source, Mapping):
+        # mapping type can no longer be a Configuration, use .items() to unwrap values
+        return {key: _unwrap(value) for key, value in source.items()}
+
+    # nothing needed, use value as-is
+    return source
+
+
 class Configuration(Mapping):
     """
     A collection of configured values, retrievable as either `dict`-like items
@@ -54,12 +78,9 @@ def __init__(self,
         self._source: typing.MutableMapping[str, typing.Any] = {}
         for source in sources:
             if source:
-                while isinstance(source, Configuration):
-                    # _merge will walk source.items(), using source.get(), avoid resolving references now
-                    source = source._source
-
                 # merge values from source into self._source, overwriting any corresponding keys
-                merge(self._source, split_keys(source, colliding=_COLLIDING_KEYS), conflict=Conflict.OVERWRITE)
+                # unwrap the source to make sure we're dealing with simple types
+                merge(self._source, split_keys(_unwrap(source), colliding=_COLLIDING_KEYS), conflict=Conflict.OVERWRITE)
 
     def _wrap(self, value: typing.Mapping[str, typing.Any]) -> 'Configuration':
         # create an instance of our current type, copying 'configured' properties / policies


Test patch:
diff --git a/tests/test_defaults.py b/tests/test_defaults.py
deleted file mode 100644
index a1113ab..0000000
--- a/tests/test_defaults.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from confidence import Configuration, NotConfigured
-
-
-def test_constructor_defaults():
-    subject = Configuration()
-    assert subject._missing == NotConfigured
-    assert len(subject) == len(subject._source) == 0
-    assert list(subject) == []
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 0000000..c7e5b67
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,27 @@
+from confidence import Configuration, dumps, loads, NotConfigured
+from confidence.models import ConfigurationSequence
+
+
+def test_constructor_defaults():
+    subject = Configuration()
+
+    assert subject._missing == NotConfigured
+    assert len(subject) == len(subject._source) == 0
+    assert list(subject) == []
+
+
+def test_wrapped_source():
+    left = Configuration({'a': 'a', 'b': [2, 2]})
+    right = Configuration({'a': [1], 'b': Configuration({'c': 42})})
+
+    subject = Configuration({'left': left, 'middle': right.a, 'right': right})
+
+    assert not isinstance(subject._source['left'], Configuration)
+    assert not isinstance(subject._source['middle'], ConfigurationSequence)
+    assert not isinstance(subject._source['right']['b'], Configuration)
+    assert not isinstance(subject._source['right']['a'], ConfigurationSequence)
+
+    assert subject.right.b.c == 42
+    assert len(left.b) == len(subject.left.b) == 2
+    assert len(right.a) == len(subject.middle) == 1
+    assert subject._source == loads(dumps(subject))._source