data-contract-registry 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data_contract_registry/__init__.py +54 -0
- data_contract_registry/app.py +228 -0
- data_contract_registry/audit_stream.py +84 -0
- data_contract_registry/compatibility.py +197 -0
- data_contract_registry/from_decision_card.py +49 -0
- data_contract_registry/models.py +148 -0
- data_contract_registry/registry.py +166 -0
- data_contract_registry-0.1.1.dist-info/METADATA +246 -0
- data_contract_registry-0.1.1.dist-info/RECORD +11 -0
- data_contract_registry-0.1.1.dist-info/WHEEL +4 -0
- data_contract_registry-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
data-contract-registry — schema registry for data contracts.
|
|
3
|
+
|
|
4
|
+
This package answers one question for a data team: *can this producer ship the
|
|
5
|
+
new schema without breaking any downstream consumer it has a contract with?*
|
|
6
|
+
Contracts carry the schema itself, plus the things that always matter and
|
|
7
|
+
usually get lost in slack — owners, freshness SLA, deprecation policy.
|
|
8
|
+
|
|
9
|
+
Two surfaces:
|
|
10
|
+
|
|
11
|
+
Library: `from data_contract_registry import DataContract, ContractRegistry`
|
|
12
|
+
HTTP: `uvicorn data_contract_registry.app:app` (optional `[api]` extra)
|
|
13
|
+
|
|
14
|
+
Compatibility modes follow the Confluent schema-registry conventions so this
|
|
15
|
+
slots into existing data org playbooks:
|
|
16
|
+
|
|
17
|
+
BACKWARD new schema can read data produced by the previous schema
|
|
18
|
+
FORWARD previous schema can read data produced by the new schema
|
|
19
|
+
FULL both of the above
|
|
20
|
+
NONE anything goes (use for first-time onboarding only)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .compatibility import CompatibilityChecker, CompatibilityMode
|
|
26
|
+
from .from_decision_card import contract_owner_from_decision_card
|
|
27
|
+
from .models import (
|
|
28
|
+
CompatibilityReport,
|
|
29
|
+
ContractStatus,
|
|
30
|
+
DataContract,
|
|
31
|
+
DataField,
|
|
32
|
+
FieldType,
|
|
33
|
+
FreshnessSLA,
|
|
34
|
+
Owner,
|
|
35
|
+
)
|
|
36
|
+
from .registry import ContractRegistry, RegistryError
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.1"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"CompatibilityChecker",
|
|
42
|
+
"CompatibilityMode",
|
|
43
|
+
"CompatibilityReport",
|
|
44
|
+
"ContractRegistry",
|
|
45
|
+
"ContractStatus",
|
|
46
|
+
"DataContract",
|
|
47
|
+
"DataField",
|
|
48
|
+
"FieldType",
|
|
49
|
+
"FreshnessSLA",
|
|
50
|
+
"Owner",
|
|
51
|
+
"RegistryError",
|
|
52
|
+
"__version__",
|
|
53
|
+
"contract_owner_from_decision_card",
|
|
54
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI app — eight endpoints around the in-memory registry.
|
|
3
|
+
|
|
4
|
+
GET / service info
|
|
5
|
+
GET /healthz liveness probe
|
|
6
|
+
GET /datasets list registered dataset IDs
|
|
7
|
+
POST /contracts register / promote a contract
|
|
8
|
+
POST /contracts/check dry-run compatibility check
|
|
9
|
+
GET /contracts/{ds}/latest latest ACTIVE contract for a dataset
|
|
10
|
+
GET /contracts/{ds}/versions full history
|
|
11
|
+
GET /contracts/{ds}/versions/{v} fetch a specific version
|
|
12
|
+
POST /contracts/{ds}/versions/{v}/deprecate
|
|
13
|
+
POST /contracts/{ds}/versions/{v}/archive
|
|
14
|
+
POST /contracts/owners/from-decision-card cross-ecosystem hook
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from fastapi import FastAPI, HTTPException, status
|
|
25
|
+
from pydantic import BaseModel, ValidationError
|
|
26
|
+
|
|
27
|
+
from . import __version__, audit_stream
|
|
28
|
+
from .compatibility import CompatibilityMode
|
|
29
|
+
from .from_decision_card import contract_owner_from_decision_card
|
|
30
|
+
from .models import CompatibilityReport, DataContract, Owner
|
|
31
|
+
from .registry import ContractRegistry, RegistryError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _RegisterRequest(BaseModel):
|
|
35
|
+
contract: DataContract
|
|
36
|
+
compatibility: CompatibilityMode = "backward"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _DeprecateRequest(BaseModel):
|
|
40
|
+
deprecation_uri: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
45
|
+
app.state.registry = ContractRegistry()
|
|
46
|
+
# Shared httpx client for best-effort audit-stream emission. Always
|
|
47
|
+
# created; the audit_stream module no-ops when AUDIT_STREAM_URL is unset.
|
|
48
|
+
app.state.http_client = httpx.AsyncClient(
|
|
49
|
+
headers={"User-Agent": f"data-contract-registry/{__version__} (+https://kineticgain.com)"},
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
await app.state.http_client.aclose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
app = FastAPI(
|
|
58
|
+
title="data-contract-registry",
|
|
59
|
+
version=__version__,
|
|
60
|
+
description=(
|
|
61
|
+
"Schema registry for data contracts: semver, compatibility checks, "
|
|
62
|
+
"ownership, freshness SLAs. Bridges to procurement-decision-api via "
|
|
63
|
+
"POST /contracts/owners/from-decision-card."
|
|
64
|
+
),
|
|
65
|
+
lifespan=_lifespan,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _registry() -> ContractRegistry:
|
|
70
|
+
"""Typed accessor so mypy strict doesn't choke on app.state."""
|
|
71
|
+
registry = app.state.registry
|
|
72
|
+
assert isinstance(registry, ContractRegistry)
|
|
73
|
+
return registry
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _http_client() -> httpx.AsyncClient:
|
|
77
|
+
"""Shared httpx client used by audit_stream.emit (best-effort)."""
|
|
78
|
+
client = app.state.http_client
|
|
79
|
+
assert isinstance(client, httpx.AsyncClient)
|
|
80
|
+
return client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.get("/", tags=["meta"])
|
|
84
|
+
async def root() -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"name": "data-contract-registry",
|
|
87
|
+
"version": __version__,
|
|
88
|
+
"description": (
|
|
89
|
+
"Registers + checks data contracts. Compatibility modes follow Confluent: "
|
|
90
|
+
"backward / forward / full / none."
|
|
91
|
+
),
|
|
92
|
+
"endpoints": {
|
|
93
|
+
"GET /": "this page",
|
|
94
|
+
"GET /healthz": "liveness probe",
|
|
95
|
+
"GET /datasets": "list registered dataset IDs",
|
|
96
|
+
"POST /contracts": "register a contract (checks compatibility first)",
|
|
97
|
+
"POST /contracts/check": "dry-run compatibility check without registering",
|
|
98
|
+
"GET /contracts/{ds}/latest": "latest active contract for a dataset",
|
|
99
|
+
"GET /contracts/{ds}/versions": "full version history",
|
|
100
|
+
"GET /contracts/{ds}/versions/{v}": "one specific version",
|
|
101
|
+
"POST /contracts/{ds}/versions/{v}/deprecate": "mark a version deprecated",
|
|
102
|
+
"POST /contracts/{ds}/versions/{v}/archive": "archive a version",
|
|
103
|
+
"POST /contracts/owners/from-decision-card": "Decision Card -> Owner list",
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.get("/healthz", tags=["meta"])
|
|
109
|
+
async def healthz() -> dict[str, str]:
|
|
110
|
+
return {"status": "ok"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.get("/datasets", tags=["catalog"])
|
|
114
|
+
async def list_datasets() -> dict[str, list[str]]:
|
|
115
|
+
return {"datasets": _registry().datasets()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.post("/contracts", tags=["contracts"], status_code=201)
|
|
119
|
+
async def register_contract(req: _RegisterRequest) -> dict[str, Any]:
|
|
120
|
+
try:
|
|
121
|
+
report = _registry().register(req.contract, compatibility=req.compatibility)
|
|
122
|
+
except RegistryError as err:
|
|
123
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) from err
|
|
124
|
+
|
|
125
|
+
if not report.compatible:
|
|
126
|
+
await audit_stream.emit(
|
|
127
|
+
_http_client(),
|
|
128
|
+
kind="contract_compatibility_failed",
|
|
129
|
+
payload={
|
|
130
|
+
"dataset_id": req.contract.dataset_id,
|
|
131
|
+
"version": req.contract.version,
|
|
132
|
+
"mode": report.mode,
|
|
133
|
+
"issue_count": len(report.issues),
|
|
134
|
+
"issues": [i.model_dump(mode="json") for i in report.issues],
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
raise HTTPException(
|
|
138
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
139
|
+
detail={
|
|
140
|
+
"compatible": False,
|
|
141
|
+
"mode": report.mode,
|
|
142
|
+
"issues": [i.model_dump(mode="json") for i in report.issues],
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
await audit_stream.emit(
|
|
146
|
+
_http_client(),
|
|
147
|
+
kind="contract_promoted",
|
|
148
|
+
payload={
|
|
149
|
+
"dataset_id": req.contract.dataset_id,
|
|
150
|
+
"version": req.contract.version,
|
|
151
|
+
"mode": report.mode,
|
|
152
|
+
"owners": [o.team for o in req.contract.owners],
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
return {
|
|
156
|
+
"status": "registered",
|
|
157
|
+
"dataset_id": req.contract.dataset_id,
|
|
158
|
+
"version": req.contract.version,
|
|
159
|
+
"compatibility": report.model_dump(mode="json"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.post("/contracts/check", tags=["contracts"])
|
|
164
|
+
async def check_contract(req: _RegisterRequest) -> CompatibilityReport:
|
|
165
|
+
return _registry().check(req.contract, compatibility=req.compatibility)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.get("/contracts/{dataset_id}/latest", tags=["contracts"])
|
|
169
|
+
async def latest_contract(dataset_id: str) -> DataContract:
|
|
170
|
+
try:
|
|
171
|
+
return _registry().latest(dataset_id)
|
|
172
|
+
except RegistryError as err:
|
|
173
|
+
raise HTTPException(status_code=404, detail=str(err)) from err
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.get("/contracts/{dataset_id}/versions", tags=["contracts"])
|
|
177
|
+
async def contract_history(dataset_id: str) -> list[DataContract]:
|
|
178
|
+
try:
|
|
179
|
+
return _registry().history(dataset_id)
|
|
180
|
+
except RegistryError as err:
|
|
181
|
+
raise HTTPException(status_code=404, detail=str(err)) from err
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.get("/contracts/{dataset_id}/versions/{version}", tags=["contracts"])
|
|
185
|
+
async def get_version(dataset_id: str, version: str) -> DataContract:
|
|
186
|
+
try:
|
|
187
|
+
return _registry().get(dataset_id, version)
|
|
188
|
+
except RegistryError as err:
|
|
189
|
+
raise HTTPException(status_code=404, detail=str(err)) from err
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.post("/contracts/{dataset_id}/versions/{version}/deprecate", tags=["contracts"])
|
|
193
|
+
async def deprecate_version(
|
|
194
|
+
dataset_id: str,
|
|
195
|
+
version: str,
|
|
196
|
+
req: _DeprecateRequest,
|
|
197
|
+
) -> DataContract:
|
|
198
|
+
try:
|
|
199
|
+
contract = _registry().deprecate(dataset_id, version, deprecation_uri=req.deprecation_uri)
|
|
200
|
+
except RegistryError as err:
|
|
201
|
+
raise HTTPException(status_code=404, detail=str(err)) from err
|
|
202
|
+
await audit_stream.emit(
|
|
203
|
+
_http_client(),
|
|
204
|
+
kind="contract_deprecated",
|
|
205
|
+
payload={
|
|
206
|
+
"dataset_id": dataset_id,
|
|
207
|
+
"version": version,
|
|
208
|
+
"deprecation_uri": req.deprecation_uri,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
return contract
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.post("/contracts/{dataset_id}/versions/{version}/archive", tags=["contracts"])
|
|
215
|
+
async def archive_version(dataset_id: str, version: str) -> DataContract:
|
|
216
|
+
try:
|
|
217
|
+
return _registry().archive(dataset_id, version)
|
|
218
|
+
except RegistryError as err:
|
|
219
|
+
raise HTTPException(status_code=404, detail=str(err)) from err
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.post("/contracts/owners/from-decision-card", tags=["bridge"])
|
|
223
|
+
async def owners_from_decision_card(card: dict[str, Any]) -> list[Owner]:
|
|
224
|
+
"""The cross-ecosystem hook — pull owners out of a Decision Card."""
|
|
225
|
+
try:
|
|
226
|
+
return contract_owner_from_decision_card(card)
|
|
227
|
+
except (ValueError, ValidationError) as err:
|
|
228
|
+
raise HTTPException(status_code=400, detail=str(err)) from err
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional audit-stream-py integration.
|
|
3
|
+
|
|
4
|
+
When the `AUDIT_STREAM_URL` env var is set, this module fires governance
|
|
5
|
+
events at `{AUDIT_STREAM_URL}/events` for the moments the service produces.
|
|
6
|
+
Best-effort: a failed POST is logged, not raised — audit-stream outages
|
|
7
|
+
must never block contract registration or deprecation.
|
|
8
|
+
|
|
9
|
+
Event kinds this service emits:
|
|
10
|
+
contract_promoted on POST /contracts when the new version
|
|
11
|
+
is compatible and successfully registered
|
|
12
|
+
contract_compatibility_failed on POST /contracts when the compatibility
|
|
13
|
+
check fails (HTTP 422). This is the
|
|
14
|
+
"we tried to ship a breaking change"
|
|
15
|
+
governance signal — record it.
|
|
16
|
+
contract_deprecated on POST /contracts/{ds}/versions/{v}/deprecate
|
|
17
|
+
|
|
18
|
+
Same opt-in pattern as procurement-decision-api.audit_stream,
|
|
19
|
+
aeo-validator-service.audit_stream, and policy-as-code-engine.audit_stream.
|
|
20
|
+
Identical config envvars.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT_S = 2.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_enabled() -> bool:
|
|
34
|
+
"""True when AUDIT_STREAM_URL is set to a non-empty value."""
|
|
35
|
+
return bool(os.environ.get("AUDIT_STREAM_URL", "").strip())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def base_url() -> str | None:
|
|
39
|
+
"""Stripped audit-stream base URL, or None when disabled."""
|
|
40
|
+
raw = os.environ.get("AUDIT_STREAM_URL", "").strip()
|
|
41
|
+
if not raw:
|
|
42
|
+
return None
|
|
43
|
+
return raw.rstrip("/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def timeout_s() -> float:
|
|
47
|
+
"""Configured per-call timeout. Defaults to 2.5s."""
|
|
48
|
+
raw = os.environ.get("AUDIT_STREAM_TIMEOUT_S", "").strip()
|
|
49
|
+
if not raw:
|
|
50
|
+
return DEFAULT_TIMEOUT_S
|
|
51
|
+
try:
|
|
52
|
+
return max(0.1, float(raw))
|
|
53
|
+
except ValueError:
|
|
54
|
+
return DEFAULT_TIMEOUT_S
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def emit(
|
|
58
|
+
client: httpx.AsyncClient,
|
|
59
|
+
*,
|
|
60
|
+
kind: str,
|
|
61
|
+
payload: dict[str, Any],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Fire one event. Silent no-op when AUDIT_STREAM_URL is unset."""
|
|
64
|
+
url = base_url()
|
|
65
|
+
if url is None:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
body = {
|
|
69
|
+
"kind": kind,
|
|
70
|
+
"source": "data-contract-registry",
|
|
71
|
+
"payload": payload,
|
|
72
|
+
}
|
|
73
|
+
try:
|
|
74
|
+
response = await client.post(
|
|
75
|
+
f"{url}/events",
|
|
76
|
+
json=body,
|
|
77
|
+
timeout=timeout_s(),
|
|
78
|
+
)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
except (httpx.HTTPError, OSError) as err:
|
|
81
|
+
print(
|
|
82
|
+
f"audit-stream emit failed (kind={kind}): {type(err).__name__}: {err}",
|
|
83
|
+
flush=True,
|
|
84
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compatibility checker — does new schema break consumers of old schema?
|
|
3
|
+
|
|
4
|
+
Modes follow the Confluent schema registry conventions:
|
|
5
|
+
|
|
6
|
+
BACKWARD new schema can read data produced by the previous schema
|
|
7
|
+
(consumers can upgrade first)
|
|
8
|
+
FORWARD previous schema can read data produced by the new schema
|
|
9
|
+
(producers can upgrade first)
|
|
10
|
+
FULL both
|
|
11
|
+
NONE anything goes; first-time onboarding only
|
|
12
|
+
|
|
13
|
+
For our six-type system the rules are:
|
|
14
|
+
|
|
15
|
+
BACKWARD-breaking changes (new schema rejects old data):
|
|
16
|
+
- removed a field that old data carried
|
|
17
|
+
- changed a field's type
|
|
18
|
+
- turned an optional field into required (old data missing it -> reject)
|
|
19
|
+
- shrunk an enum (old enum value -> reject)
|
|
20
|
+
|
|
21
|
+
FORWARD-breaking changes (old schema rejects new data):
|
|
22
|
+
- added a required field old schema doesn't know about
|
|
23
|
+
(only matters if old schema rejects unknown fields; we treat additions
|
|
24
|
+
to enums as forward-compatible since extra values are fine for readers)
|
|
25
|
+
|
|
26
|
+
This is intentionally smaller than the Avro/Protobuf rule set — the point is
|
|
27
|
+
"can I promote this", not "let me prove every possible serialisation path."
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Literal
|
|
33
|
+
|
|
34
|
+
from .models import (
|
|
35
|
+
CompatibilityIssue,
|
|
36
|
+
CompatibilityReport,
|
|
37
|
+
DataContract,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
CompatibilityMode = Literal["backward", "forward", "full", "none"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CompatibilityChecker:
|
|
44
|
+
"""Stateless. Cheap to construct. Reuse one per process."""
|
|
45
|
+
|
|
46
|
+
def check(
|
|
47
|
+
self,
|
|
48
|
+
previous: DataContract,
|
|
49
|
+
proposed: DataContract,
|
|
50
|
+
*,
|
|
51
|
+
mode: CompatibilityMode = "backward",
|
|
52
|
+
) -> CompatibilityReport:
|
|
53
|
+
if previous.dataset_id != proposed.dataset_id:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"dataset_id mismatch: previous={previous.dataset_id!r} proposed={proposed.dataset_id!r}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
issues: list[CompatibilityIssue] = []
|
|
59
|
+
issues.extend(self._version_issues(previous, proposed))
|
|
60
|
+
issues.extend(self._owner_issues(proposed))
|
|
61
|
+
issues.extend(self._primary_key_issues(previous, proposed))
|
|
62
|
+
|
|
63
|
+
if mode in ("backward", "full"):
|
|
64
|
+
issues.extend(self._backward_issues(previous, proposed))
|
|
65
|
+
if mode in ("forward", "full"):
|
|
66
|
+
issues.extend(self._forward_issues(previous, proposed))
|
|
67
|
+
|
|
68
|
+
compatible = not any(i.severity == "error" for i in issues)
|
|
69
|
+
return CompatibilityReport(compatible=compatible, mode=mode, issues=issues)
|
|
70
|
+
|
|
71
|
+
# ---- structural checks (run regardless of mode) ---------------------
|
|
72
|
+
|
|
73
|
+
def _version_issues(self, previous: DataContract, proposed: DataContract) -> list[CompatibilityIssue]:
|
|
74
|
+
prev = _parse_semver(previous.version)
|
|
75
|
+
new = _parse_semver(proposed.version)
|
|
76
|
+
if new <= prev:
|
|
77
|
+
return [
|
|
78
|
+
CompatibilityIssue(
|
|
79
|
+
severity="error",
|
|
80
|
+
field=None,
|
|
81
|
+
kind="version_not_increasing",
|
|
82
|
+
message=(
|
|
83
|
+
f"new version {proposed.version!r} must be strictly greater than {previous.version!r}"
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
def _owner_issues(self, proposed: DataContract) -> list[CompatibilityIssue]:
|
|
90
|
+
if not proposed.owners:
|
|
91
|
+
return [
|
|
92
|
+
CompatibilityIssue(
|
|
93
|
+
severity="error",
|
|
94
|
+
field=None,
|
|
95
|
+
kind="owner_missing",
|
|
96
|
+
message="proposed contract must declare at least one owner",
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
def _primary_key_issues(self, previous: DataContract, proposed: DataContract) -> list[CompatibilityIssue]:
|
|
102
|
+
if previous.primary_key != proposed.primary_key:
|
|
103
|
+
return [
|
|
104
|
+
CompatibilityIssue(
|
|
105
|
+
severity="error",
|
|
106
|
+
field=None,
|
|
107
|
+
kind="primary_key_changed",
|
|
108
|
+
message=(f"primary_key changed: {previous.primary_key} -> {proposed.primary_key}"),
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
# ---- backward / forward checks --------------------------------------
|
|
114
|
+
|
|
115
|
+
def _backward_issues(self, previous: DataContract, proposed: DataContract) -> list[CompatibilityIssue]:
|
|
116
|
+
issues: list[CompatibilityIssue] = []
|
|
117
|
+
|
|
118
|
+
prev_fields = {f.name: f for f in previous.fields}
|
|
119
|
+
new_fields = {f.name: f for f in proposed.fields}
|
|
120
|
+
|
|
121
|
+
# Removed fields: a field that was in the old schema and isn't in the new.
|
|
122
|
+
for name, prev in prev_fields.items():
|
|
123
|
+
if name not in new_fields:
|
|
124
|
+
issues.append(
|
|
125
|
+
CompatibilityIssue(
|
|
126
|
+
severity="error",
|
|
127
|
+
field=name,
|
|
128
|
+
kind="field_removed",
|
|
129
|
+
message=f"field {name!r} was removed; old data will fail validation",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
new = new_fields[name]
|
|
134
|
+
if new.type != prev.type:
|
|
135
|
+
issues.append(
|
|
136
|
+
CompatibilityIssue(
|
|
137
|
+
severity="error",
|
|
138
|
+
field=name,
|
|
139
|
+
kind="field_type_changed",
|
|
140
|
+
message=(
|
|
141
|
+
f"field {name!r} type changed: {prev.type} -> {new.type}; "
|
|
142
|
+
"values from old schema may not round-trip"
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if not prev.required and new.required:
|
|
147
|
+
issues.append(
|
|
148
|
+
CompatibilityIssue(
|
|
149
|
+
severity="error",
|
|
150
|
+
field=name,
|
|
151
|
+
kind="field_required_added",
|
|
152
|
+
message=(f"field {name!r} was optional, now required; old rows missing it will fail"),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
if prev.enum and new.enum and set(new.enum) - set(prev.enum) != set(new.enum) - set(prev.enum):
|
|
156
|
+
# Should never happen; guard kept for clarity.
|
|
157
|
+
pass
|
|
158
|
+
if prev.enum and new.enum:
|
|
159
|
+
shrunk = set(prev.enum) - set(new.enum)
|
|
160
|
+
if shrunk:
|
|
161
|
+
issues.append(
|
|
162
|
+
CompatibilityIssue(
|
|
163
|
+
severity="error",
|
|
164
|
+
field=name,
|
|
165
|
+
kind="field_enum_shrunk",
|
|
166
|
+
message=(
|
|
167
|
+
f"field {name!r} enum shrunk; removed values {sorted(map(str, shrunk))} "
|
|
168
|
+
"may appear in old data"
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return issues
|
|
173
|
+
|
|
174
|
+
def _forward_issues(self, previous: DataContract, proposed: DataContract) -> list[CompatibilityIssue]:
|
|
175
|
+
issues: list[CompatibilityIssue] = []
|
|
176
|
+
prev_fields = {f.name: f for f in previous.fields}
|
|
177
|
+
new_fields = {f.name: f for f in proposed.fields}
|
|
178
|
+
|
|
179
|
+
for name, new in new_fields.items():
|
|
180
|
+
if name not in prev_fields and new.required:
|
|
181
|
+
issues.append(
|
|
182
|
+
CompatibilityIssue(
|
|
183
|
+
severity="error",
|
|
184
|
+
field=name,
|
|
185
|
+
kind="field_required_added",
|
|
186
|
+
message=(
|
|
187
|
+
f"required field {name!r} added; consumers on the old schema "
|
|
188
|
+
"won't know how to populate it"
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return issues
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_semver(version: str) -> tuple[int, int, int]:
|
|
196
|
+
major, minor, patch = version.split(".")
|
|
197
|
+
return int(major), int(minor), int(patch)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bridge to AI Procurement Decision Cards.
|
|
3
|
+
|
|
4
|
+
When a buyer approves a vendor whose data product the team will consume, the
|
|
5
|
+
Decision Card's `buyer.name` + `decision_maker` are *the right answers* to
|
|
6
|
+
"who owns the contract on our side". This helper extracts those fields into
|
|
7
|
+
`Owner` records so a freshly registered contract carries them automatically
|
|
8
|
+
instead of asking the data team to re-type them.
|
|
9
|
+
|
|
10
|
+
Tiny, but it's the third cross-ecosystem hook in the portfolio.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .models import Owner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def contract_owner_from_decision_card(card: dict[str, Any]) -> list[Owner]:
|
|
21
|
+
"""
|
|
22
|
+
Pull a credible owner list out of a Kinetic Gain Procurement Decision Card.
|
|
23
|
+
|
|
24
|
+
The buyer's team is always owner #0. If the card also declares a
|
|
25
|
+
`decision_maker.role` we add that as a secondary owner so on-call routing
|
|
26
|
+
has a name to page.
|
|
27
|
+
"""
|
|
28
|
+
if "buyer" not in card or not isinstance(card["buyer"], dict):
|
|
29
|
+
raise ValueError("Decision Card is missing required 'buyer' object")
|
|
30
|
+
buyer = card["buyer"]
|
|
31
|
+
name = buyer.get("name")
|
|
32
|
+
if not name or not isinstance(name, str):
|
|
33
|
+
raise ValueError("buyer.name is required and must be a non-empty string")
|
|
34
|
+
|
|
35
|
+
owners: list[Owner] = [Owner(team=name, contact=buyer.get("contact") or None)]
|
|
36
|
+
|
|
37
|
+
decision_maker = card.get("decision_maker") or {}
|
|
38
|
+
if isinstance(decision_maker, dict):
|
|
39
|
+
role = decision_maker.get("role")
|
|
40
|
+
dm_name = decision_maker.get("name")
|
|
41
|
+
if role:
|
|
42
|
+
owners.append(
|
|
43
|
+
Owner(
|
|
44
|
+
team=f"{role}" + (f" ({dm_name})" if dm_name else ""),
|
|
45
|
+
contact=decision_maker.get("authority") or None,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return owners
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic v2 models for data contracts.
|
|
3
|
+
|
|
4
|
+
A `DataContract` is the unit of agreement between a data producer and one or
|
|
5
|
+
more consumers. The schema is intentionally small — six field types covering
|
|
6
|
+
~99% of real-world dataset columns — plus the metadata that always matters:
|
|
7
|
+
|
|
8
|
+
- owners who to wake up
|
|
9
|
+
- freshness SLA how stale is too stale
|
|
10
|
+
- status draft / active / deprecated / archived
|
|
11
|
+
- deprecation_uri when status == "deprecated", where the migration plan lives
|
|
12
|
+
|
|
13
|
+
Versions are full snapshots (not diffs); the registry holds the version history.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
22
|
+
|
|
23
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StrictModel(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
FieldType = Literal["string", "integer", "number", "boolean", "timestamp", "json"]
|
|
31
|
+
"""Six canonical primitives. `json` is the escape hatch for nested payloads."""
|
|
32
|
+
|
|
33
|
+
ContractStatus = Literal["draft", "active", "deprecated", "archived"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataField(StrictModel):
|
|
37
|
+
"""One column / attribute in the contract."""
|
|
38
|
+
|
|
39
|
+
name: str = Field(..., min_length=1)
|
|
40
|
+
type: FieldType
|
|
41
|
+
required: bool = True
|
|
42
|
+
description: str | None = None
|
|
43
|
+
enum: list[str | int | bool] | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="If set, the field value must be one of these.",
|
|
46
|
+
)
|
|
47
|
+
deprecated: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Owner(StrictModel):
|
|
51
|
+
"""One owner of a contract — usually a team."""
|
|
52
|
+
|
|
53
|
+
team: str = Field(..., min_length=1)
|
|
54
|
+
contact: str | None = Field(
|
|
55
|
+
default=None,
|
|
56
|
+
description="Slack channel, pager group, or email.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FreshnessSLA(StrictModel):
|
|
61
|
+
"""How stale the dataset is allowed to be before it's considered broken."""
|
|
62
|
+
|
|
63
|
+
max_lag_seconds: int = Field(..., gt=0)
|
|
64
|
+
measurement: str = Field(
|
|
65
|
+
default="event_time",
|
|
66
|
+
description="Field whose age is measured: 'event_time', 'ingested_at', etc.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DataContract(StrictModel):
|
|
71
|
+
"""
|
|
72
|
+
The whole contract document.
|
|
73
|
+
|
|
74
|
+
Stable identity is `(dataset_id, version)`. Versions follow semver:
|
|
75
|
+
|
|
76
|
+
MAJOR incompatible change (removed field, renamed field, type change)
|
|
77
|
+
MINOR new optional field, new enum value
|
|
78
|
+
PATCH description fix, owner update, no schema change
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
dataset_id: str = Field(..., min_length=1, max_length=128)
|
|
82
|
+
version: str = Field(..., description="Semver like '1.2.0'.")
|
|
83
|
+
description: str | None = None
|
|
84
|
+
fields: list[DataField] = Field(..., min_length=1)
|
|
85
|
+
owners: list[Owner] = Field(..., min_length=1)
|
|
86
|
+
freshness_sla: FreshnessSLA | None = None
|
|
87
|
+
status: ContractStatus = "draft"
|
|
88
|
+
deprecation_uri: str | None = None
|
|
89
|
+
primary_key: list[str] = Field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _check_invariants(self) -> DataContract:
|
|
93
|
+
if not SEMVER_RE.match(self.version):
|
|
94
|
+
raise ValueError(f"version must match MAJOR.MINOR.PATCH; got {self.version!r}")
|
|
95
|
+
names = [f.name for f in self.fields]
|
|
96
|
+
if len(names) != len(set(names)):
|
|
97
|
+
raise ValueError("field names must be unique within a contract")
|
|
98
|
+
for key in self.primary_key:
|
|
99
|
+
if key not in names:
|
|
100
|
+
raise ValueError(f"primary_key field {key!r} is not declared in fields")
|
|
101
|
+
if self.status == "deprecated" and not self.deprecation_uri:
|
|
102
|
+
raise ValueError("status='deprecated' requires deprecation_uri")
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def field(self, name: str) -> DataField | None:
|
|
106
|
+
for f in self.fields:
|
|
107
|
+
if f.name == name:
|
|
108
|
+
return f
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Compatibility outputs
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CompatibilityIssue(StrictModel):
|
|
118
|
+
"""A single problem flagged by the compatibility checker."""
|
|
119
|
+
|
|
120
|
+
severity: Literal["error", "warning"]
|
|
121
|
+
field: str | None = None
|
|
122
|
+
kind: Literal[
|
|
123
|
+
"field_removed",
|
|
124
|
+
"field_renamed",
|
|
125
|
+
"field_type_changed",
|
|
126
|
+
"field_required_added",
|
|
127
|
+
"field_enum_shrunk",
|
|
128
|
+
"version_not_increasing",
|
|
129
|
+
"owner_missing",
|
|
130
|
+
"primary_key_changed",
|
|
131
|
+
]
|
|
132
|
+
message: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CompatibilityReport(StrictModel):
|
|
136
|
+
"""Result of `CompatibilityChecker.check`."""
|
|
137
|
+
|
|
138
|
+
compatible: bool
|
|
139
|
+
mode: str
|
|
140
|
+
issues: list[CompatibilityIssue]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def errors(self) -> list[CompatibilityIssue]:
|
|
144
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def warnings(self) -> list[CompatibilityIssue]:
|
|
148
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory contract registry.
|
|
3
|
+
|
|
4
|
+
Holds many contracts by `(dataset_id, version)` and exposes the moves data
|
|
5
|
+
teams actually make:
|
|
6
|
+
|
|
7
|
+
register promote a new version after compatibility passes
|
|
8
|
+
latest give me the freshest contract for this dataset
|
|
9
|
+
history give me every version
|
|
10
|
+
deprecate mark a version deprecated with a migration URI
|
|
11
|
+
archive remove from active rotation (history preserved)
|
|
12
|
+
|
|
13
|
+
Concurrency: a single `threading.Lock` around the dict. Real deployments
|
|
14
|
+
would swap this for a SQL backend; the protocol is small enough that doing
|
|
15
|
+
so is mechanical.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from threading import Lock
|
|
21
|
+
|
|
22
|
+
from .compatibility import CompatibilityChecker, CompatibilityMode
|
|
23
|
+
from .models import CompatibilityReport, DataContract
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RegistryError(Exception):
|
|
27
|
+
"""Raised by `ContractRegistry` for caller-facing failures."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ContractRegistry:
|
|
31
|
+
"""Thread-safe in-memory registry. Cheap to share across threads."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("_checker", "_contracts", "_lock")
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, checker: CompatibilityChecker | None = None) -> None:
|
|
36
|
+
self._contracts: dict[str, list[DataContract]] = {}
|
|
37
|
+
self._checker = checker or CompatibilityChecker()
|
|
38
|
+
self._lock = Lock()
|
|
39
|
+
|
|
40
|
+
# ---- writes ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def register(
|
|
43
|
+
self,
|
|
44
|
+
contract: DataContract,
|
|
45
|
+
*,
|
|
46
|
+
compatibility: CompatibilityMode = "backward",
|
|
47
|
+
) -> CompatibilityReport:
|
|
48
|
+
"""
|
|
49
|
+
Register a new version of a contract.
|
|
50
|
+
|
|
51
|
+
If there's no existing version for this `dataset_id`, the contract is
|
|
52
|
+
accepted unconditionally (a compatible-by-vacuous-truth report is returned).
|
|
53
|
+
|
|
54
|
+
If there is, the new version is checked against the most recent active
|
|
55
|
+
version. The registration only proceeds if the report's `compatible` is
|
|
56
|
+
True.
|
|
57
|
+
"""
|
|
58
|
+
with self._lock:
|
|
59
|
+
history = self._contracts.setdefault(contract.dataset_id, [])
|
|
60
|
+
existing = next((c for c in reversed(history) if c.status == "active"), None)
|
|
61
|
+
if existing is None and history:
|
|
62
|
+
# Fall back to the most recent of any status.
|
|
63
|
+
existing = history[-1]
|
|
64
|
+
|
|
65
|
+
if existing is None:
|
|
66
|
+
history.append(contract)
|
|
67
|
+
return CompatibilityReport(compatible=True, mode=compatibility, issues=[])
|
|
68
|
+
|
|
69
|
+
if existing.version == contract.version:
|
|
70
|
+
raise RegistryError(
|
|
71
|
+
f"version {contract.version!r} of {contract.dataset_id!r} is already registered"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
report = self._checker.check(existing, contract, mode=compatibility)
|
|
75
|
+
if not report.compatible:
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
history.append(contract)
|
|
79
|
+
return report
|
|
80
|
+
|
|
81
|
+
def deprecate(
|
|
82
|
+
self,
|
|
83
|
+
dataset_id: str,
|
|
84
|
+
version: str,
|
|
85
|
+
*,
|
|
86
|
+
deprecation_uri: str,
|
|
87
|
+
) -> DataContract:
|
|
88
|
+
with self._lock:
|
|
89
|
+
history = self._contracts.get(dataset_id)
|
|
90
|
+
if not history:
|
|
91
|
+
raise RegistryError(f"unknown dataset_id: {dataset_id!r}")
|
|
92
|
+
for i, c in enumerate(history):
|
|
93
|
+
if c.version == version:
|
|
94
|
+
updated = c.model_copy(
|
|
95
|
+
update={"status": "deprecated", "deprecation_uri": deprecation_uri}
|
|
96
|
+
)
|
|
97
|
+
history[i] = updated
|
|
98
|
+
return updated
|
|
99
|
+
raise RegistryError(f"{dataset_id!r} has no version {version!r}")
|
|
100
|
+
|
|
101
|
+
def archive(self, dataset_id: str, version: str) -> DataContract:
|
|
102
|
+
with self._lock:
|
|
103
|
+
history = self._contracts.get(dataset_id)
|
|
104
|
+
if not history:
|
|
105
|
+
raise RegistryError(f"unknown dataset_id: {dataset_id!r}")
|
|
106
|
+
for i, c in enumerate(history):
|
|
107
|
+
if c.version == version:
|
|
108
|
+
updated = c.model_copy(update={"status": "archived"})
|
|
109
|
+
history[i] = updated
|
|
110
|
+
return updated
|
|
111
|
+
raise RegistryError(f"{dataset_id!r} has no version {version!r}")
|
|
112
|
+
|
|
113
|
+
# ---- reads ----------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def latest(self, dataset_id: str, *, include_non_active: bool = False) -> DataContract:
|
|
116
|
+
with self._lock:
|
|
117
|
+
history = self._contracts.get(dataset_id)
|
|
118
|
+
if not history:
|
|
119
|
+
raise RegistryError(f"unknown dataset_id: {dataset_id!r}")
|
|
120
|
+
if include_non_active:
|
|
121
|
+
return history[-1]
|
|
122
|
+
for c in reversed(history):
|
|
123
|
+
if c.status == "active":
|
|
124
|
+
return c
|
|
125
|
+
raise RegistryError(f"{dataset_id!r} has no active version")
|
|
126
|
+
|
|
127
|
+
def get(self, dataset_id: str, version: str) -> DataContract:
|
|
128
|
+
with self._lock:
|
|
129
|
+
history = self._contracts.get(dataset_id)
|
|
130
|
+
if not history:
|
|
131
|
+
raise RegistryError(f"unknown dataset_id: {dataset_id!r}")
|
|
132
|
+
for c in history:
|
|
133
|
+
if c.version == version:
|
|
134
|
+
return c
|
|
135
|
+
raise RegistryError(f"{dataset_id!r} has no version {version!r}")
|
|
136
|
+
|
|
137
|
+
def history(self, dataset_id: str) -> list[DataContract]:
|
|
138
|
+
with self._lock:
|
|
139
|
+
history = self._contracts.get(dataset_id)
|
|
140
|
+
if not history:
|
|
141
|
+
raise RegistryError(f"unknown dataset_id: {dataset_id!r}")
|
|
142
|
+
return list(history)
|
|
143
|
+
|
|
144
|
+
def datasets(self) -> list[str]:
|
|
145
|
+
with self._lock:
|
|
146
|
+
return list(self._contracts.keys())
|
|
147
|
+
|
|
148
|
+
def __contains__(self, dataset_id: object) -> bool:
|
|
149
|
+
with self._lock:
|
|
150
|
+
return dataset_id in self._contracts
|
|
151
|
+
|
|
152
|
+
# ---- dry-run --------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def check(
|
|
155
|
+
self,
|
|
156
|
+
contract: DataContract,
|
|
157
|
+
*,
|
|
158
|
+
compatibility: CompatibilityMode = "backward",
|
|
159
|
+
) -> CompatibilityReport:
|
|
160
|
+
"""Check a proposed contract WITHOUT registering it."""
|
|
161
|
+
with self._lock:
|
|
162
|
+
history = self._contracts.get(contract.dataset_id)
|
|
163
|
+
existing = next((c for c in reversed(history or []) if c.status == "active"), None)
|
|
164
|
+
if existing is None:
|
|
165
|
+
return CompatibilityReport(compatible=True, mode=compatibility, issues=[])
|
|
166
|
+
return self._checker.check(existing, contract, mode=compatibility)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: data-contract-registry
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Schema registry for data contracts: semver versioning, compatibility checks (backward/forward/full), ownership, freshness SLAs. The 'you can't promote it without an approved contract' pattern for data pipelines. Optional audit-stream-py integration via AUDIT_STREAM_URL.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mizcausevic-dev/data-contract-registry
|
|
6
|
+
Project-URL: Repository, https://github.com/mizcausevic-dev/data-contract-registry
|
|
7
|
+
Project-URL: Issues, https://github.com/mizcausevic-dev/data-contract-registry/issues
|
|
8
|
+
Project-URL: Author Site, https://kineticgain.com/
|
|
9
|
+
Author-email: Miz Causevic <miz@kineticgain.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: data-contract,data-quality,fastapi,kinetic-gain,schema-registry
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Database
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Provides-Extra: api
|
|
30
|
+
Requires-Dist: fastapi>=0.115; extra == 'api'
|
|
31
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == 'api'
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
34
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
39
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == 'dev'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# data-contract-registry
|
|
44
|
+
|
|
45
|
+
[](https://github.com/mizcausevic-dev/data-contract-registry/actions/workflows/ci.yml)
|
|
46
|
+
[](https://www.python.org/)
|
|
47
|
+
[](LICENSE)
|
|
48
|
+
|
|
49
|
+
**Schema registry for data contracts.** Semver versioning, compatibility checks (backward / forward / full), declared owners, freshness SLAs. The "you can't promote a new dataset version without an approved contract" pattern, lifted from API governance and aimed at data pipelines.
|
|
50
|
+
|
|
51
|
+
The headline endpoint is `POST /contracts` — register a new version, get back a deterministic compatibility report or a 422 with every breaking change called out by field name and kind.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
The thing that gets data teams paged at 2am isn't a missing test. It's a producer who quietly removed `ltv` because "we never use it anymore" while three downstream dashboards still join on it. Schema registries (Confluent, Buf, etc.) solved this for streaming and gRPC; data pipelines need the same hardness in a shape that fits the things data teams actually argue about:
|
|
58
|
+
|
|
59
|
+
- **owners** — who do I page when this dataset goes stale
|
|
60
|
+
- **freshness SLA** — when does "stale" become "broken"
|
|
61
|
+
- **primary key** — changing it is a `MAJOR`, not a `MINOR`
|
|
62
|
+
- **enum drift** — adding a value is fine; removing one is a backward-compatibility break
|
|
63
|
+
- **deprecation policy** — flag a version with the URI of the migration plan; don't delete it
|
|
64
|
+
|
|
65
|
+
This package is the smallest thing that does all of those.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install data-contract-registry
|
|
73
|
+
# with the FastAPI surface:
|
|
74
|
+
pip install "data-contract-registry[api]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Python 3.11+. Runtime deps: `pydantic` + `PyYAML`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Library quickstart
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from data_contract_registry import (
|
|
85
|
+
ContractRegistry,
|
|
86
|
+
DataContract,
|
|
87
|
+
DataField,
|
|
88
|
+
Owner,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
registry = ContractRegistry()
|
|
92
|
+
|
|
93
|
+
v1 = DataContract(
|
|
94
|
+
dataset_id="users.daily_active",
|
|
95
|
+
version="1.0.0",
|
|
96
|
+
primary_key=["user_id", "active_date"],
|
|
97
|
+
owners=[Owner(team="growth-platform", contact="#growth-platform")],
|
|
98
|
+
fields=[
|
|
99
|
+
DataField(name="user_id", type="string"),
|
|
100
|
+
DataField(name="active_date", type="timestamp"),
|
|
101
|
+
DataField(name="plan", type="string", enum=["free", "pro", "enterprise"]),
|
|
102
|
+
DataField(name="ltv", type="number", required=False),
|
|
103
|
+
],
|
|
104
|
+
status="active",
|
|
105
|
+
)
|
|
106
|
+
registry.register(v1)
|
|
107
|
+
|
|
108
|
+
# Compatible promotion (added an optional field).
|
|
109
|
+
v1_1 = v1.model_copy(update={
|
|
110
|
+
"version": "1.1.0",
|
|
111
|
+
"fields": [*v1.fields, DataField(name="signup_source", type="string", required=False)],
|
|
112
|
+
})
|
|
113
|
+
report = registry.register(v1_1)
|
|
114
|
+
print(report.compatible) # True
|
|
115
|
+
|
|
116
|
+
# Incompatible promotion — removing a field breaks backward compatibility.
|
|
117
|
+
v2 = v1.model_copy(update={"version": "2.0.0", "fields": [f for f in v1.fields if f.name != "ltv"]})
|
|
118
|
+
report = registry.register(v2)
|
|
119
|
+
print(report.compatible) # False
|
|
120
|
+
print(report.errors[0].kind) # "field_removed"
|
|
121
|
+
print(report.errors[0].message) # "field 'ltv' was removed; old data will fail validation"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Compatibility modes
|
|
127
|
+
|
|
128
|
+
| Mode | Meaning |
|
|
129
|
+
| ---------- | --- |
|
|
130
|
+
| `backward` | New schema can read data produced by the previous schema. **Default.** Consumers upgrade first. |
|
|
131
|
+
| `forward` | Previous schema can read data produced by the new schema. Producers upgrade first. |
|
|
132
|
+
| `full` | Both. |
|
|
133
|
+
| `none` | Anything goes. First-time onboarding only. |
|
|
134
|
+
|
|
135
|
+
The checks the engine knows how to flag (each carries a structured `kind` so you can build CI gates around specific failures):
|
|
136
|
+
|
|
137
|
+
| Kind | Severity | Mode |
|
|
138
|
+
| -------------------------- | -------- | --- |
|
|
139
|
+
| `field_removed` | error | backward |
|
|
140
|
+
| `field_type_changed` | error | backward |
|
|
141
|
+
| `field_required_added` | error | backward (optional→required) **or** forward (new required field) |
|
|
142
|
+
| `field_enum_shrunk` | error | backward |
|
|
143
|
+
| `primary_key_changed` | error | always |
|
|
144
|
+
| `version_not_increasing` | error | always |
|
|
145
|
+
| `owner_missing` | error | always |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## FastAPI surface
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install "data-contract-registry[api]"
|
|
153
|
+
uvicorn data_contract_registry.app:app --port 8090
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Method | Path | What it does |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| GET | `/` | Service info. |
|
|
159
|
+
| GET | `/healthz` | Liveness probe. |
|
|
160
|
+
| GET | `/datasets` | List registered dataset IDs. |
|
|
161
|
+
| POST | `/contracts` | Register / promote a contract. 422 with a structured issue list when incompatible. |
|
|
162
|
+
| POST | `/contracts/check` | Dry-run compatibility check — does **not** register. |
|
|
163
|
+
| GET | `/contracts/{ds}/latest` | Latest **active** contract for a dataset. |
|
|
164
|
+
| GET | `/contracts/{ds}/versions` | Full version history. |
|
|
165
|
+
| GET | `/contracts/{ds}/versions/{v}` | One specific version. |
|
|
166
|
+
| POST | `/contracts/{ds}/versions/{v}/deprecate` | Mark deprecated with a migration URI. |
|
|
167
|
+
| POST | `/contracts/{ds}/versions/{v}/archive` | Archive a version (history preserved). |
|
|
168
|
+
| POST | `/contracts/owners/from-decision-card` | **Cross-ecosystem hook** — pull owners out of a Decision Card. |
|
|
169
|
+
|
|
170
|
+
Bundles are held in-memory by default. For restart-safe storage, swap `_BundleStore`'s implementation; the protocol is small.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## The cross-ecosystem hook
|
|
175
|
+
|
|
176
|
+
The third hook in the portfolio (after `procurement-decision-api` → `policy-as-code-engine` and the Suite → Decision Intelligence bridge). When a buyer approves a vendor whose data product the team will consume, the Decision Card's `buyer.name` + `decision_maker` are **the right answer** to "who owns the contract on our side":
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
curl -X POST http://localhost:8090/contracts/owners/from-decision-card \
|
|
180
|
+
-H 'Content-Type: application/json' \
|
|
181
|
+
-d @decision-card.json
|
|
182
|
+
# -> [
|
|
183
|
+
# {"team": "Springfield USD", "contact": "#data-platform"},
|
|
184
|
+
# {"team": "Director of Data (Alex Chen)", "contact": null}
|
|
185
|
+
# ]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Drop that list straight into `DataContract.owners` and the registration carries paging info the team didn't have to re-type.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## YAML authoring
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
# contracts/users-daily-active.yaml
|
|
196
|
+
dataset_id: users.daily_active
|
|
197
|
+
version: "1.0.0"
|
|
198
|
+
owners:
|
|
199
|
+
- team: growth-platform
|
|
200
|
+
contact: "#growth-platform"
|
|
201
|
+
freshness_sla:
|
|
202
|
+
max_lag_seconds: 86400
|
|
203
|
+
fields:
|
|
204
|
+
- {name: user_id, type: string}
|
|
205
|
+
- {name: active_date, type: timestamp}
|
|
206
|
+
- {name: plan, type: string, enum: [free, pro, enterprise]}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Hand-author in YAML, validate in CI, register from Python:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
import yaml
|
|
213
|
+
from pathlib import Path
|
|
214
|
+
from data_contract_registry import ContractRegistry, DataContract
|
|
215
|
+
|
|
216
|
+
raw = yaml.safe_load(Path("contracts/users-daily-active.yaml").read_text())
|
|
217
|
+
ContractRegistry().register(DataContract.model_validate(raw))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Tests
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
pip install -e ".[dev]"
|
|
226
|
+
ruff check src tests && ruff format --check src tests
|
|
227
|
+
mypy src
|
|
228
|
+
pytest -v
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
CI matrix runs Python 3.11 / 3.12 / 3.13.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Related in this ecosystem
|
|
236
|
+
|
|
237
|
+
- **[procurement-decision-api](https://github.com/mizcausevic-dev/procurement-decision-api)** — drafts the Decision Cards this registry pulls owners from.
|
|
238
|
+
- **[policy-as-code-engine](https://github.com/mizcausevic-dev/policy-as-code-engine)** — pair with this registry to enforce contracts at request time.
|
|
239
|
+
- **[slo-budget-tracker](https://github.com/mizcausevic-dev/slo-budget-tracker)** — wire your freshness SLA into the same monitoring story.
|
|
240
|
+
- More at [kineticgain.com](https://kineticgain.com/).
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
data_contract_registry/__init__.py,sha256=sC8JdBV5gMy2ectTvjW9ZCRwByvIXzpN4SsOaaD-aL8,1629
|
|
2
|
+
data_contract_registry/app.py,sha256=QCHT_gAoOgewea3fQZa6i0o_UKlA71gYPGU5mvUy8zY,8222
|
|
3
|
+
data_contract_registry/audit_stream.py,sha256=1_7xDUZIA-G9VS07tVJeS2jwy2ubrOA8RjCrUVeebmk,2536
|
|
4
|
+
data_contract_registry/compatibility.py,sha256=ACHl0mmA_j0QQVMU93AUibxGJ-JX72QsqZjWYETtUh0,7762
|
|
5
|
+
data_contract_registry/from_decision_card.py,sha256=gdujAJCSYHutYg0I6sTRgUpQbhRm0qW8YOoZj_gtM74,1757
|
|
6
|
+
data_contract_registry/models.py,sha256=yhEBodqQnF6eYNA37cxmNa9FrOSiV-18L38gTMGLC5A,4781
|
|
7
|
+
data_contract_registry/registry.py,sha256=pYaF6B4TimKitsBgb5AVWyCMJw9BeqRPR-GzUs1oRs4,6358
|
|
8
|
+
data_contract_registry-0.1.1.dist-info/METADATA,sha256=ltoblW673tYxv6RECKRbxN8cqXxwLhEneaJMa3X47tY,9702
|
|
9
|
+
data_contract_registry-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
data_contract_registry-0.1.1.dist-info/licenses/LICENSE,sha256=9REhXrEWXVgwUgFKw9H__Zaboj9yGUNNVJeYTlmqvG8,1084
|
|
11
|
+
data_contract_registry-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miz Causevic / Kinetic Gain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|