301 lines
12 KiB
Python
301 lines
12 KiB
Python
import re
|
|
|
|
class BooleanExpression:
|
|
# A simple evaluator of boolean expressions.
|
|
#
|
|
# Grammar:
|
|
# expr :: or_expr
|
|
# or_expr :: and_expr ('||' and_expr)*
|
|
# and_expr :: not_expr ('&&' not_expr)*
|
|
# not_expr :: '!' not_expr
|
|
# '(' or_expr ')'
|
|
# match_expr
|
|
# match_expr :: braced_regex
|
|
# identifier
|
|
# braced_regex match_expr
|
|
# identifier match_expr
|
|
# identifier :: [-+=._a-zA-Z0-9]+
|
|
# braced_regex :: '{{' python_regex '}}'
|
|
|
|
# Evaluates `string` as a boolean expression.
|
|
# Returns True or False. Throws a ValueError on syntax error.
|
|
#
|
|
# Variables in `variables` are true.
|
|
# Regexes that match any variable in `variables` are true.
|
|
# Substrings of `triple` are true.
|
|
# 'true' is true.
|
|
# All other identifiers are false.
|
|
@staticmethod
|
|
def evaluate(string, variables, triple=""):
|
|
try:
|
|
parser = BooleanExpression(string, set(variables), triple)
|
|
return parser.parseAll()
|
|
except ValueError as e:
|
|
raise ValueError(str(e) + ('\nin expression: %r' % string))
|
|
|
|
#####
|
|
|
|
def __init__(self, string, variables, triple=""):
|
|
self.tokens = BooleanExpression.tokenize(string)
|
|
self.variables = variables
|
|
self.variables.add('true')
|
|
self.triple = triple
|
|
self.value = None
|
|
self.token = None
|
|
|
|
# Singleton end-of-expression marker.
|
|
END = object()
|
|
|
|
# Tokenization pattern.
|
|
Pattern = re.compile(r'\A\s*([()]|&&|\|\||!|(?:[-+=._a-zA-Z0-9]+|\{\{.+?\}\})+)\s*(.*)\Z')
|
|
|
|
@staticmethod
|
|
def tokenize(string):
|
|
while True:
|
|
m = re.match(BooleanExpression.Pattern, string)
|
|
if m is None:
|
|
if string == "":
|
|
yield BooleanExpression.END;
|
|
return
|
|
else:
|
|
raise ValueError("couldn't parse text: %r" % string)
|
|
|
|
token = m.group(1)
|
|
string = m.group(2)
|
|
yield token
|
|
|
|
def quote(self, token):
|
|
if token is BooleanExpression.END:
|
|
return '<end of expression>'
|
|
else:
|
|
return repr(token)
|
|
|
|
def accept(self, t):
|
|
if self.token == t:
|
|
self.token = next(self.tokens)
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def expect(self, t):
|
|
if self.token == t:
|
|
if self.token != BooleanExpression.END:
|
|
self.token = next(self.tokens)
|
|
else:
|
|
raise ValueError("expected: %s\nhave: %s" %
|
|
(self.quote(t), self.quote(self.token)))
|
|
|
|
@staticmethod
|
|
def isMatchExpression(token):
|
|
if (token is BooleanExpression.END or token == '&&' or token == '||' or
|
|
token == '!' or token == '(' or token == ')'):
|
|
return False
|
|
return True
|
|
|
|
def parseMATCH(self):
|
|
regex = ''
|
|
for part in filter(None, re.split(r'(\{\{.+?\}\})', self.token)):
|
|
if part.startswith('{{'):
|
|
assert part.endswith('}}')
|
|
regex += '(?:{})'.format(part[2:-2])
|
|
else:
|
|
regex += re.escape(part)
|
|
regex = re.compile(regex)
|
|
self.value = self.token in self.triple or any(regex.fullmatch(var) for var in self.variables)
|
|
self.token = next(self.tokens)
|
|
|
|
def parseNOT(self):
|
|
if self.accept('!'):
|
|
self.parseNOT()
|
|
self.value = not self.value
|
|
elif self.accept('('):
|
|
self.parseOR()
|
|
self.expect(')')
|
|
elif not BooleanExpression.isMatchExpression(self.token):
|
|
raise ValueError("expected: '!', '(', '{{', or identifier\nhave: %s" %
|
|
self.quote(self.token))
|
|
else:
|
|
self.parseMATCH()
|
|
|
|
def parseAND(self):
|
|
self.parseNOT()
|
|
while self.accept('&&'):
|
|
left = self.value
|
|
self.parseNOT()
|
|
right = self.value
|
|
# this is technically the wrong associativity, but it
|
|
# doesn't matter for this limited expression grammar
|
|
self.value = left and right
|
|
|
|
def parseOR(self):
|
|
self.parseAND()
|
|
while self.accept('||'):
|
|
left = self.value
|
|
self.parseAND()
|
|
right = self.value
|
|
# this is technically the wrong associativity, but it
|
|
# doesn't matter for this limited expression grammar
|
|
self.value = left or right
|
|
|
|
def parseAll(self):
|
|
self.token = next(self.tokens)
|
|
self.parseOR()
|
|
self.expect(BooleanExpression.END)
|
|
return self.value
|
|
|
|
|
|
#######
|
|
# Tests
|
|
|
|
import unittest
|
|
|
|
class TestBooleanExpression(unittest.TestCase):
|
|
def test_variables(self):
|
|
variables = {'its-true', 'false-lol-true', 'under_score',
|
|
'e=quals', 'd1g1ts'}
|
|
self.assertTrue(BooleanExpression.evaluate('true', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('its-true', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('false-lol-true', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('under_score', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('e=quals', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('d1g1ts', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('{{its.+}}', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('{{false-[lo]+-true}}', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('{{(true|false)-lol-(true|false)}}', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('d1g{{[0-9]}}ts', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('d1g{{[0-9]}}t{{[a-z]}}', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('{{d}}1g{{[0-9]}}t{{[a-z]}}', variables))
|
|
self.assertTrue(BooleanExpression.evaluate('d1{{(g|1)+}}ts', variables))
|
|
|
|
self.assertFalse(BooleanExpression.evaluate('false', variables))
|
|
self.assertFalse(BooleanExpression.evaluate('True', variables))
|
|
self.assertFalse(BooleanExpression.evaluate('true-ish', variables))
|
|
self.assertFalse(BooleanExpression.evaluate('not_true', variables))
|
|
self.assertFalse(BooleanExpression.evaluate('tru', variables))
|
|
self.assertFalse(BooleanExpression.evaluate('{{its-true.+}}', variables))
|
|
|
|
def test_triple(self):
|
|
triple = 'arch-vendor-os'
|
|
self.assertTrue(BooleanExpression.evaluate('arch-', {}, triple))
|
|
self.assertTrue(BooleanExpression.evaluate('ar', {}, triple))
|
|
self.assertTrue(BooleanExpression.evaluate('ch-vend', {}, triple))
|
|
self.assertTrue(BooleanExpression.evaluate('-vendor-', {}, triple))
|
|
self.assertTrue(BooleanExpression.evaluate('-os', {}, triple))
|
|
self.assertFalse(BooleanExpression.evaluate('arch-os', {}, triple))
|
|
|
|
# When matching against the triple, a regex is treated as an identifier and checked
|
|
# for a literal match. This preserves existing behavior before regexes were introduced.
|
|
self.assertFalse(BooleanExpression.evaluate('arch-{{vendor}}-os', {}, triple))
|
|
self.assertTrue(BooleanExpression.evaluate('arch-{{vendor}}-os', {}, 'arch-{{vendor}}-os'))
|
|
|
|
def test_matching(self):
|
|
expr1 = 'linux && (target={{aarch64-.+}} || target={{x86_64-.+}})'
|
|
self.assertTrue(BooleanExpression.evaluate(expr1, {'linux', 'target=x86_64-unknown-linux-gnu'}))
|
|
self.assertFalse(BooleanExpression.evaluate(expr1, {'linux', 'target=i386-unknown-linux-gnu'}))
|
|
|
|
expr2 = 'use_system_cxx_lib && target={{.+}}-apple-macosx10.{{9|10|11|12}} && !no-exceptions'
|
|
self.assertTrue(BooleanExpression.evaluate(expr2, {'use_system_cxx_lib', 'target=arm64-apple-macosx10.12'}))
|
|
self.assertFalse(BooleanExpression.evaluate(expr2, {'use_system_cxx_lib', 'target=arm64-apple-macosx10.12', 'no-exceptions'}))
|
|
self.assertFalse(BooleanExpression.evaluate(expr2, {'use_system_cxx_lib', 'target=arm64-apple-macosx10.15'}))
|
|
|
|
def test_operators(self):
|
|
self.assertTrue(BooleanExpression.evaluate('true || true', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('true || false', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('false || true', {}))
|
|
self.assertFalse(BooleanExpression.evaluate('false || false', {}))
|
|
|
|
self.assertTrue(BooleanExpression.evaluate('true && true', {}))
|
|
self.assertFalse(BooleanExpression.evaluate('true && false', {}))
|
|
self.assertFalse(BooleanExpression.evaluate('false && true', {}))
|
|
self.assertFalse(BooleanExpression.evaluate('false && false', {}))
|
|
|
|
self.assertFalse(BooleanExpression.evaluate('!true', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('!false', {}))
|
|
|
|
self.assertTrue(BooleanExpression.evaluate(' ((!((false) )) ) ', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('true && (true && (true))', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('!false && !false && !! !false', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('false && false || true', {}))
|
|
self.assertTrue(BooleanExpression.evaluate('(false && false) || true', {}))
|
|
self.assertFalse(BooleanExpression.evaluate('false && (false || true)', {}))
|
|
|
|
# Evaluate boolean expression `expr`.
|
|
# Fail if it does not throw a ValueError containing the text `error`.
|
|
def checkException(self, expr, error):
|
|
try:
|
|
BooleanExpression.evaluate(expr, {})
|
|
self.fail("expression %r didn't cause an exception" % expr)
|
|
except ValueError as e:
|
|
if -1 == str(e).find(error):
|
|
self.fail(("expression %r caused the wrong ValueError\n" +
|
|
"actual error was:\n%s\n" +
|
|
"expected error was:\n%s\n") % (expr, e, error))
|
|
except BaseException as e:
|
|
self.fail(("expression %r caused the wrong exception; actual " +
|
|
"exception was: \n%r") % (expr, e))
|
|
|
|
def test_errors(self):
|
|
self.checkException("ba#d",
|
|
"couldn't parse text: '#d'\n" +
|
|
"in expression: 'ba#d'")
|
|
|
|
self.checkException("true and true",
|
|
"expected: <end of expression>\n" +
|
|
"have: 'and'\n" +
|
|
"in expression: 'true and true'")
|
|
|
|
self.checkException("|| true",
|
|
"expected: '!', '(', '{{', or identifier\n" +
|
|
"have: '||'\n" +
|
|
"in expression: '|| true'")
|
|
|
|
self.checkException("true &&",
|
|
"expected: '!', '(', '{{', or identifier\n" +
|
|
"have: <end of expression>\n" +
|
|
"in expression: 'true &&'")
|
|
|
|
self.checkException("",
|
|
"expected: '!', '(', '{{', or identifier\n" +
|
|
"have: <end of expression>\n" +
|
|
"in expression: ''")
|
|
|
|
self.checkException("*",
|
|
"couldn't parse text: '*'\n" +
|
|
"in expression: '*'")
|
|
|
|
self.checkException("no wait stop",
|
|
"expected: <end of expression>\n" +
|
|
"have: 'wait'\n" +
|
|
"in expression: 'no wait stop'")
|
|
|
|
self.checkException("no-$-please",
|
|
"couldn't parse text: '$-please'\n" +
|
|
"in expression: 'no-$-please'")
|
|
|
|
self.checkException("(((true && true) || true)",
|
|
"expected: ')'\n" +
|
|
"have: <end of expression>\n" +
|
|
"in expression: '(((true && true) || true)'")
|
|
|
|
self.checkException("true (true)",
|
|
"expected: <end of expression>\n" +
|
|
"have: '('\n" +
|
|
"in expression: 'true (true)'")
|
|
|
|
self.checkException("( )",
|
|
"expected: '!', '(', '{{', or identifier\n" +
|
|
"have: ')'\n" +
|
|
"in expression: '( )'")
|
|
|
|
self.checkException("abc{{def",
|
|
"couldn't parse text: '{{def'\n" +
|
|
"in expression: 'abc{{def'")
|
|
|
|
self.checkException("{{}}",
|
|
"couldn't parse text: '{{}}'\n" +
|
|
"in expression: '{{}}'")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|