2 Lazy conditional string evaluation module for Moe configuration variables.
4 * Each variable has ordered list of operations (definitions), each defining operation either
5 assigns (SET) or appends (APPEND) value of an expression to the variable. Each operation may be guarded by condition(s).
7 * Each condition is a formula (tree consisting of 'AND', 'OR', 'NOT' and '==', '!=' between two expressions.
9 * Expression is a list of strings and variables to be expanded.
11 .. note:: If no 'SET' applies, a variable is still undefined even if some 'APPEND' applies. This might change.
12 .. note:: All expanded data should be (or is converted to) unicode
13 .. todo:: (OPT) Cleanup of unused undefined variables.
14 .. todo:: (OPT) Better variable name checking (no name '.'-structural prefix of another)
15 .. todo:: (OPT) Implemet "subtree" listing.
18 import types, itertools, re, bisect
21 from moe import MoeError
24 "Allowed depth of recursion - includes ALL recursive calls, so should quite high."
27 "Maximum attained depth of recursion - for debug/testing"
30 "Variable name regexp, dots (separators) must be separated from edges and each other."
31 re_VARNAME = re.compile(r'\A([A-Za-z0-9_-]+\.)*[A-Za-z0-9_-]+\Z')
34 def check_depth(depth):
35 "Helper to check for recursion depth."
37 if depth > c_maxdepth:
38 raise CyclicConfigError('Too deep recursion in config evaluation (cyclic substitution?)')
39 if depth > debug_maxdepth:
40 debug_maxdepth = depth
43 class ConfigError(MoeError):
46 class UndefinedError(ConfigError):
49 class VariableNameError(ConfigError):
52 class VariableFixedError(ConfigError):
55 class CyclicConfigError(ConfigError):
58 class ParseProxy(list):
59 """Proxy helper class around values returned by `parse` and `parse_file`,
60 useful in "with" constructs."""
61 def __init__(self, config, parsed_ops):
62 super(ParseProxy, self).__init__(parsed_ops)
66 def __exit__(self, etype, value, traceback):
67 self.config.remove(list(self))
70 class ConfigTree(object):
72 Configuration tree containing all the variables.
74 The variables in `self.variables` are referenced directly by the full name.
80 def lookup(self, key, create = True):
82 Lookup and return a variable.
83 If not found and `create` set, check the name and transparently create a new one.
85 if key not in self.variables:
86 if not re_VARNAME.match(key):
87 raise VariableNameError('Invalid variable identifier %r in config', key)
89 raise UndefinedError('Config variable %r undefined.', key)
90 self.variables[key] = ConfigVar(key)
91 return self.variables[key]
93 def __getitem__(self, key):
95 Return the value of an existing variable.
97 return self.lookup(key, create=False).value()
99 def dump(self, prefix=''):
101 Pretty printing of the tree.
102 Returns an iterator of lines (strings).
104 return itertools.chain(*[
105 self.variables[k].dump(prefix) for k in sorted(self.variables.keys())
109 "Fix value of variable or list of variables. Fixing undefined variable raises `UndefinedError`."
110 if isinstance(keys, types.StringTypes):
113 self.lookup(key, create=True).fix()
115 def remove(self, parsed):
116 """Given a list [(varname, `Operation`)] as returned by `parse` or `parse_file`,
117 removes the operations from the respective variables config tree.
118 Variables/operations not present int the tree raise ValueError.
120 for vname, o in parsed:
121 v = self.lookup(vname, create = True)
122 v.remove_operation(o)
124 def parse(self, s, source=None, level=0, proxy=True):
125 """Parse `s` (stream/string) into the tree, see `moe.config_parser.ConfigParser` for details.
126 Returns list of parset operations: [(varname, `Operation`)].
127 By default returns a proxy list-like object that can be used in "with" constructs:
129 with config.parse("TEST='1'"):
133 import moe.config_parser
134 p = moe.config_parser.ConfigParser(s, self, source=source, level=level)
138 return ParseProxy(self, l)
140 def parse_file(self, filename, desc=None, level=0, proxy=True):
141 """Parse an utf-8 file into the tree using func:`parse`.
142 Names the source "`filename` <`desc`>". """
143 with open(filename, 'rt') as f:
145 filename += " <" + desc + ">"
146 return self.parse(f, source=filename, level=level, proxy=proxy)
149 class ConfigElem(object):
151 Base class for cahed config elements - variables and conditions
154 def __init__(self, name):
155 # Full name with separators, definition for conditions
157 # Vars and conditions depending on value of this one
158 self.dependants = set([])
159 # Cached value (may be None in case of evaluation error)
161 self.cached_val = None
163 def invalidate(self, depth=0):
165 Invalidate cached data and invalidate all dependants.
166 Does nothing if not cached.
170 log.debug('invalidating %s', self)
172 for d in self.dependants:
173 d.invalidate(depth + 1)
175 def value(self, depth=0):
176 "Caching helper calling self.evaluate(), returns a value or throws an exception."
179 self.cached_val = self.evaluate(depth=depth+1)
181 if self.cached_val == None:
182 raise UndefinedError("Unable to evaluate %r."%(self.name,))
183 return self.cached_val
189 class ConfigCondition(ConfigElem):
191 Condition using equality and logic operators.
192 Clause is a tuple-tree in the following recursive form::
194 ('AND', c1, c1), ('OR', c1, c2), ('NOT', c1), ('==', e1, e2), ('!=', e1, e2)
196 where e1, e2 are `ConfigExpression`, c1, c2, `ConfigCondition`.
199 def __init__(self, formula, text=None, parent=None):
201 Condition defined by `text` (informative), `formula` as in class definition,
202 `parent` is the parent condition (if any).
205 text = self.formula_string(formula)
206 super(ConfigCondition, self).__init__(text)
207 self.formula = formula
209 # Setup dependencies on used variables (not on the parent condition)
210 for v in self.variables():
211 v.dependants.add(self)
213 self.parent.dependants.add(self)
215 def variables(self, cl=None):
216 "Return an iterator of variables used in formula `cl`"
219 if cl[0] in ['==','!=']:
220 return itertools.chain(cl[1].variables(), cl[2].variables())
221 if cl[0] in ['AND','OR']:
222 return itertools.chain(self.variables(cl[1]), self.variables(cl[2]))
223 return self.variables(cl[1]) # only 'NOT' left
225 def remove_dependencies(self):
226 "Remove self as a dependant from all used variables"
227 for v in self.variables():
228 v.dependants.discard(self)
230 self.parent.dependants.discard(self)
232 def evaluate(self, cl=None, depth=0):
233 """Evaluate formula `cl` (or the entire condition).
234 Partial evaluation for AND and OR. Tests the parent condition first."""
238 if self.parent and not self.parent.value():
240 if cl[0] in ['==','!=']:
241 v = cl[1].evaluate(depth=depth+1) == cl[2].evaluate(depth=depth+1)
242 if cl[0] == '!=': v = not v
244 v1 = self.evaluate(cl=cl[1], depth=depth+1)
247 if cl[0] == 'OR' and v1: return True
248 if cl[0] == 'AND' and not v1: return False
249 return self.evaluate(cl=cl[2], depth=depth+1)
251 def formula_string(self, formula):
252 "Create a string representation of a formula."
253 if formula[0] == 'AND':
254 return itertools.chain(['('], self.formula_string(formula[1]), [' and '], self.formula_string(formula[2]),[')'])
255 elif formula[0] == 'OR':
256 return itertools.chain(['('], self.formula_string(formula[1]), [' or '], self.formula_string(formula[2]),[')'])
257 elif formula[0] == 'NOT':
258 return itertools.chain(['(not '], self.formula_string(formula[1]),[')'])
259 elif formula[0] in ['==', '!=']:
260 return itertools.chain(formula[1], formula[0], formula[2])
261 return iter(['<invalid formula>'])
263 def str(self, parents=False):
264 "Retur the defining expression, if `parents` set, then prefixed with parent conditions."
265 if parents and self.parent:
266 return self.parent.str(parents=True) + u' && ' + self.name
270 return self.str(parents=False)
273 class Operation(object):
274 "Helper class for operation data. Must not be present in more variables or present multiple times."
276 def __init__(self, operation, condition, expression, level=0, source='?'):
277 # operation is currently 'SET' and 'APPEND'
278 self.operation = operation
279 self.condition = condition
280 self.expression = expression
285 return "%s <%d, %s> [%s] %r" % ( {'SET':'=', 'APPEND':'+'}[self.operation], self.level, self.source,
286 (self.condition and self.condition.str(parents=True)) or '', unicode(self.expression))
289 class ConfigVar(ConfigElem):
291 def __init__(self, name):
292 super(ConfigVar, self).__init__(name)
293 # Ordered list of `Operations` (ascending by `level`)
295 # Fixed to value (may be None)
297 self.fixed_val = None
300 "Return a set of variables used in the expressions"
301 if not self.operations:
303 return set.union(*[ op.expression.variables() for op in self.operations ])
307 Fixes the value of the variable. Exception is raised should the variable
308 evaluate to a different value while fixed.
312 self.fixed_val = self.value()
316 "Make the variable modifiable again."
319 def value(self, depth=0):
320 "Handle the case when fixed, raise exc. on different evaluation"
321 val = super(ConfigVar,self).value(depth)
322 if self.fixed and self.fixed_val != val:
323 raise VariableFixedError("value of var %r was fixed to %r but evaluated to %r", self.name, self.fixed_val, val)
326 def add_operation(self, operation):
328 Inserts an operation. The operations are sorted by `level` (ascending), new operation goes last among
329 these with the same level.
330 Adds the variable as a dependant of the conditions and variables used in the expressions.
332 # Invalidate cached value
335 pos = bisect.bisect_right([o.level for o in self.operations], operation.level)
336 self.operations.insert(pos, operation)
337 # Create dependencies
338 for v in operation.expression.variables():
339 v.dependants.add(self)
340 if operation.condition:
341 operation.condition.dependants.add(self)
343 def remove_operation(self, operation):
345 Remove the Operation.
346 Also removes the variable as dependant from all conditions and variables used in this
347 operation that are no longer used.
349 # Invalidate cached value
351 # Remove the operation
352 self.operations.remove(operation)
353 # Remove dependencies on variables unused in other defining operations
354 vs = self.variables()
355 for v in operation.expression.variables():
357 v.dependants.remove(self)
358 # Remove the dependency on the conditions (if not used in another operation)
359 if operation.condition and operation.condition not in [op.condition for op in self.operations]:
360 operation.condition.dependants.remove(self)
362 def evaluate(self, depth=0):
364 Find the last 'SET' operation that applies and return the result of concatenating with all
365 subsequent applicable 'APPEND' operations. The result is the same as performing the operations
367 NOTE: undefined if some 'APPEND' apply but no 'SET' applies.
370 log.debug('evaluating var %r', self.name)
371 # List of strings to be concatenated
373 # Scan for last applicable expression - try each starting from the end, concatenate extensions
374 for i in range(len(self.operations)-1, -1, -1):
375 op = self.operations[i]
376 # Check the guarding condition
377 if (not op.condition) or op.condition.value(depth+1):
378 val.insert(0, op.expression.evaluate(depth=depth+1))
379 if op.operation == 'SET':
383 def dump(self, prefix=''):
385 Pretty printing of the variable. Includes all operations.
386 Returns iterator of lines (unicode strings).
388 # Try to evaluate the variable, but avoid undefined exceptions
391 v = self.value(depth=0)
394 yield prefix+u'%s = %r' % (self.name, v)
395 for op in self.operations:
396 #yield prefix+u' %s [%s] %s' % (op.operation, op.condition and op.condition.str(parents=True), op.expression)
397 yield prefix + u' ' + unicode(op)
400 class ConfigExpression(object):
402 String expression with some unexpanded config variables. Used in variable operations and conditions.
403 Expression is given as a list of unicode strings and ConfigVar variables to be expanded.
406 def __init__(self, exprlist, original = u'<unknown>'):
407 self.exprlist = list(exprlist)
408 # Original defining string
409 self.original = original
410 # Replace strings with unicode
411 for i in range(len(self.exprlist)):
413 if isinstance(e, types.StringTypes):
414 if not isinstance(e, unicode):
415 self.exprlist[i] = unicode(e, 'ascii')
418 "Return a set of variables used in the expression"
419 return set([e for e in self.exprlist if isinstance(e, ConfigVar)])
424 def evaluate(self, depth):
426 "Return unicode result of expansion of the variables."
428 for e in self.exprlist:
429 if isinstance(e, ConfigVar):
430 s.append(e.value(depth+1))
431 elif isinstance(e, unicode):
434 raise ConfigError('Invalid type %s in expression \'%s\'.'%(type(e), self))