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.
@@ -0,0 +1,4 @@
1
+ from flake8_agents._version_ import __commit_id__, __version__ # noqa: AGT300
2
+ from flake8_agents.checker import FlakeAgentsChecker
3
+
4
+ __all__ = ["FlakeAgentsChecker", "__commit_id__", "__version__"]
@@ -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()
@@ -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
+ )
@@ -0,0 +1,3 @@
1
+ from flake8_agents.cli import module_size
2
+
3
+ __all__ = ["module_size"]