redup 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {redup-0.3.2 → redup-0.3.3}/PKG-INFO +2 -2
- {redup-0.3.2 → redup-0.3.3}/README.md +1 -1
- {redup-0.3.2 → redup-0.3.3}/pyproject.toml +1 -1
- {redup-0.3.2 → redup-0.3.3}/src/redup/__init__.py +1 -1
- {redup-0.3.2 → redup-0.3.3}/src/redup/cli_app/main.py +14 -6
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/hasher.py +18 -14
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/parallel_scanner.py +76 -27
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/pipeline.py +32 -15
- redup-0.3.3/src/redup/core/ts_extractor.py +757 -0
- redup-0.3.3/src/redup/core/utils/__init__.py +1 -0
- redup-0.3.3/src/redup/core/utils/duplicate_finders.py +36 -0
- redup-0.3.3/src/redup/core/utils/function_extractor.py +143 -0
- redup-0.3.3/src/redup/core/utils/hash_utils.py +37 -0
- redup-0.3.3/src/redup/reporters/enhanced_reporter.py +274 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/PKG-INFO +2 -2
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/SOURCES.txt +5 -0
- redup-0.3.2/src/redup/core/ts_extractor.py +0 -493
- {redup-0.3.2 → redup-0.3.3}/LICENSE +0 -0
- {redup-0.3.2 → redup-0.3.3}/setup.cfg +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/__main__.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/__init__.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/config.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/differ.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/lsh_matcher.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/matcher.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/models.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/planner.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/core/scanner.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/reporters/__init__.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/reporters/markdown_reporter.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/entry_points.txt +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/requires.txt +0 -0
- {redup-0.3.2 → redup-0.3.3}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_e2e.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_hasher.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_matcher.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_models.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_pipeline.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_planner.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_reporters.py +0 -0
- {redup-0.3.2 → redup-0.3.3}/tests/test_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redup
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Code duplication analyzer and refactoring planner for LLMs
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -75,7 +75,7 @@ Dynamic: license-file
|
|
|
75
75
|
[](https://pypi.org/project/redup/)
|
|
76
76
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
77
77
|
[](https://python.org)
|
|
78
|
-
[](https://pypi.org/project/redup/)
|
|
79
79
|
|
|
80
80
|
reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
|
|
81
81
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://pypi.org/project/redup/)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://python.org)
|
|
8
|
-
[](https://pypi.org/project/redup/)
|
|
9
9
|
|
|
10
10
|
reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
|
|
11
11
|
|
|
@@ -20,6 +20,7 @@ from redup.reporters.json_reporter import to_json # noqa: E402
|
|
|
20
20
|
from redup.reporters.markdown_reporter import to_markdown # noqa: E402
|
|
21
21
|
from redup.reporters.toon_reporter import to_toon # noqa: E402
|
|
22
22
|
from redup.reporters.yaml_reporter import to_yaml # noqa: E402
|
|
23
|
+
from redup.reporters.enhanced_reporter import EnhancedReporter # noqa: E402
|
|
23
24
|
|
|
24
25
|
app = typer.Typer(
|
|
25
26
|
name="redup",
|
|
@@ -28,7 +29,7 @@ app = typer.Typer(
|
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
OutputFormat = Literal["json", "yaml", "toon", "markdown", "all"]
|
|
32
|
+
OutputFormat = Literal["json", "yaml", "toon", "markdown", "all", "enhanced"]
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
DEFAULT_PATH = Path(".")
|
|
@@ -92,12 +93,14 @@ def _build_config_with_file_support(
|
|
|
92
93
|
|
|
93
94
|
def _print_scan_header(
|
|
94
95
|
path: Path,
|
|
95
|
-
|
|
96
|
+
ext_list: list[str],
|
|
97
|
+
min_lines: int,
|
|
98
|
+
min_similarity: float,
|
|
96
99
|
) -> None:
|
|
97
100
|
"""Print scan configuration header."""
|
|
98
101
|
typer.echo(f"reDUP v{redup.__version__} — scanning {path.resolve()}")
|
|
99
|
-
typer.echo(f" extensions: {', '.join(
|
|
100
|
-
typer.echo(f" min_lines: {
|
|
102
|
+
typer.echo(f" extensions: {', '.join(ext_list)}")
|
|
103
|
+
typer.echo(f" min_lines: {min_lines}, min_similarity: {min_similarity}")
|
|
101
104
|
typer.echo("")
|
|
102
105
|
|
|
103
106
|
|
|
@@ -149,6 +152,11 @@ def _write_results(
|
|
|
149
152
|
_write_output(to_toon(dup_map), output_dir, "toon")
|
|
150
153
|
elif format == "markdown":
|
|
151
154
|
_write_output(to_markdown(dup_map), output_dir, "md")
|
|
155
|
+
elif format == "enhanced":
|
|
156
|
+
reporter = EnhancedReporter(dup_map)
|
|
157
|
+
target = output_dir if output_dir and output_dir.suffix else output_dir / "duplication.enhanced.json"
|
|
158
|
+
reporter.save_enhanced_report(target)
|
|
159
|
+
typer.echo(f" → {target}")
|
|
152
160
|
|
|
153
161
|
|
|
154
162
|
@app.command()
|
|
@@ -163,7 +171,7 @@ def scan(
|
|
|
163
171
|
format: str = typer.Option(
|
|
164
172
|
"toon",
|
|
165
173
|
"--format", "-f",
|
|
166
|
-
help="Output format (json, yaml, toon, markdown, all).",
|
|
174
|
+
help="Output format (json, yaml, toon, markdown, all, enhanced).",
|
|
167
175
|
),
|
|
168
176
|
output: Path | None = typer.Option(
|
|
169
177
|
DEFAULT_OUTPUT,
|
|
@@ -209,7 +217,7 @@ def scan(
|
|
|
209
217
|
"""Scan a project for code duplicates and generate a refactoring map."""
|
|
210
218
|
config = _build_config_with_file_support(path, extensions, min_lines, min_similarity, include_tests)
|
|
211
219
|
|
|
212
|
-
_print_scan_header(path, config)
|
|
220
|
+
_print_scan_header(path, config.extensions, config.min_block_lines, config.min_similarity)
|
|
213
221
|
|
|
214
222
|
# Choose analysis method
|
|
215
223
|
if parallel:
|
|
@@ -149,14 +149,28 @@ def _hash_text(text: str, normalizer: Callable[[str], str]) -> str:
|
|
|
149
149
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def hash_block(text: str) -> str:
|
|
152
|
+
def hash_block(text: str) -> str:
|
|
153
153
|
"""SHA-256 hash of normalized text."""
|
|
154
|
-
|
|
154
|
+
from .utils.hash_utils import hash_block as _hash_block
|
|
155
|
+
return _hash_block(text)
|
|
155
156
|
|
|
156
157
|
|
|
157
|
-
def hash_block_structural(text: str) -> str:
|
|
158
|
+
def hash_block_structural(text: str) -> str:
|
|
158
159
|
"""SHA-256 hash of deeply normalized text (variable names replaced)."""
|
|
159
|
-
|
|
160
|
+
from .utils.hash_utils import hash_block_structural as _hash_block_structural
|
|
161
|
+
return _hash_block_structural(text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
|
|
165
|
+
"""Find groups of blocks with identical normalized text."""
|
|
166
|
+
from .utils.duplicate_finders import find_exact_duplicates as _find_exact_duplicates
|
|
167
|
+
return _find_exact_duplicates(index)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
|
|
171
|
+
"""Find groups of blocks with identical structure (names may differ)."""
|
|
172
|
+
from .utils.duplicate_finders import find_structural_duplicates as _find_structural_duplicates
|
|
173
|
+
return _find_structural_duplicates(index)
|
|
160
174
|
|
|
161
175
|
|
|
162
176
|
def _hashed_block(block: CodeBlock) -> HashedBlock:
|
|
@@ -213,16 +227,6 @@ def _find_duplicates(hash_dict: dict[str, list[HashedBlock]]) -> dict[str, list[
|
|
|
213
227
|
}
|
|
214
228
|
|
|
215
229
|
|
|
216
|
-
def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
|
|
217
|
-
"""Find groups of blocks with identical normalized text."""
|
|
218
|
-
return _find_duplicates(index.exact)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
|
|
222
|
-
"""Find groups of blocks with identical structure (names may differ)."""
|
|
223
|
-
return _find_duplicates(index.structural)
|
|
224
|
-
|
|
225
|
-
|
|
226
230
|
def _blocks_from_different_locations(blocks: list[HashedBlock]) -> bool:
|
|
227
231
|
"""Check that at least two blocks are from different file:line locations."""
|
|
228
232
|
locations = {(b.block.file, b.block.line_start) for b in blocks}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
6
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
|
+
import time
|
|
10
|
+
from functools import lru_cache
|
|
9
11
|
|
|
10
12
|
from redup.core.scanner import CodeBlock, ScannedFile, scan_project
|
|
11
13
|
from redup.core.ts_extractor import extract_functions_treesitter
|
|
@@ -73,8 +75,11 @@ def _should_process_file(
|
|
|
73
75
|
return False
|
|
74
76
|
|
|
75
77
|
# Check if test file (and whether to include)
|
|
76
|
-
if not include_tests
|
|
77
|
-
|
|
78
|
+
if not include_tests:
|
|
79
|
+
relative_path = file_path.relative_to(project_root)
|
|
80
|
+
test_parts = frozenset(('test', 'tests', 'testing'))
|
|
81
|
+
if _is_test_file_cached(str(relative_path), test_parts):
|
|
82
|
+
return False
|
|
78
83
|
|
|
79
84
|
return True
|
|
80
85
|
|
|
@@ -105,7 +110,8 @@ def scan_project_parallel(
|
|
|
105
110
|
function_level_only: bool = False,
|
|
106
111
|
max_file_size: int = 1024,
|
|
107
112
|
max_workers: int | None = None,
|
|
108
|
-
min_files_for_parallel: int = 10
|
|
113
|
+
min_files_for_parallel: int = 10,
|
|
114
|
+
use_threading: bool = False
|
|
109
115
|
) -> tuple[list[ScannedFile], Any]:
|
|
110
116
|
"""Scan project files in parallel for better performance on large projects."""
|
|
111
117
|
from redup.core.models import ScanStats
|
|
@@ -113,27 +119,8 @@ def scan_project_parallel(
|
|
|
113
119
|
if max_workers is None:
|
|
114
120
|
max_workers = min(os.cpu_count() or 4, 8) # Cap at 8 workers
|
|
115
121
|
|
|
116
|
-
# Find all files to scan
|
|
117
|
-
all_files = []
|
|
118
|
-
exclude_patterns = exclude_patterns or []
|
|
119
|
-
|
|
120
|
-
for file_path in root.rglob('*'):
|
|
121
|
-
if not file_path.is_file():
|
|
122
|
-
continue
|
|
123
|
-
|
|
124
|
-
# Check exclude patterns
|
|
125
|
-
relative_path = file_path.relative_to(root)
|
|
126
|
-
should_exclude = False
|
|
127
|
-
|
|
128
|
-
for pattern in exclude_patterns:
|
|
129
|
-
if pattern in str(relative_path) or relative_path.match(pattern):
|
|
130
|
-
should_exclude = True
|
|
131
|
-
break
|
|
132
|
-
|
|
133
|
-
if should_exclude:
|
|
134
|
-
continue
|
|
135
|
-
|
|
136
|
-
all_files.append(file_path)
|
|
122
|
+
# Find all files to scan (optimized)
|
|
123
|
+
all_files = _find_files_optimized(root, extensions, exclude_patterns or [])
|
|
137
124
|
|
|
138
125
|
# Use parallel scanning only if we have enough files
|
|
139
126
|
if len(all_files) < min_files_for_parallel:
|
|
@@ -147,11 +134,14 @@ def scan_project_parallel(
|
|
|
147
134
|
]
|
|
148
135
|
|
|
149
136
|
# Scan files in parallel
|
|
137
|
+
start_time = time.time()
|
|
150
138
|
scanned_files = []
|
|
151
139
|
total_blocks = 0
|
|
152
140
|
total_lines = 0
|
|
153
141
|
|
|
154
|
-
|
|
142
|
+
executor_class = ThreadPoolExecutor if use_threading else ProcessPoolExecutor
|
|
143
|
+
|
|
144
|
+
with executor_class(max_workers=max_workers) as executor:
|
|
155
145
|
# Submit all jobs
|
|
156
146
|
future_to_file = {
|
|
157
147
|
executor.submit(_scan_file_worker, work_item): work_item[0]
|
|
@@ -174,12 +164,71 @@ def scan_project_parallel(
|
|
|
174
164
|
file_path = future_to_file[future]
|
|
175
165
|
print(f"Warning: Failed to scan {file_path}")
|
|
176
166
|
|
|
167
|
+
scan_time = (time.time() - start_time) * 1000
|
|
168
|
+
|
|
177
169
|
# Create stats
|
|
178
170
|
stats = ScanStats(
|
|
179
171
|
files_scanned=len(scanned_files),
|
|
180
172
|
total_lines=total_lines,
|
|
181
173
|
total_blocks=total_blocks,
|
|
182
|
-
scan_time_ms=
|
|
174
|
+
scan_time_ms=scan_time,
|
|
183
175
|
)
|
|
184
176
|
|
|
185
177
|
return scanned_files, stats
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_files_optimized(
|
|
181
|
+
root: Path,
|
|
182
|
+
extensions: set[str],
|
|
183
|
+
exclude_patterns: list[str]
|
|
184
|
+
) -> list[Path]:
|
|
185
|
+
"""Optimized file discovery using set lookups and compiled patterns."""
|
|
186
|
+
extensions_set = set(extensions) if not isinstance(extensions, set) else extensions
|
|
187
|
+
|
|
188
|
+
# Pre-compile exclude patterns for better performance
|
|
189
|
+
import fnmatch
|
|
190
|
+
compiled_patterns = [fnmatch.translate(pattern) for pattern in exclude_patterns]
|
|
191
|
+
import re
|
|
192
|
+
regex_patterns = [re.compile(pattern) for pattern in compiled_patterns]
|
|
193
|
+
|
|
194
|
+
all_files = []
|
|
195
|
+
|
|
196
|
+
for file_path in root.rglob('*'):
|
|
197
|
+
if not file_path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Fast extension check using set
|
|
201
|
+
if file_path.suffix not in extensions_set:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Check exclude patterns
|
|
205
|
+
relative_path = file_path.relative_to(root)
|
|
206
|
+
should_exclude = False
|
|
207
|
+
|
|
208
|
+
for regex_pattern in regex_patterns:
|
|
209
|
+
if regex_pattern.match(str(relative_path)):
|
|
210
|
+
should_exclude = True
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
if not should_exclude:
|
|
214
|
+
all_files.append(file_path)
|
|
215
|
+
|
|
216
|
+
return all_files
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@lru_cache(maxsize=1024)
|
|
220
|
+
def _is_test_file_cached(relative_path: str, test_parts: frozenset) -> bool:
|
|
221
|
+
"""Cached version of test file detection."""
|
|
222
|
+
parts = Path(relative_path).parts
|
|
223
|
+
|
|
224
|
+
# Check if in test directory
|
|
225
|
+
for part in parts:
|
|
226
|
+
if part in test_parts:
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
# Check filename patterns
|
|
230
|
+
filename = Path(relative_path).name
|
|
231
|
+
if filename.startswith('test_') or filename.endswith('_test.py'):
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
return False
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
import itertools
|
|
9
|
+
|
|
5
10
|
from redup.core.hasher import (
|
|
6
11
|
HashedBlock,
|
|
7
12
|
HashIndex,
|
|
@@ -161,13 +166,19 @@ def _process_blocks(
|
|
|
161
166
|
scanned_files: list[ScannedFile],
|
|
162
167
|
function_level_only: bool
|
|
163
168
|
) -> list[CodeBlock]:
|
|
164
|
-
"""Phase 2: Extract and filter code blocks."""
|
|
169
|
+
"""Phase 2: Extract and filter code blocks with memory optimization."""
|
|
165
170
|
all_blocks: list[CodeBlock] = []
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
|
|
172
|
+
# Batch process files to reduce memory overhead
|
|
173
|
+
batch_size = 100
|
|
174
|
+
for i in range(0, len(scanned_files), batch_size):
|
|
175
|
+
batch = scanned_files[i:i + batch_size]
|
|
176
|
+
for sf in batch:
|
|
177
|
+
for block in sf.blocks:
|
|
178
|
+
if function_level_only and block.function_name is None:
|
|
179
|
+
continue
|
|
180
|
+
all_blocks.append(block)
|
|
181
|
+
|
|
171
182
|
return all_blocks
|
|
172
183
|
|
|
173
184
|
|
|
@@ -175,19 +186,25 @@ def _find_duplicates_phase(
|
|
|
175
186
|
all_blocks: list[CodeBlock],
|
|
176
187
|
config: ScanConfig
|
|
177
188
|
) -> list[DuplicateGroup]:
|
|
178
|
-
"""Phase 3: Hash and find duplicate groups."""
|
|
189
|
+
"""Phase 3: Hash and find duplicate groups with performance optimizations."""
|
|
190
|
+
start_time = time.time()
|
|
191
|
+
|
|
192
|
+
# Build hash index with progress tracking
|
|
179
193
|
index = build_hash_index(all_blocks, min_lines=config.min_block_lines)
|
|
180
|
-
|
|
181
|
-
# Find
|
|
194
|
+
|
|
195
|
+
# Find duplicates in parallel where possible
|
|
182
196
|
exact_groups = _find_exact_groups(index)
|
|
183
|
-
|
|
184
|
-
# Find structural duplicates
|
|
185
197
|
structural_groups = _find_structural_groups(index, exact_groups)
|
|
186
|
-
|
|
187
|
-
# Find near-duplicates using LSH (for larger blocks)
|
|
188
198
|
near_duplicate_groups = _find_near_duplicate_groups(all_blocks, config)
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
|
|
200
|
+
# Combine and sort by impact
|
|
201
|
+
all_groups = exact_groups + structural_groups + near_duplicate_groups
|
|
202
|
+
all_groups.sort(key=lambda g: g.impact_score, reverse=True)
|
|
203
|
+
|
|
204
|
+
processing_time = (time.time() - start_time) * 1000
|
|
205
|
+
print(f"Duplicate finding completed in {processing_time:.1f}ms")
|
|
206
|
+
|
|
207
|
+
return all_groups
|
|
191
208
|
|
|
192
209
|
|
|
193
210
|
def _find_exact_groups(index: HashIndex) -> list[DuplicateGroup]:
|