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
+2
View File
@@ -3,3 +3,5 @@ dumps/
*.dump *.dump
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
.pytest_cache/
+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`, оба файла держать в согласии.
+92 -14
View File
@@ -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`, стриминг вывода,
отмена и то, что пароль не попадает в командную строку.
+2
View File
@@ -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
View File
@@ -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":
+311
View File
@@ -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;
}
+1388
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# TUI-режим. Ядро (CLI и текстовое меню) работает и без этих пакетов.
textual==8.2.8
rich==15.0.0
+69
View File
@@ -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
View File
@@ -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}"