redup 0.3.2__tar.gz → 0.3.4__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.
Files changed (46) hide show
  1. {redup-0.3.2 → redup-0.3.4}/PKG-INFO +10 -2
  2. {redup-0.3.2 → redup-0.3.4}/README.md +1 -1
  3. {redup-0.3.2 → redup-0.3.4}/pyproject.toml +9 -1
  4. {redup-0.3.2 → redup-0.3.4}/src/redup/__init__.py +1 -1
  5. {redup-0.3.2 → redup-0.3.4}/src/redup/cli_app/main.py +14 -6
  6. {redup-0.3.2 → redup-0.3.4}/src/redup/core/hasher.py +18 -14
  7. {redup-0.3.2 → redup-0.3.4}/src/redup/core/parallel_scanner.py +76 -27
  8. {redup-0.3.2 → redup-0.3.4}/src/redup/core/pipeline.py +32 -15
  9. redup-0.3.4/src/redup/core/ts_extractor.py +759 -0
  10. redup-0.3.4/src/redup/core/utils/__init__.py +1 -0
  11. redup-0.3.4/src/redup/core/utils/duplicate_finders.py +36 -0
  12. redup-0.3.4/src/redup/core/utils/function_extractor.py +143 -0
  13. redup-0.3.4/src/redup/core/utils/hash_utils.py +37 -0
  14. redup-0.3.4/src/redup/reporters/enhanced_reporter.py +274 -0
  15. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/PKG-INFO +10 -2
  16. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/SOURCES.txt +5 -0
  17. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/requires.txt +8 -0
  18. redup-0.3.2/src/redup/core/ts_extractor.py +0 -493
  19. {redup-0.3.2 → redup-0.3.4}/LICENSE +0 -0
  20. {redup-0.3.2 → redup-0.3.4}/setup.cfg +0 -0
  21. {redup-0.3.2 → redup-0.3.4}/src/redup/__main__.py +0 -0
  22. {redup-0.3.2 → redup-0.3.4}/src/redup/cli_app/__init__.py +0 -0
  23. {redup-0.3.2 → redup-0.3.4}/src/redup/core/__init__.py +0 -0
  24. {redup-0.3.2 → redup-0.3.4}/src/redup/core/config.py +0 -0
  25. {redup-0.3.2 → redup-0.3.4}/src/redup/core/differ.py +0 -0
  26. {redup-0.3.2 → redup-0.3.4}/src/redup/core/lsh_matcher.py +0 -0
  27. {redup-0.3.2 → redup-0.3.4}/src/redup/core/matcher.py +0 -0
  28. {redup-0.3.2 → redup-0.3.4}/src/redup/core/models.py +0 -0
  29. {redup-0.3.2 → redup-0.3.4}/src/redup/core/planner.py +0 -0
  30. {redup-0.3.2 → redup-0.3.4}/src/redup/core/scanner.py +0 -0
  31. {redup-0.3.2 → redup-0.3.4}/src/redup/reporters/__init__.py +0 -0
  32. {redup-0.3.2 → redup-0.3.4}/src/redup/reporters/json_reporter.py +0 -0
  33. {redup-0.3.2 → redup-0.3.4}/src/redup/reporters/markdown_reporter.py +0 -0
  34. {redup-0.3.2 → redup-0.3.4}/src/redup/reporters/toon_reporter.py +0 -0
  35. {redup-0.3.2 → redup-0.3.4}/src/redup/reporters/yaml_reporter.py +0 -0
  36. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/dependency_links.txt +0 -0
  37. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/entry_points.txt +0 -0
  38. {redup-0.3.2 → redup-0.3.4}/src/redup.egg-info/top_level.txt +0 -0
  39. {redup-0.3.2 → redup-0.3.4}/tests/test_e2e.py +0 -0
  40. {redup-0.3.2 → redup-0.3.4}/tests/test_hasher.py +0 -0
  41. {redup-0.3.2 → redup-0.3.4}/tests/test_matcher.py +0 -0
  42. {redup-0.3.2 → redup-0.3.4}/tests/test_models.py +0 -0
  43. {redup-0.3.2 → redup-0.3.4}/tests/test_pipeline.py +0 -0
  44. {redup-0.3.2 → redup-0.3.4}/tests/test_planner.py +0 -0
  45. {redup-0.3.2 → redup-0.3.4}/tests/test_reporters.py +0 -0
  46. {redup-0.3.2 → redup-0.3.4}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -59,6 +59,14 @@ Requires-Dist: tree-sitter-kotlin>=0.20; extra == "ast"
