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:
+246
-16
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user