flake8-agents 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flake8_agents/__init__.py +4 -0
- flake8_agents/_version.py +24 -0
- flake8_agents/_version_.py +35 -0
- flake8_agents/anti_pattern.py +393 -0
- flake8_agents/checker.py +231 -0
- flake8_agents/cli/__init__.py +3 -0
- flake8_agents/cli/module_size.py +405 -0
- flake8_agents/import_boundary.py +234 -0
- flake8_agents/py.typed +0 -0
- flake8_agents/type_escape.py +730 -0
- flake8_agents-0.1.0.dist-info/METADATA +131 -0
- flake8_agents-0.1.0.dist-info/RECORD +15 -0
- flake8_agents-0.1.0.dist-info/WHEEL +4 -0
- flake8_agents-0.1.0.dist-info/entry_points.txt +5 -0
- flake8_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
__all__ = ["__commit_id__", "__version__"]
|
|
4
|
+
|
|
5
|
+
__version__: str
|
|
6
|
+
__commit_id__: str | None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def __getattr__(name: str) -> Any: # noqa: AGT105
|
|
10
|
+
match name:
|
|
11
|
+
case "__version__":
|
|
12
|
+
try:
|
|
13
|
+
# pyrefly: ignore[missing-import]
|
|
14
|
+
from flake8_agents._version import ( # noqa: PLC0415,AGT300
|
|
15
|
+
__version__ as _version,
|
|
16
|
+
)
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
from importlib.metadata import version # noqa: PLC0415
|
|
19
|
+
|
|
20
|
+
_version = version("flake8-agents")
|
|
21
|
+
globals()["__version__"] = _version
|
|
22
|
+
return _version
|
|
23
|
+
case "__commit_id__":
|
|
24
|
+
try:
|
|
25
|
+
# pyrefly: ignore[missing-import]
|
|
26
|
+
from flake8_agents._version import ( # noqa: PLC0415,AGT300
|
|
27
|
+
__commit_id__ as _commit_id,
|
|
28
|
+
)
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
_commit_id = None
|
|
31
|
+
globals()["__commit_id__"] = _commit_id
|
|
32
|
+
return _commit_id
|
|
33
|
+
|
|
34
|
+
error_msg = f"module {__name__!r} has no attribute {name!r}"
|
|
35
|
+
raise AttributeError(error_msg)
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from typing_extensions import Self, override
|
|
10
|
+
|
|
11
|
+
from flake8_agents._version_ import __version__ # noqa: AGT300
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator, Sequence
|
|
15
|
+
|
|
16
|
+
__all__ = ["AntiPatternChecker"]
|
|
17
|
+
|
|
18
|
+
_DYNAMIC_BUILTIN_CODES: dict[str, DiagnosticCode]
|
|
19
|
+
_MONKEYPATCH_NAME = "monkeypatch"
|
|
20
|
+
_IMPORTLIB_MODULE = "importlib"
|
|
21
|
+
_IMPORT_MODULE_NAME = "import_module"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DiagnosticCode(Enum):
|
|
25
|
+
GETATTR = "AGT200"
|
|
26
|
+
SETATTR = "AGT201"
|
|
27
|
+
VARS = "AGT202"
|
|
28
|
+
DUNDER_IMPORT = "AGT203"
|
|
29
|
+
IMPORT_MODULE = "AGT204"
|
|
30
|
+
ATTRIBUTE_SETATTR = "AGT205"
|
|
31
|
+
DUNDER_SETATTR = "AGT206"
|
|
32
|
+
DUNDER_NEW = "AGT207"
|
|
33
|
+
NAMESPACE_DICT_DIRECT_INDEX = "AGT208"
|
|
34
|
+
NAMESPACE_DICT_ALIAS_ASSIGNMENT = "AGT209"
|
|
35
|
+
NAMESPACE_DICT_ALIAS_INDEX = "AGT210"
|
|
36
|
+
DOTTED_IMPORT_ALIAS = "AGT211"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_MESSAGES: dict[DiagnosticCode, str] = {
|
|
40
|
+
DiagnosticCode.GETATTR: "avoid dynamic getattr calls",
|
|
41
|
+
DiagnosticCode.SETATTR: "avoid dynamic setattr calls",
|
|
42
|
+
DiagnosticCode.VARS: "avoid dynamic vars calls",
|
|
43
|
+
DiagnosticCode.DUNDER_IMPORT: "avoid dynamic __import__ calls",
|
|
44
|
+
DiagnosticCode.IMPORT_MODULE: "avoid dynamic import_module calls",
|
|
45
|
+
DiagnosticCode.ATTRIBUTE_SETATTR: "avoid setattr-style mutation methods",
|
|
46
|
+
DiagnosticCode.DUNDER_SETATTR: "avoid dynamic __setattr__ calls",
|
|
47
|
+
DiagnosticCode.DUNDER_NEW: "avoid direct __new__ calls",
|
|
48
|
+
DiagnosticCode.NAMESPACE_DICT_DIRECT_INDEX: "avoid indexing raw __dict__",
|
|
49
|
+
DiagnosticCode.NAMESPACE_DICT_ALIAS_ASSIGNMENT: "avoid aliasing raw __dict__",
|
|
50
|
+
DiagnosticCode.NAMESPACE_DICT_ALIAS_INDEX: "avoid indexing raw __dict__ aliases",
|
|
51
|
+
DiagnosticCode.DOTTED_IMPORT_ALIAS: "avoid aliasing dotted module imports",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_DYNAMIC_BUILTIN_CODES = {
|
|
56
|
+
"getattr": DiagnosticCode.GETATTR,
|
|
57
|
+
"setattr": DiagnosticCode.SETATTR,
|
|
58
|
+
"vars": DiagnosticCode.VARS,
|
|
59
|
+
"__import__": DiagnosticCode.DUNDER_IMPORT,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Diagnostic:
|
|
65
|
+
line_number: int
|
|
66
|
+
column_number: int
|
|
67
|
+
code: DiagnosticCode
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def message(self) -> str:
|
|
71
|
+
return f"{self.code.value} {_MESSAGES[self.code]}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class SourceContext:
|
|
76
|
+
tree: ast.AST
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def build(cls, tree: ast.AST) -> Self:
|
|
80
|
+
return cls(tree=tree)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AntiPatternChecker:
|
|
84
|
+
"""Flake8 checker for dynamic Python anti-patterns."""
|
|
85
|
+
|
|
86
|
+
name = "flake8-agents-anti-pattern"
|
|
87
|
+
version = __version__
|
|
88
|
+
|
|
89
|
+
def __init__(self, tree: ast.AST, filename: str, lines: Sequence[str]) -> None:
|
|
90
|
+
del filename, lines
|
|
91
|
+
self._context = SourceContext.build(tree)
|
|
92
|
+
|
|
93
|
+
def run(self) -> Iterator[tuple[int, int, str, type[AntiPatternChecker]]]:
|
|
94
|
+
"""Yield flake8-compatible anti-pattern diagnostics for this file."""
|
|
95
|
+
diagnostics = _scan_context(self._context)
|
|
96
|
+
for diagnostic in diagnostics:
|
|
97
|
+
yield (
|
|
98
|
+
diagnostic.line_number,
|
|
99
|
+
diagnostic.column_number,
|
|
100
|
+
diagnostic.message,
|
|
101
|
+
type(self),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scan_context(context: SourceContext) -> tuple[Diagnostic, ...]:
|
|
106
|
+
visitor = _AntiPatternVisitor(context)
|
|
107
|
+
visitor.visit(context.tree)
|
|
108
|
+
return tuple(visitor.diagnostics)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _AntiPatternVisitor(ast.NodeVisitor):
|
|
112
|
+
def __init__(self, context: SourceContext) -> None:
|
|
113
|
+
self._context = context
|
|
114
|
+
self._call_shadow_scopes: deque[set[str]] = deque([set()])
|
|
115
|
+
self._import_module_scopes: deque[set[str]] = deque([
|
|
116
|
+
{f"{_IMPORTLIB_MODULE}.{_IMPORT_MODULE_NAME}"}
|
|
117
|
+
])
|
|
118
|
+
self._namespace_scopes: deque[dict[str, bool]] = deque([{}])
|
|
119
|
+
self.diagnostics: list[Diagnostic] = []
|
|
120
|
+
|
|
121
|
+
@override
|
|
122
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
123
|
+
for alias in node.names:
|
|
124
|
+
bound_name = alias.asname or alias.name.partition(".")[0]
|
|
125
|
+
if alias.name == _IMPORTLIB_MODULE:
|
|
126
|
+
self._unshadow_call(bound_name)
|
|
127
|
+
self._bind_import_module_name(f"{bound_name}.{_IMPORT_MODULE_NAME}")
|
|
128
|
+
self._bind_namespace_name(bound_name, is_alias=False)
|
|
129
|
+
else:
|
|
130
|
+
self._bind_name(bound_name, is_namespace_alias=False)
|
|
131
|
+
if alias.asname is not None and "." in alias.name:
|
|
132
|
+
self._record(node, DiagnosticCode.DOTTED_IMPORT_ALIAS)
|
|
133
|
+
|
|
134
|
+
@override
|
|
135
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
136
|
+
for alias in node.names:
|
|
137
|
+
bound_name = alias.asname or alias.name
|
|
138
|
+
if node.module == _IMPORTLIB_MODULE and alias.name == _IMPORT_MODULE_NAME:
|
|
139
|
+
self._unshadow_call(bound_name)
|
|
140
|
+
self._bind_import_module_name(bound_name)
|
|
141
|
+
self._bind_namespace_name(bound_name, is_alias=False)
|
|
142
|
+
else:
|
|
143
|
+
self._bind_name(bound_name, is_namespace_alias=False)
|
|
144
|
+
|
|
145
|
+
@override
|
|
146
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
147
|
+
self._bind_name(node.name, is_namespace_alias=False)
|
|
148
|
+
self._visit_function_scope(node)
|
|
149
|
+
|
|
150
|
+
@override
|
|
151
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
152
|
+
self._bind_name(node.name, is_namespace_alias=False)
|
|
153
|
+
self._visit_function_scope(node)
|
|
154
|
+
|
|
155
|
+
@override
|
|
156
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
157
|
+
self._bind_name(node.name, is_namespace_alias=False)
|
|
158
|
+
self._call_shadow_scopes.append(set())
|
|
159
|
+
self._import_module_scopes.append(set())
|
|
160
|
+
self._namespace_scopes.append({})
|
|
161
|
+
self.generic_visit(node)
|
|
162
|
+
self._namespace_scopes.pop()
|
|
163
|
+
self._import_module_scopes.pop()
|
|
164
|
+
self._call_shadow_scopes.pop()
|
|
165
|
+
|
|
166
|
+
@override
|
|
167
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
168
|
+
self.visit(node.value)
|
|
169
|
+
is_namespace_alias = _is_namespace_dictionary(node.value)
|
|
170
|
+
for target in node.targets:
|
|
171
|
+
self.visit(target)
|
|
172
|
+
self._bind_assignment_target(target, is_namespace_alias=is_namespace_alias)
|
|
173
|
+
if is_namespace_alias:
|
|
174
|
+
self._record(node, DiagnosticCode.NAMESPACE_DICT_ALIAS_ASSIGNMENT)
|
|
175
|
+
|
|
176
|
+
@override
|
|
177
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
178
|
+
self.visit(node.annotation)
|
|
179
|
+
if node.value is not None:
|
|
180
|
+
self.visit(node.value)
|
|
181
|
+
is_namespace_alias = node.value is not None and _is_namespace_dictionary(
|
|
182
|
+
node.value
|
|
183
|
+
)
|
|
184
|
+
self.visit(node.target)
|
|
185
|
+
self._bind_assignment_target(node.target, is_namespace_alias=is_namespace_alias)
|
|
186
|
+
if is_namespace_alias and isinstance(node.target, ast.Name):
|
|
187
|
+
self._record(node, DiagnosticCode.NAMESPACE_DICT_ALIAS_ASSIGNMENT)
|
|
188
|
+
|
|
189
|
+
@override
|
|
190
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
191
|
+
self.visit(node.value)
|
|
192
|
+
self.visit(node.target)
|
|
193
|
+
self._bind_assignment_target(node.target, is_namespace_alias=False)
|
|
194
|
+
|
|
195
|
+
@override
|
|
196
|
+
def visit_For(self, node: ast.For) -> None:
|
|
197
|
+
self.visit(node.iter)
|
|
198
|
+
self._bind_assignment_target(node.target, is_namespace_alias=False)
|
|
199
|
+
for statement in node.body:
|
|
200
|
+
self.visit(statement)
|
|
201
|
+
for statement in node.orelse:
|
|
202
|
+
self.visit(statement)
|
|
203
|
+
|
|
204
|
+
@override
|
|
205
|
+
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
|
206
|
+
self.visit(node.iter)
|
|
207
|
+
self._bind_assignment_target(node.target, is_namespace_alias=False)
|
|
208
|
+
for statement in node.body:
|
|
209
|
+
self.visit(statement)
|
|
210
|
+
for statement in node.orelse:
|
|
211
|
+
self.visit(statement)
|
|
212
|
+
|
|
213
|
+
@override
|
|
214
|
+
def visit_With(self, node: ast.With) -> None:
|
|
215
|
+
for item in node.items:
|
|
216
|
+
self.visit(item.context_expr)
|
|
217
|
+
if item.optional_vars is not None:
|
|
218
|
+
self._bind_assignment_target(
|
|
219
|
+
item.optional_vars, is_namespace_alias=False
|
|
220
|
+
)
|
|
221
|
+
for statement in node.body:
|
|
222
|
+
self.visit(statement)
|
|
223
|
+
|
|
224
|
+
@override
|
|
225
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
|
226
|
+
for item in node.items:
|
|
227
|
+
self.visit(item.context_expr)
|
|
228
|
+
if item.optional_vars is not None:
|
|
229
|
+
self._bind_assignment_target(
|
|
230
|
+
item.optional_vars, is_namespace_alias=False
|
|
231
|
+
)
|
|
232
|
+
for statement in node.body:
|
|
233
|
+
self.visit(statement)
|
|
234
|
+
|
|
235
|
+
@override
|
|
236
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
237
|
+
category = self._call_category(node)
|
|
238
|
+
if category is not None:
|
|
239
|
+
self._record(node, category)
|
|
240
|
+
self.generic_visit(node)
|
|
241
|
+
|
|
242
|
+
@override
|
|
243
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
244
|
+
if _is_namespace_dictionary(node.value):
|
|
245
|
+
self._record(node, DiagnosticCode.NAMESPACE_DICT_DIRECT_INDEX)
|
|
246
|
+
elif isinstance(node.value, ast.Name) and self._is_active_namespace_alias(
|
|
247
|
+
node.value.id
|
|
248
|
+
):
|
|
249
|
+
self._record(node, DiagnosticCode.NAMESPACE_DICT_ALIAS_INDEX)
|
|
250
|
+
self.generic_visit(node)
|
|
251
|
+
|
|
252
|
+
def _visit_function_scope(
|
|
253
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
254
|
+
) -> None:
|
|
255
|
+
argument_names = set(_iter_argument_names(node.args))
|
|
256
|
+
self._call_shadow_scopes.append(argument_names)
|
|
257
|
+
self._import_module_scopes.append(set())
|
|
258
|
+
self._namespace_scopes.append(dict.fromkeys(argument_names, False))
|
|
259
|
+
for statement in node.body:
|
|
260
|
+
self.visit(statement)
|
|
261
|
+
self._namespace_scopes.pop()
|
|
262
|
+
self._import_module_scopes.pop()
|
|
263
|
+
self._call_shadow_scopes.pop()
|
|
264
|
+
|
|
265
|
+
def _call_category(self, node: ast.Call) -> DiagnosticCode | None:
|
|
266
|
+
function = node.func
|
|
267
|
+
if isinstance(function, ast.Name):
|
|
268
|
+
return self._name_call_category(function.id)
|
|
269
|
+
if isinstance(function, ast.Attribute):
|
|
270
|
+
return self._attribute_call_category(function, node)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def _name_call_category(self, name: str) -> DiagnosticCode | None:
|
|
274
|
+
if self._is_call_shadowed(name):
|
|
275
|
+
return None
|
|
276
|
+
if self._is_import_module_name(name):
|
|
277
|
+
return DiagnosticCode.IMPORT_MODULE
|
|
278
|
+
return _DYNAMIC_BUILTIN_CODES.get(name)
|
|
279
|
+
|
|
280
|
+
def _attribute_call_category(
|
|
281
|
+
self, function: ast.Attribute, node: ast.Call
|
|
282
|
+
) -> DiagnosticCode | None:
|
|
283
|
+
if _is_shadowed_qualified_name(function, self._call_shadow_scopes):
|
|
284
|
+
return None
|
|
285
|
+
if self._is_import_module_name(_qualified_name(function)):
|
|
286
|
+
return DiagnosticCode.IMPORT_MODULE
|
|
287
|
+
if function.attr == "setattr" and not _is_monkeypatch_setattr(function):
|
|
288
|
+
return DiagnosticCode.ATTRIBUTE_SETATTR
|
|
289
|
+
if function.attr == "__setattr__":
|
|
290
|
+
return DiagnosticCode.DUNDER_SETATTR
|
|
291
|
+
if function.attr == "__new__" and not _is_object_new_call(node):
|
|
292
|
+
return DiagnosticCode.DUNDER_NEW
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def _bind_assignment_target(
|
|
296
|
+
self, target: ast.expr, *, is_namespace_alias: bool
|
|
297
|
+
) -> None:
|
|
298
|
+
for name in _stored_names(target):
|
|
299
|
+
self._bind_name(name, is_namespace_alias=is_namespace_alias)
|
|
300
|
+
|
|
301
|
+
def _bind_name(self, name: str, *, is_namespace_alias: bool) -> None:
|
|
302
|
+
self._call_shadow_scopes[-1].add(name)
|
|
303
|
+
self._bind_namespace_name(name, is_alias=is_namespace_alias)
|
|
304
|
+
|
|
305
|
+
def _unshadow_call(self, name: str) -> None:
|
|
306
|
+
self._call_shadow_scopes[-1].discard(name)
|
|
307
|
+
|
|
308
|
+
def _bind_import_module_name(self, name: str) -> None:
|
|
309
|
+
self._import_module_scopes[-1].add(name)
|
|
310
|
+
|
|
311
|
+
def _bind_namespace_name(self, name: str, *, is_alias: bool) -> None:
|
|
312
|
+
self._namespace_scopes[-1][name] = is_alias
|
|
313
|
+
|
|
314
|
+
def _is_call_shadowed(self, name: str) -> bool:
|
|
315
|
+
return any(name in scope for scope in reversed(self._call_shadow_scopes))
|
|
316
|
+
|
|
317
|
+
def _is_import_module_name(self, name: str) -> bool:
|
|
318
|
+
return any(name in scope for scope in reversed(self._import_module_scopes))
|
|
319
|
+
|
|
320
|
+
def _is_active_namespace_alias(self, name: str) -> bool:
|
|
321
|
+
for scope in reversed(self._namespace_scopes):
|
|
322
|
+
if name in scope:
|
|
323
|
+
return scope[name]
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
def _record(self, node: ast.expr | ast.stmt, code: DiagnosticCode) -> None:
|
|
327
|
+
self.diagnostics.append(
|
|
328
|
+
Diagnostic(
|
|
329
|
+
line_number=node.lineno, column_number=node.col_offset, code=code
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _is_namespace_dictionary(node: ast.AST) -> bool:
|
|
335
|
+
return isinstance(node, ast.Attribute) and node.attr == "__dict__"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_monkeypatch_setattr(function: ast.Attribute) -> bool:
|
|
339
|
+
return (
|
|
340
|
+
function.attr == "setattr"
|
|
341
|
+
and isinstance(function.value, ast.Name)
|
|
342
|
+
and function.value.id == _MONKEYPATCH_NAME
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _is_object_new_call(node: ast.Call) -> bool:
|
|
347
|
+
return (
|
|
348
|
+
isinstance(node.func, ast.Attribute)
|
|
349
|
+
and node.func.attr == "__new__"
|
|
350
|
+
and isinstance(node.func.value, ast.Name)
|
|
351
|
+
and node.func.value.id == "object"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _qualified_name(node: ast.Attribute) -> str:
|
|
356
|
+
value = node.value
|
|
357
|
+
if isinstance(value, ast.Name):
|
|
358
|
+
return f"{value.id}.{node.attr}"
|
|
359
|
+
if isinstance(value, ast.Attribute):
|
|
360
|
+
return f"{_qualified_name(value)}.{node.attr}"
|
|
361
|
+
return node.attr
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _is_shadowed_qualified_name(
|
|
365
|
+
node: ast.Attribute, shadow_scopes: Sequence[set[str]]
|
|
366
|
+
) -> bool:
|
|
367
|
+
value = node.value
|
|
368
|
+
while isinstance(value, ast.Attribute):
|
|
369
|
+
value = value.value
|
|
370
|
+
if not isinstance(value, ast.Name):
|
|
371
|
+
return False
|
|
372
|
+
return any(value.id in scope for scope in reversed(shadow_scopes))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _iter_argument_names(arguments: ast.arguments) -> Iterator[str]:
|
|
376
|
+
yield from (arg.arg for arg in arguments.posonlyargs)
|
|
377
|
+
yield from (arg.arg for arg in arguments.args)
|
|
378
|
+
yield from (arg.arg for arg in arguments.kwonlyargs)
|
|
379
|
+
if arguments.vararg is not None:
|
|
380
|
+
yield arguments.vararg.arg
|
|
381
|
+
if arguments.kwarg is not None:
|
|
382
|
+
yield arguments.kwarg.arg
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _stored_names(target: ast.expr) -> frozenset[str]:
|
|
386
|
+
if isinstance(target, ast.Name):
|
|
387
|
+
return frozenset({target.id})
|
|
388
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
389
|
+
names: set[str] = set()
|
|
390
|
+
for element in target.elts:
|
|
391
|
+
names.update(_stored_names(element))
|
|
392
|
+
return frozenset(names)
|
|
393
|
+
return frozenset()
|
flake8_agents/checker.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tokenize
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from itertools import chain
|
|
7
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
8
|
+
|
|
9
|
+
from flake8_agents._version_ import __version__ # noqa: AGT300
|
|
10
|
+
from flake8_agents.anti_pattern import AntiPatternChecker
|
|
11
|
+
from flake8_agents.import_boundary import ImportBoundaryChecker
|
|
12
|
+
from flake8_agents.type_escape import TypeEscapeChecker
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import ast
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["FlakeAgentsChecker"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_OwnedCheckerType: TypeAlias = type[
|
|
23
|
+
AntiPatternChecker | ImportBoundaryChecker | TypeEscapeChecker
|
|
24
|
+
]
|
|
25
|
+
Flake8Result: TypeAlias = tuple[
|
|
26
|
+
int, int, str, _OwnedCheckerType | type["FlakeAgentsChecker"]
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_NOQA_INLINE_RE = re.compile(
|
|
30
|
+
r"# noqa(?::[\s]?(?P<codes>([A-Z]+[0-9]*(?:[,\s]+)?)+))?", re.IGNORECASE
|
|
31
|
+
)
|
|
32
|
+
_AUDITED_AGT_NOQA_CODE_RE = re.compile(r"AGT[1-9][0-9]*\Z")
|
|
33
|
+
_AGT_UNUSED_NOQA_CODE = "AGT001"
|
|
34
|
+
_AGT_UNUSED_NOQA_MESSAGE = "unused AGT noqa suppression"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class _RawDiagnostic:
|
|
39
|
+
line_number: int
|
|
40
|
+
column_number: int
|
|
41
|
+
message: str
|
|
42
|
+
checker_type: _OwnedCheckerType
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def code(self) -> str:
|
|
46
|
+
return self.message.split(maxsplit=1)[0]
|
|
47
|
+
|
|
48
|
+
def to_result(self) -> Flake8Result:
|
|
49
|
+
return (self.line_number, self.column_number, self.message, self.checker_type)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class _NoqaSuppression:
|
|
54
|
+
line_number: int
|
|
55
|
+
column_number: int
|
|
56
|
+
codes: tuple[str, ...]
|
|
57
|
+
logical_line_numbers: frozenset[int]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FlakeAgentsChecker:
|
|
61
|
+
"""Aggregate flake8 checker for repository AGT diagnostics."""
|
|
62
|
+
|
|
63
|
+
name = "flake8-agents"
|
|
64
|
+
version = __version__
|
|
65
|
+
|
|
66
|
+
def __init__(self, tree: ast.Module, filename: str, lines: Sequence[str]) -> None:
|
|
67
|
+
self._type_escape_checker = TypeEscapeChecker(
|
|
68
|
+
tree=tree, filename=filename, lines=lines
|
|
69
|
+
)
|
|
70
|
+
self._anti_pattern_checker = AntiPatternChecker(
|
|
71
|
+
tree=tree, filename=filename, lines=lines
|
|
72
|
+
)
|
|
73
|
+
self._import_boundary_checker = ImportBoundaryChecker(
|
|
74
|
+
tree=tree, filename=filename, lines=lines
|
|
75
|
+
)
|
|
76
|
+
self._lines = lines
|
|
77
|
+
|
|
78
|
+
def run(self) -> Iterator[Flake8Result]:
|
|
79
|
+
"""Yield all flake8-compatible AGT diagnostics for this file."""
|
|
80
|
+
diagnostics = tuple(self._raw_diagnostics())
|
|
81
|
+
for diagnostic in diagnostics:
|
|
82
|
+
yield diagnostic.to_result()
|
|
83
|
+
yield from _unused_noqa_results(diagnostics, self._lines, type(self))
|
|
84
|
+
|
|
85
|
+
def _raw_diagnostics(self) -> Iterator[_RawDiagnostic]:
|
|
86
|
+
for line_number, column_number, message, checker_type in chain(
|
|
87
|
+
self._type_escape_checker.run(),
|
|
88
|
+
self._anti_pattern_checker.run(),
|
|
89
|
+
self._import_boundary_checker.run(),
|
|
90
|
+
):
|
|
91
|
+
yield _RawDiagnostic(
|
|
92
|
+
line_number=line_number,
|
|
93
|
+
column_number=column_number,
|
|
94
|
+
message=message,
|
|
95
|
+
checker_type=checker_type,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unused_noqa_results(
|
|
100
|
+
diagnostics: tuple[_RawDiagnostic, ...],
|
|
101
|
+
lines: Sequence[str],
|
|
102
|
+
checker_type: type[FlakeAgentsChecker],
|
|
103
|
+
) -> Iterator[Flake8Result]:
|
|
104
|
+
diagnostic_codes_by_line = _diagnostic_codes_by_line(diagnostics)
|
|
105
|
+
for suppression in _explicit_agt_noqa_suppressions(lines):
|
|
106
|
+
unused_codes = tuple(
|
|
107
|
+
code
|
|
108
|
+
for code in suppression.codes
|
|
109
|
+
if not _suppression_matches_any_diagnostic(
|
|
110
|
+
code, suppression.logical_line_numbers, diagnostic_codes_by_line
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
if unused_codes:
|
|
114
|
+
yield (
|
|
115
|
+
suppression.line_number,
|
|
116
|
+
suppression.column_number,
|
|
117
|
+
_unused_noqa_message(unused_codes),
|
|
118
|
+
checker_type,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _diagnostic_codes_by_line(
|
|
123
|
+
diagnostics: tuple[_RawDiagnostic, ...],
|
|
124
|
+
) -> dict[int, tuple[str, ...]]:
|
|
125
|
+
codes_by_line: dict[int, list[str]] = {}
|
|
126
|
+
for diagnostic in diagnostics:
|
|
127
|
+
codes_by_line.setdefault(diagnostic.line_number, []).append(diagnostic.code)
|
|
128
|
+
return {line_number: tuple(codes) for line_number, codes in codes_by_line.items()}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _explicit_agt_noqa_suppressions(
|
|
132
|
+
lines: Sequence[str],
|
|
133
|
+
) -> tuple[_NoqaSuppression, ...]:
|
|
134
|
+
logical_lines_by_line = _logical_lines_by_line(lines)
|
|
135
|
+
return tuple(
|
|
136
|
+
suppression
|
|
137
|
+
for token in _tokens_from_lines(lines)
|
|
138
|
+
if token.type == tokenize.COMMENT
|
|
139
|
+
for suppression in _suppression_from_comment(
|
|
140
|
+
token, lines, logical_lines_by_line
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _tokens_from_lines(lines: Sequence[str]) -> tuple[tokenize.TokenInfo, ...]:
|
|
146
|
+
line_index = 0
|
|
147
|
+
|
|
148
|
+
def readline() -> str:
|
|
149
|
+
nonlocal line_index
|
|
150
|
+
if line_index >= len(lines):
|
|
151
|
+
return ""
|
|
152
|
+
line = lines[line_index]
|
|
153
|
+
line_index += 1
|
|
154
|
+
return line
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
return tuple(tokenize.generate_tokens(readline))
|
|
158
|
+
except tokenize.TokenError:
|
|
159
|
+
return ()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _logical_lines_by_line(lines: Sequence[str]) -> dict[int, frozenset[int]]:
|
|
163
|
+
logical_lines: dict[int, frozenset[int]] = {}
|
|
164
|
+
min_line = len(lines) + 2
|
|
165
|
+
max_line = -1
|
|
166
|
+
for token in _tokens_from_lines(lines):
|
|
167
|
+
if token.type in {tokenize.ENDMARKER, tokenize.DEDENT}:
|
|
168
|
+
continue
|
|
169
|
+
min_line = min(min_line, token.start[0])
|
|
170
|
+
max_line = max(max_line, token.end[0])
|
|
171
|
+
if token.type in {tokenize.NL, tokenize.NEWLINE}:
|
|
172
|
+
current_lines = frozenset(range(min_line, max_line + 1))
|
|
173
|
+
for line_number in current_lines:
|
|
174
|
+
logical_lines[line_number] = current_lines
|
|
175
|
+
min_line = len(lines) + 2
|
|
176
|
+
max_line = -1
|
|
177
|
+
return logical_lines
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _suppression_from_comment(
|
|
181
|
+
token: tokenize.TokenInfo,
|
|
182
|
+
lines: Sequence[str],
|
|
183
|
+
logical_lines_by_line: dict[int, frozenset[int]],
|
|
184
|
+
) -> tuple[_NoqaSuppression, ...]:
|
|
185
|
+
line_number, column_number = token.start
|
|
186
|
+
if line_number > len(lines):
|
|
187
|
+
return ()
|
|
188
|
+
if not lines[line_number - 1][:column_number].strip():
|
|
189
|
+
return ()
|
|
190
|
+
match = _NOQA_INLINE_RE.search(token.string)
|
|
191
|
+
if match is None:
|
|
192
|
+
return ()
|
|
193
|
+
codes_text = match.groupdict()["codes"]
|
|
194
|
+
if codes_text is None:
|
|
195
|
+
return ()
|
|
196
|
+
codes = tuple(
|
|
197
|
+
code
|
|
198
|
+
for code in re.split(r"[,\s]+", codes_text.upper())
|
|
199
|
+
if _AUDITED_AGT_NOQA_CODE_RE.fullmatch(code) is not None
|
|
200
|
+
)
|
|
201
|
+
if not codes:
|
|
202
|
+
return ()
|
|
203
|
+
return (
|
|
204
|
+
_NoqaSuppression(
|
|
205
|
+
line_number=line_number,
|
|
206
|
+
column_number=column_number + match.start(),
|
|
207
|
+
codes=codes,
|
|
208
|
+
logical_line_numbers=logical_lines_by_line.get(
|
|
209
|
+
line_number, frozenset({line_number})
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _suppression_matches_any_diagnostic(
|
|
216
|
+
suppression_code: str,
|
|
217
|
+
logical_line_numbers: frozenset[int],
|
|
218
|
+
diagnostic_codes_by_line: dict[int, tuple[str, ...]],
|
|
219
|
+
) -> bool:
|
|
220
|
+
return any(
|
|
221
|
+
diagnostic_code == suppression_code
|
|
222
|
+
or diagnostic_code.startswith(suppression_code)
|
|
223
|
+
for line_number in logical_line_numbers
|
|
224
|
+
for diagnostic_code in diagnostic_codes_by_line.get(line_number, ())
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _unused_noqa_message(unused_codes: tuple[str, ...]) -> str:
|
|
229
|
+
return (
|
|
230
|
+
f"{_AGT_UNUSED_NOQA_CODE} {_AGT_UNUSED_NOQA_MESSAGE}: {', '.join(unused_codes)}"
|
|
231
|
+
)
|