Skip to content

Commit 03168a8

Browse files
authored
Convert the plugin to a package (#519)
1 parent 724afe5 commit 03168a8

File tree

9 files changed

+432
-381
lines changed

9 files changed

+432
-381
lines changed

.github/workflows/typeshed_primer.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: typeshed_primer
33
on:
44
pull_request:
55
paths:
6-
- "pyi.py"
6+
- "flake8_pyi/**/*"
77
- ".github/**/*"
88
workflow_dispatch:
99

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Other changes:
1313
[dependency groups](https://packaging.python.org/en/latest/specifications/dependency-groups/)
1414
rather than
1515
[optional dependencies](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-and-requirements).
16+
* The plugin now exists as a `flake8_pyi` package rather than a single `pyi.py` file.
1617
* Declare support for Python 3.14
1718

1819
## 24.9.0

CONTRIBUTING.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ end will be warmly received.
77

88
## Guide to the codebase
99

10-
The plugin consists of a single file: `pyi.py`. Tests are run using `pytest`, and can be
11-
found in the `tests` folder.
10+
The plugin consists of a single package: `flake8_pyi`. Most of the logic lives in the
11+
`flake8_pyi/visitor.py` file. Tests are run using `pytest`, and can be found in the `tests`
12+
folder.
1213

1314
PRs that make user-visible changes should generally add a short description of the change
1415
to the `CHANGELOG.md` file in the repository root.
@@ -29,8 +30,8 @@ however, we advise setting up a virtual environment first:
2930

3031
To format your code with `isort` and `black`, run:
3132

32-
$ isort pyi.py
33-
$ black pyi.py
33+
$ isort flake8_pyi
34+
$ black flake8_pyi
3435

3536
If you want, you can also run locally the commands that GitHub Actions runs.
3637
Look in `.github/workflows/` to find the commands.

flake8_pyi/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .checker import PyiTreeChecker
2+
3+
__all__ = ["PyiTreeChecker"]

flake8_pyi/checker.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import ast
5+
import logging
6+
import re
7+
from dataclasses import dataclass
8+
from typing import Any, ClassVar, Iterator, Literal
9+
10+
from flake8 import checker
11+
from flake8.options.manager import OptionManager
12+
from flake8.plugins.finder import LoadedPlugin
13+
from flake8.plugins.pyflakes import FlakesChecker
14+
from pyflakes.checker import ModuleScope
15+
16+
from . import errors, visitor
17+
18+
LOG = logging.getLogger("flake8.pyi")
19+
20+
21+
class PyflakesPreProcessor(ast.NodeTransformer):
22+
"""Transform AST prior to passing it to pyflakes.
23+
24+
This reduces false positives on recursive class definitions.
25+
"""
26+
27+
def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
28+
self.generic_visit(node)
29+
node.bases = [
30+
# Remove the subscript to prevent F821 errors from being emitted
31+
# for (valid) recursive definitions: Foo[Bar] --> Foo
32+
base.value if isinstance(base, ast.Subscript) else base
33+
for base in node.bases
34+
]
35+
return node
36+
37+
38+
class PyiAwareFlakesChecker(FlakesChecker):
39+
def __init__(self, tree: ast.AST, *args: Any, **kwargs: Any) -> None:
40+
super().__init__(PyflakesPreProcessor().visit(tree), *args, **kwargs)
41+
42+
@property
43+
def annotationsFutureEnabled(self) -> Literal[True]:
44+
"""Always allow forward references in `.pyi` files.
45+
46+
Pyflakes can already handle forward refs for annotations,
47+
but only via `from __future__ import annotations`.
48+
In a stub file, `from __future__ import annotations` is unnecessary,
49+
so we pretend to pyflakes that it's always present when linting a `.pyi` file.
50+
"""
51+
return True
52+
53+
@annotationsFutureEnabled.setter
54+
def annotationsFutureEnabled(self, value: bool) -> None:
55+
"""Does nothing, as we always want this property to be `True`."""
56+
57+
def ASSIGN(
58+
self, tree: ast.Assign, omit: str | tuple[str, ...] | None = None
59+
) -> None:
60+
"""Defer evaluation of assignments in the module scope.
61+
62+
This is a custom implementation of ASSIGN, originally derived from
63+
handleChildren() in pyflakes 1.3.0.
64+
65+
This reduces false positives for:
66+
- TypeVars bound or constrained to forward references
67+
- Assignments to forward references that are not explicitly
68+
demarcated as type aliases.
69+
"""
70+
if not isinstance(self.scope, ModuleScope):
71+
super().ASSIGN(tree)
72+
return
73+
74+
for target in tree.targets:
75+
self.handleNode(target, tree)
76+
77+
self.deferFunction(lambda: self.handleNode(tree.value, tree))
78+
79+
def handleNodeDelete(self, node: ast.AST) -> None:
80+
"""Null implementation.
81+
82+
Lets users use `del` in stubs to denote private names.
83+
"""
84+
return
85+
86+
87+
class PyiAwareFileChecker(checker.FileChecker):
88+
def run_check(self, plugin: LoadedPlugin, **kwargs: Any) -> Any:
89+
if plugin.obj is FlakesChecker:
90+
if self.filename == "-":
91+
filename = self.options.stdin_display_name
92+
else:
93+
filename = self.filename
94+
95+
if filename.endswith(".pyi"):
96+
LOG.info(
97+
f"Replacing FlakesChecker with PyiAwareFlakesChecker while "
98+
f"checking {filename!r}"
99+
)
100+
plugin = plugin._replace(obj=PyiAwareFlakesChecker)
101+
return super().run_check(plugin, **kwargs)
102+
103+
104+
_TYPE_COMMENT_REGEX = re.compile(r"#\s*type:\s*(?!\s?ignore)([^#]+)(\s*#.*?)?$")
105+
106+
107+
def _check_for_type_comments(lines: list[str]) -> Iterator[errors.Error]:
108+
for lineno, line in enumerate(lines, start=1):
109+
cleaned_line = line.strip()
110+
111+
if cleaned_line.startswith("#"):
112+
continue
113+
114+
if match := _TYPE_COMMENT_REGEX.search(cleaned_line):
115+
type_comment = match.group(1).strip()
116+
117+
try:
118+
ast.parse(type_comment)
119+
except SyntaxError:
120+
continue
121+
122+
yield errors.Error(lineno, 0, errors.Y033, PyiTreeChecker)
123+
124+
125+
@dataclass
126+
class PyiTreeChecker:
127+
name: ClassVar[str] = "flake8-pyi"
128+
tree: ast.Module
129+
lines: list[str]
130+
filename: str = "(none)"
131+
132+
def run(self) -> Iterator[errors.Error]:
133+
if self.filename.endswith(".pyi"):
134+
yield from _check_for_type_comments(self.lines)
135+
yield from visitor.PyiVisitor(filename=self.filename).run(self.tree)
136+
137+
@staticmethod
138+
def add_options(parser: OptionManager) -> None:
139+
"""This is brittle, there's multiple levels of caching of defaults."""
140+
parser.parser.set_defaults(filename="*.py,*.pyi")
141+
parser.extend_default_ignore(errors.DISABLED_BY_DEFAULT)
142+
parser.add_option(
143+
"--no-pyi-aware-file-checker",
144+
default=False,
145+
action="store_true",
146+
parse_from_config=True,
147+
help="don't patch flake8 with .pyi-aware file checker",
148+
)
149+
150+
@staticmethod
151+
def parse_options(options: argparse.Namespace) -> None:
152+
if not options.no_pyi_aware_file_checker:
153+
checker.FileChecker = PyiAwareFileChecker

flake8_pyi/errors.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, NamedTuple
4+
5+
if TYPE_CHECKING:
6+
# Import is only needed for type annotations,
7+
# and causes a circular import if it's imported at runtime.
8+
from .checker import PyiTreeChecker
9+
10+
11+
class Error(NamedTuple):
12+
lineno: int
13+
col: int
14+
message: str
15+
type: type[PyiTreeChecker]
16+
17+
18+
# Please keep error code lists in ERRORCODES and CHANGELOG up to date
19+
Y001 = "Y001 Name of private {} must start with _"
20+
Y002 = (
21+
"Y002 If test must be a simple comparison against sys.platform or sys.version_info"
22+
)
23+
Y003 = "Y003 Unrecognized sys.version_info check"
24+
Y004 = "Y004 Version comparison must use only major and minor version"
25+
Y005 = "Y005 Version comparison must be against a length-{n} tuple"
26+
Y006 = "Y006 Use only < and >= for version comparisons"
27+
Y007 = "Y007 Unrecognized sys.platform check"
28+
Y008 = 'Y008 Unrecognized platform "{platform}"'
29+
Y009 = 'Y009 Empty body should contain "...", not "pass"'
30+
Y010 = 'Y010 Function body must contain only "..."'
31+
Y011 = "Y011 Only simple default values allowed for typed arguments"
32+
Y012 = 'Y012 Class body must not contain "pass"'
33+
Y013 = 'Y013 Non-empty class body must not contain "..."'
34+
Y014 = "Y014 Only simple default values allowed for arguments"
35+
Y015 = "Y015 Only simple default values are allowed for assignments"
36+
Y016 = 'Y016 Duplicate union member "{}"'
37+
Y017 = "Y017 Only simple assignments allowed"
38+
Y018 = 'Y018 {typevarlike_cls} "{typevar_name}" is not used'
39+
Y019 = (
40+
'Y019 Use "typing_extensions.Self" instead of "{typevar_name}", e.g. "{new_syntax}"'
41+
)
42+
Y020 = "Y020 Quoted annotations should never be used in stubs"
43+
Y021 = "Y021 Docstrings should not be included in stubs"
44+
Y022 = "Y022 Use {good_syntax} instead of {bad_syntax} (PEP 585 syntax)"
45+
Y023 = "Y023 Use {good_syntax} instead of {bad_syntax}"
46+
Y024 = 'Y024 Use "typing.NamedTuple" instead of "collections.namedtuple"'
47+
Y025 = (
48+
'Y025 Use "from collections.abc import Set as AbstractSet" '
49+
'to avoid confusion with "builtins.set"'
50+
)
51+
Y026 = 'Y026 Use typing_extensions.TypeAlias for type aliases, e.g. "{suggestion}"'
52+
Y028 = "Y028 Use class-based syntax for NamedTuples"
53+
Y029 = "Y029 Defining __repr__ or __str__ in a stub is almost always redundant"
54+
Y030 = "Y030 Multiple Literal members in a union. {suggestion}"
55+
Y031 = "Y031 Use class-based syntax for TypedDicts where possible"
56+
Y032 = (
57+
'Y032 Prefer "object" to "Any" for the second parameter in "{method_name}" methods'
58+
)
59+
Y033 = (
60+
"Y033 Do not use type comments in stubs "
61+
'(e.g. use "x: int" instead of "x = ... # type: int")'
62+
)
63+
Y034 = (
64+
'Y034 {methods} usually return "self" at runtime. '
65+
'Consider using "typing_extensions.Self" in "{method_name}", '
66+
'e.g. "{suggested_syntax}"'
67+
)
68+
Y035 = (
69+
'Y035 "{var}" in a stub file must have a value, '
70+
'as it has the same semantics as "{var}" at runtime.'
71+
)
72+
Y036 = "Y036 Badly defined {method_name} method: {details}"
73+
Y037 = "Y037 Use PEP 604 union types instead of {old_syntax} (e.g. {example})."
74+
Y038 = (
75+
'Y038 Use "from collections.abc import Set as AbstractSet" '
76+
'instead of "from {module} import AbstractSet" (PEP 585 syntax)'
77+
)
78+
Y039 = 'Y039 Use "str" instead of "{module}.Text"'
79+
Y040 = 'Y040 Do not inherit from "object" explicitly, as it is redundant in Python 3'
80+
Y041 = (
81+
'Y041 Use "{implicit_supertype}" '
82+
'instead of "{implicit_subtype} | {implicit_supertype}" '
83+
'(see "The numeric tower" in PEP 484)'
84+
)
85+
Y042 = "Y042 Type aliases should use the CamelCase naming convention"
86+
Y043 = 'Y043 Bad name for a type alias (the "T" suffix implies a TypeVar)'
87+
Y044 = 'Y044 "from __future__ import annotations" has no effect in stub files.'
88+
Y045 = 'Y045 "{iter_method}" methods should return an {good_cls}, not an {bad_cls}'
89+
Y046 = 'Y046 Protocol "{protocol_name}" is not used'
90+
Y047 = 'Y047 Type alias "{alias_name}" is not used'
91+
Y048 = "Y048 Function body should contain exactly one statement"
92+
Y049 = 'Y049 TypedDict "{typeddict_name}" is not used'
93+
Y050 = (
94+
'Y050 Use "typing_extensions.Never" instead of "NoReturn" for argument annotations'
95+
)
96+
Y051 = 'Y051 "{literal_subtype}" is redundant in a union with "{builtin_supertype}"'
97+
Y052 = 'Y052 Need type annotation for "{variable}"'
98+
Y053 = "Y053 String and bytes literals >50 characters long are not permitted"
99+
Y054 = (
100+
"Y054 Numeric literals with a string representation "
101+
">10 characters long are not permitted"
102+
)
103+
Y055 = 'Y055 Multiple "type[Foo]" members in a union. {suggestion}'
104+
Y056 = (
105+
'Y056 Calling "{method}" on "__all__" may not be supported by all type checkers '
106+
"(use += instead)"
107+
)
108+
Y057 = (
109+
"Y057 Do not use {module}.ByteString, which has unclear semantics and is deprecated"
110+
)
111+
Y058 = (
112+
'Y058 Use "{good_cls}" as the return value for simple "{iter_method}" methods, '
113+
'e.g. "{example}"'
114+
)
115+
Y059 = 'Y059 "Generic[]" should always be the last base class'
116+
Y060 = (
117+
'Y060 Redundant inheritance from "{redundant_base}"; '
118+
"class would be inferred as generic anyway"
119+
)
120+
Y061 = 'Y061 None inside "Literal[]" expression. Replace with "{suggestion}"'
121+
Y062 = 'Y062 Duplicate "Literal[]" member "{}"'
122+
Y063 = "Y063 Use PEP-570 syntax to indicate positional-only arguments"
123+
Y064 = 'Y064 Use "{suggestion}" instead of "{original}"'
124+
Y065 = 'Y065 Leave {what} unannotated rather than using "Incomplete"'
125+
Y066 = (
126+
"Y066 When using if/else with sys.version_info, "
127+
'put the code for new Python versions first, e.g. "{new_syntax}"'
128+
)
129+
Y067 = 'Y067 Use "=None" instead of "Incomplete | None = None"'
130+
Y090 = (
131+
'Y090 "{original}" means '
132+
'"a tuple of length 1, in which the sole element is of type {typ!r}". '
133+
'Perhaps you meant "{new}"?'
134+
)
135+
Y091 = (
136+
'Y091 Argument "{arg}" to protocol method "{method}" should probably not be positional-or-keyword. '
137+
"Make it positional-only, since usually you don't want to mandate a specific argument name"
138+
)
139+
140+
DISABLED_BY_DEFAULT = ["Y090", "Y091"]

0 commit comments

Comments
 (0)