thailint 0.12.0__py3-none-any.whl → 0.13.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.
Files changed (121) hide show
  1. src/analyzers/__init__.py +4 -3
  2. src/analyzers/ast_utils.py +54 -0
  3. src/analyzers/typescript_base.py +4 -0
  4. src/cli/__init__.py +3 -0
  5. src/cli/config.py +12 -12
  6. src/cli/config_merge.py +241 -0
  7. src/cli/linters/__init__.py +3 -0
  8. src/cli/linters/code_patterns.py +113 -5
  9. src/cli/linters/code_smells.py +4 -0
  10. src/cli/linters/documentation.py +3 -0
  11. src/cli/linters/structure.py +3 -0
  12. src/cli/linters/structure_quality.py +3 -0
  13. src/cli_main.py +3 -0
  14. src/config.py +2 -1
  15. src/core/base.py +3 -2
  16. src/core/cli_utils.py +3 -1
  17. src/core/config_parser.py +5 -2
  18. src/core/constants.py +54 -0
  19. src/core/linter_utils.py +4 -0
  20. src/core/rule_discovery.py +5 -1
  21. src/core/violation_builder.py +3 -0
  22. src/linter_config/directive_markers.py +109 -0
  23. src/linter_config/ignore.py +225 -383
  24. src/linter_config/pattern_utils.py +65 -0
  25. src/linter_config/rule_matcher.py +89 -0
  26. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  27. src/linters/collection_pipeline/ast_utils.py +40 -0
  28. src/linters/collection_pipeline/config.py +12 -0
  29. src/linters/collection_pipeline/continue_analyzer.py +2 -8
  30. src/linters/collection_pipeline/detector.py +262 -32
  31. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  32. src/linters/collection_pipeline/linter.py +18 -35
  33. src/linters/collection_pipeline/suggestion_builder.py +68 -1
  34. src/linters/dry/base_token_analyzer.py +16 -9
  35. src/linters/dry/block_filter.py +7 -4
  36. src/linters/dry/cache.py +7 -2
  37. src/linters/dry/config.py +7 -1
  38. src/linters/dry/constant_matcher.py +34 -25
  39. src/linters/dry/file_analyzer.py +4 -2
  40. src/linters/dry/inline_ignore.py +7 -16
  41. src/linters/dry/linter.py +48 -25
  42. src/linters/dry/python_analyzer.py +18 -10
  43. src/linters/dry/python_constant_extractor.py +51 -52
  44. src/linters/dry/single_statement_detector.py +14 -12
  45. src/linters/dry/token_hasher.py +115 -115
  46. src/linters/dry/typescript_analyzer.py +11 -6
  47. src/linters/dry/typescript_constant_extractor.py +4 -0
  48. src/linters/dry/typescript_statement_detector.py +208 -208
  49. src/linters/dry/typescript_value_extractor.py +3 -0
  50. src/linters/dry/violation_filter.py +1 -4
  51. src/linters/dry/violation_generator.py +1 -4
  52. src/linters/file_header/atemporal_detector.py +4 -0
  53. src/linters/file_header/base_parser.py +4 -0
  54. src/linters/file_header/bash_parser.py +4 -0
  55. src/linters/file_header/field_validator.py +5 -8
  56. src/linters/file_header/linter.py +19 -12
  57. src/linters/file_header/markdown_parser.py +6 -0
  58. src/linters/file_placement/config_loader.py +3 -1
  59. src/linters/file_placement/linter.py +22 -8
  60. src/linters/file_placement/pattern_matcher.py +21 -4
  61. src/linters/file_placement/pattern_validator.py +21 -7
  62. src/linters/file_placement/rule_checker.py +2 -2
  63. src/linters/lazy_ignores/__init__.py +43 -0
  64. src/linters/lazy_ignores/config.py +66 -0
  65. src/linters/lazy_ignores/directive_utils.py +121 -0
  66. src/linters/lazy_ignores/header_parser.py +177 -0
  67. src/linters/lazy_ignores/linter.py +158 -0
  68. src/linters/lazy_ignores/matcher.py +135 -0
  69. src/linters/lazy_ignores/python_analyzer.py +201 -0
  70. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  71. src/linters/lazy_ignores/skip_detector.py +298 -0
  72. src/linters/lazy_ignores/types.py +67 -0
  73. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  74. src/linters/lazy_ignores/violation_builder.py +131 -0
  75. src/linters/lbyl/__init__.py +29 -0
  76. src/linters/lbyl/config.py +63 -0
  77. src/linters/lbyl/pattern_detectors/__init__.py +25 -0
  78. src/linters/lbyl/pattern_detectors/base.py +46 -0
  79. src/linters/magic_numbers/context_analyzer.py +227 -229
  80. src/linters/magic_numbers/linter.py +20 -15
  81. src/linters/magic_numbers/python_analyzer.py +4 -16
  82. src/linters/magic_numbers/typescript_analyzer.py +9 -16
  83. src/linters/method_property/config.py +4 -0
  84. src/linters/method_property/linter.py +5 -4
  85. src/linters/method_property/python_analyzer.py +5 -4
  86. src/linters/method_property/violation_builder.py +3 -0
  87. src/linters/nesting/typescript_analyzer.py +6 -12
  88. src/linters/nesting/typescript_function_extractor.py +0 -4
  89. src/linters/print_statements/linter.py +6 -4
  90. src/linters/print_statements/python_analyzer.py +85 -81
  91. src/linters/print_statements/typescript_analyzer.py +6 -15
  92. src/linters/srp/heuristics.py +4 -4
  93. src/linters/srp/linter.py +12 -12
  94. src/linters/srp/violation_builder.py +0 -4
  95. src/linters/stateless_class/linter.py +30 -36
  96. src/linters/stateless_class/python_analyzer.py +11 -20
  97. src/linters/stringly_typed/config.py +4 -5
  98. src/linters/stringly_typed/context_filter.py +410 -410
  99. src/linters/stringly_typed/function_call_violation_builder.py +93 -95
  100. src/linters/stringly_typed/linter.py +48 -16
  101. src/linters/stringly_typed/python/analyzer.py +5 -1
  102. src/linters/stringly_typed/python/call_tracker.py +8 -5
  103. src/linters/stringly_typed/python/comparison_tracker.py +10 -5
  104. src/linters/stringly_typed/python/condition_extractor.py +3 -0
  105. src/linters/stringly_typed/python/conditional_detector.py +4 -1
  106. src/linters/stringly_typed/python/match_analyzer.py +8 -2
  107. src/linters/stringly_typed/python/validation_detector.py +3 -0
  108. src/linters/stringly_typed/storage.py +14 -14
  109. src/linters/stringly_typed/typescript/call_tracker.py +9 -3
  110. src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
  111. src/linters/stringly_typed/violation_generator.py +288 -259
  112. src/orchestrator/core.py +13 -4
  113. src/templates/thailint_config_template.yaml +166 -0
  114. src/utils/project_root.py +3 -0
  115. thailint-0.13.0.dist-info/METADATA +184 -0
  116. thailint-0.13.0.dist-info/RECORD +189 -0
  117. thailint-0.12.0.dist-info/METADATA +0 -1667
  118. thailint-0.12.0.dist-info/RECORD +0 -164
  119. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
  120. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
  121. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
