evolver-tools 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. evolver_tools/__init__.py +2 -0
  2. evolver_tools/__main__.py +3 -0
  3. evolver_tools/cli.py +89 -0
  4. evolver_tools/vendor/b64/__init__.py +2 -0
  5. evolver_tools/vendor/b64/b64.py +176 -0
  6. evolver_tools/vendor/cal_tool/__init__.py +1 -0
  7. evolver_tools/vendor/cal_tool/cli.py +234 -0
  8. evolver_tools/vendor/chart_cli/__init__.py +444 -0
  9. evolver_tools/vendor/chart_cli/__main__.py +3 -0
  10. evolver_tools/vendor/colors/__init__.py +5 -0
  11. evolver_tools/vendor/colors/__main__.py +97 -0
  12. evolver_tools/vendor/csv_stats/__init__.py +5 -0
  13. evolver_tools/vendor/csv_stats/__main__.py +4 -0
  14. evolver_tools/vendor/csv_stats/analyzer.py +258 -0
  15. evolver_tools/vendor/csv_stats/cli.py +45 -0
  16. evolver_tools/vendor/dirsize/__init__.py +183 -0
  17. evolver_tools/vendor/envcheck/__init__.py +426 -0
  18. evolver_tools/vendor/ff/__init__.py +427 -0
  19. evolver_tools/vendor/ff/__main__.py +3 -0
  20. evolver_tools/vendor/find_dups/__init__.py +7 -0
  21. evolver_tools/vendor/find_dups/cli.py +392 -0
  22. evolver_tools/vendor/hashsum/__init__.py +211 -0
  23. evolver_tools/vendor/hashsum/__main__.py +5 -0
  24. evolver_tools/vendor/http_live/__init__.py +265 -0
  25. evolver_tools/vendor/http_live/__main__.py +2 -0
  26. evolver_tools/vendor/ipinfo/__init__.py +3 -0
  27. evolver_tools/vendor/ipinfo/__main__.py +30 -0
  28. evolver_tools/vendor/jq_lite/__init__.py +257 -0
  29. evolver_tools/vendor/jq_lite/__main__.py +5 -0
  30. evolver_tools/vendor/json2csv/__init__.py +3 -0
  31. evolver_tools/vendor/json2csv/__main__.py +82 -0
  32. evolver_tools/vendor/jsonql/__init__.py +326 -0
  33. evolver_tools/vendor/jsonql/__main__.py +5 -0
  34. evolver_tools/vendor/license_cli/__init__.py +1 -0
  35. evolver_tools/vendor/license_cli/__main__.py +4 -0
  36. evolver_tools/vendor/license_cli/cli.py +289 -0
  37. evolver_tools/vendor/markdown_check/__init__.py +211 -0
  38. evolver_tools/vendor/nb/__init__.py +319 -0
  39. evolver_tools/vendor/nb/__main__.py +3 -0
  40. evolver_tools/vendor/passgen/__init__.py +224 -0
  41. evolver_tools/vendor/portcheck/__init__.py +2 -0
  42. evolver_tools/vendor/portcheck/__main__.py +66 -0
  43. evolver_tools/vendor/project_doctor/__init__.py +412 -0
  44. evolver_tools/vendor/project_doctor/__main__.py +3 -0
  45. evolver_tools/vendor/ren/__init__.py +283 -0
  46. evolver_tools/vendor/ren/__main__.py +3 -0
  47. evolver_tools/vendor/siege_lite/__init__.py +250 -0
  48. evolver_tools/vendor/siege_lite/__main__.py +3 -0
  49. evolver_tools/vendor/smellfinder/__init__.py +376 -0
  50. evolver_tools/vendor/smellfinder/__main__.py +3 -0
  51. evolver_tools/vendor/sqlite_cli/__init__.py +326 -0
  52. evolver_tools/vendor/sqlite_cli/__main__.py +5 -0
  53. evolver_tools/vendor/sysmon/__init__.py +299 -0
  54. evolver_tools/vendor/sysmon/__main__.py +3 -0
  55. evolver_tools/vendor/timer/__init__.py +127 -0
  56. evolver_tools/vendor/treedir/__init__.py +2 -0
  57. evolver_tools/vendor/treedir/__main__.py +128 -0
  58. evolver_tools/vendor/urlparse_tool/__init__.py +3 -0
  59. evolver_tools/vendor/urlparse_tool/cli.py +212 -0
  60. evolver_tools/vendor/web_summary/__init__.py +341 -0
  61. evolver_tools/vendor/web_summary/__main__.py +3 -0
  62. evolver_tools/vendor/wordcount/__init__.py +2 -0
  63. evolver_tools/vendor/wordcount/__main__.py +101 -0
  64. evolver_tools-1.4.0.dist-info/METADATA +107 -0
  65. evolver_tools-1.4.0.dist-info/RECORD +69 -0
  66. evolver_tools-1.4.0.dist-info/WHEEL +5 -0
  67. evolver_tools-1.4.0.dist-info/entry_points.txt +34 -0
  68. evolver_tools-1.4.0.dist-info/licenses/LICENSE +21 -0
  69. evolver_tools-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ project-doctor — 项目健康检查工具
