# 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`. - Диалоги наследуются от `FormScreen` и подмешивают `FORM_NAV`: у `Screen` нет готового `action_focus_next`, поэтому обход полей стрелками сделан своим `action_walk`. Биндинги без `priority` — внутри `Input` стрелки вверх/вниз свободны и всплывают до экрана сами, а влево/вправо остаются за курсором. - Новый `Ctrl`-хоткей добавляется только через `with_ru_layout([...])`: при русской раскладке терминал присылает `ctrl+в` вместо `ctrl+d`, и голый биндинг молча не срабатывает. Полноту дублей проверяет тест, перебирающий `PgSyncApp.BINDINGS`. - В TUI любой `Ctrl+…`-хоткей объявляется `priority=True`: `Input` биндит `ctrl+a`, `ctrl+d`, `ctrl+u`, `ctrl+x`, `ctrl+c` — без приоритета действия молча не сработают в фильтре. `Ctrl+C` в Textual 8 по умолчанию копирует текст, а не выходит. - Отмена (`Ctrl+X`) — это kill дочерних процессов, отката нет: если `DROP DATABASE` уже прошёл, локальная база останется пустой, и UI обязан сказать это прямым текстом. `request_cancel()` зовётся только из потока (в цикле событий он вешает UI на секунды), а флаг отмены снимается только там, где воркер точно не в ядре, — в `begin()` и в `on_op_done()`. В `on_unmount()` его снимать нельзя: при выходе рабочий поток ещё жив и со снятым флагом спокойно доработает следующий `DROP DATABASE` уже без UI. - `target_db=None` в TUI не значит «как на стенде»: `do_sync` считает `target_db or cfg.target.database or dbname`. Поэтому sync нескольких баз запрещён, когда `target.database` задан, — иначе они слились бы в одну. - Строки списков строятся через `room_for()`, который вычитает `SCROLLBAR_ROOM = 2`: сам скроллбар в `content_size` не учтён, и строка ровно по ширине переносит хвост («1277 MB» рвётся на «1277» и «MB»). Проверяется тестом со `scrollbar-gutter: stable`. - Раскладка: `narrow` (<100 колонок) и `short` (<32 строк) вешаются руками в `on_resize`; `Horizontal` со сменой `layout: vertical` не раздаёт детям ни `fr`, ни проценты по высоте, поэтому в узком режиме правая колонка задана в строках. `.zoom-log #bottom` требует `!important` — равная по специфичности `.short` иначе побеждает. - `F4` разворачивает журнал; `begin()` делает это сам и помечает `_zoom_auto`, чтобы `on_op_done` свернул обратно. Ручной `F4` снимает флаг — дальше раскладкой владеет человек. - Счётчик «показано/всего» пишется в `border_subtitle` панели, а не списка: у `OptionList` в CSS `border: none`, а Textual рисует подпись только в строке рамки. Ширина строк списков и плана считается от `content_size` — фиксированные колонки переносятся и рвут вёрстку. - `pg_restore` без `--exit-on-error` возвращает 1 на игнорируемых ошибках, поэтому его `StepError` глотается и логируется как предупреждение — не «чинить» это молча. - Имя файла дампа `-YYYYmmdd-HHMMSS.dump`: по нему `db_from_dump_name()` восстанавливает имя базы для `restore` без `--target-db`. Формат менять только вместе с `STAMP_RE`. - Реконфигурация stdout/stderr в UTF-8 в начале файла нужна для русского лога в cp866-консоли Windows; вывод дочерних процессов читается с `errors="replace"`. `config.json` (рабочий, с паролями) в `.gitignore` — структура правится в `config.example.json`, оба файла держать в согласии.