With PEP 649 impending, I have some concerns that the tension between static-only and runtime type annotations may get worse. Specifically:
there is tooling that automatically moves typing-only imports behind an if TYPE_CHECKING guard, which uses from __future__ import annotations as a “trigger”; this determining of intent will no longer be possible
as a library author, I am torn between keeping import times fast by hiding my annotation imports behind a guard, versus allowing runtime reflection for users that want to do that
Here, Final at runtime would contain only a placeholder for the import; the typing module itself would not be imported. Instead, when annotations are being resolved, the logic could detect these placeholders and import them at point of use.
This would allow all typing-only imports to safely and automatically be deferred to point-of-use (and avoided completely for all users not using annotations at runtime).
Note: Jelle pointed out that PEP 690 proposed adding lazy imports in the past (and was rejected). If I understand correctly, those would have been immediately resolved on first use. We don’t need that extra level of complexity for this proposal (though it would work for this purpose if it did have it).
as a library author, I am torn between keeping import times fast by hiding my annotation imports behind a guard, versus allowing runtime reflection for users that want to do that
Another use case are type-only imports. Sometimes type stubs define classes or types that are not available at runtime. While ideally these would be moved to the runtime, that is not realistic for legacy code and code that doesn’t use type annotations. These currently must be imported using the TYPE_CHECKING guard.
Would it be possible to arrange this with a module-level __getattr__?
if TYPE_CHECKING:
from somewhere_expensive import SomeExpensiveAnnotation
else:
def __getattr__(item):
if item == "SomeExpensiveAnnotation":
from somewhere_expensive import SomeExpensiveAnnotation
return SomeExpensiveAnnotation
else:
raise AttributeError
def f(x: SomeExpensiveAnnotation):
pass
(the above all with PEP649 enabled)
That’s a lot of boilerplate of course, but with sufficient hackery maybe it could be wrapped up nicely. (Traversing the stack and monkey-patching in a __getattr__ call? Using an AST import hook?)
If we can pull in types hidden behind an if TYPE_CHECKING guard like this, do we even need the getattr definition in the module? Why not just do this logic as part of type annotation resolution?
Perhaps to avoid doing unnecessary imports, we can pass in a dict subclass to the resolution logic’s globals (or locals?) parameter that does a lazy import if an environment lookup misses.
I think libraries can do this without any change to Python?
Many packages uses from __future__ import annotations already.
And many modules don’t need runtime type inspection.
They should be able to use if TYPE_CHECKING (or even if False) for zero-cost type imports and import from type stubs without deprecation warning.
I don’t think anyone is advocating for deprecating if TYPE_CHECKING or even if False, since there’s more use-cases for it than just dealing with type checking imports.
I don’t see any harm in PEP 649 superseding PEP 563 however, they accomplish the same goal with different[1] runtime semantics[2]. It’s not quite enough to get rid of string forward references however, so runtime tooling still needs to support them.
The point of this proposal is to strike a good balance between having zero cost[3] type imports, that don’t need to worry about circular dependencies or having an actual runtime value, while keeping runtime introspectability of your type annotations as high as possible. This way your types seamlessly work with libraries such as Pydantic, without having to give up fast import times outside of that context. Otherwise you force people to redefine your types, so they become usable at runtime.
that doesn’t mean people should get deprecation warnings for the future imports, just that it’s a no-op starting with the first Python version that implements PEP 649, just like other future imports eventually became no-ops ↩︎
as long as you don’t retrieve any type annotations that make use of it ↩︎
We do need the __getattr__. Blocks guarded by if TYPE_CHECKING exist only for the purpose of pyright/mypy/etc, and are unrelated to anything that happens at runtime.
So if we didn’t have a __getattr__, then trying to resolve the annotations at runtime will fail.
Do note that my code is written assuming PEP649 is implemented – so that the annotations are only actually looked up if the annotations are introspected. For code without runtime introspection, the slow-to-import never happens.
I don’t see why we need a lazy import mechanism just for type annotations, though. If importing the necessary libraries at runtime is too costly, then:
Don’t do that - that’s what if TYPE_CHECKING should be for.
If you need the information at runtime, we should improve the libraries to make the relevant imports less costly. Just like any other importable library, this isn’t a typing-specific issue.
If a lazy import mechanism is needed, it should be general, not restricted to just typing. There are plenty of other cases where people want lazy imports.
While I’m the first to argue that typing should have as near to zero runtime cost as is possible, imports are such a fundamental part of Python that IMO importing the relevant libraries for your (runtime) needs should be a no-brainer, and if it isn’t, then that’s a problem that libraries have always been expected to fix by optimising their import-time cost (my option (2) above). And typing isn’t special enough for that general principle not to apply.
I agree with you on that in an application or script, where you have no downstream dependencies on your annotations, but as a library author you may have users pulling you into opposite directions. This would be a way to satisfy both static type checker / non type checker users[1] and runtime type checker users. The people that don’t need the annotations don’t pay for them and the people that need them, needed to pay for them to begin with.
I don’t disagree with you, generalized lazy imports would definitely be more useful than typing specific ones and thus easier to justify, but the semantics are more difficult to define. This proposal is essentially an extension to the existing type statement, which you could technically use for non-typing things as well[2]. So a type import could still be used as a general lazy import, the main difference would be, that the typing construct likely will be an object that explicitly needs to get handled by runtime libraries that use it, like having to use TypeAliasType.__value__, rather than it magically getting replaced by its value, but I think that’s actually a good thing, otherwise why not just make all imports lazy?
neither of them care about runtime access to annotations ↩︎
after all it’s just an object like any other, with behavior similar to a one-time use lambda ↩︎
I don’t think this or the linked related thread present anything particularly compelling, and I use type annotations heavily.
It’s trivially resolvable in most cases with a module __getattr__ and placing any costly type definitions in their own file (allowing your users to also reuse these definitions), limited to cases where you wouldn’t need to have imported it anyway. This is rarer than most people realize, when analyzing this in real-world code, much of the time what appears to be an expensive import was not when considering what would have been imported elsewhere anyhow. An example of this is anything that is only expensive because it imports re, where the application also needs to import re.
I’m not unsympathetic to wanting lower costs to typing, but lazy imports present enough additional complexity that I don’t think they are worth pursuing unless done to benefit more than just typing.
I think type-specific imports as suggested here make sense, and I don’t think a generic lazy import mechanism would be sufficient, because it would not allow type checkers to reason about what should be accessible at runtime and what shouldn’t. When the type keyword is attached to those imports, it becomes possible for type checkers to give errors for runtime code that tries to erroneously use a type-only import.
Similarly, I think the suggestion to work around this with a module level __getattr__ misses the same point, this does not provide enough information for a type checker to reason about correctness.
Working in large code-bases where import times is a real problem, this proposal has clear tangible value, as it seamlessly allows using types of expensive imports in top-level entry-points without taking the cost of importing the entire world.
I think the semantics are easy to understand. The type (soft) keyword already exists, and this exact idea was thought about at its infancy. Overall I think this would be a net positive addition to the language.
I think this underestimates the cost of maintaining duplicated imports across many files and libraries in open source code in the face of automated tooling adding and removing both imports and if TYPE_CHECKING guards, with no practical way of testing that the shadow runtime imports are correct and in sync. Sure, one instance looks simple, but anything looks simple if you do it once. Tooling might solve this, but the tooling doesn’t exist, and it would need mass adoption.
I do agree however that anything requiring a syntax change to Python is now definitely off the table.
Considering we’d need a backwards-compatible construct to be added to typing_extensions anyways I would tend to agree that it may be better to experiment with a typing primitive before adding new syntax, so we’re not putting the cart before the horse.
from typing import TypeImport
foo = TypeImport('foo') # import foo
Bar = TypeImport('foo.bar', 'Bar') # from foo.bar import Bar
baz = TypeImport('foo', as='baz') # import foo as baz
foo.__value__ # descriptor that reifies the import
This could be a subclass of types.TypeAliasType to help with runtime use. Any runtime library that supports the new style type alias would automatically also support these lazy type imports, since the logic would be the same.
You can read more about it here but they have their own sets of caveats that suggest not using it nearly as much as people in this thread seem to want to use it.
As for
Here’s what the __getattr__ approach looks like in practice:
if TYPE_CHECKING:
from expensive_module import A, B
from other_expensive_module import C, D
else:
def __getattr__(attr):
if attr in {"A", "B"}:
import expensive_module
return getattr(expensive_module, attr)
elif attr in {"C", "D"}:
import other_expensive_module
return getattr(other_expensive_module, attr)
raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")
I don’t think this would be difficult to write a lint for if you wanted to ensure this pattern was used properly, it’s very limited in scope, and it forces people to think about the fact that they are circumventing “normal” import semantics.
I don’t think anyone is going to argue with you, that this i something we cannot already work around today, but I think we can both agree that neither of those approaches are particularly ergonomic or aesthetic, the may be functional but certainly not what I’d call practical. Something you’d use for exceptional cases, where you need to, not something you’d do by default.
I would like to lazy import every single type I only use inside annotations. There are linter plugins which enforce this style, while the win may be small in most cases, when it’s spread across thousands of imports in hundreds of source files it will eventually add up. As a bonus you will be confronted with cyclic dependencies less frequently. This is something I frankly don’t really want to have to think about in most cases, so having an easy/ergonomic way to defer all typing imports without getting in the way of runtime analysis tools would be great.
Does this even need to be an actual lazy-loader? In my understanding of the problem, the syntax only tells a type checker where to find a type, without ever doing an import at runtime.
from mymodule type import MyType
# equivalent pseudocode with made-up keyword
MyType = TypeAliasType("MyType", module="MyModule")
The use case is with library code using another, slow-to-import library, where there is hence no way to reduce the import time. Perhaps this example would have been the better one:
from other_library type import InputType, ResultType
def my_implementation(x: InputType) -> ResultType:
from other_library import their_implementation
...