src/linters/dry/config.py CHANGED
@@ -15,11 +15,16 @@ Exports: DRYConfig dataclass
15
15
  Interfaces: DRYConfig.__init__, DRYConfig.from_dict(config: dict) -> DRYConfig
16
16
 
17
17
  Implementation: Dataclass with field defaults, __post_init__ validation, and dict-based construction
18
+
19
+ Suppressions:
20
+ - too-many-instance-attributes: Configuration dataclass with related settings
18
21
  """
19
22
 
20
23
  from dataclasses import dataclass, field
21
24
  from typing import Any
22
25
 
26
+ from src.core.constants import StorageMode
27
+
23
28
  # Default configuration constants
24
29
  DEFAULT_MIN_DUPLICATE_LINES = 3
25
30
  DEFAULT_MIN_DUPLICATE_TOKENS = 30
@@ -72,7 +77,8 @@ class DRYConfig: # pylint: disable=too-many-instance-attributes
72
77
  def __post_init__(self) -> None:
73
78
  """Validate configuration values."""
74
79
  self._validate_positive_fields()
75
- if self.storage_mode not in ("memory", "tempfile"):
80
+ valid_modes = (StorageMode.MEMORY, StorageMode.TEMPFILE)
81
+ if self.storage_mode not in valid_modes:
76
82
  raise ValueError(
77
83
  f"storage_mode must be 'memory' or 'tempfile', got '{self.storage_mode}'"
78
84
  )
@@ -12,11 +12,14 @@ Overview: Implements fuzzy matching strategies to identify related constants acr
12
12
 
13
13
  Dependencies: ConstantInfo, ConstantLocation, ConstantGroup from constant module
14
14
 
15
- Exports: ConstantMatcher class
15
+ Exports: find_constant_groups function
16
16
 
17
- Interfaces: ConstantMatcher.find_groups(constants) -> list[ConstantGroup]
17
+ Interfaces: find_constant_groups(constants) -> list[ConstantGroup]
18
18
 
19
19
  Implementation: Union-Find algorithm for grouping, word-set hashing, Levenshtein distance calculation
20
+
21
+ Suppressions:
22
+ - arguments-out-of-order: Named arguments used for clarity in ConstantLocation
20
23
  """
