Skip to content

[flake8-type-checking] Prefer lazy imports over TYPE_CHECKING on 3.15+ (TC001, TC002, TC003) - #28541

Merged
ntBre merged 4 commits into
mainfrom
brent/lazy-tc-fixes
Sep 16, 2026
Merged

ntBre merged 4 commits into
mainfrom
brent/lazy-tc-fixes

Conversation

@ntBre

@ntBre ntBre commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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__.

Test Plan

New mdtests

…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
@ntBre ntBre added rule Implementing or modifying a lint rule python315 labels Sep 11, 2026
@ntBre ntBre mentioned this pull request Sep 11, 2026
30 tasks
@astral-sh-bot

astral-sh-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

@ntBre
ntBre marked this pull request as ready for review September 11, 2026 22:03
@ntBre
ntBre requested a review from MichaReiser September 11, 2026 22:03

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need snapshots here to assert that Ruff emits the right fix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"] }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test for ban-lazy = "all"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think include = "all" is exercising the same path, but sure, I can add an additional case with this exact configuration.

Comment on lines +81 to +88
/// On Python 3.15 and later, use instead:
/// ```python
/// lazy from . import local_module
///
///
/// def func(sized: local_module.Container) -> int:
/// return len(sized)
/// ```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

/// return str(path)
/// ```
///
/// On Python 3.15 and later, use instead:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here

return Self::TypeCheckingBlock;
}

let ban_lazy = &checker.settings().flake8_tidy_imports.ban_lazy;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ntBre
ntBre merged commit 304ab86 into main Sep 16, 2026
50 checks passed
@ntBre
ntBre deleted the brent/lazy-tc-fixes branch September 16, 2026 13:53
carljm added a commit that referenced this pull request Sep 16, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python315 rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TC001 - TC003 should not apply with lazy imports

2 participants