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,3 @@
1
+ """CLI entry point for `python -m ren`"""
2
+ from ren import main
3
+ main()
@@ -0,0 +1,250 @@
1
+ # -*- coding: utf-8 -*-
2
+ """siege-lite — 轻量 HTTP 压力测试工具,零外部依赖"""
3
+
4
+ __version__ = "1.0.0"
5
+
6
+ import sys
7
+ import time
8
+ import json
9
+ import urllib.request
10
+ import urllib.error
11
+ import concurrent.futures
12
+ import statistics
13
+ from collections import Counter
14
+
15
+ # ANSI helpers
16
+ def red(s): return "\033[91m" + s + "\033[0m"
17
+ def green(s): return "\033[92m" + s + "\033[0m"
18
+ def yellow(s): return "\033[93m" + s + "\033[0m"
19
+ def dim(s): return "\033[2m" + s + "\033[0m"
20
+ def bold(s): return "\033[1m" + s + "\033[0m"
21
+
22
+ # Unicode chars (defined as constants to avoid f-string backslash issues in py<3.12)
23
+ CHECK = "\u2713"
24
+ CROSS = "\u2717"
25
+ BAR_FULL = "\u2588"
26
+ BAR_EMPTY = "\u2591"
27
+ HR = "\u2500" * 35
28
+ CHART = "\U0001f4ca" # 📊
29
+
30
+ L = {
31
+ 'target': "\u76ee\u6807",
32
+ 'concurrency': "\u5e76\u53d1",
33
+ 'requests': "\u8bf7\u6c42\u6570",
34
+ 'timeout': "\u8d85\u65f6",
35
+ 'results': "\u7ed3\u679c",
36
+ 'metrics': "\u6307\u6807",
37
+ 'value': "\u503c",
38
+ 'total_time': "\u603b\u8017\u65f6",
39
+ 'success': "\u6210\u529f\u8bf7\u6c42",
40
+ 'failed': "\u5931\u8d25\u8bf7\u6c42",
41
+ 'throughput': "\u541e\u5410\u91cf",
42
+ 'avg_latency': "\u5e73\u5747\u5ef6\u8fdf",
43
+ 'min_max': "\u6700\u5c0f/\u6700\u5927",
44
+ 'status_dist': "\u72b6\u6001\u7801\u5206\u5e03",
45
+ 'latency_pct': "\u5ef6\u8fdf\u767e\u5206\u4f4d (ms)",
46
+ 'data': "\u4f20\u8f93\u6570\u636e",
47
+ 'rate': "\u4f20\u8f93\u901f\u7387",
48
+ }
49
+
50
+
51
+ def make_request(url, timeout=10):
52
+ """Make a single HTTP request"""
53
+ start = time.time()
54
+ try:
55
+ req = urllib.request.Request(url, method='GET',
56
+ headers={'User-Agent': 'siege-lite/1.0'})
57
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
58
+ latency = (time.time() - start) * 1000
59
+ content = resp.read()
60
+ return {
61
+ 'success': True,
62
+ 'status': resp.status,
63
+ 'latency_ms': round(latency, 1),
64
+ 'size': len(content),
65
+ }
66
+ except urllib.error.HTTPError as e:
67
+ latency = (time.time() - start) * 1000
68
+ return {
69
+ 'success': True,
70
+ 'status': e.code,
71
+ 'latency_ms': round(latency, 1),
72
+ 'size': 0,
73
+ }
74
+ except Exception as e:
75
+ latency = (time.time() - start) * 1000
76
+ return {
77
+ 'success': False,
78
+ 'status': 0,
79
+ 'latency_ms': round(latency, 1),
80
+ 'error': str(e),
81
+ 'size': 0,
82
+ }
83
+
84
+
85
+ def compute_percentiles(values, pts=None):
86
+ """Compute latency percentiles"""
87
+ if not values:
88
+ return {}
89
+ if pts is None:
90
+ pts = [50, 75, 90, 95, 99, 99.9]
91
+ sorted_vals = sorted(values)
92
+ n = len(sorted_vals)
93
+ result = {}
94
+ for p in pts:
95
+ idx = min(int(n * p / 100), n - 1)
96
+ result[p] = sorted_vals[idx]
97
+ return result
98
+
99
+
100
+ def fmt_duration(seconds):
101
+ if seconds < 1:
102
+ return str(int(seconds * 1000)) + "ms"
103
+ elif seconds < 60:
104
+ return f"{seconds:.1f}s"
105
+ else:
106
+ return f"{int(seconds // 60)}m{seconds % 60:.0f}s"
107
+
108
+
109
+ def run_benchmark(url, concurrency=10, requests=100, timeout=10):
110
+ """Run the benchmark and print results"""
111
+ # Header
112
+ print()
113
+ print(bold("⚡ siege-lite") + " \u2014 HTTP \u538b\u529b\u6d4b\u8bd5")
114
+ print(" " + dim(L['target'] + ":") + " " + url)
115
+ print(" " + dim(L['concurrency'] + ":") + " " + str(concurrency))
116
+ print(" " + dim(L['requests'] + ":") + " " + str(requests))
117
+ print(" " + dim(L['timeout'] + ":") + " " + str(timeout) + "s")
118
+ print()
119
+
120
+ results = []
121
+ latencies = []
122
+ status_counts = Counter()
123
+ errors = 0
124
+
125
+ start_time = time.time()
126
+
127
+ with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor:
128
+ futures = [executor.submit(make_request, url, timeout) for _ in range(requests)]
129
+
130
+ done = 0
131
+ for future in concurrent.futures.as_completed(futures):
132
+ r = future.result()
133
+ results.append(r)
134
+ done += 1
135
+
136
+ if r['success']:
137
+ latencies.append(r['latency_ms'])
138
+ status_counts[r['status']] += 1
139
+ else:
140
+ errors += 1
141
+ status_counts['error'] += 1
142
+
143
+ if done % max(1, requests // 10) == 0 or done == requests:
144
+ pct = done * 100 // requests
145
+ bar = BAR_FULL * (pct // 5) + BAR_EMPTY * (20 - pct // 5)
146
+ sys.stdout.write(f"\r {bar} {done}/{requests} ({pct}%)")
147
+ sys.stdout.flush()
148
+
149
+ total_time = time.time() - start_time
150
+
151
+ print("\n\n" + bold(CHART + " " + L['results']))
152
+
153
+ success_count = sum(1 for r in results if r['success'] and r['status'] < 500)
154
+
155
+ def kv(key, val):
156
+ print(" " + key.ljust(20) + val)
157
+
158
+ kv(L['total_time'], fmt_duration(total_time))
159
+ kv(L['success'], str(success_count) + " " + green(CHECK))
160
+ if errors:
161
+ kv(L['failed'], str(errors) + " " + red(CROSS))
162
+ else:
163
+ kv(L['failed'], "0")
164
+
165
+ if total_time > 0:
166
+ kv(L['throughput'], f"{success_count/total_time:.1f} req/s")
167
+ if latencies:
168
+ kv(L['avg_latency'], f"{statistics.mean(latencies):.1f}ms")
169
+ kv(L['min_max'], f"{min(latencies):.1f}ms / {max(latencies):.1f}ms")
170
+
171
+ # Status codes
172
+ print("\n " + L['status_dist'])
173
+ total_status = sum(status_counts.values()) or 1
174
+ for code, count in sorted(status_counts.items()):
175
+ bar_len = int(count / total_status * 20)
176
+ bar = BAR_FULL * bar_len
177
+ label = str(code) if code != 'error' else red('error')
178
+ print(" " + label.ljust(8) + bar + " " + str(count))
179
+
180
+ # Percentiles
181
+ if latencies:
182
+ p = compute_percentiles(latencies)
183
+ print("\n " + L['latency_pct'])
184
+ print(" " + HR)
185
+ print(" P50 P75 P90 P95 P99 P99.9")
186
+ print(" " + HR)
187
+ print(" " + f"{p[50]:<8.1f}{p.get(75,0):<8.1f}{p[90]:<8.1f}"
188
+ f"{p[95]:<8.1f}{p[99]:<8.1f}{p.get(99.9,0):<8.1f}")
189
+
190
+ # Data transfer
191
+ total_bytes = sum(r.get('size', 0) for r in results)
192
+ if total_bytes > 0:
193
+ mb = total_bytes / 1048576
194
+ print()
195
+ kv(L['data'], f"{mb:.2f} MB")
196
+ if total_time > 0:
197
+ kv(L['rate'], f"{mb/total_time:.2f} MB/s")
198
+
199
+ print()
200
+
201
+ return {
202
+ 'url': url,
203
+ 'concurrency': concurrency,
204
+ 'requests': requests,
205
+ 'total_time': round(total_time, 3),
206
+ 'success_count': success_count,
207
+ 'error_count': errors,
208
+ 'throughput': round(success_count / total_time, 1) if total_time > 0 else 0,
209
+ 'avg_latency': round(statistics.mean(latencies), 1) if latencies else 0,
210
+ 'max_latency': round(max(latencies), 1) if latencies else 0,
211
+ 'min_latency': round(min(latencies), 1) if latencies else 0,
212
+ 'percentiles': {str(k): round(v, 1) for k, v in p.items()},
213
+ 'status_codes': dict(status_counts),
214
+ }
215
+
216
+
217
+ def main():
218
+ import argparse
219
+ parser = argparse.ArgumentParser(description="\u8f7b\u91cf HTTP \u538b\u529b\u6d4b\u8bd5\u5de5\u5177")
220
+ parser.add_argument('url', help="\u76ee\u6807 URL")
221
+ parser.add_argument('-c', '--concurrency', type=int, default=10,
222
+ help="\u5e76\u53d1\u6570 (\u9ed8\u8ba4: 10)")
223
+ parser.add_argument('-n', '--requests', type=int, default=100,
224
+ help="\u8bf7\u6c42\u603b\u6570 (\u9ed8\u8ba4: 100)")
225
+ parser.add_argument('-t', '--timeout', type=int, default=10,
226
+ help="\u8d85\u65f6\u79d2\u6570 (\u9ed8\u8ba4: 10)")
227
+ parser.add_argument('--json', action='store_true',
228
+ help="JSON 格式输出")
229
+ args = parser.parse_args()
230
+
231
+ try:
232
+ result = run_benchmark(
233
+ url=args.url,
234
+ concurrency=args.concurrency,
235
+ requests=args.requests,
236
+ timeout=args.timeout,
237
+ )
238
+
239
+ if args.json:
240
+ print(json.dumps(result, indent=2, ensure_ascii=False))
241
+
242
+ except KeyboardInterrupt:
243
+ print("\n" + yellow("\u6d4b\u8bd5\u88ab\u7528\u6237\u4e2d\u65ad"))
244
+ except Exception as e:
245
+ print("\n" + red(f"\u9519\u8bef: {e}"))
246
+ sys.exit(1)
247
+
248
+
249
+ if __name__ == '__main__':
250
+ main()
@@ -0,0 +1,3 @@
1
+ """siege-lite CLI entry point for `python -m siege_lite`"""
2
+ from siege_lite import main
3
+ main()
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ smellfinder — Python 代码异味检测器
4
+ 静态分析 Python 源码,报告代码质量问题。
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import sys
10
+ import re
11
+ from pathlib import Path
12
+
13
+ # ---- 配置 ----
14
+ MAX_FUNCTION_LINES = 40
15
+ MAX_ARGUMENTS = 5
16
+ MAX_NESTING_DEPTH = 3
17
+ MAX_CLASS_METHODS = 15
18
+ MAX_FILE_LINES = 400
19
+ MIN_DOCSTRING_LENGTH = 10
20
+
21
+ class CodeSmellVisitor(ast.NodeVisitor):
22
+ def __init__(self, source_lines):
23
+ self.source_lines = source_lines
24
+ self.smells = []
25
+ self.depth = 0
26
+ self.current_function = None
27
+ self.current_class = None
28
+
29
+ def add_smell(self, line, severity, category, message):
30
+ self.smells.append({
31
+ 'line': line,
32
+ 'severity': severity, # 'info', 'warning', 'error'
33
+ 'category': category,
34
+ 'message': message,
35
+ })
36
+
37
+ def check_docstring(self, node, kind):
38
+ """Check if node has a proper docstring"""
39
+ if (isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module))
40
+ and node.body and isinstance(node.body[0], ast.Expr)
41
+ and isinstance(node.body[0].value, ast.Constant)
42
+ and isinstance(node.body[0].value.value, str)):
43
+ doc = node.body[0].value.value.strip()
44
+ if len(doc) < MIN_DOCSTRING_LENGTH:
45
+ self.add_smell(node.lineno, 'info', 'docstring',
46
+ f"{kind} 文档字符串过短 ({len(doc)} 字符)")
47
+ return True
48
+ return False
49
+
50
+ def visit_FunctionDef(self, node):
51
+ self._visit_function(node, 'def')
52
+
53
+ def visit_AsyncFunctionDef(self, node):
54
+ self._visit_function(node, 'async def')
55
+
56
+ def _visit_function(self, node, kind):
57
+ old_func = self.current_function
58
+ self.current_function = node.name
59
+
60
+ # Check docstring
61
+ if not self.check_docstring(node, f"函数 {node.name}"):
62
+ self.add_smell(node.lineno, 'warning', 'docstring',
63
+ f"函数 '{node.name}' 缺少文档字符串")
64
+
65
+ # Check arguments
66
+ args = node.args.args + node.args.kwonlyargs
67
+ if node.args.vararg: args.append(node.args.vararg)
68
+ if node.args.kwarg: args.append(node.args.kwarg)
69
+ # Exclude self/cls
70
+ real_args = [a for a in args if a.arg not in ('self', 'cls')]
71
+ if len(real_args) > MAX_ARGUMENTS:
72
+ self.add_smell(node.lineno, 'warning', 'arguments',
73
+ f"函数 '{node.name}' 参数过多 ({len(real_args)} 个,建议 ≤{MAX_ARGUMENTS})")
74
+
75
+ # Check function length
76
+ if hasattr(node, 'end_lineno') and node.end_lineno:
77
+ func_lines = node.end_lineno - node.lineno
78
+ if func_lines > MAX_FUNCTION_LINES:
79
+ self.add_smell(node.lineno, 'warning', 'length',
80
+ f"函数 '{node.name}' 过长 ({func_lines} 行,建议 ≤{MAX_FUNCTION_LINES})")
81
+
82
+ # Check complexity (if/else branches)
83
+ branch_count = sum(1 for n in ast.walk(node)
84
+ if isinstance(n, (ast.If, ast.While, ast.For, ast.AsyncFor,
85
+ ast.ExceptHandler, ast.Try)))
86
+ if branch_count > 12:
87
+ self.add_smell(node.lineno, 'warning', 'complexity',
88
+ f"函数 '{node.name}' 分支过多 ({branch_count} 个控制流分支)")
89
+
90
+ # Visit body (track nesting)
91
+ self.depth += 1
92
+ old_depth = self.depth
93
+ self.generic_visit(node)
94
+ self.depth = old_depth - 1
95
+
96
+ self.current_function = old_func
97
+
98
+ def visit_ClassDef(self, node):
99
+ old_class = self.current_class
100
+ self.current_class = node.name
101
+
102
+ # Check docstring
103
+ if not self.check_docstring(node, f"类 {node.name}"):
104
+ self.add_smell(node.lineno, 'info', 'docstring',
105
+ f"类 '{node.name}' 缺少文档字符串")
106
+
107
+ # Count methods
108
+ methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
109
+ if len(methods) > MAX_CLASS_METHODS:
110
+ self.add_smell(node.lineno, 'warning', 'design',
111
+ f"类 '{node.name}' 方法过多 ({len(methods)} 个,建议 ≤{MAX_CLASS_METHODS})")
112
+
113
+ # Check for __init__
114
+ has_init = any(m.name == '__init__' for m in methods)
115
+ if not has_init:
116
+ self.add_smell(node.lineno, 'info', 'design',
117
+ f"类 '{node.name}' 没有 __init__ 方法")
118
+
119
+ self.generic_visit(node)
120
+ self.current_class = old_class
121
+
122
+ def visit_If(self, node):
123
+ self.depth += 1
124
+ if self.depth > MAX_NESTING_DEPTH and self.current_function:
125
+ self.add_smell(node.lineno, 'warning', 'nesting',
126
+ f"嵌套深度 {self.depth} 层 (在 '{self.current_function}' 中)")
127
+ self.generic_visit(node)
128
+ self.depth -= 1
129
+
130
+ def visit_Try(self, node):
131
+ # Check bare except
132
+ for handler in node.handlers:
133
+ if handler.type is None:
134
+ self.add_smell(handler.lineno, 'error', 'exception',
135
+ "裸 except: 应该指定异常类型")
136
+ self.depth += 1
137
+ if self.depth > MAX_NESTING_DEPTH and self.current_function:
138
+ self.add_smell(node.lineno, 'warning', 'nesting',
139
+ f"嵌套深度 {self.depth} 层 (在 '{self.current_function}' 中)")
140
+ self.generic_visit(node)
141
+ self.depth -= 1
142
+
143
+ def visit_Global(self, node):
144
+ self.add_smell(node.lineno, 'warning', 'design',
145
+ f"使用了全局变量: {', '.join(node.names)}")
146
+
147
+ def visit_Call(self, node):
148
+ # Check for print() in non-script code
149
+ if isinstance(node.func, ast.Name) and node.func.id == 'print':
150
+ self.add_smell(node.lineno, 'info', 'debug',
151
+ "print() 调用 (生产代码中请使用 logging)")
152
+ self.generic_visit(node)
153
+
154
+
155
+ def check_file_patterns(source, filename):
156
+ """Regex-based checks not possible with AST"""
157
+ smells = []
158
+ lines = source.split('\n')
159
+
160
+ for i, line in enumerate(lines, 1):
161
+ stripped = line.strip()
162
+
163
+ # TODO/FIXME/HACK
164
+ if re.search(r'\b(TODO|FIXME|HACK|XXX)\b', stripped):
165
+ match = re.search(r'\b(TODO|FIXME|HACK|XXX)\b', stripped)
166
+ smells.append({
167
+ 'line': i,
168
+ 'severity': 'info',
169
+ 'category': 'marker',
170
+ 'message': f"标记: {match.group(0)}"
171
+ })
172
+
173
+ # Line too long
174
+ if len(line.rstrip('\n')) > 100:
175
+ smells.append({
176
+ 'line': i,
177
+ 'severity': 'warning',
178
+ 'category': 'style',
179
+ 'message': f"行过长 ({len(line.rstrip())} 字符,建议 ≤100)"
180
+ })
181
+
182
+ # Trailing whitespace
183
+ if line.rstrip('\n') != line.rstrip():
184
+ smells.append({
185
+ 'line': i,
186
+ 'severity': 'info',
187
+ 'category': 'style',
188
+ 'message': "行尾有空白字符"
189
+ })
190
+
191
+ return smells
192
+
193
+
194
+ def check_module_level(source, filename):
195
+ """Module-level checks"""
196
+ smells = []
197
+
198
+ # Module docstring
199
+ lines = source.split('\n')
200
+ first_line = lines[0].strip() if lines else ''
201
+ if not (first_line.startswith('"""') or first_line.startswith("'''") or
202
+ first_line.startswith('#!')):
203
+ # Check if there's any top-level comment/docstring
204
+ has_header = False
205
+ for line in lines[:5]:
206
+ if line.strip().startswith(('"""', "'''", '# ')):
207
+ has_header = True
208
+ break
209
+ if not has_header:
210
+ smells.append({
211
+ 'line': 1,
212
+ 'severity': 'info',
213
+ 'category': 'docstring',
214
+ 'message': "文件缺少模块级文档"
215
+ })
216
+
217
+ # File length
218
+ if len(lines) > MAX_FILE_LINES:
219
+ smells.append({
220
+ 'line': len(lines),
221
+ 'severity': 'info',
222
+ 'category': 'length',
223
+ 'message': f"文件过长 ({len(lines)} 行,建议 ≤{MAX_FILE_LINES})"
224
+ })
225
+
226
+ return smells
227
+
228
+
229
+ def analyze_file(filepath):
230
+ """Analyze a single Python file"""
231
+ try:
232
+ with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
233
+ source = f.read()
234
+ except Exception as e:
235
+ return {
236
+ 'file': str(filepath),
237
+ 'error': str(e),
238
+ 'smells': [],
239
+ 'loc': 0,
240
+ }
241
+
242
+ lines = source.split('\n')
243
+ loc = len(lines)
244
+
245
+ # Pattern checks (before AST)
246
+ smells = check_file_patterns(source, filepath)
247
+ smells += check_module_level(source, filepath)
248
+
249
+ # AST analysis
250
+ try:
251
+ tree = ast.parse(source, filename=filepath)
252
+ visitor = CodeSmellVisitor(lines)
253
+ visitor.visit(tree)
254
+ smells += visitor.smells
255
+ except SyntaxError as e:
256
+ smells.append({
257
+ 'line': e.lineno or 0,
258
+ 'severity': 'error',
259
+ 'category': 'syntax',
260
+ 'message': f"语法错误: {e.msg}"
261
+ })
262
+
263
+ # Sort by line
264
+ smells.sort(key=lambda s: s['line'])
265
+
266
+ return {
267
+ 'file': str(filepath),
268
+ 'loc': loc,
269
+ 'smells': smells,
270
+ 'error': None,
271
+ }
272
+
273
+
274
+ def print_report(results, verbose=True):
275
+ """Print formatted report"""
276
+ total_smells = 0
277
+ total_errors = 0
278
+ total_warnings = 0
279
+ total_info = 0
280
+
281
+ print(f"\n{'='*60}")
282
+ print(f" 代码异味检测报告")
283
+ print(f"{'='*60}\n")
284
+
285
+ for result in results:
286
+ if result['error']:
287
+ print(f" ✗ {result['file']} — 错误: {result['error']}")
288
+ continue
289
+
290
+ file_smells = result['smells']
291
+ fn = result['file']
292
+
293
+ # Count severity
294
+ errors = sum(1 for s in file_smells if s['severity'] == 'error')
295
+ warnings = sum(1 for s in file_smells if s['severity'] == 'warning')
296
+ infos = sum(1 for s in file_smells if s['severity'] == 'info')
297
+ total_errors += errors
298
+ total_warnings += warnings
299
+ total_info += infos
300
+
301
+ if not file_smells:
302
+ print(f" ✓ {fn} ({result['loc']} 行) — 干净")
303
+ continue
304
+
305
+ total_smells += len(file_smells)
306
+
307
+ if verbose:
308
+ print(f" {fn} ({result['loc']} 行) — {len(file_smells)} 个异味 "
309
+ f"(🔴{errors} ⚠{warnings} ℹ{infos})")
310
+ for s in file_smells:
311
+ prefix = {
312
+ 'error': ' 🔴',
313
+ 'warning': ' ⚠',
314
+ 'info': ' ℹ',
315
+ }[s['severity']]
316
+ print(f" {prefix} L{s['line']:>4} [{s['category']}] {s['message']}")
317
+ print()
318
+
319
+ # Summary
320
+ print(f"{'='*60}")
321
+ print(f" 摘要: {len(results)} 个文件, {total_smells} 个异味")
322
+ if total_errors:
323
+ print(f" 🔴 错误: {total_errors}")
324
+ if total_warnings:
325
+ print(f" ⚠ 警告: {total_warnings}")
326
+ if total_info:
327
+ print(f" ℹ 提示: {total_info}")
328
+ print(f"{'='*60}\n")
329
+
330
+ return total_smells
331
+
332
+
333
+ def main():
334
+ import argparse
335
+ parser = argparse.ArgumentParser(description='Python 代码异味检测器')
336
+ parser.add_argument('paths', nargs='+', help='文件或目录路径')
337
+ parser.add_argument('-q', '--quiet', action='store_true', help='简洁输出')
338
+ parser.add_argument('--json', action='store_true', help='JSON 格式输出')
339
+ args = parser.parse_args()
340
+
341
+ files = []
342
+ for p in args.paths:
343
+ path = Path(p)
344
+ if path.is_file() and path.suffix == '.py':
345
+ files.append(path)
346
+ elif path.is_dir():
347
+ for f in sorted(path.rglob('*.py')):
348
+ # Skip common non-source dirs
349
+ skip_dirs = {'__pycache__', '.git', 'venv', 'env', '.venv', 'node_modules',
350
+ 'build', 'dist', '.tox', '.mypy_cache', '.pytest_cache'}
351
+ if not any(d in f.parts for d in skip_dirs):
352
+ files.append(f)
353
+
354
+ files = sorted(set(files))
355
+ if not files:
356
+ print("未找到 .py 文件")
357
+ return
358
+
359
+ results = [analyze_file(f) for f in files]
360
+
361
+ if args.json:
362
+ import json
363
+ output = []
364
+ for r in results:
365
+ output.append({
366
+ 'file': r['file'],
367
+ 'loc': r['loc'],
368
+ 'smells': r['smells'],
369
+ })
370
+ print(json.dumps(output, indent=2, ensure_ascii=False))
371
+ else:
372
+ print_report(results, verbose=not args.quiet)
373
+
374
+
375
+ if __name__ == '__main__':
376
+ main()
@@ -0,0 +1,3 @@
1
+ """CLI entry point for `python -m smellfinder`"""
2
+ from smellfinder import main
3
+ main()