thailint 0.15.5__py3-none-any.whl → 0.15.8__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/core/cli_utils.py CHANGED
@@ -143,6 +143,24 @@ def _load_json_config(config_file: Path) -> dict[str, Any]:
143
143
  return dict(result) if isinstance(result, dict) else {}
144
144
 
145
145
 
146
+ def _sanitize_string(text: str) -> str:
147
+ """Remove or replace surrogate characters that can't be encoded to UTF-8.
148
+
149
+ Surrogate characters (U+D800-U+DFFF) appear when Python reads filesystem paths
150
+ or file content with invalid UTF-8 bytes using surrogateescape error handling.
151
+ These characters cannot be encoded to UTF-8 and cause UnicodeEncodeError.
152
+
153
+ Args:
154
+ text: String that may contain surrogate characters
155
+
156
+ Returns:
157
+ String with surrogates replaced by the Unicode replacement character
158
+ """
159
+ # Encode with surrogateescape to handle surrogates, then decode back
160
+ # This effectively replaces surrogates with a replacement representation
161
+ return text.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
162
+
163
+
146
164
  def format_violations(violations: list, output_format: str) -> None:
147
165
  """Format and print violations to console.
148
166
 
@@ -168,10 +186,10 @@ def _output_json(violations: list) -> None:
168
186
  "violations": [
169
187
  {
170
188
  "rule_id": v.rule_id,
171
- "file_path": str(v.file_path),
189
+ "file_path": _sanitize_string(str(v.file_path)),
172
190
  "line": v.line,
173
191
  "column": v.column,
174
- "message": v.message,
192
+ "message": _sanitize_string(v.message),
175
193
  "severity": v.severity.name,
176
194
  }
177
195
  for v in violations
@@ -215,9 +233,11 @@ def _print_violation(v: Any) -> None:
215
233
  Args:
216
234
  v: Violation object with file_path, line, column, severity, rule_id, message
217
235
  """
218
- location = f"{v.file_path}:{v.line}" if v.line else str(v.file_path)
236
+ file_path = _sanitize_string(str(v.file_path))
237
+ message = _sanitize_string(v.message)
238
+ location = f"{file_path}:{v.line}" if v.line else file_path
219
239
  if v.column:
220
240
  location += f":{v.column}"
221
241
  click.echo(f" {location}")
222
- click.echo(f" [{v.severity.name}] {v.rule_id}: {v.message}")
242
+ click.echo(f" [{v.severity.name}] {v.rule_id}: {message}")
223
243
  click.echo()
@@ -4,8 +4,9 @@ Purpose: Configuration schema for magic numbers linter
4
4
  Scope: MagicNumberConfig dataclass with allowed_numbers and max_small_integer settings
5
5
 
6
6
  Overview: Defines configuration schema for magic numbers linter. Provides MagicNumberConfig dataclass
7
- with allowed_numbers set (default includes common acceptable numbers like -1, 0, 1, 2, 3, 4, 5, 10, 100, 1000)
8
- and max_small_integer threshold (default 10) for range() contexts. Supports per-file and per-directory
7
+ with allowed_numbers set (default includes common acceptable numbers like -1, 0, 1, 2, 3, 4, 5, 10, 100, 1000
8
+ and standard ports like 80, 443, 22, 21, 8080, 8443, 3000, 5000) and max_small_integer threshold (default 10)
9
+ for range() contexts. Supports per-file and per-directory
9
10
  config overrides through from_dict class method. Validates that configuration values are appropriate
10
11
  types. Integrates with orchestrator's configuration system to allow users to customize allowed numbers
11
12
  via .thailint.yaml configuration files.
