Add provider-neutral tabular source contracts
This commit is contained in:
230
src/govoplan_core/core/tabular_sources.py
Normal file
230
src/govoplan_core/core/tabular_sources.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user