Search code examples
pythontypesmypy

Can annotations be used to narrow types in Python


I think in reality I'm going to use a different design, where _attrib is set in the construct and can therefore not be None, however I'm fascinated to see if there's a way to make MyPy happy with this approach. I have a situation where an attribute (in this instance _attrib) is set after the construction of the Thing and there are a number of methods which require it to be set. It seamed reasonable, therefore, to spin up a teeny decorator to validate if the _attrib was set and to chuck an exception if it's not. The below code, however, causes MyPy to still complain - although the error is Item "None" of "Optional[Any]" has no attribute "upper", so I think the type of self is getting completely lost. I'm also getting Incompatible return value type (got "Callable[[Any, VarArg(Any), KwArg(Any)], Any]", expected "F") on return inner.

class AttribIsNone(Exception):
    ...

F = TypeVar("F", bound=Callable[...,Any]
def guard(func: F) -> F:
    def inner(self, *args, **kwargs):
        if self._attrib is None:
            raise AttribIsNoneException()
        return func(self, *args, **kwargs)
    return inner

class Thing:
    _attrib: str | None

    @guard
    def guarded_function(self) -> str:
        return self._attrib.upper()

Ideally I think I'd bind F to something like Callable[[Thing,...], Any] but that's not a valid thing to do right now.

Similarly I tried creating a TypeVar for the return value:

R = TypeVar("R")
F = TypeVar("F", bound=Callable[...,R]
def guard(func: F) -> F:
    def inner(self, *args, **kwargs) -> R:
        ...

However this is not allowed either.

PEP612 offers a ParamSpec but I can't seem to work out how to construct one, and I'm not even sure if I did that it would help much!


Solution

  • Yes, you can do what you're looking for in the static typing system (whether it's a good idea in practice is based on your actual use case). Here's an example to make it work.

    First, Thing needs to be a generic with respect to its _attrib type:

    _AttribT = TypeVar("_AttribT", str, None)
    
    class Thing(Generic[_AttribT]):
        _attrib: _AttribT
    

    Then, disallow @guard from accepting self: Thing[None]:

    from __future__ import annotations
    
    from typing import *
    
    
    class AttribIsNoneException(Exception):
        ...
    
    
    S = TypeVar("S", bound="Thing[Any]")
    P = ParamSpec("P")
    R = TypeVar("R")
    _AttribT = TypeVar("_AttribT", str, None)
    
    
    def guard(
        func: Callable[Concatenate[Thing[str], P], R]
    ) -> Callable[Concatenate[Thing[str], P], R]:
        def inner(self: S, /, *args: P.args, **kwargs: P.kwargs) -> R:
            if self._attrib is None:
                raise AttribIsNoneException()
            return func(self, *args, **kwargs)
    
        return inner
    

    Instance methods of Thing will then be forced to be annotated with self: Thing[str] if decorated with @guard:

    class Thing(Generic[_AttribT]):
        _attrib: _AttribT
    
        @guard  # Static typing errors
        def bad_guarded_function(self) -> str:
            return self._attrib.upper()  # Static typing errors
    
        @guard
        def guarded_function(self: Thing[str]) -> str:
            return self._attrib.upper()
    

    In practice, you will need to ensure that you're accessing guarded_function through a Thing[str] instance and not a Thing[None] instance. I don't really know what your code looks like, but here's one possible implementation:

    class Thing(Generic[_AttribT]):
        _attrib: _AttribT
    
        def __new__(cls, attrib: _AttribT) -> Thing[_AttribT]:
            thing: Thing[_AttribT] = super().__new__(cls)
            thing._attrib = attrib
            return thing
    
        @guard
        def guarded_function(self: Thing[str]) -> str:
            return self._attrib.upper()
    
    
    >>> string_thing = Thing("asdfjkl")
    >>> string_thing.guarded_function()
    >>>
    >>> none_thing = Thing(None)
    >>> none_thing.guarded_function()  # mypy: Invalid self argument "Thing[None]" to attribute function "guarded_function" with type "Callable[[Thing[str]], str]" [misc]