thailint 0.8.0__py3-none-any.whl → 0.9.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.
- src/cli.py +115 -0
- src/core/base.py +4 -0
- src/core/rule_discovery.py +110 -84
- src/core/violation_builder.py +75 -15
- src/linter_config/loader.py +43 -11
- src/linters/dry/block_filter.py +4 -0
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/token_hasher.py +5 -1
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +4 -0
- src/linters/file_header/bash_parser.py +4 -0
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/pattern_matcher.py +4 -0
- src/linters/file_placement/pattern_validator.py +4 -0
- src/linters/magic_numbers/context_analyzer.py +4 -0
- src/linters/magic_numbers/typescript_analyzer.py +4 -0
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_function_extractor.py +4 -0
- src/linters/print_statements/typescript_analyzer.py +4 -0
- src/linters/srp/class_analyzer.py +4 -0
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +83 -47
- src/linters/srp/violation_builder.py +4 -0
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +355 -0
- src/linters/stateless_class/python_analyzer.py +299 -0
- {thailint-0.8.0.dist-info → thailint-0.9.0.dist-info}/METADATA +112 -2
- {thailint-0.8.0.dist-info → thailint-0.9.0.dist-info}/RECORD +33 -29
- {thailint-0.8.0.dist-info → thailint-0.9.0.dist-info}/WHEEL +0 -0
- {thailint-0.8.0.dist-info → thailint-0.9.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.8.0.dist-info → thailint-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Main stateless class linter rule implementation
|
|
3
|
+
|
|
4
|
+
Scope: StatelessClassRule class implementing BaseLintRule interface
|
|
5
|
+
|
|
6
|
+
Overview: Implements stateless class linter rule following BaseLintRule interface.
|
|
7
|
+
Detects Python classes that have no constructor (__init__ or __new__), no instance
|
|
8
|
+
state (self.attr assignments), and 2+ methods - indicating they should be refactored
|
|
9
|
+
to module-level functions. Delegates AST analysis to StatelessClassAnalyzer. Supports
|
|
10
|
+
configuration via .thailint.yaml and comprehensive 5-level ignore system including
|
|
11
|
+
project-level patterns, linter-specific ignore patterns, file-level directives,
|
|
12
|
+
line-level directives, and block-level directives.
|
|
13
|
+
|
|
14
|
+
Dependencies: BaseLintRule, BaseLintContext, Violation, StatelessClassAnalyzer,
|
|
15
|
+
IgnoreDirectiveParser, StatelessClassConfig
|
|
16
|
+
|
|
17
|
+
Exports: StatelessClassRule class
|
|
18
|
+
|
|
19
|
+
Interfaces: StatelessClassRule.check(context) -> list[Violation]
|
|
20
|
+
|
|
21
|
+
Implementation: Composition pattern delegating analysis to specialized analyzer with
|
|
22
|
+
config loading and comprehensive ignore checking
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from src.core.base import BaseLintContext, BaseLintRule
|
|
28
|
+
from src.core.types import Severity, Violation
|
|
29
|
+
from src.linter_config.ignore import IgnoreDirectiveParser
|
|
30
|
+
|
|
31
|
+
from .config import StatelessClassConfig
|
|
32
|
+
from .python_analyzer import ClassInfo, StatelessClassAnalyzer
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
36
|
+
"""Detects stateless classes that should be module-level functions."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
"""Initialize the rule with analyzer and ignore parser."""
|
|
40
|
+
self._ignore_parser = IgnoreDirectiveParser()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def rule_id(self) -> str:
|
|
44
|
+
"""Unique identifier for this rule."""
|
|
45
|
+
return "stateless-class.violation"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def rule_name(self) -> str:
|
|
49
|
+
"""Human-readable name for this rule."""
|
|
50
|
+
return "Stateless Class Detection"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def description(self) -> str:
|
|
54
|
+
"""Description of what this rule checks."""
|
|
55
|
+
return "Classes without state should be refactored to module-level functions"
|
|
56
|
+
|
|
57
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
58
|
+
"""Check for stateless class violations.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
context: Lint context with file information
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
List of violations found
|
|
65
|
+
"""
|
|
66
|
+
if not self._should_analyze(context):
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
config = self._load_config(context)
|
|
70
|
+
if not config.enabled:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
if self._is_file_ignored(context, config):
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
if self._has_file_level_ignore(context):
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
analyzer = StatelessClassAnalyzer(min_methods=config.min_methods)
|
|
80
|
+
stateless_classes = analyzer.analyze(context.file_content) # type: ignore[arg-type]
|
|
81
|
+
|
|
82
|
+
return self._filter_ignored_violations(stateless_classes, context)
|
|
83
|
+
|
|
84
|
+
def _should_analyze(self, context: BaseLintContext) -> bool:
|
|
85
|
+
"""Check if context should be analyzed.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
context: Lint context
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if should analyze
|
|
92
|
+
"""
|
|
93
|
+
return context.language == "python" and context.file_content is not None
|
|
94
|
+
|
|
95
|
+
def _load_config(self, context: BaseLintContext) -> StatelessClassConfig:
|
|
96
|
+
"""Load configuration from context.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
context: Lint context
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
StatelessClassConfig instance
|
|
103
|
+
"""
|
|
104
|
+
if not hasattr(context, "config") or context.config is None:
|
|
105
|
+
return StatelessClassConfig()
|
|
106
|
+
|
|
107
|
+
config_dict = context.config
|
|
108
|
+
if not isinstance(config_dict, dict):
|
|
109
|
+
return StatelessClassConfig()
|
|
110
|
+
|
|
111
|
+
# Check for stateless-class specific config
|
|
112
|
+
linter_config = config_dict.get("stateless-class", config_dict)
|
|
113
|
+
return StatelessClassConfig.from_dict(linter_config)
|
|
114
|
+
|
|
115
|
+
def _is_file_ignored(self, context: BaseLintContext, config: StatelessClassConfig) -> bool:
|
|
116
|
+
"""Check if file matches ignore patterns.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
context: Lint context
|
|
120
|
+
config: Configuration
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if file should be ignored
|
|
124
|
+
"""
|
|
125
|
+
if not config.ignore:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
if not context.file_path:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
file_path = Path(context.file_path)
|
|
132
|
+
for pattern in config.ignore:
|
|
133
|
+
if self._matches_pattern(file_path, pattern):
|
|
134
|
+
return True
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
|
|
138
|
+
"""Check if file path matches a glob pattern.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
file_path: Path to check
|
|
142
|
+
pattern: Glob pattern
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if path matches pattern
|
|
146
|
+
"""
|
|
147
|
+
if file_path.match(pattern):
|
|
148
|
+
return True
|
|
149
|
+
if pattern in str(file_path):
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def _has_file_level_ignore(self, context: BaseLintContext) -> bool:
|
|
154
|
+
"""Check if file has file-level ignore directive.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
context: Lint context
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if file should be ignored at file level
|
|
161
|
+
"""
|
|
162
|
+
if not context.file_content:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
# Check first 10 lines for ignore-file directive
|
|
166
|
+
lines = context.file_content.splitlines()[:10]
|
|
167
|
+
for line in lines:
|
|
168
|
+
if self._is_file_ignore_directive(line):
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def _is_file_ignore_directive(self, line: str) -> bool:
|
|
173
|
+
"""Check if line is a file-level ignore directive.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
line: Line to check
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if line has file-level ignore for this rule
|
|
180
|
+
"""
|
|
181
|
+
line_lower = line.lower()
|
|
182
|
+
if "thailint: ignore-file" not in line_lower:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
# Check for general ignore-file (no rule specified)
|
|
186
|
+
if "ignore-file[" not in line_lower:
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
# Check for rule-specific ignore
|
|
190
|
+
return self._matches_rule_ignore(line_lower, "ignore-file")
|
|
191
|
+
|
|
192
|
+
def _matches_rule_ignore(self, line: str, directive: str) -> bool:
|
|
193
|
+
"""Check if line matches rule-specific ignore.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
line: Line to check (lowercase)
|
|
197
|
+
directive: Directive name (ignore-file or ignore)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if ignore applies to this rule
|
|
201
|
+
"""
|
|
202
|
+
import re
|
|
203
|
+
|
|
204
|
+
pattern = rf"{directive}\[([^\]]+)\]"
|
|
205
|
+
match = re.search(pattern, line)
|
|
206
|
+
if not match:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
rules = [r.strip().lower() for r in match.group(1).split(",")]
|
|
210
|
+
return any(self._rule_matches(r) for r in rules)
|
|
211
|
+
|
|
212
|
+
def _rule_matches(self, rule_pattern: str) -> bool:
|
|
213
|
+
"""Check if rule pattern matches this rule.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
rule_pattern: Rule pattern to check
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if pattern matches this rule
|
|
220
|
+
"""
|
|
221
|
+
rule_id_lower = self.rule_id.lower()
|
|
222
|
+
pattern_lower = rule_pattern.lower()
|
|
223
|
+
|
|
224
|
+
# Exact match
|
|
225
|
+
if rule_id_lower == pattern_lower:
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
# Prefix match: stateless-class matches stateless-class.violation
|
|
229
|
+
if rule_id_lower.startswith(pattern_lower + "."):
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
# Wildcard match: stateless-class.* matches stateless-class.violation
|
|
233
|
+
if pattern_lower.endswith("*"):
|
|
234
|
+
prefix = pattern_lower[:-1]
|
|
235
|
+
return rule_id_lower.startswith(prefix)
|
|
236
|
+
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def _filter_ignored_violations(
|
|
240
|
+
self, classes: list[ClassInfo], context: BaseLintContext
|
|
241
|
+
) -> list[Violation]:
|
|
242
|
+
"""Filter out violations that should be ignored.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
classes: List of stateless classes found
|
|
246
|
+
context: Lint context
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
List of violations after filtering ignored ones
|
|
250
|
+
"""
|
|
251
|
+
violations = []
|
|
252
|
+
for info in classes:
|
|
253
|
+
violation = self._create_violation(info, context)
|
|
254
|
+
if not self._should_ignore_violation(violation, info, context):
|
|
255
|
+
violations.append(violation)
|
|
256
|
+
return violations
|
|
257
|
+
|
|
258
|
+
def _should_ignore_violation(
|
|
259
|
+
self, violation: Violation, info: ClassInfo, context: BaseLintContext
|
|
260
|
+
) -> bool:
|
|
261
|
+
"""Check if violation should be ignored.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
violation: Violation to check
|
|
265
|
+
info: Class info
|
|
266
|
+
context: Lint context
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
True if violation should be ignored
|
|
270
|
+
"""
|
|
271
|
+
if not context.file_content:
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
# Check using IgnoreDirectiveParser for comprehensive ignore checking
|
|
275
|
+
if self._ignore_parser.should_ignore_violation(violation, context.file_content):
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
# Also check inline ignore on class line
|
|
279
|
+
return self._has_inline_ignore(info.line, context)
|
|
280
|
+
|
|
281
|
+
def _has_inline_ignore(self, line_num: int, context: BaseLintContext) -> bool:
|
|
282
|
+
"""Check for inline ignore directive on class line.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
line_num: Line number to check
|
|
286
|
+
context: Lint context
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
True if line has ignore directive
|
|
290
|
+
"""
|
|
291
|
+
line = self._get_line_text(line_num, context)
|
|
292
|
+
if not line:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
return self._is_ignore_directive(line.lower())
|
|
296
|
+
|
|
297
|
+
def _get_line_text(self, line_num: int, context: BaseLintContext) -> str | None:
|
|
298
|
+
"""Get text of a specific line.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
line_num: Line number (1-indexed)
|
|
302
|
+
context: Lint context
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Line text or None if invalid
|
|
306
|
+
"""
|
|
307
|
+
if not context.file_content:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
lines = context.file_content.splitlines()
|
|
311
|
+
if line_num <= 0 or line_num > len(lines):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
return lines[line_num - 1]
|
|
315
|
+
|
|
316
|
+
def _is_ignore_directive(self, line: str) -> bool:
|
|
317
|
+
"""Check if line contains ignore directive for this rule.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
line: Line text (lowercase)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
True if line has applicable ignore directive
|
|
324
|
+
"""
|
|
325
|
+
if "thailint:" not in line or "ignore" not in line:
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
# General ignore (no rule specified)
|
|
329
|
+
if "ignore[" not in line:
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
# Rule-specific ignore
|
|
333
|
+
return self._matches_rule_ignore(line, "ignore")
|
|
334
|
+
|
|
335
|
+
def _create_violation(self, info: ClassInfo, context: BaseLintContext) -> Violation:
|
|
336
|
+
"""Create violation from class info.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
info: Detected stateless class info
|
|
340
|
+
context: Lint context
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Violation instance
|
|
344
|
+
"""
|
|
345
|
+
message = (
|
|
346
|
+
f"Class '{info.name}' has no state and should be refactored to module-level functions"
|
|
347
|
+
)
|
|
348
|
+
return Violation(
|
|
349
|
+
rule_id=self.rule_id,
|
|
350
|
+
message=message,
|
|
351
|
+
file_path=str(context.file_path),
|
|
352
|
+
line=info.line,
|
|
353
|
+
column=info.column,
|
|
354
|
+
severity=Severity.ERROR,
|
|
355
|
+
)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Python AST analyzer for detecting stateless classes
|
|
3
|
+
|
|
4
|
+
Scope: AST-based analysis of Python class definitions for stateless patterns
|
|
5
|
+
|
|
6
|
+
Overview: Analyzes Python source code using AST to detect classes that have no
|
|
7
|
+
constructor (__init__ or __new__), no instance state (self.attr assignments),
|
|
8
|
+
and 2+ methods - indicating they should be refactored to module-level functions.
|
|
9
|
+
Excludes legitimate patterns like ABC, Protocol, decorated classes, and classes
|
|
10
|
+
with class-level attributes.
|
|
11
|
+
|
|
12
|
+
Dependencies: Python AST module
|
|
13
|
+
|
|
14
|
+
Exports: analyze_code function, ClassInfo dataclass
|
|
15
|
+
|
|
16
|
+
Interfaces: analyze_code(code) -> list[ClassInfo] returning detected stateless classes
|
|
17
|
+
|
|
18
|
+
Implementation: AST visitor pattern with focused helper functions for different checks
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ClassInfo:
|
|
27
|
+
"""Information about a detected stateless class."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
line: int
|
|
31
|
+
column: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def analyze_code(code: str, min_methods: int = 2) -> list[ClassInfo]:
|
|
35
|
+
"""Analyze Python code for stateless classes.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
code: Python source code
|
|
39
|
+
min_methods: Minimum methods required to flag class
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of detected stateless class info
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
tree = ast.parse(code)
|
|
46
|
+
except SyntaxError:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
return _find_stateless_classes(tree, min_methods)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find_stateless_classes(tree: ast.Module, min_methods: int = 2) -> list[ClassInfo]:
|
|
53
|
+
"""Find all stateless classes in AST.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
tree: Parsed AST module
|
|
57
|
+
min_methods: Minimum methods required to flag class
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of stateless class info
|
|
61
|
+
"""
|
|
62
|
+
results = []
|
|
63
|
+
for node in ast.walk(tree):
|
|
64
|
+
if isinstance(node, ast.ClassDef) and _is_stateless(node, min_methods):
|
|
65
|
+
results.append(ClassInfo(node.name, node.lineno, node.col_offset))
|
|
66
|
+
return results
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_stateless(class_node: ast.ClassDef, min_methods: int = 2) -> bool:
|
|
70
|
+
"""Check if class is stateless and should be functions.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
class_node: AST ClassDef node
|
|
74
|
+
min_methods: Minimum methods required to flag class
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if class is stateless violation
|
|
78
|
+
"""
|
|
79
|
+
if _should_skip_class(class_node):
|
|
80
|
+
return False
|
|
81
|
+
return _count_methods(class_node) >= min_methods
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _should_skip_class(class_node: ast.ClassDef) -> bool:
|
|
85
|
+
"""Check if class should be skipped from analysis.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
class_node: AST ClassDef node
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if class should be skipped
|
|
92
|
+
"""
|
|
93
|
+
return (
|
|
94
|
+
_has_constructor(class_node)
|
|
95
|
+
or _is_exception_case(class_node)
|
|
96
|
+
or _has_class_attributes(class_node)
|
|
97
|
+
or _has_instance_attributes(class_node)
|
|
98
|
+
or _has_base_classes(class_node)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _has_base_classes(class_node: ast.ClassDef) -> bool:
|
|
103
|
+
"""Check if class inherits from non-trivial base classes.
|
|
104
|
+
|
|
105
|
+
Classes that inherit from other classes are using polymorphism/inheritance
|
|
106
|
+
and should not be flagged as stateless.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
class_node: AST ClassDef node
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if class has non-trivial base classes
|
|
113
|
+
"""
|
|
114
|
+
if not class_node.bases:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
for base in class_node.bases:
|
|
118
|
+
base_name = _get_base_name(base)
|
|
119
|
+
# Skip trivial bases like object
|
|
120
|
+
if base_name and base_name not in ("object",):
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _count_methods(class_node: ast.ClassDef) -> int:
|
|
127
|
+
"""Count methods in class.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
class_node: AST ClassDef node
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Number of methods
|
|
134
|
+
"""
|
|
135
|
+
return sum(1 for item in class_node.body if isinstance(item, ast.FunctionDef))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_constructor(class_node: ast.ClassDef) -> bool:
|
|
139
|
+
"""Check if class has __init__ or __new__ method.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
class_node: AST ClassDef node
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if class has constructor
|
|
146
|
+
"""
|
|
147
|
+
constructor_names = ("__init__", "__new__")
|
|
148
|
+
for item in class_node.body:
|
|
149
|
+
if isinstance(item, ast.FunctionDef) and item.name in constructor_names:
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_exception_case(class_node: ast.ClassDef) -> bool:
|
|
155
|
+
"""Check if class is an exception case (ABC, Protocol, or decorated).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
class_node: AST ClassDef node
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if class is ABC, Protocol, or decorated
|
|
162
|
+
"""
|
|
163
|
+
if class_node.decorator_list:
|
|
164
|
+
return True
|
|
165
|
+
return _inherits_from_abc_or_protocol(class_node)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _inherits_from_abc_or_protocol(class_node: ast.ClassDef) -> bool:
|
|
169
|
+
"""Check if class inherits from ABC or Protocol.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
class_node: AST ClassDef node
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if inherits from ABC or Protocol
|
|
176
|
+
"""
|
|
177
|
+
for base in class_node.bases:
|
|
178
|
+
if _get_base_name(base) in ("ABC", "Protocol"):
|
|
179
|
+
return True
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_base_name(base: ast.expr) -> str:
|
|
184
|
+
"""Extract name from base class expression.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
base: AST expression for base class
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Base class name or empty string
|
|
191
|
+
"""
|
|
192
|
+
if isinstance(base, ast.Name):
|
|
193
|
+
return base.id
|
|
194
|
+
if isinstance(base, ast.Attribute):
|
|
195
|
+
return base.attr
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _has_class_attributes(class_node: ast.ClassDef) -> bool:
|
|
200
|
+
"""Check if class has class-level attributes.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
class_node: AST ClassDef node
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if class has class attributes
|
|
207
|
+
"""
|
|
208
|
+
for item in class_node.body:
|
|
209
|
+
if isinstance(item, (ast.Assign, ast.AnnAssign)):
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _has_instance_attributes(class_node: ast.ClassDef) -> bool:
|
|
215
|
+
"""Check if methods assign to self.attr.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
class_node: AST ClassDef node
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True if any method assigns to self
|
|
222
|
+
"""
|
|
223
|
+
for item in class_node.body:
|
|
224
|
+
if isinstance(item, ast.FunctionDef) and _method_has_self_assignment(item):
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _method_has_self_assignment(method: ast.FunctionDef) -> bool:
|
|
230
|
+
"""Check if method assigns to self.attr.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
method: AST FunctionDef node
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
True if method assigns to self
|
|
237
|
+
"""
|
|
238
|
+
for node in ast.walk(method):
|
|
239
|
+
if _is_self_attribute_assignment(node):
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_self_attribute_assignment(node: ast.AST) -> bool:
|
|
245
|
+
"""Check if node is a self.attr assignment.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
node: AST node to check
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
True if node is self attribute assignment
|
|
252
|
+
"""
|
|
253
|
+
if not isinstance(node, ast.Assign):
|
|
254
|
+
return False
|
|
255
|
+
return any(_is_self_attribute(t) for t in node.targets)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _is_self_attribute(node: ast.expr) -> bool:
|
|
259
|
+
"""Check if node is a self.attr reference.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
node: AST expression node
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True if node is self.attr
|
|
266
|
+
"""
|
|
267
|
+
if not isinstance(node, ast.Attribute):
|
|
268
|
+
return False
|
|
269
|
+
if not isinstance(node.value, ast.Name):
|
|
270
|
+
return False
|
|
271
|
+
return node.value.id == "self"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Legacy class wrapper for backward compatibility with linter.py
|
|
275
|
+
class StatelessClassAnalyzer:
|
|
276
|
+
"""Analyzes Python code for stateless classes.
|
|
277
|
+
|
|
278
|
+
Note: This class is a thin wrapper around module-level functions
|
|
279
|
+
to maintain backward compatibility with existing code.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, min_methods: int = 2) -> None:
|
|
283
|
+
"""Initialize the analyzer.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
min_methods: Minimum methods required to flag class
|
|
287
|
+
"""
|
|
288
|
+
self._min_methods = min_methods
|
|
289
|
+
|
|
290
|
+
def analyze(self, code: str) -> list[ClassInfo]:
|
|
291
|
+
"""Analyze Python code for stateless classes.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
code: Python source code
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
List of detected stateless class info
|
|
298
|
+
"""
|
|
299
|
+
return analyze_code(code, self._min_methods)
|