Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect unused TypeVars #83

Merged
merged 8 commits into from
Jan 16, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Change Log
unreleased
~~~~~~~~~~

* extend Y010 to check async functions in addition to normal functions
* introduce Y018 (detect unused TypeVars)
* extend Y010 to check async functions in addition to normal functions
* introduce Y093 (require using TypeAlias for type aliases)
* introduce Y017 (disallows assignments with multiple targets or non-name targets)
* extend Y001 to cover ParamSpec and TypeVarTuple in addition to TypeVar
Expand Down
10 changes: 8 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ List of warnings
This plugin reserves codes starting with **Y0**. The following warnings are
currently emitted:

* Y001: Names of TypeVars in stubs should start with `_`. This makes sure you
don't accidentally expose names internal to the stub.
* Y001: Names of TypeVars, ParamSpecs and TypeVarTuples in stubs should usually
start with `_`. This makes sure you don't accidentally expose names internal
to the stub.
* Y002: If test must be a simple comparison against `sys.platform` or
`sys.version_info`. Stub files support simple conditionals to indicate
differences between Python versions or platforms, but type checkers only
Expand Down Expand Up @@ -67,6 +68,10 @@ currently emitted:
of Y011 that includes arguments without type annotations.
* Y015: Attribute must not have a default value other than "...".
* Y016: Unions shouldn't contain duplicates, e.g. `str | str` is not allowed.
* Y017: Stubs should not contain assignments with multiple targets or non-name
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
targets.
* Y018: A private TypeVar should be used at least once in the file in which it
is defined.

The following warnings are disabled by default:

Expand All @@ -85,6 +90,7 @@ The following warnings are disabled by default:

* Y091: Function body must not contain "raise".
* Y092: Top-level attribute must not have a default value.
* Y093: Type aliases should be explicitly demarcated with ``typing.TypeAlias``.

License
-------
Expand Down
44 changes: 36 additions & 8 deletions pyi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import argparse
import ast
import sys
from collections import Counter
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from flake8 import checker # type: ignore
Expand Down Expand Up @@ -33,6 +34,11 @@ class Error(NamedTuple):
type: type


class TypeVarInfo(NamedTuple):
cls_name: str
name: str


class PyiAwareFlakesChecker(FlakesChecker):
def deferHandleNode(self, node, parent):
self.deferFunction(lambda: self.handleNode(node, parent))
Expand Down Expand Up @@ -146,6 +152,11 @@ def run_check(self, plugin, **kwargs):
class PyiVisitor(ast.NodeVisitor):
filename: Path = Path("(none)")
errors: list[Error] = field(default_factory=list)
# Mapping of all private TypeVars/ParamSpecs/TypeVarTuples to the nodes where they're defined
typevarlike_defs: dict[TypeVarInfo, ast.Assign] = field(default_factory=dict)
# Mapping of each name in the file to the no. of occurrences
all_name_occurrences: Counter[str] = field(default_factory=Counter)
# Collection of the linenos of all the union statements we've seen
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
_class_nesting: int = 0
_function_nesting: int = 0

Expand All @@ -171,22 +182,32 @@ def visit_Assign(self, node: ast.Assign) -> None:
if not isinstance(target, ast.Name):
self.error(node, Y017)
return
# Attempt to find assignments to type helpers (typevars and aliases), which should be
# private.
if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name):
if node.value.func.id in ("TypeVar", "ParamSpec", "TypeVarTuple"):
if not target.id.startswith("_"):
self.error(target, Y001.format(node.value.func.id))
assignment = node.value
# Attempt to find assignments to type helpers (typevars and aliases),
# which should usually be private. If they are private,
# they should be used at least once in the file in which they are defined.
if isinstance(assignment, ast.Call) and isinstance(assignment.func, ast.Name):
cls_name = assignment.func.id
if cls_name in ("TypeVar", "ParamSpec", "TypeVarTuple"):
typevar_name = target.id
if typevar_name.startswith("_"):
target_info = TypeVarInfo(cls_name=cls_name, name=typevar_name)
self.typevarlike_defs[target_info] = node
else:
self.error(target, Y001.format(cls_name))
return
# We allow assignment-based TypedDict creation for dicts that have
# keys that aren't valid as identifiers.
elif node.value.func.id == "TypedDict":
elif cls_name == "TypedDict":
return
if isinstance(node.value, (ast.Num, ast.Str)):
self.error(node.value, Y015)
else:
self.error(node, Y093)

def visit_Name(self, node: ast.Name) -> None:
self.all_name_occurrences[node.id] += 1

def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
if isinstance(node.annotation, ast.Name) and node.annotation.id == "TypeAlias":
return
Expand Down Expand Up @@ -226,7 +247,7 @@ def visit_BinOp(self, node: ast.BinOp) -> None:

# Do not call generic_visit(node), that would call this method again unnecessarily
for member in members:
self.generic_visit(member)
self.visit(member)

self._check_union_members(members)

Expand Down Expand Up @@ -440,6 +461,12 @@ def error(self, node: ast.AST, message: str) -> None:
def run(self, tree: ast.AST) -> Iterable[Error]:
self.errors.clear()
self.visit(tree)
for (cls_name, typevar_name), def_node in self.typevarlike_defs.items():
if self.all_name_occurrences[typevar_name] == 1:
self.error(
def_node,
Y018.format(typevarlike_cls=cls_name, typevar_name=typevar_name),
)
yield from self.errors


Expand Down Expand Up @@ -534,6 +561,7 @@ def should_warn(self, code):
Y015 = 'Y015 Attribute must not have a default value other than "..."'
Y016 = "Y016 Duplicate union member"
Y017 = "Y017 Only simple assignments allowed"
Y018 = 'Y018 {typevarlike_cls} "{typevar_name}" is not used'
Y090 = "Y090 Use explicit attributes instead of assignments in __init__"
Y091 = 'Y091 Function body must not contain "raise"'
Y092 = "Y092 Top-level attribute must not have a default value"
Expand Down
17 changes: 14 additions & 3 deletions tests/typevar.pyi
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
from typing import ParamSpec, TypeVar, TypeVarTuple

T = TypeVar("T") # Y001 Name of private TypeVar must start with _
_T = TypeVar("_T")
_T = TypeVar("_T") # Y018 TypeVar "_T" is not used
P = ParamSpec("P") # Y001 Name of private ParamSpec must start with _
_P = ParamSpec("_P")
_P = ParamSpec("_P") # Y018 ParamSpec "_P" is not used
Ts = TypeVarTuple("Ts") # Y001 Name of private TypeVarTuple must start with _
_Ts = TypeVarTuple("_Ts")
_Ts = TypeVarTuple("_Ts") # Y018 TypeVarTuple "_Ts" is not used
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

_UsedTypeVar = TypeVar("_UsedTypeVar")

def func(arg: _UsedTypeVar) -> _UsedTypeVar:
...


_TypeVarUsedInBinOp = TypeVar("_TypeVarUsedInBinOp", bound=str)

def func2(arg: _TypeVarUsedInBinOp | int) -> _TypeVarUsedInBinOp | int:
...