Python Type Hints - How to Fix Circular Imports

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)
# controllers.py


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:

# models.py
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:

# controllers.py
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:

# controllers.py
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:

# controllers.py
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


If your Django project’s long test runs bore you, I wrote a book that can help.


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,