"""Raw-file manifest: one row per monthly file, machine-readable (CLAUDE.md ยง4.2).""" from __future__ import annotations import json from dataclasses import asdict, dataclass, field from pathlib import Path @dataclass class FileRecord: dataset_id: str dataset_revision: str remote_path: str local_path: str year: int month: int expected_bytes: int | None = None downloaded_bytes: int | None = None sha256: str | None = None lfs_sha256: str | None = None status: str = "pending" # pending | downloading | complete | failed download_started_at: str | None = None download_completed_at: str | None = None row_count: int | None = None column_names: list[str] = field(default_factory=list) column_types: list[str] = field(default_factory=list) min_timestamp: str | None = None max_timestamp: str | None = None unique_symbol_count: int | None = None duplicate_key_count: int | None = None null_count_by_column: dict = field(default_factory=dict) audit_version: str = "0.1" code_commit: str | None = None class Manifest: """JSON-backed manifest with atomic writes; convertible to parquet/csv.""" def __init__(self, path: Path): self.path = path self.records: dict[str, FileRecord] = {} if path.exists(): data = json.loads(path.read_text()) for r in data["files"]: self.records[r["remote_path"]] = FileRecord(**r) def upsert(self, rec: FileRecord) -> None: self.records[rec.remote_path] = rec def save(self) -> None: tmp = self.path.with_suffix(".tmp") tmp.write_text(json.dumps( {"files": [asdict(r) for r in sorted(self.records.values(), key=lambda x: x.remote_path)]}, indent=1)) tmp.replace(self.path) def export_tabular(self) -> None: import polars as pl rows = [] for r in self.records.values(): d = asdict(r) # JSON-encode nested fields: empty structs are not writable to Parquet d["column_names"] = "|".join(d["column_names"]) d["column_types"] = "|".join(d["column_types"]) d["null_count_by_column"] = json.dumps(d["null_count_by_column"]) rows.append(d) df = pl.DataFrame(rows) df.write_parquet(self.path.with_suffix(".parquet")) df.write_csv(self.path.with_suffix(".csv")) def pending(self) -> list[FileRecord]: return [r for r in self.records.values() if r.status != "complete"]