Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add static_assert to verify type constraints in mypy #5687

Closed
TV4Fun opened this issue Sep 27, 2018 · 14 comments
Closed

Add static_assert to verify type constraints in mypy #5687

TV4Fun opened this issue Sep 27, 2018 · 14 comments

Comments

@TV4Fun
Copy link
Contributor

TV4Fun commented Sep 27, 2018

Mypy's type checking works well in many cases, but there are some complex cases that are difficult to code for, and would be impossible or costly to check at runtime. I give an example of a case where this would be useful in #5666. While I give a slightly cumbersome possible solution there, another possibility is to use explicit casting:

from abc import ABC, abstractmethod
from typing import Generic, TypeVar, cast

_InputType = TypeVar('_InputType', contravariant=True)
_IntermediateType = TypeVar('_IntermediateType')
_OutputType = TypeVar('_OutputType', covariant=True)


class GenericBase(Generic[_InputType, _IntermediateType, _OutputType], ABC):
    @abstractmethod
    def first_step(self, pipeline_input: _InputType) -> _IntermediateType: ...

    def second_step(self, state: _IntermediateType) -> _OutputType:
        # By default, pass through state unmodified
        return cast(_OutputType, state)

    def execute(self, pipeline_input: _InputType) -> _OutputType:
        state = self.first_step(pipeline_input)
        return self.second_step(state)

This works, but it eliminates type safety, as I have to trust that the implementing class chooses _IntermediateType and _OutputType in a way that that cast makes sense. If these were not TypeVars, I could do a runtime check like assert issubclass(_IntermediateType, _OutputType) to verify that this cast is safe, but obviously Python does not have enough information at runtime to do an issubclass check with TypeVars or parameterized Generics.

I propose adding a static_assert statement, which would be ignored at runtime, but would be capable of evaluating statements about Types in static analysis. So for example, in the code above, I could add:

    def second_step(self, state: _IntermediateType) -> _OutputType:
        # By default, pass through state unmodified
        static_assert issubclass(_IntermediateType, _OutputType)
        return cast(_OutputType, state)

In the definition of the base class, this would be assumed to be true if there were any possible unification of _IntermediateType and _OutputType that would allow it to be true (defaulting to true or producing a warning if this is non-trivial to evaluate), but this static_assert would be re-evaluated, whenever this class was subclassed/instantiated or this method was called, and if it could ever be determined to be false, would cause Mypy to raise an error.

Any thoughts on this?

@TV4Fun
Copy link
Contributor Author

TV4Fun commented Sep 27, 2018

I realize the particular syntax I have suggested here might be difficult to implement, as it would require extending Python so it knows not to evaluate the issubclass call here. An alternative would be to define a function static_assert_issubclass(class, classinfo) that would behave like static_assert issubclass(class, classinfo) but would not require extending Python.

@ilevkivskyi
Copy link
Member

Mypy already has such feature, it is called cast(). Of course it is not exactly the same as you propose, but I believe cast() is sufficient for your purpose.

@gvanrossum
Copy link
Member

gvanrossum commented Sep 29, 2018 via email

@ilevkivskyi
Copy link
Member

Yes, downcast() may be useful indeed. We can try experimenting with it, but the main problem is scheduling/prioritization. There are many other important tasks that needs to be done soon.

@TV4Fun
Copy link
Contributor Author

TV4Fun commented Oct 1, 2018

Mypy already has such feature, it is called cast(). Of course it is not exactly the same as you propose, but I believe cast() is sufficient for your purpose.

@ilevkivskyi My whole point here is that I would like a type-checked cast. So for example, if cast worked the way I wanted, then this should be an error:

from typing import cast, Optional, Generic, TypeVar


class A:
    pass


class B:
    pass


a: Optional[A] = None
a = cast(A, B())  # Should be type error

and the case I would really like to be able to check is this one:

T = TypeVar('T')
U = TypeVar('U')


class C(Generic[T, U]):
    def method(self, t: T) -> U:
        return cast(U, t)

This case might or might not be an error, depending on how T and U are defined, but there is not to my knowledge a way to check that they are defined in compatible ways. If I remove the cast, it is always an error, which is not what I want either.

@JukkaL
Copy link
Collaborator

JukkaL commented Oct 1, 2018

I agree that something like downcast() would be quite useful. I remember discussing similar ideas before, but the discussions never went anywhere. One issue with the proposed semantics would be that only instance types would likely be usable as the target type (or a tuple of classes). Maybe that's fine, though.

@gvanrossum Can you create a separate issue focused on downcast()?

@llchan
Copy link
Contributor

llchan commented Jan 31, 2019

