Assertion and checking functions
I'll just leave this here for potential future consideration as it came up on gitter's typing-dev channel. Typescript has something called assertion functions, which enables the type checker to understand that a function does not return if a certain condition is false. I think something similar, but more generalized, would be interesting, especially for unit test functions like assert_is_instance(), assert_true() etc.
def assert_positive(x: object) -> AssertsInstance["x", int]:
assert isinstance(x, int) and x > 0
Related, checking functions could work like this:
def is_positive(x: object) -> ChecksInstance["x", int]:
return isinstance(x, int) and x > 0
def my_func(x: Union[int, str]) -> None:
if is_positive(x):
# x must be an int
...
else:
# x can be an int or str
...
This is already possible with overload and Literal, though I don't think any type checkers can take advantage of this information yet.
@overload
def assert_positive(x: int) -> bool: ...
@overload
def assert_positive(x: object) -> NoReturn: ...
@overload
def is_positive(x: int) -> Literal[True]: ...
@overload
def is_positive(x: object) -> Literal[False]: ...
That is a clever trick that works for some functions like assert_is_instance(), but in the examples given it does not work, since it ignores the non-type check part. Another example is type narrowing, based on object fields:
class Proto(Protocol): ...
class SubProto(Proto):
sub_field: int
class MyClass: # implements protocol SubProto
sub_field = 42
def is_sub_proto(o: Proto) -> ChecksInstance["o", SubProto]:
return hasattr(o, "sub_field")
this would be almost covered with https://www.python.org/dev/peps/pep-0647/ - however type narrowing only works by returning a bool not by raising an exception
I'd like to be able to do:
class TestFoo(unittest.TestCase):
def testFoo(self):
foo: Foo | None = system_under_test()
self.assertIsNotNone(foo)
reveal_type(foo) # Foo
pyanalyze supports this; I call it "no-return-unless constraints". However the feature is currently only accessible from plugins, though it wouldn't be much work to add a user-visible feature. You can see it in action in this test case: https://github.com/quora/pyanalyze/blob/master/pyanalyze/test_stacked_scopes.py#L928.
I'd like to be able to do:
class TestFoo(unittest.TestCase): def testFoo(self): foo: Foo | None = system_under_test() self.assertIsNotNone(foo) reveal_type(foo) # Foo
How about if this worked by having to write
assert foo is not None
?
AFAIK that works (in mypy). Or do you really need it to be a function instead? If so, why?
(UPDATED: Fixed typo in example, it previously said assert self is not None.)
~@gvanrossum self is already not None, it's an instance of unittest.TestCase~
Right, that should have been assert foo is not None.
The reason I implemented support for this feature for our codebase is that our unit tests always use functions like assert_eq(actual, expected), assert_is(actual, expected) instead of raw assert so that we get nicer error messages.
Or do you really need it to be a function instead? If so, why?
assert foo is not None works, but is redundant given that self.assertIsNotNone(foo) also does that check, and does so in a manner that works for the test runner being used (presumably it can raise some TestCaseFailedException which is distinct from an AssertionError that was thrown by code besides the test case, or otherwise do things for the benefit of the test runner's result output, etc.).
Okay, so it's a matter of convention of the testing framework used. That makes sense. (I have been using pytest which actually makes pretty messages for assert statements. :-)
@gvanrossum it's not convention - it's about error message are supported by the test framework and how that interacts with type checking tools:
eg consider:
from __future__ import annotations
import unittest
class Foo:
pass
def system_under_test() -> Foo | None:
return None
class TestFoo(unittest.TestCase):
def testFoo(self):
foo = system_under_test()
self.assertIsNotNone(foo)
reveal_type(foo)
def test_foo(self):
foo = system_under_test()
assert foo is not None
reveal_type(foo)
running it under unittest shows:
python -m unittest foo.py
FF
======================================================================
FAIL: testFoo (foo.TestFoo)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/graingert/foo.py", line 14, in testFoo
self.assertIsNotNone(foo)
AssertionError: unexpectedly None
======================================================================
FAIL: test_foo (foo.TestFoo)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/graingert/foo.py", line 19, in test_foo
assert foo is not None
AssertionError
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (failures=2)
running it with pytest shows:
============================= test session starts ==============================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /home/graingert
plugins: celery-0.0.0, anyio-3.0.1
collected 2 items
foo.py FF [100%]
=================================== FAILURES ===================================
_______________________________ TestFoo.testFoo ________________________________
self = <foo.TestFoo testMethod=testFoo>
def testFoo(self):
foo = system_under_test()
> self.assertIsNotNone(foo)
E AssertionError: unexpectedly None
foo.py:14: AssertionError
_______________________________ TestFoo.test_foo _______________________________
self = <foo.TestFoo testMethod=test_foo>
def test_foo(self):
foo = system_under_test()
> assert foo is not None
E AssertionError: assert None is not None
foo.py:19: AssertionError
=========================== short test summary info ============================
FAILED foo.py::TestFoo::testFoo - AssertionError: unexpectedly None
FAILED foo.py::TestFoo::test_foo - AssertionError: assert None is not None
============================== 2 failed in 0.05s ===============================
Apparently I'm not understanding your point.
I thought that your point was that you want to be able to use a function or method that raises to narrow the type, since some testing frameworks (e.g. unittest, but not pytest) require you to use specific methods instead of assert statements. I already conceded that point, to which you replied with "it's not convention" and a long example demonstrating what I just said.
So what am I missing? That mypy doesn't narrow following the method call? I meant to agree with that too. Anything else?
fixing unittest to support fine grained assertion messages natively like pytest, and deprecating unittest.TestCase.assertIs(Not)(None) is my preferred solution. However being able to use a function or method that raises to narrow a type would also solve my immediate problem
Changes to unittest are off-topic for this tracker, though honestly I give it a really low chance of happening any time soon. I guess we're in agreement that we'd like to have a way so that static checkers can narrow based on a function call. PEP 647 isn't it, and I don't see how we could implement this without some new thing in the typing module (functions need to be specifically declared to have this narrowing effect), so if you want this, you will have to go the PEP route.
In principle I think the tools already exist for this with typing.overload and typing.NoReturn. One could imagine this annotation for assertIsNotNone (defined as if it were a standalone function for simplicity):
from typing import overload, NoReturn
@overload
def assertIsNotNone(obj: None) -> NoReturn: ...
@overload
def assertIsNotNone(obj: object) -> None: ...
def assertIsNotNone(obj: object | None) -> None:
# ...
I don't know if any existing typecheckers will currently narrow based on a NoReturn overload like this.
It's cool if that could work (mypy nor pyright currently supports it, but I don't see why they couldn't).
However I don't think it could be made to work for PEP 647 style type guards.
@overload doesn't work for narrowing function input types
Implemented by PEP 647.
Good point, I misunderstood my own proposal. :)