Provide utils.RefValue

RefValue encapsulates string parameter values and can be used to isolate
and resolve references to other parameter values.

Signed-off-by: martin f. krafft <madduck@madduck.net>
diff --git a/reclass/errors.py b/reclass/errors.py
index a327f6d..544cb15 100644
--- a/reclass/errors.py
+++ b/reclass/errors.py
@@ -9,6 +9,8 @@
 
 import posix, sys
 
+from reclass.defaults import PARAMETER_INTERPOLATION_SENTINELS
+
 class ReclassException(Exception):
 
     def __init__(self, msg, rc=posix.EX_SOFTWARE, *args):
@@ -65,3 +67,29 @@
         msg = "Class '{0}' (in ancestry of node {1}) not found under {2}://{3}" \
                 .format(self._name, self._nodename, self._storage, self._uri)
         super(ClassNotFound, self).__init__(msg)
+
+
+class InterpolationError(ReclassException):
+
+    def __init__(self, msg, rc=posix.EX_DATAERR):
+        super(InterpolationError, self).__init__(msg, rc)
+
+
+class UndefinedVariableError(InterpolationError):
+
+    def __init__(self, var, context=None):
+        self._var = var
+        msg = "Cannot resolve " + var.join(PARAMETER_INTERPOLATION_SENTINELS)
+        if context:
+            msg += ' in the context of %s' % context
+        super(UndefinedVariableError, self).__init__(msg)
+
+    var = property(lambda x: x._var)
+
+
+class IncompleteInterpolationError(InterpolationError):
+
+    def __init__(self, string, end_sentinel):
+        msg = "Missing '%s' to end reference: %s" % \
+                (end_sentinel, string.join(PARAMETER_INTERPOLATION_SENTINELS))
+        super(IncompleteInterpolationError, self).__init__(msg)
diff --git a/reclass/utils/refvalue.py b/reclass/utils/refvalue.py
new file mode 100644
index 0000000..88dc998
--- /dev/null
+++ b/reclass/utils/refvalue.py
@@ -0,0 +1,115 @@
+#
+# -*- 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 re
+
+from reclass.utils.dictpath import DictPath
+from reclass.defaults import PARAMETER_INTERPOLATION_SENTINELS, \
+        PARAMETER_INTERPOLATION_DELIMITER
+from reclass.errors import IncompleteInterpolationError, \
+        UndefinedVariableError
+
+_SENTINELS = [re.escape(s) for s in PARAMETER_INTERPOLATION_SENTINELS]
+_RE = '{0}\s*(.+?)\s*{1}'.format(*_SENTINELS)
+
+class RefValue(object):
+    '''
+    Isolates references in string values
+
+    RefValue can be used to isolate and eventually expand references to other
+    parameters in strings. Those references can then be iterated and rendered
+    in the context of a dictionary to resolve those references.
+
+    RefValue always gets constructed from a string, because templating
+    — essentially this is what's going on — is necessarily always about
+    strings. Therefore, generally, the rendered value of a RefValue instance
+    will also be a string.
+
+    Nevertheless, as this might not be desirable, RefValue will return the
+    referenced variable without casting it to a string, if the templated
+    string contains nothing but the reference itself.
+
+    For instance:
+
+      mydict = {'favcolour': 'yellow', 'answer': 42, 'list': [1,2,3]}
+      RefValue('My favourite colour is ${favolour}').render(mydict)
+      → 'My favourite colour is yellow'      # a string
+
+      RefValue('The answer is ${answer}').render(mydict)
+      → 'The answer is 42'                   # a string
+
+      RefValue('${answer}').render(mydict)
+      → 42                                   # an int
+
+      RefValue('${list}').render(mydict)
+      → [1,2,3]                              # an list
+
+    The markers used to identify references are set in reclass.defaults, as is
+    the default delimiter.
+    '''
+
+    INTERPOLATION_RE = re.compile(_RE)
+
+    def __init__(self, string, delim=PARAMETER_INTERPOLATION_DELIMITER):
+        self._strings = []
+        self._refs = []
+        self._delim = delim
+        self._parse(string)
+
+    def _parse(self, string):
+        parts = RefValue.INTERPOLATION_RE.split(string)
+        self._refs = parts[1:][::2]
+        self._strings = parts[0:][::2]
+        self._check_strings(string)
+
+    def _check_strings(self, orig):
+        for s in self._strings:
+            pos = s.find(PARAMETER_INTERPOLATION_SENTINELS[0])
+            if pos >= 0:
+                raise IncompleteInterpolationError(orig,
+                                                   PARAMETER_INTERPOLATION_SENTINELS[1])
+
+    def _resolve(self, ref, context):
+        path = DictPath(self._delim, ref)
+        try:
+            return path.get_value(context)
+        except KeyError as e:
+            raise UndefinedVariableError(ref)
+
+    def has_references(self):
+        return len(self._refs) > 0
+
+    def get_references(self):
+        return self._refs
+
+    def _assemble(self, resolver):
+        if not self.has_references():
+            return self._strings[0]
+
+        if self._strings == ['', '']:
+            # preserve the type of the referenced variable
+            return resolver(self._refs[0])
+
+        # reassemble the string by taking a string and a var pairwise
+        ret = ''
+        for i in range(0, len(self._refs)):
+            ret += self._strings[i] + resolver(self._refs[i])
+        if len(self._strings) > len(self._refs):
+            # and finally append a trailing string, if any
+            ret += self._strings[-1]
+        return ret
+
+    def render(self, context):
+        resolver = lambda s: self._resolve(s, context)
+        return self._assemble(resolver)
+
+    def __repr__(self):
+        do_not_resolve = lambda s: s.join(PARAMETER_INTERPOLATION_SENTINELS)
+        return 'RefValue(%r, %r)' % (self._assemble(do_not_resolve),
+                                     self._delim)
diff --git a/reclass/utils/tests/test_refvalue.py b/reclass/utils/tests/test_refvalue.py
new file mode 100644
index 0000000..692fddf
--- /dev/null
+++ b/reclass/utils/tests/test_refvalue.py
@@ -0,0 +1,127 @@
+#
+# -*- 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.refvalue import RefValue
+from reclass.defaults import PARAMETER_INTERPOLATION_SENTINELS, \
+        PARAMETER_INTERPOLATION_DELIMITER
+from reclass.errors import UndefinedVariableError, \
+        IncompleteInterpolationError
+import unittest
+
+def _var(s):
+    return '%s%s%s' % (PARAMETER_INTERPOLATION_SENTINELS[0], s,
+                       PARAMETER_INTERPOLATION_SENTINELS[1])
+
+CONTEXT = {'favcolour':'yellow',
+           'motd':{'greeting':'Servus!',
+                   'colour':'${favcolour}'
+                  },
+           'int':1,
+           'list':[1,2,3],
+           'dict':{1:2,3:4},
+           'bool':True
+          }
+
+def _poor_mans_template(s, var, value):
+    return s.replace(_var(var), value)
+
+class TestRefValue(unittest.TestCase):
+
+    def test_simple_string(self):
+        s = 'my cat likes to hide in boxes'
+        tv = RefValue(s)
+        self.assertFalse(tv.has_references())
+        self.assertEquals(tv.render(CONTEXT), s)
+
+    def _test_solo_ref(self, key):
+        s = _var(key)
+        tv = RefValue(s)
+        res = tv.render(CONTEXT)
+        self.assertTrue(tv.has_references())
+        self.assertEqual(res, CONTEXT[key])
+
+    def test_solo_ref_string(self):
+        self._test_solo_ref('favcolour')
+
+    def test_solo_ref_int(self):
+        self._test_solo_ref('int')
+
+    def test_solo_ref_list(self):
+        self._test_solo_ref('list')
+
+    def test_solo_ref_dict(self):
+        self._test_solo_ref('dict')
+
+    def test_solo_ref_bool(self):
+        self._test_solo_ref('bool')
+
+    def test_single_subst_bothends(self):
+        s = 'I like ' + _var('favcolour') + ' and I like it'
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        self.assertEqual(tv.render(CONTEXT),
+                         _poor_mans_template(s, 'favcolour',
+                                             CONTEXT['favcolour']))
+
+    def test_single_subst_start(self):
+        s = _var('favcolour') + ' is my favourite colour'
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        self.assertEqual(tv.render(CONTEXT),
+                         _poor_mans_template(s, 'favcolour',
+                                             CONTEXT['favcolour']))
+
+    def test_single_subst_end(self):
+        s = 'I like ' + _var('favcolour')
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        self.assertEqual(tv.render(CONTEXT),
+                         _poor_mans_template(s, 'favcolour',
+                                             CONTEXT['favcolour']))
+
+    def test_deep_subst_solo(self):
+        var = PARAMETER_INTERPOLATION_DELIMITER.join(('motd', 'greeting'))
+        s = _var(var)
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        self.assertEqual(tv.render(CONTEXT),
+                         _poor_mans_template(s, var,
+                                             CONTEXT['motd']['greeting']))
+
+    def test_multiple_subst(self):
+        greet = PARAMETER_INTERPOLATION_DELIMITER.join(('motd', 'greeting'))
+        s = _var(greet) + ' I like ' + _var('favcolour') + '!'
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        want = _poor_mans_template(s, greet, CONTEXT['motd']['greeting'])
+        want = _poor_mans_template(want, 'favcolour', CONTEXT['favcolour'])
+        self.assertEqual(tv.render(CONTEXT), want)
+
+    def test_multiple_subst_flush(self):
+        greet = PARAMETER_INTERPOLATION_DELIMITER.join(('motd', 'greeting'))
+        s = _var(greet) + ' I like ' + _var('favcolour')
+        tv = RefValue(s)
+        self.assertTrue(tv.has_references())
+        want = _poor_mans_template(s, greet, CONTEXT['motd']['greeting'])
+        want = _poor_mans_template(want, 'favcolour', CONTEXT['favcolour'])
+        self.assertEqual(tv.render(CONTEXT), want)
+
+    def test_undefined_variable(self):
+        s = _var('no_such_variable')
+        tv = RefValue(s)
+        with self.assertRaises(UndefinedVariableError):
+            tv.render(CONTEXT)
+
+    def test_incomplete_variable(self):
+        s = PARAMETER_INTERPOLATION_SENTINELS[0] + 'incomplete'
+        with self.assertRaises(IncompleteInterpolationError):
+            tv = RefValue(s)
+
+if __name__ == '__main__':
+    unittest.main()