code-towel 1.0.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.
- code_towel-1.0.0.dist-info/METADATA +722 -0
- code_towel-1.0.0.dist-info/RECORD +27 -0
- code_towel-1.0.0.dist-info/WHEEL +5 -0
- code_towel-1.0.0.dist-info/entry_points.txt +4 -0
- code_towel-1.0.0.dist-info/licenses/LICENSE +201 -0
- code_towel-1.0.0.dist-info/top_level.txt +1 -0
- towel/__init__.py +20 -0
- towel/cli.py +813 -0
- towel/unification/__init__.py +24 -0
- towel/unification/assignment_analyzer.py +403 -0
- towel/unification/ast_normalizer.py +223 -0
- towel/unification/ast_pretty_printer.py +133 -0
- towel/unification/binding_detector.py +381 -0
- towel/unification/block_signature.py +123 -0
- towel/unification/builtins.py +209 -0
- towel/unification/exceptions.py +113 -0
- towel/unification/extractor.py +884 -0
- towel/unification/models.py +190 -0
- towel/unification/nominal_unifier.py +375 -0
- towel/unification/orphan_detector.py +187 -0
- towel/unification/pipeline.py +524 -0
- towel/unification/project_layout.py +201 -0
- towel/unification/refactor_engine.py +3381 -0
- towel/unification/scope_analyzer.py +597 -0
- towel/unification/unifier.py +2128 -0
- towel/unification/visitor_utils.py +83 -0
- towel/unification/visitors.py +377 -0
towel/cli.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Command-line interface for Towel.
|
|
4
|
+
|
|
5
|
+
This module provides CLI entry points for the Towel code refactoring tool.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import argparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Tuple, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""Main entry point with subcommands."""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="code-towel",
|
|
20
|
+
description="A Python tool that DRYs your code - finds and refactors repeated code",
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog="""
|
|
23
|
+
Examples:
|
|
24
|
+
code-towel preview src/ # Preview duplicates (read-only)
|
|
25
|
+
code-towel dry src/ cleaned/ # Apply refactorings to new directory
|
|
26
|
+
code-towel dry src/ src/ # Apply refactorings in-place
|
|
27
|
+
code-towel rename-helpers # Rename extracted functions with LLM assistance
|
|
28
|
+
|
|
29
|
+
For more help on a specific command:
|
|
30
|
+
code-towel <command> --help
|
|
31
|
+
""",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--version",
|
|
36
|
+
action="version",
|
|
37
|
+
version="towel 1.0.0",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
41
|
+
|
|
42
|
+
# Add subcommand parsers
|
|
43
|
+
_add_dry_parser(subparsers)
|
|
44
|
+
_add_preview_parser(subparsers)
|
|
45
|
+
_add_rename_helpers_parser(subparsers)
|
|
46
|
+
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
if not args.command:
|
|
50
|
+
parser.print_help()
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
# Dispatch to appropriate handler
|
|
54
|
+
if args.command == "dry":
|
|
55
|
+
_run_dry(args)
|
|
56
|
+
elif args.command == "preview":
|
|
57
|
+
_run_preview(args)
|
|
58
|
+
elif args.command == "rename-helpers":
|
|
59
|
+
_run_rename_helpers(args)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _add_dry_parser(subparsers) -> None:
|
|
63
|
+
"""Add 'dry' subcommand parser."""
|
|
64
|
+
parser = subparsers.add_parser(
|
|
65
|
+
"dry",
|
|
66
|
+
help="Apply refactorings to remove duplicate code",
|
|
67
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
68
|
+
description="Analyze and apply unification-based refactorings to file(s) or directories.",
|
|
69
|
+
epilog="""
|
|
70
|
+
Examples:
|
|
71
|
+
code-towel dry src/ cleaned/ # Refactor directory until fixed point
|
|
72
|
+
code-towel dry file.py file_out.py --non-interactive # Non-interactive single file
|
|
73
|
+
code-towel dry src/ out/ --max-iterations 50 # Cap at 50 applied refactorings
|
|
74
|
+
code-towel dry src/ out/ --progress detail # Verbose per-phase output
|
|
75
|
+
""",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
parser.add_argument("input", help="Input file or directory")
|
|
79
|
+
parser.add_argument("output", help="Output file or directory (can be same as input)")
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--non-interactive",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Run without interactive confirmation (skip prompt)",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
pref_group = parser.add_mutually_exclusive_group()
|
|
87
|
+
pref_group.add_argument(
|
|
88
|
+
"--prefer-absolute-imports",
|
|
89
|
+
dest="prefer_absolute_imports",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Prefer absolute imports for cross-file extractions when possible",
|
|
92
|
+
)
|
|
93
|
+
pref_group.add_argument(
|
|
94
|
+
"--no-prefer-absolute-imports",
|
|
95
|
+
dest="prefer_absolute_imports",
|
|
96
|
+
action="store_false",
|
|
97
|
+
help="Prefer local/same-dir imports when possible",
|
|
98
|
+
)
|
|
99
|
+
parser.set_defaults(prefer_absolute_imports=None)
|
|
100
|
+
|
|
101
|
+
pep_group = parser.add_mutually_exclusive_group()
|
|
102
|
+
pep_group.add_argument(
|
|
103
|
+
"--pep420",
|
|
104
|
+
dest="pep420",
|
|
105
|
+
action="store_true",
|
|
106
|
+
help="Treat directories as namespace packages (PEP 420) when deriving module paths",
|
|
107
|
+
)
|
|
108
|
+
pep_group.add_argument(
|
|
109
|
+
"--no-pep420",
|
|
110
|
+
dest="pep420",
|
|
111
|
+
action="store_false",
|
|
112
|
+
help="Require __init__.py for packages when deriving module paths",
|
|
113
|
+
)
|
|
114
|
+
parser.set_defaults(pep420=None)
|
|
115
|
+
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--max-iterations",
|
|
118
|
+
type=int,
|
|
119
|
+
default=0,
|
|
120
|
+
help="Maximum refactorings to apply (0 = unlimited until fixed point, default: 0)",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--progress",
|
|
125
|
+
choices=["auto", "tqdm", "none", "detail"],
|
|
126
|
+
default="tqdm",
|
|
127
|
+
help="Progress display mode: 'tqdm' shows bars, 'auto' falls back if tqdm unavailable, "
|
|
128
|
+
"'none' disables output, 'detail' prints per-phase summaries.",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _add_preview_parser(subparsers) -> None:
|
|
133
|
+
"""Add 'preview' subcommand parser."""
|
|
134
|
+
parser = subparsers.add_parser(
|
|
135
|
+
"preview",
|
|
136
|
+
help="Preview duplicate code detection (read-only)",
|
|
137
|
+
description="Preview unification-based refactoring opportunities without modifying files.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
parser.add_argument("target", help="File or directory to analyze")
|
|
141
|
+
|
|
142
|
+
pref_group = parser.add_mutually_exclusive_group()
|
|
143
|
+
pref_group.add_argument(
|
|
144
|
+
"--prefer-absolute-imports",
|
|
145
|
+
dest="prefer_absolute_imports",
|
|
146
|
+
action="store_true",
|
|
147
|
+
help="Prefer absolute imports for cross-file extractions when possible",
|
|
148
|
+
)
|
|
149
|
+
pref_group.add_argument(
|
|
150
|
+
"--no-prefer-absolute-imports",
|
|
151
|
+
dest="prefer_absolute_imports",
|
|
152
|
+
action="store_false",
|
|
153
|
+
help="Prefer local/same-dir imports when possible",
|
|
154
|
+
)
|
|
155
|
+
parser.set_defaults(prefer_absolute_imports=None)
|
|
156
|
+
|
|
157
|
+
pep_group = parser.add_mutually_exclusive_group()
|
|
158
|
+
pep_group.add_argument(
|
|
159
|
+
"--pep420",
|
|
160
|
+
dest="pep420",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="Treat directories as namespace packages (PEP 420) when deriving module paths",
|
|
163
|
+
)
|
|
164
|
+
pep_group.add_argument(
|
|
165
|
+
"--no-pep420",
|
|
166
|
+
dest="pep420",
|
|
167
|
+
action="store_false",
|
|
168
|
+
help="Require __init__.py for packages when deriving module paths",
|
|
169
|
+
)
|
|
170
|
+
parser.set_defaults(pep420=None)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _add_rename_helpers_parser(subparsers) -> None:
|
|
174
|
+
"""Add 'rename-helpers' subcommand parser."""
|
|
175
|
+
parser = subparsers.add_parser(
|
|
176
|
+
"rename-helpers",
|
|
177
|
+
help="Rename extracted functions using LLM assistance",
|
|
178
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
179
|
+
description="""
|
|
180
|
+
Rename auto-generated __extracted_func_* functions into meaningful names.
|
|
181
|
+
|
|
182
|
+
This tool analyzes the extracted functions and uses an LLM to suggest better names
|
|
183
|
+
based on the code's purpose and context. You can use Claude Code, ChatGPT, or any
|
|
184
|
+
other AI coding assistant to generate the names.
|
|
185
|
+
|
|
186
|
+
The tool works in two modes:
|
|
187
|
+
|
|
188
|
+
1. INTERACTIVE MODE (default):
|
|
189
|
+
Generates a prompt for an LLM to review your code and suggest new names.
|
|
190
|
+
You paste the LLM's response back, and the tool applies the renamings.
|
|
191
|
+
|
|
192
|
+
2. FILE MODE (--rename-file):
|
|
193
|
+
Provide a JSON file with old_name -> new_name mappings.
|
|
194
|
+
The tool applies these renamings across your entire codebase.
|
|
195
|
+
""",
|
|
196
|
+
epilog="""
|
|
197
|
+
Examples:
|
|
198
|
+
# Interactive mode: generate LLM prompt and apply suggestions
|
|
199
|
+
code-towel rename-helpers src/
|
|
200
|
+
|
|
201
|
+
# List all extracted helpers
|
|
202
|
+
code-towel rename-helpers src/ --list
|
|
203
|
+
|
|
204
|
+
# Apply renamings from a JSON file
|
|
205
|
+
code-towel rename-helpers src/ --rename-file renames.json
|
|
206
|
+
|
|
207
|
+
# Dry run (preview only)
|
|
208
|
+
code-towel rename-helpers src/ --dry-run
|
|
209
|
+
|
|
210
|
+
# Specify specific files or functions
|
|
211
|
+
code-towel rename-helpers src/ --file mymodule.py
|
|
212
|
+
code-towel rename-helpers src/ --function __extracted_func_7
|
|
213
|
+
""",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"target",
|
|
218
|
+
help="Directory containing code with __extracted_func_* functions",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--list",
|
|
223
|
+
action="store_true",
|
|
224
|
+
help="List all extracted helper functions found",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--dry-run",
|
|
229
|
+
action="store_true",
|
|
230
|
+
help="Preview changes without writing files",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--rename-file",
|
|
235
|
+
type=Path,
|
|
236
|
+
help="JSON file with old_name -> new_name mappings to apply",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"--file",
|
|
241
|
+
action="append",
|
|
242
|
+
dest="files",
|
|
243
|
+
help="Limit processing to specific file(s) (relative path)",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
parser.add_argument(
|
|
247
|
+
"--function",
|
|
248
|
+
action="append",
|
|
249
|
+
dest="functions",
|
|
250
|
+
help="Limit processing to specific function(s) by name",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--llm",
|
|
255
|
+
choices=["claude", "gpt", "copilot", "generic"],
|
|
256
|
+
default="generic",
|
|
257
|
+
help="Format the LLM prompt for a specific AI assistant (default: generic)",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _run_dry(args: argparse.Namespace) -> None:
|
|
262
|
+
"""Run the dry command."""
|
|
263
|
+
# Import here to avoid loading heavy modules if not needed
|
|
264
|
+
import os
|
|
265
|
+
import shutil
|
|
266
|
+
from towel.unification.refactor_engine import UnificationRefactorEngine
|
|
267
|
+
|
|
268
|
+
input_path = args.input
|
|
269
|
+
output_path = args.output
|
|
270
|
+
|
|
271
|
+
# Check if input exists
|
|
272
|
+
if not os.path.exists(input_path):
|
|
273
|
+
print(f"Error: '{input_path}' does not exist")
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
# Determine if it's a file or directory
|
|
277
|
+
is_file = os.path.isfile(input_path)
|
|
278
|
+
is_dir = os.path.isdir(input_path)
|
|
279
|
+
|
|
280
|
+
if not is_file and not is_dir:
|
|
281
|
+
print(f"Error: '{input_path}' is neither a file nor a directory")
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
# Check input file extension for single files
|
|
285
|
+
if is_file and not input_path.endswith(".py"):
|
|
286
|
+
print(f"Warning: '{input_path}' is not a Python file (.py)")
|
|
287
|
+
response = input("Analyze anyway? (y/N): ").strip().lower()
|
|
288
|
+
if response != "y":
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Copy input to output
|
|
292
|
+
abs_input = os.path.abspath(input_path)
|
|
293
|
+
abs_output = os.path.abspath(output_path)
|
|
294
|
+
|
|
295
|
+
if abs_input == abs_output:
|
|
296
|
+
print(f"Modifying in place: {input_path}")
|
|
297
|
+
else:
|
|
298
|
+
print(f"Copying {input_path} -> {output_path}")
|
|
299
|
+
if is_file:
|
|
300
|
+
output_dir = os.path.dirname(output_path)
|
|
301
|
+
if output_dir:
|
|
302
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
303
|
+
shutil.copy2(input_path, output_path)
|
|
304
|
+
else:
|
|
305
|
+
if os.path.exists(output_path):
|
|
306
|
+
shutil.rmtree(output_path)
|
|
307
|
+
shutil.copytree(input_path, output_path)
|
|
308
|
+
print()
|
|
309
|
+
|
|
310
|
+
# Create engine
|
|
311
|
+
engine = UnificationRefactorEngine(
|
|
312
|
+
max_parameters=5,
|
|
313
|
+
min_lines=3,
|
|
314
|
+
parameterize_constants=True,
|
|
315
|
+
prefer_absolute_imports=args.prefer_absolute_imports,
|
|
316
|
+
pep420_namespace_packages=args.pep420,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Use fixed-point iteration
|
|
320
|
+
print("=" * 70)
|
|
321
|
+
print("APPLYING REFACTORINGS (FIXED-POINT ITERATION)")
|
|
322
|
+
print("=" * 70)
|
|
323
|
+
print()
|
|
324
|
+
print("This will apply refactorings one at a time until no more are found.")
|
|
325
|
+
print("Extracted functions will be placed at the end of files.")
|
|
326
|
+
print()
|
|
327
|
+
|
|
328
|
+
if not args.non_interactive:
|
|
329
|
+
response = input("Proceed? (y/N): ").strip().lower()
|
|
330
|
+
if response != "y":
|
|
331
|
+
print("Aborted.")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
print()
|
|
335
|
+
|
|
336
|
+
# Apply refactorings to fixed point
|
|
337
|
+
if is_file:
|
|
338
|
+
print(f"Refactoring file: {output_path}")
|
|
339
|
+
final_code, num_applied, descriptions = engine.refactor_to_fixed_point(
|
|
340
|
+
output_path,
|
|
341
|
+
max_iterations=args.max_iterations,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
with open(output_path, "w") as f:
|
|
345
|
+
f.write(final_code)
|
|
346
|
+
|
|
347
|
+
if num_applied > 0:
|
|
348
|
+
print(f"\n✓ Applied {num_applied} refactoring(s):")
|
|
349
|
+
for i, desc in enumerate(descriptions, 1):
|
|
350
|
+
print(f" {i}. {desc}")
|
|
351
|
+
else:
|
|
352
|
+
print("\nNo refactorings found!")
|
|
353
|
+
else:
|
|
354
|
+
print(f"Refactoring directory: {output_path}")
|
|
355
|
+
results, termination_reason = engine.refactor_directory_to_fixed_point(
|
|
356
|
+
output_path,
|
|
357
|
+
output_path,
|
|
358
|
+
max_iterations=args.max_iterations,
|
|
359
|
+
progress=args.progress,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if results:
|
|
363
|
+
total_refactorings = sum(count for count, _ in results.values())
|
|
364
|
+
print(f"\n✓ Applied {total_refactorings} refactoring(s) across {len(results)} file(s)")
|
|
365
|
+
print(f" Termination: {termination_reason}")
|
|
366
|
+
for file_path, (count, descriptions) in sorted(results.items()):
|
|
367
|
+
print(f"\n {file_path}: {count} refactoring(s)")
|
|
368
|
+
for desc in descriptions[:3]:
|
|
369
|
+
print(f" - {desc}")
|
|
370
|
+
if len(descriptions) > 3:
|
|
371
|
+
print(f" ... and {len(descriptions) - 3} more")
|
|
372
|
+
else:
|
|
373
|
+
print("\nNo refactorings found! Termination: fixed_point")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _run_preview(args: argparse.Namespace) -> None:
|
|
377
|
+
"""Run the preview command."""
|
|
378
|
+
import os
|
|
379
|
+
import ast
|
|
380
|
+
from towel.unification.refactor_engine import (
|
|
381
|
+
UnificationRefactorEngine,
|
|
382
|
+
filter_overlapping_proposals,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
target = args.target
|
|
386
|
+
|
|
387
|
+
# Check if target exists
|
|
388
|
+
if not os.path.exists(target):
|
|
389
|
+
print(f"Error: '{target}' does not exist")
|
|
390
|
+
sys.exit(1)
|
|
391
|
+
|
|
392
|
+
is_file = os.path.isfile(target)
|
|
393
|
+
is_dir = os.path.isdir(target)
|
|
394
|
+
|
|
395
|
+
if not is_file and not is_dir:
|
|
396
|
+
print(f"Error: '{target}' is neither a file nor a directory")
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
|
|
399
|
+
# Create engine
|
|
400
|
+
engine = UnificationRefactorEngine(
|
|
401
|
+
max_parameters=5,
|
|
402
|
+
min_lines=3,
|
|
403
|
+
parameterize_constants=True,
|
|
404
|
+
prefer_absolute_imports=args.prefer_absolute_imports,
|
|
405
|
+
pep420_namespace_packages=args.pep420,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Analyze
|
|
409
|
+
if is_file:
|
|
410
|
+
if not target.endswith(".py"):
|
|
411
|
+
print(f"Note: '{target}' is not a Python file (.py)")
|
|
412
|
+
print()
|
|
413
|
+
|
|
414
|
+
print(f"Analyzing file: {target}")
|
|
415
|
+
all_proposals = engine.analyze_file(target)
|
|
416
|
+
else:
|
|
417
|
+
print(f"Analyzing directory: {target}")
|
|
418
|
+
all_proposals = engine.analyze_directory(
|
|
419
|
+
target, recursive=True, verbose=True, progress="tqdm"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
print(f"\nFound {len(all_proposals)} refactoring opportunities")
|
|
423
|
+
|
|
424
|
+
if not all_proposals:
|
|
425
|
+
print("No duplicates found!")
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
# Filter overlapping proposals
|
|
429
|
+
proposals = filter_overlapping_proposals(all_proposals)
|
|
430
|
+
|
|
431
|
+
if len(proposals) < len(all_proposals):
|
|
432
|
+
print(f"Filtered to {len(proposals)} non-overlapping proposals")
|
|
433
|
+
print(f"(Removed {len(all_proposals) - len(proposals)} overlapping proposals)")
|
|
434
|
+
|
|
435
|
+
print("\n" + "=" * 70)
|
|
436
|
+
print("REFACTORING OPPORTUNITIES")
|
|
437
|
+
print("=" * 70)
|
|
438
|
+
|
|
439
|
+
for i, prop in enumerate(proposals[:10], 1):
|
|
440
|
+
print(f"\n{i}. {prop.description}")
|
|
441
|
+
print(f" Parameters: {prop.parameters_count}")
|
|
442
|
+
|
|
443
|
+
# Show which files are affected
|
|
444
|
+
files_affected = set()
|
|
445
|
+
for item in prop.replacements:
|
|
446
|
+
if len(item) == 3:
|
|
447
|
+
files_affected.add(item[2])
|
|
448
|
+
else:
|
|
449
|
+
files_affected.add(prop.file_path)
|
|
450
|
+
|
|
451
|
+
if len(files_affected) > 1:
|
|
452
|
+
print(f" Type: Cross-file ({len(files_affected)} files)")
|
|
453
|
+
for f in sorted(files_affected):
|
|
454
|
+
print(f" - {f}")
|
|
455
|
+
else:
|
|
456
|
+
print(f" Type: Same file ({prop.file_path})")
|
|
457
|
+
|
|
458
|
+
# Show extracted function preview
|
|
459
|
+
print("\n Extracted function preview:")
|
|
460
|
+
try:
|
|
461
|
+
func_code = ast.unparse(prop.extracted_function)
|
|
462
|
+
lines = func_code.split("\n")
|
|
463
|
+
for line in lines[:8]:
|
|
464
|
+
print(f" {line}")
|
|
465
|
+
if len(lines) > 8:
|
|
466
|
+
print(f" ... ({len(lines) - 8} more lines)")
|
|
467
|
+
except ValueError as e:
|
|
468
|
+
print(f" (Preview unavailable: {e})")
|
|
469
|
+
print(f" Function name: {prop.extracted_function.name}")
|
|
470
|
+
|
|
471
|
+
if len(proposals) > 10:
|
|
472
|
+
print(f"\n... and {len(proposals) - 10} more proposals")
|
|
473
|
+
|
|
474
|
+
print("\n" + "=" * 70)
|
|
475
|
+
print("\nTo apply these refactorings, run:")
|
|
476
|
+
if is_file:
|
|
477
|
+
print(f" code-towel dry {target} <output>")
|
|
478
|
+
else:
|
|
479
|
+
print(f" code-towel dry {target} <output_dir>")
|
|
480
|
+
print()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _run_rename_helpers(args: argparse.Namespace) -> None:
|
|
484
|
+
"""Run the rename-helpers command."""
|
|
485
|
+
target = Path(args.target)
|
|
486
|
+
|
|
487
|
+
if not target.exists():
|
|
488
|
+
print(f"Error: '{target}' does not exist")
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
|
|
491
|
+
if not target.is_dir():
|
|
492
|
+
print(f"Error: '{target}' must be a directory")
|
|
493
|
+
sys.exit(1)
|
|
494
|
+
|
|
495
|
+
# Find all extracted helper functions
|
|
496
|
+
helpers = _find_extracted_helpers(target, args.files, args.functions)
|
|
497
|
+
|
|
498
|
+
if not helpers:
|
|
499
|
+
print(f"No __extracted_func_* functions found in {target}")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
# List mode
|
|
503
|
+
if args.list:
|
|
504
|
+
print(f"\nFound {len(helpers)} extracted helper function(s):\n")
|
|
505
|
+
for file_path, func_name, lineno, source_preview in helpers:
|
|
506
|
+
rel_path = file_path.relative_to(target)
|
|
507
|
+
print(f" {rel_path}:{lineno}")
|
|
508
|
+
print(f" Function: {func_name}")
|
|
509
|
+
if source_preview:
|
|
510
|
+
lines = source_preview.split("\n")[:3]
|
|
511
|
+
for line in lines:
|
|
512
|
+
print(f" {line}")
|
|
513
|
+
if len(source_preview.split("\n")) > 3:
|
|
514
|
+
print(" ...")
|
|
515
|
+
print()
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Apply renamings from file
|
|
519
|
+
if args.rename_file:
|
|
520
|
+
_apply_rename_file(target, helpers, args.rename_file, args.dry_run)
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
# Interactive LLM mode
|
|
524
|
+
_run_interactive_llm_mode(target, helpers, args.llm, args.dry_run)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _find_extracted_helpers(
|
|
528
|
+
target: Path,
|
|
529
|
+
file_filters: Optional[List[str]],
|
|
530
|
+
function_filters: Optional[List[str]],
|
|
531
|
+
) -> List[Tuple[Path, str, int, str]]:
|
|
532
|
+
"""Find all __extracted_func_* functions in target directory."""
|
|
533
|
+
import ast
|
|
534
|
+
import re
|
|
535
|
+
|
|
536
|
+
helper_pattern = re.compile(r"^__extracted_func(?:_\d+)?$")
|
|
537
|
+
helpers = []
|
|
538
|
+
|
|
539
|
+
for py_file in target.rglob("*.py"):
|
|
540
|
+
# Apply file filters
|
|
541
|
+
if file_filters:
|
|
542
|
+
rel_path = str(py_file.relative_to(target))
|
|
543
|
+
if not any(f in rel_path for f in file_filters):
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
source = py_file.read_text()
|
|
548
|
+
tree = ast.parse(source)
|
|
549
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
for node in ast.walk(tree):
|
|
553
|
+
if isinstance(node, ast.FunctionDef):
|
|
554
|
+
if helper_pattern.match(node.name):
|
|
555
|
+
# Apply function filters
|
|
556
|
+
if function_filters and node.name not in function_filters:
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
# Get source preview
|
|
560
|
+
lines = source.split("\n")
|
|
561
|
+
if hasattr(node, "lineno") and node.lineno <= len(lines):
|
|
562
|
+
start = node.lineno - 1
|
|
563
|
+
end = min(start + 5, len(lines))
|
|
564
|
+
preview = "\n".join(lines[start:end])
|
|
565
|
+
else:
|
|
566
|
+
preview = ""
|
|
567
|
+
|
|
568
|
+
helpers.append((py_file, node.name, node.lineno, preview))
|
|
569
|
+
|
|
570
|
+
return sorted(helpers, key=lambda x: (str(x[0]), x[2]))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _apply_rename_file(
|
|
574
|
+
target: Path,
|
|
575
|
+
helpers: List[Tuple[Path, str, int, str]],
|
|
576
|
+
rename_file: Path,
|
|
577
|
+
dry_run: bool,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Apply renamings from a JSON file."""
|
|
580
|
+
import json
|
|
581
|
+
import re
|
|
582
|
+
|
|
583
|
+
# Load rename mappings
|
|
584
|
+
try:
|
|
585
|
+
with open(rename_file) as f:
|
|
586
|
+
renames = json.load(f)
|
|
587
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
588
|
+
print(f"Error reading rename file: {e}")
|
|
589
|
+
sys.exit(1)
|
|
590
|
+
|
|
591
|
+
if not isinstance(renames, dict):
|
|
592
|
+
print("Error: Rename file must contain a JSON object (dict)")
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
|
|
595
|
+
# Apply renamings
|
|
596
|
+
total_changes = 0
|
|
597
|
+
|
|
598
|
+
for old_name, new_name in renames.items():
|
|
599
|
+
if not isinstance(new_name, str) or not new_name:
|
|
600
|
+
print(f"Warning: Skipping invalid mapping {old_name} -> {new_name}")
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
# Validate new name
|
|
604
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", new_name):
|
|
605
|
+
print(f"Warning: Skipping invalid Python identifier: {new_name}")
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
# Find and replace in all Python files
|
|
609
|
+
count = _rename_function_in_directory(target, old_name, new_name, dry_run)
|
|
610
|
+
if count > 0:
|
|
611
|
+
total_changes += count
|
|
612
|
+
print(f" {old_name} -> {new_name} ({count} replacement(s))")
|
|
613
|
+
|
|
614
|
+
if dry_run:
|
|
615
|
+
print(f"\n[DRY RUN] Would make {total_changes} change(s)")
|
|
616
|
+
else:
|
|
617
|
+
print(f"\n✓ Applied {total_changes} change(s)")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _rename_function_in_directory(
|
|
621
|
+
target: Path,
|
|
622
|
+
old_name: str,
|
|
623
|
+
new_name: str,
|
|
624
|
+
dry_run: bool,
|
|
625
|
+
) -> int:
|
|
626
|
+
"""Rename a function throughout all Python files in directory."""
|
|
627
|
+
import re
|
|
628
|
+
|
|
629
|
+
# Pattern to match function definitions and calls
|
|
630
|
+
# This is a simple regex - could be improved with AST rewriting for accuracy
|
|
631
|
+
patterns = [
|
|
632
|
+
(rf"\bdef {re.escape(old_name)}\b", f"def {new_name}"),
|
|
633
|
+
(rf"\b{re.escape(old_name)}\(", f"{new_name}("),
|
|
634
|
+
]
|
|
635
|
+
|
|
636
|
+
total_replacements = 0
|
|
637
|
+
|
|
638
|
+
for py_file in target.rglob("*.py"):
|
|
639
|
+
try:
|
|
640
|
+
content = py_file.read_text()
|
|
641
|
+
new_content = content
|
|
642
|
+
|
|
643
|
+
for pattern, replacement in patterns:
|
|
644
|
+
new_content = re.sub(pattern, replacement, new_content)
|
|
645
|
+
|
|
646
|
+
if new_content != content:
|
|
647
|
+
replacements = sum(len(re.findall(pattern, content)) for pattern, _ in patterns)
|
|
648
|
+
total_replacements += replacements
|
|
649
|
+
|
|
650
|
+
if not dry_run:
|
|
651
|
+
py_file.write_text(new_content)
|
|
652
|
+
|
|
653
|
+
except (UnicodeDecodeError, PermissionError):
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
return total_replacements
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _run_interactive_llm_mode(
|
|
660
|
+
target: Path,
|
|
661
|
+
helpers: List[Tuple[Path, str, int, str]],
|
|
662
|
+
llm_type: str,
|
|
663
|
+
dry_run: bool,
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Run interactive mode: generate LLM prompt and apply suggestions."""
|
|
666
|
+
print(f"\nFound {len(helpers)} extracted helper function(s) to rename.\n") # noqa: F541
|
|
667
|
+
print("=" * 70)
|
|
668
|
+
print("STEP 1: LLM PROMPT GENERATION")
|
|
669
|
+
print("=" * 70)
|
|
670
|
+
print()
|
|
671
|
+
|
|
672
|
+
# Generate LLM prompt
|
|
673
|
+
prompt = _generate_llm_prompt(target, helpers, llm_type)
|
|
674
|
+
|
|
675
|
+
print("Copy the following prompt and paste it into your LLM assistant:")
|
|
676
|
+
print("\n" + "=" * 70)
|
|
677
|
+
print(prompt)
|
|
678
|
+
print("=" * 70 + "\n")
|
|
679
|
+
|
|
680
|
+
print("After the LLM provides suggestions, paste the JSON response below.")
|
|
681
|
+
print("The response should be a JSON object mapping old names to new names:")
|
|
682
|
+
print(
|
|
683
|
+
'Example: {"__extracted_func_1": "calculate_total", "__extracted_func_2": "validate_input"}'
|
|
684
|
+
)
|
|
685
|
+
print()
|
|
686
|
+
print("Paste the JSON response (press Ctrl+D or Ctrl+Z when done):")
|
|
687
|
+
print()
|
|
688
|
+
|
|
689
|
+
# Read LLM response from stdin
|
|
690
|
+
try:
|
|
691
|
+
llm_response = sys.stdin.read().strip()
|
|
692
|
+
except KeyboardInterrupt:
|
|
693
|
+
print("\nAborted.")
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
if not llm_response:
|
|
697
|
+
print("No response provided. Aborted.")
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
# Extract JSON from response (handle markdown code blocks)
|
|
701
|
+
import json
|
|
702
|
+
import re
|
|
703
|
+
|
|
704
|
+
# Try to extract JSON from markdown code block
|
|
705
|
+
json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", llm_response, re.DOTALL)
|
|
706
|
+
if json_match:
|
|
707
|
+
json_str = json_match.group(1)
|
|
708
|
+
else:
|
|
709
|
+
# Try to find JSON object directly
|
|
710
|
+
json_match = re.search(r"\{.*\}", llm_response, re.DOTALL)
|
|
711
|
+
if json_match:
|
|
712
|
+
json_str = json_match.group(0)
|
|
713
|
+
else:
|
|
714
|
+
json_str = llm_response
|
|
715
|
+
|
|
716
|
+
# Parse JSON
|
|
717
|
+
try:
|
|
718
|
+
renames = json.loads(json_str)
|
|
719
|
+
except json.JSONDecodeError as e:
|
|
720
|
+
print(f"\nError parsing JSON response: {e}")
|
|
721
|
+
print("Please ensure the response is valid JSON.")
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
if not isinstance(renames, dict):
|
|
725
|
+
print("Error: Response must be a JSON object (dict)")
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
print("\n" + "=" * 70)
|
|
729
|
+
print("STEP 2: APPLYING RENAMINGS")
|
|
730
|
+
print("=" * 70)
|
|
731
|
+
print()
|
|
732
|
+
|
|
733
|
+
# Apply renamings
|
|
734
|
+
total_changes = 0
|
|
735
|
+
for old_name, new_name in renames.items():
|
|
736
|
+
if not isinstance(new_name, str) or not new_name:
|
|
737
|
+
print(f"Warning: Skipping invalid mapping {old_name} -> {new_name}")
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
count = _rename_function_in_directory(target, old_name, new_name, dry_run)
|
|
741
|
+
if count > 0:
|
|
742
|
+
total_changes += count
|
|
743
|
+
print(f" ✓ {old_name} -> {new_name} ({count} replacement(s))")
|
|
744
|
+
|
|
745
|
+
if dry_run:
|
|
746
|
+
print(f"\n[DRY RUN] Would make {total_changes} change(s)")
|
|
747
|
+
else:
|
|
748
|
+
print(f"\n✓ Successfully applied {total_changes} change(s)")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _generate_llm_prompt(
|
|
752
|
+
target: Path,
|
|
753
|
+
helpers: List[Tuple[Path, str, int, str]],
|
|
754
|
+
llm_type: str,
|
|
755
|
+
) -> str:
|
|
756
|
+
"""Generate a prompt for an LLM to suggest better function names."""
|
|
757
|
+
|
|
758
|
+
intro = """I have extracted duplicate code into helper functions, but they have generic names like __extracted_func_1, __extracted_func_2, etc.
|
|
759
|
+
|
|
760
|
+
Please review each function and suggest a better, more descriptive name based on what the function does. The new names should:
|
|
761
|
+
- Be descriptive and indicate the function's purpose
|
|
762
|
+
- Follow Python naming conventions (lowercase with underscores)
|
|
763
|
+
- Be concise but meaningful
|
|
764
|
+
- Avoid generic names like "helper" or "utility"
|
|
765
|
+
|
|
766
|
+
Here are the extracted functions:
|
|
767
|
+
|
|
768
|
+
"""
|
|
769
|
+
|
|
770
|
+
functions_section = ""
|
|
771
|
+
for i, (file_path, func_name, lineno, source_preview) in enumerate(helpers, 1):
|
|
772
|
+
rel_path = file_path.relative_to(target)
|
|
773
|
+
functions_section += f"\n{i}. {func_name} ({rel_path}:{lineno})\n"
|
|
774
|
+
if source_preview:
|
|
775
|
+
functions_section += "```python\n"
|
|
776
|
+
functions_section += source_preview + "\n"
|
|
777
|
+
functions_section += "```\n"
|
|
778
|
+
|
|
779
|
+
output_format = """
|
|
780
|
+
|
|
781
|
+
Please provide your suggestions as a JSON object mapping old names to new names. For example:
|
|
782
|
+
|
|
783
|
+
```json
|
|
784
|
+
{
|
|
785
|
+
"__extracted_func_1": "calculate_total_price",
|
|
786
|
+
"__extracted_func_2": "validate_email_format",
|
|
787
|
+
"__extracted_func_3": "format_date_string"
|
|
788
|
+
}
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
Only include the JSON object in your response.
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
return intro + functions_section + output_format
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# Legacy entry points for backwards compatibility
|
|
798
|
+
def dry_main() -> None:
|
|
799
|
+
"""Direct entry point for 'towel-dry' command (legacy)."""
|
|
800
|
+
# Emulate 'towel dry' by injecting 'dry' as first argument
|
|
801
|
+
sys.argv.insert(1, "dry")
|
|
802
|
+
main()
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def preview_main() -> None:
|
|
806
|
+
"""Direct entry point for 'towel-preview' command (legacy)."""
|
|
807
|
+
# Emulate 'towel preview' by injecting 'preview' as first argument
|
|
808
|
+
sys.argv.insert(1, "preview")
|
|
809
|
+
main()
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
if __name__ == "__main__":
|
|
813
|
+
main()
|