266 lines
11 KiB
Python
266 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare two 1C saved-state object reports in 1C object terms."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def object_map(report: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
items = ((report.get("agent_summary") or {}).get("object_changes") or [])
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
full_name = str(item.get("full_name") or "")
|
|
if full_name:
|
|
result[full_name] = item
|
|
return result
|
|
|
|
|
|
def system_map(report: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
items = ((report.get("agent_summary") or {}).get("system_changes") or [])
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
key = f"{item.get('layer')}::{item.get('extension')}::{item.get('name')}"
|
|
result[key] = item
|
|
return result
|
|
|
|
|
|
def stable_fingerprint(value: Any) -> str:
|
|
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def comparable_object(item: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"full_name": item.get("full_name"),
|
|
"layer": item.get("layer"),
|
|
"extension": item.get("extension"),
|
|
"kind": item.get("kind"),
|
|
"kind_ru": item.get("kind_ru"),
|
|
"name": item.get("name"),
|
|
"synonym": item.get("synonym"),
|
|
"parts_count": item.get("parts_count"),
|
|
"text_diff_parts": item.get("text_diff_parts"),
|
|
"active_missing_parts": item.get("active_missing_parts"),
|
|
"added_terms": item.get("added_terms") or [],
|
|
"removed_terms": item.get("removed_terms") or [],
|
|
"parts": [
|
|
{
|
|
"file_name": part.get("file_name"),
|
|
"payload_role": part.get("payload_role"),
|
|
"active_exists": part.get("active_exists"),
|
|
"summary": part.get("summary"),
|
|
"delta_chars": part.get("delta_chars"),
|
|
}
|
|
for part in item.get("parts") or []
|
|
if isinstance(part, dict)
|
|
],
|
|
}
|
|
|
|
|
|
def terms_delta(before: list[Any], after: list[Any]) -> dict[str, list[Any]]:
|
|
before_set = {str(value) for value in before}
|
|
after_set = {str(value) for value in after}
|
|
return {
|
|
"added": [value for value in after if str(value) not in before_set],
|
|
"removed": [value for value in before if str(value) not in after_set],
|
|
}
|
|
|
|
|
|
def parts_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, Any]:
|
|
before_by_name = {str(part.get("file_name")): part for part in before if part.get("file_name")}
|
|
after_by_name = {str(part.get("file_name")): part for part in after if part.get("file_name")}
|
|
before_names = set(before_by_name)
|
|
after_names = set(after_by_name)
|
|
changed = []
|
|
for name in sorted(before_names & after_names):
|
|
before_part = before_by_name[name]
|
|
after_part = after_by_name[name]
|
|
if stable_fingerprint(before_part) != stable_fingerprint(after_part):
|
|
changed.append({
|
|
"file_name": name,
|
|
"before": before_part,
|
|
"after": after_part,
|
|
})
|
|
return {
|
|
"added": [after_by_name[name] for name in sorted(after_names - before_names)],
|
|
"removed": [before_by_name[name] for name in sorted(before_names - after_names)],
|
|
"changed": changed,
|
|
}
|
|
|
|
|
|
def object_delta(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
before_cmp = comparable_object(before)
|
|
after_cmp = comparable_object(after)
|
|
return {
|
|
"full_name": after.get("full_name") or before.get("full_name"),
|
|
"before_fingerprint": stable_fingerprint(before_cmp),
|
|
"after_fingerprint": stable_fingerprint(after_cmp),
|
|
"before": before_cmp,
|
|
"after": after_cmp,
|
|
"term_delta": {
|
|
"added_terms": terms_delta(before_cmp.get("added_terms") or [], after_cmp.get("added_terms") or []),
|
|
"removed_terms": terms_delta(before_cmp.get("removed_terms") or [], after_cmp.get("removed_terms") or []),
|
|
},
|
|
"part_delta": parts_delta(before_cmp.get("parts") or [], after_cmp.get("parts") or []),
|
|
}
|
|
|
|
|
|
def compact_object(item: dict[str, Any]) -> dict[str, Any]:
|
|
comparable = comparable_object(item)
|
|
comparable["fingerprint"] = stable_fingerprint(comparable)
|
|
return comparable
|
|
|
|
|
|
def compare_reports(before_path: Path, after_path: Path) -> dict[str, Any]:
|
|
before_path = before_path.resolve()
|
|
after_path = after_path.resolve()
|
|
before = load_json(before_path)
|
|
after = load_json(after_path)
|
|
before_objects = object_map(before)
|
|
after_objects = object_map(after)
|
|
before_names = set(before_objects)
|
|
after_names = set(after_objects)
|
|
|
|
changed = []
|
|
unchanged = []
|
|
for name in sorted(before_names & after_names):
|
|
before_fp = stable_fingerprint(comparable_object(before_objects[name]))
|
|
after_fp = stable_fingerprint(comparable_object(after_objects[name]))
|
|
if before_fp == after_fp:
|
|
unchanged.append(name)
|
|
else:
|
|
changed.append(object_delta(before_objects[name], after_objects[name]))
|
|
|
|
before_system = system_map(before)
|
|
after_system = system_map(after)
|
|
system_added = sorted(set(after_system) - set(before_system))
|
|
system_removed = sorted(set(before_system) - set(after_system))
|
|
system_changed = [
|
|
key
|
|
for key in sorted(set(before_system) & set(after_system))
|
|
if stable_fingerprint(before_system[key]) != stable_fingerprint(after_system[key])
|
|
]
|
|
|
|
return {
|
|
"schema": "onec_saved_state_object_report_delta.v1",
|
|
"before_report": str(before_path),
|
|
"after_report": str(after_path),
|
|
"database": after.get("database") or before.get("database"),
|
|
"objects": {
|
|
"added": [compact_object(after_objects[name]) for name in sorted(after_names - before_names)],
|
|
"removed": [compact_object(before_objects[name]) for name in sorted(before_names - after_names)],
|
|
"changed": changed,
|
|
"unchanged": unchanged,
|
|
},
|
|
"system_changes": {
|
|
"added": [after_system[key] for key in system_added],
|
|
"removed": [before_system[key] for key in system_removed],
|
|
"changed": [{"key": key, "before": before_system[key], "after": after_system[key]} for key in system_changed],
|
|
},
|
|
"counts": {
|
|
"objects_added": len(after_names - before_names),
|
|
"objects_removed": len(before_names - after_names),
|
|
"objects_changed": len(changed),
|
|
"objects_unchanged": len(unchanged),
|
|
"system_added": len(system_added),
|
|
"system_removed": len(system_removed),
|
|
"system_changed": len(system_changed),
|
|
},
|
|
"safety": {
|
|
"read_only": True,
|
|
"sql_write_performed": False,
|
|
"public_terms_are_1c_objects": True,
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(delta: dict[str, Any]) -> str:
|
|
module_path = REPO_ROOT / "scripts" / "render_1c_saved_state_object_report_delta_markdown.py"
|
|
spec = importlib.util.spec_from_file_location("saved_state_delta_markdown", module_path)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"Cannot load Markdown renderer: {module_path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module.render(delta)
|
|
|
|
|
|
def check_delta(delta_path: Path, check_output: Path) -> None:
|
|
module_path = REPO_ROOT / "scripts" / "check_1c_saved_state_object_report_delta.py"
|
|
spec = importlib.util.spec_from_file_location("saved_state_delta_check", module_path)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"Cannot load delta checker: {module_path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
result = module.check_delta(delta_path)
|
|
module.write_json(check_output, result)
|
|
if not result.get("passed"):
|
|
raise RuntimeError(f"Saved-state delta check failed: {check_output}")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Compare two 1C saved-state object reports.")
|
|
parser.add_argument("--before", type=Path, required=True)
|
|
parser.add_argument("--after", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path)
|
|
parser.add_argument("--markdown-output", type=Path)
|
|
parser.add_argument("--skip-markdown", action="store_true")
|
|
parser.add_argument("--check-output", type=Path)
|
|
parser.add_argument("--skip-check", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
result = compare_reports(args.before, args.after)
|
|
markdown_output = args.markdown_output
|
|
if markdown_output is None and args.output and not args.skip_markdown:
|
|
markdown_output = args.output.with_suffix(".md")
|
|
if markdown_output and not args.skip_markdown:
|
|
markdown_output = markdown_output.resolve()
|
|
result["markdown"] = str(markdown_output)
|
|
check_output = args.check_output
|
|
if check_output is None and args.output and not args.skip_check:
|
|
check_output = args.output.with_name(f"{args.output.stem}-check.json")
|
|
if check_output and not args.skip_check:
|
|
check_output = check_output.resolve()
|
|
result["check"] = str(check_output)
|
|
if args.output:
|
|
write_json(args.output, result)
|
|
if markdown_output and not args.skip_markdown:
|
|
markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
|
markdown_output.write_text(render_markdown(result), encoding="utf-8")
|
|
if args.output:
|
|
write_json(args.output, result)
|
|
if check_output and not args.skip_check:
|
|
if not args.output:
|
|
raise SystemExit("Use --output when delta check output is enabled.")
|
|
check_delta(args.output, check_output)
|
|
print(json.dumps({"output": str(args.output) if args.output else None, "schema": result["schema"], "counts": result["counts"]}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|