Skip to content

Commit

Permalink
implemented msg in all assert functions (closes #208)
Browse files Browse the repository at this point in the history
also documented assertRaises and added tests for assert functions
  • Loading branch information
gdementen committed Jan 23, 2018
1 parent 25b527b commit 2276a81
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 28 deletions.
20 changes: 20 additions & 0 deletions doc/usersguide/source/changes/version_0_12.rst.inc
Expand Up @@ -64,6 +64,26 @@

* implemented assertRaises to check for expected errors.

* added support for a new "msg" argument to all assertion functions. It specifies a custom message to append to the
normal message displayed when the assertion fails. This is most useful when the condition of the assertion is complex.
The message can be **any** expression or tuple of expressions, and it is only evaluated if the assertion fails
(closes :issue:`208`). ::

- assertTrue(all(age >= 0), msg="we have persons with negative age!")

will display: ::

AssertionError: all((age >= 100)) is not True: we have persons with negative age!

instead of just: ::

AssertionError: all((age >= 100)) is not True

Using dump(), csv(dump()) or breakpoint() as the msg argument can be useful.

- assertTrue(all(age < 150), msg=("we have abnormally old persons", dump(filter=age >= 150))
- assertTrue(all(total_income >= 0), breakpoint())


Miscellaneous improvements
--------------------------
Expand Down
27 changes: 19 additions & 8 deletions doc/usersguide/source/processes.rst
Expand Up @@ -2524,16 +2524,27 @@ assertions

Assertions can be used to check that your model really produce the results it
should produce. The behavior when an assertion fails is determined by
the :ref:`assertions-label` simulation option.
the :ref:`assertions-label` simulation option. All assertion functions accept a msg argument where you can specify a
custom message to append to the normal message displayed when the assertion fails. This message can be **any**
expression or tuple of expressions, and it is only evaluated if the assertion fails.

- assertTrue(expr): evaluates the expression and check its result is True.
- assertFalse(expr): evaluates the expression and check its result is False.
- assertEqual(expr1, expr2): evaluates both expressions and check their
- assertTrue(expr, msg=None): evaluates the expression and check its result is True.
- assertFalse(expr, msg=None): evaluates the expression and check its result is False.
- assertEqual(expr1, expr2, msg=None): evaluates both expressions and check their
results are equal.
- assertNanEqual(expr1, expr2): evaluates both expressions and check their
- assertNanEqual(expr1, expr2, msg=None): evaluates both expressions and check their
results are equal, even in the presence of nans (because normally nan != nan).
- assertEquiv(expr1, expr2): evaluates both expressions and check their
- assertEquiv(expr1, expr2, msg=None): evaluates both expressions and check their
results are equal tolerating a difference in shape (though they must be
compatible).
- assertIsClose(expr1, expr2): evaluates both expressions and check their
compatible). This is only useful to compare arrays.
- assertIsClose(expr1, expr2, msg=None): evaluates both expressions and check their
results are almost equal.
- assertRaises(exception_name, expr, msg=None): evaluate the `expr` expression and raises an assertion if it did NOT
raise an Exception. This is mostly used internally to test that other assertions functions work but could be used
in models to check that some bad condition does not actually happen.

*examples* ::

- assertTrue(all(age >= 0), msg="we have persons with negative age!")
- assertTrue(all(age < 150), msg=("we have abnormally old persons", dump(filter=age >= 150))
- assertTrue(all(total_income >= 0), msg=breakpoint())
50 changes: 30 additions & 20 deletions liam2/actions.py
Expand Up @@ -148,8 +148,14 @@ def compute(self, context, period=None):


class Assert(FunctionExpr):
def eval_assertion(self, context, *args):
raise NotImplementedError()
# subclasses should have msg in their no_eval attribute. We delay evaluating it because it can potentially be
# costly to compute. e.g. when using msg=dump()
no_eval = ('msg',)

def __init__(self, *args, **kwargs):
assert self.argspec.args[-1] == 'msg', \
"%s.compute MUST have 'msg' as its last argument" % self.__class__.__name__
FunctionExpr.__init__(self, *args, **kwargs)

def evaluate(self, context):
if config.assertions == "skip":
Expand All @@ -159,51 +165,60 @@ def evaluate(self, context):
args, kwargs = self._eval_args(context)
if config.log_level == "processes":
print("assertion", end=' ')
failure = self.eval_assertion(context, *args)
failure = self.compute(context, *args, **kwargs)
if failure:
# evaluate msg. It MUST be the last argument.
msg = expr_eval(args[-1], context)
if msg is None:
msg = failure
else:
if isinstance(msg, tuple):
msg = ' '.join(str(v) for v in msg)
msg = '{}: {}'.format(failure, msg)
if config.assertions == "warn":
# if config.log_level == "processes":
print("FAILED:", failure, end=' ')
print("FAILED:", msg, end=' ')
else:
raise AssertionError(failure)
raise AssertionError(msg)
else:
if config.log_level == "processes":
print("ok", end=' ')

# any (direct) subclass MUST have a compute method with "msg" as its the last argument.
def compute(self, context, msg=None):
raise NotImplementedError()


class AssertTrue(Assert):
funcname = 'assertTrue'

def eval_assertion(self, context, value, msg=None):
def compute(self, context, value, msg=None):
if not value:
return str(self.args[0]) + " is not True"


class AssertFalse(Assert):
funcname = 'assertFalse'

def eval_assertion(self, context, value):
def compute(self, context, value, msg=None):
if value:
return str(self.args[0]) + " is not False"


class ComparisonAssert(Assert):
inv_op = None

def eval_assertion(self, context, v1, v2, msg=None):
def compute(self, context, v1, v2, msg=None):
result = self.compare(v1, v2)
if isinstance(result, tuple):
result, details = result
else:
details = ''
if not result:
op = self.inv_op
if isinstance(msg, tuple):
msg = ' '.join(str(v) for v in msg)
msg = ': {}'.format(msg) if msg is not None else ''
# use %r to print values. At least for floats on python2, this yields to a better precision.
return "%s %s %s (%r %s %r)%s%s" % (self.args[0], op, self.args[1],
v1, op, v2, details, msg)
return "%s %s %s (%r %s %r)%s" % (self.args[0], op, self.args[1],
v1, op, v2, details)

def compare(self, v1, v2):
raise NotImplementedError()
Expand Down Expand Up @@ -259,14 +274,9 @@ def compare(self, v1, v2):

class AssertRaises(Assert):
funcname = 'assertRaises'
no_eval = ('expr', 'msg')

# avoid normal argument evaluation because we need to catch exceptions
# using no_eval for this does not work because "expr" is not in argspec (because argspec is computed from the
# "compute" method)
def _eval_args(self, context):
return self.args, dict(self.kwargs)

def eval_assertion(self, context, exception, expr):
def compute(self, context, exception, expr, msg=None):
expected_exception = eval(exception)
try:
expr_eval(expr, context)
Expand Down
50 changes: 50 additions & 0 deletions liam2/tests/functional/simulation.yml
Expand Up @@ -302,6 +302,55 @@ entities:
bp:
- breakpoint(2002)

test_assert:
# check that assertions function do not raise when their "assertion" is respected
- assertTrue(all(age >= 100), msg="we have persons with negative age!")
- assertTrue(True)
- assertFalse(False)
- assertEqual(42, 42)
- assertEqual(42, 43 - 1)
- assertNanEqual(42, 42)
- assertNanEqual(42, 43 - 1)
- assertNanEqual(nan, nan)
- assertEquiv(42, 42)
- assertEquiv([42, 42], 42)
- assertIsClose(1, 1)
- assertIsClose(1., 1.0000001)

# check that we get an assertion when trying to access some invalid index and that
# assertRaises catch it properly
- assertRaises('IndexError', id[1234567890])

# check that assertRaises raises NameError when used with an invalid Exception name
- assertRaises('NameError', assertRaises('YADA', True))

# check that assertions function raises when their "assertion" is not respected
- assertRaises('AssertionError', assertTrue(False))
- assertRaises('AssertionError', assertFalse(True))
- assertRaises('AssertionError', assertEqual(0, 1))
- assertRaises('AssertionError', assertNanEqual(0, 1))
- assertRaises('AssertionError', assertEquiv(0, 1))
- assertRaises('AssertionError', assertIsClose(0, 1))

# check that the different ways to pass the msg argument works
- assertRaises('AssertionError', assertTrue(False, "False is NOT True !"))
- assertRaises('AssertionError', assertTrue(False, msg="False is NOT True !"))
- assertRaises('AssertionError', assertTrue(False, msg=("False", "is", "NOT", "True", "!")))
- assertRaises('AssertionError', assertTrue(all(age > 1000), ("humans do no live that long!", age)))
- assertRaises('AssertionError', assertTrue(all(age > 1000), msg=("humans do no live that long!", age)))
- assertRaises('AssertionError', assertTrue(all(age > 1000), msg=dump(age, filter=id < 10)))
- assertRaises('AssertionError',
assertTrue(all(age > 1000), msg=csv(dump(age, filter=id < 10), fname='error_dump.csv')))
- assertRaises('AssertionError', assertTrue(all(age > 1000), msg=breakpoint()))

# check that the "usual way" to specify a custom message works for all assert functions
- assertRaises('AssertionError', assertTrue(False, msg="False is NOT True !"))
- assertRaises('AssertionError', assertFalse(True, msg="True is NOT False !"))
- assertRaises('AssertionError', assertEqual(0, 1, msg="0 and 1 are NOT equal !"))
- assertRaises('AssertionError', assertNanEqual(0, 1, msg="0 and 1 are NOT nan equal !"))
- assertRaises('AssertionError', assertEquiv(0, 1, msg="0 and 1 are NOT equivalent !"))
- assertRaises('AssertionError', assertIsClose(0, 1000, msg="0 and 1000 are NOT close !"))

expand:
- last_non_clone_id: max(id)
- toclone: household.clone_id != -1
Expand Down Expand Up @@ -2883,6 +2932,7 @@ simulation:
processes:
- person: [
# bp,
test_assert,
compute_agegroup,
test_remove,

Expand Down

0 comments on commit 2276a81

Please sign in to comment.