feat(ingest): I5 — SQL D7-speil, bygget fra commons-spec alene
Speiler MAF I4 fra shared/ingest-spec.md alene: manifest → SQL-konnektor → materialisert OKF-bundle, byte-identisk med den delte golden-fasiten. Gaten I4→I5 verifisert løst mot ground truth (MAF-commits d7e5f2f/4f45fe6/1b7612b) før arbeidet startet. Spec byte-identisk delt, ingen spec-endring (I4). - ingest.py: SqlSource (type: sql, id, connection_ref); ManifestContract.source er nå diskriminert union FileSource | SqlSource på type (http/ukjent tag → fail-fast). _render_sql_cell (§5 typed: NULL→"", int→decimal, float→korteste round-trip, str→verbatim m/ delt _escape_cell, annet→fail — aldri stille coercion). _resolve_connection_ref (env-oppslag §4/§8, usatt → fail-fast). _read_sql (read-only sqlite file:?mode=ro, ett SELECT, max_rows §8). _read_extraction dispatcher på source.type; materialisering/index/replacement uendret fra I3. - examples/ingest-golden-sql/: repo-lokal golden (byte-frossen kopi av I4s fasit). - Speiltester (I4s load-bearing-sett, gjennom SQL-konnektoren, detach-bevist røde): sql-golden byte-fasit + mutasjonskontroller · typed-cell/NULL (NY §I5-søm) · provenance/navigability/verdict-reservasjon/re-ingest-safety · SqlSource-kontrakt/ typed-rendering/connection_ref/max_rows/read-only · spec-integritet utvidet med connection_ref. Stale type:"sql"-avvisningscase erstattet (sql er gyldig post-I5). - docs/2026-07-04-I5-brief.md: brief + premiss-verifisering. Suite 265 passed uten nøkkel/nettverk (239 + 26 nye) · ruff + mypy --strict rene. [skip-docs] README + docs/extending.md er bevisst utsatt til I7 per sesjonsplan (programmet batcher ingest-doc der, avgrenset til det D7 faktisk har — CSV + SQL nå, HTTP/MCP kun pekere). Dokumentert i docs/2026-07-04-I5-brief.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MM6BWb1hWmJZuXFZ7rjxT
This commit is contained in:
parent
e03bd79876
commit
32640deffc
13 changed files with 615 additions and 9 deletions
|
|
@ -4,9 +4,10 @@ An addition IN FRONT of the loop (ingest-spec §1): a deterministic step that co
|
|||
framework to a real data source and materializes the extract as an OKF knowledge bundle,
|
||||
which the existing 8-step loop then consumes UNCHANGED. Zero model calls; no network.
|
||||
|
||||
D7 scope (I3, mirror of MAF I2): the ``file`` source type (a local CSV catalogue). The
|
||||
``sql`` and ``http`` source types are later work — a manifest naming them is rejected
|
||||
fail-fast at validation, never silently accepted.
|
||||
D7 scope (I3–I5, mirror of MAF I2/I4): the ``file`` source type (a local CSV catalogue) and
|
||||
the ``sql`` source type (a local SQL database). The ``http`` source type is a later, OPTIONAL
|
||||
extension point (§1) — a manifest naming it is rejected fail-fast at validation, never
|
||||
silently accepted.
|
||||
|
||||
Contract discipline mirrors ``contracts.py``: the manifest is schema-validated fail-fast
|
||||
BEFORE any source call (§4), and queries are declarative configuration, never evaluated as
|
||||
|
|
@ -20,10 +21,12 @@ import csv
|
|||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
|
@ -65,6 +68,26 @@ class FileSource(BaseModel):
|
|||
return value
|
||||
|
||||
|
||||
class SqlSource(BaseModel):
|
||||
"""A local SQL database (``source.type == "sql"``, ingest-spec §4).
|
||||
|
||||
``connection_ref`` is the NAME of a runtime-resolved environment variable whose value is
|
||||
the database path (or connection string) — the secret/location never lives in the manifest
|
||||
(§4, §8), so the manifest stays versionable and shareable without credentials.
|
||||
"""
|
||||
|
||||
type: Literal["sql"]
|
||||
id: str
|
||||
connection_ref: str
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _id_grammar(cls, value: str) -> str:
|
||||
if not _ID_RE.match(value):
|
||||
raise ValueError(f"source.id {value!r} must match [a-z0-9][a-z0-9-]*")
|
||||
return value
|
||||
|
||||
|
||||
class Extraction(BaseModel):
|
||||
"""One extraction description (ingest-spec §4). ``max_rows`` is a required cap (§8)."""
|
||||
|
||||
|
|
@ -103,7 +126,7 @@ class ManifestContract(BaseModel):
|
|||
"""The ingest manifest (§4) — schema-validated fail-fast before any source call."""
|
||||
|
||||
manifest_version: Literal[1]
|
||||
source: FileSource
|
||||
source: Annotated[FileSource | SqlSource, Field(discriminator="type")]
|
||||
bundle_summary: str
|
||||
extractions: list[Extraction] = Field(min_length=1)
|
||||
|
||||
|
|
@ -174,6 +197,77 @@ def _read_csv(csv_path: Path, max_rows: int) -> list[list[str]]:
|
|||
return rows
|
||||
|
||||
|
||||
def _render_sql_cell(value: object) -> str:
|
||||
"""Type a SQL cell to its §5 string form (BEFORE §5 escaping via ``_escape_cell``).
|
||||
|
||||
``None`` (SQL NULL) → the empty string; ``int`` → plain decimal; ``float`` → its shortest
|
||||
round-trip decimal form (Python ``repr``); ``str`` → verbatim. Any other value type (a
|
||||
BLOB, say) MUST fail — never a silent coercion (§5).
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return repr(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
raise ValueError(f"SQL cell of type {type(value).__name__} is not a supported value type (§5)")
|
||||
|
||||
|
||||
def _resolve_connection_ref(connection_ref: str) -> str:
|
||||
"""Resolve the ``connection_ref`` env var to the database location at run time (§4, §8).
|
||||
|
||||
Locations/credentials never live in the manifest — the reference is resolved from the
|
||||
environment. An unset reference fails fast (never a silent empty connection).
|
||||
"""
|
||||
dsn = os.environ.get(connection_ref)
|
||||
if not dsn:
|
||||
raise ValueError(f"connection_ref {connection_ref!r} is not set in the environment")
|
||||
return dsn
|
||||
|
||||
|
||||
def _read_sql(dsn: str, query: str, max_rows: int) -> list[list[str]]:
|
||||
"""Run one read-only SELECT against a local sqlite database (ingest-spec §4, §5, §8).
|
||||
|
||||
The connection is opened read-only (§4: the connector SHOULD enforce read-only access), and
|
||||
sqlite executes exactly ONE statement per ``execute`` call — so the single-statement rule is
|
||||
enforced by the driver. ``max_rows`` is enforced fail-fast on the returned rows (§8).
|
||||
Returns the column-name header row followed by typed-then-stringified data rows, in source
|
||||
order (§5); a stable order is the manifest query's responsibility via ORDER BY (§4).
|
||||
"""
|
||||
uri = Path(dsn).resolve().as_uri() + "?mode=ro"
|
||||
connection = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
cursor = connection.execute(query)
|
||||
header = [description[0] for description in cursor.description]
|
||||
data = cursor.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
if len(data) > max_rows:
|
||||
raise ValueError(f"extraction exceeds max_rows: {len(data)} > {max_rows}")
|
||||
rows: list[list[str]] = [header]
|
||||
for record in data:
|
||||
rows.append([_render_sql_cell(value) for value in record])
|
||||
return rows
|
||||
|
||||
|
||||
def _read_extraction(
|
||||
source: FileSource | SqlSource, manifest_dir: Path, extraction: Extraction
|
||||
) -> list[list[str]]:
|
||||
"""Dispatch to the connector for ``source.type`` — header + data rows in source order (§5).
|
||||
|
||||
``file``: resolve the query path within ``root`` (boundary-checked fail-closed, §4) and read
|
||||
the CSV. ``sql``: resolve ``connection_ref`` from the environment (§4, §8) and run the
|
||||
read-only SELECT. Both enforce ``max_rows`` fail-fast (§8).
|
||||
"""
|
||||
if source.type == "file":
|
||||
csv_path = _resolve_within(manifest_dir / source.root, extraction.query)
|
||||
return _read_csv(csv_path, extraction.max_rows)
|
||||
dsn = _resolve_connection_ref(source.connection_ref)
|
||||
return _read_sql(dsn, extraction.query, extraction.max_rows)
|
||||
|
||||
|
||||
def _resolve_within(root: Path, relative: str) -> Path:
|
||||
"""Resolve ``relative`` under ``root``, boundary-checked fail-closed (the OKF path rule)."""
|
||||
resolved = (root / relative).resolve()
|
||||
|
|
@ -264,7 +358,6 @@ def materialize(manifest_path: Path, bundle_dir: Path, ingested_at: str) -> list
|
|||
"""
|
||||
loaded = load_manifest(manifest_path)
|
||||
source = loaded.contract.source
|
||||
root = manifest_path.parent / source.root
|
||||
|
||||
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -276,8 +369,7 @@ def materialize(manifest_path: Path, bundle_dir: Path, ingested_at: str) -> list
|
|||
# 2. Write the new set.
|
||||
generated: list[Path] = []
|
||||
for extraction in loaded.contract.extractions:
|
||||
csv_path = _resolve_within(root, extraction.query)
|
||||
rows = _read_csv(csv_path, extraction.max_rows)
|
||||
rows = _read_extraction(source, manifest_path.parent, extraction)
|
||||
_logger.info(
|
||||
"ingest source=%s extraction=%s rows=%d",
|
||||
source.id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue