Skip to content

Instantly share code, notes, and snippets.

@tgs
Last active May 1, 2019 21:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tgs/338ffa2ccf382b55d975769726e442fa to your computer and use it in GitHub Desktop.
Save tgs/338ffa2ccf382b55d975769726e442fa to your computer and use it in GitHub Desktop.
Prototype Python library for making error messages that explain complicated conditions
# This is a work-for-hire for the Government of the United States, so it is not
# subject to copyright protection.
"""
logiq - A way to build complex conditions and evaluate them against the world.
The goal is to make the conditions easy to construct, and give them useful and
concise error messages as well.
These are some requirement nodes:
>>> Have('cats')
Have(cats)
>>> No('weasels')
No(weasels)
The nodes are evaluated against a dictionary containing some information or
state.
>>> state = {'cats': 33}
>>> Have('cats').eval(state)
True
>>> No('weasels').eval(state)
True
>>> No('cats').eval(state)
False
Nodes can be combined into larger conditions, for example ``And(...)``.
>>> cats = Have('cats')
>>> dogs = Have('dogs')
>>> both = (cats & dogs)
>>> both
And(Have(cats), Have(dogs))
>>> from pprint import pprint as pp
>>> pp(both.trace({'cats': 1}))
['FALSE = And(Have(cats), Have(dogs))',
' true = Have(cats)',
' FALSE = Have(dogs)']
You can use the ``require`` method to raise an exception if the conditions
aren't met. This attempts to give you some useful information about why there
was a problem. Look for the lines starting with FALSE.
>>> No('nukes').require({'nukes': 10000})
Traceback (most recent call last):
...
logiq.RequirementException: "nukes" should not be set, but it is.
{'nukes': 10000}
FALSE = No(nukes)
"""
class RequirementException(ValueError):
pass
class BoolNode:
"Base class for conditions"
def __and__(self, other):
return And(self, other)
def __or__(self, other):
return Or(self, other)
def __bool__(self):
raise TypeError(
"Don't use me directly as a bool, use .eval"
" or other methods instead.")
__nonzero__ = __bool__
def __str__(self):
return repr(self)
def children(self):
"Return any child nodes of this condition"
raise NotImplementedError
def eval(self, where):
"Evaluate the conditions, return True or False"
raise NotImplementedError
def problem(self, where):
"Return a string describing any problems, or empty string if no probs"
raise NotImplementedError
def require(self, where):
prob = self.problem(where)
if prob:
raise RequirementException(
'\n '.join([prob, repr(where)] + self.trace(where)))
def trace(self, where, indent=0):
t = [
'%s%s = %s' % (' ' * indent,
'true' if self.eval(where) else 'FALSE',
self),
]
t.extend(self._trace_children(where, indent=indent + 2))
return t
def _trace_children(self, where, indent=0):
t = []
for p in self.children():
t.extend(p.trace(where, indent=indent))
return t
class Have(BoolNode):
"""
Require that some key is set and its value is truthy.
>>> Have('it').eval({'it': 1})
True
>>> Have('it').eval({'it': 'fun times'})
True
>>> Have('it').eval({'it': 0})
False
>>> Have('it').eval({})
False
"""
_err_message = "\"%s\" should be set, but it isn't."
def __init__(self, name):
self.name = name
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, self.name)
def problem(self, where):
if not self.eval(where):
return self._err_message % self.name
def eval(self, where):
return bool(where.get(self.name))
def children(self):
return []
class No(Have):
"""
Require that some key is not set, or its value is falsey.
>>> No('thing').eval({})
True
>>> No('thing').eval({'thing': ''})
True
>>> No('thing').eval({'thing': None})
True
>>> No('thing').eval({'thing': 1})
False
"""
_err_message = "\"%s\" should not be set, but it is."
def eval(self, where):
return not super().eval(where)
class Equals(BoolNode):
"""
Require that a value in the state is equal to some other value.
>>> niece = Equals('age', 4)
>>> niece.eval({'age': 4, 'interests': ['moomins', 'dogs']})
True
>>> niece.problem({'age': 11})
'age should be set to 4, but instead it is 11'
"""
def __init__(self, name, expect):
self.name = name
self.expect = expect
def __repr__(self):
return 'Equals(%s, %r)' % (self.name, self.expect)
def eval(self, where):
return where.get(self.name) == self.expect
def problem(self, where):
if not self.eval(where):
return '%s should be set to %r, but instead it is %r' % (
self.name, self.expect, where.get(self.name))
def children(self):
return []
class And(BoolNode):
"""
Require that all sub-nodes evaluate to True.
>>> normal = And(Have('cats'), No('weasels'))
>>> normal.problem({'cats': 1, 'dogs': 2})
>>> normal.require({}) # doctest: +NORMALIZE_WHITESPACE
Traceback (most recent call last):
...
logiq.RequirementException: All of these should be set,
but at least one is not: (Have(cats), No(weasels))
{}
FALSE = And(Have(cats), No(weasels))
FALSE = Have(cats)
true = No(weasels)
Similar to Python ``all()``, And of an empty list evaluates to True.
"""
def __init__(self, *parts):
self.parts = tuple(parts)
def __repr__(self):
return 'And' + repr(self.parts)
def problem(self, where):
if not self.eval(where):
return ("All of these should be set, but at least one is not: "
+ repr(self.parts))
def eval(self, where):
return all(p.eval(where) for p in self.parts)
def children(self):
return self.parts
class Or(BoolNode):
"""
Require that at least one sub-node evaluates to True.
>>> either = Or(Have('love'), Have('money'))
>>> either.require({'love': 'lots'})
>>> either.require({'money': 'lots'})
>>> either.problem({}) # doctest: +NORMALIZE_WHITESPACE
'At least one condition must be fulfilled, but none are:
(Have(love), Have(money))'
>>> either | Have('reddit')
Or(Or(Have(love), Have(money)), Have(reddit))
"At least one" means that there must be at least one sub-node:
>>> Or().eval({})
False
"""
def __init__(self, *parts):
self.parts = tuple(parts)
def __repr__(self):
return 'Or' + repr(self.parts)
def problem(self, where):
if not self.eval(where):
return ('At least one condition must be fulfilled, but '
'none are: ' + repr(self.parts))
def eval(self, where):
return any(p.eval(where) for p in self.parts)
def children(self):
return self.parts
class Implies(BoolNode):
"""
Require that any time A is true, B must be true.
>>> imp = Implies(Have('dogs'), Have('fur everywhere'))
As in formal logic, if the first condition is not true, then we have no
opinion about the second condition:
>>> imp.problem({})
>>> imp.problem({'fur everywhere': 'yes, 3 cats'})
When the first condition IS met, then the second condition is required.
>>> imp.problem({'dogs': 1, 'fur everywhere': 'why not'})
>>> imp.problem({'dogs': 1}) # doctest: +NORMALIZE_WHITESPACE
'Because Have(dogs), expected Have(fur everywhere),
but it was not true.'
A more complex example:
>>> normal_pets = ['goldfish', 'dog', 'cat', 'sugar glider']
>>> only_normal = Implies(Have('pet'),
... Or(*[Have(p) for p in normal_pets]))
>>> only_normal.eval({})
True
>>> only_normal.eval({'pet': 1, 'dragon': 1})
False
>>> only_normal.problem(
... {'pet': 1, 'dragon': 1}) # doctest: +NORMALIZE_WHITESPACE
'Because Have(pet), expected Or(Have(goldfish),
Have(dog), Have(cat), Have(sugar glider)), but it was not true.'
"""
def __init__(self, cond, req):
self.cond = cond
self.req = req
def __repr__(self):
return 'Implies(%r, %r)' % (self.cond, self.req)
def problem(self, where):
if not self.eval(where):
return ("Because %r, expected %r, but it was not true."
% (self.cond, self.req))
def eval(self, where):
return (not self.cond.eval(where)) or self.req.eval(where)
def children(self):
return (self.cond, self.req)
"""
Dreams for Someday...
I think we could make error messages like this:
> Given that the Publication is unpublished (./@status is 'eUnpublished'
> at line 22), Expected that the Publication is not available in PubMed
> (./DbType is 'eNotAvailable').
> However, ./DbType is 'ePubMed' at line 30.
To make this happen, the .require() method would need another parameter,
for the name of what is being examined, 'the Publication' in this case.
Also, the Equals and other methods would need an optional parameter for English
descriptions of what they are testing. For example,
Equals('./@status', 'eUnpublished', 'is unpublished')
Then, ``str(BoolNode)`` would become different from ``repr()`` - it would give
the English description if available, followed by an auto-generated snippet
like "./@status is 'eUnpublished'" in parens. If the English version isn't
available, the autogen'd version would be the only thing there.
Finally, the line numbers. That's a bit hard to do in the current system,
where the state-of-the-world is forced into a simple model, a dictionary.
"./DbType is 'ePubMed' at line 30". Maybe there would have to be XML
subclasses of the BoolNode. XEquals('./@status', 'ePublished') maybe. That
could also root the relative xpath at a concrete node, maybe, which would be
clearer.
"""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment