diff --git a/.flake8 b/.flake8 index 72e5313e..31c4d73e 100644 --- a/.flake8 +++ b/.flake8 @@ -5,3 +5,5 @@ ignore = E302, E501, E701, W503, E203 max-line-length = 80 max-complexity = 12 select = B,C,E,F,W,Y,B9 +per-file-ignores = + tests/imports.pyi: F401, F811 \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 37df761d..4ae81f4e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,10 @@ unreleased * introduce Y016 (duplicate union member) * support Python 3.10 * discontinue support for Python 3.6 +* introduce Y022 (prefer stdlib classes over ``typing`` aliases) +* introduce Y023 (prefer ``typing`` over ``typing_extensions``) +* introduce Y024 (prefer ``typing.NamedTuple`` to ``collections.namedtuple``) +* introduce Y025 (always alias ``collections.abc.Set``) 20.10.0 ~~~~~~~ diff --git a/README.rst b/README.rst index 8772fbfb..f9f9ab05 100644 --- a/README.rst +++ b/README.rst @@ -81,6 +81,16 @@ currently emitted: an instance of ``cls``, and ``__new__`` methods. * Y020: Quoted annotations should never be used in stubs. * Y021: Docstrings should not be included in stubs. +* Y022: Imports linting: use typing-module aliases to stdlib objects as little + as possible (e.g. ``builtins.list`` over ``typing.List``, + ``collections.Counter`` over ``typing.Counter``, etc.). +* Y023: Where there is no detriment to backwards compatibility, import objects + such as ``ClassVar`` and ``NoReturn`` from ``typing`` rather than + ``typing_extensions``. +* Y024: Use ``typing.NamedTuple`` instead of ``collections.namedtuple``, as it allows + for more precise type inference. +* Y025: Always alias ``collections.abc.Set`` when importing it, so as to avoid + confusion with ``builtins.set``. The following warnings are disabled by default: diff --git a/pyi.py b/pyi.py index a706ee31..bf74a4ca 100644 --- a/pyi.py +++ b/pyi.py @@ -42,6 +42,59 @@ class TypeVarInfo(NamedTuple): name: str +# OrderedDict is omitted from this blacklist: +# -- In Python 3, we'd rather import it from collections, not typing or typing_extensions +# -- But in Python 2, it cannot be imported from collections or typing, only from typing_extensions +# +# ChainMap does not exist in typing or typing_extensions in Python 2, +# so we can disallow importing it from anywhere except collections +_BAD_Y022_IMPORTS = { + # typing aliases for collections + "typing.Counter": "collections.Counter", + "typing.Deque": "collections.deque", + "typing.DefaultDict": "collections.defaultdict", + "typing.ChainMap": "collections.ChainMap", + # typing aliases for builtins + "typing.Dict": "builtins.dict", + "typing.FrozenSet": "builtins.frozenset", + "typing.List": "builtins.list", + "typing.Set": "builtins.set", + "typing.Tuple": "builtins.tuple", + "typing.Type": "builtins.type", + # One typing alias for contextlib + "typing.AsyncContextManager": "contextlib.AbstractAsyncContextManager", + # typing_extensions aliases for collections + "typing_extensions.Counter": "collections.Counter", + "typing_extensions.Deque": "collections.deque", + "typing_extensions.DefaultDict": "collections.defaultdict", + "typing_extensions.ChainMap": "collections.ChainMap", + # One typing_extensions alias for a builtin + "typing_extensions.Type": "builtins.type", + # one typing_extensions alias for contextlib + "typing_extensions.AsyncContextManager": "contextlib.AbstractAsyncContextManager", +} + +# typing_extensions.ContextManager is omitted from this collection - special-cased +_BAD_Y023_IMPORTS = frozenset( + { + # collections.abc aliases + "Awaitable", + "Coroutine", + "AsyncIterable", + "AsyncIterator", + "AsyncGenerator", + # typing aliases + "Protocol", + "runtime_checkable", + "ClassVar", + "NewType", + "overload", + "Text", + "NoReturn", + } +) + + class PyiAwareFlakesChecker(FlakesChecker): def deferHandleNode(self, node, parent): self.deferFunction(lambda: self.handleNode(node, parent)) @@ -187,6 +240,73 @@ def in_class(self) -> bool: """Determine whether we are inside a `class` statement""" return bool(self._class_nesting) + def _check_import_or_attribute( + self, node: ast.Attribute | ast.ImportFrom, module_name: str, object_name: str + ) -> None: + fullname = f"{module_name}.{object_name}" + + # Y022 errors + if fullname in _BAD_Y022_IMPORTS: + error_message = Y022.format( + good_cls_name=f'"{_BAD_Y022_IMPORTS[fullname]}"', + bad_cls_alias=fullname, + ) + + # Y023 errors + elif module_name == "typing_extensions": + if object_name in _BAD_Y023_IMPORTS: + error_message = Y023.format( + good_cls_name=f'"typing.{object_name}"', + bad_cls_alias=f"typing_extensions.{object_name}", + ) + elif object_name == "ContextManager": + suggested_syntax = ( + '"contextlib.AbstractContextManager" ' + '(or "typing.ContextManager" in Python 2-compatible code)' + ) + error_message = Y023.format( + good_cls_name=suggested_syntax, + bad_cls_alias="typing_extensions.ContextManager", + ) + else: + return + + # Y024 errors + elif fullname == "collections.namedtuple": + error_message = Y024 + + else: + return + + self.error(node, error_message) + + def visit_Attribute(self, node: ast.Attribute) -> None: + self.generic_visit(node) + thing = node.value + if not isinstance(thing, ast.Name): + return + + self._check_import_or_attribute( + node=node, module_name=thing.id, object_name=node.attr + ) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + module_name, imported_objects = node.module, node.names + + if module_name is None: + return + + if module_name == "collections.abc" and any( + obj.name == "Set" and obj.asname != "AbstractSet" + for obj in imported_objects + ): + return self.error(node, Y025) + + for obj in imported_objects: + self._check_import_or_attribute( + node=node, module_name=module_name, object_name=obj.name + ) + def visit_Assign(self, node: ast.Assign) -> None: if self.in_function: # We error for unexpected things within functions separately. @@ -262,6 +382,7 @@ def visit_Expr(self, node: ast.Expr) -> None: self.generic_visit(node) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self.generic_visit(node) if isinstance(node.annotation, ast.Name) and node.annotation.id == "TypeAlias": return if node.value and not isinstance(node.value, ast.Ellipsis): @@ -753,6 +874,13 @@ def should_warn(self, code): Y019 = 'Y019 Use "_typeshed.Self" instead of "{typevar_name}"' Y020 = "Y020 Quoted annotations should never be used in stubs" Y021 = "Y021 Docstrings should not be included in stubs" +Y022 = 'Y022 Use {good_cls_name} instead of "{bad_cls_alias}"' +Y023 = 'Y023 Use {good_cls_name} instead of "{bad_cls_alias}"' +Y024 = 'Y024 Use "typing.NamedTuple" instead of "collections.namedtuple"' +Y025 = ( + 'Y025 Use "from collections.abc import Set as AbstractSet" ' + 'to avoid confusion with "builtins.set"' +) Y093 = "Y093 Use typing_extensions.TypeAlias for type aliases" DISABLED_BY_DEFAULT = [Y093] diff --git a/tests/del.pyi b/tests/del.pyi index 0909c978..d92a95a0 100644 --- a/tests/del.pyi +++ b/tests/del.pyi @@ -1,7 +1,7 @@ -from typing import List, Union +from typing import Union -ManyStr = List[EitherStr] +ManyStr = list[EitherStr] EitherStr = Union[str, bytes] diff --git a/tests/imports.pyi b/tests/imports.pyi new file mode 100644 index 00000000..ecb80b6f --- /dev/null +++ b/tests/imports.pyi @@ -0,0 +1,173 @@ +# NOTE: F401 & F811 are ignored in this file in the .flake8 config file + +# GOOD IMPORTS +import typing +import typing_extensions +import collections +import collections.abc +from collections import ChainMap, Counter, OrderedDict, UserDict, UserList, UserString, defaultdict, deque +from collections.abc import ( + Awaitable, + Coroutine, + AsyncIterable, + AsyncIterator, + AsyncGenerator, + Hashable, + Iterable, + Iterator, + Generator, + Reversible, + Set as AbstractSet, + Sized, + Container, + Callable, + Collection, + MutableSet, + MutableMapping, + MappingView, + KeysView, + ItemsView, + ValuesView, + Sequence, + MutableSequence, + ByteString +) + +# Things that are of no use for stub files are intentionally omitted. +from typing import ( + Any, + Callable, + ClassVar, + Generic, + Optional, + Protocol, + TypeVar, + Union, + AbstractSet, + ByteString, + Container, + Hashable, + ItemsView, + Iterable, + Iterator, + KeysView, + Mapping, + MappingView, + MutableMapping, + MutableSequence, + MutableSet, + Sequence, + Sized, + ValuesView, + Awaitable, + AsyncIterator, + AsyncIterable, + Coroutine, + Collection, + AsyncGenerator, + Reversible, + SupportsAbs, + SupportsBytes, + SupportsComplex, + SupportsFloat, + SupportsIndex, + SupportsInt, + SupportsRound, + Generator, + BinaryIO, + IO, + NamedTuple, + Match, + Pattern, + TextIO, + AnyStr, + NewType, + NoReturn, + overload, + ContextManager # ContextManager must be importable from typing (but not typing_extensions) for Python 2 compabitility +) +from typing_extensions import ( + Concatenate, + Final, + ParamSpec, + SupportsIndex, + final, + Literal, + TypeAlias, + TypeGuard, + Annotated, + TypedDict, + OrderedDict # OrderedDict must be importable from typing_extensions (but not typing) for Python 2 compatibility +) + + +# BAD IMPORTS (Y022 code) +from typing import Dict # Y022 Use "builtins.dict" instead of "typing.Dict" +from typing import Counter # Y022 Use "collections.Counter" instead of "typing.Counter" +from typing import AsyncContextManager # Y022 Use "contextlib.AbstractAsyncContextManager" instead of "typing.AsyncContextManager" +from typing import ChainMap # Y022 Use "collections.ChainMap" instead of "typing.ChainMap" +from typing_extensions import Type # Y022 Use "builtins.type" instead of "typing_extensions.Type" +from typing_extensions import DefaultDict # Y022 Use "collections.defaultdict" instead of "typing_extensions.DefaultDict" +from typing_extensions import ChainMap # Y022 Use "collections.ChainMap" instead of "typing_extensions.ChainMap" +from typing_extensions import AsyncContextManager # Y022 Use "contextlib.AbstractAsyncContextManager" instead of "typing_extensions.AsyncContextManager" + +# BAD IMPORTS (Y023 code) +from typing_extensions import ClassVar # Y023 Use "typing.ClassVar" instead of "typing_extensions.ClassVar" +from typing_extensions import Awaitable # Y023 Use "typing.Awaitable" instead of "typing_extensions.Awaitable" +from typing_extensions import ContextManager # Y023 Use "contextlib.AbstractContextManager" (or "typing.ContextManager" in Python 2-compatible code) instead of "typing_extensions.ContextManager" + +# BAD IMPORTS: OTHER +from collections import namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" +from collections.abc import Set # Y025 Use "from collections.abc import Set as AbstractSet" to avoid confusion with "builtins.set" + +# GOOD ATTRIBUTE ACCESS +foo: typing.SupportsIndex + +@typing_extensions.final +def bar(arg: collections.abc.Sized) -> typing_extensions.Literal[True]: + ... + + +class Fish: + blah: collections.deque[int] + + def method(self, arg: typing.SupportsInt = ...) -> None: + ... + + +# BAD ATTRIBUTE ACCESS (Y022 code) +a: typing.Dict[str, int] # Y022 Use "builtins.dict" instead of "typing.Dict" + +def func1() -> typing.Counter[float]: # Y022 Use "collections.Counter" instead of "typing.Counter" + ... + + +def func2(c: typing.AsyncContextManager[None]) -> None: # Y022 Use "contextlib.AbstractAsyncContextManager" instead of "typing.AsyncContextManager" + ... + + +def func3(d: typing.ChainMap[int, str] = ...) -> None: # Y022 Use "collections.ChainMap" instead of "typing.ChainMap" + ... + + +class Spam: + def meth1() -> typing_extensions.DefaultDict[bytes, bytes]: # Y022 Use "collections.defaultdict" instead of "typing_extensions.DefaultDict" + ... + + def meth2(self, f: typing_extensions.ChainMap[str, str]) -> None: # Y022 Use "collections.ChainMap" instead of "typing_extensions.ChainMap" + ... + + def meth3(self, g: typing_extensions.AsyncContextManager[Any] = ...) -> None: # Y022 Use "contextlib.AbstractAsyncContextManager" instead of "typing_extensions.AsyncContextManager" + ... + + +# BAD ATTRIBUTE ACCESS (Y023 code) +class Foo: + attribute: typing_extensions.ClassVar[int] # Y023 Use "typing.ClassVar" instead of "typing_extensions.ClassVar" + + +h: typing_extensions.Awaitable[float] # Y023 Use "typing.Awaitable" instead of "typing_extensions.Awaitable" +i: typing_extensions.ContextManager[None] # Y023 Use "contextlib.AbstractContextManager" (or "typing.ContextManager" in Python 2-compatible code) instead of "typing_extensions.ContextManager" + +# BAD ATTRIBUTE ACCESS: OTHER +j: collections.namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" diff --git a/tests/vanilla_flake8_not_clean_forward_refs.pyi b/tests/vanilla_flake8_not_clean_forward_refs.pyi index 3702e208..379defb4 100644 --- a/tests/vanilla_flake8_not_clean_forward_refs.pyi +++ b/tests/vanilla_flake8_not_clean_forward_refs.pyi @@ -1,8 +1,8 @@ # flags: --no-pyi-aware-file-checker -from typing import List, Union +from typing import Union -ManyStr = List[EitherStr] # F821 undefined name 'EitherStr' +ManyStr = list[EitherStr] # F821 undefined name 'EitherStr' EitherStr = Union[str, bytes]