Add alternatives to TypeIs for user-defined assertions

unittest.TestCase provides a number of assert helpers. A number of these perform type narrowing but it is not currently possible to express this with existing type syntax because there is no way to indicate what I’m calling “user-defined assertions” (I’m sure there’s a better/pre-existing term). This results in duplicate code like the following:

def test_foo(self):
    foo = optionally_get_thing()
    self.assertIsInstance(foo, Foo)
    assert isinstance(foo, Foo)
    self.assertIn(foo.data, 'value')
    assert 'value' in foo.data
    self.assertTrue(foo.data['value'])
    assert foo.data['value'] is True  # not really needed but you get the idea

I would like to propose a number of additional typing primitives to allow us to describe these user-defined assertions. While my ostensible focus would be annotating the unittest (and testtools) stubs, I’m hoping that these are generic enough to have use elsewhere. I’d be happy to explore PEP work, but obviously want to see if this idea is sane first so, please, let me know what you think.


TypeAssertIs[T], TypeAssertIsNot[T]

These would behave similarly to TypeIs but instead of expecting a boolean return value, they would return None on success and raise an exception on error. Like TypeIs, the first positional non-self argument would be narrowed to T. This would allow us to describe assertIsInstance, assertIsNotInstance, assertIsNone and assertIsNotNone.

def assertIsInstance(
    self, obj: object, cls: type[_T], msg: object = ...
) -> TypeAssertIs[_T]: ...


def assertNotIsInstance(
    self, obj: object, cls: type[_T], msg: object = ...
) -> TypeAssertIsNot[_T]: ...


def assertIsNone(
    self, expr: object, msg: object = ...
) -> TypeAssertIs[None]: ...


def assertIsNotNone(
    self, expr: _T | None, msg: object = ...
) -> TypeAssertIsNot[None]: ...

Which for anyone not familar with unittest are used at runtime like so:

x: str | int = get_value()
self.assertIsInstance(x, str)
reveal_type(x)  # str

x: str | int = get_value()
self.assertIsNotInstance(x, str)
reveal_type(x)  # int

y: str | None = maybe_str()
self.assertIsNone(y)
reveal_type(y)  # None

y: str | None = maybe_str()
self.assertIsNotNone(y)
reveal_type(y)  # str

I believe Eric Traut previously suggested something similar here.

(note: we may also wish to explore adding TypeIsNot[T]to avoid leaving a gap here, though I don’t know how useful this would be in practice)

TypeAssertCondition, TypeAssertNotCondition

This one’s needed for assertTrue and assertFalse. In this case, the first positional non-self argument would be an expression that returns a boolean value.

def assertTrue(
    self, expr: object, msg: object = ...
) -> TypeAssertCondition: ...

def assertFalse(
    self, expr: object, msg: object = ...
) -> TypeAssertNotCondition: ...

This would give the following behavior:

x: str | int = get_value()
self.assertTrue(isinstance(x, str))
reveal_type(x)  # str   <-- propagated from isinstance() narrowing

y: str | None = maybe_str()
self.assertTrue(y is not None)
reveal_type(y)  # str   <-- propagated from is-not-None narrowing

z: Literal["a", "b", "c"] | int = get_val()
self.assertTrue(z == "b")
reveal_type(z)  # Literal["b"]   <-- propagated from equality narrowing

I suspect this one would be a lot trickier to implement and my understanding of type logic and the mypy/pyright/ty/pyre/… codebases is not strong enough to know if it’s actually feasible or not, but I expect this to be very powerful.


Additional, nice-to-haves

TypeAssertSubclass[T], TypeAssertNotSubclass[T]

These handle assertIsSubclass, assertIsNotSubclass. This behaves similar to TypeAssertIs but it works with two types instead of an instance and a type.

def assertIsSubclass(
    self, cls: type, superclass: type[_T], msg: object = ...
) -> TypeAssertSubclass[_T]: ...

def assertIsNotSubclass(
    self, cls: type, superclass: type[_T], msg: object = ...
) -> TypeAssertNotSubclass[_T]: ...

This would give the following behaviour:

klass: type = get_class()
self.assertIsSubclass(klass, Base)
reveal_type(klass)  # type[Base]

Unlike the above, I don’t know how much value this would give for non-unittest users, though it certainly would be nice to avoid the aforementioned duplication of statements.

TypeAssertIn, TypeAssertNotIn

These handle assertIn, assertNotIn. The first non-self positional argument is the member and the second is the container. This would be helpful outside of unittest cases to e.g. distinguish between classes via a discriminated union.

def assertIn(
    self, member: object, container: Container[_T], msg: object = ...
) -> TypeAssertIn: ...

def assertNotIn(
    self, member: object, container: Container[_T], msg: object = ...
) -> TypeAssertNotIn: ...

With usage like so:

x: str = get_key()
VALID: Final = ("alpha", "beta", "gamma")
self.assertIn(x, VALID)
reveal_type(x)  # Literal["alpha"] | Literal["beta"] | Literal["gamma"]

status: str | int = get_value()
self.assertIn(status, [200, 404, 500])
reveal_type(status)  # Literal[200] | Literal[404] | Literal[500]
1 Like

Isn’t this whole self.assertTrue(… setup anachronistic? Pytest has a far superior interface. Why spend a lot of energy improving this?

I fully support the core of this idea.
See also: Request: an AssertingTypeGuard type for TypeGuard-like semantics · Issue #930 · python/typing · GitHub

(Which has links from the ty issue tracker, and I think is generally understood by the typing folks in that issue to be a valid use case.)

I would suggest that only the minimum number of new constructs be added. I don’t think the conditional propagation idea is as well motivated as the type assertion one.

  1. Lots of people use unittest, even if you don’t
  2. This paradigm appears in other contexts

I added a version of this to pycroscope some years ago: pycroscope/pycroscope/extensions.py at 5f579e3d1af18f2b074e505bb82fedb582734a08 · JelleZijlstra/pycroscope · GitHub

def assert_is_int(arg: object) -> Annotated[bool, NoReturnGuard["arg", int]]:
    assert isinstance(arg, int)

I haven’t had much use for it, but feel free to use it if it is useful for you!

I did find an interesting internal use for this mechanism in pycroscope. It is used for pycroscope’s internal tracking of mutable containers, which is not entirely safe but practically useful.

def f(x: int, y: str) -> None:
    lst = [x]
    reveal_type(lst)  # <list containing [int]>
    lst.append(y)
    reveal_type(lst)  # <list containing [int, str]>

Internally, list.append is implemented like an assertion that sets the type of list to a new type that includes the new list member.

2 Likes