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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sysx-cli
3
- Version: 1.0.0
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
  [![Tests](https://github.com/maoxian3824/sysx/actions/workflows/test.yml/badge.svg)](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.0.0-py3-none-any.whl
89
- pip install sysx_cli-1.0.0-py3-none-any.whl
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
- | 监听端口 | `/proc/net/tcp`、`/proc/net/tcp6` |
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
  [![Tests](https://github.com/maoxian3824/sysx/actions/workflows/test.yml/badge.svg)](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.0.0-py3-none-any.whl
54
- pip install sysx_cli-1.0.0-py3-none-any.whl
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
- | 监听端口 | `/proc/net/tcp`、`/proc/net/tcp6` |
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.0.0"
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.0.0"
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 颜色。当输出不是 TTY 或设置了 NO_COLOR 时自动禁用。"""
36
- _enabled = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
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 (OSError, IOError):
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 = [len(str(h)) for h in headers]
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
- # 中文等宽字符粗略按 2 个宽度计算
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
- cur = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
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 模式下打印 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 = None
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 = datetime.now() - timedelta(seconds=uptime_seconds)
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 content.splitlines():
204
- if "=" in line:
205
- key, _, value = line.partition("=")
206
- info[key.strip()] = value.strip().strip('"')
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
- 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"])
400
+ for part in best.values():
337
401
  try:
338
402
  usage = shutil.disk_usage(part["mountpoint"])
339
- except (OSError, IOError):
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 psutil_like_partitions():
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
- # 反转义 /proc/mounts 中的八进制转义
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
- print("未找到可用的块设备挂载点")
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
- "mem_percent": round(cur["rss"] / total_mem * 100, 2) if total_mem else 0,
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
- print("未读到进程信息(需要 Linux /proc 文件系统)")
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 > 0 else None
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" 本机 IP {_paint(data['local_ip'], C.GREEN)}")
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
- """从 /proc/net/tcp 解析监听端口。"""
733
+ """解析监听/绑定的端口:
734
+ TCP / TCP6 取 LISTEN(状态 0A);UDP / UDP6 取已绑定的未连接
735
+ socket(状态 07,即 UDP 的"监听")。"""
648
736
  ports = []
649
- for proto, path in (("tcp", "/proc/net/tcp"), ("tcp6", "/proc/net/tcp6")):
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 != "0A": # 0A = LISTEN
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 == "tcp":
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
- print("未发现监听中的 TCP 端口")
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 _check_network():
743
- """多目标 TCP 探测,任一可达即认为外网连通。"""
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
- 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 "连接失败"
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
- check("root 权限", os.geteuid() == 0,
779
- "当前是 root" if os.geteuid() == 0 else "普通用户")
780
-
781
- # 网络探测
782
- dns_ok, dns_detail = _check_dns()
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, func, collector, renderer, extra=None):
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(func=func, collector=collector, renderer=renderer)
969
+ p.set_defaults(collector=collector, renderer=renderer)
848
970
 
849
- add("info", "显示系统概览信息", None, collect_info, render_info)
850
- add("mem", "显示内存与交换分区使用情况", None, collect_mem, render_mem)
851
- add("disk", "显示磁盘挂载点与使用率", None, collect_disk, render_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", "显示占用最高的进程", None, proc_collect, render_proc,
984
+ add("proc", "显示占用最高的进程", proc_collect, render_proc,
863
985
  extra=proc_extra)
864
986
 
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)
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
- if getattr(args, "json", False) and not sys.stdout.isatty():
883
- C.disable()
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
- if args.json:
888
- print(json.dumps(data, indent=2, ensure_ascii=False))
889
- else:
890
- args.renderer(data)
891
- print()
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
- sys.exit(main())
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.0.0
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
  [![Tests](https://github.com/maoxian3824/sysx/actions/workflows/test.yml/badge.svg)](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.0.0-py3-none-any.whl
89
- pip install sysx_cli-1.0.0-py3-none-any.whl
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
- | 监听端口 | `/proc/net/tcp`、`/proc/net/tcp6` |
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