mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-30 16:36:56 +03:00
516 lines
20 KiB
Python
516 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
# db-load-dt v1.12 — Load 1C information base from DT file
|
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
|
|
|
import argparse
|
|
import atexit
|
|
import glob
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
def _find_project_v8path():
|
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
|
d = os.getcwd()
|
|
while True:
|
|
pf = os.path.join(d, ".v8-project.json")
|
|
if os.path.isfile(pf):
|
|
try:
|
|
with open(pf, encoding="utf-8-sig") as f:
|
|
data = json.load(f)
|
|
v = data.get("v8path")
|
|
if v:
|
|
return v
|
|
except Exception:
|
|
pass
|
|
return None
|
|
parent = os.path.dirname(d)
|
|
if parent == d:
|
|
return None
|
|
d = parent
|
|
|
|
|
|
# --- Additional platform arguments ---
|
|
V8_OWNED_KEYS = [
|
|
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
|
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
|
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
|
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
|
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
|
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
|
]
|
|
IBCMD_OWNED_KEYS = [
|
|
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
|
"--import", "--export", "--apply", "--force", "--create-database",
|
|
"--user", "--password",
|
|
]
|
|
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
|
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
|
|
|
|
|
def arg_key_match(token, key):
|
|
"""Token matches a key when it equals it, or starts with it and the next character
|
|
is not a letter — catches glued /N"user" and --password=x, while keeping
|
|
/ClearCache distinct from /C."""
|
|
if len(token) < len(key):
|
|
return False
|
|
if token[: len(key)].lower() != key.lower():
|
|
return False
|
|
if len(token) == len(key):
|
|
return True
|
|
return not token[len(key)].isalpha()
|
|
|
|
|
|
def project_extra_args(name):
|
|
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
|
d = os.getcwd()
|
|
while True:
|
|
pf = os.path.join(d, ".v8-project.json")
|
|
if os.path.isfile(pf):
|
|
try:
|
|
with open(pf, encoding="utf-8-sig") as f:
|
|
data = json.load(f)
|
|
v = data.get(name)
|
|
if v:
|
|
return [str(x) for x in v]
|
|
except Exception:
|
|
pass
|
|
return []
|
|
parent = os.path.dirname(d)
|
|
if parent == d:
|
|
return []
|
|
d = parent
|
|
|
|
|
|
def assert_extra_args(extra, engine, hints):
|
|
"""The platform accepts only one batch operation, and a duplicate connection or
|
|
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
|
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
|
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
|
for tok in extra:
|
|
if engine == "ibcmd" and not tok.startswith("-"):
|
|
print(
|
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
|
f"({param} cannot extend the ibcmd command)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
for k in owned:
|
|
if arg_key_match(tok, k):
|
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
|
print(
|
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def format_args_for_display(arglist, engine):
|
|
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
|
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
|
a leaked password does."""
|
|
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
|
res = []
|
|
mask_next = False
|
|
for tok in arglist:
|
|
if mask_next:
|
|
res.append("***")
|
|
mask_next = False
|
|
continue
|
|
hit = None
|
|
for k in keys:
|
|
if tok[: len(k)].lower() == k.lower():
|
|
hit = k
|
|
break
|
|
if hit is None:
|
|
res.append(tok)
|
|
elif len(tok) == len(hit):
|
|
res.append(tok)
|
|
mask_next = True
|
|
elif tok[len(hit)] == "=":
|
|
res.append(hit + "=***")
|
|
else:
|
|
res.append(hit + "***")
|
|
return res
|
|
|
|
|
|
def extract_extra_args(argv, known_opts):
|
|
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
|
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
|
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
|
rest, v8, ibcmd = [], [], []
|
|
i = 0
|
|
while i < len(argv):
|
|
low = argv[i].lower()
|
|
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
|
target = v8 if low == "-additionalv8arguments" else ibcmd
|
|
i += 1
|
|
while i < len(argv) and argv[i].lower() not in known_opts:
|
|
target.append(argv[i])
|
|
i += 1
|
|
continue
|
|
rest.append(argv[i])
|
|
i += 1
|
|
return rest, v8, ibcmd
|
|
|
|
|
|
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|
"""Pick the argument list for the selected engine and validate it. An explicitly
|
|
passed parameter for the other engine is an error; the same keys coming from
|
|
.v8-project.json simply do not apply — a project may describe both engines.
|
|
|
|
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
|
so that form is the documented one and both ports must accept it. A value containing
|
|
a comma is not supported."""
|
|
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
|
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
|
if engine == "ibcmd" and v8_extra:
|
|
print(
|
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
|
"(use -AdditionalIbcmdArguments)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
if engine != "ibcmd" and ibcmd_extra:
|
|
print(
|
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
|
"(use -AdditionalV8Arguments)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
if engine == "ibcmd":
|
|
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
|
else:
|
|
extra = project_extra_args("v8args") + list(v8_extra)
|
|
if extra:
|
|
assert_extra_args(extra, engine, hints)
|
|
return extra
|
|
|
|
|
|
def _version_dir(p):
|
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
|
parent = os.path.dirname(p)
|
|
if os.path.basename(parent).lower() == "bin":
|
|
parent = os.path.dirname(parent)
|
|
return os.path.basename(parent)
|
|
|
|
|
|
def _version_key(p):
|
|
"""Numeric sort key from version dir name."""
|
|
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
|
|
|
|
|
def resolve_v8path(v8path):
|
|
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
|
if not v8path:
|
|
v8path = _find_project_v8path()
|
|
if not v8path:
|
|
if os.name == "nt":
|
|
candidates = (
|
|
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
|
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
|
)
|
|
else:
|
|
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
|
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
|
if candidates:
|
|
v8path = max(candidates, key=_version_key)
|
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
|
else:
|
|
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
|
sys.exit(1)
|
|
if os.path.isdir(v8path):
|
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
|
v8path = os.path.join(v8path, exe)
|
|
if not os.path.isfile(v8path):
|
|
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return v8path
|
|
|
|
|
|
IBCMD_NOUSER_HINT = (
|
|
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
|
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
|
"call may block instead of failing. If it does not return promptly, abort and "
|
|
"re-run with -UserName and -Password.\n"
|
|
)
|
|
|
|
|
|
def decode_platform_bytes(data):
|
|
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
|
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
|
code page (what text=True uses) mangles both."""
|
|
if not data:
|
|
return ""
|
|
try:
|
|
return data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return data.decode("cp866", errors="replace")
|
|
|
|
|
|
def assert_infobase_exists(path):
|
|
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
|
"Неверные или отсутствующие параметры соединения" after a launch."""
|
|
if not path:
|
|
return
|
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def clean_path(value, param=""):
|
|
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
|
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
|
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
|
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
|
if not value:
|
|
return value
|
|
v = value.strip()
|
|
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
v = v[1:-1].strip()
|
|
if len(v) > 3 and v[-1] in "\\/":
|
|
v = v[:-1]
|
|
if '"' in v:
|
|
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return v
|
|
|
|
|
|
def quote_if_needed(token):
|
|
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
|
verbatim, so a token with a space needs quotes of its own."""
|
|
if token and (" " in token or "\t" in token) and '"' not in token:
|
|
return f'"{token}"'
|
|
return token
|
|
|
|
|
|
def run_v8(v8path, arguments):
|
|
"""Run 1cv8 in batch mode and capture its console output.
|
|
|
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
|
escape those quotes, so there the command line is handed over ready-made.
|
|
"""
|
|
if os.name == "nt":
|
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
|
else:
|
|
cmd = [v8path] + arguments
|
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
|
r.stdout = decode_platform_bytes(r.stdout)
|
|
r.stderr = decode_platform_bytes(r.stderr)
|
|
return r
|
|
|
|
|
|
def print_platform_output(result):
|
|
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
|
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
|
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
|
if not text:
|
|
return
|
|
limit = 65536
|
|
if len(text) > limit:
|
|
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
|
print("--- Вывод платформы ---")
|
|
print(text)
|
|
print("--- End ---")
|
|
|
|
|
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|
"""Run an ibcmd command non-interactively.
|
|
|
|
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
|
On Windows without -UserName ibcmd reads the console directly and may still block —
|
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
|
"""
|
|
if warn_no_user and os.name == "nt" and not has_username:
|
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
|
sys.stderr.flush()
|
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
|
r.stdout = decode_platform_bytes(r.stdout)
|
|
r.stderr = decode_platform_bytes(r.stderr)
|
|
return r
|
|
|
|
|
|
def describe_exit(code):
|
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
|
if code is None:
|
|
return ""
|
|
win = {
|
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
|
}
|
|
if code in win:
|
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
|
if -64 <= code < 0:
|
|
try:
|
|
import signal
|
|
name = signal.Signals(-code).name
|
|
except (ValueError, AttributeError):
|
|
name = f"signal {-code}"
|
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
|
return ""
|
|
|
|
|
|
def _redact(text, *secrets):
|
|
"""Redact literal secret values (password, user) from a display string —
|
|
precise, never touches lookalike paths."""
|
|
for s in secrets:
|
|
if s:
|
|
text = text.replace(s, "***")
|
|
return text
|
|
|
|
|
|
def main():
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
parser = argparse.ArgumentParser(
|
|
description="Load 1C information base from DT file",
|
|
allow_abbrev=False,
|
|
)
|
|
parser.add_argument("-V8Path", default="")
|
|
parser.add_argument("-InfoBasePath", default="")
|
|
parser.add_argument("-InfoBaseServer", default="")
|
|
parser.add_argument("-InfoBaseRef", default="")
|
|
parser.add_argument("-UserName", default="")
|
|
parser.add_argument("-Password", default="")
|
|
parser.add_argument("-InputFile", required=True)
|
|
parser.add_argument("-JobsCount", type=int, default=0)
|
|
parser.add_argument("-UnlockCode", default="")
|
|
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
|
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
|
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
|
help="Extra ibcmd arguments in --key=value form")
|
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
|
args = parser.parse_args(argv)
|
|
|
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
|
assert_infobase_exists(args.InfoBasePath)
|
|
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
|
|
|
v8path = resolve_v8path(args.V8Path)
|
|
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
|
|
|
# --- Resolve additional arguments for the selected engine ---
|
|
arg_hints = {
|
|
"/F": "-InfoBasePath",
|
|
"/S": "-InfoBaseServer + -InfoBaseRef",
|
|
"/N": "-UserName",
|
|
"/P": "-Password",
|
|
"--db-path": "-InfoBasePath",
|
|
"--user": "-UserName",
|
|
"--password": "-Password",
|
|
}
|
|
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
|
|
|
# --- Validate connection ---
|
|
if engine == "ibcmd":
|
|
if not args.InfoBasePath:
|
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# --- Validate input file ---
|
|
if not os.path.isfile(args.InputFile):
|
|
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# --- ibcmd branch (file infobase only) ---
|
|
if engine == "ibcmd":
|
|
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
|
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
|
arguments.append("--create-database")
|
|
if args.UserName:
|
|
arguments.append(f"--user={args.UserName}")
|
|
if args.Password:
|
|
arguments.append(f"--password={args.Password}")
|
|
arguments.append(args.InputFile)
|
|
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
|
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
|
arguments.append(f"--data={ib_data}")
|
|
arguments.extend(extra_args)
|
|
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
|
if result.returncode == 0:
|
|
print(f"Information base restored successfully from: {args.InputFile}")
|
|
else:
|
|
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
|
sys.exit(result.returncode)
|
|
|
|
# --- Temp dir ---
|
|
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
|
|
try:
|
|
# --- Build arguments ---
|
|
arguments = ["DESIGNER"]
|
|
|
|
if args.InfoBaseServer and args.InfoBaseRef:
|
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
|
else:
|
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
|
|
|
if args.UserName:
|
|
arguments.append(f'/N"{args.UserName}"')
|
|
if args.Password:
|
|
arguments.append(f'/P"{args.Password}"')
|
|
if args.UnlockCode:
|
|
arguments.append(f'/UC"{args.UnlockCode}"')
|
|
|
|
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
|
|
if args.JobsCount > 0:
|
|
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
|
|
|
# --- Output ---
|
|
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
|
arguments.extend(["/Out", f'"{out_file}"'])
|
|
arguments.append("/DisableStartupDialogs")
|
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
|
|
|
# --- Execute ---
|
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
|
result = run_v8(v8path, arguments)
|
|
exit_code = result.returncode
|
|
|
|
# --- Result ---
|
|
if exit_code == 0:
|
|
print(f"Information base restored successfully from: {args.InputFile}")
|
|
else:
|
|
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
|
|
|
if os.path.isfile(out_file):
|
|
try:
|
|
with open(out_file, "r", encoding="utf-8-sig") as f:
|
|
log_content = f.read()
|
|
if log_content:
|
|
print("--- Log ---")
|
|
print(log_content)
|
|
print("--- End ---")
|
|
except Exception:
|
|
pass
|
|
|
|
print_platform_output(result)
|
|
sys.exit(exit_code)
|
|
|
|
finally:
|
|
if os.path.isdir(temp_dir):
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|