Add provider-neutral tabular source contracts

This commit is contained in:
2026-07-28 11:12:24 +02:00
parent 74034947c6
commit d36bb94335
3 changed files with 348 additions and 1 deletions

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import csv
import io
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
class TabularSourceError(ValueError):
"""Stable base error for provider-neutral tabular source operations."""
class TabularSourceNotFoundError(TabularSourceError):
pass
class TabularSourceAccessError(TabularSourceError):
pass
class TabularSourceValidationError(TabularSourceError):
pass
def parse_tabular_csv(
csv_text: str,
*,
delimiter: str = ",",
max_rows: int = 10_000,
) -> tuple[Mapping[str, object], ...]:
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
if len(delimiter) != 1:
raise TabularSourceValidationError("CSV delimiter must be one character.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
normalized_headers = [str(name).strip() for name in reader.fieldnames]
normalized_headers[0] = normalized_headers[0].lstrip("\ufeff")
if any(not name for name in normalized_headers):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized_headers)) != len(normalized_headers):
raise TabularSourceValidationError("CSV column names must be unique.")
rows: list[dict[str, object]] = []
for row in reader:
extras = row.get(None)
if extras and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
values = [row.get(original) for original in reader.fieldnames]
if all(value is None or not value.strip() for value in values):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(
{
normalized_headers[position]: _csv_scalar(row.get(original))
for position, original in enumerate(reader.fieldnames)
}
)
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_scalar(value: str | None) -> object:
if value is None:
return None
text = value.strip()
if not text:
return None
lowered = text.casefold()
if lowered in {"true", "false"}:
return lowered == "true"
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
return int(text)
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
return float(text)
return text
@dataclass(frozen=True, slots=True)
class TabularColumn:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class TabularSource:
"""Opaque, policy-filtered source reference exposed to consuming modules."""
ref: str
provider: str
source_name: str
name: str
description: str | None = None
schema: tuple[TabularColumn, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularReadRequest:
source_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class TabularReadResult:
source: TabularSource
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@dataclass(frozen=True, slots=True)
class TabularSnapshotInput:
name: str
source_name: str
rows: tuple[Mapping[str, object], ...]
description: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class TabularSourceProvider(Protocol):
"""Principal-aware catalogue and bounded reader for tabular sources."""
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[TabularSource]:
...
def get_source(
self,
session: object,
principal: object,
*,
source_ref: str,
) -> TabularSource | None:
...
def read_source(
self,
session: object,
principal: object,
*,
request: TabularReadRequest,
) -> TabularReadResult:
...
@runtime_checkable
class TabularSnapshotWriter(Protocol):
"""Optional capability for importing bounded static tabular snapshots."""
def create_snapshot(
self,
session: object,
principal: object,
*,
snapshot: TabularSnapshotInput,
) -> TabularSource:
...
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
return capability if isinstance(capability, TabularSourceProvider) else None
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
return capability if isinstance(capability, TabularSnapshotWriter) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"TabularColumn",
"TabularReadRequest",
"TabularReadResult",
"TabularSnapshotInput",
"TabularSnapshotWriter",
"TabularSource",
"TabularSourceAccessError",
"TabularSourceError",
"TabularSourceNotFoundError",
"TabularSourceProvider",
"TabularSourceValidationError",
"parse_tabular_csv",
"tabular_snapshot_writer",
"tabular_source_provider",
]