pythonpython-typingcircular-dependency

Avoiding circular imports with type annotations in situations where __future__.annotations is insufficient


When I have the following minimum reproducing code:

start.py

from __future__ import annotations

import a

a.py

from __future__ import annotations

from typing import Text

import b

Foo = Text

b.py

from __future__ import annotations

import a

FooType = a.Foo

I get the following error:

soot@soot:~/code/soot/experimental/amol/typeddict-circular-import$ python3 start.py
Traceback (most recent call last):
  File "start.py", line 3, in <module>
    import a
  File "/home/soot/code/soot/experimental/amol/typeddict-circular-import/a.py", line 5, in <module>
    import b
  File "/home/soot/code/soot/experimental/amol/typeddict-circular-import/b.py", line 5, in <module>
    FooType = a.Foo
AttributeError: partially initialized module 'a' has no attribute 'Foo' (most likely due to a circular import)

I included __future__.annotations because most qa of this sort is resolved by simply including the future import at the top of the file. However, the annotations import does not improve the situation here because simply converting the types to text (as the annotations import does) doesn't actually resolve the import order dependency.

More broadly, this seems like an issue whenever you want to create composite types from multiple (potentially circular) sources, e.g.

CompositeType = Union[a.Foo, b.Bar, c.Baz]

What are the available options to resolve this issue? Is there any other way to 'lift' the type annotations so they are all evaluated after everything is imported?


Solution

  • In most cases using typing.TYPE_CHECKING should be enough to resolve circular import issues related to use in annotations.

    Note annotations future-import (details), alternatively you can enclose all names not available at runtime (imported under if TYPE_CHECKING) in quotes.

    # a.py
    from __future__ import annotations
    from typing import TYPE_CHECKING
    
    if TYPE_CHECKING:
        from b import B
    
    class A: pass
    def foo(b: B) -> None: pass
    
    # b.py
    from __future__ import annotations
    from typing import TYPE_CHECKING
    
    if TYPE_CHECKING:
        from a import A
    
    class B: pass
    def bar(a: A) -> None: pass
    
    # __main__.py
    from a import A
    from b import B
    

    However, for exactly your MRE it won't work. If the circular dependency is introduced not only by type annotations (e.g. your type aliases), the resolving may become really tricky.

    If you don't need Foo available at runtime in your example, it can be declared in if TYPE_CHECKING: block too, mypy will interpret that properly. If it is for runtime too, then everything depends on exact code structure (in your MRE dropping import b is enough). Union type can be declared in separate file that imports a, b and c and creates Union. If you need this union in a, b or c, then things are a bit more complicated, probably some functionality needs to be extracted into separate file d that creates union and uses it (also the code will be a bit cleaner this way, because every file will contain only common functionality).