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()