Python Type Hints - How to Fix Circular Imports

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 ImportError
s.
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.
Improve your Django develompent experience with my new book.
One summary email a week, no spam, I pinky promise.
Related posts: