Last active
May 1, 2019 21:19
-
-
Save tgs/338ffa2ccf382b55d975769726e442fa to your computer and use it in GitHub Desktop.
Prototype Python library for making error messages that explain complicated conditions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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