Currently, when an assert statement fails in Python, it raises a generic AssertionError with no context unless a message is explicitly provided. This can make debugging significantly harder.
Example:
assert 1 == 2
# Current output:
# AssertionError
Suggestion:
Enhance the assert statement so that, by default, the raised AssertionError includes the evaluated values of the expression.
a = 5
b = 6
assert a == b
# Suggested output:
# AssertionError: 5 != 6
Motivation:
Helps developers immediately see why the assertion failed.
Saves time during debugging by removing the need to write custom messages like:
assert a == b, f"{a} != {b}"
Makes assert more informative out of the box, especially for beginners.
Similar behavior is already implemented in pytest via AST rewriting.
Possible Implementation Ideas:
Inject a descriptive error message at compile time using AST transformations.
Could be controlled by a runtime flag or environment variable (e.g. -X assert-details or PYTHONASSERTDETAIL=1)
Should not impact performance when assert is optimized away with -O.
Backward Compatibility:
No breaking change: current behavior would remain the default in production (optimized) mode.
Assertion messages would only be enhanced during normal (non-optimized) runs.
Conclusion:
This change would significantly improve Python’s developer experience, especially during debugging and testing, without sacrificing performance or compatibility.
I’d love to hear thoughts on this idea, and would be happy to help explore or contribute to an implementation if it’s accepted.
One option is that the expression evaluation will be compiled in some special way giving hints were exactly the assertion failed and with which values. I don’t know how much work is that, but I’m pretty sure, it is not trivial at all.
If we want to keep it simple, yet helpful, a dump of all involved variables might be easy to implement.
failed assertion: a==b
where:
a is 1
b is 2
Probably also with attribute access:
failed assertion: task.name == "reader"
where:
task.name is "writer"
But without calling functions. Assertion a == b and factorial(c) == d will not evaluate factorial(c) if the first equality test fails. Variable c could be huge or even non-numeric. We should not try to print the result of a function call.
Two more remarks:
I think, we should not change the expression as in the original post. Failed assertion a == b or c == d should not print something like 1 != 2 and 3 !=4, because it is confusing (IMO at least).
Finally, this new information should be probably added as an exception note - for maximal backward compatibility.
When instances of a class don’t have unique representations,
class A:
def __repr__(s):
return 'An A'
a = A()
b = A()
assert a == b
or their representations raises exceptions
a = 2**222222
b = 2**222222 + 1
assert a == b
one still wouldn’t have any information from the message. pytest prints
E AssertionError: assert <[ValueError('Exceeds the limit (4300) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit') raised in repr()] int object at 0x5dd8ce70a460> == 4444445
Having the message made for you could also mean that the choice of how the message looks like is taken away from you. I hope that if this is done one still has the option to override it.
Hi, I am against it so far, as the expression being tested may involve many variables and/or values with long string representations, or an expression needing too much thought to understand from mere values.
assert all(mn <= datum < mx
for mn, mx, datum in zip(ranges, ranges[1:], data)), "A datum out of range"
The above is easy to write, but I can’t see what auto variable printing would add to this?
A message about the invariant being checked by the assertion really should be plenty for properly used asserts. I don’t see a reason to make the handling here more complex in any way.
pytest doing other things around it may be useful for tests that transform how asserts are handled, but it doesn’t need to be language-level behavior. asserts in normal code should be for things that should never be false; a failure in one of these should result in reverting the change that broke the assumed invariant or changing the code to no longer rely on that invariant if the change was otherwise desirable.
I wrote the “pretty assert” plug-in for nose2 thinking that reading frame locals from the point of the exception + re-parsing the assertion lines would let me do this nicely. Turns out that cases like the one you showed have some fairly useless output, since we can’t capture the interesting bits of the assertion.
And while assertions with side effects (like consuming a generator) are poor practice in production code, they’re plentiful in tests.
I’m interested in the aforementioned proposal to bring some of the pytest semantics to the language. But one thing I don’t know right now – and look forward to reading about – is what the motivation is for it.
In this thread, I think what’s missing is a clear motivating user story.
Just a hot take to increase the controversy: it should use the walrus operator to show the failure, so that the error message would be valid Python code.
>>> a = 0
>>> b = 1
>>> assert a * 2 == b
AssertionError: (a := 0) * 2 == (b := 1)
And now I can copy paste (a := 0) * 2 == (b := 1) into the REPL to dissect the problem.
It could just keep the reference to the factorial(c) return value. No need to re-evaluate anything. That does mean that if a == b fails there won’t be a return value, but that’s OK because it’s not relevant to what failed.
Shortcut operators are not a problem. Staying with the example, the assert would compile into something functionally like this:
__trove = []
__a = a
__trove.append(('a', __a))
__b = b
__trove.append(('b', __b))
if not (__a == __b):
raise AssertionError(subexpressions=__trove)
__f = factorial(c)
__trove.append(('factorial(c)', __f))
__d = d
__trove.append(('d', __d))
if not (__f == __d):
raise AssertionError(subexpressions=__trove)
(Using double underscore to indicate synthetic variables internal to the implementation. I am aware that many of the synthetic variables are redundant, and that more things could be stored. This is just to show the concept.)
Nothing is stored under the label factorial(c) if a != b, but then you wouldn’t want that anyway.
We are planning to migrate pytest towards using a dictionary that tracks subecpression statement source location as those are also used for nicer tracebacks
While pytest’s assertion rewriting can be useful it can also be not useful in many situations. It makes bigger pyc files, slows test collection, generates more complicated output etc. There is an option --assert=plain that I regularly use.
I don’t think that this feature is good idea as part of the general runtime. At best it should be off by default but if it was off by default then I expect that it would be used too little to be worth adding.
If it is possible to improve the error message purely at traceback rendering time without any prior ast rewriting, extra redundant byte code or .pyc compilation etc then that might be reasonable.
These error messages are nice:
>>> a = 'asd'
>>> a.uppe()
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
a.uppe()
^^^^^^
AttributeError: 'str' object has no attribute 'uppe'. Did you mean: 'upper'?
I think that error message is only generated after the fact though from the data contained in the AttributeError object:
>>> try:
... a.uppe()
... except AttributeError as e:
... f = e
...
>>> f.obj
'asd'
>>> f.name
'uppe'
It isn’t posssible to improve just the error message at traceback rendering time unless it can be assumed that the expressions being asserted do not change state when evaluated, which is usually a dangerous assumption to make. The values of the two operands of a binary operator are otherwise consumed by the operation and unrecoverable by any subsequent code.
One possible approach to implementing this feature would be to make the compiler generate two COPY bytecodes before the outermost comparison operator in an expression bound for the assert statement, so the operation would consume only copies of the operands. There would then be bytecodes that effectively builds an error message of f'{stack[-2]} != {stack[-1]}' (depending on what the outermost comparison is) as the second argument for the call to AssertionError should the assertion fail, and there would be two POP_TOPs to clean up the operands if the assertion succeeded. All of these additional bytecodes can be skipped with the -O option.
In the mean time, one can create a dummy unittest.TestCase to call one of its assert methods instead. To make the assert methods return True when successful (so the assert statement is happy) and to make them available in the global namespace, here’s my ugly hack:
from unittest import TestCase
_DUMMY_TESTCASE = TestCase()
globals().update(
(name[6:], lambda *args, method=obj: not method(_DUMMY_TESTCASE, *args))
for name, obj in vars(TestCase).items() if name.startswith('assert')
)
assert Equal(1, 2) # AssertionError: 1 != 2