4
+ 扫描项目目录,检查基础设施完整性,评分并提建议。
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ import re
10
+ import json
11
+ from pathlib import Path
12
+
13
+ def red(s): return f"\033[91m{s}\033[0m"
14
+ def green(s): return f"\033[92m{s}\033[0m"
15
+ def yellow(s): return f"\033[93m{s}\033[0m"
16
+ def cyan(s): return f"\033[96m{s}\033[0m"
17
+ def dim(s): return f"\033[2m{s}\033[0m"
18
+ def bold(s): return f"\033[1m{s}\033[0m"
19
+
20
+ class ProjectDoctor:
21
+ """Project health checker"""
22
+
23
+ def __init__(self, path, verbose=False):
24
+ self.root = Path(path).resolve()
25
+ self.verbose = verbose
26
+ self.checks = []
27
+ self.score = 0
28
+ self.max_score = 0
29
+ self.findings = [] # (category, severity, message, weight)
30
+
31
+ def check(self, name, weight, fn):
32
+ """Run a check function"""
33
+ self.max_score += weight
34
+ try:
35
+ result = fn()
36
+ if result:
37
+ self.score += weight
38
+ self.findings.append(('pass', 'ok', f"{name}: {result}", 0))
39
+ else:
40
+ self.findings.append(('fail', 'warn', f"{name}: {red('未通过')}", weight))
41
+ return result
42
+ except Exception as e:
43
+ if self.verbose:
44
+ self.findings.append(('error', 'error', f"{name}: 检查出错 ({e})", 0))
45
+ return False
46
+
47
+ def check_file(self, *paths):
48
+ """Check if a file exists (relative to project root)"""
49
+ return any((self.root / p).is_file() for p in paths)
50
+
51
+ def check_dir(self, path):
52
+ """Check if directory exists"""
53
+ return (self.root / path).is_dir()
54
+
55
+ def read_file(self, path):
56
+ """Read a file if it exists"""
57
+ f = self.root / path
58
+ if f.is_file():
59
+ try:
60
+ return f.read_text(encoding='utf-8', errors='replace')
61
+ except:
62
+ return None
63
+ return None
64
+
65
+ def scan(self):
66
+ """Run all checks"""
67
+
68
+ # ---- 1. 元文件检查 ----
69
+ self.findings.append(('section', 'info', bold('\n📦 元文件'), 0))
70
+
71
+ # README
72
+ self.check('README', 10, lambda:
73
+ green(f"✓ {self._find_file('README*')}") if self._find_file('README*') else None)
74
+
75
+ # LICENSE
76
+ self.check('LICENSE', 5, lambda:
77
+ green(f"✓ {self._find_file('LICENSE*')}") if self._find_file('LICENSE*') else None)
78
+
79
+ # .gitignore
80
+ self.check('.gitignore', 8, lambda:
81
+ self._check_gitignore() if self.check_file('.gitignore') else None)
82
+
83
+ # .git
84
+ self.check('.git (版本控制)', 10, lambda:
85
+ green(f"✓ {self._check_git_health()}") if self.check_dir('.git') else None)
86
+
87
+ # CHANGELOG
88
+ self.check('CHANGELOG', 3, lambda:
89
+ green(f"✓ {self._find_file('CHANGELOG*')}") if self._find_file('CHANGELOG*') else None)
90
+
91
+ # ---- 2. Python 项目检查 ----
92
+ has_python = self._has_python_files()
93
+ if has_python:
94
+ self.findings.append(('section', 'info', bold('\n🐍 Python 项目'), 0))
95
+
96
+ # requirements.txt / pyproject.toml
97
+ self.check('依赖管理', 8, lambda:
98
+ green(f"✓ {self._find_file('requirements.txt') or self._find_file('pyproject.toml') or self._find_file('setup.py') or self._find_file('Pipfile')}")
99
+ if self._find_file('requirements.txt') or self._find_file('pyproject.toml') or self._find_file('setup.py') or self._find_file('Pipfile') else None)
100
+
101
+ # venv / .venv
102
+ self.check('虚拟环境', 6, lambda:
103
+ green(f"✓ {self._find_dir('venv') or self._find_dir('.venv')}") if self.check_dir('venv') or self.check_dir('.venv') else yellow("⚠ 建议: 创建虚拟环境"))
104
+
105
+ # __init__.py in packages
106
+ packages = list(self.root.rglob('__init__.py'))
107
+ self.check('包结构', 3, lambda:
108
+ cyan(f"{len(packages)} 个包") if packages else yellow("无 Python 包"))
109
+
110
+ # Python version pinning
111
+ py_version = self.read_file('.python-version')
112
+ if py_version:
113
+ self.findings.append(('pass', 'info', f" Python 版本固定: {py_version.strip()}", 0))
114
+
115
+ # ---- 3. JavaScript/Node 项目检查 ----
116
+ has_js = self.check_file('package.json')
117
+ if has_js:
118
+ self.findings.append(('section', 'info', bold('\n📦 Node.js 项目'), 0))
119
+
120
+ # package.json validity
121
+ pkg = self._read_json('package.json')
122
+ if pkg:
123
+ scripts = pkg.get('scripts', {})
124
+ has_test = 'test' in scripts
125
+ has_build = 'build' in scripts
126
+ deps = len(pkg.get('dependencies', {}))
127
+ dev_deps = len(pkg.get('devDependencies', {}))
128
+ self.findings.append(('pass', 'info',
129
+ f" 依赖: {deps} 生产 + {dev_deps} 开发", 0))
130
+ if has_test:
131
+ self.score += 2
132
+ self.findings.append(('pass', 'ok', f" test 脚本: {green('✓')}", 0))
133
+ if has_build:
134
+ self.score += 2
135
+ self.findings.append(('pass', 'ok', f" build 脚本: {green('✓')}", 0))
136
+
137
+ # node_modules
138
+ nm = self.check_dir('node_modules')
139
+ if nm:
140
+ self.findings.append(('pass', 'info', f" node_modules: {green('已安装')}", 0))
141
+ else:
142
+ self.findings.append(('pass', 'info', f" node_modules: {yellow('未安装 (需先 npm install)')}", 0))
143
+
144
+ # .nvmrc
145
+ if self.check_file('.nvmrc'):
146
+ nvm = self.read_file('.nvmrc')
147
+ self.findings.append(('pass', 'info', f" Node 版本: {nvm.strip()}", 0))
148
+
149
+ # ---- 4. 项目结构检查 ----
150
+ self.findings.append(('section', 'info', bold('\n📁 项目结构'), 0))
151
+
152
+ # Entry point detection
153
+ entry = self._find_entry_point()
154
+ if entry:
155
+ self.check('入口文件', 5, lambda: green(entry))
156
+
157
+ # Test directory
158
+ test_dirs = ['tests', 'test', '__tests__', 'spec']
159
+ found_test = False
160
+ for td in test_dirs:
161
+ if self.check_dir(td):
162
+ test_count = len(list((self.root / td).rglob('*.py' if has_python else '*.js')))
163
+ self.findings.append(('pass', 'info', f" 测试目录: {green(td)} ({test_count} 个测试文件)", 0))
164
+ found_test = True
165
+ break
166
+ if not found_test:
167
+ self.findings.append(('fail', 'warn', f" 测试目录: {yellow('⚠ 未发现 (建议: 创建 tests/)')}", 3))
168
+
169
+ # CI config
170
+ ci_files = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'Jenkinsfile',
171
+ '.travis.yml', 'azure-pipelines.yml']
172
+ self.check('CI/CD 配置', 5, lambda:
173
+ green(f"✓ {self._find_first_ci()}") if self._find_first_ci() else None)
174
+
175
+ # Docker
176
+ if self.check_file('Dockerfile', 'docker-compose.yml', 'compose.yaml'):
177
+ self.findings.append(('pass', 'info', f" Docker: {green('已配置')}", 0))
178
+
179
+ # Makefile
180
+ if self.check_file('Makefile', 'makefile', 'Justfile', 'Taskfile.yml'):
181
+ self.findings.append(('pass', 'info', f" 构建工具: {green('已配置')}", 0))
182
+
183
+ # ---- 5. 代码质量检查 ----
184
+ self.findings.append(('section', 'info', bold('\n🔍 代码质量'), 0))
185
+
186
+ # Linter config
187
+ lint_configs = ['.pylintrc', '.flake8', '.eslintrc*', '.eslintrc.json',
188
+ '.eslintrc.js', '.prettierrc', 'pyproject.toml',
189
+ '.ruff.toml', 'ruff.toml']
190
+ self.check('Linter 配置', 5, lambda:
191
+ green(f"✓ {self._find_any(lint_configs)}") if self._find_any(lint_configs) else None)
192
+
193
+ # Editor config
194
+ if self.check_file('.editorconfig'):
195
+ self.findings.append(('pass', 'info', f" EditorConfig: {green('✓')}", 0))
196
+
197
+ # File count and sizes
198
+ all_files = list(self.root.rglob('*'))
199
+ file_count = len([f for f in all_files if f.is_file()])
200
+ dir_count = len([f for f in all_files if f.is_dir()])
201
+
202
+ # Largest files
203
+ py_files = list(self.root.rglob('*.py'))
204
+ if py_files:
205
+ total_loc = sum(len(f.read_text().splitlines()) for f in py_files if f.is_file())
206
+ self.findings.append(('pass', 'info',
207
+ f" Python 代码: {len(py_files)} 个文件, {total_loc} 行", 0))
208
+
209
+ self.findings.append(('pass', 'info',
210
+ f" 项目结构: {file_count} 个文件, {dir_count} 个目录", 0))
211
+
212
+ # ---- Summary ----
213
+ return {
214
+ 'root': str(self.root),
215
+ 'score': self.score,
216
+ 'max_score': self.max_score,
217
+ 'percentage': round(self.score / max(1, self.max_score) * 100, 1),
218
+ 'findings': self.findings,
219
+ }
220
+
221
+ def _find_file(self, pattern):
222
+ """Find a file by glob pattern"""
223
+ matches = list(self.root.glob(pattern))
224
+ if matches:
225
+ return matches[0].name
226
+ # Try with case-insensitive
227
+ for f in self.root.iterdir():
228
+ if f.is_file() and f.name.lower().startswith(pattern.lower().replace('*', '')):
229
+ return f.name
230
+ return None
231
+
232
+ def _find_dir(self, name):
233
+ """Find a directory"""
234
+ d = self.root / name
235
+ if d.is_dir():
236
+ return name
237
+ return None
238
+
239
+ def _has_python_files(self):
240
+ return len(list(self.root.glob('*.py'))) > 0
241
+
242
+ def _read_json(self, path):
243
+ try:
244
+ content = self.read_file(path)
245
+ if content:
246
+ return json.loads(content)
247
+ except:
248
+ pass
249
+ return None
250
+
251
+ def _find_entry_point(self):
252
+ """Detect main entry point"""
253
+ candidates = ['main.py', 'app.py', 'cli.py', 'index.js', 'index.ts',
254
+ 'server.py', 'run.py', 'manage.py', 'src/main.py',
255
+ 'cmd/main.go']
256
+ for c in candidates:
257
+ if self.check_file(c):
258
+ return c
259
+ # Check for __main__.py
260
+ if self._find_any(['__main__.py', '*/__main__.py']):
261
+ return '__main__.py'
262
+ return None
263
+
264
+ def _check_gitignore(self):
265
+ content = self.read_file('.gitignore')
266
+ if not content:
267
+ return "文件为空"
268
+ lines = [l.strip() for l in content.split('\n') if l.strip() and not l.startswith('#')]
269
+ return f"{len(lines)} 条规则"
270
+
271
+ def _check_git_health(self):
272
+ """Check git repo health"""
273
+ try:
274
+ import subprocess
275
+ # Count commits
276
+ result = subprocess.run(
277
+ ['git', '-C', str(self.root), 'rev-list', '--count', 'HEAD'],
278
+ capture_output=True, text=True, timeout=5
279
+ )
280
+ commits = result.stdout.strip()
281
+ # Check for recent commit
282
+ result2 = subprocess.run(
283
+ ['git', '-C', str(self.root), 'log', '-1', '--format=%cr'],
284
+ capture_output=True, text=True, timeout=5
285
+ )
286
+ recent = result2.stdout.strip()
287
+ return f"{commits} 次提交 (最近: {recent})"
288
+ except:
289
+ return "存在 .git 目录"
290
+
291
+ def _find_first_ci(self):
292
+ """Find CI/CD config"""
293
+ ci_map = {
294
+ '.github/workflows/': '.github/workflows',
295
+ '.gitlab-ci.yml': '.gitlab-ci.yml',
296
+ '.circleci/config.yml': '.circleci/config.yml',
297
+ 'Jenkinsfile': 'Jenkinsfile',
298
+ '.travis.yml': '.travis.yml',
299
+ }
300
+ for name, path in ci_map.items():
301
+ if self.check_file(path) or self.check_dir(path):
302
+ return name.rstrip('/')
303
+ return None
304
+
305
+ def _find_any(self, patterns):
306
+ """Find first matching pattern"""
307
+ for p in patterns:
308
+ matches = list(self.root.glob(p))
309
+ if matches:
310
+ return matches[0].name
311
+ # Also check as exact name
312
+ if self.check_file(p):
313
+ return p
314
+ return None
315
+
316
+
317
+ def print_report(result):
318
+ """Print formatted health report"""
319
+ root = result['root']
320
+ score = result['score']
321
+ max_score = result['max_score']
322
+ pct = result['percentage']
323
+
324
+ # Header
325
+ print(f"\n {bold('🏥 项目健康检查报告')}")
326
+ print(f" {dim('─' * 55)}")
327
+ print(f" {dim('项目:')} {root}")
328
+ print(f" {dim('时间:')} {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M')}")
329
+ print(f" {dim('─' * 55)}")
330
+
331
+ # Score bar
332
+ if pct >= 80:
333
+ score_color = green
334
+ grade = '优秀'
335
+ elif pct >= 60:
336
+ score_color = yellow
337
+ grade = '良好'
338
+ elif pct >= 40:
339
+ score_color = yellow
340
+ grade = '需改进'
341
+ else:
342
+ score_color = red
343
+ grade = '不健康'
344
+
345
+ bar_w = 30
346
+ filled = int(bar_w * pct / 100)
347
+ bar = '█' * filled + '░' * (bar_w - filled)
348
+ print(f"\n {bold('健康评分:')} {score_color(f'{score}/{max_score} ({pct}%)')} {bold(grade)}")
349
+ print(f" {score_color(bar)}")
350
+ print()
351
+
352
+ # Findings by category
353
+ current_section = ''
354
+ warnings = []
355
+ suggestions = []
356
+
357
+ for finding in result['findings']:
358
+ ftype, severity, msg, weight = finding
359
+
360
+ if ftype == 'section':
361
+ print(msg)
362
+ continue
363
+
364
+ if ftype == 'pass':
365
+ if severity == 'ok':
366
+ print(f" {green('✓')} {msg}")
367
+ elif severity == 'info':
368
+ print(f" {cyan('ℹ')} {dim(msg)}")
369
+
370
+ elif ftype == 'fail':
371
+ if weight > 0:
372
+ warnings.append((msg, weight))
373
+ print(f" {yellow('⚠')} {msg}")
374
+
375
+ # Suggestions section
376
+ if warnings:
377
+ print(f"\n {bold('💡 改进建议')}")
378
+ print(f" {dim('─' * 55)}")
379
+ for msg, weight in sorted(warnings, key=lambda x: -x[1]):
380
+ icon = {10: '🔴', 8: '🟠', 5: '🟡', 3: '🟢'}.get(weight, '⚪')
381
+ print(f" {icon} {msg} (权重: {weight})")
382
+ print()
383
+
384
+ # Legend
385
+ print(f" {dim('评分标准: ≥80 优秀 | ≥60 良好 | ≥40 需改进 | <40 不健康')}")
386
+ print()
387
+
388
+
389
+ def main():
390
+ import argparse
391
+ parser = argparse.ArgumentParser(description='项目健康检查工具')
392
+ parser.add_argument('path', nargs='?', default='.', help='项目目录路径 (默认: 当前目录)')
393
+ parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
394
+ parser.add_argument('--json', action='store_true', help='JSON 格式输出')
395
+ args = parser.parse_args()
396
+
397
+ path = Path(args.path).resolve()
398
+ if not path.is_dir():
399
+ print(f"{red('✗ 目录不存在:')} {path}")
400
+ sys.exit(1)
401
+
402
+ doctor = ProjectDoctor(path, verbose=args.verbose)
403
+ result = doctor.scan()
404
+
405
+ if args.json:
406
+ print(json.dumps(result, indent=2, ensure_ascii=False))
407
+ else:
408
+ print_report(result)
409
+
410
+
411
+ if __name__ == '__main__':
412
+ main()
@@ -0,0 +1,3 @@
1
+ """CLI entry point for `python -m project_doctor`"""
2
+ from project_doctor import main
3
+ main()
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ren — 批量文件重命名工具
4
+ 安全、可预览、可撤销的文件重命名。
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+
13
+ def green(s): return f"\033[92m{s}\033[0m"
14
+ def red(s): return f"\033[91m{s}\033[0m"
15
+ def yellow(s): return f"\033[93m{s}\033[0m"
16
+ def cyan(s): return f"\033[96m{s}\033[0m"
17
+ def dim(s): return f"\033[2m{s}\033[0m"
18
+
19
+ def collect_files(patterns, recursive=False):
20
+ """Collect files matching glob patterns"""
21
+ files = []
22
+ for pattern in patterns:
23
+ p = Path(pattern)
24
+ if p.is_dir():
25
+ # Directory mode: collect all files in dir
26
+ for f in p.iterdir() if not recursive else p.rglob('*'):
27
+ if f.is_file():
28
+ files.append(f)
29
+ elif '*' in pattern or '?' in pattern:
30
+ # Glob pattern
31
+ matched = list(Path('.').glob(pattern))
32
+ if not matched:
33
+ # Try parent glob
34
+ matched = list(Path('.').glob(pattern))
35
+ files.extend(matched)
36
+ else:
37
+ # Exact path
38
+ p = Path(pattern)
39
+ if p.exists():
40
+ files.append(p)
41
+ return sorted(set(files), key=lambda f: f.name)
42
+
43
+
44
+ def apply_operation(file, op, args):
45
+ """Apply a rename operation, returns (original, new_name, applied)"""
46
+ stem = file.stem
47
+ suffix = file.suffix
48
+
49
+ if op == 'prefix':
50
+ prefix = args[0]
51
+ new_stem = prefix + stem
52
+ elif op == 'suffix':
53
+ suffix_text = args[0]
54
+ new_stem = stem + suffix_text
55
+ elif op == 'replace':
56
+ old, new = args[0], args[1] if len(args) > 1 else ''
57
+ new_stem = stem.replace(old, new)
58
+ elif op == 'regex':
59
+ pattern, repl = args[0], args[1] if len(args) > 1 else ''
60
+ new_stem = re.sub(pattern, repl, stem)
61
+ elif op == 'lower':
62
+ new_stem = stem.lower()
63
+ elif op == 'upper':
64
+ new_stem = stem.upper()
65
+ elif op == 'number':
66
+ fmt = args[0] if args else '{:03d}'
67
+ # Need index info - handled in main loop
68
+ return None
69
+ elif op == 'strip':
70
+ chars_to_strip = args[0] if args else ' _-'
71
+ new_stem = stem.strip(chars_to_strip)
72
+ elif op == 'truncate':
73
+ max_len = int(args[0]) if args else 40
74
+ new_stem = stem[:max_len]
75
+ elif op == 'date':
76
+ fmt = args[0] if args else '%Y%m%d'
77
+ new_stem = datetime.now().strftime(fmt) + '_' + stem
78
+ elif op == 'ext':
79
+ ext = args[0] if args else ''
80
+ if not ext.startswith('.'):
81
+ ext = '.' + ext
82
+ new_path = file.parent / (stem + ext)
83
+ return (file, new_path, True)
84
+ else:
85
+ return None
86
+
87
+ new_path = file.parent / (new_stem + suffix)
88
+ # Don't rename to same name
89
+ if new_path == file:
90
+ return (file, new_path, False)
91
+ return (file, new_path, True)
92
+
93
+
94
+ def show_help():
95
+ print("ren — 批量文件重命名工具")
96
+ print()
97
+ print("用法: ren <glob/文件>... <操作> [参数]")
98
+ print()
99
+ print("操作:")
100
+ print(" --prefix <text> 添加前缀")
101
+ print(" --suffix <text> 添加后缀(扩展名前)")
102
+ print(" --replace <old> <new> 替换文件名中的文本")
103
+ print(" --regex <pat> <repl> 正则替换文件名")
104
+ print(" --lower 转为小写")
105
+ print(" --upper 转为大写")
106
+ print(" --number [fmt] 编号 (默认 {:03d})")
107
+ print(" --strip [chars] 去除字符 (默认 ' _-')")
108
+ print(" --truncate <n> 截断到 n 个字符")
109
+ print(" --date [fmt] 添加日期前缀 (默认 %%Y%%m%%d)")
110
+ print(" --ext <ext> 更改扩展名")
111
+ print()
112
+ print("选项:")
113
+ print(" --dry-run 仅预览,不执行")
114
+ print(" --recursive / -r 递归子目录")
115
+ print(" --verbose / -v 详细信息")
116
+ print()
117
+ print("示例:")
118
+ print(" ren *.txt --prefix draft- # 所有 txt 加 draft- 前缀")
119
+ print(" ren *.jpg --replace IMG_ photo_ # 替换文件名文本")
120
+ print(" ren *.md --number Chapter- # 编号 Chapter-01.md 等")
121
+ print(" ren . --lower --dry-run # 预览当前目录小写化")
122
+ print(" ren log.txt --ext md # 改扩展名 .txt → .md")
123
+
124
+
125
+ def main():
126
+ if not sys.argv[1:] or sys.argv[1] in ('--help', '-h', 'help'):
127
+ show_help()
128
+ return
129
+
130
+ args = sys.argv[1:]
131
+
132
+ # Parse operations and options
133
+ ops = []
134
+ patterns = []
135
+ options = {
136
+ 'dry_run': False,
137
+ 'recursive': False,
138
+ 'verbose': False,
139
+ }
140
+
141
+ i = 0
142
+ while i < len(args):
143
+ a = args[i]
144
+ if a.startswith('--') and a not in ('--dry-run', '--recursive', '--verbose', '-v', '-r'):
145
+ # Operation
146
+ op_name = a[2:] # --prefix -> prefix
147
+ op_args = []
148
+ i += 1
149
+ # Collect arguments until next option
150
+ while i < len(args) and not args[i].startswith('--') and args[i] not in ('-v', '-r'):
151
+ op_args.append(args[i])
152
+ i += 1
153
+ ops.append((op_name, op_args))
154
+ continue
155
+ elif a == '--dry-run':
156
+ options['dry_run'] = True
157
+ elif a in ('--recursive', '-r'):
158
+ options['recursive'] = True
159
+ elif a in ('--verbose', '-v'):
160
+ options['verbose'] = True
161
+ else:
162
+ patterns.append(a)
163
+ i += 1
164
+
165
+ if not patterns:
166
+ print("错误: 未指定文件模式")
167
+ sys.exit(1)
168
+
169
+ if not ops:
170
+ print("错误: 未指定操作")
171
+ sys.exit(1)
172
+
173
+ # Collect files
174
+ files = collect_files(patterns, options['recursive'])
175
+ if not files:
176
+ print("(未找到匹配的文件)")
177
+ return
178
+
179
+ print(f"找到 {len(files)} 个文件\n" if options['verbose'] else "", end="")
180
+
181
+ # Generate rename plan
182
+ rename_plan = [] # [(old_path, new_path, applied)]
183
+ number_op = None
184
+ other_ops = []
185
+
186
+ for op_name, op_args in ops:
187
+ if op_name == 'number':
188
+ number_op = op_args
189
+ else:
190
+ other_ops.append((op_name, op_args))
191
+
192
+ if number_op:
193
+ fmt = number_op[0] if number_op else '{:03d}'
194
+ # Also check for prefix/suffix in format
195
+ prefix_text = ''
196
+ suffix_text = ''
197
+ if ',' in fmt:
198
+ parts = fmt.split(',')
199
+ fmt = parts[0]
200
+ if len(parts) > 1:
201
+ prefix_text = parts[1]
202
+ else:
203
+ # Check if fmt contains non-format characters
204
+ pass
205
+ for idx, file in enumerate(files, 1):
206
+ stem = file.stem
207
+ suffix = file.suffix
208
+ num = fmt.format(idx)
209
+ new_stem = prefix_text + num + suffix_text + stem
210
+ # But wait, I want to insert the number somewhere
211
+ # Simpler: number replaces stem or prefixes
212
+ if prefix_text:
213
+ new_stem = prefix_text + num + '_' + stem
214
+ else:
215
+ new_stem = num + '_' + stem
216
+ new_path = file.parent / (new_stem + suffix)
217
+ rename_plan.append((file, new_path, new_path != file))
218
+ else:
219
+ for file in files:
220
+ current = file
221
+ applied = True
222
+ for op_name, op_args in other_ops:
223
+ result = apply_operation(current, op_name, op_args)
224
+ if result is None:
225
+ continue
226
+ _, new_path, was_applied = result
227
+ if not was_applied:
228
+ applied = False
229
+ break
230
+ current = new_path
231
+ rename_plan.append((file, current, applied))
232
+
233
+ # Filter unchanged
234
+ rename_plan = [(o, n, a) for o, n, a in rename_plan if a and o != n]
235
+
236
+ if not rename_plan:
237
+ print("(无需重命名)")
238
+ return
239
+
240
+ # Show plan
241
+ print(f"\n将重命名 {len(rename_plan)} 个文件:\n")
242
+ for old, new, _ in rename_plan:
243
+ if old.parent == new.parent:
244
+ print(f" {dim(old.name)} → {green(new.name)}")
245
+ else:
246
+ print(f" {old} → {green(new)}")
247
+
248
+ if options['dry_run']:
249
+ print(f"\n{yellow('干跑模式 — 未执行任何操作')}")
250
+ return
251
+
252
+ # Confirm
253
+ print()
254
+ try:
255
+ confirm = input(f"执行以上 {len(rename_plan)} 个重命名? (y/N) ").lower()
256
+ except (EOFError, KeyboardInterrupt):
257
+ print()
258
+ print("取消")
259
+ return
260
+
261
+ if confirm != 'y':
262
+ print("取消")
263
+ return
264
+
265
+ # Execute
266
+ renamed = 0
267
+ errors = 0
268
+ for old, new, _ in rename_plan:
269
+ try:
270
+ new.parent.mkdir(parents=True, exist_ok=True)
271
+ old.rename(new)
272
+ if options['verbose']:
273
+ print(f" ✓ {old.name} → {new.name}")
274
+ renamed += 1
275
+ except OSError as e:
276
+ print(f" ✗ {old.name}: {e}")
277
+ errors += 1
278
+
279
+ print(f"\n✓ 已重命名 {renamed} 个文件" + (f", {errors} 个错误" if errors else ""))
280
+
281
+
282
+ if __name__ == '__main__':
283
+ main()