Два режима работы и выбор базы из списка

Интерактивное меню (Dump+Restore / Dump / Restore) и CLI-команды
sync/dump/restore/list/dumps. База и файл дампа выбираются из списка
с фильтрацией по вводу, имя базы больше не зашито в конфиг.

Пароль берётся из конфига, переменной окружения или спрашивается с
терминала; без пароля утилиты вызываются с --no-password, иначе psql
подвисает на приглашении.
@
This commit is contained in:
2026-08-20 03:17:49 +03:00
parent 1a72fbb720
commit b30406bbf6
3 changed files with 427 additions and 69 deletions
+54 -8
View File
@@ -3,6 +3,8 @@
Перенос базы PostgreSQL со стенда на локальный сервер: `pg_dump -Fc` → пересоздание локальной БД → `pg_restore -j`.
Только stdlib Python 3.10+, внешних зависимостей нет. Утилиты берутся из поставки pgAdmin 4.
Работает в двух режимах: интерактивное меню (запуск без аргументов) и команды CLI.
## Быстрый старт
```bash
@@ -15,16 +17,51 @@ copy config.example.json config.json
python pg_stand_sync.py
```
Появится меню:
```
стенд: postgres@postgresql.lan:5432
локально: postgres@localhost:5432
1. Dump + Restore — снять со стенда и залить локально
2. Dump — только снять дамп со стенда
3. Restore — залить локально готовый дамп
0. Выход
```
После выбора пункта показывается список: для Dump — базы стенда, для Restore — файлы из `dumps`.
Ввод в списке работает так:
| Ввод | Что делает |
|---|---|
| текст | фильтрует список по подстроке (регистр не важен) |
| номер | выбирает пункт; в Dump можно `2,5,7` — несколько баз |
| пусто | сбрасывает фильтр |
| `q` | назад в меню |
`config.json` в `.gitignore` — пароли в репозиторий не попадают.
## Ключи
## CLI
| Ключ | Что делает |
```bash
python pg_stand_sync.py list # базы на стенде
python pg_stand_sync.py dumps # локальные дампы
python pg_stand_sync.py dump zpas # только дамп
python pg_stand_sync.py sync zpas --target-db zpas_l # дамп + restore
python pg_stand_sync.py restore dumps/zpas-20260820-031332.dump
```
Имя базы (или файла) можно не указывать — тогда откроется тот же список с фильтрацией.
| Команда / ключ | Что делает |
|---|---|
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) |
| `--dump-only` | снять дамп со стенда и остановиться |
| `--restore-only FILE` | залить готовый `.dump` локально, без обращения к стенду |
| `sync [DB…]` | dump со стенда + restore локально |
| `dump [DB…]` | только снять дампы со стенда (можно несколько баз) |
| `restore [FILE]` | залить готовый `.dump` локально, без обращения к стенду |
| `list` / `dumps` | базы стенда / файлы дампов |
| `menu` | интерактивное меню (то же, что запуск без команды) |
| `--target-db NAME` | имя локальной базы (по умолчанию как на стенде) |
| `--no-recreate` | не делать DROP/CREATE DATABASE, накатить поверх (`pg_restore --clean --if-exists`) |
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) |
| `--dry-run` | напечатать команды, ничего не выполнять |
## Конфиг
@@ -34,16 +71,25 @@ python pg_stand_sync.py
| `pg_bin_dir` | из `PATH` | каталог с `pg_dump.exe`, `pg_restore.exe`, `psql.exe` |
| `dump_dir` | `dumps` | куда складывать дампы (относительный путь — от каталога скрипта) |
| `keep_dumps` | `5` | сколько последних дампов хранить, `0` — не чистить |
| `source.maintenance_database` | `postgres` | база, к которой подключаться для чтения списка баз |
| `source.password_env` | — | имя переменной окружения с паролем вместо `password` в файле |
| `target.database` | `null` | имя локальной базы; `null` — как на стенде |
| `target.recreate` | `true` | DROP + CREATE локальной базы перед восстановлением |
| `dump.schemas` / `exclude_*` | `[]` | ограничение состава: схемы, таблицы, данные таблиц |
| `restore.jobs` | `4` | параллельные воркеры `pg_restore` |
| `restore.exit_on_error` | `false` | падать на первой ошибке восстановления |
| `post_restore_sql` | `[]` | список SQL, выполняемых в целевой базе после восстановления |
Пароли можно не хранить в файле: задать `password_env` и передать значение через окружение
(`$env:STAND_PGPASSWORD = '...'`). Пароль всегда уходит в дочерний процесс через `PGPASSWORD`,
в командной строке не появляется.
Пароль ищется в три шага: `password` в конфиге → переменная окружения из `password_env`
запрос с терминала (один раз за запуск, ввод скрыт). В неинтерактивном запуске третьего шага
нет — либо `password_env`, либо `pgpass.conf`. Пароль всегда уходит в дочерний процесс через
`PGPASSWORD`, в командной строке не появляется; если пароля нет вовсе, утилиты вызываются с
`--no-password`, чтобы скрипт не подвисал на приглашении.
```powershell
$env:STAND_PGPASSWORD = '...'
python pg_stand_sync.py sync zpas
```
## Как это работает
+5 -5
View File
@@ -4,10 +4,10 @@
"keep_dumps": 5,
"source": {
"host": "stand.example.lan",
"host": "postgresql.lan",
"port": 5432,
"database": "zpas",
"user": "readonly",
"maintenance_database": "postgres",
"user": "postgres",
"password_env": "STAND_PGPASSWORD",
"sslmode": "prefer",
"connect_timeout": 15
@@ -16,9 +16,9 @@
"target": {
"host": "localhost",
"port": 5432,
"database": "zpas",
"database": null,
"user": "postgres",
"password": "postgres",
"password_env": "LOCAL_PGPASSWORD",
"maintenance_database": "postgres",
"owner": "postgres",
"recreate": true
+368 -56
View File
@@ -1,11 +1,16 @@
#!/usr/bin/env python3
"""Перенос базы PostgreSQL со стенда на локальный сервер: pg_dump -Fc + pg_restore."""
"""Перенос баз PostgreSQL со стенда на локальный сервер: pg_dump -Fc + pg_restore.
Два режима: интерактивное меню (запуск без аргументов) и команды CLI.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import re
import shutil
import subprocess
import sys
@@ -14,6 +19,7 @@ from datetime import datetime
from pathlib import Path
DEFAULT_CONFIG = Path(__file__).with_name("config.json")
STAMP_RE = re.compile(r"-\d{8}-\d{6}$")
# Windows-консоль по умолчанию отдаёт cp866, русский лог в ней превращается в мусор
for stream in (sys.stdout, sys.stderr):
@@ -29,10 +35,17 @@ class StepError(Exception):
pass
class Cancelled(Exception):
"""Пользователь отказался от выбора в интерактивном режиме."""
def log(msg: str) -> None:
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
# --------------------------------------------------------------------------- конфиг
def load_config(path: Path) -> dict:
if not path.exists():
raise ConfigError(
@@ -48,7 +61,7 @@ def load_config(path: Path) -> dict:
node = cfg.get(section)
if not isinstance(node, dict):
raise ConfigError(f'{path}: нет секции "{section}"')
for key in ("host", "database", "user"):
for key in ("host", "user"):
if not node.get(key):
raise ConfigError(f"{path}: {section}.{key} не задан")
return cfg
@@ -71,21 +84,44 @@ def resolve_tools(cfg: dict) -> dict:
return tools
def conn_env(node: dict) -> dict:
_password_cache: dict = {}
def resolve_password(node: dict, label: str) -> str | None:
"""Пароль: из конфига, из переменной окружения, иначе спросить один раз за запуск."""
if node.get("password"):
return str(node["password"])
var = node.get("password_env")
if var:
value = os.environ.get(var)
if value:
return value
key = (label, node.get("host"), node.get("port", 5432), node.get("user"))
if key in _password_cache:
return _password_cache[key]
if not sys.stdin.isatty():
if var:
raise ConfigError(f"переменная окружения {var} пуста, а спросить пароль негде")
return None
value = getpass.getpass(
f"пароль {node['user']}@{node['host']} ({label}), Enter — без пароля: ")
_password_cache[key] = value
return value
def conn_env(node: dict, label: str = "", dbname: str | None = None) -> dict:
"""Параметры подключения уходят в окружение, чтобы пароль не светился в командной строке."""
env = dict(os.environ)
env["PGHOST"] = str(node["host"])
env["PGPORT"] = str(node.get("port", 5432))
env["PGUSER"] = str(node["user"])
env["PGDATABASE"] = str(node["database"])
password = node.get("password")
database = dbname or node.get("database") or node.get("maintenance_database") or "postgres"
env["PGDATABASE"] = str(database)
password = resolve_password(node, label or node["host"])
if password:
env["PGPASSWORD"] = str(password)
elif node.get("password_env"):
value = os.environ.get(node["password_env"])
if not value:
raise ConfigError(f"переменная окружения {node['password_env']} пуста")
env["PGPASSWORD"] = value
env["PGPASSWORD"] = password
else:
env.pop("PGPASSWORD", None)
if node.get("sslmode"):
env["PGSSLMODE"] = str(node["sslmode"])
env.setdefault("PGCLIENTENCODING", "UTF8")
@@ -93,6 +129,9 @@ def conn_env(node: dict) -> dict:
return env
# --------------------------------------------------------------------------- запуск утилит
def run(cmd: list, env: dict, what: str, dry_run: bool) -> None:
shown = " ".join(f'"{c}"' if " " in c else c for c in cmd)
log(f"{what}: {shown}")
@@ -109,13 +148,107 @@ def run(cmd: list, env: dict, what: str, dry_run: bool) -> None:
log(f"{what}: готово за {time.monotonic() - started:.1f} c")
def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str, dry_run: bool) -> None:
env = conn_env({**node, "database": dbname})
cmd = [tools["psql"], "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "-c", sql]
def no_prompt(env: dict) -> list:
"""Без пароля в окружении утилиты просят его с терминала и подвисают в скриптах."""
return [] if env.get("PGPASSWORD") else ["--no-password"]
def capture(cmd: list, env: dict, what: str) -> str:
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
detail = (proc.stderr or "").strip().splitlines()
raise StepError(f"{what}: " + (detail[-1] if detail else f"код {proc.returncode}"))
return proc.stdout
def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str,
dry_run: bool, label: str = "") -> None:
env = conn_env(node, label, dbname)
cmd = [tools["psql"], "--no-psqlrc", "-v", "ON_ERROR_STOP=1", *no_prompt(env), "-c", sql]
run(cmd, env, what, dry_run)
def dump(cfg: dict, tools: dict, out: Path, dry_run: bool) -> Path:
def list_databases(tools: dict, node: dict, label: str) -> list:
"""Список баз сервера: (имя, человекочитаемый размер)."""
admin_db = node.get("maintenance_database") or node.get("database") or "postgres"
env = conn_env(node, label, admin_db)
sql = (
"SELECT datname, "
" CASE WHEN has_database_privilege(datname, 'CONNECT') "
" THEN pg_size_pretty(pg_database_size(datname)) ELSE '-' END "
"FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname;"
)
cmd = [tools["psql"], "--no-psqlrc", "-At", "-F", "\t", *no_prompt(env), "-c", sql]
out = capture(cmd, env, f"список баз {node['host']}")
rows = []
for line in out.splitlines():
if not line.strip():
continue
name, _, size = line.partition("\t")
rows.append((name, size or ""))
return rows
# --------------------------------------------------------------------------- интерактив
def ask(prompt: str) -> str:
try:
return input(prompt).strip()
except EOFError:
raise Cancelled from None
def pick(items: list, title: str, render, multi: bool = False) -> list:
"""Список с фильтрацией по вводу: текст сужает список, число(а) выбирают, пустой ввод — сброс."""
if not items:
raise StepError(f"{title}: список пуст")
if not sys.stdin.isatty():
raise StepError(f"{title}: нет терминала для выбора")
flt = ""
while True:
shown = [it for it in items if flt.lower() in render(it).lower()] if flt else list(items)
print()
print(f"{title}" + (f" фильтр: {flt!r}" if flt else ""))
if not shown:
print(" ничего не найдено")
for i, it in enumerate(shown, 1):
print(f" {i:>3}. {render(it)}")
hint = "номера через запятую" if multi else "номер"
choice = ask(f"[{hint} | текст — фильтр | пусто — сброс | q — назад]: ")
if choice.lower() in ("q", "выход", "назад"):
raise Cancelled
if not choice:
flt = ""
continue
if re.fullmatch(r"[\d\s,]+", choice):
nums = [int(n) for n in re.split(r"[\s,]+", choice) if n]
bad = [n for n in nums if not 1 <= n <= len(shown)]
if bad or not nums:
print(f" нет такого номера: {', '.join(map(str, bad)) or '-'}")
continue
if not multi and len(nums) > 1:
print(" здесь выбирается один пункт")
continue
return [shown[n - 1] for n in nums]
flt = choice
def confirm(question: str, default: bool = True) -> bool:
suffix = "[Y/n]" if default else "[y/N]"
answer = ask(f"{question} {suffix}: ").lower()
if not answer:
return default
return answer in ("y", "yes", "д", "да")
# --------------------------------------------------------------------------- шаги
def dump(cfg: dict, tools: dict, dbname: str, out: Path, dry_run: bool) -> Path:
src = cfg["source"]
opts = cfg.get("dump", {})
cmd = [tools["pg_dump"], "--format=custom"]
@@ -139,18 +272,19 @@ def dump(cfg: dict, tools: dict, out: Path, dry_run: bool) -> Path:
cmd += ["--exclude-table-data", table]
cmd += ["--file", str(out)]
env = conn_env(src, "стенд", dbname)
cmd += no_prompt(env)
out.parent.mkdir(parents=True, exist_ok=True)
run(cmd, conn_env(src), f"дамп {src['database']}@{src['host']}", dry_run)
run(cmd, env, f"дамп {dbname}@{src['host']}", dry_run)
if not dry_run:
size = out.stat().st_size / 1024 / 1024
log(f"дамп: {out} ({size:.1f} МБ)")
return out
def recreate_target(cfg: dict, tools: dict, dry_run: bool) -> None:
def recreate_target(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
tgt = cfg["target"]
maintenance = tgt.get("maintenance_database", "postgres")
dbname = tgt["database"]
quoted = '"' + dbname.replace('"', '""') + '"'
literal = "'" + dbname.replace("'", "''") + "'"
@@ -158,24 +292,25 @@ def recreate_target(cfg: dict, tools: dict, dry_run: bool) -> None:
psql_exec(tools, tgt, maintenance,
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
f"WHERE datname = {literal} AND pid <> pg_backend_pid();",
"обрыв активных сессий", dry_run)
"обрыв активных сессий", dry_run, "локальная")
psql_exec(tools, tgt, maintenance, f"DROP DATABASE IF EXISTS {quoted};",
"DROP DATABASE", dry_run)
"DROP DATABASE", dry_run, "локальная")
create = f"CREATE DATABASE {quoted}"
if tgt.get("owner"):
create += ' OWNER "' + tgt["owner"].replace('"', '""') + '"'
if tgt.get("template"):
create += f" TEMPLATE {tgt['template']}"
psql_exec(tools, tgt, maintenance, create + ";", "CREATE DATABASE", dry_run)
psql_exec(tools, tgt, maintenance, create + ";", "CREATE DATABASE", dry_run, "локальная")
def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: bool) -> None:
def restore(cfg: dict, tools: dict, dump_file: Path, dbname: str,
recreated: bool, dry_run: bool) -> None:
tgt = cfg["target"]
opts = cfg.get("restore", {})
if not dry_run and not dump_file.exists():
raise StepError(f"файл дампа не найден: {dump_file}")
cmd = [tools["pg_restore"], "--dbname", tgt["database"]]
cmd = [tools["pg_restore"], "--dbname", dbname]
jobs = int(opts.get("jobs", 4))
if jobs > 1:
cmd += ["--jobs", str(jobs)]
@@ -189,10 +324,12 @@ def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: b
cmd.append("--exit-on-error")
if opts.get("verbose"):
cmd.append("--verbose")
env = conn_env(tgt, "локальная", dbname)
cmd += no_prompt(env)
cmd.append(str(dump_file))
try:
run(cmd, conn_env(tgt), f"restore в {tgt['database']}@{tgt['host']}", dry_run)
run(cmd, env, f"restore в {dbname}@{tgt['host']}", dry_run)
except StepError as exc:
# без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках
if opts.get("exit_on_error", False):
@@ -200,11 +337,11 @@ def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: b
log(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
def post_sql(cfg: dict, tools: dict, dry_run: bool) -> None:
def post_sql(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
statements = cfg.get("post_restore_sql") or []
for i, sql in enumerate(statements, 1):
psql_exec(tools, cfg["target"], cfg["target"]["database"], sql,
f"post-SQL {i}/{len(statements)}", dry_run)
psql_exec(tools, cfg["target"], dbname, sql,
f"post-SQL {i}/{len(statements)}", dry_run, "локальная")
def prune(dump_dir: Path, keep: int) -> None:
@@ -216,47 +353,222 @@ def prune(dump_dir: Path, keep: int) -> None:
old.unlink()
def main() -> int:
ap = argparse.ArgumentParser(description="Перенос БД PostgreSQL со стенда на локальный сервер")
# --------------------------------------------------------------------------- сценарии
def dump_dir_of(cfg: dict) -> Path:
dump_dir = Path(cfg.get("dump_dir") or "dumps")
if not dump_dir.is_absolute():
dump_dir = Path(__file__).parent / dump_dir
return dump_dir
def dump_path_for(cfg: dict, dbname: str) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return dump_dir_of(cfg) / f"{dbname}-{stamp}.dump"
def db_from_dump_name(path: Path) -> str:
return STAMP_RE.sub("", path.stem)
def local_dumps(cfg: dict) -> list:
dump_dir = dump_dir_of(cfg)
if not dump_dir.exists():
return []
return sorted(dump_dir.glob("*.dump"), key=lambda p: p.stat().st_mtime, reverse=True)
def do_dump(cfg: dict, tools: dict, dbnames: list, dry_run: bool) -> list:
made = []
for dbname in dbnames:
made.append(dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run))
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
return made
def do_restore(cfg: dict, tools: dict, dump_file: Path, target_db: str,
no_recreate: bool, dry_run: bool) -> None:
recreate = bool(cfg["target"].get("recreate", True)) and not no_recreate
if recreate:
recreate_target(cfg, tools, target_db, dry_run)
restore(cfg, tools, dump_file, target_db, recreate, dry_run)
post_sql(cfg, tools, target_db, dry_run)
def do_sync(cfg: dict, tools: dict, dbnames: list, target_db: str | None,
no_recreate: bool, dry_run: bool) -> None:
for dbname in dbnames:
dump_file = dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run)
local = target_db or cfg["target"].get("database") or dbname
do_restore(cfg, tools, dump_file, local, no_recreate, dry_run)
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
# --------------------------------------------------------------------------- меню
def choose_source_dbs(cfg: dict, tools: dict, multi: bool) -> list:
log(f"читаю список баз со стенда {cfg['source']['host']}")
rows = list_databases(tools, cfg["source"], "стенд")
chosen = pick(rows, f"базы на {cfg['source']['host']}",
lambda r: f"{r[0]:<32} {r[1]}", multi=multi)
return [name for name, _ in chosen]
def choose_dump_file(cfg: dict) -> Path:
files = local_dumps(cfg)
if not files:
raise StepError(f"в {dump_dir_of(cfg)} нет файлов *.dump")
def render(p: Path) -> str:
st = p.stat()
return (f"{p.name:<44} {st.st_size / 1024 / 1024:>8.1f} МБ "
f"{datetime.fromtimestamp(st.st_mtime):%d.%m %H:%M}")
return pick(files, f"дампы в {dump_dir_of(cfg)}", render)[0]
def ask_target_db(cfg: dict, default: str) -> str:
answer = ask(f"локальная база [{default}] (Enter — оставить): ")
return answer or default
def confirm_target(cfg: dict, what: str, target_db: str) -> bool:
recreate = bool(cfg["target"].get("recreate", True))
action = (f"пересоздать (DROP+CREATE) локальную {target_db}" if recreate
else f"накатить поверх локальной {target_db}")
return confirm(f"{what}{action}?", default=not recreate)
def interactive(cfg: dict, tools: dict, dry_run: bool) -> int:
src, tgt = cfg["source"], cfg["target"]
while True:
print()
print("=" * 62)
print(f" стенд: {src['user']}@{src['host']}:{src.get('port', 5432)}")
print(f" локально: {tgt['user']}@{tgt['host']}:{tgt.get('port', 5432)}"
+ (" [dry-run]" if dry_run else ""))
print("=" * 62)
print(" 1. Dump + Restore — снять со стенда и залить локально")
print(" 2. Dump — только снять дамп со стенда")
print(" 3. Restore — залить локально готовый дамп")
print(" 0. Выход")
try:
choice = ask("выбор: ")
if choice in ("0", "q", "выход", ""):
return 0
if choice == "1":
dbs = choose_source_dbs(cfg, tools, multi=False)
target_db = ask_target_db(cfg, cfg["target"].get("database") or dbs[0])
if not confirm_target(cfg, dbs[0], target_db):
continue
do_sync(cfg, tools, dbs, target_db, no_recreate=False, dry_run=dry_run)
log("перенос завершён")
elif choice == "2":
dbs = choose_source_dbs(cfg, tools, multi=True)
do_dump(cfg, tools, dbs, dry_run)
log("дамп завершён")
elif choice == "3":
dump_file = choose_dump_file(cfg)
default_db = cfg["target"].get("database") or db_from_dump_name(dump_file)
target_db = ask_target_db(cfg, default_db)
if not confirm_target(cfg, dump_file.name, target_db):
continue
do_restore(cfg, tools, dump_file, target_db, no_recreate=False, dry_run=dry_run)
log("восстановление завершено")
else:
print("нет такого пункта")
except Cancelled:
continue
except StepError as exc:
print(f"ОШИБКА: {exc}", file=sys.stderr)
# --------------------------------------------------------------------------- CLI
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
description="Перенос БД PostgreSQL со стенда на локальный сервер. "
"Без команды запускается интерактивное меню.")
ap.add_argument("-c", "--config", type=Path, default=DEFAULT_CONFIG, help="путь к config.json")
ap.add_argument("--dump-only", action="store_true", help="только снять дамп со стенда")
ap.add_argument("--restore-only", metavar="FILE", type=Path,
help="только восстановить указанный дамп локально")
ap.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
ap.add_argument("--dry-run", action="store_true", help="показать команды, ничего не выполнять")
args = ap.parse_args()
sub = ap.add_subparsers(dest="command")
p_sync = sub.add_parser("sync", help="dump + restore")
p_sync.add_argument("db", nargs="*", help="базы на стенде (без имени — выбор из списка)")
p_sync.add_argument("--target-db", help="имя локальной базы (по умолчанию как на стенде)")
p_sync.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
p_dump = sub.add_parser("dump", help="только снять дамп со стенда")
p_dump.add_argument("db", nargs="*", help="базы на стенде (без имени — выбор из списка)")
p_restore = sub.add_parser("restore", help="только залить дамп локально")
p_restore.add_argument("file", nargs="?", type=Path,
help="файл .dump (без имени — выбор из списка)")
p_restore.add_argument("--target-db", help="имя локальной базы")
p_restore.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
sub.add_parser("list", help="показать базы на стенде")
sub.add_parser("dumps", help="показать локальные дампы")
sub.add_parser("menu", help="интерактивное меню (то же, что запуск без команды)")
return ap
def main() -> int:
args = build_parser().parse_args()
try:
cfg = load_config(args.config)
tools = resolve_tools(cfg)
dump_dir = Path(cfg.get("dump_dir") or "dumps")
if not dump_dir.is_absolute():
dump_dir = Path(__file__).parent / dump_dir
command = args.command or "menu"
if args.restore_only:
dump_file = args.restore_only
else:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
dump_file = dump_dir / f"{cfg['source']['database']}-{stamp}.dump"
dump(cfg, tools, dump_file, args.dry_run)
if args.dump_only:
log("режим --dump-only: restore пропущен")
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
return 0
if command == "menu":
return interactive(cfg, tools, args.dry_run)
if command == "list":
for name, size in list_databases(tools, cfg["source"], "стенд"):
print(f"{name:<40} {size}")
return 0
if command == "dumps":
for path in local_dumps(cfg):
st = path.stat()
print(f"{path.name:<48} {st.st_size / 1024 / 1024:>8.1f} МБ "
f"{datetime.fromtimestamp(st.st_mtime):%d.%m.%Y %H:%M}")
return 0
if command in ("sync", "dump"):
dbs = args.db or choose_source_dbs(cfg, tools, multi=(command == "dump"))
if command == "dump":
do_dump(cfg, tools, dbs, args.dry_run)
log("дамп завершён")
else:
do_sync(cfg, tools, dbs, args.target_db, args.no_recreate, args.dry_run)
log("перенос завершён")
return 0
if command == "restore":
dump_file = args.file or choose_dump_file(cfg)
target_db = (args.target_db or cfg["target"].get("database")
or db_from_dump_name(dump_file))
do_restore(cfg, tools, dump_file, target_db, args.no_recreate, args.dry_run)
log("восстановление завершено")
return 0
recreate = bool(cfg["target"].get("recreate", True)) and not args.no_recreate
if recreate:
recreate_target(cfg, tools, args.dry_run)
restore(cfg, tools, dump_file, recreate, args.dry_run)
post_sql(cfg, tools, args.dry_run)
if not args.restore_only:
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
log("перенос завершён")
return 0
except (ConfigError, StepError) as exc:
print(f"ОШИБКА: {exc}", file=sys.stderr)
return 1
except Cancelled:
print("отменено", file=sys.stderr)
return 130
except KeyboardInterrupt:
print("прервано пользователем", file=sys.stderr)
return 130