Provide utils.DictPath

DictPath is a class used to represent "paths" to values in nested
dictionaries. This works around Python's limitation of not being able to
assign to the variable pointed to by a reference. It's used to translate
variable strings like ${foo:bar} into locations in the parameters
dictionary.

Signed-off-by: martin f. krafft <madduck@madduck.net>
diff --git a/reclass/utils/__init__.py b/reclass/utils/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reclass/utils/__init__.py
diff --git a/reclass/utils/dictpath.py b/reclass/utils/dictpath.py
new file mode 100644
index 0000000..5e075ee
--- /dev/null
+++ b/reclass/utils/dictpath.py
@@ -0,0 +1,125 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+
+import types, re
+
+class DictPath(object):
+    '''
+    Represents a path into a nested dictionary.
+
+    Given a dictionary like
+
+      d['foo']['bar'] = 42
+
+    it can be desirable to obtain a reference to the value stored in the
+    sub-levels, allowing that value to be accessed and changed. Unfortunately,
+    Python provides no easy way to do this, since
+
+      ref = d['foo']['bar']
+
+    does become a reference to the integer 42, but that reference is
+    overwritten when one assigns to it. Hence, DictPath represents the path
+    into a nested dictionary, and can be "applied to" a dictionary to obtain
+    and set values, using a list of keys, or a string representation using
+    a delimiter (which can be escaped):
+
+      p = DictPath(':', 'foo:bar')
+      p.get_value(d)
+      p.set_value(d, 43)
+
+    This is a bit backwards, but the right way around would require support by
+    the dict() type.
+
+    The primary purpose of this class within reclass is to cater for parameter
+    interpolation, so that a reference such as ${foo:bar} in a parameter value
+    may be resolved in the context of the Parameter collections (a nested
+    dict).
+
+    If the value is a list, then the "key" is assumed to be and interpreted as
+    an integer index:
+
+      d = {'list': [{'one':1},{'two':2}]}
+      p = DictPath(':', 'list:1:two')
+      p.get_value(d)  → 2
+
+    This heuristic is okay within reclass, because dictionary keys (parameter
+    names) will always be strings. Therefore it is okay to interpret each
+    component of the path as a string, unless one finds a list at the current
+    level down the nested dictionary.
+    '''
+
+    def __init__(self, delim, contents=None):
+        self._delim = delim
+        if contents is None:
+            self._parts = []
+        else:
+            if isinstance(contents, types.StringTypes):
+                self._parts = self._split_string(contents)
+            elif isinstance(contents, tuple):
+                self._parts = list(contents)
+            elif isinstance(contents, list):
+                self._parts = contents
+            else:
+                raise TypeError('DictPath() takes string or list, '\
+                                'not %s' % type(contents))
+
+    def __repr__(self):
+        return "DictPath(%r, %r)" % (self._delim, str(self))
+
+    def __str__(self):
+        return self._delim.join(str(i) for i in self._parts)
+
+    def __eq__(self, other):
+        if isinstance(other, types.StringTypes):
+            other = DictPath(self._delim, other)
+
+        return self._parts == other._parts \
+                and self._delim == other._delim
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __hash__(self):
+        return hash(str(self))
+
+    def _get_path(self):
+        return self._parts
+    path = property(_get_path)
+
+    def _get_key(self):
+        if len(self._parts) == 0:
+            return None
+        return self._parts[-1]
+
+    def _get_innermost_container(self, base):
+        container = base
+        for i in self.path[:-1]:
+            if isinstance(container, (list, tuple)):
+                container = container[int(i)]
+            else:
+                container = container[i]
+        return container
+
+    def _split_string(self, string):
+        return re.split(r'(?<!\\)' + re.escape(self._delim), string)
+
+    def _escape_string(self, string):
+        return string.replace(self._delim, '\\' + self._delim)
+
+    def new_subpath(self, key):
+        try:
+            return DictPath(self._delim, self._parts + [self._escape_string(key)])
+        except AttributeError as e:
+            return DictPath(self._delim, self._parts + [key])
+
+    def get_value(self, base):
+        return self._get_innermost_container(base)[self._get_key()]
+
+    def set_value(self, base, value):
+        self._get_innermost_container(base)[self._get_key()] = value
diff --git a/reclass/utils/tests/__init__.py b/reclass/utils/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reclass/utils/tests/__init__.py
diff --git a/reclass/utils/tests/test_dictpath.py b/reclass/utils/tests/test_dictpath.py
new file mode 100644
index 0000000..35ed40f
--- /dev/null
+++ b/reclass/utils/tests/test_dictpath.py
@@ -0,0 +1,158 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from reclass.utils.dictpath import DictPath
+import unittest
+
+class TestDictPath(unittest.TestCase):
+
+    def test_constructor0(self):
+        p = DictPath(':')
+        self.assertListEqual(p._parts, [])
+
+    def test_constructor_list(self):
+        l = ['a', 'b', 'c']
+        p = DictPath(':', l)
+        self.assertListEqual(p._parts, l)
+
+    def test_constructor_str(self):
+        delim = ':'
+        s = 'a{0}b{0}c'.format(delim)
+        l = ['a', 'b', 'c']
+        p = DictPath(delim, s)
+        self.assertListEqual(p._parts, l)
+
+    def test_constructor_str_escaped(self):
+        delim = ':'
+        s = 'a{0}b\{0}b{0}c'.format(delim)
+        l = ['a', 'b\\{0}b'.format(delim), 'c']
+        p = DictPath(delim, s)
+        self.assertListEqual(p._parts, l)
+
+    def test_constructor_invalid_type(self):
+        with self.assertRaises(TypeError):
+            p = DictPath(':', 5)
+
+    def test_equality(self):
+        delim = ':'
+        s = 'a{0}b{0}c'.format(delim)
+        l = ['a', 'b', 'c']
+        p1 = DictPath(delim, s)
+        p2 = DictPath(delim, l)
+        self.assertEqual(p1, p2)
+
+    def test_inequality_content(self):
+        delim = ':'
+        s = 'a{0}b{0}c'.format(delim)
+        l = ['d', 'e', 'f']
+        p1 = DictPath(delim, s)
+        p2 = DictPath(delim, l)
+        self.assertNotEqual(p1, p2)
+
+    def test_inequality_delimiter(self):
+        l = ['a', 'b', 'c']
+        p1 = DictPath(':', l)
+        p2 = DictPath('%', l)
+        self.assertNotEqual(p1, p2)
+
+    def test_repr(self):
+        delim = '%'
+        s = 'a:b\:b:c'
+        p = DictPath(delim, s)
+        self.assertEqual('%r' % p, 'DictPath(%r, %r)' % (delim, s))
+
+    def test_str(self):
+        s = 'a:b\:b:c'
+        p = DictPath(':', s)
+        self.assertEqual(str(p), s)
+
+    def test_path_accessor(self):
+        l = ['a', 'b', 'c']
+        p = DictPath(':', l)
+        self.assertListEqual(p.path, l)
+
+    def test_new_subpath(self):
+        l = ['a', 'b', 'c']
+        p = DictPath(':', l[:-1])
+        p = p.new_subpath(l[-1])
+        self.assertListEqual(p.path, l)
+
+    def test_get_value(self):
+        v = 42
+        l = ['a', 'b', 'c']
+        d = {'a':{'b':{'c':v}}}
+        p = DictPath(':', l)
+        self.assertEqual(p.get_value(d), v)
+
+    def test_get_value_escaped(self):
+        v = 42
+        l = ['a', 'b:b', 'c']
+        d = {'a':{'b:b':{'c':v}}}
+        p = DictPath(':', l)
+        self.assertEqual(p.get_value(d), v)
+
+    def test_get_value_listindex_list(self):
+        v = 42
+        l = ['a', 1, 'c']
+        d = {'a':[None, {'c':v}, None]}
+        p = DictPath(':', l)
+        self.assertEqual(p.get_value(d), v)
+
+    def test_get_value_listindex_str(self):
+        v = 42
+        s = 'a:1:c'
+        d = {'a':[None, {'c':v}, None]}
+        p = DictPath(':', s)
+        self.assertEqual(p.get_value(d), v)
+
+    def test_set_value(self):
+        v = 42
+        l = ['a', 'b', 'c']
+        d = {'a':{'b':{'c':v}}}
+        p = DictPath(':', l)
+        p.set_value(d, v+1)
+        self.assertEqual(d['a']['b']['c'], v+1)
+
+    def test_set_value_escaped(self):
+        v = 42
+        l = ['a', 'b:b', 'c']
+        d = {'a':{'b:b':{'c':v}}}
+        p = DictPath(':', l)
+        p.set_value(d, v+1)
+        self.assertEqual(d['a']['b:b']['c'], v+1)
+
+    def test_set_value_escaped_listindex_list(self):
+        v = 42
+        l = ['a', 1, 'c']
+        d = {'a':[None, {'c':v}, None]}
+        p = DictPath(':', l)
+        p.set_value(d, v+1)
+        self.assertEqual(d['a'][1]['c'], v+1)
+
+    def test_set_value_escaped_listindex_str(self):
+        v = 42
+        s = 'a:1:c'
+        d = {'a':[None, {'c':v}, None]}
+        p = DictPath(':', s)
+        p.set_value(d, v+1)
+        self.assertEqual(d['a'][1]['c'], v+1)
+
+    def test_get_nonexistent_value(self):
+        l = ['a', 'd']
+        p = DictPath(':', l)
+        with self.assertRaises(KeyError):
+            p.get_value(dict())
+
+    def test_set_nonexistent_value(self):
+        l = ['a', 'd']
+        p = DictPath(':', l)
+        with self.assertRaises(KeyError):
+            p.set_value(dict(), 42)
+
+if __name__ == '__main__':
+    unittest.main()