Python type hints: 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 my related post).

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


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,