From 5f7e9379fa62905ee03f0488649a4a2de7788235 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 28 Jun 2023 13:38:42 +0100 Subject: [PATCH] Emit Y020 for quoted annotations used in TypeVar constraints (#407) --- CHANGELOG.md | 2 ++ pyi.py | 16 ++++++++-------- tests/quotes.pyi | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e128c8..122df107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Bugfixes: TypeVars/ParamSpecs/TypeAliases/TypedDicts/Protocols if the object in question had multiple definitions in the same file (e.g. across two branches of an `if sys.version_info >= (3, 10)` check). This bug has now been fixed. +* Y020 was previously not emitted if quoted annotations were used in TypeVar + constraints. This bug has now been fixed. Other changes: * flake8-pyi no longer supports being run on Python 3.7. diff --git a/pyi.py b/pyi.py index 889eab4c..8ce80ddb 100644 --- a/pyi.py +++ b/pyi.py @@ -1137,15 +1137,15 @@ def visit_Call(self, node: ast.Call) -> None: ): return self.error(node, Y056.format(method=f".{function.attr}()")) - # String literals can appear in positional arguments for - # TypeVar definitions. - with self.string_literals_allowed.enabled(): - for arg in node.args: - self.visit(arg) - # But in keyword arguments they're most likely TypeVar bounds, + # String literals can appear as the first positional argument for + # TypeVar/ParamSpec/TypeVarTuple/NamedTuple/TypedDict/NewType definitions, etc. + if node.args: + with self.string_literals_allowed.enabled(): + self.visit(node.args[0]) + # But in other arguments they're most likely TypeVar bounds, # which should not be quoted. - for kw in node.keywords: - self.visit(kw) + for arg in chain(node.args[1:], node.keywords): + self.visit(arg) def visit_Constant(self, node: ast.Constant) -> None: if isinstance(node.value, str) and not self.string_literals_allowed.active: diff --git a/tests/quotes.pyi b/tests/quotes.pyi index 0da90f0b..42fbdc6b 100644 --- a/tests/quotes.pyi +++ b/tests/quotes.pyi @@ -1,6 +1,6 @@ import sys import typing -from typing import Annotated, Literal, TypeAlias, TypeVar +from typing import Annotated, Literal, NewType, TypeAlias, TypeVar import typing_extensions @@ -15,6 +15,16 @@ __slots__ = ('foo',) # Y052 Need type annotation for "__slots__" def f(x: "int"): ... # Y020 Quoted annotations should never be used in stubs def g(x: list["int"]): ... # Y020 Quoted annotations should never be used in stubs _T = TypeVar("_T", bound="int") # Y020 Quoted annotations should never be used in stubs +_T2 = TypeVar("_T", bound=int) +_S = TypeVar("_S") +_U = TypeVar("_U", "int", "str") # Y020 Quoted annotations should never be used in stubs # Y020 Quoted annotations should never be used in stubs +_U2 = TypeVar("_U", int, str) + +# This is invalid, but type checkers will flag it, so we don't need to +_V = TypeVar() + +def make_sure_those_typevars_arent_flagged_as_unused(a: _T, b: _T2, c: _S, d: _U, e: _U2, f: _V) -> tuple[_T, _T2, _S, _U, _U2, _V]: ... + def h(w: Literal["a", "b"], x: typing.Literal["c"], y: typing_extensions.Literal["d"], z: _T) -> _T: ... def i(x: Annotated[int, "lots", "of", "strings"], b: typing.Annotated[str, "more", "strings"]) -> None: @@ -60,3 +70,6 @@ class DocstringAndPass: k = "" # Y052 Need type annotation for "k" el = r"" # Y052 Need type annotation for "el" m = u"" # Y052 Need type annotation for "m" + +_N = NewType("_N", int) +_NBad = NewType("_N", "int") # Y020 Quoted annotations should never be used in stubs