"""Artifact extraction framework (Section 45). Derived artifacts are plain-text files generated from binary sources (e.g. SQLite databases) and versioned in the repository so Git history shows readable, row-level changes instead of opaque binary blobs. Follows the same pluggable-interface principle as author resolution (Section 44): an ArtifactExtractor abstraction with format-specific implementations added independently. """ from __future__ import annotations import datetime as dt from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path @dataclass(frozen=True) class ExtractionSource: """One configured binary source (Section 45.3).""" #: Repository-relative path of the source database (POSIX separators) path: str #: Tables to extract; empty means all user tables tables: tuple[str, ...] = () @dataclass class ExtractionResult: """Outcome of extracting one source database.""" source: str #: Repository-relative artifact paths written or refreshed artifacts: list[str] = field(default_factory=list) #: Repository-relative artifact paths removed (stale, Section 45.6) removed: list[str] = field(default_factory=list) #: True when nothing changed relative to the previous artifacts unchanged: bool = False class ArtifactExtractor(ABC): """Pluggable content extractor (Section 45.1). Implementations refresh derived artifacts from one configured source into a sub-folder of the output root named after the source file. Contract (Sections 45.4 - 45.7): * open the source read-only; never write to it (FR-034); * operate on a snapshot when the live source may change concurrently; * produce byte-identical output for unchanged content (FR-031); * remove artifacts whose source table no longer exists (FR-033); * raise ExtractionError when the source cannot be read or an artifact cannot be written; the caller aborts the collection (FR-032) - silent staleness is not acceptable. """ #: Registry name used in configuration (extraction.sources[].format) name: str = "abstract" @abstractmethod def extract( self, source: ExtractionSource, repository_root: Path, output_root: Path, ) -> ExtractionResult: """Refresh the derived artifacts for one source; returns its result.""" class ExtractionError(Exception): """Raised when extraction fails; aborts the collection (FR-032).""" def default_output_root(name: str) -> str: """Repository-relative output root folder (Section 45.2, default).""" return name if name else "extracted" def artifact_folder(source: ExtractionSource) -> str: """Sub-folder name for one source database (Section 45.2). Named after the database file (without extension), so table artifact files cannot collide between databases. """ stem = Path(source.path).stem return stem or "source" def artifact_filename(table: str) -> str: """Deterministic, platform-safe artifact file name for a table (FR-029).""" # Keep characters that are safe on Windows/Linux/macOS; replace the # rest with underscores; collapse runs; strip leading/trailing ones. safe = "".join(ch if (ch.isalnum() or ch in "._- ") else "_" for ch in table) while "__" in safe: safe = safe.replace("__", "_") safe = safe.strip("._") reserved = { "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", } if safe.lower() in reserved: safe = f"_{safe}" return f"{safe or 'table'}.jsonl" def to_jsonl(rows: list[dict[str, object]]) -> str: """Serialize rows to deterministic JSON Lines (Section 45.4). Keys sorted for stable column order; ensure_ascii=False for readable, byte-stable UTF-8; separators without padding for minimal diffs. Rows must already be in deterministic order (caller orders by PK). """ import json lines = [ json.dumps(row, sort_keys=True, ensure_ascii=False, separators=(",", ":")) for row in rows ] return "\n".join(lines) + "\n" if lines else "" def write_if_changed(path: Path, content: str) -> bool: """Write content only when it differs; returns True when written. Avoiding unconditional writes keeps file mtime (and therefore the commit message's last-modified column) stable for unchanged tables, and prevents spurious change records. """ if path.exists(): try: if path.read_text(encoding="utf-8") == content: return False except OSError: pass path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8", newline="\n") return True def remove_if_exists(path: Path) -> bool: """Delete a stale artifact file; returns True when removed (FR-033).""" if not path.exists(): return False try: path.unlink() return True except OSError: return False def timestamp_note(moment: dt.datetime) -> str: # pragma: no cover - future use """Reserved for potential sidecar metadata (Section 45.9).""" return moment.isoformat()