21
24
 
22
25
  from collections.abc import Callable
@@ -80,29 +83,35 @@ class UnionFind:
80
83
  self._parent[px] = py
81
84
 
82
85
 
83
- class ConstantMatcher:
84
- """Fuzzy matching for constant names."""
85
-
86
- def find_groups(self, constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantGroup]:
87
- """Find groups of related constants."""
88
- if not constants:
89
- return []
90
- locations = _build_locations(constants)
91
- exact_groups = _group_by_exact_name(locations)
92
- return self._merge_fuzzy_groups(exact_groups)
93
-
94
- def _merge_fuzzy_groups(self, groups: dict[str, ConstantGroup]) -> list[ConstantGroup]:
95
- """Merge groups that match via fuzzy matching."""
96
- names = list(groups.keys())
97
- uf = UnionFind(names)
98
- _union_matching_pairs(names, uf, self._is_fuzzy_match)
99
- return _build_merged_groups(names, groups, uf)
100
-
101
- def _is_fuzzy_match(self, name1: str, name2: str) -> bool:
102
- """Check if two constant names should be considered a match."""
103
- if name1 == name2:
104
- return True
105
- return _is_fuzzy_similar(name1, name2)
86
+ def find_constant_groups(constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantGroup]:
87
+ """Find groups of related constants.
88
+
89
+ Args:
90
+ constants: List of (file_path, ConstantInfo) tuples
91
+
92
+ Returns:
93
+ List of ConstantGroup instances representing related constants
94
+ """
95
+ if not constants:
96
+ return []
97
+ locations = _build_locations(constants)
98
+ exact_groups = _group_by_exact_name(locations)
99
+ return _merge_fuzzy_groups(exact_groups)
100
+
101
+
102
+ def _merge_fuzzy_groups(groups: dict[str, ConstantGroup]) -> list[ConstantGroup]:
103
+ """Merge groups that match via fuzzy matching."""
104
+ names = list(groups.keys())
105
+ uf = UnionFind(names)
106
+ _union_matching_pairs(names, uf, _is_fuzzy_match)
107
+ return _build_merged_groups(names, groups, uf)
108
+
109
+
110
+ def _is_fuzzy_match(name1: str, name2: str) -> bool:
111
+ """Check if two constant names should be considered a match."""
112
+ if name1 == name2:
113
+ return True
114
+ return _is_fuzzy_similar(name1, name2)
106
115
 
107
116
 
108
117
  def _build_locations(constants: list[tuple[Path, ConstantInfo]]) -> list[ConstantLocation]:
@@ -18,6 +18,8 @@ Implementation: Delegates to language-specific analyzers, always performs fresh
18
18
 
19
19
  from pathlib import Path
20
20
 
21
+ from src.core.constants import Language
22
+
21
23
  from .block_filter import BlockFilterRegistry, create_default_registry
22
24
  from .cache import CodeBlock
23
25
  from .config import DRYConfig
@@ -83,8 +85,8 @@ class FileAnalyzer:
83
85
  List of CodeBlock instances
84
86
  """
85
87
  # Analyze file based on language
86
- if language == "python":
88
+ if language == Language.PYTHON:
87
89
  return self._python_analyzer.analyze(file_path, content, config)
88
- if language in ("typescript", "javascript"):
90
+ if language in (Language.TYPESCRIPT, Language.JAVASCRIPT):
89
91
  return self._typescript_analyzer.analyze(file_path, content, config)
90
92
  return []
@@ -50,14 +50,11 @@ class InlineIgnoreParser:
50
50
  Returns:
51
51
  List of (start, end) tuples for ignore ranges
52
52
  """
