feat: update version and dependencies in setup.py; add privilege management and process handling modules
- Updated version from 2026.05.31 to 2026.06.11 in setup.py - Replaced 'textual' with 'rich' in install_requires - Added privilege.py for managing sudo privileges with a keep-alive mechanism - Introduced process.py for handling subprocesses with enhanced control and output streaming - Created ui.py for unified console interactions and user prompts
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from cobot.ui import console, done, header
|
||||
|
||||
# Type of an optional callback invoked for every streamed output line.
|
||||
# Тип опционального колбэка, вызываемого для каждой строки потокового вывода.
|
||||
LineHook = Callable[[str], None]
|
||||
|
||||
# Registry of all live subprocesses, so the signal handler can kill them on exit.
|
||||
# Maps pid -> Popen. Guarded by a lock because procs start/finish in helper calls.
|
||||
# Реестр всех живых подпроцессов, чтобы обработчик сигнала мог убить их при выходе.
|
||||
# Сопоставляет pid -> Popen. Защищён блокировкой, т.к. процессы создаются/завершаются в хелперах.
|
||||
_procs: Dict[int, subprocess.Popen] = {}
|
||||
_procs_lock = threading.Lock()
|
||||
_handlers_installed = False
|
||||
|
||||
|
||||
def _register(proc: subprocess.Popen) -> None:
|
||||
with _procs_lock:
|
||||
_procs[proc.pid] = proc
|
||||
|
||||
|
||||
def _unregister(proc: subprocess.Popen) -> None:
|
||||
with _procs_lock:
|
||||
_procs.pop(proc.pid, None)
|
||||
|
||||
|
||||
def _kill_proc(proc: subprocess.Popen) -> None:
|
||||
"""Terminate a process and everything it spawned.
|
||||
|
||||
Three strategies, in order of how the process was started:
|
||||
* a custom kill_fn (e.g. ``docker kill <container>``) registered on the proc;
|
||||
* a new-session process (e.g. ros2 launch) — every node shares the session, so
|
||||
``pkill -s <sid>`` reaches all of them (killpg would only hit the launcher);
|
||||
* otherwise the process group (SIGTERM then SIGKILL), or the bare process.
|
||||
|
||||
Завершает процесс и всё, что он породил. Три стратегии по способу запуска:
|
||||
пользовательский kill_fn (например ``docker kill``); процесс в новой сессии
|
||||
(ros2 launch — все узлы делят сессию, поэтому ``pkill -s`` достаёт каждый);
|
||||
иначе группа процессов (SIGTERM→SIGKILL) или сам процесс.
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
|
||||
kill_fn = getattr(proc, "_cobot_kill_fn", None)
|
||||
if kill_fn is not None:
|
||||
try:
|
||||
kill_fn()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(proc, "_cobot_new_session", False):
|
||||
try:
|
||||
sid = os.getsid(proc.pid)
|
||||
subprocess.run(["pkill", "-TERM", "-s", str(sid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
subprocess.run(["pkill", "-KILL", "-s", str(sid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def kill_all() -> None:
|
||||
"""Kill every registered subprocess. Used by the signal handler and atexit.
|
||||
Убивает каждый зарегистрированный подпроцесс. Используется обработчиком сигнала и atexit.
|
||||
"""
|
||||
with _procs_lock:
|
||||
procs = list(_procs.values())
|
||||
for proc in procs:
|
||||
_kill_proc(proc)
|
||||
|
||||
|
||||
def _on_sigint(signum, frame): # noqa: ANN001
|
||||
"""SIGINT handler: stop all children, print a cancel note, and exit non-zero.
|
||||
Обработчик SIGINT: останавливает всех потомков, печатает заметку об отмене и выходит с ненулём.
|
||||
"""
|
||||
kill_all()
|
||||
console.print("\n[yellow]Прервано пользователем (Ctrl-C).[/yellow]")
|
||||
raise SystemExit(130)
|
||||
|
||||
|
||||
def install_signal_handlers() -> None:
|
||||
"""Install the SIGINT handler and atexit cleanup exactly once.
|
||||
Устанавливает обработчик SIGINT и очистку atexit ровно один раз.
|
||||
"""
|
||||
global _handlers_installed
|
||||
if _handlers_installed:
|
||||
return
|
||||
_handlers_installed = True
|
||||
signal.signal(signal.SIGINT, _on_sigint)
|
||||
atexit.register(kill_all)
|
||||
|
||||
|
||||
def spawn(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
kill_fn: Optional[Callable] = None,
|
||||
) -> subprocess.Popen:
|
||||
"""Start a subprocess with merged stdout/stderr as text, register it, and return it.
|
||||
|
||||
new_session=True puts the process in its own session/process-group so the whole
|
||||
tree (e.g. all ros2 launch nodes) can be torn down with one signal. kill_fn is an
|
||||
optional custom teardown (e.g. ``docker kill``) used by the cleanup logic.
|
||||
|
||||
Запускает подпроцесс с объединённым stdout/stderr в текстовом режиме, регистрирует
|
||||
его и возвращает. new_session=True помещает процесс в собственную сессию/группу.
|
||||
kill_fn — опциональная функция завершения (например ``docker kill``) для очистки.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
start_new_session=new_session,
|
||||
shell=shell,
|
||||
)
|
||||
proc._cobot_new_session = new_session # type: ignore[attr-defined]
|
||||
proc._cobot_kill_fn = kill_fn # type: ignore[attr-defined]
|
||||
_register(proc)
|
||||
return proc
|
||||
|
||||
|
||||
def stream(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
on_line: Optional[LineHook] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
echo: bool = True,
|
||||
kill_fn: Optional[Callable] = None,
|
||||
) -> int:
|
||||
"""Run a command and stream every output line to the console (and on_line hook).
|
||||
|
||||
Returns the process exit code. SIGKILL (-9) / SIGTERM (-15) are returned as-is so
|
||||
callers can treat user cancellation differently from real failures.
|
||||
|
||||
Запускает команду и транслирует каждую строку вывода в консоль (и в колбэк on_line).
|
||||
Возвращает код возврата процесса. SIGKILL (-9) / SIGTERM (-15) возвращаются как есть,
|
||||
чтобы вызывающий код мог отличать отмену пользователем от реальных ошибок.
|
||||
"""
|
||||
proc = spawn(cmd, env=env, cwd=cwd, new_session=new_session, shell=shell, kill_fn=kill_fn)
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if on_line is not None:
|
||||
on_line(s)
|
||||
elif echo and s:
|
||||
console.print(f" [dim]{_escape(s)}[/dim]")
|
||||
proc.wait()
|
||||
finally:
|
||||
_unregister(proc)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def _escape(s: str) -> str:
|
||||
"""Escape Rich markup so raw command output is never interpreted as markup.
|
||||
Экранирует разметку Rich, чтобы сырой вывод команды не интерпретировался как разметка.
|
||||
"""
|
||||
return s.replace("[", "\\[")
|
||||
|
||||
|
||||
# A progress bar that sticks to the bottom while log lines scroll above it.
|
||||
# Прогресс-бар, "прилипающий" к низу, пока строки лога прокручиваются над ним.
|
||||
def make_progress() -> Progress:
|
||||
"""Create a Progress with a spinner, bar, percentage, and description column.
|
||||
Создаёт Progress со спиннером, баром, процентами и колонкой описания.
|
||||
"""
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
BarColumn(bar_width=30),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TextColumn("[dim]{task.description}[/dim]"),
|
||||
console=console,
|
||||
transient=True,
|
||||
)
|
||||
|
||||
|
||||
def run_step(
|
||||
title: str,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
show_progress: bool = True,
|
||||
total: float = 100.0,
|
||||
parse_progress: Optional[Callable[[str], Optional[tuple]]] = None,
|
||||
on_line: Optional[LineHook] = None,
|
||||
success_msg: str = "",
|
||||
fail_msg: str = "",
|
||||
finish: bool = True,
|
||||
) -> int:
|
||||
"""Run one command as a self-contained "block": header, live log + progress, status.
|
||||
|
||||
parse_progress(line) may return (pct, label) to advance the bar, or None to ignore.
|
||||
Returns the exit code. Prints a ✓/✗ line unless finish=False (used when chaining
|
||||
several commands under one header).
|
||||
|
||||
Запускает одну команду как самодостаточный "блок": заголовок, живой лог + прогресс,
|
||||
статус. parse_progress(line) может вернуть (pct, label) для продвижения бара или None.
|
||||
Возвращает код возврата. Печатает строку ✓/✗, если finish=True (иначе — при цепочке
|
||||
нескольких команд под одним заголовком).
|
||||
"""
|
||||
if title:
|
||||
header(title)
|
||||
|
||||
if not show_progress:
|
||||
rc = stream(cmd, env=env, cwd=cwd, on_line=on_line,
|
||||
new_session=new_session, shell=shell)
|
||||
else:
|
||||
progress = make_progress()
|
||||
with progress:
|
||||
task = progress.add_task("", total=total)
|
||||
|
||||
def _line(s: str) -> None:
|
||||
if parse_progress is not None:
|
||||
parsed = parse_progress(s)
|
||||
if parsed is not None:
|
||||
pct, label = parsed
|
||||
progress.update(task, completed=pct,
|
||||
description=label or "")
|
||||
if on_line is not None:
|
||||
on_line(s)
|
||||
elif s:
|
||||
progress.console.print(f" [dim]{_escape(s)}[/dim]")
|
||||
|
||||
rc = stream(cmd, env=env, cwd=cwd, on_line=_line,
|
||||
new_session=new_session, shell=shell)
|
||||
progress.update(task, completed=total)
|
||||
|
||||
ok = rc in (0, -9, -15)
|
||||
if finish:
|
||||
if ok:
|
||||
done(True, success_msg or "Готово")
|
||||
else:
|
||||
done(False, fail_msg or f"Команда завершилась с кодом {rc}")
|
||||
return rc
|
||||
|
||||
|
||||
# A live progress context for tasks that run several commands or Python work and
|
||||
# need to drive the bar manually. Yields a small controller with .log()/.set().
|
||||
# Живой контекст прогресса для задач, выполняющих несколько команд или Python-работу
|
||||
# и управляющих баром вручную. Отдаёт небольшой контроллер с .log()/.set().
|
||||
class StepProgress:
|
||||
"""Manual progress controller used as a context manager.
|
||||
|
||||
Usage:
|
||||
with StepProgress("Building") as p:
|
||||
p.set(10, "step one")
|
||||
p.log("some output")
|
||||
|
||||
Ручной контроллер прогресса, используемый как менеджер контекста.
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, total: float = 100.0, show: bool = True):
|
||||
if title:
|
||||
header(title)
|
||||
self._total = total
|
||||
self._show = show
|
||||
self._progress: Optional[Progress] = None
|
||||
self._task = None
|
||||
|
||||
def __enter__(self) -> "StepProgress":
|
||||
if self._show:
|
||||
self._progress = make_progress()
|
||||
self._progress.__enter__()
|
||||
self._task = self._progress.add_task("", total=self._total)
|
||||
return self
|
||||
|
||||
def set(self, pct: float, label: str = "") -> None:
|
||||
if self._progress is not None:
|
||||
self._progress.update(self._task, completed=pct, description=label or "")
|
||||
|
||||
def log(self, line: str, style: str = "dim") -> None:
|
||||
out = self._progress.console if self._progress is not None else console
|
||||
if line == "":
|
||||
out.print()
|
||||
else:
|
||||
out.print(f" [{style}]{_escape(line)}[/{style}]" if style else f" {line}")
|
||||
|
||||
def raw(self, renderable) -> None:
|
||||
"""Print a pre-built Rich renderable/markup string without escaping.
|
||||
Печатает готовый Rich-объект/строку с разметкой без экранирования.
|
||||
"""
|
||||
out = self._progress.console if self._progress is not None else console
|
||||
out.print(renderable)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self._progress is not None:
|
||||
self._progress.__exit__(exc_type, exc, tb)
|
||||
self._progress = None
|
||||
Reference in New Issue
Block a user