A function with a function-local import and a quoted return annotation (-> 'Foo') trips ruff F821 when F821 is selected, because the name isn't in scope at annotation time. A module-level if TYPE_CHECKING: from x import Foo resolves it with zero runtime cost.
To keep module import cheap (e.g. a CLI that defers a heavy dependency), code sometimes imports a type inside the function and uses a string forward-ref annotation:
def _get_cover_image(url: str) -> 'ImageData':
from sform.utils import ImageData # lazy, runtime-only
...With ruff check --select E9,F821 this fails:
F821 Undefined name `ImageData`
--> views.py:68:48
|
68 | def _get_cover_image(url: str) -> 'ImageData':The lazy import is in function scope, so the name is not defined where ruff evaluates the (string) annotation at module/def scope.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sform.utils import ImageData # resolves the forward ref for tooling onlyTYPE_CHECKING is False at runtime, so this adds no import cost; keep the real lazy import inside the function. The quoted annotation now resolves for ruff/type-checkers.
A pre-commit ruff hook can be stricter than CI. If CI only runs --select E9 (syntax) but the local hook runs E9,F821, a pre-existing F821 stays invisible until you touch that file for an unrelated change — then your commit is blocked by a lint issue you didn't introduce. Resolve it in-place (the TYPE_CHECKING import is the clean fix) rather than reaching for --no-verify.