53
- ranges = []
54
-
55
- for i, line in enumerate(lines, start=1):
56
- ignore_range = self._parse_ignore_directive(line, i, len(lines))
57
- if ignore_range:
58
- ranges.append(ignore_range)
59
-
60
- return ranges
53
+ return [
54
+ ignore_range
55
+ for i, line in enumerate(lines, start=1)
56
+ if (ignore_range := self._parse_ignore_directive(line, i, len(lines)))
57
+ ]
61
58
 
62
59
  def _parse_ignore_directive(
63
60
  self, line: str, line_num: int, total_lines: int
@@ -115,10 +112,7 @@ class InlineIgnoreParser:
115
112
  Returns:
116
113
  True if ranges overlap
117
114
  """
118
- for ign_start, ign_end in ranges:
119
- if line <= ign_end and end_line >= ign_start:
120
- return True
121
- return False
115
+ return any(line <= ign_end and end_line >= ign_start for ign_start, ign_end in ranges)
122
116
 
123
117
  def _check_single_line(self, line: int, ranges: list[tuple[int, int]]) -> bool:
124
118
  """Check if single line is in any ignore range.
@@ -130,10 +124,7 @@ class InlineIgnoreParser:
130
124
  Returns:
131
125
  True if line is in any range
132
126
  """
133
- for start, end in ranges:
134
- if start <= line <= end:
135
- return True
136
- return False
127
+ return any(start <= line <= end for start, end in ranges)
137
128
 
138
129
  def clear(self) -> None:
139
130
  """Clear all stored ignore ranges."""
src/linters/dry/linter.py CHANGED
@@ -11,18 +11,23 @@ Overview: Implements DRY linter rule following BaseLintRule interface with state
11
11
  with SRP.
12
12
 
13
13
  Dependencies: BaseLintRule, BaseLintContext, ConfigLoader, StorageInitializer, FileAnalyzer,
14
- DuplicateStorage, ViolationGenerator, PythonConstantExtractor, TypeScriptConstantExtractor,
15
- ConstantMatcher, ConstantViolationBuilder
14
+ DuplicateStorage, ViolationGenerator, extract_python_constants, TypeScriptConstantExtractor,
15
+ find_constant_groups, ConstantViolationBuilder
16
16
 
17
17
  Exports: DRYRule class
18
18
 
19
19
  Interfaces: DRYRule.check(context) -> list[Violation], finalize() -> list[Violation]
20
20
 
21
21
  Implementation: Delegates all logic to helper classes, maintains only orchestration and state
22
+
23
+ Suppressions:
24
+ - too-many-instance-attributes: DRYComponents groups related helper dependencies
25
+ - B101: Type narrowing assertions after guards (storage initialized, file_path/content set)
22
26
  """
23
27
 
24
28
  from __future__ import annotations
25
29
 
30
+ from collections.abc import Callable
26
31
  from dataclasses import dataclass
27
32
  from pathlib import Path
28
33
 
@@ -33,12 +38,12 @@ from src.core.types import Violation
33
38
  from .config import DRYConfig
34
39
  from .config_loader import ConfigLoader
35
40
  from .constant import ConstantInfo
36
- from .constant_matcher import ConstantMatcher
41
+ from .constant_matcher import find_constant_groups
37
42
  from .constant_violation_builder import ConstantViolationBuilder
38
43
  from .duplicate_storage import DuplicateStorage
39
44
  from .file_analyzer import FileAnalyzer
40
45
  from .inline_ignore import InlineIgnoreParser
41
- from .python_constant_extractor import PythonConstantExtractor
46
+ from .python_constant_extractor import extract_python_constants
42
47
  from .storage_initializer import StorageInitializer
43
48
  from .typescript_constant_extractor import TypeScriptConstantExtractor
44
49
  from .violation_generator import ViolationGenerator
@@ -53,9 +58,7 @@ class DRYComponents: # pylint: disable=too-many-instance-attributes
53
58
  file_analyzer: FileAnalyzer
54
59
  violation_generator: ViolationGenerator
55
60
  inline_ignore: InlineIgnoreParser
56
- python_extractor: PythonConstantExtractor
57
61
  typescript_extractor: TypeScriptConstantExtractor
58
- constant_matcher: ConstantMatcher
59
62
  constant_violation_builder: ConstantViolationBuilder
60
63
 
61
64
 
@@ -79,12 +82,22 @@ class DRYRule(BaseLintRule):
79
82
  file_analyzer=FileAnalyzer(), # Placeholder, will be replaced with configured one
80
83
  violation_generator=ViolationGenerator(),
81
84
  inline_ignore=InlineIgnoreParser(),
82
- python_extractor=PythonConstantExtractor(),
83
85
  typescript_extractor=TypeScriptConstantExtractor(),
84
- constant_matcher=ConstantMatcher(),
85
86
  constant_violation_builder=ConstantViolationBuilder(),
86
87
  )
