fix(db-dump-xml): Changes без тихой полной перевыгрузки, инкремент в ibcmd

- -update без -force: при другой версии формата — отказ с подсказкой
  про -Mode Full, а не молчаливая перезапись всего дерева в новом формате.
- Первая выгрузка режимом по умолчанию: в каталог без выгрузки (пусто,
  только .git/README) — полностью; выгрузка без ConfigDumpInfo.xml — ошибка.
- ibcmd: Changes через --sync; Full в непустой каталог — через временный
  каталог и копирование поверх, как пишет конфигуратор; ошибка копирования
  даёт код 1. UpdateInfo с -AllExtensions отвергается, а не выгружает всё.
- py-порт печатает вывод ibcmd, как PS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANXwbMUhuRFSCsq2RgmTTm
This commit is contained in:
Nick Shirokov
2026-09-24 18:08:20 +03:00
co-authored by Claude Opus 5.5
parent ce7238792e
commit f98e7e9e81
12 changed files with 514 additions and 18 deletions
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# db-dump-xml v1.22 — Dump 1C configuration to XML files
# db-dump-xml v1.23 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -494,6 +494,27 @@ def dir_nonempty(path):
return os.path.isdir(path) and any(os.scandir(path))
def copy_tree_over(src, dst):
"""Скопировать дерево поверх каталога: файлы заменяются, лишнее в приёмнике остаётся —
так пишет полную выгрузку конфигуратор (устаревшие объекты, .git и прочее не трогает).
Исключение на первой же ошибке. Пустые каталоги не переносятся."""
for base, _dirs, files in os.walk(src):
rel = os.path.relpath(base, src)
target = dst if rel == "." else os.path.join(dst, rel)
for name in files:
os.makedirs(target, exist_ok=True)
shutil.copy2(os.path.join(base, name), os.path.join(target, name))
def print_format_mismatch_hint(text):
"""Отказ инкрементальной выгрузки из-за версии формата: каталог выгружен другой платформой.
Тексты: 1cv8 — «версия формата платформы отличается от версии формата выгрузки»,
ibcmd — «Версия формат выгрузки и платформы не совпадают»."""
if re.search(r"верси\S* формат\S* .{0,40}(отличается|не совпадают)", text or "", re.IGNORECASE):
print("[подсказка] Каталог выгружен в другой версии формата. -Mode Full перевыгрузит конфигурацию")
print(" целиком в формате текущей платформы — изменятся все файлы каталога.")
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
@@ -604,6 +625,25 @@ def main():
print("Error: -Objects or -ObjectsFile required for Partial mode")
sys.exit(1)
# --- Changes: инкремент ведётся от файла версий прошлой выгрузки ---
# Без ConfigDumpInfo.xml платформа инкремент отвергает. Пустой каталог — первая выгрузка, она
# полная. Непустой без файла версий не трогаем: полная выгрузка перепишет его целиком, и это
# решение пользователя. У -AllExtensions файлы версий лежат в подкаталогах расширений — их
# проверяет платформа.
if args.Mode == "Changes":
if args.AllExtensions:
first_dump = not dir_nonempty(args.ConfigDir)
else:
first_dump = (not os.path.exists(os.path.join(args.ConfigDir, "ConfigDumpInfo.xml"))
and not os.path.exists(os.path.join(args.ConfigDir, "Configuration.xml")))
if first_dump:
print("[note] выгрузки в каталоге ещё нет — первая выгрузка выполняется полностью")
args.Mode = "Full"
elif not args.AllExtensions and not os.path.exists(os.path.join(args.ConfigDir, "ConfigDumpInfo.xml")):
print(f"Error: no ConfigDumpInfo.xml in {args.ConfigDir} — incremental dump (Changes) is impossible")
print(" -Mode Full выгрузит конфигурацию целиком поверх каталога.")
sys.exit(1)
# --- Create output dir if needed ---
if not os.path.exists(args.ConfigDir):
os.makedirs(args.ConfigDir, exist_ok=True)
@@ -614,11 +654,25 @@ def main():
if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1)
if args.AllExtensions:
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "UpdateInfo":
# Полный экспорт ibcmd в непустой каталог отказывает («Каталог не пуст»), а конфигуратор
# пишет поверх. Чтобы движки вели себя одинаково, такой экспорт идёт во временный каталог
# и копируется поверх ConfigDir.
if args.AllExtensions and args.Mode == "Changes":
print("[note] ibcmd выгружает все расширения только полностью")
overlay = (args.Mode != "Partial" and (args.AllExtensions or args.Mode == "Full")
and dir_nonempty(args.ConfigDir))
out_dir = args.ConfigDir
if overlay:
export_tmp = tempfile.mkdtemp(prefix="ibcmd_export_")
atexit.register(shutil.rmtree, export_tmp, ignore_errors=True)
out_dir = os.path.join(export_tmp, "export")
if args.Mode == "UpdateInfo":
# Раньше развилки по -AllExtensions: иначе вместо обновления файла версий ушла бы
# полная выгрузка всех расширений.
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
sys.exit(1)
elif args.AllExtensions:
arguments = ["infobase", "config", "export", "all-extensions", out_dir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "Partial":
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
arguments = ["infobase", "config", "export", "objects"] + obj_list
@@ -629,7 +683,10 @@ def main():
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
arguments.append(args.ConfigDir)
# Инкремент — --sync; без --force: при другой версии формата ibcmd честно отказывает.
if args.Mode == "Changes":
arguments.append("--sync")
arguments.append(out_dir)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
@@ -641,15 +698,28 @@ def main():
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
exit_code = result.returncode
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
out_missing = exit_code == 0 and not dir_nonempty(out_dir)
if out_missing:
exit_code = 1
copy_error = None
if exit_code == 0 and overlay:
try:
copy_tree_over(out_dir, args.ConfigDir)
except OSError as e:
copy_error = str(e)
exit_code = 1
if exit_code == 0:
print(f"Configuration exported successfully to: {args.ConfigDir}")
elif copy_error:
print(f"Error: export succeeded, but copying it over {args.ConfigDir} failed: {copy_error}")
print(" Каталог мог остаться частично обновлённым — повторите выгрузку.")
elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
else:
print(f"Error exporting configuration (code: {exit_code})")
print_platform_output(result)
if exit_code != 0:
print_format_mismatch_hint((result.stdout or "") + (result.stderr or ""))
sys.exit(exit_code)
# --- Temp dir ---
@@ -682,8 +752,8 @@ def main():
print("Executing full configuration dump...")
elif args.Mode == "Changes":
print("Executing incremental configuration dump...")
# Без -force: с ним при другой версии формата платформа молча выгружает всё заново.
arguments.append("-update")
arguments.append("-force")
elif args.Mode == "Partial":
print("Executing partial configuration dump...")
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
@@ -730,6 +800,7 @@ def main():
else:
print(f"Error dumping configuration (code: {exit_code})")
log_content = ""
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
@@ -742,6 +813,8 @@ def main():
pass
print_platform_output(result)
if exit_code != 0:
print_format_mismatch_hint(log_content + "\n" + (result.stdout or "") + (result.stderr or ""))
sys.exit(exit_code)
finally: