Are there any type checkers that support reporting of unnecessary assertions?
E.g. if there was test_call that used to return possible None value but None return value was deprecated and it returns just object, so all assertions that were used for typing became unnecessary as they always return True.
# def test_call() -> object | None: ...
def test_call() -> object: ...
def main() -> None:
res = test_call()
# Report as unnecessary assertions as they always true?
assert isinstance(res, object)
assert res is not None
object | None is just object (None is an object). isinstance(res, object) will always return True regardless of what res is, while res is not None has ambiguous truthiness.
Pyright should be able to report the first when used as an if condition (not in an assertion), but for some reason it does not collapse object | None into object:
def _() -> None:
a = f()
b = g()
reveal_type(a) # object | None
reveal_type(b) # object
if isinstance(a, object): # no error
reveal_type(a) # object
if isinstance(b, object): # error: Unnecessary isinstance call
reveal_type(b) # object
if a is not None: # no error
reveal_type(a) # object
if b is not None: # no error
reveal_type(b) # object
Youâre absoulte right, it wasnât a perfect example.
Hereâs a better example. With Pyright indeed it seems to work if strict is enabled and only for if statements but not for assert. pyright-play
# pyright: strict
class A: ...
# def test_call() -> A | None: ...
def test_call() -> A: ...
def main() -> None:
res = test_call()
# Report as unnecessary assertions as they always true?
assert isinstance(res, A)
assert res is not None
# Unnecessary isinstance call; "A" is always an instance of "A"
if isinstance(res, A):
pass
# Condition will always evaluate to True since
# the types "A" and "None" have no overlap
if res is not None:
pass
In the general case, if something is being asserted then it should always be true. If itâs not then thatâs a bug, especially as the assertions are removed when running with -O.
Even in the case where the assertion is checking a type narrowing expression that doesnât actually narrow the type I donât think this should be reported for assertions.
If the intention of the assert statement is to confirm that something that should be true is actually true when developing/debugging, then the type narrowing type checkers do is a side effect. (It may be why you might add such an assertion, but itâs not the only function the assert statement performs). The type checker canât know if itâs there purely for type narrowing or if itâs left there as a sanity check and so probably shouldnât consider it an error.
You may want to propose this to the Ruff type checker or linter. Assertions that are statically true can be made to demand a # noqa: RUF... to indicate that youâre sure you want the assertion.
Yes, it should always be true when code is actually executed but itâs not the same as some statement being statically true.
There could be checks like assert isintance(arg, A) in def func(arg: A) if for some reason we really want to be sure that arg is A at runtime. But if weâre talking about the strict typing, in theory, other types besides A should never be passed as arg, so there should be no need to check something that is statically always True and assert is unnecessary in this case.
I donât know how common this use of assert is by but typically I use assert when I know from some other external conditions (type system isnât perfect) that for example itâs impossible that some arg type will be None. assert is short, doesnât create nesting, doesnât require additional handling if it fails and it prevents code from going in the wrong direction if bug occurred.
static_assert seems very different, not sure what are the usecases for it but it doesnât work with isinstance.
from knot_extensions import static_assert
from typing import assert_type
def f(a: str):
static_assert(1 + 1 == 2)
static_assert(None is None)
static_assert(a is not None)
# Static assertion error: argument of type `bool` has an ambiguous static truthiness
static_assert(isinstance(a, str))
If you need to guarantee that arg is A at runtime you should not be using an assert as assert statements are removed at runtime under -O.
As you say later, they are to be used when you know itâs not possible for the assert not to be True, because at runtime there is no guarantee the assert will even be there. As such they are not there[1] to prevent things from going wrong if a bug occurs.
I guess the crux of the issue is that technically all asserts should be unnecessary, they are there for development and debugging and I donât think that because a type checker also believes the expression to be True it should tell you to remove it.
I think youâre missing the point of the issue. All asserts check things that are supposed to be true.
However, some asserts are statically true, and some are statically undecided. Statically true assertions are more likely to have been added or left in code by mistake.
For example
def f(x: float | None) -> None:
if x is None:
x = 1.0
assert x is not None # Possibly added by mistake for the benefit of some type checker.
...
Such assertions can be added to help static type checkers narrow types. I agree that type checkers should probably not warn on extraneous asserts by default, but having an option to warn can help remove unnecessary assertions.
On the other hand, sometimes an interface has many untyped clients, in which case you may want such assertions:
def f(x: float ) -> None:
assert x is not None # Added to mitigate calling bugs and provide early warning.
...
These two situations are indistinguishable except by a comment, e.g., # noqa: RUF... for some new error code.
If the goal is purely to correct a static type checker, then typing.cast should be used and not assert. A cast call that isnât performing any narrowing is already raised as an error by mypy and pyright in strict mode.
Yes thatâs part what I was trying to get at, other than pushing back on any suggestion that an assert provides a runtime guarantee.
The type checker knows that itâs not doing any narrowing based on the assert, but it canât necessarily distinguish between an assert thatâs only there to perform type narrowing for a type checker and one thatâs there as a debug check and as such it shouldnât be telling you to remove all of them.
If a static tool does want to add support for erroring on assertions it believes are only there for type narrowing it should not be something that is enabled by default[1]. I donât think itâs good if an update to a tool suddenly starts highlighting valid asserts and potentially leads to people removing the assert to avoid having to put # noqa comments everywhere.
or, Iâd argue in any âstandardâ strict mode âŠď¸
cast is just unsafe as itâs discarding available typing information completely.
# pyright: strict
from typing import cast
def test(a: str):
b = cast(int, a)
# No errors.
print(b + 25)
Regarding runtime guarantees - yes, there is -O that disables asserts but there are cases when itâs not used, and type checkers already taken position where they do accept assert as being enabled since they do type narrowing based on the assert.
# pyright: strict
def test(a: str | int):
assert isinstance(a, str)
# information: Type of "a" is "str"
# note: Revealed type is "builtins.str"
reveal_type(a)
Assertions not being true are not necessarily bugs. Assertions can be used to indicate assumptions that a piece of code is making. They are used to indicate that this piece of code is intended to work as expected under those assumptions. The implication that if the assumption is not satisfied, then something it wrong, is not necessarily satisfied. For every piece of code it is possible to find valid uses that are outside of what it was intended for.
I think typing.cast is inferior for most uses because it doesnât do any verification at runtime.
Right sure. They can decide what they want to do. Thatâs why I suggested filing this as an issue with Red Knot so that they can decide if they like it, and whether it should be on by default. (They will probably collect some statistics about false positives/true positives before deciding?)
Short version: assert statements are there for development. They are not to be relied upon for any verification purposes in production. As a development and debugging tool I donât think that type checkers should be flagging them for removal.
I think I was fairly clear in stating this was the case if the goal was purely to correct a static type checker, not if you also wanted to do any checking at runtime.
Perhaps âat runtimeâ is too vague for the point Iâm trying to make.
If you need verification in production, you should not be using an assert because thereâs no guarantee it will be there. You should be checking the type and raising a TypeError if it is incorrect. Strict pyright will already tell you if it believes you donât need these checks[1].
If you only want verification when developing/debugging then an assert is fine, itâs there to confirm that something you assume to be True is in fact True (and to make sure during development you donât unintentionally make it False). If a static type checker also assumes itâs True that doesnât necessarily mean you no longer want the check for debugging purposes (perhaps both you and the type checker are incorrect).
This may be a minor quibble, but I donât believe the logic is based on the assert being âenabledâ. By using the assert statement the expression is declared as True whether or not itâs actually being checked at runtime. As the expression âmustâ be True, the type checker can then narrow accordingly.
Certainly theyâre used to indicate such assumptions, but if the assumptions arenât satisfied then youâll be getting AssertionErrors during development. Iâm not sure under which circumstances this isnât a bug in someoneâs code? Even if that bug is incorrect assumptions.
I donât think I really like this behaviour as this could be user facing code that gives a nice TypeError with a clear message instead of a more confusing AttributeError for example triggered further on. Thankfully mypy doesnât consider this an error. âŠď¸
Well, a behavior is a bug when one had an intension but by accident something is going against that intension. If code is going to be used with new intensions, doors are open for all creativity to imagine scenarios of behaviors that would formerly be bugs but now are not.
For example, take this function written with the intension to solve degree one polynomial equations ax+b=0
def f(a, b):
assert a != 0
return -b/a
Here is a change of intension, that arguably is valid and in which the assertion is false. I want now to see what happens for numpy.float64
import numpy as np
a = np.float64(0.0)
b = np.float64(1.0)
c = f(a, b)
print(f'{c=}')
We run it and the assertion complains that we are using it outside the original intension. Fine. We running it with -O and we get a result that says some information about numpy.float64 and about the original function f, when used in this new environment.
I think you are having a very specific idea of code in mind. One context in which the behavior of software is common to change from being seen as bugs to just expected behavior is code acting on code. Think of testing or code analysis. Code could be taking the example function f and running it under different scenarios, including some for which f was not initially designed. The behavior of f in those are not bugs of this new code that is using f. A bug of the new code would be to not handle some exception coming from f and not being able to report or process what f did, for example.
It is data/information for the new code that f without -O and certain inputs can fail an assertion, that with -O it outputs something with whatever meaning for inputs that made the assertion fail.
This has gone off topic to whether or not a type checker should treat an assertion with an expression it believes to be True statically as something it should warn you about.
In general however, assertions should not be used for input validation (as they are in your example). Invalid user input should trigger a TypeError or a ValueError or something similar, not an AssertionError.