Search code examples
pythondecoratorpython-decoratorsmypytyping

For type checking, can I use decorators to check optional typed class attributes are defined to prevent None object has no attribute errors?


I have a code where a Main class receives instances of a Child class as attributes. Those attributes are typed as Optional, because they are undefined at application startup, in which case they get initialised.

Methods of the Main instance rely in many places on methods and attributes of those Child objects.

Here is a simplified example of my code, which is synchronous but in my actual app it's all async hencewhy the child does not get instantiated in __init__, and I have a separate initialise method:

import typing

class Child():
    def print_foo(self) -> None:
        print("foo")

class Main():
    def __init__(self) -> None:
        self.child: typing.Optional[Child] = None

    def initialise(self) -> None:
        if not self.child:
            self.child = Child()

    def foo(self) -> None:
        self.child.print_foo()

main = Main()
main.initialise()
main.foo()

I am trying to enforce proper type checking in my application pipeline using mypy.

My problem is that since those child attributes are Optional and initially undefined, mypy complains that

Item "None" of "Optional[Child]" has no attribute "print_foo"  [union-attr]

I could check in every method that the relevant attribute is defined, such as

def foo(self) -> None:
    if self.child:
        self.child.print_foo()
    else:
        raise Exception("child not defined")

but I do not want to do that because that would mean repeating this code for every method that is encountering that problem, and there are many in my application.

One way I tried to solve it is using a decorator:

import typing

def check_defined(func):
    def wrapper(*args, **kwargs):
        _self = args[0]
        if not _self.child:
            raise Exception("child not defined")
        else:
            return func(*args, **kwargs)
    return wrapper

class Child():
    def print_foo(self) -> None:
        print("foo")

class Main():
    def __init__(self) -> None:
        self.child: typing.Optional[Child] = None

    def initialise(self) -> None:
        if not self.child:
            self.child = Child()

    @check_defined
    def foo(self) -> None:
        self.child.print_foo()

main = Main()
main.initialise()
main.foo()

However this does not change the outcome of the type checking.

Therefore my question is twofold:

  • why can't I use such a decorator to enforce that the attribute is defined and fix my type checking issue?
  • what is a good solution to systematically verify optional attributes are defined without repeating code?

Solution

  • Your Main class seems to be trying to be two things:

    1. Something that has a foo method
    2. Something that builds an object that has a working foo method.

    These should probably be two separate classes.

    from dataclasses import dataclass
    from typing import Optional
    
    
    class Child:
        def print_foo(self) -> None:
            print("foo")
    
    
    @dataclass
    class Main:
        child: Child
    
        def foo(self) -> None:
            self.child.print_foo()
    
    
    @dataclass
    class MainBuilder:
        child: Optional[Child] = None
        
        def set_child(self) -> None:
            self.child = Child()
    
        def build(self) -> Main:
            if self.child is None:
                raise ValueError("Child not yet available")
    
            # Type narrowing: if we reach this line,
            # we can assume self.child is not None,
            # and thus assume its type is Child, not Optional[Child]
            return Main(self.child)
    

    Now you can start by instantiating a MainBuilder object, with set_child replacing the old initialise method.

    mb = MainBuilder()
    mb.set_child()
    

    You create a Main object not by calling Main directly, but by calling mb.build, which will raise an exception if you don't yet have a child. Once you have a Main object, then it is guaranteed to be ready to call foo.

    main = mb.build()
    main.foo()
    

    You can instantiate Main directly, but now a Child argument to Main.__init__ is defined and not optional.

    main2 = Main(Child())