diff --git a/.gitignore b/.gitignore index ae67825..b5e1e72 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dumps/ *.dump __pycache__/ *.pyc +.venv/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52ba299 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Что это + +`pg_stand_sync.py` переносит базы PostgreSQL со стенда на локальный сервер через +`pg_dump -Fc` → `DROP/CREATE DATABASE` → `pg_restore -j`. Ядро (CLI и текстовое меню) — +только stdlib Python 3.10+; TUI-пульт лежит в `pg_stand_sync_tui.py` и требует `textual` +(`requirements.txt`). Без textual и вне tty всё работает по-старому текстом. + +Утилиты берутся не из PATH, а из поставки pgAdmin 4 (`pg_bin_dir` в конфиге, psql 18) — +локальный сервер при этом PG 16, поэтому дамп со стенда новее 16 может частично +не примениться. + +## Команды + +```bash +python pg_stand_sync.py # TUI-пульт (основной режим) +python pg_stand_sync.py --no-tui # то же текстовым меню +python pg_stand_sync.py list # базы стенда — самая быстрая проверка связи +python pg_stand_sync.py --dry-run sync DB # печатает все команды, к серверам не ходит +``` + +Проверка изменений — `tests_tui.py` (pytest, TUI гоняется headless через `App.run_test()` +и `Pilot`, к стенду никто не ходит), плюс `--dry-run` для сверки состава командных строк и +`list` для реального подключения: + +```bash +.venv/Scripts/python.exe -m pytest tests_tui.py -q +``` + +⚠️ Запускать TUI в терминале агента нельзя — сессия заблокируется. Всё проверяется только +через Pilot и `--dry-run`. Pylance в строгом режиме шумит десятками «частично неизвестных +типов» — это ожидаемо, аннотации намеренно нестрогие. + +Стенд — `postgresql.lan` (`postgres.lan` не резолвится), PG 16; локальный сервер — PG 15, +утилиты из pgAdmin — 18, отсюда шум `unrecognized configuration parameter` при restore +(объясняется строкой `NEWER_CLIENT_HINT`, данные не страдают). + +Пароль стенда лежит в `config.json` (файл в `.gitignore`), поэтому неинтерактивные проверки +проходят молча. Если его там нет — задать `$env:STAND_PGPASSWORD`: без пароля и без tty +`getpass` подвесил бы скрипт. + +## Устройство скрипта + +Слои, снизу вверх, — правки почти всегда касаются одного из них: + +``` +conn_env(node,label,db) libpq-переменные: пароль только через PGPASSWORD, никогда в argv + └─ resolve_password() config.password → password_env → хук UI → getpass (кэш на процесс) +run() / capture() Popen + построчное чтение; убиваемы через request_cancel() +no_prompt(env) --no-password, когда пароля нет: иначе psql ждёт ввода вечно +dump / recreate_target / restore / post_sql шаги, все принимают dry_run +do_dump / do_restore / do_sync сценарии, ими пользуются оба режима +pick() + interactive() текстовый режим; CLI-ветка в main() вызывает те же do_* +tui_enabled(args) единственная точка решения о режиме + └─ pg_stand_sync_tui.run_tui() пульт на Textual, те же do_* +``` + +Четыре необязательных хука — всё, что ядро знает об UI. Пока они не установлены, текстовый +вывод не отличается от прежнего ни на символ: + +```ini +set_sink(fn) ; fn(kind, text), kind = log | warn | out; иначе print в stdout +set_progress(fn) ; fn(event, what, elapsed), event = start | done | fail +set_password_prompt(fn) ; fn(label, node) вместо getpass; вернул None — Cancelled +request_cancel() ; terminate + taskkill /T /F дерева; это kill, а не rollback +matches(text, query) ; общий фильтр для pick() и обоих списков TUI +``` + +Ключевые инварианты: + +- Диалог `DbOpModal` — единственный вход в операции над базой: галочка автоподнятия решает + `kind` (`dump` или `sync`), а правки полей уезжают в `OpSpec.overrides` и накладываются + копией конфига в `op_cfg()`. Файл `config.json` пульт не переписывает, значения живут + до выхода в `DashboardScreen.op_defaults`. +- Имя базы — параметр, а не поле конфига. `source` описывает только сервер + (`maintenance_database` — куда подключаться, чтобы прочитать `pg_database`), + `target.database: null` означает «локально как на стенде»; переопределяется `--target-db`. +- Любая новая операция добавляется как `do_*` и подключается в оба режима — в `interactive()` + и в `main()`. Дублировать логику в ветке меню нельзя. +- `dry_run` протаскивается до `run()`, который в этом режиме только печатает команду. Новый + вызов внешней утилиты обязан идти через `run`/`capture`, иначе `--dry-run` соврёт. +- `pick()` — способ выбора из списка в текстовом режиме: текст фильтрует, число выбирает, + `multi=True` разрешает `2,5,7`, `q` поднимает `Cancelled` (в меню — возврат, не выход). + Fallback-выбор в CLI-командах (`sync` без имени базы) остаётся текстовым даже при + установленном textual: TUI посреди CLI-команды сломал бы pipe-сценарии. +- Текст `StepError` — `«{what} завершился с кодом {N}»` — менять нельзя: на него завязано + глушение ошибок `pg_restore`. +- Новый `Ctrl`-хоткей добавляется только через `with_ru_layout([...])`: при русской раскладке + терминал присылает `ctrl+в` вместо `ctrl+d`, и голый биндинг молча не срабатывает. Полноту + дублей проверяет тест, перебирающий `PgSyncApp.BINDINGS`. +- В TUI любой `Ctrl+…`-хоткей объявляется `priority=True`: `Input` биндит `ctrl+a`, `ctrl+d`, + `ctrl+u`, `ctrl+x`, `ctrl+c` — без приоритета действия молча не сработают в фильтре. + `Ctrl+C` в Textual 8 по умолчанию копирует текст, а не выходит. +- Отмена (`Ctrl+X`) — это kill дочерних процессов, отката нет: если `DROP DATABASE` уже + прошёл, локальная база останется пустой, и UI обязан сказать это прямым текстом. + `request_cancel()` зовётся только из потока (в цикле событий он вешает UI на секунды), + а флаг отмены снимается только там, где воркер точно не в ядре, — в `begin()` и в + `on_op_done()`. В `on_unmount()` его снимать нельзя: при выходе рабочий поток ещё жив и со + снятым флагом спокойно доработает следующий `DROP DATABASE` уже без UI. +- `target_db=None` в TUI не значит «как на стенде»: `do_sync` считает + `target_db or cfg.target.database or dbname`. Поэтому sync нескольких баз запрещён, + когда `target.database` задан, — иначе они слились бы в одну. +- Строки списков строятся через `room_for()`, который вычитает `SCROLLBAR_ROOM = 2`: сам + скроллбар в `content_size` не учтён, и строка ровно по ширине переносит хвост («1277 MB» + рвётся на «1277» и «MB»). Проверяется тестом со `scrollbar-gutter: stable`. +- Раскладка: `narrow` (<100 колонок) и `short` (<32 строк) вешаются руками в `on_resize`; + `Horizontal` со сменой `layout: vertical` не раздаёт детям ни `fr`, ни проценты по высоте, + поэтому в узком режиме правая колонка задана в строках. `.zoom-log #bottom` требует + `!important` — равная по специфичности `.short` иначе побеждает. +- `F4` разворачивает журнал; `begin()` делает это сам и помечает `_zoom_auto`, чтобы + `on_op_done` свернул обратно. Ручной `F4` снимает флаг — дальше раскладкой владеет человек. +- Счётчик «показано/всего» пишется в `border_subtitle` панели, а не списка: у `OptionList` + в CSS `border: none`, а Textual рисует подпись только в строке рамки. Ширина строк списков + и плана считается от `content_size` — фиксированные колонки переносятся и рвут вёрстку. +- `pg_restore` без `--exit-on-error` возвращает 1 на игнорируемых ошибках, поэтому его + `StepError` глотается и логируется как предупреждение — не «чинить» это молча. +- Имя файла дампа `-YYYYmmdd-HHMMSS.dump`: по нему `db_from_dump_name()` восстанавливает + имя базы для `restore` без `--target-db`. Формат менять только вместе с `STAMP_RE`. +- Реконфигурация stdout/stderr в UTF-8 в начале файла нужна для русского лога в cp866-консоли + Windows; вывод дочерних процессов читается с `errors="replace"`. + +`config.json` (рабочий, с паролями) в `.gitignore` — структура правится в +`config.example.json`, оба файла держать в согласии. diff --git a/README.md b/README.md index fa935c7..0fddd1a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # pg-stand-sync Перенос базы PostgreSQL со стенда на локальный сервер: `pg_dump -Fc` → пересоздание локальной БД → `pg_restore -j`. -Только stdlib Python 3.10+, внешних зависимостей нет. Утилиты берутся из поставки pgAdmin 4. +Ядро — stdlib Python 3.10+; TUI-пульт требует `textual` (см. `requirements.txt`), без него +работает прежний текстовый режим. Утилиты берутся из поставки pgAdmin 4. -Работает в двух режимах: интерактивное меню (запуск без аргументов) и команды CLI. +Работает в двух режимах: пульт (запуск без аргументов) и команды CLI. ## Быстрый старт @@ -11,33 +12,93 @@ copy config.example.json config.json ``` +```powershell +.\run.ps1 -Setup +``` + Заполнить `source` (стенд) и `target` (локальная PG), затем: -```bash -python pg_stand_sync.py +```powershell +.\run.ps1 ``` -Появится меню: +`run.ps1` — обёртка запуска: берёт интерпретатор из `.venv` (иначе системный), переключает +консоль в UTF-8 и пробрасывает остальные аргументы в скрипт (`.\run.ps1 list`, +`.\run.ps1 --dry-run sync zpas`). `-Setup` создаёт окружение и ставит зависимости, +`-Tests` прогоняет тесты. Без обёртки всё то же работает через `python pg_stand_sync.py`. + +Откроется пульт: оба списка сразу на экране, отдельного шага «выбор действия» нет. ``` - стенд: postgres@postgresql.lan:5432 - локально: postgres@localhost:5432 - 1. Dump + Restore — снять со стенда и залить локально - 2. Dump — только снять дамп со стенда - 3. Restore — залить локально готовый дамп - 0. Выход +┌ pg-stand-sync ─────────────────────── ◆ DRY-RUN ─┐ +│ стенд postgres@postgresql.lan → локально :5432 │ +├──────────────────────────┬───────────────────────┤ +│ Базы стенда показано 3/41│ Приёмник · localhost │ +│ фильтр▸ zpa▌ │ режим DROP+CREATE │ +│ ● zpas 1284 MB │ цель как на стенде │ +│ ○ zpas_arch 312 MB ├──────────────────────┤ +│ ○ zpas_test 88 MB │ Дампы показано 5/12 │ +│ отмечено 1 │ ▸ zpas-…0313 1.2 ГБ │ +├──────────────┬────────────┴──────────────────────┤ +│ план │ журнал │ +│ ✓ дамп 12.4c │ [03:14:07] дамп zpas: "…pg_dump…" │ +│ ▸ DROP ⣾ │ pg_dump: сохранение "public…" │ +├──────────────┴───────────────────────────────────┤ +│ ⣾ sync zpas · шаг 3/6 · 00:42 ████████░░░ ^X │ +└──────────────────────────────────────────────────┘ ``` -После выбора пункта показывается список: для Dump — базы стенда, для Restore — файлы из `dumps`. -Ввод в списке работает так: +Фильтр сужает список на каждом набранном символе — Enter для этого не нужен. + +| Клавиша | Что делает | +|---|---| +| набор букв | фильтр панели под фокусом; несколько слов — И | +| `↑` `↓` `PgUp` `PgDn` | курсор в списке, фокус остаётся в поле фильтра | +| `Tab` | следующая панель | +| `Enter` | базы → окно параметров дампа с галочкой «поднять локально», дампы → Restore | +| `Space` | отметить базу (когда фокус в списке) | +| `Ctrl+D` | то же окно с выключённым автоподнятием — снять только дамп | +| `Ctrl+A` / `Ctrl+U` | отметить показанные / снять все отметки | +| `Esc` | очистить фильтр, затем отметки | +| `F4` | журнал во весь экран и обратно (`Esc` — тоже назад) | +| `F5` | перечитать оба списка | +| `Ctrl+X` | прервать операцию (это kill процессов, а не откат) | +| `Ctrl+L` / `Ctrl+S` | очистить журнал / сохранить его в `dumps/session-.log` | +| `F1` / `F2` / `F3` | помощь / тема / показывать ли вывод утилит | +| `Ctrl+C` | выход | + +Раскладка клавиатуры значения не имеет: каждый `Ctrl`-хоткей продублирован кириллическим +двойником по ЙЦУКЕН (`Ctrl+В` = `Ctrl+D`, `Ctrl+Ч` = `Ctrl+X` и так далее). + +На старте операции пульт сам разворачивает журнал на весь экран и сам сворачивает его обратно, +когда всё закончилось; если развернуть журнал руками (`F4`), пульт больше не трогает раскладку. +Раскладка адаптивная: уже 100 колонок — панели встают друг под друга, ниже 32 строк журнал +занимает долю экрана вместо фиксированных 20 строк. Списки, журнал и план листаются стрелками +и `PgUp`/`PgDn`. + +Отметки переживают смену фильтра и не сужаются им: в операцию уходят все отмеченные базы, +сколько бы их ни показывала панель. Вывод `pg_dump`/`pg_restore` идёт в журнал построчно, +пока утилита работает. + +Sync нескольких баз сразу возможен, только когда `target.database` = `null` (каждая база +льётся в одноимённую локальную). С заданным `target.database` пульт откажет: все отмеченные +базы легли бы по очереди в одну и ту же локальную базу. + +### Текстовый режим + +Пульт не запускается, если не установлен `textual`, вывод не в терминал, задан `--no-tui` +или переменная `PG_STAND_SYNC_NO_TUI`. Тогда открывается прежнее меню, а список выглядит так: | Ввод | Что делает | |---|---| -| текст | фильтрует список по подстроке (регистр не важен) | +| текст | фильтрует список по подстроке (регистр не важен), применяется по Enter | | номер | выбирает пункт; в Dump можно `2,5,7` — несколько баз | | пусто | сбрасывает фильтр | | `q` | назад в меню | +Флаг `--tui` — наоборот: при отсутствии `textual` он завершается ошибкой, а не откатывается +молча в текст. + `config.json` в `.gitignore` — пароли в репозиторий не попадают. ## CLI @@ -63,6 +124,12 @@ python pg_stand_sync.py restore dumps/zpas-20260820-031332.dump | `--no-recreate` | не делать DROP/CREATE DATABASE, накатить поверх (`pg_restore --clean --if-exists`) | | `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) | | `--dry-run` | напечатать команды, ничего не выполнять | +| `--no-tui` | меню текстом, без Textual (то же делает `PG_STAND_SYNC_NO_TUI=1`) | +| `--tui` | только пульт: без `textual` завершиться ошибкой, а не откатываться в текст | + +Имя базы или файла у CLI-команд без аргумента по-прежнему спрашивается текстовым списком, +даже когда `textual` установлен: подсовывать полноэкранный пульт в середину `sync DB` значит +ломать pipe-сценарии. ## Конфиг @@ -107,3 +174,14 @@ config.json Обезличивание данных стенда, если оно нужно, делается через `post_restore_sql` — уже в локальной базе, чтобы стенд оставался нетронутым. + +## Тесты + +```bash +.venv/Scripts/python.exe -m pytest tests_tui.py -q +``` + +`tests_tui.py` гоняет пульт headless (`App.run_test()` + `Pilot`): настоящий терминал не +открывается, список баз подменяется фикстурой, к `postgresql.lan` никто не ходит. Там же +проверяется паритет текстового режима: состав `argv`, текст `StepError`, стриминг вывода, +отмена и то, что пароль не попадает в командную строку. diff --git a/config.example.json b/config.example.json index 223fc8b..4538eb4 100644 --- a/config.example.json +++ b/config.example.json @@ -8,6 +8,7 @@ "port": 5432, "maintenance_database": "postgres", "user": "postgres", + "password": null, "password_env": "STAND_PGPASSWORD", "sslmode": "prefer", "connect_timeout": 15 @@ -18,6 +19,7 @@ "port": 5432, "database": null, "user": "postgres", + "password": null, "password_env": "LOCAL_PGPASSWORD", "maintenance_database": "postgres", "owner": "postgres", diff --git a/pg_stand_sync.py b/pg_stand_sync.py index c7e64f2..cbe4655 100644 --- a/pg_stand_sync.py +++ b/pg_stand_sync.py @@ -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": diff --git a/pg_stand_sync.tcss b/pg_stand_sync.tcss new file mode 100644 index 0000000..7182b82 --- /dev/null +++ b/pg_stand_sync.tcss @@ -0,0 +1,311 @@ +/* Пульт pg-stand-sync. Литеральных цветов здесь нет: всё через переменные темы, + иначе светлая тема рассыпается на первом же захардкоженном #hex. */ + +Screen { + background: $background; + color: $foreground; +} + +#topbar { + height: 2; + padding: 0 1; + background: $surface; + color: $foreground; +} + +#board { + layout: horizontal; + height: 1fr; + min-height: 6; +} + +#pane-db { + width: 1fr; +} + +#col-right { + width: 1fr; +} + +.panel { + border: round $panel; + background: $surface; + padding: 0 1; +} + +.panel:focus-within { + border: round $primary; +} + +.panel > Input { + border: none; + height: 1; + padding: 0; + background: $surface; +} + +.panel > OptionList { + height: 1fr; + background: $surface; + border: none; + padding: 0; + scrollbar-size-vertical: 1; +} + +#target-card { + height: auto; + border: round $panel; + background: $surface; + padding: 0 1; +} + +#pane-dump { + height: 1fr; +} + +#bottom { + height: 20; + max-height: 60%; + min-height: 6; +} + +#plan { + width: 36; + border: round $panel; + background: $surface; + padding: 0 1; +} + +#log { + width: 1fr; + border: round $panel; + background: $surface; + padding: 0 1; + scrollbar-size-vertical: 1; +} + +#status { + height: 1; + padding: 0 1; + background: $surface; +} + +#op-spin { + width: 2; + color: $accent; +} + +#op-title { + width: 1fr; + content-align: left middle; +} + +#op-timer { + width: 10; + content-align: right middle; + color: $text-muted; +} + +#op-progress { + width: 22; +} + +/* Во время операции списки заперты: менять выбор посреди restore нечего */ +.locked { + opacity: 0.55; +} + +.panel.locked { + border: round $panel; +} + +.pane-error { + border: round $error; +} + +#db-error, #dump-note { + height: auto; + padding: 0 1; + color: $text-error; + display: none; +} + +#db-error.shown, #dump-note.shown { + display: block; +} + +/* --------------------------------------------------------------- модалки */ + +ModalScreen { + align: center middle; + background: $background 60%; +} + +.dialog { + width: 72; + height: auto; + max-height: 80%; + border: round $primary; + background: $surface; + padding: 1 2; + opacity: 1; + transition: opacity 140ms; +} + +.dialog > .title { + text-style: bold; + color: $text-primary; + padding-bottom: 1; +} + +.dialog > .subject { + padding-bottom: 1; +} + +.dialog Input { + margin-bottom: 1; +} + +.danger { + background: $error 25%; + color: $text-error; + border-left: thick $error; + padding: 0 1; + margin-bottom: 1; + height: auto; +} + +.danger.hidden { + display: none; +} + +.row { + height: auto; + align-horizontal: right; +} + +.row > Button { + margin-left: 2; +} + +.switch-row { + height: auto; + margin-bottom: 1; +} + +.switch-row > Label { + padding: 1 1 0 1; +} + +#help-body { + height: auto; +} + +/* Узкое окно: пульт складывается в колонку, обзорность теряется, но ничего не режется */ +.narrow #board { + layout: vertical; + height: 1fr; +} + +.narrow #pane-db { + width: 1fr; + height: 1fr; + min-height: 5; +} + +.narrow #col-right { + width: 1fr; + height: 12; +} + +.narrow.short #col-right { + height: 7; +} + +.narrow #bottom { + layout: vertical; +} + +.narrow #plan { + width: 1fr; + height: 35%; + min-height: 3; +} + +.dialog > .form { + height: auto; + max-height: 24; +} + +.dialog .section { + text-style: bold; + color: $text-primary; + padding-top: 1; +} + +.dialog Collapsible { + border: none; + padding: 0; + margin-bottom: 1; +} + +.dialog #target-block { + height: auto; +} + +.dialog #target-block.hidden { + display: none; +} + +.dialog .form Label { + color: $text-muted; + padding: 0 0 0 1; +} + +.dialog .form Input { + width: 1fr; + border: tall $panel; + background: $boost; +} + +.dialog .form Input:focus { + border: tall $primary; +} + +.dialog Collapsible > CollapsibleTitle { + color: $text-primary; +} + +.row > Button { + min-width: 14; +} + +/* журнал во весь экран: на низком окне списки и лог не помещаются вместе */ +.zoom-log #board { + display: none; +} + +.zoom-log #bottom { + height: 1fr !important; + max-height: 100% !important; +} + +/* низкое окно: фиксированные 20 строк снизу не оставляют места спискам */ +.short #bottom { + height: 35%; + min-height: 5; +} + +.short #plan { + width: 28; +} + +/* низкое окно: карточка приёмника — справочная, место нужнее спискам */ +.short #target-card { + display: none; +} + +.zoom-log.narrow #plan { + height: 25%; + min-height: 3; +} diff --git a/pg_stand_sync_tui.py b/pg_stand_sync_tui.py new file mode 100644 index 0000000..d8c66fb --- /dev/null +++ b/pg_stand_sync_tui.py @@ -0,0 +1,1388 @@ +#!/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, Collapsible, Footer, Input, Label, OptionList, + ProgressBar, RichLog, Static, Switch) +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: + yield Label("локальная база") + yield Input(value=self.spec.target_db, id="target") + with Horizontal(classes="switch-row"): + yield Switch(value=self.recreate, id="recreate") + yield Label("пересоздать (DROP + CREATE)") + yield Static(self._danger_text(), id="danger", + classes="danger" + ("" if self.recreate else " hidden")) + with Horizontal(classes="row"): + yield Button("Отмена", id="cancel") + yield Button("Начать", variant="success", id="start") + + 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_switch_changed(self, event: Switch.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", Switch).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: + with Vertical(classes="dialog"): + yield Static("Операция с базой", classes="title") + yield Static(", ".join(self.dbs), classes="subject") + with VerticalScroll(classes="form"): + with Horizontal(classes="switch-row"): + yield Switch(value=self.autorestore, id="autorestore") + yield Label("поднять на локальной PG сразу после дампа") + + yield Static("Дамп", classes="section") + for wid, label, key in (("schema-only", "только схема, без данных", "schema_only"), + ("no-owner", "без владельцев (--no-owner)", "no_owner"), + ("no-priv", "без прав (--no-privileges)", "no_privileges")): + with Horizontal(classes="switch-row"): + yield Switch(value=bool(self.dump_opts.get(key, key != "schema_only")), + id=wid) + yield Label(label) + yield Label("сжатие 0–9") + yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress") + with Collapsible(title="состав дампа", collapsed=True): + yield Label("только схемы (через запятую)") + yield Input(value=_csv(self.dump_opts.get("schemas")), id="schemas") + yield Label("исключить схемы") + yield Input(value=_csv(self.dump_opts.get("exclude_schemas")), + id="ex-schemas") + yield Label("исключить таблицы") + yield Input(value=_csv(self.dump_opts.get("exclude_tables")), + id="ex-tables") + yield Label("исключить только данные таблиц") + yield Input(value=_csv(self.dump_opts.get("exclude_table_data")), + id="ex-data") + + with Vertical(id="target-block", + classes="" if self.autorestore else "hidden"): + yield Static("Локальная база", classes="section") + if self.target_db is None: + yield Static("каждая база — в одноимённую локальную", classes="subject") + else: + yield Label("имя") + yield Input(value=self.target_db, id="target") + with Horizontal(classes="switch-row"): + yield Switch(value=self.recreate, id="recreate") + yield Label("пересоздать (DROP + CREATE)") + yield Label("параллельных воркеров restore") + yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs") + yield Static(self._danger_text(), id="danger", + classes="danger" + ("" if self.recreate else " hidden")) + with Horizontal(classes="row"): + yield Button("Отмена", id="cancel") + yield Button("Начать", variant="success", id="start") + + 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_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "autorestore": + self.autorestore = event.value + self.query_one("#target-block").set_class(not event.value, "hidden") + elif event.switch.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", Switch).value, + "no_owner": self.query_one("#no-owner", Switch).value, + "no_privileges": self.query_one("#no-priv", Switch).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", Switch).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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee00883 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# TUI-режим. Ядро (CLI и текстовое меню) работает и без этих пакетов. +textual==8.2.8 +rich==15.0.0 diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..7ca28bb --- /dev/null +++ b/run.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Запуск pg-stand-sync: находит интерпретатор, чинит кодировку консоли, пробрасывает аргументы. + +.EXAMPLE + .\run.ps1 # пульт (TUI) + .\run.ps1 list # базы на стенде + .\run.ps1 --dry-run sync zpas # проверка команд без выполнения + .\run.ps1 -Setup # создать .venv и поставить зависимости + .\run.ps1 -Tests # прогнать тесты +#> + +[CmdletBinding()] +param( + # Создать .venv и установить requirements.txt + [switch]$Setup, + # Прогнать tests_tui.py вместо запуска приложения + [switch]$Tests, + # Всё остальное уходит в pg_stand_sync.py как есть + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Rest +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$venvPython = Join-Path $root '.venv\Scripts\python.exe' + +# Русский лог и рамки Textual в cp866-консоли превращаются в мусор +[Console]::OutputEncoding = [Text.Encoding]::UTF8 +[Console]::InputEncoding = [Text.Encoding]::UTF8 +$env:PYTHONIOENCODING = 'utf-8' + +function Find-Python { + if (Test-Path $venvPython) { return $venvPython } + foreach ($name in 'python', 'py') { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + } + throw 'Python не найден: поставьте его или создайте окружение через .\run.ps1 -Setup' +} + +if ($Setup) { + if (-not (Test-Path $venvPython)) { + $bootstrap = Find-Python + Write-Host "создаю .venv ($bootstrap)" -ForegroundColor Cyan + & $bootstrap -m venv (Join-Path $root '.venv') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + Write-Host 'ставлю зависимости' -ForegroundColor Cyan + & $venvPython -m pip install --disable-pip-version-check -r (Join-Path $root 'requirements.txt') + exit $LASTEXITCODE +} + +$python = Find-Python + +if ($Tests) { + & $python -m pytest (Join-Path $root 'tests_tui.py') -q @Rest + exit $LASTEXITCODE +} + +# Без textual приложение молча откатится в текстовое меню — предупредить, пока не поздно +& $python -c 'import textual' 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host 'textual не установлен — откроется текстовый режим. Полный пульт: .\run.ps1 -Setup' ` + -ForegroundColor Yellow +} + +& $python (Join-Path $root 'pg_stand_sync.py') @Rest +exit $LASTEXITCODE diff --git a/tests_tui.py b/tests_tui.py new file mode 100644 index 0000000..37e602f --- /dev/null +++ b/tests_tui.py @@ -0,0 +1,972 @@ +#!/usr/bin/env python3 +"""Тесты ядра и TUI. Textual гоняется headless через App.run_test() + Pilot. + +Запуск: .venv/Scripts/python.exe -m pytest tests_tui.py -q +Настоящий терминал не открывается, к postgresql.lan никто не ходит: список баз подменяется, +внешние утилиты — короткие python -c. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import threading +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + +import pg_stand_sync as core +import pg_stand_sync_tui as tui +from textual.widgets import Input, Switch + +PY = sys.executable +DBS = [("zpas", "1284 MB"), ("zpas_arch", "312 MB"), ("zpas_test", "88 MB"), + ("postgres", "8 MB"), ("registry", "44 MB")] + + +def script(body: str) -> list: + return [PY, "-c", body] + + +def child_env() -> dict: + # ядро выставляет дочерним PGCLIENTENCODING=UTF8; для python -c аналог — PYTHONIOENCODING + env = dict(os.environ) + env["PYTHONIOENCODING"] = "utf-8" + return env + + +@pytest.fixture(autouse=True) +def clean_hooks(): + """Хуки — глобальные: тест, забывший их снять, сломает соседние.""" + yield + core.set_sink(None) + core.set_progress(None) + core.set_password_prompt(None) + core.clear_cancel() + core._password_cache.clear() + + +@pytest.fixture +def cfg(tmp_path): + return { + "dump_dir": str(tmp_path / "dumps"), + "keep_dumps": 5, + "source": {"host": "stand.example", "port": 5432, "user": "postgres", + "maintenance_database": "postgres", "password": "s3cret"}, + "target": {"host": "localhost", "port": 5432, "user": "postgres", "database": None, + "maintenance_database": "postgres", "recreate": True, "password": "s3cret"}, + "restore": {"jobs": 4}, + "post_restore_sql": [], + } + + +# --------------------------------------------------------------------------- ядро + + +def test_matches_common_filter(): + assert core.matches("zpas_core", "zpa") + assert core.matches("zpas_core", "ZPA") + assert core.matches("zpas_core", "zp core") + assert not core.matches("zpas_test", "zp core") + assert core.matches("что угодно", "") + + +def test_pick_uses_matches(monkeypatch): + """Текстовый режим и TUI обязаны фильтровать одинаково — через одну функцию.""" + answers = iter(["zp core", "1"]) + monkeypatch.setattr(core, "ask", lambda prompt: next(answers)) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + got = core.pick(["zpas_core", "zpas_test", "registry"], "базы", lambda s: s) + assert got == ["zpas_core"] + + +def test_dry_run_does_not_spawn(monkeypatch, capsys): + monkeypatch.setattr(core.subprocess, "Popen", + lambda *a, **k: pytest.fail("dry-run не должен запускать процесс")) + core.run(script("print(1)"), child_env(), "проверка", dry_run=True) + out = capsys.readouterr().out.strip().splitlines() + assert len(out) == 1 and "проверка:" in out[0] + + +def test_step_error_text_is_stable(): + with pytest.raises(core.StepError) as err: + core.run(script("import sys; sys.exit(3)"), child_env(), "шаг", dry_run=False) + assert str(err.value) == "шаг завершился с кодом 3" + + +def test_text_mode_output_format(capsys): + core.run(script("print('первая'); print('вторая')"), child_env(), "шаг", dry_run=False) + lines = capsys.readouterr().out.splitlines() + assert lines[1] == " первая" and lines[2] == " вторая" + assert lines[0].startswith("[") and "шаг: " in lines[0] + assert "готово за" in lines[3] + + +def test_output_streams_before_exit(): + seen = [] + core.set_sink(lambda kind, text: seen.append((time.monotonic(), kind, text))) + started = time.monotonic() + core.run(script("import time,sys\n" + "for i in range(5):\n" + " print(i, flush=True); time.sleep(0.15)"), + child_env(), "поток", dry_run=False) + finished = time.monotonic() + first_out = next(t for t, kind, _ in seen if kind == "out") + assert first_out - started < (finished - started) / 2 + assert [text for kind, text in ((k, t) for _, k, t in seen) if kind == "out"] == list("01234") + + +def test_progress_hook_marks_step(): + events = [] + core.set_progress(lambda event, what, elapsed: events.append((event, what))) + core.run(script("print('x')"), child_env(), "шаг", dry_run=False) + assert events == [("start", "шаг"), ("done", "шаг")] + + +def test_progress_hook_silent_in_text_mode(capsys): + core.run(script("print('x')"), child_env(), "шаг", dry_run=False) + assert "start" not in capsys.readouterr().out + + +def test_cancel_kills_running_process(): + core.set_sink(lambda kind, text: None) + box = {} + + def worker(): + try: + core.run(script("import time; time.sleep(30)"), child_env(), "долго", False) + except BaseException as exc: + box["exc"] = exc + + thread = threading.Thread(target=worker) + thread.start() + time.sleep(1.0) + core.request_cancel() + thread.join(timeout=10) + assert not thread.is_alive() + assert isinstance(box.get("exc"), (core.Cancelled, core.StepError)) + assert not core._procs + + +def test_cancel_before_spawn(monkeypatch): + core.request_cancel() + monkeypatch.setattr(core.subprocess, "Popen", + lambda *a, **k: pytest.fail("после отмены процесс не запускают")) + with pytest.raises(core.Cancelled): + core.run(script("print(1)"), child_env(), "шаг", dry_run=False) + + +def test_spawn_refuses_after_cancel(monkeypatch): + """Проверка флага и регистрация в _procs — под одним замком, иначе процесс переживёт отмену.""" + core.request_cancel() + monkeypatch.setattr(core.subprocess, "Popen", + lambda *a, **k: pytest.fail("после отмены процесс не запускают")) + with pytest.raises(core.Cancelled): + core._spawn(script("print(1)"), child_env(), None) + + +def test_password_prompt_replaces_getpass(monkeypatch): + monkeypatch.setattr(core.getpass, "getpass", + lambda *a, **k: pytest.fail("getpass под TUI звать нельзя")) + calls = [] + core.set_password_prompt(lambda label, node: calls.append(label) or "hunter2") + node = {"host": "stand", "user": "postgres", "port": 5432} + assert core.resolve_password(node, "стенд") == "hunter2" + assert core.resolve_password(node, "стенд") == "hunter2" + assert calls == ["стенд"] + + +def test_password_needed_and_priming(): + node = {"host": "stand", "user": "postgres", "port": 5432, "password_env": "NOPE_PG"} + assert core.password_needed(node, "стенд") + core.prime_password(node, "стенд", "abc") + assert not core.password_needed(node, "стенд") + core.forget_password(node, "стенд") + assert core.password_needed(node, "стенд") + assert not core.password_needed({"password": "x", "host": "h", "user": "u"}, "стенд") + + +def test_password_never_in_argv(cfg, monkeypatch): + seen = [] + monkeypatch.setattr(core, "run", lambda cmd, env, what, dry: seen.append((cmd, env))) + tools = {"pg_dump": "pg_dump", "pg_restore": "pg_restore", "psql": "psql"} + core.dump(cfg, tools, "zpas", Path(cfg["dump_dir"]) / "zpas.dump", dry_run=True) + core.recreate_target(cfg, tools, "zpas", dry_run=True) + core.restore(cfg, tools, Path(cfg["dump_dir"]) / "zpas.dump", "zpas", True, dry_run=True) + assert seen + for cmd, env in seen: + assert env["PGPASSWORD"] == "s3cret" + assert not any("s3cret" in str(part) for part in cmd) + + +def test_restore_error_becomes_warning(cfg, monkeypatch): + kinds = [] + core.set_sink(lambda kind, text: kinds.append((kind, text))) + monkeypatch.setattr(core, "run", lambda *a, **k: (_ for _ in ()).throw( + core.StepError("restore в zpas@localhost завершился с кодом 1"))) + path = Path(cfg["dump_dir"]) + path.mkdir(parents=True, exist_ok=True) + (path / "zpas.dump").write_bytes(b"x") + core.restore(cfg, {"pg_restore": "pg_restore"}, path / "zpas.dump", "zpas", True, False) + assert any(kind == "warn" and "внимание:" in text for kind, text in kinds) + + cfg["restore"]["exit_on_error"] = True + with pytest.raises(core.StepError): + core.restore(cfg, {"pg_restore": "pg_restore"}, path / "zpas.dump", "zpas", True, False) + + +def parse(argv): + return core.build_parser().parse_args(argv) + + +def test_tui_enabled_matrix(monkeypatch): + monkeypatch.setattr(core, "tui_available", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.delenv("PG_STAND_SYNC_NO_TUI", raising=False) + assert core.tui_enabled(parse([])) + assert core.tui_enabled(parse(["menu"])) + assert not core.tui_enabled(parse(["--no-tui"])) + assert not core.tui_enabled(parse(["list"])) + monkeypatch.setenv("PG_STAND_SYNC_NO_TUI", "1") + assert not core.tui_enabled(parse([])) + monkeypatch.delenv("PG_STAND_SYNC_NO_TUI") + monkeypatch.setattr(sys.stdout, "isatty", lambda: False, raising=False) + assert not core.tui_enabled(parse([])) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + monkeypatch.setattr(core, "tui_available", lambda: False) + assert not core.tui_enabled(parse([])) + + +# --------------------------------------------------------------------------- TUI + + +def make_app(cfg, monkeypatch, dbs=DBS, failure=None): + def fake_list(tools, node, label): + if failure: + raise core.StepError(failure) + return list(dbs) + + monkeypatch.setattr(core, "list_databases", fake_list) + tools = {"pg_dump": "pg_dump", "pg_restore": "pg_restore", "psql": "psql"} + return tui.PgSyncApp(cfg, tools, dry_run=True) + + +async def settle(app, pilot): + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + +def test_filter_narrows_on_every_keystroke(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + assert lst.option_count == len(DBS) + counts = [] + for char in "zpa": + await pilot.press(char) + await pilot.pause() + counts.append(lst.option_count) + # Enter не нажимался ни разу + assert counts == [3, 3, 3] + assert app.dashboard.query_one("#flt-db").value == "zpa" + for char in "s_a": + await pilot.press(char) + await pilot.pause() + assert [row.name for row in lst.rows] == ["zpas_arch"] + # счётчик живёт на рамке панели: у самого OptionList border: none, подпись не рисуется + assert app.dashboard.query_one("#pane-db").border_subtitle == "показано 1/5" + asyncio.run(scenario()) + + +def test_escape_clears_filter_then_marks(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + await pilot.press("z", "p", "a") + await pilot.pause() + assert lst.option_count == 3 + await pilot.press("escape") + await pilot.pause() + assert app.dashboard.query_one("#flt-db").value == "" + assert lst.option_count == len(DBS) + lst.marked.add("zpas") + await pilot.press("escape") + await pilot.pause() + assert not lst.marked + asyncio.run(scenario()) + + +def test_space_marks_only_in_list_and_survives_filter(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + flt = app.dashboard.query_one("#flt-db") + + await pilot.press("space") # фокус в фильтре — это пробел, не отметка + await pilot.pause() + assert flt.value == " " and not lst.marked + flt.value = "" + await pilot.pause() + + await pilot.press("tab") + await pilot.pause() + assert app.focused is lst + await pilot.press("space") + await pilot.press("down") + await pilot.press("space") + await pilot.pause() + assert lst.marked == {"zpas", "zpas_arch"} + + flt.focus() + await pilot.press("r", "e", "g") # фильтр прячет отмеченные + await pilot.pause() + assert [row.name for row in lst.rows] == ["registry"] + assert lst.marked == {"zpas", "zpas_arch"} + assert "отмечено 2" in app.dashboard.query_one("#pane-db").border_subtitle + # состав операции не зависит от фильтра: отмечено 2 — значит и берутся обе + assert lst.selection() == ["zpas", "zpas_arch"] + asyncio.run(scenario()) + + +def painted(app) -> str: + """Что реально нарисовано на экране: border_subtitle без рамки в SVG не попадает вовсе.""" + return "\n".join(strip.text for strip in app.screen._compositor.render_strips()) + + +def test_counter_is_actually_rendered(cfg, monkeypatch): + """border_subtitle без рамки не рисуется — требование «показано/всего» проверяем по экрану.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + assert "показано 5/5" in painted(app) + await pilot.press("q", "q", "q") + await pilot.pause() + screen = painted(app) + assert "показано 0/5" in screen + assert "ничего не найдено по фильтру" in screen + asyncio.run(scenario()) + + +def test_rows_fit_the_pane_width(cfg, monkeypatch): + """Строки списков собираются под реальную ширину: перенос ломает колонки и съедает место.""" + dump_dir = Path(cfg["dump_dir"]) + dump_dir.mkdir(parents=True, exist_ok=True) + (dump_dir / "zpas_analytics_reporting_daily-20260820-031332.dump").write_bytes(b"x") + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(100, 40)) as pilot: + await settle(app, pilot) + for widget_id, kind in (("#tbl-db", tui.DbList), ("#tbl-dump", tui.DumpList)): + lst = app.dashboard.query_one(widget_id, kind) + width = lst.content_size.width + assert all(len(lst.line(row)) <= width for row in lst.rows) + asyncio.run(scenario()) + + +def test_multi_sync_refused_when_target_database_fixed(cfg, monkeypatch): + """target.database слил бы отмеченные базы в одну — do_sync берёт имя из конфига.""" + cfg["target"]["database"] = "zpas_local" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + dash.query_one("#tbl-db", tui.DbList).marked.update({"zpas", "zpas_arch"}) + dash.sync_selected() + for _ in range(20): + await pilot.pause() + assert not isinstance(app.screen, tui.DbOpModal) + asyncio.run(scenario()) + + +def test_focus_returns_after_operation(cfg, monkeypatch): + """Панели на время операции disabled: Textual снимает фокус, и фильтр молчит до Tab.""" + app = make_app(cfg, monkeypatch) + monkeypatch.setattr(core, "do_dump", lambda *a: []) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + assert app.focused.id == "flt-db" + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + await settle(app, pilot) + assert app.focused is not None and app.focused.id == "flt-db" + await pilot.press("z") + await pilot.pause() + assert dash.query_one("#flt-db").value == "z" + asyncio.run(scenario()) + + +def test_failed_operation_forgets_password(cfg, monkeypatch): + """Опечатка в модалке иначе живёт в кэше до перезапуска: модалка больше не покажется.""" + cfg["target"].pop("password") + app = make_app(cfg, monkeypatch) + monkeypatch.setattr(core, "do_restore", lambda *a: (_ for _ in ()).throw( + core.StepError("DROP DATABASE завершился с кодом 1"))) + core.prime_password(cfg["target"], "локальная", "typo") + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + dash.begin(tui.OpSpec("restore", dump_file=Path("x.dump"), target_db="zpas")) + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + assert not dash.op_active + assert core.password_needed(cfg["target"], "локальная") + asyncio.run(scenario()) + + +def test_priority_hotkeys_survive_input_focus(cfg, monkeypatch): + """Input биндит ctrl+a/ctrl+d/ctrl+u: без priority=True действия молча не сработают.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + flt = app.dashboard.query_one("#flt-db") + await pilot.press("z", "p") + await pilot.pause() + await pilot.press("ctrl+a") + await pilot.pause() + assert lst.marked == {"zpas", "zpas_arch", "zpas_test"} + assert flt.value == "zp" + await pilot.press("ctrl+u") + await pilot.pause() + assert not lst.marked and flt.value == "zp" + + await pilot.press("left") + await pilot.press("ctrl+d") # у Input это delete_right + await pilot.pause() + await pilot.pause() + assert flt.value == "zp" + assert isinstance(app.screen, tui.DbOpModal) + await pilot.press("escape") + await pilot.pause() + asyncio.run(scenario()) + + +def test_arrows_from_filter_move_list_cursor(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + flt = app.dashboard.query_one("#flt-db") + assert lst.highlighted == 0 + await pilot.press("down", "down") + await pilot.pause() + assert lst.highlighted == 2 + assert app.focused is flt + asyncio.run(scenario()) + + +def test_connection_error_does_not_kill_app(cfg, monkeypatch): + app = make_app(cfg, monkeypatch, failure="список баз stand.example: неверный пароль") + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + assert app.is_running + error = app.dashboard.query_one("#db-error") + assert error.has_class("shown") + assert "неверный пароль" in str(error.content) + assert app.dashboard.query_one("#pane-db").has_class("pane-error") + await pilot.press("f5") # повтор попытки не роняет приложение + await settle(app, pilot) + assert app.is_running + asyncio.run(scenario()) + + +def test_live_log_and_finish(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + def fake_dump(cfg_, tools_, dbs_, dry_run_): + core.log(f"дамп {dbs_[0]}@stand.example: pg_dump …") + for i in range(200): + core._emit("out", f"строка {i}") + return [] + + monkeypatch.setattr(core, "do_dump", fake_dump) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + assert dash.op_active + assert app.dashboard.query_one("#tbl-db").disabled + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + assert not dash.op_active + assert not app.dashboard.query_one("#tbl-db").disabled + body = [text for kind, _, text in dash.journal if kind == "out"] + assert body == [f"строка {i}" for i in range(200)] + assert any(kind == "ok" for kind, _, _ in dash.journal) + asyncio.run(scenario()) + + +def test_operation_failure_is_reported_not_raised(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + monkeypatch.setattr(core, "do_dump", lambda *a: (_ for _ in ()).throw( + core.StepError("дамп zpas@stand.example завершился с кодом 1"))) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + assert app.is_running and not dash.op_active + assert any(kind == "err" and "кодом 1" in text for kind, _, text in dash.journal) + asyncio.run(scenario()) + + +def test_password_modal_answers_worker_thread(cfg, monkeypatch): + """resolve_password из середины do_* обязан спрашивать модалкой, а не вешать приложение.""" + cfg["source"].pop("password") + app = make_app(cfg, monkeypatch) + got = {} + + def fake_dump(cfg_, tools_, dbs_, dry_run_): + got["password"] = core.resolve_password(cfg_["source"], "стенд") + return [] + + monkeypatch.setattr(core, "do_dump", fake_dump) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(60): + await pilot.pause() + if isinstance(app.screen, tui.PasswordModal): + break + assert isinstance(app.screen, tui.PasswordModal) + await pilot.press("h", "u", "n", "t") + await pilot.press("enter") + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + assert got.get("password") == "hunt" + asyncio.run(scenario()) + + +def test_ctrl_c_exits_with_zero(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+c") + for _ in range(40): + await pilot.pause() + if not app.is_running: + break + assert app.return_value == 0 + asyncio.run(scenario()) + + +def test_ctrl_c_during_operation_asks_and_returns_130(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + release = threading.Event() + cancels = [] + monkeypatch.setattr(core, "do_dump", lambda *a: release.wait(10)) + monkeypatch.setattr(core, "request_cancel", lambda: cancels.append(1) or True) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + app.dashboard.begin(tui.OpSpec("dump", dbs=["zpas"])) + await pilot.pause() + await pilot.press("ctrl+c") + for _ in range(40): + await pilot.pause() + if isinstance(app.screen, tui.QuitConfirm): + break + assert isinstance(app.screen, tui.QuitConfirm) + await pilot.press("tab") + await pilot.press("enter") + for _ in range(40): + await pilot.pause() + if not app.is_running: + break + release.set() + # 130 достижим только через ветку «операция идёт» — она же зовёт request_cancel + assert app.return_value == 130 + assert cancels + asyncio.run(scenario()) + + +def test_exit_releases_thread_waiting_for_password(cfg, monkeypatch): + """Колбэк модалки при разборе экранов не приходит: без отпускания поток ждёт до таймаута.""" + cfg["source"].pop("password") + app = make_app(cfg, monkeypatch) + box = {} + + def fake_dump(cfg_, tools_, dbs_, dry_run_): + started = time.monotonic() + try: + core.resolve_password(cfg_["source"], "стенд") + except core.Cancelled: + box["cancelled"] = True + box["waited"] = time.monotonic() - started + return [] + + monkeypatch.setattr(core, "do_dump", fake_dump) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + app.dashboard.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(60): + await pilot.pause() + if isinstance(app.screen, tui.PasswordModal): + break + assert isinstance(app.screen, tui.PasswordModal) + app.exit(0) + for _ in range(40): + await pilot.pause() + if not app.is_running: + break + for _ in range(50): + if "waited" in box: + break + await asyncio.sleep(0.1) + assert box.get("cancelled") and box["waited"] < 30 + asyncio.run(scenario()) + + +def test_exit_leaves_cancel_flag_set(cfg, monkeypatch): + """Снятый на выходе флаг дал бы ещё живому потоку доработать DROP DATABASE уже без UI.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+c") + for _ in range(40): + await pilot.pause() + if not app.is_running: + break + assert core.cancel_requested() + asyncio.run(scenario()) + + +def test_reload_works_after_cancelled_operation(cfg, monkeypatch): + """Флаг отмены, доживший до reload(), краснит панель баз и не лечится F5.""" + app = make_app(cfg, monkeypatch) + monkeypatch.setattr(core, "do_dump", lambda *a: (_ for _ in ()).throw(core.Cancelled())) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + core.request_cancel() + dash.op_active = True + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + await settle(app, pilot) + assert not core.cancel_requested() + assert not dash.query_one("#db-error").has_class("shown") + asyncio.run(scenario()) + + +def test_restore_pane_enter_opens_confirm(cfg, monkeypatch): + dump_dir = Path(cfg["dump_dir"]) + dump_dir.mkdir(parents=True, exist_ok=True) + (dump_dir / "zpas-20260820-031332.dump").write_bytes(b"x" * 2048) + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dumps = app.dashboard.query_one("#tbl-dump", tui.DumpList) + assert dumps.option_count == 1 + app.dashboard.query_one("#flt-dump").focus() + await pilot.pause() + await pilot.press("enter") + for _ in range(40): + await pilot.pause() + if isinstance(app.screen, tui.ConfirmModal): + break + assert isinstance(app.screen, tui.ConfirmModal) + assert app.screen.spec.kind == "restore" + assert app.screen.spec.target_db == "zpas" + assert app.focused.id == "cancel" # recreate=true -> согласие не по умолчанию + await pilot.press("escape") + await pilot.pause() + asyncio.run(scenario()) + + +def _open_db_dialog(app, pilot, autorestore=True): + """Открыть диалог операции над базой под курсором и дождаться его появления.""" + app.dashboard.open_db_op(autorestore=autorestore) + + async def wait(): + for _ in range(40): + await pilot.pause() + if isinstance(app.screen, tui.DbOpModal): + return app.screen + return app.screen + return wait() + + +def test_db_dialog_carries_dump_options(cfg, monkeypatch): + """Клик по базе открывает параметры дампа, а не голое подтверждение.""" + app = make_app(cfg, monkeypatch) + captured = {} + monkeypatch.setattr(core, "do_sync", + lambda cfg_, tools, dbs, target, no_recreate, dry: + captured.update(cfg=cfg_, dbs=dbs, target=target)) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + modal = await _open_db_dialog(app, pilot) + assert isinstance(modal, tui.DbOpModal) + modal.query_one("#schema-only", Switch).value = True + modal.query_one("#compress", Input).value = "0" + modal.query_one("#ex-schemas", Input).value = "audit, tmp" + modal.action_start() + for _ in range(60): + await pilot.pause() + if captured: + break + assert captured["cfg"]["dump"]["schema_only"] is True + assert captured["cfg"]["dump"]["compress"] == 0 + assert captured["cfg"]["dump"]["exclude_schemas"] == ["audit", "tmp"] + # исходный конфиг диалог не трогает: правки живут в копии + assert "schema_only" not in (cfg.get("dump") or {}) + asyncio.run(scenario()) + + +def test_autorestore_switch_picks_operation(cfg, monkeypatch): + """Снятая галочка — это dump, поднятая — dump + restore; выбор запоминается на сессию.""" + app = make_app(cfg, monkeypatch) + calls = [] + monkeypatch.setattr(core, "do_dump", lambda *a, **k: calls.append("dump")) + monkeypatch.setattr(core, "do_sync", lambda *a, **k: calls.append("sync")) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + modal = await _open_db_dialog(app, pilot) + switch = modal.query_one("#autorestore", Switch) + assert switch.value is True # главный сценарий по умолчанию + switch.value = False + await pilot.pause() + assert modal.query_one("#target-block").has_class("hidden") + modal.action_start() + for _ in range(60): + await pilot.pause() + if calls: + break + assert calls == ["dump"] + assert app.dashboard.op_defaults["autorestore"] is False + asyncio.run(scenario()) + + +def test_db_dialog_rejects_bad_compress(cfg, monkeypatch): + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + modal = await _open_db_dialog(app, pilot) + modal.query_one("#compress", Input).value = "42" + modal.action_start() + await pilot.pause() + assert isinstance(app.screen, tui.DbOpModal) # диалог не закрылся + await pilot.press("escape") + await pilot.pause() + asyncio.run(scenario()) + + +def test_version_mismatch_noise_gets_explained(): + """SET из дампа новее сервера — шум: строку-объяснение выдаём один раз на вызов.""" + seen = [] + core.set_sink(lambda kind, text: seen.append((kind, text))) + core.run(script("print('pg_restore: error: unrecognized configuration parameter \"x\"')\n" + "print('pg_restore: error: unrecognized configuration parameter \"y\"')"), + child_env(), "restore", dry_run=False) + hints = [text for kind, text in seen if kind == "warn"] + assert hints == [core.NEWER_CLIENT_HINT] + + +def test_zoom_toggles_and_scrolls_log(cfg, monkeypatch): + """F4 разворачивает журнал, стрелки листают его, Esc возвращает списки.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + dash = app.dashboard + for i in range(120): + dash._write(("out", None, f"строка {i}")) + await pilot.pause() + await pilot.press("f4") + await pilot.pause() + assert dash.zoomed and dash.has_class("zoom-log") + assert not dash.query_one("#board").display + log = dash.query_one("#log") + log.scroll_end(animate=False) + await pilot.pause() + bottom = log.scroll_offset.y + await pilot.press("pageup") + await pilot.pause() + assert log.scroll_offset.y < bottom # журнал листается клавишами + await pilot.press("escape") + await pilot.pause() + assert not dash.zoomed + assert dash.query_one("#board").display + asyncio.run(scenario()) + + +def test_operation_switches_to_log(cfg, monkeypatch): + """Во время операции смотреть нечего, кроме журнала: пульт переключается сам.""" + app = make_app(cfg, monkeypatch) + # операция должна быть заметно длиннее одного тика, иначе состояние «во время» не поймать + monkeypatch.setattr(core, "do_dump", lambda *a, **k: time.sleep(0.4)) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + assert not dash.zoomed + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + await pilot.pause() + assert dash.zoomed # сразу на старте, а не по завершении + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + # авто-разворот сам и сворачивается: иначе фокус застрял бы в журнале + assert not dash.zoomed + assert app.focused is dash.query_one("#flt-db") + + dash.toggle_zoom() # развернули руками + await pilot.pause() + dash.begin(tui.OpSpec("dump", dbs=["zpas"])) + for _ in range(80): + await pilot.pause() + if not dash.op_active: + break + assert dash.zoomed # ручной выбор пульт не отменяет + asyncio.run(scenario()) + + +def test_layout_adapts_to_small_console(cfg, monkeypatch): + """Адаптив: на низком и узком окне панели не вылезают за экран.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(80, 24)) as pilot: + await settle(app, pilot) + dash = app.dashboard + assert dash.has_class("narrow") and dash.has_class("short") + board = dash.query_one("#board").size.height + bottom = dash.query_one("#bottom").size.height + assert board + bottom <= 24 - 4 # шапка 2 + статус 1 + футер 1 + assert not dash.query_one("#target-card").display + await pilot.resize_terminal(160, 50) + await pilot.pause() + assert not dash.has_class("narrow") and not dash.has_class("short") + assert dash.query_one("#target-card").display + asyncio.run(scenario()) + + +def test_rows_do_not_wrap_when_scrollbar_takes_a_column(cfg, monkeypatch): + """Скроллбар отъедает колонку, и строка ровно по ширине рвёт хвост «1277 MB» пополам.""" + many = [("homeassistant", "1277 MB")] + [(f"filler_{i:02d}", f"{i * 13} MB") + for i in range(40)] + app = make_app(cfg, monkeypatch, dbs=many) + # scrollbar-gutter: stable уводит полосу в поток — так же ведёт себя настоящий терминал + app.CSS = "#tbl-db { overflow-y: scroll; scrollbar-gutter: stable; }" + + async def scenario(): + async with app.run_test(size=(200, 40)) as pilot: + await settle(app, pilot) + lst = app.dashboard.query_one("#tbl-db", tui.DbList) + assert lst.option_count == len(many) + assert max(len(lst.line(row)) for row in lst.rows) <= lst.content_size.width - 1 + assert lst.virtual_size.height == lst.option_count # ни одна строка не перенеслась + asyncio.run(scenario()) + + +def test_hotkeys_work_in_russian_layout(cfg, monkeypatch): + """При русской раскладке терминал присылает Ctrl+«в» — хоткей обязан сработать так же.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + dash = app.dashboard + lst = dash.query_one("#tbl-db", tui.DbList) + dash.query_one("#flt-db", Input).focus() + await pilot.pause() + + await pilot.press("ctrl+ф") # ctrl+a — отметить показанные + await pilot.pause() + assert lst.marked, "ctrl+ф не отметил базы" + await pilot.press("ctrl+г") # ctrl+u — снять отметки + await pilot.pause() + assert not lst.marked + + await pilot.press("ctrl+в") # ctrl+d — окно операции + for _ in range(40): + await pilot.pause() + if isinstance(app.screen, tui.DbOpModal): + break + assert isinstance(app.screen, tui.DbOpModal) + await pilot.press("escape") + await pilot.pause() + assert dash.query_one("#flt-db", Input).value == "" # символы не утекли в фильтр + asyncio.run(scenario()) + + +def test_ru_layout_covers_every_ctrl_hotkey(): + """Каждый Ctrl-хоткей пульта имеет кириллический двойник с тем же действием.""" + pairs = {b.key: b.action for b in tui.PgSyncApp.BINDINGS} + for key, action in list(pairs.items()): + if key.startswith("ctrl+") and len(key) == 6 and key[5].isascii(): + twin = f"ctrl+{tui.RU_LAYOUT[key[5]]}" + assert pairs.get(twin) == action, f"нет двойника для {key}"