syscheck-cli 0.2.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.
syscheck/providers.py ADDED
@@ -0,0 +1,705 @@
1
+ """Провайдеры данных — собирают метрики, не печатают.
2
+
3
+ Отделены от рендера, чтобы одни и те же данные можно было показывать
4
+ в CLI-выводе, в TUI-панели и в JSON.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import platform
10
+ import re
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ from datetime import datetime
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ import psutil
20
+
21
+
22
+ # --- CPU: неблокирующий расчёт дельт по собственным выборкам -------------
23
+ _cpu_prev: Optional[Tuple[float, Any, Any]] = None
24
+
25
+
26
+ def _cpu_percent_from_times(a, b) -> float:
27
+ """Процент занятости CPU из двух выборок cpu_times() одного ядра."""
28
+ idle_d = max(b.idle - a.idle, 0)
29
+ total_d = max(sum(b) - sum(a), idle_d)
30
+ if total_d <= 0:
31
+ return 0.0
32
+ return max(0.0, min(100.0, (total_d - idle_d) / total_d * 100.0))
33
+
34
+
35
+ def cpu_delta() -> Optional[Tuple[float, List[float]]]:
36
+ """Неблокирующий (overall %, per-core %).
37
+
38
+ Считает прирост по сырым счётчикам cpu_times() с прошлого вызова —
39
+ без блокирующего interval и без сна. Первый вызов возвращает None
40
+ (нет базовой выборки).
41
+ """
42
+ global _cpu_prev
43
+ now = time.time()
44
+ overall = psutil.cpu_times()
45
+ per = psutil.cpu_times(percpu=True)
46
+ if _cpu_prev is None:
47
+ _cpu_prev = (now, overall, per)
48
+ return None
49
+ t0, o0, p0 = _cpu_prev
50
+ _cpu_prev = (now, overall, per)
51
+ dt = now - t0
52
+ if dt <= 0.05:
53
+ return None
54
+ return _cpu_percent_from_times(o0, overall), [
55
+ _cpu_percent_from_times(x0, x) for x0, x in zip(p0, per)
56
+ ]
57
+
58
+
59
+ def cpu_info(interval: float = 0.3) -> Dict[str, Any]:
60
+ freq = psutil.cpu_freq()
61
+ return {
62
+ "percent": psutil.cpu_percent(interval=interval),
63
+ "count_logical": psutil.cpu_count(logical=True),
64
+ "count_physical": psutil.cpu_count(logical=False),
65
+ "freq_current": freq.current if freq else None,
66
+ "freq_max": freq.max if freq else None,
67
+ "per_cpu": psutil.cpu_percent(interval=0, percpu=True),
68
+ }
69
+
70
+
71
+ def ram_info(top: bool = True) -> Dict[str, Any]:
72
+ mem = psutil.virtual_memory()
73
+ swap = _swap_slow()
74
+ procs = []
75
+ if top:
76
+ for p in psutil.process_iter(["pid", "name", "memory_percent"]):
77
+ try:
78
+ pinfo = p.info
79
+ if pinfo["memory_percent"] and pinfo["memory_percent"] > 0.1:
80
+ procs.append(pinfo)
81
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
82
+ pass
83
+ procs.sort(key=lambda x: x["memory_percent"] or 0, reverse=True)
84
+ return {
85
+ "total": mem.total,
86
+ "available": mem.available,
87
+ "used": mem.used,
88
+ "cached": getattr(mem, "cached", 0) or 0,
89
+ "percent": mem.percent,
90
+ "swap_total": swap.total,
91
+ "swap_used": swap.used,
92
+ "swap_percent": swap.percent,
93
+ "top_procs": procs[:10],
94
+ }
95
+
96
+
97
+ _swap_cache: dict = {"ts": 0.0, "v": None}
98
+ _SWAP_TTL = 5.0
99
+
100
+
101
+ def _swap_slow():
102
+ """psutil.swap_memory() на Windows может занимать ~600 мс — кэшируем."""
103
+ now = time.time()
104
+ v = _swap_cache["v"]
105
+ if v is None or now - _swap_cache["ts"] > _SWAP_TTL:
106
+ _swap_cache["v"] = psutil.swap_memory()
107
+ _swap_cache["ts"] = now
108
+ return _swap_cache["v"]
109
+
110
+
111
+ def disk_info() -> List[Dict[str, Any]]:
112
+ disks = []
113
+ for part in psutil.disk_partitions(all=False):
114
+ try:
115
+ usage = psutil.disk_usage(part.mountpoint)
116
+ disks.append({
117
+ "device": part.device,
118
+ "mountpoint": part.mountpoint,
119
+ "total": usage.total,
120
+ "used": usage.used,
121
+ "free": usage.free,
122
+ "percent": usage.percent,
123
+ })
124
+ except (PermissionError, OSError):
125
+ pass
126
+ io = None
127
+ try:
128
+ io = psutil.disk_io_counters()
129
+ except Exception:
130
+ pass
131
+ return {"disks": disks, "io": io}
132
+
133
+
134
+ def net_info() -> Dict[str, Any]:
135
+ addrs = psutil.net_if_addrs()
136
+ stats = psutil.net_if_stats()
137
+ interfaces = []
138
+ for name, iface_addrs in addrs.items():
139
+ stat = stats.get(name)
140
+ ipv4 = [a.address for a in iface_addrs if a.family == socket.AF_INET]
141
+ mac_list = [a.address for a in iface_addrs if a.family == psutil.AF_LINK]
142
+ interfaces.append({
143
+ "name": name,
144
+ "up": bool(stat and stat.isup),
145
+ "speed": stat.speed if stat else 0,
146
+ "ipv4": ipv4,
147
+ "mac": mac_list[:1] or [],
148
+ })
149
+ try:
150
+ io = psutil.net_io_counters()
151
+ io_data = {
152
+ "bytes_sent": io.bytes_sent,
153
+ "bytes_recv": io.bytes_recv,
154
+ "packets_sent": io.packets_sent,
155
+ "packets_recv": io.packets_recv,
156
+ "errin": io.errin,
157
+ "errout": io.errout,
158
+ }
159
+ except Exception:
160
+ io_data = None
161
+ return {"interfaces": interfaces, "io": io_data}
162
+
163
+
164
+ def _decode_ping_output(raw: bytes) -> str:
165
+ for enc in ("cp866", "cp1251", "utf-8", "latin-1"):
166
+ try:
167
+ return raw.decode(enc)
168
+ except (UnicodeDecodeError, LookupError):
169
+ continue
170
+ return raw.decode("latin-1", errors="replace")
171
+
172
+
173
+ def ping(host: str, count: int = 4) -> Dict[str, Any]:
174
+ """Пингует хост и возвращает статистику."""
175
+ param = "-n" if sys.platform == "win32" else "-c"
176
+ try:
177
+ result = subprocess.run(
178
+ ["ping", param, str(count), host],
179
+ capture_output=True, timeout=15,
180
+ )
181
+ output = _decode_ping_output(result.stdout)
182
+ pattern = re.compile(r"=\s*(\d+)\s*(?:\u043c\u0441|ms)", re.IGNORECASE)
183
+ matches = list(pattern.finditer(output))
184
+ times = []
185
+ for m in matches[:count]:
186
+ ctx = output[m.end():m.end() + 20]
187
+ if "TTL" in ctx or not re.search(r"\d+\s*(?:\u043c\u0441|ms)", ctx):
188
+ try:
189
+ times.append(float(m.group(1)))
190
+ except (ValueError, TypeError):
191
+ pass
192
+ avg = sum(times) / len(times) if times else None
193
+ return {
194
+ "host": host,
195
+ "success": result.returncode == 0,
196
+ "avg_ms": avg,
197
+ "min_ms": min(times) if times else None,
198
+ "max_ms": max(times) if times else None,
199
+ "lost": max(0, count - len(times)),
200
+ "total": count,
201
+ }
202
+ except (subprocess.TimeoutExpired, Exception) as e:
203
+ return {
204
+ "host": host,
205
+ "success": False,
206
+ "error": str(e),
207
+ "avg_ms": None,
208
+ "lost": count,
209
+ "total": count,
210
+ }
211
+
212
+
213
+ # --- процессы: кэш объектов + неблокирующие дельты CPU ------------------
214
+ _proc_objs: List[Any] = []
215
+ _proc_list_time: float = 0.0
216
+ _proc_samples: Dict[int, Tuple[float, float, float]] = {}
217
+ _proc_raw: List[Dict[str, Any]] = []
218
+ _proc_raw_time: float = 0.0
219
+ _proc_lock = threading.Lock()
220
+ _PROC_REFRESH_EVERY = 8.0
221
+ _PROC_CPU_EVERY = 3.0
222
+
223
+
224
+ def _refresh_proc_list() -> None:
225
+ """Пересобирает список процессов не чаще раза в _PROC_REFRESH_EVERY сек."""
226
+ global _proc_objs, _proc_list_time
227
+ if time.time() - _proc_list_time < _PROC_REFRESH_EVERY:
228
+ return
229
+ with _proc_lock:
230
+ if time.time() - _proc_list_time < _PROC_REFRESH_EVERY:
231
+ return
232
+ listed = []
233
+ for p in psutil.process_iter(["pid", "name", "memory_percent", "status", "memory_info"]):
234
+ try:
235
+ # на Windows pid 0 — «System Idle Process», его cpu% равен общему
236
+ # простою всех ядер (может быть >1000%) и вводит в заблуждение.
237
+ if p.info["pid"] == 0:
238
+ continue
239
+ listed.append(p)
240
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
241
+ pass
242
+ _proc_objs = listed
243
+ _proc_list_time = time.time()
244
+ live = {p.pid for p in listed}
245
+ for pid in [k for k in _proc_samples if k not in live]:
246
+ _proc_samples.pop(pid, None)
247
+
248
+
249
+ def _process_cpu_delta(p) -> float:
250
+ """CPU% процесса по приросту cpu_times() между вызовами (без блокировок)."""
251
+ try:
252
+ t = p.cpu_times()
253
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
254
+ return 0.0
255
+ now = time.time()
256
+ pid = p.pid
257
+ prev = _proc_samples.get(pid)
258
+ _proc_samples[pid] = (now, t.user, t.system)
259
+ if prev is None:
260
+ return 0.0
261
+ dt = now - prev[0]
262
+ if dt <= 0.05:
263
+ return 0.0
264
+ du = max((t.user - prev[1]) + (t.system - prev[2]), 0.0)
265
+ return du / dt * 100.0
266
+
267
+
268
+ def _refresh_proc_raw() -> List[Dict[str, Any]]:
269
+ """Сырые данные процессов (с CPU-дельтой). Пересчёт CPU не чаще _PROC_CPU_EVERY."""
270
+ global _proc_raw, _proc_raw_time
271
+ now = time.time()
272
+ if now - _proc_raw_time < _PROC_CPU_EVERY and _proc_raw:
273
+ return _proc_raw
274
+ _refresh_proc_list()
275
+ raw = []
276
+ for p in _proc_objs:
277
+ try:
278
+ info = p.info # имя/pid/status кэшированы при сборке списка
279
+ info["cpu_percent"] = _process_cpu_delta(p)
280
+ mi = info.get("memory_info")
281
+ info["rss"] = getattr(mi, "rss", 0) if mi else 0
282
+ raw.append(info)
283
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
284
+ continue
285
+ _proc_raw = raw
286
+ _proc_raw_time = now
287
+ return raw
288
+
289
+
290
+ def processes(sort_by: str = "cpu", limit: int = 15) -> List[Dict[str, Any]]:
291
+ data = list(_refresh_proc_raw())
292
+ key = {"cpu": "cpu_percent", "ram": "memory_percent", "name": "name"}.get(
293
+ sort_by, "cpu_percent"
294
+ )
295
+ if key == "name":
296
+ data.sort(key=lambda x: x.get(key, "") or "")
297
+ else:
298
+ data.sort(key=lambda x: x.get(key, 0) or 0, reverse=True)
299
+ return data[:limit]
300
+
301
+
302
+ def temperatures() -> Dict[str, Any]:
303
+ if not hasattr(psutil, "sensors_temperatures"):
304
+ return {"available": False, "temps": {}}
305
+ try:
306
+ temps = psutil.sensors_temperatures()
307
+ except AttributeError:
308
+ return {"available": False, "temps": {}}
309
+ return {"available": bool(temps), "temps": temps}
310
+
311
+
312
+ def system_info() -> Dict[str, Any]:
313
+ uname = platform.uname()
314
+ boot = psutil.boot_time()
315
+ users = []
316
+ try:
317
+ users = sorted(set(u.name for u in psutil.users()))
318
+ except Exception:
319
+ pass
320
+ return {
321
+ "os": f"{uname.system} {uname.release}",
322
+ "version": uname.version,
323
+ "machine": uname.machine,
324
+ "node": uname.node,
325
+ "processor": uname.processor or "N/A",
326
+ "python": platform.python_version(),
327
+ "boot_time": boot,
328
+ "uptime_seconds": time.time() - boot,
329
+ "boot_datetime": datetime.fromtimestamp(boot).strftime("%Y-%m-%d %H:%M:%S"),
330
+ "users": users,
331
+ }
332
+
333
+
334
+ def system_quick() -> Dict[str, Any]:
335
+ data = {"cpu_percent": psutil.cpu_percent(interval=0.3)}
336
+ mem = psutil.virtual_memory()
337
+ data["ram_percent"] = mem.percent
338
+ data["ram_available"] = mem.available
339
+ data["disks"] = []
340
+ for d in disk_info()["disks"]:
341
+ data["disks"].append((d["device"], d["percent"], d["free"]))
342
+ if psutil.disk_usage("C:\\" if os.name == "nt" else "/"):
343
+ try:
344
+ root = psutil.disk_usage("C:\\" if os.name == "nt" else "/")
345
+ data["main_percent"], data["main_free"] = root.percent, root.free
346
+ except Exception:
347
+ pass
348
+ try:
349
+ io = psutil.net_io_counters()
350
+ data["net_sent"] = io.bytes_sent
351
+ data["net_recv"] = io.bytes_recv
352
+ except Exception:
353
+ pass
354
+ return data
355
+
356
+
357
+ # --- дисковые скорости чтения/записи (MB/s по дельтам) ----------------
358
+ _io_prev: Optional[Tuple[float, int, int]] = None
359
+
360
+
361
+ def disk_io_speeds() -> Dict[str, float]:
362
+ """Скорости диска за интервал между вызовами (bytes/s). Неблокирующе."""
363
+ global _io_prev
364
+ try:
365
+ io = psutil.disk_io_counters()
366
+ except Exception:
367
+ return {"read": 0.0, "write": 0.0}
368
+ now = time.time()
369
+ if _io_prev is None:
370
+ _io_prev = (now, io.read_bytes, io.write_bytes)
371
+ return {"read": 0.0, "write": 0.0}
372
+ t0, r0, w0 = _io_prev
373
+ _io_prev = (now, io.read_bytes, io.write_bytes)
374
+ dt = max(now - t0, 1e-6)
375
+ return {
376
+ "read": max(io.read_bytes - r0, 0) / dt,
377
+ "write": max(io.write_bytes - w0, 0) / dt,
378
+ }
379
+
380
+
381
+ # --- GPU (best-effort, кэш) -------------------------------------------
382
+ _gpu_cache: dict = {"ts": 0.0, "data": None}
383
+ _GPU_TTL = 30.0
384
+
385
+
386
+ def gpu_info() -> Dict[str, Any]:
387
+ """Имя/VRAM видеокарты через WMI. Утилизация/temp обычно недоступны.
388
+
389
+ Возвращает {"name","vram_gb","util","temp"}; недоступные поля — None.
390
+ """
391
+ now = time.time()
392
+ if _gpu_cache["data"] is not None and now - _gpu_cache["ts"] < _GPU_TTL:
393
+ return _gpu_cache["data"]
394
+ data: Dict[str, Any] = {"name": None, "vram_gb": None, "util": None, "temp": None}
395
+ try:
396
+ out = subprocess.run(
397
+ [
398
+ "powershell", "-NoProfile", "-Command",
399
+ "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notlike '*Virtual*' } |"
400
+ " Select-Object -First 1 -Property Name,AdapterRAM | ConvertTo-Json -Compress",
401
+ ],
402
+ capture_output=True, text=True, timeout=10, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
403
+ )
404
+ raw = out.stdout.strip()
405
+ if raw:
406
+ import json as _json
407
+ info = _json.loads(raw)
408
+ vram = info.get("AdapterRAM")
409
+ if vram:
410
+ vram = float(vram) / (1024 ** 3)
411
+ data["name"] = info.get("Name")
412
+ data["vram_gb"] = round(vram, 1) if vram else None
413
+ except Exception:
414
+ pass
415
+ if not data["name"]:
416
+ try:
417
+ out = subprocess.run(
418
+ ["wmic", "path", "win32_VideoController", "get", "name"],
419
+ capture_output=True, text=True, timeout=8,
420
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
421
+ )
422
+ lines = [ln.strip() for ln in out.stdout.splitlines()
423
+ if ln.strip() and ln.strip().lower() != "name"]
424
+ lines = [ln for ln in lines if "virtual" not in ln.lower()]
425
+ data["name"] = lines[0] if lines else None
426
+ except Exception:
427
+ pass
428
+ _gpu_cache["data"], _gpu_cache["ts"] = data, now
429
+ return data
430
+
431
+
432
+ def process_count() -> int:
433
+ """Количество процессов из кэша (после пересборки списка)."""
434
+ _refresh_proc_list()
435
+ return len(_proc_objs)
436
+
437
+
438
+ def cpu_temp_celsius() -> Optional[float]:
439
+ """Температура CPU, если psutil её видит на данной ОС."""
440
+ try:
441
+ for entries in psutil.sensors_temperatures().values():
442
+ if entries:
443
+ return entries[0].current
444
+ except (AttributeError, Exception):
445
+ return None
446
+ return None
447
+
448
+ # --- CPU: детальная карточка -------------------------------------------
449
+ def cpu_detail() -> Dict[str, Any]:
450
+ """CPU с моделью и температурой — для команды/панели cpu."""
451
+ d = dict(cpu_info(interval=0.25))
452
+ d["model"] = platform.processor() or "N/A"
453
+ d["temp"] = cpu_temp_celsius()
454
+ return d
455
+
456
+
457
+ # --- батарея ------------------------------------------------------------
458
+ def battery_info() -> Optional[Dict[str, Any]]:
459
+ """Данные батареи или None, если в системе нет батареи."""
460
+ try:
461
+ b = psutil.sensors_battery()
462
+ if b is None:
463
+ return None
464
+ secs = b.secsleft
465
+ left = None
466
+ if secs not in (psutil.POWER_TIME_UNLIMITED, psutil.POWER_TIME_UNKNOWN) and secs > 0:
467
+ left = int(secs)
468
+ return {"percent": b.percent, "plugged": bool(b.power_plugged), "seconds_left": left}
469
+ except (AttributeError, Exception):
470
+ return None
471
+
472
+
473
+ # --- сеть: скорости по дельтам ------------------------------------------
474
+ _net_prev: Optional[Tuple[float, int, int]] = None
475
+
476
+
477
+ def network_speeds() -> Dict[str, float]:
478
+ """Текущие скорости сети (bytes/s) по дельтам счётчиков."""
479
+ global _net_prev
480
+ try:
481
+ io = psutil.net_io_counters()
482
+ except Exception:
483
+ return {"down": 0.0, "up": 0.0}
484
+ now = time.time()
485
+ if _net_prev is None:
486
+ _net_prev = (now, io.bytes_recv, io.bytes_sent)
487
+ return {"down": 0.0, "up": 0.0}
488
+ t0, r0, s0 = _net_prev
489
+ _net_prev = (now, io.bytes_recv, io.bytes_sent)
490
+ dt = max(now - t0, 1e-6)
491
+ return {
492
+ "down": max(io.bytes_recv - r0, 0) / dt,
493
+ "up": max(io.bytes_sent - s0, 0) / dt,
494
+ }
495
+
496
+
497
+ # --- сеть: соединения по процессам (кэш) --------------------------------
498
+ _conn_cache: dict = {"ts": 0.0, "data": None}
499
+ _CONN_TTL = 4.0
500
+
501
+
502
+ def connections_by_process(top_n: int = 10, kind: str = "tcp") -> Dict[str, Any]:
503
+ """TCP-соединения, сгруппированные по процессам (имя -> число)."""
504
+ now = time.time()
505
+ if _conn_cache["data"] is not None and now - _conn_cache["ts"] < _CONN_TTL:
506
+ return _conn_cache["data"]
507
+ _refresh_proc_list()
508
+ pid2name = {(p.info.get("pid")): (p.info.get("name") or "?") for p in _proc_objs}
509
+ counts: Dict[str, int] = {}
510
+ total = 0
511
+ try:
512
+ conns = psutil.net_connections(kind=kind)
513
+ except (psutil.AccessDenied, psutil.PermissionError):
514
+ conns = []
515
+ except Exception:
516
+ conns = []
517
+ for c in conns:
518
+ total += 1
519
+ if c.pid in (0, 4):
520
+ name = "system"
521
+ else:
522
+ name = pid2name.get(c.pid) if c.pid is not None else None
523
+ if name is None:
524
+ name = "kernel" if c.pid is None else f"?({c.pid})"
525
+ counts[name] = counts.get(name, 0) + 1
526
+ top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:top_n]
527
+ data = {"total": total, "top": top}
528
+ _conn_cache["data"], _conn_cache["ts"] = data, now
529
+ return data
530
+
531
+
532
+ # --- процессы: детали и управление --------------------------------------
533
+ def _window_handle_count(pid: int) -> Optional[int]:
534
+ """Число открытых хэндлов (Windows) или None."""
535
+ if sys.platform != "win32":
536
+ return None
537
+ try:
538
+ from ctypes import byref, c_ulong, windll
539
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
540
+ h = windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
541
+ if not h:
542
+ return None
543
+ try:
544
+ cnt = c_ulong()
545
+ if windll.kernel32.GetProcessHandleCount(h, byref(cnt)):
546
+ return int(cnt.value)
547
+ return None
548
+ finally:
549
+ windll.kernel32.CloseHandle(h)
550
+ except Exception:
551
+ return None
552
+
553
+
554
+ def process_detail(pid: int) -> Optional[Dict[str, Any]]:
555
+ """Расширенная карточка процесса или None, если процесс исчез."""
556
+ try:
557
+ p = psutil.Process(pid)
558
+ except psutil.NoSuchProcess:
559
+ return None
560
+ info: Dict[str, Any] = {
561
+ "pid": pid,
562
+ "name": "?", "cpu": 0.0, "memory_percent": 0.0, "rss": 0,
563
+ "threads": None, "status": "?", "cmdline": [], "exe": None,
564
+ "handles": None, "create_time": None, "uptime_seconds": 0.0,
565
+ }
566
+ try:
567
+ info["name"] = p.name() or "?"
568
+ except (psutil.AccessDenied, Exception):
569
+ pass
570
+ try:
571
+ info["cpu"] = _process_cpu_delta(p)
572
+ except Exception:
573
+ pass
574
+ try:
575
+ info["memory_percent"] = p.memory_percent()
576
+ except (psutil.AccessDenied, Exception):
577
+ pass
578
+ try:
579
+ info["rss"] = p.memory_info().rss
580
+ except (psutil.AccessDenied, Exception):
581
+ pass
582
+ try:
583
+ info["threads"] = p.num_threads()
584
+ except (psutil.AccessDenied, Exception):
585
+ pass
586
+ try:
587
+ info["status"] = p.status()
588
+ except (psutil.AccessDenied, Exception):
589
+ pass
590
+ try:
591
+ info["cmdline"] = list(p.cmdline() or [])
592
+ except (psutil.AccessDenied, Exception):
593
+ pass
594
+ try:
595
+ info["exe"] = p.exe()
596
+ except (psutil.AccessDenied, Exception):
597
+ pass
598
+ try:
599
+ info["handles"] = _window_handle_count(pid)
600
+ except Exception:
601
+ pass
602
+ try:
603
+ info["create_time"] = p.create_time()
604
+ info["uptime_seconds"] = max(time.time() - info["create_time"], 0)
605
+ except (psutil.AccessDenied, Exception):
606
+ pass
607
+ return info
608
+
609
+
610
+ def find_process(query: str) -> Optional[Dict[str, Any]]:
611
+ """Ищет процесс по pid или подстроке имени."""
612
+ _refresh_proc_list()
613
+ q = query.strip().lower()
614
+ if q.isdigit():
615
+ pid = int(q)
616
+ for p in _proc_objs:
617
+ if p.info["pid"] == pid:
618
+ return _proc_detail_from_cache(p)
619
+ return None
620
+ for p in _proc_objs:
621
+ nm = p.info.get("name") or ""
622
+ if nm.lower().find(q) != -1:
623
+ return _proc_detail_from_cache(p)
624
+ return None
625
+
626
+
627
+ def _proc_detail_from_cache(p) -> Dict[str, Any]:
628
+ return {
629
+ "pid": p.info.get("pid"), "name": p.info.get("name") or "?",
630
+ "cpu": p.info.get("cpu_percent") or 0.0,
631
+ "memory_percent": p.info.get("memory_percent") or 0.0,
632
+ }
633
+
634
+
635
+ def list_processes(query: Optional[str] = None, sort: str = "cpu",
636
+ limit: int = 25) -> List[Dict[str, Any]]:
637
+ """Список процессов (имя+pid+лог), с фильтром и сортировкой."""
638
+ data = [dict(p) for p in _refresh_proc_raw()]
639
+ if query:
640
+ q = query.lower()
641
+ data = [d for d in data if q in (d.get("name") or "").lower()
642
+ or q in " ".join([str(d.get("pid"))])]
643
+ key = {"cpu": "cpu_percent", "ram": "memory_percent", "name": "name"}.get(sort, "cpu_percent")
644
+ if key == "name":
645
+ data.sort(key=lambda x: (x.get(key) or "") or "")
646
+ else:
647
+ data.sort(key=lambda x: x.get(key, 0) or 0, reverse=True)
648
+ return data[:limit]
649
+
650
+
651
+ def resolve_pid(query: str) -> Optional[int]:
652
+ """Возвращает pid по числу или подстроке имени."""
653
+ p = find_process(query)
654
+ return p["pid"] if p else None
655
+
656
+
657
+ def kill_process(pid: int) -> str:
658
+ """Завершает процесс; отказывает для собственного pid."""
659
+ if pid == os.getpid():
660
+ raise ValueError("refusing to kill self")
661
+ try:
662
+ p = psutil.Process(pid)
663
+ except psutil.NoSuchProcess:
664
+ raise ValueError(f"no such process: {pid}")
665
+ p.terminate()
666
+ try:
667
+ p.wait(timeout=5)
668
+ except psutil.TimeoutExpired:
669
+ p.kill()
670
+ p.wait(timeout=3)
671
+ return "killed"
672
+
673
+
674
+ def suspend_process(pid: int) -> str:
675
+ if pid == os.getpid():
676
+ raise ValueError("refusing to suspend self")
677
+ psutil.Process(pid).suspend()
678
+ return "suspended"
679
+
680
+
681
+ def resume_process(pid: int) -> str:
682
+ psutil.Process(pid).resume()
683
+ return "resumed"
684
+
685
+
686
+ def restart_process(pid: int) -> str:
687
+ """Перезапуск: завершить и поднять exe заново (best-effort)."""
688
+ if pid == os.getpid():
689
+ raise ValueError("refusing to restart self")
690
+ pid = int(pid)
691
+ exe = None
692
+ try:
693
+ exe = psutil.Process(pid).exe()
694
+ except (psutil.AccessDenied, psutil.NoSuchProcess, Exception):
695
+ pass
696
+ kill_process(pid)
697
+ if exe and os.path.isfile(exe):
698
+ cwd = os.path.dirname(exe) or None
699
+ flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
700
+ try:
701
+ subprocess.Popen([exe], cwd=cwd, close_fds=True, creationflags=flags)
702
+ return f"restarted ({exe})"
703
+ except OSError:
704
+ return f"killed, failed to relaunch ({exe})"
705
+ return "killed, nothing to relaunch"