TUI-пульт на Textual вместо текстового меню
Списки баз стенда и локальных дампов на одном экране, фильтр сужает их на каждом набранном символе. Enter по базе открывает окно параметров дампа с галочкой автоподнятия: снята — только dump, поднята — dump плюс restore. Правки полей уезжают в копию конфига, config.json не трогается. Ядро получило четыре хука для UI (лог, границы шагов, пароль, отмена), run() читает вывод утилит построчно через Popen, поэтому журнал наполняется во время работы pg_dump. Текстовый режим, CLI и --dry-run ведут себя как раньше; без textual или вне tty пульт не запускается. Журнал разворачивается на весь экран по F4 и сам на старте операции. Раскладка адаптивная: narrow ниже 100 колонок, short ниже 32 строк. Ctrl-хоткеи продублированы кириллицей — русская раскладка их не глушит. Запуск через run.ps1 (venv, UTF-8-консоль, -Setup и -Tests). Проверка — tests_tui.py: 46 тестов, TUI гоняется headless через Pilot.
This commit is contained in:
@@ -3,3 +3,5 @@ dumps/
|
|||||||
*.dump
|
*.dump
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
@@ -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` глотается и логируется как предупреждение — не «чинить» это молча.
|
||||||
|
- Имя файла дампа `<db>-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`, оба файла держать в согласии.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
# pg-stand-sync
|
# pg-stand-sync
|
||||||
|
|
||||||
Перенос базы PostgreSQL со стенда на локальный сервер: `pg_dump -Fc` → пересоздание локальной БД → `pg_restore -j`.
|
Перенос базы 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
|
copy config.example.json config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\run.ps1 -Setup
|
||||||
|
```
|
||||||
|
|
||||||
Заполнить `source` (стенд) и `target` (локальная PG), затем:
|
Заполнить `source` (стенд) и `target` (локальная PG), затем:
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
python pg_stand_sync.py
|
.\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
|
┌ pg-stand-sync ─────────────────────── ◆ DRY-RUN ─┐
|
||||||
локально: postgres@localhost:5432
|
│ стенд postgres@postgresql.lan → локально :5432 │
|
||||||
1. Dump + Restore — снять со стенда и залить локально
|
├──────────────────────────┬───────────────────────┤
|
||||||
2. Dump — только снять дамп со стенда
|
│ Базы стенда показано 3/41│ Приёмник · localhost │
|
||||||
3. Restore — залить локально готовый дамп
|
│ фильтр▸ zpa▌ │ режим DROP+CREATE │
|
||||||
0. Выход
|
│ ● 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-<stamp>.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` — несколько баз |
|
| номер | выбирает пункт; в Dump можно `2,5,7` — несколько баз |
|
||||||
| пусто | сбрасывает фильтр |
|
| пусто | сбрасывает фильтр |
|
||||||
| `q` | назад в меню |
|
| `q` | назад в меню |
|
||||||
|
|
||||||
|
Флаг `--tui` — наоборот: при отсутствии `textual` он завершается ошибкой, а не откатывается
|
||||||
|
молча в текст.
|
||||||
|
|
||||||
`config.json` в `.gitignore` — пароли в репозиторий не попадают.
|
`config.json` в `.gitignore` — пароли в репозиторий не попадают.
|
||||||
|
|
||||||
## CLI
|
## 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`) |
|
| `--no-recreate` | не делать DROP/CREATE DATABASE, накатить поверх (`pg_restore --clean --if-exists`) |
|
||||||
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) |
|
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) |
|
||||||
| `--dry-run` | напечатать команды, ничего не выполнять |
|
| `--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` — уже в локальной
|
Обезличивание данных стенда, если оно нужно, делается через `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`, стриминг вывода,
|
||||||
|
отмена и то, что пароль не попадает в командную строку.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"port": 5432,
|
"port": 5432,
|
||||||
"maintenance_database": "postgres",
|
"maintenance_database": "postgres",
|
||||||
"user": "postgres",
|
"user": "postgres",
|
||||||
|
"password": null,
|
||||||
"password_env": "STAND_PGPASSWORD",
|
"password_env": "STAND_PGPASSWORD",
|
||||||
"sslmode": "prefer",
|
"sslmode": "prefer",
|
||||||
"connect_timeout": 15
|
"connect_timeout": 15
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"port": 5432,
|
"port": 5432,
|
||||||
"database": null,
|
"database": null,
|
||||||
"user": "postgres",
|
"user": "postgres",
|
||||||
|
"password": null,
|
||||||
"password_env": "LOCAL_PGPASSWORD",
|
"password_env": "LOCAL_PGPASSWORD",
|
||||||
"maintenance_database": "postgres",
|
"maintenance_database": "postgres",
|
||||||
"owner": "postgres",
|
"owner": "postgres",
|
||||||
|
|||||||
+246
-16
@@ -8,12 +8,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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:
|
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 = {}
|
_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:
|
def resolve_password(node: dict, label: str) -> str | None:
|
||||||
"""Пароль: из конфига, из переменной окружения, иначе спросить один раз за запуск."""
|
"""Пароль: из конфига, из переменной окружения, иначе спросить один раз за запуск."""
|
||||||
if node.get("password"):
|
if node.get("password"):
|
||||||
@@ -96,9 +227,16 @@ def resolve_password(node: dict, label: str) -> str | None:
|
|||||||
value = os.environ.get(var)
|
value = os.environ.get(var)
|
||||||
if value:
|
if value:
|
||||||
return 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:
|
if key in _password_cache:
|
||||||
return _password_cache[key]
|
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 not sys.stdin.isatty():
|
||||||
if var:
|
if var:
|
||||||
raise ConfigError(f"переменная окружения {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:
|
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)
|
shown = " ".join(f'"{c}"' if " " in c else c for c in cmd)
|
||||||
log(f"{what}: {shown}")
|
log(f"{what}: {shown}")
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return
|
return
|
||||||
|
if _cancel.is_set():
|
||||||
|
raise Cancelled
|
||||||
|
_notify_progress("start", what)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
|
proc = _spawn(cmd, env, subprocess.STDOUT)
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
explained = False
|
||||||
if proc.stdout:
|
try:
|
||||||
for line in proc.stdout.splitlines():
|
for line in proc.stdout:
|
||||||
print(" " + line, flush=True)
|
text = line.rstrip("\r\n")
|
||||||
if proc.returncode != 0:
|
_emit("out", text)
|
||||||
raise StepError(f"{what} завершился с кодом {proc.returncode}")
|
if not explained and "unrecognized configuration parameter" in text:
|
||||||
log(f"{what}: готово за {time.monotonic() - started:.1f} c")
|
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:
|
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:
|
def capture(cmd: list, env: dict, what: str) -> str:
|
||||||
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
|
# тоже через _spawn: на недоступном стенде psql висит до PGCONNECT_TIMEOUT и должен убиваться
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
if _cancel.is_set():
|
||||||
|
raise Cancelled
|
||||||
|
proc = _spawn(cmd, env, subprocess.PIPE)
|
||||||
|
try:
|
||||||
|
out, err = proc.communicate()
|
||||||
|
finally:
|
||||||
|
_forget(proc)
|
||||||
if proc.returncode != 0:
|
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}"))
|
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,
|
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:
|
def ask(prompt: str) -> str:
|
||||||
try:
|
try:
|
||||||
return input(prompt).strip()
|
return input(prompt).strip()
|
||||||
@@ -209,7 +406,7 @@ def pick(items: list, title: str, render, multi: bool = False) -> list:
|
|||||||
|
|
||||||
flt = ""
|
flt = ""
|
||||||
while True:
|
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()
|
||||||
print(f"— {title} —" + (f" фильтр: {flt!r}" if flt else ""))
|
print(f"— {title} —" + (f" фильтр: {flt!r}" if flt else ""))
|
||||||
if not shown:
|
if not shown:
|
||||||
@@ -334,7 +531,7 @@ def restore(cfg: dict, tools: dict, dump_file: Path, dbname: str,
|
|||||||
# без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках
|
# без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках
|
||||||
if opts.get("exit_on_error", False):
|
if opts.get("exit_on_error", False):
|
||||||
raise
|
raise
|
||||||
log(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
|
warn(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
|
||||||
|
|
||||||
|
|
||||||
def post_sql(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
|
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:
|
def do_dump(cfg: dict, tools: dict, dbnames: list, dry_run: bool) -> list:
|
||||||
made = []
|
made = []
|
||||||
for dbname in dbnames:
|
for dbname in dbnames:
|
||||||
|
if _cancel.is_set():
|
||||||
|
raise Cancelled
|
||||||
made.append(dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run))
|
made.append(dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run))
|
||||||
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
|
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
|
||||||
return made
|
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,
|
def do_sync(cfg: dict, tools: dict, dbnames: list, target_db: str | None,
|
||||||
no_recreate: bool, dry_run: bool) -> None:
|
no_recreate: bool, dry_run: bool) -> None:
|
||||||
for dbname in dbnames:
|
for dbname in dbnames:
|
||||||
|
if _cancel.is_set():
|
||||||
|
raise Cancelled
|
||||||
dump_file = dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run)
|
dump_file = dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run)
|
||||||
local = target_db or cfg["target"].get("database") or dbname
|
local = target_db or cfg["target"].get("database") or dbname
|
||||||
do_restore(cfg, tools, dump_file, local, no_recreate, dry_run)
|
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("-c", "--config", type=Path, default=DEFAULT_CONFIG, help="путь к config.json")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="показать команды, ничего не выполнять")
|
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")
|
sub = ap.add_subparsers(dest="command")
|
||||||
|
|
||||||
p_sync = sub.add_parser("sync", help="dump + restore")
|
p_sync = sub.add_parser("sync", help="dump + restore")
|
||||||
@@ -521,15 +726,40 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
return ap
|
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:
|
def main() -> int:
|
||||||
args = build_parser().parse_args()
|
args = build_parser().parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if args.tui and not tui_available():
|
||||||
|
raise ConfigError("textual не установлен: pip install -r requirements.txt")
|
||||||
cfg = load_config(args.config)
|
cfg = load_config(args.config)
|
||||||
tools = resolve_tools(cfg)
|
tools = resolve_tools(cfg)
|
||||||
command = args.command or "menu"
|
command = args.command or "menu"
|
||||||
|
|
||||||
if command == "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)
|
return interactive(cfg, tools, args.dry_run)
|
||||||
|
|
||||||
if command == "list":
|
if command == "list":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
# TUI-режим. Ядро (CLI и текстовое меню) работает и без этих пакетов.
|
||||||
|
textual==8.2.8
|
||||||
|
rich==15.0.0
|
||||||
@@ -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
|
||||||
+972
@@ -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}"
|
||||||
Reference in New Issue
Block a user