thailint 0.7.0__py3-none-any.whl → 0.8.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,419 @@
1
+ """
2
+ Purpose: Main method-should-be-property linter rule implementation
3
+
4
+ Scope: Method-should-be-property detection for Python files
5
+
6
+ Overview: Implements method-should-be-property linter rule following MultiLanguageLintRule
7
+ interface. Orchestrates configuration loading, Python AST analysis for property candidates,
8
+ and violation building through focused helper classes. Detects methods that should be
9
+ converted to @property decorators following Pythonic conventions. Supports configurable
10
+ max_body_statements threshold, ignore patterns for excluding files, and inline ignore
11
+ directives (thailint: ignore, noqa) for suppressing specific violations. Handles test file
12
+ detection and non-Python languages gracefully.
13
+
14
+ Dependencies: BaseLintContext and MultiLanguageLintRule from core, ast module, pathlib,
15
+ analyzer classes, config classes
16
+
17
+ Exports: MethodPropertyRule class implementing MultiLanguageLintRule interface
18
+
19
+ Interfaces: check(context) -> list[Violation] for rule validation, standard rule properties
20
+ (rule_id, rule_name, description)
21
+
22
+ Implementation: Composition pattern with helper classes (analyzer, violation builder),
23
+ AST-based analysis for Python with comprehensive exclusion rules
24
+ """
25
+
26
+ import ast
27
+ from pathlib import Path
28
+
29
+ from src.core.base import BaseLintContext, MultiLanguageLintRule
30
+ from src.core.types import Violation
31
+
32
+ from .config import MethodPropertyConfig
33
+ from .python_analyzer import PropertyCandidate, PythonMethodAnalyzer
34
+ from .violation_builder import ViolationBuilder
35
+
36
+
37
+ class MethodPropertyRule(MultiLanguageLintRule): # thailint: ignore[srp,dry]
38
+ """Detects methods that should be @property decorators."""
39
+
40
+ def __init__(self) -> None:
41
+ """Initialize the method property rule."""
42
+ self._violation_builder = ViolationBuilder(self.rule_id)
43
+
44
+ @property
45
+ def rule_id(self) -> str:
46
+ """Unique identifier for this rule."""
47
+ return "method-property.should-be-property"
48
+
49
+ @property
50
+ def rule_name(self) -> str:
51
+ """Human-readable name for this rule."""
52
+ return "method should be property"
53
+
54
+ @property
55
+ def description(self) -> str:
56
+ """Description of what this rule checks."""
57
+ return "Methods should be converted to @property decorators for Pythonic attribute access"
58
+
59
+ def _load_config(self, context: BaseLintContext) -> MethodPropertyConfig:
60
+ """Load configuration from context.
61
+
62
+ Args:
63
+ context: Lint context
64
+
65
+ Returns:
66
+ MethodPropertyConfig instance
67
+ """
68
+ test_config = self._try_load_test_config(context)
69
+ if test_config is not None:
70
+ return test_config
71
+
72
+ return MethodPropertyConfig()
73
+
74
+ # dry: ignore-block
75
+ def _try_load_test_config(self, context: BaseLintContext) -> MethodPropertyConfig | None:
76
+ """Try to load test-style configuration.
77
+
78
+ Args:
79
+ context: Lint context
80
+
81
+ Returns:
82
+ Config if found, None otherwise
83
+ """
84
+ if not hasattr(context, "config"):
85
+ return None
86
+ config_attr = context.config
87
+ if config_attr is None or not isinstance(config_attr, dict):
88
+ return None
89
+
90
+ # Check for method-property specific config
91
+ linter_config = config_attr.get("method-property", config_attr)
92
+ return MethodPropertyConfig.from_dict(linter_config)
93
+
94
+ # dry: ignore-block
95
+ def _is_file_ignored(self, context: BaseLintContext, config: MethodPropertyConfig) -> bool:
96
+ """Check if file matches ignore patterns.
97
+
98
+ Args:
99
+ context: Lint context
100
+ config: Configuration
101
+
102
+ Returns:
103
+ True if file should be ignored
104
+ """
105
+ if not config.ignore:
106
+ return False
107
+
108
+ if not context.file_path:
109
+ return False
110
+
111
+ file_path = Path(context.file_path)
112
+ for pattern in config.ignore:
113
+ if self._matches_pattern(file_path, pattern):
114
+ return True
115
+ return False
116
+
117
+ # dry: ignore-block
118
+ def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
119
+ """Check if file path matches a glob pattern.
120
+
121
+ Args:
122
+ file_path: Path to check
123
+ pattern: Glob pattern
124
+
125
+ Returns:
126
+ True if path matches pattern
127
+ """
128
+ if file_path.match(pattern):
129
+ return True
130
+ if pattern in str(file_path):
131
+ return True
132
+ return False
133
+
134
+ # dry: ignore-block
135
+ def _is_test_file(self, file_path: object) -> bool:
136
+ """Check if file is a test file.
137
+
138
+ Args:
139
+ file_path: Path to check
140
+
141
+ Returns:
142
+ True if test file
143
+ """
144
+ path_str = str(file_path)
145
+ file_name = Path(path_str).name
146
+
147
+ # Check test_*.py pattern
148
+ if file_name.startswith("test_") and file_name.endswith(".py"):
149
+ return True
150
+
151
+ # Check *_test.py pattern
152
+ if file_name.endswith("_test.py"):
153
+ return True
154
+
155
+ return False
156
+
157
+ def _check_python(
158
+ self, context: BaseLintContext, config: MethodPropertyConfig
159
+ ) -> list[Violation]:
160
+ """Check Python code for method property violations.
161
+
162
+ Args:
163
+ context: Lint context with Python file information
164
+ config: Method property configuration
165
+
166
+ Returns:
167
+ List of violations found in Python code
168
+ """
169
+ if self._is_file_ignored(context, config):
170
+ return []
171
+
172
+ if self._is_test_file(context.file_path):
173
+ return []
174
+
175
+ tree = self._parse_python_code(context.file_content)
176
+ if tree is None:
177
+ return []
178
+
179
+ analyzer = PythonMethodAnalyzer(
180
+ max_body_statements=config.max_body_statements,
181
+ exclude_prefixes=config.exclude_prefixes,
182
+ exclude_names=config.exclude_names,
183
+ )
184
+ candidates = analyzer.find_property_candidates(tree)
185
+ candidates = self._filter_ignored_methods(candidates, config)
186
+ return self._collect_violations(candidates, context)
187
+
188
+ def _filter_ignored_methods(
189
+ self,
190
+ candidates: list[PropertyCandidate],
191
+ config: MethodPropertyConfig,
192
+ ) -> list[PropertyCandidate]:
193
+ """Filter out candidates with ignored method names.
194
+
195
+ Args:
196
+ candidates: List of property candidates
197
+ config: Configuration with ignore_methods list
198
+
199
+ Returns:
200
+ Filtered list of candidates
201
+ """
202
+ if not config.ignore_methods:
203
+ return candidates
204
+ return [c for c in candidates if c.method_name not in config.ignore_methods]
205
+
206
+ # dry: ignore-block
207
+ def _parse_python_code(self, code: str | None) -> ast.AST | None:
208
+ """Parse Python code into AST.
209
+
210
+ Args:
211
+ code: Python source code
212
+
213
+ Returns:
214
+ AST or None if parse fails
215
+ """
216
+ try:
217
+ return ast.parse(code or "")
218
+ except SyntaxError:
219
+ return None
220
+
221
+ def _collect_violations(
222
+ self,
223
+ candidates: list[PropertyCandidate],
224
+ context: BaseLintContext,
225
+ ) -> list[Violation]:
226
+ """Collect violations from property candidates.
227
+
228
+ Args:
229
+ candidates: List of property candidates
230
+ context: Lint context
231
+
232
+ Returns:
233
+ List of violations
234
+ """
235
+ violations = []
236
+ for candidate in candidates:
237
+ violation = self._create_violation(candidate, context)
238
+ if not self._should_ignore(violation, candidate, context):
239
+ violations.append(violation)
240
+ return violations
241
+
242
+ def _create_violation(
243
+ self,
244
+ candidate: PropertyCandidate,
245
+ context: BaseLintContext,
246
+ ) -> Violation:
247
+ """Create a violation for a property candidate.
248
+
249
+ Args:
250
+ candidate: The property candidate
251
+ context: Lint context
252
+
253
+ Returns:
254
+ Violation object
255
+ """
256
+ return self._violation_builder.create_violation(
257
+ method_name=candidate.method_name,
258
+ line=candidate.line,
259
+ column=candidate.column,
260
+ file_path=context.file_path,
261
+ is_get_prefix=candidate.is_get_prefix,
262
+ class_name=candidate.class_name,
263
+ )
264
+
265
+ def _should_ignore(
266
+ self,
267
+ violation: Violation,
268
+ candidate: PropertyCandidate,
269
+ context: BaseLintContext,
270
+ ) -> bool:
271
+ """Check if violation should be ignored based on directives.
272
+
273
+ Args:
274
+ violation: Violation to check
275
+ candidate: The property candidate
276
+ context: Lint context
277
+
278
+ Returns:
279
+ True if violation should be ignored
280
+ """
281
+ if self._has_inline_ignore(violation, context):
282
+ return True
283
+ if self._has_docstring_ignore(candidate, context):
284
+ return True
285
+ return False
286
+
287
+ # dry: ignore-block
288
+ def _has_inline_ignore(self, violation: Violation, context: BaseLintContext) -> bool:
289
+ """Check for inline ignore directive on method line.
290
+
291
+ Args:
292
+ violation: Violation to check
293
+ context: Lint context
294
+
295
+ Returns:
296
+ True if line has ignore directive
297
+ """
298
+ line_text = self._get_line_text(violation.line, context)
299
+ if line_text is None:
300
+ return False
301
+
302
+ line_lower = line_text.lower()
303
+
304
+ # Check for thailint: ignore[method-property]
305
+ if "thailint:" in line_lower and "ignore" in line_lower:
306
+ return True
307
+
308
+ # Check for noqa
309
+ if "# noqa" in line_lower:
310
+ return True
311
+
312
+ return False
313
+
314
+ def _has_docstring_ignore(
315
+ self,
316
+ candidate: PropertyCandidate,
317
+ context: BaseLintContext,
318
+ ) -> bool:
319
+ """Check for ignore directive in method docstring.
320
+
321
+ Args:
322
+ candidate: Property candidate
323
+ context: Lint context
324
+
325
+ Returns:
326
+ True if docstring has ignore directive
327
+ """
328
+ tree = self._parse_python_code(context.file_content)
329
+ if tree is None:
330
+ return False
331
+
332
+ docstring = self._find_method_docstring(tree, candidate)
333
+ if docstring is None:
334
+ return False
335
+
336
+ docstring_lower = docstring.lower()
337
+ return "thailint: ignore" in docstring_lower
338
+
339
+ def _find_method_docstring(
340
+ self,
341
+ tree: ast.AST,
342
+ candidate: PropertyCandidate,
343
+ ) -> str | None:
344
+ """Find the docstring for a method.
345
+
346
+ Args:
347
+ tree: AST tree
348
+ candidate: Property candidate
349
+
350
+ Returns:
351
+ Docstring text or None
352
+ """
353
+ target_class = self._find_class_node(tree, candidate.class_name)
354
+ if target_class is None:
355
+ return None
356
+ return self._find_method_in_class(target_class, candidate.method_name)
357
+
358
+ def _find_class_node(self, tree: ast.AST, class_name: str) -> ast.ClassDef | None:
359
+ """Find a class node by name in the AST.
360
+
361
+ Args:
362
+ tree: AST tree
363
+ class_name: Name of the class to find
364
+
365
+ Returns:
366
+ ClassDef node or None
367
+ """
368
+ for node in ast.walk(tree):
369
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
370
+ return node
371
+ return None
372
+
373
+ def _find_method_in_class(self, class_node: ast.ClassDef, method_name: str) -> str | None:
374
+ """Find method docstring within a class.
375
+
376
+ Args:
377
+ class_node: Class node to search
378
+ method_name: Method name to find
379
+
380
+ Returns:
381
+ Docstring or None
382
+ """
383
+ for item in class_node.body:
384
+ if isinstance(item, ast.FunctionDef) and item.name == method_name:
385
+ return ast.get_docstring(item)
386
+ return None
387
+
388
+ def _get_line_text(self, line: int, context: BaseLintContext) -> str | None:
389
+ """Get the text of a specific line.
390
+
391
+ Args:
392
+ line: Line number (1-indexed)
393
+ context: Lint context
394
+
395
+ Returns:
396
+ Line text or None
397
+ """
398
+ if not context.file_content:
399
+ return None
400
+
401
+ lines = context.file_content.splitlines()
402
+ if line <= 0 or line > len(lines):
403
+ return None
404
+
405
+ return lines[line - 1]
406
+
407
+ def _check_typescript(
408
+ self, context: BaseLintContext, config: MethodPropertyConfig
409
+ ) -> list[Violation]:
410
+ """Check TypeScript code for violations.
411
+
412
+ Args:
413
+ context: Lint context
414
+ config: Configuration
415
+
416
+ Returns:
417
+ Empty list (not implemented for TypeScript)
418
+ """
419
+ return []