87
88
 
89
+ @property
90
+ def _active_storage(self) -> DuplicateStorage:
91
+ """Get storage, asserting it has been initialized."""
92
+ assert self._storage is not None, "Storage not initialized" # nosec B101
93
+ return self._storage
94
+
95
+ @property
96
+ def _active_file_analyzer(self) -> FileAnalyzer:
97
+ """Get file analyzer, asserting it has been initialized."""
98
+ assert self._file_analyzer is not None, "File analyzer not initialized" # nosec B101
99
+ return self._file_analyzer
100
+
88
101
  @property
89
102
  def rule_id(self) -> str:
90
103
  """Unique identifier for this rule."""
@@ -115,8 +128,12 @@ class DRYRule(BaseLintRule):
115
128
 
116
129
  def _process_file(self, context: BaseLintContext, config: DRYConfig) -> None:
117
130
  """Process a single file for duplicates and constants."""
118
- file_path = Path(context.file_path) # type: ignore[arg-type]
119
- self._helpers.inline_ignore.parse_file(file_path, context.file_content or "")
131
+ # should_process_file ensures file_path and file_content are set
132
+ assert context.file_path is not None # nosec B101
133
+ assert context.file_content is not None # nosec B101
134
+
135
+ file_path = context.file_path
136
+ self._helpers.inline_ignore.parse_file(file_path, context.file_content)
120
137
  self._ensure_storage_initialized(context, config)
121
138
  self._analyze_and_store(context, config)
122
139
  if config.detect_duplicate_constants:
@@ -134,15 +151,18 @@ class DRYRule(BaseLintRule):
134
151
  """Analyze file and store blocks."""
135
152
  if not self._can_analyze(context):
136
153
  return
