typing icon indicating copy to clipboard operation
typing copied to clipboard

Assertion and checking functions

Open srittau opened this issue 5 years ago • 19 comments

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
        ...

srittau avatar Feb 19 '20 11:02 srittau

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]: ...

TeamSpen210 avatar Feb 20 '20 09:02 TeamSpen210

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")

srittau avatar Feb 20 '20 10:02 srittau

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

graingert avatar May 11 '21 10:05 graingert

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.

JelleZijlstra avatar May 11 '21 13:05 JelleZijlstra

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 avatar May 11 '21 18:05 gvanrossum

~@gvanrossum self is already not None, it's an instance of unittest.TestCase~

graingert avatar May 11 '21 18:05 graingert

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.

JelleZijlstra avatar May 11 '21 18:05 JelleZijlstra

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.).

wsanchez avatar May 11 '21 18:05 wsanchez

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 avatar May 11 '21 18:05 gvanrossum

@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 ===============================

graingert avatar May 11 '21 19:05 graingert

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?

gvanrossum avatar May 11 '21 20:05 gvanrossum

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

graingert avatar May 11 '21 20:05 graingert

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.

gvanrossum avatar May 11 '21 20:05 gvanrossum

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.

carljm avatar May 11 '21 21:05 carljm

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.

gvanrossum avatar May 11 '21 21:05 gvanrossum

@overload doesn't work for narrowing function input types

graingert avatar May 11 '21 21:05 graingert

Implemented by PEP 647.

srittau avatar Nov 04 '21 12:11 srittau

Implemented by PEP 647.

My understanding was that assertions were not supported by pep 647

graingert avatar Nov 04 '21 12:11 graingert

Good point, I misunderstood my own proposal. :)

srittau avatar Nov 04 '21 12:11 srittau