sysx-cli 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.
sysx.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
sysx - 轻量级系统信息与开发辅助 CLI 工具
|
|
4
|
+
|
|
5
|
+
纯 Python 标准库实现,零第三方依赖。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
# 系统级常量,用于 /proc 解析
|
|
22
|
+
try:
|
|
23
|
+
CLK_TCK = os.sysconf("SC_CLK_TCK")
|
|
24
|
+
except (ValueError, OSError, AttributeError):
|
|
25
|
+
CLK_TCK = 100
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
PAGE_SIZE = os.sysconf("SC_PAGE_SIZE")
|
|
29
|
+
except (ValueError, OSError, AttributeError):
|
|
30
|
+
PAGE_SIZE = 4096
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------- ANSI 颜色
|
|
33
|
+
|
|
34
|
+
class C:
|
|
35
|
+
"""ANSI 颜色。当输出不是 TTY 或设置了 NO_COLOR 时自动禁用。"""
|
|
36
|
+
_enabled = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
37
|
+
|
|
38
|
+
RESET = "\033[0m"
|
|
39
|
+
BOLD = "\033[1m"
|
|
40
|
+
DIM = "\033[2m"
|
|
41
|
+
RED = "\033[31m"
|
|
42
|
+
GREEN = "\033[32m"
|
|
43
|
+
YELLOW = "\033[33m"
|
|
44
|
+
BLUE = "\033[34m"
|
|
45
|
+
MAGENTA = "\033[35m"
|
|
46
|
+
CYAN = "\033[36m"
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def disable(cls):
|
|
50
|
+
cls._enabled = False
|
|
51
|
+
for name in ("RESET", "BOLD", "DIM", "RED", "GREEN",
|
|
52
|
+
"YELLOW", "BLUE", "MAGENTA", "CYAN"):
|
|
53
|
+
setattr(cls, name, "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _paint(text, color):
|
|
57
|
+
if not C._enabled:
|
|
58
|
+
return text
|
|
59
|
+
return f"{color}{text}{C.RESET}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------- 工具函数
|
|
63
|
+
|
|
64
|
+
def human_bytes(n):
|
|
65
|
+
"""把字节数格式化为人类可读形式。"""
|
|
66
|
+
size = float(n)
|
|
67
|
+
for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
|
|
68
|
+
if size < 1024 or unit == "PB":
|
|
69
|
+
if unit == "B":
|
|
70
|
+
return f"{int(size)} {unit}"
|
|
71
|
+
return f"{size:.1f} {unit}"
|
|
72
|
+
size /= 1024
|
|
73
|
+
return f"{size:.1f} PB"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def human_duration(seconds):
|
|
77
|
+
"""把秒数格式化为人类可读形式。"""
|
|
78
|
+
seconds = int(seconds)
|
|
79
|
+
days, rem = divmod(seconds, 86400)
|
|
80
|
+
hours, rem = divmod(rem, 3600)
|
|
81
|
+
minutes, secs = divmod(rem, 60)
|
|
82
|
+
parts = []
|
|
83
|
+
if days:
|
|
84
|
+
parts.append(f"{days}d")
|
|
85
|
+
if hours or days:
|
|
86
|
+
parts.append(f"{hours}h")
|
|
87
|
+
if minutes or hours or days:
|
|
88
|
+
parts.append(f"{minutes}m")
|
|
89
|
+
parts.append(f"{secs}s")
|
|
90
|
+
return " ".join(parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_text(path):
|
|
94
|
+
"""安全读取文本文件,失败返回 None。"""
|
|
95
|
+
try:
|
|
96
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
97
|
+
return fh.read()
|
|
98
|
+
except (OSError, IOError):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_table(headers, rows):
|
|
103
|
+
"""渲染对齐的文本表格。"""
|
|
104
|
+
if not rows:
|
|
105
|
+
return ""
|
|
106
|
+
cols = len(headers)
|
|
107
|
+
widths = [len(str(h)) for h in headers]
|
|
108
|
+
for row in rows:
|
|
109
|
+
for i in range(cols):
|
|
110
|
+
cell = str(row[i]) if i < len(row) else ""
|
|
111
|
+
# 中文等宽字符粗略按 2 个宽度计算
|
|
112
|
+
width = sum(2 if ord(ch) > 0x2E80 else 1 for ch in cell)
|
|
113
|
+
widths[i] = max(widths[i], width)
|
|
114
|
+
|
|
115
|
+
def pad(text, width):
|
|
116
|
+
text = str(text)
|
|
117
|
+
cur = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
|
|
118
|
+
return text + " " * max(0, width - cur)
|
|
119
|
+
|
|
120
|
+
out = []
|
|
121
|
+
out.append(_paint(" ".join(pad(h, widths[i]) for i, h in enumerate(headers)),
|
|
122
|
+
C.BOLD))
|
|
123
|
+
out.append(" ".join("-" * widths[i] for i in range(cols)))
|
|
124
|
+
for row in rows:
|
|
125
|
+
out.append(" ".join(pad(row[i] if i < len(row) else "", widths[i])
|
|
126
|
+
for i in range(cols)))
|
|
127
|
+
return "\n".join(out)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def emit(data, as_json, renderer):
|
|
131
|
+
"""统一输出分发:JSON 模式下打印 JSON,否则调用渲染函数。"""
|
|
132
|
+
if as_json:
|
|
133
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
134
|
+
else:
|
|
135
|
+
renderer(data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def section(title):
|
|
139
|
+
"""打印小节标题。"""
|
|
140
|
+
print()
|
|
141
|
+
print(_paint(f"▸ {title}", C.BOLD + C.CYAN))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------- info 子命令
|
|
145
|
+
|
|
146
|
+
def collect_info(args=None):
|
|
147
|
+
uname = platform.uname()
|
|
148
|
+
boot_time = None
|
|
149
|
+
uptime_seconds = None
|
|
150
|
+
|
|
151
|
+
# Linux: 从 /proc/uptime 取更精确的运行时长
|
|
152
|
+
uptime_raw = read_text("/proc/uptime")
|
|
153
|
+
if uptime_raw:
|
|
154
|
+
try:
|
|
155
|
+
uptime_seconds = float(uptime_raw.split()[0])
|
|
156
|
+
boot_time = datetime.now() - timedelta(seconds=uptime_seconds)
|
|
157
|
+
except (ValueError, IndexError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
load = None
|
|
161
|
+
loadavg = read_text("/proc/loadavg")
|
|
162
|
+
if loadavg:
|
|
163
|
+
parts = loadavg.split()
|
|
164
|
+
if len(parts) >= 3:
|
|
165
|
+
load = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
166
|
+
|
|
167
|
+
cpu_count = os.cpu_count()
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"hostname": socket.gethostname(),
|
|
171
|
+
"os": {
|
|
172
|
+
"system": uname.system,
|
|
173
|
+
"release": uname.release,
|
|
174
|
+
"version": uname.version,
|
|
175
|
+
"machine": uname.machine,
|
|
176
|
+
"distro": _read_distro(),
|
|
177
|
+
},
|
|
178
|
+
"kernel": uname.release,
|
|
179
|
+
"arch": uname.machine,
|
|
180
|
+
"cpu": {
|
|
181
|
+
"model": _read_cpu_model(),
|
|
182
|
+
"cores": cpu_count,
|
|
183
|
+
},
|
|
184
|
+
"python": {
|
|
185
|
+
"version": platform.python_version(),
|
|
186
|
+
"implementation": platform.python_implementation(),
|
|
187
|
+
"executable": sys.executable,
|
|
188
|
+
},
|
|
189
|
+
"uptime": {
|
|
190
|
+
"seconds": uptime_seconds,
|
|
191
|
+
"human": human_duration(uptime_seconds) if uptime_seconds else None,
|
|
192
|
+
"boot_time": boot_time.strftime("%Y-%m-%d %H:%M:%S") if boot_time else None,
|
|
193
|
+
},
|
|
194
|
+
"load_average": load,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_distro():
|
|
199
|
+
content = read_text("/etc/os-release")
|
|
200
|
+
if not content:
|
|
201
|
+
return None
|
|
202
|
+
info = {}
|
|
203
|
+
for line in content.splitlines():
|
|
204
|
+
if "=" in line:
|
|
205
|
+
key, _, value = line.partition("=")
|
|
206
|
+
info[key.strip()] = value.strip().strip('"')
|
|
207
|
+
return info.get("PRETTY_NAME") or info.get("NAME")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_cpu_model():
|
|
211
|
+
content = read_text("/proc/cpuinfo")
|
|
212
|
+
if not content:
|
|
213
|
+
return platform.processor() or None
|
|
214
|
+
for line in content.splitlines():
|
|
215
|
+
if line.lower().startswith("model name"):
|
|
216
|
+
return line.split(":", 1)[1].strip()
|
|
217
|
+
if line.lower().startswith("hardware"): # ARM 平台
|
|
218
|
+
return line.split(":", 1)[1].strip()
|
|
219
|
+
return platform.processor() or None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_info(data):
|
|
223
|
+
print(_paint(f" {data['hostname']}", C.BOLD + C.MAGENTA))
|
|
224
|
+
print()
|
|
225
|
+
|
|
226
|
+
rows = [
|
|
227
|
+
("操作系统", data["os"]["distro"] or data["os"]["system"]),
|
|
228
|
+
("内核版本", data["os"]["release"]),
|
|
229
|
+
("架构", data["os"]["machine"]),
|
|
230
|
+
("CPU", data["cpu"]["model"] or "未知"),
|
|
231
|
+
("CPU 核心", data["cpu"]["cores"]),
|
|
232
|
+
("Python", f"{data['python']['version']} ({data['python']['implementation']})"),
|
|
233
|
+
]
|
|
234
|
+
if data["uptime"]["human"]:
|
|
235
|
+
rows.append(("运行时长", data["uptime"]["human"]))
|
|
236
|
+
if data["uptime"]["boot_time"]:
|
|
237
|
+
rows.append(("启动时间", data["uptime"]["boot_time"]))
|
|
238
|
+
if data["load_average"]:
|
|
239
|
+
rows.append(("负载 (1/5/15m)", " / ".join(f"{v:.2f}" for v in data["load_average"])))
|
|
240
|
+
|
|
241
|
+
print(render_table(["项目", "值"], rows))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- mem 子命令
|
|
245
|
+
|
|
246
|
+
def collect_mem(args=None):
|
|
247
|
+
content = read_text("/proc/meminfo")
|
|
248
|
+
if not content:
|
|
249
|
+
# 非 Linux 平台无 /proc,返回结构化结果而非 None,
|
|
250
|
+
# 保证 JSON 消费方拿到的始终是对象
|
|
251
|
+
return {"supported": False, "reason": "当前平台无 /proc/meminfo"}
|
|
252
|
+
|
|
253
|
+
raw = {}
|
|
254
|
+
for line in content.splitlines():
|
|
255
|
+
if ":" in line:
|
|
256
|
+
key, _, value = line.partition(":")
|
|
257
|
+
value = value.strip().split()[0]
|
|
258
|
+
try:
|
|
259
|
+
raw[key.strip()] = int(value) * 1024 # kB -> bytes
|
|
260
|
+
except ValueError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
total = raw.get("MemTotal", 0)
|
|
264
|
+
free = raw.get("MemFree", 0)
|
|
265
|
+
available = raw.get("MemAvailable", free)
|
|
266
|
+
buffers = raw.get("Buffers", 0)
|
|
267
|
+
cached = raw.get("Cached", 0) + raw.get("SReclaimable", 0)
|
|
268
|
+
used = total - available if total else 0
|
|
269
|
+
percent = (used / total * 100) if total else 0
|
|
270
|
+
|
|
271
|
+
swap_total = raw.get("SwapTotal", 0)
|
|
272
|
+
swap_free = raw.get("SwapFree", 0)
|
|
273
|
+
swap_used = swap_total - swap_free
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"supported": True,
|
|
277
|
+
"total": total,
|
|
278
|
+
"used": used,
|
|
279
|
+
"free": free,
|
|
280
|
+
"available": available,
|
|
281
|
+
"buffers": buffers,
|
|
282
|
+
"cached": cached,
|
|
283
|
+
"percent": round(percent, 1),
|
|
284
|
+
"swap": {
|
|
285
|
+
"total": swap_total,
|
|
286
|
+
"used": swap_used,
|
|
287
|
+
"free": swap_free,
|
|
288
|
+
"percent": round((swap_used / swap_total * 100) if swap_total else 0, 1),
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def render_bar(percent, width=30):
|
|
294
|
+
"""渲染进度条,按用量着色。"""
|
|
295
|
+
filled = int(width * percent / 100)
|
|
296
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
297
|
+
if percent >= 90:
|
|
298
|
+
color = C.RED
|
|
299
|
+
elif percent >= 70:
|
|
300
|
+
color = C.YELLOW
|
|
301
|
+
else:
|
|
302
|
+
color = C.GREEN
|
|
303
|
+
return _paint(bar, color)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def render_mem(data):
|
|
307
|
+
if not data or not data.get("supported", True):
|
|
308
|
+
print(_paint(" 当前平台不支持内存信息读取(需要 Linux /proc)", C.YELLOW))
|
|
309
|
+
return
|
|
310
|
+
section("内存")
|
|
311
|
+
print(f" 总量 {human_bytes(data['total'])}")
|
|
312
|
+
print(f" 已用 {human_bytes(data['used'])} {render_bar(data['percent'])} "
|
|
313
|
+
f"{data['percent']}%")
|
|
314
|
+
print(f" 可用 {human_bytes(data['available'])}")
|
|
315
|
+
print(f" 缓存 {human_bytes(data['cached'])}")
|
|
316
|
+
print(f" 缓冲 {human_bytes(data['buffers'])}")
|
|
317
|
+
|
|
318
|
+
section("交换分区")
|
|
319
|
+
if data["swap"]["total"] == 0:
|
|
320
|
+
print(" 未启用交换分区")
|
|
321
|
+
else:
|
|
322
|
+
print(f" 总量 {human_bytes(data['swap']['total'])}")
|
|
323
|
+
print(f" 已用 {human_bytes(data['swap']['used'])} "
|
|
324
|
+
f"{render_bar(data['swap']['percent'])} {data['swap']['percent']}%")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------- disk 子命令
|
|
328
|
+
|
|
329
|
+
def collect_disk(args=None):
|
|
330
|
+
mounts = []
|
|
331
|
+
seen_devices = set()
|
|
332
|
+
for part in psutil_like_partitions():
|
|
333
|
+
# 容器环境中同一块设备常被 bind mount 到多个路径,按设备去重
|
|
334
|
+
if part["device"] in seen_devices:
|
|
335
|
+
continue
|
|
336
|
+
seen_devices.add(part["device"])
|
|
337
|
+
try:
|
|
338
|
+
usage = shutil.disk_usage(part["mountpoint"])
|
|
339
|
+
except (OSError, IOError):
|
|
340
|
+
continue
|
|
341
|
+
percent = (usage.used / usage.total * 100) if usage.total else 0
|
|
342
|
+
mounts.append({
|
|
343
|
+
"device": part["device"],
|
|
344
|
+
"mountpoint": part["mountpoint"],
|
|
345
|
+
"fstype": part["fstype"],
|
|
346
|
+
"total": usage.total,
|
|
347
|
+
"used": usage.used,
|
|
348
|
+
"free": usage.free,
|
|
349
|
+
"percent": round(percent, 1),
|
|
350
|
+
})
|
|
351
|
+
mounts.sort(key=lambda m: m["percent"], reverse=True)
|
|
352
|
+
return mounts
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def psutil_like_partitions():
|
|
356
|
+
"""从 /proc/mounts 解析挂载点,只保留真实块设备。"""
|
|
357
|
+
content = read_text("/proc/mounts")
|
|
358
|
+
if not content:
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
skip_fs = {
|
|
362
|
+
"proc", "sysfs", "tmpfs", "devtmpfs", "devpts", "cgroup", "cgroup2",
|
|
363
|
+
"overlay", "squashfs", "autofs", "mqueue", "hugetlbfs", "debugfs",
|
|
364
|
+
"tracefs", "securityfs", "pstore", "bpf", "configfs", "fusectl",
|
|
365
|
+
"binfmt_misc", "rpc_pipefs", "nsfs", "ramfs", "efivarfs",
|
|
366
|
+
}
|
|
367
|
+
seen = set()
|
|
368
|
+
result = []
|
|
369
|
+
for line in content.splitlines():
|
|
370
|
+
fields = line.split()
|
|
371
|
+
if len(fields) < 3:
|
|
372
|
+
continue
|
|
373
|
+
device, mountpoint, fstype = fields[0], fields[1], fields[2]
|
|
374
|
+
if fstype in skip_fs:
|
|
375
|
+
continue
|
|
376
|
+
if not device.startswith("/dev/"):
|
|
377
|
+
continue
|
|
378
|
+
# 跳过容器/快照层的噪音挂载点
|
|
379
|
+
if mountpoint.startswith(("/.oldroot", "/.PlnPyKFp4CRfFtgC1",
|
|
380
|
+
"/snap/", "/var/lib/docker/")):
|
|
381
|
+
continue
|
|
382
|
+
if mountpoint in seen:
|
|
383
|
+
continue
|
|
384
|
+
seen.add(mountpoint)
|
|
385
|
+
# 反转义 /proc/mounts 中的八进制转义
|
|
386
|
+
mountpoint = (mountpoint.replace("\\040", " ")
|
|
387
|
+
.replace("\\011", "\t")
|
|
388
|
+
.replace("\\012", "\n"))
|
|
389
|
+
result.append({"device": device, "mountpoint": mountpoint, "fstype": fstype})
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def render_disk(data):
|
|
394
|
+
if not data:
|
|
395
|
+
print("未找到可用的块设备挂载点")
|
|
396
|
+
return
|
|
397
|
+
rows = []
|
|
398
|
+
for m in data:
|
|
399
|
+
rows.append([
|
|
400
|
+
m["mountpoint"],
|
|
401
|
+
m["device"],
|
|
402
|
+
m["fstype"],
|
|
403
|
+
human_bytes(m["total"]),
|
|
404
|
+
human_bytes(m["used"]),
|
|
405
|
+
human_bytes(m["free"]),
|
|
406
|
+
f"{m['percent']}%",
|
|
407
|
+
])
|
|
408
|
+
print(render_table(
|
|
409
|
+
["挂载点", "设备", "类型", "总量", "已用", "可用", "使用率"], rows))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------- proc 子命令
|
|
413
|
+
|
|
414
|
+
def _read_proc_stat(pid_str):
|
|
415
|
+
"""读取单个进程的 stat,返回 (comm, state, cpu_ticks, rss_bytes)。"""
|
|
416
|
+
stat = read_text(f"/proc/{pid_str}/stat")
|
|
417
|
+
if not stat:
|
|
418
|
+
return None
|
|
419
|
+
rparen = stat.rfind(")")
|
|
420
|
+
if rparen < 0:
|
|
421
|
+
return None
|
|
422
|
+
open_paren = stat.find("(")
|
|
423
|
+
if open_paren < 0 or open_paren > rparen:
|
|
424
|
+
return None
|
|
425
|
+
comm = stat[open_paren + 1:rparen]
|
|
426
|
+
rest = stat[rparen + 2:].split()
|
|
427
|
+
if len(rest) < 22:
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
state = rest[0]
|
|
431
|
+
utime = int(rest[11])
|
|
432
|
+
stime = int(rest[12])
|
|
433
|
+
rss_pages = int(rest[21])
|
|
434
|
+
except (ValueError, IndexError):
|
|
435
|
+
return None
|
|
436
|
+
return {
|
|
437
|
+
"name": comm,
|
|
438
|
+
"state": state,
|
|
439
|
+
"cpu_ticks": utime + stime,
|
|
440
|
+
"rss": rss_pages * PAGE_SIZE,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _read_total_cpu_ticks():
|
|
445
|
+
"""读取系统整体累计 CPU tick 数。"""
|
|
446
|
+
content = read_text("/proc/stat")
|
|
447
|
+
if not content:
|
|
448
|
+
return None
|
|
449
|
+
first = content.splitlines()[0]
|
|
450
|
+
if not first.startswith("cpu "):
|
|
451
|
+
return None
|
|
452
|
+
try:
|
|
453
|
+
return sum(int(x) for x in first.split()[1:])
|
|
454
|
+
except ValueError:
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def collect_proc(limit=10, sort_by="cpu", interval=0.25):
|
|
459
|
+
"""采样两次 /proc/stat,计算进程在采样窗口内的真实 CPU 占用率。"""
|
|
460
|
+
num_cpus = os.cpu_count() or 1
|
|
461
|
+
|
|
462
|
+
total_mem = 0
|
|
463
|
+
meminfo = read_text("/proc/meminfo")
|
|
464
|
+
if meminfo:
|
|
465
|
+
for line in meminfo.splitlines():
|
|
466
|
+
if line.startswith("MemTotal:"):
|
|
467
|
+
try:
|
|
468
|
+
total_mem = int(line.split()[1]) * 1024
|
|
469
|
+
except (ValueError, IndexError):
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
def snapshot():
|
|
473
|
+
try:
|
|
474
|
+
pids = [e for e in os.listdir("/proc") if e.isdigit()]
|
|
475
|
+
except OSError:
|
|
476
|
+
return {}
|
|
477
|
+
data = {}
|
|
478
|
+
for pid_str in pids:
|
|
479
|
+
info = _read_proc_stat(pid_str)
|
|
480
|
+
if info is not None:
|
|
481
|
+
data[pid_str] = info
|
|
482
|
+
return data
|
|
483
|
+
|
|
484
|
+
first = snapshot()
|
|
485
|
+
first_total = _read_total_cpu_ticks()
|
|
486
|
+
time.sleep(interval)
|
|
487
|
+
second = snapshot()
|
|
488
|
+
second_total = _read_total_cpu_ticks()
|
|
489
|
+
|
|
490
|
+
if not second:
|
|
491
|
+
return []
|
|
492
|
+
|
|
493
|
+
# 窗口内系统总 tick 增量
|
|
494
|
+
delta_total = 0
|
|
495
|
+
if first_total is not None and second_total is not None:
|
|
496
|
+
delta_total = second_total - first_total
|
|
497
|
+
window_ticks = delta_total if delta_total > 0 else num_cpus * CLK_TCK * interval
|
|
498
|
+
|
|
499
|
+
procs = []
|
|
500
|
+
for pid_str, cur in second.items():
|
|
501
|
+
prev = first.get(pid_str)
|
|
502
|
+
delta = cur["cpu_ticks"] - prev["cpu_ticks"] if prev else cur["cpu_ticks"]
|
|
503
|
+
if delta < 0:
|
|
504
|
+
delta = 0
|
|
505
|
+
# 单核 100% 为满,允许超过 100%(多线程进程)
|
|
506
|
+
cpu_percent = delta / window_ticks * num_cpus * 100 if window_ticks else 0.0
|
|
507
|
+
cpu_percent = min(cpu_percent, num_cpus * 100)
|
|
508
|
+
procs.append({
|
|
509
|
+
"pid": int(pid_str),
|
|
510
|
+
"name": cur["name"],
|
|
511
|
+
"state": cur["state"],
|
|
512
|
+
"cpu_percent": round(cpu_percent, 1),
|
|
513
|
+
"rss": cur["rss"],
|
|
514
|
+
"mem_percent": round(cur["rss"] / total_mem * 100, 2) if total_mem else 0,
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
key = "rss" if sort_by == "mem" else "cpu_percent"
|
|
518
|
+
procs.sort(key=lambda p: p[key], reverse=True)
|
|
519
|
+
return procs[:limit]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def render_proc(data):
|
|
523
|
+
if not data:
|
|
524
|
+
print("未读到进程信息(需要 Linux /proc 文件系统)")
|
|
525
|
+
return
|
|
526
|
+
rows = []
|
|
527
|
+
for p in data:
|
|
528
|
+
rows.append([
|
|
529
|
+
p["pid"],
|
|
530
|
+
p["name"][:28],
|
|
531
|
+
p["state"],
|
|
532
|
+
f"{p['cpu_percent']}%",
|
|
533
|
+
human_bytes(p["rss"]),
|
|
534
|
+
f"{p['mem_percent']}%",
|
|
535
|
+
])
|
|
536
|
+
print(render_table(["PID", "名称", "状态", "CPU", "内存", "内存占比"], rows))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------- net 子命令
|
|
540
|
+
|
|
541
|
+
def collect_net(args=None):
|
|
542
|
+
info = {
|
|
543
|
+
"hostname": socket.gethostname(),
|
|
544
|
+
"interfaces": [],
|
|
545
|
+
"dns": [],
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
# 从 /sys/class/net 读取网卡
|
|
549
|
+
try:
|
|
550
|
+
for name in sorted(os.listdir("/sys/class/net")):
|
|
551
|
+
iface = {"name": name, "address": None, "state": None, "speed": None}
|
|
552
|
+
addr = read_text(f"/sys/class/net/{name}/address")
|
|
553
|
+
if addr:
|
|
554
|
+
iface["address"] = addr.strip()
|
|
555
|
+
state = read_text(f"/sys/class/net/{name}/operstate")
|
|
556
|
+
if state:
|
|
557
|
+
iface["state"] = state.strip()
|
|
558
|
+
try:
|
|
559
|
+
with open(f"/sys/class/net/{name}/speed") as fh:
|
|
560
|
+
speed = int(fh.read().strip())
|
|
561
|
+
# 网卡未连接或虚拟网卡时内核返回负数
|
|
562
|
+
iface["speed"] = speed if speed > 0 else None
|
|
563
|
+
except (OSError, ValueError):
|
|
564
|
+
pass
|
|
565
|
+
info["interfaces"].append(iface)
|
|
566
|
+
except OSError:
|
|
567
|
+
pass
|
|
568
|
+
|
|
569
|
+
# 主机 IP(通过 UDP 连接探测,不会真的发包)
|
|
570
|
+
try:
|
|
571
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
572
|
+
sock.settimeout(0.5)
|
|
573
|
+
sock.connect(("8.8.8.8", 80))
|
|
574
|
+
info["local_ip"] = sock.getsockname()[0]
|
|
575
|
+
except (OSError, socket.error):
|
|
576
|
+
info["local_ip"] = None
|
|
577
|
+
|
|
578
|
+
# DNS 服务器
|
|
579
|
+
resolv = read_text("/etc/resolv.conf")
|
|
580
|
+
if resolv:
|
|
581
|
+
for line in resolv.splitlines():
|
|
582
|
+
line = line.strip()
|
|
583
|
+
if line.startswith("nameserver"):
|
|
584
|
+
parts = line.split()
|
|
585
|
+
if len(parts) >= 2:
|
|
586
|
+
info["dns"].append(parts[1])
|
|
587
|
+
|
|
588
|
+
return info
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def render_net(data):
|
|
592
|
+
print(_paint(f" 主机名 {data['hostname']}", C.BOLD))
|
|
593
|
+
if data.get("local_ip"):
|
|
594
|
+
print(f" 本机 IP {_paint(data['local_ip'], C.GREEN)}")
|
|
595
|
+
if data.get("dns"):
|
|
596
|
+
print(f" DNS {', '.join(data['dns'])}")
|
|
597
|
+
|
|
598
|
+
section("网络接口")
|
|
599
|
+
if not data["interfaces"]:
|
|
600
|
+
print(" 未找到网络接口")
|
|
601
|
+
return
|
|
602
|
+
rows = []
|
|
603
|
+
for i in data["interfaces"]:
|
|
604
|
+
speed = f"{i['speed']} Mb/s" if i.get("speed") else "-"
|
|
605
|
+
state = i.get("state") or "-"
|
|
606
|
+
if state == "up":
|
|
607
|
+
state = _paint(state, C.GREEN)
|
|
608
|
+
elif state == "down":
|
|
609
|
+
state = _paint(state, C.DIM)
|
|
610
|
+
rows.append([i["name"], i.get("address") or "-", state, speed])
|
|
611
|
+
print(render_table(["接口", "MAC 地址", "状态", "速率"], rows))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ---------------------------------------------------------------- env 子命令
|
|
615
|
+
|
|
616
|
+
def collect_env(args=None):
|
|
617
|
+
interesting = [
|
|
618
|
+
"SHELL", "TERM", "LANG", "LC_ALL", "USER", "HOME", "PATH",
|
|
619
|
+
"EDITOR", "VISUAL", "PAGER", "TZ", "PWD", "SSH_CONNECTION",
|
|
620
|
+
"VIRTUAL_ENV", "PYTHONPATH", "NODE_ENV", "GOPATH", "VIRTUAL_ENV_PROMPT",
|
|
621
|
+
]
|
|
622
|
+
found = {}
|
|
623
|
+
for key in interesting:
|
|
624
|
+
value = os.environ.get(key)
|
|
625
|
+
if value:
|
|
626
|
+
found[key] = value
|
|
627
|
+
# 额外收录所有以常见前缀开头的变量
|
|
628
|
+
for key, value in sorted(os.environ.items()):
|
|
629
|
+
if key in found:
|
|
630
|
+
continue
|
|
631
|
+
if key.startswith(("PYTHON", "PIP", "NPM", "CARGO", "GO", "RUST", "JAVA", "DOCKER")):
|
|
632
|
+
found[key] = value
|
|
633
|
+
return found
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def render_env(data):
|
|
637
|
+
if not data:
|
|
638
|
+
print("未找到相关环境变量")
|
|
639
|
+
return
|
|
640
|
+
rows = [[k, v if len(v) < 70 else v[:67] + "..."] for k, v in sorted(data.items())]
|
|
641
|
+
print(render_table(["变量", "值"], rows))
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ---------------------------------------------------------------- ports 子命令
|
|
645
|
+
|
|
646
|
+
def collect_ports(args=None):
|
|
647
|
+
"""从 /proc/net/tcp 解析监听端口。"""
|
|
648
|
+
ports = []
|
|
649
|
+
for proto, path in (("tcp", "/proc/net/tcp"), ("tcp6", "/proc/net/tcp6")):
|
|
650
|
+
content = read_text(path)
|
|
651
|
+
if not content:
|
|
652
|
+
continue
|
|
653
|
+
lines = content.splitlines()[1:] # 跳过表头
|
|
654
|
+
for line in lines:
|
|
655
|
+
fields = line.split()
|
|
656
|
+
if len(fields) < 4:
|
|
657
|
+
continue
|
|
658
|
+
local = fields[1]
|
|
659
|
+
state = fields[3]
|
|
660
|
+
if state != "0A": # 0A = LISTEN
|
|
661
|
+
continue
|
|
662
|
+
try:
|
|
663
|
+
addr_hex, port_hex = local.rsplit(":", 1)
|
|
664
|
+
port = int(port_hex, 16)
|
|
665
|
+
except ValueError:
|
|
666
|
+
continue
|
|
667
|
+
try:
|
|
668
|
+
if proto == "tcp":
|
|
669
|
+
ip = ".".join(str(int(addr_hex[i:i + 2], 16))
|
|
670
|
+
for i in (6, 4, 2, 0))
|
|
671
|
+
else:
|
|
672
|
+
ip = "::"
|
|
673
|
+
if addr_hex == "0" * 32:
|
|
674
|
+
ip = "[::]"
|
|
675
|
+
except ValueError:
|
|
676
|
+
ip = "?"
|
|
677
|
+
|
|
678
|
+
ports.append({
|
|
679
|
+
"proto": proto,
|
|
680
|
+
"address": ip,
|
|
681
|
+
"port": port,
|
|
682
|
+
"inode": fields[9] if len(fields) > 9 else None,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
# 去重并按端口排序
|
|
686
|
+
uniq = {}
|
|
687
|
+
for p in ports:
|
|
688
|
+
uniq[(p["proto"], p["port"])] = p
|
|
689
|
+
return sorted(uniq.values(), key=lambda p: p["port"])
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def render_ports(data):
|
|
693
|
+
if not data:
|
|
694
|
+
print("未发现监听中的 TCP 端口")
|
|
695
|
+
return
|
|
696
|
+
rows = [[p["proto"], p["address"], p["port"]] for p in data]
|
|
697
|
+
print(render_table(["协议", "监听地址", "端口"], rows))
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ---------------------------------------------------------------- uptime 子命令
|
|
701
|
+
|
|
702
|
+
def collect_uptime(args=None):
|
|
703
|
+
raw = read_text("/proc/uptime")
|
|
704
|
+
load = read_text("/proc/loadavg")
|
|
705
|
+
result = {"seconds": None, "human": None, "load": None}
|
|
706
|
+
if raw:
|
|
707
|
+
try:
|
|
708
|
+
result["seconds"] = float(raw.split()[0])
|
|
709
|
+
result["human"] = human_duration(result["seconds"])
|
|
710
|
+
except (ValueError, IndexError):
|
|
711
|
+
pass
|
|
712
|
+
if load:
|
|
713
|
+
parts = load.split()
|
|
714
|
+
if len(parts) >= 3:
|
|
715
|
+
result["load"] = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
716
|
+
return result
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def render_uptime(data):
|
|
720
|
+
if not data["human"]:
|
|
721
|
+
print("无法读取运行时间")
|
|
722
|
+
return
|
|
723
|
+
print(f" 运行时长 {_paint(data['human'], C.BOLD + C.GREEN)}")
|
|
724
|
+
if data["load"]:
|
|
725
|
+
print(f" 负载 {_paint(' / '.join(f'{v:.2f}' for v in data['load']), C.CYAN)}"
|
|
726
|
+
f" {_paint('(1 / 5 / 15 分钟)', C.DIM)}")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ---------------------------------------------------------------- doctor 子命令
|
|
730
|
+
|
|
731
|
+
def _check_dns():
|
|
732
|
+
"""尝试解析几个常见域名,任一成功即认为 DNS 可用。"""
|
|
733
|
+
for host in ("example.com", "github.com", "cloudflare.com"):
|
|
734
|
+
try:
|
|
735
|
+
ip = socket.gethostbyname(host)
|
|
736
|
+
return True, f"{host} → {ip}"
|
|
737
|
+
except (OSError, socket.error):
|
|
738
|
+
continue
|
|
739
|
+
return False, "所有域名解析均失败"
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _check_network():
|
|
743
|
+
"""多目标 TCP 探测,任一可达即认为外网连通。"""
|
|
744
|
+
targets = [("1.1.1.1", 443), ("8.8.8.8", 53), ("223.5.5.5", 443)]
|
|
745
|
+
errors = []
|
|
746
|
+
for host, port in targets:
|
|
747
|
+
try:
|
|
748
|
+
# 用 with 确保异常路径下 socket 也会被关闭
|
|
749
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
750
|
+
sock.settimeout(3)
|
|
751
|
+
sock.connect((host, port))
|
|
752
|
+
return True, f"可访问 {host}:{port}"
|
|
753
|
+
except (OSError, socket.error) as exc:
|
|
754
|
+
errors.append(str(exc))
|
|
755
|
+
continue
|
|
756
|
+
detail = errors[0] if errors else "连接失败"
|
|
757
|
+
return False, detail[:60]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def collect_doctor(args=None):
|
|
761
|
+
"""开发环境体检。"""
|
|
762
|
+
checks = []
|
|
763
|
+
|
|
764
|
+
def check(name, ok, detail=""):
|
|
765
|
+
checks.append({"name": name, "ok": ok, "detail": detail})
|
|
766
|
+
|
|
767
|
+
# Python
|
|
768
|
+
check("Python", True, f"{platform.python_version()} ({sys.executable})")
|
|
769
|
+
|
|
770
|
+
# 常用开发工具
|
|
771
|
+
tools = ["git", "curl", "wget", "gcc", "make", "docker", "node", "npm",
|
|
772
|
+
"go", "rustc", "java", "vim", "tmux"]
|
|
773
|
+
for tool in tools:
|
|
774
|
+
path = shutil.which(tool)
|
|
775
|
+
check(tool, path is not None, path or "未安装")
|
|
776
|
+
|
|
777
|
+
# 权限
|
|
778
|
+
check("root 权限", os.geteuid() == 0,
|
|
779
|
+
"当前是 root" if os.geteuid() == 0 else "普通用户")
|
|
780
|
+
|
|
781
|
+
# 网络探测
|
|
782
|
+
dns_ok, dns_detail = _check_dns()
|
|
783
|
+
check("DNS 解析", dns_ok, dns_detail)
|
|
784
|
+
|
|
785
|
+
net_ok, net_detail = _check_network()
|
|
786
|
+
check("外网连通", net_ok, net_detail)
|
|
787
|
+
|
|
788
|
+
# 磁盘余量
|
|
789
|
+
try:
|
|
790
|
+
usage = shutil.disk_usage("/")
|
|
791
|
+
free_percent = usage.free / usage.total * 100
|
|
792
|
+
check("根分区余量", free_percent > 10,
|
|
793
|
+
f"{human_bytes(usage.free)} 可用 ({free_percent:.1f}%)")
|
|
794
|
+
except OSError:
|
|
795
|
+
pass
|
|
796
|
+
|
|
797
|
+
# 内存余量
|
|
798
|
+
mem = collect_mem()
|
|
799
|
+
if mem and mem.get("supported", True):
|
|
800
|
+
check("内存余量", mem["percent"] < 90,
|
|
801
|
+
f"{human_bytes(mem['available'])} 可用")
|
|
802
|
+
|
|
803
|
+
return checks
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def render_doctor(data):
|
|
807
|
+
if not data:
|
|
808
|
+
print("无检查项")
|
|
809
|
+
return
|
|
810
|
+
passed = sum(1 for c in data if c["ok"])
|
|
811
|
+
for c in data:
|
|
812
|
+
mark = _paint("✓", C.GREEN) if c["ok"] else _paint("✗", C.RED)
|
|
813
|
+
name = c["name"].ljust(14)
|
|
814
|
+
detail = c["detail"]
|
|
815
|
+
if not c["ok"]:
|
|
816
|
+
detail = _paint(detail, C.DIM)
|
|
817
|
+
print(f" {mark} {name} {detail}")
|
|
818
|
+
|
|
819
|
+
print()
|
|
820
|
+
total = len(data)
|
|
821
|
+
color = C.GREEN if passed == total else C.YELLOW
|
|
822
|
+
print(_paint(f" {passed}/{total} 项通过", color + C.BOLD))
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# ---------------------------------------------------------------- CLI 定义
|
|
826
|
+
|
|
827
|
+
def build_parser():
|
|
828
|
+
parser = argparse.ArgumentParser(
|
|
829
|
+
prog="sysx",
|
|
830
|
+
description="轻量级系统信息与开发辅助 CLI 工具",
|
|
831
|
+
epilog="示例: sysx info 显示系统概览\n"
|
|
832
|
+
" sysx mem -j 以 JSON 输出内存信息\n"
|
|
833
|
+
" sysx doctor 检查开发环境",
|
|
834
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
835
|
+
)
|
|
836
|
+
parser.add_argument("-V", "--version", action="version",
|
|
837
|
+
version=f"sysx {__version__}")
|
|
838
|
+
|
|
839
|
+
sub = parser.add_subparsers(dest="command", metavar="<命令>")
|
|
840
|
+
|
|
841
|
+
def add(name, help_text, func, collector, renderer, extra=None):
|
|
842
|
+
p = sub.add_parser(name, help=help_text, description=help_text)
|
|
843
|
+
p.add_argument("-j", "--json", action="store_true",
|
|
844
|
+
help="以 JSON 格式输出")
|
|
845
|
+
if extra:
|
|
846
|
+
extra(p)
|
|
847
|
+
p.set_defaults(func=func, collector=collector, renderer=renderer)
|
|
848
|
+
|
|
849
|
+
add("info", "显示系统概览信息", None, collect_info, render_info)
|
|
850
|
+
add("mem", "显示内存与交换分区使用情况", None, collect_mem, render_mem)
|
|
851
|
+
add("disk", "显示磁盘挂载点与使用率", None, collect_disk, render_disk)
|
|
852
|
+
|
|
853
|
+
def proc_extra(p):
|
|
854
|
+
p.add_argument("-n", "--limit", type=int, default=10,
|
|
855
|
+
help="显示条数 (默认 10)")
|
|
856
|
+
p.add_argument("-s", "--sort", choices=["cpu", "mem"], default="cpu",
|
|
857
|
+
help="排序依据 (默认 cpu)")
|
|
858
|
+
|
|
859
|
+
def proc_collect(args):
|
|
860
|
+
return collect_proc(limit=args.limit, sort_by=args.sort)
|
|
861
|
+
|
|
862
|
+
add("proc", "显示占用最高的进程", None, proc_collect, render_proc,
|
|
863
|
+
extra=proc_extra)
|
|
864
|
+
|
|
865
|
+
add("net", "显示网络接口与 DNS 信息", None, collect_net, render_net)
|
|
866
|
+
add("env", "显示相关环境变量", None, collect_env, render_env)
|
|
867
|
+
add("ports", "显示监听中的 TCP 端口", None, collect_ports, render_ports)
|
|
868
|
+
add("uptime", "显示系统运行时长与负载", None, collect_uptime, render_uptime)
|
|
869
|
+
add("doctor", "检查开发环境完整性", None, collect_doctor, render_doctor)
|
|
870
|
+
|
|
871
|
+
return parser
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def main(argv=None):
|
|
875
|
+
parser = build_parser()
|
|
876
|
+
args = parser.parse_args(argv)
|
|
877
|
+
|
|
878
|
+
if not args.command:
|
|
879
|
+
parser.print_help()
|
|
880
|
+
return 0
|
|
881
|
+
|
|
882
|
+
if getattr(args, "json", False) and not sys.stdout.isatty():
|
|
883
|
+
C.disable()
|
|
884
|
+
|
|
885
|
+
data = args.collector(args)
|
|
886
|
+
|
|
887
|
+
if args.json:
|
|
888
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
889
|
+
else:
|
|
890
|
+
args.renderer(data)
|
|
891
|
+
print()
|
|
892
|
+
|
|
893
|
+
return 0
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
if __name__ == "__main__":
|
|
897
|
+
sys.exit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sysx-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 轻量级系统信息与开发辅助 CLI 工具,纯标准库实现,零第三方依赖
|
|
5
|
+
Author-email: maoxian3824 <maoxian3824@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/maoxian3824/sysx
|
|
8
|
+
Project-URL: Repository, https://github.com/maoxian3824/sysx
|
|
9
|
+
Project-URL: Documentation, https://github.com/maoxian3824/sysx#readme
|
|
10
|
+
Project-URL: Changelog, https://github.com/maoxian3824/sysx/blob/master/CHANGELOG.md
|
|
11
|
+
Project-URL: Issues, https://github.com/maoxian3824/sysx/issues
|
|
12
|
+
Keywords: cli,system,monitoring,devops,procfs,sysinfo,console
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Topic :: System :: Monitoring
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.8
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# sysx
|
|
37
|
+
|
|
38
|
+
> 轻量级系统信息与开发辅助 CLI 工具 — 纯 Python 标准库实现,零第三方依赖。
|
|
39
|
+
|
|
40
|
+
[](https://github.com/maoxian3824/sysx/actions/workflows/test.yml)
|
|
41
|
+
[](https://pypi.org/project/sysx-cli/)
|
|
42
|
+
[](https://www.python.org/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
[](#)
|
|
45
|
+
|
|
46
|
+
`sysx` 把日常排查系统状态时零散用到的 `top`、`df`、`free`、`ip`、`ss` 等命令,
|
|
47
|
+
收敛成一套统一、可读、支持 JSON 输出的子命令集。适合放进 CI 脚本、容器镜像,
|
|
48
|
+
或者单纯想少记几个命令参数的时候。
|
|
49
|
+
|
|
50
|
+
## 特性
|
|
51
|
+
|
|
52
|
+
- **零依赖** — 只用 Python 标准库,`pip install` 不需要拉任何第三方包
|
|
53
|
+
- **JSON 输出** — 每个子命令都支持 `-j`,方便喂给 `jq` 或脚本解析
|
|
54
|
+
- **自动配色** — 输出到终端时着色;重定向到文件或管道时自动禁用
|
|
55
|
+
- **只读安全** — 全部操作仅读取 `/proc`、`/sys`,不做任何修改
|
|
56
|
+
- **跨平台降级** — Linux 下功能最全,macOS / Windows 下优雅降级不报错
|
|
57
|
+
|
|
58
|
+
## 安装
|
|
59
|
+
|
|
60
|
+
> **包名 vs 命令名**:PyPI 发行名是 `sysx-cli`(`sysx` 已被占用),
|
|
61
|
+
> 但安装后的运行命令仍是 `sysx`。
|
|
62
|
+
|
|
63
|
+
### 方式一:pip 安装(推荐)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install sysx-cli
|
|
67
|
+
sysx info
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 方式二:直接运行单文件(无需安装)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
curl -LO https://raw.githubusercontent.com/maoxian3824/sysx/master/sysx.py
|
|
74
|
+
python3 sysx.py info
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 方式三:从源码
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/maoxian3824/sysx.git
|
|
81
|
+
cd sysx
|
|
82
|
+
python3 sysx.py info
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 方式四:从 GitHub Release 安装
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.0.0-py3-none-any.whl
|
|
89
|
+
pip install sysx_cli-1.0.0-py3-none-any.whl
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 使用
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
sysx <命令> [选项]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 子命令一览
|
|
99
|
+
|
|
100
|
+
| 命令 | 说明 |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `info` | 系统概览:OS、内核、CPU、内存、运行时长、负载 |
|
|
103
|
+
| `mem` | 内存与交换分区使用情况(带进度条) |
|
|
104
|
+
| `disk` | 磁盘挂载点与使用率(按使用率排序) |
|
|
105
|
+
| `proc` | 占用最高的进程,可按 CPU / 内存排序 |
|
|
106
|
+
| `net` | 网络接口、MAC、链路状态、本机 IP、DNS |
|
|
107
|
+
| `ports` | 监听中的 TCP 端口 |
|
|
108
|
+
| `env` | 相关环境变量(Shell、语言工具链、容器等) |
|
|
109
|
+
| `uptime` | 运行时长与 1/5/15 分钟负载 |
|
|
110
|
+
| `doctor` | 开发环境体检:工具链、网络、磁盘余量 |
|
|
111
|
+
|
|
112
|
+
### 示例
|
|
113
|
+
|
|
114
|
+
**系统概览**
|
|
115
|
+
|
|
116
|
+
```console
|
|
117
|
+
$ sysx info
|
|
118
|
+
16b3429714a6
|
|
119
|
+
|
|
120
|
+
项目 值
|
|
121
|
+
-------------- -------------------------------
|
|
122
|
+
操作系统 Ubuntu 24.04.3 LTS
|
|
123
|
+
内核版本 6.6.117-45.11.6.tl4.x86_64
|
|
124
|
+
架构 x86_64
|
|
125
|
+
CPU AMD EPYC 9K84 96-Core Processor
|
|
126
|
+
CPU 核心 32
|
|
127
|
+
Python 3.11.1 (CPython)
|
|
128
|
+
运行时长 1h 41m 33s
|
|
129
|
+
启动时间 2026-10-06 17:45:39
|
|
130
|
+
负载 (1/5/15m) 2.78 / 2.16 / 2.24
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**内存**
|
|
134
|
+
|
|
135
|
+
```console
|
|
136
|
+
$ sysx mem
|
|
137
|
+
|
|
138
|
+
▸ 内存
|
|
139
|
+
总量 123.3 GB
|
|
140
|
+
已用 16.5 GB ████░░░░░░░░░░░░░░░░░░░░░░░░░░ 13.4%
|
|
141
|
+
可用 106.8 GB
|
|
142
|
+
缓存 97.0 GB
|
|
143
|
+
缓冲 1.4 GB
|
|
144
|
+
|
|
145
|
+
▸ 交换分区
|
|
146
|
+
未启用交换分区
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**进程(按 CPU 排序)**
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
$ sysx proc -n 5
|
|
153
|
+
PID 名称 状态 CPU 内存 内存占比
|
|
154
|
+
---- --------------- -- ---- -------- -----
|
|
155
|
+
1323 envd S 8.0% 15.9 MB 0.01%
|
|
156
|
+
62 supervisord S 0.0% 13.8 MB 0.01%
|
|
157
|
+
91 node S 0.0% 55.7 MB 0.04%
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**开发环境体检**
|
|
161
|
+
|
|
162
|
+
```console
|
|
163
|
+
$ sysx doctor
|
|
164
|
+
✓ Python 3.11.1 (/usr/bin/python3)
|
|
165
|
+
✓ git /usr/bin/git
|
|
166
|
+
✓ docker /usr/local/bin/docker
|
|
167
|
+
✗ tmux 未安装
|
|
168
|
+
✓ root 权限 当前是 root
|
|
169
|
+
✓ DNS 解析 example.com → 172.66.147.243
|
|
170
|
+
✓ 外网连通 可访问 8.8.8.8:53
|
|
171
|
+
✓ 根分区余量 254.0 GB 可用 (99.2%)
|
|
172
|
+
|
|
173
|
+
18/19 项通过
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**JSON 输出(配合 jq)**
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# 内存占用百分比
|
|
180
|
+
sysx mem -j | jq '.percent'
|
|
181
|
+
|
|
182
|
+
# 找出最占内存的 3 个进程名
|
|
183
|
+
sysx proc -j -n 3 -s mem | jq -r '.[].name'
|
|
184
|
+
|
|
185
|
+
# 列出所有监听端口号
|
|
186
|
+
sysx ports -j | jq -r '.[].port'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## 选项
|
|
190
|
+
|
|
191
|
+
| 选项 | 适用命令 | 说明 |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `-j, --json` | 全部 | 以 JSON 格式输出 |
|
|
194
|
+
| `-n, --limit N` | `proc` | 显示条数(默认 10) |
|
|
195
|
+
| `-s, --sort {cpu,mem}` | `proc` | 排序依据(默认 cpu) |
|
|
196
|
+
| `-V, --version` | 全局 | 显示版本号 |
|
|
197
|
+
| `-h, --help` | 全局 | 显示帮助 |
|
|
198
|
+
|
|
199
|
+
## 环境变量
|
|
200
|
+
|
|
201
|
+
| 变量 | 作用 |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `NO_COLOR` | 设置后禁用彩色输出(遵循 [no-color.org](https://no-color.org/) 约定) |
|
|
204
|
+
|
|
205
|
+
## 实现说明
|
|
206
|
+
|
|
207
|
+
`sysx` 不依赖 `psutil` 等第三方库,直接解析内核暴露的接口:
|
|
208
|
+
|
|
209
|
+
| 数据 | 来源 |
|
|
210
|
+
|---|---|
|
|
211
|
+
| 内存 | `/proc/meminfo` |
|
|
212
|
+
| 运行时长 | `/proc/uptime` |
|
|
213
|
+
| 负载 | `/proc/loadavg` |
|
|
214
|
+
| 进程列表 | `/proc/<pid>/stat` |
|
|
215
|
+
| CPU 占用率 | 两次采样 `/proc/stat` 计算增量 |
|
|
216
|
+
| 磁盘挂载 | `/proc/mounts` + `shutil.disk_usage` |
|
|
217
|
+
| 网络接口 | `/sys/class/net/*` |
|
|
218
|
+
| DNS | `/etc/resolv.conf` |
|
|
219
|
+
| 监听端口 | `/proc/net/tcp`、`/proc/net/tcp6` |
|
|
220
|
+
|
|
221
|
+
**关于 CPU 占用率**:直接读 `/proc/<pid>/stat` 只能拿到进程的累计 CPU 时间,
|
|
222
|
+
除以总和会得出错误结果(长运行进程会被严重高估)。`sysx` 采用两次采样(间隔 250ms)
|
|
223
|
+
计算增量占比,结果与 `top` 一致。
|
|
224
|
+
|
|
225
|
+
**容器环境适配**:在 Docker / Kubernetes 中,`/proc/mounts` 会包含大量 bind mount,
|
|
226
|
+
同一块设备重复出现几十次。`sysx` 按设备去重并过滤快照层挂载点,输出干净的磁盘列表。
|
|
227
|
+
|
|
228
|
+
## 兼容性
|
|
229
|
+
|
|
230
|
+
| 平台 | 状态 |
|
|
231
|
+
|---|---|
|
|
232
|
+
| Linux | 完整支持 |
|
|
233
|
+
| macOS | 部分支持(无 `/proc`,`info`/`disk`/`doctor` 可用) |
|
|
234
|
+
| Windows | 基础支持(`info`/`doctor` 可用) |
|
|
235
|
+
|
|
236
|
+
## 开发
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# 运行测试套件(46 个用例,纯标准库 unittest)
|
|
240
|
+
python3 -m unittest discover -s tests -v
|
|
241
|
+
|
|
242
|
+
# 严格模式:资源泄漏视为错误
|
|
243
|
+
python3 -W error::ResourceWarning -m unittest discover -s tests
|
|
244
|
+
|
|
245
|
+
# 构建并校验
|
|
246
|
+
python3 -m build
|
|
247
|
+
python3 -m twine check dist/* # PyPI 元数据校验
|
|
248
|
+
python3 scripts/check_no_deps.py # 零依赖校验
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
CI 在 Python 3.8 – 3.12 矩阵及 macOS 上自动运行上述检查,
|
|
252
|
+
详见 [.github/workflows/test.yml](.github/workflows/test.yml)。
|
|
253
|
+
|
|
254
|
+
发布到 PyPI 的完整流程见 [PUBLISHING.md](PUBLISHING.md)。
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sysx.py,sha256=92ZRhv0JzqeOMRZsM6_1zPx7RNb1LetQT9pni1jLZps,28777
|
|
2
|
+
sysx_cli-1.0.0.dist-info/licenses/LICENSE,sha256=U7o0IcPxSPyYPZ-uRxlXQdZsT2ifj53qEkMEaiFEis4,1068
|
|
3
|
+
sysx_cli-1.0.0.dist-info/METADATA,sha256=DKxYzN41pQActgpMOYQ6qEzPBNSrfEOLc-VlDWDbu8c,8266
|
|
4
|
+
sysx_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
sysx_cli-1.0.0.dist-info/entry_points.txt,sha256=0Bk1_a--CjkWP9pgMV9mtUmRTNfaMPtqM-UFN6K2L1k,35
|
|
6
|
+
sysx_cli-1.0.0.dist-info/top_level.txt,sha256=ipl8m5TWFrWuGLBTdRqRh8IFOcomm_VQfiWzpfPdLIg,5
|
|
7
|
+
sysx_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 maoxian3824
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sysx
|