137
- file_path = Path(context.file_path) # type: ignore[arg-type]
138
- blocks = self._file_analyzer.analyze( # type: ignore[union-attr]
139
- file_path,
140
- context.file_content, # type: ignore[arg-type]
154
+ # _can_analyze ensures file_path and file_content are set
155
+ assert context.file_path is not None # nosec B101
156
+ assert context.file_content is not None # nosec B101
157
+
158
+ blocks = self._active_file_analyzer.analyze(
159
+ context.file_path,
160
+ context.file_content,
141
161
  context.language,
142
162
  config,
143
163
  )
144
164
  if blocks:
145
- self._storage.add_blocks(file_path, blocks) # type: ignore[union-attr]
165
+ self._active_storage.add_blocks(context.file_path, blocks)
146
166
 
147
167
  def _can_analyze(self, context: BaseLintContext) -> bool:
148
168
  """Check if context is ready for analysis."""
@@ -158,9 +178,9 @@ class DRYRule(BaseLintRule):
158
178
  if context.file_path is None or context.file_content is None:
159
179
  return
160
180
  file_path = Path(context.file_path)
161
- extractor = _get_extractor_for_language(context.language, self._helpers)
162
- if extractor:
163
- self._constants.extend((file_path, c) for c in extractor.extract(context.file_content))
181
+ extract_fn = _get_extractor_for_language(context.language, self._helpers)
182
+ if extract_fn:
183
+ self._constants.extend((file_path, c) for c in extract_fn(context.file_content))
164
184
 
165
185
  def finalize(self) -> list[Violation]:
166
186
  """Generate violations after all files processed."""
@@ -180,14 +200,17 @@ class DRYRule(BaseLintRule):
180
200
  return violations
181
201
 
182
202
 
203
+ ConstantExtractorFn = Callable[[str], list[ConstantInfo]]
204
+
205
+
183
206
  def _get_extractor_for_language(
184
207
  language: str | None, helpers: DRYComponents
185
- ) -> PythonConstantExtractor | TypeScriptConstantExtractor | None:
186
- """Get the appropriate constant extractor for a language."""
187
- extractors: dict[str, PythonConstantExtractor | TypeScriptConstantExtractor] = {
188
- "python": helpers.python_extractor,
189
- "typescript": helpers.typescript_extractor,
190
- "javascript": helpers.typescript_extractor,
208
+ ) -> ConstantExtractorFn | None:
209
+ """Get the appropriate constant extractor function for a language."""
210
+ extractors: dict[str, ConstantExtractorFn] = {
211
+ "python": extract_python_constants,
212
+ "typescript": helpers.typescript_extractor.extract,
213
+ "javascript": helpers.typescript_extractor.extract,
191
214
  }
192
215
  return extractors.get(language or "")
193
216
 
@@ -199,6 +222,6 @@ def _generate_constant_violations(
199
222
  rule_id: str,
200
223
  ) -> list[Violation]:
201
224
  """Generate violations for duplicate constants."""
202
- groups = helpers.constant_matcher.find_groups(constants)
225
+ groups = find_constant_groups(constants)
203
226
  helpers.constant_violation_builder.min_occurrences = config.min_constant_occurrences
204
227
  return helpers.constant_violation_builder.build_violations(groups, rule_id)
@@ -8,7 +8,7 @@ Overview: Analyzes Python source files to extract code blocks for duplicate dete
8
8
  Filters out docstrings at the tokenization level to prevent false positive duplication
9
9
  detection on documentation strings.
10
10
 
11
- Dependencies: BaseTokenAnalyzer, CodeBlock, DRYConfig, pathlib.Path, ast, TokenHasher
11
+ Dependencies: BaseTokenAnalyzer, CodeBlock, DRYConfig, pathlib.Path, ast, token_hasher module
12
12
 
13
13
  Exports: PythonDuplicateAnalyzer class
14
14
 
@@ -17,6 +17,12 @@ Interfaces: PythonDuplicateAnalyzer.analyze(file_path: Path, content: str, confi
17
17
 
18
18
  Implementation: Uses custom tokenizer that filters docstrings before hashing
19
19
 
20
+ Suppressions:
21
+ - too-many-arguments,too-many-positional-arguments: Line processing with related params
22
+ - type:ignore[arg-type]: ast.get_docstring returns str|None, typing limitation
23
+ - srp.violation: Complex AST analysis algorithm for duplicate detection. See SRP Exception below.
24
+ - nesting.excessive-depth: analyze method uses nested loops for docstring extraction.
25
+
20
26
  SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max 8 methods/200 lines)
21
27
  Justification: Complex AST analysis algorithm for duplicate code detection with sophisticated
22
28
  false positive filtering. Methods form tightly coupled algorithm pipeline: docstring extraction,
@@ -31,6 +37,7 @@ SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max
31
37
  import ast
32
38
  from pathlib import Path
33
39
 
40
+ from . import token_hasher
34
41
  from .base_token_analyzer import BaseTokenAnalyzer
35
42
  from .block_filter import BlockFilterRegistry, create_default_registry
36
43
  from .cache import CodeBlock
@@ -98,14 +105,15 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
98
105
  content: str,
99
106
  ) -> list[CodeBlock]:
100
107
  """Filter hash windows and create valid CodeBlock instances."""