59
59
  Requires-Dist: tree-sitter-swift>=0.20; extra == "ast"
60
60
  Requires-Dist: tree-sitter-objc>=0.20; extra == "ast"
61
61
  Requires-Dist: tree-sitter-c-sharp>=0.20; extra == "ast"
62
+ Requires-Dist: tree-sitter-lua>=0.20; extra == "ast"
63
+ Requires-Dist: tree-sitter-graphql>=0.20; extra == "ast"
64
+ Requires-Dist: tree-sitter-dockerfile>=0.20; extra == "ast"
65
+ Requires-Dist: tree-sitter-make>=0.20; extra == "ast"
66
+ Requires-Dist: tree-sitter-vim>=0.20; extra == "ast"
67
+ Requires-Dist: tree-sitter-nginx>=0.20; extra == "ast"
68
+ Requires-Dist: tree-sitter-svelte>=0.20; extra == "ast"
69
+ Requires-Dist: tree-sitter-vue>=0.20; extra == "ast"
62
70
  Provides-Extra: lsh
63
71
  Requires-Dist: datasketch>=1.6; extra == "lsh"
64
72
  Provides-Extra: dev
@@ -75,7 +83,7 @@ Dynamic: license-file
75
83
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
76
84
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
77
85
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
78
- [![Version](https://img.shields.io/badge/version-0.3.2-green.svg)](https://pypi.org/project/redup/)
86
+ [![Version](https://img.shields.io/badge/version-0.3.4-green.svg)](https://pypi.org/project/redup/)
79
87
 
80
88
  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
89
 
@@ -5,7 +5,7 @@
5
5
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
8
- [![Version](https://img.shields.io/badge/version-0.3.2-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.3.4-green.svg)](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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redup"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Code duplication analyzer and refactoring planner for LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -66,6 +66,14 @@ ast = [
66
66
  "tree-sitter-swift>=0.20",
67
67
  "tree-sitter-objc>=0.20",
68
68
  "tree-sitter-c-sharp>=0.20",
69
+ "tree-sitter-lua>=0.20",
70
+ "tree-sitter-graphql>=0.20",
71
+ "tree-sitter-dockerfile>=0.20",
72
+ "tree-sitter-make>=0.20",
73
+ "tree-sitter-vim>=0.20",
74
+ "tree-sitter-nginx>=0.20",
75
+ "tree-sitter-svelte>=0.20",
76
+ "tree-sitter-vue>=0.20",
69
77
  ]
70
78
  lsh = ["datasketch>=1.6"]
71
79
  dev = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.2"
5
+ __version__ = "0.3.4"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -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
- config: ScanConfig,
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(config.extensions)}")
100
- typer.echo(f" min_lines: {config.min_block_lines}, min_similarity: {config.min_similarity}")
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: # nodup: accepted thin API wrapper
152
+ def hash_block(text: str) -> str:
153
153
  """SHA-256 hash of normalized text."""
154
- return _hash_text(text, _normalize_text)
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: # nodup: accepted thin API wrapper
158
+ def hash_block_structural(text: str) -> str:
158
159
  """SHA-256 hash of deeply normalized text (variable names replaced)."""
159
- return _hash_text(text, _normalize_ast_text)
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 and _is_test_file(file_path, project_root):
77
- return False
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
- with ProcessPoolExecutor(max_workers=max_workers) as executor:
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=0.0, # Will be updated by caller
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
- for sf in scanned_files:
167
- for block in sf.blocks:
168
- if function_level_only and block.function_name is None:
169
- continue
170
- all_blocks.append(block)
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 exact duplicates
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
- return exact_groups + structural_groups + near_duplicate_groups
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]: