Define connector runtime preview contract

This commit is contained in:
2026-08-02 14:54:44 +02:00
parent c6ef644842
commit af5c6af0e7
3 changed files with 312 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from urllib.parse import urlsplit
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
ConnectorDiagnosticStage = Literal[
"configuration",
"authentication",
"discovery",
"read",
"mapping",
"planning",
"apply",
"reconciliation",
]
ConnectorEffectKind = Literal[
"create",
"update",
"delete",
"conflict",
"unchanged",
"ignored",
]
ConnectorOutcomeState = Literal[
"preview",
"accepted",
"rejected",
"outcome_unknown",
]
class ConnectorContractError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorEndpoint:
"""Sanitized endpoint identity. Credentials never belong in this value."""
url: str
credential_ref: str | None = None
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
options: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
normalized = self.url.strip()
parsed = urlsplit(normalized)
if not parsed.scheme or not parsed.hostname:
raise ConnectorContractError("Connector endpoints require an absolute URL.")
if parsed.username is not None or parsed.password is not None:
raise ConnectorContractError(
"Connector endpoint URLs must not contain credentials."
)
object.__setattr__(self, "url", normalized)
if self.credential_ref is not None:
credential_ref = self.credential_ref.strip()
if not credential_ref:
raise ConnectorContractError("Credential references cannot be blank.")
object.__setattr__(self, "credential_ref", credential_ref)
@dataclass(frozen=True, slots=True)
class ConnectorDiagnostic:
severity: ConnectorDiagnosticSeverity
code: str
message: str
stage: ConnectorDiagnosticStage
retryable: bool = False
source_ref: str | None = None
object_ref: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.code.strip() or len(self.code) > 120:
raise ConnectorContractError(
"Connector diagnostic codes must contain 1 to 120 characters."
)
if not self.message.strip():
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectPreview:
effect: ConnectorEffectKind
source_object_ref: str
target_object_ref: str | None = None
changed_fields: tuple[str, ...] = ()
sample: Mapping[str, object] = field(default_factory=dict)
reason_code: str | None = None
outcome: ConnectorOutcomeState = "preview"
revision: str | None = None
def __post_init__(self) -> None:
if not self.source_object_ref.strip():
raise ConnectorContractError("Preview effects require a source object reference.")
if self.outcome != "preview":
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
@dataclass(frozen=True, slots=True)
class ConnectorEffectSummary:
creates: int = 0
updates: int = 0
deletes: int = 0
conflicts: int = 0
unchanged: int = 0
ignored: int = 0
@property
def total(self) -> int:
return (
self.creates
+ self.updates
+ self.deletes
+ self.conflicts
+ self.unchanged
+ self.ignored
)
@dataclass(frozen=True, slots=True)
class ConnectorDryRunRequest:
tenant_id: str
source_ref: str
force_full: bool = False
max_items: int = 1_000
expected_source_revision: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.source_ref.strip():
raise ConnectorContractError("Dry runs require tenant and source references.")
if not 1 <= self.max_items <= 10_000:
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
@dataclass(frozen=True, slots=True)
class ConnectorDryRunResult:
contract_version: str
source_ref: str
source_revision: str
source_fingerprint: str
input_hash: str
generated_at: datetime
summary: ConnectorEffectSummary
effects: tuple[ConnectorEffectPreview, ...] = ()
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
truncated: bool = False
stale: bool = False
apply_token: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
raise ConnectorContractError(
f"Unsupported connector contract version: {self.contract_version!r}."
)
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
if not str(getattr(self, name)).strip():
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
if self.summary.total != len(self.effects):
raise ConnectorContractError(
"Dry-run summary counts must match the returned effect list."
)
@property
def can_apply(self) -> bool:
return (
self.apply_token is not None
and not self.truncated
and not self.stale
and self.summary.conflicts == 0
and not any(item.severity == "error" for item in self.diagnostics)
)
def summarize_connector_effects(
effects: tuple[ConnectorEffectPreview, ...],
) -> ConnectorEffectSummary:
counts = Counter(item.effect for item in effects)
return ConnectorEffectSummary(
creates=counts["create"],
updates=counts["update"],
deletes=counts["delete"],
conflicts=counts["conflict"],
unchanged=counts["unchanged"],
ignored=counts["ignored"],
)
@runtime_checkable
class ConnectorDryRunProvider(Protocol):
def preview(
self,
session: object,
principal: object,
*,
request: ConnectorDryRunRequest,
) -> ConnectorDryRunResult:
...
__all__ = [
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
"ConnectorContractError",
"ConnectorDiagnostic",
"ConnectorDiagnosticSeverity",
"ConnectorDiagnosticStage",
"ConnectorDryRunProvider",
"ConnectorDryRunRequest",
"ConnectorDryRunResult",
"ConnectorEffectKind",
"ConnectorEffectPreview",
"ConnectorEffectSummary",
"ConnectorEndpoint",
"ConnectorOutcomeState",
"summarize_connector_effects",
]