101
- blocks = []
102
- for hash_val, start_line, end_line, snippet in windows:
103
- block = self._create_block_if_valid(
104
- file_path, content, hash_val, start_line, end_line, snippet
108
+ return [
109
+ block
110
+ for hash_val, start_line, end_line, snippet in windows
111
+ if (
112
+ block := self._create_block_if_valid(
113
+ file_path, content, hash_val, start_line, end_line, snippet
114
+ )
105
115
  )
106
- if block:
107
- blocks.append(block)
108
- return blocks
116
+ ]
109
117
 
110
118
  def _create_block_if_valid( # pylint: disable=too-many-arguments,too-many-positional-arguments
111
119
  self,
@@ -229,11 +237,11 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
229
237
  Returns:
230
238
  Tuple of (new_import_state, normalized_line or None if should skip)
231
239
  """
232
- normalized = self._hasher._normalize_line(line) # pylint: disable=protected-access
240
+ normalized = token_hasher.normalize_line(line)
233
241
  if not normalized:
234
242
  return in_multiline_import, None
235
243
 
236
- new_state, should_skip = self._hasher._should_skip_import_line( # pylint: disable=protected-access
244
+ new_state, should_skip = token_hasher.should_skip_import_line(
237
245
  normalized, in_multiline_import
238
246
  )
239
247
  if should_skip:
@@ -11,9 +11,9 @@ Overview: Extracts module-level constant definitions from Python source code usi
11
11
 
12
12
  Dependencies: Python ast module, re for pattern matching, ConstantInfo from constant module
13
13
 
14
- Exports: PythonConstantExtractor class
14
+ Exports: extract_python_constants function
15
15
 
16
- Interfaces: PythonConstantExtractor.extract(content: str) -> list[ConstantInfo]
16
+ Interfaces: extract_python_constants(content: str) -> list[ConstantInfo]
17
17
 
18
18
  Implementation: AST-based parsing with module-level filtering and ALL_CAPS regex matching
19
19
  """
@@ -26,49 +26,55 @@ from .constant import CONSTANT_NAME_PATTERN, ConstantInfo
26
26
  CONTAINER_REPRESENTATIONS = {ast.List: "[...]", ast.Dict: "{...}", ast.Tuple: "(...)"}
27
27
 
28
28
 
29
- class PythonConstantExtractor:
30
- """Extracts module-level constants from Python source code."""
31
-
32
- def extract(self, content: str) -> list[ConstantInfo]:
33
- """Extract constants from Python source code."""
34
- try:
35
- tree = ast.parse(content)
36
- except SyntaxError:
37
- return []
38
- constants: list[ConstantInfo] = []
39
- for node in tree.body:
40
- constants.extend(self._extract_from_node(node))
41
- return constants
42
-
43
- def _extract_from_node(self, node: ast.stmt) -> list[ConstantInfo]:
44
- """Extract constants from a single AST node."""
45
- if isinstance(node, ast.Assign):
46
- return self._extract_from_assign(node)
47
- if isinstance(node, ast.AnnAssign):
48
- return self._extract_from_ann_assign(node)
29
+ def extract_python_constants(content: str) -> list[ConstantInfo]:
30
+ """Extract constants from Python source code.
31
+
32
+ Args:
33
+ content: Python source code as string
34
+
35
+ Returns:
36
+ List of ConstantInfo for module-level constants
37
+ """
38
+ try:
39
+ tree = ast.parse(content)
40
+ except SyntaxError:
41
+ return []
42
+ constants: list[ConstantInfo] = []
43
+ for node in tree.body:
44
+ constants.extend(_extract_from_node(node))
45
+ return constants
46
+
47
+
48
+ def _extract_from_node(node: ast.stmt) -> list[ConstantInfo]:
49
+ """Extract constants from a single AST node."""
50
+ if isinstance(node, ast.Assign):
51
+ return _extract_from_assign(node)
52
+ if isinstance(node, ast.AnnAssign):
53
+ return _extract_from_ann_assign(node)
54
+ return []
55
+
56
+
57
+ def _extract_from_assign(node: ast.Assign) -> list[ConstantInfo]:
58
+ """Extract constants from a simple assignment."""
59
+ return [info for t in node.targets if (info := _to_const_info(t, node.value, node.lineno))]
60
+
61
+
62
+ def _extract_from_ann_assign(node: ast.AnnAssign) -> list[ConstantInfo]:
63
+ """Extract constants from an annotated assignment."""
64
+ if node.value is None:
49
65
  return []
66
+ info = _to_const_info(node.target, node.value, node.lineno)
67
+ return [info] if info else []
50
68
 
51
- def _extract_from_assign(self, node: ast.Assign) -> list[ConstantInfo]:
52
- """Extract constants from a simple assignment."""
53
- return [
54
- info for t in node.targets if (info := self._to_const_info(t, node.value, node.lineno))
55
- ]
56
-
57
- def _extract_from_ann_assign(self, node: ast.AnnAssign) -> list[ConstantInfo]:
58
- """Extract constants from an annotated assignment."""
59
- if node.value is None:
60
- return []
61
- info = self._to_const_info(node.target, node.value, node.lineno)
62
- return [info] if info else []
63
-
64
- def _to_const_info(self, target: ast.expr, value: ast.expr, lineno: int) -> ConstantInfo | None:
65
- """Extract constant info from target and value."""
66
- if not isinstance(target, ast.Name):
67
- return None
68
- name = target.id
69
- if not _is_constant_name(name):
70
- return None
71
- return ConstantInfo(name=name, line_number=lineno, value=_get_value_string(value))
69
+
70
+ def _to_const_info(target: ast.expr, value: ast.expr, lineno: int) -> ConstantInfo | None:
71
+ """Extract constant info from target and value."""
72
+ if not isinstance(target, ast.Name):
73
+ return None
74
+ name = target.id
75
+ if not _is_constant_name(name):
76
+ return None
77
+ return ConstantInfo(name=name, line_number=lineno, value=_get_value_string(value))
72
78
 
73
79
 
74
80
  def _is_constant_name(name: str) -> bool:
@@ -78,8 +84,8 @@ def _is_constant_name(name: str) -> bool:
78
84
 
79
85
  def _get_value_string(value: ast.expr) -> str | None:
80
86
  """Get string representation of a value expression."""
81
- if isinstance(value, (ast.Constant, ast.Num, ast.Str)):
82
- return _literal_repr(value)
87
+ if isinstance(value, ast.Constant):
88
+ return repr(value.value)
83
89
  if isinstance(value, ast.Name):
84
90
  return value.id
85
91
  if isinstance(value, ast.Call):
@@ -87,13 +93,6 @@ def _get_value_string(value: ast.expr) -> str | None:
87
93
  return CONTAINER_REPRESENTATIONS.get(type(value))
88
94
 
89
95
 
90
- def _literal_repr(node: ast.expr) -> str:
91
- """Get repr of a literal node."""
92
- if isinstance(node, ast.Constant):
93
- return repr(node.value)
94
- return repr(getattr(node, "n", None) or getattr(node, "s", None))
95
-
96
-
97
96
  def _call_to_string(node: ast.Call) -> str:
98
97
  """Convert call expression to string."""
99
98
  if isinstance(node.func, ast.Name):
@@ -18,6 +18,12 @@ Interfaces: SingleStatementDetector.is_single_statement(content, start_line, end
18
18
 
19
19
  Implementation: AST walking with line-to-node index optimization for performance
20
20
 
21
+ Suppressions:
22
+ - type:ignore[attr-defined]: Tree-sitter Node.text attribute access (optional dependency)
23
+ - type:ignore[operator]: Tree-sitter Node comparison operations (optional dependency)
24
+ - too-many-arguments,too-many-positional-arguments: Builder pattern with related params
25
+ - srp.violation: Complex AST analysis algorithm for single-statement detection. See SRP Exception below.
26
+
21
27
  SRP Exception: SingleStatementDetector has 33 methods and 308 lines (exceeds max 8 methods/200 lines)
22
28
  Justification: Complex AST analysis algorithm for single-statement pattern detection with sophisticated
23
29
  false positive filtering. Methods form tightly coupled algorithm pipeline: class field detection,
@@ -157,17 +163,13 @@ class SingleStatementDetector: # thailint: ignore[srp.violation]
157
163
  self, nodes: set[ast.AST], start_line: int, end_line: int
158
164
  ) -> bool:
159
165
  """Check if any node matches single-statement pattern."""
160
- for node in nodes:
161
- if self._is_single_statement_pattern(node, start_line, end_line):
162
- return True
163
- return False
166
+ return any(self._is_single_statement_pattern(node, start_line, end_line) for node in nodes)
164
167
 
165
168
  def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
166
169
  """Check nodes using ast.walk() fallback."""
167
- for node in ast.walk(tree):
168
- if self._node_matches_via_walk(node, start_line, end_line):
169
- return True
170
- return False
170
+ return any(
171
+ self._node_matches_via_walk(node, start_line, end_line) for node in ast.walk(tree)
172
+ )
171
173
 
172
174
  def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
173
175
  """Check if a single node overlaps and matches pattern."""
@@ -368,10 +370,10 @@ class SingleStatementDetector: # thailint: ignore[srp.violation]
368
370
 
369
371
  def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
370
372
  """Check if any function or class in the tree has decorators."""
371
- for stmt in tree.body:
372
- if isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list:
373
- return True
374
- return False
373
+ return any(
374
+ isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list
375
+ for stmt in tree.body
376
+ )
375
377
 
376
378
  return self.check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)
377
379