Home > Mobile >  Mypy 'Self' type narrowing for inherited `__init__`
Mypy 'Self' type narrowing for inherited `__init__`

Time:12-26

I'm trying to bring together typing class attributes via generic types pointing at the current instance and inheritance. I ran into a problem that mypy seems to fail in narrowing down generic types bound by the class at creation to the same type as the current self. I want to know if it's a bug or misunderstanding on my side.

Before we start:

  • python version: 3.10.6
  • mypy version: 0.991

Consider a simple scenario of implementing a Node of a tree structure.

Naturally, such a node should have parent and children attributes. These can be optional. But, when they are not None, they should be of the same class as Node.

Let me illustrate that below:

from __future__ import annotations

from collections import abc


class Node:
    def __init__(
        self, parent: Node | None = None, children: abc.Sequence[Node] | None = None
    ):
        self.parent = parent
        self.children = children

Now, if we are to inherit from Node without changing the __init__'s signature, we'd run into trouble: type of children doesn't reflect the inheritance:

from typing_extensions import reveal_type


class SpecialNode(Node):
    ...


sn = SpecialNode()
reveal_type(sn.children)  # Union[Sequence[Node], None]

The correct way out (until PEP-673 is supported, see #11871) should be using generic types bound by Node (see this answer for instance):

import typing as t

T = t.TypeVar('T', bound='Node')


class Node:
    def __init__(
        self: T, parent: T | None = None, children: abc.Sequence[T] | None = None
    ):
        self.parent = parent
        self.children = children

However, inheriting from this class doesn't narrow the type T down to the subclass for the attributes:

sn = SpecialNode()
reveal_type(sn.children)  # Union[Sequence[T`-1], None]

(It's not really clear to me at the moment what -1 stands for, but I assume T is still a type variable bound by Node)

The problem here is that if we were to use sn.children in a function accepting Sequence[SpecialNode], mypy would not see Sequence[T`-1] as an equivalent type and treat this attempt as an error.

def fn(xs: abc.Sequence[SpecialNode]) -> None:
    ...


if sn.children is not None:
    fn(sn.children)  # Argument 1 has incompatible type "Sequence[T]"

What I would want instead is for sn.children to have the type Union[Sequence[SpecialNode], None] or a type variable TypeVar('S', bound=SpecialNode).

One could also attempt to manually narrow down the T type to the current instance's type:

def is_type(x: t.Any, _type: t.Type[T]) -> t.TypeGuard[T]:
    return isinstance(x, _type)


def is_seq_of(s: abc.Sequence[t.Any], _type: t.Type[T]) -> t.TypeGuard[abc.Sequence[T]]:
    return all(isinstance(x, _type) for x in s)


class Node:
    def __init__(
        self: T, parent: T | None = None, children: abc.Sequence[T] | None = None
    ):
        assert parent is None or is_type(parent, self.__class__)
        assert children is None or is_seq_of(children, self.__class__)
        self.parent = parent
        self.children = children

        reveal_type(self.parent)  # Union[T`-1, None]
        reveal_type(self.children)  # Union[Sequence[T`-1], None`]


sn = SpecialNode()
reveal_type(sn.children)  # Union[Sequence[T`-1], None]

However, the revealed type is still Union[Sequence[T`-1], None]; hence the type narrowing didn't work out.

CodePudding user response:

Let me begin with sane solution, that uses master branch of mypy where Self is already supported (it will be added in v 1.0 release).

from collections import abc
import sys

# You may omit this guard and pick the import for your version;
# typing_extensions import will be valid forever, 
# but stl is preferrable in general
if sys.version_info < (3, 11):
    from typing_extensions import Self
else:
    from typing import Self

class Node:
    def __init__(
        self, parent: Self | None = None, children: abc.Sequence[Self] | None = None
    ):
        self.parent = parent
        self.children = children
        
class SpecialNode(Node):
    ...


sn = SpecialNode()
reveal_type(sn.children)  # N: Revealed type is "Union[typing.Sequence[__main__.SpecialNode], None]"

Here's a playground link for this.

However, Self type is not strictly necessary. Your example fails, because T is bound only in __init__, but ends up in attribute annotation, being completely pointless. Check this issue (inspired by your question, though I've seen much simpler cases of the same problem on SO) for some context. You can write an ugly workaround. This shows that mypy actually has some knowledge about _parent and _children attributes (because we don't cast), but doesn't expose it without rain dances.

from collections import abc
import typing as t

_Self = t.TypeVar('_Self', bound='Node')


class Node:
    # Here we require that constructr arguments are really of proper type, 
    # but hide _Self under properties later
    def __init__(
        self: _Self, parent: _Self | None = None, children: abc.Sequence[_Self] | None = None
    ):
        self._parent = parent
        self._children = children
        
    @property
    def parent(self: _Self) -> _Self | None:
        return self._parent
    
    @parent.setter
    def parent(self: _Self, parent: _Self | None) -> None:
        self._parent = parent
    
    @parent.deletter
    def parent(self) -> None:
        del self._parent
        
    @property
    def children(self: _Self) -> abc.Sequence[_Self] | None:
        return self._children
    
    @children.setter
    def children(self: _Self, children: abc.Sequence[_Self] | None) -> None:
        self._children = children
    
    @children.deletter
    def children(self) -> None:
        del self._children
        


class SpecialNode(Node):
    ...


sn = SpecialNode()
reveal_type(sn.children)  # N: Revealed type is "Union[typing.Sequence[__main__.SpecialNode], None]"
reveal_type(sn.parent)  # N: Revealed type is "Union[__main__.SpecialNode, None]"
sn.parent = SpecialNode()
sn.children = []
sn.parent = Node()  # E: Incompatible types in assignment (expression has type "Node", variable has type "Optional[SpecialNode]")  [assignment]
sn.children = [Node()]  # E: List item 0 has incompatible type "Node"; expected "SpecialNode"  [list-item]

Here's playground link for this.

Bonus: if you still have an opportunity to adjust the API, do it, please. There should be one - and preferably only one - way to do something. In your case children = [] and children = None means essentially the same - no children. If it doesn't make real difference (e.g. None meaning "not set yet"), then non-optional sequence would be saner.

Also (again, if mutating original list/set is not intended), I'd use children setter anyway to accept any abc.Iterable[_Self] and convert it to a list or tuple in setter - but this will kill mypy, unfortunately (very annoying issue with unsupported asymmetric properties).

  • Related