+1 a static_assert would be useful. For my particular use case, I'm not looking to do any casting, but just to add static asserts to tests and such to make sure "compile"-time types are what I expect them to be. My current workflow involves sprinkling in reveal_type() and then verifying manually. Runtime type checks in tests are not sufficient because "compile"-time types may be overly broad (or incorrect).

A general/arbitrary static_assert may be tricky to implement, but as proposed above, maybe static_assert_is_subclass would be a good start, and if we go that route, it'd be good to also include a static_assert_is_same for exact type matches.

@SemMulder
Copy link

SemMulder commented Jul 24, 2019

+1, I'm running into this when converting third party API responses into properly typed dataclasses. I want to be able to write:

raw_response: JsonDict

@dataclass
class ResponseDataclass:
    field: str

ResponseDataclass(
    field=downcast(str, raw_response['field'])
)

I can give a stab at implementing this if a PR is welcome?

@SemMulder
Copy link

SemMulder commented Jul 24, 2019

As an aside, if we could declare lower bounds based on TypeVars we could implement downcast as follows:

TargetType = TypeVar('TargetType')
ValueType = TypeVar('ValueType', lower_bound=TargetType)

def downcast(target: Type[TargetType], value: ValueType) -> TargetType:
    assert isinstance(value, target)
    return value

Or even better if we also have Intersection:

TargetType = TypeVar('TargetType')
ValueType = TypeVar('ValueType', lower_bound=TargetType)

def downcast(target: Type[TargetType], value: ValueType) -> Intersection[TargetType, ValueType]:
    assert isinstance(value, target)
    return value

The latter would also take care of assigning the correct type parameters if e.g. target = dict and value: Union[str, Dict[str, str]].

@JukkaL
Copy link
Collaborator

JukkaL commented Jul 24, 2019

@SemMulder Since we still don't have a consensus of how the feature would behave exactly, the easiest way to move forward might be to add it to mypy_extensions (https://github.com/python/mypy_extensions) and implement support in mypy. Once folks are happy with how it works, we can propose to add it to typing/typing_extensions. I'm open to accepting an implementation to mypy, but it would be good to discuss the precise semantics a bit first -- in particular, what would the behavior be with all possible types, including things like TypedDict. This can happen here or in the mypy issue tracker.

@DustinWehr
Copy link

Is there currently a way to make a version of cast that has only property 1 of #5687 (comment)?
So it behaves exactly like cast for static checking, but does an assert isinstance call at runtime (and of course only works with types compatible with isinstance)?

The function in untyped python is:

# checked cast
def chcast(class_or_class_tuple, x):
    assert isinstance(x, class_or_class_tuple)
    return x

@mthuurne
Copy link
Contributor

mthuurne commented Jan 4, 2020

@DustinWehr: With this annotation your function looks like a cast to mypy:

from typing import Any, Type, TypeVar

T = TypeVar('T')

def checked_cast(t: Type[T], x: Any) -> T:
    assert isinstance(x, t), type(x)
    return x

Note that I dropped the tuple support in the type argument to keep things simple.

If you're going to use this in actual code, it might be better to raise TypeError instead of AssertionError.

@lejar
Copy link

lejar commented Aug 4, 2022

I have a real-world example where this would be useful. In my case I have the data role which determines if the passed value should go into the _data dict. Since the base class has typing.Any as the value, I don't want to restrict it in the function definition.

import typing

from PyQt5 import QtCore


T = typing.TypeVar('T')


class CustomModel(QtCore.QAbstractTableModel, typing.Generic[T]):
    def __init__(self, parent: typing.Optional[QtCore.QObject] = None) -> None:
        super().__init__(parent)
        self._data: typing.Dict[typing.Tuple[int, int], T] = {}

    def setData(self,
                index: QtCore.QModelIndex,
                value: typing.Any,
                role: int = QtCore.Qt.DisplayRole,
                ) -> typing.Any:
        if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
            # static_assert(value, T)
            self._data[index.row(), index.column()] = value

        return super().setData(index, value, role)


def main() -> None:
    app = QtCore.QCoreApplication([])
    model = CustomModel[int]()

    index = model.index(0, 0)
    model.setData(index, 0) # This is okay

    index = model.index(0, 1)
    model.setData(index, '0') # I want mypy to report an error here, but currently it does not because of typing.Any.


if __name__ == '__main__':
    main()

@gvanrossum gvanrossum changed the title Add static_assert to verify type constraints in Mypy Add static_assert to verify type constraints in mypy Aug 4, 2022
@hauntsaninja
Copy link
Collaborator

There are a couple different feature requests here, but the relatively new assert_type can be used to do some simple static-only assertions.

Any additional requests should be discussed at https://github.com/python/typing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests