diff --git a/python/ql/lib/semmle/python/Exprs.qll b/python/ql/lib/semmle/python/Exprs.qll index 47e31bb07cdd..accc370481aa 100644 --- a/python/ql/lib/semmle/python/Exprs.qll +++ b/python/ql/lib/semmle/python/Exprs.qll @@ -746,6 +746,24 @@ class Guard extends Guard_ { override Expr getASubExpression() { result = this.getTest() } } +/** An annotation, such as the `int` part of `x: int` */ +class Annotation extends Expr { + Annotation() { + this = any(AnnAssign a).getAnnotation() + or + exists(Arguments args | + this in [ + args.getAnAnnotation(), + args.getAKwAnnotation(), + args.getKwargannotation(), + args.getVarargannotation() + ] + ) + or + this = any(FunctionExpr f).getReturns() + } +} + /* Expression Contexts */ /** A context in which an expression used */ class ExprContext extends ExprContext_ { } diff --git a/python/ql/src/Variables/UnusedModuleVariable.ql b/python/ql/src/Variables/UnusedModuleVariable.ql index 869c31cb4fa1..c9009d9bf369 100644 --- a/python/ql/src/Variables/UnusedModuleVariable.ql +++ b/python/ql/src/Variables/UnusedModuleVariable.ql @@ -34,6 +34,14 @@ predicate complex_all(Module m) { ) } +predicate used_in_forward_declaration(Name used, Module mod) { + exists(StringLiteral s, Annotation annotation | + s.getS() = used.getId() and + s.getEnclosingModule() = mod and + annotation.getASubExpression*() = s + ) +} + predicate unused_global(Name unused, GlobalVariable v) { not exists(ImportingStmt is | is.contains(unused)) and forex(DefinitionNode defn | defn.getNode() = unused | @@ -55,7 +63,8 @@ predicate unused_global(Name unused, GlobalVariable v) { unused.defines(v) and not name_acceptable_for_unused_variable(v) and not complex_all(unused.getEnclosingModule()) - ) + ) and + not used_in_forward_declaration(unused, unused.getEnclosingModule()) } from Name unused, GlobalVariable v diff --git a/python/ql/src/change-notes/2025-03-04-fix-forward-annotation-fp-in-unused-global-var-query.md b/python/ql/src/change-notes/2025-03-04-fix-forward-annotation-fp-in-unused-global-var-query.md new file mode 100644 index 000000000000..78142ea3fc68 --- /dev/null +++ b/python/ql/src/change-notes/2025-03-04-fix-forward-annotation-fp-in-unused-global-var-query.md @@ -0,0 +1,5 @@ +--- +category: fix +--- + +- The `py/unused-global-variable` now no longer flags variables that are only used in forward references (e.g. the `Foo` in `def bar(x: "Foo"): ...`). diff --git a/python/ql/test/query-tests/Variables/unused/variables_test.py b/python/ql/test/query-tests/Variables/unused/variables_test.py index ff8ac50a541c..611b9fbd6b2a 100644 --- a/python/ql/test/query-tests/Variables/unused/variables_test.py +++ b/python/ql/test/query-tests/Variables/unused/variables_test.py @@ -137,3 +137,21 @@ def test_dict_unpacking(queryset, field_name, value): for tag in value.split(','): queryset = queryset.filter(**{field_name + '__name': tag}) return queryset + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + ParamAnnotation = int + ReturnAnnotation = int + AssignmentAnnotation = int + ForwardParamAnnotation = int + ForwardReturnAnnotation = int + ForwardAssignmentAnnotation = int + +def test_direct_annotation(x: ParamAnnotation) -> ReturnAnnotation: + if x: + y : AssignmentAnnotation = 1 + +def test_forward_annotation(x: "ForwardParamAnnotation") -> "ForwardReturnAnnotation": + if x: + y : "ForwardAssignmentAnnotation" = 1