from __future__ import annotations import hashlib import json import os import tempfile import threading import time from contextlib import contextmanager from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path from typing import Any, Iterator def canonical_bytes(value: Any) -> bytes: def encode(item: Any) -> Any: if isinstance(item, Decimal): return format(item, "f") if isinstance(item, Path): return str(item) raise TypeError(f"Object of type {type(item).__name__} is not JSON serializable") return json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=encode, ).encode("utf-8") def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def atomic_write(path: Path, data: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temp = Path(raw_temp) try: with os.fdopen(fd, "wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(temp, path) finally: if temp.exists(): temp.unlink() def request_fingerprint(preimage: dict[str, Any]) -> str: return sha256_bytes(canonical_bytes(preimage)) DATA_KIND_TTLS = { "stock_identity": 7 * 86400, "announcement_index_recent": 6 * 3600, "announcement_index_historical": 30 * 86400, "market_close": 30 * 86400, "shares_market_cap": 15 * 60, "finance_main_recent": 6 * 3600, "finance_main_historical": 30 * 86400, "finance_income_recent": 6 * 3600, "finance_income_historical": 30 * 86400, "finance_balance_recent": 6 * 3600, "finance_balance_historical": 30 * 86400, "finance_cashflow_recent": 6 * 3600, "finance_cashflow_historical": 30 * 86400, "forecast_summary": 6 * 3600, "forecast_detail": 6 * 3600, } PROVIDER_KINDS = { "announcements": ("stock_identity", "announcement_index"), "market": ("market_close", "shares_market_cap"), "finance": ("finance_main", "finance_income", "finance_balance", "finance_cashflow"), "forecast": ("forecast_summary", "forecast_detail"), } class BlobIntegrityError(OSError): """A content-addressed object could not be written and verified safely.""" _PROCESS_LOCKS: dict[str, threading.Lock] = {} _PROCESS_LOCKS_GUARD = threading.Lock() @contextmanager def _ticker_lock(path: Path) -> Iterator[None]: key = os.path.normcase(os.path.abspath(path)) with _PROCESS_LOCKS_GUARD: local = _PROCESS_LOCKS.setdefault(key, threading.Lock()) with local: path.parent.mkdir(parents=True, exist_ok=True) handle = open(path, "a+b") try: handle.seek(0) if handle.tell() == 0 and path.stat().st_size == 0: handle.write(b"0") handle.flush() if os.name == "nt": import msvcrt handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_EX) yield finally: try: handle.seek(0) if os.name == "nt": import msvcrt msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_UN) finally: handle.close() class ContentCache: def __init__(self, root: Path): self.root = root def load_reusable( self, provider: str, fingerprint: str, process_start: datetime ) -> dict[str, Any] | None: index = self.root / "reusable" / provider / f"{fingerprint}.json" if not index.is_file(): return None try: meta = json.loads(index.read_text(encoding="utf-8")) expires = datetime.fromisoformat(meta["expires_at"]) blob = self.root / meta["blob_path"] data = blob.read_bytes() except (OSError, ValueError, KeyError, json.JSONDecodeError): return None if process_start > expires or sha256_bytes(data) != meta.get("blob_hash"): return None if not (200 <= int(meta.get("http_status", 0)) < 300): return None if not all( meta.get(key) is True for key in ("transport_complete", "parse_ok", "schema_ok", "as_of_ok") ) or meta.get("semantic_status") != "OK": return None return {"body": data, "meta": meta} def store( self, provider: str, fingerprint: str, body: bytes, fetched_at: datetime, ttl_seconds: int, reusable: bool, raw_meta: dict[str, Any], ) -> dict[str, Any]: digest = sha256_bytes(body) blob_rel = Path("blobs") / "sha256" / digest[:2] / f"{digest}.bin" blob = self.root / blob_rel blob_valid = False if blob.is_file(): try: blob_valid = sha256_bytes(blob.read_bytes()) == digest except OSError: blob_valid = False if not blob_valid: try: atomic_write(blob, body) except OSError as exc: raise BlobIntegrityError( f"内容寻址 blob 无法原子写入:{digest}" ) from exc try: if sha256_bytes(blob.read_bytes()) != digest: raise BlobIntegrityError(f"内容寻址 blob 写后校验失败:{digest}") except BlobIntegrityError: raise except OSError as exc: raise BlobIntegrityError(f"内容寻址 blob 无法形成可信实物:{digest}") from exc meta = { **raw_meta, "schema_version": 1, "complete": True, "provider_id": provider, "fingerprint": fingerprint, "fetched_at": fetched_at.isoformat(), "expires_at": (fetched_at + timedelta(seconds=ttl_seconds)).isoformat(), "blob_path": blob_rel.as_posix(), "blob_hash": digest, "bytes": len(body), } run_id = raw_meta.get("run_id", "unknown") atomic_write( self.root / "raw-index" / provider / fingerprint / f"{run_id}.json", canonical_bytes(meta), ) if reusable: atomic_write( self.root / "reusable" / provider / f"{fingerprint}.json", canonical_bytes(meta), ) return meta def promote_reusable( self, provider: str, fingerprint: str, meta: dict[str, Any] ) -> dict[str, Any]: promoted = { **meta, "parse_ok": True, "schema_ok": True, "as_of_ok": True, "semantic_status": "OK", } atomic_write( self.root / "reusable" / provider / f"{fingerprint}.json", canonical_bytes(promoted), ) return promoted def _baseline_material(self, baseline: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in baseline.items() if k not in {"created_at", "data_hash"}} def _baseline_valid(self, baseline: dict[str, Any]) -> bool: """Validate only the immutable baseline envelope, not every kind blob.""" expected = baseline.get("data_hash") if not isinstance(expected, str) or len(expected) != 64: return False if sha256_bytes(canonical_bytes(self._baseline_material(baseline))) != expected: return False return True def _data_kind_blob_valid(self, entry: dict[str, Any]) -> bool: digest = str(entry.get("raw_hash", "")).lower() if len(digest) != 64: return False blob = self.root / "blobs" / "sha256" / digest[:2] / f"{digest}.bin" try: return sha256_bytes(blob.read_bytes()) == digest except OSError: return False @staticmethod def _selection_key(item: dict[str, Any]) -> tuple[str, bytes, str]: return ( item.get("baseline_as_of", ""), canonical_bytes(item.get("watermark", {})), str(item.get("data_hash", "")).lower(), ) def select_baseline(self, ticker: str, requested_as_of: str) -> dict[str, Any] | None: root = self.root / "companies" / ticker / "baselines" candidates: list[dict[str, Any]] = [] if not root.is_dir(): return None for path in root.glob("*.json"): try: item = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue if item.get("ticker") != ticker or item.get("baseline_as_of", "9999-12-31") > requested_as_of: continue if self._baseline_valid(item): candidates.append(item) return max(candidates, key=self._selection_key) if candidates else None @staticmethod def data_kind_state( entry: dict[str, Any] | None, requested_as_of: str, process_start: datetime, adapter_version: str = "1.0.0", ) -> str: if entry is None: return "MISSING" required = ( "data_kind", "provider_id", "request_fingerprint", "ticker", "requested_as_of", "baseline_as_of", "data_date", "fetched_at", "expires_at", "adapter_version", "raw_hash", "schema_status", "as_of_status", "semantic_status", "watermark", ) if any(key not in entry for key in required): return "STALE" try: expires = datetime.fromisoformat(entry["expires_at"]) except (TypeError, ValueError): return "STALE" if entry["baseline_as_of"] > requested_as_of or entry["data_date"] > requested_as_of: return "STALE" if entry["requested_as_of"] != requested_as_of: return "STALE" publish = entry.get("publish_date") if entry.get("requires_publish_date", False) and ( not publish or publish > requested_as_of ): return "STALE" if process_start > expires or entry["adapter_version"] != adapter_version: return "STALE" if entry["schema_status"] != "PASS" or entry["as_of_status"] != "PASS": return "STALE" if entry["semantic_status"] != "OK" or len(str(entry["raw_hash"])) != 64: return "STALE" kind = entry["data_kind"] if kind == "market_close" and not entry.get("is_latest_eligible_trade_date"): return "STALE" if kind == "shares_market_cap" and not entry.get("historical_capture_valid"): return "STALE" if kind.startswith("finance_") and not entry.get("required_periods_complete"): return "STALE" if kind == "forecast_summary" and not entry.get("summary_complete"): return "STALE" if kind == "forecast_detail" and not entry.get("detail_complete"): return "STALE" return "FRESH" def load_baseline_blob( self, entry: dict[str, Any], provider_id: str, request_fingerprint_value: str, ) -> dict[str, Any] | None: """Load a semantically validated per-kind baseline without a request index.""" if ( entry.get("provider_id") != provider_id or entry.get("request_fingerprint") != request_fingerprint_value ): return None digest = str(entry.get("raw_hash", "")).lower() if len(digest) != 64: return None blob = self.root / "blobs" / "sha256" / digest[:2] / f"{digest}.bin" try: body = blob.read_bytes() except OSError: return None if sha256_bytes(body) != digest: return None meta = { "schema_version": 1, "provider_id": provider_id, "fingerprint": request_fingerprint_value, "blob_path": blob.relative_to(self.root).as_posix(), "blob_hash": digest, "bytes": len(body), "http_status": 200, "transport_complete": True, "parse_ok": True, "schema_ok": True, "as_of_ok": True, "semantic_status": "OK", "fetched_at": entry["fetched_at"], "expires_at": entry["expires_at"], "data_kind": entry["data_kind"], "started_at": None, "finished_at": None, "remaining_before": None, "remaining_after": None, "attempts": [], "baseline_reused": True, } return {"body": body, "meta": meta} def baseline_states( self, ticker: str, requested_as_of: str, process_start: datetime ) -> tuple[dict[str, Any] | None, dict[str, str]]: baseline = self.select_baseline(ticker, requested_as_of) entries = (baseline or {}).get("data_kinds", {}) all_kinds = {kind for kinds in PROVIDER_KINDS.values() for kind in kinds} states: dict[str, str] = {} for kind in sorted(all_kinds): entry = entries.get(kind) state = self.data_kind_state(entry, requested_as_of, process_start) if state == "FRESH" and not self._data_kind_blob_valid(entry): state = "STALE" states[kind] = state return baseline, states def baseline_entries_complete_and_valid( self, entries: dict[str, dict[str, Any]] ) -> bool: """A company baseline advances only when every kind has a trusted blob.""" expected = {kind for kinds in PROVIDER_KINDS.values() for kind in kinds} return set(entries) == expected and all( self._data_kind_blob_valid(entries[kind]) for kind in expected ) def fresh_provider_results( self, ticker: str, requested_as_of: str, process_start: datetime ) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: baseline, states = self.baseline_states(ticker, requested_as_of, process_start) fresh: dict[str, dict[str, Any]] = {} if baseline: for name, kinds in PROVIDER_KINDS.items(): if all(states.get(kind) == "FRESH" for kind in kinds): result = json.loads(json.dumps(baseline["provider_results"][name])) result["baseline_reused"] = True result["request_telemetry"] = [] fresh[name] = result return fresh, states def write_baseline( self, ticker: str, baseline: dict[str, Any], supersede_data_hash: str | None = None, ) -> Path: baseline = dict(baseline) baseline["data_hash"] = sha256_bytes(canonical_bytes(self._baseline_material(baseline))) target = ( self.root / "companies" / ticker / "baselines" / f"{baseline['baseline_as_of']}-{baseline['data_hash']}.json" ) lock_path = self.root / "companies" / ticker / ".current.lock" with _ticker_lock(lock_path): atomic_write(target, canonical_bytes(baseline)) if supersede_data_hash and supersede_data_hash != baseline["data_hash"]: old = ( self.root / "companies" / ticker / "baselines" / f"{baseline['baseline_as_of']}-{supersede_data_hash}.json" ) if old.is_file(): history = self.root / "companies" / ticker / "history" / old.name history.parent.mkdir(parents=True, exist_ok=True) os.replace(old, history) current = self.root / "companies" / ticker / "current.json" candidate = { "baseline_as_of": baseline["baseline_as_of"], "watermark": baseline.get("watermark", {}), "data_hash": baseline["data_hash"].lower(), "path": target.relative_to(self.root).as_posix(), } existing = None if current.is_file(): try: existing = json.loads(current.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): pass existing_is_superseded = ( existing is not None and supersede_data_hash is not None and existing.get("data_hash") == supersede_data_hash.lower() ) if ( existing is None or existing_is_superseded or self._selection_key(candidate) > self._selection_key(existing) ): atomic_write(current, canonical_bytes(candidate)) return target