Type-only imports

Hi everyone!

We have a problem with type hints when we need to import objects from other modules. It can lead to cyclic imports, longer import times, and increased complexity.

Currently it can be solved with imports guarded by if TYPE_CHECKING:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import module_a
    from module_b import b_class

But it does not fully solve the problem. Because it does not actually evaluate to any runtime object. So, this will fail:

>>> from typing import TYPE_CHECKING, get_type_hints
>>> if TYPE_CHECKING:
...     from email.message import Message
...
>>> def send(msg: Message) -> None: ...
...
>>> get_type_hints(send)
Traceback (most recent call last):
  File "<python-input-4>", line 1, in <module>
    get_type_hints(send)
    ~~~~~~~~~~~~~~^^^^^^
  File "<python-input-3>", line 1, in __annotate__
    def send(msg: Message) -> None: ...
                  ^^^^^^^
NameError: name 'Message' is not defined

In some cases it can be a 'Message' string when explicit strings or from future import annotations are used. This is not ideal because it will also fail with NameError when calling get_type_hints.

Currently PEP-649 and PEP-749 provide a new way of dealing with such objects: ForwardRef.

I propose to add a new syntax and a new runtime feature: type import:

from email.message import type Message
import type os

At runtime these new objects will be ForwardRef objects with proper context provided by the compiler that will natively work with any existing annotationlib.get_annotations or typing.get_type_hints calls.

During type checking, these new objects will be considered a special object, which can be used in annotations only.

Grammar changes

The grammar for type imports should be the same as regular import and from import.
Including the * part and all other rules.

We can differentiate whether the optional type soft keyword was provided or not. If provided, we can set the new is_type_only attribute to True, which would be False by default.

Compiler changes

Currently, ForwardRef is a Python object. But, we can teach the compiler to call a different (instinct?) bytecode instruction instead of IMPORT_NAME and IMPORT_FROM to get a ForwardRef. Or create new IMPORT_NAME_TYPE and IMPORT_FROM_TYPE instructions if it would be better for optimizations.

Typing spec changes

This section is only for type checkers and type-checking semantics of type-only imports.

Using names from type-only imports should be allowed in all annotations: functions, classes, inline, type aliases, etc.

But using type-only names in other contexts should not be allowed:

from mod type import A

x: A  # ok, used as annotation
A(1, 2)  # should raise a type checking error

Because at runtime it will also fail:

Traceback (most recent call last):
  File "<python-input-9>", line 1, in <module>
    A(1, 2)
    ~^^^^^^
TypeError: 'ForwardRef' object is not callable
12 Likes

Related discussion: "import type" statement to replace typing.TYPE_CHECKING idiom and fix circular references (plus a few more linked therein)

While this would be nice, lazy imports can be useful outside of typing too, and I’d prefer a solution that isn’t limited to typing imports.

PEP 690 provides such a solution. It was rejected, but mostly for being too magical: a variant with explicitly marked lazy imports might well be accepted. However, it would need someone (or someones) to do the work to design the feature, write a PEP, and implement it.

2 Likes

I think type imports should be considered separately from lazy imports. Using TYPE_CHECKING allows me to block expensive imports and get a NameError if I use them at runtime, while lazy loading would defer the import until used, and it may be difficult to identify an accidental runtime access if another module eagerly imports the module first. The proposal to use ForwardRefs seems an elegant way to both permit runtime access to types for libraries using annotationlib while indicating mistaken runtime uses.

6 Likes

Isn’t this the kind of thing that PEPs 649 and 749 address? Or is that insufficient?

typing.get_type_hints does not fully handle PEP-649/749 annotations and will raise a NameError on forward references as documented

If you wish to get annotations with forward references you need to use annotationlib.get_annotations.

>>> from typing import TYPE_CHECKING
>>> from annotationlib import get_annotations, Format
>>> 
>>> if TYPE_CHECKING:
...     from email.message import Message
...     
>>> def send(msg: Message) -> None: ...
... 
>>> get_annotations(send, format=Format.FORWARDREF)
{'msg': ForwardRef('Message', owner=<function send at 0x7fd22b911900>), 'return': None}

I also don’t think it’s as elegant as it may first appear to use ForwardRef in this way. If ForwardRef exists in the module namespace, then any annotations lookup can return some values as ForwardRef, currently this is only for Format.FORWARDREF lookups. These also don’t play nicely with subscripting and attribute access at runtime, which includes when trying to retrieve annotations.

from annotationlib import Format, ForwardRef, get_annotations

List = ForwardRef("list")  # A forwardref that will resolve with evaluate()
List.evaluate()  # <class 'list'>

class Ex:
    a: List[str]

annos = get_annotations(Ex)
# TypeError: 'ForwardRef' object is not subscriptable

annos = get_annotations(Ex, format=Format.FORWARDREF)
# {'a': ForwardRef('List[__annotationlib_name_1__]', is_class=True, owner=<class '__main__.Ex'>)}
# Trying to evaluate this ForwardRef gives the same TypeError

One alternative would be if such typing imports didn’t exist in the module namespace at all, but were instead in a separate namespace which would resolve the imports when the names are looked up. This namespace could then be included by functions resolving annotations, such as get_annotations.

Hacked together implementation using a context manager instead of new syntax. Based on another experiment from a full lazy importer module.


Personally though I also prefer full lazy imports. Checking that you haven’t accidentally imported something is just as important in that case as it is for type imports to avoid performance regressions[1]. PEP-649/749 annotations would mean that the modules don’t get imported unless the annotations are looked up.

A clean, fast way of doing lazy imports may also be nice for consistency and stdlib maintenance as there are currently a variety of ways these are implemented including different methods in the same module.


PEP-649/749 should be fine if you don’t care about resolving the references. Not being able to resolve them may actually be advantageous in the case of something like dataclasses as if they were resolvable then making the class into a dataclass would perform the imports.

If you do need them to be resolvable though it’s not enough on its own.


  1. Even PEP-690 accepted lazy imports couldn’t be the default. ↩︎