@@ -18,11 +19,41 @@ Interfaces: MagicNumberConfig(allowed_numbers: set, max_small_integer: int, enab
18
19
  from_dict class method for loading configuration from dictionary
19
20
 
20
21
  Implementation: Dataclass with validation and defaults, matches reference implementation patterns
22
+
23
+ Suppressions:
24
+ - unnecessary-lambda: The lambda is required here because dataclass field default_factory
25
+ needs a callable, and DEFAULT_ALLOWED_NUMBERS.copy() is a method call, not a callable.
26
+ Using `default_factory=DEFAULT_ALLOWED_NUMBERS.copy` would call the method at class
27
+ definition time, not at instance creation time.
21
28
  """
22
29
 
23
30
  from dataclasses import dataclass, field
24
31
  from typing import Any
25
32
 
33
+ # Default allowed numbers including common small integers and standard ports
34
+ DEFAULT_ALLOWED_NUMBERS: set[int | float] = {
35
+ # Common small integers
36
+ -1,
37
+ 0,
38
+ 1,
39
+ 2,
40
+ 3,
41
+ 4,
42
+ 5,
43
+ 10,
44
+ 100,
45
+ 1000,
46
+ # Standard ports
47
+ 21, # FTP
48
+ 22, # SSH
49
+ 80, # HTTP
50
+ 443, # HTTPS
51
+ 3000, # Common dev server (Node.js, Rails)
52
+ 5000, # Flask default
53
+ 8080, # Alternate HTTP
54
+ 8443, # Alternate HTTPS
55
+ }
56
+
26
57
 
27
58
  @dataclass
28
59
  class MagicNumberConfig:
@@ -30,10 +61,11 @@ class MagicNumberConfig:
30
61
 
31
62
  enabled: bool = True
32
63
  allowed_numbers: set[int | float] = field(
33
- default_factory=lambda: {-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000}
64
+ default_factory=lambda: DEFAULT_ALLOWED_NUMBERS.copy() # pylint: disable=unnecessary-lambda
34
65
  )
35
66
  max_small_integer: int = 10
36
67
  ignore: list[str] = field(default_factory=list)
68
+ exempt_definition_files: bool = True
37
69
 
38
70
  def __post_init__(self) -> None:
39
71
  """Validate configuration values."""
@@ -58,16 +90,14 @@ class MagicNumberConfig:
58
90
  allowed_numbers = set(
59
91
  lang_config.get(
60
92
  "allowed_numbers",
61
- config.get("allowed_numbers", {-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000}),
93
+ config.get("allowed_numbers", DEFAULT_ALLOWED_NUMBERS),
62
94
  )
63
95
  )
64
96
  max_small_integer = lang_config.get(
65
97
  "max_small_integer", config.get("max_small_integer", 10)
66
98
  )
67
99
  else:
68
- allowed_numbers = set(
69
- config.get("allowed_numbers", {-1, 0, 1, 2, 3, 4, 5, 10, 100, 1000})
70
- )
100
+ allowed_numbers = set(config.get("allowed_numbers", DEFAULT_ALLOWED_NUMBERS))
71
101
  max_small_integer = config.get("max_small_integer", 10)
72
102
 
73
103
  ignore_patterns = config.get("ignore", [])
@@ -79,4 +109,5 @@ class MagicNumberConfig:
79
109
  allowed_numbers=allowed_numbers,
80
110
  max_small_integer=max_small_integer,
81
111
  ignore=ignore_patterns,
112
+ exempt_definition_files=config.get("exempt_definition_files", True),
82
113
  )
@@ -0,0 +1,226 @@
1
+ """
2
+ Purpose: Detect constant definition files that should be exempt from magic number checking
3
+
4
+ Scope: File-level detection of definition patterns (status codes, constants files)
5
+
6
+ Overview: Provides functions to detect if a file is a constant definition file that should
7
+ be exempt from magic number violations. Definition files exist specifically to define
8
+ named constants and shouldn't be flagged. Detection is based on:
9
+ 1. Filename patterns (*_codes.py, *_constants.py, constants.py)
10
+ 2. Content patterns (dicts with 5+ int keys, 10+ UPPERCASE constant assignments)
11
+ Files matching these patterns contain legitimate constant definitions.
12
+
13
+ Dependencies: ast module for parsing, pathlib for Path handling, re for pattern matching
14
+
15
+ Exports: is_definition_file function
16
+
17
+ Interfaces: is_definition_file(file_path, content) -> bool
18
+
19
+ Implementation: Filename pattern matching and AST-based content analysis
20
+ """
21
+
22
+ import ast
23
+ import re
24
+ from pathlib import Path
25
+
26
+ # Threshold for number of UPPERCASE constants to consider a file as definition file
27
+ MIN_UPPERCASE_CONSTANTS = 10
28
+
29
+ # Threshold for number of int keys in a dict to consider it a definition pattern
30
+ MIN_DICT_INT_KEYS = 5
31
+
32
+
33
+ def is_definition_file(file_path: Path | str | None, content: str | None) -> bool:
34
+ """Check if file is a constant definition file that should be exempt.
35
+
36
+ Args:
37
+ file_path: Path to the file
38
+ content: File content
39
+
40
+ Returns:
41
+ True if file is a definition file that should be exempt
42
+ """
43
+ if _matches_definition_filename(file_path):
44
+ return True
45
+
46
+ if content and _has_definition_content_patterns(content):
47
+ return True
48
+
49
+ return False
50
+
51
+
52
+ def _matches_definition_filename(file_path: Path | str | None) -> bool:
53
+ """Check if filename matches definition file patterns.
54
+
55
+ Patterns:
56
+ - *_codes.py (status_codes.py, error_codes.py, etc.)
57
+ - *_constants.py (app_constants.py, etc.)
58
+ - constants.py
59
+
60
+ Args:
61
+ file_path: Path to the file
62
+
63
+ Returns:
64
+ True if filename matches definition patterns
65
+ """
66
+ if not file_path:
67
+ return False
68
+
69
+ file_name = Path(file_path).name.lower()
70
+
71
+ # Check for *_codes.py pattern
72
+ if file_name.endswith("_codes.py"):
73
+ return True
74
+
75
+ # Check for constants.py or *_constants.py
76
+ if file_name == "constants.py" or file_name.endswith("_constants.py"):
77
+ return True
78
+
79
+ return False
80
+
81
+
82
+ def _has_definition_content_patterns(content: str) -> bool:
83
+ """Check if content has definition file patterns.
84
+
85
+ Patterns:
86
+ - 10+ UPPERCASE constant assignments
87
+ - Dict with 5+ integer keys
88
+
89
+ Args:
90
+ content: File content
91
+
92
+ Returns:
93
+ True if content matches definition patterns
94
+ """
95
+ try:
96
+ tree = ast.parse(content)
97
+ except SyntaxError:
98
+ return False
99
+
100
+ # Check for many UPPERCASE constants
101
+ if _count_uppercase_constants(tree) >= MIN_UPPERCASE_CONSTANTS:
102
+ return True
103
+
104
+ # Check for dicts with many int keys
105
+ if _has_dict_with_int_keys(tree):
106
+ return True
107
+
108
+ return False
109
+
110
+
111
+ def _count_uppercase_constants(tree: ast.Module) -> int:
112
+ """Count UPPERCASE constant assignments at module level.
113
+
114
+ Args:
115
+ tree: Parsed AST module
116
+
117
+ Returns:
118
+ Number of UPPERCASE constant assignments
119
+ """
120
+ count = 0
121
+ for node in tree.body:
122
+ if isinstance(node, ast.Assign):
123
+ count += _count_numeric_constant_targets(node)
124
+ return count
125
+
126
+
127
+ def _count_numeric_constant_targets(assign_node: ast.Assign) -> int:
128
+ """Count UPPERCASE constant targets with numeric values in an assignment.
129
+
130
+ Args:
131
+ assign_node: AST Assign node
132
+
133
+ Returns:
134
+ Number of uppercase constant targets with numeric values
135
+ """
136
+ if not _is_numeric_constant(assign_node.value):
137
+ return 0
138
+ return sum(1 for t in assign_node.targets if _is_uppercase_name_target(t))
139
+
140
+
141
+ def _is_numeric_constant(value: ast.expr) -> bool:
142
+ """Check if value is a numeric constant.
143
+
144
+ Args:
145
+ value: AST expression node
146
+
147
+ Returns:
148
+ True if value is a numeric constant
149
+ """
150
+ return isinstance(value, ast.Constant) and isinstance(value.value, (int, float))
151
+
152
+
153
+ def _is_uppercase_name_target(target: ast.expr) -> bool:
154
+ """Check if target is an uppercase name.
155
+
156
+ Args:
157
+ target: AST expression node
158
+
159
+ Returns:
160
+ True if target is an uppercase Name node
161
+ """
162
+ return isinstance(target, ast.Name) and _is_constant_name(target.id)
163
+
164
+
165
+ def _is_constant_name(name: str) -> bool:
166
+ """Check if name follows UPPERCASE constant convention.
167
+
168
+ Args:
169
+ name: Variable name
170
+
171
+ Returns:
172
+ True if name is UPPERCASE (with underscores allowed)
173
+ """
174
+ # Must be uppercase and contain at least 2 characters
175
+ if len(name) < 2:
176
+ return False
177
+ # Allow underscores but must have uppercase letters
178
+ return re.match(r"^[A-Z][A-Z0-9_]*$", name) is not None
179
+
180
+
181
+ def _has_dict_with_int_keys(tree: ast.Module) -> bool:
182
+ """Check if module has a dict with many integer keys.
183
+
184
+ Args:
185
+ tree: Parsed AST module
186
+
187
+ Returns:
188
+ True if there's a dict with MIN_DICT_INT_KEYS+ int keys
189
+ """
190
+ return any(_has_enough_int_keys(node) for node in ast.walk(tree) if isinstance(node, ast.Dict))
191
+
192
+
193
+ def _has_enough_int_keys(dict_node: ast.Dict) -> bool:
194
+ """Check if dict has enough integer keys to be a definition pattern.
195
+
196
+ Args:
197
+ dict_node: AST Dict node
198
+
199
+ Returns:
200
+ True if dict has MIN_DICT_INT_KEYS or more integer keys
201
+ """
202
+ return _count_int_keys(dict_node) >= MIN_DICT_INT_KEYS
203
+
204
+
205
+ def _count_int_keys(dict_node: ast.Dict) -> int:
206
+ """Count integer keys in a dict.
207
+
208
+ Args:
209
+ dict_node: AST Dict node
210
+
211
+ Returns:
212
+ Number of integer constant keys
213
+ """
214
+ return sum(1 for key in dict_node.keys if _is_int_key(key))
215
+
216
+
217
+ def _is_int_key(key: ast.expr | None) -> bool:
218
+ """Check if key is an integer constant.
219
+
220
+ Args:
221
+ key: AST expression node (or None for **dict unpacking)
222
+
223
+ Returns:
224
+ True if key is an integer constant
225
+ """
226
+ return isinstance(key, ast.Constant) and isinstance(key.value, int)
@@ -40,6 +40,7 @@ from src.linter_config.ignore import get_ignore_parser
40
40
 
41
41
  from .config import MagicNumberConfig
42
42
  from .context_analyzer import is_acceptable_context
43
+ from .definition_detector import is_definition_file
43
44
  from .python_analyzer import PythonMagicNumberAnalyzer
44
45
  from .typescript_analyzer import TypeScriptMagicNumberAnalyzer
45
46
  from .typescript_ignore_checker import TypeScriptIgnoreChecker
@@ -174,6 +175,12 @@ class MagicNumberRule(MultiLanguageLintRule): # thailint: ignore[srp]
174
175
  if self._is_file_ignored(context, config):
175
176
  return []
176
177
 
178
+ # Check if file is a definition file (status_codes.py, constants.py, etc.)
179
+ if config.exempt_definition_files and is_definition_file(
180
+ context.file_path, context.file_content
181
+ ):
182
+ return []
183
+
177
184
  tree = self._parse_python_code(context.file_content)
178
185
  if tree is None:
179
186
  return []
@@ -5,10 +5,12 @@ Scope: Python code nesting depth analysis using ast module
5
5
 
6
6
  Overview: Analyzes Python code to calculate maximum nesting depth using AST traversal. Implements
7
7
  visitor pattern to walk AST, tracking current depth and maximum depth found. Increments depth
8
- for If, For, While, With, AsyncWith, Try, ExceptHandler, Match, and match_case nodes. Starts
9
- depth counting at 1 for function body, matching reference implementation behavior. Returns
10
- maximum depth found and location information for violation reporting. Provides helper method
11
- to find all function definitions in an AST tree for batch processing.
8
+ for If, For, While, With, AsyncWith, Try, ExceptHandler, Match, and match_case nodes. Correctly
9
+ handles elif chains by detecting when an If node is in elif position (sole child in parent's
10
+ orelse list) and not incrementing depth. Starts depth counting at 1 for function body, matching
11
+ reference implementation behavior. Returns maximum depth found and location information for
12
+ violation reporting. Provides helper method to find all function definitions in an AST tree
13
+ for batch processing.
12
14
 
13
15
  Dependencies: ast module for Python parsing
14
16
 
@@ -16,11 +18,37 @@ Exports: PythonNestingAnalyzer class with calculate_max_depth method
16
18
 
17
19
  Interfaces: calculate_max_depth(func_node: ast.FunctionDef) -> tuple[int, int], find_all_functions
18
20
 
19
- Implementation: AST visitor pattern with depth tracking, based on reference implementation algorithm
21
+ Implementation: AST visitor pattern with depth tracking, elif detection via parent orelse inspection
20
22
  """
21
23
 
22
24
  import ast
23
25
 
26
+ # Control structure types that increase nesting depth
27
+ _CONTROL_STRUCTURES = (
28
+ ast.For,
29
+ ast.While,
30
+ ast.With,
31
+ ast.AsyncWith,
32
+ ast.Try,
33
+ ast.Match,
34
+ ast.match_case,
35
+ )
36
+
37
+
38
+ class _DepthTracker:
39
+ """Tracks maximum nesting depth during AST traversal."""
40
+
41
+ def __init__(self, default_line: int) -> None:
42
+ """Initialize tracker with default line number."""
43
+ self.max_depth = 0
44
+ self.max_depth_line = default_line
45
+
46
+ def record(self, node: ast.AST, depth: int, default_line: int) -> None:
47
+ """Record depth if it's the new maximum."""
48
+ if depth > self.max_depth:
49
+ self.max_depth = depth
50
+ self.max_depth_line = getattr(node, "lineno", default_line)
51
+
24
52
 
25
53
  class PythonNestingAnalyzer:
26
54
  """Calculates maximum nesting depth in Python functions."""
@@ -40,42 +68,12 @@ class PythonNestingAnalyzer:
40
68
  Returns:
41
69
  Tuple of (max_depth, line_number_of_max_depth)
42
70
  """
43
- max_depth = 0
44
- max_depth_line = func_node.lineno
45
-
46
- def visit_node(node: ast.AST, current_depth: int = 0) -> None:
47
- nonlocal max_depth, max_depth_line
48
-
49
- if current_depth > max_depth:
50
- max_depth = current_depth
51
- max_depth_line = getattr(node, "lineno", func_node.lineno)
52
-
53
- # Nodes that increase nesting depth
54
- if isinstance(
55
- node,
56
- (
57
- ast.If,
58
- ast.For,
59
- ast.While,
60
- ast.With,
61
- ast.AsyncWith,
62
- ast.Try,
63
- ast.ExceptHandler,
64
- ast.Match,
65
- ast.match_case,
66
- ),
67
- ):
68
- current_depth += 1
69
-
70
- # Visit children
71
- for child in ast.iter_child_nodes(node):
72
- visit_node(child, current_depth)
73
-
74
- # Start at depth 1 for function body (matching reference implementation)
71
+ tracker = _DepthTracker(func_node.lineno)
72
+
75
73
  for stmt in func_node.body:
76
- visit_node(stmt, 1)
74
+ _visit_node(stmt, 0, tracker, func_node.lineno)
77
75
 
78
- return max_depth, max_depth_line
76
+ return tracker.max_depth, tracker.max_depth_line
79
77
 
80
78
  def find_all_functions(self, tree: ast.AST) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
81
79
  """Find all function definitions in AST.
@@ -91,3 +89,68 @@ class PythonNestingAnalyzer:
91
89
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
92
90
  functions.append(node)
93
91
  return functions
92
+
93
+
94
+ def _visit_node(
95
+ node: ast.AST,
96
+ current_depth: int,
97
+ tracker: _DepthTracker,
98
+ default_line: int,
99
+ is_elif: bool = False,
100
+ ) -> None:
101
+ """Visit AST node, tracking nesting depth for control structures only."""
102
+ if isinstance(node, ast.If):
103
+ _visit_if_node(node, current_depth, tracker, default_line, is_elif)
104
+ elif isinstance(node, _CONTROL_STRUCTURES):
105
+ _visit_control_structure(node, current_depth, tracker, default_line)
106
+ else:
107
+ _visit_children(node, current_depth, tracker, default_line)
108
+
109
+
110
+ def _visit_if_node(
111
+ node: ast.If, current_depth: int, tracker: _DepthTracker, default_line: int, is_elif: bool
112
+ ) -> None:
113
+ """Visit If node with special elif handling."""
114
+ if not is_elif:
115
+ current_depth += 1
116
+ tracker.record(node, current_depth, default_line)
117
+
118
+ # Visit body
119
+ for child in node.body:
120
+ _visit_node(child, current_depth, tracker, default_line)
121
+
122
+ # Handle orelse - check for elif chain
123
+ if _is_elif_chain(node.orelse):
124
+ _visit_node(node.orelse[0], current_depth, tracker, default_line, is_elif=True)
125
+ else:
126
+ for child in node.orelse:
127
+ _visit_node(child, current_depth, tracker, default_line)
128
+
129
+
130
+ def _visit_control_structure(
131
+ node: ast.AST, current_depth: int, tracker: _DepthTracker, default_line: int
132
+ ) -> None:
133
+ """Visit a control structure node that increases depth."""
134
+ current_depth += 1
135
+ tracker.record(node, current_depth, default_line)
136
+ _visit_children(node, current_depth, tracker, default_line)
137
+
138
+
139
+ def _visit_children(
140
+ node: ast.AST, current_depth: int, tracker: _DepthTracker, default_line: int
141
+ ) -> None:
142
+ """Visit all children of a node without incrementing depth."""
143
+ for child in ast.iter_child_nodes(node):
144
+ _visit_node(child, current_depth, tracker, default_line)
145
+
146
+
147
+ def _is_elif_chain(orelse: list[ast.stmt]) -> bool:
148
+ """Check if orelse list represents an elif (single If node).
149
+
150
+ Args:
151
+ orelse: The orelse list from an If node
152
+
153
+ Returns:
154
+ True if this is an elif (single If in orelse), False otherwise
155
+ """
156
+ return len(orelse) == 1 and isinstance(orelse[0], ast.If)
@@ -30,6 +30,8 @@ class StatelessClassConfig:
30
30
  enabled: bool = True
31
31
  min_methods: int = 2
32
32
  ignore: list[str] = field(default_factory=list)
33
+ exempt_test_classes: bool = True
34
+ exempt_mixins: bool = True
33
35
 
34
36
  @classmethod
35
37
  def from_dict(
@@ -55,4 +57,6 @@ class StatelessClassConfig:
55
57
  enabled=config.get("enabled", True),
56
58
  min_methods=config.get("min_methods", 2),
57
59
  ignore=ignore_patterns,
60
+ exempt_test_classes=config.get("exempt_test_classes", True),
61
+ exempt_mixins=config.get("exempt_mixins", True),
58
62
  )
@@ -27,6 +27,8 @@ Suppressions:
27
27
  exceeds limit due to comprehensive 5-level ignore system support.
28
28
  """
29
29
 
30
+ import ast
31
+ from collections.abc import Callable
30
32
  from pathlib import Path
31
33
 
32
34
  from src.core.base import BaseLintContext, BaseLintRule
@@ -36,7 +38,13 @@ from src.linter_config.ignore import get_ignore_parser
36
38
  from src.linter_config.rule_matcher import rule_matches
37
39
 
38
40
  from .config import StatelessClassConfig
39
- from .python_analyzer import ClassInfo, StatelessClassAnalyzer
41
+ from .python_analyzer import (
42
+ ClassInfo,
43
+ StatelessClassAnalyzer,
44
+ is_mixin_class,
45
+ is_test_class,
46
+ is_test_file,
47
+ )
40
48
 
41
49
 
42
50
  class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
@@ -74,16 +82,41 @@ class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
74
82
  return []
75
83
 
76
84
  config = self._load_config(context)
77
- if not config.enabled or self._should_skip_file(context, config):
85
+ if not config.enabled:
86
+ return []
87
+ if self._should_skip_file(context, config):
78
88
  return []
79
89
 
80
90
  # _should_analyze ensures file_content is set
81
91
  assert context.file_content is not None # nosec B101
82
92
 
93
+ stateless_classes = self._find_stateless_classes(context, config)
94
+ return self._filter_ignored_violations(stateless_classes, context)
95
+
96
+ def _find_stateless_classes(
97
+ self, context: BaseLintContext, config: StatelessClassConfig
98
+ ) -> list[ClassInfo]:
99
+ """Find stateless classes and apply exemptions.
100
+
101
+ Args:
102
+ context: Lint context
103
+ config: Configuration
104
+
105
+ Returns:
106
+ List of stateless classes after applying exemptions
107
+ """
108
+ assert context.file_content is not None # nosec B101
109
+
83
110
  analyzer = StatelessClassAnalyzer(min_methods=config.min_methods)
84
- stateless_classes = analyzer.analyze(context.file_content)
111
+ classes = analyzer.analyze(context.file_content)
85
112
 
86
- return self._filter_ignored_violations(stateless_classes, context)
113
+ if config.exempt_test_classes:
114
+ classes = self._filter_test_classes(classes, context)
115
+
116
+ if config.exempt_mixins:
117
+ classes = self._filter_mixin_classes(classes, context)
118
+
119
+ return classes
87
120
 
88
121
  def _should_skip_file(self, context: BaseLintContext, config: StatelessClassConfig) -> bool:
89
122
  """Check if file should be skipped due to ignore patterns or directives.
@@ -230,6 +263,85 @@ class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
230
263
  """
231
264
  return rule_matches(self.rule_id, rule_pattern)
232
265
 
266
+ def _parse_class_nodes(self, context: BaseLintContext) -> dict[str, ast.ClassDef] | None:
267
+ """Parse code and build map of class names to AST nodes.
268
+
269
+ Args:
270
+ context: Lint context
271
+
272
+ Returns:
273
+ Dict mapping class names to ClassDef nodes, or None if parsing fails
274
+ """
275
+ if not context.file_content:
276
+ return None
277
+ try:
278
+ tree = ast.parse(context.file_content)
279
+ except SyntaxError:
280
+ return None
281
+ return {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
282
+
283
+ def _filter_test_classes(
284
+ self, classes: list[ClassInfo], context: BaseLintContext
285
+ ) -> list[ClassInfo]:
286
+ """Filter out test classes from stateless class list.
287
+
288
+ Args:
289
+ classes: List of stateless classes found
290
+ context: Lint context
291
+
292
+ Returns:
293
+ List of classes with test classes removed
294
+ """
295
+ # If file is a test file, exempt all classes
296
+ if is_test_file(str(context.file_path) if context.file_path else None):
297
+ return []
298
+
299
+ class_nodes = self._parse_class_nodes(context)
300
+ if class_nodes is None:
301
+ return classes
302
+
303
+ return self._filter_by_predicate(classes, class_nodes, is_test_class)
304
+
305
+ def _filter_by_predicate(
306
+ self,
307
+ classes: list[ClassInfo],
308
+ class_nodes: dict[str, ast.ClassDef],
309
+ predicate: Callable[[ast.ClassDef], bool],
310
+ ) -> list[ClassInfo]:
311
+ """Filter classes based on a predicate function.
312
+
313
+ Args:
314
+ classes: List of class info to filter
315
+ class_nodes: Map of class names to AST nodes
316
+ predicate: Function that returns True for classes to exclude
317
+
318
+ Returns:
319
+ List of classes not matching the predicate
320
+ """
321
+ return [
322
+ info
323
+ for info in classes
324
+ if info.name not in class_nodes or not predicate(class_nodes[info.name])
325
+ ]
326
+
327
+ def _filter_mixin_classes(
328
+ self, classes: list[ClassInfo], context: BaseLintContext
329
+ ) -> list[ClassInfo]:
330
+ """Filter out mixin classes from stateless class list.
331
+
332
+ Args:
333
+ classes: List of stateless classes found
334
+ context: Lint context
335
+
336
+ Returns:
337
+ List of classes with mixin classes removed
338
+ """
339
+ class_nodes = self._parse_class_nodes(context)
340
+ if class_nodes is None:
341
+ return classes
342
+
343
+ return self._filter_by_predicate(classes, class_nodes, is_mixin_class)
344
+
233
345
  def _filter_ignored_violations(
234
346
  self, classes: list[ClassInfo], context: BaseLintContext
235
347
  ) -> list[Violation]:
@@ -6,14 +6,16 @@ Scope: AST-based analysis of Python class definitions for stateless patterns
6
6
  Overview: Analyzes Python source code using AST to detect classes that have no
7
7
  constructor (__init__ or __new__), no instance state (self.attr assignments),
8
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.
9
+ Excludes legitimate patterns like ABC, Protocol, decorated classes, classes
10
+ with class-level attributes, test classes (Test* prefix or TestCase inheritance),
11
+ and mixin classes (name contains "Mixin").
11
12
 
12
13
  Dependencies: Python AST module
13
14
 
14
- Exports: analyze_code function, ClassInfo dataclass
15
+ Exports: analyze_code function, ClassInfo dataclass, is_test_class function
15
16
 
16
- Interfaces: analyze_code(code) -> list[ClassInfo] returning detected stateless classes
17
+ Interfaces: analyze_code(code) -> list[ClassInfo] returning detected stateless classes,
18
+ is_test_class(class_node) -> bool for test class detection
17
19
 
18
20
  Implementation: AST visitor pattern with focused helper functions for different checks
19
21
  """
@@ -262,6 +264,86 @@ def _is_self_attribute(node: ast.expr) -> bool:
262
264
  return node.value.id == "self"
263
265
 
264
266
 
267
+ def is_test_class(class_node: ast.ClassDef) -> bool:
268
+ """Check if class is a test class that should be exempt.
269
+
270
+ Test classes are exempt because they commonly have multiple methods
271
+ without instance state (setup/teardown patterns, assertion methods).
272
+
273
+ Criteria:
274
+ - Class name starts with "Test"
275
+ - Class inherits from unittest.TestCase or TestCase
276
+
277
+ Args:
278
+ class_node: AST ClassDef node
279
+
280
+ Returns:
281
+ True if class is a test class
282
+ """
283
+ # Check class name
284
+ if class_node.name.startswith("Test"):
285
+ return True
286
+
287
+ # Check base classes for TestCase
288
+ for base in class_node.bases:
289
+ base_name = _get_base_name(base)
290
+ if base_name in ("TestCase", "unittest.TestCase"):
291
+ return True
292
+
293
+ return False
294
+
295
+
296
+ def is_test_file(file_path: str | None) -> bool:
297
+ """Check if file is a test file based on path.
298
+
299
+ Args:
300
+ file_path: Path to the file
301
+
302
+ Returns:
303
+ True if file is in tests/ directory or named test_*.py
304
+ """
305
+ if not file_path:
306
+ return False
307
+
308
+ path_str = str(file_path)
309
+ return _is_in_tests_directory(path_str) or _has_test_filename(path_str)
310
+
311
+
312
+ def _is_in_tests_directory(path_str: str) -> bool:
313
+ """Check if path is in a tests/ directory."""
314
+ return (
315
+ "/tests/" in path_str
316
+ or "\\tests\\" in path_str
317
+ or path_str.startswith("tests/")
318
+ or path_str.startswith("tests\\")
319
+ )
320
+
321
+
322
+ def _has_test_filename(path_str: str) -> bool:
323
+ """Check if path has a test_*.py filename."""
324
+ file_name = path_str.rsplit("/", maxsplit=1)[-1].rsplit("\\", maxsplit=1)[-1]
325
+ return file_name.startswith("test_")
326
+
327
+
328
+ def is_mixin_class(class_node: ast.ClassDef) -> bool:
329
+ """Check if class is a mixin class that should be exempt.
330
+
331
+ Mixin classes provide reusable methods intended to be combined with other
332
+ classes via multiple inheritance. They commonly have multiple methods without
333
+ instance state, which is an intentional pattern.
334
+
335
+ Criteria:
336
+ - Class name contains "Mixin" (case-insensitive)
337
+
338
+ Args:
339
+ class_node: AST ClassDef node
340
+
341
+ Returns:
342
+ True if class is a mixin class
343
+ """
344
+ return "mixin" in class_node.name.lower()
345
+
346
+
265
347
  # Legacy class wrapper for backward compatibility with linter.py
266
348
  class StatelessClassAnalyzer:
267
349
  """Analyzes Python code for stateless classes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thailint
3
- Version: 0.15.5
3
+ Version: 0.15.8
4
4
  Summary: The AI Linter - Enterprise-grade linting and governance for AI-generated code across multiple languages
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -152,10 +152,12 @@ See [How to Ignore Violations](https://thai-lint.readthedocs.io/en/latest/how-to
152
152
  - name: Run thailint
153
153
  run: |
154
154
  pip install thai-lint
155
- thailint dry src/
156
- thailint nesting src/
155
+ thailint --parallel dry src/
156
+ thailint --parallel nesting src/
157
157
  ```
158
158
 
159
+ Use `--parallel` for faster linting on large codebases (2-4x speedup on multi-core systems).
160
+
159
161
  Exit codes: `0` = success, `1` = violations found, `2` = error.
160
162
 
161
163
  ## Documentation
@@ -23,7 +23,7 @@ src/cli_main.py,sha256=C0Ey7YNlG3ipqb3KsJZ8rL8PJ4ueVp_45IUirGidvHI,1618
23
23
  src/config.py,sha256=O3ixzsYekGjlggmIsawCU1bctOa0MyG2IczHpg3mGyw,12753
24
24
  src/core/__init__.py,sha256=5FtsDvhMt4SNRx3pbcGURrxn135XRbeRrjSUxiXwkNc,381
25
25
  src/core/base.py,sha256=u5A8geprlKnsJk4ShiLHTKXRekZUB4I6rPQWxgiFeto,8019
26
- src/core/cli_utils.py,sha256=ZdFSPrZ4WfpTMh-mc_Z3u5OYidE1YyPRKflMynPosa8,6552
26
+ src/core/cli_utils.py,sha256=o7lWPSlic96lRyfcsmBf8S1ej25M1-wlclx8_Eup20g,7446
27
27
  src/core/config_parser.py,sha256=CRHV2-csxag6yQzx_4IYYz57QSUYjPkeSb0XvOyshRI,4272
28
28
  src/core/constants.py,sha256=PKtPDqk6k9VuOSgjq1FAdi2CTvlnhdXvLj91dNaMDTA,1584
29
29
  src/core/linter_utils.py,sha256=StnKFzJgSvLyao1S0LpTKhsXo8nOwpdKpxo7mXl5PIg,8594
@@ -141,9 +141,10 @@ src/linters/lbyl/pattern_detectors/string_validator_detector.py,sha256=GXXcsCfmp
141
141
  src/linters/lbyl/python_analyzer.py,sha256=auPrWFUEmFxtv1GB3exJzz7uX71gXqEUCHKO64UDR8w,8041
142
142
  src/linters/lbyl/violation_builder.py,sha256=6wVX9U7Jq1ONWcGuasvIwJE9mXHcT778p0OcPC0Wx7w,10296
143
143
  src/linters/magic_numbers/__init__.py,sha256=17dkCUf0uiYLvpOZF01VDojj92NzxXZMtRhrSBUzsdc,1689
144
- src/linters/magic_numbers/config.py,sha256=3zV6ZNezouBWUYy4kMw5PUlPNvIWXVwOxTz1moZfRoI,3270
144
+ src/linters/magic_numbers/config.py,sha256=l18AO6XY6zkrX6_aumn3CZLpCsw185sfy5B_v2nY-OA,4233
145
145
  src/linters/magic_numbers/context_analyzer.py,sha256=EgDyxxjvEqyD3FX0Fnxj5RcOPyvyVs_rYFxj2HOxYdg,7309
146
- src/linters/magic_numbers/linter.py,sha256=CGo_35ujoCbNXbb0XI4KGCm5C9PCe_LzvXrgmvYN-I4,16736
146
+ src/linters/magic_numbers/definition_detector.py,sha256=brENrT17ofYzZUpFjAq05DeG4DS2pKdWAWm4DyGTrDY,6156
147
+ src/linters/magic_numbers/linter.py,sha256=JoguQjpWNt1Xp818HuzzCdTv9RqVKQoZXy6fFK8zk0o,17023
147
148
  src/linters/magic_numbers/python_analyzer.py,sha256=Ba-EODvAkUIOhqMFv86MxMlXqF20ngvgubiWN_U_IUk,2446
148
149
  src/linters/magic_numbers/typescript_analyzer.py,sha256=-2YPmNWXHJN8R2siV3pJk_3Baj-A9nnvQRpU35YBKgs,7519
149
150
  src/linters/magic_numbers/typescript_ignore_checker.py,sha256=9JWqtXd8KU_GCc_66KSZT2X7uQhNGpxE2ikOyjcLyao,2847
@@ -156,7 +157,7 @@ src/linters/method_property/violation_builder.py,sha256=A7SwZWlVG_7W5pJiHOvIroI2
156
157
  src/linters/nesting/__init__.py,sha256=tszmyCEQMpEwB5H84WcAUfRYDQl7jpsn04es5DtAHsM,3200
157
158
  src/linters/nesting/config.py,sha256=PfPA2wJn3i6HHXeM0qu6Qx-v1KJdRwlRkFOdpf7NhS8,2405
158
159
  src/linters/nesting/linter.py,sha256=bn5aPlxKZNw3T2LsOSfZUK_shkxcsdUS_LtFVsGJexk,6622
159
- src/linters/nesting/python_analyzer.py,sha256=__fs_NE9xA4NM1MDOHBGdrI0zICkTcgbVZtfT03cxF0,3230
160
+ src/linters/nesting/python_analyzer.py,sha256=ZaZuFErwpyEG3G0O5LqYwWn7Kmae9mqB8GCSRHSkjmU,5344
160
161
  src/linters/nesting/typescript_analyzer.py,sha256=70TsjP3EJWiHJ1ncMaveFE0e9_HdukWZr9LM0_MDXr8,3639
161
162
  src/linters/nesting/typescript_function_extractor.py,sha256=dDB1otJnFMCo-Pj4mTr4gekKe7V4ArOAtX6gV0dBDc4,4494
162
163
  src/linters/nesting/violation_builder.py,sha256=WwgR_Q9pfPJOoVuNZQL4MU3-Wc6RX_GGL5Rc2-RVlbI,4829
@@ -186,9 +187,9 @@ src/linters/srp/typescript_analyzer.py,sha256=Wi0P_G1v5AnZYtMN3sNm1iHva84-8Kep2L
186
187
  src/linters/srp/typescript_metrics_calculator.py,sha256=cDaHlnzMgFSTd2Sn5-tldR2HS6P8GMv4Qptep6PJozw,4093
187
188
  src/linters/srp/violation_builder.py,sha256=jaIjVtRYWUTs1SVJVwd0FxCojo0DxhPzfhyfMKmAroM,3881
188
189
  src/linters/stateless_class/__init__.py,sha256=8ePpinmCD27PCz7ukwUWcNwo-ZgyvhOquns-U51MyiQ,1063
189
- src/linters/stateless_class/config.py,sha256=u8Jt_xygIkuxZx2o0Uw_XFatOh11QhC9aN8lB_vfnLk,1993
190
- src/linters/stateless_class/linter.py,sha256=Rm3fZfkyUOYeBodcLPUcMNKUHPuc5NgKOeDioGXyu_M,11338
191
- src/linters/stateless_class/python_analyzer.py,sha256=psEx2pG-eZJfK9ViX4YaNCLFEXEqUoViA3rc32o_sVQ,7623
190
+ src/linters/stateless_class/config.py,sha256=nLowY3nGjvku-GSfPwzclCmVieRulyhaoTjTyWpElk4,2195
191
+ src/linters/stateless_class/linter.py,sha256=G6ftfGqocCbAg24IWJgi2wk8Rld-cJu7OVRPdToZmqY,14783
192
+ src/linters/stateless_class/python_analyzer.py,sha256=P7PJAoCw_1mJdyyqe7EN5ybkDB4FguuTvgTeyy2qgJs,10018
192
193
  src/linters/stringly_typed/__init__.py,sha256=6r4IIykZ6mm551KQpRTSDp418EFqJQbuzjSfLHcwyBc,1511
193
194
  src/linters/stringly_typed/config.py,sha256=-M7fwwr9axQsQcGtowVINC9Bh1cS1b2-KPxFb2GtL3M,7500
194
195
  src/linters/stringly_typed/context_filter.py,sha256=JohTFvXiHKfVzUowRbsDrY37QngJDmhFfoxyoTzKriY,11422
@@ -219,8 +220,8 @@ src/orchestrator/language_detector.py,sha256=ALt2BEZKXQM2dWr1ChF9lZVj83YF4Bl9xwr
219
220
  src/templates/thailint_config_template.yaml,sha256=57ZtLxnIoOHtR5Ejq3clb4nhY9J4n6h36XFb79ZZPlc,12020
220
221
  src/utils/__init__.py,sha256=NiBtKeQ09Y3kuUzeN4O1JNfUIYPQDS2AP1l5ODq-Dec,125
221
222
  src/utils/project_root.py,sha256=aaxUM-LQ1okrPClmZWPFd_D09W3V1ArgJiidEEp_eU8,6262
222
- thailint-0.15.5.dist-info/METADATA,sha256=eCz6fB_QjLHkSrbUvyQU8pGObNYH2ZezmBgagz7oYbA,7202
223
- thailint-0.15.5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
224
- thailint-0.15.5.dist-info/entry_points.txt,sha256=DNoGUlxpaMFqxQDgHp1yeGqohOjdFR-kH19uHYi3OUY,72
225
- thailint-0.15.5.dist-info/licenses/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
226
- thailint-0.15.5.dist-info/RECORD,,
223
+ thailint-0.15.8.dist-info/METADATA,sha256=7kLsrDHppJeIvPAlTUieZEBLz7kG0bqG6o7SyTV-Zg0,7318
224
+ thailint-0.15.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
225
+ thailint-0.15.8.dist-info/entry_points.txt,sha256=DNoGUlxpaMFqxQDgHp1yeGqohOjdFR-kH19uHYi3OUY,72
226
+ thailint-0.15.8.dist-info/licenses/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
227
+ thailint-0.15.8.dist-info/RECORD,,