thailint 0.5.0__py3-none-any.whl → 0.15.3__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/__init__.py +1 -0
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +30 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +480 -0
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +67 -0
- src/cli/linters/code_patterns.py +270 -0
- src/cli/linters/code_smells.py +342 -0
- src/cli/linters/documentation.py +83 -0
- src/cli/linters/performance.py +287 -0
- src/cli/linters/shared.py +331 -0
- src/cli/linters/structure.py +327 -0
- src/cli/linters/structure_quality.py +328 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +37 -0
- src/config.py +38 -25
- src/core/base.py +7 -2
- src/core/cli_utils.py +19 -2
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +95 -6
- src/core/python_lint_rule.py +101 -0
- src/core/registry.py +1 -1
- src/core/rule_discovery.py +147 -84
- src/core/types.py +13 -0
- src/core/violation_builder.py +78 -15
- src/core/violation_utils.py +69 -0
- src/formatters/__init__.py +22 -0
- src/formatters/sarif.py +202 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +254 -395
- src/linter_config/loader.py +45 -12
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +75 -0
- src/linters/collection_pipeline/continue_analyzer.py +94 -0
- src/linters/collection_pipeline/detector.py +360 -0
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +420 -0
- src/linters/collection_pipeline/suggestion_builder.py +130 -0
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +120 -20
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache.py +104 -10
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/config.py +54 -11
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +223 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/duplicate_storage.py +5 -4
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +183 -48
- src/linters/dry/python_analyzer.py +60 -439
- src/linters/dry/python_constant_extractor.py +100 -0
- src/linters/dry/single_statement_detector.py +417 -0
- src/linters/dry/token_hasher.py +116 -112
- src/linters/dry/typescript_analyzer.py +68 -382
- src/linters/dry/typescript_constant_extractor.py +138 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +70 -0
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +5 -4
- src/linters/dry/violation_generator.py +71 -14
- src/linters/file_header/atemporal_detector.py +68 -50
- src/linters/file_header/base_parser.py +93 -0
- src/linters/file_header/bash_parser.py +66 -0
- src/linters/file_header/config.py +90 -16
- src/linters/file_header/css_parser.py +70 -0
- src/linters/file_header/field_validator.py +36 -33
- src/linters/file_header/linter.py +140 -144
- src/linters/file_header/markdown_parser.py +130 -0
- src/linters/file_header/python_parser.py +14 -58
- src/linters/file_header/typescript_parser.py +73 -0
- src/linters/file_header/violation_builder.py +13 -12
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/linter.py +66 -34
- src/linters/file_placement/pattern_matcher.py +41 -6
- src/linters/file_placement/pattern_validator.py +31 -12
- src/linters/file_placement/rule_checker.py +12 -7
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +74 -0
- src/linters/lazy_ignores/directive_utils.py +164 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +168 -0
- src/linters/lazy_ignores/python_analyzer.py +209 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +71 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +135 -0
- src/linters/lbyl/__init__.py +31 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +53 -0
- src/linters/lbyl/pattern_detectors/base.py +63 -0
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/magic_numbers/context_analyzer.py +227 -225
- src/linters/magic_numbers/linter.py +28 -82
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -12
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +138 -0
- src/linters/method_property/linter.py +414 -0
- src/linters/method_property/python_analyzer.py +473 -0
- src/linters/method_property/violation_builder.py +119 -0
- src/linters/nesting/linter.py +24 -16
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/violation_builder.py +1 -0
- src/linters/performance/__init__.py +91 -0
- src/linters/performance/config.py +43 -0
- src/linters/performance/constants.py +49 -0
- src/linters/performance/linter.py +149 -0
- src/linters/performance/python_analyzer.py +365 -0
- src/linters/performance/regex_analyzer.py +312 -0
- src/linters/performance/regex_linter.py +139 -0
- src/linters/performance/typescript_analyzer.py +236 -0
- src/linters/performance/violation_builder.py +160 -0
- src/linters/print_statements/config.py +7 -12
- src/linters/print_statements/linter.py +26 -43
- src/linters/print_statements/python_analyzer.py +91 -93
- src/linters/print_statements/typescript_analyzer.py +15 -25
- src/linters/print_statements/violation_builder.py +12 -14
- src/linters/srp/class_analyzer.py +11 -7
- src/linters/srp/heuristics.py +56 -22
- src/linters/srp/linter.py +15 -16
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +110 -50
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +349 -0
- src/linters/stateless_class/python_analyzer.py +290 -0
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +189 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +135 -0
- src/linters/stringly_typed/ignore_checker.py +100 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +376 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +348 -0
- src/linters/stringly_typed/python/call_tracker.py +175 -0
- src/linters/stringly_typed/python/comparison_tracker.py +257 -0
- src/linters/stringly_typed/python/condition_extractor.py +134 -0
- src/linters/stringly_typed/python/conditional_detector.py +179 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +94 -0
- src/linters/stringly_typed/python/validation_detector.py +189 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +620 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +335 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
- src/linters/stringly_typed/violation_generator.py +419 -0
- src/orchestrator/core.py +252 -14
- src/orchestrator/language_detector.py +5 -3
- src/templates/thailint_config_template.yaml +196 -0
- src/utils/project_root.py +3 -0
- thailint-0.15.3.dist-info/METADATA +187 -0
- thailint-0.15.3.dist-info/RECORD +226 -0
- thailint-0.15.3.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -1665
- thailint-0.5.0.dist-info/METADATA +0 -1286
- thailint-0.5.0.dist-info/RECORD +0 -96
- thailint-0.5.0.dist-info/entry_points.txt +0 -4
- {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
- {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,
|
|
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,
|
|
@@ -29,22 +35,14 @@ SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max
|
|
|
29
35
|
"""
|
|
30
36
|
|
|
31
37
|
import ast
|
|
32
|
-
from collections.abc import Callable
|
|
33
38
|
from pathlib import Path
|
|
34
|
-
from typing import cast
|
|
35
39
|
|
|
40
|
+
from . import token_hasher
|
|
36
41
|
from .base_token_analyzer import BaseTokenAnalyzer
|
|
37
42
|
from .block_filter import BlockFilterRegistry, create_default_registry
|
|
38
43
|
from .cache import CodeBlock
|
|
39
44
|
from .config import DRYConfig
|
|
40
|
-
|
|
41
|
-
# AST context checking constants
|
|
42
|
-
AST_LOOKBACK_LINES = 10
|
|
43
|
-
AST_LOOKFORWARD_LINES = 5
|
|
44
|
-
|
|
45
|
-
# Type alias for AST nodes that have line number attributes
|
|
46
|
-
# All stmt and expr nodes have lineno and end_lineno after parsing
|
|
47
|
-
ASTWithLineNumbers = ast.stmt | ast.expr
|
|
45
|
+
from .single_statement_detector import SingleStatementDetector
|
|
48
46
|
|
|
49
47
|
|
|
50
48
|
class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violation]
|
|
@@ -62,11 +60,8 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
62
60
|
"""
|
|
63
61
|
super().__init__()
|
|
64
62
|
self._filter_registry = filter_registry or create_default_registry()
|
|
65
|
-
#
|
|
66
|
-
self.
|
|
67
|
-
self._cached_content: str | None = None
|
|
68
|
-
# Performance optimization: Line-to-node index for O(1) lookups instead of O(n) ast.walk()
|
|
69
|
-
self._line_to_nodes: dict[int, list[ast.AST]] | None = None
|
|
63
|
+
# Single-statement detector is created per-analysis with cached AST data
|
|
64
|
+
self._statement_detector: SingleStatementDetector | None = None
|
|
70
65
|
|
|
71
66
|
def analyze( # thailint: ignore[nesting.excessive-depth]
|
|
72
67
|
self, file_path: Path, content: str, config: DRYConfig
|
|
@@ -81,12 +76,10 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
81
76
|
Returns:
|
|
82
77
|
List of CodeBlock instances with hash values
|
|
83
78
|
"""
|
|
84
|
-
# Performance optimization: Parse AST once and
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# Performance optimization: Build line-to-node index for O(1) lookups
|
|
89
|
-
self._line_to_nodes = self._build_line_to_node_index(self._cached_ast)
|
|
79
|
+
# Performance optimization: Parse AST once and create detector with cached data
|
|
80
|
+
cached_ast = self._parse_content_safe(content)
|
|
81
|
+
line_to_nodes = SingleStatementDetector.build_line_to_node_index(cached_ast)
|
|
82
|
+
self._statement_detector = SingleStatementDetector(cached_ast, content, line_to_nodes)
|
|
90
83
|
|
|
91
84
|
try:
|
|
92
85
|
# Get docstring line ranges
|
|
@@ -102,10 +95,8 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
102
95
|
|
|
103
96
|
return self._filter_valid_blocks(windows, file_path, content)
|
|
104
97
|
finally:
|
|
105
|
-
# Clear
|
|
106
|
-
self.
|
|
107
|
-
self._cached_content = None
|
|
108
|
-
self._line_to_nodes = None
|
|
98
|
+
# Clear detector after analysis to avoid memory leaks
|
|
99
|
+
self._statement_detector = None
|
|
109
100
|
|
|
110
101
|
def _filter_valid_blocks(
|
|
111
102
|
self,
|
|
@@ -114,14 +105,15 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
114
105
|
content: str,
|
|
115
106
|
) -> list[CodeBlock]:
|
|
116
107
|
"""Filter hash windows and create valid CodeBlock instances."""
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
)
|
|
121
115
|
)
|
|
122
|
-
|
|
123
|
-
blocks.append(block)
|
|
124
|
-
return blocks
|
|
116
|
+
]
|
|
125
117
|
|
|
126
118
|
def _create_block_if_valid( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
127
119
|
self,
|
|
@@ -133,7 +125,9 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
133
125
|
snippet: str,
|
|
134
126
|
) -> CodeBlock | None:
|
|
135
127
|
"""Create CodeBlock if it passes all validation checks."""
|
|
136
|
-
if self.
|
|
128
|
+
if self._statement_detector and self._statement_detector.is_single_statement(
|
|
129
|
+
content, start_line, end_line
|
|
130
|
+
):
|
|
137
131
|
return None
|
|
138
132
|
|
|
139
133
|
block = CodeBlock(
|
|
@@ -217,25 +211,43 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
217
211
|
lines_with_numbers = []
|
|
218
212
|
in_multiline_import = False
|
|
219
213
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
# Update multi-line import state and check if line should be skipped
|
|
229
|
-
in_multiline_import, should_skip = self._hasher._should_skip_import_line( # pylint: disable=protected-access
|
|
214
|
+
non_docstring_lines = (
|
|
215
|
+
(line_num, line)
|
|
216
|
+
for line_num, line in enumerate(content.split("\n"), start=1)
|
|
217
|
+
if line_num not in docstring_lines
|
|
218
|
+
)
|
|
219
|
+
for line_num, line in non_docstring_lines:
|
|
220
|
+
in_multiline_import, normalized = self._normalize_and_filter_line(
|
|
230
221
|
line, in_multiline_import
|
|
231
222
|
)
|
|
232
|
-
if
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
lines_with_numbers.append((line_num, line))
|
|
223
|
+
if normalized is not None:
|
|
224
|
+
lines_with_numbers.append((line_num, normalized))
|
|
236
225
|
|
|
237
226
|
return lines_with_numbers
|
|
238
227
|
|
|
228
|
+
def _normalize_and_filter_line(
|
|
229
|
+
self, line: str, in_multiline_import: bool
|
|
230
|
+
) -> tuple[bool, str | None]:
|
|
231
|
+
"""Normalize line and check if it should be included.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
line: Raw source line
|
|
235
|
+
in_multiline_import: Current multi-line import state
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Tuple of (new_import_state, normalized_line or None if should skip)
|
|
239
|
+
"""
|
|
240
|
+
normalized = token_hasher.normalize_line(line)
|
|
241
|
+
if not normalized:
|
|
242
|
+
return in_multiline_import, None
|
|
243
|
+
|
|
244
|
+
new_state, should_skip = token_hasher.should_skip_import_line(
|
|
245
|
+
normalized, in_multiline_import
|
|
246
|
+
)
|
|
247
|
+
if should_skip:
|
|
248
|
+
return new_state, None
|
|
249
|
+
return new_state, normalized
|
|
250
|
+
|
|
239
251
|
def _rolling_hash_with_tracking(
|
|
240
252
|
self, lines_with_numbers: list[tuple[int, str]], window_size: int
|
|
241
253
|
) -> list[tuple[int, int, int, str]]:
|
|
@@ -268,24 +280,6 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
268
280
|
|
|
269
281
|
return hashes
|
|
270
282
|
|
|
271
|
-
def _is_single_statement_in_source(self, content: str, start_line: int, end_line: int) -> bool:
|
|
272
|
-
"""Check if a line range in the original source is a single logical statement.
|
|
273
|
-
|
|
274
|
-
Performance optimization: Uses cached AST if available (set by analyze() method)
|
|
275
|
-
to avoid re-parsing the entire file for each hash window check.
|
|
276
|
-
"""
|
|
277
|
-
# Use cached AST if available and content matches
|
|
278
|
-
tree: ast.Module | None
|
|
279
|
-
if self._cached_ast is not None and content == self._cached_content:
|
|
280
|
-
tree = self._cached_ast
|
|
281
|
-
else:
|
|
282
|
-
# Fallback: parse content (used by tests or standalone calls)
|
|
283
|
-
tree = self._parse_content_safe(content)
|
|
284
|
-
if tree is None:
|
|
285
|
-
return False
|
|
286
|
-
|
|
287
|
-
return self._check_overlapping_nodes(tree, start_line, end_line)
|
|
288
|
-
|
|
289
283
|
@staticmethod
|
|
290
284
|
def _parse_content_safe(content: str) -> ast.Module | None:
|
|
291
285
|
"""Parse content, returning None on syntax error."""
|
|
@@ -293,376 +287,3 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
|
|
|
293
287
|
return ast.parse(content)
|
|
294
288
|
except SyntaxError:
|
|
295
289
|
return None
|
|
296
|
-
|
|
297
|
-
@staticmethod
|
|
298
|
-
def _build_line_to_node_index(tree: ast.Module | None) -> dict[int, list[ast.AST]] | None:
|
|
299
|
-
"""Build an index mapping each line number to overlapping AST nodes.
|
|
300
|
-
|
|
301
|
-
Performance optimization: This allows O(1) lookups instead of O(n) ast.walk() calls.
|
|
302
|
-
For a file with 5,144 nodes and 673 hash windows, this reduces 3.46M node operations
|
|
303
|
-
to just ~3,365 relevant node checks (99.9% reduction).
|
|
304
|
-
|
|
305
|
-
Args:
|
|
306
|
-
tree: Parsed AST tree (None if parsing failed)
|
|
307
|
-
|
|
308
|
-
Returns:
|
|
309
|
-
Dictionary mapping line numbers to list of AST nodes overlapping that line,
|
|
310
|
-
or None if tree is None
|
|
311
|
-
"""
|
|
312
|
-
if tree is None:
|
|
313
|
-
return None
|
|
314
|
-
|
|
315
|
-
line_to_nodes: dict[int, list[ast.AST]] = {}
|
|
316
|
-
for node in ast.walk(tree):
|
|
317
|
-
if PythonDuplicateAnalyzer._node_has_line_info(node):
|
|
318
|
-
PythonDuplicateAnalyzer._add_node_to_index(node, line_to_nodes)
|
|
319
|
-
|
|
320
|
-
return line_to_nodes
|
|
321
|
-
|
|
322
|
-
@staticmethod
|
|
323
|
-
def _node_has_line_info(node: ast.AST) -> bool:
|
|
324
|
-
"""Check if node has valid line number information."""
|
|
325
|
-
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
|
326
|
-
return False
|
|
327
|
-
return node.lineno is not None and node.end_lineno is not None
|
|
328
|
-
|
|
329
|
-
@staticmethod
|
|
330
|
-
def _add_node_to_index(node: ast.AST, line_to_nodes: dict[int, list[ast.AST]]) -> None:
|
|
331
|
-
"""Add node to all lines it overlaps in the index."""
|
|
332
|
-
for line_num in range(node.lineno, node.end_lineno + 1): # type: ignore[attr-defined]
|
|
333
|
-
if line_num not in line_to_nodes:
|
|
334
|
-
line_to_nodes[line_num] = []
|
|
335
|
-
line_to_nodes[line_num].append(node)
|
|
336
|
-
|
|
337
|
-
def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
|
|
338
|
-
"""Check if any AST node overlaps and matches single-statement pattern.
|
|
339
|
-
|
|
340
|
-
Performance optimization: Use line-to-node index for O(1) lookups instead of O(n) ast.walk().
|
|
341
|
-
"""
|
|
342
|
-
if self._line_to_nodes is not None:
|
|
343
|
-
return self._check_nodes_via_index(start_line, end_line)
|
|
344
|
-
return self._check_nodes_via_walk(tree, start_line, end_line)
|
|
345
|
-
|
|
346
|
-
def _check_nodes_via_index(self, start_line: int, end_line: int) -> bool:
|
|
347
|
-
"""Check nodes using line-to-node index for O(1) lookups."""
|
|
348
|
-
candidates = self._collect_candidate_nodes_from_index(start_line, end_line)
|
|
349
|
-
return self._any_node_matches_pattern(candidates, start_line, end_line)
|
|
350
|
-
|
|
351
|
-
def _collect_candidate_nodes_from_index(self, start_line: int, end_line: int) -> set[ast.AST]:
|
|
352
|
-
"""Collect unique nodes that overlap with the line range from index."""
|
|
353
|
-
candidate_nodes: set[ast.AST] = set()
|
|
354
|
-
for line_num in range(start_line, end_line + 1):
|
|
355
|
-
if self._line_to_nodes and line_num in self._line_to_nodes:
|
|
356
|
-
candidate_nodes.update(self._line_to_nodes[line_num])
|
|
357
|
-
return candidate_nodes
|
|
358
|
-
|
|
359
|
-
def _any_node_matches_pattern(
|
|
360
|
-
self, nodes: set[ast.AST], start_line: int, end_line: int
|
|
361
|
-
) -> bool:
|
|
362
|
-
"""Check if any node matches single-statement pattern."""
|
|
363
|
-
for node in nodes:
|
|
364
|
-
if self._is_single_statement_pattern(node, start_line, end_line):
|
|
365
|
-
return True
|
|
366
|
-
return False
|
|
367
|
-
|
|
368
|
-
def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
|
|
369
|
-
"""Check nodes using ast.walk() fallback for tests or standalone calls."""
|
|
370
|
-
for node in ast.walk(tree):
|
|
371
|
-
if self._node_matches_via_walk(node, start_line, end_line):
|
|
372
|
-
return True
|
|
373
|
-
return False
|
|
374
|
-
|
|
375
|
-
def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
|
|
376
|
-
"""Check if a single node overlaps and matches pattern."""
|
|
377
|
-
if not self._node_overlaps_range(node, start_line, end_line):
|
|
378
|
-
return False
|
|
379
|
-
return self._is_single_statement_pattern(node, start_line, end_line)
|
|
380
|
-
|
|
381
|
-
@staticmethod
|
|
382
|
-
def _node_overlaps_range(node: ast.AST, start_line: int, end_line: int) -> bool:
|
|
383
|
-
"""Check if node overlaps with the given line range."""
|
|
384
|
-
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
|
385
|
-
return False
|
|
386
|
-
node_end = node.end_lineno
|
|
387
|
-
node_start = node.lineno
|
|
388
|
-
return not (node_end < start_line or node_start > end_line)
|
|
389
|
-
|
|
390
|
-
def _node_overlaps_and_matches(self, node: ast.AST, start_line: int, end_line: int) -> bool:
|
|
391
|
-
"""Check if node overlaps with range and matches single-statement pattern."""
|
|
392
|
-
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
|
393
|
-
return False
|
|
394
|
-
|
|
395
|
-
overlaps = not (node.end_lineno < start_line or node.lineno > end_line)
|
|
396
|
-
if not overlaps:
|
|
397
|
-
return False
|
|
398
|
-
|
|
399
|
-
return self._is_single_statement_pattern(node, start_line, end_line)
|
|
400
|
-
|
|
401
|
-
def _is_single_statement_pattern(self, node: ast.AST, start_line: int, end_line: int) -> bool:
|
|
402
|
-
"""Check if an AST node represents a single-statement pattern to filter.
|
|
403
|
-
|
|
404
|
-
Args:
|
|
405
|
-
node: AST node that overlaps with the line range
|
|
406
|
-
start_line: Starting line number (1-indexed)
|
|
407
|
-
end_line: Ending line number (1-indexed)
|
|
408
|
-
|
|
409
|
-
Returns:
|
|
410
|
-
True if this node represents a single logical statement pattern
|
|
411
|
-
"""
|
|
412
|
-
contains = self._node_contains_range(node, start_line, end_line)
|
|
413
|
-
if contains is None:
|
|
414
|
-
return False
|
|
415
|
-
|
|
416
|
-
return self._dispatch_pattern_check(node, start_line, end_line, contains)
|
|
417
|
-
|
|
418
|
-
def _node_contains_range(self, node: ast.AST, start_line: int, end_line: int) -> bool | None:
|
|
419
|
-
"""Check if node completely contains the range. Returns None if invalid."""
|
|
420
|
-
if not self._has_valid_line_numbers(node):
|
|
421
|
-
return None
|
|
422
|
-
# Type narrowing: _has_valid_line_numbers ensures node has line numbers
|
|
423
|
-
# Safe to cast after validation check above
|
|
424
|
-
typed_node = cast(ASTWithLineNumbers, node)
|
|
425
|
-
# Use type: ignore to suppress MyPy's inability to understand runtime validation
|
|
426
|
-
return typed_node.lineno <= start_line and typed_node.end_lineno >= end_line # type: ignore[operator]
|
|
427
|
-
|
|
428
|
-
@staticmethod
|
|
429
|
-
def _has_valid_line_numbers(node: ast.AST) -> bool:
|
|
430
|
-
"""Check if node has valid line number attributes."""
|
|
431
|
-
if not (hasattr(node, "lineno") and hasattr(node, "end_lineno")):
|
|
432
|
-
return False
|
|
433
|
-
return node.lineno is not None and node.end_lineno is not None
|
|
434
|
-
|
|
435
|
-
def _dispatch_pattern_check(
|
|
436
|
-
self, node: ast.AST, start_line: int, end_line: int, contains: bool
|
|
437
|
-
) -> bool:
|
|
438
|
-
"""Dispatch to node-type-specific pattern checkers."""
|
|
439
|
-
# Simple containment check for Expr nodes
|
|
440
|
-
if isinstance(node, ast.Expr):
|
|
441
|
-
return contains
|
|
442
|
-
|
|
443
|
-
# Delegate to specialized checkers
|
|
444
|
-
return self._check_specific_pattern(node, start_line, end_line, contains)
|
|
445
|
-
|
|
446
|
-
def _check_specific_pattern(
|
|
447
|
-
self, node: ast.AST, start_line: int, end_line: int, contains: bool
|
|
448
|
-
) -> bool:
|
|
449
|
-
"""Check specific node types with their pattern rules."""
|
|
450
|
-
if isinstance(node, ast.ClassDef):
|
|
451
|
-
return self._check_class_def_pattern(node, start_line, end_line)
|
|
452
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
453
|
-
return self._check_function_def_pattern(node, start_line, end_line)
|
|
454
|
-
if isinstance(node, ast.Call):
|
|
455
|
-
return self._check_call_pattern(node, start_line, end_line, contains)
|
|
456
|
-
if isinstance(node, ast.Assign):
|
|
457
|
-
return self._check_assign_pattern(node, start_line, end_line, contains)
|
|
458
|
-
return False
|
|
459
|
-
|
|
460
|
-
def _check_class_def_pattern(self, node: ast.ClassDef, start_line: int, end_line: int) -> bool:
|
|
461
|
-
"""Check if range is in class field definitions (not method bodies)."""
|
|
462
|
-
first_method_line = self._find_first_method_line(node)
|
|
463
|
-
class_start = self._get_class_start_with_decorators(node)
|
|
464
|
-
return self._is_in_class_fields_area(
|
|
465
|
-
class_start, start_line, end_line, first_method_line, node.end_lineno
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
@staticmethod
|
|
469
|
-
def _find_first_method_line(node: ast.ClassDef) -> int | None:
|
|
470
|
-
"""Find line number of first method in class."""
|
|
471
|
-
for item in node.body:
|
|
472
|
-
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
473
|
-
return item.lineno
|
|
474
|
-
return None
|
|
475
|
-
|
|
476
|
-
@staticmethod
|
|
477
|
-
def _get_class_start_with_decorators(node: ast.ClassDef) -> int:
|
|
478
|
-
"""Get class start line, including decorators if present."""
|
|
479
|
-
if node.decorator_list:
|
|
480
|
-
return min(d.lineno for d in node.decorator_list)
|
|
481
|
-
return node.lineno
|
|
482
|
-
|
|
483
|
-
@staticmethod
|
|
484
|
-
def _is_in_class_fields_area(
|
|
485
|
-
class_start: int,
|
|
486
|
-
start_line: int,
|
|
487
|
-
end_line: int,
|
|
488
|
-
first_method_line: int | None,
|
|
489
|
-
class_end_line: int | None,
|
|
490
|
-
) -> bool:
|
|
491
|
-
"""Check if range is in class fields area (before methods)."""
|
|
492
|
-
if first_method_line is not None:
|
|
493
|
-
return class_start <= start_line and end_line < first_method_line
|
|
494
|
-
if class_end_line is not None:
|
|
495
|
-
return class_start <= start_line and class_end_line >= end_line
|
|
496
|
-
return False
|
|
497
|
-
|
|
498
|
-
def _check_function_def_pattern(
|
|
499
|
-
self, node: ast.FunctionDef | ast.AsyncFunctionDef, start_line: int, end_line: int
|
|
500
|
-
) -> bool:
|
|
501
|
-
"""Check if range is in function decorator pattern."""
|
|
502
|
-
if not node.decorator_list:
|
|
503
|
-
return False
|
|
504
|
-
|
|
505
|
-
first_decorator_line = min(d.lineno for d in node.decorator_list)
|
|
506
|
-
first_body_line = self._get_function_body_start(node)
|
|
507
|
-
|
|
508
|
-
if first_body_line is None:
|
|
509
|
-
return False
|
|
510
|
-
|
|
511
|
-
return start_line >= first_decorator_line and end_line < first_body_line
|
|
512
|
-
|
|
513
|
-
@staticmethod
|
|
514
|
-
def _get_function_body_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int | None:
|
|
515
|
-
"""Get the line number where function body starts."""
|
|
516
|
-
if not node.body or not hasattr(node.body[0], "lineno"):
|
|
517
|
-
return None
|
|
518
|
-
return node.body[0].lineno
|
|
519
|
-
|
|
520
|
-
def _check_call_pattern(
|
|
521
|
-
self, node: ast.Call, start_line: int, end_line: int, contains: bool
|
|
522
|
-
) -> bool:
|
|
523
|
-
"""Check if range is part of a function/constructor call."""
|
|
524
|
-
return self._check_multiline_or_contained(node, start_line, end_line, contains)
|
|
525
|
-
|
|
526
|
-
def _check_assign_pattern(
|
|
527
|
-
self, node: ast.Assign, start_line: int, end_line: int, contains: bool
|
|
528
|
-
) -> bool:
|
|
529
|
-
"""Check if range is part of a multi-line assignment."""
|
|
530
|
-
return self._check_multiline_or_contained(node, start_line, end_line, contains)
|
|
531
|
-
|
|
532
|
-
def _check_multiline_or_contained(
|
|
533
|
-
self, node: ast.AST, start_line: int, end_line: int, contains: bool
|
|
534
|
-
) -> bool:
|
|
535
|
-
"""Check if node is multiline containing start, or single-line containing range."""
|
|
536
|
-
if not self._has_valid_line_numbers(node):
|
|
537
|
-
return False
|
|
538
|
-
|
|
539
|
-
# Type narrowing: _has_valid_line_numbers ensures node has line numbers
|
|
540
|
-
# Safe to cast after validation check above
|
|
541
|
-
typed_node = cast(ASTWithLineNumbers, node)
|
|
542
|
-
# Use type: ignore to suppress MyPy's inability to understand runtime validation
|
|
543
|
-
is_multiline = typed_node.lineno < typed_node.end_lineno # type: ignore[operator]
|
|
544
|
-
if is_multiline:
|
|
545
|
-
return typed_node.lineno <= start_line <= typed_node.end_lineno # type: ignore[operator]
|
|
546
|
-
return contains
|
|
547
|
-
|
|
548
|
-
def _is_standalone_single_statement(
|
|
549
|
-
self, lines: list[str], start_line: int, end_line: int
|
|
550
|
-
) -> bool:
|
|
551
|
-
"""Check if the exact range parses as a single statement on its own."""
|
|
552
|
-
source_lines = lines[start_line - 1 : end_line]
|
|
553
|
-
source_snippet = "\n".join(source_lines)
|
|
554
|
-
|
|
555
|
-
try:
|
|
556
|
-
tree = ast.parse(source_snippet)
|
|
557
|
-
return len(tree.body) == 1
|
|
558
|
-
except SyntaxError:
|
|
559
|
-
return False
|
|
560
|
-
|
|
561
|
-
def _check_ast_context( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
562
|
-
self,
|
|
563
|
-
lines: list[str],
|
|
564
|
-
start_line: int,
|
|
565
|
-
end_line: int,
|
|
566
|
-
lookback: int,
|
|
567
|
-
lookforward: int,
|
|
568
|
-
predicate: Callable[[ast.Module, int], bool],
|
|
569
|
-
) -> bool:
|
|
570
|
-
"""Generic helper for AST-based context checking.
|
|
571
|
-
|
|
572
|
-
Args:
|
|
573
|
-
lines: Source file lines
|
|
574
|
-
start_line: Starting line number (1-indexed)
|
|
575
|
-
end_line: Ending line number (1-indexed)
|
|
576
|
-
lookback: Number of lines to look backward
|
|
577
|
-
lookforward: Number of lines to look forward
|
|
578
|
-
predicate: Function that takes AST tree and returns bool
|
|
579
|
-
|
|
580
|
-
Returns:
|
|
581
|
-
True if predicate returns True for the parsed context
|
|
582
|
-
"""
|
|
583
|
-
lookback_start = max(0, start_line - lookback)
|
|
584
|
-
lookforward_end = min(len(lines), end_line + lookforward)
|
|
585
|
-
|
|
586
|
-
context_lines = lines[lookback_start:lookforward_end]
|
|
587
|
-
context = "\n".join(context_lines)
|
|
588
|
-
|
|
589
|
-
try:
|
|
590
|
-
tree = ast.parse(context)
|
|
591
|
-
return predicate(tree, lookback_start)
|
|
592
|
-
except SyntaxError:
|
|
593
|
-
pass
|
|
594
|
-
|
|
595
|
-
return False
|
|
596
|
-
|
|
597
|
-
def _is_part_of_decorator(self, lines: list[str], start_line: int, end_line: int) -> bool:
|
|
598
|
-
"""Check if lines are part of a decorator + function definition.
|
|
599
|
-
|
|
600
|
-
A decorator pattern is @something(...) followed by def/class.
|
|
601
|
-
"""
|
|
602
|
-
|
|
603
|
-
def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
|
|
604
|
-
"""Check if any function or class in the tree has decorators."""
|
|
605
|
-
for stmt in tree.body:
|
|
606
|
-
if isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list:
|
|
607
|
-
return True
|
|
608
|
-
return False
|
|
609
|
-
|
|
610
|
-
return self._check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)
|
|
611
|
-
|
|
612
|
-
def _is_part_of_function_call(self, lines: list[str], start_line: int, end_line: int) -> bool:
|
|
613
|
-
"""Check if lines are arguments inside a function/constructor call.
|
|
614
|
-
|
|
615
|
-
Detects patterns like:
|
|
616
|
-
obj = Constructor(
|
|
617
|
-
arg1=value1,
|
|
618
|
-
arg2=value2,
|
|
619
|
-
)
|
|
620
|
-
"""
|
|
621
|
-
|
|
622
|
-
def is_single_non_function_statement(tree: ast.Module, _lookback_start: int) -> bool:
|
|
623
|
-
"""Check if context has exactly one statement that's not a function/class def."""
|
|
624
|
-
return len(tree.body) == 1 and not isinstance(
|
|
625
|
-
tree.body[0], (ast.FunctionDef, ast.ClassDef)
|
|
626
|
-
)
|
|
627
|
-
|
|
628
|
-
return self._check_ast_context(
|
|
629
|
-
lines, start_line, end_line, 10, 10, is_single_non_function_statement
|
|
630
|
-
)
|
|
631
|
-
|
|
632
|
-
def _is_part_of_class_body(self, lines: list[str], start_line: int, end_line: int) -> bool:
|
|
633
|
-
"""Check if lines are field definitions inside a class body.
|
|
634
|
-
|
|
635
|
-
Detects patterns like:
|
|
636
|
-
class Foo:
|
|
637
|
-
field1: Type1
|
|
638
|
-
field2: Type2
|
|
639
|
-
"""
|
|
640
|
-
|
|
641
|
-
def is_within_class_body(tree: ast.Module, lookback_start: int) -> bool:
|
|
642
|
-
"""Check if flagged range falls within a class body."""
|
|
643
|
-
for stmt in tree.body:
|
|
644
|
-
if not isinstance(stmt, ast.ClassDef):
|
|
645
|
-
continue
|
|
646
|
-
|
|
647
|
-
# Adjust line numbers: stmt.lineno is relative to context
|
|
648
|
-
# We need to convert back to original file line numbers
|
|
649
|
-
class_start_in_context = stmt.lineno
|
|
650
|
-
class_end_in_context = stmt.end_lineno if stmt.end_lineno else stmt.lineno
|
|
651
|
-
|
|
652
|
-
# Convert to original file line numbers (1-indexed)
|
|
653
|
-
class_start_original = lookback_start + class_start_in_context
|
|
654
|
-
class_end_original = lookback_start + class_end_in_context
|
|
655
|
-
|
|
656
|
-
# Check if the flagged range overlaps with class body
|
|
657
|
-
if start_line >= class_start_original and end_line <= class_end_original:
|
|
658
|
-
return True
|
|
659
|
-
return False
|
|
660
|
-
|
|
661
|
-
return self._check_ast_context(
|
|
662
|
-
lines,
|
|
663
|
-
start_line,
|
|
664
|
-
end_line,
|
|
665
|
-
AST_LOOKBACK_LINES,
|
|
666
|
-
AST_LOOKFORWARD_LINES,
|
|
667
|
-
is_within_class_body,
|
|
668
|
-
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Extract Python module-level constants using AST parsing
|
|
3
|
+
|
|
4
|
+
Scope: Python constant extraction for duplicate constants detection
|
|
5
|
+
|
|
6
|
+
Overview: Extracts module-level constant definitions from Python source code using the AST module.
|
|
7
|
+
Identifies constants as module-level assignments where the target name matches the ALL_CAPS
|
|
8
|
+
naming convention (e.g., API_TIMEOUT = 30). Excludes private constants (leading underscore),
|
|
9
|
+
class-level constants, and function-level constants to focus on public module constants that
|
|
10
|
+
should be consolidated across files.
|
|
11
|
+
|
|
12
|
+
Dependencies: Python ast module, re for pattern matching, ConstantInfo from constant module
|
|
13
|
+
|
|
14
|
+
Exports: extract_python_constants function
|
|
15
|
+
|
|
16
|
+
Interfaces: extract_python_constants(content: str) -> list[ConstantInfo]
|
|
17
|
+
|
|
18
|
+
Implementation: AST-based parsing with module-level filtering and ALL_CAPS regex matching
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
|
|
23
|
+
from .constant import CONSTANT_NAME_PATTERN, ConstantInfo
|
|
24
|
+
|
|
25
|
+
# Container types with fixed representations
|
|
26
|
+
CONTAINER_REPRESENTATIONS = {ast.List: "[...]", ast.Dict: "{...}", ast.Tuple: "(...)"}
|
|
27
|
+
|
|
28
|
+
|
|
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:
|
|
65
|
+
return []
|
|
66
|
+
info = _to_const_info(node.target, node.value, node.lineno)
|
|
67
|
+
return [info] if info else []
|
|
68
|
+
|
|
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))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_constant_name(name: str) -> bool:
|
|
81
|
+
"""Check if name matches constant naming convention."""
|
|
82
|
+
return not name.startswith("_") and bool(CONSTANT_NAME_PATTERN.match(name))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_value_string(value: ast.expr) -> str | None:
|
|
86
|
+
"""Get string representation of a value expression."""
|
|
87
|
+
if isinstance(value, ast.Constant):
|
|
88
|
+
return repr(value.value)
|
|
89
|
+
if isinstance(value, ast.Name):
|
|
90
|
+
return value.id
|
|
91
|
+
if isinstance(value, ast.Call):
|
|
92
|
+
return _call_to_string(value)
|
|
93
|
+
return CONTAINER_REPRESENTATIONS.get(type(value))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _call_to_string(node: ast.Call) -> str:
|
|
97
|
+
"""Convert call expression to string."""
|
|
98
|
+
if isinstance(node.func, ast.Name):
|
|
99
|
+
return f"{node.func.id}(...)"
|
|
100
|
+
return "call(...)"
|