up-type-checking-lazy-import (SMP306)

SMP306🔧 Fix sometimes availableImports and dependenciesAll rules

Flags an if TYPE_CHECKING: block that holds only imports and no else, where Python 3.15 can spell each import as a lazy import. The guard is then only needed for names that are not imported at runtime, which lazy covers.

Why is this bad?

The guard keeps the imports out of the running program, so the names they bind exist only for type checkers, and code that reads them at runtime fails. A lazy import binds the name at module level and runs the import on first use (PEP 810), so the guard is no longer needed and a name used at runtime resolves.

Example

Example: fix

pyproject.toml

1[project]
2name = "demo"
3requires-python = ">=3.15"

Before

1# Demo module.
2from typing import TYPE_CHECKING
3
4if TYPE_CHECKING:
5    from collections.abc import Sequence
6
7
8def first(values: Sequence[int]) -> int:
9    return values[0]

After

1# Demo module.
2
3lazy from collections.abc import Sequence
4
5
6def first(values: Sequence[int]) -> int:
7    return values[0]

Configuration

  • The Python version the code targets, taken from target-version in [tool.ruff], falling back to project.requires-python; see Python version

    Example: configuration

    pyproject.toml

    1[tool.ruff]
    2target-version = "py314"
    
    1from typing import TYPE_CHECKING
    2
    3if TYPE_CHECKING:
    4    from collections.abc import Sequence
    

What it does

Active on

  • if TYPE_CHECKING: (or typing.TYPE_CHECKING) at module level, with a body of import and from ... import statements only and no else or elif. The report sits on the condition. The fix is unsafe: a lazy import loads the module on first use, so a name a program never touches costs nothing, while a name it does touch runs the import then. An import cycle or a module missing at runtime, such as _typeshed, then surfaces at that point instead of never.

    Example: fix

    pyproject.toml

    1[project]
    2name = "demo"
    3requires-python = ">=3.15"
    

    Before

    1# Demo module.
    2from typing import TYPE_CHECKING
    3
    4if TYPE_CHECKING:
    5    from os import PathLike
    6
    7
    8def path(value: PathLike) -> None:
    9    pass
    

    After

    1# Demo module.
    2
    3lazy from os import PathLike
    4
    5
    6def path(value: PathLike) -> None:
    7    pass
    

Exceptions

  • Blocks with an else, an elif, or other statements: the rule needs a block that only imports.

    Example: not reported

    pyproject.toml

    1[project]
    2name = "demo"
    3requires-python = ">=3.15"
    
    1from typing import TYPE_CHECKING
    2
    3if TYPE_CHECKING:
    4    from os import PathLike
    5else:
    6    PathLike = str
    
  • Imports of from __future__ and from x import *: lazy has no form for them.

    Example: not reported

    pyproject.toml

    1[project]
    2name = "demo"
    3requires-python = ">=3.15"
    
    1from typing import TYPE_CHECKING
    2
    3if TYPE_CHECKING:
    4    from os import *
    
  • Blocks inside another statement, such as a try or a function: lazy imports are not allowed there, so the rule does not look at them.

    Example: not reported

    pyproject.toml

    1[project]
    2name = "demo"
    3requires-python = ">=3.15"
    
    1from typing import TYPE_CHECKING
    2
    3try:
    4    if TYPE_CHECKING:
    5        from os import PathLike
    6except ImportError:
    7    pass
    

Fix

The fix is unsafe, as described under “Active on” above. It replaces the block with one lazy import per statement, and, when TYPE_CHECKING is then unused, removes its from typing import TYPE_CHECKING line. A block with a comment in it is reported without a fix, because the fix would drop the comment. A block importing _typeshed is too.

Example: fix

pyproject.toml

1[project]
2name = "demo"
3requires-python = ">=3.15"

Before

1# Demo module.
2from typing import TYPE_CHECKING
3
4if TYPE_CHECKING:
5    from os import PathLike

After

1# Demo module.
2
3lazy from os import PathLike

Other tools

  • Ruff: open issue #29378. TID254, with require-lazy set, adds lazy to the imports in the block but keeps the if TYPE_CHECKING: guard this rule removes.