157 lines
5.2 KiB
Python
157 lines
5.2 KiB
Python
"""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()
|