拆分报告渲染函数的第一阶段,把 34 个无外层依赖的辅助函数搬到 report_render。
同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
32b238cf86
commit
dbba127e71
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-compare phase-1 report rendering against a baseline worktree.
|
||||
|
||||
The current tree builds real-engine packets and pickles them outside the repo.
|
||||
A baseline checkout renders those same packets. Baseline-vs-baseline runs first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRODUCTION_BODY = {
|
||||
"format": "markdown",
|
||||
"edition": "full_data",
|
||||
"include_fact_tables": True,
|
||||
"languages": ["zh", "en"],
|
||||
}
|
||||
FOURTH = {
|
||||
"name": "虚构丁",
|
||||
"year": 1990,
|
||||
"month": 1,
|
||||
"day": 1,
|
||||
"hour": 12,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": 28.6139,
|
||||
"lon": 77.2090,
|
||||
"tz": 5.5,
|
||||
"node_mode": "mean",
|
||||
"ayanamsa": "lahiri",
|
||||
"today": "2026-10-13",
|
||||
"transit_date": "2026-10-13",
|
||||
"target_year": 2026,
|
||||
"age": 36,
|
||||
"birth_time_accuracy": "confirmed",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_hashseed() -> None:
|
||||
if os.environ.get("PYTHONHASHSEED") == "0":
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env["PYTHONHASHSEED"] = "0"
|
||||
raise SystemExit(subprocess.call([sys.executable, *sys.argv], env=env))
|
||||
|
||||
|
||||
def _activate(root: Path) -> None:
|
||||
root = root.resolve()
|
||||
script_dir = str(Path(__file__).resolve().parent)
|
||||
while script_dir in sys.path:
|
||||
sys.path.remove(script_dir)
|
||||
folders = [str(root / "jyotish_vedic"), str(root), str(root / "scripts")]
|
||||
for folder in folders:
|
||||
if not Path(folder).exists():
|
||||
continue
|
||||
while folder in sys.path:
|
||||
sys.path.remove(folder)
|
||||
sys.path.insert(0, folder)
|
||||
|
||||
|
||||
def _mask_pyjhora() -> None:
|
||||
import importlib
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="nojhora-"))
|
||||
package = root / "jhora"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8")
|
||||
sys.path.insert(0, str(root))
|
||||
prior = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "")
|
||||
for name in list(sys.modules):
|
||||
if name == "jhora" or name.startswith("jhora."):
|
||||
del sys.modules[name]
|
||||
importlib.invalidate_caches()
|
||||
|
||||
|
||||
def _sha(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _freeze_clocks() -> None:
|
||||
import datetime as dt_mod
|
||||
|
||||
real = dt_mod.datetime
|
||||
fixed_utc = real(2026, 10, 13, 0, 0, 0)
|
||||
fixed_local = real(2026, 10, 13, 8, 0, 0)
|
||||
|
||||
class Frozen(real):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
if tz is not None:
|
||||
return fixed_utc.replace(tzinfo=tz)
|
||||
return fixed_local
|
||||
|
||||
@classmethod
|
||||
def utcnow(cls):
|
||||
return fixed_utc
|
||||
|
||||
for module in list(sys.modules.values()):
|
||||
if module is None:
|
||||
continue
|
||||
current = getattr(module, "datetime", None)
|
||||
if isinstance(current, type) and current.__name__ == "datetime":
|
||||
module.datetime = Frozen
|
||||
|
||||
|
||||
def _cases() -> list[tuple[str, dict]]:
|
||||
from tests.test_report_english_edition import CASES
|
||||
|
||||
ordered = [(name, dict(CASES[name])) for name in ("day", "night", "south")]
|
||||
ordered.append(("fourth", dict(FOURTH)))
|
||||
return ordered
|
||||
|
||||
|
||||
def _subset_pack_id(packet: dict) -> str | None:
|
||||
manifest = packet.get("report_pack_manifest") if isinstance(packet, dict) else None
|
||||
packs = manifest.get("packs") if isinstance(manifest, dict) else None
|
||||
if not isinstance(packs, list):
|
||||
return None
|
||||
for item in packs:
|
||||
if isinstance(item, dict) and item.get("id") and item.get("id") != "full":
|
||||
return str(item["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _build_records() -> list[dict]:
|
||||
from scripts.jyotish_engine import (
|
||||
_apply_pl9_pack_selection,
|
||||
build_professional_report_reference_packet,
|
||||
cmd_full_reading,
|
||||
)
|
||||
from scripts.professional_report_reference import _export_args
|
||||
|
||||
records = []
|
||||
for name, case in _cases():
|
||||
print(f"build {name}", file=sys.stderr, flush=True)
|
||||
reading = cmd_full_reading(SimpleNamespace(**case))
|
||||
packet = build_professional_report_reference_packet(reading, _export_args(case), [])
|
||||
records.append({"id": f"{name}/reference", "kind": "pl9", "payload": packet})
|
||||
stripped = copy.deepcopy(packet)
|
||||
if isinstance(stripped, dict):
|
||||
stripped.pop("personal_report_producer", None)
|
||||
records.append({"id": f"{name}/no-producer", "kind": "pl9", "payload": stripped})
|
||||
pack_id = _subset_pack_id(packet if isinstance(packet, dict) else {})
|
||||
if pack_id:
|
||||
selected = _apply_pl9_pack_selection(copy.deepcopy(packet), [pack_id])
|
||||
records.append({"id": f"{name}/pack/{pack_id}", "kind": "pl9", "payload": selected})
|
||||
else:
|
||||
print(f"skip pack {name}: no non-full pack", file=sys.stderr)
|
||||
records.append({"id": f"{name}/full-data-zh", "kind": "full", "payload": packet})
|
||||
english = copy.deepcopy(packet)
|
||||
if isinstance(english, dict):
|
||||
english["report_language"] = "en"
|
||||
records.append({"id": f"{name}/full-data-en", "kind": "full", "payload": english})
|
||||
records.append({
|
||||
"id": f"{name}/api-production",
|
||||
"kind": "api",
|
||||
"payload": {"birth": case, "reading": reading},
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def _render_text(kind: str, payload) -> str:
|
||||
if kind == "pl9":
|
||||
from scripts.jyotish_engine import render_pl9_markdown
|
||||
|
||||
return render_pl9_markdown(copy.deepcopy(payload))
|
||||
if kind == "full":
|
||||
from scripts.pl9_full_data_export import render_full_data_markdown
|
||||
|
||||
return render_full_data_markdown(copy.deepcopy(payload))
|
||||
if kind == "api":
|
||||
from scripts.professional_report_reference import build_professional_report_reference
|
||||
|
||||
birth = payload["birth"]
|
||||
reading = copy.deepcopy(payload["reading"])
|
||||
|
||||
class Handler:
|
||||
def _high_rigor_birth_payload(self, _body):
|
||||
return dict(birth)
|
||||
|
||||
def _compute_full_reading_for_thematic(self, _birth):
|
||||
return reading
|
||||
|
||||
response = build_professional_report_reference(Handler(), dict(PRODUCTION_BODY))
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
raise RuntimeError(f"unknown kind {kind}")
|
||||
|
||||
|
||||
def _render_records(records: list[dict]) -> list[dict]:
|
||||
import scripts.jyotish_engine # noqa: F401
|
||||
import scripts.pl9_full_data_export # noqa: F401
|
||||
import scripts.professional_report_reference # noqa: F401
|
||||
|
||||
_freeze_clocks()
|
||||
rendered = []
|
||||
for record in records:
|
||||
text = _render_text(record["kind"], record["payload"])
|
||||
if not isinstance(text, str):
|
||||
raise RuntimeError(f"{record['id']} did not render to text")
|
||||
rendered.append({"id": record["id"], "sha": _sha(text), "text": text})
|
||||
return rendered
|
||||
|
||||
|
||||
def _diff_context(left: str, right: str) -> str:
|
||||
left_lines = left.splitlines() or [left]
|
||||
right_lines = right.splitlines() or [right]
|
||||
limit = min(len(left_lines), len(right_lines))
|
||||
index = next((i for i in range(limit) if left_lines[i] != right_lines[i]), limit)
|
||||
start = max(0, index - 2)
|
||||
rows = []
|
||||
for line_no in range(start, start + 5):
|
||||
sample = left_lines[line_no] if line_no < len(left_lines) else ""
|
||||
other = right_lines[line_no] if line_no < len(right_lines) else ""
|
||||
if sample == other and line_no != index:
|
||||
rows.append(f" {line_no + 1}: {sample}")
|
||||
continue
|
||||
cut = _window(sample, other)
|
||||
rows.append(f"- {line_no + 1}: {cut[0]}")
|
||||
rows.append(f"+ {line_no + 1}: {cut[1]}")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _window(left: str, right: str) -> tuple[str, str]:
|
||||
if len(left) <= 240 and len(right) <= 240:
|
||||
return left, right
|
||||
limit = min(len(left), len(right))
|
||||
index = next((i for i in range(limit) if left[i] != right[i]), limit)
|
||||
start = max(0, index - 80)
|
||||
stop = index + 80
|
||||
return left[start:stop], right[start:stop]
|
||||
|
||||
|
||||
def _invoke_baseline(baseline: Path, pickle_path: Path, result_path: Path, mask: bool) -> subprocess.CompletedProcess[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
"--render-only",
|
||||
"--pickle",
|
||||
str(pickle_path),
|
||||
"--engine-root",
|
||||
str(baseline),
|
||||
"--result",
|
||||
str(result_path),
|
||||
]
|
||||
if mask:
|
||||
command.append("--mask-pyjhora")
|
||||
env = dict(os.environ)
|
||||
env["PYTHONHASHSEED"] = "0"
|
||||
env["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
env["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=str(baseline),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _load_result(path: Path) -> list[dict]:
|
||||
return pickle.loads(path.read_bytes())
|
||||
|
||||
|
||||
def _print_shas(rows: list[dict]) -> None:
|
||||
for row in rows:
|
||||
print(f"{row['id']}\t{row['sha']}")
|
||||
|
||||
|
||||
def _compare(label: str, left: list[dict], right: list[dict]) -> int:
|
||||
if [row["id"] for row in left] != [row["id"] for row in right]:
|
||||
print(f"{label}: record ids differ", file=sys.stderr)
|
||||
return 1
|
||||
failed = 0
|
||||
for lhs, rhs in zip(left, right):
|
||||
if lhs["sha"] == rhs["sha"]:
|
||||
continue
|
||||
failed += 1
|
||||
print(f"{label} mismatch {lhs['id']} {lhs['sha']} vs {rhs['sha']}", file=sys.stderr)
|
||||
print(_diff_context(lhs["text"], rhs["text"]), file=sys.stderr)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def _render_only(args: argparse.Namespace) -> int:
|
||||
if args.mask_pyjhora:
|
||||
_mask_pyjhora()
|
||||
_activate(Path(args.engine_root))
|
||||
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
records = pickle.loads(Path(args.pickle).read_bytes())
|
||||
rendered = _render_records(records)
|
||||
Path(args.result).write_bytes(pickle.dumps(rendered, protocol=pickle.HIGHEST_PROTOCOL))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_ensure_hashseed()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--baseline", type=Path)
|
||||
parser.add_argument("--mask-pyjhora", action="store_true")
|
||||
parser.add_argument("--render-only", action="store_true")
|
||||
parser.add_argument("--pickle", type=Path)
|
||||
parser.add_argument("--engine-root", type=Path)
|
||||
parser.add_argument("--result", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.render_only:
|
||||
return _render_only(args)
|
||||
if args.baseline is None:
|
||||
print("missing --baseline", file=sys.stderr)
|
||||
return 2
|
||||
if args.mask_pyjhora:
|
||||
_mask_pyjhora()
|
||||
_activate(ROOT)
|
||||
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
|
||||
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
|
||||
records = _build_records()
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pl9-render-split-"))
|
||||
try:
|
||||
pickle_path = temp_dir / "packets.pkl"
|
||||
pickle_path.write_bytes(pickle.dumps(records, protocol=pickle.HIGHEST_PROTOCOL))
|
||||
first_path = temp_dir / "baseline-a.pkl"
|
||||
second_path = temp_dir / "baseline-b.pkl"
|
||||
first = _invoke_baseline(args.baseline, pickle_path, first_path, args.mask_pyjhora)
|
||||
second = _invoke_baseline(args.baseline, pickle_path, second_path, args.mask_pyjhora)
|
||||
if first.returncode != 0 or second.returncode != 0:
|
||||
print(first.stderr[-4000:], file=sys.stderr)
|
||||
print(second.stderr[-4000:], file=sys.stderr)
|
||||
print(first.stdout[-1000:], file=sys.stderr)
|
||||
return first.returncode or second.returncode or 1
|
||||
baseline_a = _load_result(first_path)
|
||||
baseline_b = _load_result(second_path)
|
||||
if _compare("baseline-vs-baseline", baseline_a, baseline_b) != 0:
|
||||
print("基线对基线两次 sha 不同,存在非确定性,停止", file=sys.stderr)
|
||||
return 2
|
||||
current = _render_records(pickle.loads(pickle_path.read_bytes()))
|
||||
if _compare("current-vs-baseline", current, baseline_a) != 0:
|
||||
return 1
|
||||
_print_shas(current)
|
||||
print(f"ok items={len(current)} masked={int(args.mask_pyjhora)}")
|
||||
return 0
|
||||
finally:
|
||||
for child in temp_dir.glob("*"):
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
temp_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user