Files
pg-stand-sync/pg_stand_sync_tui.py
T
mikhail e8fd19714a Компактный диалог операции и кнопки, которые не уезжают
Пары Switch + Label заменены на Checkbox с встроенной подписью: строка
вместо трёх. Поля стали однострочными, чекбоксы дампа встали в ряд,
заголовок и имя базы слились в одну строку — диалог ужался с почти
целого экрана до 17-18 строк на любом размере консоли.

Ряд кнопок пропадал потому, что .dialog с height: auto складывал высоту
детей и упирался в max-height, обрезая нижнюю строку. Теперь у диалога
операции своя высота, а форма забирает остаток через height: 1fr и
скроллится сама. Диалог restore приведён к тому же виду.
2026-08-20 05:36:01 +03:00

1391 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""TUI-пульт на Textual: списки стенда и дампов на одном экране, фильтр сужает их по буквам.
Модуль ничего не знает про pg_dump и не собирает командных строк: он вызывает те же
do_sync / do_dump / do_restore, что и CLI, а видит их через четыре хука ядра —
вывод (set_sink), границы шагов (set_progress), пароль (set_password_prompt) и отмену.
Отсутствие textual роняет только импорт этого файла, ядро остаётся работоспособным.
"""
from __future__ import annotations
import importlib.util
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.content import Content
from textual.message import Message
from textual.screen import ModalScreen, Screen
from textual.theme import Theme
from textual.widgets import (Button, Checkbox, Collapsible, Footer, Input, Label,
OptionList, ProgressBar, RichLog, Static)
from textual.widgets.option_list import Option
from textual.worker import WorkerState, get_current_worker
import pg_stand_sync as core
CSS_FILE = Path(__file__).with_name("pg_stand_sync.tcss")
# Только BMP-геометрия: emoji в Windows Terminal занимают две ячейки и рвут колонки
SPINNER = "⣾⣽⣻⢿⡿⣟⣯⣷"
STATE_GLYPH = {"pending": "·", "active": "", "done": "", "warn": "", "fail": ""}
STATE_STYLE = {"pending": "dim", "active": "bold $accent", "done": "$success",
"warn": "$warning", "fail": "$error"}
# глиф дублирует цвет: журнал должен читаться и в монохромном скриншоте
LOG_STYLE = {"log": "", "out": "dim", "warn": "$warning", "err": "$error", "ok": "$success"}
JOURNAL_LIMIT = 20000 # держится в памяти; в панель всё равно влезает max_lines=5000
LOG_LINES = 5000
PASSWORD_WAIT = 600.0 # верхний предел ожидания модалки рабочим потоком
DARK = Theme(
name="pgsync-dark", dark=True,
background="#14181c", surface="#1b2126", panel="#222a30", foreground="#d7e0e6",
primary="#3fb8a8", secondary="#5aa9e6", accent="#e3a83c",
success="#6ec06e", warning="#e3a83c", error="#e05f5f",
)
LIGHT = Theme(
name="pgsync-light", dark=False,
background="#f5f7f8", surface="#ffffff", panel="#eceff1", foreground="#1d2429",
primary="#17796d", secondary="#2f6690", accent="#a86b12",
success="#2f7d32", warning="#8a5b00", error="#b23a3a",
)
# ЙЦУКЕН поверх QWERTY: при русской раскладке терминал присылает Ctrl+«в» вместо Ctrl+D,
# и хоткей молча не срабатывает. Дубли вешаются автоматически, чтобы список клавиш не разъехался.
RU_LAYOUT = {"a": "ф", "c": "с", "d": "в", "l": "д", "r": "к", "s": "ы", "u": "г", "x": "ч"}
def with_ru_layout(bindings: list) -> list:
"""Добавить к Ctrl-хоткеям их кириллические двойники."""
extra = []
for binding in bindings:
key = binding.key
if key.startswith("ctrl+") and len(key) == 6 and key[5] in RU_LAYOUT:
extra.append(Binding(f"ctrl+{RU_LAYOUT[key[5]]}", binding.action,
binding.description, priority=binding.priority, show=False))
return list(bindings) + extra
def available() -> bool:
return importlib.util.find_spec("textual") is not None
# --------------------------------------------------------------------------- данные
@dataclass
class DbRow:
name: str
size: str
@dataclass
class DumpRow:
path: Path
name: str
size_mb: float
mtime: datetime
db: str
@dataclass
class OpSpec:
"""Ровно аргументы do_* — больше TUI об операции ничего не знает."""
kind: str # sync | dump | restore
dbs: list = field(default_factory=list)
dump_file: Path | None = None
target_db: str | None = None
no_recreate: bool = False
# правки секций dump/restore на этот запуск; в ядро уходят копией cfg, файл не трогаем
overrides: dict = field(default_factory=dict)
@dataclass
class PlanStep:
label: str
state: str = "pending"
elapsed: float | None = None
class MetaLoaded(Message):
def __init__(self, dbs: list, dumps: list) -> None:
super().__init__()
self.dbs = dbs
self.dumps = dumps
class MetaFailed(Message):
def __init__(self, error: str) -> None:
super().__init__()
self.error = error
class OpDone(Message):
def __init__(self, ok: bool, error: str, elapsed: float, cancelled: bool = False) -> None:
super().__init__()
self.ok = ok
self.error = error
self.elapsed = elapsed
self.cancelled = cancelled
# --------------------------------------------------------------------------- списки
def clip(text: str, room: int) -> str:
"""Обрезать с многоточием: молча срезанный хвост делает длинные имена неразличимыми."""
if room <= 1 or len(text) <= room:
return text[:room] if room > 0 else ""
return text[:room - 1] + ""
def highlighted_content(text: str, query: str) -> Content:
"""Подсветить куски, из-за которых строка прошла фильтр: иначе непонятно, почему она тут."""
content = Content(text)
if not query:
return content
low = text.lower()
for word in query.lower().split():
start = low.find(word)
if start >= 0:
content = content.stylize("bold $primary", start, start + len(word))
return content
class FilterList(OptionList):
"""Общее для обоих списков: строки-объекты рядом с опциями, счётчик и подсветка."""
def __init__(self, **kwargs) -> None:
# markup=False: в именах файлов и в выводе утилит попадаются [ ], разметка бы их съела
super().__init__(markup=False, compact=True, **kwargs)
self.rows: list = []
self.total = 0
self.query = ""
self._relaying = False
def line(self, row) -> str:
raise NotImplementedError
def decorate(self, row) -> Content:
return highlighted_content(self.line(row), self.query)
def counter_host(self):
"""Счётчик рисуется на рамке панели: у самого OptionList в CSS border: none,
а Textual выводит border_subtitle только в строке рамки."""
return self.parent if self.parent is not None else self
def counter(self) -> str:
return f"показано {len(self.rows)}/{self.total}"
# запас под вертикальный скроллбар: content_size его не вычитает, и строка ровно по
# ширине переносит хвост («1277 MB» разрывается на «1277» и «MB»)
SCROLLBAR_ROOM = 2
def room_for(self, tail: str, fallback: int) -> int:
"""Сколько ячеек остаётся имени: считаем от реальной ширины, иначе строка переносится."""
width = self.content_size.width or fallback
return max(width - len(tail) - self.SCROLLBAR_ROOM, 12)
def show(self, rows: list, query: str, total: int) -> None:
keep = self.highlighted or 0
self.rows = list(rows)
self.query = query
self.total = total
self.clear_options()
self.add_options([Option(self.decorate(row)) for row in self.rows])
if self.rows:
self.highlighted = min(keep, len(self.rows) - 1)
elif query:
# пустая панель неотличима от незагрузившейся — текстовый pick() здесь тоже говорит
self.add_option(Option(Content("ничего не найдено по фильтру").stylize("dim"),
disabled=True))
self.counter_host().border_subtitle = self.counter()
def on_resize(self) -> None:
# строки собраны под прежнюю ширину: после изменения размера их надо пересобрать
if not self.rows or self._relaying:
return
self._relaying = True
try:
self.show(self.rows, self.query, self.total)
finally:
self._relaying = False
@property
def current(self):
index = self.highlighted
if index is None or not self.rows:
return None
return self.rows[index]
class DbList(FilterList):
"""Список баз стенда. Отметки живут в множестве имён — фильтр их не теряет."""
BINDINGS = [Binding("space", "toggle_mark", "отметить", show=True)]
class MarksChanged(Message):
pass
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.marked: set = set()
def counter(self) -> str:
# счётчик отметок держится в той же строке: иначе он терялся при каждой перерисовке
base = super().counter()
return base + (f" · отмечено {len(self.marked)}" if self.marked else "")
def line(self, row) -> str:
mark = "" if row.name in self.marked else ""
tail = f"{row.size:>10}"
room = self.room_for(f"{mark} {tail}", 46)
return f"{mark} {clip(row.name, room):<{room}}{tail}"
def action_toggle_mark(self) -> None:
row = self.current
if row is None:
return
if row.name in self.marked:
self.marked.discard(row.name)
else:
self.marked.add(row.name)
self.replace_option_prompt_at_index(self.highlighted, self.decorate(row))
self.post_message(self.MarksChanged())
def selection(self) -> list:
"""Все отмеченные базы, а если отметок нет — та, что под курсором.
Фильтр отметок не сужает: счётчик «отмечено N» обязан совпадать с составом операции.
"""
if self.marked:
return sorted(self.marked)
row = self.current
return [row.name] if row else []
class DumpList(FilterList):
def line(self, row) -> str:
tail = f"{row.size_mb:>9.1f} МБ {row.mtime:%d.%m %H:%M}"
room = self.room_for(f" {tail}", 66)
return f"{clip(row.name, room):<{room}} {tail}"
# --------------------------------------------------------------------------- модалки
class PasswordModal(ModalScreen):
"""getpass под запущенным Textual невозможен: пароль спрашивается только отсюда."""
BINDINGS = [Binding("escape", "cancel", "отмена")]
def __init__(self, label: str, node: dict) -> None:
super().__init__()
self.label = label
self.node = node
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Static("Нужен пароль", classes="title")
yield Static(f"{self.node.get('user')}@{self.node.get('host')} ({self.label}), "
f"Enter — без пароля", classes="subject")
yield Input(password=True, id="pwd")
def on_mount(self) -> None:
self.query_one("#pwd", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value)
def action_cancel(self) -> None:
self.dismiss(None)
class ConfirmModal(ModalScreen):
"""Последний экран перед деструктивной операцией: имя базы, режим и прямая формулировка."""
BINDINGS = [Binding("escape", "cancel", "отмена"),
Binding("ctrl+enter", "start", "начать", priority=True)]
TITLES = {"sync": "Dump + Restore", "dump": "Dump", "restore": "Restore"}
def __init__(self, spec: OpSpec, cfg: dict, subject: str) -> None:
super().__init__()
self.spec = spec
self.cfg = cfg
self.subject = subject
self.recreate = bool(cfg["target"].get("recreate", True))
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Static(self.TITLES[self.spec.kind], classes="title")
yield Static(self.subject, classes="subject")
if self.spec.kind != "dump":
if self.spec.target_db is None:
yield Static("локальные базы — как на стенде (выбрано несколько)",
classes="subject")
else:
with Horizontal(classes="field"):
yield Label("локальная база", classes="inline")
yield Input(value=self.spec.target_db, id="target", compact=True)
yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate,
id="recreate", compact=True)
yield Static(self._danger_text(), id="danger",
classes="danger" + ("" if self.recreate else " hidden"))
with Horizontal(classes="row"):
yield Button("Отмена", id="cancel", compact=True)
yield Button("Начать", variant="success", id="start", compact=True)
def on_mount(self) -> None:
# повторяет умолчание confirm_target: при recreate=true согласие не подразумевается
target = "cancel" if (self.recreate and self.spec.kind != "dump") else "start"
self.query_one(f"#{target}", Button).focus()
def _danger_text(self) -> str:
name = self.spec.target_db or "<как на стенде>"
return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — "
f"её текущее содержимое пропадёт")
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
self.query_one("#danger", Static).set_class(not event.value, "hidden")
def on_input_submitted(self) -> None:
# Enter в поле имени НЕ запускает операцию: случайное нажатие не должно дропать базу
self.query_one("#start", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "start":
self.action_start()
else:
self.action_cancel()
def action_start(self) -> None:
if self.spec.kind != "dump":
if self.spec.target_db is not None:
value = self.query_one("#target", Input).value.strip()
if not value:
self.notify("имя локальной базы пустое", severity="error")
return
self.spec.target_db = value
self.spec.no_recreate = not self.query_one("#recreate", Checkbox).value
self.dismiss(self.spec)
def action_cancel(self) -> None:
self.dismiss(None)
def _csv(values: list) -> str:
return ", ".join(str(v) for v in values or [])
def _split_csv(text: str) -> list:
return [part.strip() for part in text.split(",") if part.strip()]
class DbOpModal(ModalScreen):
"""Что делаем с выбранной базой: параметры дампа и галочка автоподнятия локально.
Заменяет собой выбор действия: снятый флаг — просто dump, поднятый — dump + restore.
Правки живут до выхода из пульта, config.json остаётся источником умолчаний.
"""
BINDINGS = [Binding("escape", "cancel", "отмена"),
Binding("ctrl+enter", "start", "начать", priority=True)]
def __init__(self, dbs: list, cfg: dict, defaults: dict) -> None:
super().__init__()
self.dbs = dbs
self.cfg = cfg
self.dump_opts = dict(defaults.get("dump") or {})
self.restore_opts = dict(defaults.get("restore") or {})
self.autorestore = bool(defaults.get("autorestore", True))
self.recreate = bool(defaults.get("recreate", cfg["target"].get("recreate", True)))
configured = cfg["target"].get("database")
# несколько баз льются каждая в одноимённую локальную — общего имени тут нет
self.target_db = None if len(dbs) > 1 else (configured or dbs[0])
def compose(self) -> ComposeResult:
# Checkbox вместо пары Switch+Label: подпись встроена, строка вместо трёх
with Vertical(classes="dialog op-dialog"):
yield Static(f"Операция с базой · {', '.join(self.dbs)}", classes="title")
with VerticalScroll(classes="form"):
yield Checkbox("поднять на локальной PG сразу после дампа",
value=self.autorestore, id="autorestore", compact=True)
yield Static("Дамп", classes="section")
yield Checkbox("только схема, без данных",
value=bool(self.dump_opts.get("schema_only", False)),
id="schema-only", compact=True)
with Horizontal(classes="check-row"):
yield Checkbox("без владельцев",
value=bool(self.dump_opts.get("no_owner", True)),
id="no-owner", compact=True)
yield Checkbox("без прав",
value=bool(self.dump_opts.get("no_privileges", True)),
id="no-priv", compact=True)
yield Label("сжатие", classes="inline")
yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress",
compact=True, classes="num")
with Collapsible(title="состав дампа", collapsed=True):
for wid, label, key in (
("schemas", "только схемы", "schemas"),
("ex-schemas", "исключить схемы", "exclude_schemas"),
("ex-tables", "исключить таблицы", "exclude_tables"),
("ex-data", "исключить данные таблиц", "exclude_table_data")):
with Horizontal(classes="field"):
yield Label(label, classes="inline")
yield Input(value=_csv(self.dump_opts.get(key)), id=wid,
compact=True, placeholder="через запятую")
with Vertical(id="target-block",
classes="" if self.autorestore else "hidden"):
yield Static("Локальная база", classes="section")
with Horizontal(classes="field"):
if self.target_db is None:
yield Label("каждая база — в одноимённую локальную", classes="inline")
else:
yield Label("имя", classes="inline")
yield Input(value=self.target_db, id="target", compact=True)
with Horizontal(classes="check-row"):
yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate,
id="recreate", compact=True)
yield Label("воркеров", classes="inline")
yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs",
compact=True, classes="num")
yield Static(self._danger_text(), id="danger",
classes="danger" + ("" if self.recreate else " hidden"))
with Horizontal(classes="row"):
yield Button("Отмена", id="cancel", compact=True)
yield Button("Начать", variant="success", id="start", compact=True)
def on_mount(self) -> None:
# согласие на DROP не подразумевается: фокус на «Отмена», пока база пересоздаётся
risky = self.autorestore and self.recreate
self.query_one("#cancel" if risky else "#start", Button).focus()
def _danger_text(self) -> str:
name = self.target_db or "<как на стенде>"
return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — "
f"её текущее содержимое пропадёт")
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
if event.checkbox.id == "autorestore":
self.autorestore = event.value
self.query_one("#target-block").set_class(not event.value, "hidden")
elif event.checkbox.id == "recreate":
self.recreate = event.value
self.query_one("#danger", Static).set_class(not event.value, "hidden")
def on_input_submitted(self) -> None:
# Enter в поле НЕ запускает операцию: случайное нажатие не должно дропать базу
self.query_one("#start", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "start":
self.action_start()
else:
self.action_cancel()
def _number(self, widget_id: str, low: int, high: int) -> int | None:
raw = self.query_one(f"#{widget_id}", Input).value.strip()
try:
value = int(raw)
except ValueError:
value = None
if value is None or not low <= value <= high:
self.notify(f"нужно число {low}{high}", severity="error")
self.query_one(f"#{widget_id}", Input).focus()
return None
return value
def action_start(self) -> None:
compress = self._number("compress", 0, 9)
if compress is None:
return
dump_opts = {
"schema_only": self.query_one("#schema-only", Checkbox).value,
"no_owner": self.query_one("#no-owner", Checkbox).value,
"no_privileges": self.query_one("#no-priv", Checkbox).value,
"compress": compress,
"schemas": _split_csv(self.query_one("#schemas", Input).value),
"exclude_schemas": _split_csv(self.query_one("#ex-schemas", Input).value),
"exclude_tables": _split_csv(self.query_one("#ex-tables", Input).value),
"exclude_table_data": _split_csv(self.query_one("#ex-data", Input).value),
}
overrides = {"dump": dump_opts}
target_db, no_recreate = None, False
if self.autorestore:
jobs = self._number("jobs", 1, 64)
if jobs is None:
return
overrides["restore"] = {"jobs": jobs}
no_recreate = not self.query_one("#recreate", Checkbox).value
if self.target_db is not None:
target_db = self.query_one("#target", Input).value.strip()
if not target_db:
self.notify("имя локальной базы пустое", severity="error")
return
spec = OpSpec("sync" if self.autorestore else "dump", dbs=self.dbs,
target_db=target_db, no_recreate=no_recreate, overrides=overrides)
self.dismiss(spec)
def action_cancel(self) -> None:
self.dismiss(None)
class QuitConfirm(ModalScreen):
BINDINGS = [Binding("escape", "stay", "остаться")]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Static("Операция ещё идёт", classes="title")
yield Static("выход прервёт её; если DROP DATABASE уже прошёл, "
"локальная база останется пустой", classes="danger")
with Horizontal(classes="row"):
yield Button("Остаться", id="stay")
yield Button("Прервать и выйти", variant="error", id="quit")
def on_mount(self) -> None:
self.query_one("#stay", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "quit")
def action_stay(self) -> None:
self.dismiss(False)
class HelpModal(ModalScreen):
HELP = """\
[b]набор букв[/b] фильтр панели под фокусом, сужается на каждом символе
[b]↑ ↓ PgUp PgDn[/b] курсор в списке, фокус остаётся в поле фильтра
[b]Tab[/b] следующая панель
[b]Enter[/b] базы — параметры дампа (галочка «поднять локально»),
дампы — Restore
[b]Space[/b] отметить базу (когда фокус в списке баз)
[b]Ctrl+D[/b] то же окно с выключенным автоподнятием — просто Dump
[b]Ctrl+A / Ctrl+U[/b] отметить показанные / снять все отметки
[b]Esc[/b] очистить фильтр, затем отметки
[b]F5[/b] перечитать списки
[b]Ctrl+X[/b] прервать операцию (это kill, не откат)
[b]Ctrl+L / Ctrl+S[/b] очистить журнал / сохранить его в dumps
[b]F4[/b] журнал во весь экран (при операции — сам, Esc — назад)
[b]F2 / F3[/b] тема / показывать ли вывод утилит
[b]Ctrl+C[/b] выход
"""
BINDINGS = [Binding("escape", "close", "закрыть")]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Static("Клавиши", classes="title")
yield Static(self.HELP, id="help-body")
def on_key(self) -> None:
self.dismiss(None)
def action_close(self) -> None:
self.dismiss(None)
# --------------------------------------------------------------------------- пульт
class RunPlan(VerticalScroll):
"""Шаги операции с состоянием: на двадцатиминутном restore спиннер не отвечает «где мы»."""
def compose(self) -> ComposeResult:
yield Static(id="plan-body")
def render_steps(self, steps: list, frame: str) -> None:
body = self.query_one("#plan-body", Static)
if not steps:
body.update(Content("операция не запущена").stylize("dim"))
return
# ширина берётся у виджета: фиксированные 24+8 не влезали в панель и рвали «1.2 c» пополам
width = self.content_size.width or 32
lines = []
for step in steps:
glyph = frame if step.state == "active" else STATE_GLYPH[step.state]
tail = f"{step.elapsed:>6.1f} c" if step.elapsed is not None else ""
room = max(width - len(tail) - 2, 8)
lines.append(Content(f"{glyph} {clip(step.label, room):<{room}}{tail}")
.stylize(STATE_STYLE[step.state]))
body.update(Content("\n").join(lines))
# длинный план не влезает в панель: активный шаг должен оставаться видимым сам
active = next((i for i, step in enumerate(steps) if step.state == "active"), None)
if active is not None and self.content_size.height:
if not (self.scroll_offset.y <= active < self.scroll_offset.y + self.content_size.height):
self.scroll_to(y=max(active - self.content_size.height // 2, 0), animate=False)
class DashboardScreen(Screen):
"""Единственный рабочий экран: оба списка на виду, поэтому шага «выбор действия» нет."""
BINDINGS = [
Binding("down", "cursor('down')", show=False),
Binding("up", "cursor('up')", show=False),
Binding("pagedown", "cursor('page_down')", show=False),
Binding("pageup", "cursor('page_up')", show=False),
Binding("ctrl+home", "cursor('first')", show=False),
Binding("ctrl+end", "cursor('last')", show=False),
Binding("escape", "back", "сброс", show=False),
]
def __init__(self, cfg: dict, tools: dict, dry_run: bool) -> None:
super().__init__()
self.cfg = cfg
self.tools = tools
self.dry_run = dry_run
self.all_dbs: list = []
self.all_dumps: list = []
self.op_active = False
self.show_out = True
# (kind, ts, text) — переживает очистку панели; предел свой, потому что --verbose
# на большой базе даёт сотни тысяч строк, а перерисовка журнала идёт в цикле событий
self.journal: deque = deque(maxlen=JOURNAL_LIMIT)
self.steps: list = []
self.op_title = ""
self.op_started = 0.0
self.frame = 0
self._focus_before_op = None
self.zoomed = False # журнал во весь экран
self._focus_before_zoom = None
self._zoom_auto = False # развернули сами на время операции — сами и свернём
# умолчания диалога операции: из конфига, дальше — то, что выбрали в прошлый раз
self.op_defaults = {"dump": dict(cfg.get("dump") or {}),
"restore": dict(cfg.get("restore") or {}),
"recreate": bool(cfg["target"].get("recreate", True)),
"autorestore": True}
self._queue: deque = deque()
self._queue_lock = threading.Lock()
# ---------------------------------------------------------------- разметка
def compose(self) -> ComposeResult:
yield Static(id="topbar")
with Vertical(id="board"):
with Vertical(id="pane-db", classes="panel"):
yield Input(placeholder="фильтр…", id="flt-db", compact=True)
yield Static(id="db-error")
yield DbList(id="tbl-db")
with Vertical(id="col-right"):
yield Static(id="target-card")
with Vertical(id="pane-dump", classes="panel"):
yield Input(placeholder="фильтр…", id="flt-dump", compact=True)
yield Static(id="dump-note")
yield DumpList(id="tbl-dump")
with Horizontal(id="bottom"):
yield RunPlan(id="plan")
yield RichLog(id="log", markup=True, highlight=False, wrap=False,
max_lines=LOG_LINES, auto_scroll=True)
with Horizontal(id="status"):
yield Static("", id="op-spin")
yield Static("готов", id="op-title")
yield ProgressBar(id="op-progress", total=1, show_eta=False)
yield Static("", id="op-timer")
yield Footer(compact=True, show_command_palette=False)
def on_mount(self) -> None:
src, tgt = self.cfg["source"], self.cfg["target"]
badge = " ◆ DRY-RUN" if self.dry_run else ""
self.query_one("#topbar", Static).update(
Content(f"pg-stand-sync{badge}\n"
f"стенд {src['user']}@{src['host']}:{src.get('port', 5432)}"
f" → локально {tgt['user']}@{tgt['host']}:{tgt.get('port', 5432)}"))
self.query_one("#pane-db").border_title = f"Базы стенда · {src['host']}"
self.query_one("#pane-dump").border_title = f"Дампы · {core.dump_dir_of(self.cfg).name}"
self.query_one("#plan").border_title = "План"
self.query_one("#log").border_title = "Журнал"
self._render_target_card()
self.query_one("#op-progress", ProgressBar).display = False
self.set_interval(0.08, self._drain) # батч строк: --verbose иначе топит цикл
self.set_interval(0.1, self._tick)
self.query_one("#flt-db", Input).focus()
self.reload()
def _render_target_card(self) -> None:
tgt = self.cfg["target"]
mode = "DROP + CREATE" if tgt.get("recreate", True) else "накатить поверх"
database = tgt.get("database") or "как на стенде"
jobs = self.cfg.get("restore", {}).get("jobs", 4)
card = self.query_one("#target-card", Static)
card.border_title = "Приёмник"
card.update(Content(f"режим {mode}\n"
f"цель {database}\n"
f"jobs {jobs} · keep_dumps {self.cfg.get('keep_dumps', 5)}"))
def on_resize(self) -> None:
# media-queries в Textual нет, поэтому классы вешаются руками
self.set_class(self.size.width < 100, "narrow")
# в низком окне фиксированные 20 строк журнала не оставляют места спискам
self.set_class(self.size.height < 32, "short")
# ---------------------------------------------------------------- данные
def reload(self) -> None:
db_list = self.query_one("#tbl-db", DbList)
db_list.loading = True
self.query_one("#db-error", Static).remove_class("shown")
self.query_one("#pane-db").remove_class("pane-error")
self.load_meta()
@work(thread=True, exclusive=True, group="meta", exit_on_error=False)
def load_meta(self) -> None:
# exclusive отменяет только задачу, поток продолжает крутиться: результат вытесненного
# чтения (и его forget_password) не должен перетирать состояние следующего
worker = get_current_worker()
try:
rows = [DbRow(name, size)
for name, size in core.list_databases(self.tools, self.cfg["source"], "стенд")]
except core.Cancelled:
if not worker.is_cancelled:
self.post_message(MetaFailed("чтение списка баз отменено"))
return
except (core.StepError, core.ConfigError) as exc:
if worker.is_cancelled:
return
# пароль мог не подойти — иначе F5 будет молча повторять ту же ошибку
core.forget_password(self.cfg["source"], "стенд")
self.post_message(MetaFailed(str(exc)))
return
if worker.is_cancelled:
return
dumps = []
for path in core.local_dumps(self.cfg):
st = path.stat() # stat снимается один раз, а не на каждой перерисовке
dumps.append(DumpRow(path, path.name, st.st_size / 1024 / 1024,
datetime.fromtimestamp(st.st_mtime), core.db_from_dump_name(path)))
self.post_message(MetaLoaded(rows, dumps))
def on_meta_loaded(self, message: MetaLoaded) -> None:
self.query_one("#tbl-db", DbList).loading = False
self.all_dbs = message.dbs
self.all_dumps = message.dumps
self._apply_filters()
note = self.query_one("#dump-note", Static)
if not self.all_dumps:
note.update(f"в {core.dump_dir_of(self.cfg)} нет файлов *.dump")
note.add_class("shown")
else:
note.remove_class("shown")
def on_meta_failed(self, message: MetaFailed) -> None:
self.query_one("#tbl-db", DbList).loading = False
self.query_one("#pane-db").add_class("pane-error")
error = self.query_one("#db-error", Static)
error.update(f"{message.error}\nF5 — повторить")
error.add_class("shown")
self._write(("err", datetime.now(), message.error))
def _apply_filters(self) -> None:
db_query = self.query_one("#flt-db", Input).value
rows = [row for row in self.all_dbs if core.matches(f"{row.name} {row.size}", db_query)]
self.query_one("#tbl-db", DbList).show(rows, db_query, len(self.all_dbs))
dump_query = self.query_one("#flt-dump", Input).value
dumps = [row for row in self.all_dumps if core.matches(row.name, dump_query)]
self.query_one("#tbl-dump", DumpList).show(dumps, dump_query, len(self.all_dumps))
self._render_marks()
def _render_marks(self) -> None:
db_list = self.query_one("#tbl-db", DbList)
db_list.counter_host().border_subtitle = db_list.counter()
def on_input_changed(self, event: Input.Changed) -> None:
# приходит на каждый символ — Enter для сужения списка не нужен
if event.input.id in ("flt-db", "flt-dump"):
self._apply_filters()
def on_db_list_marks_changed(self) -> None:
self._render_marks()
# ---------------------------------------------------------------- действия
def _pane(self) -> str:
focused = self.focused.id if self.focused else ""
return "dump" if focused in ("flt-dump", "tbl-dump") else "db"
def _active_list(self) -> FilterList:
if self._pane() == "dump":
return self.query_one("#tbl-dump", DumpList)
return self.query_one("#tbl-db", DbList)
CURSOR_ACTIONS = {"down": "action_cursor_down", "up": "action_cursor_up",
"page_down": "action_page_down", "page_up": "action_page_up",
"first": "action_first", "last": "action_last"}
LOG_SCROLL = {"down": "scroll_down", "up": "scroll_up",
"page_down": "scroll_page_down", "page_up": "scroll_page_up",
"first": "scroll_home", "last": "scroll_end"}
def action_cursor(self, what: str) -> None:
"""Стрелки из поля фильтра двигают список, не забирая фокус у ввода."""
if self.zoomed:
getattr(self.query_one("#log", RichLog), self.LOG_SCROLL[what])()
return
getattr(self._active_list(), self.CURSOR_ACTIONS[what])()
def toggle_zoom(self) -> None:
# ручное переключение отменяет авто-возврат: раз человек решил сам, дальше решает он
self._zoom_auto = False
self.set_zoom(not self.zoomed)
def set_zoom(self, on: bool) -> None:
"""Журнал во весь экран: на узком окне списки и лог не помещаются одновременно."""
if on == self.zoomed:
return
self.zoomed = on
self.set_class(on, "zoom-log")
if on:
self._focus_before_zoom = self.focused
self.query_one("#log", RichLog).focus()
return
widget = self._focus_before_zoom
self._focus_before_zoom = None
if widget is None or not widget.is_attached or widget.disabled:
widget = self.query_one("#flt-db", Input)
self.set_focus(widget)
def action_back(self) -> None:
if self.zoomed:
self.set_zoom(False)
return
pane = self._pane()
field_id = "#flt-dump" if pane == "dump" else "#flt-db"
field = self.query_one(field_id, Input)
if field.value:
field.value = ""
return
db_list = self.query_one("#tbl-db", DbList)
if db_list.marked:
self.clear_marks()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id in ("flt-db", "flt-dump"):
self.primary_action()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
self.primary_action()
def primary_action(self) -> None:
if self._pane() == "dump":
self.restore_selected()
else:
self.sync_selected()
def sync_selected(self) -> None:
self.open_db_op(autorestore=True)
def dump_selected(self) -> None:
self.open_db_op(autorestore=False)
def open_db_op(self, autorestore: bool) -> None:
"""Клик по базе открывает не «подтверждение», а параметры дампа с галочкой restore."""
db_list = self.query_one("#tbl-db", DbList)
dbs = db_list.selection()
if not self._ready(dbs, "не выбрана база"):
return
configured = self.cfg["target"].get("database")
if autorestore and len(dbs) > 1 and configured:
# do_sync считает local = target_db or cfg.target.database or dbname, поэтому None
# здесь не значит «как на стенде»: все отмеченные базы легли бы в configured по очереди
self.notify(f"в конфиге target.database = {configured}: несколько баз слились бы "
f"в одну — синхронизируйте по одной", severity="warning")
return
defaults = dict(self.op_defaults, autorestore=autorestore)
self.ask_and_run(DbOpModal(dbs, self.cfg, defaults))
def remember_defaults(self, spec: OpSpec) -> None:
"""Параметры живут до выхода из пульта: следующий диалог открывается на них же."""
self.op_defaults["dump"] = dict(spec.overrides.get("dump") or {})
if spec.kind == "sync":
self.op_defaults["restore"] = dict(spec.overrides.get("restore") or {})
self.op_defaults["recreate"] = not spec.no_recreate
self.op_defaults["autorestore"] = spec.kind == "sync"
def op_cfg(self, spec: OpSpec) -> dict:
"""Копия конфига с правками из диалога: ядро принимает cfg, менять его не нужно."""
if not spec.overrides:
return self.cfg
cfg = dict(self.cfg)
for section, values in spec.overrides.items():
cfg[section] = {**(self.cfg.get(section) or {}), **values}
return cfg
def restore_selected(self) -> None:
row = self.query_one("#tbl-dump", DumpList).current
if not self._ready([row] if row else [], "не выбран файл дампа"):
return
target = self.cfg["target"].get("database") or row.db
spec = OpSpec("restore", dump_file=row.path, target_db=target)
self.ask_and_run(ConfirmModal(spec, self.cfg, row.name))
def _ready(self, items: list, complaint: str) -> bool:
if self.op_active:
self.notify("операция уже идёт", severity="warning")
return False
if not items:
self.notify(complaint, severity="warning")
return False
return True
def _password_nodes(self, spec: OpSpec) -> list:
"""Чьи пароли понадобятся операции: их же забываем, если она провалилась."""
nodes = []
if spec.kind in ("sync", "dump"):
nodes.append((self.cfg["source"], "стенд"))
if spec.kind in ("sync", "restore"):
nodes.append((self.cfg["target"], "локальная"))
return nodes
@work(exclusive=True, group="prep")
async def ask_and_run(self, dialog: ModalScreen) -> None:
"""Диалог и пароли — до старта воркера: getpass из потока подвесил бы UI."""
confirmed = await self.app.push_screen_wait(dialog)
if confirmed is None:
return
if confirmed.dbs:
self.remember_defaults(confirmed)
for node, label in self._password_nodes(confirmed):
if not core.password_needed(node, label):
continue
value = await self.app.push_screen_wait(PasswordModal(label, node))
if value is None:
return
core.prime_password(node, label, value)
self.begin(confirmed)
# ---------------------------------------------------------------- операция
def begin(self, spec: OpSpec) -> None:
core.clear_cancel()
self.op_active = True
self.op_started = time.monotonic()
self.op_title = {"sync": "sync", "dump": "dump", "restore": "restore"}[spec.kind]
if spec.dbs:
self.op_title += " " + ", ".join(spec.dbs)
elif spec.dump_file:
self.op_title += " " + spec.dump_file.name
self.steps = [PlanStep(label) for label in self._expected_steps(spec)]
bar = self.query_one("#op-progress", ProgressBar)
bar.display = True
bar.update(total=max(len(self.steps), 1), progress=0)
# disabled у предка снимает фокус: без запоминания фильтр после операции немой до Tab
self._focus_before_op = self.focused
for widget_id in ("#pane-db", "#pane-dump", "#flt-db", "#flt-dump",
"#tbl-db", "#tbl-dump"):
widget = self.query_one(widget_id)
widget.add_class("locked")
widget.disabled = True
self._write(("log", datetime.now(), "" * 8 + f" {self.op_title} " + "" * 8))
# смотреть во время операции всё равно нечего, кроме журнала и плана
self._zoom_auto = not self.zoomed
self.set_zoom(True)
self.run_op(spec)
def _expected_steps(self, spec: OpSpec) -> list:
"""План строится по тем же формулам, что и what в run(); дрейф дописывается на ходу."""
src, tgt = self.cfg["source"], self.cfg["target"]
recreate = bool(tgt.get("recreate", True)) and not spec.no_recreate
post = len(self.cfg.get("post_restore_sql") or [])
labels = []
def restore_block(dbname: str) -> None:
if recreate:
labels.extend(["обрыв активных сессий", "DROP DATABASE", "CREATE DATABASE"])
labels.append(f"restore в {dbname}@{tgt['host']}")
labels.extend(f"post-SQL {i}/{post}" for i in range(1, post + 1))
if spec.kind in ("sync", "dump"):
for dbname in spec.dbs:
labels.append(f"дамп {dbname}@{src['host']}")
if spec.kind == "sync":
restore_block(spec.target_db or tgt.get("database") or dbname)
else:
restore_block(spec.target_db or "")
return labels
@work(thread=True, exclusive=True, group="op", exit_on_error=False)
def run_op(self, spec: OpSpec) -> None:
started = time.monotonic()
cfg = self.op_cfg(spec)
try:
if spec.kind == "sync":
core.do_sync(cfg, self.tools, spec.dbs, spec.target_db,
spec.no_recreate, self.dry_run)
elif spec.kind == "dump":
core.do_dump(cfg, self.tools, spec.dbs, self.dry_run)
else:
core.do_restore(cfg, self.tools, spec.dump_file, spec.target_db,
spec.no_recreate, self.dry_run)
except core.Cancelled:
self.post_message(OpDone(False, "операция прервана", time.monotonic() - started, True))
return
except (core.StepError, core.ConfigError) as exc:
# пароль мог не подойти: без сброса кэша модалка больше не покажется,
# и все следующие операции будут падать тем же текстом до перезапуска
for node, label in self._password_nodes(spec):
core.forget_password(node, label)
self.post_message(OpDone(False, str(exc), time.monotonic() - started))
return
self.post_message(OpDone(True, "", time.monotonic() - started))
def on_op_done(self, message: OpDone) -> None:
self._drain() # хвост очереди обгонять итоговой строкой нельзя
self.op_active = False
# единственное место, где снимать флаг отмены безопасно: воркер уже отдал результат
# и в ядро больше не заходит, а следующий reload() иначе упрётся в ту же отмену
core.clear_cancel()
for widget_id in ("#pane-db", "#pane-dump", "#flt-db", "#flt-dump",
"#tbl-db", "#tbl-dump"):
widget = self.query_one(widget_id)
widget.remove_class("locked")
widget.disabled = False
self._restore_focus()
for step in self.steps:
if step.state in ("pending", "active"):
step.state = "fail" if not message.ok else "done"
if message.ok:
self._write(("ok", datetime.now(), f"готово за {message.elapsed:.1f} c"))
self.query_one("#op-title", Static).update("готов")
elif message.cancelled:
self._write(("err", datetime.now(),
"операция прервана; если DROP DATABASE уже прошёл, "
"локальная база осталась пустой — повторите restore"))
self.query_one("#op-title", Static).update("прервано")
else:
self._write(("err", datetime.now(), message.error))
self.query_one("#op-title", Static).update("ошибка")
self.query_one("#op-spin", Static).update("")
self.query_one("#op-timer", Static).update("")
self.query_one("#op-progress", ProgressBar).display = False
self.reload()
def _restore_focus(self) -> None:
"""Вернуть фокус туда, где он был до блокировки панелей: иначе фильтр не набирается."""
widget = self._focus_before_op
self._focus_before_op = None
if self.zoomed and self._zoom_auto:
# свернуть обратно: иначе фокус остаётся в журнале и фильтр не набирается
self._zoom_auto = False
self._focus_before_zoom = widget
self.set_zoom(False)
return
if self.zoomed:
self._focus_before_zoom = widget
return
if widget is None or not widget.is_attached or widget.disabled:
widget = self.query_one("#flt-db", Input)
self.set_focus(widget)
def on_worker_state_changed(self, event) -> None:
# второй рубеж: непойманное исключение воркера должно стать строкой в журнале, не падением
if event.state is not WorkerState.ERROR:
return
error = event.worker.error
self._write(("err", datetime.now(), f"сбой ({event.worker.group}): {error!r}"))
if event.worker.group == "op":
self.post_message(OpDone(False, str(error), time.monotonic() - self.op_started))
elif event.worker.group == "meta":
self.post_message(MetaFailed(str(error)))
def cancel_op(self) -> None:
if not self.op_active:
return
self._write(("warn", datetime.now(), "прерываю: убиваю дочерние процессы"))
self.kill_children()
@work(thread=True, group="cancel", exit_on_error=False)
def kill_children(self) -> None:
"""request_cancel ждёт смерти процессов секундами: в цикле событий это замерший UI."""
if not core.request_cancel():
self.sink("err", "остались процессы pg_restore — "
"проверьте соединения к локальной базе")
# ---------------------------------------------------------------- журнал и план
def sink(self, kind: str, text: str) -> None:
"""Вызывается из рабочего потока: только очередь, никаких обращений к виджетам."""
with self._queue_lock:
self._queue.append(("line", kind, datetime.now(), text))
def progress(self, event: str, what: str, elapsed=None) -> None:
with self._queue_lock:
self._queue.append(("step", event, what, elapsed))
def _drain(self) -> None:
with self._queue_lock:
if not self._queue:
return
batch = list(self._queue)
self._queue.clear()
for item in batch:
if item[0] == "line":
self._write(item[1:])
else:
_, event, what, elapsed = item
self._step_event(event, what, elapsed)
def _step_event(self, event: str, what: str, elapsed) -> None:
step = next((s for s in self.steps if s.label == what and s.state == "pending"), None)
if step is None:
step = PlanStep(what)
self.steps.append(step) # конфиг мог разойтись с планом — не ломаемся об это
self.query_one("#op-progress", ProgressBar).update(total=len(self.steps))
if event == "start":
step.state = "active"
self.query_one("#op-title", Static).update(
f"{self.op_title} · {what} · шаг {self.steps.index(step) + 1}/{len(self.steps)}")
else:
step.state = "done" if event == "done" else "fail"
step.elapsed = elapsed
done = sum(1 for s in self.steps if s.state in ("done", "fail", "warn"))
self.query_one("#op-progress", ProgressBar).update(progress=done)
def _write(self, record, journal: bool = True) -> None:
kind, stamp, text = record
if journal:
self.journal.append(record)
if kind == "out" and not self.show_out:
return
if kind == "out":
# строки утилит пишутся Content'ом, а не markup: [ ] в их выводе — обычные скобки
line = Content(f" {text}")
else:
glyph = {"warn": "", "err": "", "ok": ""}.get(kind, "")
line = Content(f"[{stamp:%H:%M:%S}] {glyph}{text}")
style = LOG_STYLE[kind]
self.query_one("#log", RichLog).write(line.stylize(style) if style else line)
def _tick(self) -> None:
self.frame = (self.frame + 1) % len(SPINNER)
frame = SPINNER[self.frame]
self.query_one("#plan", RunPlan).render_steps(self.steps, frame)
if not self.op_active:
return
self.query_one("#op-spin", Static).update(frame)
span = int(time.monotonic() - self.op_started)
self.query_one("#op-timer", Static).update(f"{span // 60:02d}:{span % 60:02d}")
# ---------------------------------------------------------------- мелкие действия
def mark_all(self) -> None:
db_list = self.query_one("#tbl-db", DbList)
db_list.marked.update(row.name for row in db_list.rows)
db_list.show(db_list.rows, db_list.query, len(self.all_dbs))
self._render_marks()
def clear_marks(self) -> None:
db_list = self.query_one("#tbl-db", DbList)
db_list.marked.clear()
db_list.show(db_list.rows, db_list.query, len(self.all_dbs))
self._render_marks()
def clear_log(self) -> None:
self.query_one("#log", RichLog).clear()
def toggle_out(self) -> None:
"""RichLog обрезается по max_lines: иногда проще спрятать шум утилит целиком."""
self.show_out = not self.show_out
log_view = self.query_one("#log", RichLog)
log_view.clear()
# перерисовывается только хвост: длиннее max_lines панель всё равно не покажет
for record in list(self.journal)[-LOG_LINES:]:
self._write(record, journal=False)
self.notify("вывод утилит: " + ("виден" if self.show_out else "скрыт"))
def save_log(self) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
path = core.dump_dir_of(self.cfg) / f"session-{stamp}.log"
path.parent.mkdir(parents=True, exist_ok=True)
lines = []
for kind, when, text in self.journal:
lines.append(f" {text}" if kind == "out" else f"[{when:%H:%M:%S}] {text}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
# --------------------------------------------------------------------------- приложение
class PgSyncApp(App):
"""Ctrl-хоткеи объявлены priority: иначе Input съедает ctrl+a/d/x/c и они молча не сработают."""
CSS_PATH = CSS_FILE
TITLE = "pg-stand-sync"
ENABLE_COMMAND_PALETTE = False
BINDINGS = with_ru_layout([
Binding("ctrl+c", "leave", "выход", priority=True),
Binding("ctrl+d", "dump", "dump", priority=True),
Binding("ctrl+a", "mark_all", "все", priority=True),
Binding("ctrl+u", "clear_marks", "снять", priority=True),
Binding("ctrl+x", "stop", "стоп", priority=True),
Binding("ctrl+l", "clear_log", "очистить", priority=True, show=False),
Binding("ctrl+s", "save_log", "журнал", priority=True, show=False),
Binding("f5", "reload", "обновить"),
Binding("ctrl+r", "reload", "обновить", show=False),
Binding("f1", "help", "помощь"),
Binding("f2", "switch_theme", "тема", show=False),
Binding("f3", "toggle_out", "вывод", show=False),
Binding("f4", "zoom", "журнал"),
])
def __init__(self, cfg: dict, tools: dict, dry_run: bool) -> None:
super().__init__()
self.cfg = cfg
self.tools = tools
self.dry_run = dry_run
self.dashboard: DashboardScreen | None = None
# (событие, ответ) каждой открытой PasswordModal: при выходе их надо отпустить руками
self._prompts: list = []
def on_mount(self) -> None:
self.register_theme(DARK)
self.register_theme(LIGHT)
self.theme = DARK.name
self.dashboard = DashboardScreen(self.cfg, self.tools, self.dry_run)
core.set_sink(self.dashboard.sink)
core.set_progress(self.dashboard.progress)
core.set_password_prompt(self._password_from_thread)
self.push_screen(self.dashboard)
def on_unmount(self) -> None:
core.set_sink(None)
core.set_progress(None)
core.set_password_prompt(None)
# флаг отмены здесь НЕ снимается: рабочий поток может быть ещё жив, и со снятым флагом
# он спокойно доработает следующий DROP DATABASE уже без UI. Сброс делает begin().
core.request_cancel()
# dismiss при разборе экранов не вызывается — колбэк модалки не придёт никогда
for answered, holder in list(self._prompts):
holder.setdefault("value", None)
answered.set()
self._prompts.clear()
def _password_from_thread(self, label: str, node: dict):
"""Пароль, запрошенный ленивым resolve_password из середины do_sync.
Блокируется рабочий поток, а не цикл событий: модалку открывает call_from_thread,
ответ приходит колбэком. Ждать через worker-контекст нельзя — его здесь нет.
"""
holder: dict = {}
answered = threading.Event()
def opener() -> None:
def got(value) -> None:
holder["value"] = value
answered.set()
self.push_screen(PasswordModal(label, node), got)
pending = (answered, holder)
self._prompts.append(pending)
try:
self.call_from_thread(opener)
except Exception:
self._prompts.remove(pending)
return None
try:
# шагами, а не одним wait: закрытое приложение обязано отпустить поток сразу,
# иначе процесс не завершается, пока не истечёт весь таймаут
deadline = time.monotonic() + PASSWORD_WAIT
while not answered.wait(0.2):
if not self.is_running or time.monotonic() > deadline:
return None
finally:
if pending in self._prompts:
self._prompts.remove(pending)
return holder.get("value")
# ---- действия зовут пульт, а не текущий экран: сверху может лежать модалка
def _dash(self) -> DashboardScreen | None:
if self.dashboard is not None and isinstance(self.screen, DashboardScreen):
return self.dashboard
return None
def action_dump(self) -> None:
dash = self._dash()
if dash:
dash.dump_selected()
def action_mark_all(self) -> None:
dash = self._dash()
if dash:
dash.mark_all()
def action_clear_marks(self) -> None:
dash = self._dash()
if dash:
dash.clear_marks()
def action_reload(self) -> None:
dash = self._dash()
if dash and not dash.op_active:
dash.reload()
def action_stop(self) -> None:
dash = self._dash()
if dash:
dash.cancel_op()
def action_clear_log(self) -> None:
dash = self._dash()
if dash:
dash.clear_log()
def action_toggle_out(self) -> None:
dash = self._dash()
if dash:
dash.toggle_out()
def action_save_log(self) -> None:
dash = self._dash()
if not dash:
return
try:
self.notify(f"журнал: {dash.save_log()}")
except OSError as exc:
self.notify(f"не сохранился: {exc}", severity="error")
def action_zoom(self) -> None:
if self.dashboard is not None:
self.dashboard.toggle_zoom()
def action_help(self) -> None:
if not isinstance(self.screen, HelpModal):
self.push_screen(HelpModal())
def action_switch_theme(self) -> None:
self.theme = LIGHT.name if self.theme == DARK.name else DARK.name
@work(exclusive=True, group="quit")
async def action_leave(self) -> None:
"""Ctrl+C в Textual 8 по умолчанию копирует текст: выход даёт только priority-биндинг."""
dash = self.dashboard
if dash is not None and dash.op_active:
if not await self.push_screen_wait(QuitConfirm()):
return
core.request_cancel()
self.exit(130)
return
self.exit(0)
def run_tui(cfg: dict, tools: dict, dry_run: bool) -> int:
"""Точка входа для main(): наружу отдаётся только код возврата."""
app = PgSyncApp(cfg, tools, dry_run)
result = app.run()
return 0 if result is None else int(result)