[flake8-type-checking] Prefer lazy imports over TYPE_CHECKING on 3.15+ (TC001, TC002, TC003) - #28541
Conversation
…3.15+ (`TC001`, `TC002`, `TC003`) Summary -- This PR closes #25181 by recognizing and suggesting lazy imports in the typing-only runtime imports rules. As noted in [PEP 810], one common use for `TYPE_CHECKING` blocks is to avoid importing code that isn't actually needed at runtime: > Type annotations frequently require imports that are never used at runtime. The common workaround > is to wrap them in `if TYPE_CHECKING:` blocks \[1\]. With lazy imports, annotation-only imports > impose no runtime penalty, eliminating the need for such guards and making annotated codebases > cleaner. The footnote is even more relevant: > Furthermore, there’s also external tooling, in the form of flake8-type-checking, because it is > common for developers to mislocate imports and accidentally introduce a runtime dependency on an > import only imported in such a block. Ironically, the static type checker is of no help in these > circumstances. In line with this usage, this PR exempts existing lazy imports from these rules, which otherwise would still suggest moving them into `TYPE_CHECKING` blocks, and it also adds a new fix to all three rules that will add `lazy` in front of an import on 3.15+. In line with our fixes for `TID254` and `TID255`, we fall back on the existing `TYPE_CHECKING` fix if the `lazy` addition is too complicated, i.e. when multiple names are imported on the same line or when we'd have to edit `__lazy_modules__`. [PEP 810]: https://peps.python.org/pep-0810/ Test Plan -- New mdtests
|
MichaReiser
left a comment
There was a problem hiding this comment.
Thank you. Nice PR. I suggest we soften the wording on how to fix the violation. Either a TYPE_CHECKING block or the use of lazy is fine for this rule. It has no opinion on this.
Related to this: We extensively discussed this change as a team and it's very likely that we need a new rule or setting in the future that allows users to define how lazyiness should be achieved. That is, whether it is by using an explicit TYPE_CHECKING import or the simpler lazy import. For the TC rules specifically, recognizing an import as lazy when it's either in a TYPE_CHECKING block or uses lazy import enforces the goal of the rule.
Something else noting is that using lazy is not as reversible:
- Before: Making an import typing only, then using it in a runtime position raises TC004 (with a typing specific error message)
- With this PR: Making an import typing only, then using it in a runtime position (at a module level) raises
lazy-import-immediately-resolved. An import in a function level raises no diagnostics.
I don't think this requires any changes from our end. It's just something I realized that using lazy and TYPE_CHECKING give a different user experience.
| | | ||
| 1 | import app as local # snapshot: typing-only-first-party-import | ||
| | ^^^^^ | ||
| help: Convert to a lazy import |
There was a problem hiding this comment.
Given that this isn't the only "blessed" fix, should we add a note saying. Or move the import in an if TYPE_CHECKING block?
There was a problem hiding this comment.
Sure, I'll try a sub-diagnostic for that!
I was wondering about the reverse case, but I guess it doesn't make sense to mention lazy imports on the normal fix since we prefer lazy imports when they're available.
There was a problem hiding this comment.
Hmm, I really wanted a diagnostic like this, with the additional info after the fix:
error[TC001]: Make application import `app` lazy
--> src/mdtest_snippet.py:1:15
|
1 | import app as local # snapshot: typing-only-first-party-import
| ^^^^^
help: Convert to a lazy import
|
- import app as local # snapshot: typing-only-first-party-import
1 + lazy import app as local # snapshot: typing-only-first-party-import
2 | from vendor import Model # snapshot: typing-only-third-party-import
|
note: This is an unsafe fix and may change runtime behavior
info: Alternatively, move the import into a type-checking block
but I forgot that our diagnostics don't support that yet. I guess I'll just add something like (or move the import to a type-checking block) in the fix title itself.
|
|
||
| ```py | ||
| try: | ||
| from vendor import Model # error: [typing-only-third-party-import] "type-checking block" |
There was a problem hiding this comment.
Don't we need snapshots here to assert that Ruff emits the right fix?
There was a problem hiding this comment.
The diagnostic message also includes "type-checking block" or "lazy," so I think the error message assertions are sufficient, but snapshots are also fine with me if you prefer.
|
|
||
| ## Multiple imported names | ||
|
|
||
| An import with multiple names retains the type-checking-block fix, leaving its runtime-used names |
There was a problem hiding this comment.
Don't we need snapshots here to assert that Ruff emits the right fix?
|
|
||
| ## Lazy import policies | ||
|
|
||
| Imports prohibited by `ban-lazy` retain the type-checking-block fix. An excluded module can still be |
There was a problem hiding this comment.
Don't we need snapshots here to assert that Ruff emits the right fix?
| select = ["TC003", "TID254", "TID255"] | ||
|
|
||
| [lint.flake8-tidy-imports] | ||
| ban-lazy = { include = "all", exclude = ["pathlib"] } |
There was a problem hiding this comment.
Can we add a test for ban-lazy = "all"?
There was a problem hiding this comment.
I think include = "all" is exercising the same path, but sure, I can add an additional case with this exact configuration.
| /// On Python 3.15 and later, use instead: | ||
| /// ```python | ||
| /// lazy from . import local_module | ||
| /// | ||
| /// | ||
| /// def func(sized: local_module.Container) -> int: | ||
| /// return len(sized) | ||
| /// ``` |
There was a problem hiding this comment.
Let's rephrase this as, on Python 3.15 or later, using a lazy import is also an option to make it clear that this rule has no strong preference to how you make the import lazy
| /// return len(df) | ||
| /// ``` | ||
| /// | ||
| /// On Python 3.15 and later, use instead: |
| /// return str(path) | ||
| /// ``` | ||
| /// | ||
| /// On Python 3.15 and later, use instead: |
| return Self::TypeCheckingBlock; | ||
| } | ||
|
|
||
| let ban_lazy = &checker.settings().flake8_tidy_imports.ban_lazy; |
There was a problem hiding this comment.
Initially, I was worried about reading another setting here. But this is fine. The behavior doesn't depend on whether the other rule is enabled. It only ensures that the behavior is consistent with the configuration
…aliases * origin/main: Bump version to 0.16.8 (#28648) [ty] Bound aliased intersection expansion during inference (#28546) renovate: update uv hashes correctly with setup-uv (#28621) [ty] Compact reachable binding and declaration histories (#28349) [ty] Avoid storing constraint nodes twice (#28375) [ty] Compare bound-method receivers before signatures (#28384) [`flake8-type-checking`] Prefer lazy imports over `TYPE_CHECKING` on 3.15+ (`TC001`, `TC002`, `TC003`) (#28541) [ty] Watch script dependencies in CLI watch mode (#28125) [flake8-tidy-imports] Add `extend-banned-api` (#28644) [ty] Support `type[A & B]` (#27124) # Conflicts: # crates/ty_python_semantic/src/types/set_theoretic/builder.rs
Summary
This PR closes #25181 by recognizing and suggesting lazy imports in the typing-only runtime imports
rules. As noted in PEP 810, one common use for
TYPE_CHECKINGblocks is to avoid importing codethat isn't actually needed at runtime:
The footnote is even more relevant:
In line with this usage, this PR exempts existing lazy imports from these rules, which otherwise
would still suggest moving them into
TYPE_CHECKINGblocks, and it also adds a new fix to all threerules that will add
lazyin front of an import on 3.15+.In line with our fixes for
TID254andTID255, we fall back on the existingTYPE_CHECKINGfixif the
lazyaddition is too complicated, i.e. when multiple names are imported on the same line orwhen we'd have to edit
__lazy_modules__.Test Plan
New mdtests