from __future__ import annotations import hashlib import json import os import shutil import stat import tempfile import time import uuid import warnings from datetime import date, datetime from pathlib import Path from typing import Any, Callable from .acquisition import acquire_all from .cache import ContentCache, atomic_write, canonical_bytes from .full_report import render_report from .http_client import HttpClient from .judgment import apply_overlay, load_overlay from .report_qa import run_qa, verify_manifest from .snapshot_builder import CoreInputError, build_data_snapshot from .telemetry import RunClock, aware_now, iso from .v1_bridge import V1Bridge class InputContractError(ValueError): pass def _hash_file(path: Path) -> dict[str, Any]: data = path.read_bytes() return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} def _atomic_workflow_write( path: Path, data: bytes, inject: Callable[[str], None], label: str, ) -> None: """Atomic write with every durable boundary exposed to fault injection.""" path.parent.mkdir(parents=True, exist_ok=True) inject(f"file:{label}:temp") fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temp = Path(raw_temp) try: with os.fdopen(fd, "wb") as handle: inject(f"file:{label}:write") handle.write(data) inject(f"file:{label}:flush") handle.flush() inject(f"file:{label}:fsync") os.fsync(handle.fileno()) inject(f"file:{label}:replace") os.replace(temp, path) finally: if temp.exists(): temp.unlink() def _is_reparse(path: Path) -> bool: try: attrs = path.lstat().st_file_attributes # type: ignore[attr-defined] except (AttributeError, OSError): return path.is_symlink() return bool(attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT) def _lexical_absolute(path: Path) -> Path: return Path(os.path.abspath(os.fspath(path))) def _lexists(path: Path) -> bool: return os.path.lexists(path) def _validate_parent_chain(path: Path, label: str) -> None: for parent in path.parents: if not _lexists(parent): continue if parent.is_symlink() or _is_reparse(parent): raise InputContractError(f"{label} 父链不得含链接/reparse:{parent}") if not parent.is_dir(): raise InputContractError(f"{label} 父链必须是目录:{parent}") def _validate_directory(path: Path, label: str, allow_existing: bool) -> None: _validate_parent_chain(path, label) if not _lexists(path): return if path.is_symlink() or _is_reparse(path): raise InputContractError(f"{label} 不得是链接或 reparse point:{path}") if not path.is_dir(): raise InputContractError(f"{label} 必须是目录:{path}") if not allow_existing: raise InputContractError(f"{label} 已存在:{path}") def _validate_file(path: Path, label: str, must_exist: bool) -> None: _validate_parent_chain(path, label) if not _lexists(path): if must_exist: raise InputContractError(f"{label} 不存在:{path}") return if path.is_symlink() or _is_reparse(path) or not path.is_file(): raise InputContractError(f"{label} 必须是普通文件:{path}") def _input_summary( clock: RunClock, error: BaseException, task_start_valid: bool = False ) -> dict[str, Any]: terminal = aware_now() process = max(0.0, time.monotonic() - clock.process_start_mono) task = None if task_start_valid and clock.task_start_wall is not None: task, _ = clock.walls(terminal) return { "schema_version": 1, "status": "INPUT_ERROR", "run_id": None, "exit_code": 2, "output_dir": None, "failed_dir": None, "manifest_path": None, "manifest_sha256": None, "receipt_path": None, "receipt_sha256": None, "receipt_bytes": None, "task_start": iso(clock.task_start_wall) if task is not None else None, "task_start_source": clock.task_start_source if task is not None else None, "process_start": iso(clock.process_start_wall), "terminal_at": iso(terminal), "task_wall_seconds": task, "process_wall_seconds": process, "gap_count": None, "error_code": "E_INPUT_CONTRACT", "error": f"{type(error).__name__}: {error}", } def _terminal_summary( *, clock: RunClock, status: str, exit_code: int, run_id: str, output_dir: Path, failed_dir: Path | None, manifest_path: Path | None, manifest_sha256: str | None, receipt_path: Path | None, receipt_sha256: str | None, receipt_bytes: int | None, terminal: datetime, task_wall: float, process_wall: float, gap_count: int, error_code: str | None, error: str | None, ) -> dict[str, Any]: return { "schema_version": 1, "status": status, "run_id": run_id, "exit_code": exit_code, "output_dir": str(output_dir), "failed_dir": str(failed_dir) if failed_dir else None, "manifest_path": str(manifest_path) if manifest_path else None, "manifest_sha256": manifest_sha256, "receipt_path": str(receipt_path) if receipt_path else None, "receipt_sha256": receipt_sha256, "receipt_bytes": receipt_bytes, "task_start": iso(clock.task_start_wall), "task_start_source": clock.task_start_source, "process_start": iso(clock.process_start_wall), "terminal_at": iso(terminal), "task_wall_seconds": task_wall, "process_wall_seconds": process_wall, "gap_count": gap_count, "error_code": error_code, "error": error, } def _clean_owned(path: Path) -> None: if _lexists(path) and path.is_dir() and not path.is_symlink() and not _is_reparse(path): shutil.rmtree(path) def _failure_package( failed: Path, clock: RunClock, run_id: str, status: str, error_code: str, original: BaseException, rollback_errors: list[str], provider_results: dict[str, Any] | None, ) -> None: _clean_owned(failed) failed.mkdir(parents=True) terminal = aware_now() task, process = clock.walls(terminal) atomic_write( failed / "failure.json", canonical_bytes( { "schema_version": 2, "complete": False, "run_id": run_id, "status": status, "error_code": error_code, "error_type": type(original).__name__, "error": str(original), "rollback_errors": rollback_errors, "recorded_at": iso(terminal), } ), ) atomic_write( failed / "runtime_metrics.json", canonical_bytes( { "schema_version": 1, "run_id": run_id, "status": status, "task_start": iso(clock.task_start_wall), "task_start_source": clock.task_start_source, "process_start": iso(clock.process_start_wall), "terminal_at": iso(terminal), "task_wall_seconds": task, "process_wall_seconds": process, "wall_scope": "through_failure_recorded", "phases": clock.phases, "providers": clock.providers, } ), ) message = f"{status} {error_code}: {type(original).__name__}: {original}\n" if rollback_errors: message += "rollback_errors=" + json.dumps(rollback_errors, ensure_ascii=False) + "\n" atomic_write(failed / "runtime.log", message.encode("utf-8")) if provider_results: atomic_write(failed / "provider_results.json", canonical_bytes(provider_results)) raw_hashes = sorted( { digest for result in provider_results.values() for digest in result.get("raw_artifact_hashes", []) } ) atomic_write( failed / "source_evidence.json", canonical_bytes({"schema_version": 1, "raw_hashes": raw_hashes}), ) def _reserved_conflicts(parent: Path, output_name: str) -> list[Path]: prefixes = ( f".{output_name}.staging-", f".{output_name}.backup-", f".{output_name}.backup-receipt-", f".{output_name}.failed-new-", f"{output_name}.failed-", ) if not parent.is_dir(): return [] return [item for item in parent.iterdir() if any(item.name.startswith(prefix) for prefix in prefixes)] def run_ticker_pipeline( *, ticker: str, as_of: str, output_dir: Path, cache_dir: Path, judgment_path: Path | None = None, fixture_dir: Path | None = None, task_start: str | None = None, force: bool = False, network_budget_seconds: float = 90.0, fault_injector: Callable[[str], None] | None = None, ) -> tuple[int, dict[str, Any]]: clock = RunClock() task_valid = False try: clock.set_task_start(task_start) task_valid = True if not __import__("re").fullmatch(r"\d{6}\.(SZ|SH|BJ)", ticker): raise InputContractError("ticker 必须是 NNNNNN.SZ|SH|BJ") as_of_date = date.fromisoformat(as_of) if as_of_date > date.today(): raise InputContractError("as-of 不得晚于当前日期") output_dir = _lexical_absolute(output_dir) cache_dir = _lexical_absolute(cache_dir) if fixture_dir is not None: fixture_dir = _lexical_absolute(fixture_dir) _validate_directory(fixture_dir, "fixture-dir", True) if judgment_path is not None: judgment_path = _lexical_absolute(judgment_path) _validate_file(judgment_path, "judgment", True) _validate_directory(output_dir, "output-dir", force) _validate_directory(cache_dir, "cache-dir", True) parent = output_dir.parent _validate_directory(parent, "output parent", True) receipt = parent / f"{output_dir.name}.commit-receipt.json" _validate_file(receipt, "receipt", False) if _lexists(receipt) and not force: raise InputContractError(f"receipt 已存在:{receipt}") conflicts = _reserved_conflicts(parent, output_dir.name) if conflicts: raise InputContractError(f"运行路径冲突:{conflicts[0]}") except (ValueError, OSError) as exc: return 2, _input_summary(clock, exc, task_valid) run_id = uuid.uuid4().hex receipt = output_dir.parent / f"{output_dir.name}.commit-receipt.json" stage = output_dir.parent / f".{output_dir.name}.staging-{run_id}" backup = output_dir.parent / f".{output_dir.name}.backup-{run_id}" backup_receipt = output_dir.parent / f".{output_dir.name}.backup-receipt-{run_id}.json" failed = output_dir.parent / f"{output_dir.name}.failed-{run_id}" provider_results: dict[str, Any] | None = None gaps: list[dict[str, Any]] = [] old_moved = False old_receipt_moved = False new_installed = False receipt_written = False def inject(point: str) -> None: if fault_injector: fault_injector(point) def stage_write(relative: str, value: Any, raw: bool = False) -> None: inject(f"write:{relative}") data = value if raw else canonical_bytes(value) _atomic_workflow_write(stage / relative, data, inject, relative) try: inject("mkdir_staging") stage.mkdir(parents=True) bridge = V1Bridge.load() registry_path = Path(__file__).with_name("provider_registry.json") registry = json.loads(registry_path.read_text(encoding="utf-8")) cache_dir.mkdir(parents=True, exist_ok=True) cache = ContentCache(cache_dir) selected_baseline, baseline_states = cache.baseline_states( ticker, as_of, clock.process_start_wall ) baseline_entries = dict((selected_baseline or {}).get("data_kinds", {})) clock.begin("acquisition") network_started = time.monotonic() client = HttpClient( registry, cache, network_started + network_budget_seconds, run_id, clock.process_start_wall, fixture_dir, baseline_states, baseline_entries, ) provider_results = acquire_all(client, ticker, as_of) clock.end("acquisition") clock.providers = list(client.telemetry) gaps = [gap for result in provider_results.values() for gap in result.get("gaps", [])] clock.begin("snapshot") data_snapshot, build_report = build_data_snapshot(ticker, as_of, provider_results) clock.end("snapshot") build_report["baseline_states_before"] = baseline_states results = None working_snapshot = data_snapshot if judgment_path: clock.begin("judgment_and_v1") overlay = load_overlay(judgment_path) working_snapshot = apply_overlay(data_snapshot, overlay) stage_write("valuation_snapshot.json", working_snapshot) results = bridge.compute(stage / "valuation_snapshot.json") stage_write("valuation_results.json", results) clock.end("judgment_and_v1") status = "COMPLETE_WITH_GAPS" if gaps else "COMPLETE" else: gaps.append( { "gap_id": "W_JUDGMENT_REQUIRED", "provider": "human_judgment", "field": "judgment_overlay", "reason": "机械数据已就绪,尚未提供最小人工判断覆盖层", "impact": "不得生成方向性估值结论", "blocking": False, "budget_used_seconds": None, "manual_action": "提供受 schema 约束的 judgment overlay", } ) status = "DATA_READY_NEEDS_JUDGMENT" build_report["gaps"] = gaps stage_write("data_snapshot.json", data_snapshot) stage_write("snapshot_build_report.json", build_report) stage_write("provider_results.json", provider_results) stage_write("gaps.json", gaps) stage_write( "source_evidence.json", { "schema_version": 2, "sources": data_snapshot["sources"], "raw_hashes": build_report["raw_hashes"], "field_lineage": build_report["field_lineage"], }, ) data_kinds = { kind: entry for result in provider_results.values() for kind, entry in result.get("data_kinds", {}).items() } baseline = { "schema_version": 1, "adapter_bundle_version": "2.1.0", "ticker": ticker, "baseline_as_of": as_of, "created_at": iso(aware_now()), "watermark": { "announcement_time": max( (source["publish_date"] for source in data_snapshot["sources"]), default="", ), "report_date": data_snapshot["meta"]["report_period_end"], "market_date": data_snapshot["market"]["shares_date"], "forecast_date": max( ( item.get("report_date") or "" for item in data_snapshot["institutions"].get("forecasts", []) ), default="", ), }, "provider_results": provider_results, "data_kinds": data_kinds, "field_lineage": build_report["field_lineage"], "source_hashes": build_report["raw_hashes"], } if client.remaining() <= 0: raise CoreInputError("E_NETWORK_DEADLINE:截止后禁止提交公司基线") cache_integrity_failure = any( result.get("cache_integrity_failure") is True for result in provider_results.values() ) if ( not cache_integrity_failure and cache.baseline_entries_complete_and_valid(data_kinds) ): cache.write_baseline( ticker, baseline, supersede_data_hash=(selected_baseline or {}).get("data_hash"), ) clock.begin("report_and_qa") report = render_report(working_snapshot, build_report, gaps, results) stage_write("report.md", report.encode("utf-8"), raw=True) qa = run_qa(report, working_snapshot, build_report, results, stage) stage_write("qa_report.json", qa) if qa["status"] != "PASS": raise RuntimeError("QA_FAIL: " + ";".join(qa["errors"])) clock.end("report_and_qa") stage_write("runtime.log", b"pipeline completed through commit-ready\n", raw=True) commit_ready = aware_now() stage_write("runtime_metrics.json", clock.metrics(run_id, status, commit_ready)) inject("manifest") artifacts = { path.relative_to(stage).as_posix(): _hash_file(path) for path in sorted(stage.rglob("*")) if path.is_file() } manifest = { "schema_version": 1, "run_id": run_id, "status": status, "complete": True, "ticker": ticker, "as_of": as_of, "artifacts": artifacts, "v1_contract": bridge.metadata(), } _atomic_workflow_write( stage / "manifest.json", canonical_bytes(manifest), inject, "manifest.json" ) manifest_meta = _hash_file(stage / "manifest.json") if _lexists(output_dir): inject("rename_old_to_backup") os.replace(output_dir, backup) old_moved = True if _lexists(receipt): os.replace(receipt, backup_receipt) old_receipt_moved = True inject("rename_stage_to_output") os.replace(stage, output_dir) new_installed = True manifest_errors = verify_manifest(output_dir) if manifest_errors: raise RuntimeError("MANIFEST_VERIFY_FAIL: " + ";".join(manifest_errors)) receipt_write_started = aware_now() receipt_payload = { "schema_version": 2, "run_id": run_id, "status": status, "output_path": str(output_dir), "manifest_path": str(output_dir / "manifest.json"), "manifest_sha256": manifest_meta["sha256"], "manifest_bytes": manifest_meta["bytes"], "commit_ready_at": iso(commit_ready), "receipt_write_started_at": iso(receipt_write_started), "task_start": iso(clock.task_start_wall), "task_start_source": clock.task_start_source, "process_start": iso(clock.process_start_wall), "wall_scope": "through_receipt_write_started", } inject("receipt") _atomic_workflow_write( receipt, canonical_bytes(receipt_payload), inject, "receipt" ) inject("receipt:read") receipt_bytes_value = receipt.read_bytes() inject("receipt:hash") receipt_digest = hashlib.sha256(receipt_bytes_value).hexdigest() inject("receipt:stat") receipt_size = receipt.stat().st_size if receipt_size != len(receipt_bytes_value): raise RuntimeError("RECEIPT_VERIFY_FAIL: bytes changed during validation") if json.loads(receipt_bytes_value.decode("utf-8")) != receipt_payload: raise RuntimeError("RECEIPT_VERIFY_FAIL: canonical payload mismatch") receipt_meta = {"bytes": receipt_size, "sha256": receipt_digest} receipt_written = True cleanup_warning: BaseException | None = None if _lexists(backup): try: inject("cleanup_backup") shutil.rmtree(backup) if _lexists(backup_receipt): backup_receipt.unlink() except BaseException as exc: cleanup_warning = exc elif _lexists(backup_receipt): try: backup_receipt.unlink() except BaseException as exc: cleanup_warning = exc if cleanup_warning is not None: # New output and receipt are already the durable truth. Retain backup evidence. warnings.warn( f"backup cleanup failed; new output remains authoritative: {cleanup_warning}", RuntimeWarning, ) terminal = aware_now() task_wall, process_wall = clock.walls(terminal) return 0, _terminal_summary( clock=clock, status=status, exit_code=0, run_id=run_id, output_dir=output_dir, failed_dir=None, manifest_path=output_dir / "manifest.json", manifest_sha256=manifest_meta["sha256"], receipt_path=receipt, receipt_sha256=receipt_meta["sha256"], receipt_bytes=receipt_meta["bytes"], terminal=terminal, task_wall=task_wall, process_wall=process_wall, gap_count=len(gaps), error_code=None, error=None, ) except BaseException as original: rollback_errors: list[str] = [] try: if old_moved and not new_installed and _lexists(backup): inject("restore") os.replace(backup, output_dir) old_moved = False elif new_installed and not receipt_written: orphan = output_dir.parent / f".{output_dir.name}.failed-new-{run_id}" if _lexists(output_dir): os.replace(output_dir, orphan) if _lexists(backup): inject("restore") os.replace(backup, output_dir) old_moved = False _clean_owned(orphan) if not old_receipt_moved and _lexists(receipt): receipt.unlink() if old_receipt_moved and _lexists(backup_receipt): os.replace(backup_receipt, receipt) old_receipt_moved = False except BaseException as rollback_exc: rollback_errors.append(f"{type(rollback_exc).__name__}: {rollback_exc}") try: _clean_owned(stage) except BaseException as cleanup_exc: rollback_errors.append(f"cleanup {type(cleanup_exc).__name__}: {cleanup_exc}") status = "BLOCKED" if isinstance(original, CoreInputError) else "FAILED" exit_code = 4 if status == "BLOCKED" else 5 error_code = "E_CORE_INPUT" if status == "BLOCKED" else "E_RUNTIME" _failure_package( failed, clock, run_id, status, error_code, original, rollback_errors, provider_results, ) terminal = aware_now() task_wall, process_wall = clock.walls(terminal) summary = _terminal_summary( clock=clock, status=status, exit_code=exit_code, run_id=run_id, output_dir=output_dir, failed_dir=failed, manifest_path=None, manifest_sha256=None, receipt_path=None, receipt_sha256=None, receipt_bytes=None, terminal=terminal, task_wall=task_wall, process_wall=process_wall, gap_count=len(gaps), error_code=error_code, error=f"{type(original).__name__}: {original}", ) if isinstance(original, (KeyboardInterrupt, SystemExit)): raise return exit_code, summary