@@ -582,6 +582,66 @@ def handle_fastapi_decorator(self, node: Union[AsyncFunctionDef, FunctionDef]) -
582582 self .visit (node .args .vararg .annotation )
583583
584584
585+ class FunctoolsSingledispatchMixin :
586+ """
587+ Contains the necessary logic for `functools.singledispatch` support.
588+
589+ `functools.singledispatch` and `functools.singledispatchmethod` require
590+ runtime access to all annotations.
591+
592+ ```python
593+ from functools import singledispatch
594+
595+ from mylib import Foo
596+
597+ @singledispatch
598+ def foo(arg: Foo) -> str:
599+ return arg.name
600+ ```
601+
602+ Since the only use of `Foo` is within an annotation, we would usually emit
603+ a TC003 for the `mylib` import. But since `singledispatch` requires runtime
604+ access to `Foo`, that would be a false positive.
605+ """
606+
607+ if TYPE_CHECKING :
608+
609+ def in_type_checking_block (self , lineno : int , col_offset : int ) -> bool : # noqa: D102
610+ ...
611+
612+ def lookup_full_name (self , node : ast .AST ) -> str | None : # noqa: D102
613+ ...
614+
615+ def visit (self , node : ast .AST ) -> ast .AST : # noqa: D102
616+ ...
617+
618+ def visit_FunctionDef (self , node : FunctionDef ) -> None :
619+ """Remove and map function arguments and returns."""
620+ super ().visit_FunctionDef (node ) # type: ignore[misc]
621+ if self .has_singledispatch_decorator (node ):
622+ self .handle_singledispatch_decorator (node )
623+
624+ def visit_AsyncFunctionDef (self , node : AsyncFunctionDef ) -> None :
625+ """Remove and map function arguments and returns."""
626+ super ().visit_AsyncFunctionDef (node ) # type: ignore[misc]
627+ if self .has_singledispatch_decorator (node ):
628+ self .handle_singledispatch_decorator (node )
629+
630+ def has_singledispatch_decorator (self , node : FunctionDef | AsyncFunctionDef ) -> bool :
631+ """Determine whether this function is decorated with `functools.singledispatch`."""
632+ return any (
633+ self .lookup_full_name (decorator_node ) in ('functools.singledispatch' , 'functools.singledispatchmethod' )
634+ for decorator_node in node .decorator_list
635+ )
636+
637+ def handle_singledispatch_decorator (self , node : FunctionDef | AsyncFunctionDef ) -> None :
638+ """Walk all the annotations to register them as runtime uses."""
639+ for path in [node .args .args , node .args .kwonlyargs , node .args .posonlyargs ]:
640+ for argument in path :
641+ if hasattr (argument , 'annotation' ) and argument .annotation :
642+ self .visit (argument .annotation )
643+
644+
585645@dataclass
586646class ImportName :
587647 """DTO for representing an import in different string-formats."""
@@ -949,7 +1009,14 @@ def visit_annotated_value(self, node: ast.expr) -> None:
9491009
9501010
9511011class ImportVisitor (
952- DunderAllMixin , AttrsMixin , InjectorMixin , FastAPIMixin , PydanticMixin , SQLAlchemyMixin , ast .NodeVisitor
1012+ DunderAllMixin ,
1013+ FunctoolsSingledispatchMixin ,
1014+ AttrsMixin ,
1015+ InjectorMixin ,
1016+ FastAPIMixin ,
1017+ PydanticMixin ,
1018+ SQLAlchemyMixin ,
1019+ ast .NodeVisitor ,
9531020):
9541021 """Map all imports outside of type-checking blocks."""
9551022
0 commit comments