sysx-cli 1.0.0__tar.gz → 1.1.0__tar.gz
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_cli-1.0.0/sysx_cli.egg-info → sysx_cli-1.1.0}/PKG-INFO +7 -5
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/README.md +6 -4
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/pyproject.toml +1 -1
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx.py +235 -96
- {sysx_cli-1.0.0 → sysx_cli-1.1.0/sysx_cli.egg-info}/PKG-INFO +7 -5
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/tests/test_sysx.py +229 -2
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/LICENSE +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/setup.cfg +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx_cli.egg-info/SOURCES.txt +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx_cli.egg-info/dependency_links.txt +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx_cli.egg-info/entry_points.txt +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx_cli.egg-info/requires.txt +0 -0
- {sysx_cli-1.0.0 → sysx_cli-1.1.0}/sysx_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sysx-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: 轻量级系统信息与开发辅助 CLI 工具,纯标准库实现,零第三方依赖
|
|
5
5
|
Author-email: maoxian3824 <maoxian3824@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,6 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
# sysx
|
|
37
37
|
|
|
38
|
+
> English version: [README.en.md](README.en.md)
|
|
39
|
+
|
|
38
40
|
> 轻量级系统信息与开发辅助 CLI 工具 — 纯 Python 标准库实现,零第三方依赖。
|
|
39
41
|
|
|
40
42
|
[](https://github.com/maoxian3824/sysx/actions/workflows/test.yml)
|
|
@@ -85,8 +87,8 @@ python3 sysx.py info
|
|
|
85
87
|
### 方式四:从 GitHub Release 安装
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
|
-
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.
|
|
89
|
-
pip install sysx_cli-1.
|
|
90
|
+
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.1.0-py3-none-any.whl
|
|
91
|
+
pip install sysx_cli-1.1.0-py3-none-any.whl
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
## 使用
|
|
@@ -104,7 +106,7 @@ sysx <命令> [选项]
|
|
|
104
106
|
| `disk` | 磁盘挂载点与使用率(按使用率排序) |
|
|
105
107
|
| `proc` | 占用最高的进程,可按 CPU / 内存排序 |
|
|
106
108
|
| `net` | 网络接口、MAC、链路状态、本机 IP、DNS |
|
|
107
|
-
| `ports` | 监听中的 TCP 端口 |
|
|
109
|
+
| `ports` | 监听中的 TCP 与绑定的 UDP 端口 |
|
|
108
110
|
| `env` | 相关环境变量(Shell、语言工具链、容器等) |
|
|
109
111
|
| `uptime` | 运行时长与 1/5/15 分钟负载 |
|
|
110
112
|
| `doctor` | 开发环境体检:工具链、网络、磁盘余量 |
|
|
@@ -216,7 +218,7 @@ sysx ports -j | jq -r '.[].port'
|
|
|
216
218
|
| 磁盘挂载 | `/proc/mounts` + `shutil.disk_usage` |
|
|
217
219
|
| 网络接口 | `/sys/class/net/*` |
|
|
218
220
|
| DNS | `/etc/resolv.conf` |
|
|
219
|
-
|
|
|
221
|
+
| 监听/绑定端口 | `/proc/net/tcp`、`/proc/net/tcp6`、`/proc/net/udp`、`/proc/net/udp6` |
|
|
220
222
|
|
|
221
223
|
**关于 CPU 占用率**:直接读 `/proc/<pid>/stat` 只能拿到进程的累计 CPU 时间,
|
|
222
224
|
除以总和会得出错误结果(长运行进程会被严重高估)。`sysx` 采用两次采样(间隔 250ms)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# sysx
|
|
2
2
|
|
|
3
|
+
> English version: [README.en.md](README.en.md)
|
|
4
|
+
|
|
3
5
|
> 轻量级系统信息与开发辅助 CLI 工具 — 纯 Python 标准库实现,零第三方依赖。
|
|
4
6
|
|
|
5
7
|
[](https://github.com/maoxian3824/sysx/actions/workflows/test.yml)
|
|
@@ -50,8 +52,8 @@ python3 sysx.py info
|
|
|
50
52
|
### 方式四:从 GitHub Release 安装
|
|
51
53
|
|
|
52
54
|
```bash
|
|
53
|
-
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.
|
|
54
|
-
pip install sysx_cli-1.
|
|
55
|
+
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.1.0-py3-none-any.whl
|
|
56
|
+
pip install sysx_cli-1.1.0-py3-none-any.whl
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
## 使用
|
|
@@ -69,7 +71,7 @@ sysx <命令> [选项]
|
|
|
69
71
|
| `disk` | 磁盘挂载点与使用率(按使用率排序) |
|
|
70
72
|
| `proc` | 占用最高的进程,可按 CPU / 内存排序 |
|
|
71
73
|
| `net` | 网络接口、MAC、链路状态、本机 IP、DNS |
|
|
72
|
-
| `ports` | 监听中的 TCP 端口 |
|
|
74
|
+
| `ports` | 监听中的 TCP 与绑定的 UDP 端口 |
|
|
73
75
|
| `env` | 相关环境变量(Shell、语言工具链、容器等) |
|
|
74
76
|
| `uptime` | 运行时长与 1/5/15 分钟负载 |
|
|
75
77
|
| `doctor` | 开发环境体检:工具链、网络、磁盘余量 |
|
|
@@ -181,7 +183,7 @@ sysx ports -j | jq -r '.[].port'
|
|
|
181
183
|
| 磁盘挂载 | `/proc/mounts` + `shutil.disk_usage` |
|
|
182
184
|
| 网络接口 | `/sys/class/net/*` |
|
|
183
185
|
| DNS | `/etc/resolv.conf` |
|
|
184
|
-
|
|
|
186
|
+
| 监听/绑定端口 | `/proc/net/tcp`、`/proc/net/tcp6`、`/proc/net/udp`、`/proc/net/udp6` |
|
|
185
187
|
|
|
186
188
|
**关于 CPU 占用率**:直接读 `/proc/<pid>/stat` 只能拿到进程的累计 CPU 时间,
|
|
187
189
|
除以总和会得出错误结果(长运行进程会被严重高估)。`sysx` 采用两次采样(间隔 250ms)
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
# PyPI 上的发行包名。运行命令仍是 sysx(见 [project.scripts])。
|
|
7
7
|
# 注意:PyPI 上 sysx / systools / systool 均已被占用,故使用 sysx-cli。
|
|
8
8
|
name = "sysx-cli"
|
|
9
|
-
version = "1.
|
|
9
|
+
version = "1.1.0"
|
|
10
10
|
description = "轻量级系统信息与开发辅助 CLI 工具,纯标准库实现,零第三方依赖"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.8"
|
|
@@ -6,17 +6,20 @@ sysx - 轻量级系统信息与开发辅助 CLI 工具
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import ipaddress
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import platform
|
|
13
|
+
import re
|
|
12
14
|
import shutil
|
|
13
15
|
import socket
|
|
14
16
|
import subprocess
|
|
15
17
|
import sys
|
|
16
18
|
import time
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
20
|
from datetime import datetime, timedelta
|
|
18
21
|
|
|
19
|
-
__version__ = "1.
|
|
22
|
+
__version__ = "1.1.0"
|
|
20
23
|
|
|
21
24
|
# 系统级常量,用于 /proc 解析
|
|
22
25
|
try:
|
|
@@ -29,11 +32,15 @@ try:
|
|
|
29
32
|
except (ValueError, OSError, AttributeError):
|
|
30
33
|
PAGE_SIZE = 4096
|
|
31
34
|
|
|
35
|
+
# 是否存在 /proc 文件系统(用于跨平台降级提示)
|
|
36
|
+
HAS_PROC = os.path.isdir("/proc")
|
|
37
|
+
|
|
32
38
|
# ---------------------------------------------------------------- ANSI 颜色
|
|
33
39
|
|
|
34
40
|
class C:
|
|
35
|
-
"""ANSI
|
|
36
|
-
|
|
41
|
+
"""ANSI 颜色。是否启用在运行时由 main() 按当前 stdout 判定,
|
|
42
|
+
而非导入时求值,以兼容 stdout 被重定向/替换的场景。"""
|
|
43
|
+
_enabled = True # 默认启用;main() 启动时会按实际 stdout 重新判定
|
|
37
44
|
|
|
38
45
|
RESET = "\033[0m"
|
|
39
46
|
BOLD = "\033[1m"
|
|
@@ -62,8 +69,10 @@ def _paint(text, color):
|
|
|
62
69
|
# ---------------------------------------------------------------- 工具函数
|
|
63
70
|
|
|
64
71
|
def human_bytes(n):
|
|
65
|
-
"""
|
|
72
|
+
"""把字节数格式化为人类可读形式。负数按 0 处理。"""
|
|
66
73
|
size = float(n)
|
|
74
|
+
if size < 0:
|
|
75
|
+
size = 0.0
|
|
67
76
|
for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
|
|
68
77
|
if size < 1024 or unit == "PB":
|
|
69
78
|
if unit == "B":
|
|
@@ -74,8 +83,10 @@ def human_bytes(n):
|
|
|
74
83
|
|
|
75
84
|
|
|
76
85
|
def human_duration(seconds):
|
|
77
|
-
"""
|
|
86
|
+
"""把秒数格式化为人类可读形式。负值(如时钟回拨)按 0 处理。"""
|
|
78
87
|
seconds = int(seconds)
|
|
88
|
+
if seconds < 0:
|
|
89
|
+
seconds = 0
|
|
79
90
|
days, rem = divmod(seconds, 86400)
|
|
80
91
|
hours, rem = divmod(rem, 3600)
|
|
81
92
|
minutes, secs = divmod(rem, 60)
|
|
@@ -95,27 +106,29 @@ def read_text(path):
|
|
|
95
106
|
try:
|
|
96
107
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
97
108
|
return fh.read()
|
|
98
|
-
except
|
|
109
|
+
except OSError:
|
|
99
110
|
return None
|
|
100
111
|
|
|
101
112
|
|
|
113
|
+
def _disp_width(text):
|
|
114
|
+
"""按显示宽度统计:CJK 等宽字符按 2 计,其余按 1。"""
|
|
115
|
+
return sum(2 if ord(ch) > 0x2E80 else 1 for ch in str(text))
|
|
116
|
+
|
|
117
|
+
|
|
102
118
|
def render_table(headers, rows):
|
|
103
|
-
"""
|
|
119
|
+
"""渲染对齐的文本表格(表头与数据行使用同一套宽度统计)。"""
|
|
104
120
|
if not rows:
|
|
105
121
|
return ""
|
|
106
122
|
cols = len(headers)
|
|
107
|
-
widths = [
|
|
123
|
+
widths = [_disp_width(h) for h in headers]
|
|
108
124
|
for row in rows:
|
|
109
125
|
for i in range(cols):
|
|
110
126
|
cell = str(row[i]) if i < len(row) else ""
|
|
111
|
-
|
|
112
|
-
width = sum(2 if ord(ch) > 0x2E80 else 1 for ch in cell)
|
|
113
|
-
widths[i] = max(widths[i], width)
|
|
127
|
+
widths[i] = max(widths[i], _disp_width(cell))
|
|
114
128
|
|
|
115
129
|
def pad(text, width):
|
|
116
130
|
text = str(text)
|
|
117
|
-
|
|
118
|
-
return text + " " * max(0, width - cur)
|
|
131
|
+
return text + " " * max(0, width - _disp_width(text))
|
|
119
132
|
|
|
120
133
|
out = []
|
|
121
134
|
out.append(_paint(" ".join(pad(h, widths[i]) for i, h in enumerate(headers)),
|
|
@@ -128,11 +141,12 @@ def render_table(headers, rows):
|
|
|
128
141
|
|
|
129
142
|
|
|
130
143
|
def emit(data, as_json, renderer):
|
|
131
|
-
"""统一输出分发:JSON
|
|
144
|
+
"""统一输出分发:JSON 模式打印 JSON,否则调用渲染函数并补一个空行。"""
|
|
132
145
|
if as_json:
|
|
133
146
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
134
147
|
else:
|
|
135
148
|
renderer(data)
|
|
149
|
+
print()
|
|
136
150
|
|
|
137
151
|
|
|
138
152
|
def section(title):
|
|
@@ -143,17 +157,32 @@ def section(title):
|
|
|
143
157
|
|
|
144
158
|
# ---------------------------------------------------------------- info 子命令
|
|
145
159
|
|
|
160
|
+
def _read_boot_time():
|
|
161
|
+
"""从 /proc/stat 的 btime 字段读取真实启动时间(epoch 转 datetime)。"""
|
|
162
|
+
content = read_text("/proc/stat")
|
|
163
|
+
if content:
|
|
164
|
+
for line in content.splitlines():
|
|
165
|
+
if line.startswith("btime "):
|
|
166
|
+
try:
|
|
167
|
+
return datetime.fromtimestamp(int(line.split()[1]))
|
|
168
|
+
except (ValueError, OSError, OverflowError):
|
|
169
|
+
return None
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
146
173
|
def collect_info(args=None):
|
|
147
174
|
uname = platform.uname()
|
|
148
|
-
boot_time =
|
|
175
|
+
boot_time = _read_boot_time()
|
|
149
176
|
uptime_seconds = None
|
|
150
177
|
|
|
151
|
-
# Linux: 从 /proc/uptime
|
|
178
|
+
# Linux: 从 /proc/uptime 取更精确的运行时长;启动时间优先用 btime,
|
|
179
|
+
# 取不到时才用 now - uptime 兜底(后者会受系统时间被修改影响)。
|
|
152
180
|
uptime_raw = read_text("/proc/uptime")
|
|
153
181
|
if uptime_raw:
|
|
154
182
|
try:
|
|
155
183
|
uptime_seconds = float(uptime_raw.split()[0])
|
|
156
|
-
boot_time
|
|
184
|
+
if boot_time is None:
|
|
185
|
+
boot_time = datetime.now() - timedelta(seconds=uptime_seconds)
|
|
157
186
|
except (ValueError, IndexError):
|
|
158
187
|
pass
|
|
159
188
|
|
|
@@ -199,11 +228,31 @@ def _read_distro():
|
|
|
199
228
|
content = read_text("/etc/os-release")
|
|
200
229
|
if not content:
|
|
201
230
|
return None
|
|
231
|
+
# 合并以反斜杠续行的行(os-release 规范允许)
|
|
232
|
+
merged = []
|
|
233
|
+
pending = ""
|
|
234
|
+
for raw in content.splitlines():
|
|
235
|
+
if raw.endswith("\\"):
|
|
236
|
+
pending += raw[:-1]
|
|
237
|
+
else:
|
|
238
|
+
merged.append(pending + raw)
|
|
239
|
+
pending = ""
|
|
240
|
+
if pending:
|
|
241
|
+
merged.append(pending)
|
|
242
|
+
|
|
202
243
|
info = {}
|
|
203
|
-
for line in
|
|
204
|
-
if "=" in line:
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
for line in merged:
|
|
245
|
+
if "=" not in line:
|
|
246
|
+
continue
|
|
247
|
+
key, _, value = line.partition("=")
|
|
248
|
+
key = key.strip()
|
|
249
|
+
value = value.strip()
|
|
250
|
+
# 仅在首尾成对出现时剥离一层引号,避免误伤值内嵌的引号
|
|
251
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
252
|
+
value = value[1:-1]
|
|
253
|
+
# 还原值内的换义符(\n / \t)
|
|
254
|
+
value = value.replace("\\n", "\n").replace("\\t", "\t")
|
|
255
|
+
info[key] = value
|
|
207
256
|
return info.get("PRETTY_NAME") or info.get("NAME")
|
|
208
257
|
|
|
209
258
|
|
|
@@ -291,7 +340,10 @@ def collect_mem(args=None):
|
|
|
291
340
|
|
|
292
341
|
|
|
293
342
|
def render_bar(percent, width=30):
|
|
294
|
-
"""渲染进度条,按用量着色。"""
|
|
343
|
+
"""渲染进度条,按用量着色。percent 会被 clamp 到 [0, 100],None 视为 0。"""
|
|
344
|
+
if percent is None:
|
|
345
|
+
percent = 0.0
|
|
346
|
+
percent = min(max(float(percent), 0.0), 100.0)
|
|
295
347
|
filled = int(width * percent / 100)
|
|
296
348
|
bar = "█" * filled + "░" * (width - filled)
|
|
297
349
|
if percent >= 90:
|
|
@@ -326,17 +378,29 @@ def render_mem(data):
|
|
|
326
378
|
|
|
327
379
|
# ---------------------------------------------------------------- disk 子命令
|
|
328
380
|
|
|
381
|
+
def _mount_rank(mountpoint):
|
|
382
|
+
"""挂载点优先级,用于同一设备多个 bind mount 时择优:
|
|
383
|
+
/ 最优先,其次路径越浅越优先,最后按字典序保证稳定。"""
|
|
384
|
+
if mountpoint == "/":
|
|
385
|
+
return (0, 0, "")
|
|
386
|
+
return (1, mountpoint.count("/"), mountpoint)
|
|
387
|
+
|
|
388
|
+
|
|
329
389
|
def collect_disk(args=None):
|
|
390
|
+
# 容器环境中同一设备常被 bind mount 到多个路径。为每个设备挑选
|
|
391
|
+
# 最"根级"的挂载点(优先 /,其次最浅路径),避免保留 /etc/hosts
|
|
392
|
+
# 这类噪音路径而漏掉真正的根分区。
|
|
393
|
+
best = {}
|
|
394
|
+
for part in parse_proc_mounts():
|
|
395
|
+
dev = part["device"]
|
|
396
|
+
if dev not in best or _mount_rank(part["mountpoint"]) < _mount_rank(best[dev]["mountpoint"]):
|
|
397
|
+
best[dev] = part
|
|
398
|
+
|
|
330
399
|
mounts = []
|
|
331
|
-
|
|
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"])
|
|
400
|
+
for part in best.values():
|
|
337
401
|
try:
|
|
338
402
|
usage = shutil.disk_usage(part["mountpoint"])
|
|
339
|
-
except
|
|
403
|
+
except OSError:
|
|
340
404
|
continue
|
|
341
405
|
percent = (usage.used / usage.total * 100) if usage.total else 0
|
|
342
406
|
mounts.append({
|
|
@@ -352,7 +416,7 @@ def collect_disk(args=None):
|
|
|
352
416
|
return mounts
|
|
353
417
|
|
|
354
418
|
|
|
355
|
-
def
|
|
419
|
+
def parse_proc_mounts():
|
|
356
420
|
"""从 /proc/mounts 解析挂载点,只保留真实块设备。"""
|
|
357
421
|
content = read_text("/proc/mounts")
|
|
358
422
|
if not content:
|
|
@@ -382,17 +446,22 @@ def psutil_like_partitions():
|
|
|
382
446
|
if mountpoint in seen:
|
|
383
447
|
continue
|
|
384
448
|
seen.add(mountpoint)
|
|
385
|
-
|
|
386
|
-
mountpoint = (mountpoint.replace("\\040", " ")
|
|
387
|
-
.replace("\\011", "\t")
|
|
388
|
-
.replace("\\012", "\n"))
|
|
449
|
+
mountpoint = _unescape_mount(mountpoint)
|
|
389
450
|
result.append({"device": device, "mountpoint": mountpoint, "fstype": fstype})
|
|
390
451
|
return result
|
|
391
452
|
|
|
392
453
|
|
|
454
|
+
def _unescape_mount(s):
|
|
455
|
+
"""解码 /proc/mounts 中的八进制转义(\\040=空格, \\011=Tab, \\134=反斜杠)。"""
|
|
456
|
+
return re.sub(r"\\(\d{3})", lambda m: chr(int(m.group(1), 8)), s)
|
|
457
|
+
|
|
458
|
+
|
|
393
459
|
def render_disk(data):
|
|
394
460
|
if not data:
|
|
395
|
-
|
|
461
|
+
if HAS_PROC:
|
|
462
|
+
print("未找到可用的块设备挂载点")
|
|
463
|
+
else:
|
|
464
|
+
print("当前平台不支持磁盘信息(需要 Linux /proc/mounts)")
|
|
396
465
|
return
|
|
397
466
|
rows = []
|
|
398
467
|
for m in data:
|
|
@@ -511,7 +580,8 @@ def collect_proc(limit=10, sort_by="cpu", interval=0.25):
|
|
|
511
580
|
"state": cur["state"],
|
|
512
581
|
"cpu_percent": round(cpu_percent, 1),
|
|
513
582
|
"rss": cur["rss"],
|
|
514
|
-
|
|
583
|
+
# 读不到 MemTotal 时标记为 None,避免把"无数据"误显示成 0%
|
|
584
|
+
"mem_percent": round(cur["rss"] / total_mem * 100, 2) if total_mem else None,
|
|
515
585
|
})
|
|
516
586
|
|
|
517
587
|
key = "rss" if sort_by == "mem" else "cpu_percent"
|
|
@@ -521,7 +591,10 @@ def collect_proc(limit=10, sort_by="cpu", interval=0.25):
|
|
|
521
591
|
|
|
522
592
|
def render_proc(data):
|
|
523
593
|
if not data:
|
|
524
|
-
|
|
594
|
+
if HAS_PROC:
|
|
595
|
+
print("未读到进程信息")
|
|
596
|
+
else:
|
|
597
|
+
print("当前平台不支持进程信息(需要 Linux /proc 文件系统)")
|
|
525
598
|
return
|
|
526
599
|
rows = []
|
|
527
600
|
for p in data:
|
|
@@ -531,7 +604,7 @@ def render_proc(data):
|
|
|
531
604
|
p["state"],
|
|
532
605
|
f"{p['cpu_percent']}%",
|
|
533
606
|
human_bytes(p["rss"]),
|
|
534
|
-
f"{p['mem_percent']}%",
|
|
607
|
+
f"{p['mem_percent']}%" if p["mem_percent"] is not None else "-",
|
|
535
608
|
])
|
|
536
609
|
print(render_table(["PID", "名称", "状态", "CPU", "内存", "内存占比"], rows))
|
|
537
610
|
|
|
@@ -556,10 +629,10 @@ def collect_net(args=None):
|
|
|
556
629
|
if state:
|
|
557
630
|
iface["state"] = state.strip()
|
|
558
631
|
try:
|
|
559
|
-
with open(f"/sys/class/net/{name}/speed") as fh:
|
|
632
|
+
with open(f"/sys/class/net/{name}/speed", encoding="utf-8") as fh:
|
|
560
633
|
speed = int(fh.read().strip())
|
|
561
|
-
#
|
|
562
|
-
iface["speed"] = speed if speed
|
|
634
|
+
# 未连接/虚拟网卡返回负数;内核用 0xFFFFFFFF 表示速率未知
|
|
635
|
+
iface["speed"] = speed if 0 < speed < 0xFFFFFFFF else None
|
|
563
636
|
except (OSError, ValueError):
|
|
564
637
|
pass
|
|
565
638
|
info["interfaces"].append(iface)
|
|
@@ -591,7 +664,7 @@ def collect_net(args=None):
|
|
|
591
664
|
def render_net(data):
|
|
592
665
|
print(_paint(f" 主机名 {data['hostname']}", C.BOLD))
|
|
593
666
|
if data.get("local_ip"):
|
|
594
|
-
print(f"
|
|
667
|
+
print(f" 默认出口 IP {_paint(data['local_ip'], C.GREEN)}")
|
|
595
668
|
if data.get("dns"):
|
|
596
669
|
print(f" DNS {', '.join(data['dns'])}")
|
|
597
670
|
|
|
@@ -643,10 +716,31 @@ def render_env(data):
|
|
|
643
716
|
|
|
644
717
|
# ---------------------------------------------------------------- ports 子命令
|
|
645
718
|
|
|
719
|
+
def _decode_ipv6(addr_hex):
|
|
720
|
+
"""把 /proc/net/*6 的 32 位十六进制 IPv6 地址解码为标准字符串。
|
|
721
|
+
内核按 4 个 u32 存储、每个 u32 内小端,故每组 4 字节需反转。"""
|
|
722
|
+
try:
|
|
723
|
+
raw = bytes.fromhex(addr_hex)
|
|
724
|
+
except ValueError:
|
|
725
|
+
return "?"
|
|
726
|
+
if len(raw) != 16:
|
|
727
|
+
return "?"
|
|
728
|
+
ordered = bytes(b for i in range(0, 16, 4) for b in raw[i:i + 4][::-1])
|
|
729
|
+
return ipaddress.IPv6Address(ordered).compressed
|
|
730
|
+
|
|
731
|
+
|
|
646
732
|
def collect_ports(args=None):
|
|
647
|
-
"""
|
|
733
|
+
"""解析监听/绑定的端口:
|
|
734
|
+
TCP / TCP6 取 LISTEN(状态 0A);UDP / UDP6 取已绑定的未连接
|
|
735
|
+
socket(状态 07,即 UDP 的"监听")。"""
|
|
648
736
|
ports = []
|
|
649
|
-
|
|
737
|
+
specs = (
|
|
738
|
+
("tcp", "/proc/net/tcp", "0A"),
|
|
739
|
+
("tcp6", "/proc/net/tcp6", "0A"),
|
|
740
|
+
("udp", "/proc/net/udp", "07"),
|
|
741
|
+
("udp6", "/proc/net/udp6", "07"),
|
|
742
|
+
)
|
|
743
|
+
for proto, path, listen_state in specs:
|
|
650
744
|
content = read_text(path)
|
|
651
745
|
if not content:
|
|
652
746
|
continue
|
|
@@ -657,7 +751,7 @@ def collect_ports(args=None):
|
|
|
657
751
|
continue
|
|
658
752
|
local = fields[1]
|
|
659
753
|
state = fields[3]
|
|
660
|
-
if state !=
|
|
754
|
+
if state != listen_state:
|
|
661
755
|
continue
|
|
662
756
|
try:
|
|
663
757
|
addr_hex, port_hex = local.rsplit(":", 1)
|
|
@@ -665,13 +759,11 @@ def collect_ports(args=None):
|
|
|
665
759
|
except ValueError:
|
|
666
760
|
continue
|
|
667
761
|
try:
|
|
668
|
-
if proto
|
|
762
|
+
if proto.endswith("6"):
|
|
763
|
+
ip = _decode_ipv6(addr_hex)
|
|
764
|
+
else:
|
|
669
765
|
ip = ".".join(str(int(addr_hex[i:i + 2], 16))
|
|
670
766
|
for i in (6, 4, 2, 0))
|
|
671
|
-
else:
|
|
672
|
-
ip = "::"
|
|
673
|
-
if addr_hex == "0" * 32:
|
|
674
|
-
ip = "[::]"
|
|
675
767
|
except ValueError:
|
|
676
768
|
ip = "?"
|
|
677
769
|
|
|
@@ -682,16 +774,19 @@ def collect_ports(args=None):
|
|
|
682
774
|
"inode": fields[9] if len(fields) > 9 else None,
|
|
683
775
|
})
|
|
684
776
|
|
|
685
|
-
#
|
|
777
|
+
# 去重并按协议+端口排序
|
|
686
778
|
uniq = {}
|
|
687
779
|
for p in ports:
|
|
688
780
|
uniq[(p["proto"], p["port"])] = p
|
|
689
|
-
return sorted(uniq.values(), key=lambda p: p["port"])
|
|
781
|
+
return sorted(uniq.values(), key=lambda p: (p["proto"], p["port"]))
|
|
690
782
|
|
|
691
783
|
|
|
692
784
|
def render_ports(data):
|
|
693
785
|
if not data:
|
|
694
|
-
|
|
786
|
+
if HAS_PROC:
|
|
787
|
+
print("未发现监听/绑定的端口")
|
|
788
|
+
else:
|
|
789
|
+
print("当前平台不支持端口信息(需要 Linux /proc/net)")
|
|
695
790
|
return
|
|
696
791
|
rows = [[p["proto"], p["address"], p["port"]] for p in data]
|
|
697
792
|
print(render_table(["协议", "监听地址", "端口"], rows))
|
|
@@ -739,24 +834,44 @@ def _check_dns():
|
|
|
739
834
|
return False, "所有域名解析均失败"
|
|
740
835
|
|
|
741
836
|
|
|
742
|
-
def
|
|
743
|
-
"""
|
|
837
|
+
def _probe_tcp(target, timeout=3):
|
|
838
|
+
"""探测单个 TCP 目标,返回 (ok, detail)。"""
|
|
839
|
+
host, port = target
|
|
840
|
+
try:
|
|
841
|
+
# 用 with 确保异常路径下 socket 也会被关闭
|
|
842
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
843
|
+
sock.settimeout(timeout)
|
|
844
|
+
sock.connect((host, port))
|
|
845
|
+
return True, f"可访问 {host}:{port}"
|
|
846
|
+
except (OSError, socket.error) as exc:
|
|
847
|
+
return False, str(exc)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _check_network(timeout=3):
|
|
851
|
+
"""多目标 TCP 并发探测,任一可达即认为外网连通。
|
|
852
|
+
并发后断网最坏耗时约 timeout,而非 timeout × 目标数。"""
|
|
744
853
|
targets = [("1.1.1.1", 443), ("8.8.8.8", 53), ("223.5.5.5", 443)]
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 "连接失败"
|
|
854
|
+
with ThreadPoolExecutor(max_workers=len(targets)) as pool:
|
|
855
|
+
results = [f.result() for f in (pool.submit(_probe_tcp, t, timeout)
|
|
856
|
+
for t in targets)]
|
|
857
|
+
for ok, detail in results:
|
|
858
|
+
if ok:
|
|
859
|
+
return True, detail
|
|
860
|
+
detail = next((d for ok, d in results if d), "连接失败")
|
|
757
861
|
return False, detail[:60]
|
|
758
862
|
|
|
759
863
|
|
|
864
|
+
def _is_root():
|
|
865
|
+
"""是否 root;平台不支持(如 Windows 无 geteuid)时返回 None。"""
|
|
866
|
+
geteuid = getattr(os, "geteuid", None)
|
|
867
|
+
if geteuid is None:
|
|
868
|
+
return None
|
|
869
|
+
try:
|
|
870
|
+
return geteuid() == 0
|
|
871
|
+
except OSError:
|
|
872
|
+
return None
|
|
873
|
+
|
|
874
|
+
|
|
760
875
|
def collect_doctor(args=None):
|
|
761
876
|
"""开发环境体检。"""
|
|
762
877
|
checks = []
|
|
@@ -774,15 +889,20 @@ def collect_doctor(args=None):
|
|
|
774
889
|
path = shutil.which(tool)
|
|
775
890
|
check(tool, path is not None, path or "未安装")
|
|
776
891
|
|
|
777
|
-
#
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
892
|
+
# 权限(Windows 无 geteuid,跳过而非误报失败)
|
|
893
|
+
root = _is_root()
|
|
894
|
+
if root is None:
|
|
895
|
+
check("root 权限", True, "当前平台不支持该检查")
|
|
896
|
+
else:
|
|
897
|
+
check("root 权限", root, "当前是 root" if root else "普通用户")
|
|
898
|
+
|
|
899
|
+
# 网络探测:DNS 与外网连通并发执行,缩短断网环境下的等待
|
|
900
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
901
|
+
dns_future = pool.submit(_check_dns)
|
|
902
|
+
net_future = pool.submit(_check_network)
|
|
903
|
+
dns_ok, dns_detail = dns_future.result()
|
|
904
|
+
net_ok, net_detail = net_future.result()
|
|
783
905
|
check("DNS 解析", dns_ok, dns_detail)
|
|
784
|
-
|
|
785
|
-
net_ok, net_detail = _check_network()
|
|
786
906
|
check("外网连通", net_ok, net_detail)
|
|
787
907
|
|
|
788
908
|
# 磁盘余量
|
|
@@ -835,20 +955,22 @@ def build_parser():
|
|
|
835
955
|
)
|
|
836
956
|
parser.add_argument("-V", "--version", action="version",
|
|
837
957
|
version=f"sysx {__version__}")
|
|
958
|
+
parser.add_argument("--debug", action="store_true",
|
|
959
|
+
help="发生错误时打印完整堆栈信息")
|
|
838
960
|
|
|
839
961
|
sub = parser.add_subparsers(dest="command", metavar="<命令>")
|
|
840
962
|
|
|
841
|
-
def add(name, help_text,
|
|
963
|
+
def add(name, help_text, collector, renderer, extra=None):
|
|
842
964
|
p = sub.add_parser(name, help=help_text, description=help_text)
|
|
843
965
|
p.add_argument("-j", "--json", action="store_true",
|
|
844
966
|
help="以 JSON 格式输出")
|
|
845
967
|
if extra:
|
|
846
968
|
extra(p)
|
|
847
|
-
p.set_defaults(
|
|
969
|
+
p.set_defaults(collector=collector, renderer=renderer)
|
|
848
970
|
|
|
849
|
-
add("info", "显示系统概览信息",
|
|
850
|
-
add("mem", "显示内存与交换分区使用情况",
|
|
851
|
-
add("disk", "显示磁盘挂载点与使用率",
|
|
971
|
+
add("info", "显示系统概览信息", collect_info, render_info)
|
|
972
|
+
add("mem", "显示内存与交换分区使用情况", collect_mem, render_mem)
|
|
973
|
+
add("disk", "显示磁盘挂载点与使用率", collect_disk, render_disk)
|
|
852
974
|
|
|
853
975
|
def proc_extra(p):
|
|
854
976
|
p.add_argument("-n", "--limit", type=int, default=10,
|
|
@@ -859,14 +981,14 @@ def build_parser():
|
|
|
859
981
|
def proc_collect(args):
|
|
860
982
|
return collect_proc(limit=args.limit, sort_by=args.sort)
|
|
861
983
|
|
|
862
|
-
add("proc", "显示占用最高的进程",
|
|
984
|
+
add("proc", "显示占用最高的进程", proc_collect, render_proc,
|
|
863
985
|
extra=proc_extra)
|
|
864
986
|
|
|
865
|
-
add("net", "显示网络接口与 DNS 信息",
|
|
866
|
-
add("env", "显示相关环境变量",
|
|
867
|
-
add("ports", "
|
|
868
|
-
add("uptime", "显示系统运行时长与负载",
|
|
869
|
-
add("doctor", "检查开发环境完整性",
|
|
987
|
+
add("net", "显示网络接口与 DNS 信息", collect_net, render_net)
|
|
988
|
+
add("env", "显示相关环境变量", collect_env, render_env)
|
|
989
|
+
add("ports", "显示监听/绑定的 TCP 与 UDP 端口", collect_ports, render_ports)
|
|
990
|
+
add("uptime", "显示系统运行时长与负载", collect_uptime, render_uptime)
|
|
991
|
+
add("doctor", "检查开发环境完整性", collect_doctor, render_doctor)
|
|
870
992
|
|
|
871
993
|
return parser
|
|
872
994
|
|
|
@@ -879,19 +1001,36 @@ def main(argv=None):
|
|
|
879
1001
|
parser.print_help()
|
|
880
1002
|
return 0
|
|
881
1003
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
data = args.collector(args)
|
|
1004
|
+
# 颜色状态在运行时按当前 stdout 判定(而非导入时),兼容 stdout
|
|
1005
|
+
# 被重定向/替换的场景;NO_COLOR 约定依旧生效。
|
|
1006
|
+
C._enabled = bool(sys.stdout.isatty()) and not os.environ.get("NO_COLOR")
|
|
886
1007
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1008
|
+
try:
|
|
1009
|
+
data = args.collector(args)
|
|
1010
|
+
emit(data, args.json, args.renderer)
|
|
1011
|
+
except BrokenPipeError:
|
|
1012
|
+
# 下游管道提前关闭(如 `sysx info | head`):静默优雅退出,
|
|
1013
|
+
# 并把 stdout 重定向到 devnull,避免解释器退出时再次 flush 报错。
|
|
1014
|
+
try:
|
|
1015
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
1016
|
+
except OSError:
|
|
1017
|
+
pass
|
|
1018
|
+
return 0
|
|
1019
|
+
except KeyboardInterrupt:
|
|
1020
|
+
print("\n已取消", file=sys.stderr)
|
|
1021
|
+
return 130
|
|
1022
|
+
except Exception as exc: # 单项失败给友好提示,不暴露 traceback
|
|
1023
|
+
if getattr(args, "debug", False):
|
|
1024
|
+
raise
|
|
1025
|
+
print(f"sysx: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
1026
|
+
print("(加 --debug 可查看完整堆栈)", file=sys.stderr)
|
|
1027
|
+
return 1
|
|
892
1028
|
|
|
893
1029
|
return 0
|
|
894
1030
|
|
|
895
1031
|
|
|
896
1032
|
if __name__ == "__main__":
|
|
897
|
-
|
|
1033
|
+
try:
|
|
1034
|
+
sys.exit(main())
|
|
1035
|
+
except BrokenPipeError:
|
|
1036
|
+
os._exit(0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sysx-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: 轻量级系统信息与开发辅助 CLI 工具,纯标准库实现,零第三方依赖
|
|
5
5
|
Author-email: maoxian3824 <maoxian3824@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,6 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
# sysx
|
|
37
37
|
|
|
38
|
+
> English version: [README.en.md](README.en.md)
|
|
39
|
+
|
|
38
40
|
> 轻量级系统信息与开发辅助 CLI 工具 — 纯 Python 标准库实现,零第三方依赖。
|
|
39
41
|
|
|
40
42
|
[](https://github.com/maoxian3824/sysx/actions/workflows/test.yml)
|
|
@@ -85,8 +87,8 @@ python3 sysx.py info
|
|
|
85
87
|
### 方式四:从 GitHub Release 安装
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
|
-
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.
|
|
89
|
-
pip install sysx_cli-1.
|
|
90
|
+
curl -LO https://github.com/maoxian3824/sysx/releases/latest/download/sysx_cli-1.1.0-py3-none-any.whl
|
|
91
|
+
pip install sysx_cli-1.1.0-py3-none-any.whl
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
## 使用
|
|
@@ -104,7 +106,7 @@ sysx <命令> [选项]
|
|
|
104
106
|
| `disk` | 磁盘挂载点与使用率(按使用率排序) |
|
|
105
107
|
| `proc` | 占用最高的进程,可按 CPU / 内存排序 |
|
|
106
108
|
| `net` | 网络接口、MAC、链路状态、本机 IP、DNS |
|
|
107
|
-
| `ports` | 监听中的 TCP 端口 |
|
|
109
|
+
| `ports` | 监听中的 TCP 与绑定的 UDP 端口 |
|
|
108
110
|
| `env` | 相关环境变量(Shell、语言工具链、容器等) |
|
|
109
111
|
| `uptime` | 运行时长与 1/5/15 分钟负载 |
|
|
110
112
|
| `doctor` | 开发环境体检:工具链、网络、磁盘余量 |
|
|
@@ -216,7 +218,7 @@ sysx ports -j | jq -r '.[].port'
|
|
|
216
218
|
| 磁盘挂载 | `/proc/mounts` + `shutil.disk_usage` |
|
|
217
219
|
| 网络接口 | `/sys/class/net/*` |
|
|
218
220
|
| DNS | `/etc/resolv.conf` |
|
|
219
|
-
|
|
|
221
|
+
| 监听/绑定端口 | `/proc/net/tcp`、`/proc/net/tcp6`、`/proc/net/udp`、`/proc/net/udp6` |
|
|
220
222
|
|
|
221
223
|
**关于 CPU 占用率**:直接读 `/proc/<pid>/stat` 只能拿到进程的累计 CPU 时间,
|
|
222
224
|
除以总和会得出错误结果(长运行进程会被严重高估)。`sysx` 采用两次采样(间隔 250ms)
|
|
@@ -9,13 +9,15 @@ sysx 自动化测试套件
|
|
|
9
9
|
python3 tests/test_sysx.py
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime
|
|
12
14
|
import io
|
|
13
15
|
import json
|
|
14
16
|
import os
|
|
15
17
|
import subprocess
|
|
16
18
|
import sys
|
|
17
19
|
import unittest
|
|
18
|
-
from contextlib import redirect_stdout
|
|
20
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
19
21
|
from unittest import mock
|
|
20
22
|
|
|
21
23
|
# 让测试可以直接 import sysx.py
|
|
@@ -123,6 +125,46 @@ class TestHelpers(unittest.TestCase):
|
|
|
123
125
|
self.assertEqual(sysx.render_bar(0, 10).count("█"), 0)
|
|
124
126
|
self.assertEqual(sysx.render_bar(100, 10).count("█"), 10)
|
|
125
127
|
|
|
128
|
+
def test_render_bar_out_of_range_clamped(self):
|
|
129
|
+
# percent 超出 [0,100] 时必须被 clamp,进度条长度恒等于 width
|
|
130
|
+
sysx.C.disable()
|
|
131
|
+
for bad in (-10, 150, 999):
|
|
132
|
+
with self.subTest(percent=bad):
|
|
133
|
+
bar = sysx.render_bar(bad, width=20)
|
|
134
|
+
self.assertEqual(len(bar), 20)
|
|
135
|
+
# >100 时填满,<0 时为空
|
|
136
|
+
self.assertEqual(sysx.render_bar(150, 10).count("█"), 10)
|
|
137
|
+
self.assertEqual(sysx.render_bar(-5, 10).count("█"), 0)
|
|
138
|
+
|
|
139
|
+
def test_human_duration_negative_clamped(self):
|
|
140
|
+
# 负值(时钟回拨等异常)不应产生 "-1d -1h..." 这类输出
|
|
141
|
+
for raw in (-1, -3600, -999999):
|
|
142
|
+
with self.subTest(raw=raw):
|
|
143
|
+
out = sysx.human_duration(raw)
|
|
144
|
+
self.assertEqual(out, "0s")
|
|
145
|
+
self.assertNotIn("-", out)
|
|
146
|
+
|
|
147
|
+
def test_disp_width(self):
|
|
148
|
+
self.assertEqual(sysx._disp_width("abc"), 3)
|
|
149
|
+
self.assertEqual(sysx._disp_width("名称"), 4) # CJK 双宽
|
|
150
|
+
self.assertEqual(sysx._disp_width("中a"), 3)
|
|
151
|
+
|
|
152
|
+
def test_render_table_cjk_header_width(self):
|
|
153
|
+
# 表头中文应按双宽计算,分隔行该列为 4 个 '-'
|
|
154
|
+
sysx.C.disable()
|
|
155
|
+
out = sysx.render_table(["项目"], [["os"]])
|
|
156
|
+
self.assertEqual(out.splitlines()[1], "----")
|
|
157
|
+
|
|
158
|
+
def test_human_bytes_negative_is_zero(self):
|
|
159
|
+
self.assertEqual(sysx.human_bytes(-1), "0 B")
|
|
160
|
+
self.assertEqual(sysx.human_bytes(-(1024 ** 3)), "0 B")
|
|
161
|
+
|
|
162
|
+
def test_render_bar_none(self):
|
|
163
|
+
sysx.C.disable()
|
|
164
|
+
bar = sysx.render_bar(None, width=10)
|
|
165
|
+
self.assertEqual(len(bar), 10)
|
|
166
|
+
self.assertEqual(bar.count("█"), 0)
|
|
167
|
+
|
|
126
168
|
|
|
127
169
|
class TestColorControl(unittest.TestCase):
|
|
128
170
|
"""颜色控制与 NO_COLOR 约定。"""
|
|
@@ -247,7 +289,7 @@ class TestCollectors(unittest.TestCase):
|
|
|
247
289
|
for item in data:
|
|
248
290
|
self.assertIn("proto", item)
|
|
249
291
|
self.assertIn("port", item)
|
|
250
|
-
self.assertIn(item["proto"], ("tcp", "tcp6"))
|
|
292
|
+
self.assertIn(item["proto"], ("tcp", "tcp6", "udp", "udp6"))
|
|
251
293
|
self.assertGreater(item["port"], 0)
|
|
252
294
|
self.assertLessEqual(item["port"], 65535)
|
|
253
295
|
|
|
@@ -439,5 +481,190 @@ class TestPackaging(unittest.TestCase):
|
|
|
439
481
|
self.assertIn("Repository", urls)
|
|
440
482
|
|
|
441
483
|
|
|
484
|
+
class TestPortParsing(unittest.TestCase):
|
|
485
|
+
"""/proc/net/{tcp,udp} 端口解析的纯逻辑(用 mock 喂假数据)。"""
|
|
486
|
+
|
|
487
|
+
# 注意:IPv4 地址是小端十六进制。0100007F -> 127.0.0.1;00000000 -> 0.0.0.0
|
|
488
|
+
TCP = (
|
|
489
|
+
" sl local_address rem_address st ...\n"
|
|
490
|
+
" 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 111 1\n"
|
|
491
|
+
" 1: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 222 1\n"
|
|
492
|
+
" 2: 0100007F:9999 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 1000 0 333 1\n"
|
|
493
|
+
)
|
|
494
|
+
UDP = (
|
|
495
|
+
" sl local_address rem_address st ...\n"
|
|
496
|
+
" 0: 00000000:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 444 1\n"
|
|
497
|
+
" 1: 0100007F:1234 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 555 1\n"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def run_collect(self, tcp, tcp6=None, udp=None, udp6=None):
|
|
501
|
+
mapping = {
|
|
502
|
+
"/proc/net/tcp": tcp,
|
|
503
|
+
"/proc/net/tcp6": tcp6,
|
|
504
|
+
"/proc/net/udp": udp,
|
|
505
|
+
"/proc/net/udp6": udp6,
|
|
506
|
+
}
|
|
507
|
+
with mock.patch.object(sysx, "read_text",
|
|
508
|
+
side_effect=lambda p: mapping.get(p)):
|
|
509
|
+
return sysx.collect_ports(None)
|
|
510
|
+
|
|
511
|
+
def test_tcp_listen_parsed(self):
|
|
512
|
+
data = self.run_collect(tcp=self.TCP)
|
|
513
|
+
ports = {(p["proto"], p["port"]) for p in data}
|
|
514
|
+
self.assertIn(("tcp", 8080), ports) # 0x1F90
|
|
515
|
+
self.assertIn(("tcp", 80), ports) # 0x0050
|
|
516
|
+
# 状态非 LISTEN(0A) 的 9999(0x9999=39321) 不应出现
|
|
517
|
+
self.assertNotIn(("tcp", 39321), ports)
|
|
518
|
+
|
|
519
|
+
def test_ipv4_address_decoded(self):
|
|
520
|
+
data = self.run_collect(tcp=self.TCP)
|
|
521
|
+
by_port = {p["port"]: p for p in data}
|
|
522
|
+
self.assertEqual(by_port[8080]["address"], "127.0.0.1")
|
|
523
|
+
self.assertEqual(by_port[80]["address"], "0.0.0.0")
|
|
524
|
+
|
|
525
|
+
def test_udp_bound_parsed(self):
|
|
526
|
+
data = self.run_collect(tcp="", udp=self.UDP)
|
|
527
|
+
ports = {(p["proto"], p["port"]) for p in data}
|
|
528
|
+
self.assertIn(("udp", 53), ports) # 0x0035
|
|
529
|
+
# 状态非 07 的 UDP(0x1234=4660) 不应出现
|
|
530
|
+
self.assertNotIn(("udp", 4660), ports)
|
|
531
|
+
|
|
532
|
+
def test_missing_files_skipped(self):
|
|
533
|
+
# 只喂 tcp,其余为 None,不应崩溃,且只产出 tcp
|
|
534
|
+
data = self.run_collect(tcp=self.TCP)
|
|
535
|
+
self.assertTrue(data)
|
|
536
|
+
self.assertTrue(all(p["proto"] == "tcp" for p in data))
|
|
537
|
+
|
|
538
|
+
def test_dedup_same_proto_port(self):
|
|
539
|
+
dup = self.TCP + (
|
|
540
|
+
" 3: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 666 1\n"
|
|
541
|
+
)
|
|
542
|
+
data = self.run_collect(tcp=dup)
|
|
543
|
+
tcp_8080 = [p for p in data if p["proto"] == "tcp" and p["port"] == 8080]
|
|
544
|
+
self.assertEqual(len(tcp_8080), 1)
|
|
545
|
+
|
|
546
|
+
def test_invalid_port_hex_skipped(self):
|
|
547
|
+
broken = (
|
|
548
|
+
" sl local_address rem_address st ...\n"
|
|
549
|
+
" 0: 0100007F:ZZZZ 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n"
|
|
550
|
+
" 1: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1\n"
|
|
551
|
+
)
|
|
552
|
+
data = self.run_collect(tcp=broken)
|
|
553
|
+
self.assertEqual([p["port"] for p in data if p["proto"] == "tcp"], [8080])
|
|
554
|
+
|
|
555
|
+
def test_invalid_ip_hex_yields_question_mark(self):
|
|
556
|
+
broken = (
|
|
557
|
+
" sl local_address rem_address st ...\n"
|
|
558
|
+
" 0: ZZZZZZZZ:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n"
|
|
559
|
+
)
|
|
560
|
+
data = self.run_collect(tcp=broken)
|
|
561
|
+
self.assertEqual(len(data), 1)
|
|
562
|
+
self.assertEqual(data[0]["port"], 8080)
|
|
563
|
+
self.assertEqual(data[0]["address"], "?")
|
|
564
|
+
|
|
565
|
+
def test_decode_ipv6(self):
|
|
566
|
+
self.assertEqual(sysx._decode_ipv6("0" * 32), "::")
|
|
567
|
+
# 内核小端表示的 loopback ::1
|
|
568
|
+
self.assertEqual(sysx._decode_ipv6("00000000000000000000000001000000"), "::1")
|
|
569
|
+
# 长度不足或非法均返回 "?"
|
|
570
|
+
self.assertEqual(sysx._decode_ipv6("00" * 15), "?")
|
|
571
|
+
self.assertEqual(sysx._decode_ipv6("xyz"), "?")
|
|
572
|
+
|
|
573
|
+
def test_unescape_mount(self):
|
|
574
|
+
self.assertEqual(sysx._unescape_mount("/mnt/my\\040disk"), "/mnt/my disk")
|
|
575
|
+
self.assertEqual(sysx._unescape_mount("/a\\134b"), "/a\\b") # \134 = 反斜杠
|
|
576
|
+
self.assertEqual(sysx._unescape_mount("/plain/path"), "/plain/path")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class TestDistroParsing(unittest.TestCase):
|
|
580
|
+
"""/etc/os-release 解析:引号剥离与续行合并。"""
|
|
581
|
+
|
|
582
|
+
def _read(self, content):
|
|
583
|
+
with mock.patch.object(sysx, "read_text", return_value=content):
|
|
584
|
+
return sysx._read_distro()
|
|
585
|
+
|
|
586
|
+
def test_quotes_stripped_once(self):
|
|
587
|
+
content = 'NAME="Ubuntu"\nPRETTY_NAME="Ubuntu 24.04.3 LTS"\n'
|
|
588
|
+
self.assertEqual(self._read(content), "Ubuntu 24.04.3 LTS")
|
|
589
|
+
|
|
590
|
+
def test_unmatched_quote_preserved(self):
|
|
591
|
+
# 仅一端有引号时不应剥离(旧 .strip('"') 会误剥首引号)
|
|
592
|
+
content = 'PRETTY_NAME="Ubuntu\n'
|
|
593
|
+
self.assertEqual(self._read(content), '"Ubuntu')
|
|
594
|
+
|
|
595
|
+
def test_inner_quote_not_stripped(self):
|
|
596
|
+
# 首尾不成对(尾非引号)时,旧 .strip('"') 会误伤首引号
|
|
597
|
+
content = 'PRETTY_NAME="a"b\n'
|
|
598
|
+
self.assertEqual(self._read(content), '"a"b')
|
|
599
|
+
|
|
600
|
+
def test_single_quotes(self):
|
|
601
|
+
content = "PRETTY_NAME='Arch Linux'\n"
|
|
602
|
+
self.assertEqual(self._read(content), "Arch Linux")
|
|
603
|
+
|
|
604
|
+
def test_line_continuation(self):
|
|
605
|
+
# 反斜杠续行应被合并
|
|
606
|
+
content = 'NAME="Debian\\\n GNU/Linux"\n'
|
|
607
|
+
self.assertEqual(self._read(content), "Debian GNU/Linux")
|
|
608
|
+
|
|
609
|
+
def test_pretty_name_preferred(self):
|
|
610
|
+
content = 'NAME="Ubuntu"\nPRETTY_NAME="Ubuntu 24.04"\n'
|
|
611
|
+
self.assertEqual(self._read(content), "Ubuntu 24.04")
|
|
612
|
+
|
|
613
|
+
def test_fallback_to_name(self):
|
|
614
|
+
content = 'NAME="Alpine"\nID=alpine\n'
|
|
615
|
+
self.assertEqual(self._read(content), "Alpine")
|
|
616
|
+
|
|
617
|
+
def test_empty_returns_none(self):
|
|
618
|
+
self.assertIsNone(self._read(""))
|
|
619
|
+
self.assertIsNone(self._read(None))
|
|
620
|
+
|
|
621
|
+
def test_newline_unescape(self):
|
|
622
|
+
# 值内的 \n 应还原为真实换行
|
|
623
|
+
content = 'PRETTY_NAME="Line1\\nLine2"\n'
|
|
624
|
+
self.assertEqual(self._read(content), "Line1\nLine2")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class TestRobustness(unittest.TestCase):
|
|
628
|
+
"""平台判断、启动时间、debug 模式等健壮性逻辑。"""
|
|
629
|
+
|
|
630
|
+
def test_is_root_returns_bool_or_none(self):
|
|
631
|
+
r = sysx._is_root()
|
|
632
|
+
# Linux 返回 bool;无 geteuid 的平台返回 None
|
|
633
|
+
self.assertTrue(r is None or isinstance(r, bool))
|
|
634
|
+
|
|
635
|
+
def test_read_boot_time_parses_btime(self):
|
|
636
|
+
fake = "cpu 1 2 3\nbtime 1700000000\nprocesses 10\n"
|
|
637
|
+
with mock.patch.object(sysx, "read_text", return_value=fake):
|
|
638
|
+
bt = sysx._read_boot_time()
|
|
639
|
+
self.assertEqual(bt, datetime.datetime.fromtimestamp(1700000000))
|
|
640
|
+
|
|
641
|
+
def test_read_boot_time_missing(self):
|
|
642
|
+
with mock.patch.object(sysx, "read_text", return_value=None):
|
|
643
|
+
self.assertIsNone(sysx._read_boot_time())
|
|
644
|
+
|
|
645
|
+
def test_debug_reraises_exception(self):
|
|
646
|
+
def boom(args):
|
|
647
|
+
raise ValueError("boom")
|
|
648
|
+
args = argparse.Namespace(command="info", json=False, debug=True,
|
|
649
|
+
collector=boom, renderer=lambda d: None)
|
|
650
|
+
with mock.patch.object(sysx, "build_parser") as bp:
|
|
651
|
+
bp.return_value.parse_args.return_value = args
|
|
652
|
+
with self.assertRaises(ValueError):
|
|
653
|
+
sysx.main([])
|
|
654
|
+
|
|
655
|
+
def test_no_debug_returns_one_on_error(self):
|
|
656
|
+
def boom(args):
|
|
657
|
+
raise ValueError("boom")
|
|
658
|
+
args = argparse.Namespace(command="info", json=False, debug=False,
|
|
659
|
+
collector=boom, renderer=lambda d: None)
|
|
660
|
+
with mock.patch.object(sysx, "build_parser") as bp:
|
|
661
|
+
bp.return_value.parse_args.return_value = args
|
|
662
|
+
buf = io.StringIO()
|
|
663
|
+
with redirect_stderr(buf):
|
|
664
|
+
rc = sysx.main([])
|
|
665
|
+
self.assertEqual(rc, 1)
|
|
666
|
+
self.assertIn("ValueError", buf.getvalue())
|
|
667
|
+
|
|
668
|
+
|
|
442
669
|
if __name__ == "__main__":
|
|
443
670
|
unittest.main(verbosity=2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|