TUI-пульт на Textual вместо текстового меню

Списки баз стенда и локальных дампов на одном экране, фильтр сужает их
на каждом набранном символе. Enter по базе открывает окно параметров
дампа с галочкой автоподнятия: снята — только dump, поднята — dump плюс
restore. Правки полей уезжают в копию конфига, config.json не трогается.

Ядро получило четыре хука для UI (лог, границы шагов, пароль, отмена),
run() читает вывод утилит построчно через Popen, поэтому журнал
наполняется во время работы pg_dump. Текстовый режим, CLI и --dry-run
ведут себя как раньше; без textual или вне tty пульт не запускается.

Журнал разворачивается на весь экран по F4 и сам на старте операции.
Раскладка адаптивная: narrow ниже 100 колонок, short ниже 32 строк.
Ctrl-хоткеи продублированы кириллицей — русская раскладка их не глушит.

Запуск через run.ps1 (venv, UTF-8-консоль, -Setup и -Tests).
Проверка — tests_tui.py: 46 тестов, TUI гоняется headless через Pilot.
This commit is contained in:
2026-08-20 05:21:52 +03:00
parent b30406bbf6
commit 9fdb02c72a
10 changed files with 3211 additions and 30 deletions
+246 -16
View File
@@ -8,12 +8,14 @@ from __future__ import annotations
import argparse
import getpass
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
@@ -39,8 +41,113 @@ class Cancelled(Exception):
"""Пользователь отказался от выбора в интерактивном режиме."""
# --------------------------------------------------------------------------- шлюзы наружу
#
# Ядро ничего не знает про UI. Три необязательных хука позволяют TUI перехватить то, что
# иначе ушло бы в stdout или в терминал: строки лога, состояние шага и запрос пароля.
# Пока хуки не установлены, поведение текстового режима не отличается ни на символ.
_sink = None # (kind, text) -> None; kind: log | warn | out
_progress = None # (event, what, elapsed) -> None; event: start | done | fail
_password_prompt = None # (label, node) -> str | None; замена getpass под TUI
def set_sink(fn) -> None:
"""Перенаправить лог и вывод дочерних процессов; None — вернуть печать в stdout."""
global _sink
_sink = fn
def set_progress(fn) -> None:
"""Подписаться на границы шагов (нужно панели плана в TUI)."""
global _progress
_progress = fn
def set_password_prompt(fn) -> None:
"""Заменить getpass: из запущенного Textual читать с терминала нельзя."""
global _password_prompt
_password_prompt = fn
def _emit(kind: str, text: str) -> None:
if _sink is not None:
_sink(kind, text)
return
if kind == "out":
print(" " + text, flush=True)
else:
print(f"[{datetime.now():%H:%M:%S}] {text}", flush=True)
def _notify_progress(event: str, what: str, elapsed: float | None = None) -> None:
if _progress is not None:
_progress(event, what, elapsed)
def log(msg: str) -> None:
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
_emit("log", msg)
def warn(msg: str) -> None:
"""То же, что log, но получатель может отличить предупреждение от обычной строки."""
_emit("warn", msg)
# --------------------------------------------------------------------------- отмена
#
# Отмена — это kill дочерних процессов, а не rollback: если DROP DATABASE уже прошёл,
# локальная база останется пустой. UI обязан говорить об этом прямым текстом.
_cancel = threading.Event()
_procs: set = set()
_proc_lock = threading.Lock()
def cancel_requested() -> bool:
return _cancel.is_set()
def clear_cancel() -> None:
_cancel.clear()
def request_cancel() -> bool:
"""Прервать текущую операцию. True — если все процессы удалось добить."""
_cancel.set()
with _proc_lock:
victims = list(_procs)
for proc in victims:
try:
proc.terminate()
except OSError:
pass
clean = True
deadline = time.monotonic() + 3.0
for proc in victims:
while proc.poll() is None and time.monotonic() < deadline:
time.sleep(0.05)
if proc.poll() is None and not _hard_kill(proc):
clean = False
return clean
def _hard_kill(proc) -> bool:
"""pg_restore -j плодит воркеров: terminate() гасит только родителя, нужно дерево."""
if os.name == "nt":
killer = subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
capture_output=True, text=True, errors="replace")
if killer.returncode == 0:
return True
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=2)
return True
except subprocess.TimeoutExpired:
return False
# --------------------------------------------------------------------------- конфиг
@@ -87,6 +194,30 @@ def resolve_tools(cfg: dict) -> dict:
_password_cache: dict = {}
def _password_key(node: dict, label: str) -> tuple:
return (label, node.get("host"), node.get("port", 5432), node.get("user"))
def password_needed(node: dict, label: str = "") -> bool:
"""Придётся ли спрашивать пароль у пользователя (или он уже известен)."""
if node.get("password"):
return False
var = node.get("password_env")
if var and os.environ.get(var):
return False
return _password_key(node, label or node.get("host", "")) not in _password_cache
def prime_password(node: dict, label: str, value: str) -> None:
"""Положить введённый в UI пароль в общий кэш — второй раз его не спросят."""
_password_cache[_password_key(node, label)] = value
def forget_password(node: dict, label: str) -> None:
"""Забыть пароль после отказа сервера, иначе повтор попытки бесполезен."""
_password_cache.pop(_password_key(node, label), None)
def resolve_password(node: dict, label: str) -> str | None:
"""Пароль: из конфига, из переменной окружения, иначе спросить один раз за запуск."""
if node.get("password"):
@@ -96,9 +227,16 @@ def resolve_password(node: dict, label: str) -> str | None:
value = os.environ.get(var)
if value:
return value
key = (label, node.get("host"), node.get("port", 5432), node.get("user"))
key = _password_key(node, label)
if key in _password_cache:
return _password_cache[key]
if _password_prompt is not None:
# ленивый вызов может прилететь из середины do_sync — тогда спрашивает UI, не getpass
value = _password_prompt(label, node)
if value is None:
raise Cancelled
_password_cache[key] = value
return value
if not sys.stdin.isatty():
if var:
raise ConfigError(f"переменная окружения {var} пуста, а спросить пароль негде")
@@ -132,20 +270,63 @@ def conn_env(node: dict, label: str = "", dbname: str | None = None) -> dict:
# --------------------------------------------------------------------------- запуск утилит
def _spawn(cmd: list, env: dict, stderr) -> subprocess.Popen:
"""Popen вместо run(): вывод нужен построчно по мере появления, а процесс — убиваемым."""
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
# запуск и регистрация — под одним замком: иначе процесс, стартовавший между снимком
# victims в request_cancel и добавлением в _procs, переживёт отмену и доработает до конца
with _proc_lock:
if _cancel.is_set():
raise Cancelled
proc = subprocess.Popen(cmd, env=env, text=True, encoding="utf-8", errors="replace",
bufsize=1, stdout=subprocess.PIPE, stderr=stderr,
creationflags=flags)
_procs.add(proc)
return proc
def _forget(proc) -> None:
with _proc_lock:
_procs.discard(proc)
# pg_dump новее сервера пишет в дамп SET неизвестных ему GUC (transaction_timeout — с PG 17):
# restore их не применит и вернёт код 1, хотя данные встали. Строка объясняет красный вывод.
NEWER_CLIENT_HINT = ("утилиты новее целевого сервера: неизвестные ему SET пропущены — "
"на данные это не влияет; чтобы убрать шум, снимайте дамп pg_dump той же "
"мажорной версии, что и локальный сервер")
def run(cmd: list, env: dict, what: str, dry_run: bool) -> None:
shown = " ".join(f'"{c}"' if " " in c else c for c in cmd)
log(f"{what}: {shown}")
if dry_run:
return
if _cancel.is_set():
raise Cancelled
_notify_progress("start", what)
started = time.monotonic()
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if proc.stdout:
for line in proc.stdout.splitlines():
print(" " + line, flush=True)
if proc.returncode != 0:
raise StepError(f"{what} завершился с кодом {proc.returncode}")
log(f"{what}: готово за {time.monotonic() - started:.1f} c")
proc = _spawn(cmd, env, subprocess.STDOUT)
explained = False
try:
for line in proc.stdout:
text = line.rstrip("\r\n")
_emit("out", text)
if not explained and "unrecognized configuration parameter" in text:
explained = True
warn(NEWER_CLIENT_HINT)
code = proc.wait()
finally:
_forget(proc)
proc.stdout.close()
elapsed = time.monotonic() - started
if code != 0:
_notify_progress("fail", what, elapsed)
if _cancel.is_set():
raise Cancelled
raise StepError(f"{what} завершился с кодом {code}")
_notify_progress("done", what, elapsed)
log(f"{what}: готово за {elapsed:.1f} c")
def no_prompt(env: dict) -> list:
@@ -154,12 +335,20 @@ def no_prompt(env: dict) -> list:
def capture(cmd: list, env: dict, what: str) -> str:
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# тоже через _spawn: на недоступном стенде psql висит до PGCONNECT_TIMEOUT и должен убиваться
if _cancel.is_set():
raise Cancelled
proc = _spawn(cmd, env, subprocess.PIPE)
try:
out, err = proc.communicate()
finally:
_forget(proc)
if proc.returncode != 0:
detail = (proc.stderr or "").strip().splitlines()
if _cancel.is_set():
raise Cancelled
detail = (err or "").strip().splitlines()
raise StepError(f"{what}: " + (detail[-1] if detail else f"код {proc.returncode}"))
return proc.stdout
return out
def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str,
@@ -193,6 +382,14 @@ def list_databases(tools: dict, node: dict, label: str) -> list:
# --------------------------------------------------------------------------- интерактив
def matches(text: str, query: str) -> bool:
"""Фильтр списков, общий для pick() и TUI: подстроки без регистра, несколько слов — И."""
if not query:
return True
low = text.lower()
return all(word in low for word in query.lower().split())
def ask(prompt: str) -> str:
try:
return input(prompt).strip()
@@ -209,7 +406,7 @@ def pick(items: list, title: str, render, multi: bool = False) -> list:
flt = ""
while True:
shown = [it for it in items if flt.lower() in render(it).lower()] if flt else list(items)
shown = [it for it in items if matches(render(it), flt)]
print()
print(f"{title}" + (f" фильтр: {flt!r}" if flt else ""))
if not shown:
@@ -334,7 +531,7 @@ def restore(cfg: dict, tools: dict, dump_file: Path, dbname: str,
# без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках
if opts.get("exit_on_error", False):
raise
log(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
warn(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
def post_sql(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
@@ -382,6 +579,8 @@ def local_dumps(cfg: dict) -> list:
def do_dump(cfg: dict, tools: dict, dbnames: list, dry_run: bool) -> list:
made = []
for dbname in dbnames:
if _cancel.is_set():
raise Cancelled
made.append(dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run))
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
return made
@@ -399,6 +598,8 @@ def do_restore(cfg: dict, tools: dict, dump_file: Path, target_db: str,
def do_sync(cfg: dict, tools: dict, dbnames: list, target_db: str | None,
no_recreate: bool, dry_run: bool) -> None:
for dbname in dbnames:
if _cancel.is_set():
raise Cancelled
dump_file = dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run)
local = target_db or cfg["target"].get("database") or dbname
do_restore(cfg, tools, dump_file, local, no_recreate, dry_run)
@@ -497,6 +698,10 @@ def build_parser() -> argparse.ArgumentParser:
"Без команды запускается интерактивное меню.")
ap.add_argument("-c", "--config", type=Path, default=DEFAULT_CONFIG, help="путь к config.json")
ap.add_argument("--dry-run", action="store_true", help="показать команды, ничего не выполнять")
ap.add_argument("--no-tui", action="store_true",
help="меню текстом, без Textual (для скриптов и отладки)")
ap.add_argument("--tui", action="store_true",
help="только TUI: если textual не установлен — ошибка, а не откат в текст")
sub = ap.add_subparsers(dest="command")
p_sync = sub.add_parser("sync", help="dump + restore")
@@ -521,15 +726,40 @@ def build_parser() -> argparse.ArgumentParser:
return ap
def tui_available() -> bool:
return importlib.util.find_spec("textual") is not None
def tui_enabled(args) -> bool:
"""Единственная точка решения о режиме: условия не размазываются по слоям."""
if (args.command or "menu") != "menu":
return False
if args.no_tui or os.environ.get("PG_STAND_SYNC_NO_TUI"):
return False
if not (sys.stdout.isatty() and sys.stdin.isatty()):
return False
return tui_available()
def main() -> int:
args = build_parser().parse_args()
try:
if args.tui and not tui_available():
raise ConfigError("textual не установлен: pip install -r requirements.txt")
cfg = load_config(args.config)
tools = resolve_tools(cfg)
command = args.command or "menu"
if command == "menu":
if args.tui or tui_enabled(args):
try:
import pg_stand_sync_tui as tui
return tui.run_tui(cfg, tools, args.dry_run)
except Exception as exc: # TUI не должен лишать работоспособности
if args.tui:
raise
print(f"TUI не поднялся ({exc}), текстовый режим", file=sys.stderr)
return interactive(cfg, tools, args.dry_run)
if command == "list":