Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path(r"Z:\codex\1C\XML\UPO\Структура базы 1с")
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def direct_child(element: ET.Element, name: str) -> ET.Element | None:
|
||||
return next((child for child in element if local_name(child.tag) == name), None)
|
||||
|
||||
|
||||
def property_text(properties: ET.Element | None, name: str) -> str:
|
||||
if properties is None:
|
||||
return ""
|
||||
node = next((child for child in properties if local_name(child.tag) == name), None)
|
||||
return str(node.text or "").strip() if node is not None else ""
|
||||
|
||||
|
||||
def parse_metadata_file(path: Path, layer: str) -> dict[str, Any]:
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
metadata = next(iter(root), None) if local_name(root.tag) == "MetaDataObject" else root
|
||||
if metadata is None:
|
||||
raise ValueError("metadata object element is missing")
|
||||
kind = local_name(metadata.tag)
|
||||
properties = direct_child(metadata, "Properties")
|
||||
children = direct_child(metadata, "ChildObjects")
|
||||
child_schemas: dict[str, dict[str, Any]] = {}
|
||||
if children is not None:
|
||||
grouped: dict[str, list[ET.Element]] = defaultdict(list)
|
||||
for child in children:
|
||||
grouped[local_name(child.tag)].append(child)
|
||||
for child_kind, values in grouped.items():
|
||||
child_properties: set[str] = set()
|
||||
for value in values:
|
||||
value_properties = direct_child(value, "Properties")
|
||||
if value_properties is not None:
|
||||
child_properties.update(local_name(item.tag) for item in value_properties)
|
||||
child_schemas[child_kind] = {"count": len(values), "properties": sorted(child_properties)}
|
||||
return {
|
||||
"status": "ok",
|
||||
"layer": layer,
|
||||
"path": str(path),
|
||||
"kind": kind,
|
||||
"name": property_text(properties, "Name"),
|
||||
"uuid": str(metadata.attrib.get("uuid") or "").lower(),
|
||||
"properties": sorted(local_name(child.tag) for child in properties) if properties is not None else [],
|
||||
"child_schemas": child_schemas,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "layer": layer, "path": str(path), "message": str(exc)}
|
||||
|
||||
|
||||
def layer_files(root: Path) -> list[Path]:
|
||||
files = [root / "Configuration.xml"] if (root / "Configuration.xml").is_file() else []
|
||||
for folder in root.iterdir():
|
||||
if folder.is_dir() and folder.name != "Ext":
|
||||
files.extend(sorted(folder.glob("*.xml")))
|
||||
return files
|
||||
|
||||
|
||||
def artifact_files(root: Path, max_depth: int = 5) -> list[Path]:
|
||||
result: set[Path] = set()
|
||||
for folder in (path for path in root.iterdir() if path.is_dir()):
|
||||
for depth in range(1, max_depth + 1):
|
||||
pattern = "/".join(["*"] * depth + ["Ext", "*.xml"])
|
||||
result.update(path for path in folder.glob(pattern) if path.is_file())
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def parse_artifact(path: Path) -> dict[str, Any]:
|
||||
tags: set[str] = set()
|
||||
attributes: dict[str, set[str]] = defaultdict(set)
|
||||
root_tag = ""
|
||||
try:
|
||||
for _event, element in ET.iterparse(path, events=("start",)):
|
||||
tag = local_name(element.tag)
|
||||
if not root_tag:
|
||||
root_tag = tag
|
||||
tags.add(tag)
|
||||
attributes[tag].update(local_name(name) for name in element.attrib)
|
||||
return {
|
||||
"status": "ok",
|
||||
"path": str(path),
|
||||
"artifact": path.name,
|
||||
"root_tag": root_tag,
|
||||
"tags": sorted(tags),
|
||||
"attributes": {key: sorted(value) for key, value in sorted(attributes.items()) if value},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "path": str(path), "artifact": path.name, "message": str(exc)}
|
||||
|
||||
|
||||
def scan_artifacts(root: Path, workers: int) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
files = artifact_files(root)
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
|
||||
parsed = list(executor.map(parse_artifact, files))
|
||||
errors = [item for item in parsed if item["status"] != "ok"]
|
||||
grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in parsed:
|
||||
if item["status"] == "ok":
|
||||
grouped[(str(item["artifact"]), str(item["root_tag"]))].append(item)
|
||||
schemas: dict[str, Any] = {}
|
||||
for (artifact, root_tag), values in sorted(grouped.items()):
|
||||
tags: set[str] = set()
|
||||
attributes: dict[str, set[str]] = defaultdict(set)
|
||||
for value in values:
|
||||
tags.update(value["tags"])
|
||||
for tag, names in value["attributes"].items():
|
||||
attributes[tag].update(names)
|
||||
key = f"{artifact}:{root_tag}"
|
||||
schemas[key] = {
|
||||
"files": len(values),
|
||||
"root_tag": root_tag,
|
||||
"tags": sorted(tags),
|
||||
"attributes": {tag: sorted(names) for tag, names in sorted(attributes.items())},
|
||||
"samples": [value["path"] for value in values[:3]],
|
||||
}
|
||||
return {"files": len(files), "schemas": schemas}, errors
|
||||
|
||||
|
||||
def scan_layer(root: Path, layer: str, workers: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
files = layer_files(root)
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
|
||||
parsed = list(executor.map(lambda path: parse_metadata_file(path, layer), files))
|
||||
return [item for item in parsed if item["status"] == "ok"], [item for item in parsed if item["status"] != "ok"]
|
||||
|
||||
|
||||
def merge_kind_schemas(objects: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
kinds: dict[str, dict[str, Any]] = {}
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in objects:
|
||||
grouped[str(item["kind"])].append(item)
|
||||
for kind, values in sorted(grouped.items()):
|
||||
properties: set[str] = set()
|
||||
child_counts: Counter[str] = Counter()
|
||||
child_properties: dict[str, set[str]] = defaultdict(set)
|
||||
for value in values:
|
||||
properties.update(value.get("properties") or [])
|
||||
for child_kind, schema in (value.get("child_schemas") or {}).items():
|
||||
child_counts[child_kind] += int(schema.get("count") or 0)
|
||||
child_properties[child_kind].update(schema.get("properties") or [])
|
||||
kinds[kind] = {
|
||||
"objects": len(values),
|
||||
"properties": sorted(properties),
|
||||
"children": {
|
||||
child_kind: {"objects": child_counts[child_kind], "properties": sorted(child_properties[child_kind])}
|
||||
for child_kind in sorted(child_counts)
|
||||
},
|
||||
"samples": [
|
||||
{"ref": f"{kind}.{value['name']}" if value.get("name") else kind, "uuid": value.get("uuid")}
|
||||
for value in values[:3]
|
||||
],
|
||||
}
|
||||
return kinds
|
||||
|
||||
|
||||
def object_ref(item: dict[str, Any]) -> str:
|
||||
return f"{item.get('kind')}.{item.get('name')}" if item.get("name") else f"{item.get('kind')}#{item.get('uuid')}"
|
||||
|
||||
|
||||
def extension_summary(name: str, objects: list[dict[str, Any]], base_refs: set[str]) -> dict[str, Any]:
|
||||
refs = {object_ref(item) for item in objects}
|
||||
return {
|
||||
"name": name,
|
||||
"objects": len(objects),
|
||||
"kinds": merge_kind_schemas(objects),
|
||||
"overrides": sorted(refs & base_refs),
|
||||
"extension_only": sorted(refs - base_refs),
|
||||
"counts": {"overrides": len(refs & base_refs), "extension_only": len(refs - base_refs)},
|
||||
}
|
||||
|
||||
|
||||
def build_report(root: Path, workers: int, include_artifacts: bool) -> dict[str, Any]:
|
||||
configuration_root = root / "Конфигурация"
|
||||
extensions_root = root / "Расширения"
|
||||
base_objects, errors = scan_layer(configuration_root, "configuration", workers)
|
||||
base_refs = {object_ref(item) for item in base_objects}
|
||||
extensions: list[dict[str, Any]] = []
|
||||
configuration_artifacts: dict[str, Any] = {"status": "not_requested", "files": 0, "schemas": {}}
|
||||
if include_artifacts:
|
||||
configuration_artifacts, artifact_errors = scan_artifacts(configuration_root, workers)
|
||||
configuration_artifacts["status"] = "ok" if not artifact_errors else "partial"
|
||||
errors.extend(artifact_errors)
|
||||
if extensions_root.is_dir():
|
||||
for extension_root in sorted(path for path in extensions_root.iterdir() if path.is_dir()):
|
||||
objects, extension_errors = scan_layer(extension_root, f"extension:{extension_root.name}", workers)
|
||||
errors.extend(extension_errors)
|
||||
summary = extension_summary(extension_root.name, objects, base_refs)
|
||||
if include_artifacts:
|
||||
artifacts, artifact_errors = scan_artifacts(extension_root, workers)
|
||||
artifacts["status"] = "ok" if not artifact_errors else "partial"
|
||||
summary["artifacts"] = artifacts
|
||||
errors.extend(artifact_errors)
|
||||
extensions.append(summary)
|
||||
kinds = merge_kind_schemas(base_objects)
|
||||
return {
|
||||
"schema": "onec_xml_metadata_analysis.v1",
|
||||
"status": "ok" if not errors else "partial",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_root": str(root),
|
||||
"assumption": "The exported base configuration is equivalent to the SQL base; extensions are independent overlays and may differ.",
|
||||
"configuration": {
|
||||
"objects": len(base_objects),
|
||||
"kinds": kinds,
|
||||
"counts": {"kinds": len(kinds), "objects": len(base_objects)},
|
||||
"artifacts": configuration_artifacts,
|
||||
},
|
||||
"extensions": extensions,
|
||||
"counts": {
|
||||
"configuration_kinds": len(kinds),
|
||||
"configuration_objects": len(base_objects),
|
||||
"extensions": len(extensions),
|
||||
"extension_objects": sum(item["objects"] for item in extensions),
|
||||
"artifact_files": int(configuration_artifacts.get("files") or 0) + sum(int((item.get("artifacts") or {}).get("files") or 0) for item in extensions),
|
||||
"parse_errors": len(errors),
|
||||
},
|
||||
"errors": errors[:100],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract complete declared metadata property schemas from a 1C XML configuration export.")
|
||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
parser.add_argument("--include-artifacts", action="store_true", help="Also scan nested Ext XML files such as forms, rights, and templates.")
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--json", action="store_true", help="Print the full JSON report instead of a compact summary.")
|
||||
args = parser.parse_args()
|
||||
report = build_report(args.root, args.workers, args.include_artifacts)
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered + "\n", encoding="utf-8")
|
||||
if args.json:
|
||||
print(rendered)
|
||||
else:
|
||||
print(json.dumps({"status": report["status"], **report["counts"]}, ensure_ascii=False))
|
||||
return 0 if report["status"] == "ok" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user