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.
- evolver_tools/__init__.py +2 -0
- evolver_tools/__main__.py +3 -0
- evolver_tools/cli.py +89 -0
- evolver_tools/vendor/b64/__init__.py +2 -0
- evolver_tools/vendor/b64/b64.py +176 -0
- evolver_tools/vendor/cal_tool/__init__.py +1 -0
- evolver_tools/vendor/cal_tool/cli.py +234 -0
- evolver_tools/vendor/chart_cli/__init__.py +444 -0
- evolver_tools/vendor/chart_cli/__main__.py +3 -0
- evolver_tools/vendor/colors/__init__.py +5 -0
- evolver_tools/vendor/colors/__main__.py +97 -0
- evolver_tools/vendor/csv_stats/__init__.py +5 -0
- evolver_tools/vendor/csv_stats/__main__.py +4 -0
- evolver_tools/vendor/csv_stats/analyzer.py +258 -0
- evolver_tools/vendor/csv_stats/cli.py +45 -0
- evolver_tools/vendor/dirsize/__init__.py +183 -0
- evolver_tools/vendor/envcheck/__init__.py +426 -0
- evolver_tools/vendor/ff/__init__.py +427 -0
- evolver_tools/vendor/ff/__main__.py +3 -0
- evolver_tools/vendor/find_dups/__init__.py +7 -0
- evolver_tools/vendor/find_dups/cli.py +392 -0
- evolver_tools/vendor/hashsum/__init__.py +211 -0
- evolver_tools/vendor/hashsum/__main__.py +5 -0
- evolver_tools/vendor/http_live/__init__.py +265 -0
- evolver_tools/vendor/http_live/__main__.py +2 -0
- evolver_tools/vendor/ipinfo/__init__.py +3 -0
- evolver_tools/vendor/ipinfo/__main__.py +30 -0
- evolver_tools/vendor/jq_lite/__init__.py +257 -0
- evolver_tools/vendor/jq_lite/__main__.py +5 -0
- evolver_tools/vendor/json2csv/__init__.py +3 -0
- evolver_tools/vendor/json2csv/__main__.py +82 -0
- evolver_tools/vendor/jsonql/__init__.py +326 -0
- evolver_tools/vendor/jsonql/__main__.py +5 -0
- evolver_tools/vendor/license_cli/__init__.py +1 -0
- evolver_tools/vendor/license_cli/__main__.py +4 -0
- evolver_tools/vendor/license_cli/cli.py +289 -0
- evolver_tools/vendor/markdown_check/__init__.py +211 -0
- evolver_tools/vendor/nb/__init__.py +319 -0
- evolver_tools/vendor/nb/__main__.py +3 -0
- evolver_tools/vendor/passgen/__init__.py +224 -0
- evolver_tools/vendor/portcheck/__init__.py +2 -0
- evolver_tools/vendor/portcheck/__main__.py +66 -0
- evolver_tools/vendor/project_doctor/__init__.py +412 -0
- evolver_tools/vendor/project_doctor/__main__.py +3 -0
- evolver_tools/vendor/ren/__init__.py +283 -0
- evolver_tools/vendor/ren/__main__.py +3 -0
- evolver_tools/vendor/siege_lite/__init__.py +250 -0
- evolver_tools/vendor/siege_lite/__main__.py +3 -0
- evolver_tools/vendor/smellfinder/__init__.py +376 -0
- evolver_tools/vendor/smellfinder/__main__.py +3 -0
- evolver_tools/vendor/sqlite_cli/__init__.py +326 -0
- evolver_tools/vendor/sqlite_cli/__main__.py +5 -0
- evolver_tools/vendor/sysmon/__init__.py +299 -0
- evolver_tools/vendor/sysmon/__main__.py +3 -0
- evolver_tools/vendor/timer/__init__.py +127 -0
- evolver_tools/vendor/treedir/__init__.py +2 -0
- evolver_tools/vendor/treedir/__main__.py +128 -0
- evolver_tools/vendor/urlparse_tool/__init__.py +3 -0
- evolver_tools/vendor/urlparse_tool/cli.py +212 -0
- evolver_tools/vendor/web_summary/__init__.py +341 -0
- evolver_tools/vendor/web_summary/__main__.py +3 -0
- evolver_tools/vendor/wordcount/__init__.py +2 -0
- evolver_tools/vendor/wordcount/__main__.py +101 -0
- evolver_tools-1.4.0.dist-info/METADATA +107 -0
- evolver_tools-1.4.0.dist-info/RECORD +69 -0
- evolver_tools-1.4.0.dist-info/WHEEL +5 -0
- evolver_tools-1.4.0.dist-info/entry_points.txt +34 -0
- evolver_tools-1.4.0.dist-info/licenses/LICENSE +21 -0
- evolver_tools-1.4.0.dist-info/top_level.txt +1 -0
|
@@ -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,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()
|