Python Type Hints - How to Fix Circular Imports

2021-05-13 Around and around and around the imports go.

Circular imports are always annoying when they arise in Python, and type hints make them more common. Thankfully, there’s a trick to add circular imports for type hints without causing ImportErrors.

Take these two files:

# models.py
from controllers import BookController


class Book:
    ...

    def get_controller(self):
        return BookController(self)
class BookController:
    def __init__(self, book):
        self.book = book

    ...

So far, so good. We can run models.py with no issues:

$ python -i models.py
>>> Book().get_controller()
<controllers.BookController object at 0x1090f91f0>

We run into a circular import if we add type hints in both files though. Our models.py is not a problem as the only type to annotate is BookController, which is already imported:

from controllers import BookController


class Book:
    ...

    def get_controller(self) -> BookController:
        return BookController(self)

But when adding the type hint for BookController.__init__(), we need to import Book:

from models import Book


class BookController:
    def __init__(self, book: Book) -> None:
        self.book = book

    ...

We’ve now added a circular import, as models imports controllers, which imports models. Python now raises an ImportError when we run the code:

$ python models.py
Traceback (most recent call last):
  File "/.../models.py", line 1, in <module>
    from controllers import BookController
  File "/.../controllers.py", line 1, in <module>
    from models import Book
  File "/.../models.py", line 1, in <module>
    from controllers import BookController
ImportError: cannot import name 'BookController' from partially initialized module 'controllers' (most likely due to a circular import) (/.../controllers.py)

So, how can we fix this?

The answer is to use the special typing.TYPE_CHECKING constant. This is hardcoded to False, but set to True by type checkers like Mypy. We can use it to make the import in controllers.py conditional:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models import Book


class BookController:
    def __init__(self, book: "Book") -> None:
        self.book = book

    ...

Now the code can run as before, and mypy can still check our types.

Note that we had to change the Book type hint into a string. This isn’t necessary if we add from __future__ import annotations at the top of the file, which makes all annotations strings by default (see PEP 563). (__future__.annotations will become the default in Python 3.11).

For example:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models import Book


class BookController:
    def __init__(self, book: Book) -> None:
        self.book = book

    ...

Nice.

Fin

May your runtime import graph remain a DAG,

—Adam


🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: mypy, python