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.
@@ -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
+ [![CI](https://github.com/mizcausevic-dev/data-contract-registry/actions/workflows/ci.yml/badge.svg)](https://github.com/mizcausevic-dev/data-contract-registry/actions/workflows/ci.yml)
46
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.