Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect exported 1C SQL Config/Params files without semantic guessing.
|
||||
|
||||
This script operates on files exported from Config/ConfigSave/ConfigCAS/
|
||||
ConfigCASSave/Params tables. It only performs mechanical steps:
|
||||
|
||||
1. try known compression envelopes;
|
||||
2. try common text encodings;
|
||||
3. parse the brace-based 1C serialized value syntax into a generic tree;
|
||||
4. report structural shape, strings, GUIDs, and selected tree paths.
|
||||
|
||||
It deliberately does not map SQLKind codes to 1C object types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
GUID_RE = re.compile(
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||
)
|
||||
|
||||
|
||||
def try_decompress(data: bytes) -> tuple[bytes, str]:
|
||||
attempts = (
|
||||
("raw_deflate", lambda value: zlib.decompress(value, -15)),
|
||||
("zlib", zlib.decompress),
|
||||
("gzip", gzip.decompress),
|
||||
)
|
||||
for name, func in attempts:
|
||||
try:
|
||||
return func(data), name
|
||||
except Exception:
|
||||
pass
|
||||
return data, "none"
|
||||
|
||||
|
||||
def try_decode(data: bytes) -> tuple[str | None, str | None]:
|
||||
candidates = ("utf-8-sig", "utf-16-le", "utf-16-be", "cp1251")
|
||||
best: tuple[str | None, str | None, int] = (None, None, -1)
|
||||
for encoding in candidates:
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
printable = sum(1 for char in text[:20000] if char.isprintable() or char in "\r\n\t")
|
||||
nul_count = text[:20000].count("\x00")
|
||||
score = printable - nul_count * 10
|
||||
if score > best[2]:
|
||||
best = (text, encoding, score)
|
||||
return best[0], best[1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
kind: str
|
||||
value: str
|
||||
pos: int
|
||||
|
||||
|
||||
class Lexer:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
|
||||
def tokens(self) -> list[Token]:
|
||||
result: list[Token] = []
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
if char.isspace():
|
||||
self.pos += 1
|
||||
continue
|
||||
if char in "{},:":
|
||||
result.append(Token(char, char, self.pos))
|
||||
self.pos += 1
|
||||
continue
|
||||
if char == '"':
|
||||
result.append(self._string())
|
||||
continue
|
||||
result.append(self._atom())
|
||||
result.append(Token("EOF", "", self.pos))
|
||||
return result
|
||||
|
||||
def _string(self) -> Token:
|
||||
start = self.pos
|
||||
self.pos += 1
|
||||
chars: list[str] = []
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
self.pos += 1
|
||||
if char == '"':
|
||||
if self.pos < len(self.text) and self.text[self.pos] == '"':
|
||||
chars.append('"')
|
||||
self.pos += 1
|
||||
continue
|
||||
break
|
||||
chars.append(char)
|
||||
return Token("string", "".join(chars), start)
|
||||
|
||||
def _atom(self) -> Token:
|
||||
start = self.pos
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
if char.isspace() or char in "{},:":
|
||||
break
|
||||
self.pos += 1
|
||||
return Token("atom", self.text[start : self.pos], start)
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, tokens: list[Token]) -> None:
|
||||
self.tokens = tokens
|
||||
self.index = 0
|
||||
|
||||
def parse(self) -> Any:
|
||||
values = []
|
||||
while not self._peek("EOF"):
|
||||
if self._peek(","):
|
||||
self.index += 1
|
||||
continue
|
||||
values.append(self._value())
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
return {"type": "sequence", "items": values}
|
||||
|
||||
def _value(self) -> Any:
|
||||
if self._peek("{"):
|
||||
return self._list()
|
||||
token = self._next()
|
||||
if token.kind == "string":
|
||||
return {"type": "string", "value": token.value}
|
||||
if token.kind == "atom":
|
||||
return {"type": "atom", "value": token.value}
|
||||
return {"type": "token", "kind": token.kind, "value": token.value}
|
||||
|
||||
def _list(self) -> Any:
|
||||
start = self._next()
|
||||
items = []
|
||||
while not self._peek("EOF") and not self._peek("}"):
|
||||
if self._peek(","):
|
||||
self.index += 1
|
||||
continue
|
||||
items.append(self._value())
|
||||
if self._peek("}"):
|
||||
self.index += 1
|
||||
return {"type": "list", "pos": start.pos, "items": items}
|
||||
|
||||
def _peek(self, kind: str) -> bool:
|
||||
return self.tokens[self.index].kind == kind
|
||||
|
||||
def _next(self) -> Token:
|
||||
token = self.tokens[self.index]
|
||||
self.index += 1
|
||||
return token
|
||||
|
||||
|
||||
def tree_shape(value: Any, depth: int = 0, max_depth: int = 4) -> Any:
|
||||
if depth >= max_depth:
|
||||
return "..."
|
||||
if isinstance(value, dict) and value.get("type") == "list":
|
||||
items = value.get("items") or []
|
||||
return {
|
||||
"type": "list",
|
||||
"len": len(items),
|
||||
"items": [tree_shape(item, depth + 1, max_depth) for item in items[:12]],
|
||||
}
|
||||
if isinstance(value, dict) and value.get("type") in {"string", "atom"}:
|
||||
raw = str(value.get("value") or "")
|
||||
return {"type": value["type"], "value": raw[:120], "len": len(raw)}
|
||||
if isinstance(value, dict) and value.get("type") == "sequence":
|
||||
items = value.get("items") or []
|
||||
return {
|
||||
"type": "sequence",
|
||||
"len": len(items),
|
||||
"items": [tree_shape(item, depth + 1, max_depth) for item in items[:12]],
|
||||
}
|
||||
return str(value)[:120]
|
||||
|
||||
|
||||
def collect_strings(value: Any, limit: int = 200) -> list[str]:
|
||||
result: list[str] = []
|
||||
|
||||
def walk(node: Any) -> None:
|
||||
if len(result) >= limit:
|
||||
return
|
||||
if isinstance(node, dict) and node.get("type") == "string":
|
||||
text = str(node.get("value") or "")
|
||||
if text:
|
||||
result.append(text)
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for child in node.get("items") or []:
|
||||
walk(child)
|
||||
|
||||
walk(value)
|
||||
return result
|
||||
|
||||
|
||||
def inspect_file(path: Path, *, parse_limit_chars: int) -> dict[str, Any]:
|
||||
original = path.read_bytes()
|
||||
payload, compression = try_decompress(original)
|
||||
text, encoding = try_decode(payload)
|
||||
result: dict[str, Any] = {
|
||||
"file_name": path.name,
|
||||
"bytes": len(original),
|
||||
"payload_bytes": len(payload),
|
||||
"compression": compression,
|
||||
"encoding": encoding,
|
||||
"guids": [],
|
||||
"strings": [],
|
||||
"parse_status": "not_text",
|
||||
}
|
||||
if text is None:
|
||||
return result
|
||||
clean = text.replace("\x00", "").replace("\ufeff", "")
|
||||
result["text_preview"] = clean[:500]
|
||||
result["guids"] = sorted(set(match.lower() for match in GUID_RE.findall(clean)))[:200]
|
||||
if "{" not in clean:
|
||||
result["parse_status"] = "text_no_braces"
|
||||
return result
|
||||
parse_text = clean[:parse_limit_chars]
|
||||
try:
|
||||
parsed = Parser(Lexer(parse_text).tokens()).parse()
|
||||
except Exception as exc:
|
||||
result["parse_status"] = "parse_error"
|
||||
result["parse_error"] = str(exc)
|
||||
return result
|
||||
result["parse_status"] = "parsed_prefix" if len(clean) > parse_limit_chars else "parsed"
|
||||
result["shape"] = tree_shape(parsed)
|
||||
result["strings"] = collect_strings(parsed)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Inspect exported 1C SQL files mechanically.")
|
||||
parser.add_argument("input", type=Path, help="Directory with exported SQL files")
|
||||
parser.add_argument("--output", type=Path, required=True, help="JSON report path")
|
||||
parser.add_argument("--parse-limit-chars", type=int, default=2_000_000)
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
files = sorted(path for path in args.input.iterdir() if path.is_file())
|
||||
if args.limit > 0:
|
||||
files = files[: args.limit]
|
||||
|
||||
report = {
|
||||
"schema": "onec_sql_file_inspection.v1",
|
||||
"input": str(args.input),
|
||||
"file_count": len(files),
|
||||
"files": [inspect_file(path, parse_limit_chars=args.parse_limit_chars) for path in files],
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"output": str(args.output), "files": len(files)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user