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

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

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

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

Запуск через run.ps1 (venv, UTF-8-консоль, -Setup и -Tests).
Проверка — tests_tui.py: 46 тестов, TUI гоняется headless через Pilot.
This commit is contained in:
2026-08-20 05:21:52 +03:00
parent b30406bbf6
commit 9fdb02c72a
10 changed files with 3211 additions and 30 deletions
+126
View File
@@ -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`, оба файла держать в согласии.